C++面向对象和虚函数

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Point
{
int x;
int y;
};

class 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);
};

// 客户端使用方法
Graphics* g = getGraphics();
g->drawLine(0, 0, 100, 200);
releaseGraphics(g);

虚函数作为接口的弊端

C++ 以 vtable[offset] 方式实现虚函数调用,而 offset 又是根据虚函数声明的位置隐式确定的。增加 drawLine(double x0, double y0, double x1, double y1),造成 vtable 的排列发生了变化,现有的二进制可执行文件无法再用旧的 offset 调用到正确的函数。有种不太优雅的做法,可以直接把新的虚函数放到 interface 的末尾。如果 Graphics 被继承,那么新增虚函数会改变派生类中的 vtable offset 变化,同样不是二进制兼容的。

有两种似乎安全的做法:

  1. 通过链式继承来扩展现有的 interface
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    class 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);
    };
  2. 通过多重继承来扩展现有的 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
    33
    class 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 作为接口。
  1. 暴露的接口里边不要有虚函数,要显式声明构造函数、析构函数、并且不能 inline。另外 sizeof(Graphics) == sizeof(Graphics::Impl*)。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class 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;
    };
  2. 在库的实现中把调用转发(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
    #include <graphics.h>

    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);
    }
  3. 如果要加入新的功能,不必通过继承来扩展,可以原地修改,并且很容易保持二进制兼容性。

采用 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
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
36
class Foo
{
public:
void methodA();
void methodInt(int a);
void methodString(const string& str);
};

class Bar
{
public:
void methodB();
};

boost::function<void()> f1; // 无参数,无返回值

Foo foo;
f1 = boost::bind(&Foo::methodA, &foo);
f1(); // 调用 foo.methodA();

Bar bar;
f1 = boost::bind(&Bar::methodB, &bar);
f1(); // 调用 bar.methodB();

f1 = boost::bind(&Foo::methodInt, &foo, 42);
f1(); // 调用 foo.methodInt(42);

f1 = boost::bind(&Foo::methodString, &foo, "hello");
f1(); // 调用 foo.methodString("hello");
// bind 拷贝的是实参类型(const char*),不是形参类型(string)
// 这里形参中的 string 对象的构造发生在调用 f1 的时候,而非 bind 的时候,因此要留意 bind 的实参(const char*)的生命期,
// 必要时可通过 bind(&Foo::methodString, &Foo, string(aTempBuf))来保证安全。

boost::function<void(int)> f2; // int 参数,无返回值
f2 = boost::bind(&Foo::methodInt, &foo, _1);
f2(53); // 调用 foo.methodInt(53);

有了 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
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
36
37
class Thread
{
public:
typedef boost::function<void()> ThreadCallback;

Thread(ThreadCallback cb) : cb_(cb)
{ }

void start()
{
// some magic to call run() in new created thread
}

private:
void run();
{
cb_();
}

ThreadCallback cb_;
// ...
};

// 使用方式

class Foo // 不需要继承
{
public:
void runInThread();
void runInAnotherThread();
};

Foo foo;
Thread thread1(boost::bind(&Foo::runInThread, &foo));
Thread thread2(boost::bind(&Foo::runInAnotherThread, &foo, 43));
thread1.start(); // 在两个线程中分别运行两个成员函数
thread2.start();

2. 网络库

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class Connection;

class NetServer : boost::noncopyable
{
public:
typedef boost::function<void (Connection*)> ConnectionCallback;
typedef boost::function<void (Connection*, const void*, int len)> MessageCallback;

NetServer(uint16_t port);
~NetServer();

void registerConnectionCallback(const ConnectionCallback&);
void registerMessageCallback(const MessageCallback&);
void sendMessage(Connection*, const void* buf, int len);

private:
// ...
};

class EchoService
{
public:
// 符合 NetServer::sendMessage 的原型
typedef boost::function<void (Connection*, const void*, int)> SendMessageCallback;

EchoService(const SendMessageCallback& sendMsgCb)
: sendMessageCb_(sendMsgCb) // 保存 boost::funtion
{ }

// 符合 NetServer::MessageCallback 的原型
void onMessage(Connection* conn, const void* buf, int size)
{
printf("Received Msg from Connection %d: %.*s\n",
conn->id(), size, (const char*)buf);
sendMessageCb_(conn, buf, size); // echo back
}

// 符合 NetServer::ConnectionCallback 的原型
void onConnection(Connection* conn)
{
printf("Connection from %s:%d is %s\n", conn->ipAddr(), conn->port(),
conn->connected() ? "UP" : "DOWN");
}

private:
SendMessageCallback sendMessageCb_;
};

// 扮演上帝的角色,把各部分拼起来
int main()
{
NetServer server(7);
EchoService echo(bind(&NetService::sendMessage, &server, _1, _2, _3));
server.registerMessageCallback(
bind(&EchoService::onMessage, &echo, _1, _2, _3));
)
server.registerConnectionCallback(
bind(&EchoService::onConnection, &echo, _1));
)
server.run();
}

对面向对象程序设计的影响

面向对象的三要素是封装、继承和多态。继承和多态不仅规定了函数的名称、参数、返回类型,还规定了类的继承关系。在现代的 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
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class Penguin  // 企鹅能游泳,也能跑
{
public:
void run();
void swim();
};

class Sparrow // 麻雀能飞,也能跑
{
public:
void fly();
void run();
};

// 以 boost::function 作为接口
typedef boost::function<void()> FlyCallback;
typedef boost::function<void()> RunCallback;
typedef boost::function<void()> SwimCallback;

// 一个既用到 run,也用到 fly 的客户 class
class Foo
{
public:
Foo(FlyCallback flyCb, RunCallback runCb)
: flyCb_(flyCb), runCb_(runCb)
{ }

private:
FlyCallback flyCb_;
RunCallback runCb_;
};

// 一个既用到 run,也用到 swim 的客户 class
class Bar
{
public:
Bar(SwimCallback swimCb, RunCallback runCb)
: swimCb_(swimCb), runCb_(runCb)
{ }

private:
SwimCallback swimCb_;
RunCallback runCb_;
};

int main()
{
Sparrow s;
Penguin p;
// 装配起来,Foo 要麻雀,Bar 要企鹅
Foo foo(bind(&Sparrow::fly, &s), bind(&Sparrow::run, &s));
Bar bar(bind(&Penguin::swim, &p), bind(&Penguin::run, &p));
}
评论

:D 一言句子获取中...

加载中,最新评论有1分钟缓存...