C++ 动态联编和静态联编
本文较长,非常详细,主要关于动态联编、静态联编和虚函数。建议前置阅读如何理解基类和派生类的关系
当你写了一个函数,程序运行时,编译器会如何执行你的函数呢?
什么是联编?
你会认为这个问题很弱智,代码怎么写的编译器就怎么执行呗?这对于C语言来说是成立的,因为每一个函数名都对应一个不同的函数。但是C++由于引入了重载、重写,一个函数名可能对应多个不同的函数。编译器必须查看函数参数以及函数名才能确定具体执行哪个函数。
将源代码中的函数调用解释为执行特定的函数代码块的过程称为函数名联编。
意思就是,同一个名称的函数有多种,联编就是把调用和具体的实现进行链接映射的操作。
而联编中,C++编译器在编译过程中完成的编译叫做静态联编。
静态联编
静态联编工作是在程序编译连接阶段进行的,这种联编又称为早期联编,因为这种联编实在程序开始运行之前完成的。在程序编译阶段进行的这种联编在编译时就解决了程序的操作调用与执行该操作代码间的关系。
但是重载、重写、虚函数使得这项工作变得困难。因为编译器不知道用户将选择哪种类型的对象,执行具体哪一块代码。所以,编译器必须生成能够在程序运行时选择正确的虚函数的代码,这个过程被称为动态联编,又称晚期联编。
动态联编
编译程序在编译阶段并不能确切地指导将要调用的函数,只有在程序执行时才能确定将要调用的函数,为此要确切地指导将要调用的函数,要求联编工作在程序运行时进行,这种在程序运行时进行的联编工作被称为动态联编,或称动态束定,又叫晚期联编。
为了深入的探讨联编的内容,我们先从指针、引用和虚函数开始讲起。
指针和引用的兼容性
C++中,动态联编与指针、引用调用方法息息相关。从某个角度而言,动态联编的产生和继承如影随形。继承是如何处理指向对象的指针、引用我们已经有所讨论基类与派生类。通常,C++不允许将一种类型的地址赋给另一种类型的指针。也不允许一种类型的引用指向另一种类型。
例如
double x = 3.14;
int* pi = &x; // 不可以,int指针不可以指向double
long& ri = x; // 不可以,long引用不可以引用double
但是,就像我们之前讨论的,指向基类的引用或指针可以引用派生类对象,而不需要进行显式转换。
例如
Student terry("Terry", 18, male); // 派生类对象
Person* p = &terry; // ok
Person& r = terry; // ok
这种,将派生类引用或指针转换为基类引用或指针称为向上强制转换,这使得公有继承不需要进行显式类型转换就可以通过基类指针或引用来引用派生类对象。这符合is-a规则,所有Student对象都是Person对象,因为Student对象继承了Person对象所有的数据成员和成员函数。也就是说,可以对Person对象执行的任何操作,也适用于Student对象。因此,为处理Person对象引用而设计的函数可以对Student对象执行同样的操作,而不必担心出任何问题,这也是多态中最基础的应用。将指针向对象的指针作为函数参数时,也是如此。同样,向上强制转换是可以传递的。Person对象肯定是Mammal对象,那么Student对象肯定也是Mammal对象,因为他们是由is-a关系一路传递下来的。所以,对于Mammal对象的操作,也适用于对Student对象的操作。
相反,将基类指针或引用转换为派生类指针或引用,称为向下强制转换。如果不使用显式类型转换,则向下强制转换是不允许的。原因很容易想明白,因为is-a关系是不可逆的。不是所有的Person都是Student,不是所有的Mammal都是Person。同样,派生类肯定会新增数据成员和成员函数,这些数据成员的类成员函数不能应用于基类。例如,Student对象添加了scores数据表示学生的分数,添加了goSchool()方法表示去上学。但是这两个成员对于Person来说毫无意义,我一个退休*,是一个Person,但是退休*不需要分数,也不需要上学。如果C++允许隐式向下强制转换,则可能出现一些荒谬的问题。所以,C++只支持显式向下强制转换。
例如
class Person {
private:
string name;
...
public:
void printName();
...
};
class Student :public Person {
private:
double scores;
...
public:
void goSchool();
...
};
...
Person aPerson;
Student aStudent;
...
Person* pp = &aStudent; // 允许向上隐式转换
Student* ps = (Student*)&aPerson; // 必须向下显式转换
pp->printName(); // 向上转换是安全操作,因为每个人都有名字
ps->goSchool(); // 向下转换是风险操作,不是每个人都要去上学
那么我们再看下面这段代码
class Person {
...
public:
virtual void talk(); // 虚函数!!
};
class Student :public Person {
...
public:
void talk();
};
...
void convertV(Person p); // uses p.talk()
void convertR(Person& p); // uses p.talk();
void convertP(Person* p); // uses p->talk();
...
Student aStudent;
...
convertV(aStudent); // Person::talk()
convertR(aStudent); // Student::talk()
convertP(&aStudent); // Student::talk()
我们的convert函数,按照值传递,即使我传入了一个aStudent,一个Student类型的对象,它只会传入Student对象的Person部分。但由于引用和指针发生的隐式向上转换,且基类的talk方法是虚函数。导致函数convertR()和convertP()分别为Student对象使用了Student::talk()。
隐式向上强制转换的存在,使得基类指针和引用可以指向派生类对象,因此需要动态联编。即程序运行时,我才知道究竟要执行哪一个。C++通过虚函数来满足这样的需求。
虚成员函数和动态联编
如果读过我以前文章的朋友看到这里可能会有疑义。诶?之前这么调用明明调用的是基类方法啊!
没错,我们先回顾一下以前的用基类指针和引用调用派生类的过程。
class Person {
...
public:
void talk(); // 非虚函数!!
};
class Student :public Person {
...
public:
void talk();
};
...
void convertV(Person p); // uses p.talk()
void convertR(Person& p); // uses p.talk();
void convertP(Person* p); // uses p->talk();
...
Student aStudent;
Person* pp = &aStudent;
...
convertV(aStudent); // Person::talk()
convertR(aStudent); // Person::talk()
convertP(&aStudent); // Person::talk()
pp->talk(); // Person::talk()
这是不是就是以前提到的?如果我们没有在基类中将talk()定义为虚的,则调用talk()时,将根据指针类型和引用类型来调用Person::talk()。指针类型和引用类型在编译时是已知的,因此编译器在编译时,可以将这里的talk()关联到Person::talk()。总之,编译器对非虚方法使用静态联编。
然而在基类中将talk()声明为虚的,则调用talk()时,将根据指针、引用的类型(Student)调用Student::talk()。这个过程通常只有在运行过程中才能确定对象的类型,所以编译器生成的代码将在程序运行时,根据对象类型将talk()关联到 Person::talk() 或 Student::talk()。总之,编译器对虚方法使用动态联编。
在绝大多数情况下,动态联编很好,它很“高级”,能让程序选择特定类型设计的方法,那么,你肯定会问了:
- 为什么有两种类型的联编?
- 既然动态联编这么好,那还要静态联编干嘛?
- 动态联编这也太强了,怎么实现的?
为什么有两种类型的联编以及为什么默认是静态联编
我们已经看到了,动态联编很灵活,可以重新定义类的方法,可以很好的实现多态。那为什么还要保留静态联编呢,原因有二 —— 效率和概念模型。
首先是效率。这很好理解,如果一个程序在运行过程中进行决策,那必然需要一些手段来追踪基类指针或引用指向的对象。这必然会增大系统的硬件开销。例如,某些类压根就不用于继承,则完全不需要动态联编。同样,如果派生类不重写基类的任何方法,那么完全没必要使用动态联编。这些情况下,静态联编更合理,效率也更高。C++可是出了名的高性能语言,由于静态联编效率更高,因此被设置为C++的默认选择。毕竟C++的宗旨是,不要为不使用的特性付出代价(内存或时间)。所以动态联编只有当程序设计确实需要虚函数时,才会使用。
其次是概念模型。在设计类时,可能包含一些不在派生类重新定义的成员函数。例如我们之前看到的Person::printName(),人都有名字,我学生没必要重写。所以这个函数就不应该被定义为虚函数,有两方面的好处:第一是效率更高,第二是指出不需要重新定义的函数意味着应该将需要重新定义的函数声明为虚的。
总结:如果在派生类中重新定义基类的方法,则将它设为虚方法;否则,设为非虚方法。
虚函数的工作原理
通常,C++编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向存放函数地址的数组的指针。这种数组称为虚函数表(virtual function table, vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。
例如:基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表(也就是和基类无关)的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址。如果派生类没有重新定义虚函数,该vtbl将保存原始版本的地址(理论上和基类的虚函数表相同)。如果派生类定义了新的虚函数(指基类没有的),则该函数的地址也将被添加到vtbl中。
注意,不论包含的虚函数是1个还是100个,都只需要在对象中添加一个地址成员,只是大小不同而已。
当调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相应的函数地址表。如果类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行该地址对应的函数。如果使用类声明中的第三个函数,程序将使用地址为数组中的第三个元素的函数。
所以可以发现,使用虚函数和动态联编,无可避免地会增加内存和时间的开销,
- 每个对象都将增大,增大量为存储地址的空间;
- 对于每个类,编译器都会创建一个虚函数地址表(本质上就是数组);
- 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。
有关虚函数的注意事项
- 在基类方法的声明中使用关键词virtual可使该方法在基类以及所有派生类(包括派生类派生出来的类)中是虚的(也就是说派生类的派生类,也可覆盖基类的虚函数);
- 如果使用指向对象的引用或者指针来调用虚方法,程序将使用为对象类型定义的方法,而不是该引用或指针类型定义的方法。这称为动态联编(晚期联编)。这种行为非常重要,使得基类引用或指针可以指向派生类对象;
- 虚函数是C++中用于实现多态的机制,核心理念就是上条的通过基类访问派生类定义的函数;
- 如果定义的类作为基类,必须将那些在派生类中重新定义的类方法声明为虚的。
以下针对一些特殊的函数做讨论:
- 构造函数
派生类不会继承基类的构造函数,自然构造函数不能是虚函数。每个类都会有自己的构造函数,创建派生类对象时先会调用派生类的构造函数,派生类的构造函数又会首先调用基类的构造函数。这个顺序之前提到过很多了,因此虚的构造函数毫无意义。
- 析构函数
与构造函数相反,析构函数就应该是虚函数,除非这个类不会派生。
我们看如下代码:
Person * pp = new Student; // 合法的声明
...
delete pp; // ???
如果析构函数不是虚函数的话,那么会采用静态联编。delete语句将直接调用 ~Person() 析构函数,这会释放这个Student对象中Person部分指向的内存,但是不会释放新的类成员指向的内存。但是如果析构函数是虚的,则上述代码将调用 ~Student()析构函数释放由Student组件指向的内存,然后通过派生类的析构函数自动调用基类 ~Person() 析构函数来释放指向Person组件指向的内存。
所以,通常应该给基类提供一个虚析构函数,即使它并不需要析构函数。
- 友元函数
友元函数不是类成员,所以不能是虚函数。
- 派生类不重新定义
如果派生类中没有重新定义函数,将使用该函数的基类版本。如果派生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类的版本是隐藏的。(第五点会提到)
- 重新定义将隐藏方法
有以下代码
class Person{
public:
virtual void talk(int voice);
...
};
class Student : public Person{
public:
virtual void talk();
...
};
我们可以看见,派生类新定义了一个不接受任何参数的函数。重新定义并不会生成talk()函数的两个重载版本,而是隐藏了接受一个int参数的基类版本。也就是说,现在那个能传参的基类的talk()方法已经没用了。总之,重新定义继承的方法并不是重载。如果在派生类中重新定义函数,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。
这条规则引出了两条经验:第一,如果要重新定义继承的方法,应确保与原来的原型完全相同。但如果返回类型是基类的引用或指针,则可以修改为指向派生类的引用或指针(这是一种特例)。这种特性被称为返回类型协变(covariance of return type),因为允许返回类型随类型的变化而变化。
class Person{
public:
// base class
virtual Person* getAdd(int n);
...
};
class Student : public Person{
public:
// a derived class with a covariance return type
virtual Student* getAdd(int n); // same function signature
};
注意:这种返回模式只针对于返回值,而不适用于参数。
第二,如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。
class Person{
public:
// three override talk() in base class
virtual void talk();
virtual void talk(int a);
virtual void talk(char n);
...
};
class Student : public Person{
public:
// three redefined talk() in derived class
virtual void talk();
virtual void talk(int a);
virtual void talk(char n);
};
为什么呢?因为如果在派生类只重定义一个版本,则编译器会认为你触发了隐藏机制,另外两个没有被重新定义的版本会被隐藏。
可以这么理解隐藏,如果说把基类的定义看作是一种秩序。那么当且仅当所有的重载都在派生类中实现,才不会打破这种秩序。如果秩序被打破了,那么编译器认为派生类要创造一种新的秩序。所以会隐藏基类以前秩序,则别的没有被重新定义的版本就会被隐藏。
如果你说你不想重新定义,不需要修改,就要用基类那个。那也必须写出新定义,可以这么写。
void Student::talk() {
Person::talk(); // use function in base class
}
纯虚函数
- 什么是纯虚函数?
纯虚函数是指在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后面加=0。
virtual ReturnType FunctionName()= 0;
- 为什么需要纯虚函数?
- 为了方便多态的使用,我们常需要在基类中定义纯虚函数,让各个派生类去实现。
- 在很多情况下,基类本身生成对象是很不合理的。例如,文具可以作为基类可以派生出钢笔、铅笔、橡皮擦等派生类。但是文具本身作为对象很不合理,什么是一个文具?
为了解决上述问题,引入了纯虚函数的概念。
编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。也就是,文具就是一个完全高高在上的概念,商店只会卖钢笔、铅笔、橡皮擦,但是不可能出售“文具”这个商品。
声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。
纯虚函数最显著的特征是:它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。
定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。就好比你的电脑的USB接口,他正是因为没有写死要插入什么东西,所以又可以插U盘,又可以连接鼠标,又可以连接键盘。
纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的默认实现。
所以类纯虚函数的声明就是在告诉子类的设计者,"你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它"。
抽象类
抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。
(1)抽象类的定义: 称带有纯虚函数的类为抽象类。
(2)抽象类的作用: 抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
(3)使用抽象类时注意:
- 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。
- 抽象类是不能定义对象的。