Memoization in PHP: Reine Funktionen automatisch cachen
AI generated
<?php
8.4
PHP · Funktionale Programmierung · Performance
Memoization in PHP
Reine Funktionen automatisch cachen

Memoization speichert das Ergebnis einer Funktion beim ersten Aufruf und liefert es bei jedem weiteren Aufruf mit denselben Argumenten sofort aus dem Cache. Weil dieses Verfahren nur bei referenziell transparenten, reinen Funktionen garantiert korrekt ist, verbindet Memoization funktionale Programmierung und Performance-Optimierung in PHP 8.4 direkt miteinander.

17 Min. Lesezeit memoize · Caching · Pure Functions PHP 8.2 · 8.3 · 8.4

1. Was Memoization ist und warum sie reine Funktionen voraussetzt

Memoization ist eine Optimierungstechnik, bei der das Ergebnis eines Funktionsaufrufs beim ersten Mal berechnet und zusammen mit den verwendeten Argumenten in einem Cache abgelegt wird. Bei jedem weiteren Aufruf mit denselben Argumenten liefert die memoisierte Funktion das gespeicherte Ergebnis direkt zurück, ohne die eigentliche Berechnung erneut auszuführen. Der Name leitet sich vom lateinischen Wort für Gedächtnis ab und beschreibt genau dieses Verhalten: die Funktion erinnert sich an bereits berechnete Ergebnisse.

Diese Technik funktioniert nur korrekt, wenn die zu memoisierende Funktion eine Pure Function ist, also bei gleichen Argumenten garantiert immer dasselbe Ergebnis liefert und keine externen Zustände abfragt. Würde man eine Funktion memoisieren, die etwa die aktuelle Uhrzeit oder den Inhalt einer sich ändernden Datenbanktabelle abfragt, würde der Cache irgendwann veraltete, falsche Werte zurückgeben, weil die zugrunde liegende Voraussetzung von Memoization, gleiche Eingabe gleich gleiche Ausgabe, verletzt wäre.

In PHP lässt sich Memoization ohne Framework und ohne externe Abhängigkeit direkt mit Closures und Arrays umsetzen. Dieser Artikel zeigt, wie eine generische memoize-Funktion für beliebige Parameteranzahlen aussieht, wie man Cache-Keys für Objekte und Arrays erzeugt, und wo die praktischen Grenzen dieser Technik liegen.

2. Eine minimale memoize()-Funktion für einen Parameter

Die einfachste Form von Memoization nimmt eine Closure entgegen und gibt eine neue Closure zurück, die vor jedem Aufruf prüft, ob das Argument bereits im internen Cache-Array vorhanden ist. Ist es vorhanden, wird der gespeicherte Wert zurückgegeben, ohne die ursprüngliche Funktion erneut auszuführen. Ist es nicht vorhanden, wird die ursprüngliche Funktion aufgerufen, das Ergebnis im Cache abgelegt und anschließend zurückgegeben.

Entscheidend für diese Implementierung ist die Verwendung von use (&$cache) mit Referenz, weil der Cache über mehrere Aufrufe der zurückgegebenen Closure hinweg erhalten bleiben muss. Ohne die Referenz würde bei jedem Aufruf eine frische Kopie des Cache-Arrays verwendet, wodurch Memoization wirkungslos bliebe, weil kein Aufruf je einen Cache-Treffer finden könnte.


<?php

declare(strict_types=1);

/**
 * Memoize a single-argument pure function.
 *
 * @return Closure(int): int
 */
function memoize(Closure $fn): Closure
{
    $cache = [];

    return function (int $arg) use ($fn, &$cache): int {
        if (!array_key_exists($arg, $cache)) {
            $cache[$arg] = $fn($arg);
        }

        return $cache[$arg];
    };
}

$callCount = 0;

$expensiveSquare = function (int $n) use (&$callCount): int {
    $callCount++;
    usleep(50_000); // simulate an expensive computation
    return $n * $n;
};

$memoizedSquare = memoize($expensiveSquare);

echo $memoizedSquare(12); // computed, callCount becomes 1
echo $memoizedSquare(12); // returned from cache, callCount stays 1
echo $memoizedSquare(7);  // computed, callCount becomes 2

echo "Total calculations: {$callCount}"; // 2, not 3

3. Cache-Keys für mehrere Argumente erzeugen

Sobald eine Funktion mehr als ein Argument entgegennimmt, reicht ein einfacher Array-Schlüssel wie im vorherigen Beispiel nicht mehr aus, weil PHP-Arrays nur skalare oder String-Schlüssel unterstützen. Die praktische Lösung besteht darin, alle Argumente zu einem eindeutigen String zusammenzufassen, etwa mit serialize() oder mit json_encode(), und diesen String als Cache-Key zu verwenden. Wichtig dabei: die Serialisierung muss deterministisch sein, damit dieselbe Argumentkombination immer denselben Schlüssel erzeugt.

Ein häufiger Fehler bei mehreren Argumenten ist, sie einfach mit einem Trennzeichen wie einem Komma zu verketten, ohne den Typ zu berücksichtigen. Der String "1,2" könnte dann sowohl aus den Argumenten (1, 2) als auch aus ("1", 2) entstehen, wodurch zwei eigentlich unterschiedliche Aufrufe denselben Cache-Eintrag teilen würden. serialize() behält Typinformationen bei und vermeidet diese Kollision zuverlässig.


<?php

declare(strict_types=1);

/**
 * Memoize a function accepting any number of scalar arguments.
 *
 * @return Closure(mixed ...$args): mixed
 */
function memoizeVariadic(Closure $fn): Closure
{
    $cache = [];

    return function (mixed ...$args) use ($fn, &$cache): mixed {
        // serialize() keeps type information, avoiding key collisions
        $key = serialize($args);

        if (!array_key_exists($key, $cache)) {
            $cache[$key] = $fn(...$args);
        }

        return $cache[$key];
    };
}

$calculateShipping = function (float $weight, string $country, bool $express): float {
    $base = $weight * 2.5;
    $countryFactor = $country === 'DE' ? 1.0 : 1.4;
    $expressFactor = $express ? 1.8 : 1.0;

    return round($base * $countryFactor * $expressFactor, 2);
};

$memoizedShipping = memoizeVariadic($calculateShipping);

echo $memoizedShipping(3.5, 'DE', false); // computed once
echo $memoizedShipping(3.5, 'DE', false); // cache hit, identical arguments
echo $memoizedShipping(3.5, 'DE', true);  // computed, different arguments

4. Memoization mit Objekten und Arrays als Argumenten

Objekte als Argumente bringen eine zusätzliche Schwierigkeit mit sich: serialize() funktioniert zwar auch mit Objekten, erzeugt aber bei zwei Objekten mit identischem Zustand nur dann denselben Schlüssel, wenn beide Objekte tatsächlich dieselben Eigenschaftswerte tragen. Das ist bei einfachen, unveränderlichen Value Objects meist unkritisch, wird aber problematisch, sobald ein Objekt private, nicht relevante interne Zustände mitschleppt, die den serialisierten String unnötig verändern, ohne das eigentliche Ergebnis der Funktion zu beeinflussen.

Eine robustere Alternative für Objekte ist, den Cache-Key explizit aus den relevanten Eigenschaften selbst zu bilden, statt sich auf die automatische Serialisierung des gesamten Objekts zu verlassen. Diese explizite Schlüsselbildung macht sichtbar, welche Objekteigenschaften tatsächlich das Ergebnis beeinflussen, und verhindert, dass irrelevante interne Details unnötig viele Cache-Einträge erzeugen.


<?php

declare(strict_types=1);

final class TaxContext
{
    public function __construct(
        public readonly string $countryCode,
        public readonly string $vatClass,
    ) {
    }
}

/**
 * Memoize using an explicit key builder instead of raw serialization.
 *
 * @return Closure(float, TaxContext): float
 */
function memoizeWithKeyBuilder(Closure $fn, Closure $keyBuilder): Closure
{
    $cache = [];

    return function (float $amount, TaxContext $context) use ($fn, $keyBuilder, &$cache): float {
        $key = $keyBuilder($amount, $context);

        if (!array_key_exists($key, $cache)) {
            $cache[$key] = $fn($amount, $context);
        }

        return $cache[$key];
    };
}

$calculateTax = function (float $amount, TaxContext $context): float {
    $rate = match ($context->vatClass) {
        'standard' => 0.19,
        'reduced' => 0.07,
        default => 0.0,
    };

    return round($amount * $rate, 2);
};

// Key only depends on the fields that actually influence the result
$keyBuilder = fn (float $amount, TaxContext $ctx): string =>
    sprintf('%.2f|%s|%s', $amount, $ctx->countryCode, $ctx->vatClass);

$memoizedTax = memoizeWithKeyBuilder($calculateTax, $keyBuilder);

echo $memoizedTax(100.0, new TaxContext('DE', 'standard')); // 19.0

5. Speicherverbrauch begrenzen: LRU und Cache-Größe

Eine naive Memoization-Implementierung wächst unbegrenzt, weil jedes neue Argument einen neuen Cache-Eintrag erzeugt, der nie wieder entfernt wird. Bei Funktionen mit wenigen möglichen Argumentkombinationen ist das unkritisch, bei Funktionen mit sehr vielen oder gar unbegrenzt vielen möglichen Eingaben führt es aber zu stetig wachsendem Speicherverbrauch, besonders in langlaufenden PHP-Prozessen wie Swoole-Workern oder CLI-Daemons.

Die gängige Lösung ist eine Least-Recently-Used-Strategie, kurz LRU: der Cache erhält eine feste Maximalgröße, und sobald diese Grenze erreicht ist, wird der am längsten nicht mehr verwendete Eintrag entfernt, bevor ein neuer hinzukommt. PHP-Arrays behalten die Einfügereihenfolge bei, was sich für eine einfache LRU-Implementierung ausnutzen lässt: der älteste Schlüssel steht am Anfang des Arrays und kann mit array_key_first() gefunden und entfernt werden.


<?php

declare(strict_types=1);

/**
 * Memoize with a bounded cache using a simple LRU eviction strategy.
 *
 * @return Closure(int): int
 */
function memoizeWithLimit(Closure $fn, int $maxEntries = 100): Closure
{
    $cache = [];

    return function (int $arg) use ($fn, &$cache, $maxEntries): int {
        if (array_key_exists($arg, $cache)) {
            // Move to the end to mark as recently used
            $value = $cache[$arg];
            unset($cache[$arg]);
            $cache[$arg] = $value;

            return $value;
        }

        if (count($cache) >= $maxEntries) {
            $oldestKey = array_key_first($cache);
            unset($cache[$oldestKey]);
        }

        $cache[$arg] = $fn($arg);

        return $cache[$arg];
    };
}

$memoizedFactorial = memoizeWithLimit(
    fn (int $n): int => array_product(range(1, max($n, 1))),
    maxEntries: 50,
);

6. Memoization bei rekursiven Funktionen: Fibonacci als Klassiker

Das klassische Lehrbeispiel für Memoization ist die naive rekursive Fibonacci-Funktion, deren Laufzeit ohne Cache exponentiell mit der Eingabegröße wächst, weil dieselben Teilergebnisse immer wieder neu berechnet werden. Mit Memoization wird jedes Teilergebnis nur einmal berechnet, wodurch die Laufzeit von exponentiell auf linear sinkt, ein Unterschied, der bei größeren Eingaben den Unterschied zwischen Millisekunden und praktisch unendlicher Laufzeit ausmacht.

Bei rekursiven Funktionen ist eine Besonderheit zu beachten: die Closure muss sich selbst referenzieren können, um den Cache auch innerhalb der Rekursion zu nutzen. In PHP löst man das entweder über eine benannte Funktion mit statischem Cache oder über eine Closure, die sich selbst per Referenz erfasst, damit auch die rekursiven Aufrufe durch den Cache profitieren, statt nur der äußerste Aufruf.


<?php

declare(strict_types=1);

function fibonacciMemoized(int $n): int
{
    static $cache = [];

    if ($n <= 1) {
        return $n;
    }

    if (array_key_exists($n, $cache)) {
        return $cache[$n];
    }

    // Recursive calls also hit the same static cache
    $result = fibonacciMemoized($n - 1) + fibonacciMemoized($n - 2);
    $cache[$n] = $result;

    return $result;
}

echo fibonacciMemoized(40); // fast, linear time instead of exponential

7. Lebensdauer des Caches: Request, Prozess oder Redis

Ein memoisierter Cache in einem Array oder einer statischen Variable lebt nur solange wie der aktuelle PHP-Request, weil PHP nach jedem Request unter klassischem PHP-FPM-Betrieb den kompletten Speicher freigibt. Für Berechnungen, die innerhalb eines einzelnen Requests mehrfach mit denselben Argumenten aufgerufen werden, reicht dieser kurzlebige Cache völlig aus und ist die einfachste Form von Memoization.

Soll ein Cache über mehrere Requests hinweg bestehen bleiben, braucht es eine Persistenzschicht außerhalb des PHP-Prozesses, etwa Redis oder ein OPcache-basierter Shared-Memory-Ansatz. In langlaufenden Prozessen wie Swoole-Workern oder ReactPHP-Anwendungen lebt ein memoisierter Cache dagegen über viele Requests hinweg im selben Prozessspeicher, was die Wirkung von Memoization deutlich verstärkt, aber auch die Notwendigkeit einer Größenbegrenzung wichtiger macht.

8. Grenzen: wann Memoization gefährlich statt nützlich wird

Memoization einer unpure Funktion ist der klassische Fehler, der zu schwer auffindbaren Bugs führt: wird eine Funktion memoisiert, die von externem, veränderlichem Zustand abhängt, etwa einem Wechselkurs aus einer Datenbank, liefert der Cache nach der ersten Berechnung dauerhaft den ursprünglichen, mittlerweile veralteten Wert zurück, selbst wenn sich der Wechselkurs zwischenzeitlich geändert hat. Der Fehler zeigt sich oft erst Tage später und ist dann schwer auf die eigentliche Ursache zurückzuführen.

Ein zweiter Grenzfall betrifft Funktionen, deren Berechnung schneller ist als das Nachschlagen im Cache selbst. Bei sehr einfachen arithmetischen Operationen kann der Overhead der Cache-Verwaltung, Serialisierung des Schlüssels und Array-Zugriff, größer sein als die eigentliche Berechnung, wodurch Memoization die Performance sogar verschlechtert statt verbessert. Ein Benchmark vor der Einführung von Memoization ist deshalb bei unklaren Fällen unverzichtbar.

9. Memoization-Strategien im Vergleich

Je nach Anwendungsfall eignet sich eine andere Variante von Memoization. Die folgende Tabelle ordnet die wichtigsten Strategien ein.

Situation Strategie Vorteil Risiko
Ein Skalar-Argument Einfaches Array als Cache Minimaler Code, sehr schnell Kein Größenlimit
Mehrere Skalar-Argumente serialize() als Schlüssel Typsicher, einfach zu implementieren Serialisierungs-Overhead
Objekte als Argumente Expliziter Key-Builder Nur relevante Felder im Schlüssel Mehr eigener Code nötig
Unbegrenzt viele Eingaben LRU mit Größenlimit Kontrollierter Speicherverbrauch Mögliche Cache-Misses bei Rotation
Über mehrere Requests hinweg Redis oder Shared Cache Persistenz über Prozessgrenzen Netzwerk-Overhead, Invalidierung nötig

Die Grundregel: Memoization lohnt sich immer dann, wenn eine Pure Function mit wiederkehrenden, überschaubar vielen Argumentkombinationen aufgerufen wird und die Berechnung selbst spürbar teurer ist als ein Array-Zugriff. Bei unklarer Reinheit der Funktion oder unbegrenzt vielen möglichen Argumenten ist besondere Vorsicht mit Größenlimit und Invalidierungsstrategie geboten.

Mironsoft

PHP-Performance-Analyse und funktionale Muster im Team-Alltag

Wiederholte teure Berechnungen in eurem PHP-Code?

Wir identifizieren teure, wiederholt aufgerufene Funktionen und zeigen, wo sicheres Memoization mit passender Cache-Strategie spürbare Performance-Gewinne bringt.

Performance-Analyse

Profiling zur Identifikation memoisierbarer, teurer Funktionen

Implementierung

Memoization mit passenden Cache-Keys und Größenlimit einführen

Schulung

Sichere Anwendung von Memoization praxisnah im Team vermitteln

10. Zusammenfassung

Memoization speichert das Ergebnis einer Funktion beim ersten Aufruf und liefert es bei gleichen Argumenten sofort aus dem Cache zurück, ohne die Berechnung erneut auszuführen. Diese Technik ist nur bei Pure Functions garantiert korrekt, weil sie voraussetzt, dass gleiche Eingaben immer gleiche Ausgaben liefern. Eine minimale memoize()-Funktion lässt sich mit einer Closure und einem Cache-Array umsetzen, für mehrere Argumente sorgt serialize() für eindeutige, typsichere Cache-Keys.

Bei rekursiven Funktionen wie Fibonacci senkt Memoization die Laufzeit von exponentiell auf linear. Für unbegrenzt viele mögliche Eingaben ist eine LRU-Strategie mit Größenlimit nötig, um den Speicherverbrauch zu kontrollieren. Die größte Gefahr bleibt die Memoization unpurer Funktionen, deren Ergebnis von externem, veränderlichem Zustand abhängt, weil der Cache dann veraltete Werte liefert, ohne dass ein Fehler sofort sichtbar wird.

Memoization in PHP — Das Wichtigste auf einen Blick

Definition

Ergebnis eines Funktionsaufrufs wird zwischengespeichert und bei gleichen Argumenten direkt zurückgegeben.

Voraussetzung

Nur bei Pure Functions garantiert korrekt, gleiche Eingabe muss immer gleiche Ausgabe liefern.

Cache-Keys

serialize() für mehrere Skalare, expliziter Key-Builder für Objekte mit irrelevanten Feldern.

Speicherbegrenzung

LRU-Strategie mit array_key_first() verhindert unbegrenztes Wachstum in langlaufenden Prozessen.

11. FAQ: Memoization in PHP

1Was ist Memoization?
Ergebnis eines Funktionsaufrufs wird beim ersten Mal berechnet und im Cache abgelegt, bei gleichen Argumenten direkt zurückgegeben.
2Warum nur bei Pure Functions zuverlässig?
Weil gleiche Eingaben garantiert gleiche Ausgaben liefern müssen, sonst würde der Cache irgendwann veraltete Werte zurückgeben.
3Cache-Key bei mehreren Argumenten?
Mit serialize() über das Argumente-Array, weil es Typinformationen erhält und Kollisionen vermeidet.
4Objekte als Argumente?
Am robustesten mit einem expliziten Key-Builder, der nur relevante Felder in den Schlüssel einbezieht.
5Unbegrenztes Wachstum verhindern?
Mit einer LRU-Strategie, die den am längsten ungenutzten Eintrag entfernt, sobald eine Maximalgröße erreicht ist.
6Memoization bei Rekursion?
Über einen statischen Cache, damit auch rekursive Aufrufe von bereits berechneten Teilergebnissen profitieren.
7Wie lange lebt der Cache?
Bei PHP-FPM nur den aktuellen Request lang, in langlaufenden Prozessen über viele Requests hinweg.
8Über mehrere Requests hinweg möglich?
Ja, mit externer Persistenzschicht wie Redis, die den Cache außerhalb des PHP-Prozesses hält.
9Kann Memoization schaden?
Ja, wenn der Overhead größer ist als die eigentliche Berechnung. Benchmark vor Einführung ist ratsam.
10Größte Gefahr?
Memoisierung unpurer Funktionen, deren Cache dauerhaft veraltete Werte liefert, ohne sofort sichtbaren Fehler.