IdentityMap.php

PHP

Path: src/Util/IdentityMap.php

<?php
namespace mini\Util;

/**
 * Identity map pattern implementation with weak references
 *
 * Maintains a bidirectional mapping between unique identifiers and objects,
 * ensuring that only one instance exists for each identifier. Uses weak references
 * to avoid preventing garbage collection of objects that are no longer needed elsewhere.
 *
 * This is commonly used in ORM systems and service containers to ensure that multiple
 * requests for the same entity/service return the exact same object instance (object identity).
 *
 * # Features
 *
 * - **Weak references**: Objects can be garbage collected when no longer referenced elsewhere
 * - **Bidirectional lookup**: Find object by ID or ID by object
 * - **Automatic cleanup**: Dead weak references are periodically removed
 * - **Type-safe**: Generic template ensures type consistency
 *
 * # Usage Example
 *
 * ```php
 * // Create identity map for User objects
 * $map = new IdentityMap();
 *
 * // Store a user
 * $user = new User(id: 123, name: 'John');
 * $map->remember($user, 123);
 *
 * // Later, retrieve by ID - returns the exact same instance
 * $sameUser = $map->tryGet(123);
 * assert($sameUser === $user); // true - same object instance
 *
 * // When all external references are gone, object can be garbage collected
 * unset($user, $sameUser);
 * // Next lookup returns null (object was garbage collected)
 * $map->tryGet(123); // null
 * ```
 *
 * @template T of object The type of objects stored in this identity map
 */
final class IdentityMap
{
    /** @var array<string|int, \WeakReference<T>> Mapping from ID to weak reference */
    private array $byId = [];

    /** @var \WeakMap<T, string|int> Mapping from object to ID (auto-cleaned by PHP) */
    private \WeakMap $byObj;

    /** @var int Operation counter for periodic cleanup */
    private int $ops = 0;

    /** @var int Perform cleanup sweep every N operations */
    private int $sweepEvery;

    /**
     * Create a new identity map
     *
     * @param int $sweepEvery How often to sweep for dead weak references (default: 200 operations)
     *                        Minimum value is 10. Lower values mean more frequent cleanup but
     *                        higher overhead. Higher values mean less overhead but dead references
     *                        linger longer.
     *
     * @example
     * ```php
     * // Default cleanup interval
     * $map = new IdentityMap();
     *
     * // More aggressive cleanup (every 50 operations)
     * $map = new IdentityMap(sweepEvery: 50);
     *
     * // Less frequent cleanup (every 1000 operations)
     * $map = new IdentityMap(sweepEvery: 1000);
     * ```
     */
    public function __construct(int $sweepEvery = 200)
    {
        $this->byObj = new \WeakMap();
        $this->sweepEvery = max(10, $sweepEvery);
    }

    /**
     * Try to retrieve an object by its identifier
     *
     * Returns the object if it exists and hasn't been garbage collected,
     * or null if the object doesn't exist or has been garbage collected.
     *
     * Dead weak references are automatically cleaned up when encountered.
     *
     * @param string|int $id The unique identifier
     * @return T|null The object if found and still alive, null otherwise
     *
     * @example
     * ```php
     * $user = $map->tryGet(123);
     * if ($user !== null) {
     *     // Object exists and is still alive
     *     echo $user->name;
     * } else {
     *     // Object doesn't exist or was garbage collected
     *     $user = new User(id: 123);
     *     $map->remember($user, 123);
     * }
     * ```
     */
    public function tryGet(string|int $id): ?object
    {
        $ref = $this->byId[$id] ?? null;
        if (!$ref) return null;

        $obj = $ref->get();
        if ($obj === null) { // dead, clean lazily
            unset($this->byId[$id]);
            return null;
        }
        $this->tick();
        return $obj;
    }

    /**
     * Store an object with its identifier
     *
     * Associates the given object with the provided identifier using a weak reference.
     * If an object with the same ID already exists, it will be replaced.
     *
     * The object can be garbage collected when all external references are removed,
     * at which point it will automatically be removed from the identity map.
     *
     * @param T $obj The object to store
     * @param string|int $id The unique identifier for this object
     *
     * @example
     * ```php
     * $user = new User(id: 123, name: 'John');
     * $map->remember($user, 123);
     *
     * // Later retrieval returns the exact same instance
     * $sameUser = $map->tryGet(123);
     * assert($sameUser === $user); // true
     *
     * // Can overwrite with new object
     * $newUser = new User(id: 123, name: 'Jane');
     * $map->remember($newUser, 123);
     * ```
     */
    public function remember(object $obj, string|int $id): void
    {
        $this->byId[$id] = \WeakReference::create($obj);
        $this->byObj[$obj] = $id;   // auto-removed when $obj is GC'd
        $this->tick();
    }

    /**
     * Remove an object from the map by its identifier
     *
     * Removes the mapping for the given identifier. The object itself is not
     * affected and will be garbage collected normally when no external references remain.
     *
     * @param string|int $id The identifier to remove
     *
     * @example
     * ```php
     * $map->remember($user, 123);
     * $map->tryGet(123); // Returns $user
     *
     * $map->forgetById(123);
     * $map->tryGet(123); // Returns null
     * ```
     */
    public function forgetById(string|int $id): void
    {
        unset($this->byId[$id]);
    }

    /**
     * Remove an object from the map by the object itself
     *
     * Removes the mapping for the given object. Useful when you have the object
     * but not its identifier.
     *
     * @param T $obj The object to remove
     *
     * @example
     * ```php
     * $user = new User(id: 123);
     * $map->remember($user, 123);
     *
     * // Remove by object reference
     * $map->forgetObject($user);
     * $map->tryGet(123); // Returns null
     * ```
     */
    public function forgetObject(object $obj): void
    {
        $id = $this->byObj[$obj] ?? null;
        if ($id !== null) unset($this->byId[$id], $this->byObj[$obj]);
    }

    private function tick(): void
    {
        if ((++$this->ops % $this->sweepEvery) === 0) $this->sweep();
    }

    private function sweep(): void
    {
        foreach ($this->byId as $id => $ref) {
            if ($ref->get() === null) unset($this->byId[$id]);
        }
    }
}