<audio id="audio" title="05 | 面向对象编程：怎样才能写出一个“好”的类？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/8f/c5070fcfb479a08e9578eb1668b1d88f.mp3"></audio>

你好，我是Chrono。

如果按照前几节课的惯例，今天应该是讲运行阶段的。但是，运行阶段跟前面的编码、预处理和编译阶段不同，它是动态的、实时的，内外部环境非常复杂，CPU、内存、磁盘、信号、网络套接字……各种资源交织在一起，可谓千变万化（正如我在[第1节课](https://time.geekbang.org/column/article/231454)里所说，每一个阶段的差异都非常大）。

解决这个阶段面临的问题已经不是编程技术了，更多的是要依靠各种调试、分析、日志工具，比如GDB、Valgrind、Systemtap等。

所以，我觉得把这些运行阶段的工具、技巧放在课程前面不是太合适，咱们还是往后延一延，等把C++的核心知识点都学完了，再来看它比较好。

那么，今天要和你聊哪些内容呢？

我想了想，还是讲讲“面向对象编程”（Object Oriented Programming）吧。毕竟，它是C++诞生之初“安身立命”的看家本领，也是C++的核心编程范式。

不管我们是否喜欢，“面向对象”早就已经成为了编程界的共识和主流。C++、Java、Python等流行的语言，无一不支持面向对象编程，而像Pascal、BASIC、PHP那样早期面向过程的语言，在发展过程中也都增加了对它的支持，新出的Go、Swift、Rust就更不用说了。

毫无疑问，**掌握“面向对象编程”是现在程序员的基本素养**。但落到实际开发时，每个人对它的理解程度却有深有浅，应用的水平也有高有低，有的人设计出的类精致灵活，而有的人设计出来的却是粗糙笨重。

细想起来，“面向对象”里面可以研究的地方实在是太多了。那么，到底“面向对象”的精髓是什么？怎样才能用好它？怎样才能写出一个称得上是“好”的类呢？

所以，今天我就从设计思想、实现原则和编码准则这几个角度谈谈我对它的体会心得，以及在C++里应用的一些经验技巧，帮你写出更高效、更安全、更灵活的类。（在第19、20课，我还会具体讲解，到时候你可以参考下。）

## 设计思想

首先要说的是，虽然很多语言都内建语法支持面向对象编程，但它本质上是一种设计思想、方法，与语言细节无关，要点是**抽象**（Abstraction）和**封装**（Encapsulation）。

掌握了这种代码之外的思考方式，就可以“高屋建瓴”，站在更高的维度上去设计程序，不会被语言、语法所限制。

所以，即使是像C这样“纯”面向过程的编程语言，也能够应用面向对象的思想，以struct实现抽象和封装，得到良好的程序结构。

面向对象编程的基本出发点是“对现实世界的模拟”，把问题中的实体抽象出来，封装为程序里的类和对象，这样就在计算机里为现实问题建立了一个“虚拟模型”。

然后以这个模型为基础不断演化，继续抽象对象之间的关系和通信，再用更多的对象去描述、模拟……直到最后，就形成了一个由许多互相联系的对象构成的系统。

把这个系统设计出来、用代码实现出来，就是“面向对象编程”了。

不过，因为现实世界非常复杂，“面向对象编程”作为一种工程方法，是不可能完美模拟的，纯粹的面向对象也有一些缺陷，其中最明显的就是“继承”。

“继承”的本意是重用代码，表述类型的从属关系（Is-A），但它却不能与现实完全对应，所以用起来就会出现很多意外情况。

比如那个著名的长方形的例子。Rectangle表示长方形，Square继承Rectangle，表示正方形。现在问题就来了，这个关系在数学中是正确的，但表示为代码却不太正确。长方形可以用成员函数单独变更长宽，但正方形却不行，长和宽必须同时变更。

还有那个同样著名的鸟类的例子。基类Bird有个Fly方法，所有的鸟类都应该继承它。但企鹅、鸵鸟这样的鸟类却不会飞，实现它们就必须改写Fly方法。

各种编程语言为此都加上了一些“补丁”，像C++就有“多态”“虚函数”“重载”，虽然解决了“继承”的问题，但也使代码复杂化了，一定程度上扭曲了“面向对象”的本意。

## 实现原则

说了些“高大上”的理论，是不是有点犯迷糊？没关系，下面，我就在C++里细化一下。

就像我刚才说的，“面向对象编程”的关键点是“抽象”和“封装”，而“继承”“多态”并不是核心，只能算是附加品。

所以，我建议你在设计类的时候**尽量少用继承和虚函数**。

特别的，如果完全没有继承关系，就可以让对象不必承受“父辈的重担”（父类成员、虚表等额外开销），轻装前行，更小更快。没有隐含的重用代码也会降低耦合度，让类更独立，更容易理解。

还有，把“继承”切割出去之后，可以避免去记忆、实施那一大堆难懂的相关规则，比如public/protected/private继承方式的区别、多重继承、纯虚接口类、虚析构函数，还可以绕过动态转型、对象切片、函数重载等很多危险的陷阱，减少冗余代码，提高代码的健壮性。

如果非要用继承不可，那么我觉得一定要**控制继承的层次**，用UML画个类体系的示意图来辅助检查。如果继承深度超过三层，就说明有点“过度设计”了，需要考虑用组合关系替代继承关系，或者改用模板和泛型。

<img src="https://static001.geekbang.org/resource/image/14/d5/145780476bd0beb148e5e130c2336ed5.jpg" alt="">

在设计类接口的时候，我们也要让类尽量简单、“短小精悍”，**只负责单一的功能**。

如果很多功能混在了一起，出现了“万能类”“意大利面条类”（有时候也叫God Class），就要应用设计模式、重构等知识，把大类拆分成多个各负其责的小类。

我还看到过很多人有一种不好的习惯，就是喜欢在类内部定义一些嵌套类，美其名曰“高内聚”。但恰恰相反，这些内部类反而与上级类形成了强耦合关系，也是另一种形式的“万能类”。

其实，这本来是名字空间该做的事情，用类来实现就有点“越权”了。正确的做法应该是，**定义一个新的名字空间，把内部类都“提”到外面，降低原来类的耦合度和复杂度**。

## 编码准则

有了这些实现原则，下面我再来讲几个编码时的细节，从安全和性能方面帮你改善类的代码。

C++11新增了一个特殊的标识符“**final**”（注意，它不是关键字），把它用于类定义，就可以显式地禁用继承，防止其他人有意或者无意地产生派生类。无论是对人还是对编译器，效果都非常好，我建议你一定要积极使用。

```
class DemoClass final    // 禁止任何人继承我
{ ... };

```

在必须使用继承的场合，建议你**只使用public继承，避免使用virtual、protected**，因为它们会让父类与子类的关系变得难以捉摸，带来很多麻烦。当到达继承体系底层时，也要及时使用“final”，终止继承关系。

```
class Interface        // 接口类定义，没有final，可以被继承
{ ... };           

class Implement final : // 实现类，final禁止再被继承
      public Interface    // 只用public继承
{ ... };

```

C++里类的四大函数你一定知道吧，它们是构造函数、析构函数、拷贝构造函数、拷贝赋值函数。C++11因为引入了右值（Rvalue）和转移（Move），又多出了两大函数：**转移构造函数**和**转移赋值函数**。所以，在现代C++里，一个类总是会有六大基本函数：**三个构造**、**两个赋值**、**一个析构**。

好在C++编译器会自动为我们生成这些函数的默认实现，省去我们重复编写的时间和精力。但我建议，对于比较重要的构造函数和析构函数，应该用“**= default**”的形式，明确地告诉编译器（和代码阅读者）：“应该实现这个函数，但我不想自己写。”这样编译器就得到了明确的指示，可以做更好的优化。

```
class DemoClass final 
{
public:
    DemoClass() = default;  // 明确告诉编译器，使用默认实现
   ~DemoClass() = default;  // 明确告诉编译器，使用默认实现
};

```

这种“= default”是C++11新增的专门用于六大基本函数的用法，相似的，还有一种“**= delete**”的形式。它表示**明确地禁用某个函数形式**，而且不限于构造/析构，可以用于任何函数（成员函数、自由函数）。

比如说，如果你想要禁止对象拷贝，就可以用这种语法显式地把拷贝构造和拷贝赋值“delete”掉，让外界无法调用。

```
class DemoClass final 
{
public:
    DemoClass(const DemoClass&amp;) = delete;              // 禁止拷贝构造
    DemoClass&amp; operator=(const DemoClass&amp;) = delete;  // 禁止拷贝赋值
};

```

因为C++有隐式构造和隐式转型的规则，如果你的类里有单参数的构造函数，或者是转型操作符函数，为了防止意外的类型转换，保证安全，就要使用“**explicit**”将这些函数标记为“显式”。

```
class DemoClass final 
{
public:
    explicit DemoClass(const string_type&amp; str)  // 显式单参构造函数
    { ... }

    explicit operator bool()                  // 显式转型为bool
    { ... }
};

```

## 常用技巧

C++11里还有很多能够让类更优雅的新特性，这里我从“投入产出比”的角度出发，挑出了三个我最喜欢的特性，给你介绍一下，让你不用花太多力气就能很好地改善代码质量。

第一个是“**委托构造**”（delegating constructor）。

如果你的类有多个不同形式的构造函数，为了初始化成员肯定会有大量的重复代码。为了避免重复，常见的做法是把公共的部分提取出来，放到一个init()函数里，然后构造函数再去调用。这种方法虽然可行，但**效率和可读性较差**，毕竟init()不是真正的构造函数。

在C++11里，你就可以使用“委托构造”的新特性，一个构造函数直接调用另一个构造函数，把构造工作“委托”出去，既简单又高效。

```
class DemoDelegating final
{
private:
    int a;                              // 成员变量
public:
    DemoDelegating(int x) : a(x)        // 基本的构造函数
    {}  

    DemoDelegating() :                 // 无参数的构造函数
        DemoDelegating(0)               // 给出默认值，委托给第一个构造函数
    {}  

    DemoDelegating(const string&amp; s) : // 字符串参数构造函数
        DemoDelegating(stoi(s))        // 转换成整数，再委托给第一个构造函数
    {}  
};

```

第二个是“**成员变量初始化**”（In-class member initializer）。

如果你的类有很多成员变量，那么在写构造函数的时候就比较麻烦，必须写出一长串的名字来逐个初始化，不仅不美观，更危险的是，容易“手抖”，遗漏成员，造成未初始化的隐患。

而在C++11里，你可以在类里声明变量的同时给它赋值，实现初始化，这样**不但简单清晰，也消除了隐患。**

```
class DemoInit final                  // 有很多成员变量的类
{
private:
    int                 a = 0;        // 整数成员，赋值初始化
    string              s = &quot;hello&quot;;  // 字符串成员，赋值初始化
    vector&lt;int&gt;         v{1, 2, 3};   // 容器成员，使用花括号的初始化列表
public:
    DemoInit() = default;             // 默认构造函数
   ~DemoInit() = default;             // 默认析构函数
public:
    DemoInit(int x) : a(x) {}         // 可以单独初始化成员，其他用默认值
};


```

第三个是“**类型别名**”（Type Alias）。

C++11扩展了关键字using的用法，增加了typedef的能力，可以定义类型别名。它的格式与typedef正好相反，别名在左边，原名在右边，是标准的赋值形式，所以易写易读。

```
using uint_t = unsigned int;        // using别名
typedef unsigned int uint_t；      // 等价的typedef


```

在写类的时候，我们经常会用到很多外部类型，比如标准库里的string、vector，还有其他的第三方库和自定义类型。这些名字通常都很长（特别是带上名字空间、模板参数），书写起来很不方便，这个时候我们就可以在类里面用using给它们起别名，不仅简化了名字，同时还能增强可读性。

```
class DemoClass final
{
public:
    using this_type         = DemoClass;          // 给自己也起个别名
    using kafka_conf_type   = KafkaConfig;        // 外部类起别名

public:
    using string_type   = std::string;            // 字符串类型别名
    using uint32_type   = uint32_t;              // 整数类型别名

    using set_type      = std::set&lt;int&gt;;          // 集合类型别名
    using vector_type   = std::vector&lt;std::string&gt;;// 容器类型别名

private:
    string_type     m_name  = &quot;tom&quot;;              // 使用类型别名声明变量
    uint32_type     m_age   = 23;                  // 使用类型别名声明变量
    set_type        m_books;                      // 使用类型别名声明变量

private:
    kafka_conf_type m_conf;                       // 使用类型别名声明变量
};

```

类型别名不仅能够让代码规范整齐，而且因为引入了这个“语法层面的宏定义”，将来在维护时还可以随意改换成其他的类型。比如，把字符串改成string_view（C++17里的字符串只读视图），把集合类型改成unordered_set，只要变动别名定义就行了，原代码不需要做任何改动。

## 小结

今天我们谈了“面向对象编程”，这节课的内容也比较多，我划一下重点。

1. “面向对象编程”是一种设计思想，要点是“抽象”和“封装”，“继承”“多态”是衍生出的特性，不完全符合现实世界。
1. 在C++里应当少用继承和虚函数，降低对象的成本，绕过那些难懂易错的陷阱。
1. 使用特殊标识符“final”可以禁止类被继承，简化类的层次关系。
1. 类有六大基本函数，对于重要的构造/析构函数，可以使用“= default”来显式要求编译器使用默认实现。
1. “委托构造”和“成员变量初始化”特性可以让创建对象的工作更加轻松。
1. 使用using或typedef可以为类型起别名，既能够简化代码，还能够适应将来的变化。

所谓“仁者见仁智者见智”，今天我讲的也只能算是我自己的经验、体会。到底要怎么用，你还是要看自己的实际情况，千万不要完全照搬。

## 课下作业

这次的课下作业时间，我给你留两个思考题：

1. 你对“面向对象编程”有哪些认识，是否赞同这节课的观点？（希望你大胆地说出来，如果意见有分歧，那也很正常，我们一起讨论。）
1. 你觉得应用这节课讲到的准则和技巧能否写出一个“好”的类，还缺什么吗？

欢迎你在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

<img src="https://static001.geekbang.org/resource/image/67/b3/6739782414607164bdbe20fca7fd5fb3.jpg" alt="">
