Skip to content

Software Design Fundamentals

什么是软件设计(Software Design)?为什么软件设计很重要?软件设计的原则是什么?软件设计的模式有哪些?这些都是软件设计的基础问题,这里及后续章节将逐一解答这些问题。

软件设计的重要性

代码质量有很多评价指标,比如可读性、可测试性、可维护性、可扩展性、可重用性等等。对于一个项目来说,至关重要的是软件的整体结构(structure)。好的结构最终会影响到上述的各种质量指标。软件设计的目标就是为了构建一个好的结构,从而提高软件的质量。好的软件设计能够容忍某些糟糕的实现决定,反之则不行,差的软件设计不能靠堆砌新的语言特性来弥补。

变化是软件的常态,是所有问题的根源。软件设计的目标就是为了更好地应对变化。

大部分的开发者应该遇到过这样的情况:当需要修改一个很小的功能或者修复一个 bug 时,却需要修改很多文件,涉及整个系统的多个模块。这就是依赖(dependency)问题。不管项目大小,依赖都是最核心的问题。因此,软件设计是一门管理组件之间依赖关系的艺术,目的是最小化人为的、技术性的依赖,并引入必要的抽象与折中。软件设计,或者说软件工程(engineering),更像是一门艺术,或者说是一种工艺(craft),与科学相结合。有时,软件架构(software architecture)这个术语表达的也是类似的意思,两者可以互换使用。也有的人将架构和设计区分开来,认为架构是更高层次的设计。

Dependency is the key problem in software development at all scales.

Kent Beck, Test-Driven Development: By Example (Addison-Wesley, 2002).

The goal of software architecture is to minimize the human resources required to build and maintain the required system.

Robert C. Martin, Clean Architecture (Addison-Wesley, 2017).

我们可以将软件开发分成两个层次,设计层(design level)包含架构和设计,代码层(code level)包含实现细节(implementation details)。实现细节这一层有一些常见的实现模式(implementation patterns),比如 RAII、Pimpl、SFINAE(Substitution Failure Is Not An Error)等等,这些模式是 C++ 特有的,其他语言可能没有或者有不同的实现方式。再往上是软件设计,这一层包含一些设计模式(design patterns),比如装饰者、策略等等,这些就是语言无关的了。再往上是软件架构,如前所述,这里的共识不多,通常涉及关键决策。这一层包含一些架构模式(architectural patterns),比如微服务、客户端-服务器等。两者的共同点都是解决整体结构和相互依赖的问题。架构是更高层次的设计,解决的是模块、组件级别的,而设计则是更细粒度的,解决的是类、函数级别的。

惯用法(idiom)的出现使得实现和设计两者的界限更加模糊一些。惯用法表示某种模式(pattern),在某种语言中被广泛使用,比如前面提到的 RAII 就是 C++ 中的一种惯用法,还有 copy-and-swap 也是 C++ 中的一种惯用法。大部分的 C++ 惯用法都是实现层次的,设计层次的惯用法相对较少一些,比如 NVI(Non-Virtual Interface)和 Pimpl 都是一种设计层次的惯用法,分别脱胎于设计模式中的模板方法(Template Method)和桥接模式(Bridge Pattern)。这里涉及到一个问题,就是掌握编程语言的细节和掌握软件设计的原则哪个更重要?答案是两者都重要。没有必要厚此薄彼。

Design and programming are human activities; forget that and all is lost.

Bjarne Stroustrup, The C++ Programming Language, 3rd Edition (Addison-Wesley, 2000).

拥抱变化的设计

变化是问题的根源,因此分离关注点(separation of concerns)是应对变化的重要原则,核心思想就是对各个功能进行拆分、隔离或者抽取,将整个系统分解为更小的、命名清晰的、易于理解的小单元,能够提升效率,更好地应对变化。

如何衡量拆分是否合理呢?内聚(cohesion)是一个衡量模块之间关系紧密程度的指标。一个高内聚的集合,各个语句、数据块之间联系紧密,被视为一个不可分割的整体。任何试图将它们拆分的做法,都会增加耦合程度并降低代码的可读性。

单一职责

SOLID 原则是最基础的设计原则,分别是单一职责原则(Single Responsibility Principle, SRP)、开放封闭原则(Open-Closed Principle, OCP)、里氏替换原则(Liskov Substitution Principle, LSP)、接口隔离原则(Interface Segregation Principle, ISP)和依赖倒置原则(Dependency Inversion Principle, DIP)。这里先介绍 SRP,后续章节会介绍其他的设计原则。

SRP 是说一个类应该只有一个职责(responsibility),或者说一个类应该只有一个引起它变化的原因。但是问题在于什么是职责?什么是单一职责?有时,这个原则也被解释为只做一件事情,但是这个描述并没有提供更多的信息,描述依旧非常模糊。忽略名字,这个原则想描述的事情是将真正属于同类的东西聚集在一起,而将不属于同类的东西分离开来,如果有不同原因发生变化,那么应该将这些变化分类开。理想情况下,想要修改软件的某个方面,只需要修改一处即可。

下面分析一个违反 SRP 的例子。

// #include <some_json_library.h> // Potential physical dependency

class Document
{
public:
    // ...
    virtual ~Document() = default;

    virtual void ExportToJSON(/*...*/) const = 0;
    virtual void Serialize(ByteStream &, /*...*/) const = 0;
    // ...
};
上面这个基类的定义看似很好,能够导出 JSON 这种通用格式,也能将数据序列化为二进制并写入 ByteStream,可能用于 Word、PDF 等文档。但是这个类引入了三类依赖。

第一类是 ExportToJSON 方法引入了对 JSON 库的依赖,这个库可能是第三方的,也可能是我们自己写的。为了一致性,所有的子类不得不依赖同一个类库,因此这些子类并没有真正独立,而是耦合到了一起。同时,由于依赖了 JSON 类库,类本身变得不够轻量,后续重用也是一个问题。Serialize 很可能会依赖于 protobuf 这种二进制序列化类库,这一依赖与 JSON 类库正交,但可能会引发连锁反应。

第二类是 ExportToJSON 的参数很可能暴露了一些 JSON 类库的细节,导致一旦需要变更 JSON 类库,需要修改函数签名,那么所有的子类都需要修改,调用者也需要修改,这就是一个典型的依赖问题。

第三个问题是由 Serialize 方法引入的,要序列化成什么格式呢?常见做法是给一个枚举类,比如

enum class DocumentType
{
    Word,
    PDF,
    // ...
};
如果这种低级别的文档表示形式被直接用在各个子类的实现内部,就会在不知不觉中把不同种类的文档强行耦合在了一起。新增一个类型,可能会影响到各个子类,这明显是一个设计缺陷。

总结一下,以下原因都会导致这个类以及相关子类的修改:

  • 直接依赖的 JSON 类库变化了,那么需要修改 ExportToJSON 的实现细节。
  • 由于底层实现变了,导致 ExportToJSON 的函数签名变了。
  • 由于直接依赖的 ByteStream 类库变化,导致 Serialize 的修改。
  • 由于底层实现细节变了而需要修改 Serialize 的函数。
  • 由于 DocumentType 枚举类的修改,导致所有 Document 类需要修改。

如果大家都把所谓正交(独立)的功能都耦合到 Document 类,进一步推高了复杂度,那么修改就更困难了。并且一旦涉及到各个子类的修改,改动范围就会变大,带来维护风险。

现在看一下使用这个 Document 类的情况。假定有一个 User 类要使用 Document 类,这个依赖是必然的,我们不必关心。但是 Document 对所选择的 JSON 和 ByteStream 类库的依赖导致 User 类也间接受到了影响,但是 User 不应该依赖 JSON 或者序列化这些事情。依赖关系如下:

            User-------------
              |             |
              v             | transitive dependency
          Document          |
         /        \         |
        v          v        v
    JSON Library   ByteStream Library

SRP 的思想就是分离这些关注点,将由不同理由导致变化的部分隔离到不同的变化点(variation point)。比如将 JSON 和序列化的功能抽取到不同的类中,这样就能够将这些变化点隔离开来,降低耦合,提高内聚。下面是一种可能的依赖关系。后续章节会介绍一些设计模式,以及如何实现这些分离的设计。

    JSON        User      Serialization
    |    \        |        /      |
    |     \       |       /       |
    |      \      |      /        |
    |       \     |     /         |
    |        \    |    /          |
    |         \   |   /           |
    v          v  v  v            v
JSON Library   Document      ByteStream Library

Don't Repeat Yourself

我们先看下面一个例子,下面有一个 Item 类,Price() 函数能够返回这个 Item 的价格,形式是 Money 类的对象。

//---- <Item.h> ----------------

#include <Money.h>

class Item
{
public:
    virtual ~Item() = default;
    virtual Money Price() const = 0;
};
假定我们有两个子类,分别是 CppBook 和 ConferenceTicket,它们都继承自 Item 类,并且都实现了 Price() 函数。
//---- <CppBook.h> ----------------

#include <Item.h>
#include <Money.h>

#include <string>

class CppBook : public Item
{
public:
    explicit CppBook(std::string title, std::string author, Money price)
        : title_(std::move(title))
        , author_(std::move(author))
        , priceWithTax_(price * 1.15) // 15% tax rate
    {}

    const std::string &Title() const { return title_; }

    const std::string &Author() const { return author_; }

    Money Price() const override { return priceWithTax_; }

private:
    std::string title_;
    std::string author_;
    Money priceWithTax_;
};

//---- <ConferenceTicket.h> ----------------

#include <Item.h>
#include <Money.h>

#include <string>

class ConferenceTicket : public Item
{
public:
    explicit ConferenceTicket(std::string name, Money price)
        : name_(std::move(name))
        , priceWithTax_(price * 1.15) // 15% tax rate
    {}

    const std::string &Name() const { return name_; }

    Money Price() const override { return priceWithTax_; }

private:
    std::string name_;
    Money priceWithTax_;
};
上述设计看起来很好,分离了关注点,满足前面说的 SRP,但是它违反了 DRY(Don't Repeat Yourself)原则。CppBook 和 ConferenceTicket 类中都包含了计算价格的逻辑,如果税率发生了变化,那么我们就需要修改这两个类的代码,甚至更多的子类。

SRP 告诉我们每个事物只能有一个职责(一个变化的理由),DRY 原则告诉我们每个职责也应该有且只有一份实现。该原则建议不要在多处重复同样的信息,而是要通过合理的设计,确保只需要在一个地方做出修改就足够了。理想情况下,税率这个信息应该只在一处定义即可。通常情况下,DRY 原则和 SRP 是相辅相成的,SRP 能够帮助我们更好地实现 DRY 原则,反之亦然。不过在一些特殊的情况下,要想同时满足这两个原则需要一些额外的设计。后续章节会介绍一些设计模式,阐述如何实现这些原则。

不要过早进行关注点分离

过早进行关注点分离(premature separation of concerns)会导致过度设计(over-engineering),为了分离而分离会增加复杂性,引入不必要的胶水代码和间接层。

我们的目标是可维护性,SOLID 原则是工具,工具是来帮助我们达到我们的目标的,而不是要达成 SOLID!SRP 和 DRY 是很好的工具但不是目的。

Don't try to achieve SOLID, use SOLID to achieve maintainability.

Katerina Trajchevska, "Becoming a Better Developer by Using the SOLID Design Principles", Laracon EU, August 30–31, 2018.

如果知道哪里会变化,使用这些工具让未来的变化更容易,如果不知道,那就等到变化出现的时候再重构,让代码变得更好。

接口隔离

回到 Document 类的例子。假定用户写了一个函数来处理 Document 类的对象,将其导出为 JSON 格式。

void ExportDocumentToJSON(const Document &doc)
{
    // ...
    doc.ExportToJSON(/*...*/);
    // ...
}
这个函数只需要 JSON 相关的功能,但是由于 Document 类的设计,这个函数不得不依赖了 Document 类的其他功能,比如序列化相关的功能。如果 ByteStream 类发生了变化,或者 Serialize 函数签名发生了变化,会影响到所有使用 Document 类的代码,包括这个 ExportDocumentToJSON 函数,需要重新编译、测试。如果 Document 类耦合了更多正交的功能,这个问题就会更严重了。

这里违反了接口隔离原则(Interface Segregation Principle, ISP),这就是 SOLID 中的 I。ISP 是说一个类不应该依赖于它不使用的方法。ISP 的思想是通过分离接口来分离关注点。

这个例子中,我们应该引入两个接口 JSONExportable 和 Serializable,Document 类分别实现这两个接口,这样就能够将这些功能分离开来。

class JSONExportable
{
public:
    // ...
    virtual ~JSONExportable() = default;
    virtual void ExportToJSON(/*...*/) const = 0;
    // ...
};

class Serializable
{
public:
    // ...
    virtual ~Serializable() = default;
    virtual void Serialize(ByteStream &bs, /*...*/) const = 0;
    // ...
};

class Document
    : public JSONExportable
    , public Serializable
{
public:
    // ...
};
使用的地方仅仅需要依赖 JSONExportable 接口即可,即仅仅依赖它所需要的东西。
void ExportDocumentToJSON(const JSONExportable &exportable)
{
    // ...
    exportable.ExportToJSON(/* pass necessary arguments */);
    // ...
}

有些人可能会质疑,这不就是 SRP 吗?社区在这一点上也达成了共识,认为 ISP 是 SRP 的一个特例,做到了 SRP 就做到了 ISP。但是这个原则还是非常有价值的,大家常常在后期维护的时候,有意无意地往同一个接口中加入彼此正交的功能,结果就是接口变得非常臃肿,使用方还是会依赖其并不需要的东西,同时接口一旦发布,后续的修改就会变得非常困难了。因此,在设计、重构的时候,ISP 是一个非常重要的原则,能够帮助我们设计出更好的接口。

ISP 的理念还可以应用于模板参数的设计,比如 std::copy 这个函数。

template<std::input_iterator InputIt, std::output_iterator OutputIt>
OutputIt copy(InputIt first, InputIt last, OutputIt d_first);
这保证了约束的最小化:std::input_iterator 支持读取和单次遍历,输出迭代器只需支持写入和递增。若错误地用要求更强的 std::forward_iterator 约束输入迭代器,就会引入不必要的多次遍历要求,导致某些场景无法使用这个 copy 函数,例如输入流迭代器不满足多次遍历的要求。

面向可测试性的设计

软件一直在变化,这是本质问题。在修改的时候,可能会有意无意地引入问题,如何防止这一点呢?测试!完整的测试会保护我们不受修改的影响。但是前提是我们写测试,如果我们不写测试,那么我们就无法保护自己了。因此,我们的设计应该是面向可测试性(testability)的设计,简单说就是要容易写测试。

先来看看下面这个“矫揉造作”的例子。假定我们有一个 Widget 类,这个类有一个私有函数 UpdateCollection(),如何测试这个函数呢?换句话说,如果修改了这个函数,如何确保修改没有引入问题呢?

class Widget
{
    // ...

private:
    void UpdateCollection(/* some arguments needed to update the collection */);

    std::vector<Blob> blobs_;
    /* Potentially other data members */
};
你可能会说,或许有 AddBlob() 这个 public 函数会调用 UpdateCollection(),我们可以通过测试 AddBlob() 来间接测试 UpdateCollection()。如果 UpdateCollection() 只是 AddBlob() 的实现细节,那么这正是合适的黑盒测试(black-box test),只测试对外可观察的预期行为,而不依赖其内部是否调用了某个特定函数。若实现不再调用 UpdateCollection(),相关测试也不应约束这一已经改变的实现细节。

你可能会说,把这个函数修改成 public 的,技术上可行,但绝不是一个好主意。

你可能会说,使用友元测试类(friend test class)的方式来测试这个函数,Widget 里面加上 friend class WidgetTest;,这个也是技术上可行的,但也不是一个好主意。这样,测试类就和被测试类耦合在了一起了,增加了被测试类对测试类的依赖。

你可能会说,把这个函数修改成 protected,让测试类继承 Widget 类。这通常会引入不必要的继承耦合,并不推荐。

仅为测试私有函数而将其公开或抽取出来并不是正确的解法。只有当 UpdateCollection() 包含可独立描述、具有独立变化原因或可复用的行为时,才应将其抽取为类外函数,或者独立类的成员函数,此时可以直接测试其公开行为,也不会引入不必要的测试依赖。

void UpdateCollection(std::vector<Blob> &blobs, /* some arguments needed to update the collection */);
或者
namespace widgetDetails
{

class BlobCollection
{
public:
    void UpdateCollection(/* some arguments needed to update the collection */);

private:
    std::vector<Blob> blobs_;
};

} // namespace widgetDetails
这样将原本放在一起的东西分离开,是否违背了 SRP 呢?不一定。仅仅因为一个函数需要测试,并不能说明它必须是独立单元,或说明原有类违反了 SRP。只有当它代表独立的概念、能够单独演进时,抽取才有助于让 Widget 聚焦自身职责,并使新单元的行为能够被直接测试。

这样是否违反了封装呢?不一定。Scott Meyers 在《Effective C++》中提到过,在不需要访问私有成员时,应优先考虑非成员、非友元函数(non-member, non-friend function)。成员函数天生能访问所有数据成员,容易访问到不相关的数据成员,导致耦合度增加。抽取后的 UpdateCollection() 若只接收其真正需要的输入,便可缩小其可访问的状态范围,改善访问控制和内聚。

在适合抽取的情况下,上述设计可带来以下好处:(1)能够直接测试 UpdateCollection() 的公开行为;(2)缩小可访问状态的范围;(3)使 UpdateCollection() 与 Widget 解耦,并可能复用于其他类。

这里并不是鼓励把所有函数都设计成独立函数,甚至单独抽取到一个类。我们应仔细分析被放入 private 的函数是否确实具有独立职责和可独立验证的行为。如果这个函数是虚函数该怎么办呢?后续章节会讨论这个问题。

面向扩展性的设计

扩展性(extensibility)是软件设计的重要目标之一,驱使我们做出好的设计。除非软件已经到生命周期尽头了,否则就需要添加新的功能,扩展现有代码是基本操作。扩展性并不是天上掉馅饼,而是需要我们在软件设计的时候就考虑到这一点。

开放封闭原则

回到之前 Document 类的例子,它有一个纯虚函数 Serialize(),假定有一个子类 PDF 继承了 Document 类,并且实现了 Serialize() 函数。

class PDF : public Document
{
public:
    // ...
    void Serialize(ByteStream &bs, /*...*/) const override;
    // ...
};
之前提到过,为了区别不同类型的文档,我们可能会引入类
enum class DocumentType
{
    Word,
    PDF,
    // ...
};
在序列化的时候,可能第一个字节就是这个 DocumentType,用来区分不同类型的文档。这样 PDF 类会依赖包含 Word 枚举值的 DocumentType,从而产生不必要的耦合。整个结构如下所示。
                   Document
                      ^
                      |
                      |
         +------------+--------------+
         |            |              |
         |            |              |
        PDF ----> DocumentType <-- Word
如果我们想要添加一个子类,比如 XML,我们就需要修改 DocumentType 枚举。这至少会导致依赖该枚举的 PDF 和 Word 重新编译。更糟糕的是,这限制了其他人对代码进行扩展的能力,因为这可能是一个类库,而很多人没有权限修改 DocumentType 枚举。从设计的角度来说,PDF 和 Word 这两个类本不应该知道新增文档类型 XML 的存在。

上述的例子违反了开放封闭原则(Open-Closed Principle, OCP),这就是 SOLID 中的 O。OCP 是说软件实体(类、模块、函数等等)应该对扩展开放,对修改关闭。也就是说,理想状态下,我们应该能够通过添加新代码来扩展软件的功能,而无需修改现有的代码。

幸运的是我们之前提到的分离关注点的方法就能解决这个问题。调整之后,只有序列化 Serialization 模块需要知道 DocumentType 的存在,不同的 Document 子类都不知道 DocumentType,进而彼此是解耦的。

                        Document
                           ^
                           |
            +--------------+-------------+
            |                            |
            |                            |
           PDF                         Word
            ^                            ^
            |                            |
            +----------------------------+
            |                            |
            |                            |
           JSON                  Serialization(DocumentType)
序列化模块知道 DocumentType 的存在并不违反 OCP,我们并不需要修改更高层次或者同级的其他模块。从功能设计的角度看,这个依赖是必须的,而序列化模块也必须适配每一个 Document 的子类,因此这个依赖是合理的。

从分离关注点的角度说,真正的解决方案就是坚守 SRP,因此业内也有人认为也不应将 OCP 看作是独立的原则,它和 SRP 是一样的。大部分情况下,做到了分离关注点也就带来了扩展性。因此,将二者看作是紧密相关的甚至是融合在一起的原则也没有问题。

不过单独讨论 OCP,给了我们从另一个视角来审视问题本身。当我们显式的考虑扩展性的时候,会影响我们应用 SRP 的方式。相比于 SRP,OCP 更注重于扩展性,影响在扩展性方面做出的决策。因此,它或许不是 SRP 的副产物,完全取决于我们看待事物的角度。

编译期扩展性

日常开发中,常常会使用 C++ 标准库,它的设计考虑了扩展性,因此我们可以轻松地使用它来实现自己的功能。先看第一个例子 std::swap,它的实现如下所示。

template<class T>
void swap(T &a, T &b)
{
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}
这个函数的实现非常简单,使用了 move 语义来避免不必要的复制操作。这个函数的设计考虑了扩展性,能够适用于任何类型 T。同时,如果自定义类型没有提供高效的移动操作,可以提供自己的 swap 函数,那么也能够正确的调用自定义的 swap 函数,而不是使用默认的实现。
namespace custom
{

class CustomType
{
    /* Implementation that requires a special form of swap */
};

void swap(CustomType &a, CustomType &b) { /* Special implementation for swapping two instances of type 'CustomType' */ }

} // namespace custom
使用的时候采用非限定调用(unqualified call)的方式,结合 ADL(Argument-Dependent Lookup),就能调用到合适的 swap 函数。
using std::swap;

swap(a, b); // Calls std::swap for built-in types, custom::swap for CustomType
下面是第二个例子。
template<typename InputIt, typename T>
constexpr InputIt find(InputIt first, InputIt last, const T &value);

template<typename InputIt, typename UnaryPredicate>
constexpr InputIt find_if(InputIt first, InputIt last, UnaryPredicate p);
std::find 和 std::find_if 通过模板参数支持任意类型的查找,std::find_if 还支持通过谓词(predicate)来定义查找条件,可以自定义比较对象的方式。

最后一个定制方式是模板特化(template specialization),比如 std::hash。

template<>
struct std::hash<CustomType>
{
    std::size_t operator()(const CustomType &v) const noexcept { return /*...*/; }
};
std::hash 的设计使得我们能够为自定义类型提供哈希函数的实现,完全不需要修改任何现有的代码。通过模板特化来定制我们的需求。

避免过早进行扩展性设计

扩展性很重要,但是这不意味着应该不加思考地在实现细节层面广泛使用多态、模板来确保未来的扩展性。和不要过早地优化程序、过早地进行关注点分离一样,我们也不应该过早地关注扩展性。如果还不知道哪些地方会如何演进,那么就等到需要扩展的时候再进行设计和实现就好,不要预判一个可能永远不会扩展的点。未来的需求会指导我们,给出未来演进的方向,此时再重构代码,使得后续更容易扩展。