[PHP]spl_object_hash完全解説|オブジェクトの一意なハッシュ値を取得して同一性を判定する方法

PHP

はじめに

PHPでオブジェクトを扱うとき、「この2つの変数は同じオブジェクトを指しているか」「このオブジェクトを辞書のキーとして使いたい」といった要件が生じることがあります。

spl_object_hash() は、オブジェクトインスタンスに対して一意な文字列ハッシュを返す関数です。同じオブジェクト(同一インスタンス)なら必ず同じハッシュ、異なるオブジェクトなら必ず異なるハッシュが返ります。

ただし、PHPのオブジェクトは内部的に再利用されることがあるため、GCで回収されたオブジェクトのハッシュが新しいオブジェクトに再利用されるという注意点があります。この記事では仕様・使い方・落とし穴を詳しく解説します。


関数の基本情報

項目内容
関数名spl_object_hash()
利用可能バージョンPHP 5.2以降
所属SPL(Standard PHP Library)
戻り値string(32文字の16進数ハッシュ)
拡張機能SPL(デフォルトで有効)

シグネチャ

spl_object_hash(object $object): string

パラメータ

パラメータ説明
$objectobjectハッシュを取得したいオブジェクトインスタンス

戻り値の特性

特性内容
形式32文字の16進数文字列(md5ハッシュと同じ長さ)
同一インスタンス常に同じハッシュを返す
異なるインスタンス必ず異なるハッシュを返す(生存中)
ハッシュの再利用GCで回収後、新しいオブジェクトが同じハッシュを持つ場合がある

spl_object_id() との比較(PHP 7.2以降)

観点spl_object_hash()spl_object_id()
戻り値32文字の16進数文字列int(内部オブジェクトID)
利用可能バージョンPHP 5.2以降PHP 7.2以降
辞書キー用途✅ 文字列なので直接使える✅ intも使える
軽量さ△ 文字列生成のコスト✅ intなので高速
用途文字列ベースのキー・ログ数値IDが必要な場面

実践サンプル集(PHP 8.x対応)

サンプル1:基本動作の確認

<?php
declare(strict_types=1);

$objA = new stdClass();
$objB = new stdClass();
$objC = $objA; // 同じインスタンスへの参照

echo "objA hash: " . spl_object_hash($objA) . "\n";
echo "objB hash: " . spl_object_hash($objB) . "\n";
echo "objC hash: " . spl_object_hash($objC) . "\n";

echo "\n";
echo "A === B: " . ($objA === $objB ? 'true' : 'false') . "\n"; // false(別インスタンス)
echo "A === C: " . ($objA === $objC ? 'true' : 'false') . "\n"; // true(同一インスタンス)

echo "\n";
echo "hash(A) === hash(B): "
    . (spl_object_hash($objA) === spl_object_hash($objB) ? 'true' : 'false') . "\n"; // false
echo "hash(A) === hash(C): "
    . (spl_object_hash($objA) === spl_object_hash($objC) ? 'true' : 'false') . "\n"; // true

// プロパティが変わってもハッシュは変わらない
$objA->name = '変更しました';
echo "\nプロパティ変更後も同じハッシュ: "
    . (spl_object_hash($objA) === spl_object_hash($objC) ? 'true' : 'false') . "\n"; // true

実行結果:

objA hash: 000000003f2a1b5c0000000012a4e8d7
objB hash: 000000003f2a1c3a0000000012a4e8d7
objC hash: 000000003f2a1b5c0000000012a4e8d7

A === B: false
A === C: true

hash(A) === hash(B): false
hash(A) === hash(C): true

プロパティ変更後も同じハッシュ: true

解説: ハッシュはオブジェクトの内容ではなく同一性(アイデンティティ)に基づきます。プロパティを何度変更してもハッシュは変わりません。また $objC = $objA は代入ではなく参照のコピーであるため、同じハッシュになります。


サンプル2:オブジェクトを配列キーとして使う

PHPの配列はオブジェクトをキーにできないため、spl_object_hash() でキー化する古典的パターンです。

<?php
declare(strict_types=1);

class EventEmitter
{
    /** @var array<string, array<string, callable>> イベント名 → [オブジェクトハッシュ → コールバック] */
    private array $listeners = [];

    /**
     * リスナーを登録(同じオブジェクトが登録済みなら上書き)
     */
    public function on(string $event, object $listener, callable $callback): void
    {
        $hash = spl_object_hash($listener);
        $this->listeners[$event][$hash] = $callback;
        echo "[EventEmitter] 登録: {$event} / listener=" . get_class($listener) . "\n";
    }

    /**
     * リスナーを解除
     */
    public function off(string $event, object $listener): void
    {
        $hash = spl_object_hash($listener);
        unset($this->listeners[$event][$hash]);
        echo "[EventEmitter] 解除: {$event} / listener=" . get_class($listener) . "\n";
    }

    /**
     * イベントを発火
     */
    public function emit(string $event, mixed ...$args): void
    {
        foreach ($this->listeners[$event] ?? [] as $hash => $callback) {
            $callback(...$args);
        }
    }

    public function listenerCount(string $event): int
    {
        return count($this->listeners[$event] ?? []);
    }
}

class LogListener
{
    public function __construct(private readonly string $prefix) {}
    public function handle(string $message): void
    {
        echo "[{$this->prefix}] {$message}\n";
    }
}

$emitter = new EventEmitter();

$listenerA = new LogListener('A');
$listenerB = new LogListener('B');

$emitter->on('login', $listenerA, [$listenerA, 'handle']);
$emitter->on('login', $listenerB, [$listenerB, 'handle']);
$emitter->on('login', $listenerA, [$listenerA, 'handle']); // 重複登録 → 上書き

echo "\nリスナー数: " . $emitter->listenerCount('login') . "\n"; // 2(重複しない)

echo "\n--- login イベント発火 ---\n";
$emitter->emit('login', 'ユーザーがログインしました');

echo "\n--- listenerA を解除後 ---\n";
$emitter->off('login', $listenerA);
$emitter->emit('login', '再ログイン');

実行結果:

[EventEmitter] 登録: login / listener=LogListener
[EventEmitter] 登録: login / listener=LogListener
[EventEmitter] 登録: login / listener=LogListener

リスナー数: 2

--- login イベント発火 ---
[A] ユーザーがログインしました
[B] ユーザーがログインしました

--- listenerA を解除後 ---
[EventEmitter] 解除: login / listener=LogListener
[B] 再ログイン

解説: spl_object_hash() をキーにすることで、オブジェクトを辞書のキーとして使えます。同じオブジェクトの重複登録を自動的に防げる点がポイントです。


サンプル3:オブジェクトグラフの循環参照検出

再帰的な構造を走査する際に、訪問済みオブジェクトを追跡して循環を防ぐパターンです。

<?php
declare(strict_types=1);

class TreeNode
{
    public ?TreeNode $parent   = null;
    /** @var list<TreeNode> */
    public array $children = [];
    public string $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function addChild(TreeNode $child): self
    {
        $child->parent   = $this;
        $this->children[] = $child;
        return $this;
    }
}

/**
 * ツリーを再帰的に走査して循環参照を検出しながらシリアライズ
 *
 * @param array<string, bool> $visited ハッシュ → 訪問済みフラグ
 */
function serializeTree(TreeNode $node, array &$visited = [], int $depth = 0): string
{
    $hash = spl_object_hash($node);

    // 訪問済みなら循環参照
    if (isset($visited[$hash])) {
        return str_repeat('  ', $depth) . "⚠️  [循環参照: {$node->name}]\n";
    }

    $visited[$hash] = true;
    $indent = str_repeat('  ', $depth);
    $result = "{$indent}{$node->name}\n";

    foreach ($node->children as $child) {
        $result .= serializeTree($child, $visited, $depth + 1);
    }

    // スタックを巻き戻す(他のブランチに影響させない)
    unset($visited[$hash]);

    return $result;
}

// 通常のツリー
$root  = new TreeNode('root');
$nodeA = new TreeNode('A');
$nodeB = new TreeNode('B');
$nodeC = new TreeNode('C');

$root->addChild($nodeA)->addChild($nodeB);
$nodeA->addChild($nodeC);

echo "=== 通常のツリー ===\n";
echo serializeTree($root);

// 循環参照を作る
$nodeC->addChild($nodeA); // A → C → A(循環!)

echo "\n=== 循環参照ありのツリー ===\n";
$visited = [];
echo serializeTree($root, $visited);

実行結果:

=== 通常のツリー ===
root
  A
    C
  B

=== 循環参照ありのツリー ===
root
  A
    C
      ⚠️  [循環参照: A]
  B

解説: spl_object_hash() を訪問済みセットのキーに使うことで、オブジェクトグラフの循環を効率よく検出できます。同じオブジェクトを指す参照が複数あっても正確に追跡できます。


サンプル4:軽量なオブジェクトキャッシュ(WeakMapとの比較)

spl_object_hash() を使った手動のオブジェクトキャッシュと PHP 8.0+ の WeakMap の比較です。

<?php
declare(strict_types=1);

/**
 * spl_object_hash を使った手動オブジェクトキャッシュ
 * (PHP 8.0未満、またはWeakMapが使えない環境向け)
 */
class ObjectCache
{
    /** @var array<string, mixed> ハッシュ → キャッシュ値 */
    private array $cache = [];

    /** @var array<string, object> ハッシュ → 強参照(GC防止) */
    private array $refs = [];

    public function set(object $key, mixed $value): void
    {
        $hash                = spl_object_hash($key);
        $this->cache[$hash]  = $value;
        $this->refs[$hash]   = $key; // GCされないよう保持
    }

    public function get(object $key): mixed
    {
        return $this->cache[spl_object_hash($key)] ?? null;
    }

    public function has(object $key): bool
    {
        return isset($this->cache[spl_object_hash($key)]);
    }

    public function delete(object $key): void
    {
        $hash = spl_object_hash($key);
        unset($this->cache[$hash], $this->refs[$hash]);
    }

    public function count(): int
    {
        return count($this->cache);
    }
}

class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $name
    ) {}
}

// --- spl_object_hash を使った手動キャッシュ ---
$cache = new ObjectCache();

$alice = new User(1, 'Alice');
$bob   = new User(2, 'Bob');

$cache->set($alice, ['posts' => 42, 'comments' => 128]);
$cache->set($bob,   ['posts' => 7,  'comments' => 33]);

echo "=== ObjectCache ===\n";
echo "Alice: ";
print_r($cache->get($alice));
echo "Bob exists: " . ($cache->has($bob) ? 'yes' : 'no') . "\n";
echo "キャッシュ数: " . $cache->count() . "\n";

// --- PHP 8.0+ の WeakMap(推奨) ---
if (class_exists('WeakMap')) {
    echo "\n=== WeakMap(PHP 8.0+)===\n";
    $weakMap = new WeakMap();

    $weakMap[$alice] = ['posts' => 42, 'comments' => 128];
    $weakMap[$bob]   = ['posts' => 7,  'comments' => 33];

    echo "Alice posts: " . $weakMap[$alice]['posts'] . "\n";
    echo "キャッシュ数: " . count($weakMap) . "\n";

    // $bob を解放するとWeakMapから自動削除される
    unset($bob);
    echo "bob解放後の数: " . count($weakMap) . "\n"; // 1(自動削除)
}

実行結果:

=== ObjectCache ===
Alice: Array
(
    [posts] => 42
    [comments] => 128
)
Bob exists: yes
キャッシュ数: 2

=== WeakMap(PHP 8.0+)===
Alice posts: 42
キャッシュ数: 2
bob解放後の数: 1

解説: PHP 8.0以降では WeakMap を使うのがベストです(オブジェクトがGCされると自動削除)。それ以前のバージョンや特殊な用途では spl_object_hash() ベースの手動キャッシュが有効です。強参照($refs)で保持することでGCによるハッシュ再利用を防いでいます。


サンプル5:デバッグ用オブジェクトトラッカー

どこでオブジェクトが生成・参照されたかをハッシュで追跡するデバッグツールです。

<?php
declare(strict_types=1);

class ObjectTracker
{
    /** @var array<string, array{class: string, created: float, access_count: int, label: string}> */
    private static array $registry = [];

    /**
     * オブジェクトを登録して追跡開始
     */
    public static function track(object $obj, string $label = ''): object
    {
        $hash = spl_object_hash($obj);
        self::$registry[$hash] = [
            'class'        => get_class($obj),
            'created'      => microtime(true),
            'access_count' => 0,
            'label'        => $label ?: get_class($obj),
        ];
        return $obj;
    }

    /**
     * アクセスをカウント
     */
    public static function touch(object $obj): void
    {
        $hash = spl_object_hash($obj);
        if (isset(self::$registry[$hash])) {
            self::$registry[$hash]['access_count']++;
        }
    }

    /**
     * オブジェクトの追跡情報を取得
     */
    public static function info(object $obj): ?array
    {
        return self::$registry[spl_object_hash($obj)] ?? null;
    }

    /**
     * 全追跡オブジェクトのレポートを出力
     */
    public static function report(): void
    {
        if (empty(self::$registry)) {
            echo "追跡オブジェクトなし\n";
            return;
        }

        echo "=== ObjectTracker Report (" . count(self::$registry) . "件) ===\n";
        foreach (self::$registry as $hash => $info) {
            $age = round((microtime(true) - $info['created']) * 1000, 1);
            printf(
                "  %-30s %-10s アクセス:%3d回  生存: %sms  hash: %s\n",
                $info['label'],
                $info['class'],
                $info['access_count'],
                $age,
                substr($hash, 0, 8) . '...'
            );
        }
    }

    public static function reset(): void
    {
        self::$registry = [];
    }
}

// --- 使用例 ---
class Repository
{
    public function __construct(private readonly string $name) {}
    public function getName(): string { return $this->name; }
}

class Service
{
    public function __construct(private readonly Repository $repo) {}
    public function execute(): string { return $this->repo->getName(); }
}

$userRepo    = ObjectTracker::track(new Repository('users'),    'UserRepository');
$productRepo = ObjectTracker::track(new Repository('products'), 'ProductRepository');
$service     = ObjectTracker::track(new Service($userRepo),     'UserService');

// 各オブジェクトへのアクセスを記録
ObjectTracker::touch($userRepo);
ObjectTracker::touch($userRepo);
ObjectTracker::touch($productRepo);
ObjectTracker::touch($service);
ObjectTracker::touch($service);
ObjectTracker::touch($service);

// 特定オブジェクトの情報確認
$info = ObjectTracker::info($userRepo);
echo "UserRepository アクセス数: {$info['access_count']}\n";
echo "ハッシュ: " . spl_object_hash($userRepo) . "\n\n";

ObjectTracker::report();

実行結果:

UserRepository アクセス数: 2
ハッシュ: 000000005f3a1b2c...

=== ObjectTracker Report (3件) ===
  UserRepository                 Repository  アクセス:  2回  生存: 0.3ms  hash: 000000005...
  ProductRepository              Repository  アクセス:  1回  生存: 0.2ms  hash: 000000006...
  UserService                    Service     アクセス:  3回  生存: 0.1ms  hash: 000000007...

解説: ハッシュをキーにしてオブジェクトのメタ情報を管理するパターンです。「どのオブジェクトが何回参照されたか」をシンプルに追跡でき、パフォーマンス分析や依存関係のデバッグに役立ちます。


サンプル6:有向グラフのトポロジカルソート

オブジェクトをノードとするグラフのトポロジカルソートで spl_object_hash() を活用するパターンです。

<?php
declare(strict_types=1);

class Task
{
    /** @var list<Task> */
    public array $dependencies = [];

    public function __construct(public readonly string $name) {}

    public function dependsOn(Task ...$tasks): self
    {
        $this->dependencies = array_merge($this->dependencies, $tasks);
        return $this;
    }
}

/**
 * タスクの依存関係をトポロジカルソートで解決する
 *
 * @param  list<Task> $tasks
 * @return list<Task>
 * @throws RuntimeException 循環依存がある場合
 */
function topologicalSort(array $tasks): array
{
    $visited  = []; // hash → true(処理済み)
    $visiting = []; // hash → true(処理中=循環検出用)
    $sorted   = [];

    $visit = function (Task $task) use (&$visit, &$visited, &$visiting, &$sorted): void {
        $hash = spl_object_hash($task);

        if (isset($visiting[$hash])) {
            throw new \RuntimeException("循環依存を検出: {$task->name}");
        }

        if (isset($visited[$hash])) {
            return; // すでに処理済み
        }

        $visiting[$hash] = true;

        foreach ($task->dependencies as $dep) {
            $visit($dep);
        }

        unset($visiting[$hash]);
        $visited[$hash] = true;
        $sorted[] = $task;
    };

    foreach ($tasks as $task) {
        $visit($task);
    }

    return $sorted;
}

// --- タスクの依存関係定義 ---
$install    = new Task('install');
$configure  = new Task('configure');
$build      = new Task('build');
$test       = new Task('test');
$deploy     = new Task('deploy');

$configure->dependsOn($install);
$build->dependsOn($configure);
$test->dependsOn($build);
$deploy->dependsOn($build, $test);

$allTasks = [$deploy, $test, $build, $configure, $install]; // 順序はバラバラ

echo "=== トポロジカルソート結果 ===\n";
$sorted = topologicalSort($allTasks);
foreach ($sorted as $i => $task) {
    echo ($i + 1) . ". {$task->name}\n";
}

// 循環依存のテスト
echo "\n=== 循環依存の検出 ===\n";
$a = new Task('A');
$b = new Task('B');
$c = new Task('C');
$a->dependsOn($b);
$b->dependsOn($c);
$c->dependsOn($a); // 循環!

try {
    topologicalSort([$a]);
} catch (\RuntimeException $e) {
    echo "エラー: " . $e->getMessage() . "\n";
}

実行結果:

=== トポロジカルソート結果 ===
1. install
2. configure
3. build
4. test
5. deploy

=== 循環依存の検出 ===
エラー: 循環依存を検出: A

解説: visited(処理済み)とvisiting(処理中)の2つのセットを spl_object_hash() をキーにして管理することで、オブジェクトグラフのトポロジカルソートと循環依存の検出を実装できます。


ハッシュの再利用に注意

spl_object_hash() の最大の落とし穴は、GCで回収されたオブジェクトのハッシュが新しいオブジェクトに再利用されることです。

<?php
declare(strict_types=1);

$obj1 = new stdClass();
$hash1 = spl_object_hash($obj1);
echo "obj1 hash: {$hash1}\n";

// obj1 を破棄(GCが回収する)
unset($obj1);

// 新しいオブジェクトが同じメモリアドレスに割り当てられると…
$obj2 = new stdClass();
$hash2 = spl_object_hash($obj2);
echo "obj2 hash: {$hash2}\n";

// ハッシュが一致する可能性がある!
if ($hash1 === $hash2) {
    echo "⚠️  ハッシュが再利用されました!\n";
} else {
    echo "✅ ハッシュは異なります\n";
}

対策

// ✅ 1. WeakMap を使う(PHP 8.0+)
//    オブジェクトがGCされると自動でエントリが削除される
$map = new WeakMap();

// ✅ 2. オブジェクトへの強参照を保持する
//    GCされないようにオブジェクト自体もキャッシュに保持する
$cache = [];
$refs  = [];
$key   = spl_object_hash($obj);
$refs[$key]  = $obj;   // 強参照(GC防止)
$cache[$key] = $value;

// ✅ 3. spl_object_id() との組み合わせで確認(PHP 7.2+)
//    IDとハッシュが両方一致するなら本当に同じオブジェクト
function isSameObject(object $a, object $b): bool {
    return spl_object_id($a)   === spl_object_id($b)
        && spl_object_hash($a) === spl_object_hash($b);
}

よくある落とし穴まとめ

① ハッシュ再利用によるゴーストエントリ

GCで回収されたオブジェクトのハッシュが新オブジェクトに使われると、古いキャッシュエントリが誤ってヒットします。WeakMap(PHP 8.0+) を使うか、強参照を一緒に保持することで防げます。

② 内容の同等性との混同

$a = new stdClass();
$b = new stdClass();
$a->x = 1;
$b->x = 1;

// 内容は同じだがハッシュは違う
echo (spl_object_hash($a) === spl_object_hash($b)) ? 'same' : 'different'; // different

// 内容の同等比較には == を使う
echo ($a == $b) ? 'equal' : 'not equal'; // equal

③ シリアライズ後のハッシュ変化

$obj = new stdClass();
$hash1 = spl_object_hash($obj);

$restored = unserialize(serialize($obj));
$hash2 = spl_object_hash($restored);

echo ($hash1 === $hash2) ? 'same' : 'different'; // different(別インスタンス)

まとめ

ポイント内容
主な用途オブジェクトの同一性確認・辞書キーとして利用・訪問済みセット管理
戻り値32文字の16進数文字列(インスタンス単位で一意)
同一性同じインスタンス → 同じハッシュ。内容が同じでも別インスタンスなら別ハッシュ
最大の注意点GC後にハッシュが再利用される可能性 → 強参照を保持するかWeakMapを使う
PHP 8.0+の推奨WeakMap を使うとGC時の自動削除が保証されて安全
軽量な代替spl_object_id() (PHP 7.2+)でint型のIDを取得できる

spl_object_hash() はオブジェクトをキーとして扱いたいすべての場面で活躍する関数です。ただしGCによるハッシュ再利用という特性を正しく理解し、PHP 8.0以降では WeakMap を積極的に活用することで、より安全な実装が実現できます。


PHP 8.x / 執筆時点の最新安定版にて動作確認済み

タイトルとURLをコピーしました