Referenzielle Transparenz als Fundament sauberen Codes
Eine Pure Function liefert bei gleichen Argumenten immer dasselbe Ergebnis und verändert nichts außerhalb ihres eigenen Rückgabewerts. Dieses Prinzip der referenziellen Transparenz macht PHP-Code vorhersagbar, leicht testbar und parallelisierbar, ganz ohne zusätzliches Framework oder neue Sprachsyntax.
Inhaltsverzeichnis
- 1. Was Pure Functions und referenzielle Transparenz bedeuten
- 2. Merkmale einer Pure Function im Detail
- 3. Referenzielle Transparenz konkret: Ausdruck durch Ergebnis ersetzen
- 4. Seiteneffekte erkennen und isolieren
- 5. Pure Functions in Klassen: Methoden ohne this-Mutation
- 6. Testbarkeit: warum Pure Functions ohne Mocks auskommen
- 7. Pure Functions und Performance: Vorhersagbarkeit und Caching
- 8. Grenzen: wann PHP-Code niemals vollständig pur sein kann
- 9. Pure Functions im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was Pure Functions und referenzielle Transparenz bedeuten
Eine Pure Function erfüllt zwei Bedingungen gleichzeitig: sie liefert bei denselben Argumenten immer dasselbe Ergebnis, und sie verändert außerhalb ihres eigenen Rückgabewerts nichts, keine Datenbank, keine globale Variable, keine Eigenschaft eines übergebenen Objekts. Diese zweite Bedingung wird als Freiheit von Seiteneffekten bezeichnet und ist der eigentliche Kern jeder Pure Function. Die Konsequenz aus beiden Bedingungen zusammen nennt man referenzielle Transparenz: ein Aufruf einer Pure Function kann jederzeit durch sein Ergebnis ersetzt werden, ohne das Verhalten des restlichen Programms zu ändern.
In PHP ist dieses Konzept keine neue Spracherweiterung, sondern eine Disziplin beim Schreiben von Funktionen und Methoden. Jede gewöhnliche PHP-Funktion kann pur oder unpur sein, je nachdem, ob sie auf externen Zustand zugreift oder ihn verändert. Der Unterschied zwischen einer Funktion, die einen Steuerbetrag berechnet, und einer Funktion, die zusätzlich eine Log-Zeile schreibt, ist genau dieser: die erste ist eine Pure Function mit referenzieller Transparenz, die zweite nicht mehr, obwohl beide dieselbe Kernberechnung enthalten.
Der praktische Nutzen zeigt sich überall dort, wo Code korrekt, testbar und nebenläufig sicher sein muss: Preisberechnungen, Validierungsregeln, Datentransformationen und Formatierungslogik lassen sich fast immer als Pure Functions schreiben. Dieser Artikel zeigt, wie man solche Funktionen erkennt, schreibt und von unvermeidbaren Seiteneffekten sauber trennt, mit lauffähigem PHP-8.4-Code in jedem Abschnitt.
2. Merkmale einer Pure Function im Detail
Das erste Merkmal, Determinismus, bedeutet, dass eine Pure Function niemals von etwas außerhalb ihrer Parameter abhängen darf. Eine Funktion, die intern date('Y'), rand() oder eine globale Konstante abfragt, ist nicht mehr deterministisch, weil ihr Ergebnis vom Aufrufzeitpunkt oder Zufall abhängt statt ausschließlich von den übergebenen Argumenten. Das zweite Merkmal, Seiteneffektfreiheit, verbietet jede Veränderung außerhalb der Funktion: kein Schreiben in eine Datei, keine Datenbankabfrage, keine Mutation eines als Referenz übergebenen Arrays, keine Änderung einer statischen Eigenschaft.
Ein Detail, das häufig übersehen wird: auch das Werfen einer Exception kann als Seiteneffekt gelten, wenn sie den normalen Kontrollfluss durch eine Ausnahme statt durch einen regulären Rückgabewert modelliert. Streng funktionale Sprachen kodieren Fehlerfälle deshalb über Rückgabetypen wie Result oder Either statt über Exceptions. In PHP ist dieser Purismus selten praktikabel, aber das Bewusstsein dafür hilft, zwischen erwartbaren Validierungsfehlern, die als Wert zurückgegeben werden sollten, und wirklich außergewöhnlichen Zuständen zu unterscheiden, für die eine Exception angemessen bleibt.
<?php
declare(strict_types=1);
// IMPURE: depends on external state (current time) and has no
// deterministic output for the same $birthYear across calls
function calculateAgeImpure(int $birthYear): int
{
return (int) date('Y') - $birthYear;
}
// PURE: same inputs always produce the same output, no hidden dependency
function calculateAge(int $birthYear, int $currentYear): int
{
return $currentYear - $birthYear;
}
echo calculateAge(1990, 2026); // always 36, regardless of when it runs
// IMPURE: mutates the array passed in, a hidden side effect for the caller
function addDiscountImpure(array &$prices, float $percent): void
{
foreach ($prices as $key => $price) {
$prices[$key] = $price * (1 - $percent / 100);
}
}
// PURE: returns a new array, leaves the original untouched
function withDiscount(array $prices, float $percent): array
{
return array_map(
fn (float $price): float => $price * (1 - $percent / 100),
$prices,
);
}
3. Referenzielle Transparenz konkret: Ausdruck durch Ergebnis ersetzen
Referenzielle Transparenz lässt sich mit einem einfachen Gedankenexperiment prüfen: kann ein Funktionsaufruf im Quelltext durch sein berechnetes Ergebnis ersetzt werden, ohne dass sich irgendetwas am Verhalten des Programms ändert? Bei einer Pure Function wie calculateAge(1990, 2026) ist die Antwort immer ja, der Ausdruck kann direkt durch 36 ersetzt werden. Bei einer Funktion, die zusätzlich eine Log-Zeile schreibt oder einen Zähler erhöht, verschwindet dieser Effekt beim Ersetzen durch den reinen Wert, das Substitutionsmodell bricht.
Dieses Substitutionsprinzip ist mehr als eine akademische Übung, es ist die Grundlage für sicheres Refactoring. Wenn ein Compiler oder ein Entwickler weiß, dass ein Ausdruck referenziell transparent ist, kann er ihn verschieben, einmal statt mehrfach auswerten, oder das Ergebnis zwischenspeichern, ohne das Programmverhalten zu riskieren. Genau diese Eigenschaft nutzen spätere Abschnitte über Memoization und Caching aus: nur bei referenzieller Transparenz ist ein Cache-Treffer garantiert korrekt, weil identische Eingaben zwangsläufig identische Ausgaben bedeuten.
<?php
declare(strict_types=1);
final class Money
{
public function __construct(
public readonly int $cents,
public readonly string $currency,
) {
}
// PURE: referentially transparent, can be replaced by its result
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return new self($this->cents + $other->cents, $this->currency);
}
}
$a = new Money(1000, 'EUR');
$b = new Money(250, 'EUR');
// This expression...
$total = $a->add($b);
// ...can always be substituted by its concrete result
$totalAgain = new Money(1250, 'EUR');
var_dump($total->cents === $totalAgain->cents); // true, every single time
4. Seiteneffekte erkennen und isolieren
Vollständig reiner Code ist in einer realen Anwendung unmöglich, weil jede Anwendung irgendwann mit einer Datenbank, dem Dateisystem, der Uhrzeit oder externen Diensten sprechen muss. Der praktikable Weg ist nicht, Seiteneffekte zu eliminieren, sondern sie an die Ränder der Architektur zu verschieben. Dieses Muster heißt Functional Core, Imperative Shell: die Geschäftslogik, die Entscheidungen trifft und Werte berechnet, bleibt als Pure Function im Kern, während eine dünne äußere Schicht die eigentlichen Seiteneffekte wie Datenbankzugriffe oder HTTP-Aufrufe ausführt.
Praktisch bedeutet das: eine Funktion berechnet, ob ein Rabatt gewährt wird und wie hoch er ist, rein basierend auf übergebenen Werten, ohne selbst eine Datenbank abzufragen. Der Aufrufer, die Imperative Shell, lädt die benötigten Daten vorher, übergibt sie an die Pure Function und persistiert das Ergebnis danach. Diese Trennung macht den Kern der Geschäftslogik ohne Datenbank, ohne Mocks und ohne Netzwerk testbar, während die dünne äußere Schicht meist so einfach bleibt, dass sie kaum eigene Fehler produziert.
<?php
declare(strict_types=1);
final class OrderLine
{
public function __construct(
public readonly string $sku,
public readonly int $quantity,
public readonly float $unitPrice,
) {
}
}
// PURE core: all business logic, no I/O, no database, fully testable
function calculateOrderTotal(array $lines, float $loyaltyDiscount): float
{
$subtotal = array_reduce(
$lines,
fn (float $carry, OrderLine $line): float => $carry + $line->quantity * $line->unitPrice,
0.0,
);
return round($subtotal * (1 - $loyaltyDiscount), 2);
}
// IMPURE shell: the only place touching the database and the clock
final class OrderService
{
public function __construct(private readonly OrderRepository $repository)
{
}
public function checkout(int $orderId): float
{
$lines = $this->repository->findLines($orderId); // side effect: DB read
$discount = $this->repository->findLoyaltyDiscount($orderId); // side effect: DB read
$total = calculateOrderTotal($lines, $discount); // pure calculation
$this->repository->saveTotal($orderId, $total); // side effect: DB write
return $total;
}
}
5. Pure Functions in Klassen: Methoden ohne this-Mutation
Objektorientierter PHP-Code und Pure Functions schließen sich nicht aus. Eine Methode ist pur, wenn sie $this und ihre übergebenen Objekte niemals verändert, sondern stattdessen ein neues Objekt mit dem berechneten Ergebnis zurückgibt. readonly-Eigenschaften seit PHP 8.1 unterstützen dieses Muster direkt auf Sprachebene, weil eine einmal gesetzte Eigenschaft danach gar nicht mehr verändert werden kann, was viele klassische Seiteneffekte von vornherein ausschließt.
Der Unterschied zwischen einer mutierenden Setter-Methode und einer Pure Function-artigen Methode zeigt sich am deutlichsten bei Value Objects: statt $money->addCents(500), das den Zustand von $money verändert, gibt $money->add($other) ein neues Objekt zurück und lässt das Original unangetastet. Dieses Muster verhindert eine ganze Klasse von Bugs, bei denen ein Objekt an mehreren Stellen im Code gehalten wird und eine Änderung an einer Stelle unerwartet an einer anderen sichtbar wird, weil beide Stellen dieselbe Referenz teilen.
<?php
declare(strict_types=1);
final class ShoppingCart
{
/**
* @param list<OrderLine> $lines
*/
public function __construct(private readonly array $lines = [])
{
}
// PURE: returns a new cart, never mutates $this->lines
public function withLine(OrderLine $line): self
{
return new self([...$this->lines, $line]);
}
// PURE: computed purely from immutable state, no hidden dependency
public function total(): float
{
return array_reduce(
$this->lines,
fn (float $carry, OrderLine $line): float => $carry + $line->quantity * $line->unitPrice,
0.0,
);
}
}
$cart = new ShoppingCart();
$updatedCart = $cart->withLine(new OrderLine('SKU-1', 2, 19.90));
// The original cart is provably unchanged — no mutation happened anywhere
var_dump($cart->total() === 0.0); // true
var_dump($updatedCart->total() === 39.8); // true
6. Testbarkeit: warum Pure Functions ohne Mocks auskommen
Der größte praktische Vorteil von Pure Functions zeigt sich beim Testen. Eine Funktion ohne Seiteneffekte braucht kein Mock-Objekt, keinen In-Memory-Datenbank-Ersatz und keine Zeitmanipulation, um vorhersagbar getestet zu werden. Der Test ruft die Funktion mit konkreten Eingabewerten auf und prüft den Rückgabewert, fertig. Kein Setup einer Datenbankverbindung, kein Aufräumen nach dem Test, keine Reihenfolgeabhängigkeit zwischen Testfällen, weil keine Funktion einen versteckten Zustand hinterlässt, den ein anderer Test sehen könnte.
Dieser Effekt verstärkt sich mit wachsender Testsuite: unpure Funktionen mit Datenbankzugriff oder Dateisystemzugriff führen oft zu langsamen, manchmal instabilen Tests, die von externer Infrastruktur abhängen. Pure Functions lassen sich dagegen in Millisekunden ausführen, tausendfach parallel, ohne gegenseitige Beeinflussung. Wer den Functional-Core-Ansatz konsequent verfolgt, verschiebt den Großteil der Testfälle auf diese schnellen, robusten Unit-Tests und braucht nur wenige Integrationstests für die dünne, unpure Außenschicht.
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class CalculateOrderTotalTest extends TestCase
{
// No mocks, no database, no setUp() needed — pure input, pure output
public function testAppliesLoyaltyDiscountCorrectly(): void
{
$lines = [
new OrderLine('SKU-1', 2, 10.0),
new OrderLine('SKU-2', 1, 5.0),
];
$total = calculateOrderTotal($lines, 0.1);
self::assertSame(22.5, $total); // (20 + 5) * 0.9
}
public function testEmptyCartReturnsZero(): void
{
self::assertSame(0.0, calculateOrderTotal([], 0.2));
}
// Property-style test: the function is deterministic across many calls
public function testIsDeterministicAcrossRepeatedCalls(): void
{
$lines = [new OrderLine('SKU-1', 3, 7.5)];
$first = calculateOrderTotal($lines, 0.05);
$second = calculateOrderTotal($lines, 0.05);
self::assertSame($first, $second);
}
}
7. Pure Functions und Performance: Vorhersagbarkeit und Caching
Weil eine Pure Function bei identischen Argumenten garantiert dasselbe Ergebnis liefert, lässt sich ihr Ergebnis gefahrlos zwischenspeichern. Dieses Prinzip ist die Grundlage von Memoization, aber auch die theoretische Rechtfertigung für jede Form von Ergebnis-Caching in PHP: nur wenn eine Berechnung referenziell transparent ist, kann ein Cache-Treffer garantiert korrekt sein, ohne veraltete oder falsche Ergebnisse zu riskieren. Bei einer unpure Funktion, die von externem Zustand abhängt, wäre ein Cache dagegen ein Bug in Wartestellung.
Ein zweiter, weniger offensichtlicher Vorteil betrifft Nebenläufigkeit: Pure Functions können ohne Synchronisation parallel ausgeführt werden, weil sie keinen gemeinsamen veränderlichen Zustand teilen. In PHP mit Fibers oder externen Prozessen wie Swoole-Workern ist das relevant, weil parallele Aufrufe derselben Pure Function sich niemals gegenseitig durch Race Conditions beeinflussen können. Der JIT-Compiler in PHP 8.4 profitiert außerdem von vorhersagbaren, seiteneffektfreien Funktionen, weil deren Kontrollfluss leichter zu optimieren ist als der einer Funktion mit unklaren externen Abhängigkeiten.
8. Grenzen: wann PHP-Code niemals vollständig pur sein kann
Ein Web-Request-Handler in PHP kann per Definition niemals vollständig pur sein, weil sein einziger Zweck darin besteht, einen Seiteneffekt zu erzeugen, eine HTTP-Antwort zu senden. Ebenso müssen Konstruktoren, die eine Datenbankverbindung öffnen, oder Repository-Methoden, die Datensätze lesen und schreiben, per Natur unpur bleiben. Der Anspruch ist deshalb nicht, jede einzelne Funktion einer Anwendung zu einer Pure Function zu machen, sondern den Anteil an purem Code so groß wie möglich zu halten und die verbleibenden Seiteneffekte bewusst an klar erkennbaren Stellen zu bündeln.
Ein zweiter praktischer Grenzfall betrifft Logging und Metriken innerhalb ansonsten reiner Funktionen. Streng genommen ist jeder error_log()-Aufruf ein Seiteneffekt. In der Praxis toleriert man solche minimalen, für den Rückgabewert irrelevanten Effekte oft pragmatisch, sollte sie aber konsequent dokumentieren, damit klar bleibt, welche Funktionen tatsächlich vollständig referenziell transparent sind und welche nur nahezu pur, mit einem bewusst akzeptierten Nebeneffekt für Observability.
9. Pure Functions im direkten Vergleich
Nicht jeder Code-Bereich profitiert im selben Maß davon, konsequent als Pure Function geschrieben zu werden. Die folgende Tabelle zeigt typische Aufgaben in PHP-Anwendungen und ordnet sie danach ein, wie gut sie sich für referenziell transparenten Code eignen.
| Aufgabe | Typischer Ansatz | Pure-Function-Eignung | Empfehlung |
|---|---|---|---|
| Preis- und Rabattberechnung | Methode mit Objektmutation | Sehr hoch | Als Pure Function schreiben |
| Validierungsregeln | Werfende Methode mit Exception | Hoch | Ergebnis als Wert statt Exception zurückgeben |
| Datenbankzugriff | Repository-Methode | Niemals rein möglich | In dünner Imperative Shell isolieren |
| Zeitstempel-abhängige Logik | date() intern aufgerufen |
Niedrig ohne Anpassung | Zeitpunkt als Parameter übergeben |
| HTTP-Response senden | Controller-Aktion | Nie rein | Ganz am Rand der Architektur belassen |
Die Grundregel: je näher eine Funktion an der eigentlichen Fachlogik liegt, desto größer der Nutzen daraus, sie als Pure Function zu schreiben. Je näher eine Funktion an Infrastruktur wie Datenbank, Dateisystem oder Netzwerk liegt, desto mehr lohnt es sich, ihre Seiteneffekte bewusst zu akzeptieren und klar zu kennzeichnen, statt sie künstlich zu verschleiern.
Mironsoft
PHP-Architektur, Code-Reviews und funktionale Muster im Team-Alltag
Geschäftslogik, die sich ohne Mocks testen lässt?
Wir prüfen bestehenden PHP-Code auf versteckte Seiteneffekte und zeigen, wie sich Fachlogik als Pure Functions isolieren lässt, sauber getrennt von Datenbank, Zeit und Netzwerk.
Code-Review
Analyse auf versteckte Seiteneffekte und fehlende referenzielle Transparenz
Refactoring
Functional Core, Imperative Shell in bestehenden Modulen einführen
Schulung
Pure Functions und Testbarkeit praxisnah im Team vermitteln
10. Zusammenfassung
Pure Functions liefern bei gleichen Argumenten immer dasselbe Ergebnis und verändern nichts außerhalb ihres Rückgabewerts. Diese beiden Eigenschaften zusammen ergeben referenzielle Transparenz: ein Funktionsaufruf kann jederzeit durch sein Ergebnis ersetzt werden, ohne das Programmverhalten zu ändern. In PHP lässt sich dieses Prinzip durch das Muster Functional Core, Imperative Shell konsequent umsetzen, indem Geschäftslogik als Pure Functions geschrieben wird, während eine dünne äußere Schicht Datenbank, Dateisystem und Netzwerk anspricht.
Der Gewinn zeigt sich in drei Bereichen: Tests brauchen keine Mocks mehr und laufen in Millisekunden, Refactoring wird sicherer, weil Ausdrücke substituierbar bleiben, und Caching von Ergebnissen ist garantiert korrekt, weil identische Eingaben zwangsläufig identische Ausgaben liefern. Vollständige Reinheit ist in einer realen Anwendung unmöglich, aber jede zusätzliche Pure Function im Kern der Fachlogik reduziert die Fläche für unerwartete Bugs spürbar.
Pure Functions in PHP — Das Wichtigste auf einen Blick
Definition
Gleiche Argumente liefern immer dasselbe Ergebnis, keine Veränderung außerhalb des Rückgabewerts.
Referenzielle Transparenz
Ein Aufruf kann jederzeit durch sein Ergebnis ersetzt werden, Grundlage für sicheres Refactoring und Caching.
Architektur-Muster
Functional Core, Imperative Shell trennt reine Fachlogik von unvermeidbaren Seiteneffekten.
Testbarkeit
Keine Mocks, keine Datenbank, keine Reihenfolgeabhängigkeit zwischen Testfällen nötig.