PHP 補給包:static, self , parent 靜態變數,以及在 OOP 中各種理解不能的概念解析

SleeperShark
14 min readNov 21, 2022

--

身為一個後端工程師,如果是單純學習 Laravel 的應用,那其實不太需要有深厚的 OOP 基礎,只要大概看的懂文件上的操作範例,就可以運用自如了;
然而,如果想更深入挖掘 Laravel 各種套件的潛能、分析不同工具最好的 solution、或是一些文件上沒有註明的細節,那就一定要對 PHP 的 OOP 框架有一定的認識與了解,才能看得懂套件原始碼中的程式的邏輯與架構。

這一篇特別整理在 PHP OOP 裡最讓人望而生畏的概念:static, self, this 等關鍵字的對象分析,還有 Late Static Binding 等專有名詞的意思。

說文解字: static

根據牛津字典對 static 一詞的解釋:

staying in one place without moving, or not changing for a long time.

直觀解釋的話,是指物體的位置或狀態不隨時間而改變的特性,中文翻譯通常翻成“靜態的”。

而在程式世界中,static 的靜態特性表現在 記憶體位置 的儲存,在維基百科也能看到相關說明:

靜態變數(英語:Static Variable)在電腦編程領域指在程式執行前系統就為之靜態分配(也即在執行時中不再改變分配情況)儲存空間的一類變數。

在 function 中宣告靜態變數,則靜態變數在script 編譯後就確定了儲存位置,在 app 中不論是 function 執行的前、中、後,都不會發生改變。
function 中非靜態的變數 (稱為局部變數) 則會跟著 function 的初始化而分配、隨著 function 的結束而釋放。

function tictok()
{
static $counter = 1; // 靜態變數
$time = 1; // 非靜態變數,在 function 結束後釋放

echo "counter = ".$counter.PHP_EOL;
echo "time = ".$time.PHP_EOL;
echo PHP_EOL;

$time++;
$counter++;
}

tictok();
tictok();
tictok();

/*
counter = 1
time = 1

counter = 2
time = 1

counter = 3
time = 1
*/

同理,在 class內靜態的函式 / 變數,表示這些程式碼是在 class 的物件實體化之前就已經分配儲存資源了,而這些 變數/函式 的呼叫也不仰賴物件,可以直接藉由 class 名稱來存取。
(存取 static 的符號 :: 稱作 Scope Resolution Operator )

class Animal
{
public static $number = 0;
public static function printTheNumber()
{
echo static::$number.PHP_EOL;
return;
}
}

echo Animal::$number.PHP_EOL;
Animal::printTheNumber();

// 另外,物件可以呼叫 static function,但不能呼叫 static variable
$a = new Animal();
echo $a->number.PHP_EOL; // PHP Warning
$a->printTheNumber();

基於 static 的特殊性,在 OOP 宣告 static 時有幾點需要注意:

  1. 由於 static 宣告需要在一開始就分配儲存資源,必須在宣告 static 變數時就 assign value,不能像普通變數一樣在 __construct 才 assign。
    如果 static 變數有變動需求,那最常見的做法是先 assign 為 null,等到實際要存取時再來判斷要如何操作 static variable。
  2. static function 在物件還沒實體化時就可以呼叫,因此在 static function內是無法存取 non-static variable,當然也不能用代表物件的 pseudo-variable $this 的。

在以前不理解 static 實際意義的時候,因為 static variable 在物件之間有一種“共有”的特質,因此我把他當作“物件共享的變數”,對 static 還不是很能通盤理解的朋友不免可以先這樣思考。

forwarding call VS. non-forwarding call

理解完 static 的概念後,再來看看在官方文件說明 static 變數規則時,最常看到的一個關鍵概念:forwarding call 和 non-forwarding call。

這兩個名詞直覺上來看不太清楚,一樣先從英文辭意下手:forward 有往前(位置上)/往後(時間上) 的含義,商用英文中 forward 也有轉寄 email 的意思。
簡言之,forward 在這裡的概念是“更進一步”,去找實際的對象。

我們來看實際的例子:

<?php

class Foo
{
public static $name = 'Foo';

public static function callWithSelf()
{
echo self::$name.PHP_EOL;
}

public static function callWithStatic()
{
echo static::$name.PHP_EOL;
}
}

class Bar extends Foo
{
public static $name = 'Bar';

public static function callWithPatent()
{
echo parent::$name.PHP_EOL;
}
}

Bar::callWithPatent(); // Foo
Bar::callWithSelf(); // Foo
Bar::callWithStatic(); // Bar

在上述例子中,總共有四種透過 :: 存取靜態變數/函式的方式,分別是

Bar::staticVar
parent::staticVar
self::staticVar
static::staticVar

其中,透過 class 名稱直接去存取 static 資料的 (Bar:: 這組),就稱為 non-forwarding call,可以很明確的知道,是哪一個 class 去呼叫了這組 function。

而剩下的 parent::, self::, static:: ,就是 forwarding call,無法直接判斷這些 pseudo-variable 代表什麼,而是要去根據他們定義的位置的上下文,去判斷他們代表的到底是哪一個 class,在把實際的 class 名稱帶入、取得相對應的值。

比如說在 Bar 裡的 callWithParent(),由於 Bar 是繼承 Foo 的 children class,因此 parent 其實就是 Foo;把 Foo 代入其中,變成 Foo::$name,由此得知要取得的變數是定義在 Foo 裡的 $name,故得 Foo 這個結果。

實際上電腦的編譯的過程中,就會一步步把你定義的 self, parent等名詞(static 比較特殊,後面詳述),替換成實際的 class,就是一個 forwarding:進一步去轉換的過程,因此運用這些 keywords 的 function 稱為 forwarding call。

Late Static Binding

理解完 forwarding call 的意思以後,你才能看懂官方文件中對於 late static binding 的解釋:

More precisely, late static bindings work by storing the class named in the last “non-forwarding call”.

在 OOP 的規則中,若想在 static function 中呼叫 static variable/function 時,必須透過一個變數來指向自己這個 class,再由這個變數取得要存取的對象。

這個用來指向自己的變數就是 self — 從字義上很好理解,就是自己。
因此在上述範例可以看到,在 Foo 的 callWithSelf() 中,利用 self::$name 存取到了屬於自己的 $name 這個變數。

但問題來了。實際上我們都知道電腦在編譯 source code 的過程中,就已經把 self 等 keywords 轉換成 Foo 了,因此,如果你呼叫 Bar 的 callWithSelf(),他會先在 Bar class 內找有沒有這個函式,沒有的話再到 Foo class 找,最後執行 Foo class 中定義的 callWithSelf();但這個裡面的 slef 是被編譯成 Foo 了,因此 Boo instance 呼叫的 callWithSelf(),實際上 echo 的變數會是 Foo::$name

這時你有兩個辦法:如果你想要 Bar 在 static function 內呼叫的事該 class 自己的 static $name,一是在 Bar 中也寫一組一模一樣的callWithSelf(),這樣在編譯時執行的是 Bar 中的callWithSelf(),成功取得 Bar::$name,但同時也犯了 DRY,失去了我們利用 OOP 繼承的意義;

第二個方法,就是改用 static 變數,作為指向變數。
在 class 內用 static:: 來取用的 static 資源,會有一個特殊的操作,叫做 late static binding : 電腦不會急著把 static 取代成 class 名稱,保留這個取代變數的身份彈性。
等到這個函示真的被呼叫時,電腦再來檢視源頭是誰呼叫了這個 static function (non-forwarding call,直接用 class name 呼叫了 static function),並把這個 class 名稱代入 static,最終得知真正需要的變數。

<?php

class Foo
{
public static $name = 'Foo';

public static function callWithSelf()
{
echo self::$name.PHP_EOL;
}

public static function callWithStatic()
{
echo static::$name.PHP_EOL;
}
}

class Bar extends Foo
{
public static $name = 'Bar';

public static function callWithPatent()
{
echo parent::$name.PHP_EOL;
}
}

class Yoo extends Bar
{
public static $name = 'Yoo';

public static function callWithSelf()
{
echo self::$name.PHP_EOL;
}
}


Bar::callWithPatent();
// Parent 在 Bar 中是 Foo,因此實際上是 Foo::$name
Bar::callWithSelf();
// Self 在 Foo 中的 callWithSelf()定義,因此實際上是 Foo::$name
Bar::callWithStatic();
// Static 根據呼叫的源頭是 Bar,帶入後得到的是 Bar::$name

Yoo::callWithPatent();
// Parent 在 Bar 中是 Foo,因此實際上是 Foo::$name
Yoo::callWithSelf();
// self 在 Yoo 中定義,因此這裡編譯結果是 Yoo::$name
Yoo::callWithStatic();
// Static 根據呼叫的源頭是 Yoo,帶入後得到的是 Yoo::$name

最後簡單總結:

  • parent::, self:: 對象根據實際 src code 定義的位置,在哪一層 class,就編譯成相對應的 class 名稱,相當於是寫死的。
  • static:: 保留彈性,即使是定義在 mother class 中,根據最後是由哪一個 class 呼叫的,把呼叫的 class 代入其中。

另外除了這幾個名詞以外,也有一些 keyword 也是有 寫死/彈性 的差異,比如說:

  • __Class__ 是寫死的,get_called_class() 是彈性的。
  • 在呼叫繼承於父類別的 static function 時,forward_static_call 會保留 late static binding 的對象(而且只能在 mehtod 中使用),而call_user_func 則是忠實的還原呼叫對象。
    (不過這兩個 function 實際應用的情境好像蠻少的,就提一下給大家參考。)
class Beer {
const NAME = 'Beer!';
public static function printed(){
echo 'static Beer:NAME = '. static::NAME . PHP_EOL;
}
}

class Ale extends Beer {
const NAME = 'Ale!';
public static function printed(){
forward_static_call(array('parent','printed'));
// 呼叫 parent::printed();
call_user_func(array('parent','printed'));
// 呼叫 parented::printed();

forward_static_call(array('Beer','printed'));
// 呼叫 Beer::printed(),但是 static 對象是 Ale!
call_user_func(array('Beer','printed'));
// 呼叫 Beer::printed(),static 對象就是呼叫者 Beer。
}
}

實際案例: self 的運用

大部分在 class 內呼叫靜態變數/函式的情境中,應該多是用 static 來指向目前的類別,畢竟 OOP 中多數的行為對象還是自己的類別為主,因此 static 的使用情境大家應該比較能夠想像。

在結尾的部分,我就來援引一個實際的範例,究竟在什麼樣的情境下,需要用 self 變數來綁定身份呢?

// Illuminate\Support\Collection.php;

/**
* Get a base Support collection instance from this collection.
*
* @return \Illuminate\Support\Collection<TKey, TValue>
*/
public function toBase()
{
return new self($this);
}

上面的 src code 是 Laravel 的 Collection 類別原始碼,其中的 toBase() 函式,可以將把自己代入 constructor 中,回傳一個新的自己。

其中在呼叫 constructor 時,用的就是 new self(),綁定了這個新建立的物件,就是要 Collection 這個 class,無論是從哪裡呼叫的。

這個 function 在Collection 類別裡看似有點多此一舉,不過如果是使用在他的子類別中,就非常合情合理了:

// Illuminate\Database\Eloquent\Collection.php

/**
* Get an array with the values of a given key.
*
* @param string|array<array-key, string> $value
* @param string|null $key
* @return \Illuminate\Support\Collection<int, mixed>
*/
public function pluck($value, $key = null)
{
return $this->toBase()->pluck($value, $key);
}

EloquentCollection 是透過 Laravel ORM 回傳的複數 model 的資料型別,繼承的就是上面的 Collection 類別。而在 pluck() 這個函式裡,EloquentCollection 會先透過 toBase() 轉成 Collection 物件,再去執行 pluck 函式,以此得到最終的 array 結果。

在這個例子裡,Collection 就是綁定了self 的對象,因此不論是繼承了多少層以後的子代,只要呼叫了 toBase() ,就能確保一定能將目前的物件轉換成 Collection 類別,在透過 Collection 類別的特性進行其他的操作處理,實在是非常優雅的處理方式。

這次的筆記就大致到這裡告一段落,主要是幫自己釐清了關於 PHP OOP 裡一些比較底層的規則,有很多敘述段落是我根據自己的理解加以轉譯的,如果有任何不精確的地方就麻煩大家多留言指教了。

--

--