Programowanie obiektowe

Wykład 9

Przykłady na podsumowanie

Przykład innego związku między klasami

import java.util.Objects;

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

    public Author(String name, int age) {
        this.name = name != null ? name : "";
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name != null ? name : "";
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

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

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Author author = (Author) o;
        return age == author.age &&
                Objects.equals(name, author.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}
import java.util.Objects;

public class Book {
    private Author author;
    private double price;

    public Book(Author author, double price) {
        this.author = author != null ? new Author(author.getName(), author.getAge()) : new Author("", 0);
        this.price = price;
    }

    public Author getAuthor() {
        return new Author(author.getName(), author.getAge());
    }

    public void setAuthor(Author author) {
        this.author = author != null ? new Author(author.getName(), author.getAge()) : new Author("", 0);
    }

    public double getPrice() {
        return price;
    }

    public void setPrice(double price) {
        this.price = price;
    }

    @Override
    public String toString() {
        return "Book{" +
                "author=" + author +
                ", price=" + price +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Book book = (Book) o;
        return Double.compare(book.price, price) == 0 &&
                Objects.equals(author, book.author);
    }

    @Override
    public int hashCode() {
        return Objects.hash(author, price);
    }
}
public class TestBook {

    public static void main(String[] args) {
        Author author = new Author("George Orwell", 46);
        Book book = new Book(author, 19.99);

        System.out.println(book);
        Author author1 = book.getAuthor();
        author1.setName("Adam Mickiewicz");
        System.out.println(book);

        Book sameBook = new Book(new Author("George Orwell", 46), 19.99);
        System.out.println("Are books equal? " + book.equals(sameBook));
        System.out.println("Hashcode of book: " + book.hashCode());
        System.out.println("Hashcode of sameBook: " + sameBook.hashCode());
    }
}

Wyjątki i obsługa błędów

Rodzaje błędów

  1. Błędy Kompilacji (Compile-Time Errors) To błędy wykrywane w trakcie kompilacji programu, zanim zostanie on uruchomiony. Obejmują:
  • Błędy składniowe: Związane z naruszeniem reguł składni języka Java, np. brak średnika na końcu instrukcji, niewłaściwe użycie nawiasów, błędy w deklaracji zmiennych itd.
  • Błędy typowania: Pojawiają się, gdy kod narusza reguły dotyczące typów danych, np. próba przypisania wartości typu String do zmiennej typu int.
  • Błędy związane z zakresem zmiennych: Występują, gdy próbuje się uzyskać dostęp do zmiennej poza jej zakresem (np. poza blokiem, w którym została zadeklarowana).
  1. Błędy Wykonania (Runtime Errors) To błędy, które pojawiają się podczas działania programu i zazwyczaj prowadzą do awarii programu. Obejmują:
  • Wyjątki: Są to błędy, które można obsłużyć w programie za pomocą bloków try-catch. Przykłady to NullPointerException (odwołanie do obiektu, którego wartość jest null), ArrayIndexOutOfBoundsException (odwołanie do indeksu tablicy poza jej zakresem), ArithmeticException (np. dzielenie przez zero).
  • Błędy systemowe: Są to poważniejsze problemy, które są trudne lub niemożliwe do obsłużenia przez program, np. OutOfMemoryError (brak dostępnej pamięci).
  1. Błędy Logiczne (Logical Errors) To błędy, które powodują, że program działa, ale nie daje oczekiwanych wyników. Są to najtrudniejsze do wykrycia i naprawienia błędy, ponieważ program “działa”, ale nie robi tego, co powinien. Przykłady obejmują:
  • Błędy w algorytmie: Nieprawidłowa logika lub procedura stosowana do rozwiązania problemu.
  • Nieprawidłowe przypisanie lub porównanie: Na przykład używanie operatora = (przypisanie) zamiast == (porównanie) w instrukcji warunkowej.
  1. Błędy Semantyczne Są to błędy, które powodują niezgodność kodu z logiką biznesową lub zadanymi wymaganiami, mimo że kod kompiluje się i wykonuje poprawnie.

  2. Błędy Środowiska Obejmują one problemy z konfiguracją środowiska uruchomieniowego Java (np. niewłaściwe ścieżki, brak wymaganych bibliotek) oraz problemy związane z platformą (np. różnice w zachowaniu programu na różnych systemach operacyjnych)

Klasyfikacja wyjątków

W Javie, wyjątki (exceptions) są klasyfikowane na podstawie różnych kryteriów, w tym ich zachowania w czasie wykonania programu i sposobu ich obsługi.

Główne kategorie to:

  1. Sprawdzone Wyjątki (Checked Exceptions) To wyjątki, które muszą być obsłużone (przechwycone lub zadeklarowane w sygnaturze metody) w kodzie programu. Są to wyjątki, które nie są wynikiem błędów programisty, ale raczej zewnętrznych okoliczności, na które programista nie ma wpływu. Przykłady to:
  • IOException: występuje podczas operacji wejścia/wyjścia, np. odczytu pliku.
  • SQLException: występuje w trakcie interakcji z bazami danych.
  • ClassNotFoundException: pojawia się, gdy próbujemy załadować klasę, która nie istnieje.
  1. Niesprawdzone Wyjątki (Unchecked Exceptions) To wyjątki, które nie muszą być jawnie obsługiwane w kodzie programu. W większości przypadków są one wynikiem błędów w kodzie. Do tej kategorii zaliczamy:
  • RuntimeException: Jest to klasa bazowa dla wielu wyjątków związanych z błędami, które pojawiają się podczas działania programu. Przykłady to:
    • NullPointerException: występuje, gdy odwołujemy się do metody lub zmiennej obiektu, który jest null.
    • ArrayIndexOutOfBoundsException: występuje, gdy próbujemy uzyskać dostęp do elementu tablicy poza jej zakresem.
    • IllegalArgumentException: wyrzucany, gdy metoda otrzymuje argument, który jest nieprawidłowy lub nieodpowiedni.
  • Error: Reprezentuje poważne błędy, które zazwyczaj nie powinny być obsługiwane przez aplikację. Są to sytuacje, które zazwyczaj są poza kontrolą programisty, np. OutOfMemoryError lub StackOverflowError.

Klasyfikacja na Podstawie Dziedziczenia

W Javie, wszystkie wyjątki i błędy są potomkami klasy Throwable. Ta klasa ma dwa bezpośrednie podklasy:

  • Exception: Klasa bazowa dla wyjątków, które mogą być obsłużone przez programistę.
  • Error: Reprezentuje poważne problemy, które raczej nie powinny być obsługiwane przez programistę, ponieważ zwykle oznaczają poważne problemy na poziomie systemu.

Jakie Wyjątki Obsługiwać?

  • Sprawdzone wyjątki: Zawsze wymagają obsługi. Należy je przechwycić za pomocą bloku try-catch lub zadeklarować w sygnaturze metody za pomocą throws.
  • Niesprawdzone wyjątki i błędy: Obsługa tych wyjątków zależy od kontekstu. Często są one wynikiem błędów programistycznych i powinny być eliminowane poprzez poprawienie kodu, a nie przez ich przechwytywanie.

Dobre Praktyki

  • Minimalizacja Obsługi Wyjątków: Obsługiwać tylko te wyjątki, które rzeczywiście można sensownie obsłużyć.
  • Specyficzne Obsługi: Zamiast łapać ogólny Exception, zawsze staraj się łapać najbardziej specyficzny wyjątek.
  • Czyste Przechwytywanie: Unikać przechwytywania Throwable, Error lub RuntimeException, chyba że jest to absolutnie konieczne i jesteś świadom konsekwencji.

Blok try-catch-finally

W Javie, blok try-catch-finally jest mechanizmem do obsługi wyjątków, umożliwiającym wykrycie i eleganckie zarządzanie błędami, które mogą wystąpić podczas wykonania programu. Każda część tego bloku ma swoją specyficzną rolę:

  1. Blok try Blok try zawiera kod, który może wygenerować wyjątek. Jest to kod, który jest “ryzykowny” i może powodować błędy w czasie wykonania, takie jak operacje wejścia/wyjścia, parsowanie danych, itp. Jeśli wewnątrz bloku try pojawi się wyjątek, wykonanie kodu w tym bloku jest natychmiast przerywane, a kontrola jest przekazywana do odpowiedniego bloku catch.

  2. Blok catch Blok catch służy do przechwycenia i obsługi wyjątków, które mogą zostać zgłoszone w bloku try. Można mieć wiele bloków catch po bloku try, z których każdy może przechwytywać różne typy wyjątków. Blok catch jest wykonany tylko wtedy, gdy typ wyjątku zgłoszonego w bloku try pasuje do typu wyjątku zadeklarowanego w bloku catch.

  3. Blok finally Blok finally jest opcjonalny i służy do wykonania kodu, który ma zostać wykonany niezależnie od tego, czy wyjątek został zgłoszony czy nie. Jest to idealne miejsce do umieszczenia kodu czyszczącego lub zamykającego, takiego jak zamknięcie połączenia z bazą danych lub plikiem. Nawet jeśli w bloku try lub catch wystąpi instrukcja return, blok finally zostanie wykonany przed opuszczeniem metody.

try {
    // kod, który może zgłosić wyjątek
} catch (SpecificExceptionType e) {
    // obsługa specyficznego wyjątku
} catch (AnotherExceptionType e) {
    // obsługa innego wyjątku
} finally {
    // kod, który zostanie wykonany niezależnie od wyjątku
}
public class DivisionExample {

    public static void main(String[] args) {
        int numerator = 10;
        int denominator = 0;
        double result = 0.0;

        try {
            result = divide(numerator, denominator);
        } catch (ArithmeticException e) {
            System.out.println("Wystąpił wyjątek: " + e.getMessage());
        } finally {
            System.out.println("Blok finally został wykonany.");
        }

        System.out.println("Wynik dzielenia: " + result);
    }

    public static double divide(int numerator, int denominator) {
        return numerator / denominator;
    }
}

Interfejsy

Interfejs

W Javie, interfejs jest fundamentalnym elementem programowania obiektowego, służącym do definiowania szkieletu metod, które klasa implementująca dany interfejs musi zrealizować. Interfejs jest podobny do klasy, ale z kilkoma kluczowymi różnicami i charakterystycznymi cechami.

  • Definicja: Interfejs definiuje metody bez ich implementacji. Jest to zbiór abstrakcyjnych metod (od Javy 8 mogą zawierać też metody domyślne i statyczne).
  • Implementacja: Klasy implementujące interfejs muszą dostarczyć konkretną implementację wszystkich jego abstrakcyjnych metod, chyba że są to klasy abstrakcyjne.
  • W przeciwieństwie do klas, interfejsy pozwalają na wielokrotną implementację, co oznacza, że klasa może implementować wiele interfejsów.
  • Metody: Wszystkie metody w interfejsie są domyślnie publiczne i abstrakcyjne. Od Javy 8 wprowadzono możliwość definiowania metod domyślnych (z implementacją) i statycznych.
  • Pola: Wszystkie pola w interfejsie są domyślnie publiczne, statyczne i finalne (konstanty).
  • Definiowanie Kontraktu: Interfejs definiuje kontrakt, którego muszą przestrzegać klasy go implementujące. Dzięki temu inne klasy mogą polegać na metodach zdefiniowanych przez interfejs, niezależnie od konkretnego obiektu, który je implementuje.
  • Oddzielenie Implementacji od Abstrakcji: Umożliwia projektowanie systemów, w których sposób działania jest odseparowany od jego implementacji.
  • Wielokrotne Dziedziczenie Typu: W Javie nie ma wielokrotnego dziedziczenia klas, ale interfejsy pozwalają na coś podobnego dla dziedziczenia typów.
public interface Vehicle {
    void drive();
}

public class Car implements Vehicle {
    @Override
    public void drive() {
        System.out.println("Samochód jedzie.");
    }
}

public class Bike implements Vehicle {
    @Override
    public void drive() {
        System.out.println("Rower jedzie.");
    }
}

Od Javy 8 - Metody Domyślne i Statyczne

Od Javy 8, interfejsy mogą zawierać metody domyślne z implementacją, co pozwala na dodawanie nowych funkcji do interfejsów bez naruszania istniejących implementacji. Mogą także zawierać metody statyczne.

Interfejsy są kluczowym elementem programowania w Javie, umożliwiającym tworzenie bardziej modularnych i elastycznych aplikacji.

public class Main {

    public static void main(String[] args) {
        Car car = new Car();

        car.drive(); // Wywołanie metody abstrakcyjnej
        car.turnOnLights(); // Wywołanie metody domyślnej

        Vehicle.horn(); // Wywołanie metody statycznej interfejsu
    }
}


interface Vehicle {

    // Metoda abstrakcyjna
    void drive();

    // Metoda domyślna
    default void turnOnLights() {
        System.out.println("Światła włączone.");
    }

    // Metoda statyczna
    static void horn() {
        System.out.println("Trąbienie!");
    }
}


class Car implements Vehicle {

    @Override
    public void drive() {
        System.out.println("Samochód jedzie.");
    }
}

Pola w interfejsie?

Od Javy 8, interfejsy mogą zawierać także pola. Pola w interfejsie są zawsze statyczne i finalne, co oznacza, że są to stałe. Wartość tych pól musi być przypisana w momencie deklaracji. Nie można ich modyfikować.

interface MyInterface {
    // Static and final field
    int MAX_AGE = 100;

    // Abstract method
    void showAge();

    // Default method
    default void showMaxAge() {
        System.out.println("Maximum age is: " + MAX_AGE);
    }
}

public class MyClass implements MyInterface {
    private int age;

    public MyClass(int age) {
        this.age = age;
    }

    @Override
    public void showAge() {
        System.out.println("Age: " + age);
    }

    public static void main(String[] args) {
        MyClass person = new MyClass(30);
        person.showAge();
        person.showMaxAge();
        System.out.println("Maximum age is: " + MyInterface.MAX_AGE);
    }
}

Modyfikatory

W interfejsach wszystkie metody są domyślnie publiczne, więc deklarowanie ich jako public jest opcjonalne, ale często stosowane dla lepszej czytelności. Pola w interfejsie są zawsze publiczne, statyczne i finalne, więc nie trzeba ich jawnie oznaczać jako public, static czy final.

Wszystkie metody w interfejsie, które nie są domyślne (default) ani statyczne, są automatycznie uważane za abstrakcyjne. W Javie 8 wprowadzono możliwość definiowania metod domyślnych i statycznych, które nie są abstrakcyjne. Używanie słowa kluczowego abstract przy metodach w interfejsie jest więc zbędne i nie jest stosowane.

Prywatne metody w interfejsie?

Od Javy 9 wprowadzono możliwość definiowania prywatnych metod w interfejsach. Prywatne metody w interfejsach umożliwiają enkapsulację wspólnej logiki, która jest wykorzystywana tylko wewnątrz danego interfejsu, nie będąc dostępną dla klas, które go implementują.

public interface MyInterface {

    // Prywatna metoda niestatyczna
    private void privateMethod() {
        // Implementacja metody
        System.out.println("To jest prywatna metoda w interfejsie.");
    }

    // Prywatna metoda statyczna
    private static void privateStaticMethod() {
        // Implementacja metody
        System.out.println("To jest prywatna statyczna metoda w interfejsie.");
    }

    // Metoda domyślna wykorzystująca prywatną metodę
    default void defaultMethod() {
        privateMethod();
    }

    // Metoda statyczna wykorzystująca prywatną statyczną metodę
    static void staticMethod() {
        privateStaticMethod();
    }
}

Kiedy używać?

  1. Unikanie duplikacji kodu: Prywatne metody w interfejsach umożliwiają wyodrębnienie wspólnej logiki używanej przez metody domyślne (default) i/lub statyczne. Dzięki temu można uniknąć powielania tego samego kodu w wielu metodach, co jest zgodne z zasadą DRY (Don’t Repeat Yourself).

  2. Enkapsulacja logiki wewnętrznej: Prywatne metody w interfejsie pozwalają na ukrycie szczegółów implementacyjnych, które nie są istotne dla konsumentów interfejsu. Pozwala to na utrzymanie czystości API i zapewnia lepszą separację odpowiedzialności.

  3. Ułatwienie utrzymania i rozwoju: Kiedy logika jest skoncentrowana w prywatnych metodach, modyfikacje tej logiki są łatwiejsze do przeprowadzenia i mają mniejszy wpływ na klasy implementujące interfejs, co ułatwia utrzymanie i rozwój kodu.

  4. Poprawa czytelności i organizacji kodu: Użycie prywatnych metod pozwala na lepszą organizację kodu w interfejsach, co przekłada się na lepszą czytelność i łatwość zrozumienia struktury interfejsu.

Jednakże, warto pamiętać o kilku ograniczeniach i najlepszych praktykach:

  • Ograniczony dostęp: Prywatne metody są dostępne tylko wewnątrz interfejsu, w którym są zdefiniowane. Nie mogą być wykorzystywane przez klasy implementujące interfejs ani inne interfejsy.
  • Używaj z umiarem: Chociaż prywatne metody są przydatne, ważne jest, aby nie przesadzać z ich ilością i złożonością. Interfejsy powinny pozostać skoncentrowane na definiowaniu kontraktu, a nie na zbyt szczegółowej implementacji.