在我的早期印象中,C++这门语言是软件工程发展过程中,出于对面向对象语言级支持不可或缺的情况下,一群曾经信誓旦旦想要用C统治宇宙的极客们妥协出来的一个高性能怪咖。
它驳杂万分,但引人入胜,出于多(mian)种(shi)原因,我把它拿出来进行一次重新的学习。
这篇笔记从G++编译出的汇编代码出发,对部分C++的常用面向对象特性进行原理性解释和总结,其中包括 引用、类(成员函数,构造函数)、多态(编译时,运行时)、模板与泛型
Here we go!
引用
这是一个老生常谈的话题了,C++ primer中文译本上说引用是对象的一个别名,别名是什么鬼?
上码:
int invoke(int a) {
return ++a;
}
int main(int argc, char **argv) {
int a = 123; // movl $123,-20(%rbp)
int *pa = &a; // leaq -20(%rbp),%rax
// movq %rax,-16(%rbp)
int &ra = a; // leaq -20(%rbp),%rax
// movq %rax,-8(%rbp)
invoke(a); // movl -20(%rbp),%eax
// movl %eax,%edi
// call _Z6invokei
invoke(*pa); // movq -16(%rbp),%rax
// movl (%rax),%eax
// movl %eax,%edi
// call _Z6invokei
invoke(ra); // movq -8(%rbp),%rax
// movl (%rax),%eax
// movl %eax,%edi
// call _Z6invokei
}
简单明了,pa是一个指向a的指针,ra是一个a的引用,可以看到编译器对pa和ra的的定义以及参数传递做的工作几乎是一模一样,它们都在栈里有自己的空间且都存了一个a的地址,因此可以十分肯定的说引用是用指针实现的。
引用是对指针的一个语言级别的封装,其出现的意义大概是为了提升程序的可读性,通常都是用来进行参数传递。
关于引用的好处和使用技巧,有待进一步学习。//TODO
类(成员函数,构造函数)
贴代码之前,有必要回顾一下标号这个概念,在汇编语言里,每条指令的前面都可以拥有一个标号,以代表和指示该指令地址的汇编地址,因为毕竟由我们自己来计算和跟踪每条指令所在的汇编地址是极其困难的。
在汇编翻译成机器码的过程中,这些标号会被转换成标号所在行的具体偏移地址,多数情况下用来标记指令块入口地址,就是进行所谓函数的跳转。忘记的同学可以先行度娘。
接下来的代码,会在每个函数后的注释中标出该函数编译后的标号名。
int invoke(int a) { // _Z6invokei
return ++a;
}
class Animal {
public:
int age;
int weight;
Animal(): age(0), weight(0.0) {} // _ZN6AnimalC2Ev
void run() { } // _ZN6Animal3runEv
};
class Human {
public:
Human() {} // _ZN5HumanC2Ev
};
int main(int argc, char **argv) {
Animal cat; // leaq -16(%rbp), %rax
// movq %rax, %rdi
// call _ZN6AnimalC1Ev
cat.age = 5; // movl $5, -16(%rbp)
cat.weight = 2; // movl $2, -12(%rbp)
cat.run(); // leaq -16(%rbp), %rax
// movq %rax, %rdi
// call _ZN6Animal3runEv
}
相比上一个例子,这波代码里,增加了一个Animal类和一个Human类。
我们从main函数开始
-
对象初始化
首先语句Animal cat;
初始化了一个Animal的对象cat,从右边的汇编代码可以看到,cat作为一个复合类型被存入新扩展的栈帧的第16个字节的偏移处-16(%rbp)
,然后将cat的地址存入rdi,显而易见,这就是C++在调用类的成员函数时传递的隐式参数this指针,接着跳转到标号名为_ZN6AnimalC1Ev
的地方继续执行,在Animal类里可以看到,对应该标号名的函数就是Animal类的构造函数。 -
类成员赋值
这没什么好谈的,跟C里结构体成员的赋值一样。 -
成员函数调用
对成员函数run()
的调用,编译器的处理方式与对构造函数的调用一模一样。
对比G++编译过程中对不同的函数的标号命名:
Animal 类
普通函数: invoke() _Z6invokei
普通成员函数:run() _ZN6Animal3runEv
构造函数: Animal() _ZN6AnimalC2Ev
Human 类:
构造函数: Human() _ZN5HumanC2Ev
在语法层面上,C++规定了不同函数的定义和调用方式,编译器会对不同函数使用不同的处理方式,比如调用成员函数会隐式传递this指针,比如直接调用成员函数会导致编译出错,在成功编译后,所有函数都不外乎是以一个特定标号标志的指令序列。
从标号的命名上可以看出C++确保其唯一的方式。
因此,狭义上讲,所谓类,其实就是一个复合类型,所谓成员函数,其实就是一个默认会传递调用对象本身指针的普通函数,所谓构造函数,其实就是一个在对象初始化的时候会自动调用的普通函数,这些额外的特性都是在编译阶段实现的。
多态(编译时,运行时)
- 重载
从汇编的角度看,重载的多个函数也不过是对应多个不同的标号名而已:
class Animal {
public:
void run() {} // _ZN6Animal3runEv
void run(int a) {} // _ZN6Animal3runEi
void run(char b) {} // _ZN6Animal3runEc
void run(int a, Human p) {} // _ZN6Animal3runEi5Human
};
G++正是通过重载的多个函数的不同形参列表来对标号进行唯一的命名,也是所谓的编译时多态。
-
继承
简单的继承是很容易实现的,第一点,编译器在分配空间的时候会分配子类自有成员变量和其父类成员变量的总大小,第二点,编译时会在子类构造函数的中调用父类的构造函数。
这里就不给例子了,主要篇幅放在下面的运行时多态上。 -
运行时多态
class Animal {
public:
virtual void run() {} // _ZN6Animal3runEv
};
class Cat : public Animal {
public:
void run() {} // _ZN3Cat3runEv
};
int main(int argc, char **argv) {
Animal *tom = new Cat(); // _ZN3CatC2Ev:
// _ZN6AnimalC2Ev:
// movq $_ZTV6Animal+16, (%rax)
// movq $_ZTV3Cat+16, (%rax)
tom.run(); // movq %rbx, -24(%rbp)
// movq -24(%rbp), %rax
// movq (%rax), %rax
// movq (%rax), %rax
// movq -24(%rbp), %rdx
// movq %rdx, %rdi
// call *%rax
}
这里,我们把new Cat()
要调用的2个构造函数按照执行顺序进行选择性展开,可以看到两条关键的汇编代码,其中(%rax)表示tom对象在堆中的起始位置,于是,唯一有效的最后一条代码movq $_ZTV3Cat+16, (%rax)
将Cat类的_虚函数表_指针存入了cat对象的起始位置。
再看tom.run()
的汇编,追踪发现,最后一条代码call *%rax
正好调用了Cat类的虚函数表的第一个函数。
这就是所谓的运行时多态的调用逻辑,为什么说是所谓的呢?因为这个逻辑在编译的时候就可以实现了,有些聪明的编译器会在你将tom指针指向Cat对象的时候就确定了tom到底对哪个run进行调用,它会将tom.run()
直接优化编译成call _ZN3Cat3runEv
。
那么,什么样的运行时多态是在编译阶段做不了的呢?看下面代码:
int main(int argc, char **argv) {
Animal *tom;
if (argc == 0)
tom = new Animal();
else
tom = new Cat();
tom->run();
}
这时,编译tom->run()
的时候是不可能知道该调哪个run的,所以,根据上一段代码我们展开的构造函数可以知道,在运行时,哪一个构造函数被调用,tom所指向的对象里就存了哪个类的虚函数表指针,这才是真正意义上的运行时多态。
模板与泛型
class Cat {};
class Mouse {};
template <typename T>
class Cave {
public:
void capture(T& a) {};
};
int main(int argc, char **argv) {
Cat tom;
Mouse jerry;
Cave<Cat> catsCave;
catsCave.capture(tom); // call _ZN4CaveI3CatE7captureERS0_
Cave<Mouse> miceCave;
miceCave.capture(jerry); // call _ZN4CaveI5MouseE7captureERS0_
}
有了之前对函数和标号的认识,理解模板与泛型的实现就是信手拈来了。
编译器会识别一个模板类有几种指定了不同类型的声明,然后会为每一种类型生成对应的唯一的函数标号和不同的函数实现。
就这个简单的例子来说,编译器会为抓猫的笼子和抓老鼠的笼子编译出不同捕捉函数。
传统的实现方式是为不同的笼子声明不同的类和函数,这所产生的汇编代码与使用模板与泛型产生的汇编代码在功能上是一模一样的,甚至在代码细节上都是差不多的,不同的只是标号名罢了。
模板与泛型在语言级别上提供了这种简便且扩展性极佳的编程方式,这种设计思维是C++所推荐的。
希望这写篇笔记能够为C++初学者提供些许指引,同时为我即将开始的求职之路提供一些帮助。
附上《C++程序设计语言》上的一句话:C++是一个可以伴随你成长的语言。
欢迎批评和讨论。