並不是使用 Train Wreck 就違反迪米特法則

迪米特法則也稱為最小知識原則,是物件導向 SOLID 原則中的 L 其中之一 LKP ( Least Knowledge Principle),是 1987 年 Ian Holland 在美國東北大學所提出,此法則應用在其 The Demeter Project 而得名,是物件導的基本原則。

Motivation


很多人沒有認清迪米特法則的本質,只要看到類似 Clean Code p. 110 書中提到的 Train Wreck 風格的程式碼

1
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

就認為違反的迪米特法則,真的是這樣嗎?

定義


高階模組不應該知道低階模組的內部如何運作。

低階模組不應該暴露內部物件,不應該暴露實踐細節,應僅提供方法給高階模組使用。

白話就是

Controller 不應該知道 service 的內部如何運作。

Service 應該將內部所用的其它 service 封裝起來,提供 method 給 controller 使用,而非直接提供內部 service 給 controller 呼叫。

Train Wreck


SMSService

1
2
3
4
5
6
7
8
9
namespace App\Services;

class SMSService
{

public function getMessage(): string
{

return 'Message';
}
}

SMSService 僅有一個 getMessage()

NotificationService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace App\Services;

class NotificationService
{

/** @var SMSService */
private $smsService;

/**
* NotificationService constructor.
* @param SMSService $smsService
*/

public function __construct(SMSService $smsService)
{

$this->smsService = $smsService;
}

/**
* @return SMSService
*/

public function getSMSService(): SMSService
{

return $this->smsService;
}
}

NotificationService 相依了 SMSService,直接使用 getSMSService()SMSService 物件傳出去。

PostController

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
namespace App\Http\Controllers;

use App\Services\NotificationService;
use View;

class PostController extends Controller
{

/** @var NotificationService */
private $notificationService;

/**
* PostController constructor.
* @param NotificationService $notificationService
*/

public function __construct(NotificationService $notificationService)
{

$this->notificationService = $notificationService;
}

/**
* 顯示所有簡訊
* @return View
*/

public function index()
{

$data['message'] = $this->notificationService->getSMSService()->getMessage();
return view('posts.index', $data);
}
}

因為 getSMSService() 傳回 SMSService 物件,導致 controller 必須寫出 Train Wreck

1
$this->notificationService->getSMSService()->getMessage();

這種寫法有幾個缺點 :

  • PostControllerNotificationService 內部的 SMSService 強烈耦合,若想要換掉 SMSService 物件,則 PostController 必須跟著修改,也就是暴露內部物件
  • PostController 為了要顯示 message,竟然還必須知道 NotificationService 內部使用了 SMSService 物件,先使用 getSMSService() 才行,也就是暴露實踐細節
  • 違反了物件導向的封裝原則,PostController 竟然可以將手伸進去執行 SMSService 的方法。

簡單來說,迪米特法則就是物件導向封裝特性的具體實現。

建議將以上程式碼重構成以下寫法

NotificationService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace App\Services;

class NotificationService
{

/** @var SMSService */
private $smsService;

/**
* NotificationService constructor.
* @param SMSService $smsService
*/

public function __construct(SMSService $smsService)
{

$this->smsService = $smsService;
}

public function getMessage(): string
{

return $this->smsService->getMessage();
}
}

PostController

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
namespace App\Http\Controllers;

use App\Services\NotificationService;
use View;

class PostController extends Controller
{

/** @var NotificationService */
private $notificationService;

/**
* PostController constructor.
* @param NotificationService $notificationService
*/

public function __construct(NotificationService $notificationService)
{

$this->notificationService = $notificationService;
}

public function index()
{

$data['message'] = $this->notificationService->getMessage();
return view('posts.index', $data);
}
}

重構之後,PostController 不再出現 Train Wreck

1
$this->notificationService->getMessage();
  • PostController 完全不知道NotificationService內部物件,若想要換掉 SMSService 物件,則 PostController 完全不用修改。
  • PostController 為了要顯示 message,不必再知道實踐細節, 直接使用 getMessage() 就可以抓到資料。
  • 符合了物件導向的封裝原則,PostController 無法將手伸進去執行 SMSService 的方法。

當 service 直接將內部使用的 service 傳出後,逼 controller 必須先了解其內部實踐細節,使得 controller 與 service 的內部其它 service 強烈耦合,這違反了物件導向封裝特性,也違反了迪米特法則

違反迪米特法則,通常會寫出 Train Wreck ,因此可使用 Train Wreck 檢查是否違反迪米特法則

最小知識原則


物件導向 SOLID 原則的最小知識原則 LKP (Least Knowledge Principle),事實上與迪米特法則講的是同一件事情。

一個物件應該對其他物件有最少的了解。

白話就是

Controller 應該以最簡單的方式使用 service。

之前的 PostController ,因為違反了迪米特法則

1
$this->notificationService->getSMSService()->getMessage();

因此必須知道NotificationServiceSMSService 之後才能 getMessage()

但重構之後

1
$this->notificationService->getMessage();

只要知道 NotificationService 就可以 getMessage()了。

符合最小知識原則,自然符合迪米特法則

再論 Train Wreck


我們知道違法迪米特法則會寫出 Train Wreck,但寫出 Train Wreck 一定違反迪米特法則嗎?

1
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

Clean Code p.111 認為,要看 getOptions()getScratchDir()getAbolutePath() 回傳的是 object 還是 data structure?

這裡的 data structure 不是我們在學校念書時所謂的資料結構,如 linked list、tree 那些,而是指一個物件只有資料,沒有任何商業邏輯

1
class Student
{
    public $id;
    public $name;
}

這種完全用 public field 的物件算 data structure。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Student
{

/** @var int */
private $id;
/** @var string */
private $name;

public function __construct(int $id, string $name)
{

$this->id = $id;
$this->name = $name;
}

public function getId()
{

return $this->id;
}

public function getName()
{

return $this->name;
}
}

儘管多了 constructor 與 getter,它還是 data structure,因為沒有任何商業邏輯。

如果回傳的只是一種無其它行為的 data structure,那它們在本質上必然會揭露內部的結構,所以迪米特法則在這種狀況下並不適用。

Clean Code p.111

如前例回傳 SMSService, 因為包含商業邏輯,所以回傳算是 object,而非 data structure,只要包含商業邏輯,就會暴露實踐細節,而導致商業邏輯無法抽換,因此 controller 與 service 就必須解耦合,遵守迪米特法則

Train Wreck 不見得違反迪米特原則,要看回傳的是 data structure 還是 object。

Fluent Interface

在 Laravel 的 Eloquent,我們會這樣寫

1
2
3
4
$flights = Flight::where('active', 1)
->orderBy('name', 'desc')
->take(10)
->get();

這也是 Train Wreck,也違反迪米特法則嗎?

where()orderBy()take() 這些,並沒有回傳其內部物件,而是傳回 $this,因此沒有暴露內部物件暴露實踐細節的問題,也沒有與內部物件強烈耦合問題,因此 fluent interface 並沒有違反迪米特法則

並不是 Train Wreck 一定違反迪米特法則,關鍵在於有沒有暴露內部物件暴露實踐細節,而不在於 Train Wreck

Conclusion


  • 違反迪米特法則會寫出 Train Wreck,但 Train Wreck 不一定會違反迪米特法則
  • 迪米特法則重點在於強調物件導向的封裝特性,關鍵在於不該暴露內部物件,進而暴露實踐細節,導致使用端與內部物件強烈耦合而無法抽換商業邏輯。
  • 迪米特法則要求所有的動作都必須透過物件本身的方法操作,而不能傳出內部物件,讓使用端直接操作內部物件,而不在於是否使用 Train Wreck

Reference


Robert C. Martin, 無瑕的程式碼
良葛格, 封裝與迪米特法則
Martin Fowler, Fluent Interface

2017-04-20