Lekcja 29 (OOP 9): Cechy (Traits) - Horyzontalne Współdzielenie Funkcjonalności

Witaj w dziewiątej lekcji naszego modułu o Programowaniu Obiektowym w PHP! W poprzedniej lekcji omówiliśmy polimorfizm i interfejsy, które pozwalają na tworzenie elastycznych kontraktów i wielopostaciowych zachowań obiektów. PHP, podobnie jak wiele innych języków obiektowych, wspiera tylko pojedyncze dziedziczenie klas (klasa może dziedziczyć bezpośrednio tylko po jednej klasie nadrzędnej). Może to być ograniczeniem, gdy chcemy współdzielić funkcjonalności między klasami, które niekoniecznie pasują do jednej hierarchii dziedziczenia. Aby rozwiązać ten problem, PHP od wersji 5.4 wprowadziło mechanizm zwany Cechami (Traits).

Cechy pozwalają na horyzontalne współdzielenie kodu – czyli na reużywanie zestawów metod w niezależnych klasach, które nie są ze sobą powiązane relacją dziedziczenia. Jest to forma kompozycji kodu, która pozwala na "wstrzykiwanie" funkcjonalności do klas bez konieczności uciekania się do dziedziczenia. W tej lekcji dokładnie przyjrzymy się, czym są cechy, jak je definiować, używać, oraz jak rozwiązywać konflikty nazw, które mogą pojawić się przy korzystaniu z wielu cech.

Czym Są Cechy (Traits)?

Cecha (Trait) to mechanizm reużywania kodu w językach z pojedynczym dziedziczeniem, takich jak PHP. Cecha jest podobna do klasy, ale ma na celu grupowanie funkcjonalności w drobnoziarnisty i spójny sposób. Metody zdefiniowane w cesze mogą być następnie "włączone" (użyte) wewnątrz definicji klasy. Użycie cechy w klasie jest podobne do skopiowania jej metod do tej klasy, ale z dodatkowymi mechanizmami zarządzania konfliktami.

Kluczowe cechy Cech (Traits):

Definiowanie i Używanie Cechy

Stworzenie i użycie cechy jest stosunkowo proste. Najpierw definiujemy cechę z metodami, które chcemy współdzielić, a następnie używamy jej w klasie.

<?php
// Definicja cechy
trait Logowanie
{
    // Właściwość w cesze (od PHP 8.0, wcześniej cechy głównie zawierały metody)
    // Należy jednak pamiętać, że stan (właściwości) w cechach może prowadzić do problemów,
    // jeśli nie jest zarządzany ostrożnie, ponieważ cecha nie wie, w jakim kontekście klasy będzie użyta.
    // Lepiej, gdy cechy dostarczają zachowania (metody), a stan jest zarządzany przez klasę.
    private string $prefixLogu = "[LOG]"; 

    public function log(string $wiadomosc): void
    {
        $czas = date("Y-m-d H:i:s");
        echo $this->prefixLogu . " [" . $czas . "]: " . $wiadomosc . "<br>";
    }

    public function setPrefixLogu(string $prefix): void
    {
        $this->prefixLogu = $prefix;
    }

    // Cecha może mieć metody abstrakcyjne, które muszą być zaimplementowane przez klasę używającą cechy
    abstract public function getIdentyfikatorLogowania(): string;

    public function logZIdentyfikatorem(string $wiadomosc): void
    {
        $identyfikator = $this->getIdentyfikatorLogowania(); // Wywołanie metody abstrakcyjnej
        $this->log("({$identyfikator}) - {$wiadomosc}");
    }
}

class UzytkownikSystemu
{
    use Logowanie; // Użycie cechy Logowanie

    public string $nazwa;

    public function __construct(string $nazwa)
    {
        $this->nazwa = $nazwa;
        $this->setPrefixLogu("[USER_LOG]"); // Możemy użyć metody z cechy do konfiguracji
    }

    public function wykonajAkcje(): void
    {
        // Metoda log() jest dostępna tak, jakby była zdefiniowana w klasie UzytkownikSystemu
        $this->log("Użytkownik " . $this->nazwa . " wykonuje akcję.");
        $this->logZIdentyfikatorem("Akcja specjalna wykonana.");
    }

    // Implementacja metody abstrakcyjnej wymaganej przez cechę Logowanie
    public function getIdentyfikatorLogowania(): string
    {
        return "User:" . $this->nazwa;
    }
}

class ProcesSystemowy
{
    use Logowanie; // Ta sama cecha użyta w innej klasie

    private int $pid;

    public function __construct(int $pid)
    {
        $this->pid = $pid;
        // Domyślny prefix logu z cechy zostanie użyty, jeśli go nie zmienimy
    }

    public function uruchom(): void
    {
        $this->log("Proces PID: " . $this->pid . " został uruchomiony.");
        $this->logZIdentyfikatorem("Uruchomienie procesu zakończone.");
    }

    public function getIdentyfikatorLogowania(): string
    {
        return "PID:" . $this->pid;
    }
}

$user = new UzytkownikSystemu("Alicja");
$user->wykonajAkcje();

echo "<hr>";

$proces = new ProcesSystemowy(12345);
$proces->uruchom();

/*
Przykładowy wynik:
[USER_LOG] [2023-10-27 10:00:00]: Użytkownik Alicja wykonuje akcję.
[USER_LOG] [2023-10-27 10:00:00]: (User:Alicja) - Akcja specjalna wykonana.
---
[LOG] [2023-10-27 10:00:00]: Proces PID: 12345 został uruchomiony.
[LOG] [2023-10-27 10:00:00]: (PID:12345) - Uruchomienie procesu zakończone.
*/
?>

W tym przykładzie:

Rozwiązywanie Konfliktów Nazw (Name Conflicts)

Problem może pojawić się, gdy klasa używa wielu cech, a niektóre z tych cech definiują metody o tej samej nazwie. PHP wymaga jawnego rozwiązania takich konfliktów. Można to zrobić za pomocą operatorów insteadof (zamiast) i as (jako).

<?php
trait CechaA
{
    public function wspolnaMetoda(): void
    {
        echo "Metoda z CechaA<br>";
    }
    public function metodaTylkoZA(): void
    {
        echo "Metoda tylko z CechaA<br>";
    }
}

trait CechaB
{
    public function wspolnaMetoda(): void
    {
        echo "Metoda z CechaB<br>";
    }
    public function metodaTylkoZB(): void
    {
        echo "Metoda tylko z CechaB<br>";
    }
}

class MojaKlasaKonflikt
{
    use CechaA, CechaB {
        // Rozwiązanie konfliktu dla wspolnaMetoda()
        // Chcemy użyć implementacji z CechaB, a implementację z CechaA zignorować (lub uczynić dostępną pod inną nazwą)
        CechaB::wspolnaMetoda insteadof CechaA;
        // Możemy również udostępnić metodę z CechaA pod inną nazwą (aliasem)
        CechaA::wspolnaMetoda as wspolnaMetodaZA;
    }

    // Jeśli nie rozwiążemy konfliktu, PHP zgłosi Fatal error: Trait method ... has not been applied as ... because of collision by ...
}

$objK = new MojaKlasaKonflikt();
$objK->wspolnaMetoda();      // Wywoła: Metoda z CechaB
$objK->wspolnaMetodaZA();   // Wywoła: Metoda z CechaA
$objK->metodaTylkoZA();    // Wywoła: Metoda tylko z CechaA
$objK->metodaTylkoZB();    // Wywoła: Metoda tylko z CechaB


// Inny przykład: zmiana modyfikatora dostępu metody z cechy
trait UzyteczneNarzedzia
{
    private function pomocniczaFunkcja(): string
    {
        return "Wynik funkcji pomocniczej";
    }

    public function wykonajZadanie(): void
    {
        echo "Wykonuję zadanie używając: " . $this->pomocniczaFunkcja() . "<br>";
    }
}

class ManagerZadan
{
    use UzyteczneNarzedzia {
        // Możemy zmienić modyfikator dostępu metody z cechy w kontekście tej klasy
        // np. uczynić metodę prywatną z cechy publiczną w tej klasie (choć to rzadkie i może naruszać enkapsulację cechy)
        // lub publiczną z cechy uczynić chronioną/prywatną w klasie.
        // Tutaj przykład uczynienia metody publicznej z cechy chronioną w klasie:
        // UzyteczneNarzedzia::wykonajZadanie as protected wykonajChronioneZadanie;
        
        // Możemy też po prostu użyć metody publicznej i zmienić jej alias oraz modyfikator
        UzyteczneNarzedzia::pomocniczaFunkcja as public getWynikPomocniczy; // Uczynienie prywatnej metody publiczną pod nową nazwą
    }

    public function uruchom(): void
    {
        $this->wykonajZadanie(); // Działa, bo wykonajZadanie jest public w cesze
        // echo $this->pomocniczaFunkcja(); // Błąd: pomocniczaFunkcja jest private w cesze i nie została wyeksponowana inaczej
        echo "Publiczny dostęp do wyniku: " . $this->getWynikPomocniczy() . "<br>";
    }
}

$manager = new ManagerZadan();
$manager->uruchom();

/*
Przykładowy wynik:
Metoda z CechaB
Metoda z CechaA
Metoda tylko z CechaA
Metoda tylko z CechaB
Wykonuję zadanie używając: Wynik funkcji pomocniczej
Publiczny dostęp do wyniku: Wynik funkcji pomocniczej
*/
?>

W bloku use:

Kolejność Pierwszeństwa (Precedence)

Kiedy metody są dziedziczone z klasy bazowej, implementowane z interfejsu i włączane z cechy, istnieje określona kolejność pierwszeństwa:

  1. Metody zdefiniowane bezpośrednio w bieżącej klasie mają najwyższy priorytet i nadpisują metody z cech oraz metody odziedziczone.
  2. Metody z cech nadpisują metody odziedziczone z klasy bazowej.
  3. Metody odziedziczone z klasy bazowej mają najniższy priorytet (ale wyższy niż domyślne implementacje z interfejsów, jeśli takie istnieją od PHP 8.0).

Oznacza to, że jeśli klasa dziedziczy metodę od rodzica i używa cechy, która definiuje metodę o tej samej nazwie, to wersja z cechy zostanie użyta. Jeśli sama klasa zdefiniuje metodę o tej nazwie, to jej własna implementacja będzie miała pierwszeństwo przed cechą i klasą bazową.

<?php
class KlasaBazowaPrecedencja
{
    public function przywitaj(): void
    {
        echo "Witaj z Klasy Bazowej!<br>";
    }
}

trait CechaPrecedencja
{
    public function przywitaj(): void
    {
        echo "Witaj z Cechy!<br>";
    }
}

class KlasaPotomnaPrecedencja extends KlasaBazowaPrecedencja
{
    use CechaPrecedencja;

    // Jeśli odkomentujemy tę metodę, ona będzie miała najwyższy priorytet:
    /*
    public function przywitaj(): void
    {
        echo "Witaj z Klasy Potomnej!<br>";
    }
    */
}

$objP = new KlasaPotomnaPrecedencja();
$objP->przywitaj(); // Wyświetli: Witaj z Cechy!
                    // Jeśli metoda w KlasaPotomnaPrecedencja byłaby odkomentowana, wyświetliłoby: Witaj z Klasy Potomnej!
?>

Cechy Mogą Używać Innych Cech

Jedna cecha może być skomponowana z innych cech, co pozwala na jeszcze większą modularność.

<?php
trait PomocnikStringow
{
    public function czyPusty(string $str): bool
    {
        return empty(trim($str));
    }
}

trait Walidator
{
    use PomocnikStringow; // Cecha Walidator używa cechy PomocnikStringow

    public function walidujEmail(string $email): bool
    {
        if ($this->czyPusty($email)) { // Użycie metody z PomocnikStringow
            return false;
        }
        return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
    }
}

class RejestracjaUzytkownika
{
    use Walidator; // Użycie cechy Walidator (która zawiera PomocnikStringow)

    public function zarejestruj(string $email, string $haslo): void
    {
        if (!$this->walidujEmail($email)) {
            echo "Niepoprawny email!<br>";
            return;
        }
        if ($this->czyPusty($haslo)) { // Metoda z PomocnikStringow jest dostępna
            echo "Hasło nie może być puste!<br>";
            return;
        }
        echo "Użytkownik z emailem {$email} zarejestrowany.<br>";
    }
}

$rejestracja = new RejestracjaUzytkownika();
$rejestracja->zarejestruj("test@example.com", "secret");
$rejestracja->zarejestruj("invalid-email", "password");
$rejestracja->zarejestruj("valid@example.com", "  ");

/*
Przykładowy wynik:
Użytkownik z emailem test@example.com zarejestrowany.
Niepoprawny email!
Hasło nie może być puste!
*/
?>

Właściwości w Cechach (od PHP 8.0)

Od PHP 8.0 cechy mogą definiować właściwości. Jednakże, jeśli klasa używająca cechy już definiuje właściwość o tej samej nazwie (zgodną co do typu i wartości domyślnej, jeśli są zadeklarowane), nie spowoduje to błędu. Jeśli właściwości są niezgodne (np. różne typy lub różne wartości domyślne bez możliwości nadpisania), PHP zgłosi błąd. Generalnie, poleca się, aby cechy były bezstanowe (nie miały własnych właściwości) i operowały na stanie klasy, w której są używane (np. poprzez $this lub metody abstrakcyjne dostarczające dane).

Kiedy Używać Cech?

Należy jednak używać cech z umiarem. Nadmierne ich stosowanie lub tworzenie bardzo dużych, złożonych cech może prowadzić do kodu trudnego do zrozumienia i debugowania ("Trait hell"). Cechy powinny być małe, spójne i dobrze zdefiniowane.

Podsumowanie Lekcji

W tej lekcji nauczyliśmy się, czym są Cechy (Traits) w PHP i jak pozwalają one na horyzontalne współdzielenie kodu oraz reużywanie funkcjonalności w klasach, które nie są ze sobą powiązane hierarchią dziedziczenia. Zobaczyliśmy, jak definiować cechy za pomocą słowa kluczowego trait i jak włączać je do klas za pomocą use.

Omówiliśmy mechanizmy rozwiązywania konfliktów nazw metod przy użyciu wielu cech (insteadof i as) oraz zasady pierwszeństwa metod (klasa > cecha > klasa bazowa). Dowiedzieliśmy się również, że cechy mogą używać innych cech oraz że od PHP 8.0 mogą zawierać właściwości, choć zaleca się ostrożność w ich stosowaniu.

Cechy są potężnym narzędziem do kompozycji zachowań i redukcji duplikacji kodu, stanowiąc ważne uzupełnienie dla mechanizmów dziedziczenia i interfejsów w programowaniu obiektowym w PHP.

W następnej, ostatniej lekcji tego modułu, zajmiemy się organizacją kodu w większych projektach za pomocą Przestrzeni Nazw (Namespaces) oraz automatycznym ładowaniem klas (Autoloading), w tym standardem PSR-4.


Zadanie praktyczne

Stwórz cechę Timestampable, która dodaje do klasy właściwości $createdAt i $updatedAt oraz metody do ich ustawiania.

  1. Zdefiniuj cechę Timestampable.
    • Powinna zawierać chronione właściwości ?DateTimeImmutable $createdAt = null; i ?DateTimeImmutable $updatedAt = null; (użyj typów dopuszczających null i wartości domyślnej null).
    • Metodę publiczną touch(): void, która ustawia $createdAt na aktualny czas (jeśli jest null) oraz zawsze ustawia $updatedAt na aktualny czas. Użyj new DateTimeImmutable().
    • Metody publiczne getCreatedAt(): ?DateTimeImmutable i getUpdatedAt(): ?DateTimeImmutable.
  2. Stwórz klasę Artykul, która używa cechy Timestampable.
    • Dodaj właściwość publiczną $tytul.
    • Konstruktor powinien przyjmować tytuł i od razu wywoływać metodę touch().
    • Dodaj metodę aktualizujTytul(string $nowyTytul): void, która zmienia tytuł i ponownie wywołuje touch().
  3. Stwórz klasę Komentarz, która również używa cechy Timestampable.
    • Dodaj właściwość publiczną $tresc.
    • Konstruktor powinien przyjmować treść i od razu wywoływać metodę touch().
  4. Przetestuj: utwórz obiekt Artykul, wyświetl jego czasy, zaktualizuj tytuł, ponownie wyświetl czasy. Utwórz obiekt Komentarz i wyświetl jego czasy. Sformatuj daty do czytelnej postaci (np. Y-m-d H:i:s).

Kliknij, aby zobaczyć przykładowe rozwiązanie
<?php
trait Timestampable
{
    protected ?DateTimeImmutable $createdAt = null;
    protected ?DateTimeImmutable $updatedAt = null;

    public function touch(): void
    {
        if ($this->createdAt === null) {
            $this->createdAt = new DateTimeImmutable();
        }
        $this->updatedAt = new DateTimeImmutable();
    }

    public function getCreatedAt(): ?DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function getUpdatedAt(): ?DateTimeImmutable
    {
        return $this->updatedAt;
    }

    public function formatTimestamp(?DateTimeImmutable $timestamp): string
    {
        return $timestamp ? $timestamp->format("Y-m-d H:i:s") : "N/A";
    }
}

class Artykul
{
    use Timestampable;
    public string $tytul;

    public function __construct(string $tytul)
    {
        $this->tytul = $tytul;
        $this->touch();
    }

    public function aktualizujTytul(string $nowyTytul): void
    {
        $this->tytul = $nowyTytul;
        $this->touch();
    }

    public function wyswietlDane(): void
    {
        echo "Artykuł: " . $this->tytul . "<br>";
        echo "Utworzono: " . $this->formatTimestamp($this->getCreatedAt()) . "<br>";
        echo "Zaktualizowano: " . $this->formatTimestamp($this->getUpdatedAt()) . "<br>";
    }
}

class Komentarz
{
    use Timestampable;
    public string $tresc;

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

    public function wyswietlDane(): void
    {
        echo "Komentarz: " . $this->tresc . "<br>";
        echo "Utworzono: " . $this->formatTimestamp($this->getCreatedAt()) . "<br>";
        echo "Zaktualizowano: " . $this->formatTimestamp($this->getUpdatedAt()) . "<br>";
    }
}

// Testowanie
$artykul = new Artykul("Pierwszy artykuł o PHP Traits");
$artykul->wyswietlDane();

echo "<hr>Czekamy sekundę...<br>";
sleep(1); // Pauza na 1 sekundę, aby zobaczyć różnicę w updatedAt

$artykul->aktualizujTytul("Zaktualizowany tytuł artykułu o PHP Traits");
$artykul->wyswietlDane();

echo "<hr>";

$komentarz = new Komentarz("Świetny artykuł!");
$komentarz->wyswietlDane();

/* Przykładowy wynik (daty będą aktualne):
Artykuł: Pierwszy artykuł o PHP Traits
Utworzono: 2023-10-27 10:30:00
Zaktualizowano: 2023-10-27 10:30:00
---
Czekamy sekundę...
Artykuł: Zaktualizowany tytuł artykułu o PHP Traits
Utworzono: 2023-10-27 10:30:00
Zaktualizowano: 2023-10-27 10:30:01
---
Komentarz: Świetny artykuł!
Utworzono: 2023-10-27 10:30:01
Zaktualizowano: 2023-10-27 10:30:01
*/
?>

Zadanie do samodzielnego wykonania

Stwórz cechę LicznikInstancji, która będzie zliczać, ile instancji danej klasy (używającej tej cechy) zostało utworzonych.

  1. Cecha LicznikInstancji powinna:
    • Zawierać prywatną, statyczną właściwość $licznik = 0;.
    • Zawierać metodę (np. chronioną), która jest wywoływana w konstruktorze klasy używającej cechy, aby inkrementować self::$licznik. Pamiętaj, że cecha nie ma własnego konstruktora, który byłby automatycznie wywoływany. Klasa używająca cechy musi to obsłużyć.
    • Zawierać publiczną, statyczną metodę getLiczbaInstancji(): int zwracającą wartość licznika.
  2. Stwórz dwie różne klasy, np. Produkt i Zamowienie, które używają cechy LicznikInstancji. Upewnij się, że konstruktory tych klas odpowiednio zarządzają licznikiem z cechy.
  3. Przetestuj: utwórz kilka instancji każdej z klas i sprawdź, czy NazwaKlasy::getLiczbaInstancji() zwraca poprawne wartości. Zauważ, że licznik będzie wspólny dla wszystkich klas używających tej samej definicji cechy, jeśli właściwość statyczna jest zdefiniowana w samej cesze. Zastanów się, jak można by to zmodyfikować, aby każda klasa miała swój własny, niezależny licznik (podpowiedź: właściwość statyczna musiałaby być zdefiniowana w klasie, a cecha by na niej operowała, np. poprzez metody abstrakcyjne). Dla tego zadania wystarczy wersja z licznikiem współdzielonym przez cechę.


FAQ - Cechy (Traits)

Czy cecha może dziedziczyć po klasie lub implementować interfejs?

Nie, cechy nie mogą dziedziczyć po klasach (extends) ani implementować interfejsów (implements) w tradycyjny sposób. Są one mechanizmem do horyzontalnego współdzielenia kodu. Jednak klasa używająca cechy może normalnie dziedziczyć po innej klasie i implementować interfejsy.

Jaka jest różnica między cechą a klasą abstrakcyjną?

Klasa abstrakcyjna jest częścią hierarchii dziedziczenia (relacja "is-a") i może mieć stan oraz konstruktory; nie można jej instancjonować. Cecha służy do współdzielenia kodu (metod) między klasami, które nie muszą być w tej samej hierarchii (kompozycja zachowań). Klasa może dziedziczyć tylko po jednej klasie (abstrakcyjnej lub nie), ale może używać wielu cech.

Czy metody w cesze mogą być abstrakcyjne?

Tak, cecha może deklarować metody abstrakcyjne. Jeśli klasa używa takiej cechy, musi zaimplementować te metody abstrakcyjne, chyba że sama klasa jest również abstrakcyjna. Pozwala to cesze zdefiniować pewien kontrakt, który klasa używająca musi spełnić.

Czy cecha może mieć właściwości statyczne i metody statyczne?

Tak, cechy mogą zawierać zarówno właściwości statyczne, jak i metody statyczne. Działają one podobnie jak w klasach. Właściwości statyczne zdefiniowane w cesze będą współdzielone przez wszystkie klasy używające tej cechy, chyba że zostaną nadpisane w klasie.

Jak działa operator $this wewnątrz cechy?

Wewnątrz metod cechy, $this odnosi się do instancji klasy, która aktualnie używa tej cechy. Cecha "pożycza" kontekst $this od klasy. Oznacza to, że metody cechy mogą uzyskiwać dostęp do innych metod i właściwości (publicznych i chronionych) tej klasy, a także do metod i właściwości z innych cech używanych przez tę samą klasę.

Czy można zmienić modyfikator dostępu metody z cechy w klasie jej używającej?

Tak, można zmienić modyfikator dostępu metody z cechy za pomocą słowa kluczowego as w bloku use. Na przykład: use MojaCecha { MojaCecha::metodaPubliczna as protected; } uczyni metodę metodaPubliczna z cechy chronioną w kontekście klasy. Można również tworzyć aliasy z nowymi modyfikatorami.

Czy cechy to to samo co "mixins" w innych językach?

Tak, cechy w PHP są bardzo podobne do koncepcji "mixins" znanej z niektórych innych języków programowania (np. Ruby, Scala). Służą one do włączania zestawów funkcjonalności do klas bez użycia dziedziczenia.