Value Object
Oft haben wir es beim Programmieren mit zwei oder mehr Informationen zu tun, die eng zusammengehören. Beispiele dafür sind 8 Meter, 3 Liter, 4 Kilogramm oder 12,50 Euro. Natürlich könnten wir solche Werte jeweils mit einzelnen skalaren Variablen repräsentieren:
<?php declare(strict_types=1);
$amount = 200;
$currency = 'EUR';
do_something($amount, $currency);
function do_something(int $amount, string $currency): void
{
// ...
}
Dabei geht aber schnell was schief, weil zusammengehörige Information durch voneinander unabhängige Variablen repräsentiert wird. Wir könnten leicht etwas durcheinander bringen:
<?php declare(strict_types=1);
$amount1 = 200;
$currency1 = 'EUR';
$amount2 = 200;
$currency2 = 'GBP';
do_something($amount1, $currency2);
function do_something(int $amount, string $currency): void
{
// ...
}
PHP unterstützt keine vom Entwickler definierten Datenstrukturen. Wir könnten die beiden Variablen also in einem assoziativen Array zusammenfassen. Das könnte in etwa so aussehen:
<?php declare(strict_types=1);
$money = [
'amount' => 200,
'currency' => 'EUR'
];
Nun müssten wir allerdings - zumindest dann, wenn wir sauber arbeiten wollen - bei jedem Zugriff prüfen, ob der entsprechende Schlüssel im Array überhaupt existiert, da wir ansonsten einen Hinweis produzieren und eher unerwartet mit einem Nullwert weiterarbeiten.
Noch schlimmer aber ist, dass jeder alle Werte jederzeit nicht nur lesen, sondern auch schreiben und damit verändern kann. Das ist ja ein bißchen wie das Problem mit den globalen Variablen: es gerät viel zu schnell außer Kontrolle.
Die beste Lösung ist daher, die beiden Variablen in einem Objekt zusammenzufassen. Weil dieses Objekt einen fachlichen Wert repräsentiert, nennt man es auch ein Wertobjekt (Value Object).
<?php declare(strict_types=1);
class Money
{
public function __construct(
private int $amount,
private string $currency
) {}
}
Jetzt müssen wir uns allerdings in ein relativ technisches Detail wühlen, um ein großes Problem unserer Lösung zu verstehen.
Werte und Referenzen
PHP unterscheidet beim Aufruf von Methoden beziehungsweise Funktionen zwischen der Parameterübergabe im call by value- und dem call by reference-Verfahren. Für skalare Werte wird beim Aufruf im lokalen Gültigkeitsbereich der Funktion beziehungsweise Methode eine Kopie der übergebenen Daten erzeugt. Wenn als Parameter Objekte übergeben werden, arbeitet PHP mit Referenzen. Das ermöglicht die Programmierung mit Seiteneffekten, wenn nämlich der Zustand der übergebenen Objekte durch Methodenaufrufe verändert wird.
Exkurs: Was ich hier beschrieben habe, ist das sichtbare Verhalten von PHP. Hinter den Kulissen arbeitet PHP anders, nämlich auch beim call by value mit (internen) Referenzen und Referenzzählern. Das bedeutet, dass PHP viel schlauer agiert als wir denken, weil nämlich eine Kopie von Daten tatsächlich nur dann erzeugt wird, wenn diese im lokalen Gültigkeitsbereich auch verändert wird. Das nennt man übrigens Copy on Write. Das ist übrigens auch der Grund dafür, warum man in PHP bis auf wenige Ausnahmefälle nicht explizit mit Referenzen arbeiten möchte.
Wenn wir nun ein Objekt beziehungsweise eine Klasse verwenden, um einen fachlichen aus zwei oder mehr Informationen bestehenden Wert zu repräsentieren, dann haben wir ein Problem mit der Semantik beim Aufruf, denn ein Wert möchte kopiert werden, während das Objekt aber per Referenz übergeben wird.
Wundersame Geldvermehrung
Das führt zu dem lustigen Problem der wundersamen Geldvermehrung. Stellen wir uns ein (falsch implementiertes) Wertobjekt vor, das Geld repräsentiert. Das Objekt hat als Member den Betrag (amount
) und die Währung (currency
). Wir repräsentieren den Betrag als Integer in Cent, um Rundungsfehler zu vermeiden.
<?php declare(strict_types=1);
class Money
{
public function __construct(
private int $amount,
private string $currency
) {}
public function increase(int $amount): void
{
$this->amount += $amount;
}
}
$myMoney = new Money(200, 'EUR');
$yourMoney = $myMoney;
// ...
$yourMoney->increase(100);
var_dump($myMoney, $yourMoney);
Dieses Objekt ist so natürlich nicht praxistauglich, weil jegliche Plausiblitätsprüfungen fehlen. Aber darum geht es uns gerade nicht. Ich erzeuge mir erst mal 20 EUR ($myMoney
). Dann schenke ich Dir diese 20 EUR ($yourMoney
). Eine Weile später, vielleicht weil Du an der Börse spekuliert hast, hast Du mehr Geld und musst daher die $amount
erhöhen (increase()
):
mutable.php:23: class Money#1 (2) { private int $amount => int(300) private string $currency => string(3) "EUR" } mutable.php:23: class Money#1 (2) { private int $amount => int(300) private string $currency => string(3) "EUR" }
Aber was ist jetzt da passiert? Ich bin reich geworden, und das ganz von selbst. Das ist die wundersame Geldvermehrung, von der ich oben sprach. Eine tolle Sache, oder?
Im Ernst: das geht natürlich so nicht. Wenn wir das mal aus technischer Sicht betrachten wollen, dann halten nach meiner Schenkung noch immer wir beide den 20 EUR-Schein in der Hand. Das sehen wir daran, dass var_dump()
uns bei beiden Objekten jeweils #1
anzeigt, das bedeutet, dass sowohl $myMoney
als auch $yourMoney
das identische Objekt referenzieren.
Das ist aber nur erste Problem. Viel schlimmer wird es, wenn Du Dein Geld mehrst, indem Du die Methode increase()
aufrufst. Dadurch verändert sich dann auch der Betrag, den ich in der Hand habe. Okay, so wirklich gut für mich wäre es erst, wenn das Objekt eine withdraw()
-Methode hätte ...
Wenn wir statt eines Objektes nur skalare Werte verwenden würden, hätten wir hier kein Problem:
<?php declare(strict_types=1);
$myMoney = 200;
$yourMoney = $myMoney;
// ...
$yourMoney += 100;
var_dump($myMoney, $yourMoney);
scalar.php:10: int(200) scalar.php:10: int(300)
Jetzt sind wir aber wieder zurück bei der Tatsache, dass Betrag und Währung eng zusammengehören, aber als getrennte Variablen verwaltet werden. Das ist fehlerträchtig, weil es sehr leicht passieren kann, dass wir einen Betrag mit der falschen Währung zusammenmischen. Oder, weil globale Variablen in PHP ja ger nicht typsicher sind, völlig unsinnige Werte irgendwo reinschreiben.
Wir müssen also das Beste aus zwei Welten kombinieren: die call by value-Semantik und die Arbeit mit Objekten.
Unveränderlichkeit
Da wir die Art und Weise, wie PHP intern mit Objekten arbeitet, nicht verändern können, müssen wir Zustandsänderungen unseres Wertobjekts verbieten beziehungsweise unmöglich machen. Ein solches Objekt nennt man immutable (unveränderlich). Ein solches unveränderliches Objekt wird durch den Konstruktor initialisiert und darf dann seinen Zustand nicht mehr verändern. Das können wir in aktuellen PHP-Versionen sehr schön durch öffentliche readonly
-Properties abbilden:
<?php declare(strict_types=1);
class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency
) {}
}
$money = new Money(200, 'EUR');
var_dump($money->amount, $money->currency);
$money->amount += 300;
Lesen darf hier jeder, aber verändern ist nicht möglich:
immutable-will-fail.php:13: int(200) immutable-will-fail.php:13: string(3) "EUR" PHP Fatal error: Uncaught Error: Cannot modify readonly property Money::$amount in immutable-will-fail.php:15 Stack trace: #0 {main} thrown in immutable-will-fail.php on line 15
Aber was machen wir jetzt mit unserer increase()
-Methode? Die lassen wir eine neue Instanz von Money
zurückgeben. Somit bleibt das ursprüngliche Objekt unverändert (und damit unveränderlich) und wir haben ein weitere, neu erzeugtes Objekt, das den veränderten Wert repräsentiert:
<?php declare(strict_types=1);
class Money
{
public function __construct(
public readonly int $amount,
public readonly string $currency
) {}
public function increase(int $amount): self
{
$this->amount += $amount;
}
}
$money = new Money(200, 'EUR');
var_dump($money->amount, $money->currency);
Als Rückgabetyp verwenden wir self
, das vom Compiler zu Money
aufgelöst wird.
money.php:18: int(200) money.php:18: string(3) "EUR"
Unsere Wertobjekte sind also im Prinzip Wegwerfobjekte. Wir erzeugen davon so viele Instanzen, wie wir brauchen. Die Garbage Collection räumt die Objekte schon auf, wenn sie nicht mehr referenziert werden. Die Erzeugung eines Money
-Objekts ist nicht aufwändig und geht daher sehr schnell.
Noch mehr Wertobjekte
Das Wertobjekt ist eines der wichtigsten Entwurfsmuster, das von den meisten Entwicklern viel zu wenig eingesetzt wird. Stattdessen gibt es viel zu viele Entities, weil deren Definition besagt, dass eine Entity ein Objekt mit Identität repräsentiert. Da jeder Record, der aus einer Datenbank kommt, nun mal einen Primary Key und damit einen Identifier hat, schließen viele Entwickler daraus, dass mehr oder weniger alles, was jemals aus einer Datenbank geladen wurde, eine Entität ist. ORMs lassen grüßen.
In ganz vielen Anwendungsfällen ist die Identität einer Sache nicht wichtig. Im Alltag interessiert uns die Identität eines 20 Euro-Scheins beispielsweise nicht, genau genommen interessiert uns vermutlich nicht einmal, ob wir einen 20 Euro-Schein oder beispielsweise zwei 10 Euro-Scheine in der Hand haben. Eine Zentralbank dagegen wird sich sehr wohl für den Identifier eines Geldscheins, nämlich die Seriennummer, interessieren. In diesem Kontext sollte der Geldschein als Entität repräsentiert werden, im Normalfall wird und ein Wertobjekt genügen.
Ein Wertobjekt ist kein DTO, da es Funktionialität enthält. Wir wollen beispielsweise sicherstellen, dass wir nicht unterschiedliche Währungen addieren:
<?php declare(strict_types=1);
$money = new Money(200, 'EUR');
$sum = $money->add(new Money(100, 'EUR'));
var_dump($sum);
class Money
{
public function __construct(
private int $amount,
private string $currency
) {}
public function add(self $that): self
{
$this->ensureSameCurrency($that);
return new self($this->amount + $that->amount, $this->currency);
}
private function ensureSameCurrency(self $that): void
{
if ($this->currency !== $that->currency) {
throw new MoneyException('Currency mismatch');
};
}
}
class MoneyException extends RuntimeException
{
}
Wenn wir gleiche Währungen addieren, geht alles gut:
Money.php:6: class Money#3 (2) { private int $amount => int(300) private string $currency => string(3) "EUR" }
Das Addieren von unterschiedlichen Währungen dagegen führt zu einer Exception:
<?php declare(strict_types=1);
$money = new Money(200, 'EUR');
$money->add(new Money(100, 'GBP'));
class Money
{
public function __construct(
private int $amount,
private string $currency
) {}
public function add(self $that): self
{
$this->ensureSameCurrency($that);
return new self($this->amount + $that->amount, $this->currency);
}
private function ensureSameCurrency(self $that): void
{
if ($this->currency !== $that->currency) {
throw new MoneyException('Currency mismatch');
};
}
}
class MoneyException extends RuntimeException
{
}
PHP Fatal error: Uncaught MoneyException: Currency mismatch in Money-will-fail.php:23 Stack trace: #0 Money-will-fail.php(15): Money->ensureSameCurrency() #1 Money-will-fail.php(4): Money->add() #2 {main} thrown in Money-will-fail.php on line 23
Ein Money
-Objekt hat in Wirklichkeit natürlich noch mehr Methoden, und die Währung würden wir nicht als string
, sondern vermutlich als Enum
repräsentieren. Das ist aber hier gerade nicht der Fokus.
Wertobjekte haben normalerweise eine equals()
-Methode, mit der sie vergleichen werden können.
Manchmal erstellen wir Wertobjekte, die nur auf einer einzigen skalaren Information basiert. Das ist immer dann sinnvoll, wenn es Plausiblitätsprüfungen beziehungsweise Einschränkungen im Wertebereich gibt, die erfüllt sein müssen. Anstelle sich entweder blind darauf zu verlassen, dass irgendjemand Daten vorab validiert hat oder alternativ eventuelle Prüfungen vor jeder Verwendung eines skalaren Werts zu wiederholen, kann man dann ein sich selbst validierendes Objekt erzeugen und herumreichen. Da Wertobjekte immer unveränderlich sind, liegt es in der Natur der Sache, dass sich die einmal im Konstruktor validierten Daten nie mehr ändern können. Wir haben es hier also mit einem Objekt zu tun, dass Datenqualität garantiert.