C++核心编程 day07 静态联编和动态联编、多态、虚函数与纯虚函数
1. 静态联编和动态联编
静态联编和动态联编实际上就是C++中的多态。面向对象有三个基本特征,分别是封装、继承和多态,而在前面我们已经学习了封装和继承的一系列知识。多态提供了接口与具体实现之间的隔离。多态可以改善代码的可读性和组织性,同时也使得程序具有良好的可扩展性,项目不仅可以在创建初期的时候扩展,也可以在后期项目需要新功能的时候也能很好地扩展。
多态可以分为静态多态和动态多态。静态多态和动态多态的区别在于函数地址是早绑定还是晚绑定。例如前面学的运算符的重载和函数的重载就是在编译时候进行函数绑定的,也就是静态多态,在编译阶段就可以确定函数的调用地址。而动态多态属于晚绑定,也就是程序在运行时才能确定函数的调用地址。比如前面类的继承就是一种动态多态,此节后续的虚函数实现也是一种动态多态。静态多态也就是静态联编,动态多态也就是动态联编。接下来我们看一个关于静态多态的例子,代码如下:
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
class Animal
{
public:
void speak()
{
cout << "动物在说话" << endl;
}
void eat(int a)
{
cout << "动物在吃饭" << endl;
}
};
class Cat : public Animal
{
public:
void speak()
{
cout << "小猫在说话" << endl;
}
void eat(int a)
{
cout << "小猫在吃饭" << endl;
}
};
class Dog : public Animal
{
public:
void speak()
{
cout << "小狗在说话" << endl;
}
void eat(int a)
{
cout << "小狗在吃饭" << endl;
}
};
void doAct(Animal &animal)
{
animal.speak();
animal.eat(1);
}
void test01()
{
Cat cat;
doAct(cat);
Dog dog;
doAct(dog);
}
int main()
{
test01();
system("pause");
return 0;
}
该程序的运行结果为:
我们先单独地看一下关于Dog
类的模型:
实际上在编译的时候我们就已经决定了这里Animal
类中的speak
和eat
函数就绑定了&Animal::speak
和&Animal::eat
函数。如果我们想要实现Cat
类使用Cat
类的speak
和eat
函数,Dog
类使用Dog
类的speak
和eat
函数,我们就需要Animal
类中的speak
和eat
函数实现动态的绑定,也就是运行的时候才决定函数的调用地址。实现这个方法可以使用虚函数来实现,也就是在Animal
类中的speak
和eat
函数前面加上virtual
进行修饰,此时就会出现动态绑定,代码如下。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
class Animal
{
public:
// 虚函数
virtual void speak()
{
cout << "动物在说话" << endl;
}
virtual void eat(int a)
{
cout << "动物在吃饭" << endl;
}
};
class Cat : public Animal
{
public:
void speak()
{
cout << "小猫在说话" << endl;
}
void eat(int a)
{
cout << "小猫在吃饭" << endl;
}
};
class Dog : public Animal
{
public:
void speak()
{
cout << "小狗在说话" << endl;
}
void eat(int a)
{
cout << "小狗在吃饭" << endl;
}
};
// 动态多态产生条件:
// 先有继承关系
// 父类中有虚函数,子类重写了父类中的虚函数
// 父类的指针或者引用指向了子类对象
// 对于有父子关系的两个类 指针或者引用是可以直接转换的
void doAct(Animal &animal)
{
// 如果地址早就绑定好了,地址早绑定属于静态联编
// 如果想要调用子类中的方法,那么函数的地址就不能早就绑定好,而是在运行阶段再去绑定函数调用地址,属于动态联编
animal.speak();
animal.eat(1);
}
void test01()
{
Cat cat;
doAct(cat);
Dog dog;
doAct(dog);
}
int main()
{
test01();
system("pause");
return 0;
}
此时的运行结果为:
实现了各个类分别调用各个类中的函数。那么动态多态的产生要有什么条件呢?需要三个条件,分别如下:
- 类之间要有继承关系。
- 父类中有虚函数,子类要重写了父类中的虚函数。在子类中重写的的父类虚函数前的
virtual
关键字可以加上,也可以不用加上。 - 父类的指针或者引用指向了子类对象。 这种情况下不需要对对象进行强制类型转换。
我们继续看一下关于这个Dog
类的模型:
我们可以发现这里是维护了一个vfptr
指针,也就是虚函数表指针。虚函数表指针指向vftable
,也就是虚函数表。在表中我们可以看见有两个函数的入口地址,这两个入口地址都是本类中重写了的虚函数。假如我们没有重写父类的虚函数呢?则会绑定父类的虚函数地址,下面是没有重写时候的Dog
类模型图。
这样就可以实现我们的多态。同样因为我们在动态多态中的时候需要维护一个虚函数表,所有在空对象的时候也会有一个指针的大小,所有空对象所占用的内存空间也是4
字节,而在静态多态中空对象占用的空间大小为1
字节。
既然如此接下来有这么一行代码:
Animal *animal = new Dog;
那么我们如何根据指针的偏移去调用这两个函数呢?首先我们来看看speak
函数。
首先animal
就是该对象的地址,接下来强制转换为int *
类型,由于虚函数表指针的偏移量是0
,所以直接解引用就是虚函数表。也就是*(int *)animal
就表示的是虚函数表。接下来将类型强制转换为int *
指针,由于&Dog::speak
的偏移量为0
,所有直接解引用就是Dog::speak
的地址。也就是*(int *)*(int *)animal
表示的就是&Dog::speak
,但是指针类型不对,由于speak
的返回值为void
类型,形参列表为空,将该地址强转为void (*)()
函数类型就可以直接调用。所有最终的调用形式为((void (*)()) *(int *)*(int *)animal)();
。
参照上述的操作,我们也可以写出来eat
函数使用指针偏移进行调用的形式,答案是((void (*)(int)) *((int *)*(int *)animal + 1))(1);
。但是这个结果运行的时候会出现以下的错误.
这里的错误并不是我们写错了,而是我们的调用惯例不对。调用惯例在C语言阶段已经提过,主要是函数的入栈方式以及函数结束的时候空间由主调函数释放还是被调函数释放。由于我们的eat
函数只有一个参数,所以不可能是形参的入栈顺序出错,因为无论是从左往右入栈还是从右往左入栈的结果都是一样的,所以应该是释放内存的时候出现的错误。在我们C/C++中,默认的调用惯例是__cdecl
,而我们真实的调用惯例却是__stdcall
,所以我们需要在上述的强制类型转换中写明调用管理。定义函数指针类型可以按照一下的类型定义。
typedef 返回类型 (调用管理 *函数类型名)(形参列表);
因此在我们强制类型转化的时候可以写成这样((void (__stdcall*)(int)) *((int *)*(int *)animal + 1))(1);
。这样就可以不会报任何错误。
上述完整的代码如下(略有重复)。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
class Animal
{
public:
// 虚函数
virtual void speak()
{
cout << "动物在说话" << endl;
}
virtual void eat(int a)
{
cout << "动物在吃饭" << endl;
}
};
class Cat : public Animal
{
public:
void speak()
{
cout << "小猫在说话" << endl;
}
void eat(int a)
{
cout << "小猫在吃饭" << endl;
}
};
class Dog : public Animal
{
public:
void speak()
{
cout << "小狗在说话" << endl;
}
void eat(int a)
{
cout << "小狗在吃饭" << endl;
}
};
// 动态多态产生条件:
// 先有继承关系
// 父类中有虚函数,子类重写了父类中的虚函数
// 父类的指针或者引用指向了子类对象
// 对于有父子关系的两个类 指针或者引用是可以直接转换的
void doAct(Animal &animal)
{
// 如果地址早就绑定好了,地址早绑定属于静态联编
// 如果想要调用子类中的方法,那么函数的地址就不能早就绑定好,而是在运行阶段再去绑定函数调用地址,属于动态联编
animal.speak();
animal.eat(1);
}
void test01()
{
Cat cat;
doAct(cat);
Dog dog;
doAct(dog);
}
void test02()
{
Animal *animal = new Dog;
// *(int *)animal 解引用到虚函数表中
// *(int *)*(int *)animal 解引用到函数speak的地址
((void (*)()) *(int *)*(int *)animal)();
// C/C++默认调用惯例是__cdecl
// 在下面调用的时候真实的调用惯例是__stdcall
((void (__stdcall*)(int)) *((int *)*(int *)animal + 1))(1);
}
int main()
{
test01();
test02();
system("pause");
return 0;
}
2. 多态案例----计算器案例
在设计的时候,我们有一个开闭原则。也就是对扩展进行开放,对修改进行关闭。例如接下来的计算器案例我们使用多态设计如下:
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
#include <string.h>
class AbstractCaculator
{
public:
virtual int getResult()
{
return 0;
}
int m_A;
int m_B;
};
// 加法计算器
class AddCaculator : public AbstractCaculator
{
public:
virtual int getResult()
{
return this->m_A + this->m_B;
}
};
// 减法计算器
class SubCaculator : public AbstractCaculator
{
public:
virtual int getResult()
{
return this->m_A - this->m_B;
}
};
// 乘法计算器
class MulCaculator : public AbstractCaculator
{
public:
virtual int getResult()
{
return this->m_A * this->m_B;
}
};
int main()
{
AbstractCaculator *caculator = new AddCaculator;
caculator->m_A = 10;
caculator->m_B = 20;
cout << caculator->getResult() << endl;
delete caculator;
caculator = new SubCaculator;
caculator->m_A = 20;
caculator->m_B = 10;
cout << caculator->getResult() << endl;
delete caculator;
caculator = new MulCaculator;
caculator->m_A = 10;
caculator->m_B = 10;
cout << caculator->getResult() << endl;
delete caculator;
system("pause");
return 0;
}
在上面的案例中,我们在设计计算器类的时候只设计了virtual int getResult();
,也就是提供了一个规范,这样在后面的继承之中只需要重写这个接口函数就行。这样我们有了新的功能也只需要继续继承重写即可。但是这个不会强制让你重写。如果需要强制重写,则我们需要将上述的接口定义纯虚函数。纯虚函数的定义为virtual 返回值 函数名(形参列表) = 0;
,例如上面的计算器类可以写成这样。
class AbstractCaculator
{
public:
virtual int getResult() = 0;
int m_A;
int m_B;
};
需要注意的是如果一个类中有纯虚函数,则这个类不能进行实例化对象。当一个类继承了有纯虚函数的类,那么子类没有重写纯虚函数的话也不能实例化对象。纯虚函数强制规定了子类必须重写原来的某个纯虚函数,否则不能实例化,同时某些函数我们只是设计了接口不需要实现的时候就可以写成纯虚函数。有纯虚函数的类我们通常称为抽象类。
3. 虚析构和纯虚析构
在析构函数前面加上关键字virtual
后就是虚析构函数。如下面的代码:
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
class Animal
{
public:
Animal()
{
cout << "Animal的构造函数调用" << endl;
}
virtual void speak()
{
cout << "动物在说话" << endl;
}
virtual ~Animal()
{
cout << "Animal的虚析构函数调用" << endl;
}
};
class Cat : public Animal
{
public:
Cat(char *name)
{
cout << "Cat的构造函数调用" << endl;
this->name = new char[strlen(name) + 1];
strcpy(this->name, name);
}
void speak()
{
cout << "小猫在说话" << endl;
}
~Cat()
{
if (this->name != NULL)
{
cout << "Cat的析构函数调用" << endl;
delete[] this->name;
this->name = NULL;
}
}
char *name;
};
int main()
{
Animal *animal = new Cat("Tom");
animal->speak();
delete animal;
system("pause");
return 0;
}
现在我们的要求是在使用delete
的时候animal
会去调用Cat
类中的析构函数实现name
在堆区的数据的释放。这个程序的运行结果如下。
我们可以知道虚析构函数的作用是在派生类派生类对象被删除时,先调用派生类的析构函数,再调用基类的析构函数。这样可以确保对象的析构顺序正确,避免在派生类析构函数中访问已经被删除的成员变量和对象错误。而纯虚函数的作用是将派生类的析构函数声明为纯虚函数,从而强制派生类必须实现自己的析构函数。如果一个类中有了纯虚析构函数,那么这个类也是抽象类,不可以实例化对象。上述的代码要实现释放堆区的内存则需要改成如下。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
class Animal
{
public:
Animal()
{
cout << "Animal的构造函数调用" << endl;
}
virtual void speak()
{
cout << "动物在说话" << endl;
}
// 如果子类中有指向堆区的属性,那么利用虚析构技术,在delete的时候调用子类的析构函数
//virtual ~Animal()
//{
// cout << "Animal的虚析构函数调用" << endl;
//}
// 纯虚析构需要有声明,也需要有实现
// 如果一个类中有了纯虚析构函数,那么这个类也属于抽象类,无法实例化对象
virtual ~Animal() = 0;
};
Animal::~Animal()
{
cout << "Animal的纯虚析构函数调用" << endl;
}
class Cat : public Animal
{
public:
Cat(char *name)
{
cout << "Cat的构造函数调用" << endl;
this->name = new char[strlen(name) + 1];
strcpy(this->name, name);
}
void speak()
{
cout << "小猫在说话" << endl;
}
~Cat()
{
if (this->name != NULL)
{
cout << "Cat的析构函数调用" << endl;
delete[] this->name;
this->name = NULL;
}
}
char *name;
};
int main()
{
Animal *animal = new Cat("Tom");
animal->speak();
delete animal;
system("pause");
return 0;
}
运行结果如下。
4. 多态案例----电脑组装案例
在这里我们就对多态的学习结束了。在多态的时候我们是将父类的指针指向子类的对象,或者是父类的引用接收子类的对象,这是不需要去强制类型转换的,这是安全的。但是如果我们把父类的对象强制类型转化子类就会有安全隐患的产生,因为当父类转换为子类的时候就会出现访问越界的情况。
最后再区分一个概念,关于函数的重载、重定义以及重写。函数的重载要求必须在同一个作用域中且函数名相同。重载可以是函数的参数个数,也可以是函数的参数顺序,或者是参数的数据类型。重载是和函数返回值是没有任何关系的。函数的重定义发生在继承之中,子类重新定义了父类的非虚函数,此时编译器会把父类的同名成员函数包括所有同名的重载都隐藏起来,需要访问必须借助作用域运算符。函数的重写也是要求有继承关系,是子类重写了父类的虚函数。函数的返回值、函数的名字以及函数参数必须和基类中的虚函数保持一致。
下面给一个关于多态的计算机组装的案例。要求是由三个基类分别是CPU、显卡和内存条。有两家厂商分别生产这三样东西,而我们需要将这些东西组装成一台电脑,代码如下。
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
// CPU基类
class CPU
{
public:
virtual void caculate() = 0;
};
// 显卡基类
class GraphicsCard
{
public:
virtual void display() = 0;
};
// 内存条基类
class Memory
{
public:
virtual void storage() = 0;
};
// 电脑
class Computer
{
public:
Computer(CPU *cpu, GraphicsCard *graphicsCard, Memory *memory)
{
cout << "电脑的构造函数调用" << endl;
this->cpu = cpu;
this->graphicsCard = graphicsCard;
this->memory = memory;
}
void work()
{
this->cpu->caculate();
this->graphicsCard->display();
this->memory->storage();
}
~Computer()
{
cout << "电脑的析构函数调用" << endl;
if (this->cpu != NULL)
{
delete this->cpu;
this->cpu = NULL;
}
if (this->graphicsCard != NULL)
{
delete this->graphicsCard;
this->graphicsCard = NULL;
}
if (this->memory != NULL)
{
delete this->memory;
this->memory = NULL;
}
}
CPU *cpu;
GraphicsCard *graphicsCard;
Memory *memory;
};
// Intel厂家
class IntelCPU : public CPU
{
public:
void caculate()
{
cout << "Intel CPU正在计算" << endl;
}
};
class IntelGraphicsCard : public GraphicsCard
{
public:
void display()
{
cout << "Intel 显卡正在显示" << endl;
}
};
class IntelMemory : public Memory
{
public:
void storage()
{
cout << "Intel 内存条正在存储" << endl;
}
};
// Lenovo厂商
class LenovoCPU : public CPU
{
public:
void caculate()
{
cout << "Lenovo CPU正在疯狂计算中" << endl;
}
};
class LenovoGraphicsCard : public GraphicsCard
{
public:
void display()
{
cout << "Lenovo 显卡正在疯狂显示中" << endl;
}
};
class LenovoMemory : public Memory
{
public:
void storage()
{
cout << "Lenovo 内存条正在疯狂存储中" << endl;
}
};
int main()
{
cout << "组装第一台电脑:" << endl;
CPU *cpu1 = new LenovoCPU;
GraphicsCard *graphicsCard1 = new IntelGraphicsCard;
Memory *memory1 = new IntelMemory;
Computer *computer1 = new Computer(cpu1, graphicsCard1, memory1);
computer1->work();
delete computer1;
cout << "组装第二台电脑:" << endl;
CPU *cpu2 = new IntelCPU;
GraphicsCard *graphicsCard2 = new IntelGraphicsCard;
Memory *memory2 = new IntelMemory;
Computer *computer2 = new Computer(cpu2, graphicsCard2, memory2);
computer2->work();
delete computer2;
cout << "组装第三台电脑:" << endl;
CPU *cpu3 = new LenovoCPU;
GraphicsCard *graphicsCard3 = new LenovoGraphicsCard;
Memory *memory3 = new LenovoMemory;
Computer *computer3 = new Computer(cpu3, graphicsCard3, memory3);
computer3->work();
delete computer3;
system("pause");
return 0;
}