PHP 8.0: Atrybuty (3/4)

4 lata temu Ze strony David Grudl  

PHP w wersji 8.0 jest właśnie wydawane. Jest ona pełna nowych rzeczy, jak żadna inna wersja wcześniej. Ich wprowadzenie zasługiwało na cztery osobne artykuły. W trzeciej części przyjrzymy się atrybutom.

Atrybuty zapewniają zupełnie nowy sposób pisania ustrukturyzowanych metadanych dla klas i wszystkich jej członków, a także funkcji, domknięć i ich parametrów. Do tej pory używano do tego celu komentarzy PhpDoc, ale ich składnia zawsze była tak luźna i niespójna, że nie dało się uruchomić maszynowego ich przetwarzania. Dlatego są one zastępowane przez atrybuty o ustalonej składni i wsparciu w klasach refleksyjnych.

Z tego powodu biblioteki, które do tej pory pobierały metadane poprzez parsowanie komentarzy phpDoc będą mogły zastąpić je atrybutami. Przykładem jest Nette, gdzie w najnowszych wersjach Application i DI można już używać atrybutów Persistent, CrossOrigin i Inject zamiast adnotacji @persistent, @crossOrigin i @inject.

Kod wykorzystujący adnotacje:

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

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

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

To samo z atrybutami:

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 ocenia nazwy atrybutów tak samo, jakby były one klasami, w kontekście przestrzeni nazw i klauzul use. Można by więc nawet napisać je np. w następujący sposób:

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, ale nie musi istnieć. Ale zdecydowanie lepiej, jeśli istnieje, ponieważ wtedy edytor może sugerować go podczas pisania, analizator statyczny rozpoznaje 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ć także w kodzie, który powinien działać w starszych wersjach.

Składnia pojedynczego atrybutu wygląda jak tworzenie instancji obiektu, jeśli pominiemy operator new. Czyli nazwa klasy, a po niej opcjonalne argumenty w nawiasach:

#[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 mogą być zapisane pojedynczo lub oddzielone 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;

Proste wyrażenia i stałe, które mogą być oceniane podczas kompilacji i są używane jako wartości domyślne dla właściwości, mogą być używane jako argumenty w atrybutach:

#[
	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ść argumentu nie może być innym atrybutem, czyli atrybuty nie mogą być zagnieżdżone. Na przykład nie ma prostego sposobu, aby następująca adnotacja używana w Doctrine została przekształcona 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.

Odzwierciedlenie atrybutu

To, jakie atrybuty posiadają poszczególne elementy, można ustalić za pomocą refleksji. Klasy refleksyjne posiadają nową metodę getAttributes(), która zwraca tablicę obiektów ReflectionAttribute.

use MyAttributes\Example;

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

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

foreach ($reflection->getAttributes() as $attribute) {
	$attribute->getName();      // full attribute name, e.g. MyAttributes\Example
	$attribute->getArguments(); // ['Hello', 123]
	$attribute->newInstance();  // returns an instance: new MyAttributes\Example('Hello', 123)
}

Zwracane atrybuty mogą być filtrowane przez parametr, np. $reflection->getAttributes(Example::class) zwraca tylko atrybuty Example.

Klasy atrybutów

Klasa atrybutów MyAttributes\Example nie może istnieć. Dopiero wywołanie metody newInstance() wymaga jej istnienia, bo ją instancjonuje. Napiszmy więc ją. Będzie to zupełnie zwykła klasa, tylko musimy dostarczyć atrybut Attribute (czyli z globalnej przestrzeni nazw systemu):

namespace MyAttributes;

use Attribute;

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

Można ograniczyć, dla jakich elementów języka użycie atrybutu będzie dozwolone. Na przykład 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 i domyślna TARGET_ALL.

Ale uwaga, sprawdzenie poprawności lub niepoprawności użycia następuje zaskakująco dopiero po wywołaniu metody newInstance(). Sam kompilator nie wykonuje tego sprawdzenia.

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 od pierwszej wersji używało adnotacji do wskazywania trwałych parametrów i komponentów, nie były one masowo używane, ponieważ nie były konstrukcją rodzimego języka, więc redaktorzy nie sugerowali się nimi i łatwo było popełnić błąd. Nawet jeśli jest to już rozwiązywane przez wtyczki do edytorów, to naprawdę natywny sposób, który przynoszą atrybuty, otwiera zupełnie nowe możliwości.

Przy okazji atrybuty zyskały wyjątek w Standardzie Kodowania Nette, który wymaga, aby nazwa klasy oprócz specyfiki (np. Product, InvalidValue) zawierała także ogólność (czyli ProductPresenter, InvalidValueException). W przeciwnym razie, po użyciu w kodzie, nie byłoby jasne, co dokładnie reprezentuje dana klasa. W przypadku atrybutów nie jest to pożądane, dlatego klasa nazywa się Inject zamiast InjectAttribute.

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