inhoudstafel en auteursrecht
* STER *


4. Objecten en klassen

4.1 Abstractie in Java

Abstractie is een van de manieren waarop mensen proberen ingewikkelde dingen overzichtelijk en begrijpelijk te maken. Het betekent dat een aantal aspecten van de werkelijkheid bewust vergeten of verborgen worden, om iets eenvoudigers over te houden dat we dan gemakshalve de "essentie" noemen. Goede bedrijfsleiders abstraheren voortdurend: "Geef me de hoofdlijnen, bespaar me de details." Ook wie een complex informatiesysteem ontwerpt, probeert best niet alle details van de implementatie de hele tijd in het hoofd te houden.

Java biedt op verschillende niveaus goede ondersteuning voor abstract ontwerp. We zagen al in paragraaf 3.4 hoe methoden de details van een handeling verbergen achter één aanroep vanuit het hoofdprogramma. In dit hoofdstuk bespreken we de volgende mechanismen:

4.2 Klassen

Een klasse is een gegevenstype. De mogelijke waarden die een veranderlijke of uitdrukking van een dergelijk type kan aannemen, worden "objecten" genoemd.

Je kan zelf een nieuw gegevenstype opbouwen door bestaande gegevenstypes te combineren in structuren. Stel dat het informatiesysteem dat we ontwerpen, te maken krijgt met bankrekeningen. Vanuit informatie-analytisch standpunt bestaat een bankrekening uit verschillende elementaire gegevens: de naam van de houder, het adres, het rekeningnummer en het saldo. In Java programmeren we dit als volgt.

class Bankrekening {
  String naam;
  String adres;
  int rekeningnummer;
  double saldo;
}

(We gaan er even van uit dat een rekeningnummer kan worden voorgesteld als een int, wat in België alvast niet klopt.)

De elementen naam, adres, rekeningnummer en saldo noemen we de eigenschappen of attributen van een bankrekening. Iedere eigenschap heeft een type, net zoals veranderlijken in paragraaf 2.3.

Met een bankrekening associëren we niet alleen de vier bovenstaande eigenschappen, maar ook een aantal handelingen: stortingen, overschrijvingen en terugtrekkingen. Het raadplegen van de rekeningstand is eveneens een frequente handeling. Deze handelingen brengen we onder in de definitie van de klasse Bankrekening, en wel in de vorm van methoden.

class Bankrekening {
  String naam;
  String adres;
  int rekeningnummer;
  double saldo;
  void stort(double bedrag) {
    saldo = saldo + bedrag;
  }
  void trekTerug(double bedrag) {
    saldo = saldo - bedrag;
  }
  double geefHuidigeStand() {
    return saldo;
  }
  void schrijfOver(double bedrag, Bankrekening begunstigde) {
    trekTerug(bedrag);
    begunstigde.stort(bedrag);
  }
}

In de implementatie van een methode mogen de eigenschappen gebruikt worden alsof het gewone veranderlijken waren. Zo kan de methode stort het saldo verhogen met de opdracht

    saldo = saldo + bedrag;

Methoden kunnen een uitdrukking van een bepaalde waarde teruggeven aan hun oproeper, zoals de methode geefHuidigeStand dat doet met het huidige rekeningsaldo. Het type van de teruggegeven waarde (kortweg terugkeertype) geven we aan vóór de naam van de methode, in dit geval double. De opdracht

    return saldo;

beëindigt de uitvoering van de methode en specificeert de terugkeerwaarde (noodzakelijk van het type double). Als een methode geen waarden teruggeeft, wordt ze gedeclareerd met het terugkeertype void.

Methoden kunnen bepaalde waarden meekrijgen van hun oproeper. Dat gebeurt aan de hand van argumenten, soms ook parameters genoemd. De declaratie van een methode bevat een opsomming van de argumenten en hun gegevenstypes tussen een paar ronde haken. Als een methode geen argumenten heeft, blijft het stel ronde haken verplicht. Dat zien we bij de methode geefHuidigeStand.

De klasse Bankrekening definieert een volwaardig nieuw gegevenstype in Java. Dat blijkt ondermeer uit de methode schrijfOver, die als tweede parameter een uitdrukking van het type Bankrekening verwacht.

In de methode schrijfOver zien we twee typische manieren om een methode aan te roepen:

    trekTerug(bedrag);
    begunstigde.stort(bedrag);

De aanroep van de methode stort wordt voorafgegaan door de referentie naar de parameter begunstigde, namelijk, precies door de rekening waarop moet gestort worden. Bij de aanroep van trekTerug is dat niet het geval: de terugtrekking slaat namelijk op de rekening waarvoor de methode schrijfOver zelf wordt aangeroepen. Je kan dit (optioneel) benadrukken door gebruik te maken van het sleutelwoord this:

  void schrijfOver(double bedrag, Bankrekening begunstigde) {
    this.trekTerug(bedrag);
    begunstigde.stort(bedrag);
  }

Een methode wordt binnen eenzelfde klasse geïdentificeerd door de combinatie van haar naam en het aantal en de types van de argumenten. De naam alleen hoeft dus niet uniek te zijn! Om dit te illustreren maken we nog een tweede methode met de naam trekTerug, ditmaal echter met een beveiliging tegen kredietoverschrijding. De tweede versie neemt naast de parameter bedrag ook nog een tweede parameter limiet, eveneens van het type double, die aangeeft hoeveel het eindsaldo maximaal onder nul mag gaan. Als de limiet overschreden zou worden, gaat de terugtrekking eenvoudigweg niet door.

  void trekTerug(double bedrag) {
    saldo = saldo - bedrag;
  }
  void trekTerug(double bedrag, double limiet) {
    if (saldo - bedrag + limiet >= 0)
      saldo = saldo - bedrag;
  }

De Java-compiler maakt het onderscheid tussen aanroepen van deze twee versies aan de hand van het aantal en de types van de parameters waarmee ze worden opgeroepen. Eventuele dubbelzinnigheden geven aanleiding tot compilatiefouten (en kunnen dus tijdig worden opgelost, voor de gebruikers er last van hebben).

We hadden de tweede versie dus ook kunnen schrijven met gebruikmaking van de eerste versie:

  void trekTerug(double bedrag, double limiet) {
    if (saldo - bedrag + limiet >= 0)
      trekTerug(bedrag);
  }

Het gebruik van dezelfde methode-naam voor twee of meer verschillende methoden binnen één klasse heet het overladen van die naam. Overladen kan een programma leesbaarder maken, op voorwaarde dat het zinvol gebruikt wordt. Als twee methoden echt verschillende functies hebben, kunnen ze beter verschillende namen dragen.

Een declaratie van een attribuut kan worden vergezeld van één enkele toekenningsopdracht om er een beginwaarde aan toe te kennen. We spreken dan van initialisatie. Als een attribuut geen initialisatie krijgt, begint ze automatisch met een waarde die zo goed mogelijk het begrip "niets" uitdrukt. Voor getaltypes als int en double betekent dit gewoon het getal nul.

We zouden dus het saldo van een rekening voor alle zekerheid in het begin op nul kunnen stellen:

class Bankrekening {
  String naam;
  String adres;
  int rekeningnummer;
  double saldo = 0.0;
// ...

maar dit is strikt genomen niet nodig, aangezien attributen automatisch geïnitialiseerd worden.

Als de declaratie van een attribuut voorafgegaan wordt door het sleutelwoord final, dan mag dat attribuut slechts éénmaal het linkerlid van een toekenningsopdracht vormen. Zijn waarde is dan verder constant gedurende zijn hele levensduur. Die eerste toekenningsopdracht kan eventueel een initialisatie zijn, maar kan ook binnen een methode voorkomen, op voorwaarde dat er geen uitdrukkelijke initialisatie aan voorafging.

4.3 Objecten

De eigenlijke geheugen-inhouden die een klasse-gegevenstype vertegenwoordigen, heten objecten. Om een object te creëren, volstaat het niet een veranderlijke te declareren. Veranderlijken verwijzen slechts naar objecten, en een pas gedeclareerde veranderlijke verwijst nog nergens naar.

De meest voorkomende wijze om een nieuw object van een bepaald type te creëren, is door gebruik te maken van het sleutelwoord new. Dat sleutelwoord wordt gevolgd door een aanroep van een constructor.

Een constructor is iets wat op een methode gelijkt, met drie verschillen:

Net als andere methoden, kunnen constructoren nul of meer parameters van diverse types aannemen. En net als bij andere methoden bestaat de mogelijkheid van overladen: er kunnen verschillende constructoren in dezelfde klasse optreden, als het aantal of de types van de parameters maar duidelijk verschillen.

De klasse Bankrekening, uitgebreid met een constructor, zou er als volgt kunnen uitzien.

class Bankrekening {
  String naam;
  String adres;
  int rekeningnummer;
  double saldo;

  Bankrekening(String nm, String ad, int nr, double sa) {
    naam = nm;
    adres = ad;
    rekeningnummer = nr;
    saldo = sa;
  }

  void stort(double bedrag) {
    saldo = saldo + bedrag;
  }
  void trekTerug(double bedrag) {
    saldo = saldo - bedrag;
  }
  double geefHuidigeStand() {
    return saldo;
  }
  void schrijfOver(double bedrag, Bankrekening begunstigde) {
    trekTerug(bedrag);
    begunstigde.stort(bedrag);
  }
}

In het opdrachtenblok van een constructor worden typisch de nodige opdrachten geschreven om de attributen een zinvolle beginwaarde te geven. De eerste opdracht in een constructor mag een aanroep van een andere constructor zijn. Met het sleutelwoord this, gevolgd door een lijst van parameters tussen haakjes, wordt een andere constructor van dezelfde klasse aangeroepen.

De volgende alternatieve constructor laat toe, een bankrekening te openen zonder dat het adres van de houder bekend is.

  Bankrekening(String nm, int nr, double sa) {
    this(nm, "", nr, sa);
  }

In het hoofdprogramma kunnen we nu naar hartelust nieuwe bankrekeningen openen.

class TestBankrekening {
  public static void main(String[] args) {
    Bankrekening b;   // declaratie, geen constructie
    b = new Bankrekening("Lieven", "Affligem", 1, 0.0);
    /* Nu verwijst b naar een bestaand object van het
       type Bankrekening. */

    b.stort(2000);
    b.trekTerug(150);
    System.out.println("Stand van rekening b: "
      + b.geefHuidigeStand());

    // We gebruiken de alternatieve constructor:
    Bankrekening b2 = new Bankrekening("Jan", 2, 0.0);

    b.schrijfOver(100, b2);
    System.out.println("Stand van rekening b: "
      + b.geefHuidigeStand());
    System.out.println("Stand van rekening b2: "
      + b2.geefHuidigeStand());
  }
}

Als de programmeur een klasse declareert zonder constructor, dan maakt de Java-compiler zelf een standaardconstructor aan. Deze neemt geen parameters en doet niets. Het zou hetzelfde zijn alsof we schreven

  Bankrekening() {
  }

De declaratie van minstens één constructor, zelfs een die parameters aanneemt, zorgt ervoor dat de compiler geen standaardconstructor maakt. De volgende code is dus niet correct:

Bankrekening b = new Bankrekening();

Er bestaat immers geen parameterloze constructor voor de klasse Bankrekening.

Oefening 4.1

Schrijf een klasse Rechthoek waarvan de objecten horizontale rechthoeken op een computerscherm modelleren. De positie en afmetingen van een dergelijke rechthoek wordt vastgelegd door vier gehele getallen: de horizontale coördinaten van de linker- en rechterrand, en de verticale coördinaten van de onder- en bovenkant. De objecten moeten een methode printRechterbovenhoek() hebben (zonder parameters en zonder terugkeerwaarde) die de coördinaten van de rechterbovenhoek op het scherm afdrukt. Ze moeten ook een methode opp() hebben (zonder parameters en met terugkeertype int) die de oppervlakte berekent.

Je kan je oplossing testen met behulp van onderstaand programma.

class TestRechthoek {
  public static void main(String[] args) {
    //  links, boven, rechts, onder
    Rechthoek r = new Rechthoek(10, 10, 20, 30);
    r.printRechterBovenhoek();
    System.out.println("oppervlakte: " + r.opp());
  }
}

De output van het testprogramma zou er als volgt moeten uitzien.

(20, 10)
oppervlakte: 200

Oplossing

De acht primitieve gegevenstypes van Java (byte, short, int, long, char, boolean, float en double) zijn géén klassen. Er zijn dus ook geen methoden mee verbonden. Soms is dat wel jammer. Het zou bijvoorbeeld nuttig zijn aan een geheel getal een methode toe te kennen die de tekstrepresentatie van dat getal teruggeeft als een String.

De klassenbibliotheek voorziet acht klassen die "verpakkingen" (wrappers) vormen voor de primitieve types. Het zijn de volgende klassen, allemaal lid van het "pakket" (zie paragraaf 4.6) java.lang:

Byte            Character
Short           Boolean
Integer         Float
Long            Double

Let op de schrijfwijze. De hoofdletter geeft een referentietype aan. Integer en Character worden voluit geschreven, in tegenstelling to hun primitieve tegenhanger.

Elk object van deze klassen beschikt over een aantal nuttige methoden waarvan de documentatie online beschikbaar is. Zo hebben ze allemaal een methode

  String toString()

waarvan de terugkeerwaarde een tekstrepresentatie is van de primitieve waarde. Ze hebben ook alle acht een constructor die één argument van het onderliggende primitieve type neemt, bijvoorbeeld

  Long(long value)

4.3b Rijen

Het voorbeeld van paragraaf 4.3 maakt gebruik van twee verschillende bankrekening-objecten. Voor elk van de twee declareren we een afzonderlijke veranderlijke. Soms is het praktischer een hele collectie objecten (van dezelfde klasse) tegelijk te declareren. Dat gebeurt door middel van een rij.

Je declareert een rij met naam eennaam van het basistype eentype door te schrijven

eentype[] eennaam ;

Net als een veranderlijke van een klasse-type, is ook een rij-veranderlijke niet meer dan een verwijzing naar een object. De eigenlijke constructie van het rij-object gebeurt met de operator new, gevolgd door de naam van het basistype, gevolgd door een geheel getal tussen rechte haken. Dat gehele getal is het aantal elementen in de rij.

In het volgende voorbeeld wordt een rij van tien bankrekeningen eerst gedeclareerd, en vervolgens geïnstantieerd.

Bankrekening[] lijst;
lijst = new Bankrekening[10];

De afzonderlijke objecten van een rij worden aangesproken door een oplopende nummering, te beginnen vanaf 0. Het nummer van een element wordt weergegeven tussen rechte haken, onmiddellijk volgend op de naam van de rij.

In bovenstaand voorbeeld is een rij van 10 bankrekeningen geïnstantieerd; er is echter nog geen enkele bankrekening geopend! Ieder element van de rij is op zijn beurt een referentie, en moet afzonderlijk worden geïnstantieerd. Bijvoorbeeld

int i;
for (i = 0; i < 10; i = i + 1) {
  lijst[i] = new Bankrekening("Lieven", i, 0.0);
}

opent tien verschillende bankrekeningen op naam van dezelfde persoon. De elementen lijst[0], lijst[1] tot en met lijst[9] kunnen gebruikt worden als gewone veranderlijken van het type Bankrekening.

Het aantal elementen van een rij-object is onveranderlijke na de instantiatie. Het kan steeds worden opgevraagd met de eigenschap length. De lus in bovenstaande voorbeeldcode had dus ook kunnen geschreven worden als

for (i = 0; i < lijst.length; i++)
  // ...

4.4 Overerving

Soms hebben we behoefte aan een nieuw gegevenstype dat een subtype is van een bestaand gegevenstype. Alle objecten van het nieuwe type hebben alle eigenschappen en methoden van het bestaande type, plus nog een beetje meer.

In Java kan je een nieuw gegevenstype definiëren als uitbreiding (dus subtype) van een bestaand gegevenstype. Declareer een klasse, en laat de naam van de nieuwe klasse volgen door het sleutelwoord extends en daarachter de naam van de oude klasse. Alle bestaande eigenschappen en methoden (maar niet de constructoren) worden dan geërfd door de nieuwe klasse: ze moeten niet meer uitdrukkelijk worden gedeclareerd.

Stel dat we een nieuw gegevenstype Spaarrekening willen invoeren. Een spaarrekening is een bankrekening waarop intrest wordt uitgekeerd. Alle eigenschappen en methoden van een gewone bankrekening zijn van toepassing op deze bijzondere soort, maar bovendien moeten we een extra eigenschap hebben om de rentevoet te onthouden.

class Spaarrekening extends Bankrekening {
  double rentevoet; // in fracties, dus 10 procent is 0.1
}

We kunnen nu gewoon programmeren met spaarrekeningen, gebruikmakend van alle eigenschappen en methoden van een spaarrekening, zowel de rentevoet als de geërfde eigenschappen en methoden. We moeten wel nog een constructor voorzien, bijvoorbeeld.

  Spaarrekening(String nm, String ad, int nr, double sa,
    double rv) {
    naam = nm;
    adres = ad;
    nummer = nr;
    saldo = sa;
    rentevoet = rv;
  }

De eerste opdracht in een constructor van een subklasse mag een verwijzing zijn naar een constructor van de basisklasse. Dit gebeurt door middel van het sleutelwoord super, gevolgd door een lijst van parameters tussen haakjes. We hadden dus korter (en beter) kunnen schrijven

  Spaarrekening(String nm, String ad, int nr, double sa,
    double rv) {
    super(nm, ad, nr, sa);
    rentevoet = rv;
  }

Als we op het einde van het jaar de intrest willen uitkeren, dan hangt die natuurlijk af van de tijdsduur waarover het geld op de spaarrekening heeft gestaan. We lossen dat op door een interne veranderlijke voorlopigeIntrest die aan het begin op nul wordt gezet, en die wordt aangepast bij elke storting (in positieve zin) en bij elke terugtrekking (in negatieve zin). De aanpassing is evenredig met het bedrag van de operatie en met de rentevoet, maar ook met de tijd die er nog rest in het lopende jaar. De volgende tabel illustreert dit aan de hand van enkele voorbeelden, bij een rentevoet van 0.05 ofwel 5 percent:

bewerking bedrag datum resterende fractie van een jaar invloed op de intrest
storting 1000 1 jan 1.00 +50.00
storting 1000 1 jul 6/12 = 0.50 +25.00
terugtrekking 1000 1 okt 3/12 = 0.25 -12.50
storting 5000 1 dec 1/12 = 0.0833... +20.83

Een spaarrekening die op 1 januari is geopend en waarop de vier bovenstaande verrichtingen zijn uitgevoerd, levert dus op 31 december een intrest van 83.33 op. Het eindsaldo bedraagt 6083.33

De definitie van de klasse Spaarrekening gaat er dan als volgt uitzien.

class Spaarrekening extends Bankrekening {
  double rentevoet; // in fracties, dus 10 procent is 0.1

  /** Bedrag van de intrest op 31 december, als
   *  geen verdere verrichtingen meer plaatsvinden.
   */
  double voorlopigeIntrest;

  Spaarrekening(String nm, String ad, int nr, double sa,
    double rv) {
    super(nm, ad, nr, sa);
    rentevoet = rv;
    voorlopigeIntrest = saldo * rentevoet;
  }

  /** Nieuwe versie van "stort". Veronderstelt dat er een
   *  (fictieve) veranderlijke "Datum.jaarfractie" bestaat die aangeeft
   *  welk deel van het jaar er nog rest.
   */
  void stort(double bedrag) {
    saldo = saldo + bedrag;
    voorlopigeIntrest = voorlopigeIntrest
      + bedrag * rentevoet * Datum.jaarfractie;
  }

  /** Nieuwe versie van "trekTerug". Veronderstelt dat er een
   *  (fictieve) veranderlijke "Datum.jaarfractie" bestaat die aangeeft
   *  welk deel van het jaar er nog rest.
   */
  void trekTerug(double bedrag) {
    saldo = saldo - bedrag;
    voorlopigeIntrest = voorlopigeIntrest
      - bedrag * rentevoet * Datum.jaarfractie;
  }

  /** Deze methode mag slechts één keer per jaar,
   *  op 31 december, worden uitgevoerd.
   */
  void keerIntrestUit() {
    saldo = saldo + voorlopigeIntrest;
    voorlopigeIntrest = saldo * rentevoet;
  }
}

Oefening 4.2

Schrijf een programma met de naam TestSpaarrekening dat een spaarrekening opent en vervolgens de vier verrichtingen uit de tabel uitvoert. Verifieer op het einde dat het eindsaldo na het uitkeren van de intrest inderdaad 6083.33 bedraagt.

Opmerking. Je zult in je bronbestand wel de volgende extra klasse moeten opnemen...

class Datum {
  static double jaarfractie;
}

...en bovendien vóór elke storting een toekenningsopdracht uitvoeren van de vorm

Datum.jaarfractie = 0.50;   // (of een ander getal)

Oplossing

Wanneer we de methode stort oproepen voor een object van het type Spaarrekening, dan wordt automatisch de correcte (nieuwe) versie geactiveerd. De methode geefSaldo is daarentegen niet opnieuw gedefinieerd, objecten van klasse Spaarrekening erven haar van de bovenklasse Bankrekening.

Objecten van beide types zijn in zekere mate verwisselbaar, volgens de algemene regel

Overal waar een object van een algemeen type wordt verwacht, mag een object van een bijzonder subtype ingevuld worden.

Dat betekent ondermeer dat een toekenningsopdracht mogelijk is waarbij in het linkerlid een veranderlijke van het type Bankrekening optreedt, en in het rechterlid een uitdrukking die een object van het type Spaarrekening oplevert.

Bankrekening b;
b = new Spaarrekening("Suske", "Amoras", 1234543, 0, 0.05);

Het omgekeerde is niet toegelaten. De code

Spaarrekening s;
s = new Bankrekening("Wiske", "Ons Dorp", 321, 0);

veroorzaakt een compilerfout die ongeveer als volgt gesignaleerd wordt:

TestSpaarrekening.java:106: incompatible types
found   : Bankrekening
required: Spaarrekening
    s = new Bankrekening("Wiske", "Ons Dorp", 321, 0);
        ^
1 error

Laten we even verderwerken met het voorbeeld van de bankrekening-veranderlijke b die in feite een spaarrekening-object bevat. Welke versie van de methode stort wordt opgeroepen in de volgende code ?

Bankrekening b;
b = new Spaarrekening("Suske", "Amoras", 1234543, 0, 0.05);
b.stort(1000);

Het antwoord is misschien enigszins verrassend: de nieuwe versie, zoals gedefinieerd in de klasse Spaarrekening ! Het spaarrekening-object blijft dus zijn eigen type onthouden, zelfs als het verscholen is in een veranderlijke van het type Bankrekening.

Dit dynamische gedrag van object-methoden heet technisch polymorfisme. Het is niet van toepassing op attributen.

Het is in het laatste codevoorbeeld niet rechtstreeks mogelijk de methode keerIntrestUit voor de veranderlijke b op te roepen. De compiler ziet nog steeds een veranderlijke van het type Bankrekening, en de methode keerIntrestUit behoort tot het subtype Spaarrekening. Er bestaat in Java geen "omgekeerde overerving" waarmee ouders van hun kinderen zouden erven.

Je kan in Java testen of een object van een bepaald type is met de bewerking instanceof.

if (b instanceof Spaarrekening) {
  // ... specifieke code ...
}

Als een object tot een bepaald type behoort, kan het grammaticaal tot dat type worden "gepromoveerd" door een typecast, d.w.z. door de naam van het nieuwe type tussen haakjes voor de uitdrukking te plaatsen. Zo kunnen we van b uiteindelijk toch nog de intrest berekenen:

if (b instanceof Spaarrekening) {
  ((Spaarrekening) b).keerIntrestUit();
}

Als je binnen een methode van een subklasse gebruik wil maken naar een methode of attribuut van de superklasse, dan kan je het sleutelwoord super hanteren om te verwijzen naar het huidige object, opgevat als behorend tot de meer algemene superklasse. Zo hadden we de methoden stort en trekTerug van de klasse Spaarrekening ook kunnen implementeren in termen van hun tegenhangers uit de klasse Bankrekening:

  void stort(double bedrag) {
    super.stort(bedrag);
    voorlopigeIntrest = voorlopigeIntrest + bedrag * rentevoet * Datum.jaarfractie;
  }

  void trekTerug(double bedrag) {
    super.trekTerug(bedrag);
    voorlopigeIntrest = voorlopigeIntrest - bedrag * rentevoet * Datum.jaarfractie;
  }

Als je als programmeur niet wil dat er subklassen worden afgeleid van jouw klasse, dan kun je dat aangeven door het sleutelwoord final onmiddellijk voor het woord class te schrijven.

final class MijnKlasse {
  // ...
}

Een klasse die een andere klasse uitbreidt, kan op haar beurt aanleiding geven tot verdere uitbreiding.

Verschillende klassen kunnen eenzelfde superklasse uitbreiden, maar een klasse kan hoogstens één andere klasse uitbreiden. Java, in tegenstelling tot sommige andere object-georiënteerde programmeertalen, kent geen meervoudige overerving.

In feite zijn alle klassen subtypes van een andere klasse. Als je een klasse zonder het sleutelwoord extends definieert, dan gaat de compiler er automatisch van uit dat die klasse is afgeleid van Object, de universele "grootmoederklasse".

Samenvattend kunnen we zeggen dat de relatie extends een boomstructuur definieert. De klasse Object vormt de wortel, alle andere klassen zijn takken en bladeren. Bij die andere klassen zijn zowel de 2097 klassen die Sun ons aanbiedt via de klassenbibliotheek (J2SDK versie 1.4.2) als de klassen die we zelf definiëren of van derden betrekken.

De methoden van de klasse Object nemen dus een bijzondere plaats in, aangezien ze door alle Java-objecten geërfd worden. We bespreken hier kort één van hen, de methode

  boolean equals(Object obj)

Om te testen of twee primitieve waarden aan elkaar gelijk zijn, gebruiken we de vergelijkings-operator == (dubbel gelijkteken). Bijvoorbeeld

int a;
// ... we nemen aan dat a ondertussen een waarde krijgt ...
if (a == 5)
  System.out.println("a is vijf");
else
  System.out.println("a is verschillend van vijf");

Deze aanpak is niet geldig bij referentietypes! De vergelijkings-operator, toegepast op twee objecten, geeft alleen aan of de twee uitdrukkingen naar dezelfde plaats in het geheugen verwijzen. Als we bijvoorbeeld twee tekststrengen vergelijken, kan dat tot onaangename verrassingen leiden:

String antw;
System.out.println("Wilt U verdergaan (J/N) ?");
// ... we nemen aan dat we hier het antwoord lezen en in de
//     veranderlijke antw opslaan ...
if (antw == "J")
  System.out.println("Ik doe verder");
else
  System.out.println("Ik kap ermee");

In bovenstaand programma zal het antwoord "J" nooit herkend worden, omdat de "J" van de gebruiker zich op een ander geheugenadres bevindt dan de "J" van het programma! De correcte manier om twee uitdrukkingen van een referentietype inhoudelijk te vergelijken, is via de methode equals. Het terugkeertype van deze methode is boolean, zodat ze rechtstreeks in een voorwaardelijke opdracht kan verwerkt worden.

String antw;
System.out.println("Wilt U verdergaan (J/N) ?");
// ... we nemen aan dat we hier het antwoord lezen en in de
//     veranderlijke antw opslaan ...
if (antw.equals("J"))
  System.out.println("Ik doe verder");
else
  System.out.println("Ik kap ermee");

Het argument "J" is van het type String en mag dus ingevuld worden op de plaats van de formele Object-parameter. Sommige klassen geven hun eigen betekenis aan "inhoudelijk gelijk zijn" door de methode die ze van Object erven, te herdefiniëren.

4.5 Statische eigenschappen en methoden

Normale methoden kunnen gebruik maken van de attributen van een object alsof die attributen gewone veranderlijken zijn. Ze zijn dus nauw met dat object verbonden. Het kan ook nuttig zijn, attributen en objecten te associëren met de hele verzameling objecten van hetzelfde type, dus met de klasse als geheel. Als de declaratie van een attribuut of methode voorafgegaan wordt door het sleutelwoord static, betreft het een lid van de klasse, niet van één object in het bijzonder. Constructoren zijn nooit statisch.

Statische methoden kunnen niet zonder meer verwijzen naar gewone attributen, althans niet zonder uitdrukkelijk aan te geven met welk object ze geassocieerd zijn. De volgende tabel geeft aan welke clientcode wat voor attributen en methoden met hun korte naam (dus zonder aangifte van het object) mogen hanteren.

  clientcode in statische methode clientcode in gewone (niet-statische) methode of in constructor
object-attribuut (niet statisch) neen ja
klassen-attribuut (statisch) ja ja
object-methode (niet statisch) neen ja
klassen-methode (statisch) ja ja
constructor ja ja

Voorbeeld

De volgende klasse modelleert leden van een vereniging. Ieder lid krijgt bij de constructie automatisch een uniek nummer toegewezen. De klasse onthoudt op statisch niveau het laatst toegekende nummer. De constructor gebruikt dat nummer (toegelaten, zie bovenstaande tabel) en verhoogt het met één.

class Lid {
  /** Uniek nummer van het individuele lid. */
  int lidNummer;

  /** Familienaam van het lid. */
  String naam;

  /** Nummer van het laatst gecreeerde lid. */
  static int laatsteNummer;

  /** Construeer een nieuw lid aan de hand van zijn familienaam.
   *  Een uniek nummer wordt automatisch toegekend.
   */
  Lid(String naam) {
    this.naam = naam;
    // verhoog de statische veranderlijke alvorens het nummer toe te kennen
    laatsteNummer = laatsteNummer + 1;
    lidNummer = laatsteNummer;
  }
}

Statische methoden en eigenschappen bestaan onafhankelijk van de objecten van de klasse. Ze worden gecreërd op het moment dat de klasse voor het eerst in het geheugen van de computer geladen wordt. Dat wil zeggen dat ze bruikbaar zijn vanaf het begin van het programma (een object kan pas worden gebruikt nadat het geconstrueerd is). De methode main, die wordt opgeroepen van buiten het programma, is vanzelfsprekend statisch. De notie van "deelprogramma" in paragraaf 3.4 komt overeen met een statische methode.

De declaratie van een statisch attribuut kan worden uitgebreid met een toekenningsopdracht, net als bij gewone attributen. Het verschil is het tijdstip waarop de initialisatie wordt doorgevoerd. De initialisatie van een gewoon attribuut vindt plaats tijdens de constructie van het object. De initialisatie van een statisch attribuut vindt plaats zodra de klasse in het geheugen van de computer wordt geladen. Dat is dus voordat de main-methode in werking treedt!

Soms is het nodig, een stukje Java-code uit te voeren dat voorafgaat aan de main-methode, zonder dat het precies over de initialisatie van een statisch attribuut gaat. Vroege Java-programmeurs deden dat met een truukje. Ze declareerden een statisch attribuut met een nietszeggende naam (bijvoorbeeld static int dummy), en initialiseerden het met een methode-aanroep. Die methode bevatte dan de eigenlijke initialisatiecode van het programma. Sinds Java 1.1 is dat truukje overbodig, omdat je met het sleutelwoord static aan de klasse een initialisatieblok (static initializer) kan toevoegen.

Voorbeeld

Het statische attribuut laatsteNummer van de klasse Lid wordt automatisch geïnitialiseerd met de waarde 0. Als we liever vanaf 1000 beginnen te tellen, dan kan dat op twee manieren. We kunnen een "klassieke" toekenning in de declaratie van het attribuut plaatsen:

  // Eerste manier: initialisatie bij declaratie
  static int laatsteNummer = 1000;

We kunnen ook een initialisatieblok declareren:

  // Tweede manier: initialisatieblok

  // geen uitdrukkelijke initialisatie bij declaratie, impliciet eventjes 0
  static int laatsteNummer;

  static {
    // deze code wordt uitgevoerd zodra de klassendefinitie in het geheugen is geladen,
    // dus gegarandeerd ook voor de constuctie van het eerste Lid-object
    laatsteNummer = 1000;
  }

Op statische methoden is geen polymorfisme van toepassing zoals beschreven in paragraaf 4.4.

4.6 Toegangsbeperking

De onderhoudbaarheid van een klasse kan in het gedrang komen als ze een groot aantal eigenschappen, constructoren en methoden telt. Het is immers moeilijk na te gaan welke andere klassen deze diverse eigenschappen, constructoren en methoden gebruiken, dus elke kleine verandering aan onze klasse kan elders fouten tot gevolg hebben.

In Java wordt dit probleem voor een deel opgelost door toegangsbeperking. De programmeur specificeert uitdrukkelijk welke eigenschappen, constructoren en methoden door andere klassen mogen gebruikt worden, en welke niet. Daartoe kan hun declaratie voorafgegaan worden door één van de drie sleutelwoorden public, protected en private.

Er zijn aldus vier soorten toegangsbeperking:

sleutelwoordwelke klassen hebben toegang
public alle klassen
protected klassen uit hetzelfde pakket (zie verder)
subklassen van de huidige klasse
(geen sleutelwoord) klassen uit hetzelfde pakket
private alleen de klasse zelf

Als bijvoorbeeld de klasse EenKlasse een declaratie bevat van een attribuut

private int aantal;

dan protesteert de compiler wanneer een methode van de klasse AnderKlasse naar het attribuut aantal van een object van het type EenKlasse verwijst. Toegangsbeperking gebeurt op het niveau van klassen, niet van individuele objecten. Verschillende objecten van hetzelfde type hebben wél toegang tot elkaars private onderdelen.

Pakketten

Een pakket is een logische groepering van een aantal Java-klassen (en nog een ander soort types dat we interfaces noemen, zie paragraaf 4.8). Klassen van hetzelfde pakket kunnen naar elkaar verwijzen met hun korte benaming. Klassen uit verschillende pakketten moeten naar elkaar verwijzen door de naam van de klasse of constructor te laten voorafgaan door de naam van het pakket en een punt.

Voorbeeld

class IOVoorbeeld {
  public static void main(String[] args) throws java.io.IOException {
    // ...
    java.io.BufferedReader toetsenbord
      = new java.io.BufferedReader(new java.io.InputStreamReader(System.in));
    //...
  }
}

Door een import-opdracht bovenaan een bronbestand kunnen klassen uit ander pakketten toch weer met hun korte naam benoemd worden. Een import-opdracht verwijst ofwel naar een afzonderlijke klasse (of interface), ofwel naar een heel pakket.

Voorbeeld

import java.io.IOException;
import java.io.BufferedReader;
import java.io.InputStreamReader;

class IOVoorbeeld {
  public static void main(String[] args) throws IOException {
    // ...
    BufferedReader toetsenbord
      = new BufferedReader(new InputStreamReader(System.in));
    //...
  }
}

De drie import-opdrachten kunnen ook vervangen worden door één enkele regel

import java.io.*;

Je kan ook zelf pakketten samenstellen. Neem dan bovenaan in de bronbestanden de volgende opdracht op:

package <pakketnaam> ;

Toegang tot klassen

Klassen hebben slechts toegang tot andere klassen in andere pakketten, of het nu via de korte of via de lange benaming is, als deze laatsten voorafgegaan worden door het sleutelwoord public. Dus op klassenniveau geldt de volgende beperkte tabel voor toegangsrechten.

sleutelwoordwelke klassen hebben toegang
public alle klassen
(geen sleutelwoord) klassen uit hetzelfde pakket

Als een klasse public is, moet haar naam identiek zijn aan de naam van het bronbestand. Daaruit volgt dat elk bronbestand ten hoogste één publieke klasse mag bevatten (naast een willekeurig aantal gewone, niet-publieke klassen).

4.7 Abstracte klassen

Een abstracte klasse is herkenbaar aan het sleutelwoord abstract dat voorafgaat aan de definitie van de klasse.

public abstract class MijnKlasse {
  // methoden en attributen...
}

Een abstracte klasse is niet rechtstreeks instantieerbaar. Dat wil zeggen dat objecten van dat type niet kunnen bestaan, tenzij ze tot een (niet-abstract) subtype behoren. De klasse MijnKlasse hierboven heeft dus slechts zin als er subklassen voor gecreëerd worden, bijvoorbeeld

public class MijnAndereKlasse extends MijnKlasse {
  // methoden en attributen...
}

De volgende code geeft dus aanleiding tot een compilerfout.

MijnKlasse m = new MijnKlasse();

Let wel: de declaratie van een veranderlijke van het type MijnKlasse is niet verboden, het is de rechtstreekse constructor-aanroep die voor problemen zorgt. De volgende code is dus wél aanvaardbaar.

MijnKlasse m = new MijnAndereKlasse();

Het tegenovergestelde van een abstracte klasse is een finale klasse, herkenbaar aan het sleutelwoord final. Een finale klasse kan geen subklassen hebben.

public final class MijnFinaleKlasse {
  // methoden en attributen...
}

De klasse String uit het bibliotheekpakket java.lang is een voorbeeld van een vaak gebruikte finale klasse.

Het is duidelijk dat een final abstract klasse geen zin heeft. De compiler verbiedt dit dan ook.

Een abstracte klasse kan abstracte methoden bevatten. Een abstracte methode wordt voorafgegaan door het woord abstract. Ze bevat geen implementatie (geen accoladen), maar alleen een specificatie van de toegangsbeperking (public, protected, niets of private), een terugkeertype (of void), een naam, een lijst van parameters en eventuele exceptions. Als een niet-abstracte klasse afstamt van een abstracte klasse (als dochter, of kleindochter, of nog verder), dan moet de niet-abstracte klasse alle abstracte methoden herdefiniëren.

Voorbeeld

We bouwen een informatiesysteem dat objecten van verschillende types implementeert die gemeen hebben dat er geldbedragen kunnen op gestort worden, en dat er geldbedragen van kunnen worden teruggetrokken. Voorbeelden van dergelijke types zouden kunnen zijn: bankrekeningen, boekhoudkundige journaalposten, projectbegrotingen,...

Om een gemeenschappelijke behandeling te kunnen geven aan dergelijke objecten, wensen we een gemeenschappelijk supertype Rekening te creëren waarvan ze allemaal deel uitmaken.

public abstract class Rekening {
  void stort(double bedrag) {
    //...
  }
  void trekTerug(double bedrag) {
    //...
  }
}

Deze klasse is abstract, hetgeen wil zeggen dat we geen "algemene" rekeningen wensen te creëren, alleen concrete bankrekeningen, journaalposten, begrotingen enzovoort. De concrete implementatie van de methoden stort en trekTerug kan slechts zinvol gedefinieerd worden in deze concrete gevallen: we weten niet wat het voor een algemeen object van het type Rekening zou moeten betekenen, dat er een bedrag op gestort wordt. In dat geval is het zinvol, deze twee methoden abstract te maken.

public abstract class Rekening {
  abstract void stort(double bedrag);
  abstract void trekTerug(double bedrag);
}

Hiermee bedoelen we: elke concrete klasse die een uitbreiding is van de klasse Rekening, moet een implementatie voorzien van de methoden stort en trekTerug. De accoladen die normaal een implementatie omsluiten, worden bij abstracte methoden vervangen door een kommapunt.

4.8 Interfaces

Als een abstracte klasse "zuiver abstract" is, d.w.z. dat ze geen attributen noch niet-abstracte methoden bevat, dan biedt Java een grammaticaal alternatief voor het begrip abstract class, en wel in de vorm van een interface.

De declaratie van een interface is dus erg eenvoudig, bijvoorbeeld

public interface Rekening {
  void stort(double bedrag);
  void trekTerug(double bedrag);
}

Het woord abstract wordt niet meer vermeld, ook niet bij de methoden, omdat een interface uitsluitend abstracte methoden bevat. Verder zijn alle methoden impliciet public, zodat ook geen toegangsbeperking meer geformuleerd wordt.

Om te zeggen dat een klasse een subtype is van een interface, gebruiken we het sleutelwoord implements in plaats van extends. Bijvoorbeeld

class Bankrekening implements Rekening {
  String naam;
  // en de andere attributen...

  Bankrekening(String nm, String ad, int nr, double sa) {
    naam = nm;
    adres = ad;
    rekeningnummer = nr;
    saldo = sa;
  }

  void stort(double bedrag) {
    saldo = saldo + bedrag;
  }
  void trekTerug(double bedrag) {
    saldo = saldo - bedrag;
  }
  // en de andere methoden...
}

Een belangrijk verschil tussen het uitbreiden van een (al dan niet abstracte) klasse enerzijds, en het implementeren van een interface anderzijds, is dat een klasse ten hoogste één superklasse mag hebben, terwijl ze een willekeurig aantal interfaces kan implementeren. Een klasse mag zelfs een andere klasse uitbreiden en tegelijkertijd een of meer interfaces implementeren.

public class DummyVoorbeeld extends Moederklasse implements Interface1, Interface2, Interface3 {
  //...
}

Ook interfaces kunnen elkaar onderling uitbreiden. De restrictie "single inheritance" (hoogstens één moeder) vervalt hier.

public interface Dochter extends MoederInterface1, MoederInterface2 {
  //...
}

Een groep zeer nuttige voorbeelden van interfaces wordt gevormd door de zogenaamde listener-types bij het programmeren met grafische gebruikersinterfaces: zie paragraaf 5.3.

Wie zelf pakketten op de markt brengt ten behoeve van andere programmeurs, moet ervoor zorgen dat opeenvolgende versies achterwaarts compatibel blijven met bestaande client code. Bestaande public en protected methoden mogen wel van implementatie veranderen (bijvoorbeeld om efficiënter te worden), maar de parameter-structuur moet dezelfde blijven. Er mogen uiteraard wel nieuwe gelijknamige methoden met een verschillende parameter-structuur bijkomen, volgens het eerder geschetste mechanisme van overladen.

Er mogen ook niet zonder meer abstracte methoden worden toegevoegd aan een klasse in het pakket, zelfs niet aan een bestaande abstracte klasse. Dat zou namelijk tot gevolg hebben dat bestaande concrete dochterklassen, geschreven door een afnemer van het pakket, plots niet meer compileren - or ontbreekt namelijk een implementatie.

Bovenstaande regels lijken nogal voor de hand te liggen. Er is echter een merkwaardig gevolg aan verbonden voor het onderhoud van interfaces:

Interfaces mogen niet groeien.

Een methode toevoegen aan een interface is zoveel als het toevoegen van een abstracte methode - nefast voor concrete klassen die de interface al implementeerden. Als je van een bestaande interface een nieuwe versie op de markt wil brengen, dan doe je dat beter door een sub-interface te creëren die de oude versie uitbreidt. Vaak wordt de naam van de nieuwe interface dan gevormd door achter de oude naam het cijfer 2 toe te voegen.

Voorbeeld

We ontwikkelen en verkopen een pakket be.ster.graphics waarvan de volgende interface deel uitmaakt.

package be.ster.graphics;

/** Een object dat grafisch kan worden weergegeven
 *  op een uitvoerapparaat met rasterstructuur.
 */
public interface Tekenbaar {
  /** Teken het object op het gegeven apparaat. */
  void teken(Uitvoerapparaat ua);
}

Eén van onze klanten gebruikt deze interface in haar eigen klassendefinitie:

import be.ster.graphics.*;

class Lijnstuk implements Tekenbaar {
  private int x0, y0, x1, y1;

  // ... nog wat code, en dan:

  public void teken(Uitvoerapparaat ua) {
    ua.drawLine(x0, y0, x1, y1);
  }
}

Ons pakket wordt een groot commercieel succes, en na een jaar brengen we een nieuwe versie op de markt met allerlei krachtige nieuwe mogelijkheden. Het volgende zou een ernstige vergissing zijn:

package be.ster.graphics;

/** Een object dat grafisch kan worden weergegeven
 *  op een uitvoerapparaat met rasterstructuur.
 *  Nu ook met regelbare lijndikte!
 */
public interface Tekenbaar {
  /** Teken het object op het gegeven apparaat. */
  void teken(Uitvoerapparaat ua);

  /** Teken het object op het gegeven apparaat,
   *  met regelbare lijndikte.
   */
  void teken(Uitvoerapparaat ua, double lijndikte);
}

Als onze klant haar oude code compileert met de nieuwe versie van ons pakket, krijgt ze de volgende compilatiefout (die er tevoren niet was). Haar roep om "geld terug!" zou niet eens zo onterecht klinken.

Lijnstuk.java:3: Lijnstuk is not abstract and does not override abstract method
teken(Uitvoerapparaat,double) in Tekenbaar
class Lijnstuk implements Tekenbaar {
^
1 error

In plaats daarvan hadden we beter als volgt een nieuwe interface gecreëerd. Daarmee laten we onze klanten de keus of ze wel van de nieuwe mogelijkheid gebruik willen maken, en zo ja wanneer.

package be.ster.graphics;

/** Een object dat grafisch kan worden weergegeven
 *  op een uitvoerapparaat met rasterstructuur.
 *  Nu ook met regelbare lijndikte!
 */
public interface Tekenbaar2 extends Tekenbaar {
  /** Teken het object op het gegeven apparaat,
   *  met regelbare lijndikte.
   */
  void teken(Uitvoerapparaat ua, double lijndikte);
}

In voorbereiding: bijzondere rol van de klasse Object

4.9 Inwendige klassen

Een klasse mag ook gedeclareerd worden als onderdeel van de definitie van een andere klasse. We spreken dan van een inwendige klasse. De klasse binnen wier accoladen de inwendige klasse gedefinieerd wordt, heet bevattende klasse. Een inwendige klasse kan op haar beurt inwendige klassen bevatten, maar dit komt in de praktijk zelden voor; de leesbaarheid heeft ook haar rechten.

Inwendige klassen voegen geen nieuwe mogelijkheden toe aan Java; ze zijn eerder beperkend bedoeld, om programma's overzichtelijk te houden. Zo kan de declaratie van de inwendige klasse worden voorafgegaan door één van de sleutelwoorden private, protected of public (of niets), met precies dezelfde gevolgen voor de toegang vanuit andere klassen als in paragraaf 4.6. Bij het interpreteren van de tabel in paragraaf 4.6 wordt dan onder "de klasse zelf" of "de huidige klasse" verstaan: de bevattende klasse. Inwendige klassen kunnen ook gedeclareerd worden binnen de implementatie van een methode. Dan is hun naam uitsluitend toegankelijk binnen die methode.

Als een inwendige klasse wordt vernoemd buiten de bevattende klasse (bijvoorbeeld, een methode van een derde klasse wil een veranderlijke declareren van het type van de inwendige klasse), dan moet de naam van de inwendige klasse worden voorafgegaan door de naam van de bevattende klasse en een punt.

Voorbeeld

/** Een voorbeeld van een klasse met drie inwendige klassen. */
class Bevattend {
  // gewone inwendige klasse met toegangsbeperking "pakket" (geen sleutelwoord)
  class Inwendig1 {
    // constructor van de inwendige klasse
    public Inwendig1(int i) {
      // ...
    }
  }

  // gewone inwendige klasse met toegangsbeperking "private"
  private class Inwendig2 {
    // attribuut van de inwendige klasse
    String naam = "Hallo";
  }

  // methode van de bevattende klasse
  public void test() {
    // locale veranderlijke met als type van een inwendige klasse
    Inwendig1 voorbeeld;

    // inwendige klasse binnen de methode
    class Inwendig3 {
      // methode van de inwendige klasse
      void zomaar(char s) {
        // ...
      }
    }
  }
}

/** Een voorbeeld van een klasse die naar andermans inwendige klassen verwijst. */
class Test {
  public static void main(String[] args) {
    // locale veranderlijke van het type van een inwendige klasse
    Bevattend.Inwendig1 voorbeeld;
  }
}

We onderscheiden statische en gewone inwendige klassen naargelang van het al dan niet optreden van het sleutelwoord static. Deze twee soorten inwendige klassen gedragen zich nogal verschillend. We beginnen met de eenvoudige notie van statische inwendige klasse. Vervolgens bespreken we het krachtige mechanisme van gewone inwendige klassen. We besluiten met een bijzonder geval van gewone inwendige klassen, de anonieme klassen.

4.9.1 Statische inwendige klassen

Een inwendige klasse is statisch als haar klassendefinitie wordt voorafgegaan door het sleutel­woord static. Statische inwendige klassen gedragen zich precies als alle andere klassen, met de reeds gemaakte opmerkingen over toegangsrechten:

Statische inwendige klassen kunnen zowel statische als niet-statische attributen en methoden (en inwendige klassen) hebben. Ze kunnen afgeleid zijn van een moederklasse (al dan niet inwendig) en ze kunnen zelf als moederklasse fungeren. Hun voornaamste voordeel ten opzichte van gewone klassen is dat de hogergenoemde twee beperkingen, mits goed gehanteerd, het programma overzichtelijker kunnen maken.

Een statische inwendige klasse heeft onbeperkte toegang tot de attributen, methoden, constructoren en inwendige klassen van de bevattende klasse. Niet-statische attributen en methoden (en niet-statische inwendige klassen) moeten uiteraard via een tevoren geconstrueerd object aangesproken worden.

Voorbeeld

Het volgende voorbeeld demonstreert het gebruik van statische inwendige klassen om een programma overzichtelijk te structureren. De klasse Typografie bevat twee statische inwendige klassen, Regel en Woord. Binnen het bereik van de bevattende klasse worden deze klassen steeds met hun korte naam benoemd (bijvoorbeeld het argumenttype van de methode voegToe) maar daarbuiten, in de klasse TestTypo, moet hun naam voorafgegaan worden door de naam van de bevattende klasse.

class Typografie {
  static class Regel {
    private String tekst = "";
    Regel() {
    }
    Regel(String init) {
      tekst = init;
    }
    public String toString() {
      return tekst;
    }
    void voegToe(Woord w) {
      tekst += " " + w.tekst;
    }
  }

  static class Woord {
    private final String tekst;
    Woord(String init) {
      tekst = init;
    }
    public String getTekst() {
      return tekst;
    }
  }
}

class TestTypo {
  public static void main(String[] args) {
    Typografie.Woord w1 = new Typografie.Woord("Het");
    Typografie.Woord w2 = new Typografie.Woord("innerlijke");
    Typografie.Woord w3 = new Typografie.Woord("Licht");
    Typografie.Regel r = new Typografie.Regel(w1.getTekst());
    r.voegToe(w2);
    r.voegToe(w3);
    System.out.println(r.toString());
  }
}

(Om dit programma uit te voeren: java TestTypo)

De methode voegToe van de klasse Regel heeft rechtstreekse toegang tot het attribuut tekst van de klasse Woord, hoewel dit laatste gedeclareerd is als private. In de methode main gaat dit niet, en moeten we onze toevlucht nemen tot een aanroep van getTekst. Deze laatste is toegankelijk omdat de klassen Typografie en TestTypo tot hetzelfde pakket behoren.

Precies omdat de twee klassen in één pakket zitten, maakt het normaal niet veel uit of we attributen en methoden laten voorafgaan door protected of public. De methode toString moest wel public gedeclareerd worden omdat ze geërfd wordt van de klasse Object, en een herdefinitie van een geërfde methode mag geen strengere toegangsrechten hebben dan de oorspronkelijke methode (wel eventueel ruimere).

De methode main is eveneens public. Ze wordt weliswaar niet geërfd, maar ze moet toegankelijk zijn voor de "buitenkant" (de Java Virtual Machine).

4.9.2 Gewone inwendige klassen

In tegenstelling tot een statische inwendige klasse is een gewone inwendige klasse erg nauw verbonden met haar bevattende klasse. Een object van het inwendige type wordt altijd geconstrueerd in de context van één bestaand object van het bevattende type, en kan naar de attributen en methoden van dat object (dus niet-statisch) verwijzen. Net als bij statische inwendige klassen spelen toegangsbeperkingen geen geen rol, een "inwendig" object heeft ook toegang tot de private attributen en methoden van de bevattende klasse.

De constructie van een object van een gewone inwendige klasse volgt een speciale grammatica. Stel dat <bestaand object> een uitdrukking is die naar een reeds vroeger geconstrueerd object van de bevattend klasse verwijst. Dan wordt een nieuw object van de inwendige klasse geconstrueerd met de uitdrukking

<bestaand object>.new <constructor-aanroep>

Uitzondering: binnen de constructor en de niet-statische methoden van het bestaande object mag in plaats van

this.new <constructor-aanroep>

ook gewoon

new <constructor-aanroep>

geschreven worden.

Voorbeeld

In voorbereiding.

4.9.3 Anonieme klassen

In voorbereiding.


inhoudstafel en auteursrecht
* STER *

Valid HTML 4.0! Valid CSS!