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, який представив підтримку trait. Це дозволило трансформацію у trait Nette\SmartObject, що принесло більшу гнучкість – розробники могли використовувати функціональність навіть у класах, які вже успадковувалися від іншого класу. У той час як оригінальний клас Nette\Object припинив існування з появою PHP 7.2 (який заборонив називати класи словом ‘Object’), trait 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. Він навіть може автоматично виправляти такі помилки.

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

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

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

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

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

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

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

	// getter для property $radius
	protected function getRadius(): float
	{
		return $this->radius;
	}

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

	// getter для property $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;
	}
}

Extension methods

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

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

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

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

Отримання назви класу

Для отримання назви класу 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];

Method getters

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