SmartObject

SmartObject przez lata ulepszał zachowanie obiektów w PHP. Od wersji PHP 8.4 wszystkie jego funkcje są już częścią samego PHP, czym zakończył swoją historyczną misję bycia pionierem nowoczesnego podejścia obiektowego w PHP.

Instalacja:

composer require nette/utils

SmartObject powstał w 2007 roku jako rewolucyjne rozwiązanie niedociągnięć ówczesnego modelu obiektowego PHP. W czasach, gdy PHP cierpiało z powodu wielu problemów z projektowaniem obiektowym, przyniósł znaczące ulepszenie i uproszczenie pracy dla deweloperów. Stał się legendarną częścią frameworka Nette. Oferował funkcjonalność, którą PHP uzyskało dopiero wiele lat później – od kontroli dostępu do właściwości obiektów po wyrafinowane cukierki składniowe. Wraz z nadejściem PHP 8.4 zakończył swoją historyczną misję, ponieważ wszystkie jego funkcje stały się natywną częścią języka. Wyprzedził rozwój PHP o niezwykłe 17 lat.

Technicznie SmartObject przeszedł interesujący rozwój. Początkowo był zaimplementowany jako klasa Nette\Object, od której inne klasy dziedziczyły potrzebną funkcjonalność. Zasadnicza zmiana przyszła wraz z PHP 5.4, które przyniosło wsparcie dla traitów. Umożliwiło to transformację do postaci traitu Nette\SmartObject, co przyniosło większą elastyczność – deweloperzy mogli wykorzystać funkcjonalność również w klasach, które już dziedziczyły po innej klasie. Podczas gdy oryginalna klasa Nette\Object zniknęła wraz z nadejściem PHP 7.2 (które zakazało nazywania klas słowem Object), trait Nette\SmartObject żyje dalej.

Przejdźmy przez właściwości, które kiedyś oferowały Nette\Object, a później Nette\SmartObject. Każda z tych funkcji w swoim czasie stanowiła znaczący krok naprzód w dziedzinie programowania obiektowego w PHP.

Spójne stany błędów

Jednym z najbardziej palących problemów wczesnego PHP było niespójne zachowanie podczas pracy z obiektami. Nette\Object wprowadził do tego chaosu porządek i przewidywalność. Spójrzmy, jak wyglądało oryginalne zachowanie PHP:

echo $obj->undeclared;    // E_NOTICE, później E_WARNING
$obj->undeclared = 1;     // przejdzie cicho bez zgłoszenia
$obj->unknownMethod();    // Fatal error (nie do przechwycenia za pomocą try/catch)

Fatal error kończył aplikację bez możliwości jakiejkolwiek reakcji. Cichy zapis do nieistniejących składowych bez ostrzeżenia mógł prowadzić do poważnych błędów, które były trudne do wykrycia. Nette\Object wszystkie te przypadki przechwytywał i rzucał wyjątek MemberAccessException, co pozwalało programistom reagować na błędy i je rozwiązywać.

echo $obj->undeclared;   // rzuci Nette\MemberAccessException
$obj->undeclared = 1;    // rzuci Nette\MemberAccessException
$obj->unknownMethod();   // rzuci Nette\MemberAccessException

Od PHP 7.0 język już nie powoduje nieprzechwytywalnych błędów fatalnych, a od PHP 8.2 dostęp do niezadeklarowanych składowych jest uważany za błąd.

Pomoc “Did you mean?”

Nette\Object przyszedł z bardzo przyjemną funkcją: inteligentną pomocą przy literówkach. Gdy deweloper popełnił błąd w nazwie metody lub zmiennej, nie tylko zgłaszał błąd, ale także oferował pomocną dłoń w postaci sugestii poprawnej nazwy. Ten ikoniczny komunikat, znany jako “did you mean?”, oszczędził programistom godziny szukania literówek:

class Foo extends Nette\Object
{
	public static function from($var)
	{
	}
}

$foo = Foo::form($var);
// rzuci Nette\MemberAccessException
// "Wywołanie niezdefiniowanej metody statycznej Foo::form(), czy chodziło ci o from()?"

Dzisiejsze PHP wprawdzie nie ma żadnej formy „did you mean?”, ale ten dopisek potrafi dodawać do błędów Tracy. A nawet takie błędy potrafi samo naprawiać.

Właściwości z kontrolowanym dostępem

Znaczącą innowacją, którą SmartObject wprowadził do PHP, były właściwości z kontrolowanym dostępem. Ten koncept, powszechny w językach takich jak C# czy Python, umożliwił deweloperom elegancko kontrolować dostęp do danych obiektu i zapewnić ich spójność. Właściwości są potężnym narzędziem programowania obiektowego. Działają jak zmienne, ale w rzeczywistości są reprezentowane przez metody (gettery i settery). To pozwala walidować dane wejściowe lub generować wartości dopiero w momencie odczytu.

Aby używać właściwości, musisz:

  • Dodać klasie adnotację w postaci @property <type> $xyz
  • Stworzyć getter o nazwie getXyz() lub isXyz(), setter o nazwie setXyz()
  • Zapewnić, aby getter i setter były public lub protected. Są opcjonalne – mogą więc istnieć jako właściwości read-only lub write-only

Pokażmy praktyczny przykład na klasie Circle, gdzie wykorzystamy właściwości do zapewnienia, że promień będzie zawsze liczbą nieujemną. Zastąpimy oryginalny public $radius właściwością:

/**
 * @property float $radius
 * @property-read bool $visible
 */
class Circle
{
	use Nette\SmartObject;

	private float $radius = 0.0; // nie jest public!

	// getter dla właściwości $radius
	protected function getRadius(): float
	{
		return $this->radius;
	}

	// setter dla właściwości $radius
	protected function setRadius(float $radius): void
	{
		// wartość przed zapisem sanityzujemy
		$this->radius = max(0.0, $radius);
	}

	// getter dla właściwości $visible
	protected function isVisible(): bool
	{
		return $this->radius > 0;
	}
}

$circle = new Circle;
$circle->radius = 10;  // w rzeczywistości wywołuje setRadius(10)
echo $circle->radius;  // wywołuje getRadius()
echo $circle->visible; // wywołuje isVisible()

Od PHP 8.4 można osiągnąć tę samą funkcjonalność za pomocą property hooks, które oferują znacznie bardziej elegancką i zwięzłą składnię:

class Circle
{
	public float $radius = 0.0 {
		set => max(0.0, $value);
	}

	public bool $visible {
		get => $this->radius > 0;
	}
}

Metody rozszerzeń

Nette\Object wprowadził do PHP kolejny interesujący koncept inspirowany nowoczesnymi językami programowania – metody rozszerzeń. Ta funkcja, przejęta z C#, umożliwiła deweloperom elegancko rozszerzać istniejące klasy o nowe metody bez konieczności ich modyfikowania lub dziedziczenia po nich. Na przykład, można było dodać do formularza metodę addDateTime(), która doda własny DateTimePicker:

Form::extensionMethod(
	'addDateTime',
	fn(Form $form, string $name) => $form[$name] = new DateTimePicker,
);

$form = new Form;
$form->addDateTime('date');

Metody rozszerzeń okazały się niepraktyczne, ponieważ ich nazwy nie były podpowiadane przez edytory, wręcz przeciwnie, zgłaszały, że metoda nie istnieje. Dlatego ich wsparcie zostało zakończone. Dziś częściej wykorzystuje się kompozycję lub dziedziczenie do rozszerzania funkcjonalności klas.

Pobieranie nazwy klasy

Do pobierania nazwy klasy SmartObject oferował prostą metodę:

$class = $obj->getClass(); // za pomocą Nette\Object
$class = $obj::class;      // od PHP 8.0

Dostęp do refleksji i adnotacji

Nette\Object oferował dostęp do refleksji i adnotacji za pomocą metod getReflection() i getAnnotation(). To podejście znacznie uprościło pracę z metainformacjami klas:

/**
 * @author John Doe
 */
class Foo extends Nette\Object
{
}

$obj = new Foo;
$reflection = $obj->getReflection();
$reflection->getAnnotation('author'); // zwróci 'John Doe'

Od PHP 8.0 możliwe jest uzyskanie dostępu do metainformacji w postaci atrybutów, które oferują jeszcze większe możliwości i lepszą kontrolę typów:

#[Author('John Doe')]
class Foo
{
}

$obj = new Foo;
$reflection = new ReflectionObject($obj);
$reflection->getAttributes(Author::class)[0];

Gettery metod

Nette\Object oferował elegancki sposób, jak przekazywać metody, jakby były zmiennymi:

class Foo extends Nette\Object
{
	public function adder($a, $b)
	{
		return $a + $b;
	}
}

$obj = new Foo;
$method = $obj->adder;
echo $method(2, 3); // 5

Od PHP 8.1 można wykorzystać tzw. first-class callable syntax, która przenosi ten koncept jeszcze dalej:

$obj = new Foo;
$method = $obj->adder(...);
echo $method(2, 3); // 5

Zdarzenia

SmartObject oferuje uproszczoną składnię do pracy ze zdarzeniami. Zdarzenia pozwalają obiektom informować inne części aplikacji o zmianach swojego stanu:

class Circle extends Nette\Object
{
	public array $onChange = [];

	public function setRadius(float $radius): void
	{
		$this->onChange($this, $radius);
		$this->radius = $radius;
	}
}

Kod $this->onChange($this, $radius) jest równoważny następującej pętli:

foreach ($this->onChange as $callback) {
	$callback($this, $radius);
}

Ze względu na czytelność zalecamy unikanie magicznej metody $this->onChange(). Praktycznym zamiennikiem jest na przykład funkcja Nette\Utils\Arrays::invoke:

Nette\Utils\Arrays::invoke($this->onChange, $this, $radius);
wersja: 4.0