Video introductiv: link
Scopul acestui laborator este familiarizarea studenților cu noțiunile de agregare și de moștenire a claselor.
Aspectele urmărite sunt:
Agregarea și compunerea se referă la prezența unei referințe pentru un obiect într-o altă clasă. Acea clasă practic va refolosi codul din clasa corespunzătoare obiectului.
Inițializarea obiectelor conținute poate fi făcută în 3 momente de timp distincte:
Exemple de cod:
Compunere:
public class Foo { // Obiectul de tip Bar nu poate exista dacă obiectul Foo nu există private Bar bar = new Bar(); }
Agregare:
public class Foo { private Bar bar; // Obiectul de tip Bar poate continua să existe chiar dacă obiectul Foo nu există Foo(Bar bar) { this.bar = bar; } }
Exemplu practic:
class Page { private String content; public int numberOfPages; public Page(String content, int numberOfPages) { this.content = content; this.numberOfPages = numberOfPages; } } class Book { private String title; // Compunere private Page[] pages; // Compunere private LibraryRow libraryRow = null; // Agregare public Book(int size, String title, LibraryRow libraryRow) { this.libraryRow = libraryRow; this.title = title; pages = new Page[size]; for (int i = 0; i < size; i++) { pages[i] = new Page("Page " + i, i); } } } class LibraryRow { private String rowName = null; // Agregare public LibraryRow(String rowName) { this.rowName = rowName; } } class Library { public static void main(String[] args) { LibraryRow row = new LibraryRow("a1"); Book book = new Book(100, "title", row); // După ce nu mai există nici o referință la obiectul Carte, // Garbage Collector-ul va șterge (la un moment dat, nu // neapărat imediat) acea instanță, dar obiectul LibraryRow // transmis constructorului nu este afectat. book = null; } }
Numită și derivare, moștenirea este un mecanism de refolosire a codului specific limbajelor orientate obiect și reprezintă posibilitatea de a defini o clasă care extinde o altă clasă deja existentă. Ideea de bază este de a prelua funcționalitatea existentă într-o clasă și de a adăuga una nouă sau de a o modela pe cea existentă.
Clasa existentă este numită clasa-părinte, clasa de bază sau super-clasă. Clasa care extinde clasa-părinte se numește clasa-copil (child), clasa derivată sau sub-clasă.
super
, cum vom vedea mai jos), acel părinte este unic determinat.
Când se folosește moștenirea și când agregarea?
Răspunsul la această întrebare depinde, în principal, de datele problemei analizate dar și de concepția designerului, neexistând o rețetă general valabilă în acest sens. În general, agregarea este folosită atunci când se dorește folosirea trăsăturilor unei clase în interiorul altei clase, dar nu și interfața sa (prin moștenire, noua clasă ar expune și metodele clasei de bază). Putem distinge două cazuri:
private
. public
. Un exemplu în acest sens ar fi o clasă numită Car
care conține ca membrii publici obiecte de tip Engine
, Wheel
etc.
Moștenirea este un mecanism care permite crearea unor versiuni “specializate” ale unor clase existente (de bază). Moștenirea este folosită în general atunci când se dorește construirea unui tip de date care să reprezinte o implementare specifică (o specializare oferită prin clasa derivată) a unui lucru mai general. Un exemplu simplu ar fi clasa Dacia
care moștenește clasa Car
.
Diferența dintre moștenire și agregare este de fapt diferența dintre cele 2 tipuri de relații majore prezente între obiectele unei aplicații :
Animal
și o clasă Dog
, atunci ar fi normal să avem Dog
derivat din Animal
, cu alte cuvinte Dog
is an Animal
) Car
și o clasă Engine
, atunci ar fi normal să avem Engine
referit în cadrul Car
, cu alte cuvinte Car
has a Engine
) Convertirea unei referințe la o clasă derivată într-una a unei clase de bază poartă numele de upcasting. Upcasting-ul este făcut automat și nu trebuie declarat explicit de către programator.
Exemplu de upcasting:
class Instrument { public void play() {} static void tune(Instrument i) { i.play(); } } // Obiectele Wind sunt instrumente // deoarece au ca și clasa-parinte clasa Instrument public class Wind extends Instrument { public static void main(String[] args) { Wind flute = new Wind(); Instrument.tune(flute); // !! Upcasting automat pentru că metoda primește // un obiect de tip Instrument, nu un obiect de tip Wind // Deci ar fi redundant să faci un cast explicit cum ar fi: // Instrument.tune((Instrument) flute) } }
Deși obiectul flute
este o instanță a clasei Wind
, acesta este pasat ca parametru în locul unui obiect de tip Instrument
, care este o superclasa a clasei Wind
. Upcasting-ul se face la pasarea parametrului. Termenul de upcasting provine din diagramele de clase (în special UML) în care moștenirea se reprezintă prin 2 blocuri așezate unul sub altul, reprezentând cele 2 clase (sus este clasa de bază iar jos clasa derivată), unite printr-o săgeată orientată spre clasa de bază.
Downcasting este operația inversă upcast-ului și este o conversie explicită de tip în care se merge în jos pe ierarhia claselor (se convertește o clasă de bază într-una derivată). Acest cast trebuie făcut explicit de către programator. Downcasting-ul este posibil numai dacă obiectul declarat ca fiind de o clasă de bază este, de fapt, instanță clasei derivate către care se face downcasting-ul.
Iată un exemplu în care este folosit downcasting:
class Animal { public void eat() { System.out.println("Animal eating"); } } class Wolf extends Animal { public void howl() { System.out.println("Wolf howling"); } public void eat() { System.out.println("Wolf eating"); } } class Snake extends Animal { public void bite() { System.out.println("Snake biting"); } } public class Test { public static void main(String[] args) { Animal[] animals = new Animal[2]; animals[0] = new Wolf(); // Upcasting automat animals[1] = new Snake(); // Upcasting automat for (int i = 0; i < animals.length; i++) { animals[i].eat(); // 1 if (animals[i] instanceof Wolf) { ((Wolf)animals[i]).howl(); // 2 } if (animals[i] instanceof Snake) { ((Snake)animals[i]).bite(); // 3 } } } }
Codul va afișa:
Wolf eating Wolf howling Animal eating Snake biting
În liniile marcate cu 2 și 3 se execută un downcast de la Animal
la Wolf
, respectiv Snake
pentru a putea fi apelate metodele specifice definite în aceste clase. Înaintea execuției downcast-ului (conversia de tip la Wolf respectiv Snake) verificăm dacă obiectul respectiv este de tipul dorit (utilizând operatorul instanceof
). Dacă am încerca să facem downcast către tipul Wolf
al unui obiect instanțiat la Snake
mașina virtuală ar semnala acest lucru aruncând o excepție la rularea programului.
Apelarea metodei eat()
(linia 1) se face direct, fără downcast, deoarece această metodă este definită și în clasa de bază Animal
. Datorită faptului că Wolf
suprascrie (overrides) metoda eat()
, apelul a[0].eat()
va afișa “Wolf eating”. Apelul a[1].eat()
va apela metoda din clasă de bază (la ieșire va fi afișat “Animal eating”) deoarece a[1]
este instanțiat la Snake
, iar Snake
nu suprascrie metoda eat()
.
Totuși, deși v-am ilustrat cum instanceof
ne poate ajuta să ne dăm seama la ce să facem downcasting, este de preferat să ne organizăm clasele și designul codului în așa fel încât să lăsăm limbajul Java să facă automat verificarea tipului și să cheme metoda corespunzătoare. Vom refactoriza codul anterior pentru a nu fi nevoie de instanceof
:
class Animal { public void eat() { System.out.println("Animal eating"); } public void action() { // avem nevoie de această metodă deoarece vom crea un vector // cu instanțe Animal și vom apela această metodă pe ele } } class Wolf extends Animal { public void action() { System.out.println("Wolf howling"); } public void eat() { System.out.println("Wolf eating"); } } class Snake extends Animal { public void action() { System.out.println("Snake biting"); } } class Test { public static void main(String[] args) { Animal a [] = new Animal[2]; a[0] = new Wolf(); a[1] = new Snake(); for (int i = 0; i < a.length; i++) { a[i].eat(); // acum că ele sunt numite la fel, putem apela metoda action // din clasa Animal (observați de ce a fost nevoie să definim // metoda action în clasa Animal), iar metoda corespunzătoare // va fi apelată pentru tipul specific al instanței a[i] a[i].action(); } } }
Codul va afișa:
Wolf eating Wolf howling Animal eating Snake biting
instanceof
fiind un type comparison operator ce va compara la runtime, în schimb genericitatea fiind rezolvată la compile-time. Mai multe detalii aici si in laboratorul 10 despre genericitate.
Soluții pentru evitarea instanceof
:
instanceof
?
instanceof
poate fi folosit când nu controlăm ierarhia claselor și suntem obligați să facem
testarea tipului deoarece nu putem aplica polimorfismul - clasele provin dintr-o librărie externă.
În Java, clasele și membrii acestora (metode, variabile, clase interne) pot avea diverși specificatori de acces, prezentați pe wiki în Organizarea surselor și controlul accesului.
protected
- specifică faptul că membrul sau metoda respectivă poate fi accesată doar din cadrul clasei înseși sau din clasele derivate din această clasă. Clasele nu pot avea acest specificator, doar membrii acestora!private
- specifică faptul că membrul sau metoda respectivă poate fi accesată doar din cadrul clasei înseși, nu și din clasele derivate din această clasă. Clasele nu pot avea acest specificator, doar membrii acestora!Constructorii nu se moștenesc și pot fi apelați doar în contextul unui constructor copil. Apelurile de constructor sunt înlănțuite, ceea ce înseamnă că înainte de a se inițializa obiectul copil, mai întâi se va inițializa obiectul părinte. În cazul în care părintele este copil la rândul lui, se va înițializa părintele lui (până se va ajunge la parintele suprem – root).
Pe lângă reutilizarea codului, moștenirea dă posibilitatea de a dezvolta pas cu pas o aplicație (procedeul poartă numele de incremental development). Astfel, putem folosi un cod deja funcțional și adaugă alt cod nou la acesta, în felul acesta izolându-se bug-urile în codul nou adăugat. Pentru mai multe informații citiți capitolul Reusing Classes din cartea Thinking în Java (Bruce Eckel)
Suprascrierea (overriding) presupune înlocuirea funcționalității din clasa/clasele părinte pentru instanța curentă. Supraîncărcarea (overloading) presupune furnizarea de funcționalitate în plus, fie pentru metodele din clasa curentă, fie pentru clasa/clasele părinte.
public class Car { public void print() { System.out.println("Car"); } public void init() { System.out.println("Car"); } public void addGasoline() { // do something } } class Dacia extends Car { public void print() { System.out.println("Dacia"); } public void init() { System.out.println("Dacia"); } // Exemplu de suprascriere public void addGasoline() { // do something } // Exemplu de supraîncărcare public void addGasoline(Integer gallons) { // do something } }
Metodele dependente de instanță sunt polimorfice (la runtime pot avea diferite implementări) deci ele pot fi suprascrise sau supraîncarcăte. Metoda print
este suprascrisă în clasa Dacia
ceea ce înseamnă că orice instanță, chiar dacă se face cast la tipul Car
metoda ce se va apela va fi mereu metoda print
din clasa Dacia
. Metoda addGasoline
este supraîncărcată ceea ce înseamnă că putem executa metode cu semnături diferite dar același nume (cel mai folosit in crearea metodelor de conversie).
Car a = new Car(); Car b = new Dacia(); Dacia c = new Dacia(); Car d = null; a.print(); // afișează Car b.print(); // afișează Dacia c.print(); // afișează Dacia d.print(); // aruncă NullPointerException
Suprascrierea nu se aplică și metodelor statice pentru că ele nu sunt dependente de instanță. Dacă în exemplul de mai sus facem metodele print din Car și din Dacia statice, rezultatul va fi următorul:
Car a = new Car(); Car b = new Dacia(); Dacia c = new Dacia(); Car d = null; a.print(); // afișează Car b.print(); // afișează Car pentru că tipul dat la inițializare al lui b este Car c.print(); // afișează Dacia pentru că tipul dat la inițializare al lui c este Dacia d.print(): // afișează Car pentru că tipul dat la inițializare al lui d este Car
Una din problemele cele mai des întâlnite este suprascrierea corectă a metodei equals. Mai jos putem vedea un exemplu de suprascriere incorectă a acestei metode.
public class Car { public boolean equals(Car c) { System.out.println("Car"); return true; } public boolean equals(Object o) { System.out.println("Object"); return false; } }
Prima metodă este o supraîncărcare a metodei equals iar a doua metodă este suprascrierea metodei equals.
Car a = new Car(); Dacia b = new Dacia(); int c = 10; a.equals(a); // afișează Car a.equals(b); // afișează Car deoarece se face upcasting de la Dacia la Car a.equals(c); // afișează Object deoarece se face upcasting de la Int la Object
Problema care se poate observa este că putem pasa ca argumente metodei equals si tipuri de date diferite de Car
, lucru ce ar putea arunca excepții de cast sau când vrem să accesăm anumite proprietăți din instanță. Mai jos este modul corect de a suprascrie metoda equals.
public class Car { public boolean equals(Car c) { return true; } public boolean equals(Object o) { if (o == this) { return true; } if (!(o instanceof Car)) { return false; } return equals((Car) o); } }
De reținut că folosirea instanceof
nu este recomandată, însă în acest caz este singurul mod prin care ne putem asigura ca instanța de obiect trimisă metodei este de tip Car
.
Cuvântul cheie super
se referă la instanța părinte a clasei curente. Acesta poate fi folosit în două moduri: apelând o metoda suprascrisă (overriden) sau apelând constructorul părinte.
public class Superclass { public void printMethod() { System.out.println("Printed in Superclass."); } } public class Subclass extends Superclass { // overrides printMethod in Superclass public void printMethod() { super.printMethod(); // apelează metoda părinte System.out.println("Printed in Subclass."); } public static void main(String[] args) { Subclass s = new Subclass(); s.printMethod(); } }
Codul va afișa:
Printed in Superclass.
Printed in Subclass.
class Superclass { public Superclass() { System.out.println("Printed in Superclass constructor with no args."); } public Superclass(int a) { System.out.println("Printed in Superclass constructor with one integer argument."); } } class Subclass extends Superclass { public Subclass() { super(); // apelează constructorul părinte // acest apel trebuie să fie pe prima linie a constructorului !! System.out.println("Printed in Subclass constructor with no args."); } public Subclass(int a) { super(a); // apelează constructorul părinte // acest apel trebuie să fie pe prima linie a constructorului !! System.out.println("Printed in Subclass constructor with one integer argument."); } public static void main(String[] args) { Subclass s1 = new Subclass(20); Subclass s2 = new Subclass(); } }
Codul va afișa:
Printed in Superclass constructor with one integer argument. Printed in Subclass constructor with one integer argument. Printed in Superclass constructor with no args. Printed in Subclass constructor with no args.
super
din constructor).
super()
, compilatorul va apela automat constructor-ul implicit al părintelui însă dacă se dorește apelarea altui constructor, apelul de super(args)
respectiv este obligatoriu
Clasa ArrayList este un array redimensionabil, iar această clasă poate fi găsită în pachetul java.util
.
Principalele metode ale acestei clase sunt:
add()
- adaugă un element la ArrayList.get()
- accesează elementul de pe o anumită poziție din ArrayList.size()
- returnează numărul de elemente din ArrayList.set()
- modifică un element de pe o anumită poziție din ArrayList. remove()
- șterge un element de pe o anumită poziție din ArrayList.clear()
- șterge toate elementele din ArrayList.Pentru a putea parcurge elementele unui ArrayList se poate folosit atât un for-each, cât și clasa Iterator. Un exemplu de utilizare a metodelor clasei ArrayList și de parcurgere a elementelor stocate de această clasă este următorul:
import java.util.ArrayList; import java.util.Iterator; public class Main { public static void main(String[] args) { ArrayList<String> animals = new ArrayList<>(); animals.add("Dog"); animals.add("Cat"); animals.add("Sheep"); System.out.println("First animal from the list is: " + animals.get(0)); System.out.println("Number of animals from the list: " + animals.size()); animals.set(0, "Lion"); System.out.println("First animal from the list is: " + animals.get(0)); animals.remove(0); for (String animal : animals) { System.out.println(animal); } Iterator iterator = animals.iterator(); while(iterator.hasNext()) { System.out.println(iterator.next()); } animals.clear(); } }
Variabilele declarate cu atributul final
pot fi inițializate o singură dată. Observăm că astfel unei variabile de tip referință care are atributul final
îi poate fi asignată o singură valoare (variabila poate puncta către un singur obiect). O încercare nouă de asignare a unei astfel de variabile va avea ca efect generarea unei erori la compilare.
Totuși, obiectul către care punctează o astfel de variabilă poate fi modificat intern, prin apeluri de metode sau acces la câmpuri.
Exemplu:
class Student { private final Group group; // a student cannot change the group he was assigned in private static final int UNIVERSITY_CODE = 15; // declaration of an int constant public Student(Group group) { // reference initialization; any other attempt to initialize it will be an error this.group = group; } }
Dacă toate atributele unui obiect admit o unică inițializare, spunem că obiectul respectiv este immutable
, în sensul că nu putem schimba obiectul in sine (informația pe care o stochează, de exemplu), ci doar referința către un alt obiect.. Exemple de astfel de obiecte sunt instanțele claselor String
și Integer
. Odată create, prelucrările asupra lor (ex.: toUpperCase()
) se fac prin instanțierea de noi obiecte și nu prin alterarea obiectelor înseși.
Exemplu:
String s1 = "abc"; String s2 = s1.toUpperCase(); // s1 does not change; the method returns a reference to a new object which can be accessed using s2 variable s1 = s1.toUpperCase(); // s1 is now a reference to a new object
String pool
pentru a limita memoria utilizată. Asta înseamnă că dacă mai declarăm un alt literal “abc”, nu se va mai aloca memorie pentru încă un String, ci vom primi o referință către s-ul inițial. În cazul în care folosim constructorul pentru String, se alocă memorie pentru obiectul respectiv și primim o referință nouă.
Pentru a evidenția concret cum funcționează acest String pool
, să luăm următorul exemplu:
String s1 = "a" + "bc"; String s2 = "ab" + "c";
În momentul în care compilatorul va încerca să aloce memorie pentru cele 2 obiecte, va observa că ele conțin, de fapt, aceeași informație. Prin urmare, va instanția un singur obiect, către care vor pointa ambele variabile, s1 și s2. Observați că această optimizare (de a reduce memoria) e posibilă datorită faptului că obiectele de tip String sunt immutable.
O întrebare legitimă este, așadar, cum putem compara două String-uri (ținând cont de faptul că avem referințele către ele, cum am arătat mai sus). Să urmărim codul de mai jos:
String a = "abc"; String b = "abc"; System.out.println(a == b); // True String c = new String("abc"); String d = new String("abc"); System.out.println(c == d); // False
equals
. Același lucru este valabil și pentru oricare alt tip de referință: operatorul "==" testează egalitatea referințelor (i.e. dacă cei doi operanzi sunt de fapt același obiect).
Dacă vrem să testăm “egalitatea” a două obiecte, se apelează metoda: public boolean equals(Object obj)
.
Reţineţi semnătura acestei metode!
public static String concatenationUsingTheStringClass() { String t = "Java"; for (int i = 0; i < 10000; i++) { t = t + "POO"; } return t; }
În acest caz, numărul de obiecte create în memorie este unul foarte mare. Dintre acestea doar cel rezultat la final este util. Pentru a preveni alocarea nejustificată a obiectelor de tip String care reprezintă pași intermediari în obținerea șirului dorit putem alege să folosim clasa StringBuilder creată special pentru a efectua operații pe șiruri de caractere.
public static String concatenationUsingTheStringBuilderClass() { StringBuilder sb = new StringBuilder("Java"); for (int i = 0; i < 10000; i++) { sb.append("POO"); } return sb.toString(); }
Cuvântul cheie final poate fi folosit și în alt context decât cel prezentat anterior. De exemplu, aplicat unei clase împiedică o eventuală derivare a acestei clase prin moștenire.
final class ParentClass { } class ChildClass extends ParentClass { // compilation error, the class ParentClass cannot be extended }
În mod similar, în cazul în care aplicăm cuvântul cheie final unei metode, acest lucru împiedică o eventuală suprascriere a acelei metode.
class ParentClass { public final void dontOverride() { System.out.println("You cannot override this method"); } } class ChildClass extends ParentClass { public void dontOverride() { // compilation error, the method dontOverride() from System.out.println("But I want to!"); // the parent class cannot be overriden } }
Relații între obiecte:
Upcasting:
Downcasting:
Suprascrierea:
Supraîncărcarea:
Cuvântul cheie super:
Task 1 [1p]
Veți proiecta o clasă Form care va avea câmpul privat color (String).
Clasa va avea, de asemenea:
- un constructor fără parametri, care va inițializa culoarea cu “white”;
- un constructor cu parametri;
- o metodă de tip float getArea(), care va întoarce valoarea 0;
- o metodă toString(): “This form has the color [color]”.
Task 2 [2p]
Din clasa Form derivați clasele Square, Triangle, Circle:
- clasa Triangle (triunghi isoscel) va avea 2 membri height și base (adiacenta unghiurilor congruente) de tip float;
- clasa Circle va avea membrul radius de tip float;
- clasa Square va avea membrul side de tip float.
Clasele vor avea: - constructori fără parametri;
- constructori care permit inițializarea membrilor. Identificați o modalitate de reutilizare a codului existent;
- suprascrieți metoda getArea() pentru a întoarce aria specifică fiecărei figuri geometrice;
- suprascrieți metoda toString() în clasele derivate, astfel încât aceasta să utilizeze implementarea metodei toString() din clasa de baza.
Task 3 [2p]
Adăugați o metodă equals() în clasa Triangle.
Justificați criteriul de echivalență ales.
Hint: Puteți genera automat metoda, cu ajutorul IDE. Selectați câmpurile considerate și analizați în ce fel va fi suprascrisă metoda equals.
Task 4 - Upcasting [1p]
Creați un vector de obiecte Form și populați-l cu obiecte de tip Triangle, Circle și Square (upcasting).
Parcurgeți acest vector și apelați metoda toString() pentru elementele sale. Ce observați?
Task 5 - Downcasting [2p]
Adăugați:
- clasei Triangle metoda printTriangleDimensions,
- clasei Circle metoda printCircleDimensions
- clasei Square metoda printSquareDimensions
Implementarea metodelor constă în afișarea bazei și înălțimii, razei, respectiv laturii.
Parcurgeți vectorul de la exercițiul anterior și, folosind downcasting la clasa corespunzătoare, apelați metodele specifice fiecărei clase:
- printTriangleDimensions pentru Triangle
- printCircleDimensions pentru Circle
- printSquareDimensions pentru Square
Pentru a stabili tipul obiectului curent folosiți operatorul instanceof.
Task 6 - Agregare [1p]
Afișați dimensiunile formelor din vectorul creat fără a folosi operatorul instanceof.
Task 7 - Final [1p]
Afișați perimetrul fiecărui obiect din vectorul creat utilizând exclusiv funcția printPerimeter, modificând doar ce se afla in corpul funcției, lasând antetul identic.