SmartObject

SmartObject przez wiele lat ulepszał zachowanie obiektów w PHP. Od PHP 8.4 wszystkie jego możliwości stały się natywną częścią samego PHP, przez co dopełnił swoją historyczną misję pioniera nowoczesnego podejścia obiektowego w PHP.

Instalacja:

composer require nette/utils

SmartObject pojawił się w 2007 roku jako rewolucyjne rozwiązanie niedostatków ówczesnego modelu obiektowego PHP. W czasach, gdy PHP borykało się z licznymi problemami projektowania obiektowego, przyniósł znaczące ulepszenia i uprościł pracę programistom. Stał się legendarnym elementem Nette Framework. SmartObject oferował funkcjonalność, którą PHP zyskało dopiero wiele lat później – od kontroli dostępu do właściwości obiektów po wyrafinowany lukier składniowy. Wraz z wydaniem PHP 8.4 dopełnił swoją historyczną misję, bo większość jego możliwości stała się natywną częścią języka. Wyprzedził rozwój PHP o imponujące 17 lat.

Technicznie SmartObject przeszedł ciekawą ewolucję. Początkowo był zaimplementowany jako klasa Nette\Object, po której inne klasy dziedziczyły potrzebną funkcjonalność. Istotna zmiana przyszła z PHP 5.4, które wprowadziło obsługę traitów. Umożliwiło to przekształcenie w trait Nette\SmartObject, co dało większą elastyczność: programiści mogli używać tej funkcjonalności również w klasach, które już po czymś dziedziczyły. Pierwotna klasa Nette\Object przestała istnieć wraz z PHP 7.2 (które zabroniło nazywania klas słowem Object), ale trait Nette\SmartObject żyje dalej.

Przyjrzyjmy się możliwościom, które oferowały Nette\Object, a później Nette\SmartObject. Każda z nich stanowiła w swoim czasie znaczący krok naprzód w programowaniu obiektowym w PHP.

Spójne stany błędów

Jednym z najbardziej palących problemów wczesnego PHP było niespójne zachowanie przy pracy z obiektami. Nette\Object wniósł do tego chaosu porządek i przewidywalność. Zobaczmy, jak PHP zachowywało się pierwotnie:

echo $obj->undeclared;    // E_NOTICE, później E_WARNING
$obj->undeclared = 1;     // przechodzi po cichu bez ostrzeżenia
$obj->unknownMethod();    // Fatal error (nie do złapania przez try/catch)

Błąd fatalny kończył aplikację bez jakiejkolwiek możliwości reakcji. Ciche zapisywanie do nieistniejących składowych bez ostrzeżenia mogło prowadzić do poważnych, trudnych do wykrycia błędów. Nette\Object wyłapywał wszystkie te przypadki i zgłaszał MemberAccessException, dzięki czemu programiści mogli na te błędy reagować i je obsługiwać:

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

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

Pomocnik “did you mean?”

Nette\Object przyszedł z bardzo wygodną funkcją: inteligentnymi podpowiedziami przy literówkach. Gdy programista pomylił się w nazwie metody albo zmiennej, nie tylko zgłaszał błąd, ale też oferował pomoc, podpowiadając poprawną nazwę. Ten kultowy komunikat, znany jako “did you mean?”, oszczędził programistom godziny polowania na literówki:

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

$foo = Foo::form($var);
// zgłasza Nette\MemberAccessException
// "Call to undefined static method Foo::form(), did you mean from()?"

Współczesne PHP nie ma żadnej formy “did you mean?”, ale taki dopisek może dodawać do błędów Tracy. A nawet potrafi takie błędy automatycznie naprawiać.

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

Istotną nowością, którą SmartObject wniósł do PHP, były właściwości z kontrolowanym dostępem. Ta koncepcja, powszechna w językach takich jak C# czy Python, pozwoliła programistom elegancko kontrolować dostęp do danych obiektu i zapewnić ich spójność. Właściwości to potężne narzędzie programowania obiektowego. Zachowują się jak zmienne, ale w rzeczywistości reprezentowane są przez metody (gettery i settery). Pozwala to walidować dane wejściowe albo generować wartość w chwili odczytu.

Aby użyć właściwości, trzeba było:

  • dodać do klasy adnotację @property <typ> $xyz
  • utworzyć getter o nazwie getXyz() albo isXyz() i setter o nazwie setXyz()
  • zadbać, aby getter i setter były public albo protected. Były opcjonalne, mogły więc istnieć właściwości tylko do odczytu albo tylko do zapisu

Spójrzmy na praktyczny przykład z klasą Circle, w której użyjemy właściwości, aby zapewnić, że promień jest zawsze nieujemny. Zastąpimy public $radius właściwością:

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

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

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

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

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

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

Od PHP 8.4 tę samą funkcjonalność można uzyskać za pomocą property hooks, które oferują o wiele elegantszą i zwięźlejszą składnię:

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

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

Metody rozszerzające

Nette\Object wniósł do PHP jeszcze jedną ciekawą koncepcję zainspirowaną nowoczesnymi językami programowania: metody rozszerzające. Ta funkcja, zapożyczona z C#, pozwalała programistom elegancko rozszerzać istniejące klasy o nowe metody, bez modyfikowania ich ani dziedziczenia po nich. Mogłeś na przykład dodać do formularza metodę addDateTime(), która dodaje własny DateTimePicker:

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

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

Metody rozszerzające okazały się niepraktyczne, bo edytory kodu nie podpowiadały ich nazw, tylko zgłaszały, że metoda nie istnieje. Dlatego ich wsparcie zostało zakończone. Dziś do rozszerzania funkcjonalności klas częściej używa się kompozycji albo dziedziczenia.

Pobranie nazwy klasy

SmartObject oferował prostą metodę do pobrania nazwy klasy:

$class = $obj->getClass(); // z użyciem Nette\Object
$class = $obj::class;      // od PHP 8.0

Dostęp do refleksji i adnotacji

Nette\Object udostępniał dostęp do refleksji i adnotacji przez metody getReflection() i getAnnotation(). Podejście to znacząco upraszczało pracę z metainformacjami klasy:

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

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

Od PHP 8.0 do metainformacji można sięgać przez atrybuty, które oferują jeszcze więcej 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 przekazywania metod tak, 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żesz użyć first-class callable syntax, która idzie z tą koncepcją jeszcze dalej:

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

Zdarzenia

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

class Circle
{
	use Nette\SmartObject;

	public array $onChange = [];

	private float $radius = 0.0;

	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);
}

Dla przejrzystości zalecamy unikanie magicznej metody $this->onChange(). Praktycznym zamiennikiem jest funkcja Nette\Utils\Arrays::invoke:

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