在C++类声明中为什么既包含接口又包含实现

包含接口和实现的类声明

C++备受一些人吐槽的一点是它在类的声明中既包含了实现细节又包含了接口细节,比如:

1
2
3
4
5
6
7
8
9
#include "Point.h"

class Circle {
public:
double Area(); // 接口
private:
double _radius; // 实现
Point _center; // 实现
};

这其中带来的很大的一个问题就是有时候即使只是改变类内容的实现细节(比如Point 类 定义发生了变化,或者把_radius变成 float 类型),类的用户也需要重新编译自己的程 序(这通常称为编译依赖),这一点让很多人难以接受。(当然还存在其他问题,比如用户 知道了实现细节就可以通过一些手段欺骗编译器,写出一些依赖实现而不是依赖接口的代码 )。

为什么C++会如此定义

这种方式来自C++的前身C With Class,该语言的设计初衷是结合Simula语言在程序组 织上的便利性和C语言本身的高效性。当年C++之父使用Simula写模拟器,发现类的概念非 常好用,但是最终因为Simula本身效率非常的低而不得不使用BCPL语言重写。

他发现Simula之所以低效的一个很重要的原因是无法在栈和静态数据区(存放全局变量、 静态变量的区域)中创建用户自定义的对象(其中的原因我不清楚,不过从《C++语言的设 计和演化》一书中的论述来看,应该是因为它把类接口声明和类实现声明分离开来了)。

为了能够像下面的代码一样在栈中创建一个对象:

1
2
3
4
5
6
#include "Circle.h"

void test_circle() {
Circle circle;
circle.Area();
}

编译器必须在编译期间知道circle对象占用多大空间。把实现细节放在类的声明当中让 这一点变成可能而且实现较为简单。所以出于效率方面的考虑C With Class使用了这种方 式,之后的C++语言沿用了这一方式。

当然这么做的另一个原因是让用户自定义类型的对象模型和C语言的结构体的对象模型能够 兼容。

允许不代表必须

在C++的设计和演化中,有一个永恒不变的指导思想就是不强迫用户使用单一的方式解决问 题(这估计也是C++领域会有那么多的最佳实践的书籍的原因)。C++允许你这么做,但是不 强迫你把实现细节写在类的声明当中。你如果不想让你的用户知道任何的实现细节,或者说 你想要摆脱编译依赖,你完全可以这样写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 给用户的接口
class Circle {
public:
static Circle* CreateNew();
virtual void Area() = 0;
};

// 隐藏在用户背后的实现
class CircleImp : public Circle {
public:
virtual void Area();
private:
double _radius;
Point _center;
};

// 用户代码

void test_circle() {
Circle* circle = Circle::CreateNew();
circle->Area();
}

这种做法需要使用到虚拟函数机制,所以会有一定的开销(主要是vptr和通过指针间接调用 函数的开销),而且没有办法在栈上面创建对象(静态存储区域中也是如此)。如果你不想 要这些开销,你可以通过“pointer to implement”机制屏蔽掉实现的细节。

1
2
3
4
5
6
7
8
9
class CircleImp;

class Circle {
public:
void Area();

private:
CircleImp* _imp;
};

这样一来,你可以通过不完整类型CircleImp来隐藏实现信息,当然它还是需要间接指针 调用的运行开销,但是开销相对于上面的方式来说会小很多(至少你可以再栈中创建该类型 的对象)。此外上面两种方式基本上都没有办法正常的实现内联,所以内联带来的性能提升 你无法享受。这些方式都不是最高效的方式,不过毕竟你最关心并不是效率对吗,否则你完 全可以把实现细节写在类的声明当中。


关于以上讨论的更详细的内容请参考《Effective C++》和《C++语言的设计和演化》两本书 。