PHP/Metody magiczne

Z Wikibooks, biblioteki wolnych podręczników.
< PHP
Poprzedni rozdział: Elementy statyczne
Następny rozdział: Iteratory

Metody magiczne[edytuj]

W każdej klasie możemy utworzyć szereg metod, które będą traktowane w specjalny sposób przez PHP. Zwyczajowo nazywa się je metodami magicznymi, gdyż wywołuje je interpreter w odpowiedzi na różne zdarzenia, a nie programista. Można je poznać po tym, że ich nazwy zaczynają się od dwóch podkreśleń, co oznacza, że dwie takie metody powinniśmy już kojarzyć. Są to __construct() oraz __destruct() wywoływane automatycznie przy tworzeniu i niszczeniu obiektu. To jednak tylko czubek góry lodowej.

Dostęp do pól obiektu[edytuj]

Gdy próbujemy odwołać się do nieistniejącego pola klasy, PHP zazwyczaj generuje komunikat E_NOTICE. Dzięki metodom __get() oraz __set() możemy zaprogramować własną akcję i wykorzystać to do swoich celów.

Przyjrzyjmy się naszemu stale ulepszanemu systemowi konfiguracji. Odwoływanie się do opcji konfiguracyjnych przez $config->get('nazwa') jest odrobinę niewygodne. Dlatego zasymulujemy, że poszczególne opcje są dostępne jako pola klasy. Poniżej pokazany jest fragment klasy Config z nową metodą:

<?php
class Config implements Countable
{
   private $_config = array();
   private $_awaitingLoaders = array();
 
   public function __get($name)
   {
      return $this->get($name);
   } // end __get();
 
   // pozostała część klasy
} // end Config;

$config = new Config;
$config->addLoader(new FileConfigLoader('./config.ini.php'));
echo $config->websiteTitle;

Metoda __get wywoływana jest w podświetlonej linijce, gdy próbujemy dostać się do nieistniejącego pola. PHP pobiera jej wynik i zwraca skryptowi jako jego wartość. Jednocześnie ponieważ nie chcemy, aby ktoś modyfikował konfigurację, nie udostępniamy metody __set() wywoływanej przy próbie przypisania nieistniejącemu polu jakiejś wartości.

Wywoływanie metod[edytuj]

Magiczne metody potrafią także przechwytywać wywołania metod. Zasada działania jest analogiczna - jeśli PHP stwierdzi, że nie istnieje metoda o podanej nazwie, ale w klasie zdefiniowana jest operacja __call(), zrzuca całą robotę na nią. __call() dostaje dwa argumenty: nazwę metody oraz tablicę argumentów, z którymi programista próbował ją wywołać. Wykorzystajmy to do stworzenia mechanizmu zdarzeń. Zasada działania jest bardzo prosta. Jak pamiętamy, klasy posiadają pewne zachowania reprezentowane poprzez metody. Chcielibyśmy mieć możliwość rejestrowania dodatkowych czynności do wykonania, gdy zajdzie określone zdarzenie. Możemy dodatkowe czynności zapisać w postaci specjalnej klasy, której obiekt "wstrzykniemy" do właściwego obiektu. Znajdujący się tam kod sam zadba o to, aby czynności zostały wykonane.

Opis ten brzmi nieco abstrakcyjnie, ale spójrzmy na przykładowe zastosowanie. Mamy klasę User, która m.in. potrafi dodawać nowych użytkowników do naszej aplikacji. Chcemy, aby dodanie użytkownika z poziomu panelu administracyjnego zostało odnotowane w logach. Nic prostszego - piszemy klasę, w której implementujemy metodę onAdd(), która będzie zapisywać informację do logów, a utworzony od niej obiekt dodamy do obiektu User już w trakcie działania skryptu, gdy zorientujemy się, że wyświetlamy panel administracyjny. Nie musimy modyfikować kodu oryginalnej klasy. Co więcej, gdybyśmy chcieli wykonać przy tej okazji jeszcze inne czynności, wystarczy że napiszemy jeszcze więcej klas zdarzeń i podepniemy je pod User.

Najpierw zapoznajmy się z interfejsem EventDispatcher. Musi go implementować każda klasa, która będzie chciała obsługiwać zdarzenia. Przy okazji napiszemy też korzystającą z niego klasę User:

<?php
interface EventDispatcher
{
   public function addEventHandler(EventHandler $handler);
}

class User implements EventDispatcher
{
   private $_handlers = array();

   public function addEventHandler(EventHandler $handler)
   {
      $this->_handlers[] = $handler;
   } // end addEventHandler();

   public function addUser($login, $password)
   {
      foreach($this->_handlers as $handler)
      {
         $handler->onAdd($login, $password);
      }
      echo 'Użytkownik '.$login.' został dodany<br/>';
   } // end addUser();
} // end User;

Teraz pora na klasę EventHandler tworzącą podstawę dla wszystkich klas obsługi zdarzeń, jakie będziemy chcieli stworzyć. Zauważmy, że nie zawsze będziemy chcieli implementować wszystkie możliwe zdarzenia. Klasa User na razie obsługuje jedynie onAdd(), ale przecież w rzeczywistej aplikacji może tez posiadać zdarzenia onEdit(), onDelete() i inne. Po co tworzyć puste implementacje, kiedy można wszystko elegancko przechwycić poprzez __call() i odpowiednio przetworzyć przed użyciem? Ponadto, przed wywołaniem zdarzenia warto sprawdzić, czy spełnione są wszystkie warunki. EventHandler będzie zawierać dodatkową metodę _checkConditions(), która musi zwrócić true, aby wyrazić zgodę na wykonanie zdarzenia.

<?php

class EventHandler
{
   protected function _checkConditions()
   {
      return true;
   } // end _checkConditions();

   public function __call($name, $arguments)
   {
      if($this->_checkConditions())
      {
         $name = '_'.$name.'Event';
         if(method_exists($this, $name))
         {
            $this->$name($arguments);
         }
      }
   } // end __call();
} // end EventHandler;

Zwróćmy uwagę na linijkę 17 - oto odpowiedź, dlaczego nazwa pola klasy nie jest poprzedzona znakiem dolara. W tym miejscu PHP odczytuje nazwę metody do wywołania wprost ze zmiennej, dzięki czemu możemy dynamicznie decydować, co wykonać! Oczywiście warto wcześniej upewnić się, że w ogóle posiadamy odpowiednią metodę do obsługi zdarzenia i temu służy funkcja method_exists(). Zauważmy, że jeśli nie chcemy obsługiwać zdarzenia, nie grozi to nam teraz żadnymi konsekwencjami w postaci Fatal error. Wszystko przechwyci __call() i jeśli stwierdzi, że nie potrafi zająć się zdarzeniem, po prostu je zignoruje.

Poniżej pokazujemy przykładowe zastosowanie. Stworzymy sobie dwie klasy do obsługi zdarzeń. Pierwsza będzie rejestrować fakt dodania nowego użytkownika, a druga pomoże nam w organizacji konkursu, gdzie pięciu losowo wybranych nowo zarejestrowanych użytkowników wygrywa nagrodę.

<?php
class LoggingEvent extends EventHandler
{
   protected function _onAddEvent($arguments)
   {
      $f = fopen('./logs/user_log.log', 'a');
      fwrite($f, date('r').': '.$arguments[0].' zostal zarejestrowany.');
      fclose($f);
   } // end _onAddEvent();
} // end LoggingEvent;

class CompetitionEvent extends EventHandler
{
   protected $_winners = 0;

   protected function _checkConditions()
   {
      // sprawdzmy, czy w ogole mozna jeszcze wygrywac.
      $this->_winners = trim(file_get_contents('./winner_count.txt'));
      return ($this->_winners < 5);
   } // end _checkConditions();

   protected function _onAddEvent($arguments)
   {
      // Losuj zwyciezce z prawdopodobienstwem 0,2%
      if(rand(0, 1000) < 2)
      {
         echo $arguments[0].' wygrał!<br/>';
         file_put_contents('./winner_count.txt', ++$this->_winners);
      }
   } // end _onAddEvent();
} // end CompetitionEvent;

$user = new User;

// Tworzymy normalnie, bez zadnych bajerow.
$user->addUser('wacek', 'foo');

// Teraz bedziemy rejestrowac fakt utworzenia w logach.
$user->addEventHandler(new LoggingEvent);
$user->addUser('franek', 'bar');

// A ten uzytkownik wezmie udzial w konkursie.
$user->addEventHandler(new CompetitionEvent);
$user->addUser('jacek', 'joe');

Oto potęga programowania obiektowego - przy odrobinie pomysłowości możemy rozszerzać istniejące klasy bez zmiany ani jednej linijki ich kodu. Wszystko odbywa się w pełni dynamicznie, a pomogła nam w tym magiczna metoda __call(). W ramach ćwiczenia przyjrzyj się dokładnie kodowi do losowania zwycięzców. W pewnej sytuacji nie zadziała on prawidłowo. Czy potrafisz powiedzieć kiedy i dlaczego?

Klonowanie obiektów[edytuj]

Jak pamiętamy, operator przypisania kopiuje jedynie referencję do obiektu. Są jednak sytuacje, gdy chcielibyśmy fizycznie skopiować cały obiekt. Najprostsze wyjście to wykorzystanie operatora clone:

$kopia = clone $obiekt;

Jednak nasz obiekt może mieć otwarte różne zasoby (np. pliki) lub przechowywać referencje do innych obiektów. Standardowe klonowanie po prostu skopiuje same referencje, dlatego musimy mieć możliwość "poprawienia" sklonowanej kopii tak, by była ona w pełni samodzielna wtedy, gdy tego potrzebujemy. Tu do akcji wkracza magiczna metoda __clone(). Gdy znajduje się ona w klasie, PHP wywołuje ją tuż po sklonowaniu obiektu, dzięki czemu ma ona możliwość wprowadzenia poprawek.

Aby zademonstrować działanie wspomnianej funkcji, napiszemy implementację struktury danych zwanej kolejką. Struktury danych są jednym z filarów algorytmiki. Służą do przechowywania dowolnie dużej liczby informacji ograniczonej wyłącznie rozmiarami pamięci, oferując dostęp do nich w pewien ściśle określony sposób. Działanie kolejki jest podobne do kolejek w sklepie - klienci przychodzą z jednej strony, a z drugiej są obsługiwani. W informatyce klientów zastępują dane dodawane z jednej strony, a zdejmowane z drugiej. Będziemy potrzebować dwóch klas: pierwszej do reprezentowania pojedynczego elementu kolejki oraz drugiej do zarządzania całą strukturą. Nas szczególnie będzie interesować proces klonowania. Klonując kolejkę, musimy także sklonować wszystkie obiekty reprezentujące jej elementy, dlatego musimy skorzystać z magicznej metody __clone().

Zaczynamy od napisania klasy pojedynczego elementu kolejki. Będzie on przechowywać informację oraz referencję do następnego elementu w kolejce. Następny element może mieć namiary na kolejny i tak dalej, aż do końca - tworzy się w ten sposób łańcuch elementów prowadzących od pierwszego do ostatniego czekającego elementu.

<?php

class QueueElement
{
   private $_data;
   private $_next = null;

   public function __construct($data)
   {
      $this->_data = $data;
   } // end __construct();

   public function getData()
   {
      return $this->_data;
   } // end getData();

   public function setNext(QueueElement $next)
   {
      $this->_next = $next;
   } // end setNext();

   public function getNext()
   {
      return $this->_next;
   } // end getNext();
} // end QueueElement;

Nasza klasa składa się praktycznie wyłącznie z getterów i setterów. Nie ma tutaj jeszcze operacji klonowania, ponieważ na tym poziomie nie mamy jeszcze nic do zrobienia. Dopiero główna klasa kolejki zawierać będzie całe sterowanie. Znajdować się tu będą namiary na pierwszy i ostatni element kolejki tak, aby można było się do nich szybko dostać podczas odczytu lub zapisu.

<?php

class Queue
{
   private $_first = null;
   private $_last = null;

   public function enqueue($data)
   {
      // Nic nie ma w kolejce.
      if($this->_first === null)
      {
         $this->_first = $this->_last = new QueueElement($data);
      }
      else
      // Coś jest w kolejce, doklejamy się do ostatniego.
      {
         $element = new QueueElement($data);
         $this->_last->setNext($element);
         $this->_last = $element;
      }
   } // end enqueue();

   public function dequeue()
   {
      if($this->_first === null)
      {
         throw new UnderflowException('Nie można pobrać elementu z pustej kolejki.');
      }
      $dequeued = $this->_first;
      $this->_first = $this->_first->getNext();
      
      if($this->_first === null)
      {
         $this->_last = null;
      }

      return $dequeued->getData();
   } // end dequeue();

   public function __clone()
   {
      // Zalozmy, ze sklonowana kolejka jest pusta
      $top = $this->_first;      
      $this->_first = $this->_last = null;

      // Przejedźmy się po dotychczasowej kolejce i skopiujmy
      // wszystkie jej elementy
      while($top != null)
      {
         $this->enqueue($top->getData());
         $top = $top->getNext();
      }
   } // end __clone();
} // end Queue;

Przykładowe działanie:

<?php

$queue = new Queue;
$queue->enqueue(5);
$queue->enqueue(4);
$queue->enqueue(3);

$cloned = clone $queue;
$cloned->enqueue(2);

try
{
   while(true)
   {
      echo $queue->dequeue().'<br/>';
   }
}
catch(UnderflowException $exception)
{
   // pusto
}

W przykładzie dodajemy do kolejki trzy elementy, a następnie ją klonujemy i dorzucamy do kopii jeszcze jeden element. Na końcu wyświetlamy elementy oryginału. Gdyby podczas klonowania pozostawić niezmienione elementy, wyświetliłyby nam się cztery liczby. Elementy kolejki są obiektami, zatem w skrypcie dostępne są jedynie referencje do nich. Operacja enqueue() sprawiłaby, że czwarta liczba zostałaby doczepiona do wspólnego elementu, a co gorsza - oryginał wcale by o tym nie wiedział! Mogłoby to doprowadzić do poważnych błędów w działaniu obu list. Zakomentuj operację __clone() i prześledź wszystko samodzielnie.

Przy implementowaniu własnej wersji __clone() musimy pamiętać, że operuje ona na już sklonowanym obiekcie, a nie na oryginale. Innymi słowy, PHP najpierw wykonuje dokładną kopię całego obiektu, a dopiero później wykonuje na niej __clone(), by pozwolić jej pozmieniać te rzeczy, które powinny być zmodyfikowane.

Serializacja obiektów[edytuj]

Pisząc w PHP nie musimy przejmować się, jak interpreter reprezentuje tablice i obiekty w pamięci komputera. Jednak zdarza się, że chcielibyśmy zapisać je w całości do pliku lub przesłać komuś przez sieć. Za przetłumaczenie złożonego typu do postaci tekstowej i złożenie z niej z powrotem oryginału po drugiej stronie odpowiada tzw. serializacja. Spróbujmy zobaczyć, jak ten proces przebiega:

<?php
$array = array(
   'foo' => 'foo',
   'bar' => 'bar',
   'joe' => 'joe'
);

// Sprawdźmy, jak wygląda tablica normalnie
var_dump($array);

// Zserializujmy ją
$serialized = serialize($array);
echo $serialized.'<br/>';

// A teraz przywracamy oryginał
var_dump(unserialize($serialized));

Wynikiem działania skryptu powinno być:

array
  'foo' => string 'foo' (length=3)
  'bar' => string 'bar' (length=3)
  'joe' => string 'joe' (length=3)
a:3:{s:3:"foo";s:3:"foo";s:3:"bar";s:3:"bar";s:3:"joe";s:3:"joe";}
array
  'foo' => string 'foo' (length=3)
  'bar' => string 'bar' (length=3)
  'joe' => string 'joe' (length=3)

Funkcja serialize() przekonwertowała tablicę na specjalnie sformatowany ciąg tekstowy, w którym zawarte są informacje o wszystkich jej elementach. Gdy wprowadzimy go do funkcji unserialize(), PHP z powrotem odtworzy naszą tablicę. Serializować można także obiekty, a konwersji podlegają wszystkie pola. Nie zawsze jest to zjawisko pożądane. Jeśli nasz obiekt zawiera uchwyt do pliku, zostanie on zgubiony podczas przesyłania, dlatego dobrze by było, gdyby zamiast niego w zserializowanym tekście znalazła się po prostu ścieżka do niego. Podczas deserializacji musielibyśmy otworzyć go ponownie.

Niestandardowe reguły serializacji takie, jak powyżej opisana, mogą zostać zaprogramowane dzięki magicznym metodom __sleep() oraz __wakeup(), które umieszczamy w naszej klasie. Nie pobierają one żadnych argumentów. Pierwsza z nich powinna zwrócić tablicę z nazwami pól, które mają podlegać serializacji, zaś w drugiej możemy umieścić cokolwiek. Zobaczmy zatem, jak przesłać tekstem obiekt reprezentujący połączenie z plikiem:

<?php

class File
{
   private $_handle;
   private $_name;

   public function __construct($filename)
   {
      $this->_name = $filename;
      $this->_handle = fopen($filename, 'r');
   } // end __construct();

   public function __destruct()
   {
      fclose($this->_handle);
   } // end __destruct();

   public function getFilename()
   {
      return $this->_name;
   } // end getFilename();

   public function read($bytes)
   {
      return fread($this->_handle, (int)$bytes);
   } // end read();

   public function __sleep()
   {
      return array('_name');
   } // end __sleep();

   public function __wakeup()
   {
      echo 'Odtwarzam połączenie z plikiem...<br/>';
      $this->_handle = fopen($this->_name, 'r');
   } // end __wakeup();
} // end File;

W metodzie magicznej __sleep() mówimy: serializacja pola $_handle nie ma sensu, wystarczy nam nazwa pliku na odtworzenie obiektu. Odtworzenie w __wakeup() polega po prostu na ponownym otwarciu pliku. Możemy teraz przetestować całość:

<?php
require('./File.php');

$file = new File('plik.txt');
echo $file->read(100).'<br/>';

$file2 = unserialize(serialize($file));
echo $file2->read(100).'<br/>';

Pierwszy, oryginalny obiekt tworzymy w tradycyjny sposób i odczytujemy z pliku 100 bajtów. Następnie całość serializujemy i odserializowujemy, by spróbować coś odczytać poprzez "nowy" obiekt. Po uruchomieniu zauważymy, że oba odczyty się udały. PHP wywołał __wakeup() podczas deserializacji i ponownie otworzył plik, a świadczy o tym komunikat "Odtwarzam połączenie z plikiem...".

Jest kilka zastosowań dla serializacji. Może posłużyć jako szybki cache złożonych struktur. Zamiast mozolnie budować dużą kolekcję obiektów przy każdym wejściu na stronę, możemy zbudować ją raz, zserializować i zapisać do pliku, a przy kolejnych żądaniach HTTP ładować z powrotem. Jednak musimy tutaj uważać. Prostota serializacji wielu programistom zupełnie przesłania kwestię bezpieczeństwa. Przede wszystkim nie wolno zserializowanych danych pokazywać użytkownikom naszej witryny w sposób jawny (nawet w ciastkach!). Spróbuj wyświetlić sobie zserializowaną postać obiektu klasy File. Zauważysz, że zapisana jest tam informacja o tym, do jakiej klasy obiekt należy. Jeśli będziesz bezmyślnie odserializowywać wszystko, co przyjdzie do Ciebie z przeglądarki, sprytny hacker może tak spreparować ciąg, by powstał jakiś kluczowy dla aplikacji obiekt i wykorzystać go do swoich niecnych celów. Oto przykład: mamy klasę Mailer służącą do wysyłania e-maili i wpadliśmy na pomysł, by dane o zalogowanych użytkownikach trzymać w ciastkach jako zserializowane tablice. Agresor jednak modyfikuje takie ciastko tak, aby po odczytaniu przez skrypt tworzyło obiekt klasy Mailer i rozesłało spam w Twoim imieniu. Musisz przyznać, że nie jest to ciekawa perspektywa. Dlatego trzy razy pomyśl, zanim zserializowane dane udostępnisz komukolwiek poza serwerem.

Rozszerzony dostęp do pól obiektów[edytuj]

Rozdział ten zaczęliśmy od omówienia metod __get() i __set(), pisząc prosty system konfiguracyjny. Możemy odczytywać i zapisywać nowe wartości do różnych opcji, ale nie mamy jak sprawdzić czy konkretna opcja istnieje. Uzupełnimy teraz to niedopatrzenie dzięki dwóm kolejnym metodom magicznym: __isset() oraz __unset(). Pierwsza z nich wywoływana jest, gdy na danym polu zadziałamy komendą isset():

if(isset($config->property))
{
   ...
}

Metoda __unset() wywoływana jest podczas wykonywania komendy unset(), czyli próby usunięcia pola z obiektu:

unset($config->property);

Obie pobierają jeden argument będący nazwą pola. Dodajmy zatem do naszej klasy Config metodę __isset().

public function __isset($name)
{
   return isset($this->_config[$name]);
} // end __isset();

Jak widać, jest to bardzo proste. Jedyne, co robimy, to każemy sprawdzić czy opcja istnieje w naszej tablicy z opcjami.

Konwersja do ciągu tekstowego[edytuj]

W PHP każdy obiekt można przekonwertować na ciąg tekstowy, a dzięki magicznym metodom możemy oprogramować tę operację. Na tapetę weźmiemy naszą klasę File. Nie chcemy ciągle pisać skomplikowanych odwołań w stylu $file->getFilename() za każdym razem, gdy próbujemy wyświetlić nazwę pliku. Dlatego dodamy metodę __toString(), która pozwoli nam to nieco uprościć. Dodaj poniższy kod do ciała wspomnianej klasy:

public function __toString()
{
   return $this->_name;
} // end __toString();

Od metody tej oczekujemy, że wygeneruje tekstową reprezentację obiektu. W naszym przypadku ma to być po prostu nazwa pliku. Dzięki temu możemy teraz wyświetlić ją dużo prościej:

<?php
require('./File.php');

$file = new File('plik.txt');
echo 'Otworzyłem plik '.$file.'<br/>';

Zakończenie[edytuj]

Metody magiczne są dobrym mechanizmem do reagowania na różne zdarzenia, które wykonujemy na obiektach, a które nie są związane z wywoływaniem metod. Jednak pamiętaj, że wszystko wymaga umiaru. Nie stosuj ich do dodawania obiektom kompletnie nieintuicyjnych zachowań, gdyż inni programiści będą mieć później spore problemy ze zrozumieniem Twojego kodu. Metody magiczne nie są też dobrze wspierane przez zaawansowane edytory z dynamicznym podpowiadaniem składni. Jeśli jakaś metoda jest normalnie zaprogramowana w klasie jako metoda, edytor pokaże ją na podglądzie. Jeśli będzie ona wybierana przez skomplikowany algorytm ukryty w __call(), część programistów nawet nie zauważy jej istnienia, a ich praca nad kodem będzie żmudniejsza.