简介
SOLID是五个软件设计核心基本原则的助记首字母缩写:
- 单一职责原则(Single responsibility)
- 开闭原则(Open-closed)
- 里氏替换原则(Liskov substitution)
- 接口隔离原则(Interface segregation)
- 依赖倒置原则(Dependency inversion)
深入理解这些概念可以帮助我们编写更易于理解、灵活且易于维护的代码。
单一职责原则
一个类应该只有一个改变的理由,即它的单一职责。
单一职责原则(SRP)指出每个模块、类或函数应该只负责一件事,并且只封装该部分逻辑。
我们应该用许多较小的组件来组装项目,而不是构建庞大的类。较短的类和方法更容易解释、理解和实现。
当我们创建一个GameObject时,它包含各种较小的组件。例如,它可能包括:
- 一个MeshFilter,用于存储对3D模型的引用
- 一个Renderer,用于控制模型表面在屏幕上的显示方式
- 一个Transform,用于存储缩放、旋转和位置
- 一个Rigidbody,用于与物理模拟交互
c#
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();
}
}
这个未重构的Player类包含了多种不同的职责:当玩家与物体发生碰撞时播放声音、管理输入以及处理移动等。尽管目前这个类的代码量还比较少,但随着项目的演进,它会变得越来越难以维护。建议将Player类拆分成几个更小的类。
cs
[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
{
...
}
开闭原则
在软件设计中,有一个叫做"开闭原则"的重要原则。它的意思是说,当我们设计一个类(class)的时候,应该让这个类易于扩展新的功能,但是不需要修改原来的代码。
举个例子,假设我们要写一个计算图形面积的程序。我们可以先写一个AreaCalculator
的类,里面有计算矩形面积和圆面积的方法。
要计算矩形的面积,只需要知道矩形的宽和高;要计算圆的面积,只需要知道圆的半径和π的值。
c#
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
"里一直加新的方法,让这个类变得越来越臃肿。
另一种方法是写一个"图形"的父类,在里面写一个处理各种图形的方法。但这样的话,方法里面就要写一堆 if else 的判断语句来区分不同的图形,扩展起来也很麻烦。
我们真正想要的,是在不改动"面积计算器"原有代码的情况下,让它能够灵活地支持新的图形。虽然目前的AreaCalculator
能用,但它并没有遵守"开闭原则",因为添加新图形的时候不得不修改原来的代码。
实际上,我们可以定义出一个抽象类Shape
,然后让Rectangle和Circle去实现,那么后期只需新增新的实现类,不改AreaCalculator的代码就可以拓展功能。
c#
public abstract class Shape
{
public abstract float CalculateArea();
}
c#
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"的代码。原有的代码保持不变,你只需要检查新代码中是否有错误的逻辑。要充分利用接口和抽象。这有助于避免在代码逻辑中使用冗长的 switch 或 if 语句,因为那样以后会很难扩展。一旦你习惯了按照"开闭原则"来设计类,以后添加新代码就会变得更简单。
里氏替换原则
"里氏替换原则"意思是说子类必须能够替换掉它们的父类,而不会影响程序的正确性。
假设在游戏中需要一个叫做"Vehicle"的类,它将作为其他具体车辆类(Car、Trunk)的父类。在任何使用"Vehicle"类的地方,你都应该能够使用它的子类(Car、Trunk),而不会引起程序出错。
c#
public class Vehicle
{
public float speed = 100;
public Vector3 direction;
public void GoForward()
{
...
}
public void Reverse()
{
...
}
public void TurnRight()
{
...
}
public void TurnLeft()
{
...
}
}
假设我们需要在地图上移动各种车辆。再写一个叫做"Navigator"的类,用来指挥车辆沿着特定的路径行驶。
c#
public class Navigator
{
public void Move(Vehicle vehicle)
{
vehicle.GoForward();
vehicle.TurnLeft();
vehicle.GoForward();
vehicle.TurnRight();
vehicle.GoForward();
}
}
这个Navigator
类的 Move 方法应该可以接受任何Vehicle
对象,用它来移动汽车和卡车都没问题。但是,如果你想要再实现一个Train
类呢?
"导航器"类中的 TurnLeft 和 TurnRight 方法显然不适用于火车,因为火车不能离开铁轨。如果把一个火车对象传入 Move 方法,程序运行到那几行代码时,会抛出一个未实现的异常,或者什么也不做。
一个类只能继承一个抽象类,但可以实现多个接口。为了满足里氏替换原则,我们用接口来重新设计。
c#
public interface ITurnable
{
public void TurnRight();
public void TurnLeft();
}
public interface IMovable
{
public void GoForward();
public void Reverse();
}
c#
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
{
...
}
通过接口实现来拆分父类,我们解决了子类不能实现父类方法的问题。
接口隔离原则
接口隔离原则简单来说就是子类不能去实现接口中用不到的方法,接口的职责要清晰。
下面的屎山代码就是典型的反例。
c#
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; }
}
我们来重新设计接口,将原来的大接口拆分为4个接口,具体实现类去实现不同的接口,如下所示。
c#
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
{
...
}
依赖倒置原则
依赖倒置原则其实就是高层模块不应该直接依赖低层模块,而是通过接口进行设计。 如下图所示,好的设计应该是高内聚,低耦合。
我们来看一个例子,Switch类直接依赖Door。
最大的问题就是扩展性差,如果下次加入了一个新的NewDoor类,Switch类要重新引入依赖,引起不必要的变更。
重构也很简单,我们引入一个 ISwitch 接口,解除Switch类对Door类的依赖。
不难发现,引入接口后,系统更加容易扩展,我们可以加入 Light类、NPC类等等。
c#
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
{
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.");
}
}
当然我们也可以使用抽象类来来支持静态成员变量、常量。
但最大的问题就是一个类只能继承一个抽象类。
对NPC类来说,它实际上应该继承Robot抽象类来复用代码,实现多个不同的接口来保证一定的拓展性。
我们在选用抽象类或者接口要根据自身需求,如下表所示。