Sist endret: 17.06.2004  
 

Objektdesorientert javaprogrammering

Bakgrunnen for dette notatet er at det er skrevet mer enn nok om objektorientert programmering i java fra før. Det er også et faktum at objektorientering bankes inn som svaret på alt, mens andre områder blir forsømt når man skal forsøke å lære objektorientert programmering uten å ha et skikkelig grunnlag innen generell programmering.

Dersom du føler deg som en nybegynner i programmering så kan kanskje dette notatet være med på å demystifisere javaprogrammering noe. For deg som har erfaring med objektorientert programmering, kan det også være lurt å lese igjennom for å se det hele fra en litt annen vinkel. Hvis du synes det dukker opp mange nye ting underveis er det et tegn på at du skal ta et skritt tilbake fra objektorientert programmering. Slik kan du ta flere skritt fram igjen, når det grunnleggende er forstått.

Hva er et godt javaprogram?

God objektorientert design er viktig i store systemer for å holde orden på de ulike komponentene. Velskrevet kode er viktig, slik at det er lett for andre å sette seg inn i og vedlikeholde koden. I faget TDT4120 Algoritmer og datastrukturer vil de fleste programmene bli små implementasjoner av kjente algoritmer. Det betyr at velskrevet kode er langt viktigere enn inngående kunnskap om- og utstrakt bruk av objektorientering. I mange tilfeller vil det være beste være å avstå fra å lage egne objekter i det hele tatt.

Java uten objektorientering

Det første man bør lære om java er hvordan man skriver enkel kode uten egendefinerte objekter. Objektene lærer man fort når det grunnleggende er forstått og det kan føre til en åpenbaring av hvilke muligheter som finnes. Nøkkelordet for ikkeobjektorientert javaprogrammering er static. Et javaprogram i sin enkleste form inneholder kun den statiske main()-metoden, mens javaprogrammer i sin "nest enkleste form" i tillegg inneholder andre metoder og variabler som er deklarert static.

public class MinKlasse {
    static int demoDummy = 14;

    /** returnerer i fakultet for 0 <= i <= 12 .*/
    public static int fakultet(int i)
    {
        if( i > 1 ) 
            return i * fakultet( i - 1 );
        else        
            return 1;
    }

    public static void main(String[] args)
    {
        System.out.println("6 fakultet: " + fakultet(6) );
    }
}

Variabler som er deklarert static kalles klassevariabler. Tilsvarende kalles variabler som ikke er deklarert static for medlemsvariabler. Medlemsvariablene er unike for hver objektinstans (medlem) av klassen og når det opprettes en objektinstans, settes det egentlig av plass til alle medlemsvariablene i minnet. Klassevariablene er felles for hele klassen og trenger derfor ikke repeteres for hver instans. Tilsvarende har vi klassemetoder og medlemsmetoder som deklareres henholdsvis med og uten bruk av static. Dersom en utelater static på steder det skulle vært brukt, er dette en av mange faktorer som kan bidra til å obfuskere koden. Spesielt etter å ha blitt ekspert på objektorientert programmering med java er det viktig å huske at:
  • En variabel skal deklareres static med mindre den skal være unik for hver instans av klassen.
  • En metode skal være static med mindre den skal operere på medlemsvariable.
Det er dårlig objektorientert design å tvinge brukere av klasser til å opprette objekter uten mening. Eksempelet under er hentet fra DinKlasse og viser hvordan gal design av MinKlasse kan vanskeliggjøre kode i andre klasser. Den beste løsningen oppnås kun dersom fakultetmetoden er deklarert static i MinKlasse.
//Bra
int i = MinKlasse.fakultet(6);

//Dårlig design av MinKlasse
MinKlasse m = new MinKlasse(6);
int i = m.fakultet();

//Dårlig design av MinKlasse
MinKlasse m = new MinKlasse();
int i = m.fakultet(6);

Programflyt

Før man begynner å ta for seg av mulighetene objektorientering gir, bør en ha full kontroll på programflyt. Med programflyt menes i hvilken rekkefølge de forskjellige delene av programmet blir utført. Vi vet at et javaprogram starter med at main()-metoden blir kalt. Derfra blir koden utført linje for linje helt til og med siste linjen i mainmetoden, der programmet slutter (med mindre vi har startet opp andre tråder enn hovedtråden). Hva som skjer i hvilken rekkefølge er imidlertid ikke alltid like intuitivt. Hva skrives for eksempel ut av denne kodesnutten?
public class Program {
    public static char f( char c ){
       System.out.print( c++ );
       return c--;
    }

    public static void main(String[] args)
    {
       if( f('j') == 'k' || f('f') == 'f'){
          System.out.println( f('d') );
       }
    }
}
Dersom du ser dette med en gang kan du hoppe til neste avsnitt. Dersom du ikke ser hva som vil bli skrevet ut, se litt nøyere. Dersom du fremdeles ikke ser hva som vil bli skrevet ut, kompiler og kjør programmet. Dersom du etter å ha kjørt programmet ikke forstår nøyaktig hva som skjedde, kontakt studass og sørg for å forstå denne biten. Det er riktignok ikke så viktig at du forstår alle mulige obskure programsnutter, men det er viktig for å at du skjønner programflyt for at du skal kunne programmere skikkelig.

Programflyten styres i tillegg til metodekall av kontrollstrukturer; if else, for, while, switch og av nøkkelord for å gjøre hopp; return, break, continue. Alt dette regnes som kjent stoff og dersom du skulle være i tvil om noe av dette finner du svaret i javaboka di. Programflyten ved metodekall regnes også som kjent stoff, men for sikkerhets skyld repeterer vi hvordan det fungerer.

Dersom vi kaller en metode fra en gitt kontekst (blokk), må denne metoden returnere før det neste metodekallet gjøres fra den samme konteksten. For å kunne kalle en metode må alle inputparametre (og en eventuell instans å kalle metoden på) være kjent. Det vil si at at disse må evalueres før selve metodekallet gjøres. Rekkefølgen er definert slik at det eventuelle objektet som har metoden evalueres før parameterne til metoden. I eksempelet under evalueres først metoden a() deretter b() og til slutt c(), fordi c blir kalt på objektet returnert fra a() og skal ha b()'s returverdi som parameter.

a().c( b() );
I motsetning til C og enkelte andre C-style språk har java en mer generell regel som sier at alle uttrykk blir evaluert fra venstre mot høyre. Resultatene av uttrykkene under er derfor veldefinert i java, mens C ikke sier noe om hvordan slike uttrykk skal håndteres.
int i = (i = 3) + i; // i = 3 + 3;

minus( i = 5, i); // minus( 5, 5);
På samme måte blir logiske uttrykk evaluert fra venstre mot høyre i java, såvel som i de fleste andre språk. Ofte vil resultatet av hele uttrykket være kjent før alle deluttrykkene er evaluert fullstendig. I slike tilfeller er det unødvendig å evaluere den siste delen av uttrykket, og de gjenstående delene vil derfor ikke bli evaluert. I eksemplene under vil bare de fire første uttrykkene bli evaluert i hver "if", fordi det fjerde deluttrykket i begge tilfeller avgjør utfallet av hele uttrykket.
boolean T = true, F = false;
if( F || F || F || T || F || F || T || F || T );
if( T && T && T && F && T && F && F && T && F );
Selv om reglene klart sier i hvilken rekkefølge de forskjellige delene av koden skal kjøre er det dumt å skrive kode som i stor grad baserer seg på slike regler. Dersom du skriver kode som er avhengig av slike regler må du kunne reglene ordentlig og du bør uansett begrense bruken. Kjenner du ikke reglene godt nok risikerer du å skrive gal kode til tross for at den kan fungere når du tester den på din maskin.

Presedens

Presedensregler er det som bestemmer i hvilken rekkefølge operatorer i uttrykk skal evalueres. Disse reglene sørger for at betydningen av uttrykk er entydig definert - selv uten parenteser. Parenteser er likevel klargjørende og kan brukes selv om språket ikke krever det. De operatorene som binder operandene tettest til seg har høyest presedens. Fra matematikken vet vi for eksempel at multiplikasjon og divisjon har høyere presedens enn addisjon og subtraksjon og at parenteser brukes til å overstyre disse reglene. Under følger presedensreglene for operatorer i java.

postfix operators[] . (params) expr++ expr--
unary operators ++expr --expr +expr -expr ~ !
creation or castnew (type)expr
multiplicative* / %
additive+ -
shift<< >> >>>
relational< > <= >= instanceof
equality== !=
bitwise AND&
bitwise exclusive OR^
bitwise inclusive OR|
logical AND&&
logical OR||
conditional? :
assignment= += -= *= /= %= &= ^= |= <<= >>= >>>=

Bruk tabellen over til å forstå hva som skjer i dette utrykket:

i+=i+++i;

Presedensregler kommer ikke i konflikt med regelen om at uttrykk skal evalueres fra venstre mot høyre. I uttrykket under evalueres a først, så b og deretter multipliseres disse. Tilsvarende skjer med c og d før hele utrykket adderes. Begrepet evaluere gir større mening dersom vi ser på a, b, c og d som mindre delutrykk, for eksempel metodekall. Operandene evalueres altså fra venstre mot høyre, mens operatorene følger presedensreglene.

a * b + c * d

Parameteroverføring ved metodekall

Grunnen til at det er satt av et eget avsnitt til parameteroverføring til metoder, er at java behandler dette på to fundamentalt forskjellig måter ut i fra hva slags datatype som blir overført. De primitive datatypene som finnes i java er boolean, char, byte, short, int, long, double, float. Som parametre til metodekall blir disse overført som rene verdier. Det vil si at metoden som blir kalt ikke vil kunne modifisere parameterne slik at det virker inn på koden den kalles fra. Når vi sender objekter som parametre til metodekall sender vi derimot kun referansene til objektene. Dette betyr at metoden som blir kalt kan modifisere det opprinnelige objektet. Dette illustreres best ved et eksempel. I program A under vil modifiser() kun motta en verdi og kallet til modifiser() vil ikke ha noen effekt fra main(). I program B opprettes ett tabellobjekt og det er referansen til dette objektet som blir overført. Fordi det er en referanse til den opprinnelige tabellen som sendes, kan modifiser() endre verdiene som ligger i tabellen.
(A) Verdioverføring (B) Referanseoverføring

public class Program {
    public static void modifiser( int i )
    {
        i++;
    }

    public static void main(String[] args)
    {
        int i = 42;
        modifiser( i );          //ingen effekt.
        System.out.println( i ); //print 42
    }
}

public class Program {
    public static void modifiser( int[] array )
    {
        array[0]++;
    }

    public static void main(String[] args)
    {
        int array[] = [ 42 ];
        modifiser( array );
        System.out.println( array[0] ); //print 43
    }
}

Java med litt objektorientering

I tillegg til de primitive datatypene har java har mange klasser som dekker en rekke behov. Før eller siden vil vi likevel få behov for egendefinerte datatyper. En datatype er noe vi kommer til å trenge mange unike instanser av, derfor trenger datatypen vår medlemsvariable. Det kan for eksempel være at vi ønsker å opprette personer. Unikt for en person i denne sammenhengen er personens navn, personens mor og personens far. Ettersom de nevnte dataene er unike for hver instans må vi sløyfe static. En minimal definisjon av en slik datatype er:
class Person {
    Person mor;
    Person far;
    String navn;
}
Når vi har behov for personobjekter oppretter vi dem med operatoren new. Dette fører til at det opprettes en plass til det nye personobjektet i minnet. Slik klassen Person er definert, har andre klasser fri tilgang utenfra til variablene inne i klassen. Dette er en dårlig løsning med tanke på objektorientert design, men det gjør koden veldig lesbar og det går klart fram av koden at Person er en datatype og ikke noe mer.
  Person jeg   = new Person();
  jeg.navn     = "h4X0r";
  jeg.mor      = new Person();
  jeg.mor.navn = "Kari";
  jeg.far      = new Person();
  jeg.far.navn = "Per";
Kodebiten over bruker datatypen vår og bygger opp et lite familietre. At navnene på personene er skrevet inn i koden gjør koden ubrukelig til alt annet enn demonstrasjon. En funksjon for å traversere treet er mere generelt og er som oftest veldig nyttig i forbindelse med trær generelt.
    public static void traverser( Person p )
    {
        System.out.println( p.navn );
            if( p.mor != null ) traverser( p.mor );
            if( p.far != null ) traverser( p.far );
    }
Under er hele eksempelet gjengitt i form av et fullstendig og javaprogram (A). I B er eksempelet omskrevet til forholdsvis velskrevet objektorientert kode. Legg spesielt merke til forskjellene og likhetene i traverser()-metoden som følge av at den forandret fra å være en klassemetode i en klasse til å bli en medlemsmetode i en annen. Datatypen Person har akkurat de samme dataene som før, men direkte aksess på variabler utenfra er umulig. Fordi aksessen på variabler er begrenset er metoder for å sette mor og far lagt til. Tilsvarende kunne vi hatt en metode for å sette navn, men i stedet lager vi en konstruktør som tvinger brukere av klassen til å oppgi navn i det personobjektet opprettes. Slik kan vi kontrollere at alle personer har navn og at de ikke foretar navnebytte i tide og utide. Eksempel A har i objektorientert forstand mange alt for enkle løsninger og egner seg dårlig til større utvidelser. Vurderer vi programmene i seg selv, uten tanke på videre utvidelser, kan likevel A sies å være det beste fordi det oppnår samme funksjonalitet med enklere kode og programflyt. Til dette formålet er datatypen Person i sin enkleste form med andre ord vel så bra som den mer komplekse varianten.
 
(A) Enkel løsning (B) OO-løsning


class Person {
    String navn;
    Person mor;
    Person far;
}


public class Program {

    public static void traverser( Person p )
    {
        System.out.println( p.navn );
        if( p.mor != null ) 
            traverser( p.mor );
        if( p.far != null ) 
           traverser( p.far );    
    }

    public static void main(String[] args)
    {
        Person jeg   = new Person();
        jeg.navn     = "h4X0r";
        jeg.mor      = new Person();
        jeg.mor.navn = "Kari";
        jeg.far      = new Person();
        jeg.far.navn = "Per";
        traverser( jeg );
    }
}



class Person {
    private String navn;
    private Person mor;
    private Person far;

    /*konstruktør*/
    public Person(String navn)
    { 
        this.navn = navn;
    }

    public void settMor( Person mor )
    { 
        this.mor = mor; 
    }

    public void settFar( Person far ){
        this.far = far; 
    }
 
    public void traverser()
    {
       System.out.println( this.navn );
       if( this.mor != null ) 
           this.mor.traverser();
       if( this.far != null ) 
           this.far.traverser();    
    }
}


public class Program {
    public static void main(String[] args)
    {
        Person jeg   = new Person( "h4X0r" );
        jeg.settMor(   new Person( "Kari"  ) );
        jeg.settFar(   new Person( "Per"   ) );
        jeg.traverser();
    }
}

Objektorientert programmering

Objektorientert tankegang baserer seg på at det finnes objekter (data) som gjør noe. Denne tankegangen ligner menneskers syn på hvordan verden er bygd opp og fungerer som en abstraksjon over det som skjer inne i datamaskinen. Sett ovenfra (designmessig) er det derfor lettere å tenke i objektorientert rettning. I TDT4120 Algoritmer og datastrukturer er det viktigst å forstå hvordan dataprogram fungerer uavhengig av språk, men med tanke på algoritmene og datastrukturene. Avansert bruk av objektorienterte teknikker ligger derfor utenfor skopet til TDT4120 Algoritmer og datastrukturer. Det holder å at vite algoritmer er noe som kan implementeres i statiske metoder i java og at datastrukturer kan bygges opp av enkle objekter, slik som Person i forrige avsnitt. Det er selvfølgelig opp til hver enkelt i hvor stor grad man benytter objektorienterte teknikker og det tas forbehold om at utgitt kode (lf etc.) inneholder kode som er objektorientert utover det minimale.

Hvordan skrive god kode

Det er viktig å ha et bra overordnet design for programmet som skal utvikles. Like viktig med tanke på vedlikehold er det å skrive god og lesbar kode. Her er noen regler for å klare dette. De kan føles som en tvangstrøye i begynnelsen, men så snart de er innarbeidet vil du selv føle at koden blir bedre og du får dermed bedre oversikt og kontroll. Det blir også lettere å få fornuftige tilbakemeldinger fra andre på koden din, fordi det blir lettere å sette seg inn i den.
  • Keep it simple. Ikke gjør ting vanskeligere enn det er. Spør deg selv hele tiden om dette kan gjøres enklere?
  • En metode/funksjon skal utføre én oppgave, og utføre den oppgaven best mulig.
  • Programkode skal være lettlest. Sørg alltid for at koden gjenspeiler det du prøver å gjøre.
  • Gi beskrivende variabel- og metodenavn. Bruk korte navn som 'i' eller tilsvarende på lokale variabler for å telle igjennom løkker.
  • Indenter riktig. Dersom du ikke har noen formening om hva riktig indentering er, bruk en teksteditor som gjør det for deg.
  • Bruk mellomrom og linjeskift der det er naturlig for å øke lesbarheten.
  • Vær konsekvent ved bruk av indentering, mellomrom og linjeskift.
  • Ikke skriv for lange metoder. 20 linjer er max. Blir den lengre blir den vanskelig å forstå og du må tenke over hvilken del av metoden som skulle vært utskilt og skrive om metoden.
  • Ikke skriv for lange linjer. 80 tegn er max. Dette gjør koden lettere å lese og du kan kan lese den i et terminalvindu eller skrive den ut uten at linjer blir brutt opp.
  • Ikke bruk for mange nøstede nivåer. 5 nivåer er max - mer gjør koden uleselig.
  • Indenteringen bør være minst fire tegn, men du kan godt bruke 8 ettersom du uansett aldri har behov for mer enn 5 nivåer.
  • Alle inputparametre og returverdi hos en metode skal beskrives over metoden.
  • Metoder skal ha et minimalt sett med inputparametre. Ikke send parametre som lett kan beregnes av de andre parameterne (for eksempel en tabell og tabellens lengde).
  • Kommentarer inne i metoder skal være overflødige. Dersom du trenger kommentarer inne i koden er det et tegn på at den er lite lesbar og du bør skrive den om.
  • Ikke gjør optimaliseringer på bit/instruksjonsnivå på bekostning av lesbar kode.
Noen praktiske eksempler kan være på sin plass.
  • En loop som skal gå igjennom alle elementer i en tabell (int array[]) kan skrives på mange måter. Den beste måten er den som er mest lesbar, dvs. den enkleste. Uansett bør array.length brukes som maksimumsgrense. Dette gjør det lett for leseren å se at hele tabellen blir gått igjennom og det motvirker feil når array har en annen størrelse enn du opprinnelig forutså.
    for( int i = 0; i < array.length; i++) {
        // behandle array[i]
        ...
        ...
    }
    
  • Boolske uttrykk skal være så enkle som mulig.
        int a, b;
        if( ! ( a < b ) )             -->  if( a >= b )
        
        boolean a, b;
        if( (a && !b) || (!a && b) )  -->  if( a != b )
    
  • Bruk den uttrykkskraften språket tilbyr, men ikke mer enn at det blir lesbart. Fakultetfunksjonen kan omskrives til en linje ved hjelp av spørsmålstegn-kolon-konstruksjonen:
    public static int fakultet( int i )
    {
        return  i > 1  ? i * fakultet( i - 1 ) : 1;
    }
    
  • De logiske &&- og ||-operatorene kan være med å styre programflyten. Hvis vi har uttrykket (a && b) vil ikke b bli evaluert dersom a evaluerer til false. Dette kan forenkle enkelte kodebiter. F.eks:
    if( str != null )
        if( str.length() > i )
            if( str.charAt( i ) == '\n' ){
    
    Kan også skrives slik:
    if( (str != null) && (str.length() > i) && (str.charAt(i) == '\n') ){
    

Kilder:

  • B.W. Kernighan, R. Pike (1999). The Practice of Programming,
  • J. Gosling, B. Joy, G. Steele (1996). Java Language Specification
  • Linus Torvalds, Linux Kernel Coding Style.