Na navigaci | Klávesové zkratky

Readonly vlastnosti v PHP a jejich skrytá úskalí

S PHP 8.1 přichází mocný nástroj, který výrazně zlepšuje způsob, jakým můžeme zajistit integritu dat – readonly proměnné. Jednou nastavíte, navždy platí. Pojďme se podívat, jak vám mohou usnadnit život a na co si při jejich používání dát pozor.

Začněme jednoduchým příkladem, který ilustruje základní použití:

class User
{
	public readonly string $name;

	public function setName(string $name): void
	{
		$this->name = $name; // povolená inicializace
	}
}

$user = new User;
$user->setName('John'); // první volání - OK
echo $user->name;       // vypíše "John"
$user->setName('John'); // druhé volání - CHYBA: pokus o druhý zápis vyhodí výjimku
                        // Error: Cannot modify readonly property User::$name

Jak vidíte, jakmile je readonly proměnná jednou nastavena, nelze její hodnotu již změnit.

Inicializace a stav „uninitialized“

Jedním z častých nedorozumění je představa, že readonly proměnné musí být nastaveny pouze v konstruktoru. Ve skutečnosti je možné je inicializovat kdykoliv během životního cyklu objektu – ale pouze jednou. Před prvním přiřazením se nacházejí ve speciálním stavu ‚uninitialized‘. Logicky také nemohou mít výchozí hodnotu – to by popíralo samotný princip jednorázové inicializace.

Vyžaduje typy

Readonly proměnné vyžadují explicitní definici datového typu. Je to proto, že stav ‚uninitialized‘, který využívají, existuje pouze u typovaných proměnných. Bez uvedení typu tedy readonly proměnnou nelze definovat. Pokud typ neznáte, použijte mixed.

Readonly třídy

PHP 8.2 přineslo možnost posunout koncept readonly na vyšší úroveň – můžeme označit celou třídu jako readonly. Tím se automaticky všechny její instanční vlastnosti stanou readonly:

readonly class User
{
	public string $name;  // automaticky readonly
	public string $email; // automaticky readonly
}

Readonly třídy mají několik důležitých omezení:

  • nemohou obsahovat neotypované vlastnosti
  • nemohou obsahovat statické vlastnosti
  • nemohou používat dynamické vlastnosti (ani s atributem #[AllowDynamicProperties])
  • v PHP 8.2 platilo, že od readonly třídy mohla dědit pouze readonly třída, což od PHP 8.3 neplatí

Omezení scope a inicializace

Zajímavé je chování při pokusu o inicializaci readonly proměnné z různých kontextů. Následující kód překvapivě vyhodí výjimku, i když se jedná o první přiřazení:

$user = new User;
$user->name = 'John'; // CHYBA: Cannot initialize readonly property User::$name from global scope

Stejně tak není možné inicializovat readonly proměnnou ani z potomka třídy:

class Employee extends User
{
	public function setName(string $name): void
	{
		$this->name = 'EMP: ' . $name;
		// vyhodí výjimku: Cannot initialize readonly property User::$name from scope Employee
	}
}

$employee = new Employee;
$employee->setName('John Smith');

Readonly proměnnou lze tedy inicializovat výhradně ze třídy, která ji definovala. Přesněji řečeno šlo. PHP 8.4 totiž přináší dvě důležité změny:

  • Readonly proměnnou lze inicializovat i z potomka, tedy z třídy Employee
  • Pomocí modifikátoru public(set) lze povolit zápis do readonly proměnné i mimo scope třídy:
class User
{
	public(set) readonly string $name;
}

$user = new User;
$user->name = 'John'; // OK
echo $user->name; // OK

Readonly vs. skutečná neměnnost dat

Při práci s readonly proměnnými je klíčové pochopit, že samotné readonly nezaručuje úplnou neměnnost dat. Pokud do readonly proměnné uložíme objekt, jeho vnitřní stav zůstává modifikovatelný, objekt se automaticky nestává immutable (neměnným):

class Settings
{
	public string $theme = 'light';
}

class Configuration
{
	public function __construct(
		public readonly Settings $settings = new Settings,
	) {
	}
}

$config = new Configuration;
$config->settings->theme = 'dark'; // toto je povoleno, přestože $settings je readonly!

U polí je situace specifická. Přímá modifikace prvků pole není možná, protože PHP to považuje za změnu celého pole. Existuje však výjimka – pokud pole obsahuje reference, jejich obsah měnit můžeme, protože PHP to nepovažuje za změnu samotného pole. Toto chování je konzistentní s běžným fungováním PHP:

class Configuration
{
	public readonly array $settings;

	public function initialize(): void
	{
		$dynamicValue = 'development';
		$this->settings = ['mode' => 'debug', 'environment' => &$dynamicValue];
		var_dump($this->settings); // ['mode' => 'debug', 'environment' => 'development']
		$dynamicValue = 'production'; // povolená změna
		var_dump($this->settings); // ['mode' => 'debug', 'environment' => 'production']
	}
}

Naopak, přímá modifikace prvků pole není možná:

class Configuration
{
	public readonly array $settings;

	public function initialize(): void
	{
		$this->settings = ['debug' => true, 'cache' => false];
		$this->settings['cache'] = true; // vyhodí výjimku!
	}
}

Problém s „wither“ metodami

Při práci s neměnnými objekty (ať už kompletně nebo částečně) se často setkáváme s potřebou implementovat metody pro změnu stavu. Tyto metody, tradičně označované předponou with (oproti get), nemění původní objekt, ale vrací novou instanci (klon) objektu s požadovanou úpravou. Tento vzor využívá například specifikace PSR-7 pro práci s HTTP požadavky.

Samozřejmě by dávalo smysl označit takové objekty nebo jejich vlastnosti jako readonly. Narazíme však na technické omezení – readonly vlastnost nelze změnit ani ve wither metodě, a to ani v kopii objektu. PHP 8.3 sice přineslo možnost měnit readonly vlastnosti v metodě __clone(), ta ale sama o sobě nestačí, protože v ní nemáme přístup k nové hodnotě určené ke změně. Můžeme to však vyřešit pomocí následující obezličky:

class Request
{
	private array $changes = [];

	public function __construct(
		public readonly string $method = 'GET',
		public readonly array $headers = [],
	) {}

	public function withMethod(string $method): self
	{
		$this->changes['method'] = $method;
		$dolly = clone $this;
		$this->changes = [];
		return $dolly;
	}

	public function __clone()
	{
		foreach ($this->changes as $property => $value) {
			$this->$property = $value;
		}
		$this->changes = [];
	}
}

$request = new Request('GET');
$newRequest = $request->withMethod('POST');  // Původní $request zůstává s GET

Testování a BypassFinals

Při psaní testů můžeme narazit na problém, že readonly (stejně jako final) komplikují mockování a testování. Naštěstí existuje elegantní řešení v podobě knihovny BypassFinals.

Tato knihovna dokáže za běhu odstranit klíčová slova final a readonly z vašeho kódu, což umožňuje mockovat i třídy a metody, které jsou takto označené. Integrace s PHPUnit a dalšími testovacími frameworky je snadná:

// bootstrap.php nebo začátek test souboru
DG\BypassFinals::enable();

// Pokud chceme zachovat readonly a odstranit jen final:
DG\BypassFinals::enable(bypassReadOnly: false);

Shrnutí

Readonly proměnné představují mocný nástroj pro zlepšení bezpečnosti a předvídatelnosti vašeho kódu. Pamatujte na klíčové body:

  • Readonly proměnné lze inicializovat kdykoliv, ale pouze jednou
  • Vyžadují explicitní definici datového typu
  • Inicializace je možná pouze ve scope třídy, která je definuje (do PHP 8.4)
  • Readonly neznamená automaticky immutable – zejména u objektů
  • S příchodem PHP 8.2 můžeme jako readonly označit celé třídy
  • Pro testování lze použít BypassFinals
  • Od PHP 8.4 přichází větší flexibilita díky modifikátoru public(set)

včera v rubrice PHP | blog píše David Grudl | nahoru

Mohlo by vás zajímat

Komentáře

  1. Adam #1

    avatar

    Díky za článek, pomohl nyní na jednom webu.

    před 2 lety | odpovědět

Napište komentář

Text komentáře
Kontakt

(kvůli gravataru)



*kurzíva* **tučné** "odkaz":http://example.com /--php phpkod(); \--

phpFashion © 2004, 2024 David Grudl | o blogu

Ukázky zdrojových kódů smíte používat s uvedením autora a URL tohoto webu bez dalších omezení.