Interface segregation principle

介面隔離原則(Interface segregation principle):不應該讓一個類(class)為了實作某個介面(interface)而去讓這個類實作用不到的方法,簡單的說就是要避免一個大型的介面(interface)。最簡單的思考方向是使用單一職責(singleresponsibility principle)去考慮,讓每個介面保持單一並簡單。

假設要製作一個策略遊戲,這個遊戲有多個角色單位,每個單位會有血量,速度,等狀態,
可能會想將這些單位的狀態抽象為一個介面,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface IUnitStats
{
float Health { get; set; }
int Defense { get; set; }
void Die();
void TakeDamage();
void RestoreHealth();
float MoveSpeed { get; set; }
float Acceleration { get; set; }
void GoForward();
void Reverse();
void TurnLeft();
void TurnRight();
int Strength { get; set; }
int Dexterity { get; set; }
int Endurance { get; set; }
}

但是在遊戲中,有會有一些不可移動但是可以破壞的物件,這些物件也會需要生命值(Health),但是不需要移動相關的方法(如GoForward),因此這個介面太臃腫了。

可以考慮將這些行為切割為較小的介面,每個介面負責專一的職責

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface IMovable
{
float MoveSpeed { get; set; }
float Acceleration { get; set; }
void GoForward();
void Reverse();
void TurnLeft();
void TurnRight();
}
public interface IDamageable
{
float Health { get; set; }
int Defense { get; set; }
void Die();
void TakeDamage();
void RestoreHealth();
}
public interface IUnitStats
{
int Strength { get; set; }
int Dexterity { get; set; }
int Endurance { get; set; }
}

在實作時,敵人單位會移動,有狀態,並且是可損壞的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats
{
public float Health { get; set; }
public int Defense { get; set; }
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }

public void Die() { ... }
public void TakeDamage() { ... }
public void RestoreHealth() { ... }
public void GoForward() { ... }
public void Reverse() { ... }
public void TurnLeft() { ... }
public void TurnRight() { ... }
}

箱子只能被損壞

1
2
3
4
5
6
7
8
9
public class Box : MonoBehaviour, IDamageable
{
public float Health { get; set; }
public int Defense { get; set; }

public void Die() { ... }
public void TakeDamage() { ... }
public void RestoreHealth() { ... }
}

這樣,每個類只需要實作其所需的介面,避免了因為實作不必要的方法而增加的複雜性。

Liskov substitution principle

里氏替換原則(Liskov substitution principle):子類(Subclass)必須要可以替代父類(Super class),即任何父類出現的地方,都可以使用其子類替換,而不影響程式的正確性。

在考慮里氏替換原則時,可以遵循以下幾點:

  • 避免移除特性:如果在父類的行為中,如果子類中沒有這個特性的話,你可能違反了里氏替換原則(If you are removing features when subclassing, you are likely breaking Liskov substitution)。在設計時,
    • 如果子類中出現了NotImplementedException,那你可能違反了里氏替換原則
    • 如果出現只能是空白的方法也可能違反了里氏替換原則
  • 保持抽象簡單:盡量讓抽象保持簡單(Keep abstractions simple):如果在基類放入越多的邏輯,那你有很大的機率違反了里氏替換原則。基類應只包含子類可以共有的方法。
  • 子類需要有與基類相同的公有成員(A subclass needs to have the same public members as the base class):這些成員在呼叫時還需要具有相同的行為。
  • 先考慮類的API再建立層次結構:在建立類的層次結構前先考慮它們的API(Consider the class API before establishing class hierarchies):現實中的分類並不總能完全轉為類的結構,例如汽車(Car)與火車(Train)皆是車輛但它們不能直接繼承同一個父類,最好是將它們分開繼承
  • 優先考慮組合而非繼承(Favor composition over inheritance):在實作時,先考慮使用介面 (Interface) 或是將行為委託給其他類,而不是直接使用繼承。

例如:假設有一個交通工具 (Vehicle) 的類層次結構,會思考:汽車 (Car) 和卡車 (Truck) 繼承自交通工具 (Vehicle),

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
public class Vehicle
{
public float Speed { get; set; }
public Vector3 Direction { get; set; }

public virtual void GoForward()
{
// 移動向前的邏輯
}

public virtual void Reverse()
{
// 倒車的邏輯
}

public virtual void TurnRight()
{
// 右轉的邏輯
}

public virtual void TurnLeft()
{
// 左轉的邏輯
}
}
1
2
3
4
5
6
7
8
9
10
11
public class Navigator
{
public void Move(Vehicle vehicle)
{
vehicle.GoForward();
vehicle.TurnLeft();
vehicle.GoForward();
vehicle.TurnRight();
vehicle.GoForward();
}
}

但若是將火車(Train)作為車輛(Vehicle)的子類則會違反里氏替換原則,這是因為火車沒有右轉(TurnRight)與左轉(TurnLeft)的行為,因此火車不能作為車輛(Vehicle)的子類。

為了修正,可以將轉彎(Turn)和移動(Move)動作抽象為介面可轉彎(ITurnable)和可移動(IMovable)

1
2
3
4
5
6
7
8
9
10
11
public interface ITurnable
{
void TurnRight();
void TurnLeft();
}

public interface IMovable
{
void GoForward();
void Reverse();
}

並將車輛分為可以在一般道路上行駛的車輛RoadVehicle,它實作可轉彎(ITurnable)和可移動(IMovable)

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
public class RoadVehicle : IMovable, ITurnable
{
public float Speed { get; set; }
public float TurnSpeed { get; set; }

public virtual void GoForward()
{
// 移動向前的邏輯
}

public virtual void Reverse()
{
// 倒車的邏輯
}

public virtual void TurnLeft()
{
// 左轉的邏輯
}

public virtual void TurnRight()
{
// 右轉的邏輯
}
}

與需要在軌道上行駛的車輛RailVehicle,它只實作可移動(IMovable)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class RailVehicle : IMovable
{
public float Speed { get; set; }

public virtual void GoForward()
{
// 移動向前的邏輯
}

public virtual void Reverse()
{
// 倒車的邏輯
}
}

最後,定義汽車和火車的類:

1
2
3
4
5
6
7
8
9
public class Car : RoadVehicle
{
// Car特有的實作
}

public class Train : RailVehicle
{
// Train特有的實作
}

這樣,Navigator 類可以安全地操作所有可以移動的交通工具,而不需要關心它們是否能轉彎:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Navigator
{
public void Move(IMovable movable)
{
movable.GoForward();
movable.Reverse();
}

public void Turn(ITurnable turnable)
{
turnable.TurnLeft();
turnable.TurnRight();
}
}

Open-closed principle

開閉原則(Open-closed principle):開閉原則指的是類(Class)要可以對外擴展(open for extension),但是對內部修改封閉(closed for modification)。簡單的說就是可以為你的程式添加新的行為,但是不去修改現有程式碼。

一個經典的例子是計算體積,我們要建立一個 AreaCalculator 類,用來計算物體的體積,

下面是一個不遵守開閉原則的例子。

  • 定義了一個AreaCalculator類,它含有GetRectangleAreaGetCircleArea方法,分別用來計算四邊形與圓形的體積
  • 定義了一個Rectangle類,用來存放四邊形的寬和高
  • 定義了一個Circle類,用來存放圓形半徑
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class AreaCalculator
    {
    public float GetRectangleArea(Rectangle rectangle)
    {
    return rectangle.width * rectangle.height;
    }
    public float GetCircleArea(Circle circle)
    {
    return circle.radius * circle.radius * Mathf.PI;
    }
    }

    public class Rectangle
    {
    public float width;
    public float height;
    }

    public class Circle
    {
    public float radius;
    }
    這個AreaCalculator類是可以運作的如預期,但是如果添加更多計算體積的方法(例如三角形或五邊形),就會一直變更這個AreaCalculator類,這違反了開閉原則。

建立一個Shape基類,

1
2
3
4
public abstract class Shape
{
public abstract float CalculateArea();
}

所有物體皆繼承這個Shape基類

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Rectangle : Shape
{
public float width;
public float height;
public override float CalculateArea()
{
return width * height;
}
}

public class Circle : Shape
{
public float radius;
public override float CalculateArea()
{
return radius * radius * Mathf.PI;
}
}

AreaCalculator類更改為依賴Shape

1
2
3
4
5
6
7
public class AreaCalculator
{
public float GetArea(Shape shape)
{
return shape.CalculateArea();
}
}

如此每當添加新物體時,也不用更改AreaCalculator類,只需要新增一個繼承Shape的類即可

Single-responsibility principle

單一職責(Single-responsibility principle):一個類(class)應該只因為它負責的那件事而被更改。

注意:在單一職責中,要取得平衡,不要過度的拆分,例如拆分到一個類中只有一個方法。

拆分時可以考慮以下因素:

  • 可讀性(Readability):簡短的類通常比較容易閱讀和理解。雖然“簡短”沒有明確的定義,但通常開發者認為200~300行的類是比較合適的。
  • 擴展性(Extensibility):類是否容易擴展,修改或替換這些類時不應該無意破壞其他部分。
  • 可重用性(Reusability):是否可以更方便地重新使用這些類

以下是一個將聲音,輸入,移動皆包含在一起的Player類,隨著專案的發展,這個類會越來愈難維護,例如更改聲音會動到這個類,更改移動輸入會動到這個類…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class UnrefactoredPlayer : MonoBehaviour
{
[SerializeField] private string inputAxisName;
[SerializeField] private float positionMultiplier;
private float yPosition;
private AudioSource bounceSfx;

private void Start()
{
bounceSfx = GetComponent<AudioSource>();
}

private void Update()
{
float delta = Input.GetAxis(inputAxisName) * Time.deltaTime;
yPosition = Mathf.Clamp(yPosition + delta, -1, 1);
transform.position = new Vector3(transform.position.x, yPosition * positionMultiplier, transform.position.z);
}

private void OnTriggerEnter(Collider other)
{
bounceSfx.Play();
}
}

應考慮將這些行為拆分到各自的類,如 PlayerAudio、PlayerInput、PlayerMovement 等。Player 類仍然依賴這些行為,但這些行為已經被拆分到各自的類中。

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
[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), typeof(PlayerMovement))]
public class Player : MonoBehaviour
{
private PlayerAudio playerAudio;
private PlayerInput playerInput;
private PlayerMovement playerMovement;

private void Start()
{
playerAudio = GetComponent<PlayerAudio>();
playerInput = GetComponent<PlayerInput>();
playerMovement = GetComponent<PlayerMovement>();
}
}

public class PlayerAudio : MonoBehaviour
{
// PlayerAudio specific implementation
}

public class PlayerInput : MonoBehaviour
{
// PlayerInput specific implementation
}

public class PlayerMovement : MonoBehaviour
{
// PlayerMovement specific implementation
}

Observer pattern

在遊戲中,會發生很多事,例如擊敗一個敵人,玩家升級等,如果直接將這些行為直接在各個物件中執行,會增加很多依賴,當程式碼成長到一定程度時,會變得很難維護。

觀察者模式(Observer pattern)通常會用來解決此問題。它使用一種one-to-many依賴(dependency),讓你的物件之間的溝通可以保持在一種低耦合(loosely coupled)的關係,當一個物件的狀態改變時,會自動通知所有想要接收此訊息的物件。例如廣播系統,廣播會向外發送音樂給收聽者,廣播不需要知道誰在收聽,它只要使用固定的頻道發送音樂即可,而收聽者只需要將頻道調整到該廣播系統的頻道即可收到音樂。

  • 發送消息的物件稱為subject
  • 其他接收此消息的物件稱為observers

優點:

  • 發送消息的物件(subject)與接收此消息的物件(observers)解耦(Loose coupling)
    • 發送消息的物件不需要知道關於接收此消息的物件的資訊,它只需要負責將消息發送出去;
    • 接收此消息的物件會有一個發送消息物件的依賴,但是接收此消息的物件之間互相不知道彼此
  • 將observer需要對訊息做出反應的邏輯放到對應的observer中,讓他們各自維護。變得較容易測試與除錯
  • 非常適合使用者介面(User Interface),MVPMVC patterns就是使用觀察者模式(Observer pattern)

缺點:

  • 增加程式複雜度,
  • 如果要刪除Subject的話會比較麻煩
  • Observer仍然會對Subject有依賴,
    • 可以使用一個static EventManager除去
  • 會消耗一些效能

在C#中已經有實作Observer pattern => Event

  • publisher(即subject):有一個或多個event,在執行時,會將訊息發送給註冊這些event的物件
  • observers:這些物件會有一些event handler的方法,當publisher發送訊息時,便會執行對應的event handler方法

以下是一個最簡單的例子
Subject有一個 event ThingHappened,它暴露給其他物件讓它們可以註冊

1
2
3
4
5
6
7
8
9
public class Subject
{
public event Action ThingHappened;

public void DoThing()
{
ThingHappened?.Invoke();
}
}

Observer有一個Subject依賴,用來註冊;並且有一個OnEventHandler,當Subject發送訊息時,便會執行這個方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Observer
{
private Subject subjectToObserve;

public Observer(Subject subject)
{
subjectToObserve = subject;
subjectToObserve.ThingHappened += OnEventHandle;
}

private void OnEventHandler()
{
// any logic that responds to event goes here
Debug.Log("Observer responds");
}
}

Reference: https://unity.com/how-to/create-modular-and-maintainable-code-observer-pattern