SOLID 原則 - Open Closed Principle

Open Closed Principle

Entities should be open for extension, but closed for midification.
軟體中的對象,例如Class、Module、Method,這些對於擴展是開放的,但是對於修改是封閉的

OCP 是我在軟體開發中最愛使用的一個原則

對工程師們來說,開發軟體最怕的不是寫程式,而是改程式。改A 壞B,修C 又壞回A 的情況比比皆是

程式間的耦合性過高,雖然有寫測試化程式,但誰也無法保證測試的覆蓋率可以測到所有情況

OCP 便是減少修改過程中的出錯率,以及增加測試妥善率的最佳方法

而OCP 的最高境界就是達到:

藉由增加程式來擴充功能,而並非修改程式

講白了,舊有的程式你不去動他就沒事,而新增加的程式只要針對這部份補上測試程式碼就沒問題了

Example

以下是一個計算面積的類別,目前只支援正方形:

1
2
3
4
5
6
7
8
9
10
11
class AreaCalculator
{
public function calculate()
{
foreach ($shapes as $shape) {
$area[] = $shape->width * $shape->width;
}

return array_sum($area);
}
}

$shapes 傳入當參數,並且透過 foreach 將計算好的面積透過 $area[] 暫存,再透過 array_sum 將總合輸出。

但是現在新增了一個需求, AreaCalculator 需加入計算圓形的需求

因此我們的程式碼改成這樣:

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

public function __construct($radius)
{
$this->radius = $radius;
}
}

class AreaCalculator
{
public function calculate()
{
foreach ($shapes as $shape) {
if ($shape instanceof Square) {
$area[] = $shape->width * $shape->width;
} elseif ($shape instanceof Circle) {
$area[] = $shape->radius * $shape->radius * pi();
}
}
}
}

那麼,再加入一個三角形的需求呢?

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
class Triangle
{
public $base;

public $height;

public function __construct($base, $height)
{
$this->base = $base;

$this->height = $height;
}
}

class AreaCalculator
{
public function calculate()
{
foreach ($shapes as $shape) {
if ($shape instanceof Square) {
$area[] = $shape->width * $shape->width;
} elseif ($shape instanceof Circle) {
$area[] = $shape->radius * $shape->radius * pi();
} else if ($shape instanceof Triangle) {
// ...
}
}
}
}

因為不斷的增加計算類別的需求,因此 calculate() 的結構也不斷的被破壞,而每一次的修改,便需要多一次的測試程式碼來驗證功能

Solution

有一項簡易的口訣來幫助優化:

Separate extensible behavior behind an interface, and flip the dependencies.

把所有需擴展的功能行為透過 interface 來定義,並且反轉兩邊的相依關係。

Separate extensible behavior

將行為透過interface 來定義:

1
2
3
4
interface Shape
{
public function area();
}

Flip the dependencies

反轉相依關係:

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
class Circle implements Shape
{
public $radius;

public function __construct($radius)
{
$this->radius = $radius;
}

public function area()
{
return $this->radius * $this->radius * pi();
}
}

class Square implements Shape
{
public $width;

public function __construct($width)
{
$this->width = $width;
}

public function area()
{
return $this->width * $this->width;
}
}

Reconstruction

1
2
3
4
5
6
7
8
9
10
11
class AreaCalculator
{
public function calculate(Shape $shapes)
{
foreach ($shapes as $shape) {
$area[] = $shape->area();
}

return array_sum($area);
}
}

如此一來,當需要新增一個五邊形,或者六邊形的計算方法時,不需要再到 AreaCalculator 多寫任何一行code,而是把計算的方法反轉至所屬的class 進行。

OCP 可以運用的情境太多了,這邊再提供另一個範例

Example: SocialMedia

假設現在要開發第三方社群平台(Facebook、Google) 的登入以及註冊,要如何使用OCP 來進行

原先可能會這樣寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
class AuthenticateController
{
public function attempt(Request $request)
{
if ($request->social == 'facebook') {
// do facebook login
} else if ($request->social == 'google') {
// do google login
} else {
throw new InvalidRequestException;
}
}
}

這樣的寫法如果遇到需要新增新的第三方平台時,就會破壞原有的結構,而使用了OCP 的寫法則會更改成如下:

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
34
namespace SocialMedia;

interface ISocialable
{
public function register($token);

public function attempt($token);
}

class Facebook implementss ISocialable
{
public function register($token)
{
// implements register logic here.
}

public function attempt($token)
{
// implements login logic here.
}
}

class Google implementss ISocialable
{
public function register($token)
{
// implements register logic here.
}

public function attempt($token)
{
// implements login logic here.
}
}

最後便是重構 AuthenticateController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class AuthenticateController
{
public function attempt(Request $request)
{
$class = "\SocialMedia\\" . ucfirst($request->social);

$user = $this->fromSocialMediaLogin(new $class, $request->token);
}

private function fromSocialMediaLogin(ISocialAble $type, $token)
{
return $type->attempt($token);
}
}

如此一來,不管擴展怎樣的第三方平台,都不需要更改 AuthenticateController 原先的邏輯