十年网站开发经验 + 多家企业客户 + 靠谱的建站团队
量身定制 + 运营维护+专业推广+无忧售后,网站问题一站解决
目录🌠作者:@阿亮joy.
成都创新互联公司网站建设公司,提供做网站、网站制作,网页设计,建网站,PHP网站建设等专业做网站服务;可快速的进行网站开发网页制作和功能扩展;专业做搜索引擎喜爱的网站,是专业的做网站团队,希望更多企业前来合作!
🎆专栏:《吃透西嘎嘎》
🎇座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
👉多态的概念👈需要注意的是,本篇博客中的代码及解释都是在 VS2013 下的 x86 程序中,涉及的指针都是 4 字节。如果要其他平台下,部分代码需要改动。比如:如果是 x64 程序,则需要考虑指针是 8 字节问题等等。
👉多态的定义及实现👈 虚函数多态的概念:通俗来说,就是多种形态。具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。 注:多态分为编译时多态和运行时多态,也叫早期绑定和晚期绑定。编译时多态是早期绑定,主要通过重载实现,模板属于编译时多态。
举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。再举个栗子: 最近为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包-支付-给奖励金的活动。那么大家想想为什么有人扫的红包比较大,而有的人扫的红包都是 1 毛,5 毛等。其实这背后也是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、比如你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么你的扫码金额可能是 rand()%99;比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你去使用支付宝,那么就你扫码金额等于 rand()%1;总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。
虚函数:即被 virtual 修饰的类成员函数称为虚函数。注:全局函数不能是虚函数,只有类的成员函数才能是虚函数。虚函数是存在代码段(常量区)的。注:虚函数在类中声明和类外定义时候,virtual 关键字只在声明时加上,在类外实现时不能加。
class Person
{public:
virtual void BuyTicket() {cout<< "买票-全价"<< endl;}
};
虚函数的重写多态的构成条件虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。不符合重写,就构成隐藏关系(重定义)。
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如 Student 继承了 Person,Person 对象买票全价,Student 对象买票半价。
那么在继承中要构成多态还有两个条件:
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
#include#includeusing namespace std;
class Person
{public:
virtual void BuyTicket() {cout<< "买票-全价"<< endl; }
};
class Student : public Person
{public:
// 虚函数重写(覆盖):虚函数+三同(函数名、参数、返回值)
// 不符合重写, 就是隐藏关系(重定义)
virtual void BuyTicket() {cout<< "买票-半价"<< endl; }
};
class Soldier : public Person
{public:
virtual void BuyTicket() {cout<< "买票-优先"<< endl; }
};
void Func(Person& p)
{p.BuyTicket();
}
int main()
{Person ps;
Student st;
Soldier sd;
Func(ps);
Func(st);
Func(sd);
return 0;
}
破坏多态:不是通过基类的引用或指针调用虚函数
破坏多态:不满足虚函数重写的条件
基类函数不是虚函数
注意:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。
虚函数的参数不同
特例
协变(基类与派生类虚函数返回值类型不同):派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。注:只要父子关系就行,但需要父类返回父类的指针或引用,子类返回子类的指针或引用。
如果同名虚函数返回值不同且不形成协变,编译器会直接报错,原因是虚函数重写是接口继承,实现重写。
一道小小的笔试题
以下程序输出结果是什么()
- A: A->0
- B: B->1
- C: A->1
- D: B->0
- E: 编译出错
- F: 以上都不正确
class A
{public:
virtual void func(int val = 1)
{std::cout<< "A->"<< val<< std::endl;
}
virtual void test()
{func();
}
};
class B : public A
{public:
void func(int val = 0)
{std::cout<< "B->"<< val<< std::endl;
}
};
int main(int argc, char* argv[])
{B* p = new B;
p->test();
return 0;
}
答案:B
解析:
多态调用,需要注意指针指向谁以及引用的是谁,指向谁就调用谁的虚函数,引用谁就调用谁的虚函数。
注:如果子类重写了父类的虚函数,但只要是用对象去调用,则只能调用对应类型的方法。
析构函数的重写特例:析构函数的重写(基类与派生类析构函数的名字不同) 如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则。其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor
编译器将析构函数的名称统一处理成 destructor 是为了构成多态。当父类的析构函数不加 virtual 时,上面的场景就会出错。所以父类的析构函数建议加上 virtual,让子类的析构函数重写父类的析构函数,形成多态,才能保证 ptr1 和 ptr2 指向的对象正确地调用析构函数。
// 建议在继承中析构函数定义成虚函数
class Person
{public:
virtual ~Person() {cout<< "~Person()"<< endl; }
//int* _ptr;
};
class Student : public Person
{public:
// 析构函数名会被处理成destructor,所以这里析构函数完成虚函数重写
virtual ~Student() {cout<< "~Student()"<< endl; }
};
int main()
{Person* ptr1 = new Person;
delete ptr1;
Person* ptr2 = new Student;
delete ptr2;
return 0;
}
C++11 override 和 finalC++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失。因此,C++11提供了 override 和 final 两个关键字,可以帮助用户检测虚函数是否重写。
final:修饰虚函数,表示该虚函数不能再被重写。
// 注:以下代码无法通过编译
class Car
{public:
virtual void Drive() final {}
};
class Benz :public Car
{public:
virtual void Drive() {cout<< "Benz-舒适"<< endl; }
};
int main()
{return 0;
}
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Car
{public:
virtual void Drive() {}
};
class Benz :public Car
{public:
// 检查子类虚函数是否完成重写
virtual void Drive() override {cout<< "Benz-舒适"<< endl; }
};
int main()
{return 0;
}
在虚函数的后面写上 = 0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象,一般用于接口的定义。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。注:纯虚函数可以有函数体,但是意义不大。子类不重写父类所有的纯虚函数,则子类还属于抽象类,仍然不能实例化对象
一道小小的选择题
接口继承和实现继承假设 A 为抽象类,下列声明( )是正确的
- A. A fun(int);
- B. A*p;
- C. int fun(A);
- D. A obj;
答案:B
解析:A. 抽象类不能实例化对象,所以以对象返回是错误。B. 抽象类可以定义指针,而且经常这样做,其目的就是用父类指针指向子类从而实现多态。C. 参数为对象,所以错误。D. 抽象类直接实例化对象,这是不允许的。
👉多态的原理👈 虚函数表普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{public:
virtual void Func1()
{cout<< "Func1()"<< endl;
}
private:
int _b = 1;
};
int main()
{cout<< sizeof(Base)<< endl;
return 0;
}
通过观察测试我们发现 Base 对象是 8 字节,因为除了_b成员,还多一个 __vfptr 放在对象的前面(注意有些
平台可能会放到对象的最后面,这个跟平台有关)。对象中的这个指针,我们叫做虚函数表指针(v 代表 virtual,f 代表 function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。注:只有虚函数才会进入虚表。
class Person
{public:
virtual void BuyTicket() {cout<< "买票-全价"<< endl; }
virtual void Func() {cout<< "Func"<< endl;
void Fun1() {cout<< "Fun1"<< endl; }
}
int _a = 0;
};
class Student : public Person {public:
virtual void BuyTicket() {cout<< "买票-半价"<< endl; }
int _b = 0;
};
void Func(Person& p)
{p.BuyTicket();
}
int main()
{Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
return 0;
}
多态的本质原理:如果符合多态的两个条件,那么调用是就会到指向对象的虚表中找到对应的虚函数地址,进行调用。
多态调用,程序运行时去指向对象的虚表中找到函数的地址,进行调用;普通函数调用(尽管该函数是虚函数也不是运行时去虚表中找函数地址),编译链接时确定函数的地址,运行时直接调用。
通过观察和测试,我们发现了以下几点问题:
- 派生类对象 Johnson 中也有一个虚表指针,其虚表指针是和父类的虚表指针是不一样的。
- 基类 Johnson 对象和派生类 Mike 对象虚表是不一样的。我们发现可以 Func 完成了重写,所以 Johnson 的虚表中存的是重写的Student::BuyTicket 函数,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 另外 Func 函数继承下来后是虚函数,所以放进了虚表。Func1 函数也继承下来了,但不是虚函数,所以不会放进虚表。
- 虚函数表本质是一个存虚函数指针的指针数组。一般情况,这个数组最后面放了一个 nullptr。
- 虚表中存的是虚函数指针,不是虚函数。虚函数和普通函数一样的,都是存在代码段的,只是它的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现 VS 将虚表存在了代码段,Linux g++下大家自己去验证。
- VS 下不管是否完成重写,子类虚表跟父类虚表都不是同一个。同一个类型的对象共用一个虚表。
注:子类没有重写父类的虚函数,此时没有形成多态。尽管没有形参多态,对这个虚函数的调用也是运行时决议,需要到虚表中找函数的地址。所以不是为了实现多态,就不要将父类的成员函数弄成虚函数。
动态绑定与静态绑定👉单继承和多继承关系的虚函数表👈
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
- 买票的汇编代码很好第解释了什么是静态(编译器)绑定和动态(运行时)绑定。
单继承中的虚函数表需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的。
class Person
{public:
virtual void BuyTicket()
{cout<< "买票-全价"<< endl;
}
virtual void Func1()
{cout<< "Person::Func1()"<< endl;
}
};
class Student : public Person
{public:
virtual void BuyTicket()
{cout<< "买票-半价"<< endl;
}
virtual void Func2()
{cout<< "Student::Func2()"<< endl;
}
};
int main()
{// 同一个类型的对象共用一个虚表
Person p1;
Person p2;
// 不管是否完成重写,子类虚表跟父类虚表都不是同一个
Student s1;
Student s2;
return 0;
}
不管子类是否重写父类的虚函数,子类的虚表和父类的虚表都不是同一个虚表。同一个类型的对象共用一个虚表。监视窗口中看到的虚表不全面,我们可以通过内存来看。
虚函数表是一个函数指针数组,一般情况下,它是以 nullptr 结尾的。我们可以写一个函数来打印虚表中的函数地址。
打印虚函数表思路:
先取 p1 的地址,强转成一个 int* 的指针,再解引用取值,就取到了 p1 对象头四个字节的值,这个值就是指向虚表的指针,再强转成 VFPTR*。因为虚表就是一个存VFPTR 类型(虚函数指针类型)的数组。 最后将虚表指针传递给 PrintVFTable 函数进行打印虚表。
注:对象的前四个字节是虚表的指针。
typedef void(*VFPTR)();
//void PrintVFTable(VFPTR* table)
void PrintVFTable(VFPTR table[])
{for (size_t i = 0; table[i] != nullptr; ++i)
{printf("vft[%d]:%p->", i, table[i]);
table[i](); // 调用虚表中的函数
//VFPTR fp = table[i];
//fp();
}
cout<< endl;
}
int main()
{Student s1;
PrintVFTable((VFPTR*)(*(int*)&s1));
return 0;
}
需要说明的是,这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放 nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再编译就好了。如果还不行就使用下面的函数。
void PrintVFTable(VFPTR* table, size_t n)
{for (size_t i = 0; i< n; ++i)
{printf("vft[%d]:%p->", i, table[i]);
table[i](); // 调用虚表中的函数
//VFPTR fp = table[i];
//fp();
}
cout<< endl;
}
注以上函数的调用并不是常规的函数调用,就算这些成员函数是私有的也可以调用。
class Base1
{public:
virtual void func1() {cout<< "Base1::func1"<< endl; }
virtual void func2() {cout<< "Base1::func2"<< endl; }
private:
int b1 = 1;
};
class Base2
{public:
virtual void func1() {cout<< "Base2::func1"<< endl; }
virtual void func2() {cout<< "Base2::func2"<< endl; }
private:
int b2 = 2;
};
class Derive : public Base1, public Base2
{public:
virtual void func1() {cout<< "Derive::func1"<< endl; }
virtual void func3() {cout<< "Derive::func3"<< endl; }
private:
int d1 = 3;
};
现在我们来研究一下多继承的虚函数表,研究前,我们先来看看sizeof(Derive)
是多大。
Derive 的对象模型
在监视窗口中,我们看不到子类增加的虚函数。这样我们就看不到新增的虚函数放在哪一张虚函数标准了,那么我们可以通过将虚表打印出来看一看。注:子类 Derive 重写了父类 Base1 和 Base2 的 func1 函数,为什么两张虚表中 func1 函数地址是不一样的呢?这个问题,我们在下面的内容会探讨!
多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。
通过上图,我们发现两个虚表中的fun1
函数的地址不一样,这是为什么呢?如果没有重写,函数的地址不一样很正常。因为是继承了Base1
和Base2
的fun1
函数。那为什么重写了,还是不一样呢?现在我们来探讨一下这个问题。
首先我们需要知道:在 VS 下 call(函数地址)中的函数地址并不是函数真正的地址,而是 jmp 指令的地址。
上面打印的三个func1
函数的地址均不一样,原因就是这个并不是真正的func1
函数的地址。
注:由于多次编译,可能每次函数地址会不一样!
上图就解释了为什么fun1
函数的地址不一样,其实是这几个地址都是封装了真正的func
的函数地址。
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。如果好奇心比较强的大佬,可以去看下面的两篇链接文章。
- C++ 虚函数表解析
- C++ 对象的内存布局
虚拟菱形继承的虚函数表
class A
{public:
virtual void func1()
{cout<< "A::func1()"<< endl;
}
public:
int _a;
};
// class B : public A
class B : virtual public A
{public:
int _b;
};
// class C : public A
class C : virtual public A
{public:
int _c;
};
class D : public B, public C
{public:
int _d;
};
有虚函数的虚基类 A 比没有虚函数时,多了个虚表指针。
class A
{public:
virtual void func1()
{cout<< "A::func1()"<< endl;
}
public:
int _a;
};
// class B : public A
class B : virtual public A
{public:
virtual void func1()
{cout<< "B::func1()"<< endl;
}
public:
int _b;
};
// class C : public A
class C : virtual public A
{public:
virtual void func1()
{cout<< "C::func1()"<< endl;
}
public:
int _c;
};
class D : public B, public C
{public:
virtual void func1()
{cout<< "C::func1()"<< endl;
}
public:
int _d;
};
注:当类 B 和 类 C 都重写了虚基类 A 的 func1 函数时,子类 D 肯定也要重写 func1 函数,否则会产生二义性。因为 A 的成员在 D 中只有一份,不知道用 B 还是 C 的 func1 函数来重写 A 的 fun1 函数,所以 D 必须重写 func1 函数。
此时,D 对象中只有一个虚函数表指针。而且 B、C 对象中的虚基表中的第一个位置还是 0,没有存别的数字,第二个位置存的是偏移量。见下图:
现在,我们给 B 和 C 都加上一个虚函数func2
,看看会变成怎么样?
class A
{public:
virtual void func1()
{cout<< "A::func1()"<< endl;
}
public:
int _a;
};
// class B : public A
class B : virtual public A
{public:
virtual void func1()
{cout<< "B::func1()"<< endl;
}
virtual void func2()
{cout<< "B::func2()"<< endl;
}
public:
int _b;
};
// class C : public A
class C : virtual public A
{public:
virtual void func1()
{cout<< "C::func1()"<< endl;
}
virtual void func2()
{cout<< "C::func2()"<< endl;
}
public:
int _c;
};
class D : public B, public C
{public:
virtual void func1()
{cout<< "C::func1()"<< endl;
}
public:
int _d;
};
通过下图可以看到,现在 D 对象中有三个虚函数表指针!
以上的对象是比较复杂的,所以菱形继承就不要使用了,更不要将菱形继承和多态一起使用!!!
👉面试题👈下面程序输出结果是什么? ()
A:class A class B class C class D
B:class D class B class C class A
C:class D class C class B class A
D:class A class C class B class D
#includeusing namespace std;
class A
{public:
A(char* s) {cout<< s<< endl; }
~A() {}
};
class B :virtual public A
{public:
B(char* s1, char* s2) :A(s1) {cout<< s2<< endl; }
};
class C :virtual public A
{public:
C(char* s1, char* s2) :A(s1) {cout<< s2<< endl; }
};
class D :public B, public C
{public:
D(char* s1, char* s2, char* s3, char* s4) :B(s1, s2), C(s1, s3), A(s1)
{cout<< s4<< endl;
}
};
int main()
{D* p = new D("class A", "class B", "class C", "class D");
delete p;
return 0;
}
答案:A
解析:菱形虚拟继承解决了数据冗余的问题,故 D 中只有一份 A 的成员,而该成员只能 D 来调用其构造函数,并不是 B 和 C 来调用 A 的构造函数。A、B、C、D 构造函数的调用顺序是按照声明顺序来调用的,所以调用顺序为 A、B、C、D,故 A 选项正确。
注:B 中的构造函数也要调用 A 的构造函数来完成 A 的初始化。因为此时 A 没有默认的构造函数,需要显示调用。
什么是多态?多态是多种形态,指的是不同的对象做同一件事情,其结果是不一样的。用父类的指针或引用去调用重写的虚函数,指向谁就调用谁的虚函数。
inline 可以是虚函数吗?内联函数会在调用的地方直接展开,不会进符号表,没有函数地址;而虚函数的地址要放进虚函数表中。inline 函数与虚函数看起来是互斥的,但是 inline 只是一个建议,编译器会忽略 inline 属性,而是认为该函数是虚函数,将其放入虚表中。
静态成员函数可以是虚函数吗?不能,因为静态成员函数没有 this 指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。静态成员函数是编译时决议,而多态是运行时去找虚表中找对应的虚函数地址来进行决议。
构造函数可以是虚函数吗?不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。而虚函数是为了实现多条,多态是运行时去虚表找对应的虚函数进行调用。
析构函数可以是虚函数吗?可以,并且最好把基类的析
构函数定义成虚函数。为了保证能够正确地调用析构函数,编译器会将虚函数统一处理成 destructor()。如果基类的析构函数不加上 virtual,此时指向子类的父类指针也无法正确调用析构函数。
拷贝构造可以是虚函数吗?不可以,拷贝构造也是构造函数,也要在初始化列表中初始化对象的虚表指针。
赋值运算符重载可以是虚函数吗?赋值运算符重载可以是虚函数,但是没有什么实际价值,因为虚函数是为了实现多态。
对象访问普通函数快还是虚函数更快?首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
虚函数表是在什么阶段生成的,存在哪的?虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。注:构造函数初始化列表阶段初始化的是虚函数表指针,对象中存的也是虚函数表指针。
友元函数可以是虚函数吗?友元函数不属于成员函数,不能成为虚函数。虚函数是针对成员函数的。
👉总结👈以下简答题参考本篇博客:1. 什么是重载、重写(覆盖)、重定义(隐藏)? 2. C++菱形继承的问题?虚继承的原理? 3. 什么是抽象类?抽象类的作用?
本篇博客主要讲解了什么是多态、什么是虚函数、虚函数重写的条件、多态的条件、抽象类、多态的原理以及单继承和多继承关系的虚函数表等等。那么以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家!💖💝❣️
你是否还在寻找稳定的海外服务器提供商?创新互联www.cdcxhl.cn海外机房具备T级流量清洗系统配攻击溯源,准确流量调度确保服务器高可用性,企业级服务器适合批量采购,新人活动首月15元起,快前往官网查看详情吧