Programowanie obiektowe

Wykład 6

Statyczność

Pola statyczne

Pola statyczne, często nazywane również zmiennymi klasowymi, są zmiennymi, które są wspólne dla wszystkich instancji klasy. Oznacza to, że jedna kopia pola statycznego istnieje niezależnie od liczby obiektów stworzonych z tej klasy. Każda instancja klasy ma dostęp do tego samego pola statycznego i każda modyfikacja tego pola przez jedną instancję jest widoczna dla wszystkich pozostałych instancji.

Deklaracja Pola Statycznego

Pole statyczne deklarowane jest w klasie z użyciem słowa kluczowego static.

class MyClass {
    private static int staticField;
}

  • Współdzielenie: Ponieważ pole statyczne jest współdzielone między wszystkimi instancjami klasy, jest ono używane do reprezentowania informacji, które mają zastosowanie do całej klasy, a nie do pojedynczych obiektów.
  • Inicjalizacja: Pola statyczne są inicjalizowane podczas ładowania klasy, a nie podczas tworzenia nowego obiektu. Można je również inicjalizować w statycznych blokach inicjalizacyjnych.
  • Dostęp: Dostęp do pola statycznego jest możliwy zarówno za pośrednictwem nazwy klasy, jak i instancji klasy, ale zalecany jest dostęp za pośrednictwem nazwy klasy.
  • Cykl życia: Pole statyczne istnieje od momentu, gdy klasa jest ładowana przez ClassLoader, do momentu zakończenia działania programu lub do momentu, gdy klasa jest wyładowana, co może nastąpić, jeśli ClassLoader, który załadował klasę, zostanie usunięty z pamięci.

Częste zastosowania

  • Liczniki: Pola statyczne są często używane do śledzenia liczby instancji danej klasy, które zostały utworzone.
  • Stałe: Zmienne statyczne, które są również oznaczone jako final, są traktowane jako stałe (np., static final int MAX_SIZE = 100;) i są często pisane wielkimi literami. Te stałe są wspólne dla wszystkich instancji.
  • Singleton: Wzorzec projektowy Singleton używa zmiennej statycznej do przechowywania jednej instancji klasy.

Przykład

class Book{
    private static int id;
    
}

Metody statyczne

Metody statyczne, znane także jako metody klasowe, to takie, które są zdefiniowane z użyciem słowa kluczowego static. Są one związane z klasą, w której zostały zdefiniowane, a nie z konkretnymi instancjami tej klasy. Oznacza to, że metoda statyczna może być wywołana bez tworzenia obiektu danej klasy.

Własności:

  1. Przynależność do klasy: Metoda statyczna należy do klasy, a nie do instancji tej klasy.

  2. Wywołanie: Metodę statyczną można wywołać bezpośrednio na klasie, np. ClassName.staticMethodName().

  3. Dostęp do pól statycznych: Metody statyczne mogą bezpośrednio dostępować inne statyczne składowe (pola i metody) danej klasy.

  1. Brak dostępu do pól niestatycznych: Metoda statyczna nie może dostępować niestatycznych pól (instancyjnych) ani wywoływać niestatycznych metod bezpośrednio, ponieważ te składowe wymagają referencji do konkretnego obiektu.

  2. Używanie: Są one często używane jako metody pomocnicze (np. metody fabrykujące, metody utilitarne, metody obliczeniowe).

  3. Przesłanianie: Metody statyczne nie mogą być przesłaniane w taki sposób jak metody instancyjne. Jeśli podklasa definiuje statyczną metodę o tej samej sygnaturze co statyczna metoda w klasie bazowej, to metoda w podklasie ukrywa metodę w klasie bazowej.

public class MathUtils {

    // Statyczna metoda pomocnicza
    public static int add(int a, int b) {
        return a + b;
    }
}

Zastosowania metod statycznych:

  1. Metody fabrykujące: Metody statyczne są często używane do tworzenia instancji obiektów, kiedy chcemy dostarczyć użytkownikowi alternatywę dla konstruktorów z dodatkową logiką.

  2. Narzędzia: W przypadku narzędzi matematycznych, stringowych lub związanych z plikami, metody statyczne są używane do dostarczania funkcjonalności, która nie jest powiązana z konkretnymi instancjami klas.

  3. Stałe konfiguracyjne: Czasami statyczne metody są używane do dostępu do zmiennych konfiguracyjnych, które są wspólne dla wszystkich instancji klasy.

public class TestCounter {

    public static void main(String[] args) {
        Counter c1 = new Counter();
        Counter c2 = new Counter();
        Counter c3 = new Counter();
        System.out.println(Counter.getCount());
    }
}

class Counter {
    private static int count = 0;

    public Counter() {
        count++;
    }

    public static int getCount() {
        return count;
    }
}

Metody fabrykujące

public class TestVehicle{
    public static void main(String[] args) {
        Vehicle car = new Vehicle("Bus");
        Vehicle truck = Vehicle.createTruck();

        System.out.println(car.getType());
        System.out.println(truck.getType());
    }
}

class Vehicle {

    private String type;

    public Vehicle(String type) {
        this.type = type;
    }

    public static Vehicle createCar() {
        return new Vehicle("Car");
    }

    public static Vehicle createTruck() {
        return new Vehicle("Truck");
    }

    public String getType() {
        return type;
    }
}

Prywatny konstruktor?

Singleton

public class TestSingleton {
    public static void main(String[] args) {
        // Pobranie instancji Singleton
        Singleton singleton = Singleton.getInstance();
        Singleton singleton2 = Singleton.getInstance();
    }
}

class Singleton {

    // Jedyna instancja tej klasy
    private static Singleton instance;

    // Prywatny konstruktor
    private Singleton() {
        // inicjalizacja
    }

    // Publiczna metoda dostępowa do instancji
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

Statyczne bloki inicjujące

Statyczne bloki inicjujące są specjalnymi blokami, które służą do inicjalizacji zmiennych statycznych lub wykonywania operacji, które muszą zostać przeprowadzone tylko raz, gdy klasa jest po raz pierwszy załadowana.

public class MyClass {
    static {
        // kod wykonywany podczas ładowania klasy
    }
}

Kod w statycznym bloku inicjującym jest wykonany tylko raz, niezależnie od liczby obiektów klasy, które są tworzone. Wykonywany jest w momencie, gdy klasa jest pierwszy raz używana w programie, co oznacza, że może to być spowodowane stworzeniem pierwszej instancji klasy, odwołaniem do statycznej zmiennej klasy lub wywołaniem statycznej metody klasy.

Statyczne bloki inicjujące są użyteczne, gdy inicjalizacja zmiennych statycznych wymaga więcej niż jednej linii kodu lub kiedy inicjalizacja jest złożona i wymaga wykonania logiki, która nie mogłaby być wykonana w linii deklaracji zmiennej.

public class TestConfiguration {
    public static void main(String[] args) {
        Configuration.displayConfig();
    }
}


class Configuration {
    public static final int CONFIG_FEATURE;

    static {
        // Można tutaj wykonywać złożone operacje inicjalizacyjne
        CONFIG_FEATURE = initializeConfiguration();
    }

    private static int initializeConfiguration() {
        // Tutaj mogłoby być pobieranie danych z pliku konfiguracyjnego lub innego źródła
        // Na potrzeby przykładu, po prostu zwracamy wartość 42
        return 42;
    }

    public static void displayConfig() {
        System.out.println("Wartość konfiguracji: " + CONFIG_FEATURE);
    }
}

Import statyczny

Statyczne importy umożliwiają bezpośrednie odwołanie się do statycznych członków (pól i metod) klasy bez konieczności kwalifikowania ich nazwy za pomocą nazwy klasy. To znacznie upraszcza kod w sytuacjach, gdy potrzebujesz wielokrotnie korzystać z tych samych statycznych metod lub pól w wielu miejscach swojego kodu.

Na przykład, jeśli często używasz metody Math.sqrt() w swoim kodzie, zamiast pisać Math.sqrt(x) za każdym razem, gdy chcesz obliczyć pierwiastek kwadratowy, możesz zaimportować statycznie tę metodę i używać sqrt(x) bezpośrednio.

import static java.lang.Math.sqrt;

public class Calculator {
    public double calculateHypotenuse(double a, double b) {
        return sqrt(a * a + b * b);
    }
}
import static java.lang.Math.PI;

public class Circle {
    public double calculateArea(double radius) {
        return PI * radius * radius;
    }
}
import static java.lang.Math.*;

public class Calculator {
    public double calculateCircleCircumference(double radius) {
        return 2 * PI * radius;
    }

    public double calculateCircleArea(double radius) {
        return PI * pow(radius, 2);
    }
}
  1. Czytelność: Nadużywanie statycznych importów może uczynić kod mniej czytelnym, ponieważ może nie być od razu jasne, z której klasy pochodzi dany element (metoda lub pole).

  2. Konflikty: Jeśli importujesz statycznie członki z różnych klas, które mają takie same nazwy, możesz napotkać na konflikty, które będziesz musiał rozwiązać, kwalifikując nazwy klasą.

  3. Praktyki: Zazwyczaj zaleca się, aby używać statycznych importów umiarkowanie i tylko wtedy, gdy jest to uzasadnione, na przykład w przypadku stałych dobrze znanych w całym projekcie lub gdy jasne jest, skąd pochodzi importowana metoda lub pole.

Dziedziczenie i polimorfizm

Dziedziczenie

Dziedziczenie to jedna z podstawowych koncepcji programowania obiektowego, w tym także w Javie. Jest to mechanizm, za pomocą którego jedna klasa (nazywana klasą pochodną, podklasą lub klasą dziecka) może dziedziczyć pola i metody innej klasy (nazywanej klasą bazową, nadklasą lub klasą rodzica). Dziedziczenie pozwala na ponowne wykorzystanie kodu i ustanowienie hierarchii między klasami.

Podstawowe aspekty dziedziczenia w Javie:

  1. Jedno dziedziczenie: W Javie klasa może dziedziczyć bezpośrednio tylko po jednej klasie. Wiele języków programowania, w tym Java, stosuje jedno dziedziczenie, aby uniknąć problemów związanych z dziedziczeniem wielokrotnym, takich jak tzw. problem diamentu.

  2. Wielopoziomowe dziedziczenie: Mimo że Java nie wspiera wielokrotnego dziedziczenia bezpośrednio (klasa pochodna może mieć tylko jedną klasę bazową), pozwala na tworzenie hierarchii klas, w której klasa może dziedziczyć po klasie, która sama dziedziczy po innej klasie, itd. To umożliwia tworzenie bardziej złożonych hierarchii.

  1. super: Słowo kluczowe super w Javie odnosi się do bezpośredniego rodzica klasy. Może być użyte do wywołania konstruktora klasy nadrzędnej lub dostępu do jej metod i pól, które zostały ukryte przez klasy potomne.

  2. Nadpisywanie metod: Jeśli klasa pochodna definiuje metodę, która istnieje już w klasie bazowej, mówi się, że metoda w klasie pochodnej “nadpisuje” metodę z klasy bazowej. To pozwala na dostosowanie lub rozszerzenie zachowania klasy bazowej.

  1. final: Słowo kluczowe final może być użyte w kontekście klasy lub metody. Jeśli klasa jest oznaczona jako final, nie może być rozszerzona (innymi słowy, nie można po niej dziedziczyć). Jeśli metoda jest oznaczona jako final, nie może być nadpisywana/przesłaniana przez klasy potomne.

  2. Klasy abstrakcyjne i interfejsy: W Javie klasy abstrakcyjne i interfejsy pozwalają na określenie metod, które muszą być zaimplementowane przez klasy pochodne, oferując bardziej elastyczny sposób tworzenia kontraktów między klasami.

//klasa testująca
public class TestVehicle {
    public static void main(String[] args) {
        Car myCar = new Car();
        myCar.display();
    }
}
// Klasa bazowa
class Vehicle {
    public void display() {
        System.out.println("To jest pojazd.");
    }
}

// Klasa pochodna
class Car extends Vehicle {
    @Override
    public void display() {
        super.display();  // Wywołanie metody z klasy bazowej
        System.out.println("To jest samochód.");
    }
}

Nadpisywanie a przesłanianie (ang. override)

W języku Java pojęcie nadpisywania i przesłaniania często stosowane jest zamiennie, choć nie jest to w pełni poprawne dla osób zajmującą się teorią programowania. W innych językach np. w C# są istotne różnice. Nadpisanie/przesłanianie często bywa mylone z przeciążeniem.

Dokładniej później.

Klasa Object

Klasa Object jest najbardziej fundamentalną klasą w hierarchii klas. Każda klasa w Javie domyślnie dziedziczy z klasy Object, jeśli nie jest zadeklarowana jako podklasa innej klasy. To oznacza, że Object jest superklasą dla wszystkich innych klas.

Dziedziczenie z klasy Object zapewnia kilka podstawowych metod, które są wspólne dla wszystkich obiektów w Javie.

Wybrane metody:

  1. toString(): Zwraca reprezentację ciągu znaków danego obiektu. Jeśli nie jest nadpisana, domyślna implementacja zwraca nazwę klasy, a następnie znak ‘@’ i nieformatowany adres hash obiektu.

  2. equals(Object obj): Określa, czy dwa obiekty są “równe”. Standardowa implementacja porównuje referencje, aby zobaczyć, czy wskazują one na ten sam obiekt. Często jest nadpisywana, aby umożliwić porównanie stanu obiektów.

  1. hashCode(): Zwraca wartość hashcode obiektu, która jest używana przez struktury danych oparte na hashowaniu, takie jak HashMap. Jeśli metoda equals() jest nadpisana, hashCode() również powinna być nadpisana w taki sposób, aby dwa “równe” obiekty miały ten sam kod hash.

  2. getClass(): Zwraca obiekt Class, który reprezentuje klasę bieżącego obiektu. Może być używany do uzyskania informacji o klasie w runtime.

  3. clone(): Domyślnie zwraca płytką kopię obiektu. Jednak aby można było skorzystać z tej metody, klasa musi implementować interfejs Cloneable, a metoda musi być nadpisana, ponieważ jest protected.

  1. finalize(): Jest wywoływana przez Garbage Collector przed usunięciem obiektu, gdy już nie jest dostępny. Jest to jednak metoda rzadko używana, ponieważ nie ma gwarancji, kiedy (a nawet czy w ogóle) będzie wywołana.

  2. notify(), notifyAll(), i wait(): Są to metody, które pozwalają na współpracę wątków na jednym obiekcie.

Klasa Object jest wykorzystywana, gdy chcemy mieć referencje do obiektów nieznanego typu lub chcemy zaimplementować metody, które są w stanie działać na dowolnym obiekcie, na przykład:

public void printObjectDetails(Object obj) {
    System.out.println(obj.toString());
}

Polimorfizm

Innymi słowy to “wielopostaciowość”.

Polimorfizm to koncepcja pozwalająca na traktowanie obiektów różnych klas jako obiektów klasy bazowej lub interfejsu. Dzięki temu można używać tych obiektów w bardziej ogólnym kontekście, niezależnie od ich rzeczywistych typów klas.

Metoda toString()

Metoda toString() jest używana do dostarczenia reprezentacji tekstowej obiektu. Kiedy tworzysz klasę, możesz nadpisać tę metodę, aby zwrócić ciąg znaków, który opisuje obiekt w przydatny sposób. Jest to szczególnie przydatne podczas debugowania, gdy chcemy szybko zobaczyć ważne informacje o stanie obiektu.

Zasady

  1. Sygnatura: Metoda toString() musi być publiczna, nie przyjmować żadnych argumentów, i musi zwracać String.

  2. Nadpisywanie: Aby nadpisać metodę toString(), należy użyć adnotacji @Override. Dzięki temu kompilator Java będzie w stanie sprawdzić, czy faktycznie nadpisujesz metodę z klasy bazowej.

  3. Reprezentacja: Zwrócony ciąg znaków powinien być zwięzły, ale wystarczająco informatywny, aby przedstawić najważniejsze informacje o obiekcie.

  1. Spójność: Jeśli nadpisujesz również equals(), zwracany ciąg powinien zawierać wszystkie informacje, które są używane do porównania obiektów (dobra praktyka).

  2. Bezpieczeństwo: Nie należy w toString() uwzględniać wrażliwych informacji, które mogłyby prowadzić do problemów z bezpieczeństwem, takich jak hasła, klucze prywatne itp.

class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

Adnotacja @Override

Adnotacja @Override jest mechanizmem służącym do wskazania, że dana metoda ma na celu nadpisanie metody z klasy bazowej. Używanie tej adnotacji nie jest wymagane, aby nadpisać metodę, ale jest to uznana praktyka programistyczna.

  1. Poprawność kodu: Kiedy używasz adnotacji @Override, kompilator sprawdza, czy metoda rzeczywiście nadpisuje metodę z klasy bazowej. Jeśli nie, otrzymasz błąd kompilacji. To zapobiega błędom wynikającym z niepoprawnych sygnatur metod (np. złe nazwy, typy parametrów).

  2. Czytelność kodu: Adnotacja @Override czyni intencje programisty jaśniejszymi dla osób czytających kod. Kiedy widzisz tę adnotację, wiesz, że metoda ma być wersją metody z klasy bazowej, zamiast być nową metodą.

  1. Refaktoryzacja: Gdy klasa bazowa jest modyfikowana, np. metoda jest usuwana lub zmieniana, używanie @Override pomaga zidentyfikować miejsca, które mogą wymagać aktualizacji. Kompilator może wygenerować błędy dla metod, które nie są już prawidłowymi nadpisaniami.

  2. Dokumentacja: Adnotacja @Override służy jako część wewnętrznej dokumentacji kodu, wskazując, że dana metoda jest powiązana z hierarchią dziedziczenia klasy.

  1. Zapobieganie błędom: Pomaga zapobiegać błędom spowodowanym przez przeciążanie (overloading) zamiast nadpisywania. Jeśli programista chciał nadpisać metodę, ale zamiast tego stworzył nową metodę o innej sygnaturze, adnotacja @Override spowoduje błąd kompilacji, sygnalizując problem.

Samodzielnie definiowanie klas pochodnych

public class TestVehicle {

    public static void main(String[] args) {
        Car myCar = new Car("Toyota", 4);

        myCar.displayBrand();  // Dziedziczone z klasy Vehicle
        myCar.displayDoors();  // Specyficzne dla klasy Car
        myCar.drive();         // Przesłonięta z klasy Vehicle
    }
}

class Vehicle {
    private String brand;

    // Konstruktor
    public Vehicle(String brand) {
        this.brand = brand;
    }

    // Metoda do wyświetlania informacji o marce
    public void displayBrand() {
        System.out.println("Brand: " + brand);
    }

    // Metoda do symulacji jazdy
    public void drive() {
        System.out.println("This vehicle is driving.");
    }
}

class Car extends Vehicle {
    private int numberOfDoors;

    // Konstruktor
    public Car(String brand, int numberOfDoors) {
        super(brand); // Wywołanie konstruktora klasy bazowej
        this.numberOfDoors = numberOfDoors;
    }

    // Metoda do wyświetlania liczby drzwi
    public void displayDoors() {
        System.out.println("Number of doors: " + numberOfDoors);
    }

    // Przesłonięcie metody drive
    @Override
    public void drive() {
        System.out.println("The car is driving smoothly.");
    }
}
  1. Używanie słowa kluczowego extends: Aby stworzyć klasę pochodną, należy użyć słowa kluczowego extends, po którym następuje nazwa klasy bazowej. Na przykład, class Dog extends Animal oznacza, że klasa Dog jest klasą pochodną klasy Animal.

  2. Dostęp do składowych klasy bazowej: Klasa pochodna dziedziczy wszystkie publiczne i chronione (protected) metody oraz zmienne klasy bazowej. Metody i zmienne prywatne klasy bazowej nie są dziedziczone.

  1. Nadpisywanie metod: Klasa pochodna może przedefiniować metody klasy bazowej. Jest to znane jako nadpisywanie metod (method overriding). Nadpisana metoda w klasie pochodnej musi mieć tę samą sygnaturę co metoda w klasie bazowej.

  2. Wywołanie konstruktora klasy bazowej: Konstruktor klasy bazowej nie jest dziedziczony, ale można go wywołać w klasie pochodnej za pomocą słowa kluczowego super. Jest to zwykle pierwsza operacja wykonywana w konstruktorze klasy pochodnej.

  1. Wywołanie metod nadpisanych: W kontekście polimorfizmu, metoda przesłonięta w klasie pochodnej zostanie wywołana na rzecz obiektu tej klasy, nawet jeśli obiekt jest traktowany jako instancja klasy bazowej.

  2. Dostęp do składowych klasy bazowej: Jeśli klasa pochodna przesłania pewne składowe klasy bazowej, nadal może uzyskać do nich dostęp za pomocą słowa kluczowego super.

  1. Używanie modyfikatorów dostępu: Modyfikatory dostępu (public, protected, private) odgrywają ważną rolę w definiowaniu, jak klasy pochodne mogą interakcjonować ze składowymi klasy bazowej.

  2. Zasada podstawienia Liskov: Klasa pochodna powinna być zastępcza dla swojej klasy bazowej, co oznacza, że obiekt klasy pochodnej powinien móc być używany wszędzie tam, gdzie oczekiwany jest obiekt klasy bazowej, bez wpływu na poprawność programu.

  1. Ograniczenia dziedziczenia: Należy pamiętać, że Java nie wspiera wielokrotnego dziedziczenia dla klas (klasa może mieć tylko jedną bezpośrednią klasę bazową), chociaż pozwala na implementowanie wielu interfejsów przez jedną klasę.

  2. Wybór między dziedziczeniem a kompozycją: W niektórych przypadkach kompozycja (definiowanie klas, które mają instancje innych klas, zamiast dziedziczyć z nich) może być preferowana nad dziedziczeniem, ponieważ oferuje większą elastyczność i luźniejsze powiązanie między klasami.

Nadpisywanie a przesłanianie

Tematyka “bardzo dyskusyjna”.

Terminy “nadpisywanie” (overriding) i “przesłanianie” (hiding) dla niektórych osób odnoszą się do różnych mechanizmów

Nadpisywanie (Overriding)

  • Kontekst: Nadpisywanie metody występuje, gdy klasa pochodna definiuje metodę, która ma tę samą sygnaturę (nazwę i parametry) co metoda w klasie bazowej.
  • Polimorfizm: Jest to forma polimorfizmu dynamicznego. Metoda nadpisana jest wybierana w czasie wykonania (runtime) na podstawie rzeczywistego typu obiektu.
  • Modyfikatory dostępu: Modyfikatory dostępu nadpisanej metody nie mogą być bardziej restrykcyjne niż metody w klasie bazowej. Na przykład, jeśli metoda w klasie bazowej jest publiczna, nadpisana metoda również musi być publiczna.
  • Metody instancji: Nadpisywanie dotyczy tylko metod instancji (nie statycznych).

Przesłanianie (Hiding)

  • Kontekst: Przesłanianie występuje, gdy klasa pochodna definiuje składową (może być to statyczna metoda lub zmienna), która ma tę samą nazwę co składowa w klasie bazowej.
  • Statyczny Kontekst: Przesłanianie dotyczy głównie statycznych metod i zmiennych. Metoda lub zmienna statyczna w klasie pochodnej przesłania metodę lub zmienną statyczną o tej samej nazwie w klasie bazowej.
  • Wybór Składowej: Składowa przesłonięta jest wybierana w zależności od typu referencji, a nie rzeczywistego typu obiektu. Oznacza to, że wybór odbywa się w czasie kompilacji (compile time), a nie wykonania.
  • Metody i zmienne statyczne: Przesłanianie dotyczy metod i zmiennych statycznych.

Przykład

class BaseClass {
    public static void staticMethod() {
        System.out.println("Static method in BaseClass");
    }

    public void instanceMethod() {
        System.out.println("Instance method in BaseClass");
    }
}

class DerivedClass extends BaseClass {
    public static void staticMethod() {
        System.out.println("Static method in DerivedClass");
    }

    @Override
    public void instanceMethod() {
        System.out.println("Instance method in DerivedClass");
    }
}

Modyfikatory dostępu a dziedziczenie

  1. Public - Klasa pochodna w dowolnym pakiecie może dziedziczyć publiczne metody i zmienne klasy bazowej.

  2. Protected - Klasa pochodna może dziedziczyć protected metody i zmienne klasy bazowej. Jest to często używane do zapewnienia większej kontroli dostępu niż public, ale jednocześnie udzielania dostępu klasom pochodnym.

  3. Default - Klasa pochodna zlokalizowana w tym samym pakiecie co klasa bazowa może dziedziczyć składowe default. Klasa pochodna z innego pakietu nie ma dostępu do tych składowych.

  4. Private - Klasa pochodna nie dziedziczy prywatnych metod i zmiennych klasy bazowej. Oznacza to, że te składowe nie są dostępne bezpośrednio w klasie pochodnej.

Dodatkowe uwagi

  • Konstruktory: Konstruktory nie są dziedziczone, ale modyfikator dostępu konstruktora klasy bazowej wpływa na to, jak klasy pochodne mogą być tworzone. Na przykład, jeśli konstruktor klasy bazowej jest private, nie można utworzyć klasy pochodnej.
  • Przesłanianie metod: Jeśli klasa pochodna przesłania metodę klasy bazowej, nowa metoda nie może mieć bardziej restrykcyjnego modyfikatora dostępu niż metoda oryginalna.
  • Polimorfizm: Modyfikatory dostępu mają znaczenie przy polimorfizmie, ponieważ determinują, które metody są dostępne, gdy używa się odniesienia do klasy bazowej do odwołania się do obiektu klasy pochodnej.

Wiązanie dynamiczne

Wiązanie dynamiczne odnosi się do procesu, w którym wywołanie metody dla obiektu jest rozwiązane (przypisane) podczas wykonywania programu, a nie w czasie kompilacji. Jest to kluczowy aspekt polimorfizmu w programowaniu obiektowym i występuje, gdy metoda jest nadpisana, a nie przesłonięta. Wiązanie dynamiczne pozwala na to, że metoda wywołana na obiekcie jest wybrana na podstawie rzeczywistego typu obiektu, a nie typu referencji.

public class TestAnimal {

    public static void main(String[] args) {
        Animal myAnimal = new Dog(); // Referencja typu Animal, obiekt typu Dog
        myAnimal.sound(); // Wiązanie dynamiczne; wywołuje metodę sound() z klasy Dog
    }
}


class Animal {
    public void sound() {
        System.out.println("Animal makes a sound");
    }
}


class Dog extends Animal {
    @Override
    public void sound() {
        System.out.println("Dog barks");
    }
}
import java.util.ArrayList;

public class TestShape {

    public static void main(String[] args) {
        ArrayList<Shape> shapes = new ArrayList<>();
        shapes.add(new Shape());
        shapes.add(new Circle());
        shapes.add(new Rectangle());

        for (Shape shape : shapes) {
            shape.draw(); // Wywołanie metody draw() odpowiedniej klasy
        }
    }
}

class Shape {
    public void draw() {
        System.out.println("Drawing a shape");
    }
}

class Circle extends Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a circle");
    }
}

class Rectangle extends Shape {
    @Override
    public void draw() {
        System.out.println("Drawing a rectangle");
    }
}

Finalne klasy

  • Gdy klasa jest oznaczona jako final, oznacza to, że nie można jej rozszerzyć (nie można utworzyć klas pochodnych).
  • public final class MyClass { }
  • Jest to użyteczne, gdy chcesz zapobiec dziedziczeniu, na przykład w celu zachowania niezmienności lub bezpieczeństwa. Klasa String w Javie jest przykładem finalnej klasy.

Finalne metody

  • Gdy metoda w klasie jest oznaczona jako final, oznacza to, że nie można jej nadpisać w klasie pochodnej.
  • public final void myMethod() { }
  • Używając final w metodzie, możesz zapobiec jej nadpisaniu, co może być przydatne, jeśli metoda zawiera kluczową logikę biznesową, której zmiana mogłaby zakłócić działanie programu.

Finalne pola

  • Oznaczenie pola jako final oznacza, że jego wartość można przypisać tylko raz, albo w momencie deklaracji, albo w konstruktorze.
  • private final int value = 100;
  • Finalne pola są szczególnie użyteczne w tworzeniu klas niezmiennych (immutable classes), gdzie raz ustawiona wartość pola nie powinna być zmieniana.

Dodatkowe Uwagi

  • Dziedziczenie a Final: Użycie final w klasach i metodach bezpośrednio wpływa na mechanizm dziedziczenia w Javie. Klasa oznaczona jako final nie może być rodzicem dla innych klas, a metoda oznaczona jako final nie może być nadpisana przez klasy pochodne.
  • Final a Konstruktor: Konstruktorów nie można oznaczyć jako final, ponieważ konstruktory nie są dziedziczone.
  • Final a Wydajność: W pewnych przypadkach, użycie final może przynieść drobne korzyści wydajnościowe, ponieważ kompilator może wykorzystać tę informację do optymalizacji kodu.
  • Final a Polimorfizm: Oznaczenie metody jako final może być użyteczne, gdy chcesz uniknąć ryzyka, że polimorfizm wprowadzi nieoczekiwane zachowania w klasach pochodnych.

Przepis na niemodyfikowalną klasę w Javie

  1. Klasa powinna być oznaczona jako final, aby zapobiec jej rozszerzeniu (dziedziczeniu), co mogłoby pozwolić na zmianę jej zachowania.
  2. Wszystkie pola w klasie powinny być oznaczone jako final, co oznacza, że ich wartości mogą być przypisane tylko raz.
  3. Klasa nie powinna zawierać setterów ani innych metod, które mogłyby zmienić stan obiektu po jego utworzeniu.
  4. Wszystkie wartości pól powinny być ustawiane wyłącznie przez konstruktor.
  5. Jeśli klasa zawiera pola, które są referencjami do innych obiektów, te obiekty również powinny być niezmienne. Jeśli nie jest to możliwe (np. jeśli są to kolekcje lub klasy zewnętrzne), klasa powinna zwracać kopie tych obiektów, a nie ich bezpośrednie referencje.
import java.util.List;
import java.util.ArrayList;

public final class MyImmutableClass {
    private final int value;
    private final String name;
    private final List<String> items;

    public MyImmutableClass(int value, String name, List<String> items) {
        this.value = value;
        this.name = name;
        this.items = new ArrayList<>(items); // Tworzenie kopii listy
    }

    public int getValue() {
        return value;
    }

    public String getName() {
        return name;
    }

    public List<String> getItems() {
        return new ArrayList<>(items); // Zwraca kopię listy, a nie oryginał
    }
}

Klasy abstrakcyjne

Klasa abstrakcyjna w Javie jest klasą, która nie może być bezpośrednio zainstancjonowana (nie można utworzyć jej obiektów) i jest przeznaczona do dziedziczenia przez inne klasy. Klasy abstrakcyjne często zawierają abstrakcyjne metody, które są metodami bez ciała (implementacji), zmuszając klasy pochodne do dostarczenia własnych implementacji tych metod. Są one używane do zapewnienia szablonu dla przyszłych klas, definiując wspólne interfejsy i funkcjonalności, które muszą być zaimplementowane w klasach pochodnych.

Własności klasy abstrakcyjnej

  • Nie może być zainstancjonowana: Nie można utworzyć obiektu klasy abstrakcyjnej bezpośrednio.
  • Może zawierać metody abstrakcyjne: Metody abstrakcyjne to metody bez ciała, które muszą być nadpisane w klasach pochodnych.
  • Może zawierać metody zaimplementowane: Klasy abstrakcyjne mogą mieć normalne metody z ciałem, które mogą być dziedziczone i wykorzystywane przez klasy pochodne.
  • Konstruktor: Klasy abstrakcyjne mogą posiadać konstruktory, które są wywoływane podczas tworzenia instancji klas pochodnych.

public class TestAnimal {
    public static void main(String[] args) {
        Animal myDog = new Dog("Rex");
        myDog.makeSound(); // Wywoła "Rex barks."
        myDog.eat();       // Wywoła "Rex is eating."
    }
}

abstract class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    // Metoda abstrakcyjna
    public abstract void makeSound();

    // Metoda nieabstrakcyjna
    public void eat() {
        System.out.println(name + " is eating.");
    }

    // Getter dla name
    public String getName() {
        return name;
    }
}

class Dog extends Animal {
    public Dog(String name) {
        super(name); // Wywołanie konstruktora klasy bazowej
    }

    @Override
    public void makeSound() {
        System.out.println(getName() + " barks.");
    }
}

Metoda equals

Metoda equals() w Javie jest używana do porównywania dwóch obiektów pod kątem równości. Standardowa implementacja equals() w klasie Object porównuje referencje, co oznacza, że dwa obiekty są uznawane za równe, jeśli wskazują na to samo miejsce w pamięci. W praktyce często chcemy porównywać obiekty na podstawie ich stanu wewnętrznego (wartości ich pól), co wymaga nadpisania metody equals().

Oto zasady tworzenia dobrze zaprojektowanej metody equals():

  1. Użycie słowa kluczowego @Override: Wskazuje, że metoda nadpisuje metodę z klasy bazowej.

  2. Sprawdzenie, czy obiekt nie jest porównywany sam do siebie: Jeśli tak, metoda powinna zwrócić true.

  3. Sprawdzenie, czy obiekt do porównania nie jest null i czy jest tego samego typu.

  1. Rzutowanie obiektu do porównania na odpowiedni typ: Po potwierdzeniu, że obiekty są tego samego typu, można bezpiecznie rzutować.

  2. Porównanie pól obiektów: Porównaj wszystkie istotne pola. Dla pól, które są typami prymitywnymi, użyj operatorów porównania (==). Dla obiektów użyj equals().

  3. Zwrócenie true, jeśli wszystkie porównania pól są równe: W przeciwnym razie zwróć false.

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object obj) {
        // Sprawdzenie, czy obiekty są tym samym obiektem
        if (this == obj) return true;

        // Sprawdzenie, czy obj nie jest null i czy jest typu Person
        if (obj == null || getClass() != obj.getClass()) return false;

        // Rzutowanie obj do typu Person
        Person person = (Person) obj;

        // Porównanie pól
        if (age != person.age) return false;
        return name != null ? name.equals(person.name) : person.name == null;
    }

}
  • Kontrakt equals() i hashCode(): Jeśli nadpisujesz equals(), musisz także nadpisać hashCode(). Kontrakt między tymi dwiema metodami mówi, że dwa obiekty uznane za równe przez equals() muszą zwracać tę samą wartość hashCode().
  • Null-safety: Używanie Objects.equals() z Java 7 i nowszych w celu uniknięcia NullPointerException podczas porównywania pól, które mogą być null.
  • Porównanie pól typu float i double: Dla pól typu float użyj Float.compare(float, float), a dla double - Double.compare(double, double), aby uniknąć problemów z porównywaniem liczb zmiennoprzecinkowych.
class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public boolean equals(Object obj) {
        // Sprawdzenie, czy obiekty są tym samym obiektem
        if (this == obj) return true;

        // Sprawdzenie, czy obj jest instancją Person i rzutowanie
        if (obj instanceof Person person) {
            // Porównanie pól
            if (age != person.age) return false;
            return name != null ? name.equals(person.name) : person.name == null;
        }

        return false;
    }

    // Gettery, settery, hashCode(), toString() itp.
}
class Employee extends Person {
    private String department;

    public Employee(String name, int age, String department) {
        super(name, age);
        this.department = department;
    }

    @Override
    public boolean equals(Object obj) {
        // Sprawdzenie równości z perspektywy klasy bazowej
        if (!super.equals(obj)) return false;

        // Rzutowanie obj do typu Employee
        Employee employee = (Employee) obj;

        // Porównanie dodatkowego pola
        return department != null ? department.equals(employee.department) : employee.department == null;
    }

    // Gettery, settery, hashCode(), toString() itp.
}

hashCode()

Metoda hashCode() jest fundamentalną częścią klasy Object i ma kluczowe znaczenie w działaniu kolekcji takich jak HashSet, HashMap i Hashtable, które wykorzystują hashowanie do efektywnego przechowywania i odzyskiwania obiektów.

  1. Metoda hashCode() zwraca wartość całkowitą, która jest używana jako kod hash obiektu. Ten kod hash służy do określenia miejsca obiektu w strukturze danych opartej na hashowaniu.

  2. Kontrakt hashCode():

    • Spójność: Wartość zwracana przez hashCode() musi pozostać stała dla danego obiektu podczas jego życia, o ile informacje użyte w porównaniach equals() na tym obiekcie nie ulegną zmianie.
    • Równość: Jeśli dwa obiekty są równe zgodnie z metodą equals(), wtedy ich kody hash zwrócone przez hashCode() również muszą być równe.
    • Nierówność: Jeśli dwa obiekty są nierówne zgodnie z equals(), preferowane jest, aby ich kody hash były różne. Jednak nie jest to wymóg i różne obiekty mogą mieć tę samą wartość hash (co prowadzi do tzw. kolizji hash).
@Override
public int hashCode() {
    int result = 17; // Można zacząć od dowolnej liczby niezerowej
    result = 31 * result + age;
    result = 31 * result + (name != null ? name.hashCode() : 0);
    return result;
}
@Override
public int hashCode() {
    return  super.hashCode() + (department != null ? department.hashCode() : 0);
}