This is an old revision of the document!
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:
Aspectele bonus urmărite sunt:
Abstractizarea este unul dintre cele 4 principii POO de bază (Abstractizare, Încapsulare, Moștenire, Polimorfism).
Acest principiu este folosit pentru a ascunde detaliile interne ale obiectelor și evidențiază doar comportamentele esențiale. Ea oferă un șablon comun pentru o categorie de obiecte, fără a specifica implementarea fiecăruia. În Java, abstractizarea este realizată prin clase abstracte și interfețe.
Programarea orientată pe obiecte permite modelarea unor concepte generale care pot avea comportamente variate în funcție de implementare. Clasele și metodele abstracte oferă un mecanism esențial pentru proiectarea ierarhiilor de clase flexibile și extensibile.
O metodă abstractă este o metodă fără implementare, declarată doar pentru a defini ce ar trebui să facă o metodă, fără a spune cum o face. Rolul ei este să oblige clasele derivate să furnizeze propria implementare.
Marcarea unei metode abstracte se face folosind keyword-ul abstract.
public abstract void startEngine();
Caracteristici:
;).O clasă abstractă este o clasă care poate conține:
Pentru a marca o clasă ca fiind abstractă folosim keyword-ul abstract în declararea clasei.
public abstract class Vehicle { private String name; public Vehicle(String name) { this.name = name; } public abstract void startEngine(); public void setName(String name) { this.name = name; } public String getName() { return name; } }
public sau default.
O clasă abstractă, nu poate fi instanțiată direct, trebuie moștenită de o altă clasă folosind keyword-ul extends.
Acest mecanism funcționează și pe lanțuri de moștenire între clase abstracte: o clasă abstractă poate moșteni o altă clasă abstractă și poate adăuga metode abstracte noi. Toate metodele abstracte din lanț trebuie să fie implementate în cele din urmă într-o clasă concretă pentru a putea crea obiecte.
public abstract class Vehicle { private String name; public Vehicle(String name) { this.name = name; } public abstract void startEngine(); public void setName(String name) { this.name = name; } public String getName() { return name; } }
public abstract class Car extends Vehicle { public abstract void openTrunk(); public Car(String name) { super(name); } @Override public void startEngine() { System.out.println("Car engine started!"); } }
public class Sedan extends Car { public Sedan(String name) { super(name); } @Override public void openTrunk() { System.out.println("Trunk opened in the back of the car!"); } }
După implementare, pot fi create obiecte de tipul clasei Sedan:
Vehicle vehicle = new Sedan(); vehicle.startEngine(); // Output: Car engine started! vehicle.openTrunk(); // Output: Trunk opened in the back of the car!
Vehicle și clasa Car nu pot fi instanțiate, deoarece nu implementează toate metodele abstracte moștenite și proprii.Animal de mai sus, am putea să o declarăm ca fiind abstractă chiar dacă aceasta nu ar avea metode abstracte:public abstract class Animal { // valid private double weight; public void setWeight(double weight) { this.weight = weight; } public double getWeight() { return weight; } }
| Avantaj | Descriere |
|---|---|
| Șablon de cod | Definește structura și comportamentele esențiale pentru un grup de clase |
| Reutilizare de cod | Permite împărtășirea metodelor și a atributelor comune între subclase |
| Polimorfism | Permite folosirea unei referințe la clasa de bază pentru obiecte diferite |
| Control asupra implementării | Forțează subclasele să implementeze metodele abstracte |
Folosește o clasă abstractă atunci când:
Instrument oferă doar metodele abstracte play() și stop(), iar clasele care o moștenesc vor avea aceste metode de bază în comun (Saxophone, Guitar etc.).Vehicle oferă metoda comună start(), dar fiecare subclasă (Car, ElectricScooter) definește propriul tip de combustibil prin metoda fuelType().Document are metoda abstractă print(), pentru că doar documentele concrete (PDFDocument, WordDocument) pot defini cum se tipăresc.Shape nu poate fi instanțiată direct; doar formele concrete (Circle, Square) pot fi folosite pentru a calcula aria.Interfețele oferă un mecanism prin care putem defini comportamente fără a impune moștenire de implementare. Ele stabilesc un contract pe care orice clasă îl poate îndeplini, indiferent din ce parte a ierarhiei de clase provine.
O interfață este un tip de referință în Java ce conține doar semnături de metode (fără implementare). Ea definește un set de capabilități pe care o clasă trebuie să le ofere, astfel poate fi considerată o clasă abstractă pură, deoarece conține doar metode abstracte.
public sau default.
Interfețele se definesc cu keyword-ul interface în locul keyword-ului class. O interfață poate conține:
Metodele dintr-o interfață:
public și abstractpublic interface Driveable { boolean startEngine(); void stopEngine(); float accelerate(float acc); boolean turn(Direction dir); }
O clasă implementează o interfață folosind keyword-ul implements, furnizând implementarea tuturor metodelor:
public class Automobile implements Driveable { public boolean startEngine() { engineRunning = true; return true; } public void stopEngine() { engineRunning = false; } public float accelerate(float acc) { // logică accelerare return acc; } public boolean turn(Direction dir) { // logică pentru viraj return true; } }
Ca și în cazul moștenirii clasice, putem avea o altă clasă care implementează aceeași interfață fără a avea legătură prin moștenire:
public class Lawnmower implements Driveable { // implementări proprii pentru metodele din interfață }
public, deoarece toate metodele dintr-o interfață sunt publice implicit.
În interfețe toate câmpurile sunt implicit public static final. Nu pot exista blank finals (câmpuri finale neinițializate), dar pot exista constante non-primitive dacă sunt inițializate la declarație.
public interface MathConstants { double PI = 3.14159; // valid }
După ce definim o interfață, ea devine un tip de referință în Java, la fel ca o clasă. Asta înseamnă că putem:
Astfel, orice obiect care implementează interfața poate fi atribuit unei variabile de acel tip, indiferent de clasa sa concretă.
Driveable vehicle; vehicle = new Automobile(); vehicle.startEngine(); vehicle.stopEngine(); vehicle = new Lawnmower(); vehicle.startEngine(); vehicle.stopEngine();
Automobile, Lawnmower etc.) ca fiind de același tip (Driveable), fără să ne bazăm pe moștenirea clasică.
Interfețele pot extinde alte interfețe folosind keyword-ul extends:
public interface Monster { void actMenacing(); } public interface DangerousMonster extends Monster { void destroyEverything(); }
Clasa care va moșteni interfața DangerousMonster va trebui să implementeze ambele metode actMenacing() și destroyEverything():
public class LochNessMonster implements DangerousMonster { public void actMenacing() { System.out.println("*Jump out of water*"); } public void destroyEverything() { System.out.println("*Attack every pier and boat*"); } }
În Java, o interfață poate moșteni mai multe interfețe folosind keyword-ul extends. Aceasta permite combinarea comportamentelor din mai multe surse fără a fi nevoie de moștenire multiplă de clase (care nu este permisă în Java).
interface Flyer { void fly(); } interface Swimmer { void swim(); } // Moștenire multiplă de interfețe interface Duck extends Flyer, Swimmer { void quack(); } class Mallard implements Duck { public void fly() { System.out.println("Flying!"); } public void swim() { System.out.println("Swimming!"); } public void quack() { System.out.println("Quack!"); } }
Totodată, o clasă poate moșteni mai multe interfețe folosind keyword-ul implements:
// Interfețe separate interface Flyer { void fly(); } interface Swimmer { void swim(); } // Clasă care implementează ambele interfețe class Duck implements Flyer, Swimmer { @Override public void fly() { System.out.println("Flying high!"); } @Override public void swim() { System.out.println("Swimming in the pond!"); } }
Dacă două interfețe au metode cu aceeași semnătură și același tip de return, nu există probleme, deoarece clasa care le implementează poate furniza o singură implementare.
Însă, dacă metodele au același nume și parametri, dar tipuri de return diferite, apare un conflict, deoarece Java nu știe ce tip de return să folosească.
// Exemplu valid: metode cu aceeasi semnatura si acelasi tip de return interface A { int run(); } interface B { int run(); } class ABRunner implements A, B { @Override public int run() { return 42; // o singura implementare pentru ambele interfete } } // Exemplu care dă eroare: metode cu același nume și parametri, dar tipuri diferite interface C { String run(); } // Clasa de mai jos va genera eroare dacă încercăm să implementăm A și C simultan class ACFail implements A, C { // eroare: conflicte la tipul return @Override public ??? run() { ... } // Java nu știe dacă să returneze "int" sau "String" }
Problema diamantului în interfețe
Apare această problemă când o clasă implementează două interfețe care au aceeași metodă default cu aceeași semnătură.
interface A { default void hello() { System.out.println("Hello from A"); } } interface B { default void hello() { System.out.println("Hello from B"); } } class C implements A, B { // EROARE: conflict, Java nu știe ce versiune de hello() să folosească }
Java nu știe dacă să o folosească pe cea din A sau B, ceea ce rezultă într-un conflict.
Soluția pentru Problema diamantului în Java
Clasa care implementează interfețele trebuie să rezolve conflictul suprascriind metoda:
class C implements A, B { @Override public void hello() { A.super.hello(); // sau B.super.hello(); } }
| Avantaj | Descriere clarificată |
|---|---|
| Contract clar | Clasa care implementează interfața este obligată să furnizeze implementarea tuturor metodelor declarate în interfață. |
| Polimorfism flexibil | Obiectele diferite care implementează aceeași interfață pot fi tratate uniform prin tipul interfeței, fără să conteze clasa lor concretă. |
| Independență | O clasă poate implementa interfața fără să fie legată de o anumită ierarhie de clase; nu trebuie să extindă o clasă specifică. |
| Reutilizare | Orice clasă poate implementa aceeași interfață, ceea ce permite refolosirea logicii comune în contexte diferite. |
| Alternativă la multiple inheritance | Java nu permite moștenirea multiplă a claselor, dar o clasă poate implementa mai multe interfețe, obținând astfel comportamente multiple. |
După cum se poate observa, atât clasele abstracte cât și interfețele oferă avantaje similare. Pentru a observa asemănările și diferențele dintre acestea am creat următorul tabel:
| Caracteristică | Clasă abstractă | Interfață |
|---|---|---|
| Instanțiere | Nu poate fi instanțiată | Nu poate fi instanțiată |
| Metode abstracte | Poate conține metode abstracte și implementate | Toate metodele sunt abstracte (Java <8); Java 8+ permite și metode default și static cu implementare |
| Variabile | Poate avea variabile de instanță | Toate variabilele sunt public static final (constante) |
| Constructor | Poate avea constructor | Nu poate avea constructor |
| Moștenire | O clasă poate extinde o singură clasă abstractă | O clasă poate implementa mai multe interfețe |
| Acces metode | Poate avea orice modificator de acces (private, protected, public) | Metodele sunt implicit public abstract; Metodele default (Java 8+) sunt public și metodele static pot fi public sau private (Java 8+) |
| Utilizare | Când există cod comun de reutilizat și dorim un șablon pentru subclase | Când vrem să definim un contract pentru comportament independent de ierarhia claselor |
| Polimorfism | Permite polimorfism pe baza clasei de bază | Permite polimorfism pe baza interfeței, indiferent de clasa concretă |
| Extindere / implementare | O clasă abstractă poate extinde o altă clasă abstractă sau normală | O interfață poate extinde mai multe interfețe |
Proiectele voastre ar trebui să conțină o combinație între clase abstracte și interfețe.
Să presupunem următoarea ierarhie de clase.
Explicație:
Living este abstractă pentru că vrem să forțăm implementarea metodei eat() în toate clasele care o extind, dar oferim și un comportament comun grow().Animal este abstractă pentru că nu are sens să instanțiem un animal generic; fiecare subclasă (Cat, Dog, Bird) trebuie să implementeze makeSound().Cat, Dog, Bird) implementează metodele abstracte și pot fi instanțiate.Domesticable, Guardable, Flyable) definesc capabilități suplimentare, fără a impune moștenirea ierarhiei.Dog poate fi Domesticable și Guardable.
Reamintim din laboratoarele trecute cum final poate fi folosit pentru a crea câmpuri sau variabile locale constante:
public class Alien { private final String name; // camp constant public Alien(String name) { this.name = name; } public String sayHelloToHumans() { // Constante locale final String evilMessage = " prepare your world to be conquered!"; final String goodMessage = " we want to taste shaorma!"; if (name.equals("Good Boy Roy")) { return "My name is " + name + goodMessage; } return "My name is " + name + badMessage; } public String getName() { return name; } }
De asemenea, putem marca parametrii unei metode ca fiind final pentru a indica faptul că aceștia nu pot fi schimbați în corpul unei metode:
public class Alien { private final String name; // camp constant public Alien(String name) { this.name = name; } public String sayHelloToHumans() { // Constante locale final String evilMessage = " prepare your world to be conquered!"; final String goodMessage = " we want to taste shaorma!"; if (name.equals("Good Boy Roy")) { return "My name is " + name + goodMessage; } return "My name is " + name + badMessage; } public void shootRayGun(final Human human) { human.decreaseHealthBy(70); System.out.println("Resistence is futile!"); human = new Human(); // eroare, human este final human.job = "plumber"; // se poate, final se refera doar la referinta obiectului } public String getName() { return name; } }
Un obiect imutabil este un obiect a cărui stare internă nu poate fi modificată după ce a fost creat. Cu alte cuvinte, toate câmpurile sale sunt constante după inițializare, iar orice operație aparentă de “modificare” va genera de fapt un nou obiect.
Pentru ca o clasă să fie considerată imutabilă, trebuie să respecte următoarele reguli:
Exemplu de clasă imutabilă:
public final class Adress { private final String street; private final String city; private final int zipCode; public Adresa(String street, String city, int zipCode) { this.street = street; this.city = city; this.zipCode = zipCode; } public String getStreet() { return street; } public String getCity() { return city; } public int getZipCode() { return zipCode; } @Override public String toString() { return street + ", " + city + " (" + zipCode + ")"; } }
public class Main { public static void main(String[] args) { Adresa adresa1 = new Adresa("Str. Florilor", "București", 12345); Adresa adresa2 = new Adresa("Str. Florilor", "București", 12345); // ambele obiecte sunt independente și nu pot fi modificate System.out.println(adresa1); // Str. Florilor, București (12345) } }
Java oferă numeroase exemple de clase imutabile, printre care:
StringInteger, Double, Boolean etc.).LocalDate, LocalTime, LocalDateTime (din pachetul java.time).
String și wrappers (Integer, Double etc.) în următoarele laboratoare.
Enums sunt tipuri speciale de clasă care definesc un set fix de constante. Ele oferă o modalitate sigură și lizibilă de a reprezenta valori finite și constante într-un program.
Exemplu simplu:
enum Direction { NORTH, SOUTH, EAST, WEST }
NORTH nu este nici String, nici int. Este o instanță statică și constantă a enum-ului Direction.static final ca și cum ați declara constante clasice în Java.
enum Planet { MERCURY(3.3e+23, 2.4e6), VENUS(4.87e+24, 6.1e6), EARTH(5.97e+24, 6.4e6); private final double mass; // în kilograme private final double radius; // în metri Planet(double mass, double radius) { this.mass = mass; this.radius = radius; } public double getMass() { return mass; } public double getRadius() { return radius; } public double surfaceGravity() { final double G = 6.67300E-11; return G * mass / (radius * radius); } }
EARTH, trebuie să menționăm și valorile pentru mass și radius asociate acestuia prin folosirea constructorului (ex. EARTH(5.97e+24, 6.4e6)).
Pentru exemplul simplu de mai sus avem:
Direction dir = Direction.NORTH; switch (dir) { case NORTH: System.out.println("Mergeți spre nord"); break; case SOUTH: System.out.println("Mergeți spre sud"); break; default: System.out.println("Altă direcție"); }
Pentru exemplul mai complex avem:
public class PlanetExample { public static void main(String[] args) { double massOnEarth = 70; // masa în kg (nu greutatea!) for (Planet planet : Planet.values()) { double weight = planet.surfaceGravity() * massOnEarth; System.out.printf("Greutatea ta pe %s este %.2f N%n", planet.name(), weight); } } }
values() este o metodă generată automat de Java pentru orice Enum. Ea returnează un array cu toate constantele definite în acel enum, în ordinea în care au fost declarate (ex. MERCURY, VENUS, EARTH).
static final într-o clasă normală care are rol de a ține constante, însă este de preferat să folosiți Enum-uri fiind o alternativă mai modernă.
Tipul record este un tip special de clasă introdus în Java 16, creat pentru a reprezenta date imutabile într-un mod concis. Scopul său principal este să reducă codul boilerplate necesar pentru clasele care doar păstrează date (data carriers), cum ar fi DTO-uri sau simple modele.
Un record:
final (nu poate fi moștenit).private final).public record Person(String name, int age) {}
Exemplul de mai sus este echivalent cu:
public final class Person { private final String name; private final int age; public Person(String name, int age) { this.name = name; this.age = age; } public String name() { return name; } public int age() { return age; } @Override public boolean equals(Object o) { ... } @Override public int hashCode() { ... } @Override public String toString() { ... } }
Person p = new Person("Ana", 25); System.out.println(p.name()); // Ana System.out.println(p.age()); // 25
getName(), ci exact ca proprietatea: name().
Constructorul compact ne permite să validăm datele fără să repetăm lista de parametri.
public record Product(String name, double price) { public Product { if (price < 0) { throw new IllegalArgumentException("Price must be positive"); } } }
Pe lângă getteri și setteri putem adăuga de asemenea și metode.
public record Point(int x, int y) { public double distanceToOrigin() { return Math.sqrt(x * x + y * y); } }
Records pot implementa interfețe, dar nu pot extinde clase.
public interface Printable { void print(); } public record Book(String title, String author) implements Printable { @Override public void print() { System.out.println(title + " by " + author); } }
Records sunt potrivite pentru:
Limitări:
User dintr-o aplicație bancară am putea transmite inclusiv date confidențiale despre acesta (CNP, adresă etc.). Pentru a transmite doar datele necesare, putem crea o clasă UserDTO care conține doar câmpurile relevante pentru transfer.