Lekcja 27 (OOP 7): Klasy Abstrakcyjne i Metody Abstrakcyjne

Witaj w siódmej lekcji naszego modułu o Programowaniu Obiektowym w PHP! W poprzedniej lekcji szczegółowo omówiliśmy mechanizm dziedziczenia, który pozwala klasom potomnym przejmować i rozszerzać funkcjonalności klas bazowych. Dzisiaj wprowadzimy pojęcie klas abstrakcyjnych i metod abstrakcyjnych. Są to potężne narzędzia, które pozwalają na definiowanie pewnego rodzaju "szablonów" lub "kontraktów" dla klas potomnych, wymuszając na nich implementację określonych metod, jednocześnie pozwalając na dostarczenie pewnej wspólnej funkcjonalności już w klasie abstrakcyjnej.

Zrozumienie klas abstrakcyjnych jest kluczowe dla projektowania elastycznych i dobrze zorganizowanych hierarchii klas. Pozwalają one na osiągnięcie wyższego poziomu abstrakcji, definiując wspólny interfejs dla grupy powiązanych klas, bez konieczności dostarczania pełnej implementacji wszystkich metod w klasie bazowej. Dowiemy się, jak deklarować klasy i metody abstrakcyjne za pomocą słowa kluczowego abstract, jakie są ich właściwości i kiedy warto je stosować.

Czym Jest Klasa Abstrakcyjna?

Klasa abstrakcyjna to klasa, która nie może być bezpośrednio instancjonowana (nie można utworzyć jej obiektu za pomocą operatora new). Służy ona jako klasa bazowa (szablon) dla innych klas. Klasa abstrakcyjna może zawierać zarówno metody zaimplementowane (konkretne), jak i metody abstrakcyjne.

Kluczowe cechy klasy abstrakcyjnej:

Klasy abstrakcyjne są użyteczne, gdy chcemy zdefiniować wspólny szkielet dla grupy klas, ale pewne szczegóły implementacyjne muszą być dostarczone przez konkretne klasy potomne. Stanowią one kompromis między w pełni zaimplementowaną klasą bazową a interfejsem (o którym będziemy mówić w następnej lekcji), który definiuje tylko sygnatury metod bez żadnej implementacji.

Czym Jest Metoda Abstrakcyjna?

Metoda abstrakcyjna to metoda zadeklarowana w klasie abstrakcyjnej, która nie posiada ciała (implementacji). Definiuje ona jedynie sygnaturę metody (nazwę, parametry, typ zwracany) i musi być zaimplementowana (nadpisana) przez każdą konkretną (nieabstrakcyjną) klasę potomną, która dziedziczy po tej klasie abstrakcyjnej.

Kluczowe cechy metody abstrakcyjnej:

Metody abstrakcyjne służą do zdefiniowania "kontraktu", który muszą spełnić klasy potomne. Określają, jakie metody muszą być dostępne w obiektach tych klas, ale pozostawiają szczegóły ich działania do zaimplementowania przez te klasy.

Przykład Klasy Abstrakcyjnej i Metod Abstrakcyjnych

Rozważmy przykład z poprzedniej lekcji dotyczący figur geometrycznych. Możemy uczynić klasę FiguraGeometryczna abstrakcyjną, ponieważ sama "figura geometryczna" jest pojęciem abstrakcyjnym i nie ma sensu tworzyć jej obiektu bez określenia konkretnego kształtu. Metody obliczPole() i obliczObwod() są idealnymi kandydatami na metody abstrakcyjne, ponieważ każda konkretna figura będzie je obliczać inaczej.

<?php
// Abstrakcyjna klasa bazowa
abstract class FiguraGeometryczna
{
    protected string $nazwaFigury;
    public string $kolor;

    public function __construct(string $nazwa, string $kolor = "nieokreślony")
    {
        $this->nazwaFigury = $nazwa;
        $this->kolor = $kolor;
        echo "Tworzenie szablonu dla: " . $this->nazwaFigury . " o kolorze: " . $this->kolor . "<br>";
    }

    public function getNazwa(): string
    {
        return $this->nazwaFigury;
    }

    // Metoda konkretna, wspólna dla wszystkich figur
    public function wyswietlInfo(): void
    {
        echo "To jest " . $this->getNazwa() . ".<br>";
        echo "Kolor: " . $this->kolor . ".<br>";
        echo "Pole: " . round($this->obliczPole(), 2) . " jedn.<sup>2</sup><br>";
        echo "Obwód: " . round($this->obliczObwod(), 2) . " jedn.<br>";
    }

    // Metody abstrakcyjne - muszą być zaimplementowane przez klasy potomne
    abstract public function obliczPole(): float;
    abstract protected function obliczObwod(): float; // Może być protected, jeśli nie chcemy publicznego dostępu bezpośredniego
}

// Konkretna klasa potomna implementująca metody abstrakcyjne
class Kolo extends FiguraGeometryczna
{
    private float $promien;

    public function __construct(float $promien, string $kolor = "czerwony")
    {
        parent::__construct("Koło", $kolor); // Wywołanie konstruktora klasy bazowej
        $this->promien = $promien;
        echo "Utworzono obiekt Kolo o promieniu: " . $this->promien . "<br>";
    }

    // Implementacja metody abstrakcyjnej obliczPole
    public function obliczPole(): float
    {
        return M_PI * pow($this->promien, 2);
    }

    // Implementacja metody abstrakcyjnej obliczObwod
    // Modyfikator dostępu musi być taki sam lub mniej restrykcyjny (tutaj public >= protected)
    public function obliczObwod(): float 
    {
        return 2 * M_PI * $this->promien;
    }

    // Dodatkowa metoda specyficzna dla koła
    public function getPromien(): float
    {
        return $this->promien;
    }
}

class Prostokat extends FiguraGeometryczna
{
    private float $bokA;
    private float $bokB;

    public function __construct(float $a, float $b, string $kolor = "niebieski")
    {
        parent::__construct("Prostokąt", $kolor);
        $this->bokA = $a;
        $this->bokB = $b;
        echo "Utworzono obiekt Prostokat o bokach: " . $this->bokA . "x" . $this->bokB . "<br>";
    }

    public function obliczPole(): float
    {
        return $this->bokA * $this->bokB;
    }

    public function obliczObwod(): float
    {
        return 2 * ($this->bokA + $this->bokB);
    }
}

// Próba utworzenia obiektu klasy abstrakcyjnej - spowoduje błąd
// $figura = new FiguraGeometryczna("Jakaś figura"); // Fatal error: Cannot instantiate abstract class FiguraGeometryczna

$mojeKolo = new Kolo(7.0, "żółty");
$mojeKolo->wyswietlInfo();

echo "<hr>";

$mojProstokat = new Prostokat(5.0, 10.0);
$mojProstokat->wyswietlInfo();

// Możemy teraz łatwo dodać inną figurę, np. Trójkąt, która musi zaimplementować obliczPole i obliczObwod
/*
abstract class Wielokat extends FiguraGeometryczna { // Może być pośrednia klasa abstrakcyjna
    abstract public function liczbaBokow(): int;
}

class Trojkat extends FiguraGeometryczna { // lub Wielokat
    private float $podstawa, $wysokosc, $bok1, $bok2, $bok3;
    public function __construct(float $p, float $h, float $b1, float $b2, float $b3, string $kolor = "zielony") {
        parent::__construct("Trójkąt", $kolor);
        $this->podstawa = $p; $this->wysokosc = $h; $this->bok1 = $b1; $this->bok2 = $b2; $this->bok3 = $b3;
    }
    public function obliczPole(): float { return 0.5 * $this->podstawa * $this->wysokosc; }
    public function obliczObwod(): float { return $this->bok1 + $this->bok2 + $this->bok3; }
}

$trojkat = new Trojkat(10, 5, 7, 8, 6);
$trojkat->wyswietlInfo();
*/

?>

W tym przykładzie:

Kiedy Używać Klas Abstrakcyjnych?

Klasy abstrakcyjne są przydatne w następujących sytuacjach:

  1. Chcesz współdzielić kod między kilkoma blisko powiązanymi klasami: Jeśli wiele klas ma wspólną funkcjonalność (metody zaimplementowane, właściwości), ale także pewne aspekty, które muszą być specyficznie zaimplementowane przez każdą z nich, klasa abstrakcyjna jest dobrym wyborem. Pozwala umieścić wspólny kod w klasie bazowej i zdefiniować "szablony" dla metod specyficznych.
  2. Chcesz zdefiniować wspólny interfejs dla grupy klas, ale z częściową implementacją: Klasa abstrakcyjna może definiować, jakie publiczne metody będą miały jej klasy potomne (poprzez metody abstrakcyjne i konkretne), zapewniając pewien poziom spójności, jednocześnie dostarczając już część implementacji.
  3. Chcesz stworzyć klasę bazową, która sama w sobie nie ma sensu jako konkretny obiekt: Jak w przykładzie FiguraGeometryczna – ogólna figura nie istnieje, istnieją tylko konkretne kształty. Podobnie klasa Zwierze mogłaby być abstrakcyjna, jeśli nie chcemy tworzyć ogólnych "zwierząt", a jedynie konkretne gatunki.
  4. Chcesz kontrolować hierarchię dziedziczenia: Klasy abstrakcyjne mogą mieć konstruktory (nawet protected), co pozwala kontrolować, jak tworzone są obiekty klas potomnych i wymuszać przekazywanie określonych parametrów do konstruktora bazowego.

Klasy Abstrakcyjne vs Interfejsy

Klasy abstrakcyjne i interfejsy (które szczegółowo omówimy w następnej lekcji) służą do osiągania abstrakcji, ale robią to w różny sposób i mają różne zastosowania.

Główne różnice:

Cecha Klasa Abstrakcyjna Interfejs
Instancjonowanie Nie można utworzyć instancji Nie można utworzyć instancji
Metody z implementacją Może zawierać metody z implementacją (konkretne) Generalnie nie (od PHP 8.0 interfejsy mogą mieć domyślne implementacje metod, ale jest to rzadziej używane i ma inne cele)
Metody abstrakcyjne Może zawierać metody abstrakcyjne (bez implementacji) Wszystkie metody są domyślnie abstrakcyjne i publiczne (nie używa się słowa abstract)
Właściwości Może zawierać właściwości (zwykłe i statyczne) Może deklarować tylko publiczne stałe (const), nie może mieć właściwości instancyjnych.
Konstruktory Może mieć konstruktor Nie może mieć konstruktora
Dziedziczenie / Implementacja Klasa może dziedziczyć (extends) tylko po jednej klasie abstrakcyjnej (lub konkretnej) Klasa może implementować (implements) wiele interfejsów
Cel główny Definiowanie wspólnego szkieletu i współdzielenie kodu dla blisko powiązanych klas (relacja "jest rodzajem") Definiowanie kontraktu (zbioru metod), który muszą spełnić klasy, niezależnie od ich hierarchii dziedziczenia (relacja "potrafi zrobić")

W skrócie:

Często klasy abstrakcyjne same mogą implementować interfejsy, dostarczając częściową implementację metod wymaganych przez interfejs i pozostawiając resztę jako abstrakcyjne dla swoich potomków.

Ważne Zasady Dotyczące Metod Abstrakcyjnych

<?php
abstract class Baza
{
    abstract protected function przetworz(string $dane, bool $opcja = true): array;
}

class Potomek extends Baza
{
    // Poprawna implementacja
    public function przetworz(string $dane, bool $opcja = true): array
    {
        echo "Przetwarzam dane: {$dane} z opcją: " . ($opcja ? 'tak' : 'nie') . "<br>";
        return ['status' => 'ok', 'dane_przetworzone' => strtoupper($dane)];
    }
}

/*
class PotomekZly1 extends Baza {
    // BŁĄD: Modyfikator private jest bardziej restrykcyjny niż protected
    private function przetworz(string $dane, bool $opcja = true): array { return []; }
}

class PotomekZly2 extends Baza {
    // BŁĄD: Zmieniono typ zwracany na niekompatybilny (string zamiast array)
    public function przetworz(string $dane, bool $opcja = true): string { return ""; }
}

class PotomekZly3 extends Baza {
    // BŁĄD: Zmieniono typ parametru $dane na niekompatybilny (int zamiast string)
    public function przetworz(int $dane, bool $opcja = true): array { return []; }
}

class PotomekZly4 extends Baza {
    // BŁĄD: Dodano nowy wymagany parametr $dodatkowy
    public function przetworz(string $dane, bool $opcja = true, string $dodatkowy): array { return []; }
}
*/

$p = new Potomek();
$wynik = $p->przetworz("testowe dane");
print_r($wynik);
?>

Podsumowanie Lekcji

W tej lekcji nauczyliśmy się, czym są klasy abstrakcyjne i metody abstrakcyjne w PHP. Zrozumieliśmy, że klasy abstrakcyjne, deklarowane za pomocą słowa kluczowego abstract, nie mogą być instancjonowane i służą jako szablony dla klas potomnych. Mogą one zawierać zarówno metody zaimplementowane, jak i metody abstrakcyjne (bez ciała), które muszą być zaimplementowane przez konkretne klasy potomne.

Omówiliśmy kluczowe cechy i zasady dotyczące klas i metod abstrakcyjnych, w tym modyfikatory dostępu i zgodność sygnatur przy implementacji. Porównaliśmy również klasy abstrakcyjne z interfejsami, wskazując na ich główne różnice i typowe scenariusze użycia. Klasy abstrakcyjne są idealne do współdzielenia kodu i definiowania wspólnego szkieletu dla blisko powiązanych klas w relacji "is-a".

W następnej lekcji rozwiniemy temat abstrakcji, skupiając się na interfejsach i koncepcji polimorfizmu, które są kolejnymi fundamentalnymi elementami programowania obiektowego.


Zadanie praktyczne

Stwórz system do obsługi różnych typów powiadomień (email, SMS).

  1. Stwórz abstrakcyjną klasę Powiadomienie z:
    • Chronioną właściwością $odbiorca.
    • Chronioną właściwością $tresc.
    • Konstruktorem przyjmującym odbiorcę i treść.
    • Publiczną, konkretną metodą getTresc(): string zwracającą treść.
    • Publiczną, konkretną metodą getOdbiorca(): string zwracającą odbiorcę.
    • Abstrakcyjną publiczną metodą wyslij(): bool, która będzie odpowiedzialna za wysłanie powiadomienia.
  2. Stwórz konkretną klasę PowiadomienieEmail dziedziczącą po Powiadomienie:
    • Dodaj prywatną właściwość $tematEmail.
    • Konstruktor powinien przyjmować odbiorcę (adres email), temat i treść, i wywoływać konstruktor rodzica.
    • Zaimplementuj metodę wyslij(). W tej implementacji metoda powinna po prostu wyświetlać komunikat typu: "Wysyłanie emaila do [odbiorca] o temacie '[tematEmail]' z treścią: [tresc]" i zwracać true.
  3. Stwórz konkretną klasę PowiadomienieSMS dziedziczącą po Powiadomienie:
    • Konstruktor powinien przyjmować odbiorcę (numer telefonu) i treść, i wywoływać konstruktor rodzica.
    • Zaimplementuj metodę wyslij(). W tej implementacji metoda powinna po prostu wyświetlać komunikat typu: "Wysyłanie SMSa do [odbiorca] z treścią: [tresc]" i zwracać true.
  4. Przetestuj system: utwórz obiekty PowiadomienieEmail i PowiadomienieSMS, a następnie wywołaj na nich metodę wyslij().

Kliknij, aby zobaczyć przykładowe rozwiązanie
<?php
abstract class Powiadomienie
{
    protected string $odbiorca;
    protected string $tresc;

    public function __construct(string $odbiorca, string $tresc)
    {
        $this->odbiorca = $odbiorca;
        $this->tresc = $tresc;
    }

    public function getTresc(): string
    {
        return $this->tresc;
    }

    public function getOdbiorca(): string
    {
        return $this->odbiorca;
    }

    abstract public function wyslij(): bool;
}

class PowiadomienieEmail extends Powiadomienie
{
    private string $tematEmail;

    public function __construct(string $odbiorca, string $temat, string $tresc)
    {
        parent::__construct($odbiorca, $tresc);
        $this->tematEmail = $temat;
    }

    public function wyslij(): bool
    {
        echo sprintf(
            "Wysyłanie emaila do %s o temacie '%s' z treścią: %s<br>",
            $this->getOdbiorca(),
            $this->tematEmail,
            $this->getTresc()
        );
        return true;
    }
}

class PowiadomienieSMS extends Powiadomienie
{
    public function __construct(string $odbiorca, string $tresc)
    {
        parent::__construct($odbiorca, $tresc);
    }

    public function wyslij(): bool
    {
        echo sprintf(
            "Wysyłanie SMSa do %s z treścią: %s<br>",
            $this->getOdbiorca(),
            $this->getTresc()
        );
        return true;
    }
}

// Testowanie
$email = new PowiadomienieEmail("jan.kowalski@example.com", "Ważna informacja", "Treść ważnej informacji email.");
if ($email->wyslij()) {
    echo "Email został (symulacyjnie) wysłany.<br>";
}

echo "<hr>";

$sms = new PowiadomienieSMS("+48123456789", "Krótka wiadomość SMS.");
if ($sms->wyslij()) {
    echo "SMS został (symulacyjnie) wysłany.<br>";
}

/* Przykładowy wynik:
Wysyłanie emaila do jan.kowalski@example.com o temacie 'Ważna informacja' z treścią: Treść ważnej informacji email.
Email został (symulacyjnie) wysłany.
---
Wysyłanie SMSa do +48123456789 z treścią: Krótka wiadomość SMS.
SMS został (symulacyjnie) wysłany.
*/
?>

Zadanie do samodzielnego wykonania

Stwórz abstrakcyjną klasę Pracownik i konkretne klasy dziedziczące.

  1. Klasa Pracownik powinna być abstrakcyjna i zawierać:
    • Właściwości: protected string $imie, protected string $nazwisko, protected float $stawkaGodzinowa.
    • Konstruktor ustawiający te właściwości.
    • Metodę konkretną getDane(): string zwracającą imię i nazwisko.
    • Metodę abstrakcyjną obliczWynagrodzenieMiesieczne(int $liczbaGodzin): float.
  2. Stwórz klasę PracownikEtatowy dziedziczącą po Pracownik. W implementacji obliczWynagrodzenieMiesieczne załóż, że pracownik etatowy ma stałe wynagrodzenie miesięczne (np. 160 * stawkaGodzinowa), niezależnie od podanej liczby godzin (ale liczba godzin może być użyta do walidacji, np. czy nie jest za mała).
  3. Stwórz klasę PracownikGodzinowy dziedziczącą po Pracownik. W implementacji obliczWynagrodzenieMiesieczne wynagrodzenie jest liczone jako liczbaGodzin * stawkaGodzinowa.
  4. Stwórz klasę PracownikZleceniowy dziedziczącą po Pracownik. Dodaj mu właściwość private float $prowizjaOdSprzedazy (ustawianą w konstruktorze). W metodzie obliczWynagrodzenieMiesieczne wynagrodzenie to liczbaGodzin * stawkaGodzinowa + prowizjaOdSprzedazy (załóż, że $liczbaGodzin to przepracowane godziny, a prowizja jest dodatkiem).
  5. Przetestuj, tworząc obiekty różnych typów pracowników i obliczając ich wynagrodzenia.


FAQ - Klasy Abstrakcyjne i Metody Abstrakcyjne

Czy klasa abstrakcyjna może nie mieć żadnych metod abstrakcyjnych?

Tak, klasa może być zadeklarowana jako abstract nawet jeśli nie zawiera żadnych metod abstrakcyjnych. W takim przypadku głównym celem uczynienia jej abstrakcyjną jest uniemożliwienie tworzenia jej bezpośrednich instancji. Może ona nadal służyć jako klasa bazowa dostarczająca wspólną funkcjonalność.

Czy klasa abstrakcyjna może dziedziczyć po innej klasie abstrakcyjnej?

Tak, klasa abstrakcyjna może dziedziczyć po innej klasie abstrakcyjnej. W takim przypadku nie musi implementować metod abstrakcyjnych odziedziczonych z klasy nadrzędnej – obowiązek ten przechodzi na pierwszą konkretną (nieabstrakcyjną) klasę w hierarchii dziedziczenia.

Czy klasa abstrakcyjna może implementować interfejs?

Tak, klasa abstrakcyjna może implementować interfejs. Może ona dostarczyć implementacje niektórych (lub wszystkich) metod wymaganych przez interfejs, a te, których nie zaimplementuje, może zadeklarować jako abstrakcyjne, przerzucając obowiązek ich implementacji na swoje konkretne klasy potomne.

Jaka jest różnica między metodą abstrakcyjną a metodą w interfejsie?

Obie nie mają implementacji i definiują kontrakt. Jednak metody w interfejsach są domyślnie publiczne i abstrakcyjne (nie używa się słów kluczowych public ani abstract w ich deklaracji w interfejsie). Metody abstrakcyjne w klasie abstrakcyjnej muszą mieć jawnie podany modyfikator dostępu (public lub protected) i słowo kluczowe abstract. Ponadto, klasa abstrakcyjna może zawierać zaimplementowane metody i właściwości, a interfejs (generalnie) nie.

Czy metody abstrakcyjne mogą być statyczne?

Nie, metody abstrakcyjne nie mogą być deklarowane jako static. Metody statyczne są związane z klasą, a nie z obiektem, i ich idea często kłóci się z polimorficznym charakterem metod abstrakcyjnych, które są implementowane w różny sposób przez różne obiekty klas potomnych.

Co się stanie, jeśli klasa potomna nie zaimplementuje wszystkich metod abstrakcyjnych odziedziczonych po klasie abstrakcyjnej?

Jeśli klasa potomna nie zaimplementuje wszystkich odziedziczonych metod abstrakcyjnych, a sama nie jest zadeklarowana jako abstrakcyjna, PHP zgłosi błąd krytyczny (Fatal error) podczas próby utworzenia obiektu tej klasy potomnej lub nawet podczas parsowania skryptu, informując, że klasa zawiera niezdefiniowane metody abstrakcyjne.

Czy konstruktor w klasie abstrakcyjnej może być abstrakcyjny?

Nie, konstruktory nie mogą być deklarowane jako abstrakcyjne. Klasa abstrakcyjna może mieć normalny, zaimplementowany konstruktor (publiczny, chroniony lub nawet prywatny, choć prywatny miałby ograniczone zastosowanie). Jeśli klasa potomna ma własny konstruktor, powinna wywołać parent::__construct(), jeśli jest to potrzebne.