PHP 8.0: Atrybuty (3/4)

4 lata temu przez David Grudl  

Ukazała się wersja PHP 8.0. Jest tak naładowana nowościami, jak żadna wersja wcześniej. Ich przedstawienie wymagało aż czterech oddzielnych artykułów. W tym trzecim przyjrzymy się atrybutom.

Atrybuty wprowadzają zupełnie nowy sposób zapisywania strukturalnych metadanych do klas i wszystkich ich członków, a także funkcji, domknięć (closures) i ich parametrów. Do tego celu dotychczas wykorzystywano komentarze phpDoc, ale ich składnia była zawsze na tyle luźna i niejednolita, że nie było możliwe ich maszynowe przetwarzanie. Dlatego zastępują je atrybuty ze stałą składnią i wsparciem w klasach refleksyjnych.

Dzięki temu biblioteki, które dotychczas pozyskiwały metadane poprzez parsowanie komentarzy phpDoc, będą mogły zastąpić je atrybutami. Przykładem jest na przykład Nette, gdzie w najnowszych wersjach Application i DI można już zamiast adnotacji @persistent, @crossOrigin i @inject używać atrybutów Persistent, CrossOrigin i Inject.

Kod używający adnotacji:

/**
 * @persistent(comp1, comp2)
 */
class SomePresenter
{
	/** @persistent */
	public $page = 1;

	/** @inject */
	public Facade $facade;

	/**
	 * @crossOrigin
	 */
	public function handleSomething()
	{
	}
}

To samo za pomocą atrybutów:

use Nette\Application\Attributes\CrossOrigin;
use Nette\Application\Attributes\Persistent;
use Nette\DI\Attributes\Inject;

#[Persistent('comp1', 'comp2')]
class SomePresenter
{
	#[Persistent]
	public int $page = 1;

	#[Inject]
	public Facade $facade;

	#[CrossOrigin]
	public function handleSomething()
	{
	}
}

PHP interpretuje nazwy atrybutów tak samo, jakby były to klasy, czyli w kontekście przestrzeni nazw i klauzul use. Można by je więc zapisać na przykład również tak:

use Nette\Application\Attributes;

class SomePresenter
{
	#[Attributes\Persistent]
	public int $page = 1;

	#[\Nette\DI\Attributes\Inject]
	public Facade $facade;

Klasa reprezentująca atrybut może istnieć lub nie. Jest jednak zdecydowanie lepiej, jeśli istnieje, ponieważ wtedy edytor może ją podpowiadać podczas pisania, analizator statyczny rozpozna literówki itp.

Składnia

Sprytne jest to, że PHP przed wersją 8 widzi atrybuty tylko jako komentarze, więc można ich używać również w kodzie, który ma działać w starszych wersjach.

Zapis pojedynczego atrybutu wygląda jak tworzenie instancji obiektu, gdybyśmy pominęli operator new. Czyli nazwa klasy, po której mogą następować w nawiasach argumenty:

#[Column('string', 32, true, false)]#
protected $username;

I tu jest miejsce, gdzie można wykorzystać nową gorącą cechę PHP 8.0 – named arguments:

#[Column(
	type: 'string',
	length: 32,
	unique: true,
	nullable: false,
)]#
protected $username;

Każdy element może mieć wiele atrybutów, które można zapisać oddzielnie lub rozdzielone przecinkiem:

#[Inject]
#[Lazy]
public Foo $foo;

#[Inject, Lazy]
public Bar $bar;

Poniższy atrybut dotyczy wszystkich trzech właściwości:

#[Common]
private $a, $b, $c;

W wartościach domyślnych właściwości można używać prostych wyrażeń i stałych, które można obliczyć podczas kompilacji, i to samo dotyczy argumentów atrybutów:

#[
	ScalarExpression(1 + 1),
	ClassNameAndConstants(PDO::class, PHP_VERSION_ID),
	BitShift(4 >> 1, 4 << 1),
	BitLogic(1 | 2, JSON_HEX_TAG | JSON_HEX_APOS),
]

Niestety wartością argumentu nie może być kolejny atrybut, tj. nie można zagnieżdżać atrybutów. Na przykład poniższej adnotacji używanej w Doctrine nie można całkowicie bezpośrednio przekształcić w atrybuty:

/**
 * @Table(name="products",uniqueConstraints={@UniqueConstraint(columns={"name", "email"})})
 */

Nie ma również odpowiednika atrybutu dla pliku phpDoc, czyli komentarza znajdującego się na początku pliku, który jest wykorzystywany np. przez Nette Tester.

Odczytywanie atrybutów

Jakie atrybuty mają poszczególne elementy, dowiemy się za pomocą refleksji. Klasy refleksyjne dysponują nową metodą getAttributes(), która zwróci tablicę obiektów ReflectionAttribute.

use MyAttributes\Example;

#[Example('Witaj', 123)]
class Foo
{}

$reflection = new ReflectionClass(Foo::class);

foreach ($reflection->getAttributes() as $attribute) {
	$attribute->getName();      // pełna nazwa atrybutu, np. MyAttributes\Example
	$attribute->getArguments(); // ['Witaj', 123]
	$attribute->newInstance();  // zwraca instancję new MyAttributes\Example('Witaj', 123)
}

Zwrócone atrybuty można filtrować parametrem, np. $reflection->getAttributes(Example::class) zwróci tylko atrybuty Example.

Klasy atrybutów

Klasa atrybutu MyAttributes\Example nie musi istnieć. Jej istnienia wymaga jedynie wywołanie metody newInstance(), ponieważ tworzy jej instancję. Napiszmy ją więc. Będzie to zupełnie zwyczajna klasa, tylko musimy przy niej podać atrybut Attribute (tj. z globalnej przestrzeni nazw systemowej):

namespace MyAttributes;

use Attribute;

#[Attribute]
class Example
{
	public function __construct(string $message, int $number)
	{
		...
	}
}

Można ograniczyć, dla jakich elementów językowych będzie legalne użycie atrybutu. Na przykład tak, tylko dla klas i właściwości:

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
class Example
{
	...
}

Dostępne są flagi TARGET_CLASS, TARGET_FUNCTION, TARGET_METHOD, TARGET_PROPERTY, TARGET_CLASS_CONSTANT, TARGET_PARAMETER oraz domyślna TARGET_ALL.

Ale uwaga, do weryfikacji poprawnego lub niepoprawnego użycia dochodzi zaskakująco dopiero przy wywołaniu metody newInstance(). Sam kompilator tej kontroli nie przeprowadza.

Przyszłość z atrybutami

Dzięki atrybutom i nowym typom komentarze do dokumentacji PHP po raz pierwszy w swojej historii staną się tak naprawdę tylko komentarzami do dokumentów. PhpStorm już teraz posiada niestandardowe atrybuty, które mogą zastąpić np. adnotację @deprecated. I można założyć, że ten atrybut będzie kiedyś w PHP domyślny. Podobnie zastąpione zostaną inne adnotacje, takie jak @throws itp.

Chociaż Nette używa adnotacji od swojej pierwszej wersji do oznaczania parametrów i komponentów persistentnych, do ich masowego wykorzystania nie doszło, ponieważ nie był to natywny konstrukt językowy, więc edytory ich nie podpowiadały i łatwo było w nich popełnić błąd. Chociaż dzisiaj rozwiązują to wtyczki do edytorów, naprawdę natywna droga, jaką przynoszą atrybuty, otwiera zupełnie nowe możliwości.

Nawiasem mówiąc, atrybuty uzyskały wyjątek w Nette Coding Standard, który wymaga, aby nazwa klasy oprócz specyficzności (np. Product, InvalidValue) zawierała również ogólność (czyli ProductPresenter, InvalidValueException). W przeciwnym razie nie byłoby przy użyciu w kodzie jasne, co dokładnie klasa reprezentuje. W przypadku atrybutów jest to wręcz niepożądane, więc klasa nazywa się Inject zamiast InjectAttribute.

W ostatniej części przyjrzymy się, jakie nowe funkcje i klasy pojawiły się w PHP oraz przedstawimy Just in Time Compiler.