1. 写在前面
c++在线编译工具,可快速进行实验: https://www.dooccn.com/cpp/
这段时间打算重新把c++捡起来, 实习给我的一个体会就是算法工程师是去解决实际问题的,所以呢,不能被算法或者工程局限住,应时刻提高解决问题的能力,在这个过程中,我发现cpp很重要, 正好这段时间也在接触些c++开发相关的任务,所有想借这个机会把c++重新学习一遍。 在推荐领域, 目前我接触到的算法模型方面主要是基于Python, 而线上的服务全是c++(算法侧, 业务那边基本上用go),我们所谓的模型,也一般是训练好部署上线然后提供接口而已。所以现在也终于知道,为啥只单纯熟悉Python不太行了, cpp,才是yyds。
和python一样, 这个系列是重温,依然不会整理太基础性的东西,更像是查缺补漏, 不过,c++对我来说, 已经5年没有用过了, 这个缺很大, 也差不多相当重学了, 所以接下来的时间, 重温一遍啦 😉
资料参考主要是C语言中文网和光城哥写的C++教程,然后再加自己的理解和编程实验作为辅助,加深印象。 关于更多的细节,还是建议看这两个教程。
今天的知识也是非常硬核,面向对象程序设计里面的继承和多态是非常重要的内容,上一篇文章用大量篇幅整理继承,这篇文章专门整理多态, 多态呢, 指的是同一名字的事物可以完成不同功能,可以分为编译时多态和运行时多态。 前者主要指函数或者运算符等重载,重载函数调用等,编译时就能根据实参确定调用哪个函数。 而后者则和继承,虚函数有关,也是这篇文章重点内容啦。
这篇文章内容依然是有些多,还是各取所需即可 😉
主要内容:
- C++多态和虚函数初识
- C++虚函数注意事项及构成多态的条件
- C++虚析构函数必要性
- C++纯虚函数和抽象类
- C++虚函数表(多态实现机制原理)
- C++ typeid那些事
- C++ RTTI机制(运行时的类型识别机制)
- C++静态绑定与动态绑定
Ok, let’s go!
2. C++多态和虚函数初识
上一篇文章整理过,基类的指针可以指向派生类对象, 但是呢? 成员变量使用的是派生类的成员变量,但是成员函数却是用的基类的成员函数。 这和编译器的编译原理有关,具体可以看上一篇的内容, 这里直接看个简单例子:
class base{
public:
base(string name);
void display();
protected:
string m_name;
};
base::base(string name): m_name(name){}
void base::display(){cout << "嗨 " << m_name << ", 我是鸡肋!!" << endl;}
class paisheng: public base{
public:
paisheng(string name, int age);
void display();
private:
int m_age;
};
paisheng::paisheng(string name, int age): base(name), m_age(age){}
void paisheng::display(){
cout << "嗨 " << m_name << ", 我是派生类, 今年" << m_age << endl;
}
int main(){
base *p = new base("zhongqaing");
p -> display();
p = new paisheng("wuzhongqaing", 250);
p -> display();
return 0;
}
// 运行结果
嗨 zhongqaing, 我是鸡肋!!
嗨 wuzhongqaing, 我是鸡肋!!
这里就会发现, 当base类型的p指向paisheng对象的时候, 使用的成员变量变成了派生类的,但成员函数还是鸡肋的。即基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。
但这不行呀, 有没有方法,能让基类指针访问派生类的成员函数呢? 这就是虚函数的功效了。 虚函数使用非常简单,在函数声明前面加virtual
关键字即可。 上面代码只需要加一个:
class base{
public:
base(string name);
virtual void display(); // 这里声明的时候加个virtual
protected:
string m_name;
};
// 此时立马见效
嗨 zhongqaing, 我是鸡肋!!
嗨 wuzhongqaing, 我是派生类, 今年250
有了虚函数,基类指针指向基类时就使用基类的成员(成员变量和成员函数),指向派生类对象就使用派生类成员。即,基类指针可以按照基类方式做事,也可以按照派生类方式做事,有多种表现形式,这种现象就是多态
C++中虚函数的唯一用处,就是构成多态。 那么这多态到底有啥用呢?
C++提供多态的目的: 可以通过基类指针对所有派生类(直接或间接派生)的成员变量和成员函数进行"全方位"的访问,尤其是成员函数。如果没有多态,只能访问成员变量。
这时候, 心里可能有两个疑问:
- 还是没有明白多态有啥用,我为啥非得用基类指针指向派生类对象去访问派生类成员变量和成员函数呢? 想访问派生类的成员,我直接用派生类指针不就完事?
- 之前不是说指针调用普通成员函数时是根据指针类型判断调用哪个类的成员函数吗? 为啥到了虚函数这里,感觉不像是这个规则了?
首先,先回答第二个问题,确实,虚函数不适合这个规则,虚函数是根据指针的指向调用,和成员变量一样了,所以指针指向哪个类对象就调用哪个类的虚函数, 具体原因在虚函数表会解释。
这里要理解好第一个, 首先,面想对象的三大特性,封装,继承,多态:
- 封装式为了把过程和数据包起来,实现细节隐藏,对外只提供接口,使得代码模块化
- 继承可以对已经存在的模块代码进行重用,对于很多类共有的功能,不必在每个类里面都写,而是往上抽象一层基类,从这里面把共有的功能写上, 只需要一次定义,然后其他类继承,提高程序可复用性
- 而多态, 派生类的功能可以被基类的变量调用,向后兼容,提高可扩充性和可维护性,降低模块耦合度。 这个怎么理解呢?
对于多态的功效,如果仅用一个派生类对象,可能看不出作用,就像我们理解的,直接弄一个派生类对象访问成员变量和函数不就完事, 但假如我们有这样一个场景:
我们从小到大, 应该用过很多交通工具,自行车,电动车,汽车,火车,飞机…, 不同的交通工具可以抽象成不同的类, 每个交通工具都有自己的运行方式, 那么如果我们要设计一个系统, 遇到不同的交通工具,就调用对应的运行方式载我们出行。应该怎么弄?
有一种方式,就是把每个交通工具的类定义出来,里面有自己的run方法,也定义好。 这时候,对于给定的交通方式,我们进行判断, if是自行车,走自行车的run, if…, 这样一长串if判断。并且,这种方法,假设再有新的交通工具加入,还得再修改函数体里面的逻辑(加if判断), 使得代码块的耦合程度太高。根本就不可取。
另一种方式,就是多态的思路,我们可以声明一个base的交通工具类,里面定义一个virtual的run方法,然后让其他各个交通工具继承,重写各自的run方法,这时候呢? 用一个基类的指针,去接收子类对象,函数体里面调用run方法,此时根据多态性,传进来啥对象,就调用哪个对象的run方法。 这样说可能还感受不到,尝试写下:
class vehicle{
public:
virtual void run();
};
void vehicle::run(){cout << "我是交通工具, 你想怎么用我?" << endl;}
class bicycle: public vehicle{
public:
void run();
};
class car: public vehicle{
public:
void run();
};
class train: public vehicle{
public:
void run();
};
void bicycle::run(){cout << "我是自行车, 我有两个轱辘, 我跑的挺快..." << endl;}
void car::run(){cout << "我是汽车, 我有四个轱辘,我跑的更快..." << endl;}
void train::run(){cout << "我是火车, 我没有轱辘, 我跑的最快..." << endl;}
class People{
public:
People(vehicle *);
void drive();
private:
vehicle *m_vehicle;
};
People::People(vehicle *v): m_vehicle(v){}
void People::drive(){
m_vehicle->run();
}
int main(){
// 构建各自的对象
vehicle *v = new vehicle();
// 基类指针指向派生对象
v = new bicycle();
//v = new car();
//v = new train();
People *p = new People(v);
p -> drive();
return 0;
}
看这个例子,就能看到多态的强大之处, 在People里面,只需要初始化的时候,拿一个基类指针去接收派生类的对象,然后drive()
方法里面调用run()
函数,这时候传进啥,就能自己去匹配各自的run方法。 即使我们有新工具了,那么也只需要定义出类和run()
方法,然后在主函数里面把v的指向改到新方法里面,就执行新工具的方法。 其他都不用动。非常powerful。
这里的解耦意思就是,新增功能的时候或者新增工具,不需要修改people里面的drive方法,使得people类和交通工具类的耦合性降低, 高内聚低耦合,是软件设计里面的追求。
这就是多态一般常用到地方之一,即用到函数的参数里面。 还有一个地方就是函数的返回值中,比如,我们要造一个交通工具,此时函数的返回类型可以用基类,而返回子类对象。like 下面这样:
class VehicleFactory{
public:
vehicle* make_vehicle(string name);
};
vehicle* VehicleFactory::make_vehicle(string name){
if (name == "bicycle"){
return new bicycle();
}else if(name == "car"){
return new car();
}else if (name == "train"){
return new train();
}else{
return new vehicle();
}
}
int main(){
// 构建各自的对象
VehicleFactory *factory = new VehicleFactory();
//vehicle *v = factory -> make_vehicle("bicycle");
//vehicle *v = factory -> make_vehicle("car");
vehicle *v = factory -> make_vehicle("train"); // 我是火车, 我没有轱辘, 我跑的最快...
People *p = new People(v);
p -> drive();
return 0;
}
这就是多态最常用的两个地方。
最后一个小细节, 引用也可以实现多态,但一般不用,因为引用类似于常量,只能在定义的时候就初始化,且后面不能再引用其他数据。不灵活,失去了多态的本质妙用。 第一个例子里面主函数改成下面这样:
int main(){
base z("zhongqiang");
paisheng t("wuzhongqiang", 250);
base &rz = z;
base &rt = t;
rz.display();
rt.display();
return 0;
}
初识多态,下面看看具体的细节规则。
3. C++虚函数注意事项及构成多态的条件
3.1 虚函数的注意事项
关于虚函数的使用, 有下面几点需要知道:
- 只需要在虚函数的声明处加上virtual关键字,函数定义处可以加也可以不加
- 为了方便,可以只将基类中的函数声明为虚函数,这样所有派生类中具有遮蔽关系的同名函数都将自动成为虚函数
- 当在基类中定义了虚函数,如果派生类没有定义新的函数来遮蔽此函数,将使用基类虚函数
- 只有派生类的虚函数覆盖基类的虚函数(函数原型相同)才能构成多态(基类指针访问派生类函数),否则会报错。比如基类函数
virtual void run()
, 派生类虚函数原型virtual void run(int)
, 那么基类指针p指向派生类对象时,p->run(100)
会报错,因为此时会调用基类自己的虚函数run()
。 - 构造函数不能是虚函数。 对于基类的构造函数,仅仅是在派生类构造函数中被调用,不同于继承。即派生类不能继承基类的构造函数,将构造函数声明为虚函数没有什么意义。
- 析构函数可以声明为虚函数,而且有时候必须要声明为虚函数(后面会说)
3.2 构成多态的条件
构成多态有3个条件:
- 必须存在继承关系
- 继承关系中必须有同名的虚函数,并且它们是覆盖关系(函数原型相同)
- 存在基类的指针,通过该指针调用虚函数
看个例子:
class Base{
public:
virtual void func();
virtual void func(int);
};
void Base::func(){
cout << "void Base::func()" << endl;
}
void Base::func(int n){
cout << "void Base::func(int)" << endl;
}
class Derived: public Base{
public:
void func();
void func(string s);
};
void Derived::func(){
cout << "void Derived::func()" << endl;
}
void Derived::func(string s){
cout << "void Derived::func(char *)" << endl;
}
int main(){
Base *p = new Derived();
p -> func(); // void Derived::func() --- 构成多态,调用派生类虚函数
p -> func(10); // void Base::func(int) --- 不构成多态,调用基类虚函数,派生类没有覆盖它
//p -> func("zhongqiang"); // error --- 基类指针只能访问从基类继承过去的成员,不能访问派生类新增成员
return 0;
}
那么什么时候应该声明为虚函数呢?
首先看成员函数所在的类是否会作为基类。 然后看成员函数在类的继承后有无可能被更改功能,如果希望更改其功能的,一般将它声明为虚函数。
4. C++虚析构函数的必要性
构造函数不能是虚函数,原因其实有两个:
- 派生类不能继承基类的构造函数,将构造函数声明为虚函数没有意义
- C++中的构造函数用于创建对象时初始化工作,在执行构造函数之前,对象尚未创建完成,虚函数表尚不存在,没有指向虚函数表的指针,所以无法查询虚函数表,就不知道要调用哪个构造函数
But, 析构函数用于销毁对象时的清理工作,可以声明为虚函数,而有时候必须要声明为虚函数。
首先,看下面这个例子:
class Base{
public:
Base();
~Base();
protected:
char *str;
};
Base::Base(){
str = new char[100];
cout << "Base constructor" << endl;
}
Base::~Base(){
delete[] str;
cout << "Base destructor" << endl;
}
class Derived:public Base{
public:
Derived();
~Derived();
private:
char *name;
};
Derived::Derived(){
name = new char[100];
cout << "Derived constructor" << endl;
}
Derived::~Derived(){
delete[] name;
cout << "Derived destructor" << endl;
}
int main(){
Base *pb = new Derived();
delete pb;
cout<<"-------------------"<<endl;
Derived *pd = new Derived();
delete pd;
return 0;
}
// 结果
Base constructor
Derived constructor
Base destructor
-------------------
Base constructor
Derived constructor
Derived destructor
Base destructor
程序比较简单,定义了基类Base和派生类Derived,它们都有自己的构造函数和析构函数。 构造函数中会分配100个char类型内存空间,析构函数中,这些内存会被释放掉。 pb, pd分别是基类指针和派生类指针,都指向派生对象,最后用delete销毁pd,pd指向的对象。
从运行结果看, delete pb
, 只调用了基类的析构函数, 没有调用派生类的析构函数。 而语句delete pd
, 同时调用了派生类和基类的析构函数。
上面例子中,不调用派生类的析构函数会导致name指向的100个char类型的内存空间得不到释放,除非程序运行结束由操作系统回收,否则再也没有机会释放这些内存。这是典型的内存泄露
那么,就有两个问题要解决了:
- 为什么
delete pb
, 不会调用派生类的析构函数呢?
因为这里析构函数是非虚函数, 通过指针指向非虚函数时,编译器会根据指针的类型来确定要调用的函数。即指针指向哪个类就调用哪个类的函数,pb是基类指针,所以不管它指向基类对象还是派生类对象,始终都是调用基类析构函数。 - 为什么
delete pd
, 会同时调用派生类和基类的析构函数呢?
pd是派生类指针,编译器会根据它类型匹配到派生类的析构函数,在执行派生类析构函数的过程中,又会调用基类的析构函数。 派生类析构函数始终会调用基类的析构函数,并且这个过程是隐式完成的。
那么,如果想让pb
也调用派生类的析构函数怎么办? 把基类Base的析构函数声明为虚函数
class Base{
public:
Base();
virtual ~Base(); // 是他,是他,就是他
protected:
char *str;
};
// 结果
Base constructor
Derived constructor
Derived destructor
Base destructor
-------------------
Base constructor
Derived constructor
Derived destructor
Base destructor
基类的析构函数声明为虚函数后,派生类的析构函数也自动成为虚函数。 这个时候编译器会忽略指针的类型,根据指针的指向选择函数。 指针指向哪个类的对象,就调用哪个类的函数。 pb,pd都指向派生类对象,所以会调用派生类析构函数,继而再调用基类析构函数。 解决了内存泄露问题。
实际开发中,一旦我们自己定义了析构函数,希望他在对象销毁时进行清理工作,比如释放内存,关闭文件等,如果这个类又是一个基类,必须将析构函数声明为虚函数,否则就有内存泄露的风险,即大部分情况下,基类的析构函数会声明为虚函数。
5. C++纯虚函数和抽象类
C++中,可以将虚函数声明为纯虚函数:
virtual 返回值类型 函数名(函数参数) = 0;
纯虚函数没有函数体,只有函数声明,在虚函数声明的结尾加上=0
,表明此函数为纯虚函数。
=0
不是说函数返回值为0,而是从形式上告诉编译器,“我是纯虚的”
包含纯虚函数的类称为抽象类。 之所以抽象,是因为无法实例化,无法创建对象。因为纯虚函数没有函数体,不是完整函数,无法调用,无法为其分配内存空间。
抽象类通常是基类,让派生类去实现纯虚函数。 派生类必须实现纯虚函数才能被实例化。
// 线
class Line{
public:
Line(float len);
virtual float area() = 0;
virtual float volume() = 0;
protected:
float m_len;
};
Line::Line(float len):m_len(len){}
// 矩形
class Rec: public Line{
public:
Rec(float len, float width);
float area();
protected:
float m_width;
};
Rec::Rec(float len, float width): Line(len), m_width(width){}
float Rec::area(){return m_len * m_width;}
// 长方体
class Cuboid: public Rec{
public:
Cuboid(float len, float width, float height);
float area();
float volume();
protected:
float m_height;
};
Cuboid::Cuboid(float len, float width, float height): Rec(len, width), m_height(height){}
float Cuboid::area(){return 2 * (m_len * m_width + m_len * m_height + m_width * m_height);}
float Cuboid::volume(){return m_len * m_width * m_height;}
int main(){
//Line *q = new Rec(10, 20); // error: invalid new-expression of abstract class type 'Rec' 里面有纯虚函数
Line *p = new Cuboid(10, 20, 30);
cout << "The area of Cuboid is " << p->area() << endl;
cout << "The volume of Cuboid is " << p->volume() << endl;
return 0;
}
Line是顶层的基类,里面有两个纯虚函数,所以是一个抽象类。 Rec继承Line,实现了area()
函数,但此时Rec还有纯虚函数,也是一个抽象类,所以不能实例化,第一句代码尝试实例化报错。 Cuboid继承Rec,实现了volume()
函数,此时才算一个完整的类,可以被实例化。
实际开发中,可以定义一个抽象类,只完成部分功能,未完成的功能交给派生类实现,这部分未完成的功能,往往是基类不需要的,或者基类中无法实现的。虽然抽象基类没有完成,但是却强制要求派生类完成,这就是抽象基类的"霸王条款"
抽象基类除了约束派生类,还可以实现多态,基类指针类型的p
,可以访问派生类的area()
和volume()
函数,正是因为在Line类中这哥俩是纯虚函数。这个其实才是C++纯虚函数的主要目的。
纯虚函数,还有几点要注意:
- 一个纯虚函数就可以使得类成为抽象基类(没法实例化), 还可以包含其他的成员函数(虚函数或普通函数)和成员变量
- 只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层函数均不能声明为纯虚函数
like this:
//顶层函数不能被声明为纯虚函数
void fun() = 0; //compile error
class base{
public :
//普通成员函数不能被声明为纯虚函数
void display() = 0; //compile error
};
6. C++虚函数表
当通过指针访问类的成员函数时:
- 如果该函数是非虚函数,编译器会根据指针的类型找到该函数,即指针时哪个类的类型,就调用哪个类的函数
- 如果该函数是虚函数,并且派生类有同名的函数遮蔽它,编译器会根据指针的指向找到该函数,即指针指向的对象属于哪个类就调用哪个类的函数,这就是多态。
编译器之所以能通过指针指向的对象找到虚函数,是因为在创建对象时额外增加了虚函数表。
如果一个类包含了虚函数,那么在创建该类的对象时就会额外增加一个数组,数组中的每个元素都是虚函数的入口地址。不过数组和对象是分开存储的,为了将对象和数组关联起来,编译器还需要在对象中安插一个指针,指向数组的起始位置。 这里的数组就是虚函数表(vtable)。
从一个例子说起:
class People{
public:
People(string name, int age);
virtual void display();
virtual void eating();
protected:
string m_name;
int m_age;
};
People::People(string name, int age): m_name(name), m_age(age){}
void People::display(){
cout << "Class People: " << m_name << "今年" << m_age << "岁了\n";
}
void People::eating(){
cout << "Class People: 我正在吃饭,请不要和我说话..." << endl;
}
class Student: public People{
public:
Student(string name, int age, float score);
virtual void display();
virtual void examing();
protected:
float m_score;
};
Student::Student(string name, int age, float score): People(name, age), m_score(score){}
void Student::display(){
cout << "Class Student: " << m_name << "今年" << m_age << "岁了, 考了" << m_score << "分。" << endl;
}
void Student::examing(){
cout << "Class Student: " << m_name << "正在考试,不要打扰!!" << endl;
}
class Senior:public Student{
public:
Senior(string name, int age, float score, bool hasJob);
virtual void display();
virtual void partying();
private:
bool m_hasJob;
};
Senior::Senior(string name, int age, float score, bool hasJob): Student(name, age, score), m_hasJob(hasJob){}
void Senior::display(){
if (m_hasJob){
cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,并且顺利找到了工作,Ta今年"<<m_age<<"岁。"<<endl;
}else{
cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,不过找工作不顺利,Ta今年"<<m_age<<"岁。"<<endl;
}
}
void Senior::partying(){
cout<<"Class Senior:快毕业了,大家都在吃散伙饭..."<<endl;
}
int main(){
People *p = new People("zhongqaing", 25);
p -> display(); // Class People: zhongqaing今年25岁了
// 指向派生类
p = new Student("zhangsan", 25, 84.5);
p -> display(); // Class Student: zhangsan今年25岁了, 考了84.5分。
p =new Senior("lisi", 22, 92.0, true);
p -> display(); // Class Senior:lisi以92的成绩从大学毕业了,并且顺利找到了工作,Ta今年22岁。
return 0;
}
上面的三个类依次继承,并且每个类里面都有虚函数,此时,各个类的对象内存模型如下:
每个类的左边部分是对象占用的内存,右边部分是虚函数表。 在对象的开头位置有一个指针vfptr,指向虚函数表,并且这个指针始终位于对象的开头位置。
仔细观察虚函数表,可以发现基类的虚函数表在vtable中的索引下标是固定的,不会随着继承层次增加而改变,派生类的虚函数放在vtable的最后。 如果派生类有同名的虚函数遮蔽了基类的虚函数,那么将使用派生类的虚函数替换基类的虚函数,这样具有遮蔽关系的虚函数在vtable中只会出现一次。
当通过指针调用虚函数时,先根据指针找到vfptr,再根据vfptr找到虚函数的入口地址。比如虚函数display(), 在vtable中索引为0,所用p调用时
p -> display();
// 编译器会发生下面类似转换
(*(*(p+0) + 0))(p);
1. 0是vptr在对象中的偏移,p+0是vfptr的地址
2. *(p+0)是vfptr的值,而vfptr是指向vtable的指针,所以*(p+0)是vtable的地址
3. display()在vtable的索引是0,所以(*(p+0)+0)是display()的地址
4. 有了地址, (*(*(p+0)+0))(p)就是对display()的调用了, 这里p传递的实参,会赋值给this指针
只要调用display()
函数,不管是哪个类的,都会使用这个表达式,转换后的表达式没有用到与p的类型相关的信息,只要知道p的指向就可以调用函数,这个函数名字编码算法有本质区别。
所以呢? 通过基类指针指向派生对象的时候, 改变了p的指向,此时调用函数的时候,也是先知道p指向的对象的vfptr,然后通过上面的表达式,去找对应的函数调用即可。 这也就是为啥声明为虚函数之后, 能够通过基类的指针去访问到派生的成员函数的原因了。
7. C++ typeid那些事
typeid
运算符用来获取一个表达式的类型信息,类型信息对于编程语言非常重要,它描述了数据的各种属性:
- 对于基本类型(
int、double
等)的对象,类型信息所包含的内容比较简单,主要是指数据的类型; - 对于类类型的对象,类型信息是指对象所属的类、所包含的成员、所在的继承关系等。
typeid
会把获取到的类型信息保存到一个type_info
类型的对象里面,并返回该对象的常引用,当需要具体类型信息时,可以通过成员函数提取。
C++标准没有确切定义type_info
, 具体和编译器有关,标准只规定必须提供的四种操作:
t.name()
: 返回类型的C-style字符串,表示相应的类型名t1 == t2
: 如果两个对象t1和t2类型相同,返回true,否则返回falset1 != t2
: 如果两个对象t1和t2类型不同,返回true,否则falset1.before(t2)
: 返回指出t1是否出现在t2之前的bool值
这里直接看使用就好:
class Base{};
class Derived: public Base{};
int main()
{ // GCC编译器下的结果
cout << "bool : " << typeid(bool).name() << endl; // b
cout << "char : " << typeid(char).name() << endl; // c
cout << "short : " << typeid(short).name() << endl; // s
cout << "int : " << typeid(int).name() << endl; // i
cout << "long : " << typeid(long).name() << endl; // l
cout << "float : " << typeid(float).name() << endl; // f
cout << "double : " << typeid(double).name() << endl; // d
Base b, *pb;
Derived d, *pd;
cout << typeid(b).name() << " " << typeid(pb).name() << endl; // 4Base P4Base
cout << typeid(d).name() << " " << typeid(pd).name() << endl; // 7Derived P7Derived
Base* pbd = new Derived();
cout << typeid(pbd).name() << endl; // P4Base 基类指针指向派生类类型,指针仍然是基类类别
return 0;
}
在实际应用中, typeid
主要用于返回指针或引用所指对象的实际类型,且大部分情况下,typeid
运算符用于判断两个类型是否相等。
if (typeid(b) != typeid(d)) {
cout << "b != d" << endl;
}
if (typeid(pb) == typeid(pbd)) {
cout << "pb == pbd" << endl;
}
8. C++ RTTI机制
8.1 RTTI机制初识
一般情况下,编译期间就能确定一个表达式的类型,但当存在多态的时候,有些表达式的类型在编译期间就无法确定了,必须等到程序运行后根据实际环境确定。
RTTI(Run-Time Type Identification),通过运行时类型信息,程序能够使用基类的指针或引用来检查这些指针或引用所指的对象的实际派生类型。
好吧,这样一说反而更加迷糊,而是通过例子吧:
// 基类
class Base{
public:
virtual void func();
protected:
int m_a;
int m_b;
};
void Base::func(){cout << "Base" << endl;}
// 派生类
class Derived: public Base{
public:
void func();
private:
int m_c;
};
void Derived::func(){cout << "Derived" << endl;}
int main() {
Base *p;
int n;
cin >> n;
if (n <= 10){
p = new Base();
}else{
p = new Derived();
}
cout << typeid(*p).name() << endl;
return 0;
}
这个例子中,基类Base包含了一个虚函数,派生类Derived继承Base,定义了一个原型相同的函数遮蔽了它,构成多态。 p是基类的指针,可以指向基类对象,也可以指向派生类对象。
但从代码里面发现, 输入的n不同, *p
表示的对象就不一样, typeid
获取的类型不同。编译器在编译期间无法预估用户的输入,所以无法确定*p
的类型,只有等到程序真运行,才能根据用户的输入确定*p
的类型。
前面整理过,C++对象内存模型主要包含下面的内容:
- 如果没有虚函数也没有虚继承,那么对象内存模型中只有成员变量
- 如果类包含了虚函数, 那么会额外添加一个虚函数表,并在对象内存插入一个指针,指向这个虚函数表
- 如果类包含了虚继承,那么会额外添加一个虚基类表,并在对象内存中插入一个指针,指向这个虚基类表
现在补充一点,如果类包含了虚函数,那么该类的对象内存中还会额外增加类型信息,即type_info对象。 上面的Base和Derived的对象内存模型如下:
编译器会在虚函数表vftable的开头插入一个指针,指向当前类对应的type_info对象。程序在运行阶段获取类型信息时,可以通过对象指针p找到虚函数表指针vfptr,再通过vfptr找到type_info
对象的指针,进而取得类型信息。
**(p->vfptr - 1)
程序运行后,不管p指向Base类对象还是指向Derived类对象,执行这条语句就可以取得type_info
对象。
编译器在编译阶段无法确定p指向哪个对象,就无法获取*p
的类型信息,但编译器可以在编译阶段做好各种准备,这样程序在运行后可以借助这些准备好的数据获取类型信息。准备包括:
- 创建
type_info
对象,并在vftable的开头插入一个指针,指向type_info
对象 - 将获取类型信息的操作转换成类似
**(p->vfptr - 1)
这样的语句
虽然这样会占用更多内存,效率也低,但却是无可奈何的事情。 这种在程序运行后确定对象的类型信息的机制称为运行时类型识别(Run-Time Type Identification,RTTI)。 C++中,只有类中包含了虚函数时才会启用RTTI机制,其他所有情况都可以在编译阶段确定类型信息。
RTTI机制具体应用的例子:
//基类
class People{
public:
virtual void func(){ }
};
//派生类
class Student: public People{ };
int main(){
People *p;
int n;
cin>>n;
if(n <= 100){
p = new People();
}else{
p = new Student();
}
//根据不同的类型进行不同的操作
if(typeid(*p) == typeid(People)){
cout<<"I am human."<<endl;
}else{
cout<<"I am a student."<<endl;
}
return 0;
}
这里也能够看到typeid
的实际怎么用的, 判断类型,然后进行不同的操作。
8.2 C++ RTTI机制下对象内存模型
C++中,除了typeid运算符,dynamic_cast运算符和异常处理也依赖于RTTI机制,并且要能够通过派生类获取基类的信息,或者能够判断一个类是否是另一个类的基类,此时,仅仅依靠上面的内存模型就不大行了,必须要在基类和派生类之间再增加一条绳索
,把它们连接
起来,形成一条通路,让程序在各个对象之间游走
。 在面向对象的编程语言中,将其称为继承链。
基类和派生类连接起来很容易,只需要在基类对象中增加一个指向派生类对象的指针。 下面通过一个例子,看下真正的对象内存模型:
class A{
public:
virtual int A_virt1();
virtual int A_virt2();
static void A_static1();
void A_simple1();
protected:
int a1;
};
class B{
public:
virtual int B_virt1();
virtual int B_virt2();
protected:
int b1;
int b2;
};
class C: public A, public B{
public:
virtual int A_virt2();
virtual int B_virt2();
protected:
int c1;
};
int main() {
cout << "test\n";
return 0;
}
最终的内存模型如下, 图片来自C语言中文网
从图中可以看出,对于有虚函数的类,内存模型中除了有虚函数表,还会额外增加好几个表,以维护当前类和基类的信息,空间上的开销不小。typeid(type).name() 方法返回的类名就来自“当前类的信息表”。
类型是表达式的一个属性,不同类型支持不同操作。 类型对于编程语言非常重要,编译器内部有一个类型系统来维护表达式的各种信息。
- C/C++中,变量,函数参数,函数返回值等在定义时都必须显式指明类型,并且一旦指明类型后就不能再改,所以大部分表达式类型都能够精确的推测出来,编译器在编译期间就能搞定这些事情,这样的编程语言称为静态语言。 典型的还有Java, C#, Scala等
- 静态语言在定义变量需要显式指明类型,并且在编译期间会拼尽全力确定表达式类型信息,只有在万不得已时才让程序等到运行后动态获取类型信息(多态),这样做可以提高程序运行效率,降低内存消耗
- 动态语言,与静态语言相对,在定义变量的时候往往不需要指明类型,并且变量的类型可以随时改变,编译器在编译期间也不容易确定表达式类型信息,只能等程序运行后再动态获取。 典型的有JavaScript, python, php, ruby等
- 动态语言为了能使用灵活,部署简单,往往一边编译一边运行,模糊了传统的编译和运行的过程
总结起来,静态语言由于类型的限制会降低编码速度,但执行效率高,适合开发大型,系统级程序, 而动态语言比较灵活,编码容易,部署容易,但运行效率不高。
7. C++静态绑定与动态绑定
C/C++用变量来存储数据,用函数定义一段可以重复使用的代码,它们最终都要放到内存中供CPU使用。 CPU通过地址来取得内存中的代码和数据,程序在执行过程中告诉CPU要执行的代码以及要读写的数据地址。
CPU 访问内存时需要的是地址,而不是变量名和函数名!变量名和函数名只是地址的一种助记符,当源文件被编译和链接成可执行程序后,它们都会被替换成地址。编译和链接过程的一项重要任务就是找到这些名称所对应的地址。
假如a,b,c三个变量的地址分别是0x1000, 0x2000, 0x3000, 那么加法运算c=a+b,会把转换成:
0x3000 = (0x1000) + (0x2000); // ()表示取值操作
变量和函数名是为我们提供方便,让我们在编写代码过程中容易阅读和理解,不用直接面对二进制地址。
不妨将变量名和函数名统称为符号(Symbol),找到符号对应的地址过程叫做符号绑定。
下面看看函数是怎么被绑定的。
函数调用实际上执行函数体的代码,函数体是内存中的代码段,函数名表示该代码段的首地址,函数执行时从这里开始,这里即函数的入口地址。
找到函数名对应的地址,然后将函数调用处用该地址替换,称为函数绑定
- 一般情况下,在编译期间就能找到函数名对应的地址,完成函数的绑定,程序运行后直接使用这个地址。 这称为静态绑定(Static binding)
- 但有时候在编译期间想尽办法也确定不出来使用哪个函数,必须等到程序运行后根据具体环境或者用户操作才能决定。 这称为动态绑定(Dynamic binding)
上面提到过,C++是一门静态性语言,会尽力在编译期间找到函数的地址,提高程序的运行效率,但有时候实在没办法,只能等程序运行后再执行一段代码找到函数的地址。 而动态绑定的本质: 编译器在编译期间不能确定指针指向哪个对象,只能等到程序运行后根据具体情况决定。