04 Designing for Reuse

为什么要写可重用的代码,原因无外乎下面几点。个人是最主要的受益者,积累好的代码终生受益。不可重用的代码可能到处都是重复代码,维护起来相当困难。同组其他人可以用你的代码完成事情。设计重用代码,长远看避免重复造轮子,更省时省力。代码不太可能只用于一个地方,良好的设计能够增加未来重用的可能性。

记住:DRY - Don't Repeat Yourself! Write Once, use often. Try to avoid cod duplication.

如何写出可重用的代码呢?

抽象。接口实现分离,不要暴露细节给客户。C++ 可以用一种称为 pimpl (private implementation) idiom 的方式实现。

具体要怎么做呢?很好的组织代码,好好的设计接口。

如何组织代码?大致有以下几个方面。这些理念适用于各个层次,函数、类、类库和框架。

避免不相关的概念混杂在一起,也就是要高内聚(high cohesion),单一原则(single responsibility principle)。将程序分成各个子系统,低耦合(low coupling)。

使用类的继承来分离逻辑概念。比如有 Car 这个类,后面要增加自动驾驶的车,SelfDrivingCar,就可以继承 Car,然后所有与自动驾驶相关的功能实现在子类。需要变复杂了,除了 Car,还有 Truck,或许也能支持自动驾驶,那么继承结构需要调整。CarTruck 都继承自 Vehicle,抽取公共实现到基类。如何共享自动驾驶的代码呢?一般而言,自动驾驶大概率无需区分车的种类,此时可以写一个模板类 SelfDrivable<T> 继承自 T,实现自动驾驶,只要声明 SelfDrivable<Car> SelfDrivable<Truck> 就能得到两个具体的类实例。

使用聚合分离逻辑概念。聚合表示的是 has-a 的关系。

将 UI 和数据处理分离,实现数据处理的时候无需考虑客户使用什么样的 UI。

使用模板实现通用数据结构和算法。模版类型安全并且为每个模板实例都提供了高度优化的代码。不过模板语法复杂,只能用于同类数据结构,还有代码膨胀的问题。

对于不同类型要提供一致的功能,使用模板,编译期多态。对于相关类型实现不同行为,使用继承,运行时多态。

设计安全的代码也很重要。有两种截然不同的风格。第一种是契约式设计(design-by-contract),通过文档指定先验条件、后验条件和不变式,一切由客户端负责,比如 std::vectoroperator[],不检查索引是否合法,由调用者保证。第二种是函数和类的实现尽可能安全。比如 std::vectorat 方法,索引不合法就抛异常。

开放关闭原则(open/closed principle),如果有新的功能,扩展原有类而无需修改,灵活且稳定。这就要求把不长变的和经常变的分离。

现在分析第二个问题:设计好的接口。即使实现相当优雅相当高效,但是接口设计不好,也不是一个好的类库。

C++ 提供了 public protect private 三个关键字,可以控制需要暴露给不同使用者的接口。

好的接口要简单直观。可以从下面几个方面考虑接口的设计。首先要跟随熟悉的方式,比如和标准库保持一致,如果你的类提供 initializecleanup 而不是在构造和析构的时候做这些事情,那么使用者往往会漏用;再比如 C++ 的操作符重载可以帮助简化接口,如果设计一个 Matrix 类提供矩阵的运算,那么 + - 明显比 add sub 更直观简洁。其次是不要忽略需要的功能,要包含调用者期望的所有功能,同时尽可能包含潜在的功能。但是这一点可能会走到另一个极端,包含一切功能的接口,要想清楚什么是必要的功能,什么是必要功能的扩展,什么是无关的功能。最后,即使接口设计的相当简单直接,提供文档总是好的。

好的接口更通用。首先可以尝试提供多种方式来做同一件事,过多会导致接口太乱,肯定是不可取的,因此完成同一件事的接口应该有所差异,比如针对不同的场景,一个例子是 std::vectorat()operator[],获取元素,但是一个做边界检查一个由调用者负责。其次是要提供定制化能力,一个方式是通过回调函数或者模板参数的方式提供,比如 C++ 标准库容器允许指定分配器,另一种方式是通过依赖注入实现依赖倒置,比如 Logger 经常提供指定实现的功能。

通用和简单可能会有冲突。一个可能的解决方法是接口分离(Interface Segregation Principle),比如一组简单直观的接口解决常见场景,另一组接口提供更复杂更通用更灵活的功能。