This is an old revision of the document!


Laboratorul 2: Obiecte în Java

Obiective

Scopul acestui laborator este de a familiariza studenții cu principiile de bază ale programării orientate pe obiecte și cu modul în care memoria este gestionată în Java.

Aspectele urmărite sunt:

  • Înțelegerea conceptelor fundamentale ale programării orientate pe obiecte (POO).
  • Crearea și instanțierea obiectelor folosind constructori.
  • Definirea și utilizarea câmpurilor și metodelor.
  • Aplicarea principiului încapsulării și controlul accesului prin specificatori (private, public, protected).
  • Supraincărcarea metodelor (Overloading) și folosirea cuvântului cheie this.
  • Organizarea codului în clase și metode pentru claritate și modularitate.
  • Inițializări statice și blocuri statice.
  • Diferența dintre tipuri primitive și clase wrapper.
  • Utilizarea codului reutilizabil și întreținerea acestuia în proiecte mai mari.
  • Înțelegerea execuției și momentului apelării constructorilor și metodelor statice.

Aspectele bonus urmărite sunt:

  • Algoritmii din spatele Garbage Collector.
  • Destructori.
  • Scenariid de utilizare pentru static.
  • Utilizarea corectă a array-urilor care conțin obiecte.

  • Î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.

🧩 Programarea Orientată Obiect

Programarea orientată pe obiecte reprezintă unul dintre fundamentele limbajului Java. Aceasta presupune organizarea aplicațiilor sub forma unor obiecte care colaborează între ele pentru a rezolva probleme complexe.

Prin împărțirea unei aplicații în componente independente, procesul de dezvoltare, întreținere și reutilizare a codului devine mai simplu și mai eficient.

Java a fost conceput de la început ca un limbaj orientat pe obiecte, iar toate bibliotecile și API-urile sale reflectă această filozofie de design.

De ce este POO o paradigmă bună?

Succesul acestei paradigme se poate datora și faptului că multe dintre cele mai folosite limbaje se întamplă să fie limbaje orientate pe obiect, cum ar fi:

Android (Front-end) iOS (Back-end) Web (Front-end or Back-end)
Java Swift JavaScript
C++ Objective-C Python
Kotlin PHP
Ruby
C#
Java

Spre deosebire de programarea procedurală, care folosește o listă de instrucțiuni pentru a spune computerului pas cu pas ce să facă, programarea orientată-obiect se folosește de componente din program care știu să desfășoare anumite acțiuni și să interacționeze cu celelalte.

Astfel, POO oferă modularitate și ordine programului, reușind să modeleze situații din viața reală - fiind mai avansată decât programarea procedurală, care reflectă un mod simplu, direct de a rezolva problema.

De ce să folosim POO?

  • Scrierea de cod complex devine clară și cu minim de erori (readability)
  • Împărțirea codului între membrii echipei și urmărirea progresului (transparency)
  • Cod ușor de extins și reparat (extensibility)
  • Devine clar ce cod este testat automat și ce cod nu este testat (testability)
  • Refolosim soluții la probleme comune și uneori chiar cod (reusability)
  • Ușurința în crearea - uneori automată - a documentației (documentation)

💬 Tipul special String

Deoarece în acest laborator studiem clase și obiecte, vom avea acces și la tipul special String, fiind singurul tip fundamental care nu este de tip primitiv, ci de tip obiect.

El este folosit pentru definirea șirurilor de caractere, astfel se elimină nevoia de folosire a contrucțiilor de tip char array.

String name = "Ion";
String surname = "Popescu";
 
String fullname = name + surname; // concatenarea va rezulta într-un String, deoarece cel puțin un membru este String

  • Orice text inserat între ghilimele este considerat un String literal.
  • Vom explica în acest laborator și în laboratoarele următoare mai multe proprietăți ale tipului String.

🧱 Clase și obiecte

Clasele reprezintă tipuri de date definite de utilizator sau deja existente în sistem (din class library - set de biblioteci dinamice oferite pentru a asigura portabilitatea, eliminând dependența de sistemul pe care rulează programul).

Clasele sunt blocurile de construcție ale unei aplicații Java. Ele definesc structura și comportamentul componentelor din program.

O clasă poate conține:

  • Metode (funcții);
  • Variabile;
  • Proprietăți;
  • Cod de inițializare;
  • Alte clase, denumite clase interne (studiate în laboratoarele următoare).

Declararea și instanțierea claselor

Declararea unei clase

O clasă servește ca schelet pentru crearea instanțelor (denumite și obiecte la runtime), care implementează structura clasei. Fiecare instanță este o copie individuală a clasei.

Declararea unei clase se face folosind cuvântul cheie class și un nume ales de tine.

După cum am menționat mai sus, clasele conțin:

  • Variabile: stochează detalii sau alte informații utile.
  • Funcții: definesc ce putem face cu instanțele clasei.

În contextul unei clase avem totuși următoarele denumiri speciale:

  • Variabile → variabile membru sau câmpuri.
  • Funcții → funcții membru sau metode.
Apple.java
package objects.examples; // pachet în care se află clasa
 
// Declararea unei clase
class Apple {
    // Variabilele clasei
    float mass;
    float diameter = 1.0f;
    int x, y;
 
    // Metoda clasei
    boolean isFalling() {
        // Cod pentru logica metodei
        return false;
    }
}

Variabilele și metodele pot fi declarate în orice ordine, totuși convenția agreată este să definim mai întâi variabilele și apoi metodele.

  • Inițializatoarele variabilelor nu pot face forward reference la variabile declarate ulterior (ex. variabila diameter poate folosi variabila mass la inițializare, dar variabila mass nu poate folosi diameter pentru inițializare pentru că ea nu există încă).
  • Rețineți denumirile speciale de mai sus, deoarece de acum vom folosi denumirea metodă pentru o funcție care aparține unei clase și denumirea câmp pentru o variabilă care aparține unei clase. Prin folosirea acestor denumiri putem face distincții mai ușor (ex. variabilă locală vs câmp).

Instanțierea unei clase

Pentru a lucra cu obiectele din heap, avem nevoie de adresa la care sunt stocate. În limbaje precum C, această adresă este memorată într-un pointer, oferind control direct asupra memoriei.

Java nu permite pointeri direcți, pentru a evita erorile de acces și scurgerile de memorie.

În schimb, Java folosește referințe, care funcționează asemănător, dar sunt gestionate automat de JVM prin Garbage Collector ceea ce înseamnă că programatorul nu se mai ocupă de gestionarea memoriei manual.

Date date; // referință de tip "Date"

Prin instanțierea unei clase se înțelege crearea unui obiect pe baza unei clase, adică alocarea unei zone de memorie pentru o instanță a clasei.

Procesul de inițializare implică: declarare, instanțiere și atribuire, conform următoarei sintaxe:

Class numeVar = new Class().

Membrul stâng până la egal conține referința de tip Class, iar membrul drept conține instanța de tip Class.

Keyword-ul new este folosit pentru alocarea memoriei (instanțierea clasei), conform următorului exemplu:

Apple a1;               // declararea variabilei
a1 = new Apple();       // instanțierea obiectului
 
Apple a2 = new Apple(); // declarare și instanțiere pe aceeași linie

Dacă referința nu a fost încă asociată unui obiect, valoarea sa este null, iar accesarea membrilor va produce o eroare de tip NullPointerException.

  • În următoarele laboratoare vom observa și cazuri în care referința și instanța nu au același tip.
  • Rețineți că un obiect este de fapt o clasă instanțiată.
  • Vom explica în secțiunile următoare de ce este nevoie să punem paranteze în membrul drept după numele clasei.

Instanțierea clasei String

Clasa String se poate instanția fără să se folosească keyword-ul new, fiind un caz special:

String s1, s2; 
 
s1 = "My first string"; 
s2 = "My second string"; 

Aceasta este varianta preferată pentru instanțierea String-urilor. De remarcat că și varianta următoare este corectă, dar ineficientă, din motive ce vor fi explicate în următoarele laboratoare.

s = new String("str"); 

Accesarea variabilelor și metodelor unui obiect

Odată creat obiectul, putem accesa membrii săi (variabile și metodele) folosind dot notation (.) după numele variabilei:

PrintAppleDetails.java
package objects.examples;
 
public class PrintAppleDetails {
    // Metoda main
    public static void main(String args[]) {
        // Creăm un obiect "Apple"
        Apple a1 = new Apple();
 
        // Printarea câmpurilor
        System.out.println("Apple a1:");
        System.out.println(" mass: " + a1.mass);
        System.out.println(" diameter: " + a1.diameter);
        System.out.println(" position: (" + a1.x + ", " + a1.y +")");
 
        // Folosirea unei metode
        System.out.println(" is falling: " + a1.isFalling());
    }
}

Output

Output

Apple a1:
 mass: 0.0
 diameter: 1.0
 position: (0, 0)
 is falling: false

În Java, câmpurile neinițializate primesc valoarea implicită 0 sau echivalentul acestei valori pentru tipul de date respectiv (ex. boolfalse, float0.f, Applenull). Din acest motiv, variabila membru mass are valoarea 0. Țineți cont că acestă inițializare default se întâmplă doar pentru câmpuri și nu pentru variabilele locale dintr-o metodă.

Metoda “main” poate fi introdusă în orice clasă, puteți avea chiar mai multe metode “main” în mai multe clase, dar doar o singură metodă “main” poate fi pornită la un anumit moment de timp.

Metode

Metodele sunt blocuri de cod care descriu acțiuni sau comportamente ale obiectelor.

Exemple:

  • main() - punctul de intrare al programului;
  • printDetails() - afișează informații despre un obiect;
  • moveTo() - modifică poziția unui obiect.

Din punct de vedere al caracteristicilor, metodele pot:

  • avea parametrii (valori primite la apel);
  • returna un rezultat (de orice tip, inclusiv void dacă nu returnează nimic);
  • conține variabile locale, valabile doar în interiorul metodei.

Variabile locale

O variabilă locală:

  • este creată la apelul metodei;
  • este ștearsă automat când metoda se termină;
  • trebuie inițializată înainte de a fi folosită.
void myMethod() {
    int bar;
    bar = 42; // trebuie să primească o valoare înainte de a fi folosită
    System.out.println(bar);
}

Spre deosebire de variabilele instanței (câmpuri), variabilele locale nu primesc valori implicite. Dacă se încearcă folosirea unei variabile locale fără inițializare, se va afișa o eroare de compilare.

Inițializarea variabilelor locale

Java nu permite folosirea unei variabile locale fără inițializare sigură.

Un exemplu de cod greșit este următorul:

int bar;
if (cond) {
    bar = 42;
}
bar++; // eroare: bar poate fi neinițializat

Pentru a evita erori, fie ne asigurăm că inițializăm variabila din start, fie ne asigurăm că primește o valoare pe toate ramurile posibile (else, return etc.).

Shadowing

Dacă o metodă are parametri cu același nume ca variabilele care aparțin instanței, parametrii ascund acele variabile.

class Apple {
    int x = 2; 
    int y = 3;
 
    // Presupunem că argumentele pasate sunt: x = 10, y = 11
    void moveTo(int x, int y) {
        System.out.println("Moving apple to (" + x + ", " + y + ")");
    }
}

Output

Output

Moving apple to (10, 11)

În acest exemplu, x și y din metodă sunt parametrii metodei, nu variabilele ale instanței.

Pentru a accesa variabilele care aparțin instanței, trebuie să folosim keyword-ul this, prezentat mai jos.

Supraîncărcarea metodelor

Supraincărcarea metodelor (Method Overloading) reprezintă abilitatea unei clase de a defini mai multe metode cu același nume, dar cu liste de argumente diferite (prin număr, tip sau ordine).

Compilatorul determină care versiune a metodei trebuie apelată în funcție de semnătura apelului, verificând tipurile și numărul parametrilor transmiși.

Optimizările la compile-time sunt preferabile, deoarece reduc timpul de execuție la run-time. De exemplu, supraîncărcarea metodelor este rezolvată de compilator prin verificarea semnăturilor, evitând calculul suplimentar în timpul execuției.

Calculator.java
class Calculator {
    int sum(int a, int b) {
        return a + b;
    }
 
    double sum(double a, double b) {
        return a + b;
    }
 
    int sum(int a, int b, int c) {
        return a + b + c;
    }
}
Test.java
public class Test {
    public static void main(String[] args) {
        Calculator calc = new Calculator();
        System.out.println(calc.sum(2, 3));        // apelează versiunea int,int
        System.out.println(calc.sum(2.5, 3.7));    // apelează versiunea double,double
        System.out.println(calc.sum(1, 2, 3));     // apelează versiunea cu 3 parametri
    }
}

Un alt exemplu cu care sunteți deja familiari este chiar metoda print() în care puteți introduce mai multe tipuri de argumente.

System.out.print(232.4f);  // apelează print(float)
System.out.print(123);     // apelează print(int)
System.out.print(true);    // apelează print(boolean)

[Nice to know] Detalii despre algoritmul din spatele supraîncărcării

[Nice to know] Detalii despre algoritmul din spatele supraîncărcării

Compilatorul Java folosește următorul algoritm pentru supraîncărcare:

  1. Caută o potrivire exactă a tipurilor de argumente.
  2. Dacă nu există, caută o versiune compatibilă prin conversie (de ex. intlong).
  3. Dacă există mai multe potriviri posibile, alege metoda cea mai specifică, adică cea mai apropiată în ierarhia de moștenire.

  • Alegerea metodei supraincărcate se face la compilare, iar metoda selectată rămâne fixă chiar dacă, ulterior, clasa este modificată și apare o metodă mai potrivită. Pentru a putea permite alegerea unei variante mai bune, va trebui să recompilăm codul pentru a rula din nou algoritmul de mai sus.
  • Vom detalia moștenirea în laboratorul următor.

Crearea obiectelor

Constructorii

Un constructor este o metodă specială care:

  • are același nume ca al clasei;
  • nu are tip de return;
  • este apelată automat la crearea unui obiect pentru a-l inițializa.

Dacă nu este definit niciun constructor, compilatorul Java adaugă un constructor implicit fără parametri.

Date.java
class Date {
   int day = 1;
 
   // Constructor implicit adăugat de compilator
   // Date() {}
}
CustomDate.java
class SpecialDate {
   int day = 1;
 
   // Constructor explicit adăugat de programator
   SpecialDate(int day) {
      this.day = day;
   }
}

  • Motivul pentru care compilatorul creează un constructor default este pentru a permite inițializarea clasei.
    // Funcționează pentru că există un constructor default creat de compilator
    Date date = new Date();
  • În momentul în care am definit un constructor într-o clasă compilatorul nu va mai crea un constructor default.
    // Nu funcționează pentru că nu a fost generat constructorul default
    SpecialDate date = new SpecialDate();
     
    // Funcționează pentru că am definit acest constructor
    SpecialDate date2 = new SpecialDate(30);

Constructorii nu au tip de return pentru că returnează tipul clasei automat pentru instanțiere.

Supraincărcarea constructorilor

Java permite existența mai multor constructori în aceeași clasă, cu semnături diferite. Alegerea constructorului potrivit se face la compilare, conform regulilor de selecție a metodelor supraincărcate.

class Car {
    String model;
    int doors;
 
    Car(String model, int doors) {
        this.model = model;
        this.doors = doors;
    }
 
    Car(String model) {
        this(model, 4); // apel către alt constructor din aceeași clasă
    }
}

  • Puteți crea oricâți constructori doriți într-o clasă.
  • Din punct de vedere al coding style-ului vă recomandăm să ordonați constructorii după numărul de parametrii.

Copy constructor

În Java, există conceptul de copy constructor, acesta reprezentând un constructor care ia ca parametru un obiect de același tip cu clasa în care se află constructorul respectiv. Cu ajutorul acestui constructor, putem să copiem obiecte, prin copierea membru cu membru în constructor.

public class Student {
    private String name;
    private int averageGrade;
 
    public Student(String name, int averageGrade) {
        this.name = name;
        this.averageGrade = averageGrade;
    }
 
    // copy constructor
    public Student(Student student) {
        /* 
          "name" este câmp privat, noi îl putem accesa direct (student.name) 
          deoarece ne aflam in interiorul clasei
        */
        this.name = student.name;
        this.averageGrade = student.averageGrade;
    }
}

Câmpul privat student.name poate fi accesat deoarece ne aflăm în interiorul aceleași clasei. Chiar dacă obiectul student nu se referă la instanța curentă (aceasta fiind this), compilatorul Java ne permite să avem acces la membrii privați în această situație specială.

  • Constructorii pot avea modificatori de acces (public, protected, private) pentru a controla cine poate crea obiecte.
  • Constructorii nu pot fi abstract, final sau synchronized (mai multe despre acestea în următoarele laboratoare).
  • Variabilele statice pot fi accesate în constructori, întrucât sunt inițializate odată cu încărcarea clasei.

[Nice to know] Gestionarea corectă a array-urilor care rețin obiecte

[Nice to know] Gestionarea corectă a array-urilor care rețin obiecte

Când lucrăm cu array-uri de obiecte, trebuie să reținem că array-ul conține doar referințe către obiecte, nu obiectele în sine. De aceea, după declararea unui array, fiecare element trebuie instanțiat separat, altfel vom obține o eroare de tip NullPointerException.

Student[] arr = new Student[3];
arr[0].name = "Mihai"; // eroare: arr[0] este null

Pentru a nu avea erori ar trebui să scriem:

Student[] arr = new Student[3];
arr[0] = new Student();
arr[0].name = "Andreea";

Este recomandat să inițializați fiecare element dintr-un array înainte să îl folosiți. Pentru a facilita acest proces, puteți folosi o buclă, cum ar fi bucla for.

Distrugerea obiectelor

Java gestionează automat distrugerea obiectelor printr-un proces numit Garbage collection (GC). Astfel, programatorul nu trebuie să elibereze manual memoria, evitând erori frecvente precum memory leaks.

Garbage Collection

Mecanismul de garbage collection are rolul de a elibera memoria ocupată de obiectele care nu mai sunt accesibile. Un obiect devine inaccesibil atunci când nu mai există nicio referință activă către el.

Date christmas = new Date("Dec 25, 2022");
christmas = null; // obiectul devine eligibil pentru colectare

Java poate detecta chiar și referințe circulare (obiecte care se referă reciproc), marcându-le corect pentru colectare.

[Optional] Algoritmii din spatele Garbage Collector-ului

[Optional] Algoritmii din spatele Garbage Collector-ului

Algoritmul Mark and Sweep

Versiunile timpurii ale JVM foloseau algoritmul mark and sweep, care:

  • marchează toate obiectele accesibile ca fiind „vii”;
  • parcurge heap-ul și eliberează memoria pentru cele nemarcate.

Această metodă era sigură, dar lentă. În prezent, JVM-urile moderne folosesc colectoare generationale, care:

  • separă obiectele în funcție de durata de viață estimată (scurtă / lungă);
  • colectează mai frecvent obiectele tinere;
  • ajustează dinamic dimensiunea zonelor de memorie pentru performanță optimă.

[Optional] Colectare explicită

[Optional] Colectare explicită

De regulă, nu este nevoie să apelăm manual garbage collectorul. Totuși, pentru testare sau debugging, putem solicita o colectare explicită:

System.gc(); // solicită executarea garbage collectorului

Această apelare este doar o sugestie către JVM, implementarea poate alege să ignore cererea.

[Optional] Destructori

[Optional] Destructori

În Java, nu există destructori așa cum întâlnim în alte limbaje precum C++. Gestionarea memoriei este automată și realizată de Garbage Collector.

class Student { 
   @Override 
   protected void finalize() throws Throwable { 
      System.out.println("Obiectul Student a fost colectat"); 
   } 
}

Vom studia în laboratoarele următoare keyword-urile throws și override, momentan le puteți ignora.

Metoda finalize() este apelată de Garbage Collector înainte ca obiectul să fie distrus, dar nu se recomandă folosirea ei, deoarece:

  • momentul apelului nu este garantat;
  • afectează performanța;
  • în versiunile moderne de Java, finalize() este deprecated.

În practică, curățarea resurselor (fișiere, conexiuni etc.) se face explicit, folosind practici recomandate pe care le vom studia în laboratoarele următoare.

Astfel, eliberarea memoriei și curățarea resurselor sunt gestionate automat și în siguranță de JVM, fără ca programatorul să fie nevoit să definească destructori manual.

Cuvântul cheie this

Keyword-ul this este o referință la obiectul curent, adică la instanța clasei care execută metoda.

this se folosește pentru:

1. Dezambiguizare, prin accesarea variabilelor care aparțin instanței ce sunt ascunse de parametri:

class Apple {
    int x, y;
    void moveTo(int x, int y) {
        this.x = x; // accesează variabila obiectului curent
        this.y = y;
    }
}

Evitați folosirea this în mod excesiv. În Java este recomandat să folosim this doar când vrem să scăpăm de shadowing.

2. Pasarea referinței la obiectul curent altor metode:

Group.java
class Group {
 
    private int numberStudents;
    private Student[] students;
 
    public Group () {
        numberStudents = 0;
        students = new Student[10];
    }
 
    public boolean addStudent(String name, int grade) {
        if (numberStudents < students.length) {
            // Pasăm "this" pentru parametrul de tip "Group"
            students[numberStudents++] = new Student(this, name, grade); // (1)
            return true;
        }
 
        return false;
    }
}
Student.java
class Student {
 
    private String name;
    private int averageGrade;
    private Group group;
 
    public Student(Group group, String name, int averageGrade) {
        this.group        = group; // (2)
        this.name         = name;
        this.averageGrade = averageGrade;
    }
}

Prin pasarea lui this ca argument am pasat de fapt o referință la instanța curentă a obiectului de tip Group care va fi memorată în obiectul Student, astfel am creat o legătură directă între un obiect de tip Student și un obiect de tip Group.

3. Apelul către un alt constructor din aceeași clasă:

Apelul către un alt constructor din aceeași clasă se face cu this(…) și trebuie să fie prima instrucțiune din constructorul curent.

class Car {
    String model;
    int doors;
 
    Car(String model, int doors) {
        this.model = model;
        this.doors = doors;
    }
 
    Car(int doors) {
        if (doors < 2) {
            System.out.println("Is this the batmobile?");
        }
        this("Batmobile", doors); // eroare, apelul "this" nu se află pe prima linie din constructor
    }
 
    Car(String model) {
        this(model, 4); // corect
    }
}

  • Java 25 permite ca apelul this() să nu se afle pe prima linie din constructor.
  • Recomandăm refolosirea constructorilor pentru a evita cod duplicat.

În metode sau blocuri statice, this nu poate fi folosit, deoarece this se referă la instanța curentă a clasei, iar la momentul executării codului static niciun obiect nu există.

Ascunderea implementării

Specificatori de acces

În limbajul Java (şi în majoritatea limbajelor de programare de tipul OOP), orice clasă, atribut sau metodă posedă un specificator de acces, al cărui rol este de a restricţiona accesul la entitatea respectivă, din perspectiva altor clase.

Specificator Definitie
private limitează accesul doar în cadrul clasei curente
default accesul este permis doar în cadrul pachetului (package private)
protected accesul este permis doar în cadrul pachetului si în clasele ce moștenesc clasa curentă
public permite acces complet

Atenţie, nu confundaţi specificatorul default (lipsa unui specificator explicit) cu protected.

Până în acest moment, putem aplica specificatori de acces pentru clase, metode și câmpuri.

Element / Modificatorpublicprotecteddefaultprivate
Clasă (top-level) ✅ Vizibilă din orice pachet ❌ Nu este permis ✅ Vizibilă doar în același pachet ❌ Nu este permis
Metodă ✅ Accesibilă de oriunde ✅ Accesibilă în același pachet și în subclase ✅ Accesibilă doar în același pachet ✅ Accesibilă doar în clasa curentă
Câmp (atribut) ✅ Accesibil de oriunde ✅ Accesibil în același pachet și în subclase ✅ Accesibil doar în același pachet ✅ Accesibil doar în clasa curentă
Student.java
package org.poo
 
// Acces "public" vizibilă în orice pachet
public class Student {
   private String fullName = "Ion Popescu";
   public String prefferedName = "Ion";
   float avgGrade = 9.4f;
 
   int getAvgRoundedGrade() {
      return (int)(avgGrade + 0.5f);
   }
 
   public String getPromotedMsg() {
      return avgGrade > 5.f ? "Congrats, you passed!" : "Oh no...";
   }
}
Main.java
package org.poo.main
 
public class Main {
   public static void Main(String[] args) {
      // Avem acces la această clasă fiind declarată în același fișier
      Student st = new Student();
 
      // Eroare de compilare (private)
      System.out.println(st.fullName);
 
      // Avem acces (public)
      System.out.println(st.prefferedName);
 
      // Eroare de compilare pentru că nu ne aflăm în același pachet (default)
      System.out.println(st.getAvgRoundedGrade());
 
      // Avem acces (public)
      System.out.println(st.getPromotedMsg());
   }
}

Clasele interne pot folosi mai mulți specificatori de acces față de clasele top-level, dar vom vorbi despre aceste în laboratoarele următoare.

Încapsulare

Încapsularea (Encapsulation) reprezintă reunirea într-o clasă a atributelor și metodelor specifice unei anumite categorii de obiecte. Totodată, acest concept presupune ascunderea stării interne a obiectului (atribute și valorile lor) și controlul accesului la acestea exclusiv prin intermediul metodelor clasei.

Încapsularea este unul dintre cele 4 proprietăți principale ale unui limbaj orientat obiect.

Încapsularea conduce la izolarea modului de implementare a unei clase (atributele acesteia şi cum sunt manipulate) de utilizarea acesteia. Utilizatorii unei clase pot conta pe funcţionalitatea expusă de aceasta, indiferent de implementarea ei internă (chiar şi dacă se poate modifica în timp).

  • Utilizarea specificatorilor contribuie la realizarea încapsulării.
  • Încapsularea contribuie la reducerea erorilor prin ascunderea detaliilor de implementare. Folosind specificatori de acces, puteți impune constrângeri clare asupra utilizatorilor codului, marcând membrii clasei ca fiind interni (private sau protected). Astfel, se evită situațiile în care un coleg ar modifica accidental părți ale codului care nu ar trebui să fie accesibile public.

Proprietăți

În secțiunile de mai sus am menționat că o clasă poate conține și proprietăți. O proprietate este un câmp (membru) căruia i se atașează două metode ce îi pot expune sau modifica starea. Aceste doua metode se numesc getter si setter.

 class PropertiesExample {      
     private String myString;
 
     public String getMyString() {
         return myString;
     }
 
     public void setMyString(String myString) {
         this.myString = myString;
     }
 } 

Declarăm un obiect de tip PropertiesExample și îi inițializăm membrul myString astfel:

PropertiesExample pe = new PropertiesExample(); 
 
pe.myString = "This is bad"; // nu funcționează pentru că este "private"
 
pe.setMyString("This is my string!"); // funcționează
 
System.out.println(pe.getMyString()); 

Getter/Setter vs. public

O întrebare firească este dacă avem declarat un getter și setter pentru un câmp private, nu este ca și cum acel câmp ar fi public?

Getter-ele și setter-ele, chiar și în forma lor simplă, au efectul de a controla accesul la variabilele interne ale unei clase. În plus, ele pot include mecanisme de validare sau sanitizare, astfel încât să expună în siguranță variabila internă, reducând semnificativ riscul de utilizare incorectă sau de corupere a datelor.

public class BankAccount {
    private double balance; // variabilă internă, încapsulată
    public boolean isConfidential = false;
 
    // Getter - permite citirea soldului
    public String getBalance() {
        // returnăm valoarea doar dacă clientul ne permite interogarea
        return isConfidential ? "Cannot access balance." : "Your balance is: " + balance; 
    }
 
    // Setter - permite modificarea soldului, cu validare
    public void setBalance(double balance) {
        if (balance >= 0) { // validare: soldul nu poate fi negativ
            this.balance = balance;
        } else {
            System.out.println("Error: Balance cannot be negative for debit cards!");
        }
    }
 
    public static void main(String[] args) {
        BankAccount account = new BankAccount ();
 
        account.setBalance(1000); // valid
        System.out.println(account.getBalance()); // 1000.0
 
        account.setBalance(-500); // invalid
        System.out.println(account.getBalance()); // 1000.0 (nu s-a schimbat)
 
        account.isConfidential = true;
        System.out.println(account.getBalance()); // null
    }
}

Wrappers pentru tipuri primitive

În Java există o separare fundamentală între:

  • Tipurile primitive, utilizate pentru operații rapide și eficiente (int, double, boolean, char etc.);
  • Tipurile de clasă (obiecte), care oferă funcționalități suplimentare și fac parte din ierarhia java.lang.Object.

Obiectele oferă funcționalități suplimentare sub forma unor metode (rotateLeft(), toHex() etc.), dar aceste metode trebuie stocate în memorie pentru fiecare instanță creată. De asemenea, obiectele trebuie eliberate din memorie periodic (mai multe detalii în următoarele secțiuni), ceea ce face execuția mai lentă.

Din aceste motive, Java a ales să păstreze tipurile primitive pentru a evita costurile suplimentare ale obiectelor, mai ales în calculele numerice intensive.

  • Este recomandat să folosiți tipuri primitive ori de câte ori puteți.
  • De asemenea, dacă doriți să evitați folosirea unor valori magice pentru control (ex. numNodes == -1) puteți folosi clasele de tip wrapper care permit și starea null (ex. numNodes == null).

Clase Wrapper

Pentru a permite utilizarea valorilor primitive în contexte ce necesită obiecte (de exemplu, în colecții despre care vom vorbi în următoarele laboratore), Java oferă clase wrapper dedicate fiecărui tip primitiv.

Tip primitivClasă wrapper corespunzătoare
voidjava.lang.Void
booleanjava.lang.Boolean
charjava.lang.Character
bytejava.lang.Byte
shortjava.lang.Short
intjava.lang.Integer
longjava.lang.Long
floatjava.lang.Float
doublejava.lang.Double

Wrapper-ele tipurilor primitive sunt immutable. Vom afla în următoarele laboratoare mai multe despre acest concept.

Crearea instanțelor Wrapper

Un obiect wrapper poate fi construit:

  • dintr-o valoare primitivă
  • dintr-un șir de caractere (String) care reprezintă valoarea numerică.
Float pi = new Float(3.14);
Float pi2 = new Float("3.14");

Dacă șirul nu poate fi convertit într-o valoare numerică validă, se afișează o eroare la run-time de tip NumberFormatException.

Conversia între tipuri primitive

Toate clasele numerice (Integer, Double, Float etc.) au la dispoziție metode pentru conversia valorii interne în alte forme primitive:

Double size = new Double(32.76);
double d = size.doubleValue();  // 32.76
float f = size.floatValue();    // 32.76f
long l = size.longValue();      // 32L
int i = size.intValue();        // 32

Aceste metode sunt echivalente cu operațiile de conversie explicită (cast) între tipurile primitive.

Autoboxing și Unboxing

Începând cu Java 5, conversia între tipurile primitive și wrapper se face automat:

  • Autoboxing: conversie automată de la valoare primitivă la obiect wrapper;
  • Unboxing: conversie inversă, de la wrapper la valoare primitivă.
int primitiveValue = 42;
Integer objectValue = primitiveValue; // autoboxing automat de la int → Integer
 
Integer anotherObject = new Integer(99);
int anotherPrimitive = anotherObject; // unboxing automat de la Integer → int
 
Integer a = 10;
Integer b = 20;
int sum = a + b; // a și b sunt unboxed automat, apoi rezultatul e autoboxed dacă e atribuit unui Integer

Compilatorul inserează conversiile în mod implicit, făcând codul mai concis și mai lizibil.

Autoboxing poate genera costuri ascunse, mai ales în bucle mari, din cauza creării frecvente de obiecte.

💾 Memoria în Java

Alocarea memoriei pe Stack

De fiecare dată când o metodă este apelată, JVM creează un stack frame care conține:

  • parametrii metodei;
  • variabilele locale;
  • adresa de retur către metoda apelantă.

Când metoda se încheie, frame-ul este eliminat, iar memoria este eliberată automat.

public void test() {
    int x = 10; // stocat pe stack
    Student s;  // referință către un obiect Student (dar neinițializată)
}

Variabilele simple (de tip primitiv) sunt stocate direct în stivă, iar variabilele de tip obiect conțin doar o referință către adresa din heap.

Alocarea memoriei în Heap

Obiectele în Java sunt stocate în Heap, o zonă de memorie dedicată alocărilor dinamice. Pentru a crea un obiect, folosim operatorul new.

Student.java
public class Student {
   private String name;
   private int grade;
 
   public Student(String name, int grade) {
      this.name = name;
      this.grade = grade;
   }
 
   public String getName() {
      return name;
   }
 
   public int getGrade() {
      return grade;
   }
}
public void test() {
   Student st = new Student("Mihai", 8);
}

În exemplul de mai sus:

  • referința st este pe Stack;
  • obiectul Student este alocat pe Heap;
  • adresa obiectului din heap este copiată în referința st.

Când metoda în care a fost creat obiectul se termină, referința st dispare, dar obiectul rămâne în Heap până când Garbage Collector-ul decide că nu mai este utilizat (când numărul de referințe către acea zonă din Heap ajunge la 0).

Transferul parametrilor în metode

În Java, toți parametrii sunt transmiși prin copiere. Diferența apare în ce anume se copiază:

  • tipuri primitive: se copiază valoarea → modificările din funcție nu afectează variabila originală;
  • obiecte: se copiază referința → metoda poate modifica conținutul obiectului, dar nu poate schimba ce obiect este referit.

Datorită proprietăților de mai sus, Java este considerat un limbaj de tipul pass-by-reference.

class TestParams {
 
    static void changeReference(Student st) {
        st = new Student("Bob", 10); // schimbă doar copia referinței
    }
 
    static void changeObject(Student st) {
        st.averageGrade = 10; // modifică obiectul real din heap
    }
 
    public static void main(String[] args) {
        Student s = new Student("Alice", 5);
 
        changeReference(s);                 // (1)
        System.out.println(s.getName());    // "Alice" – obiectul nu a fost înlocuit
 
        changeObject(s);                    // (2)
        System.out.println(s.averageGrade); // "10" – atributul a fost modificat
    }
}

În apelul (1), metoda changeReference schimbă doar copia referinței, nu și referința originală.

În apelul (2), metoda modifică structura internă a obiectului, deci schimbarea este vizibilă și după apel.

🪨 Membrii statici

După cum am putut observa până acum, de fiecare dată când creăm o instanță a unei clase, valorile câmpurilor din cadrul instanței sunt unice pentru aceasta și pot fi utilizate fără pericolul ca instanţierile următoare să le modifice în mod implicit.

Apple a1 = new Apple();
Apple a2 = new Apple();
 
a1.mass = 12;
a2.mass = 15;
 
System.out.println("Masa mărului a1 este: " + a1.mass);
System.out.println("Masa mărului a2 este: " + a2.mass);

Output

Output

Masa mărului a1 este: 12
Masa mărului a2 este: 15

După cum se poate observa, a1 și a2 vor funcționa ca entități independente una de cealaltă, astfel că modificarea câmpului mass din a1 nu va avea nici un efect implicit și automat în a2. Există totuși situații când dorim să creăm câmpuri care să fie partajate și să nu fie memorate separat pentru fiecare instanță.

Membrii statici nu aparțin unei instanțe anume, ci clasei în sine. Aceștia sunt împărtășiți de-a lungul tuturor obiectelor create din acea clasă și pot să fie accesate fără să se creeze o instanță, având o locație specială în memorie (diferită de Heap și Stack).

Instanțe vs. Membrii statici

Când declarați o variabilă sau o metodă înăuntrul unei clase:

  • Membrii instanței: aparțin doar obiectului instanțiat. (ex. a1.mass și a2.mass pot avea valori diferite).
  • Membrii statici: aparțin clasei și sunt împărtășiți de către toate obiectele (ex. toate merele pot avea aceeași constantă gravitațională).
TipApartenențăAccesat prinCum este valoarea reținută în memorie
Variabilă a instanțeiObiecta1.massVariază de la obiect la obiect
Variabilă staticăClasăApple.gravAccÎmpărtășită de toate obiectele

Declararea variabilelor statice

Pentru a declara o variabilă statică, se folosește keyword-ul static:

Apple.java
class Apple {
    // variabilele instanței
    float mass;
    float diameter;
 
    // variabilă statică
    static float gravAcc = 9.8f;
}

În cazul de mai sus, gravAcc este stocat o dată per clasă, nu per instanță. Dacă îi modificați valoarea, schimbarea se va reflecta pentru toate obiectele de tip Apple.

Apple a1 = new Apple();
 
Apple.gravAcc = 3.7f; // acum toate merele cad pe Marte!
 
Apple a2 = new Apple;
 
System.out.println("Apple a1: " + a1.gravAcc);
System.out.println("Apple a2: " + a2.gravAcc);

Output

Output

Apple a1: 3.7
Apple a2: 3.7

Folosiți variabile statice pentru a avea configurații împărtășite sau constante la nivel de clase, nu pentru a stoca date per obiect.

Accesarea membrilor statici

Membrii statici pot fi accesați în două moduri:

1. Dinăuntrul aceleași clasei, direct după nume:

Apple.java
class Apple {
    // variabilele instanței
    float mass;
    float diameter;
 
    // variabilă statică
    static float gravAcc = 9.8f;
 
    float getWeight() {
       return mass * gravAcc;
    }
}

2. Dinafara clasei, folosind numele clasei:

Main.java
public class Main {
   public static void main(String[] args) {
      float g = Apple.gravAcc;
   }
}

Deși puteți accesa membrii statici printr-o instanță (ex. a1.gravAcc), acest lucru nu este recomandat, deoarece reduce claritatea codului și pentru că o variabilă statică aparține unei clase, nu unei instanțe. Este mai corect să folosim direct numele clasei Apple.gravAcc.

Constante statice

Pentru a defini constante care nu se schimbă niciodată, combinați static cu keyword-ul special final:

class Apple {
    static final float EARTH_ACC = 9.8f;
}
  • static → aparține clasei
  • final → nu poate fi modificat o dată ce a fost asignat

În Java, convenția pentru denumirea constantelor este să folosiți ALL_CAPS împreună cu marcatorul _ (ex. EARTH_ACC, MAX_SPEED etc.).

Keyword-ul final are mult mai multe funcționalități pe care le vom discuta în laboratoarele următoare.

Puteți accesa constantele din orice loc datorită proprietății lor statice:

float g = Apple.EARTH_ACC;

Constantele sunt inlined de către compilatorul Java, adică orice referință către o constantă este înlocuită cu valoarea efectivă. Din acest motiv, dacă vă decideți să schimbați o coinstantă va fi nevoie să recompilați toate clasele care folosesc acea constantă.

Blocuri statice

Pentru a facilita inițializarea câmpurilor statice pe care o clasă le deține, limbajul Java pune la dispoziție posibilitatea de a folosi blocuri statice de cod. Aceste blocuri de cod sunt executate atunci când clasa în cauză este încărcată de către mașina virtuală de Java.

Încărcarea unei clase se face în momentul în care aceasta este referită pentru prima dată în cod (se creează o instanță, se apelează o metodă statică etc.)

În consecință, blocul static de cod se va executa întotdeauna înainte ca un obiect să fie creat.

TestStaticBlock.java
class TestStaticBlock { 
    static int staticInt; 
    int objectFieldInt; 
 
    static { 
        staticInt = 10; 
        System.out.println("static block called"); 
    } 
}
Main.java
class Main { 
    public static void main(String args[]) { 
        /* 
          Chiar dacă nu am creat o instanță a clasei TestStaticBlock, blocul static tot este
          executat și rezultatul va fi "10"
        */
        System.out.println(TestStaticBlock.staticInt);  
    } 
} 

[Nice to know] Static use cases

[Nice to know] Static use cases

1. Folosirea variabilelor statice pentru opțiuni la nivel de clasă

Membrii statici pot de asemenea să stocheze opțiuni de configurație global:

class Apple {
    static int SMALL = 0, MEDIUM = 1, LARGE = 2;
 
    int size;
 
    void setSize(int s) { size = s; }
}

Aceștia pot fi accesați și folosiți astfel:

Apple typicalApple = new Apple();
typicalApple.setSize(Apple.MEDIUM);

Sau pot fi accesați chiar din aceeași clasă:

class Apple {
   ...
 
   void resetEverything() {
      setSize(MEDIUM); // nu este nevoie să scriem Apple.MEDIUM
   }
}

2. Folosirea variabilelor statice pentru contoare

Dacă dorim să numărăm de câte ori o clasă a fost instanțiată putem folosi o variabilă statică:

class ClassWithStatics {
 
    static String className = "Class With Static Members";
    private static int instanceCount = 0;
 
    public ClassWithStatics() {
        instanceCount++;
    }
 
    public static int getInstanceCount() {
        return instanceCount;
    }
}
 
class Test {
 
    public static void main(String[] args) {
        System.out.println(ClassWithStatics.getInstanceCount()); // 0
 
        ClassWithStatics instance1 = new ClassWithStatics();
        ClassWithStatics instance2 = new ClassWithStatics();
        ClassWithStatics instance3 = new ClassWithStatics();
 
        System.out.println(ClassWithStatics.getInstanceCount()); // 3
    }
}

3. Definirea unor constante globale

Constantele globale pot fi folosite oriunde în cod și ne pot ajuta să economisim memorie:

public class MathUtils {
   public final static float EULER = 0.57721f;
   public final static float PI = 3.1415f;
   public final static float GOLDEN_RATIO = 1.61803;
   ...
}

Folosiți static pentru constante, contoare, configurații globale sau metode utilitare (ex. Math.sqrt()). Nu folosiți foarte des static, deoarece poate strica încapsularea ceea ce va face codul mai greu de testat.

Metode statice

O metodă statică este o metodă care aparține clasei și poate fi apelată fără a crea un obiect al clasei, având acces doar la membri statici.

class Apple {
    public static final int SMALL = 0, MEDIUM = 1, LARGE = 2;
 
    public int size;
 
    // Funcționează
    public static String[] getAppleSizes() {
       return new String[] { "SMALL", "MEDIUM", "LARGE" };
    }
 
    // Eroare, "size" nu este static
    public static int getSize() {
       return size;
    }
}

Metodele statice pot accesa doar variabile statice, deoarece membrii statici sunt inițializați înainte de crearea instanțelor. Astfel, o variabilă care aparține instanței, cum este size, nu ar fi încă inițializată în momentul în care metoda statică getSize() este apelată.

Summary

  • POO în Java se bazează pe clase și obiecte; obiectele sunt instanțe ale claselor.
  • Obiectele se creează folosind constructori; constructorii sunt apelați la instanțiere.
  • Câmpurile (fields) stochează date; metodele definesc comportamente.
  • Accesul la câmpuri și metode se controlează cu private, protected și public.
  • Supraincărcarea metodelor (overloading) permite metode cu același nume, parametri diferiți.
  • this se folosește pentru a referi obiectul curent și a evita ambiguitățile.
  • Codul se organizează în clase și metode pentru claritate și modularitate.
  • Blocurile statice și inițializările statice sunt rulate la încărcarea clasei.
  • Tipurile primitive (int, float etc.) diferă de clasele wrapper (Integer, Float etc.).
  • Codul reutilizabil facilitează întreținerea proiectelor mari și scade redundanța.
  • Constructorii și metodele statice sunt executați la momente specifice în ciclul de viață al clasei.
  • Garbage Collector gestionează memoria automat, curățând obiectele neutilizate.
  • Destructori (finalize) pot fi folosiți pentru curățare înainte ca obiectul să fie colectat.
  • static permite variabile și metode comune pentru toate instanțele clasei, cu scenarii variate de utilizare.

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ă 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 (3p)

Să se creeze o clasă numită Complex, care are doi membri de tip int (real și imaginary), care vor fi de tip private. Realizați următoarele subpuncte:

  • să se creeze trei constructori: primul primește doi parametri de tip int (primul desemnează partea reală a unui număr complex, al doilea partea imaginară), al doilea nu primește niciun parametru și apelează primul constructor cu valorile 0 și 0, iar al treilea reprezinta un copy constructor, care primește ca parametru un obiect de tip Complex, care este copiat în obiectul this
  • să se scrie metode de tip getter și setter, prin care putem accesa membrii privați ai clasei
  • să se scrie o metodă de tip void numită addWithComplex, care primește ca parametru un obiect de tip Complex, prin care se adună numărul complex dat ca parametru la numărul care apelează funcția (adică la this)
  • să se scrie o metodă de tip void numită showNumber, prin care se afișează numărul complex astfel:
    • a + i * b, daca b >0
    • a - i * b, daca b<0
    • a, daca b = 0

Task 2 (2p)

Pe Code Devmind, aveți in clasa Student si Main două greșeli legate de referințe. Rolul vostru este să corectați aceste greșeli încât codul să aibă comportamentul dorit (există comentarii în cod despre modul cum ar trebui să se comporte).

Task 3 (3p)

Să se implementeze o clasă Point care să conțină:

  • un constructor care să primească cele două numere reale (de tip float) ce reprezintă coordonatele.
  • o metodă changeCoords() ce primește două numere reale și modifică cele două coordonate ale punctului.
  • o funcție de afișare a unui punct astfel: (x, y). Alternativ, în loc de o metodă de afișare puteți suprascrie toString().

Să se implementeze o clasă Polygon cu următoarele:

  • un constructor care preia ca parametru un singur număr “n” (reprezentând numărul de colțuri al poligonului) și alocă spațiu pentru puncte (un punct reprezentând o instanță a clasei Point).
  • un constructor care preia ca parametru un vector cu 2N numere reale, adică N perechi de puncte ce reprezintă colțurile unui poligon. Acest constructor apelează constructorul de la punctul de mai sus și completează vectorul de puncte cu cele n instanțe ale clasei Point obținute din parametrii primiți.
  • o funcție de afișare a unui poligon in care se afiseaza pe o linie toate punctele poligonului. (e.g: ”[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (7.0, 8.0), (9.0, 10.0)]”)

Task 4 (2p)

Sa se creeze clasa ContBancar care are urmatoarele campuri:

  • numele titularului de cont
  • sold-ul acestuia
  • dobanda anuala (intializat cu 0)

Hint: fiecare detinator de cont are aceeasi dobanda

Realizati urmatoarele:

  • un constructor care preia ca parametru numele titularului si sold-ul acestuia.
  • depositSold(int suma) care adauga suma la soldul curent
  • extractSold(int suma) care scade suma din soldul curent
  • setDobandaAnuala(double nouaDobanda) care seteaza noua dobanda hotarata de banca (campul de dobanda anuala reprezinta un procent) (Hint: atentie cum ati declarat campul de dobanda, dobanda anuala fiind comuna tuturor detinatorilor)
  • calculateDobanda() care intoarce valoarea dobanzii anuale individuale calculate pe baza formulei: “dobandaAnualaIndivid = sold * procentDobandaAnuala / 100”
  • completați metoda showData() pentru a afisa contul unei persoane sub forma: “Titular [NUME] are in cont [SUMA] RON si o dobanda de [dobanda].”

Referințe

poo-ca-cd/laboratoare/design-avansat-de-clase.1760384104.txt.gz · Last modified: 2025/10/13 22:35 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