2021年6月20日

PHP ArrayAccess Interface介紹,探索 Laravel Container 背後的機制

本篇為PHP系列

先貼個官方說明壓壓驚,The ArrayAccess interface

Interface to provide accessing objects as arrays.

簡單來說,這個介面讓你可以用讀取陣列Array的方式,去讀取物件Object

這個介面總共提供四種方法,讓你的物件可以使用:

ArrayAccess {
    /* Methods */
    abstract public offsetSet(mixed $offset, mixed $value): void
    abstract public offsetGet(mixed $offset): mixed
    abstract public offsetExists(mixed $offset): bool
    abstract public offsetUnset(mixed $offset): void
}

我會一個一個來看,想跳過細節說明的人,文末有Laravel Container的範例可以參考

範例與說明

ArrayAccess::offsetSet

官方說法Assign a value to the specified offset

其實就是將一個value放入offset中,

範例如下,我們定義一個類別叫做Container,並且實作ArrayAccess介面。

class Container implements ArrayAccess
{
    private $data = [];

    public function offsetSet($offset, $value)
    {
        $this->data[$offset] = $value;
    }
  	
  	public function offsetGet($offset) {}
    public function offsetExists($offset) {}
    public function offsetUnset($offset) {}
}

我們試著去執行這樣的程式,把'Demo'塞進去'name'

$container = new Container;
$container['name'] = 'Demo';

var_dump($container);

輸出結果如下,可以看到private的$data,已經儲存了剛剛丟進去的資料了:

object(Container)#1 (1) {
  ["data":"Container":private]=>
  array(1) {
    ["name"]=>
    string(4) "Demo"
  }
}

說明一下,一般來說,物件是沒辦法用square brackets,也就是沒辦法以[]的方式來操作資料的,如果對沒有實作ArrayAccess的類別使用Array操作,你會獲得這個錯誤訊息:

Uncaught Error: Cannot use object of type Container as array

那因為我們已經實作了ArrayAccess類別,所以可以。

回頭來看$container['name'] = 'Demo';做了什麼。

基本上它就是呼叫了offsetSet方法,然後將資料塞入內部的$data中,所以可以從前面的輸出結果看到,$offset其實就是[]裡面的值,也就是範例的'name',俗稱的key或index,$value則是'Demo'

特別注意的是,這邊是為了範例簡單,所以內部的$data資料容器也是一個array。其實可以看到,offsetSet就是一個普通的function,所以在裡面想要對$offset$value怎麼做都可以,可以丟到db,也可以丟到另一個物件,並沒有強制只能丟到array。

ArrayAccess::offsetGet

官方說法Offset to retrieve

透過offset取得特定value。

延續前面的範例,補上一個offsetGet的方法,

class Container implements ArrayAccess
{
    private $data = [];

    public function offsetSet($offset, $value)
    {
        $this->data[$offset] = $value;
    }

    public function offsetGet($offset)
    {
        return $this->data[$offset];
    }
  
    public function offsetExists($offset) {}
    public function offsetUnset($offset) {}
}

測試一下,

$container = new Container;
$container['name'] = 'Demo';

var_dump($container['name']);
var_dump($container['version']);

輸出結果如下,

string(4) "Demo"
NULL

跟Array很熟悉的人都知道,直接對Array使用[]就可以取值,所以$container['name']就是透過offset'name'去取出value。

這邊就是呼叫offsetGet去取值,一樣,內部的實作是可以自由發揮的,因為我們$data是array,所以直接把$offset丟給$data就好了。

所以當我尋找'name'的時候,他會回傳我前面設定的'Demo',而如果是其他offset,則會回傳NULL。

我們也可以稍微改變一下offsetGet的行為:

public function offsetGet($offset)
{
	if (array_key_exists($offset, $this->data)) {
		return $this->data[$offset];
	}
	return "Offset:{$offset} Not Found!";
}

這樣前面的輸出就會變成:

string(4) "Demo"
string(25) "Offset:version Not Found!"

ArrayAccess::offsetExists

官方說法Whether an offset exists

判斷offset是否存在。

繼續擴充我們的程式,補上offsetExists

class Container implements ArrayAccess
{
    private $data = [];

    public function offsetSet($offset, $value)
    {
        $this->data[$offset] = $value;
    }

    public function offsetGet($offset)
    {
        return $this->data[$offset];
    }

    public function offsetExists($offset)
    {
        return array_key_exists($offset, $this->data);
    }
    
    public function offsetUnset($offset) {}
}

然後執行這段程式:

$container = new Container;
$container['name'] = 'Demo';
$container['updatedAt'] = null;

var_dump(isset($container['name']));
var_dump(isset($container['updatedAt']));
var_dump(isset($container['version']));

結果如下:

bool(true) // name
bool(true) // updatedAt
bool(false) // version

沒錯,當你對某個offset執行isset()的時候,就會去觸發offsetExists方法,然後觸發array_key_exists

可以看到'name''updatedAt'key存在,所以true,而'version'不存在,所以false

一樣,開發者可以在offsetExists裡面根據實際需求做變化。

empty

另外還有一個比較特別的地方,就是empty()empty()也會去觸發offsetExists,然後回傳相反的結果。

我們來測試一下,改成這樣執行:

$container = new Container;
$container['name'] = 'Demo';
$container['updatedAt'] = null;

var_dump(empty($container['name']));
var_dump(empty($container['updatedAt']));
var_dump(empty($container['version']));

結果會是:

bool(false) // name
bool(true) // updatedAt
bool(true) // version

offset 'name'不是empty,所以為false,而'updatedAt''version'都是空的,所以會是true

看起來很合理對吧?但仔細一看這邊有個有趣的小地方。

因為我們的offsetExists是用array_key_exists去實作判斷的,如果按照前面說的邏輯來看,'updatedAt'應該是存在的offset,offsetExists會回傳true然後變成相反的false,然而並沒有。

其實是因為這邊,PHP有特別設計,當使用empty()觸發offsetExists後,如果回傳是true,offset存在,那它會進一步去呼叫offsetGet,來確保是不是真的empty。

用這個機制來看,因為'updatedAt'這個offset是存在的,PHP會在去呼叫offsetGet,然後得到的value nullnull對empty來說,就真的是空的了!

這邊稍微總結一下遇到empty的判斷方式是:

!offsetExists($offset) || empty(offsetGet($offset))

ArrayAccess::offsetUnset

官方說法Unset an offset

移除offset。

終於來到最後一個方法,offsetUnset,我們來把範例補完:

class Container implements ArrayAccess
{
    private $data = [];

    public function offsetSet($offset, $value)
    {
        $this->data[$offset] = $value;
    }

    public function offsetGet($offset)
    {
        return $this->data[$offset];
    }

    public function offsetExists($offset)
    {
        return array_key_exists($offset, $this->data);
    }

    public function offsetUnset($offset)
    {
        unset($this->data[$offset]);
    }
}

執行以下程式:

$container = new Container;
$container['name'] = 'Demo';
var_dump($container['name']);

unset($container['name']);
var_dump($container['name']);

輸出結果:

string(4) "Demo"
NULL

相信大家看到這邊應該都知道發生什麼事了,當呼叫unset的時候,會觸發offsetUnset方法。

所以我們可以在offsetUnset裡面去攥寫程式,把對應的offset的移除掉。

最後還是再提醒一次,因為這邊內部儲存機制是用Array做範例,實際上可以是任意方式喔。

現實中的應用

大家有注意到我範例的類別名稱,取名是Container嗎?其實這就是為了帶出這個範例。

在著名的框架Laravel中,它的Container其實就實作了ArrayAccess介面。

Laravel的實作當然複雜得多,所以我把部分程式碼找出來給大家看一下。

Laravel Container類別的全名是Illuminate\Container\Container

路徑應該會在這個位置,大家也可以自己去找來看看。

/vendor/laravel/framework/src/Illuminate/Container/Container.php

或是參考Laravel官方github上的檔案

可以看到Container實作了ArrayAccess Interface,

class Container implements ArrayAccess, ContainerContract

挑選其中幾個跟ArrayAccess有關的方法:

public function offsetExists($key)
{
	return $this->bound($key);
}

public function offsetGet($key)
{
	return $this->make($key);
}

public function offsetSet($key, $value)
{
	$this->bind($key, $value instanceof Closure ? $value : function () use ($value) {
		return $value;
	});
}

public function offsetUnset($key)
{
	unset($this->bindings[$key], $this->instances[$key], $this->resolved[$key]);
}

Container使用ArrayAccess結合其自身的其他方法,因此可以提供不同的方式讓開發者來操作的Container類別。

從這邊也可以了解,為什麼在Laravel的主要核心實作Application.php(參考)中,會看到$this['events']這樣子使用方式。

    public function bootstrapWith(array $bootstrappers)
    {
        $this->hasBeenBootstrapped = true;

        foreach ($bootstrappers as $bootstrapper) {
            $this['events']->dispatch('bootstrapping: '.$bootstrapper, [$this]);

            $this->make($bootstrapper)->bootstrap($this);

            $this['events']->dispatch('bootstrapped: '.$bootstrapper, [$this]);
        }
    }

因為Application繼承了Container,所以也繼承Container中,實作的ArrayAccess的相關用法,因此使用$this['events']時,其實觸發了ContaineroffsetGet

結語

ArrayAccess是PHP提供的一個介面,作為內建功能跟自訂功能連結的橋樑,讓開發者可以更有彈性的創作。

我認為有幾個時機可以使用這個介面:

  1. 需要對資料有權限控管,每次讀取之前,都要做判斷
  2. 需要封裝資料,有些資料背後的儲存機制比較複雜,需要做處理
  3. 給開發者自己好用的介面,呈上,透過這個介面把複雜的資料結構包裝起來

最後,可能要稍微注意的是,這個方法畢竟只是讓類別用起來像是Array而已,並不是真的Array,有些for array的方法,可能會有問題,大家要注意一下,避免踩雷。

 

更多PHP相關文章,請參考:PHP系列