现代C++之高级面向对象

前言

在学习一门新的编程语言时,我们总是会提到面向对象的三大要素:

  • 继承
  • 多态
  • 封装

这自然也是C++作为一门自由范式的面向对象语言,所支持的基本特性。但相较其他的编程语言,C++由于高度自由的编程范式,有了更多的复杂度和独具一格的编程用法。在食用本课程前,有如下注意事项:

  1. 还是回到我们《现代C++》系列课程所建议,请抛弃所有传统C的编程思维,以全新的编程语言认识C++。
  2. 虽说抛弃C,我们可以站在C++的视角去看C,以理解后续的部分逆向代码。
  3. 本文不会赘述,大家常见面向对象的基本概念,主要从应用场景展开。

面向对象的机制

我们常说,什么是C++大佬,通常是人肉编译器,一眼能看到汇编。这里准备通过逆向的角度,给大家讲述C++面向对象的本质。例如我们有如下测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <string>

class Parent {
public:
Parent()
{
std::cout << "Parent construct." << std::endl;
}

virtual void DoSomething()
{
std::cout << "Parent DoSomething" << std::endl;
}

virtual ~Parent()
{
std::cout << "Parent destruct." << std::endl;
}
};

同时调用方代码:

1
2
3
4
5
6
7
int main()
{
Parent parent;
parent.DoSomething();
return 0;
}
main();
Parent construct.
Parent DoSomething
Parent destruct.

而在逆向视角来看,上述代码实际上生成了Parent构造、析构以及DoSomething这三个函数。(Clang14编译器+禁用优化)

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
// 对应构造函数
int __ZN6ParentC2Ev(int arg0) {
*arg0 = *qword_100004018 + 0x10;
std::__1::basic_ostream<char, std::__1::char_traits<char> >::operator<<(std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::operator<< <std::__1::char_traits<char> >(*0x100004000, "Parent construct."), *std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::endl<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&));
r0 = arg0;
return r0;
}

// 对应DoSomething方法
int __ZN6Parent11DoSomethingEv(int arg0) {
r0 = *0x100004000;
r0 = std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::operator<< <std::__1::char_traits<char> >(r0, "Parent DoSomething");
r0 = std::__1::basic_ostream<char, std::__1::char_traits<char> >::operator<<(r0, *std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::endl<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&));
return r0;
}

// 对应析构函数
int __ZN6ParentD2Ev(int arg0) {
r31 = r31 - 0x30;
saved_fp = r29;
stack[-8] = r30;
*arg0 = *qword_100004018 + 0x10;
var_10 = arg0;
var_18 = std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::operator<< <std::__1::char_traits<char> >(*0x100004000, "Parent destruct.");
std::__1::basic_ostream<char, std::__1::char_traits<char> >::operator<<(var_18, *std::__1::basic_ostream<char, std::__1::char_traits<char> >& std::__1::endl<char, std::__1::char_traits<char> >(std::__1::basic_ostream<char, std::__1::char_traits<char> >&));
r0 = var_10;
return r0;
}

当然,从汇编逆向而来生成的伪代码,具有较差的可读性。但也足够我们理解面向对象的机制。从上述伪代码,我们可以得出如下的猜测或者说结论:

  1. arg0是成员变量结构体指针
  2. 类的构造、构造、成员方法,本质是构造、销毁及关联这个指针的静态方法。

那么这个猜测,我们可以从调用方的逆向代码里得到印证:

1
2
3
4
5
6
7
8
9
10
int _main() {
r31 = r31 - 0x40;
saved_fp = r29;
stack[-8] = r30;
r29 = &saved_fp;
Parent::Parent(r29 - 0x10);
Parent::DoSomething(r29 - 0x10);
Parent::~Parent(r29 - 0x10);
return 0x0;
}

如上是调用方的逆向代码,我们可以合理推定,r29 - 0x10就是我们提到的成员变量结构体指针。

继承的机制

类似的,我们也通过逆向角度,适当了解下C++的继承干了什么事情。假定如下的Child子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Child : public Parent {
public:
Child()
{
std::cout << "Child construct." << std::endl;
}

void DoSomething() override
{
std::cout << "Child DoSomething" << std::endl;
}

void DoSomethingElse()
{
std::cout << "Child DoSomethingElse" << std::endl;
}

virtual ~Child()
{
std::cout << "Child construct." << std::endl;
}
};
1
2
3
4
5
6
7
8
// 调用代码
int main2()
{
Child parent;
parent.DoSomething();
return 0;
}
main2();
Parent construct.
Child construct.
Child DoSomething
Child construct.
Parent destruct.

结果自然也非常显而易见,我们直接快进到Child子类的构造、析构的逆向代码,你会发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int __ZN5ChildC2Ev(int arg0) {
var_28 = arg0;
Parent::Parent(arg0);
*var_28 = var_20;
Parent::Parent(arg0);
// ...下面类似逻辑省略,上面多了对父类构造函数的调用
r0 = var_28;
return r0;
}


int __ZN5ChildD2Ev(int arg0) {
var_20 = arg0;
// ...上面类似逻辑省略,这里多了对父类析构函数的调用
Parent::~Parent(var_20);
r0 = var_20;
return r0;
}

因此,我们可以得出如下推论:

  1. 继承关系的类,实质上只是在父类方法组的基础上,新增了一套管理子类的方法组。构造和析构的调用顺序,刚好与继承相同(构造)和相反(析构)。
  2. 父类和继承类共用一个成员变量结构体指针,父类、继承类各取所需。

当然,C++还有很多其他更为复杂的继承关系,如多继承甚至棱形继承问题,本文不会赘述,大家结合上述从逆向视角得出的推论,大致可以了解到C++继承的本质。

虚函数与虚表

虚函数与虚表的概念,相信大家在卷C++面试的时候,已经滚瓜烂熟。这次我们适当从编译器和逆向视角,看看虚函数、虚表干了什么事情,来协助我们理解后续继承与多态的问题。

首先我们对比一个非虚类和虚类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Nonvirtaul {
public:
Nonvirtaul()
{
}

void DoSomething()
{
}

~Nonvirtaul()
{
}

std::uint64_t a;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Virtaul {
public:
Virtaul()
{
}

virtual void DoSomething()
{
}

virtual ~Virtaul()
{
}

std::uint64_t a;
};
1
2
3
4
5
6
7
int main()
{
std::cout << sizeof(Nonvirtaul) << std::endl;
std::cout << sizeof(Virtaul) << std::endl;
return 0;
}
main();
8
16

这里也自然引出了大家所熟知的虚函数与虚表的概念。拥有虚函数的类(包含析构函数),通常在实例的指针开头(这个取决于编译器),有8个字节的地址,指向本实例的虚表。值的一提的是,通常我们习惯把析构函数定义为虚函数,以避免在继承情况下的内存泄漏问题。因此,我们在涉及面向对象编程的场景,大部分都会使用到虚表。

虽然标准组织并没有约束详细的虚表的内存布局,但通常包含析构函数地址和对应父类方法为virtual的内存地址。纯虚函数的在对应类的虚表中,是没有地址的,这也是对应类无法被实例化的原因。下面举例上述Parent的继承类Child的虚表逆向后的真实案例(Clang14)。

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

__ZTV5Child: // vtable for Child
0000000100004038 db 0x00 ; '.' ; DATA XREF=__ZN5ChildC2Ev+16, __ZN5ChildD2Ev+16, qword_100004018
0000000100004039 db 0x00 ; '.' // 空8字节开通
000000010000403a db 0x00 ; '.'
000000010000403b db 0x00 ; '.'
000000010000403c db 0x00 ; '.'
000000010000403d db 0x00 ; '.'
000000010000403e db 0x00 ; '.'
000000010000403f db 0x00 ; '.'
0000000100004040 db 0x70 ; 'p' // 指向本虚表类的typeinfo
0000000100004041 db 0x40 ; '@'
0000000100004042 db 0x00 ; '.'
0000000100004043 db 0x00 ; '.'
0000000100004044 db 0x01 ; '.'
0000000100004045 db 0x00 ; '.'
0000000100004046 db 0x00 ; '.'
0000000100004047 db 0x00 ; '.'
0000000100004048 db 0x0c ; '.' // 指向Child::DoSomething()
0000000100004049 db 0x2c ; ','
000000010000404a db 0x00 ; '.'
000000010000404b db 0x00 ; '.'
000000010000404c db 0x01 ; '.'
000000010000404d db 0x00 ; '.'
000000010000404e db 0x00 ; '.'
000000010000404f db 0x00 ; '.'
0000000100004050 db 0x84 ; '.' // 指向析构函数方法
0000000100004051 db 0x2c ; ','
0000000100004052 db 0x00 ; '.'
0000000100004053 db 0x00 ; '.'
0000000100004054 db 0x01 ; '.'
0000000100004055 db 0x00 ; '.'
0000000100004056 db 0x00 ; '.'
0000000100004057 db 0x00 ; '.'
0000000100004058 db 0x08 ; '.' // 指向delete方法
0000000100004059 db 0x2f ; '/'
000000010000405a db 0x00 ; '.'
000000010000405b db 0x00 ; '.'
000000010000405c db 0x01 ; '.'
000000010000405d db 0x00 ; '.'
000000010000405e db 0x00 ; '.'
000000010000405f db 0x00 ; '.'

在Parent和Child实例被创建的时候,对应实例的虚表就通过指针的形式关联起来了,这解释了为什么,我们通过基类指针承载的继承类也能正确访问到继承类的方法并确保正常析构。

当然,这里简单阐述了虚表的实现原理,涉及多继承也有类似的结论,本处不再展开。

封装问题

C++的引用include机制,本质上是文本的拷贝,这也是C++想做出高度抽象的封装难度最大的地方。由于文本拷贝,这意味着头文件会出现指数级的传染,在没有做封装方案的代码,越是最上层,依赖的头文件越多,编译耗时越大。同时,对上层组件而言,也不希望下层组件小小的头文件改动,导致整个依赖链大范围受到影响。

这也是为什么封装问题,在C++这门语言最为迫切的地方。

C++作为一门高自由范式的编程语言,也自然支持多种的封装方案,本质上来讲,都是通过接口与实现分离,这里给出几种典型的封装方案,并非全集供大家参考。下面将以如下类的案例阐述几种典型的封装方案。

1
2
3
4
5
6
7
8
9
10
11
12
#include "SomeBigPrivateClass.h"

class Foo {
public:
Foo();
virtual ~Foo();

void DoHeavyLogic();

private:
SomeBigPrivateClass m_bigClass;
};

PImpl前置声明方案

个人认为,PImpl方案,在开源社区里,通常是用的最多的一种方案。对外的接口类非常适合用本方案封装,因为不涉及太多高阶C++特性,足够简单。主要思路是,接口类只引用Impl实现类的指针,并定义同名方法进行转发。

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
// Foo.h
class FooImpl; // 实现类的前置声明

class Foo {
public:
Foo();
virtual ~Foo();

virtual void DoHeavyLogic();

private:
FooImpl *m_impl;
};

// FooImpl.h
#include "SomeBigPrivateClass.h"

class FooImpl {
public:
FooImpl();
virtual ~FooImpl();

void DoHeavyLogic();

private:
SomeBigPrivateClass m_bigClass;
};

// Foo.cpp
#include "FooImpl.h"
Foo::Foo() : m_impl(new FooImpl) {}

void Foo::DoHeavyLogic() {
m_impl->DoHeavyLogic();
}

Foo::~Foo() {
delete m_impl;
}

PImpl封装方案,本质上是将所有内部实现下沉到Impl类中,对外的接口类通过前置声明,并对Impl实现类的方法进行转发。这个方案:

  • 优势:实现简单,给调用方适配提供类较大自由度,同时屏蔽业务细节
  • 劣势:封装方案,需要2份.h和.cpp,代码量比较多; 丢失了函数修饰符const。

这个封装方案,比较适合作为对外接口层面的封装。

纯虚类封装(依赖反转)

我们上面提到虚表和虚函数,刚好也解决了这个问题。对外暴露基类的指针,内部构造时通过实现类构造,实现依赖反转(也有称依赖倒置原则)。实现案例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Foo.h
class Foo {
public:
virtual ~Foo() = default;
virtual void DoHeavyLogic() = 0;
};

// FooImpl.h
#include "Foo.h"
#include "SomeBigPrivateClass.h"

class FooImpl : public Foo {
public:
FooImpl();
~FooImpl() override;
void DoHeavyLogic() override;
private:
SomeBigPrivateClass m_bigClass;
};

由于Foo和FooImpl之间有明确的继承关系,因此相应的指针可以支持隐式转换。构造时使用实现类指针,对外只提供基类的指针。这个方案:

  • 优势:可以不丢失const等修饰符,有接口继承关系。virtual方法容易mock,可测试性强。
  • 劣势:成员函数是virtual的,一定程度上约束了调用方的模板使用。

这个封装方案,相对更适合,模块内部,有明显的层与层之间之间解藕诉求的场景,同时也提供了最强的单元测试适配性。

鸭子类型(编译期多态)

C++由于模板技术,支持鸭子类型作为接口封装。什么叫鸭子类型?通常我们讲,包括C++以外的编程语言,一个类是否是某个基类的子类,是通过判断这个类是否是指定基类的派生类来判断的。

例如,狗狗(Dog)、猫猫(Cat)都继承于动物(Animal),狗狗和猫猫自然都拥有动物所约束的父类方法。相关处理和使用动物作为接口类的业务代码,才能正确处理相应逻辑。

而鸭子类型,有别于上述严格的继承关系,我们如何判断一个类是否是一种鸭子?很简单,这个玩意如果长得像鸭子(嘴巴、羽毛、爪子、翅膀),像鸭子一样动作(嘎嘎叫、会游泳),我们就可以认为它就是一个鸭子。而不是必须得约束,对象必须继承于鸭子。例子如下:

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 YellowDuck {
public:
YellowDuck();
virtual ~YellowDuck();

void Shout();
void Swim();
};

class BlackDuck {
public:
BlackDuck();
virtual ~BlackDuck();

void Shout();
void Swim();
};

template <typename Duck>
class DuckAdapter {
public:
DuckAdapter(Duck duck) : m_duck(duck) {}
virtual ~DuckAdapter() = default;

void Shout() {
m_duck.Shout();
}
void Swim() {
m_duck.Swim();
}
private:
Duck m_duck;
};

通过上面的例子我们可以看到,无论是黄鸭子、黑鸭子,对于DuckAdapter而言,他认为,只要是实现了Shout和Swim方法的类,它都会认为是鸭子。这种方案:

  • 优势:调用方和被调用方可以压根连头文件都不用依赖,在装载阶段满足编译条件即可。
  • 劣势:发生在编译期,适用面相对少一些;模板问题定位难度偏大;

这个封装方案,比较适合纯编译期的特殊逻辑封装,在涉及复杂模板编程的场景较为常用。STL也广泛使用了本方案进行封装。

小结

本次课程,我们从逆向的角度,重新认识了C++面向对象。简单阐述了继承和多态,在汇编角度做的事情。在继承和多态的基础上,介绍了PImpl、虚函数封装以及鸭子类型封装,以及,这三种封装方式各自的优缺点,大家可以结合业务场景按需使用。