本文是针对 Joshua GPBeta谈中心化插件系统的设计(二) 中评论的回复。

首先,这个设计并不只是”手工生成虚函数表”而已,通过使用固定的函数 ID 及其对应的函数签名,可以使得接口的二进制兼容性更加稳定,而且也方便客户端的使用(这点后面会说到)。

函数 ID 的做法是学习 Linux kernel 的 API 设计做法而来的(内核调用号),Linux kernel 通过这种方法在保持版本的演进的同时仍然得到了惊人的兼容性,而函数指针的做法是学习 OpenGL 的扩展方法而来的,OpenGL 通过这种方法得到了极高的扩展性,这些都是通过了事实的验证的。

只不过 Linux kernel 对于函数 ID 是每次调用都会查找一次的,对于内核调用来说,当然是可以接受的,但是用于这里性能上就不太合适,而 OpenGL 的函数指针的扩展是通过字符串来进行查找的,同样,我认为性能上也不太合适,所以才结合两者,得到了当前的这个设计。

而且这个方法不只是解决接口名不带版本号这个问题,而是为了解决引入 C++ 纯虚接口所带来的僵硬性和脆弱性因为每次改动都引入了新的纯虚接口,会造成日后客户端代码难以管理。比如,如果新版应用程序的代码使用了 Graphics3 的功能,要不要把现有代码中出现的 Graphics2 都替换掉?

  • 如果不替换,一个程序同时依赖多个版本的 Graphics,一直背着历史包袱。依赖的 Graphics 版本越积越多,将来如何管理得过来?
  • 如果要替换,为什么不相干的代码(现有的运行得好好的使用 Graphics2 的代码)也会因为别处用到了 Graphics3 而被修改?

为了说明这个问题,我举个栗子。

客户端第一个版本使用 Graphics 接口的 v1.0 实现一个绘制器用于绘制一些形状。

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
62
63
64
65
66
67
68
69
70
71
// 接口 -----------------------------------------------------
class Graphics
{
public:
virtual void drawLine(int x0, int y0, int x1, int y1) =0;
virtual void drawLine(float x0, float y0, float x1, float y1) =0;
virtual void drawRect(int x0, int y0, int x1, int y1) =0;
virtual void drawRect(float x0, float y0, float x1, float y1) =0;
};
// -----------------------------------------------------------
// 客户端 -----------------------------------------------------
class Shape
{
public:
virtual ~Shape() {}
virtual void draw(Graphics* graphics) =0;
};
class Drawer
{
public:
void addShape(Shape* shape);
void removeShape(Shape* shape);
void draw(Graphics* graphics);
};
class Line : public Shape
{
public:
Line(int x0, int y0, int x1, int y1);
void draw(Graphics* g) override
{
g->drawLine(x0, y0, x1, y1);
}
};
class Rect : public Shape
{
public:
Rect(int x0, int y0, int x1, int y1);
void draw(Graphics* g) override
{
g->drawRect(x0, y0, x1, y1);
}
};
int main()
{
// ...........
Drawer drawer = new Drawer();
std::vector<Shape*> shapes;
Shape* s;
s = new Line(0, 0, 100, 100);
drawer.addShape(s);
s = new Rect(200, 200, 400, 400);
drawer.addShape(s);
Graphics* graphics = getGraphics();
drawer.draw(graphics);
// ..........
}
// -----------------------------------------------------------

客户端第二个版本需求发生变更,需要使用新版本的 Graphics 接口绘制圆形。

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 接口 -----------------------------------------------------
class Graphics
{
public:
virtual void drawLine(int x0, int y0, int x1, int y1) =0;
virtual void drawLine(float x0, float y0, float x1, float y1) =0;
virtual void drawRect(int x0, int y0, int x1, int y1) =0;
virtual void drawRect(float x0, float y0, float x1, float y1) =0;
};
class Graphics2
{
public:
virtual void drawArc(int x, int y, int r) =0;
virtual void drawArc(float x, float y, float r) =0;
};
// -----------------------------------------------------------
// 客户端 -----------------------------------------------------
class Shape
{
public:
virtual ~Shape() {}
virtual void draw(Graphics2* graphics) =0;
};
class Drawer
{
public:
void addShape(Shape* shape);
void removeShape(Shape* shape);
void draw(Graphics2* graphics);
};
class Line : public Shape
{
public:
Line(int x0, int y0, int x1, int y1);
void draw(Graphics2* g) override
{
g->drawLine(x0, y0, x1, y1);
}
};
class Rect : public Shape
{
public:
Rect(int x0, int y0, int x1, int y1);
void draw(Graphics2* g) override
{
g->drawRect(x0, y0, x1, y1);
}
};
class Arc : public Shape
{
public:
Arc(int x, int y, int r);
void draw(Graphics2* g) override
{
g->drawArc(x, y, r);
}
};
int main()
{
// ...........
Drawer drawer = new Drawer();
std::vector<Shape*> shapes;
Shape* s;
s = new Line(0, 0, 100, 100);
drawer.addShape(s);
s = new Rect(200, 200, 400, 400);
drawer.addShape(s);
Graphics2* graphics2 = getGraphics();
drawer.draw(graphics2);
// ..........
}
// -----------------------------------------------------------

可以看到,为了能支持新的功能,导致原有正常工作的代码部分被牵连(Drawer, Shape, 以及所有继承自 Shape 的类),这就是 COM 框架带来的僵硬性和脆弱性,这还只是例子,在现实中的实际使用中带来的问题会更严重。

而这种二难境地纯粹是“以虚函数为 API 接口”这个设计造成的,如果跳出这个框框去思考,其实 C++ 的 API 很容易做得更好。

第一个版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 接口 -----------------------------------------------------
class Graphics
{
public:
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(float x0, float y0, float x1, float y1);
void drawRect(int x0, int y0, int x1, int y1) =0;
void drawRect(float x0, float y0, float x1, float y1) =0;
};
// -----------------------------------------------------------
// 客户端 -----------------------------------------------------
// 与上面第一个版本相同
// -----------------------------------------------------------

第二个版本:

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// 接口 -----------------------------------------------------
class Graphics
{
public:
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(float x0, float y0, float x1, float y1);
void drawArc(int x, int y, int r);
void drawArc(float x, float y, float r);
void drawRect(int x0, int y0, int x1, int y1) =0;
void drawRect(float x0, float y0, float x1, float y1) =0;
};
// -----------------------------------------------------------
// 客户端 -----------------------------------------------------
class Shape
{
public:
virtual ~Shape() {}
virtual void draw(Graphics* graphics) =0;
};
class Drawer
{
public:
void addShape(Shape* shape);
void removeShape(Shape* shape);
void draw(Graphics* graphics);
};
class Line : public Shape
{
public:
Line(int x0, int y0, int x1, int y1);
void draw(Graphics* g) override
{
g->drawLine(x0, y0, x1, y1);
}
};
class Rect : public Shape
{
public:
Rect(int x0, int y0, int x1, int y1);
void draw(Graphics* g) override
{
g->drawRect(x0, y0, x1, y1);
}
};
class Arc : public Shape
{
public:
Arc(int x, int y, int r);
void draw(Graphics* g) override
{
g->drawArc(x, y, r);
}
};
int main()
{
// ...........
Drawer drawer = new Drawer();
std::vector<Shape*> shapes;
Shape* s;
s = new Line(0, 0, 100, 100);
drawer.addShape(s);
s = new Rect(200, 200, 400, 400);
drawer.addShape(s);
s = new Arc(600, 600, 100);
drawer.addShape(s);
Graphics* graphics = getGraphics();
drawer.draw(graphics);
// ..........
}
// -----------------------------------------------------------

而且,对于接口的可读性也有着很大的帮助。例如以下的情况:

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
// v1 ---------------------------------------------------------
class Graphics
{
public:
virtual void drawLine(int x0, int y0, int x1, int y1) =0;
virtual void drawLine(const Point& p0, const Point& p1) =0;
virtual void drawRect(int x0, int y0, int x1, int y1) =0;
virtual void drawRect(const Point& p0, const Point& p1) =0;
};
// -----------------------------------------------------------
// v2 ---------------------------------------------------------
class Graphics
{
public:
virtual void drawLine(int x0, int y0, int x1, int y1) =0;
virtual void drawLine(const Point& p0, const Point& p1) =0;
virtual void drawRect(int x0, int y0, int x1, int y1) =0;
virtual void drawRect(const Point& p0, const Point& p1) =0;
};
class Graphics2 : public Graphics
{
public:
virtual void drawLine(float x0, float y0, float x1, float y1) =0;
virtual void drawRect(float x0, float y0, float x1, float y1) =0;
};
// -----------------------------------------------------------

在第二个版本中,新的 drawLine(float x0, float y0, float x1, float y1) 函数位于派生 Graphics2 中,没有和原来的 drawLine() 函数呆在一起,造成割裂,对接口的可读性造成了影响。

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
// v1 ---------------------------------------------------------
class Graphics
{
public:
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(const Point& p0, const Point& p1);
void drawRect(int x0, int y0, int x1, int y1) =0;
void drawRect(const Point& p0, const Point& p1);
};
// -----------------------------------------------------------
// v2 ---------------------------------------------------------
class Graphics
{
public:
void drawLine(int x0, int y0, int x1, int y1);
void drawLine(float x0, float y0, float x1, float y1);
void drawLine(const Point& p0, const Point& p1);
void drawRect(int x0, int y0, int x1, int y1) =0;
void drawRect(float x0, float y0, float x1, float y1) =0;
void drawRect(const Point& p0, const Point& p1);
};
// -----------------------------------------------------------

接下来我们分析一下类型安全的问题。

首先,对于客户端(使用者)来说,由于接口的定义者一般会封装好对应的对外 API,所以客户端对于这个接口的使用应该是非常方便的,而且绝对不会有什么类型安全的问题。

1
2
3
4
// 例子
Graphics* graphics = getGraphics();
graphics->drawLine(0, 0, 100, 100);
graphics->drawRect(100, 100, 200, 200);

只有接口的定义者和实现者才需要关心函数 ID 和函数签名,只有在转型函数指针的这个时候,才会产生一定的不安全性。

但是对于定义者来说,因为接口本身是由他定义的,所以封装起来是很方便的,而且不容易产生什么错误。而对于实现者来说,有着明确的定义文档,通过细心 + 测试,发生错误的几率也是非常非常低的。而且有时候接口的定义者本身就是实现者,那么这种错误的可能性就几乎等于零了。再说了,编程从来不是一锤子的买卖,也不存在完全不会出问题的软件,只要出问题的几率得到了控制,那么就应该是可以接受的,不是么?

而且函数指针本身带来的安全性问题就不是很大,要知道,OpenGL 的扩展里面,有着无数的函数扩展都是通过这个方法定义的,只要有着定义明确地文档,那么这个问题就不会很大。

而且,函数 ID 和其对应的函数签名有着明确地定义规则,只允许增加和废弃,不允许修改,这就代表只要是经过测试后的代码,那么就是可信的,这种设计对维护带来的好处也同样是不言而喻的。

而且,使用函数 ID 和函数指针带来的另一个好处在于对语言的无关性,函数指针本质上类同与 C 接口,而 C 接口发展到现在,已经成为了事实标准上的接口语言,基本上现有的任何语言都是可以使用 C 接口的,所以使用函数 ID 和函数指针对未来其他语言的支持是非常有帮助的。

Explicit is better than implicit, Flat is better than nested.

The Zen of Python