C++面向对象和虚函数
C++面向对象和虚函数
反思C++面向对象和虚函数
朴实的C++设计
C++ 基于对象的风格,就是具体类加全局函数。
定义并使用清晰一致的接口很重要,但接口不一定非得是抽象基类,一个类的成员函数就是它的接口。一个进程内部的解耦意义不大;相反,函数调用是最直接有效的通信方式了。或许采用接口类/实现类的一个可能的好处是依赖注入,便于单元测试。
确定性析构是 C++ 区别其他主流开发语言(Java/C#/C/动态脚本语言)的主要特性。
程序库的二进制兼容性
二进制兼容性是在升级(也可能是 bug fix)库文件的时候,不必重新编译使用了这个库的可执行文件或其他库文件,并且程序的功能不被破坏。
open 函数的原型如下,其中 flags 的取值有三个:O_RDONLY、O_WRONLY、O_RDWR。
1 | int open(const char *pathname, int flags); |
这几个值不满足按位或(bitwise-OR)的关系,即 (O_RDONLY | O_WRONLY) != O_RDWR。因为它们的值分别是0、1、2。
操作系统的 system call 可以看成 Kernel 与 User space 的 interface,kernel 在这个意义下也可以当成 shared library,可以把内核从2.6.30升级到2.6.35,而不需要重新编译所有用户态的程序。
破坏库的ABI的情况
C++ 编译器 ABI 的主要内容包括以下几个方面:
- 函数参数传递的方式,比如 x86-64 用寄存器来传函数的前4个整数参数;
- 虚函数的调用方式,通常是 vptr/vtbl 机制,然后用 vtbl[offset] 来调用;
- struct 和 class 的内存布局,通过偏移量来访问数据成员;
- name mangling;
- RTTI 和异常处理的实现。
一个修改动态库导致二进制不兼容的例子。比如原来动态库里定义了 non-virtual 函数 void foo(int),新版的库把参数写成了 double。那么现有的可执行文件就无法启动,会发生 undefined symbol 错误,因为这两个函数的 mangled name 不同。但是对于 virtual 函数 foo(int),修改其参数类型并不会导致加载错误,而是会发生诡异的运行时错误。因为虚函数的决议(resolution)是靠偏移量,并不是靠符号名。
常见的一些源代码兼容但是二进制代码不兼容的例子:
- 给函数增加默认参数,现有的可执行文件无法传这个额外的参数。
- 增加虚函数,会造成 vtbl 里排列变化。(不要考虑只在末尾增加这种取巧行为,因为你的 class 可能被继承。)
- 增加默认模板类型参数,比方说 Foo
改为 Foo<T, Alloc=alloc >,这会改变 name mangling。 - 改变 enum 的值,把 enum Color { Red = 3 }; 改为 Red = 4。这会造成错位。由于 enum 自动排列取值,添加 enum 项也是不安全的(在末尾添加除外)。
给 class Bar 增加数据成员,造成 sizeof(Bar) 变大,以及内部数据成员的 offset 变化。通常不是安全的,但也有例外。
- 如果客户代码里有 new Bar,肯定不安全。因为 new 的字节数不够装下新 Bar 对象。相反,如果 library 通过 factory 返回 Bar*(并通过 factory 来销毁对象)或者直接返回 shared_ptr
,客户端不需要用到 sizeof(Bar),那么可能是安全的。 - 如果客户代码里有 Bar* pBar; pBar->memberA = xx;,那么肯定不安全。因为 menberA 的新 Bar 的编译可能会变。相反,如果只是通过成员函数来访问对象的数据成员,客户端不需要用到 data member 的 offsets,那么可能是安全的。
- 如果客户调用 pBar->setMemberA(xx);,而 Bar::setMemberA() 是个 inline function,那么肯定不安全,因为偏移量已经被 inline 到客户的二进制代码里了。如果 setMemberA() 是 outline function,其实现位于 shared library 中,会随着 Bar 的更新而更新,那么可能是安全的。
多半是安全的做法
- 增加新的 class。
- 增加 non-virtual 成员函数或 static 成员函数。
- 修改数据成员的名称,因为生产的二进制代码是按偏移量来访问的。但是这会造成源码级的不兼容。
以只包含虚函数的 class(interface class) 作为程序库的接口,一旦发布,无法修改。Windows 下,Visual C++ 编译的时候要选择 Release 或 Debug 模式,而且 Debug 模式编译出来的 library 通常不能在 Release binary 中使用(反之亦然),这也是因为两种模式下的 CRT 二进制不兼容(主要是内存分配方面,Debug 有自己的簿记(bookkeeping))。Linux 可以混用。
解决方法
采用静态链接
不是指使用(.a),而是指完全从源码编译出可执行文件。在分布式系统里,采用静态链接也带来部署上的好处,只要把可执行文件放到机器上就能运行,不用考虑它依赖的 libraries。
通过动态库的版本管理来控制兼容性
比如 1.0.x 版本系列之间做到二进制兼容,1.1.x 版本系列之间做到二进制兼容,而 1.0.x 和 1.1.x 不必二进制兼容。
用pimpl技法,编译器防火墙
在头文件中只暴露 non-virtual 接口,并且 class 的大小固定为 sizeof(Impl*),这样可以随意更新库文件而不影响可执行文件。这么做增加了一道间接性,可能有一定的性能损失。
避免使用虚函数作为库的接口
接口表示一个广义的接口,即一个库的代码界面;用英文 interface 表示狭义的接口,即只包含 virtual function 的 class,这类 class 通常没有 data member,在 Java 里有一个专门的关键字 interface 来表示它。
程序库作者的生存环境
如果打算新写一个 C++ library,通常要做如下几个决策:
- 以什么方式发布?动态库还是静态库?
- 以什么方式暴露库的接口?可选的做法有:以全局(含 namespace 级别)函数为接口、以 class 的 non-virtual 成员函数为接口、以 virtual 函数为接口。
在作出上面两个决策之前,考虑两个基本假设:
- 代码会有 bug,库也不例外。将来可能会发布 bug fixes。
- 会有新的功能需求。
如果需要 hot fix,那么只能用动态库;否则,在分布式系统中使用静态库更容易部署,动态库比静态库节约内存这种优势现在已不是太重要。
要选择一种可扩展的(extensible)接口风格,让库的升级变得更轻松。升级有两层意思:
- 对于 bug fix only 的升级,二进制库文件的替换应该兼容现有的二进制可执行文件。
- 对于新增功能的升级,应该对客户代码友好。
虚函数作为接口的两大用途
- 调用,也就是库提供一个什么功能(比如绘图 Graphics),以虚函数为接口方式暴露给客户端代码。客户端代码一般不需要继承这个 interface,而是直接调用其 member function。
- 回调,也就是事件通知,比如网络库的连接建立、数据到达、连接断开等等。客户端代码一般会继承这个 interface,然后把对象实体注册到库里边,等库来回调自己。一般来说客户端不会自己去调用这些 member function,除非是为了写单元测试模板库的行为。
- 混合,一个 class 既可以被客户端代码继承用作回调,又可以被客户端直接调用。
对于回调方式,现代 C++ 有 boost::function + boost::bind。举一个虚构的图形库说明问题。
1 | struct Point |
虚函数作为接口的弊端
C++ 以 vtable[offset] 方式实现虚函数调用,而 offset 又是根据虚函数声明的位置隐式确定的。增加 drawLine(double x0, double y0, double x1, double y1),造成 vtable 的排列发生了变化,现有的二进制可执行文件无法再用旧的 offset 调用到正确的函数。有种不太优雅的做法,可以直接把新的虚函数放到 interface 的末尾。如果 Graphics 被继承,那么新增虚函数会改变派生类中的 vtable offset 变化,同样不是二进制兼容的。
有两种似乎安全的做法:
- 通过链式继承来扩展现有的 interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
class Graphics2 : public Graphics
{
using Graphics::drawLine;
using Graphics::drawRectangle;
using Graphics::drawArc;
// added in version 2
virtual void drawLine(double x0, double y0, double x1, double y1);
virtual void drawRectangle(double x0, double y0, double x1, double y1);
virtual void drawArc(double x, double y, double r);
}; - 通过多重继承来扩展现有的 interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33class Graphics
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
class Graphics2
{
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawLine(double x0, double y0, double x1, double y1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(double x0, double y0, double x1, double y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(double x, double y, double r);
virtual void drawArc(Point p, int r);
};
// 在实现中采用多重接口继承
class GraphicsImpl : public Graphics, // version1
public Graphics2, // version2
{
}
假如Linxu系统调用以COM接口方式实现
Linux kernal 给每一个 system call 赋予一个终身不变的数字代号,等于把虚函数表的排列固定下来。
动态库接口的推荐做法
- 其一,如果动态库的使用范围比较窄。
- 其二,如果库的使用范围很广,用户很多,各家的 release cycle 不尽相同,那么推荐 pimpl 技法,并考虑多采用 non-menber non-friend function in namespace 作为接口。
- 暴露的接口里边不要有虚函数,要显式声明构造函数、析构函数、并且不能 inline。另外 sizeof(Graphics) == sizeof(Graphics::Impl*)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Graphics
{
public:
Graphics(); // outline ctor
~Graphics(); // outline dtor
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
private:
class Impl; // 头文件只放声明
boost::scope_ptr<Impl> impl;
}; - 在库的实现中把调用转发(forward)给实现 Graphics::Impl,这部分代码位于 .so/.dll中,随库的升级一起变化。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Graphics::Impl
{
public:
virtual void drawLine(int x0, int y0, int x1, int y1);
virtual void drawLine(Point p0, Point p1);
virtual void drawRectangle(int x0, int y0, int x1, int y1);
virtual void drawRectangle(Point p0, Point p1);
virtual void drawArc(int x, int y, int r);
virtual void drawArc(Point p, int r);
};
Graphics::Graphics()
: impl(new Impl)
{
}
Graphics::~Graphics()
{
}
void Graphics::drawLine(int x0, int y0, int x1, int y1)
{
impl->drawLine(x0, y0, x1, y1);
}
void Graphics::drawLine(Point p0, Point p1)
{
impl->drawLine(p0, p1);
} - 如果要加入新的功能,不必通过继承来扩展,可以原地修改,并且很容易保持二进制兼容性。
采用 pimpl 多了一道 explicit forward 的手续,带来的好处是可扩展性与二进制兼容性。pimpl 扮演了编译器防火墙的作用。
为什么 non-virtual 函数比 vitual 函数更健壮?因为 virtual function 是 bind-by-vtable-offset,而 non-virtual function 是 bind-by-name。加载器(loader)会在程序启动时做决议(resolution),通过 mangled name 把可执行文件和动态库链接到一起。就像使用 Internet 域名比使用 IP 地址更能适应变化一样。用 free function 有时候更好。
以boost::function和boost::bind取代虚函数
用继承树这种方式来建模,确实是基于概念分类的思想。分类是西方哲学一早就有的思想,可以上溯到古希腊时期。
- 比如电影,可以分为科幻片、爱情片、伦理片、战争片、灾难片、恐怖片等等。
- 比如生物,可以分为动物和植物,动物又可以分为有脊椎动物和无脊椎动物,有脊椎动物又分为鱼类、两栖类、爬行类、鸟类、哺乳类等。
- 比如技术书籍分为电子类、通信类、计算机类等等,计算机书籍又可分为编程语言、操作系统、数据结构、数据库、网络技术等等。
Ruby 的 duck typing 和 Google Go 的无继承都可以看作以 tag 取代分类(层次化的类型)的代表。一个 object 只要提供了相应的 operations,就能当作某种东西来用,不需要显式地继承或实现某个接口。
在传统的 C++ 程序中,事件回调是通过虚函数进行的。网络库往往会定义一个或几个抽象基类(Handler class),其中声明了一些(纯)虚函数,如 onConnect()、onDisconnect()、onMessage()、onTimer()等等。使用者需要继承这些基类,并覆写(override)这些虚函数,以获得事件回调通知。C++ 的动态绑定只能通过指针和引用实现,使用者必须把派生类(MyHandler)对象的指针或引用隐式转换为基类(Handler)的指针或引用,再注册到网络库中。MyHandler 对象通常是动态创建的,位于堆上,用完后需要 delete。网络库调用基类的虚函数,通过动态绑定机制实际调用的是用户在派生类中 override 的虚函数,这也是各种 OO framework 的通行做法。
std::function + std::bind 这种方式的一个优点是不必担心对象的生存期。这种借口方式对用户代码的 class 类没有限制(不必从特定的基类派生),对成员函数名也没有限制,只对函数签名有部分限制。这样也解决了空悬指针的难题,因为传给网络库的都是具有值语义的 boost::function 对象。
基本用途
boost::function 就像 C# 里的 delegate,可以指向任何函数,包括成员函数。当用 bind 把某个成员函数绑到某个对象上时,就得到了一个 closure(闭包)。
1 | class Foo |
有了 bind,同一个类的不同对象可以 delegate 给不同的实现,从而实现不同的行为。
对程序库的影响
继承是第二强的一种耦合(最强耦合的是友元)。
1. 线程库
常规OO设计
写一个 Thread base class,含有(纯)虚函数 Thread::run(),然后应用程序派生一个 derived class,覆写 run()。程序里的每一种线程对应一个 Thread 的派生类。
缺点:如果一个 class 的三个 method 需要在三个不同的线程中执行,就得写 helper class(es)并玩一些 OO 把戏。
基于boost::function的设计
令 Thread 是一个具体类,其构造函数接受 ThreadCallback 对象。应用程序只需提供一个能转换为 ThreadCallback 的对象(可以是函数),即可创建一份 Thread 实体,然后调用 Thread::start()即可。
1 | class Thread |
2. 网络库
1 | class Connection; |
对面向对象程序设计的影响
面向对象的三要素是封装、继承和多态。继承和多态不仅规定了函数的名称、参数、返回类型,还规定了类的继承关系。在现代的 OO 编程语言里,借助反射和 attribute/annotation,已经大大放宽了限制。JUnit3.x 是用反射,找出派生类里的名字符合 void test*() 的函数来执行的。
对面向对象设计模式的影响
既然虚函数能用 closeure 代替,那么很多 OO 设计模式,尤其是行为模式,就失去了存在的必要。既然没有继承体系,那么很多创建型模式似乎也没用了(比如 Factory Method 可以用 boost::function<Base* ()> 替代)。
依赖注入与单元测试
EchoService 可算是依赖注入的例子。EchoServcie 需要一个什么东西来发送消息,它对这个东西的要求只是函数原型满足 SendMessageCallback,而并不关心数据到底发到网络上还是发到控制台。在正常使用的时候,数据应该发给网络;而在做单元测试的时候,数据应该发给某个 DataSink。
什么时候使用继承
如果是指 OO 中的 public 继承,即为了接口与实现分离。则在派生类的数目和功能完全确定的情况下使用。如果是功能继承,则会考虑继承 boost::noncopyable 或 boost::enable_shared_from_this。例如,IO multiplexing 在不同的操作系统下有不同的推荐实现,最通用的 select()、POSIX 的 poll()、Linux 的 epoll()、FreeBSD 的 kqueue() 等,数目固定,功能也完全确定,不用考虑扩展,那么设计一个 NetLoop base class 加若干具体 classes 就是不错的解决办法。用多态来代替 switch-case 以达到简化代码的目的。
基于接口的设计
不会飞的企鹅(Penguin)究竟应不应该继承自鸟(Bird),如果 Bird 定义了 virtual function fly() 的话。可以把具体的行为提出来,作为 interface,比如 Flyable(能飞的),Runnable(能跑的),然后让企鹅实现 Runnable,麻雀实现 Flyable 和 Runable。interface 的粒度应该足够小,或许一个 method 就够了,那么 interface 实际上退化成了给类型打的标签(tag)。这种情况下,完全可以使用 boost::function 来代替。
1 | class Penguin // 企鹅能游泳,也能跑 |
- 本文标题:C++面向对象和虚函数
- 本文作者:beyondhxl
- 本文链接:https://www.beyondhxl.com/post/9d2bfddd.html
- 版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!