泛型約束

泛型約束是使用where關鍵字讓泛型的類型有一定的限制

  • where T:struct :結構類型的約束,只能接收結構類型作為泛型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Test1<T> where T:struct
    {
    public T value;
    public void TestFun<K>(K v) where K: struct
    }

    // 這句會報錯,因為他不是結構類型
    //-- Test1<object> t = new Test1<object>();
    // 這句可以
    Test1<int> t2 = new Test1<int>();
  • where T:class :引用類型約束

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Test2<T> where T : class
    {
    public T value;
    public void TestFun<K>(K k) where K : class {}
    }

    // 這句可以
    Test2<object> t = new Test2<object>();

    // 這句會報錯,因為 int 不是引用類型
    //-- Test2<int> t2 = new Test2<int>();
  • where T: new() :這個泛型一定要有一個無參數public的建構子(constructor),此外若是組合使用的話new()要放到最後

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Test3<T> where T : new()
    {
    public T value;
    }

    class PubC1 {}
    class PubC2
    {
    public PubC2(int i) {}
    }

    // 這句可以
    Test3<PubC1> t = new Test3<PubC1>();

    // 這句不可以,因為 PubC2 沒有無參數的 public 建構子
    //-- Test3<PubC2> t2 = new Test3<PubC2>();
  • where T: 類名 :泛型參數必須要是其類或者是其子類

  • where T: 介面名 :泛型參數必須要是其介面的衍伸類型

  • where T:U:泛型參數為另一個泛型本身或是其衍伸類型

    1
    2
    3
    class Test6<T,U> where T:U{
    public T value;
    }
  • 多個泛型皆有約束

    1
    class Test8<K,V> where K:class,new() where K:struct{}

Singleton pattern

單例模式(Singleton pattern):確保應用程式中只有一個實例,並且提供簡單的全局訪問方式。

然而,單例模式在一些設計模式中被認為弊大於利,被列為反模式(anti-pattern),這是因為單例模式太容易使用了導致開發人員很容易設計出不必要的全域狀態或是全域依賴。

優點

  • 使用簡單,單例是全域可存取的,很容易的就可以取道該單例並在程式中使用。
  • 由於單例是全域可存取因此你不需要去想使否需要暫存它。

缺點

  • 全局存取:單例是全域可存取的,違反了 SOLD原則。
  • 測試困難:單例模式使得測試必須互相獨立變得困難,因為單例可以直接改變狀態。
  • 強依賴性:單例造成的依賴性太強,如果你想更改某個單例,就需要更改所有有使用到它的地方。

以下是一個簡單的單例模式

  • public static Instance用來保存Sence中的單例實體
  • 在Awake()中檢查是否已有這個實例,如果沒有則將其設為實例;如果有則表示有重複設定,需要將它銷毀,以確保在這個場景中只有一個實例。
  • 這個SimpleSingleton有一些缺陷
    • 當載入新Scene時會銷毀這個GameObject(可以使用 DontDestroyOnLoad 告訴Unity不要摧毀它)
    • 在使用時需要將它附加到Scene結構中
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      using UnityEngine;

      public class SimpleSingleton : MonoBehaviour
      {
      public static SimpleSingleton Instance;

      private void Awake()
      {
      if (Instance == null)
      {
      Instance = this;
      }
      else
      {
      Destroy(gameObject);
      }
      }
      }

也可以使用延遲初始化(Lazy initialization)的方式,來建立單例,下面例子中

  • 使用 DontDestroyOnLoad(gameObject) 告訴 Unity 不要銷毀它,因此即使切換場景也不會清除這個單例。
  • 不需要手動附加到Scene結構中,因為使用了 SetupInstance(),如果目前還沒有建立這個單例,它會自動建立一個 GameObject 並附加到它身上。
    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
    35
    36
    37
    38
    39
    40
    41
    public class Singleton : MonoBehaviour
    {
    private static Singleton instance;

    public static Singleton Instance
    {
    get
    {
    if (instance == null)
    {
    SetupInstance();
    }
    return instance;
    }
    }

    private void Awake()
    {
    if (instance == null)
    {
    instance = this;
    DontDestroyOnLoad(this.gameObject);
    }
    else
    {
    Destroy(gameObject);
    }
    }

    private static void SetupInstance()
    {
    instance = FindObjectOfType<Singleton>();
    if (instance == null)
    {
    GameObject gameObj = new GameObject();
    gameObj.name = "Singleton";
    instance = gameObj.AddComponent<Singleton>();
    DontDestroyOnLoad(gameObj);
    }
    }
    }

更進一步地將它一般化。在上面的單例模式中,如果需要多個不同的單例,例如 AudioManager 和 GameManager,那麼會複製很多相同的程式碼來建立單例。因此,可以將這個步驟一般化,如下:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
public abstract class MonoSingleton<T> : MonoBehaviour where T : MonoSingleton<T>
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
instance = FindFirstObjectByType<T>();
if (instance == null)
{
SetupInstance();
}
}
return instance;
}
}

public virtual void Awake()
{
RemoveDuplicates();
}
private static void SetupInstance()
{
instance = FindFirstObjectByType<T>();
if (instance == null)
{
GameObject gameObj = new();
gameObj.name = typeof(T).Name;
instance = gameObj.AddComponent<T>();
DontDestroyOnLoad(gameObj);
}
}
private void RemoveDuplicates()
{
if (instance == null)
{
instance = this as T;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
}

之後你在使用時,只需要繼承他即可

1
2
3
4
public class GameManager: Singleton<GameManager>
{
// ...
}

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/8%20Singleton

Object Pool pattern

物件池(Object Pool):是一種減輕大量建立並銷毀物件時 CPU 負擔的設計模式。使用物件池時,物件會先被建立並放入池中等待,需要時應用程式不會新建物件,而是從物件池中取得並啟用它。當使用完畢後,物件不會被銷毀,而是被放回物件池中。

改進

  • 可以在程式載入時建立物件池,這樣使用者就不會感到卡頓。
  • 考慮將物件池設為static或是singleton的:這樣可以在所有情況下方便呼叫使用。
  • 使用Dictionary來管理多個物件池:如果有多個物件池,可以使用 Key-Value 的資料結構(如 Dictionary)來管理,只需根據對應的 Key 就能方便地取得所需的物件池。
  • 注意釋放物件池中的物件:確保物件在物件池中時不會被釋放,避免執行期間發生錯誤。
  • 設定物件池上限:物件過多會消耗大量記憶體,因此需要為物件池設定一個上限,避免物件池中物件過多。

以下是一個簡單的物件池,

  • 這個物件池使用Stack
  • SetupPool()用來產生一些物件放到物件池中
  • GetPooledObject()用來啟用物件並取得,當物件不足(stack.Count == 0)時,會再產生一個新物件
  • ReturnToPool()用來將物件停用,並返回物件到物件池
  • 注意不要直接使用,因為這個例子沒有設定Stack上限,可能會產生過多的物件
    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
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    public class ObjectPool : MonoBehaviour
    {
    [SerializeField] private uint initPoolSize;
    [SerializeField] private PooledObject objectToPool;
    // 存儲物件池中的物件
    private Stack<PooledObject> stack;
    private void Start()
    {
    SetupPool();
    }
    // 建立物件池(在卡頓不明顯時調用)
    private void SetupPool()
    {
    stack = new Stack<PooledObject>();
    PooledObject instance = null;
    for (int i = 0; i < initPoolSize; i++)
    {
    instance = Instantiate(objectToPool);
    instance.Pool = this;
    instance.gameObject.SetActive(false);
    stack.Push(instance);
    }
    }
    // 從物件池中返回第一個可用的物件
    public PooledObject GetPooledObject()
    {
    // 如果物件不夠,則實例化新的物件
    if (stack.Count == 0)
    {
    PooledObject newInstance = Instantiate(objectToPool);
    newInstance.Pool = this;
    return newInstance;
    }
    // 否則,從物件池中取得下一個物件
    PooledObject nextInstance = stack.Pop();
    nextInstance.gameObject.SetActive(true);
    return nextInstance;
    }
    public void ReturnToPool(PooledObject pooledObject)
    {
    stack.Push(pooledObject);
    pooledObject.gameObject.SetActive(false);
    }
    }

建立一個 PooledObject 類別,讓它依賴 ObjectPool,這樣可以在使用完畢後返回物件池:

1
2
3
4
5
6
7
8
9
public class PooledObject : MonoBehaviour
{
private ObjectPool pool;
public ObjectPool Pool { get => pool; set => pool = value; }
public void Release()
{
pool.ReturnToPool(this);
}
}

在Unity 2021後,有內建 UnityEngine.Pool 物件池,它提供了多種容器的Pool,像是

  • ObjectPool:是一個Stack
  • DictionaryPool<T0,T1>
  • GenericPool
  • HashSetPool
  • LinkedPool
  • ListPool
    使用內建 UnityEngine.Pool 物件池有許多好處,包含
  • 可以快速的使用物件池,不需要重複造輪子
  • 有多種容器供你選用
  • 當到達最大物件數量後會摧毀該物件,不建立過多的物件

以下是UnityEngine.Pool的範例

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using UnityEngine.Pool;
public class RevisedGun : MonoBehaviour
{
// 使用基於 Stack 的 ObjectPool
private IObjectPool<RevisedProjectile> objectPool;
// 如果嘗試返回已經存在於池中的物件,則拋出異常
[SerializeField] private bool collectionCheck = true;
// 控制池的預設容量和最大大小
[SerializeField] private int defaultCapacity = 20;
[SerializeField] private int maxSize = 100;

private void Awake()
{
objectPool = new ObjectPool<RevisedProjectile>(CreateProjectile,
OnGetFromPool, OnReleaseToPool, OnDestroyPooledObject,
collectionCheck, defaultCapacity, maxSize);
}

// 在new ObjectPool建構子中使用,主要是先建立物件以填充物件池
private RevisedProjectile CreateProjectile()
{
RevisedProjectile projectileInstance = Instantiate(projectilePrefab);
projectileInstance.ObjectPool = objectPool;
return projectileInstance;
}

// 在new ObjectPool建構子中使用,告訴物件池在將物件返回時要做什麼動作
private void OnReleaseToPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(false);
}

// 在new ObjectPool建構子中使用,告訴物件池在物件池中取出物件時要做什麼初始化動作
private void OnGetFromPool(RevisedProjectile pooledObject)
{
pooledObject.gameObject.SetActive(true);
}

// 在new ObjectPool建構子中使用,告訴物件池在超過最大物件數量時如何銷毀物件
private void OnDestroyPooledObject(RevisedProjectile pooledObject)
{
Destroy(pooledObject.gameObject);
}

private void FixedUpdate()
{
// 省略其他代碼
}
}

與上面簡單的ObjectPool相同,讓RevisedProjectile依賴 IObjectPool<RevisedProjectile>,在使用完畢後呼叫objectPool.Release(this)返回物件池:

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
public class RevisedProjectile : MonoBehaviour
{
// 延遲後停用
[SerializeField] private float timeoutDelay = 3f;

private IObjectPool<RevisedProjectile> objectPool;

// 公共屬性,用於給 projectile 一個 ObjectPool 的引用
public IObjectPool<RevisedProjectile> ObjectPool { set => objectPool = value; }

public void Deactivate()
{
StartCoroutine(DeactivateRoutine(timeoutDelay));
}

private IEnumerator DeactivateRoutine(float delay)
{
yield return new WaitForSeconds(delay);

// 重置移動中的 Rigidbody
Rigidbody rBody = GetComponent<Rigidbody>();
rBody.velocity = Vector3.zero;
rBody.angularVelocity = Vector3.zero;

// 將 projectile 返回到物件池
objectPool.Release(this);
}
}

Reference: https://github.com/Unity-Technologies/game-programming-patterns-demo/tree/main/Assets/7%20Object%20Pool

Factory pattern

工廠模式(Factory pattern):讓一個特別的物件(工廠Factory)去建立其他物件(產品Product)。它封裝了生成其他物件(產品Product)的邏輯,最直接可見的好處是整理了你的程式碼。工廠物件可以有多種子類(subclass),用來產生多種不同的產品。

優點

  • 使用工廠模式的好處是當你增加產品時,你不需要修改先前的程式碼就可以增加產品

缺點

  • 增加程式碼的複雜度

建立一個IProduct介面,訂立產品必須要有的規則

  • 每個產品一定要有自己的名字(ProductName)
  • 每個產品有初始化自己的方式(Initialize())
  • 產品種類比較不會有共用的邏輯,因此將其設計為介面
    1
    2
    3
    4
    5
    public interface IProduct
    {
    public string ProductName { get; set; }
    public void Initialize();
    }

以下建立一個抽象工廠,抽象工廠用來規定工廠必須要有的動作

  • 每個工廠必須要能在對應的位置(Vector3 position)上生產產品(GetProduct())
    1
    2
    3
    4
    5
    6
    public abstract class Factory : MonoBehaviour
    {
    public abstract IProduct GetProduct(Vector3 position);
    // shared method with all factories

    }

建立一個產品A

  • 它含有一個ParticleSystem,並在初始化方法(Initialize)中會播放此ParticleSystem
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class ProductA : MonoBehaviour, IProduct
    {
    [SerializeField] private string productName = "ProductA";
    public string ProductName { get => productName; set => productName = value ; }
    private ParticleSystem particleSystem;
    public void Initialize()
    {
    // any unique logic to this product
    gameObject.name = productName;
    particleSystem = GetComponentInChildren<ParticleSystem>();
    particleSystem?.Stop();
    particleSystem?.Play();
    }
    }

建立一個工廠A

  • 它含有一個產品A的依賴,在GetProduct()方法中建立產品A實體之後,會呼叫產品A實體上的Initialize()方法用來初始化該實體。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class ConcreteFactoryA : Factory
    {
    [SerializeField] private ProductA productPrefab;
    public override IProduct GetProduct(Vector3 position)
    {
    // 使用Prefab在指定的position上建立一個instance
    GameObject instance = Instantiate(productPrefab.gameObject, position, Quaternion.identity);
    ProductA newProduct = instance.GetComponent<ProductA>();
    // 初始化產品A
    newProduct.Initialize();
    return newProduct;
    }
    }

建立一個產品B

  • 它含有一個AudioSource,在初始化方法(Initialize)中會播放此AudioSource
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class ProductB : MonoBehaviour, IProduct
    {
    [SerializeField] private string productName = "ProductB";
    public string ProductName { get => productName; set => productName = value; }

    private AudioSource audioSource;

    public void Initialize()
    {
    // do some logic here
    audioSource = GetComponent<AudioSource>();
    audioSource?.Stop();
    audioSource?.Play();

    }
    }

建立一個工廠B

  • 工廠B與工廠A類似,不過依賴換為產品B
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class ConcreteFactoryB : Factory
    {
    // used to create a Prefab
    [SerializeField] private ProductB productPrefab;

    public override IProduct GetProduct(Vector3 position)
    {
    // create a Prefab instance and get the product component
    GameObject instance = Instantiate(productPrefab.gameObject, position, Quaternion.identity);
    ProductB newProduct = instance.GetComponent<ProductB>();

    // each product contains its own logic
    newProduct.Initialize();

    // add any unique behavior to this factory
    instance.name = newProduct.ProductName;
    Debug.Log(GetLog(newProduct));

    return newProduct;
    }
    }

建立一個使用工廠的類

  • ClickToCreate這個類使用了工廠A與工廠B,
    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
    public class ClickToCreate : MonoBehaviour
    {
    [SerializeField] private LayerMask layerToClick;
    [SerializeField] private Vector3 offset;
    [SerializeField] Factory[] factories;

    private Factory factory;

    private void Update()
    {
    GetProductAtClick();
    }

    private void GetProductAtClick()
    {
    // check click with raycast
    if (Input.GetMouseButtonDown(0))
    {
    // choose a random factory
    factory = factories[Random.Range(0, factories.Length)];

    // instantiate product at raycast intersection
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    RaycastHit hitInfo;

    if (Physics.Raycast(ray, out hitInfo, Mathf.Infinity, layerToClick) && factory != null)
    {
    factory.GetProduct(hitInfo.point + offset);
    }
    }
    }
    }

SOLID principles

SOLID principles是程式設計師必須了解的五個原則

  • 單一職責原則(Single responsibility):確保類只負責一件事。
  • 開閉原則(Open-closed):在不更改現有程式碼的情況下,可以擴展一個類的功能。
  • 里氏替換原則(Liskov substitution):子類可以替代基類,而不影響程式的正確性。
  • 介面隔離原則(Interface segregation):讓介面(interface)盡可能簡單,確保實作類只需要實作其所需的介面方法。
  • 依賴倒置原則(Dependency inversion):高層模組不應依賴於低層模組,兩者都應依賴於抽象。具體實現應依賴於抽象,而不是抽象依賴於具體實現。

這些原則可以讓你的程式設計更具彈性和可維護性,但在不確定是否要使用它們時,請記住KISS(Keep It Simple, Stupid)原則,不要強迫將其應用於程式碼中。

Dependency inversion principle

依賴倒置原則(Dependency inversion principle)有兩個主要部分:

  • 高層模組不應該依賴於低層模組。兩者都應該依賴於抽象。
  • 抽象不應該依賴於具體實現。具體實現應該依賴於抽象。

在軟體設計中,如果一個類(class)使用了另一個類稱為依賴(dependencycoupling),每增加一點依賴,就會增加一些風險,因為當某個類A知道了另一個類B的內容太多的話(稱為高度耦合high degree of coupling),當B更改的話,那麼A也會需要大量的修改,這樣很容易產生錯誤。

在設計中,有些類是high-level的,而有些類是low-level的,high-level的類會依靠low-level的類去完成某些工作。我們在設計時,要考慮依賴倒置原則,減少一些耦合度。

如果要建立一個遊戲,其中角色可以探索房間,並使用開關(switch)將門(dor)打開,在實作時,你可能會想到要建立一個Switch類與一個Door類,其中

  • Switch屬於high-level的類,它負責判斷角色是否移動到對應的位置,並是否觸發對應的行為
  • Door屬於low-level的類,它負責開關門的實際操作邏輯

如果沒有使用依賴倒置原則的話,可能會如下實作

  • Switch依賴於Door,當條件觸發時,呼叫door去開門
    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
    public class Switch
    {
    public Door door;
    public bool isActivated;

    public void Toggle()
    {
    if (isActivated)
    {
    isActivated = false;
    door.Close();
    }
    else
    {
    isActivated = true;
    door.Open();
    }
    }
    }

    public class Door
    {
    public void Open()
    {
    Debug.Log("The door is open.");
    }

    public void Close()
    {
    Debug.Log("The door is closed.");
    }
    }

這樣的實作沒有問題,但是如果開關(Switch)不只是開門,還可能會觸發一些陷阱的話,就不得不去修改Switch類,這樣違反了開閉原則(Open-closed principle)

你可以將開關這個動作抽象=>ISwitchable。

1
2
3
4
5
6
public interface ISwitchable
{
bool IsActive { get; }
void Activate();
void Deactivate();
}

讓Door去實作這個抽象ISwitchable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Door : MonoBehaviour, ISwitchable
{
private bool isActive;
public bool IsActive => isActive;

public void Activate()
{
isActive = true;
Debug.Log("The door is open.");
}

public void Deactivate()
{
isActive = false;
Debug.Log("The door is closed.");
}
}

而Switch則依賴這個抽象ISwitchable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Switch : MonoBehaviour
{
public ISwitchable client;

public void Toggle()
{
if (client.IsActive)
{
client.Deactivate();
}
else
{
client.Activate();
}
}
}

透過這種方式將上層對底層的依賴剝離至抽象,讓上層可以不需更改程式碼,只需傳入不同的ISwitch實作便可以做到開關不同的物件。

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
}