www.agora.ro
Sperăm că sunteți pe fir...

Extinderea claselor

Horia Georgescu
Limbajul Java a fost conceput ca un limbaj orientat pe obiecte. Am prezentat deja două dintre caracteristicile orientate spre obiecte: încapsularea și legarea dinamică. Vom învăța în continuare despre clase extinse, redefinirea metodelor, ascunderea câmpurilor, metode și clase abstracte.

Reamintim cele două concepte învățate, legate de orientarea spre obiecte:

  • încapsularea (datele, împreună cu operațiile asupra lor, sunt "puse la un loc", și anume într-o clasă);
  • legarea dinamică: în aceeași clasă pot exista mai multe metode (respectiv constructori) cu același nume, dar cu signaturi diferite; identificarea metodei (constructorului) a cărei (cărui) signatură corespunde apelului se face în timpul executării programului, deci dinamic.

Definirea claselor extinse

Dar facilitatea cea mai importantă legată de OOP (programarea orientată pe obiecte) este cea relativă la posibilitatea de extindere a claselor. Foarte pe scurt, deci incomplet și nu în întregime riguros, aceasta constă în:

  • o clasă poate fi extinsă, adăugându-se noi câmpuri și noi metode, care permit considerarea unor trăsături suplimentare (dacă ne gândim la câmpuri) și la operații noi (asupra câmpurilor "inițiale", dar și asupra câmpurilor nou adăugate);
  • unele metode ale clasei pe care o extindem pot fi redefinite, iar anumite câmpuri ale aceste clase pot fi "ascunse";
  • un obiect având ca tip clasa extinsă poate fi folosit oriunde este așteptat un obiect al clasei de la care am pornit.

Exemplul 1

Să presupunem că dorim să urmărim mișcarea unui punct în plan. Vom începe prin a considera clasa:

class Punct {

int x,y; Punct urm;

Punct(int x,int y) {

this.x=x;

this.y=y;

urm=null;

}

void Origine() { x=0; y=0; }

Punct Miscare(int x, int y) {

Punct p=new Punct(x,y);

this.urm=p;

return p;

}

}

Clasa Punct conține:

- câmpurile x,y,urm ;

- constructorul Punct cu signatura (int,int);

- metoda Origine cu signatura () și metoda Miscare cu signatura (int,int).

Vom extinde clasa Punct, astfel încât punctul să aibă și o culoare:

class Pixel extends Punct {

String culoare;

void Origine() { super.Origine();

culoare="alb"; }

Pixel(int x, int y, String culoare) {

super(x,y);

this.culoare=culoare;

}

}

Clasa Pixel conține:

- câmpurile x,y,urm (moștenite de la clasa Punct) și culoare (ultimul a fost adăugat prin extindere);

- constructorul Pixel cu signatura

(int,int,String);

- metoda Origine cu signatura () și metoda Miscare cu signatura (int,int), cu precizarea că metoda Origine a fost modificată (redefinită).

Este adoptată următoarea terminologie: clasa Punct este superclasă a lui Pixel, iar Pixel este subclasă (clasă extinsă) a lui Punct.

În Java, clasele formează o structură de arbore în care rădăcina este clasa Object, a cărei definiție apare în pachetul java.lang. Orice clasă extinde direct (implicit sau explicit) sau indirect clasa Object.

Termenii de superclasă și subclasă se referă exclusiv la relația tată-fiu în arborele de clase. Este incorect de a interpreta acești termeni în sensul de incluziune !

În exemplul de mai sus este ușor de ghicit că apelurile super.Origine() și super(x,y) se referă respectiv la metoda Origine și la constructorul din superclasa Punct a lui Pixel. Vom reveni însă cu toate detaliile legate de super.

Fie Sub o clasă ce extinde clasa Super. Sunt importante următoarele precizări:

  • obiectele de tip Sub pot fi folosite oriunde este așteptat un obiect de tipul Super. De exemplu, dacă un parametru al unei metode este de tipul Super, putem invoca acea metodă cu un argument de tipul Sub;
  • spunem că Sub extinde comportarea lui Super;
  • o clasă poate fi subclasă a unei singure clase deoarece, reamintim, clasele formează o structură de arbore. Drept consecință o clasă nu poate extinde două clase, deci nu există moștenire multiplă ca în alte limbaje (există însă alte mecanisme pentru a include și această facilitate și anume interfețele);
  • o metodă din superclasă poate fi modificată (redefinită, suprapusă) în subclasă (evident, ne referim aici la o redefinire a metodei folosind aceeași signatură, pentru că, dacă scriem în subclasă o metodă cu signatură diferită de signaturile metodelor cu același nume din superclasă, atunci va fi vorba de o metodă nouă). La redefinire nu putem modifica tipul valorii întoarse de metodă. Accesul la metoda cu aceeași signatură din superclasă se face, după cum vom vedea, prin precalificare cu super;
  • un câmp redefinit în Sub ascunde câmpul cu același nume din Super;
  • câmpurile neascunse și metodele neredefinite sunt automat moștenite de subclasă (în exemplul de mai sus este vorba de câmpurile x și y și de metoda Miscare);
  • apelurile super din metodele și constructorii subclasei trebuie să apară ca primă acțiune.

Din nou despre constructori

Suntem acum în măsură să prezentăm mai complet acțiunile întreprinse la crearea unui obiect.

Prima acțiune constă în a atribui câmpurilor valorile inițiale prin lipsă:

0 - pentru tipuri numerice

\u0000- pentru tipul char

false - pentru tipul boolean

null - pentru referințe (la obiecte, inclusiv tablouri și String).

În continuare este invocat constructorul corespunzător (determinat de signatură); acțiunea sa cuprinde trei faze:

1) Invocarea unui constructor al superclasei și anume:

  • explicit, cu super(...) ;
  • implicit: este vorba de constructorul fără argumente.

2) Executarea, în ordinea în care apar, a inițializărilor directe ale câmpurilor;

3) Executarea corpului constructorului.

Observații:

  • un constructor poate invoca un alt constructor și anume prin this(...); această invocare poate avea loc numai ca primă acțiune a constructorului (deci nu pot fi invocați doi constructori). În schimb acest mecanism nu poate fi folosit pentru a invoca un constructor din corpul unei metode;
  • un constructor poate invoca o metodă, caz în care se consideră implementarea metodei corespunzătoare tipului real al obiectului (vezi paragraful următor);
  • dacă din constructorul Pixel din exemplul 1 am elimina invocarea super(x,y), compilatorul ar semnala o eroare, deoarece, conform mecanismului descris mai sus, constructorul Pixel va invoca implicit constructorul fără argumente al superclasei, care însă nu există (el există implicit doar dacă nu s-a specificat un alt constructor).

Exemplul 2

Să considerăm clasele:

class a { int va=1, v; a() { v=va; }

}

class b extends a {

int vb=2; b() { v=va+vb; }}

La crearea unui obiect de tipul b, prin new b(), au loc în ordine următoarele acțiuni:

  • câmpurile v,va,vb primesc valoarea prin lipsă 0;
  • este invocat constructorul b;
  • este invocat constructorul a (al superclasei);
  • are loc inițializarea directă a lui va, care primește valoarea 1;
  • este executat corpul constructorului a și ca urmare v primește valoarea 1;
  • are loc inițializarea directă a lui vb, care primește valoarea 2;
  • este executat corpul constructorului b și ca urmare v primește valoarea 3.

Redefinirea metodelor și ascunderea câmpurilor

O metodă declarată într-o clasă poate fi redefinită (suprapusă) într-o subclasă prin declararea ei în subclasă cu același nume, aceeași signatură și același tip al valorii întoarse.

Pot fi redefinite doar metode nestatice. Rezultă că metodele statice sunt finale.

În metoda redefinită putem schimba modificatorul de acces, cu condiția ca dreptul de acces să crească; reamintim că modificatorii de acces, în ordine de la cel mai restrictiv la cel mai larg, sunt: private, protected, cel implicit (package) și public.

Câmpurile nu pot fi suprapuse, ci ascunse prin redeclararea lor într-o subclasă. Câmpul cu același nume din superclasă nu mai poate fi accesat direct prin numele său, ci va trebui folosit super (vezi paragraful următor) sau o referință la tipul superclasei. Diferența de terminologie va fi explicată mai jos.

Fie A o clasă și fie B o subclasă a sa. Să considerăm următoarele patru acțiuni echivalente din punctul de vedere al obiectului a ce ia naștere:

1) A a; a=new B(...);

2) A a=new B(...);

3) A a; B b; b=new B(...); a=b;

4) A a; B b; b=new B(...); a=(A) b;

Să observăm că este vorba de o conversie de la o subclasă la o superclasă, conversie numită upcasting ; ea poate fi implicită (ca în primele trei acțiuni) sau explicită (ca în cea de a patra acțiune).

Vom spune că obiectul a are:

  • tipul real B;
  • tipul declarat A.

Tipul real al unui obiect coincide cu tipul său declarat (este cazul obiectului b) sau este o subclasă a tipului declarat (vezi obiectul a).

Fie camp un câmp al clasei A, ce este redeclarat (ascuns) în subclasa B. Dacă obiectul a face referire la câmpul camp, atunci este vorba de câmpul declarat în clasa A, adică este folosit tipul declarat al obiectului.

Fie met o metodă a clasei A, care este redefinită în subclasa B. La invocarea metodei met de către obiectul a, este folosită implementarea corespunzătoare metodei redeclarate, adică este folosit tipul real al obiectului.

Exemplul 3

Considerăm o formă nouă a claselor din exemplul 2:

class A {

int va=1, v;

A() { v=va; System.out.print(""+met()); }

int met() { return va; }

}

class B extends A {

int vb=2, v;

B() { v=va+vb; }

int met() { return vb; }

}

class constr {

public static void main (String[] s) {

A a=new B();

System.out.println("\t"+a.met());

}

}

Rezultatele tipărite de programul de mai sus sunt 0 și 2, deoarece:

  • la invocarea metodei met din cadrul constructorului clasei A este folosit tipul real al obiectului, adică este întors rezultatul furnizat de metoda met redeclarată în B; la momentul invocării, valoarea curentă a lui vb este 0 (vezi paragraful precedent);
  • invocarea metodei met în metoda principală, realizată de obiectul a, se va referi tot la metoda met din clasa B.

De ce este însă folosit un tip (cel declarat) pentru câmpuri, și alt tip (cel real) pentru metode? Ideea urmărită este ca a să "preia" cât mai multe informații din tipul A. Obiectul a are următorii membri: câmpurile v,va,vb și metoda met. Conform ideii de mai sus, pentru singurul câmp "în litigiu" și anume v, este ales tipul A, deci tipul declarat. Dar pentru metode nu este nimic de ales: va fi folosit tipul real, deoarece ele pot conține referiri și la noile câmpuri introduse în clasa B (în cazul nostru câmpul vb).

Exemplul 4

Să considerăm următorul program:

class A {

String s="Super";

void scrie() {

System.out.println("A : "+s);

}

}

class B extends A {

String s="Sub";

void scrie() {

System.out.println("A : "+s);

}

}

class Ex4 {

public static void main(String[] s) {

B b = new B(); A a = b;

a.scrie(); b.scrie();

System.out.println(a.s + "\t" + b.s);

}

}

La executare sunt tipărite următoarele:

B : Sub

B : Sub

Super Sub

adică rezultatele așteptate, ținând cont că:

  • obiectul b are tipul B atât ca tip declarat, cât și ca tip real;
  • obiectul a are tipul declarat A, iar tipul real este B.

Considerațiile de mai sus referitoare la noțiunile de tip declarat și tip extins conduc și la concluzia că o conversie de la o superclasă la o subclasă nu pot fi totdeauna acceptate. Este suficient să considerăm exemplul de mai sus pentru a realiza că o secvență de tipul:

A a; B b; a=new A(); b=a;

nu este acceptabilă, deoarece de exemplu clasa B poate defini noi câmpuri.

O conversie de la o superclasă la o subclasă (numită downcasting) este permisă numai dacă este făcută explicit și numai dacă referința la superclasă este de fapt o referință la subclasă. Iată cadrul tipic în care se realizează acest lucru (în continuare A este superclasă a lui B):

A a; B b1,b2;

b = new B(); . . .

a = b; . . .

b1 = (B) a; (1)

Menționăm că putem verifica dacă un obiect Ob este de un anumit tip Tip sau poate fi convertit la acel tip, folosind cuvântul cheie instanceof în construcția:

Ob instanceof Tip

care întoarce o valoare booleană. Referința null nu este de nici un tip, deci:

null instanceof Tip

este false, indiferent de tipul Tip.

De exemplu instrucțiunea:

System.out.print((b1 instanceof A)+

"\t"+(b1 instanceof B));

plasată după (1), produce la ieșire:

true true

Vom discuta despre conversii într-un articol aparte.

Cuvântul cheie super

Putem folosi cuvântul cheie super în orice metodă nestatică a unei clase extinse, și anume sub una dintre formele:

- super(...) : pentru a invoca un constructor al superclasei clasei curente;

- super.met(...) : pentru a invoca o metodă a superclasei, metodă ce a fost redefinită în clasa curentă;

- super.c : pentru a accesa un câmp c al superclasei, câmp ce a fost ascuns în clasa curentă prin redefinirea sa;

- super.finalize() : pentru a controla activitatea colectorului de gunoaie; amânăm prezentarea acestei facilități.

Observăm că la accesarea unui câmp sau a unei metode, super acționează ca referință la obiectul curent ca instanțiere a superclasei sale.

Pentru exemplificare, să considerăm programul:

class A { String s="Super";

void scrie() {

System.out.println("A:"+s);

}

void metoda() {

System.out.println("A:");

}

}

class B extends A {

String s="Sub";

void scrie() {

System.out.println("B:"+s+" "+super.s);

}

void metoda() {

scrie();

super.scrie(); super.metoda();

}

}

class AB {

public static void main(String[] x) {

B b = new B(); b.metoda();

System.out.println(b.s);

System.out.println("****************");

A a; a = b; a.metoda();

System.out.println(a.s);

System.out.println("****************");

A c = new A(); c.metoda();

System.out.println(c.s);

}

}

care produce la ieșire următorul rezultat:

B : Sub Super

A : Super

A:

Sub

****************

B : Sub Super

A : Super

A:

Super

****************

A:

Super

Construcțiile de forma b.super.metoda() (care ar intenționa ca pentru obiectul b să invoce metoda metoda din superclasa clasei de tipul căreia este) sau urcarea mai multor niveluri în arborele de clase prin super.super sunt incorecte, neîncadrându-se în formele prezentate anterior de folosire a lui super. Pentru a avea acces la un câmp ascuns, aflat cu două niveluri mai sus în arborele de clase, putem proceda de exemplu astfel:

class A {

int x=1;

void met() {

System.out.print("\t"+x);

}

}

class B extends A {

boolean x=false;

void met() {

System.out.print("\t"+x); super.met();

}

}

class C extends B {

double x=3.14;

void met() {

System.out.print("\t"+x); super.met();

}

}

class subsup {

public static void main (String[] s) {

C Ob=new C(); Ob.met();

}

}

prin a cărui executare obținem la ieșire: 3.14 false 1

Din nou despre modificatori

Reluăm discuția despre modificatori, pentru a include și aspectele legate de extinderea claselor.

Reamintim că modificatorii se împart în 4 categorii:

  • modificatorii de acces (public, protected, private și cel implicit);
  • modificatorul static;
  • modificatorul synchronized;
  • modificatorul final.

Modificatorul synchronized va fi studiat separat atunci când vom vorbi de fire de executare.

Legat de modificatorul static adăugăm la cele deja cunoscute că o metodă statică a unei clase nu poate fi redefinită într-o subclasă, deci este finală. În schimb un câmp static poate fi redefinit (ascuns) într-o subclasă. Evident, nici metodele declarate cu final nu pot fi redefinite în subclase.

Referitor la modificatorii de acces, elementele noi ce intervin la extinderea claselor se referă la moștenirea lor în subclase (vezi tabelul). Precizăm că problema moștenirii se pune (bineînțeles) numai pentru acei membri ai clasei care nu sunt statici.

Precizăm efectul lui protected: un membru declarat cu protected poate fi accesat dintr-o clasă numai prin intermediul unor obiecte al căror tip este un descendent al clasei în arborele de clase. Să considerăm următorul subarbore de clase:

Presupunem că în clasa A apare un câmp x declarat cu protected. Să presupunem că din B facem o referire la acest câmp prin c.x, unde c este un obiect de tipul C. Această referire nu este permisă (exceptând cazul în care clasele B și C apar în același pachet) deoarece ieșim din subarborele din care se face referirea.

Restricția de mai sus nu mai funcționează pentru membrii declarați atât cu protected, cât și cu static: pentru orice metodă din B,C,D putem accesa câmpul x prin referințe având oricare dintre tipurile A,B,C,D.

Și constructorilor le putem atașa modificatori de acces. Un caz limită este cel în care într-o clasă C există cel puțin un constructor și toți constructorii sunt declarați cu private. În acest caz nu putem construi direct din afara clasei un obiect având ca tip această clasă. Astfel de clase sunt folosite de obicei pentru a "stoca" metode și câmpuri statice, un exemplu fiind clasa System. Dar aceasta nu înseamnă că nu putem folosi o construcție indirectă: din afara clasei putem invoca de exemplu o metodă statică a lui C ce construiește un obiect de tipul C și îl întoarce ca rezultat:

class C {

private C() {}

static C met() {

return new C();

}

void scrie() {

System.out.println("O.K.");

}

}

class constrf {

public static void main (String[] s) {

C Ob = C.met();

Ob.scrie();

}

}

Modificatorul final poate fi asociat (în afara variabilelor locale și câmpurilor) atât metodelor cât și claselor. El trebuie înțeles în sensul de "variantă finală":

  • o metodă cu acest modificator nu poate fi redefinită într-o subclasă;
  • o clasă cu acest modificator nu poate fi extinsă.

Metodele declarate cu private sau/și static sunt echivalente cu final, iar din punctul de vedere al redefinirii, nu pot fi redefinite.

Dacă o metodă este finală, "ne putem baza" pe implementarea ei (bineînțeles dacă nu invocă metode nefinale). În unele situații, este bine să marcăm toate metodele cu final, dar nu și clasa, pentru a permite extensii (subclase) ale ei.

În articolul dedicat accesului și vizibilității, la determinarea semnificației unui identificator, am rămas datori cu explicarea celei de a patra căutări: ea are în vedere luarea în considerație a membrilor clasei, inclusiv a membrilor moșteniți de la superclase.

Metode și clase abstracte

Se întâmplă frecvent ca atunci când lucrăm cu clase extinse, pentru o clasă să nu putem preciza implementarea unei metode, deoarece în subclasele sale ea va fi specifică fiecăreia dintre ele. Atunci metodele din această categorie vor fi marcate cu cuvântul cheie abstract și se vor reduce la antetul lor; în același timp și clasa trebuie însoțită de atributul abstract. Fiecare metodă abstractă trebuie implementată, adică (re)definită în orice subclasă care nu este la rândul său abstractă.

Nu este posibil să creăm obiecte ca instanțe a unei clase abstracte. Pe de altă parte, într-o subclasă putem redefini o metodă a superclasei transformând-o într-o metodă abstractă; aceasta are sens, de exemplu, dacă în subarborele având ca rădăcină subclasa, comportamentul materializat prin acea clasă nu mai este valabil în subarbore și este specific fiecărei extensii a subclasei.

Exemplu

Următoarea clasă are ca scop să măsoare timpul necesitat de executarea unei metode oarecare fără parametri și care nu întoarce vreun rezultat:

abstract class C {

abstract void met();

public long timp() {

long t0 = System.currentTimeMillis();

met();

return System.currentTimeMillis()-t0;

}

}

unde a fost folosită metoda statică currentTimeMillis() a clasei System. Într-o clasă ce extinde C, se poate (re)defini metoda met și apoi, folosind un obiect ce este o instanțiere a noii clase, putem apela metoda timp pentru a determina timpul de executare a metodei met, ce are acum o implementare precisă. După cum îi sugerează numele, metoda currentTimeMillis() întoarce timpul în milisecunde:

class C1 extends C {

void met () {

int x=0;

for (int i=0; i<1000000; i++) x=x+1-1;

}

}

class abstr {

public static void main(String[] s) {

C1 Ob=new C1();

System.out.println( Ob.timp() );

}

}

Legat de exemplul de mai sus, nu trebuie să ne mire că la executări diferite vom obține timpi diferiți: aceasta este o consecință a faptului că interpretorul Java lucrează sub sistemul Windows (chiar dacă a fost lansat din DOS) și pe perioada executării programului nostru, sistemul "mai face și altceva".

Prof. dr. Horia Georgescu este cadru didactic la Facultatea de Matematică din București, poate fi contactat prin e-mail la adresa: hg@colegiu.cs.unibuc.ro

[cuprins]