Laboratorul 3: Design Avansat de Clase

  • Data publicării: 20.10.2025
  • Data ultimei modificări: 25.10.2025
    • clarificări pentru gestionarea memoriei statice și VTables.
    • schimbat tipul de return în Lazy Evaluation.
    • clarificări pentru specificatorii de acces în contextul moștenirii.

Obiective

Scopul acestui laborator este introducerea studenților în concepte mai avansate privind proiectarea claselor în Java, precum și familiarizarea acestora cu mecanismele de agregare, moștenire și polimorfism, alături de modul în care acestea pot fi utilizate pentru refolosirea și extinderea cu ușurință a codului existent.

Aspectele urmărite sunt:

  • înțelegerea conceptelor de agregare și compunere.
  • utilizarea corectă a moștenirii pentru refolosirea codului.
  • înțelegerea diferenței între moștenire și agregare.
  • exersarea conversiilor upcasting și downcasting.
  • implementarea polimorfismului în Java.
  • diferența dintre overriding și overloading.
  • întrebuințarea cuvântului cheie super.

Aspectele bonus urmărite sunt:

  • Problema diamantului.
  • Diferența dintre getClass() și instanceof.
  • Organizarea memoriei pentru moștenire în Java.

  • În acest laborator există mai multe secțiuni marcate [Optional]. Aceste secțiuni cuprind informații bonus care vă pot fi prezentate în timpul laboratorului sau pe care le puteți aprofunda în afara acestuia, ele nefiind necesare pentru laboratoarele viitoare sau pentru teme.
  • De asemenea, veți întâlni câteva secțiuni marcate [Nice to know]. Vă recomandăm ca acestea să aibă prioritate în parcurgerea secțiunilor de tip [Optional], deoarece vă pot oferi informații bonus care să fie și foarte probabil utile pentru teme sau laboratoare viitoare.

🤝 Relații între obiecte

Agregare și Compunere

În programarea orientată pe obiecte, agregarea și compunerea sunt două tipuri de relații has-a (un obiect are alt obiect). Aceste relații apar atunci când o clasă conține o referință către alt obiect.

Cum se inițializează obiectele conținute?

Un obiect conținut poate fi inițializat în mai multe moduri:

  1. În momentul declarării câmpului (Eager initialization)
    class Car {
       private Engine engine = new Engine();
     
       ...
    }
  2. Într-un bloc de inițializare
    class Car {
       private Engine engine;
     
       // Bloc de inițializare - se execută înainte de constructor
       { engine = new Engine(); }
     
       public Car() {
          ...
       }
     
       ...
    }
  3. În constructor
    class Car {
       private Engine engine;
     
       Car(Engine engine) {
          this.engine = engine;
       }
       ...
    }
  4. Doar când este necesar (Lazy initialization)
    class Car {
       private Engine engine;
     
       public Engine initializeEngine() {
          if (engine == null) {
             engine = new Engine();
          }
     
          return engine;
       }
    }

Agregarea

Agregarea reprezintă o relație slabă între obiecte. Obiectul container primește dependența din exterior, dar nu îi controlează ciclul de viață. Obiectul conținut poate exista independent.

Agregarea se mai numește Weak Has-A relationship.

Exemplu real: Un departament are angajați, dar angajații pot exista și fără acel departament.

public class Department {
    private String name;
    private Employee[] employees;
 
    public Department(String name, Employee[] employees) {
        this.name = name;
        this.employees = employees; // dependența vine din exterior → agregare
    }
}

Compunerea

Compunerea reprezintă o relație puternică între obiecte. Obiectul container creează și gestionează obiectele interne. Dacă obiectul container este distrus, obiectele conținute dispar și ele.

Compunerea se mai numește Strong Has-A relationship.

Exemplu real: O casă are camere. Camerele nu au sens să existe fără casă.

public class House {
    private Room[] rooms;
 
    public House(int numberOfRooms) {
        rooms = new Room[numberOfRooms]; // obiectele sunt create în interior → compunere
        for (int i = 0; i < numberOfRooms; i++) {
            rooms[i] = new Room("Camera " + (i + 1));
        }
    }
}

Detectarea rapidă a compunerii sau agregării

Caracteristică Agregare Compunere
Tip relație Slabă Puternică
Ciclul de viață Independent Dependent
Creează obiectele? Nu Da
Exemplu Departament–Angajat Casă–Cameră

  • Dacă obiectul este partajat între mai multe clase înseamnă că folosim agregare.
  • Când vedeți new în interiorul clasei, de obicei este vorba de compunere.
  • Dacă o clasă primește o dependență prin constructor, dar creează o nouă instanță internă pe baza celei primite, atunci relația devine compunere, deoarece obiectul își deține propria copie și nu depinde de obiectul extern:
    class Engine {
        int power;
        Engine(int power) { this.power = power; }
    }
     
    class Car {
        private Engine engine;
     
        public Car(Engine engine) {
            // Se clonează motorul → compunere (nu agregare)
            this.engine = new Engine(engine.power);
        }
    }

Moștenire (Inheritance)

Moștenirea, numită și derivare, este un mecanism care permite refolosirea și extinderea codului unei clase existente.

  • Clasa existentă: clasa-părinte / super-clasă / base class
  • Clasa care extinde: clasa-copil / sub-clasă / derived class

Clasa copil poate prelua funcționalitatea clasei părinte și poate adăuga sau modifica comportamente, creând versiuni specializate ale claselor de bază.

Drept urmare, putem spune că prin moștenire clasa copil preia toții membrii (metodele și câmpurile) clasei părinte pentru a îi extinde sau folosi direct.

Dacă la agregare și compunere foloseam relația has-a, la moștenire vom folosi relația is-a.

Exemplu: Dacia is a Car → clasa Dacia moștenește toate proprietățile și metodele lui Car.

Avantajele principale ale moștenirii sunt:

  • Reutilizarea codului
    • Clasa copil moștenește metodele și variabilele clasei părinte, deci nu trebuie să rescriem codul existent.
  • Specializare și extensibilitate
    • Permite să creem clase “specializate” care extind comportamentul clasei de bază, adăugând sau modificând funcționalități.
  • Structurare clară a ierarhiilor
    • Relațiile „is-a” (de exemplu, Dog is a Animal) oferă un mod intuitiv de a organiza clasele.
  • Incremental development
    • Dezvoltarea se poate face pas cu pas: codul deja testat rămâne stabil, iar noile funcționalități sunt adăugate treptat.
  • Polimorfism
    • Clasa copil poate fi tratată ca un obiect al clasei părinte, permițând apeluri generice și reutilizarea codului în funcții care lucrează cu clasa de bază.

Sintaxa de bază

În Java, folosim cuvântul cheie extends:

class Cat extends Mammal { ... }

  • Java permite doar moștenire simplă, adică o clasă poate extinde o singură altă clasă.
  • Moștenirea multiplă nu este permisă în Java, deoarece ar presupune niște ambiguități descrise de Problema diamantului.

[Nice to know] Problema diamantului

[Nice to know] Problema diamantului

Problema diamantului apare în limbaje care permit moștenire multiplă de clase (ex: C++), dar nu și în Java, tocmai ca să evite această problemă.

Situația apare când o clasă moștenește două clase părinte care la rândul lor moștenesc aceeași clasă, formând un diamant.

D moștenește B și C, iar ambele moștenesc A. Dacă A are o metodă close(), iar B și C o moștenesc (sau o rescriu), atunci în D apare confuzia:

Care versiune de close() moștenește D? Pe cea din B sau pe cea din C?

Această problemă poate fi rezolvată, însă Java a ales să o evite cu totul.

Ierarhii de moștenire

Prin folosirea moștenirii putem crea lanțuri de moștenire. De exemplu să presupunem că avem clasa Cat care moștenește Mammal care moștenește la rândul ei clasa Animal:

Animal (clasă de bază)
  ↓
Mammal (clasă copil pentru Animal)
  ↓
Cat (clasă copil pentru Mammal)

Într-un proiect Java, aceste moșteniri multiple ar arăta astfel:

Animal.java
class Animal {
    float weight = 10.2f;
 
    void eat() { System.out.println("The animal is eating"); }
    void sleep() { System.out.println("The animal is sleeping"); }
}
Mammal.java
class Mammal extends Animal {
    int heartRate = 90;
 
    void breathe() { System.out.println("The mammal is breathing"); }
}
Cat.java
public class Cat extends Mammal {
    boolean longHair = true;
 
    void purr() { System.out.println("The cat is purring"); }
}
Main.java
class Main {
   public static void Main(String args[]) {
      // Se pot accesa toate metodele și câmpurile moștenite de către Cat
      Cat cat = new Cat();
      cat.purr();
      cat.breathe();
      cat.eat();
      cat.sleep();
      System.out.println("Cat weight: " + cat.weight);
 
      // Se pot accesa toate metodele și câmpurile moștenite de către Mammal
      Mammal mammal = new Mammal();
      mammal.breathe();
      mammal.eat();
      mammal.sleep();
      mammal.weight = 14.5f;
      System.out.println("Mammal weight: " + mammal.weight);
 
      // Se pot accesa doar metodele și câmpurile proprii ale clasei Animal pentru că se oprește lanțul de moștenire
      Animal animal = new Animal();
      animal.eat();
      animal.sleep();
      animal.weight = 45.3f;
      System.out.println("Animal weight: " + animal.weight);
   }
}

  • Clasa Mammal moștenește weight și eat() din Animal.
  • Clasa Cat moștenește tot din Mammal și Animal la care se adaugă metoda proprie purr().

Blocarea accesului la membrii folosind specificatori de acces

Reamintim specificatorii de acces prezentați în laboratorul trecut în contextul moștenirii membrilor:

DefaultPrivateProtectedPublic
Aceeași clasăDaDaDaDa
Același pachet, subclasăDaNuDaDa
Același pachet, non-subclasăDaNuDaDa
Pachet diferit, subclasăNuNuDaDa
Pachet diferit, non-subclasăNuNuNuDa

Moștenirea constructorilor

Constructorii nu se moștenesc, însă inițializarea obiectelor urmează un lanț logic: mai întâi se construiesc membrii clasei părinte, apoi cei ai subclasei, până la clasa curentă.

Pentru exemplul de mai sus inițializarea s-ar întâmpla astfel:

Animal
  ↓
Mammal
  ↓
Cat

Ordinea inițializării constructorilor ne asigură că nu vom avea situații în care un obiect copil ar accesa un membru al părintelui care nu a fost inițializat.

class Mammal extends Animal {
   ...
 
   public Mammal() {
      // Nu avem erori pentru că "Animal" și membrii lui au fost inițializați deja
      System.out.println("Animal inherited field value is: " + weight);
   }
 
   ...
}

Vom descrie acest mecanism în cadrul secțiunii următoare despre super.

Cuvântul cheie super

Keyword-ul super este folosit în constructorul unei subclase pentru a inițializa partea moștenită a obiectului și pentru a accesa membri (metode sau variabile) ai clasei părinte.

class Parent {
    int value;
    Parent(int x) {
        value = x;
        System.out.println("Parent constructor");
    }
}
 
class Child extends Parent {
    Child(int x) {
        super(x); // apelul constructorului părinte trebuie să fie prima linie
        System.out.println("Child constructor, value = " + value);
    }
}

Pentru a asigura ordinea inițializării pe lanțul de moștenire, instrucțiunea super() sau super(…) trebuie să fie prima instrucțiune din fiecare constructor definit în clasa copil.

  • Nu se poate apela din clasa copil constructorul clasei părinte. Puteți să ajungeți la un rezultat similar prin apeluri în lanț (copilul apelează super(…) către părinte → părintele apelează super(…) către bunic).
  • Instrucțiunea super() este injectată automat de către compilator la începutul corpului din constructori.
    • Dacă scrieți explicit super(…) nu se va mai injecta super()
    • Dacă nu aveți un constructor fără parametrii în părinte, atunci trebuie să apelați explicit constructorul părinte prin super(…):
      class Parent {
          Parent(int x) {
              System.out.println("Parent constructor: " + x);
          }
      }
       
      class Child extends Parent {
          Child() {
              // Apel explicit, deoarece "super()" ar apela "Parent()" care nu există
              super(10); 
              System.out.println("Child constructor");
          }
      }

Keyword-ul super poate fi folosit și pentru accesul membrilor din părinte. Acest lucru este util când vrem să accesăm membrii ascunși sau suprascriși.

class Parent {
    int value = 100;
 
    void show() {
        System.out.println("Parent value: " + value);
    }
}
 
class Child extends Parent {
    int value = 200; // shadowing
 
    void show() {
        System.out.println("Child value: " + value);
        System.out.println("Parent value via super: " + super.value); // accesăm membrul ascuns
    }
}

  • super este similar cu this, dar this face referire la instanța curentă, iar super la instanța părinte.
  • super nu poate fi folosit în metode statice pentru că memoria statică este inițializată înaintea instanțelor.
  • super() sau super(…) garantează că subclasa nu folosește membri ai părintelui înainte ca aceștia să fie inițializați.

Clase final

În Java, o clasă poate fi declarată folosind cuvântul cheie final, ceea ce înseamnă că nu mai poate fi moștenită. Se folosește atunci când vrem să blocăm moștenirea pentru a proteja implementarea sau pentru motive de securitate/perfomanță (optimizări făcute de JVM).

public final class MathUtils {
    public static int square(int x) {
        return x * x;
    }
}

Încercarea de a moșteni o clasă final produce eroare de compilare:

class MyMath extends MathUtils { }  // Eroare: "cannot inherit from final class"

Când folosim final la clase?

  • Când nu vrem ca altcineva să modifice comportamentul clasei.
  • Pentru a păstra clasa imutabilă (vom vorbi despre imutabilitate în următoarele laboratoare).
  • În clase utilitare (ex: java.lang.Math este final).

Agregare/Compunere vs. Moștenire

Caracteristică Agregare/Compunere Moștenire
Relație has-a is-a
Expunerea metodei doar obiectul conținut toate metodele părintelui
Control asupra obiectului containerul folosește obiectul extern copilul extinde comportamentele părintelui
Exemplu Car has an Engine Dog is an Animal

În general, întrebarea has-a sau is-a este suficientă pentru a determina tipul de relație, însă există obiecte pentru care relația nu poate fi stabilită ușor folosind această întrebare.

De exemplu, obiectele Button și Toolbar:

  • Toolbar poate conține mai multe Button-uri → has-a (agregare)
  • Dar putem spune și că Toolbar e un buton specializat → is-a (moștenire)

Pentru aceste situații este bine să folosim următoarea distincție:

  • Agregare/Compunere: când vrem să folosim funcționalitatea unei clase în interiorul altei clase fără a-i expune toate metodele.
  • Moștenire: când vrem să creăm o clasă specializată a unei clase existente, reutilizând și extinzând comportamentul acesteia.

Experții în Java recomandă să folosim compunerea în locul moștenirii ori de câte ori e posibil.

Este mai bine să includem obiecte existente în clase și să le delegăm funcționalități, în loc să creem subclase pentru a le modifica comportamentul. Moștenirea ar trebui folosită doar când clasa noastră chiar reprezintă un tip al obiectului părinte, deoarece poate încălca încapsularea și limitează reutilizarea codului.

🎭 Upcasting, Downcasting și Polimorfismul în Java

Upcasting și Downcasting

În Java, upcasting și downcasting sunt operații care permit conversia între tipuri în ierarhia de moștenire a claselor.

Upcasting

Upcasting-ul este conversia unui obiect de tip derivat într-un obiect de tipul clasei de bază și are următoarele proprietăți:

  • se face automat și nu necesită declarație explicită.
  • instanța rămâne aceeași, doar referința este văzută ca tipul superior.
  • permite apelarea doar a metodelor definite în clasa de bază.

Pentru ierarhia:

Animal.java
public class Animal {
   public void makeSound() {
      System.out.println("Animal makes a sound!");
   }
 
   public void eat() {
      System.out.println("Animal eats!");
   }
}
Dog.java
public class Dog extends Animal {
   public void wagTail() {
      System.out.println("Dog moves tail!");
   }
}

Avem exemplul de upcasting:

Main.java
public class Main {
   public static void main(String[] args) {
      Dog myDog = new Dog();
      Animal animal = myDog; // Upcasting automat
 
      Animal anotherAnimal = new Dog(); // Upcasting automat
 
      animal.makeSound(); // Funcționează
      animal.eat();       // Funcționează
 
      // EROARE de compilare - metoda "wagTail()" nu există în referința de tip "Animal"
      // animal.wagTail(); 
   }
}

Explicația erorii de compilare:

  1. În instrucțiunea Dog myDog = new Dog(); se alocă memorie pentru un obiect de tip Dog (conform instanței).
  2. În instrucțiunea Animal animal = myDog; se face upcast la referința de tip Animal.
  3. În instrucțiunea animal.wagTail(); compilatorul nu știe încă ce memorie va fi alocată, deci nu se va uita la instanță, ci la referință.
    • Deoarece referința este de tip Animal, metoda wagTail() nu este găsită și vom avea o eroare.
    • Chiar dacă în memorie avem spațiu alocat pentru Dog compilatorul nu poate vedea asta.

Utilitatea upcasting-ului

Upcasting-ul permite tratarea diferitelor subclase într-un mod uniform.

De exemplu dacă avem clasa Shelter unde vrem să apelăm metoda eat() a fiecărui animal definit ar trebui să avem următoarea structură:

Shelter.java
public class Shelter {
   public void feedAnimal(Dog dog) {
      dog.eat();
   }
 
   public void feedAnimal(Cat cat) {
      cat.eat();
   }
 
   public void feedAnimal(Rabbit rabbit) {
      rabbit.eat();
   }
}
Main.java
public class Main {
   public static void main(String[] args) {
      Shelter shelter = new Shelter();
      Dog dog = new Dog();
      Cat cat = new Cat();
      Rabbit rabbit = new Rabbit();
 
      // Overloading
      shelter.feedAnimal(dog);
      shelter.feedAnimal(cat);
      shelter.feedAnimal(rabbit);
   }
}

Prin folosirea mecanismului de overloading putem apela metoda feedAnimal() pentru fiecare animal, doar că această abordare nu este scalabilă.

Putem folosi în schimb mecanismul de upcasting pentru a scurta semnificativ codul:

Shelter.java
public class Shelter {
   public void feedAnimal(Animal animal) {
      animal.eat();
   }
}
Main.java
public class Main {
   public static void main(String[] args) {
      Shelter shelter = new Shelter();
      Dog dog = new Dog();
      Cat cat = new Cat();
      Rabbit rabbit = new Rabbit();
 
      // Upcast la Animal
      shelter.feedAnimal(dog);
      shelter.feedAnimal(cat);
      shelter.feedAnimal(rabbit);
   }
}

Prin upcasting, convertim fiecare tip copil în tipul părinte Animal la pasarea în metodă, deoarece orice Dog/Cat/Rabbit este și un Animal.

Downcasting

Downcasting-ul este mecanismul prin care se face conversia explicită de la clasa de bază la o clasă derivată și are următoarele proprietăți:

  • trebuie explicit declarat de programator.
  • este sigur doar dacă obiectul este într-adevăr instanță a clasei derivate.
  • folosim adesea operatorul instanceof pentru a verifica tipul înainte.

Pentru ierarhia de clase de mai sus avem exemplul:

Animal animal = new Dog(); // Upcasting
 
if (animal instanceof Dog) {
    ((Dog) animal).wagTail(); // Downcasting explicit
}

  • Mecanismul de downcast se bazează strict pe memoria alocată la run-time, din acest motiv este nevoie să facem cast la variabila animal pentru a forța compilatorul să vadă animal ca fiind o referință de tip Dog.
  • Dacă faceți downcast greșit veți avea eroarea la run-time ClassCastException.

Utilitatea downcasting-ului

Downcasting-ul permite accesarea funcționalităților specifice subclasei atunci când avem o referință de tipul clasei părinte (upcast). Este necesar atunci când vrem să folosim metode sau câmpuri care nu sunt definite în clasa de bază, dar există în subclasa reală.

Fără downcast, nu am fi putu accesa metoda wagTail() din exemplul de mai sus.

Evitarea instanceof cu polimorfism

Folosirea instanceof este considerată bad practice, deoarece:

  • face codul repetitiv și greu de întreținut.
  • necesită modificări ori de câte ori se adaugă o subclasă nouă.
  • reduce lizibilitatea și încalcă principiul Open-Closed.

La teme este important să nu folosiți instanceof sau getClass(), deoarece acest stil de programare este anti-OOP și veți fi depunctați semnificativ.

Open-Closed Principle: O clasă sau modul trebuie să fie deschis pentru extindere, dar închis pentru modificare. Adică putem adăuga funcționalități noi fără a schimba codul existent.

Pentru a rezolva această problemă putem folosi mecanismul de suprascriere prezentat în continuare.

Polimorfismul

Polimorfismul reprezintă abilitatea unui obiect să se comporte ca un alt obiect din ierarhia de moștenire. Este strâns legat de suprascrierea metodelor și permite tratamentul uniform al obiectelor de tipuri diferite printr-o interfață comună sau o clasă de bază.

Există două tipuri principale:

  • Polimorfism dinamic (runtime): prin suprascrierea metodelor (override).
  • Polimorfism static (compile-time): prin supraîncărcarea metodelor (overload).

Suprascrierea metodelor (Overriding)

Suprascrierea se referă la redefinirea unei metode moștenite pentru a schimba comportamentul acesteia în subclasa curentă.

Reguli de bază pentru suprascriere:

  • Metoda suprascrisă trebuie să aibă aceeași semnătură și un tip de return compatibil.
    • Tip de return compatibil:
      • Pentru primitive: trebuie să folosiți același tip.
      • Pentru obiecte: trebuie să folosiți același tip sau un subtip al tipului de return din clasa părinte.
  • Nu se pot suprascrie metode statice, final sau constructorii.
  • Specificatorul de acces nu poate fi mai restrictiv decât cel al metodei din clasa părinte.
class Animal {
    public void makeSound() { System.out.println("Animal makes sound"); }
    public void eat() { System.out.println("Animal eating"); }
    public final void die() { System.out.println("Dying!"); }
}
 
class Dog extends Animal {
    // Suprascriem metoda din părinte
    @Override
    public void makeSound() { System.out.println("Bark!"); }
 
    public void wagTail() { System.out.println("Dog wags tail"); } // Nu suprascrie nici o metodă
}

  • @Override este opțional, dar recomandat pentru claritate și verificarea erorilor de compilare.
  • Suprascrierea se face la run-time pe baza tipului instanței, nu al referinței. Din acest motiv suprascrierea este cunoscută și ca polimorfism dinamic.

Este important să înțelegem că suprascrierea nu ne garantează că metoda aleasă va fi întotdeauna cea din copil. Deoarece JVM se uită la tipul instanței și nu la referință, putem avea mai multe comportamente definite:

public class Main {
   public static void main(String[] args) {
      Animal a = new Animal(); // tipul instanței lui "a" este "Animal"
      a.makeSound(); // se va apela "makeSound()" din "Animal"
 
      Dog d = new Dog(); // tipul instanței lui "d" este "Dog"
      d.makeSound(); // se va apela "makeSound()" din "Dog"
 
      Animal anotherAnimal = new Dog(); // tipul instanței lui "anotherAnimal" este "Dog"
      anotherAnimal.makeSound(); // se va apela "makeSound()" din "Dog"
 
      a = new Dog(); // am schimbat instanța în "Dog"
      a.makeSound(); // acum se va apela "makeSound()" din "Dog"
   }
}
Metode final

În Java, o metodă declarată folosind cuvântul cheie final nu poate fi suprascrisă în nicio subclasă. Aceasta este o modalitate de a proteja comportamentul unei metode astfel încât să nu fie modificat prin moștenire.

class Animal {
    public final void die() {
        System.out.println("Animal died.");
    }
}
 
class Dog extends Animal {
    // Eroare de compilare: "cannot override final method"
    @Override
    public void die() {
        System.out.println("Dog died.");
    }
}

De ce folosim metode final?

  • Previne modificarea accidentală a comportamentului unei metode importante.
  • Asigură consistență logică în ierarhiile de moștenire.
  • Sunt folosite frecvent în clasele din Java SDK pentru securitate și performanță (optimizări făcute de JVM).

Metodele private sunt implicit final din perspectiva moștenirii. Acestea nu pot fi suprascrise deoarece nu sunt vizibile în subclase.

Supraîncărcarea metodelor (Overloading)

Reamintim că supraîncărcarea este mecanismul în care putem defini mai multe metode cu același nume în aceeași clasă, dar cu parametri diferiți (număr sau tip).

  • Supraîncărcarea face parte din polimorfism pentru că o singură metodă poate lua mai multe forme pe baza parametrilor, deci se respectă definiția polimorfismului.
  • Supraîncărcarea se face la compile-time pe baza semnăturii. Din acest motiv supraîncărcarea este cunoscută și ca polimorfism static.

Pentru mai multe detalii despre supraîncărcare reluați laboratorul 2 sau consultați imaginea de mai jos.

TL;DR Overriding & Overloading

Suprascrierea metodelor speciale

În afară de primitive, totul în Java este un obiect, iar acest lucru este ilustrat prin faptul că orice clasă moștenește clasa Object.

Deci ierarhia de moștenire pentru animale din exemplul anterior nu era chiar completă, dar o putem corecta astfel:

Object (clasa moștenită de toate obiectele)
  ↓
Animal (clasă de bază definită de programator)
  ↓
Mammal (clasă copil pentru Animal)
  ↓
Cat (clasă copil pentru Mammal)

Nu este nevoie să scriem extends Object atunci când scriem o clasă, deoarece acest lucru se va întâmpla automat dacă clasa noastră nu moștenește deja o altă clasă, astfel se respectă principiul moștenirii unei singure clase.

Motivul pentru care există clasa Object este să ofere un set minim și comun de comportamente tuturor obiectelor, indiferent ce reprezintă ele. Astfel, orice instanță în Java are automat metode de bază precum:

  • toString() – descrierea obiectului ca text
  • equals(Object o) – comparație logică
  • hashCode() – folosit în colecții (HashMap, HashSet etc.)
  • getClass() – află tipul obiectului la runtime
  • clone() – copiere superficială
  • finalize() – cleanup (deprecated)

  • Prin moștenirea lui Object căpătăm și un polimorfism universal care va fi util atunci când învățăm colecții în laboratoarele următoare.
  • În acest laborator vom vorbi doar despre toString() și equals(Object o).

Metoda toString()

Cu ajutorul metodei toString(), care este deja implementată în mod predefinit pentru fiecare clasă în Java, putem obține o reprezentare a unui obiect ca String.

În cazurile claselor implementate de utilizator, este de recomandat să suprascriem metoda toString() pentru a afișa detaliile de interes ale clasei.

Un exemplu de implementare a metodei toString():

Student.java
public class Student {
    private String name;
    private int averageGrade;
 
    public Student(String name, int averageGrade) {
        this.name = name;
        this.averageGrade = averageGrade;
    }
 
 
    public String toString() {
        return "Nume student: " + name + "\nMedia studentului: " + averageGrade;
    }
}

Folosirea metodei toString():

Student st1 = new Student("Ilie Popescu", 5);
 
// Nu trebuie să specificăm "st1.toString()", compilatorul va adăuga automat apelul de metodă
System.out.println(st1);

Output

Output

Nume student: Ilie Popescu
Media studentului: 5

  • Puteți scrie și st1.toString(), doar că nu este recomandat din motive de lizibilitate.
  • Dacă nu definim un comportament specific pentru metoda toString(), se va printa adresa obiectului în memorie (pentru clasa Student am avea o adresă de tipul Student@412).

Puteți folosi IntelliJ pentru generarea metodei toString(): click dreapta oriunde în cod → Generate…toString() → alegeți câmpurile care doriți să fie printate.

Metoda equals(Object o)

Metoda equals(Object o) este folosită pentru a compara corect două obiecte. Dacă am folosi operatorul == pentru verificarea egalității am ajunge să verificăm de fapt egalitatea referințelor (adică a adreselor de memorie). Prin metoda equals putem verifica dacă două obiecte diferite au aceeași stare internă conform unor criterii specifice.

Una din problemele cele mai des întâlnite este suprascrierea corectă a metodei equals. La fel ca metoda toString(), această metodă are un comportament default în care se verifică doar dacă referințele sunt egale.

class Person {
    String name;
}
 
class Main {
   public static void main(String[] args) {
      Person p1 = new Person();
      Person p2 = new Person();
 
      System.out.println(p1.equals(p2)); // false, pentru că p1 și p2 sunt obiecte diferite
   }
}

Mai jos putem vedea un exemplu de suprascriere incorectă a acestei metode.

Car.java
public class Car {
    private String name;
    private int horsePower;
    private int year;
 
    public Car(String name, int horsePower, int year) {
        this.name = name;
        this.horsePower = horsePower;
        this.year = year;
    }
 
    public boolean equals(Car c) {
        System.out.println("Car");
        return true;
    }
 
    public boolean equals(Object o) {
        System.out.println("Object");
        return false;
    }
}
Dacia.java
public class Dacia extends Car {
   ...
}

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 {
    private String name;
    private int horsePower;
    private int year;
 
    public Car(String name, int horsePower, int year) {
        this.name = name;
        this.horsePower = horsePower;
        this.year = year;
    }
 
    @Override
    public boolean equals(Object o) {
        // Verificăm dacă obiectul o este de tipul "Car"
        if (!(o instanceof Car car)) return false;
 
        // Verificăm câmpurile claselor să fie identice
        return horsePower == car.horsePower && year == car.year && Objects.equals(name, car.name);
    }
}

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. Se poate folosi și metoda getClass() însă nici aceasta nu este recomandată din aceleași motive.

  • Întotdeauna când doriți să comparați două String-uri folosiți metoda equals, deoarece și String este un obiect. Pentru primitive este în regulă să folosiți operatorul ==.
  • Puteți folosi IntelliJ pentru generarea metodei equals(Object o): click dreapta oriunde în cod → Generate…equals() and hashSet() → alegeți getClass sau instanceof (nu este relevant) → alegeți câmpurile folosite pentru egalitate.
    • Puteți ignora momentan metoda hashSet care este generată.

[Optional] Diferența dintre getClass() și instanceof

[Optional] Diferența dintre getClass() și instanceof

instanceof verifică dacă un obiect este instanță a unei clase sau a unei superclase din ierarhia acesteia, fiind util pentru verificarea tipului în contextul polimorfismului.

În schimb, `getClass()` returnează exact clasa din care a fost creat obiectul și compararea cu `getClass()` este strictă, fără a permite moștenirea. Două obiecte sunt considerate de același tip doar dacă au exact aceeași clasă.

class Animal {}
 
class Dog extends Animal {}
 
class Main {
   public static void main(String[] args) {
      Animal a = new Animal();
      Dog d = new Dog();
 
      System.out.println(a.getClass() == d.getClass()); // false
 
      System.out.println(a instanceof Animal); // true
      System.out.println(d instanceof Animal); // true
      System.out.println(d instanceof Dog);    // true
      System.out.println(a instanceof Dog);    // false
   }
}

Astfel, instanceof este flexibil și orientat pe ierarhie, în timp ce getClass() este strict și verifică tipul concret. Din aceste motive pot apărea diferențe de performanță între cele două.

Keyword-ul super în contextul suprascrierii

Keyword-ul super poate fi folosit de Java în clasele copil pentru a accesa metoda originală din clasa părinte atunci când metoda este suprascrisă.

class Parent {
    void sayHello() {
        System.out.println("Hello from Parent");
    }
}
 
class Child extends Parent {
    @Override
    void sayHello() {
        super.sayHello(); // apelăm metoda din părinte
        System.out.println("Hello from Child");
    }
}

Output

Output

Hello from Parent
Hello from Child


🗄️ [Optional] Gestionarea moștenirii în Java la nivel de memorie

Cum este structurată memoria în Java

Când rulează o aplicație Java, memoria este împărțită în mai multe zone principale:

Zonă de memorie Ce conține Importanță
Heap Obiectele create la runtime unde trăiesc obiectele
Stack Variabile locale + referințe gestionat per thread
Metaspace Informații despre clase + metode + tabele pentru dynamic dispatch (vtable) + variabile și metode statice suportă OOP și polimorfismul

Cum contribuie Metaspace la OOP

Când o clasă este încărcată de JVM, metainformațiile ei sunt puse în Metaspace:

  • Numele clasei
  • Lista metodelor
  • Lista câmpurilor
  • Informații despre moștenire (superclass)
  • VTable (Virtual Method Table)
  • Variabile și metode statice

Metaspace-ul ne ajută să economisim memorie, deoarece în Heap se vor aloca doar valori ale instanței care pot fi schimbate la run-time (ex. câmpuri).

Ce este VTable și cum ajută la overriding?

VTable este o tabelă internă introdusă de JVM pentru a face posibil polimorfismul dinamic.

class Animal { 
   void makeSound() { 
      System.out.println("generic"); 
   } 
 
   static void info() { 
      System.out.println("Animal class"); 
   } 
}
 
class Dog extends Animal {
   @Override
   void makeSound() {
      System.out.println("woof");
   }
 
   static void info() { 
      System.out.println("Dog class"); 
   }
}

Când se încarcă clasele, JVM construiește două VTables în Metaspace, doar pentru metodele non-statice:

Animal vtable: 
- makeSound() -> Animal.makeSound 
 
Dog vtable:
- makeSound() -> Dog.makeSound (override)

VTable există doar pentru metode virtuale (non-static). Metodele statice nu sunt incluse în VTable pentru a nu folosi mecanismul de suprascriere.

Legătura cu Upcasting & Downcasting

Concept Ce face Legătura cu memoria
Upcasting (safe) tratezi Dog ca Animal DOAR schimbă tipul referinței, obiectul rămâne în Heap, legătura cu VTable se păstrează
Downcasting (risky) tratezi Animal ca Dog JVM verifică în Metaspace dacă tipul real are VTable pentru Dog

Polimorfism datorită Metaspace + VTables

Animal a = new Dog(); // upcast a.makeSound(); // JVM folosește vtable din Metaspace → Dog.makeSound
 
Animal.info(); // apelează Animal.info() din Metaspace
Dog.info(); // apelează Dog.info() din Metaspace

De ce metodele statice nu pot fi suprascrise

Caracteristică Metode de instanță Metode static
Rezolvare runtime (dinamic) compile-time (static binding)
Instrucțiune JVM invokevirtual invokestatic
Depind de instanță Da Nu
Apar în VTable Da Nu
Pot fi suprascrise Da Nu, doar ascunse (method hiding)

Metodele static aparțin clasei, nu obiectelor, deci JVM nu are motiv să le caute într-un VTable la runtime. Ele sunt apelate direct prin numele clasei, nu prin instanță.

Instrucțiunile invokevirtual și invokestatic sunt instrucțiuni din bytecode-ul de Java folosite pentru a rezolva și apela metodele corecte.

Rolul constant Pool

Apelurile către metode static sunt rezolvate folosind Constant Pool, o zonă de memorie care se află tot în Metaspace și care este asociată fiecărei clase care conține referințe simbolice către metode, câmpuri și clase.

  • Intrările din Constant Pool sunt folosite pentru a localiza metodele static în Metaspace, unde este stocată logica clasei.
  • La apel, invokestatic citește adresa metodei direct din Constant Pool, fără polimorfism, fără VTable.

  • Constant Pool-ul optimizează memoria adresând duplicarea constantelor și a String-urilor (mai multe despre String în următoarele laboratoare). Totodată, această optimizare ajută și la performanță.
  • Ideal, am stoca cât mai multe informații în Constant Pool, însă putem stoca doar constante (variabile constante, nume de clasă, nume de metodă, semnături, referințe simbolice etc.).
  • Memoria statică efectivă este stocată în Metaspace, dar referința simbolică folosită de invokestatic este stocată în Constant Pool.

Rezumat al memoriei în Java

  • Heap = locul în care trăiesc obiectele.
  • Stack = ține referințele către ele.
  • Metaspace = știe ce este fiecare obiect, ce metode are, inclusiv variabile și metode statice, prin vtables.
  • Constant Pool = stocat tot în Metaspace; conține literaluri, referințe simbolice către clase, metode și câmpuri, folosit de JVM pentru rezoluție dinamică și apeluri de metode.
  • Upcasting-ul funcționează fără probleme pentru că JVM știe din Metaspace ce este obiectul real.
  • Downcasting-ul se verifică la runtime tot cu ajutorul informațiilor din Metaspace.

Summary

Relații între obiecte:

  • Agregare - has a
  • Moștenire - is a

Upcasting:

  • convertire copilpărinte
  • realizată automat

Downcasting:

  • convertire părintecopil
  • trebuie făcută explicit de către programator
  • încercați să evitați folosirea operatorului instanceof

Suprascrierea:

  • înlocuirea funcționalității metodei din clasa de bază în clasa derivată
  • păstreaza numele și semnătura metodei

Supraîncărcarea:

  • în interiorul clasei pot exista mai multe metode cu același nume, cu condiția ca semnătura (tipul, argumentele) să fie diferită

Cuvântul cheie super:

  • instanța clasei părinte
  • amintiți-vă din laboratorul anterior că this se referă la instanța clasei curente

Exerciții

  • Exercițiile vor fi făcute pe platforma Devmind Code. Găsiți exercițiile din acest laborator în contestul aferent.
  • Vă recomandăm să copiați scheletul și să faceți exercițiile mai întâi în IntelliJ, deoarece acolo aveți acces la o serie de instrumente specifice unui IDE. După ce ați terminat exercițiile puteți să le copiați pe Devmind Code.

Task 1 (1p)

Veți proiecta o clasă Form care va avea câmpul privat color (String).

Clasa va avea, de asemenea:

  1. un constructor fără parametri, care va inițializa culoarea cu “white”;
  2. un constructor cu parametri;
  3. o metodă de tip float getArea(), care va întoarce valoarea 0;
  4. o metodă toString(): “This form has the color [color]”.

Task 2 (2p)

Din clasa Form derivați clasele Square, Triangle, Circle:

  1. clasa Triangle (triunghi isoscel) va avea 2 membri height și base (adiacenta unghiurilor congruente) de tip float;
  2. clasa Circle va avea membrul radius de tip float;
  3. clasa Square va avea membrul side de tip float.

Clasele vor avea:

  1. constructori fără parametri;
  2. constructori care permit inițializarea membrilor. Identificați o modalitate de reutilizare a codului existent;
  3. suprascrieți metoda getArea() pentru a întoarce aria specifică fiecărei figuri geometrice;
  4. 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(Object o) î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:

  1. clasei Triangle metoda printTriangleDimensions,
  2. clasei Circle metoda printCircleDimensions
  3. 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:

  1. printTriangleDimensions pentru Triangle
  2. printCircleDimensions pentru Circle
  3. 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.

poo-ca-cd/laboratoare/design-avansat-de-clase.txt · Last modified: 2025/10/25 19:50 by florian_luis.micu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0