SOLID 原则是面向对象编程中的五个重要设计原则,有助于增强软件的可维护性、可扩展性和可读性。

#S- Single Responsibility Principle 单一职责

单一原则要求一个类只能承担一个职责,并且只能有一个潜在的原因去更改这个类。

单一职责

不要刻意的追求单一原则,甚至于到了一个类只包含一个函数的程度。

#Example

如下是一个常见的 Unity Monobehavior

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UnrefactoredPlayer : MonoBehaviour
{
[SerializeField] private string inputAxisName;
[SerializeField] private float positionMultiplier;
private float m_YPosition;
private AudioSource m_BounceSfx;
private void Start() { m_BounceSfx = GetComponent<AudioSource>(); }

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

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

这个脚本中实际上耦合了音频,按键输入和对于玩家移动的控制,即:
不符合单一原则的 Player

为了符合单一职责的原则,可以将对于音频,按键输入和玩家控制的逻辑,分别拆分至 PlayerAudioPlayerInputPlayerMovement 三个类中, 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
30
[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), typeof(PlayerMovement))]
public class Player : MonoBehaviour
{
[SerializeField] private PlayerAudio playerAudio;
[SerializeField] private PlayerInput playerInput;
[SerializeField] private PlayerMovement playerMovement;

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

public class PlayerAudio : MonoBehaviour
{

}

public class PlayerInput : MonoBehaviour
{

}

public class PlayerMovement : MonoBehaviour
{

}

关系图如下,此时如果要修改音频相关的逻辑,只需要修改 PlayerAudio 脚本,不会有影响到 Input 和 Movement 的风险:
符合单一原则的 Player

#O-Open/Closed Principle 开闭原则

实体应该对  扩展  开放,对  修改  关闭。允许扩展行为而无需修改源代码。

开闭原则

#Example

如存在 AreaCalculator 类,用以计算不同形状的面积:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

不符合开闭原则的 Area Calculator

目前这个类的实现没有什么问题,但每增加一个形状,AreaCalculator 就需要修改一次。随着形状的种类的扩张, AreaCaulculator 会变得逐渐不可维护。

为了符合开闭原则,可以将所有形状抽象为 Shape 类,在派生类中定义每个形状计算面积的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public abstract class Shape
{
public abstract float CalculateArea();
}

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; }
}

public class AreaCalculator
{
public float GetArea(Shape shape) { return shape.CalculateArea(); }
}

此时 AreaCalculator 只需要关心调用 Shape 中的 CalculateArea 函数,而不需要关心具体的 Shape 类型。因此即使有了新的 Shape,也不会需要修改 AreaCalculator 类。此时类与类间的关系为:
符合开闭原则的 AreaCalculator

#L - Liskov Substitution Principle 里氏替换原则

程序中的对象应该可以被其子类实例替换掉,而不会影响程序的正确性。里氏替换原则指明了类的派生关系,要求派生类必须完全能承担基类的所有功能。

里氏替换原则

一些遵守里氏替换原则的技巧:

  • 如果在派生类中移除了某个函数的实现,则可能会破坏里氏替换原则。NotImplementedException 是一个绝对的信号,告知开发者里氏替换原则被破坏。如果子类中有空函数实现,则也标明了里氏替换原则的破坏。
  • 保持基类尽可能的简单:在基类中的逻辑越多,则越可能破坏里氏替换原则。
  • 更多的使用组合而非派生
  • 在构造类的继承关系前,先思考类的 API。实现中的抽象关系,并不一定要来自于现实的抽象关系。并不是现实中所有的 关系,都需要设计为派生关系。如下例子中,TrainCar 可以派生自不同的基类,而非派生自 Vehicle

#Example

有一个 Vehicle 基类标识交通工具,并有 CarTrunk 两个派生类:
Vehicle 和派生类

Vehicle 类如下

1
2
3
4
5
6
7
8
9
public class Vehicle
{
public float speed = 100;
public Vector3 direction;
public void GoForward() { ... }
public void Reverse() { ... }
public void TurnRight() { ... }
public void TurnLeft() { ... }
}

交通工具,可能在马路上,也可能在铁路上行驶,如下图所示:
铁路和马路

可以通过类 Navigator 来控制交通工具的行驶:

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 传递给 Navigator 就会发现,Train 在铁轨上是不能进行左右转弯的:
Train 无法进行左右转弯

此时即破坏了里氏替换原则,因为基类 Vehicle 要求交通工具要实现左右转弯,而 Train 并无法实现,即基类无法被派生类的替换。

为了修复这个问题,可以使用组合,将转向和移动拆分成两个接口,而不是集中在 Vehicle 基类中:

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

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

创建两个基类 RoadVehicleRailVehicle 使用这两个接口,如下所示。此时每一个派生类都完美的实现了基类(ITurnableIMovable)的功能,即满足了里氏替换原则:
符合里氏替换原则的 Car / Train

实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class RoadVehicle : IMovable, ITurnable
{
public float speed = 100f;
public float turnSpeed = 5f;
public virtual void GoForward() { ... }
public virtual void Reverse() { ... }
public virtual void TurnLeft() { ... }
public virtual void TurnRight() { ... }
}

public class RailVehicle : IMovable
{
public float speed = 100;
public virtual void GoForward() { ... }
public virtual void Reverse() { ... }
}

public class Car : RoadVehicle { ... }

public class Train : RailVehicle { ... }

#I - Interface Segregation Principle 接口隔离原则

使用多个特定细分的接口比单一的总接口要好,不能强迫用户去依赖他们用不到的接口。

#Example

如在一个策略游戏中,有很多的单位。开发者可能会定义一个如下接口保证所有的对象都实现相似的功能:

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

这个接口太过于复杂了,比如一个爆炸桶单位并不会移动,因此如果爆炸桶继承了该类,它必须要实现无意义的 GoForwardTurnLeft 等接口。

符合接口合理原则的方式,是将上述的接口进行拆分,如下所示。此时每一个派生类都能实现它真正需要的接口:
符合接口隔离的接口定义

定义的接口为:

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
public interface IMovable
{
public float MoveSpeed { get; set; }
public float Acceleration { get; set; }
public void GoForward();
public void Reverse();
public void TurnLeft();
public void TurnRight();
}

public interface IDamageable
{
public float Health { get; set; }
public int Defense { get; set; }
public void Die();
public void TakeDamage();
public void RestoreHealth();
}

public interface IUnitStats
{
public int Strength { get; set; }
public int Dexterity { get; set; }
public int Endurance { get; set; }
}

public interface IExplodable
{
public float Mass { get; set; }
public float ExplosiveForce { get; set; }
public float FuseDelay { get; set; }
public void Explode();
}

public class ExplodingBarrel : MonoBehaviour, IDamageable, IExplodable { ... }

public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats { ... }

#D - Dependency Inversion Principle 依赖倒置原则

程序要依赖于抽象接口,而不是具体实现。

  • 高层模块不应该依赖底层模块,二者都应该依赖于抽象
  • 抽象不应该依赖具体实现,具体实现应该依赖抽象

依赖倒置原则

关于内聚耦合的定义和解释,见 高内聚低耦合

在编码时,自然的会产生 High-Level 的模块和 Low-Level 的模块。很自然的情况下,High-Level 模块会依赖 Low-Level 模块实现某些功能,但依赖导致原则则需要将整个依赖关系转为依赖抽象。

#Example

如需要实现打开门 这一功能,常见的会定义一个 Switch 类管理门的开启或关闭,并定义 Door 表示门。 SwitchHigh-Level 模块,DoorLow-Level 模块。

最常见的实现情况如下:

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 Switch : MonoBehaviour
{
public Door door;
public bool isActivated;

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

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

此时依赖关系为:
高等级的 Switch 依赖低等级的 Door

这种实现可以正常运作,但 Switch 直接依赖了 Door,如果 Switch 要控制更多的物体,如灯泡,电风扇则需要再次修改。

解决方法是增加 ISwitchable 接口,Switch 依赖 ISwitchable 而不依赖 Door,如下:
使用 ISwitchable 作为两个类的桥梁

实现代码如下:

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
public interface ISwitchable
{
public bool isActive { get; }
public void Activate();
public void Deactivate();
}

public class Switch : MonoBehaviour
{
public ISwitchable client;

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

public class Door : MonoBehaviour, ISwitchable
{
public bool isActive { get; private set; }

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

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

此时再需要定义新的可开关变量,只需要新增类而无需修改 Switch 脚本。这就是依赖倒置原则带来的好处:

#Reference

Level up your code with game programming patterns | Unity Blog

图解你身边的 SOLID 原则 - 知乎 (zhihu.com)