深入探討PHP 5.4的Trait觀念與用法

Trait是部分class的實現 ,讓我們能將部分class注入到其他class。

trait有兩個功能 :

  • 提供如interface的合約
  • 提供如class的實作

所以trait是一個看起來像interface,但用起來像class的東西。

Version


PHP 5.4

為什麼要使用Trait?


假設現在我們有兩個class : RetailStore class與Car class,我們希望這兩個class都能提供log功能,紀錄class的內部運作。

我們分別想到了3個方法實現log功能 :

  • 使用繼承
  • 使用多型 (interface)
  • 使用trait

使用繼承

log.php
1
2
3
4
5
6
7
8
9
10
11
12
class Log 
{

function startLog()
{

...
}

function stopLog()
{

...
}
}
RetailStore.php
1
2
3
4
class RetailStore extends Log 
{

...
}
Car.php
1
2
3
4
class Car extends Log 
{

...
}

這種寫法強迫RetailStoreCar去繼承Log,繼承後也會連LogstartLog()stopLog()一起繼承。

以功能面來說,這樣是可行的,但問題繼承並不是這樣用的

解釋繼承最典型的例子就是國中學生物的界門綱目科屬種 ,舉例來說,人類屬於 :

  • 界 : 動物界 Animalia
  • 門 : 脊索動物門 Chordata
  • 綱 : 哺乳綱 Mammalia
  • 目 : 靈長目 Primates
  • 科 : 人科 Hominidae
  • 屬 : 人屬 Homo
  • 種 : 智人 H. sapiens

下一層的分類都是上一層的特化,也繼承了上一層分類的所有特性。

也就是智人包含了哺乳綱 的所有特性,所以智人也算是一種哺乳類

但因為智人還有其他的特性,是哺乳綱所沒有的,所以又再被分類成靈長目

RetailStore class在理解上與Log class不相關,Car class在理解上也與Log class不相關,也就是說RetailStore根本不算是一種Log類,而Car也根本不算是一種Log類,在這裡使用繼承完全是因為Log功能硬兜出來的,而不是因為class天生的特性加以繼承所分類。換句話說,Log class根本不適合當RetailStore class與Log class的祖先。

這是一個典型誤用繼承的例子,並不是一個好的方法。

使用多型

ILog.php
1
2
3
4
5
interface ILog 
{

public function startLog();
public function stopLog();
}
RetailStore.php
1
2
3
4
5
6
7
8
9
10
11
12
class RetailStore implements ILog 
{

public function startLog()
{

...
}

public function stopLog()
{

...
}
}
Car.php
1
2
3
4
5
6
7
8
9
10
11
12
class Car implements ILog 
{

public function startLog()
{

...
}

public function stopLog()
{

...
}
}

既然繼承不能用,那我們改用多型吧,建立一個ILog interface,定義了startLog()stopLog()兩個函式,基於之前強迫繼承的錯誤,我們只要求RetailStore class與Car class實踐ILog interface。

使用interface方式比用繼承好多了,但還不是最好,由於RetailStore class與Car class都要實踐ILog interface,這導致了startLog()stopLog()演算法在RetailStore class與Car class都要實作,也就是說,將來萬一start()stop()演算法有bug,兩個class都要去修改code,這違反了DRY原則 (Don’t Repeat Yourself)原則,因為相同的code寫兩分,將來維護會很麻煩。

目前看來物件導向兩大絕招繼承多型 已經陣亡了。

Trait就是為了解決這個問題所產生的,trait允許我們寫一個部分classTLog trait,然後將Log trait注入到RetailStore class與Car class。

使用Trait

TLog.php
1
2
3
4
5
6
7
8
9
10
11
12
trait TLog 
{
public function startLog()
{

...
}

public function stopLog()
{

...
}
}
RetailStore.php
1
2
3
4
class RetailStore 
{

use TLog;
}
Car.php
1
2
3
4
class Car 
{

use TLog;
}

既然多型還不夠好,會造成code重複的問題,假如我們能把實踐的部分也寫在interface就好了。trait就是允許我們直接將實踐寫在裡面,避免code重複。

建立一個TLog trait,定義了startLog()stopLog()兩個函式,直接將startLog()stopLog()演算法寫在trait的函式內。

既然trait已經將startLog()stopLog()都寫好了,其他class就不用implements了,PHP使用了use關鍵字,讓我們將trait的函式直接注入到RetailStore class與Car class。也就是說,雖然RetailStore class與Car class都沒有實踐startLog()stopLog(),透過use住入TLog trait之後,就相當於有了startLog()stopLog()函式了。

無論以上3種寫法,對於使用RetailStore class與Car class來說都一樣。

1
2
3
4
5
6
7
$store = new RetailStore();
$store->startLog();
$store->stopLog();

$car = new Car();
$car->startLog();
$car->stopLog();

繼承、interface與trait的比較

繼承的方式雖然可行,但很暴力也不合理;interface方式雖然也可行,但不符合DRY原則,將來不好維護;只有使用trait,一個介於繼承與interface中間的方法,巧妙的融合class與interface的優點,對於class間要加入毫不相關的功能提供了另外一種不錯的解決方式。

PHP內部實踐Trait方式

對於PHP來說,在使用use關鍵字時,PHP只是將trait的所有變數與函式「複製」進class內,讓class馬上擁有trait的所有功能。

Trait的作用域


因為trait在實作上是使用複製, 所以原本在trait內宣告的publicprotectedprivate變數與函式都會複製到class內,也就是說,class內被trait加進來變數與函式的scope將與原trait完全一樣

比較一下繼承的extends,當使用繼承時,只有publicprotected的變數與函式會被繼承下來,private函式將不會被繼承下來。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
trait PrivateTrait 
{
private privateFunc()
{
echo "This is private";
}
}

class PrivateClass
{

use privateTrait;

// This is allowed.
public publicFunc()
{
$this->privateFunc();
}
}

class ExtendingClass extends PrivateClass
{

// This is NOT allowed - privateFunc() is only available
// to the class which directly utilizes it.
public someFunc()
{
$this->privateFunc();
}
}

第1行

1
2
3
4
5
6
7
trait PrivateTrait 
{
private privateFunc()
{
echo "This is private";
}
}

定義了PrivateTrait,並宣告的一個private函式。

第9行

1
2
3
4
5
6
7
8
9
10
class PrivateClass 
{

use privateTrait;

// This is allowed.
public publicFunc()
{
$this->privateFunc();
}
}

publicFunc()函式呼叫了在PrivateTrait定義的privateFunc(),雖然privateFunc()private,但因為沒用繼承,所以在class內可以使用。

20行

1
2
3
4
5
6
7
8
9
class ExtendingClass extends PrivateClass 
{

// This is NOT allowed - privateFunc() is only available
// to the class which directly utilizes it.
public someFunc()
{
$this->privateFunc();
}
}

ExtendingClass 是繼承自PrivateClass,但在someFunc()函式裡呼叫privateFunc()是錯誤的,因為privateFunc()PrivateTrait定義是private,而private無法被繼承,所以在ExtendingClass是看不到privateFunc()的。

Insteadof與As關鍵字


由於PHP使用複製來實作use,所以有可能出現trait所定義的變數與函式已經在其他class已經被定義,PHP提出了insteadofas關鍵字來解決。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
trait A 
{
function someFunc()
{

...
}

function otherFunc()
{

...
}
}

trait B
{
function someFunc()
{

...
}

function otherFunc()
{

...
}
}

class MyClass
{

use A, B {
A::someFunc insteadof B;
B::otherFunc as differentFunc;
}
}

第1行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
trait A 
{
function someFunc()
{

...
}

function otherFunc()
{

...
}
}

trait B
{
function someFunc()
{

...
}

function otherFunc()
{

...
}
}

兩個trait所定義的函式完全相同。

25行

1
2
3
4
5
6
7
class MyClass 
{

use A, B {
A::someFunc insteadof B;
B::otherFunc as differentFunc;
}
}

MyClass同時使用了A trait與B trait,但因為這兩個trait的函式完全相同,所以造成了函式名稱衝突。

use關鍵字同時加上了AB兩個trait,後面加上大括號描述該怎麼處理函式名稱衝突。

insteadof關鍵字告訴PHP我們要使用A trait的someFunc()而不要使用B trait的someFunc()

as關鍵字可以替trait的變數名稱與函式名稱取別名。

那B::someFunc()怎麼辦?

以上面的例子,因為沒有描述B::someFunc(),所以就被忽略而消失不見了

若要使用B::someFunc()可以自行加上as另外取別名。

繼承 vs. 多型 vs. Trait


回到一個更基本的問題,何時該用繼承?何時該用多型?何時該用trait?

繼承

想要重複使用既有程式功能。缺點是耦合度高,當你繼承了某個class時,已經綁死了某個class,而且繼承在編譯時期就已經決定,無法在執行時期改變。

多型

想要解耦合既有程式功能。只要訂出interface,未來有新的功能只要實作interface即可。由於多型特性,可以在執行時期動態改變物件。優點是耦合度低,耦合只到interface層級,不會被特定class綁死。

Trait

想要共享相同程式功能。有一句話說 :

Inheritance is for extending logic, trait is for sharing behavior.

也就是所謂的Vertial Inheritance and Horizontal Reuse。繼承看起來像是垂直的結構,而trait看起來像是水平的結構。

實務上多型可以取代繼承,但trait卻無法取代多型與繼承,只要需要共享相同的功能,就要多多使用trait。

Good Practice


一個檔案只使用一個Trait
就語法上,PHP允許一個檔案內包含多個trait :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
trait TFoo 
{
public function getFoo()
{

...
}
}

trait TBar
{
public function getBar()
{

...
}
}

但實務上,基於一個class一個檔案one class per file原則,一個檔案也應該只包含一個trait。

Namespace的Use與Trait的Use
雖然namespace與trait都使用use關鍵字,但彼此並不會混淆。

1
2
3
4
5
6
7
8
namespace MyNamespace\SubNamespace; 

use Illuminate\Support\Contracts\JsonableInterface;
use Illuminate\Support\Contracts\RenderableInterface;

class MyClass {
use MyTrait;
}
  • 當use用在namespace時,會寫在class外面。
  • 當use用在trait時,會寫在class裡面。

與其他程式語言比較


Ruby

1
2
3
4
5
6
7
8
9
10
11
12
13
module MyTrait
def setAddress(address)
...
end
end

class MyClass
include MyTrait
end

class MyClass
extend MyTrait
end

宣告trait :

  • Ruby沒提供trait關鍵字,要用module模擬。

注入trait :

  • include關鍵字是instance method
  • extend關鍵字是class method(相當於PHP的 static function)。

PHP

1
2
3
4
5
6
7
8
9
10
11
12
trait MyTrait 
{
public function setAddress($address)
{

...
}
}

class MyClass
{

use MyTrait;
}

宣告trait :

  • 使用trait關鍵字。

注入trait :

  • 在class內部使用use關鍵字。

Conclusion


  • trait可以簡單的想成部分class,允許我們將trait注入到其他的class內,有些應用在繼承與多型都不好用時,可以考慮用trait。
  • insteadofas關鍵字可解決trait與class函式與變數名稱衝突的問題。
  • 實務上多型可以取代繼承,但trait卻無法取代多型與繼承,只要需要共享相同的功能,就要多多使用trait。
  • namespace與trait都使用了use關鍵字,唯用在namespace時會寫在class外面,而用在trait時會寫在class裡面。
2015-09-01