SmartObject

SmartObject роками покращував поведінку об'єктів у PHP. З версії PHP 8.4 усі його функції вже є частиною самого PHP, чим він завершив свою історичну місію бути піонером сучасного об'єктного підходу в PHP.

Встановлення:

composer require nette/utils

SmartObject виник у 2007 році як революційне вирішення недоліків тодішньої об'єктної моделі PHP. У той час, коли PHP страждав від низки проблем з об'єктним дизайном, він приніс значне покращення та спрощення роботи для розробників. Став легендарною частиною фреймворку Nette. Пропонував функціональність, яку PHP отримав лише через багато років – від контролю доступу до властивостей об'єктів до складних синтаксичних цукерок. З приходом PHP 8.4 він завершив свою історичну місію, оскільки всі його функції стали нативною частиною мови. Він випередив розвиток PHP на вражаючих 17 років.

Технічно SmartObject пройшов цікавий розвиток. Спочатку він був реалізований як клас Nette\Object, від якого інші класи успадковували необхідну функціональність. Кардинальна зміна відбулася з PHP 5.4, який приніс підтримку трейтів. Це дозволило трансформувати його у вигляд трейту Nette\SmartObject, що принесло більшу гнучкість – розробники могли використовувати функціональність навіть у класах, які вже успадковували від іншого класу. У той час як початковий клас Nette\Object зник з приходом PHP 7.2 (який заборонив називати класи словом Object), трейт Nette\SmartObject живе далі.

Розглянемо властивості, які колись пропонували Nette\Object, а пізніше Nette\SmartObject. Кожна з цих функцій свого часу представляла значний крок вперед у галузі об'єктно-орієнтованого програмування в PHP.

Консистентні стани помилок

Однією з найболючіших проблем раннього PHP була неконсистентна поведінка при роботі з об'єктами. Nette\Object приніс порядок і передбачуваність у цей хаос. Подивимося, як виглядала початкова поведінка PHP:

echo $obj->undeclared;    // E_NOTICE, пізніше E_WARNING
$obj->undeclared = 1;     // проходить тихо без повідомлення
$obj->unknownMethod();    // Fatal error (неможливо перехопити за допомогою try/catch)

Fatal error завершував додаток без можливості будь-яким чином реагувати. Тихий запис у неіснуючі члени без попередження міг призвести до серйозних помилок, які було важко виявити. Nette\Object перехоплював усі ці випадки та викидав виняток MemberAccessException, що дозволяло програмістам реагувати на помилки та вирішувати їх.

echo $obj->undeclared;   // викидає Nette\MemberAccessException
$obj->undeclared = 1;    // викидає Nette\MemberAccessException
$obj->unknownMethod();   // викидає Nette\MemberAccessException

З PHP 7.0 мова вже не спричиняє неперехоплювані fatal error, а з PHP 8.2 доступ до неоголошених членів вважається помилкою.

Підказка “Did you mean?”

Nette\Object приніс дуже приємну функцію: інтелектуальну підказку при опечатках. Коли розробник робив помилку в назві методу або змінної, він не тільки повідомляв про помилку, але й пропонував допомогу у вигляді пропозиції правильної назви. Це культове повідомлення, відоме як “did you mean?”, заощадило програмістам години пошуку опечаток:

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

$foo = Foo::form($var);
// викидає Nette\MemberAccessException
// "Call to undefined static method Foo::form(), did you mean from()?"

Сучасний PHP хоч і не має жодної форми „did you mean?“, але цей додаток вміє доповнювати помилки Tracy. І навіть такі помилки самостійно виправляти.

Властивості з контрольованим доступом

Значною інновацією, яку SmartObject приніс у PHP, були властивості з контрольованим доступом. Ця концепція, поширена в мовах як C# або Python, дозволила розробникам елегантно контролювати доступ до даних об'єкта та забезпечувати їхню консистенцію. Властивості є потужним інструментом об'єктно-орієнтованого програмування. Вони функціонують як змінні, але насправді представлені методами (гетерами та сетерами). Це дозволяє валідувати вхідні дані або генерувати значення лише в момент читання.

Для використання властивостей потрібно:

  • Додати класу анотацію у вигляді @property <type> $xyz
  • Створити гетер з назвою getXyz() або isXyz(), сетер з назвою setXyz()
  • Забезпечити, щоб гетер та сетер були public або protected. Вони є необов'язковими – отже, можуть існувати як read-only або write-only властивості.

Покажемо практичний приклад на класі Circle, де властивості використаємо для забезпечення того, щоб радіус завжди був невід'ємним числом. Замінимо початковий public $radius на властивість:

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

	private float $radius = 0.0; // не public!

	// гетер для властивості $radius
	protected function getRadius(): float
	{
		return $this->radius;
	}

	// сетер для властивості $radius
	protected function setRadius(float $radius): void
	{
		// значення перед збереженням санітизуємо
		$this->radius = max(0.0, $radius);
	}

	// гетер для властивості $visible
	protected function isVisible(): bool
	{
		return $this->radius > 0;
	}
}

$circle = new Circle;
$circle->radius = 10;  // насправді викликає setRadius(10)
echo $circle->radius;  // викликає getRadius()
echo $circle->visible; // викликає isVisible()

З PHP 8.4 можна досягти такої ж функціональності за допомогою property hooks, які пропонують набагато елегантніший та коротший синтаксис:

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

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

Методи розширення

Nette\Object приніс у PHP ще одну цікаву концепцію, натхненну сучасними мовами програмування – методи розширення. Ця функція, запозичена з C#, дозволила розробникам елегантно розширювати існуючі класи новими методами без необхідності їх змінювати або успадковувати від них. Наприклад, ви могли додати до форми метод addDateTime(), який додасть власний DateTimePicker:

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

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

Методи розширення виявилися непрактичними, оскільки їхні назви не підказували редактори, навпаки, повідомляли, що метод не існує. Тому їхня підтримка була припинена. Сьогодні більш поширеним є використання композиції або успадкування для розширення функціональності класів.

Визначення назви класу

Для визначення назви класу SmartObject пропонував простий метод:

$class = $obj->getClass(); // за допомогою Nette\Object
$class = $obj::class;      // з PHP 8.0

Доступ до рефлексії та анотацій

Nette\Object пропонував доступ до рефлексії та анотацій за допомогою методів getReflection() та getAnnotation(). Цей підхід значно спростив роботу з метаінформацією класів:

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

$obj = new Foo;
$reflection = $obj->getReflection();
$reflection->getAnnotation('author'); // поверне 'John Doe'

З PHP 8.0 можна отримувати доступ до метаінформації у вигляді атрибутів, які пропонують ще більші можливості та кращий контроль типів:

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

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

Метод-гетери

Nette\Object пропонував елегантний спосіб передавати методи так, ніби це змінні:

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

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

З PHP 8.1 можна використовувати так званий first-class callable syntax, який цю концепцію розвиває ще далі:

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

Події

SmartObject пропонує спрощений синтаксис для роботи з подіями. Події дозволяють об'єктам інформувати інші частини програми про зміни свого стану:

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

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

Код $this->onChange($this, $radius) є еквівалентним наступному циклу:

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

Для зрозумілості рекомендуємо уникати магічного методу $this->onChange(). Практичною заміною є, наприклад, функція Nette\Utils\Arrays::invoke:

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