inhoudstafel en auteursrecht
* STER *
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:
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.
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:
void);new.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.
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
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)
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++) // ...
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;
}
}
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)
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.
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 |
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.
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.
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:
| sleutelwoord | welke 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.
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.
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.
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> ;
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.
| sleutelwoord | welke 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).
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.
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.
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.
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
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.
/** 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.
Een inwendige klasse is statisch als haar klassendefinitie wordt voorafgegaan door
het sleutelwoord 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.
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).
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.
In voorbereiding.
In voorbereiding.