前置知识:类和对象
参考书籍:《C++ Primer 第五版》
目录
什么是面向过程?什么是面向对象?
一、封装
1、封装的含义以及如何实现封装
1.1 访问限定符(访问说明符)
1.2 什么是封装?
2、封装的优点
3、class与struct的区别 🔴
4、友元——“突破封装”
4.1友元的声明
4.2友元在什么时候用?有何利弊?
4.3几点注意事项
5、类的static成员——“与类关联的成员”🔴
5.1 声明静态成员
5.2 类外定义与初始化
5.3 类内定义与初始化
5.4 静态成员的使用场景
二、继承
1、继承的概念以及实现
1.1继承的概念
1.2 定义派生类——派生类列表
1.3 访问控制与继承
1.3.1 受保护的成员(protected)
1.3.2 继承基类成员访问方式的变化
1.3.3 改变个别成员的可访问性 ——"using"
2、派生类向基类的类型转换🔴
2.1 派生类对象的内存分布
2.2 派生类向基类的隐式类型转换
2.3 切掉 —— "对象之间不存在类型转换"
3、继承中的static与friend
3.1 继承于静态成员
3.2 友元与继承
4、继承中的作用域与隐藏
4.1 基类与派生类作用域关系
4.2 隐藏/重定义
5、派生类的默认成员函数
5.1 默认构造函数
5.2 拷贝构造函数。
5.3 赋值运算符重载( operator= )
5.4 析构函数
6、多继承及其造成的问题与解决方法
6.1 菱形继承及其问题
6.1.1 多继承
6.1.2 菱形继承模型
6.1.3 数据冗余与二义性问题
6.2 虚继承实现和底层原理
6.2.1 虚拟继承的实现
6.2.2 虚拟继承底层原理🔴
7、继承与组合
7.1 " Is A " 与 "Has A"
7.2 继承和组合的应用场景
三、多态
1、虚函数🔴
1.1 虚函数的定义
1.2 覆盖
1.2.1 虚函数的覆盖的条件与实现
1.2.2 协变 —— 返回值类型不同
1.3 动态绑定与静态绑定
1.3.1 动态类型
1.3.2 静态类型
1.3.3 动态绑定
1.3.4 静态绑定
1.4 虚析构函数
1.5 override 与 final 说明符
1.5.1 override
1.5.2 final
1.6 虚函数与作用域🔴
1.7 重载、覆盖(重写)、隐藏(重定义) 的总结与对比
2、什么是多态
3、 抽象基类
3.1 纯虚函数
3.2 抽象基类
4、多态的底层原理🔴
4.1 虚函数指针与虚函数表
4.2底层原理
4.3 单继承与复杂继承关系的虚函数表
4.2.1 单继承
4.2.2 多继承
4.2.3 菱形继承
4.2.4 菱形虚拟继承
什么是面向过程?什么是面向对象?
面向对象编程(object-oriented programming) : 利用数据抽象、继承以及动态绑定等技术编写程序的方法。
拿点外卖来说,如果是C我会将点外卖分为四个过程,用户拿手机点餐、外卖员取餐、外卖员送餐和用户取餐(用户评价,用餐等),对于这四个过程我都要指定一个人去做(将结构体指针传入函数),这就是面相过程编程,可以说C语言就是一个面向过程的语言。
而对于面向对象编程,我会将上述四个过程分成两个类,两个类可以实例化为多个对象(对应于有多个不同的外卖员和用户),每个对象调用自身的方法(成员函数),比如外卖员可以去做取餐和送餐的工作,而用户不能去做这些事;对于一个用户类实例化出的对象(比如我自己),则要做的是用户该做的事。
/****************代码(1) :以下代码实现了上述例子中的两个类****************/
class Delivery_boy {
public:
//类的方法
void Pick_up_meals() {}
void Food_delivery() {}
//类的属性
std::vector<std::string> orders;
std::string time;
};
class Client {
public:
void Pick_up_meals() {}
void Meal() {}
void Appraise() {}
int money;
std::string phone;
};
int main() {
Delivery_boy Tom;
Tom.Pick_up_meals(); //派送员取餐
Client Dusong;
return 0;
}
一、封装
1、封装的含义以及如何实现封装
可以看到上述两个类并没有封装,也就是说,我们可以直达对象内部并控制它的具体实现细节;
1.1 访问限定符(访问说明符)
①public:定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口
②private:定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用改类的代码访问,private部分封装了类的实现细节
(C++ Primer P240)
1.2 什么是封装?
含义:将数据与操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互
实现:通过类将数据以及操作数据的方法进行有机结合,通过访问权限(访问限定符)来隐藏对象内部的实现细节,控制哪些方法可以在类外部直接被使用
/****************代码(2) : 将上述例子中的外卖员类进行封装如下**************/
class Delivery_boy {
public:
void Pick_up_meals(std::string where, std::string which) {}
void Food_delivery() {}
private:
std::vector<std::string> orders;
std::string time;
};
int main() {
Delivery_boy Tom;
Tom.Pick_up_meals("China", "McDonald's");
//Tom.time = "2023-7-28"; 报错:不可访问
return 0;
}
2、封装的优点
①确保用户代码不会无意间破环封装对象的状态
----如代码(2)中主函数的第三行代码:用户代码访问并尝试更改私有成员,编译器报错
②被封装的类的具体实现细节可以随时改变,而无需调整用户级别的代码
----如代码(2)中主函数的第二行代码
(C++ Primer P242)
3、class与struct的区别 🔴
①封装中:class定义的类默认访问权限:private; struct定义的类默认访问权限:public
(C++ Primer P240)
②模板参数中:typename与class是可以用来定义模板参数关键字(两者有一定区别),而struct不能替代class
③继承中:class默认继承方式:private; struct默认继承方式:public (C++ Primer P546)
4、友元——“突破封装”
4.1友元的声明
友元函数是定义在类外部的函数,它可以直接访问类的私有成员,且它不属于任何类,但是需要在类的内部声明,声明时加上friend关键字
/***********************代码(3) : 友元函数********************/
class Delivery_boy {
public:
void Pick_up_meals(std::string where, std::string which) {}
void Food_delivery() {}
friend std::string Get_time(const Delivery_boy& person); //友元函数声明
private:
std::vector<std::string> orders;
std::string time;
};
std::string Get_time(const Delivery_boy& person) {
return person.time; //person对象在类外访问私有成员
}
int main() {
Delivery_boy Tom;
std::string time = Get_time(Tom);
return 0;
}
4.2友元在什么时候用?有何利弊?
什么时候用:根据上述可知,当需要在类外访问对象的私有成员时,可以使用友元函数
利:实现类之间的数据共享 ;提高程序运行效率,方便编程
弊:增加耦合度,破坏数据的隐蔽性和类的封装性;降低了程序的可维护性
4.3几点注意事项
①友元函数不能用const修饰,肯定的,因为这里的const修饰的是this指针,而友元函数不是成员函数,没有this指针;
②友元分为友元函数与友元类
③许多编译器并未强制限定友元函数必须在使用之前在类的外部声明(C++ Primer P242)
5、类的static成员——“与类关联的成员”🔴
(有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持联系)
5.1 声明静态成员
我们通过在成员的声明之前加上static关键字使得其与类关联在一起。和其他成员一样,静态成员也可以是公有(public)或者私有(private)的。需要注意的是,static成员包含static成员函数以及static成员变量,他们不属于任何对象,其static成员函数不包含this指针,即不能声明成const的
5.2 类外定义与初始化
• 当在类的外部定义静态成员变量时,不能重复static关键字,该关键字只出现在类内部的声明语句前,定义时需要指定对象的类型名、类名、作用域运算符以及成员自己的名字;
• 类似于全局变量,静态成员变量定义在任何函数之外,因此一旦它被定义,就将一直存在与程序的整个生命周期中
/*****************代码段(4) : 类外定义与初始化静态成员****************/
class Delivery_boy {
public:
void Pick_up_meals(std::string where, std::string which) {}
void Food_delivery() {}
private:
std::vector<std::string> orders;
std::string time;
static int Number_of_workers; //static成员变量的声明
};
int Delivery_boy::Number_of_workers = 10; //定义以及初始化
5.3 类内定义与初始化
(通常情况下,类的静态成员不应该在类的内部初始化)我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量的类型的constexpr。初始值必须是常量表达式,因为constexpr修饰的成员本身就是常量表达式
/*****************代码段(5) : 类外定义与初始化静态成员****************/
class Delivery_boy {
public:
void Pick_up_meals(std::string where, std::string which) {}
void Food_delivery() {}
//void change() {
// Number_of_workers = 12; ❌
//}
private:
std::vector<std::string> orders;
std::string time;
static constexpr int Number_of_workers = 10; //static成员变量的定义与初始化
//int arr[Number_of_workers]; ✔
};
//一个不带初始值的静态成员的定义
constexpr int Delivery_boy::Number_of_workers; //初始值在类的定义内提供
显然此时静态成员变量的值是无法更改的。
5.4 静态成员的使用场景
① 静态数据成员可以是不完全类型
/*****************代码段(6) : static与不完全类型****************/ class Delivery_boy { public: void Pick_up_meals(std::string where, std::string which) {} void Food_delivery() {} private: static Delivery_boy p1; //静态成员可以是不完全类型 Delivery_boy* p2; Delivery_boy& p3; //指针和引用成员可以是不完全类型 Delivery_boy p4; //错误:不允许使用不完全类型 };
② 静态成员可作为默认实参
(C++ Primer P271)
二、继承
1、继承的概念以及实现
1.1继承的概念
通过继承(inheritance)联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类(base class), 其他类则直接或间接地从基类继承而来,这些继承得到的类成为派生类(derived class)。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自的有的成员
简单来说,继承就是一种类的复用。
1.2 定义派生类——派生类列表
派生类需要使用派生类列表指明它是从哪个或哪些基类继承而来的(涉及多继承);
派生类列表的格式:在派生类后加上冒号,后面跟上以逗号分隔的基类列表
(C++ Primer P529)
1.3 访问控制与继承
派生类继承了所有基类成员,但每个类控制着其成员对于派生类来说是否可访问(accessible)
1.3.1 受保护的成员(protected)
一个类使用protected关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员;
• 与private成员类似,protected成员不能该类的对象访问
• 和public成员类似,protected成员对于派生类的成员和友元来说是可访问的
(C++ Primer P543)
/*****************代码段(7) : protected成员****************/
class Person {
void eat(std::string food) {};
void buy(std::string goods) {};
protected:
int money;
int age;
};
class Delivery_boy : public Person{
public:
friend void Pick_up_meals(Delivery_boy&);
friend void Food_delivery(Person&);
std::vector<std::string> orders;
std::string time;
};
void Pick_up_meals(Delivery_boy& d) {
d.time = "2022";
d.age = 18; //基类的protected成员,可以被派生类的成员或者友元访问
}
//该函数是派生类的友元函数,无法被基类对象访问protected成员(根本上还是在类外访问protected成员,此时与private成员类似)
void Food_delivery(Person& p) {
p.age = 10; //错误
}
1.3.2 继承基类成员访问方式的变化
1.3.3 改变个别成员的可访问性 ——"using"
有时我们需要改变派生类继承基类的莫格成员的访问级别,可以通过 using 声明到达这一目的
(C++ Primer P545 不是很常用,了解即可)
2、派生类向基类的类型转换🔴
2.1 派生类对象的内存分布
/**************代码(7) : 基于以下代码建立对象的内存存储模型************/
class Person {
public:
int age;
char gender;
};
class Programmer : public Person {
public:
int wages;
char language;
};
int main() {
Person Rose;
Rose.age = 10;
Rose.gender = 'g';
Programmer dusong;
dusong.age = 20;
dusong.gender = 'b';
dusong.language = 'C';
dusong.wages = 250;
return 0;
}
调试上述代码(7)得下图:
从内存和监视窗口我们能很清晰的看到,从派生类对象指针所指向的内存的前八个字节为从基类继承下来的成员,后八个字节为派生类对象自定义的成员;
需要注意的是,继承自基类的部分和派生类自定义的部分不一定是连续存储的。
(C++ Primer P530)
2.2 派生类向基类的隐式类型转换
由2.1可知,因为在派生类对象中含有与其基类对应的组成成分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。(C++ Primer P530)
/**************代码段(8) : 在代码(7)的main函数的最后添加以下代码************/
Person* Rose_ptr = &Rose; //Rose_ptr为指向Rose的指针
Rose_ptr = &dusong; //(1) Rose_ptr指向dusong的Person部分
Person& nobody = dusong; //(2) nobody绑定到dusong的Person部分
如代码段(8)所示,派生类可以转换到 (1)基类的指针 / (2)基类的引用,但该转换只能是单向的(即只能由派生类到基类)。
2.3 切掉 —— "对象之间不存在类型转换"
派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。
当我们用一个派生类初始化或赋值一个基类时,实际调用的时基类的拷贝构造函数或赋值运算符重载函数,在这个过程中,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分会被切掉(sliced down) (C++ Primer P535)
同样,基类拷贝或赋值给派生类是错误的。
3、继承中的static与friend
3.1 继承于静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义 (C++ Primer P532)
3.2 友元与继承
友元关系不能传递,同样,友元关系也不能继承。
• 基类的友元在访问派生类成员时不具有特殊性 (C++ Primer P545)
怎么理解这句话呢?在C++ Primer书中的第545页的第一段举例的代码中有行很有意思的代码,我将其简化为以下代码,便于理解⬇️
/********************代码(9) : 证明友元关系不能继承******************/
class Base {
//Pal是Base的友元类
friend class Pal; //Pal在访问Base的派生类Sneaky时不具有特殊性
private:
int Base_mem;
};
class Sneaky : public Base { //Sneaky为Base的派生类
private:
int Sneaky_mem;
};
class Pal {
public:
int f1(Sneaky s) {
return s.Sneaky_mem; //❌:Pal是Base的友元,但Pal不是Sneaky的友元,证明了友元关系不能继承
}
int f2(Sneaky s) {
return s.Base_mem; //✔: Pal是Base的友元类,即可访问Base的私有成员
}
};
三者关系如图:
类似的,派生类的友元也不能随意访问基类的成员
将代码(9)中Pal类给成Sneaky的友元,Pal中 f1 函数中的语句正确和 f2 中的语句报错 ,即证明派生类的友元不能访问基类的成员。
4、继承中的作用域与隐藏
4.1 基类与派生类作用域关系
在继承体系中基类和派生类都有独立的作用域,并且派生类的作用域位于基类作用域之类,如果一个名字在派生类的作用域中无法正确解析,那么编译器会继续在外层的基类作用域中寻找该名字的定义
基于独立的作用域,我们可以在继承体系的各自类域中定义同名成员(在实际编程中尽量避免)
/********************代码(10) : 继承作用域实验******************/
class Base {
public:
Base() : mem1(1), mem2(2) {} //初始化
int func() { return mem1; }
protected:
int mem1;
int mem2;
};
class Derive : public Base{
public:
Derive() : mem1(11), mem3(33) {} //初始化
int func() { return mem1; } //隐藏基类中的func
protected:
int mem1; //隐藏基类中的mem1
int mem3;
};
int main() {
Base a;
Derive b;
return 0;
}
4.2 隐藏/重定义
派生类能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(派生类)的名字将隐藏定义在外层作用域(基类)的名字
总的来说,派生类的成员将隐藏同名的基类成员
(C++ Primer P548)
/**************代码段(11) : 将以下代码加入在代码(10)的主函数末尾***************/
std::cout << b.func() << std::endl; //打印:11
std::cout << b.Base::func() << std::endl; //打印:1 ,显示访问基类成员
5、派生类的默认成员函数
5.1 默认构造函数
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表显示调用
需要注意的是,图片中的代码,Base(x)先于mem1(11)执行,这与初始化列表中的顺序无关。
5.2 拷贝构造函数。
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
/**************代码段(12) : 派生类的拷贝构造函数***************/
class Base {
public:
Base(const Base& b) : mem1(b.mem1) {}
protected:
int mem1;
};
class Derive : public Base{
public:
Derive(const Derive& d) : Base(d), mem2(d.mem2) {} //显示调用基类的拷贝构造
protected:
int mem2;
};
5.3 赋值运算符重载( operator= )
同理,派生类的operator=必须调用基类的operator=完成赋值
/**************代码(13) : 派生类的赋值运算符***************/
class Base {
public:
Base(int x) : mem1(x) {}
Base& operator=(const Base& b) {
if (this != &b) mem1 = b.mem1;
return *this;
}
protected:
int mem1;
};
class Derive : public Base{
public:
Derive(int x, int y) : Base(x), mem2(y) {}
Derive& operator=(const Derive& d) {
Base::operator=(d); //显式调用基类的operator=
if (this != &d) mem2 = d.mem2;
return *this;
}
protected:
int mem2;
};
int main() {
Derive d1(10, 20);
Derive d2(30, 40);
d2 = d1; //如果没有显示调用基类的operator=,那么d2中mem1的值仍然是30;
return 0;
}
5.4 析构函数
在析构函数题执行完成后,对象成员会被隐式销毁。类似的,对象的基类部分也是隐式销毁的。因此,和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分配的资源 (C++ Primer P556)
/**************代码段(14) : 派生类的析构函数***************/
class Base {
public:
Base(int x) : mem1(x) {}
~Base() { /*该处由用户定义清理基类成员的操作*/ }
protected:
int mem1;
};
class Derive : public Base{
public:
Derive(int x, int y) : Base(x), mem2(y) {}
~Derive() { /*该处由用户定义清理派生类成员的操作*/ }
//Base::~Base() 在~Derive之后被自动调用
protected:
int mem2;
};
总结构造和析构函数,从创建到销毁一个派生类的顺序如下图⬇️
6、多继承及其造成的问题与解决方法
6.1 菱形继承及其问题
6.1.1 多继承
在上述1.2定义派生类中我们说到,基类列表由逗号分隔,表示派生类由多个基类继承
6.1.2 菱形继承模型
尽管在派生列表中同一个基类只能出现一次,但实际上派生类可以多次继承同一个类。派生类可以通过它的两个直接基类分别继承同一个间接基类(如下图),也可以直接继承某个基类,然后通过另一个基类再一次间接继承该类
(C++ Primer P717)
直接基类:不出现在派生类的派生类列表中的基类,直接基类以直接或间接方式继承的类是派生类的间接基类(如上图,Animal是Panda的间接基类)
间接基类: 派生类直接继承的基类,直接基类在派生类的派生列表中说明,直接基类本身也可以是一个派生类(如上图,ZooAnimal是Panda的直接基类)
6.1.3 数据冗余与二义性问题
我们通过下面代码(15)的简单例子来初步理解菱形继承带来的问题:
/******************代码(15) : 菱形继承*********************/
class A {
public:
int a;
};
class B : public A {
public:
int b;
};
class C : public A {
public:
int c;
};
class D : public B, public C {
public:
int d;
};
int main() {
D d;
d.b = 1;
d.c = 2;
//通过像是访问指定访问哪个父类成员可以解决二义性问题,但是数据冗余问题无法解决
d.B::a = 3; //通过对象d访问类B继承类A的成员a
d.C::a = 4; //通过对象d访问类C继承类A的成员a
d.d = 5;
return 0;
}
通过内存窗口与监视窗口,我们可以看到一个对象里同时存在了两个类A的成员,如果不指定类域,那么编译器将无法判断访问哪一个成员,造成二义性问题
通过指定类域可以解决上述问题,数据冗余无法的到解决,就如下图所示,d对象中包含两个a成员
6.2 虚继承实现和底层原理
在C++语言中我们通过虚继承(virtual inheritance)的机制解决上述问题。
虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享它自己基类子对象的基类称为虚基类(virtual base class)。 (C++ Primer P717)
6.2.1 虚拟继承的实现
实现虚拟继承实质上就是指定虚基类,我们指定虚基类的方式是在继承方式前面或者后面加上virtual关键字
/**************代码段(16) : 将代码段(15)的B、C类定义为虚基类***************/
class B : public virtual A {};
class C : virtual public A {};
虚基类会共享其基类的子对象,实现虚拟继承,进而解决数据冗余和二义性问题
6.2.2 虚拟继承底层原理🔴
通过代码段(16)的更改之后,对于更改成虚拟继承的代码(15)进行调试得下图⬇️
对比6.1.3的内存分配图,两个基类在派生类的内存分布的前四个字节均从一个整数变为一个指针,将该指针称为虚基表指针。由图的可知,该指针指向内存中的一块位置,称为虚基表。
我们主要关注 d.B::a = 3 这一段代码的汇编代码:
• 第一行:将对象d在内存中的起始位置的值转给eax寄存器,此时我们在监视窗口看到eax寄存器的值为0x00487b48, 即eax为虚基表指针;
• 第二行:将eax(0x00487b48)+4,得到0x00487b4c,随后将该地址的值move给ecx寄存器,此时ecx的值为20(十进制);
• 第三行:学过C语言可知,d[20] 等价与 *(d + 20) ,我们可以将这一行汇编代码理解为将与d对象起始位置相离偏移量为20的地址的值设为3;
通过上述操作可知,虚基类将自己的基类子对象存储在了派生类内存的一块共享位置,并通过自己的虚基表指针找到虚基表中的偏移量来访问该地址,以达到菱形继承造成的数据冗余和二义性问题!!!
7、继承与组合
7.1 " Is A " 与 "Has A"
当我们令一个类公有的继承另一个类时,派生类应当反映与基类“是一(Is A)”的关系;
类型之间的另一种常见关系时“有一个(Has A)”的关系,具有这种关系的类暗含成员的意思。 (C++ Primer P564)
7.2 继承和组合的应用场景
继承一定程度破坏了基类的封装,基类的改变对派生类的影响较大,派生类和基类间的以来关系很强,耦合度很高;而组合类之间没有很强的依赖关系,耦合度低,所以我们优先选择类之间的组合关系,但有些关系更适合用继承或者需要实现多态时,则使用继承
三、多态
1、虚函数🔴
1.1 虚函数的定义
对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)
要定义虚函数,只需在类的成员函数的声明前加上virtual关键字
/******************代码段(17) : 定义虚函数*********************/
class Common {
public:
virtual void Buy() { //定义虚函数
std::cout << "Price" << std::endl;
}
};
1.2 覆盖
1.2.1 虚函数的覆盖的条件与实现
派生类中定义的虚函数如果与基类中定义的虚函数 ①同名 且拥有相同的 ②参数列表 与 ③返回值,则派生类版本将覆盖(override)基类的版本 (C++ Primer P537/P576)
/******************代码段(18) : 虚函数的覆盖*********************/
class Common {
public:
virtual void Buy() {
std::cout << "Price" << std::endl;
}
};
class Member : public Common{
virtual void Buy() { //覆盖(重写)
std::cout << "discount" << std::endl;
}
};
1.2.2 协变 —— 返回值类型不同
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用时,称为协变
1.3 动态绑定与静态绑定
1.3.1 动态类型
对象在运行时的类型。引用所引对象或者指针所指对象的动态类型可能与该引用或指针的静态类型不同。
基类的指针或引用可以指向一个派生类的对象,在这样的情况中,静态类型是基类的引用或指针,而动态类型是派生类的引用或指针,如代码(18)。
/**************代码(19) : 在代码段(18)之后加上如下代码*****************/
void Buy_a_toy(Common& p) { //静态类型:Common&
p.Buy();
}
int main() {
Common cp;
Member mp;
Buy_a_toy(cp);
Buy_a_toy(mp); //动态类型:Member& 实质上是Common& 绑定到mp这个对象
}
1.3.2 静态类型
对象被定义的类型或表达式产生的类型。静态类型在编译时是已知的。
总结:当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同 (C++ Primer P537)
1.3.3 动态绑定
在C++中,当我们使用基类的引用或指针调用一个虚函数时将发生动态绑定(dynamic binding) (C++ Primer P527)
动态绑定直到运行时才确定到底执行函数的哪个版本(因此又称之为运行时绑定)。在C++中,动态绑定的意思是在运行时根据引用或指针所绑定的对象的实际类型来选择执行虚函数的某一个版本
1.3.4 静态绑定
在程序编译期间确定程序的行为,也称静态多态,(例如,函数重载)
1.4 虚析构函数
与普通类的析构函数一样,但我们delete一个动态分配(new)的对象的指针时将执行析构函数;
如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况(参见本章1.3节) (C++ Primer P552)
如果基类的析构函数不是虚函数,的delete一个指向派生类对象的基类指针将产生未定义行为(此时对调用基类的析构函数,导致派生类独自的空间未被释放)⬇️
我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本:⬇️(派生类对象的析构顺序参见第二章5.4节)
为什么这里派生类和基类的析构函数名字并不相同,但是能构成虚函数覆盖呢?这里可以理解编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
1.5 override 与 final 说明符
1.5.1 override
通过第二章4.2节介绍的,派生类如果定义了一个函数与积累中虚函数的名字相同但是形参列表不同,此时构成隐藏,时合法的行为,但是如果我们原本希望该函数覆盖掉基类的版本,此时调试并发现这样的错误是很难的。
在C++11标准中我们可以使用override关键字来标记某个派生类的函数,如果该函数没有覆盖已存在的虚函数,此时编译器将报错 (C++ Primer P538)
/*****************代码(20) : override使用**********************/
struct B {
virtual void f1(int) const;
virtual void f2();
void f3();
};
struct D : public B {
void f1(int) const override; //✔: f1与基类中的f1匹配
void f2(int) override; //❌:B中没有形如f2(int)的函数 ---> 避免了无意间的函数隐藏
void f3() override; //❌:f3不是虚函数
void f4() override; //❌:B中没有名为f4()的函数,自然不构成覆盖的条件
};
1.5.2 final
相反,如果我们一个虚函数定义成final,那么之后任何尝试覆盖该函数的操作都将引发错误:⬇️
1.6 虚函数与作用域🔴
本小节我们对于书上第550页的例子做深入了解,巩固我们之前学习的动态绑定、隐藏、覆盖等知识
/*****************代码(21) : 虚函数与作用域**********************/
class Base {
public:
virtual int func();
};
class D1 : public Base {
public:
int func(int); //隐藏
virtual void f2();
};
class D2 : public D1 {
public:
int func(int); //非虚函数,隐藏了D1中的func(int)函数
int func(); //覆盖Base中的虚函数func
void f2(); //覆盖D2中的虚函数f2
};
int main() {
Base bobj;
D1 d1obj;
D2 d2obj;
Base* bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj; //动态类型
bp1->func(); //(1)虚调用,运行时调用 Base::func()
bp2->func(); //(2)虚调用,D1中的func(int)没有对基类虚函数覆盖,运行时调用Base::func()
bp3->func(); //(3)虚调用,D2中重写了func()函数,运行时调用D2::func()
D1* d1p = &d1obj;
D2* d2p = &d2obj;
bp2->f2(); //(4)错误:Base没有名为 f2 的成员
d1p->f2(); //(5)虚调用,运行时调用D1::f2()
d2p->f2(); //(6)虚调用,运行时调用D2::f2()
Base* p1 = &d2obj;
D1* p2 = &d2obj;
D2* p3 = &d2obj;
p1->func(1); //(7)错误:Base中没有接收一个int的func函数
p2->func(1); //(8)静态绑定,调用D1::func(int)
p3->func(1); //(9)静态绑定,调用D2::func(int)
return 0;
}
对于(1)(2)(3),因为func是虚函数,所以编译器产生的代码将在运行时确定使用与函数的哪个版本,判断的依据是该指针所绑定对象的类型;
对于(7)(8)(9)的调用语句中,指针都指向了D2类型的对象,但是由于我们调用的是非虚函数,所以不会发生动态绑定。实际调用的函数版本 由指针的静态类型决定。
为什么第(4)(7) 语句会错呢?我们需要注意的是,基类指针绑定到派生类指针并不意味着他能调用派生类独自定义的函数,而是通过动态绑定调用派生类所覆盖的基类虚函数
1.7 重载、覆盖(重写)、隐藏(重定义) 的总结与对比
重载:两函数处于同一作用域;函数名相同;参数不同
覆盖(重写):两函数分别位于基类和派生类作用域;函数名、参数、返回值都相同(协变除外);两函数均为虚函数
隐藏(重定义):两函数分别位于基类和派生类作用域;函数名相同;
2、什么是多态
多态可分为动态多态和静态多态:
动态多态:程序能通过引用或指针的动态类型获取类型特定行为的能力
当我们使用基类的引用或指针调用基类中定义的一个函数是,我们并不知道该函数真正作用的对象是什么类型,因为它可能是一个基类的对象也可能是一个派生类的对象。如果该函数时虚函数,则直接运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型; (C++ Primer P537)
静态多态:例如函数重载、运算符重载、函数模板等
3、 抽象基类
3.1 纯虚函数
我们通过在虚函数函数体后加上 " = 0" 即可将一个虚函数说明为纯虚函数,一个纯虚函数无需定义
/*****************代码段(22) : 纯虚函数**********************/
class Common {
public:
virtual void Buy() = 0;
};
3.2 抽象基类
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)。抽象基类负责定义接口(因此又叫做接口类),而后续的其他类可以覆盖该接口。 (C++ Primer P540)
抽象基类(或未覆盖纯虚函数直接继承的派生类)无法实例化出对象
/*****************代码(23) : 抽象基类与纯虚函数的覆盖**********************/
class Common {
public:
virtual void Buy() = 0;
};
class Member1 : public Common {
void Buy() { //覆盖基类的纯虚函数
std::cout << "discount" << std::endl;
}
};
class Member2 : public Common {
};
int main() {
Common obj1; //报错:不允许使用抽象类类型Common的对象: Common::Buy()函数是纯虚拟函数
Member1 obj2; //正确
Member2 obj3; //派生类Member2未经覆盖直接继承,任然是抽象类,无法实例化出对象
}
4、多态的底层原理🔴
4.1 虚函数指针与虚函数表
多态的底层是如何实现的?如何实现通过指针或引用调用不同的虚函数?我们先从以下代码入手⬇️
/*************代码(24) : 通过监视窗口查看虚函数表指针与虚函数表*************/
class Base {
public:
virtual void f1() {}
virtual void f2() {}
void f3() {}
private:
int a = 1;
};
class Derive : public Base{
public:
void f1() {} //覆盖基类虚函数
virtual void f4() {} //Derive自己的虚函数
private:
int b = 2;
};
int main() {
Base b;
Derive d;
return 0;
}
调试代码(24),由监视窗口查看基类和派生类对象的构成⬇️
图中_ vfptr 称为虚函数表指针,指向虚函数表,虚函数表实质上是存放虚函数指针的数组。
由图可知,派生类与基类的虚函数表指针指向地址不同,派生类对f1完成了覆盖,因此Base::f1() 与 Derive::f1() 的函数地址不同,而f2没有完成覆盖,两者函数地址相同;
这里的派生类通用定义了一个虚函数f4,但在监视窗口中没有看见,实际上该虚函数地址也存放在了派生类的虚函数表中,我们通过后面的学习验证这个说法;
4.2底层原理
/*************代码段(25) : 继承体系参考代码(24)*************/
Base bobj;
Derive dobj;
Base* p1 = &bobj, * p2 = &dobj;
p1->f1();
p2->f1();
由代码段(25)的汇编代码得:
4.3 单继承与复杂继承关系的虚函数表
4.2.1 单继承
在本章节的4.1节说到,在派生类的虚函数表中没有看到派生类自己的虚函数,我们通过以下代码打印虚函数地址验证
/*************代码(26) : 验证派生类虚函数表中的虚函数*************/
class Base {
public:
virtual void f1() { cout << "Base::f1()" << endl; }
virtual void f2() { cout << "Base::f2()" << endl; }
void f3() { cout << "Base::f3()" << endl; }
private:
int a;
};
class Derive : public Base{
public:
virtual void f1() { cout << "Derive::f1()" << endl; }
virtual void f4() { cout << "Derive::f4()" << endl; }
void f5() { cout << "Derive::f5()" << endl; }
private:
int b;
};
typedef void(*vfptr) ();
void Printvftable(vfptr vftable[]) {
for (int i = 0; vftable[i] != nullptr; i++) {
printf("第%d个虚函数地址:%p : ", i, vftable[i]);
vfptr f = vftable[i];
f(); //call
}
}
int main() {
Base bobj;
Derive dobj;
//取派生类对象的地址,强转为整型指针,再解引用得到前四个字节,再强转为函数指针数组指针
vfptr* d_vftable = (vfptr*)(*(int*)&dobj);
Printvftable(d_vftable);
return 0;
}
可以看出,内存窗口中的虚函数表与打印内容完全相符⬇️
4.2.2 多继承
/*************代码(27) : 验证多继承派生类虚函数表中的虚函数*************/
class Base1 {
public:
virtual void f1() { cout << "Base::f1()" << endl; }
virtual void f2() { cout << "Base::f2()" << endl; }
private:
int b1;
};
class Base2 {
public:
virtual void f1() { cout << "Base::f1()" << endl; }
virtual void f3() { cout << "Base::f3()" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2{
public:
virtual void f1() { cout << "Derive::f1()" << endl; } //覆盖
virtual void f4() { cout << "Derive::f4()" << endl; } //派生类自己的虚函数
private:
int d;
};
typedef void(*vfptr) ();
void Printvftable(vfptr* vftable) {
cout << "虚函数表地址:" << vftable << endl;
for (int i = 0; vftable[i] != nullptr; i++) {
printf("第%d个虚函数地址:%p : ", i, vftable[i]);
vfptr f = vftable[i];
f(); //call
}
}
int main() {
Derive dobj;
//取派生类对象的地址,强转为整型指针,再解引用得到前四个字节,再强转为函数指针数组指针
vfptr* vftable1 = (vfptr*)(*(int*)&dobj);
Printvftable(vftable1);
//强转为char*向后移动sizeof(Base1)个字节,此时指向虚函数表指针,强转为int*解引用取前四个字节,即取到虚表地址
vfptr* vftable2 = (vfptr*)(*(int*)((char*)&dobj + sizeof(Base1)));
Printvftable(vftable2);
return 0;
}
由下图内存和代码结果可知,多继承的派生类中含有多个虚表指针,派生类的虚函数存放再第一张虚表中。
4.2.3 菱形继承
/*************代码(28) : 验证菱形继承派生类虚函数表中的虚函数*************/
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址 :%p:", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
class A {
public:
virtual void f1() { cout << "A::f1()" << endl; }
virtual void f2() { cout << "A::f2()" << endl; }
int a;
};
class B : public A {
public:
virtual void f1() { cout << "B::f1()" << endl; }
virtual void f3() { cout << "B::f3()" << endl; }
int b;
};
class C : public A {
public:
virtual void f1() { cout << "C::f1()" << endl; }
virtual void f4() { cout << "C::f4()" << endl; }
int c;
};
class D : public B, public C {
public:
virtual void f1() { cout << "D::f1()" << endl; } //覆盖
virtual void f5() { cout << "D::f5()" << endl; }
int d;
};
int main() {
D d;
d.b = 1;
d.c = 2;
d.B::a = 3;
d.C::a = 4;
d.d = 5;
VFPTR* vftable = (VFPTR*)(*(int*)&d);
PrintVTable(vftable);
return 0;
}
由调式信息和打印结果可知,派生类D继承了B和C,因此拥有两张虚表,第一张虚表存放覆盖的虚函数、间接基类A的虚函数、直接基类B的虚函数以及派生类D自己的虚函数(与多继承一样,派生类的虚函数存放在第一张虚表中)
4.2.4 菱形虚拟继承
/*************代码段(29) : 验证菱形虚拟继承派生类虚函数表中的虚函数*************/
/*将代码(28)中的对应部分加上virtual关键字*/
class B : virtual public A { //虚拟继承
public:
virtual void f1() { cout << "B::f1()" << endl; }
virtual void f3() { cout << "B::f3()" << endl; }
int b;
};
class C : virtual public A { //虚拟继承
public:
virtual void f1() { cout << "C::f1()" << endl; }
virtual void f4() { cout << "C::f4()" << endl; }
int c;
};
可以看到一个简单的菱形虚拟继承的派生类组成非常复杂,
不同于菱形继承,虚拟继承共享虚基类的成员(地址唯一),而对于继承虚基类的多个派生类若拥有自己的虚函数,那么不能像普通的继承一样,将自己的虚函数存放在基类的虚函数表中,而是建立自己独立的虚表并且将虚基类的虚表独立出来,如上图所示,对于派生类D,监视窗口中显示直接基类B直接包含了间接基类A的虚表指针,且在内存中虚基类A的虚表指针独立出来。
下图,打印l对象的前四个字节所指向的内容(内存中存放的第一个虚表)⬇️
可以看出,派生类的虚函数任然存放在内存中的第一张虚表中,通过打印可验证,直接基类B的虚表中不含B的基类A的虚函数