> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# once() helper 的內部實作 — Once、Onceable、PreventsCircularRecursion

> 從 Illuminate\Support\Once 與 Onceable 兩個類別，解說全域函式 once() 的內部實作；並介紹其在 Eloquent 循環參照防護 trait PreventsCircularRecursion 上的應用。

## 什麼是 once()

`once()` 是執行 callback 並在該次請求期間將結果快取於記憶體的全域 helper。從相同呼叫位置以相同 callback 再次呼叫時，會回傳已快取的結果。

```php theme={null}
function random(): int
{
    return once(function () {
        return random_int(1, 1000);
    });
}

random(); // 123
random(); // 123（已快取的結果）
random(); // 123（已快取的結果）
```

若從物件實例的方法內呼叫，快取會以該實例為單位獨立存放。

```php theme={null}
class NumberService
{
    public function all(): array
    {
        return once(fn () => [1, 2, 3]);
    }
}

$service = new NumberService;

$service->all();
$service->all(); // （已快取的結果）

$secondService = new NumberService;

$secondService->all();
$secondService->all(); // （已快取的結果，與 $service 分屬不同快取）
```

<Info>
  這種「依呼叫位置、依實例」獨立的快取機制，與 `memoize` 這類簡單的記憶化 helper 不同。要理解其原理，需檢視 `Illuminate\Support\Once` 與 `Illuminate\Support\Onceable` 這兩個類別。
</Info>

## helpers.php 中的呼叫

`Illuminate\Support\helpers.php` 中 `once()` 函式本體極為簡單。

```php theme={null}
use Illuminate\Support\Once;
use Illuminate\Support\Onceable;

if (! function_exists('once')) {
    function once(callable $callback)
    {
        $onceable = Onceable::tryFromTrace(
            debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2),
            $callback,
        );

        return $onceable
            ? Once::instance()->value($onceable)
            : call_user_func($callback);
    }
}
```

重點在於：`once()` 本身並不持有快取，而是以 `debug_backtrace()` 取得呼叫端資訊，組出 `Onceable` 實例後，將處理委派給 `Once` 類別。

```mermaid theme={null}
flowchart TD
    A["呼叫 once(callback)"] --> B["以 debug_backtrace() 取得呼叫端"]
    B --> C["Onceable::tryFromTrace()"]
    C --> D{"能否計算出 hash?"}
    D -- 否 --> E["直接執行 callback"]
    D -- 是 --> F["Once::instance()->value(onceable)"]
    F --> G{"WeakMap 已快取?"}
    G -- 是 --> H["回傳快取值"]
    G -- 否 --> I["執行 callback 並存入快取"]
```

## Onceable — 用來鎖定呼叫位置的 hash 計算

`Onceable` 類別負責從 backtrace 計算出可唯一鎖定「哪個呼叫位置」的 hash。

```php theme={null}
class Onceable
{
    public function __construct(
        public string $hash,
        public ?object $object,
        public $callable,
    ) {
        //
    }

    public static function tryFromTrace(array $trace, callable $callable)
    {
        if (! is_null($hash = static::hashFromTrace($trace, $callable))) {
            $object = static::objectFromTrace($trace);

            return new static($hash, $object, $callable);
        }
    }

    protected static function objectFromTrace(array $trace)
    {
        return $trace[1]['object'] ?? null;
    }

    protected static function hashFromTrace(array $trace, callable $callable)
    {
        if (str_contains($trace[0]['file'] ?? '', 'eval()\'d code')) {
            return null;
        }

        $uses = array_map(
            static function (mixed $argument) {
                if ($argument instanceof HasOnceHash) {
                    return $argument->onceHash();
                }

                if (is_object($argument)) {
                    return spl_object_id($argument);
                }

                return $argument;
            },
            $callable instanceof Closure
                ? (new ReflectionClosure($callable))->getClosureUsedVariables()
                : [],
        );

        $class = $callable instanceof Closure
            ? (new ReflectionClosure($callable))->getClosureCalledClass()?->getName()
            : null;

        $class ??= $trace[1]['class'] ?? null;

        return hash('xxh128', sprintf(
            '%s@%s%s:%s (%s)',
            $trace[0]['file'],
            $class ? $class.'@' : '',
            $trace[1]['function'],
            $trace[0]['line'],
            serialize($uses),
        ));
    }
}
```

hash 的計算來源為 `檔案路徑 + 類別名稱 + 函式名稱 + 行號 + closure use 到的變數值`。也就是說，**即使是從同一行呼叫的 `once()`，只要 `use` 的變數值改變，就會被視為不同的快取**。

```php theme={null}
function greet(string $name)
{
    return once(fn () => "Hello, {$name}!");
}

greet('Alice'); // 計算並快取 "Hello, Alice!"
greet('Bob');   // $name 的值不同，hash 也不同，會重新計算
```

<Warning>
  若從 `eval()` 執行的程式碼中呼叫 `once()`，`hashFromTrace()` 會回傳 `null`，完全不會進行快取。這是針對 Blade 已編譯視圖等透過 eval 執行的情境所做的安全防範。
</Warning>

`object` 表示「呼叫端是否為實例方法」。`$trace[1]['object']` 是 `debug_backtrace()` 上一層（呼叫 `once()` 的一側）的物件，因此在實例方法中會是 `$this`，靜態方法或全域函式中則為 `null`。

## Once — 以 WeakMap 為核心的快取本體

實際的快取保存工作由 `Illuminate\Support\Once` 負責。

```php theme={null}
class Once
{
    protected static ?self $instance = null;

    protected static bool $enabled = true;

    protected function __construct(protected WeakMap $values)
    {
        //
    }

    public static function instance()
    {
        return static::$instance ??= new static(new WeakMap);
    }

    public function value(Onceable $onceable)
    {
        if (! static::$enabled) {
            return call_user_func($onceable->callable);
        }

        $object = $onceable->object ?: $this;

        $hash = $onceable->hash;

        if (! isset($this->values[$object])) {
            $this->values[$object] = [];
        }

        if (array_key_exists($hash, $this->values[$object])) {
            return $this->values[$object][$hash];
        }

        return $this->values[$object][$hash] = call_user_func($onceable->callable);
    }

    public static function enable()
    {
        static::$enabled = true;
    }

    public static function disable()
    {
        static::$enabled = false;
    }

    public static function flush()
    {
        static::$instance = null;
    }
}
```

關鍵技術是 `WeakMap`。以 `$onceable->object`（呼叫端實例）為 key，保存「hash → 結果」的關聯陣列作為值。若沒有 `object`（全域函式或靜態方法），`Once` 自身的 `$this` 會成為 key，實質上會成為整個程序共用的單一快取區域。

<Info>
  由於使用 `WeakMap`，一旦作為 key 的物件不再有其他參照，該物件對應的快取也會成為垃圾回收的對象。這是即使從大量物件呼叫 `once()` 也不易造成記憶體洩漏的設計。
</Info>

### 在測試中的應用 — enable / disable / flush

呼叫 `Once::disable()` 可完全停用快取，`once()` 每次都會執行 callback。適合測試中「每次都想取得新值」的場景。

```php theme={null}
use Illuminate\Support\Once;

beforeEach(function () {
    Once::disable();
});

afterEach(function () {
    Once::enable();
    Once::flush();
});
```

`Once::flush()` 會捨棄整個快取，於下一次存取時重建全新的 `WeakMap`。適用於 Artisan 指令測試，或 Octane 這類程序會被重複使用、需避免快取跨請求殘留的環境。

## PreventsCircularRecursion — Onceable 的應用範例

`Onceable::tryFromTrace()` 並非 `once()` 專用，Eloquent 的 `Illuminate\Database\Eloquent\Concerns\PreventsCircularRecursion` trait 也重複利用了它。這個 trait 的目的是「防止同一物件的同一呼叫位置在 call stack 中重入」。

```php theme={null}
trait PreventsCircularRecursion
{
    protected static $recursionCache;

    protected function withoutRecursion($callback, $default = null)
    {
        $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2);

        $onceable = Onceable::tryFromTrace($trace, $callback);

        if (is_null($onceable)) {
            return call_user_func($callback);
        }

        $stack = static::getRecursiveCallStack($this);

        if (array_key_exists($onceable->hash, $stack)) {
            return is_callable($stack[$onceable->hash])
                ? static::setRecursiveCallValue($this, $onceable->hash, call_user_func($stack[$onceable->hash]))
                : $stack[$onceable->hash];
        }

        try {
            static::setRecursiveCallValue($this, $onceable->hash, $default);

            return call_user_func($onceable->callable);
        } finally {
            static::clearRecursiveCallValue($this, $onceable->hash);
        }
    }

    // getRecursiveCallStack() / getRecursionCache() / setRecursiveCallValue()
    // / clearRecursiveCallValue() 皆為以 WeakMap 為基礎的實作
}
```

與 `Once` 的關鍵差異在於：**在 `finally` 區塊中，於呼叫結束後即清除快取**。`Once` 是「請求期間持續快取」，而 `PreventsCircularRecursion` 則是「僅在目前執行中的 call stack 期間快取（實質上為 lock）」，借用了相同的 `Onceable` 機制。

Eloquent Model 上典型的用途，是防止 Model 的 `toArray()` 或 accessor 在遞迴中參照到自己的情況。

```php theme={null}
use Illuminate\Database\Eloquent\Concerns\PreventsCircularRecursion;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    use PreventsCircularRecursion;

    public function getPathAttribute(): string
    {
        return $this->withoutRecursion(function () {
            // 在參照 parent 的 path 時
            // 即使發生循環參照也能於此防止無窮迴圈
            return $this->parent
                ? $this->parent->path.' > '.$this->name
                : $this->name;
        }, default: $this->name);
    }
}
```

## 套件開發的應用

自製套件中若想「讓同一實例的同一方法呼叫在單次請求中僅執行一次」，直接使用 `once()` 是最簡單的方式。直接使用 `Onceable`／`Once` 的機會不多，但在下列情境中，理解其內部實作會有幫助。

* 在 Artisan 指令或測試中，想以 `Once::disable()`／`Once::flush()` 控制快取行為時
* 想在自製 trait 中實作「以呼叫位置為單位」的快取或防止重入（可套用與 `PreventsCircularRecursion` 相同的模式）
* `once()` 的結果與預期不符而需除錯時，理解構成 hash 的元素（檔案、類別、函式、行號、use 變數）將更容易鎖定原因

<Warning>
  由於 `once()` 會將 closure `use` 的變數也納入 hash，若在迴圈中傳入每次都捕獲不同值的 closure，可能會意外地每次產生不同的快取，實質上等同於未快取。在迴圈中使用時，請確認快取 key 的粒度是否符合預期。
</Warning>

## 相關頁面

<Columns cols={2}>
  <Card title="tap() helper 與 Tappable trait" icon="hand-point-right" href="/zh-TW/advanced/tap">
    在插入副作用的同時仍回傳值的 tap() helper 實作模式。
  </Card>

  <Card title="Eloquent Observers 與 Model 事件" icon="eye" href="/zh-TW/advanced/eloquent-observers">
    以 Eloquent Model 事件與 observer 進行集中管理。
  </Card>
</Columns>


## Related topics

- [進階主題](/zh-TW/advanced/index.md)
- [tap() helper 與 Tappable trait](/zh-TW/advanced/tap.md)
- [套件自動偵測的內部結構](/zh-TW/advanced/package-discovery.md)
- [进阶主题](/zh-CN/advanced/index.md)
- [Conditionable trait](/zh-TW/advanced/conditionable.md)
