為什麼 PHP 閉包要加 static?
在 PHP 中,閉包的使用越來越普遍:依賴注入、中間件、集合回調,以及異步編程中的回調工具。
但閉包有一個行為可能會讓人意外:在實例方法內部創建的閉包會自動攜帶對當前對象的引用,即使閉包內部並未使用 $this。這種行為可能對對象生命週期產生意外影響,若不謹慎處理,還可能引發內存泄漏。
PHP 的內存管理機制
要理解這一點,需要先了解 PHP 如何管理內存。與 Java 等依賴垃圾回收器延遲釋放內存的語言不同,PHP 使用引用計數(當然,PHP 實際上也有針對循環引用的垃圾回收器,但那是另一回事)。
當變量被賦值時,其內容需要存儲在內存中;當變量不再使用時,內存可以被釋放。寫出如下代碼:
$a = 'Hello';
$b = $a;
PHP 不會為 $b 創建第二塊內存空間,而是直接標記它指向與 $a 相同的內存空間。如果隨後給 $a 賦新值(如 "Hi"),則會分配新內存空間並讓 $a 指向它,而 $b 繼續指向原來的空間。如果將 NULL 賦給 $b,那麼原來存儲 "Hello" 的內存空間就不再被任何變量引用,可以被釋放。PHP 通過維護引用計數來實現這一點,當計數歸零時,空間即被釋放。
對象的生命週期
對於對象,當引用計數歸零後,在釋放內存之前,如果類定義了 __destruct 方法,會先調用它:
class Foo {
public function __construct() {
echo "Construct\n";
}
public function __destruct() {
echo "Destruct\n";
}
}
new Foo();
echo "End\n";
輸出:
Construct
Destruct
End
對象未被賦給任何變量:它的計數器在構造函數調用後立即歸零,__destruct 隨即被調用。
如果將對象賦給變量,銷燬則會延遲:
$foo = new Foo();
echo "End\n";
輸出:
Construct
End
Destruct
只要 $foo 指向對象,計數器就保持為 1。銷燬發生在腳本末尾,所有變量被釋放之後。要強制提前銷燬,只需顯式釋放變量:
$foo = new Foo();
echo "Before release\n";
$foo = null;
echo "After release\n";
輸出:
Construct
Before release
Destruct
After release
閉包會讓對象保持存活
來看 Bar 類的例子,它定義了 getCallback() 方法,返回一個讀取 $this->id 屬性的閉包:
class Bar {
public function __construct(private string $id) {
echo "Construct\n";
}
public function __destruct() {
echo "Destruct\n";
}
public function getCallback(): Closure {
return function(): string {
return $this->id;
};
}
}
$bar = new Bar('foo');
$getId = $bar->getCallback();
echo "Before releasing the object\n";
$bar = null;
echo "After releasing the object\n";
echo $getId() . "\n";
echo "End\n";
輸出:
Construct
Before releasing the object
After releasing the object
foo
End
Destruct
給 $bar 賦 null 時對象並未被銷燬,因為閉包訪問了 $this->id,這構成了對對象的引用。只要閉包存在,計數器就不會歸零,直到腳本結束。如果提前給 $getId 重新賦值,__destruct 會更早被調用,因為釋放變量同時也釋放了對 $this 的引用。
即使不使用 $this,對象仍會存活
如果閉包內部完全不使用 $this 會怎樣?
class Bar {
public function __construct() {
echo "Construct\n";
}
public function __destruct() {
echo "Destruct\n";
}
public function getCallback(): Closure {
return function(): void {};
}
}
$bar = new Bar();
$callback = $bar->getCallback();
echo "Before releasing the object\n";
$bar = null;
echo "After releasing the object\n";
$callback = null;
echo "End\n";
輸出:
Construct
Before releasing the object
After releasing the object
Destruct
End
對象仍然保持存活。原因在於:即使閉包內部不使用 $this,PHP 會自動將 $this 綁定到在實例方法中創建的任何閉包,無論是否使用它、無論閉包是否為空。閉包因此總是攜帶對對象的引用,這一點在閲讀代碼時是看不見的。
當然,如果閉包是在靜態方法中創建的,就不會有 $this 引用,銷燬會在變量釋放時立即發生:
class Bar {
public function __construct() {
echo "Construct\n";
}
public function __destruct() {
echo "Destruct\n";
}
public static function getCallback(): Closure {
return function(): void {};
}
}
$bar = new Bar();
$closure = $bar::getCallback();
echo "Before releasing the object\n";
$bar = null;
echo "End\n";
輸出:
Construct
Before releasing the object
Destruct
End
靜態閉包
static 關鍵字應用於閉包時,會顯式禁止閉包綁定到 $this。PHP 將不再存儲任何對對象的引用,即使是隱式的。
public function getCallback(): Closure {
return static function(): void {};
}
輸出:
Construct
Before releasing the object
Destruct
End
如果需要在閉包內獲取屬性值,可以通過 use 傳遞:
public function getCallback(): Closure {
$id = $this->id;
return static function() use ($id): string {
return $id;
};
}
這次,PHP 會在變量釋放後立即銷燬對象,因為閉包不再保留對它的引用。
如果在靜態閉包內嘗試使用 $this,PHP 會報錯:
return static function(): string {
return $this->id; // Error: Using $this when not in object context
};
PHP 引擎以此保護你免受意外捕獲。
短閉包
短閉包(fn() =>)提供了更簡潔的語法,並自動從外層作用域捕獲變量,無需 use。但它在 $this 方面的行為與普通閉包相同:
public function getCallback(): Closure {
return fn(): string => $this->id;
}
這裏 $this 被隱式捕獲,與普通閉包一樣。對象會一直保持存活,直到閉包被銷燬。
static 關鍵字同樣適用於短閉包。外層作用域的變量仍會被自動捕獲,但 $this 不再被捕獲:
public function getCallback(): Closure {
return static fn(): string => $this->id; // Error: Using $this when not in object context
}
要在不傳對象的情況下傳遞值,只需提前提取:
public function getCallback(): Closure {
$id = $this->id;
return static fn(): string => $id;
}
變量 $id 按值捕獲,$this 不再參與,對象可以在其顯式引用消失後立即被釋放。
PHP 8.6 將帶來的變化
目前正在投票中的 Closure Optimizations RFC 正是針對這一行為。它引入了自動推斷:如果閉包不使用 $this,PHP 會自動將其視為靜態閉包,無需開發者顯式聲明。
本文示例中使用 use ($id) 的閉包或短閉包 fn(): string => $id,在該 RFC 通過後將不再隱式捕獲對象。
該 RFC 還包含第二項優化:不捕獲任何變量(既無 use,也無外層作用域變量)的靜態閉包會被緩存並在多次調用間複用,避免每次重新實例化。
這兩項優化對現有代碼是透明的,但有一個例外:ReflectionFunction::getClosureThis() 會對被推斷為靜態的閉包返回 null,這可能對現有代碼引入行為變更(Breaking Change)。
建議:保持顯式
作為一般規則,當閉包(或短閉包)不需要 $this 時,最好將其聲明為 static。這讓意圖更加明確,防止意外捕獲,並允許對象在最後一個顯式引用消失後立即被銷燬。
PHP 8.6 之後,這種安全行為會成為默認,但顯式聲明 static 仍有價值:它可以文檔化意圖,並保證與早期版本的兼容性。
為什麼 PHP 閉包要加 static?