C/Cpp 面向对象基础
面向对象编程(OOP)并不是一种特定的语言或者工具,它只是一种设计方法、设计思想。 OOP表现出来的三个最基本的特性就是封装、继承与多态。
在本文中我们将讨论C语言如何简单地实现OOP的基础功能,并且关注C++是如何实现OOP的,对于C++的讨论不涉及过多的语法细节,不涉及访问权限的讨论(全部使用public
)。
封装
封装就是在形式上将数据和操作数据的方法打包在一起,然后提供部分接口给外部访问,隐藏内部的实现细节。 但是C语言没有直接提供将内部信息隐藏的机制,我们只能使用其它的间接方案:
- 君子协定,约定只通过固定的接口进行访问;
- 利用动态库的符号部分导出的特点将动态库的部分信息隐藏;
- 利用
static
变量和函数对源文件外部不可见的性质,将部分信息隐藏
下面我们只关注数据和操作方法的打包,因为C语言的结构体只含有数据成员,我们需要通过函数指针在结构体中加上操作数据的函数,例如
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
typedef struct Point {
double x;
double y;
void (*show)(const struct Point *const);
void (*add)(struct Point *const, const struct Point *const);
} Point;
void show_point(const Point *const self) {
printf("(x, y) = (%f, %f)\n", self->x, self->y);
}
void add_point(Point *const self, const Point *const obj) {
self->x += obj->x;
self->y += obj->y;
}
Point make_point(double x, double y) {
Point obj;
obj.x = x;
obj.y = y;
obj.show = show_point;
obj.add = add_point;
return obj;
}
int main() {
Point p1 = make_point(1.0, 2.0);
p1.show(&p1);
Point p2 = make_point(3.0, 4.0);
p1.add(&p1, &p2);
p1.show(&p1);
return 0;
}
运行结果为 1
2(x, y) = (1.000000, 2.000000)
(x, y) = (4.000000, 6.000000)
这里我们定义了Point
对象,提供了一个它的构造函数make_point
,在其中进行对象的初始化,并且自动用成员函数指针指向对应的函数,
然后就可以通过p.add(...)
和p.show(...)
来调用对象的方法函数,就像访问对象的数据一样。
值得注意的是,我们必须显式地将当前对象自身的地址&p
传递过去,才能保证在调用时不会产生对象的复制,修改始终是针对当前对象自身的。
我们也可以不在结构内中定义成员函数指针,放弃.
风格的方法调用形式,直接使用普通的函数调用形式,例子改写如下
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
typedef struct Point {
double x;
double y;
} Point;
void show_point(const Point *const self) { printf("(x, y) = (%f, %f)\n", self->x, self->y); }
void add_point(Point *const self, const Point *const obj) {
self->x += obj->x;
self->y += obj->y;
}
Point make_point(double x, double y) {
Point obj;
obj.x = x;
obj.y = y;
return obj;
}
int main() {
Point p1 = make_point(1.0, 2.0);
show_point(&p1);
Point p2 = make_point(3.0, 4.0);
add_point(&p1, &p2);
show_point(&p1);
return 0;
}
将两个版本的C语言实现对比,我们可以发现它们其实各有优缺点:
- 第一个版本,每一个对象在内存中都存储了函数指针(实际上是调用函数表),这可能导致额外的空间占用:如果实例化 \(m\) 个对象,每个对象有 \(n\) 个成员函数,那么就要占用 \(8mn\) 的内存。但是空间上的代价带来的好处是:我们可以直接通过修改对象的函数指针达到动态多态的目的,这甚至比C++的虚函数和虚表更灵活,C++的虚表是针对每一个类型的,但是这里的调用函数表是针对每一个对象的。
- 第二个版本,放弃在每一个对象中存储函数指针,这节约了空间占用,但是从语法的角度来说,并没有实现数据和方法的严格绑定,对数据和方法的访问形式是不一样的,对方法的访问就和普通函数调用一样,只是在第一个参数传递了指向对象自身的指针。
在实践中两种方案都有被采用,也可能混合使用。
那么C++到底是怎么做的呢?C++在设计上遵顼Zero overhead 原则,这意味着在提供某种特性、功能或抽象的同时,不对程序或系统引入额外的性能开销或资源消耗。 显然第一个版本不符合这个原则,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
23
24
25
26
27
28
29
30
class Point {
public:
double x;
double y;
Point(double x, double y) : x(x), y(y) {}
void show() const {
std::cout << "(x, y) = (" << x << ", " << y << ")" << std::endl;
}
void add(const Point &other) {
x += other.x;
y += other.y;
}
};
int main() {
Point p1(1.0, 2.0);
p1.show();
Point p2(3.0, 4.0);
p1.add(p2);
p1.show();
return 0;
}
从使用者的角度来看,这更像C语言实现的第一个版本 1
2
3
4
5
6
7
8
9
10
11
12// c version 1
int main() {
Point p1 = make_point(1.0, 2.0);
p1.show(&p1);
Point p2 = make_point(3.0, 4.0);
p1.add(&p1, &p2);
p1.show(&p1);
return 0;
}
只是C++在语法上提供了直接的构造函数,并不需要我们提供专门的make_point
函数,
并且C++的普通成员函数调用会自动将this
指针传递给方法,并不需要显式传递&p
,并且通过引用传递进一步隐藏了指针参数的使用。
但是在编译器实现的角度,这更像C语言实现的第二个版本 1
2
3
4
5
6
7
8
9
10
11
12// c version 2
int main() {
Point p1 = make_point(1.0, 2.0);
show_point(&p1);
Point p2 = make_point(3.0, 4.0);
add_point(&p1, &p2);
show_point(&p1);
return 0;
}
在编译器看来,类的成员函数和普通的外部函数实质没什么区别,除了成员函数总是会自动将指向当前对象自身的this
指针作为真正意义上的第一个参数传递给函数。这里this
既不需要出现在参数列表中,也不需要出现在调用语句中。
继承
为了简化问题的讨论,我们只考虑单继承关系。
C语言实现的继承关系其实很简单,将基类作为派生类的一个成员,在后面加上派生类的新成员即可。这里的成员顺序是重要的,我们希望保证在内存的角度上,派生类对象只是在基类对象之后加上了额外的数据。对于多继承和虚继承这里的数据顺序就可能复杂多了,但是我们不做讨论。
在第一个版本的基础上继续 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
typedef struct Point {
double x;
double y;
void (*show)(const struct Point *const);
void (*add)(struct Point *const, const struct Point *const);
} Point;
void show_point(const Point *const self) {
printf("(x, y) = (%f, %f)\n", self->x, self->y);
}
void add_point(Point *const self, const Point *const obj) {
self->x += obj->x;
self->y += obj->y;
}
Point make_point(double x, double y) {
Point obj;
obj.x = x;
obj.y = y;
obj.show = show_point;
obj.add = add_point;
return obj;
}
//----------------------------------------------------------------------------//
typedef struct WeightedPoint {
Point p;
double w;
void (*show)(const struct WeightedPoint *const);
void (*add)(struct WeightedPoint *const, const struct WeightedPoint *const);
} WeightedPoint;
void show_weighted_point(const WeightedPoint *const self) {
printf("(x, y, w) = (%f, %f, %f)\n", self->p.x, self->p.y, self->w);
}
void add_weighted_point(WeightedPoint *const self, const WeightedPoint *const obj) {
self->p.add(&(self->p), &(obj->p));
// or
// add_point(&(self->p), &(obj->p));
self->w += obj->w;
}
WeightedPoint make_weighted_point(double x, double y, double w) {
WeightedPoint obj;
obj.p = make_point(x, y);
obj.w = w;
obj.show = show_weighted_point;
obj.add = add_weighted_point;
return obj;
}
//----------------------------------------------------------------------------//
int main() {
Point p1 = make_point(1.0, 2.0);
p1.show(&p1);
Point p2 = make_point(3.0, 4.0);
p1.add(&p1, &p2);
p1.show(&p1);
WeightedPoint wp1 = make_weighted_point(1.0, 2.0, 3.0);
wp1.show(&wp1);
WeightedPoint wp2 = make_weighted_point(3.0, 4.0, 5.0);
wp1.add(&wp1, &wp2);
wp1.show(&wp1);
return 0;
}
运行结果如下 1
2
3
4(x, y) = (1.000000, 2.000000)
(x, y) = (4.000000, 6.000000)
(x, y, w) = (1.000000, 2.000000, 3.000000)
(x, y, w) = (4.000000, 6.000000, 8.000000)
解释一下这里的新内容:
- 我们定义了
WeightedPoint
对象,它包含一个Point
对象,这相当于继承关系:WeightedPoint
继承了Point
。 - 我们提供了派生类的构造函数
make_weighted_point
,在其中调用了基类的构造函数make_point
,并且加上了对额外的权重数据的初始化。 - 我们还重写了
p.add(...)
和p.show(...)
方法,对于show()
方法直接完全重写,对于add()
方法则调用了基类的同名方法,在此基础上添加了权重的相加。
第二个版本的实现也是类似的,移除所有的成员函数指针,直接通过普通函数调用
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
typedef struct Point {
double x;
double y;
} Point;
void show_point(const Point *const self) {
printf("(x, y) = (%f, %f)\n", self->x, self->y);
}
void add_point(Point *const self, const Point *const obj) {
self->x += obj->x;
self->y += obj->y;
}
Point make_point(double x, double y) {
Point obj;
obj.x = x;
obj.y = y;
return obj;
}
//----------------------------------------------------------------------------//
typedef struct WeightedPoint {
Point p;
double w;
} WeightedPoint;
void show_weighted_point(const WeightedPoint *const self) {
printf("(x, y, w) = (%f, %f, %f)\n", self->p.x, self->p.y, self->w);
}
void add_weighted_point(WeightedPoint *const self, const WeightedPoint *const obj) {
add_point(&(self->p), &(obj->p));
self->w += obj->w;
}
WeightedPoint make_weighted_point(double x, double y, double w) {
WeightedPoint obj;
obj.p = make_point(x, y);
obj.w = w;
return obj;
}
//----------------------------------------------------------------------------//
int main() {
Point p1 = make_point(1.0, 2.0);
show_point(&p1);
Point p2 = make_point(3.0, 4.0);
add_point(&p1, &p2);
show_point(&p1);
WeightedPoint wp1 = make_weighted_point(1.0, 2.0, 3.0);
show_weighted_point(&wp1);
WeightedPoint wp2 = make_weighted_point(3.0, 4.0, 5.0);
add_weighted_point(&wp1, &wp2);
show_weighted_point(&wp1);
return 0;
}
使用C++重写上面的例子 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
class Point {
public:
double x;
double y;
Point(double x, double y) : x(x), y(y) {}
void show() const {
std::cout << "(x, y) = (" << x << ", " << y << ")" << std::endl;
}
void add(const Point &other) {
x += other.x;
y += other.y;
}
};
class WeightedPoint : public Point {
public:
double weight;
WeightedPoint(double x, double y, double weight)
: Point(x, y), weight(weight) {}
void show() const {
std::cout << "(x, y, w) = (" << x << ", " << y << ", " << weight << ")"
<< std::endl;
}
void add(const WeightedPoint &other) {
Point::add(other);
weight += other.weight;
}
};
int main() {
Point p1(1.0, 2.0);
p1.show();
Point p2(3.0, 4.0);
p1.add(p2);
p1.show();
WeightedPoint wp1(1.0, 2.0, 3.0);
wp1.show();
WeightedPoint wp2(3.0, 4.0, 5.0);
wp1.add(wp2);
wp1.show();
return 0;
}
这与C语言实现的第二个版本在原理上仍然是一致的。
多态
我们主要关注动态多态的C语言模拟和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
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
class Point {
public:
double x;
double y;
Point(double x, double y) : x(x), y(y) {}
virtual void show() const {
std::cout << "show from point\n";
std::cout << "(x, y) = (" << x << ", " << y << ")\n";
}
void hello() const { std::cout << "hello from point\n"; }
};
class WeightedPoint : public Point {
public:
double w;
WeightedPoint(double x, double y, double w) : Point(x, y), w(w) {}
void show() const override {
std::cout << "show from weighted point\n";
std::cout << "(x, y, w) = (" << x << ", " << y << ", " << w << ")\n";
}
void hello() const { std::cout << "hello from weighted point\n"; }
};
void test(const Point *base) {
std::cout << "test:\n";
base->hello();
base->show();
}
int main() {
Point *base = new Point(1.0, 2.0);
test(base);
delete base;
WeightedPoint *derived = new WeightedPoint(1.0, 2.0, 3.0);
test(derived);
delete derived;
return 0;
}
运行结果如下 1
2
3
4
5
6
7
8test:
hello from point
show from point
(x, y) = (1, 2)
test:
hello from point
show from weighted point
(x, y, w) = (1, 2, 3)
注意这里的hello()
和show()
得到的结果是不一样的,前者是静态绑定,后者是动态绑定(因为show()
是虚函数),这里不展开讨论。
下面分别从两个角度对这个例子使用C语言进行模拟。
基于对象的函数调用表
基于第一个方案的实现如下 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
typedef struct Point {
double x;
double y;
void (*hello)(const struct Point *const);
void (*show)(const struct Point *const); // virtual
} Point;
void show_point(const Point *const self) {
printf("show from point\n");
printf("(x, y) = (%f, %f)\n", self->x, self->y);
}
void hello_point(const Point *const self) { printf("hello from point\n"); }
void init_point(Point *const self, double x, double y) {
self->x = x;
self->y = y;
self->hello = hello_point;
self->show = show_point;
}
//----------------------------------------------------------------------------//
typedef struct WeightedPoint {
Point p;
double w;
void (*hello)(const struct WeightedPoint *const);
void (*show)(const struct WeightedPoint *const);
} WeightedPoint;
void show_weighted_point(const WeightedPoint *const self) {
printf("show from weighted point\n");
printf("(x, y, w) = (%f, %f, %f)\n", self->p.x, self->p.y, self->w);
}
void hello_weighted_point(const WeightedPoint *const self) {
printf("hello from weighted point\n");
}
void virtualoverwrite_show(const Point *const self) {
((WeightedPoint *)self)->show((WeightedPoint *const)self);
}
void init_weighted_point(WeightedPoint *const self, double x, double y, double w) {
init_point(&(self->p), x, y);
self->w = w;
self->hello = hello_weighted_point;
self->show = show_weighted_point;
self->p.show = virtualoverwrite_show;
}
//----------------------------------------------------------------------------//
void test(Point *base) {
printf("test:\n");
base->hello(base);
base->show(base);
}
int main() {
Point *base = (Point *)malloc(sizeof(Point));
init_point(base, 1.0, 2.0);
test((Point *)base);
free(base);
WeightedPoint *derived = (WeightedPoint *)malloc(sizeof(WeightedPoint));
init_weighted_point(derived, 1.0, 2.0, 3.0);
test((Point *)derived);
free(derived);
return 0;
}
运行结果与C++的代码一致。
解释一下,这里我们对hello()
和show()
进行了不同的处理:
- 对于
hello()
方法,base->hello
的调用效果完全取决于当前的base
是Point *
指针还是Derived *
指针。 - 对于
show()
方法,我们不仅将WeightedPoint
版本的实现绑定到派生类的*show
指针,还修改了派生类包含的基类对象中的*show
指针,将其指向一个类型转换接口:virtualoverwrite_show
,在其中将Point *
指针完全转换为WeightedPoint *
指针再进行处理,达到基类指针调用派生类方法的目的。
事实上在这种方案下,我们完全不需要继承关系就可以实现运行时多态的效果,例如
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
typedef struct Point {
double x;
double y;
double w;
void (*hello)(const struct Point *const);
void (*show)(const struct Point *const);
} Point;
void show_point(const Point *const self) {
printf("show from point\n");
printf("(x, y) = (%f, %f)\n", self->x, self->y);
}
void hello_point(const Point *const self) { printf("hello from point\n"); }
void init_point(Point *const self, double x, double y) {
self->x = x;
self->y = y;
self->w = 0;
self->hello = hello_point;
self->show = show_point;
}
void show_weighted_point(const Point *const self) {
printf("show from weighted point\n");
printf("(x, y, w) = (%f, %f, %f)\n", self->x, self->y, self->w);
}
void hello_weighted_point(const Point *const self) {
printf("hello from weighted point\n");
}
void init_weighted_point(Point *const self, double x, double y, double w) {
self->x = x;
self->y = y;
self->w = w;
self->hello = hello_point;
self->show = show_weighted_point;
}
//----------------------------------------------------------------------------//
void test(Point *base) {
printf("test:\n");
base->hello(base);
base->show(base);
}
int main() {
Point *tmp1 = (Point *)malloc(sizeof(Point));
init_point(tmp1, 1.0, 2.0);
test(tmp1);
free(tmp1);
Point *tmp2 = (Point *)malloc(sizeof(Point));
init_weighted_point(tmp2, 1.0, 2.0, 3.0);
test(tmp2);
free(tmp2);
return 0;
}
这里对hello()
和show()
的处理其实是一致的,在初始化函数中自由地调整函数指针即可
1
2
3
4
5
6
7
8self->hello = hello_point;
self->show = show_weighted_point;
// or
self->hello = hello_weighted_point;
self->show = show_weighted_point;
// ...
基于类型的虚函数表
基于第二个方案,虽然我们并没有将普通函数的调用通过结构体内部的函数指针实现,但是在涉及到虚函数动态调用时,
还是不得不为每一个对象加上一个虚表指针vtable
,以指向当前类型正确的调用函数,还要加上额外的类型标识符TypeInfo
,如果没有类型标识,show_dynamic
就不知道应当强制转换为哪一个类型以适配函数调用。
1 |
|
解释一下:
- 首先,我们引入了新的结构体
PointMeta
和WeightedPointMeta
,它们只含有数据成员,并且在继承关系中,后者包含前者作为普通成员。 - 在这两个结构体之外,加上类型识别和虚表指针才构成了完整的
Point
和WeightedPoint
类。在C++编译器的具体实现中,类型识别信息也被塞进了类型的虚表之中,即在数据成员之外只多出一个虚表指针,这里为了简化将它们拆开了。 hello()
方法的调用必然是静态的:我们只提供了hello_point()
和hello_weighted_point
两个明确的接口show()
方法支持静态调用:我们提供了show_point
和show_weighted_point
两个明确的接口,也支持动态调用,我们提供了show_dynamic
这个接口,下面重点分析它的实现。
show_dynamic
这个接口虽然接收的是基类指针,但是它会根据类型识别信息,获取虚函数表中的函数指针并进行相应的类型转换,然后再调用。
具体调用的既可能是show_point
,也可能是show_weighted_point
。
1
2
3
4
5
6
7
8
9void show_dynamic(const Point *const self) {
if (self->type == TYPE_POINT) {
((void (*)(const Point *const))self->vtable[0])((const Point *const)self);
}
else { // self->type == TYPE_WEIGHTED_POINT
((void (*)(const WeightedPoint *const))self->vtable[0])(
(const WeightedPoint *const)self);
}
}
运行结果与C++的代码一致。
注:为了尽可能和C++的实现保持一致,上面所有的C语言代码中的指针类型在允许的情况下都加上了很繁琐的const
修饰,有时加了两层const
,看着非常繁琐。