精通代码复用:设计原则与最佳实践
在你开始设计的所有层次上,从单一函数、类,到整个库和框架,都需要从一开始就考虑到代码复用。在接下来的文本中,所有这些不同的层次都被称为组件。以下策略将帮助你合理地组织你的代码。注意,所有这些策略都专注于使你的代码具有通用性。设计可复用代码的第二个方面,即提供易用性,更多地与你的接口设计相关,将在后面进行讨论。
避免合并无关或逻辑独立的概念
当你设计一个组件时,应该让它专注于一个单一任务或一组任务,即,你应该追求高内聚。这也被称为单一职责原则(SRP)。不要合并无关的概念,例如随机数生成器和XML解析器。即使你没有专门为了复用而设计代码,也要牢记这一策略。整个程序很少会被单独复用。相反,程序的部分或子系统会直接被纳入其他应用,或者被改编以用于稍微不同的用途。因此,你应该设计你的程序,以便你将逻辑上独立的功能划分为可以在不同程序中复用的独立组件。每个这样的组件都应具有明确定义的职责。这种程序策略模仿了现实世界中的离散、可互换部件的设计原则。
例如,你可以编写一个Car
类,并将发动机的所有属性和行为都放入其中。然而,发动机是可分离的组件,不与汽车的其他方面绑定在一起。发动机可以从一辆汽车中取出,放入另一辆汽车中。一个合适的设计应该包括一个Engine
类,其中包含所有与发动机相关的功能。一个Car
实例然后只包含一个Engine
实例。
将程序划分为逻辑子系统
你应该将你的子系统设计为可以独立复用的离散组件,即,追求低耦合。例如,如果你正在设计一个网络游戏,应该将网络和图形用户界面方面分开。这样,你就可以在不拖入另一个组件的情况下复用其中一个组件。例如,你可能想写一个非网络游戏,在这种情况下,你可以复用图形界面子系统,但不需要网络方面。同样地,你可以设计一个P2P文件共享程序,在这种情况下,你可以复用网络子系统,但不需要图形用户界面功能。确保遵循每个子系统的抽象原则。把每个子系统看作一个微型库,并为其提供一个连贯且易于使用的接口。即使你是唯一使用这些微型库的程序员,你也将从设计良好的接口和实现中受益,这些接口和实现将逻辑上不同的功能进行了分离。
使用类层次结构以分离逻辑概念
除了将程序划分为逻辑子系统外,你还应避免在类级别合并无关的概念。例如,假设你想为自动驾驶汽车编写一个类。你决定从一个基础的汽车类开始,并直接将所有自动驾驶逻辑加入其中。然而,如果你的程序中只需要一个非自动驾驶汽车呢?在这种情况下,与自动驾驶有关的所有逻辑都是无用的,可能会要求你的程序链接到它本来可以避免的库,如视觉库、LIDAR库等。一个解决方案是创建一个类层次结构,在其中自动驾驶汽车是通用汽车的派生类。这样,你就可以在不需要自动驾驶功能的程序中使用汽车基类,而不会招致这种算法的成本。
当有两个逻辑概念时,如自动驾驶和汽车,这种策略效果很好。当有三个或更多概念时,情况就变得更复杂了。例如,假设你想提供一辆卡车和一辆汽车,每辆都可能是自动驾驶或非自动驾驶的。从逻辑上讲,卡车和汽车都是车辆的特殊情况,因此它们应该是车辆类的派生类。同样,自动驾驶类可以是非自动驾驶类的派生类。你不能用一个线性层次结构提供这些分离。一个可能性是将自动驾驶方面作为一个混合类。通过使用多重继承在C++中实现了混合类的一种方式。例如,一个PictureButton
可以从Image
类和Clickable
混合类继承。然而,对于自动驾驶设计,最好使用一种不同类型的混合实现,即使用类模板。基本上,SelfDrivable
混合类可以定义如下:
template <typename T>
class SelfDrivable : public T {
};
这个SelfDrivable
混合类提供了实现自动驾驶功能所需的所有算法。一旦你有了这个SelfDrivable
混合类模板
,你就可以为汽车和卡车分别实例化一个:
SelfDrivable<Car> selfDrivingCar;
SelfDrivable<Truck> selfDrivingTruck;
这两行代码的结果是,编译器将使用SelfDrivable
混合类模板创建一个实例,其中所有的T都被替换为Car
,因此是从Car
派生的,另一个实例的T被替换为Truck
,因此是从Truck
派生的。
使用聚合以分离逻辑概念
聚合在接下来的内容中讨论,它模拟了“有一个”关系:对象包含其他对象以执行其某些方面的功能。当继承不适当时,你可以使用聚合来分离无关或相关但独立的功能。
无论你的设计在哪个层次,都应避免合并无关的概念,即,追求高内聚。例如,在方法级别,单一方法不应执行逻辑上无关的事情,混合变异(set)和检查(get)等。
例如,假设你想写一个Family
类来存储一个家庭的成员。显然,树状数据结构将是理想的存储这些信息的方式。你应该写一个单独的Tree
类,而不是在你的Family
类中集成树结构的代码。然后,你的Family
类可以包含和使用一个Tree
实例。用面向对象的术语来说,Family
has-a Tree
。采用这种技术,树状数据结构在另一个程序中更容易被复用。
消除用户界面依赖性
如果你的库是一个数据操作库,你会希望将数据操作与用户界面分开。这意味着对于这种类型的库,你绝对不应该假设库将在哪种类型的用户界面中使用。库不应使用任何标准输入和输出流,如cout
、cerr
和cin
,因为如果库是在图形用户界面的环境中使用,这些流可能没有意义。例如,一个基于Windows GUI的应用程序通常不会有任何形式的控制台I/O。即使你认为你的库只会在基于GUI的应用程序中使用,你也绝不应弹出任何类型的消息框或其他类型的通知给最终用户,因为这是客户端代码的责任。客户端代码决定如何向用户显示消息。这种类型的依赖性不仅导致可复用性差,而且还阻止了客户端代码适当地响应错误,例如,静默处理它。
模型-视图-控制器(MVC)范式是一个用于分离数据存储和数据可视化的著名设计模式。使用这个范式,模型可以在库中,而客户端代码可以提供视图和控制器。
使用模板进行通用数据结构和算法设计
C++有一个叫做模板(Templates)的概念,它允许你创建对类型或类具有通用性的结构。例如,你可能已经为整数数组编写了代码。如果你随后想要一个双精度浮点数数组,你需要重写和复制所有代码以适应双精度浮点数。模板的概念是,类型变成了规范的一个参数,你可以创建一个可以在任何类型上工作的单一代码体。模板允许你编写在任何类型上工作的数据结构和算法。
最简单的例子是std::vector
类,它是C++标准库的一部分。要创建一个整数向量,你写std::vector<int>
;要创建一个双精度浮点数向量,你写std::vector<double>
。模板编程通常非常强大,但也可能非常复杂。幸运的是,可以创建相对简单的模板用法,根据类型进行参数化。
无论何时有可能,你都应该使用通用设计来编写数据结构和算法,而不是编码某个特定程序的细节。不要编写只存储书籍对象的平衡二叉树结构。使其通用,以便它可以存储任何类型的对象。这样,你可以在书店、音乐商店、操作系统或任何需要平衡二叉树的地方使用它。
为什么模板比其他通用编程技术更好
模板并不是编写通用数据结构的唯一机制。另一种、尽管更老的方法是在C和C++中存储void*
指针,而不是特定类型的指针。客户端可以通过将其转换为void*
来存储他们想要的任何东西。然而,这种方法的主要问题是它不是类型安全的:容器不能检查或强制存储元素的类型。
与直接在你的通用非模板数据结构中使用void*
指针相比,你可以使用自C++17以来可用的std::any
类。std::any
类的底层实现在某些情况下确实使用了void*
指针,但它还跟踪了存储的类型,所以一切都保持了类型安全。
另一种方法是为特定类编写数据结构。通过多态性,该类的任何派生类都可以存储在结构中。模板,另一方面,在正确使用时是类型安全的。每个模板实例只存储一种类型。如果你尝试在同一个模板实例中存储不同的类型,你的程序将无法编译。此外,模板允许编译器为每个模板实例生成高度优化的代码。
模板的问题
模板并不完美。首先,它们的语法可能令人困惑,尤其是对于那些以前没有使用过它们的人。其次,模板需要同质的数据结构,在单一结构中只能存储相同类型的对象。这就是模板的类型安全性直接导致的限制。
从C++17开始,有一种标准化的方法来绕过这种同质性限制。你可以编写你的数据结构以存储std::variant
或std::any
对象。一个std::any
对象可以存储任何
类型的值,而一个std::variant
对象可以存储一系列类型中的一个值。std::any
和std::variant
在后文讨论。
模板的缺点:代码膨胀
模板的另一个可能的缺点是所谓的代码膨胀:最终二进制代码的大小增加。每个模板实例的高度专门化代码比稍慢的通用代码需要更多的代码。然而,通常来说,如今代码膨胀并不是一个很大的问题。
模板与继承
程序员有时发现决定是否使用模板或继承有点棘手。以下是一些帮助你做出决策的提示。
- 当你想为不同类型提供相同的功能时,使用模板。例如,如果你想编写一个适用于任何类型的通用排序算法,使用函数模板。如果你想创建一个可以存储任何类型的容器,使用类模板。
- 当你想为相关类型提供不同的行为时,使用继承。例如,在一个绘图应用程序中,使用继承来支持不同的形状,如圆形、正方形、线条等。特定的形状然后从一个基类(例如,
Shape
)派生。
值得注意的是,你可以组合继承和模板。你可以编写一个从基类模板派生的类模板。
提供适当的检查和保护措施
有两种相反的编写安全代码的风格。最佳的编程风格可能是两者之间的健康组合。
- 契约式设计(Design-by-Contract):这意味着函数或类的文档代表了一份合同,详细描述了客户端代码的责任和你的函数或类的责任。契约式设计有三个重要方面:前置条件、后置条件和不变量。
- 安全最大化设计:这一准则的最重要方面是在你的代码中进行错误检查。例如,如果你的随机数生成器需要种子在特定范围内,不要只是信任用户传递一个有效的种子。检查传入的值,并在无效时拒绝调用。
为可扩展性设计
你应该努力以这样一种方式设计你的类,使它们可以通过从它们派生另一个类来进行扩展,但它们应该是封闭的,即行为应该是可扩展的,而无需你修改其实现。这被称为开闭原则(OCP)。
作为一个例子,假设你开始实施一个绘图应用程序。第一个版本应该只支持正方形。你的设计包含两个类:Square
和Renderer
。
class Square { /* Details not important for this example. */ };
class Renderer {
public:
void render(const vector<Square>& squares) {
for (auto& square : squares) {
/* Render this square object... */
}
}
};
接下来,你添加对圆形的支持,所以你创建了一个Circle
类。
class Circle { /* Details not important for this example. */ }
为了能够渲染圆形,你必须修改Renderer
类的render()
方法。
在这个设计中,如果你想添加对新类型形状的支持,你只需要编写一个从Shape
派生并实现render()
方法的新类。你不需要在Renderer
类中修改任何内容。因此,这个设计可以在不修改现有代码的情况下进行扩展;也就是说,它是开放的,用于扩展和封闭的,用于修改。
参考:Professional C++ (English Edition) 5th Edition by Marc Gregoire
公众号:coding日记