C++之继承&多态
- 继承
- 继承之形
- 继承的作用域
- 继承的构造与析构
- 多继承
- 菱形继承
- 多态
- 多态之形
- final和override(C++11)
- 纯虚函数&抽象类
- 多态的原理
- 打印虚表(在vs2022中)
- 多继承下的虚表
- 菱形虚继承中埋的坑
- 静态多态与动态多态
- 我对虚函数和普通成员函数调用区别的理解
- 问答题
- 结语
C++作为oo语言,封装,继承,多态是它的三大特性。前面我们学了 封装,今天我们学后两个。
继承
继承之形
- 继承,就是类层次的复用。被继承的称为基类(base) / 父类,另一个称为派生类(derived) / 子类。
class A//父类
{
public:A(int a = 0):_a(a){cout << "A()" << endl;}void func(){}~A(){cout << "~A()" << endl;}
private:int _a;
};
class B :public A//子类
{
public://B(int b):A(1),_b(b)B(int b):_b(b)//走A的默认构造{cout << "B()" << endl;}B(const B& b) :A(b), _b(b._b)//这里引用接收b中A的部分{cout << "B(const B& b)" << endl;}void func(int){}//构成隐藏,会屏蔽对A中func的直接访问~B(){cout << "~B()" << endl;}
private:int _b;
};
int main()
{B b;//b.func();//编译报错//b.A::func();//指明作用域,正确
}
- 继承方式:接在B后面的public A表示继承方式。
- public继承:父类中的public成员 => 子类中成为public成员,protected => protected
- protected继承:public & protected => protected
- private继承:public & protected => private
注意,无论什么继承方式,父类的private成员,子类是无法直接访问 的。
所以,如果想被子类访问,而不想被外人访问,就用protected限定成员。而private既防外人,又防儿子。但是,说实话,搞复杂了,现实中基本都用public继承。
3. class的继承默认private继承,struct的为public。(了解)
子类对象给父类对象拷贝或赋值时,不存在隐式类型转换。
B b; A a=b;
这里编译器会去找出子类中的父类成员,从而依次拷贝过去。而当父类对象用引用或者指针接收时,那么该对象就会指向子类中父类的那一部分,这叫赋值兼容,通俗的叫“切片”。
继承的作用域
父类和子类有各自独立的作用域。所以允许子类和父类中有有同名成员,且子类会屏蔽对同名的父类成员的直接访问,这叫隐藏,也叫重定义。且成员函数只要名相同,就构成隐藏。
比如上图中B的func和A的func构成隐藏,而非重载,且使用B去调用func不传参会编译报错。
要想调用父的,需要显式指明作用域。
继承的构造与析构
- 构造:如果不在子类的初始化列表中调用父类的构造函数,编译器就走A的默认构造;如果显式调用了,就走你写的。构造顺序是先父后子。
拷贝构造类似,在参数列表调用拷贝构造函数即可,因为是引用,可以接收子类对象。 - 析构:首先,析构函数名会被处理成destructor(因为多态),所以子与父的析构构成隐藏,要想调用父的析构,需要指定类作用域。
其次,编译器会自动在子类析构函数结束时调用父类的析构。这是因为,编译器要保证*析构顺序是先子后父,*类似栈上的变量后进先出。所以不要显式手动去调父类析构。
同样的,赋值重载也要显式调用父类的operator=()
问:如何实现一个不能被继承的类?
答:封死构造或析构,或者final修饰类。将它们设为private或者=delete。
多继承
允许一个类C可以继承多个类比如B和C时,称为多继承。这会带来二义性和数据冗余问题。比如B和C中都有name成员,那就歧义了。而name通常只需要一个,那就冗余了。虽然二义性可以通过指明作用域来解决,但数据冗余的空间浪费可就严重了。
解决方法:虚继承。在继承处加上关键字virtual。class B:virtual public A{};
这样name就是同一个了。
当B和C都继承A,再来一个D继承B和C时,就发生菱形继承。这是个大坑。尽量避开
菱形继承
我们前面说了,菱形继承能避开就避开,但是面试可能涉及它的解决原理,也就是虚继承的底层。也就是虚继承是怎么解决二义性和数据冗余的问题。
class A
{int _a = 1;
};
class B :virtual public A
{int _b = 2;
};
class C :virtual public A
{int _c = 3;
};
class D :public B, public C
{int _d = 4;
};
int main()
{D d;
}
上图中,首先看到,不使用虚继承时,_a是有两份的。
以下方便起见,我们在32位下做实验。
可以看到,B和C在原本存_a的位置分别存了一个指针,指向的位置的第4~8字节(前四字节为多态使用)用来标识从B/C起始到_a的偏移量(单位为字节),比如C的起始地址+0c也就是+12字节,正好指向A的起始部分,也就找到了那个唯一的A部分。
问1:既然我们/编译器知道A部分在最下面,那还需要存偏移量吗?
答1:要。如果是“切片”,比如B*ptrb=&d;
这个ptrb肯定是指向D中B部分起始位置的,但它不知道D中其他部分,那它解引用ptrb->a;
怎么找_a呢?如果没有偏移量,它可不知道_a在哪里。还有当使用虚继承后,如果定义B b;B*ptrb2=&b;
,可以看到汇编层面ptrb和ptrb2解引用找_a是一模一样的,这里的b也是在_a的位置存了指针指向偏移量,可见,此处设计是有深远考量的。
问2:二义性解决了,那数据冗余呢?
答2:虚继承后,此处多出的是8字节,32位下两个指针大小。指向空间不用考虑,因为定义很多d对象时,它们指向的是同一个偏移量。所以当A部分大于8字节时,在空间上,虚继承就赚了。
问3:这里找到了_a,如何找到A的其他成员?
答3:找到了A的起始位置,接下来编译器按结构体内存对齐计算就行。
问4:为什么不存绝对位置?
答4:可以是可以,但是尊重本贾尼的选择。大佬有大佬的考量。
注意,谁先被继承,谁先被声明。所以,初始化列表中,谁先被继承,先调谁的构造函数。比如,class D :public B, public C 这一行中,D先继承B,就先构造B。注意,A一定是最先初始化的,无论D中调默认构造还是有参构造,而B和C中对A的初始化无效。
组合和继承的区别:组合典型的是内部类,耦合度低。组合是has-a的关系,比如你有眼睛;继承耦合度高,它是is-a的关系,比如你是个人。
多态
多态之形
多态,即不同的对象做同样的行为却有不同的结果。比如有人吃榴莲觉得香,有人觉得臭。
class A
{
public:virtual void func(){cout << "class A->func" << endl;}
};
class B:public A
{
public:virtual void func(){cout << "class B->func" << endl;}
};
void func(A& x)
{x.func();
}
int main()
{A a;B b;func(a);func(b);
}
在子类和父类的同名、同参数列表(不看缺省值)和同返回值的函数前写virtual关键字(virtual修饰的成员函数称为虚函数),那么在父类指针或引用去调用该虚函数时,根据整体对象,调对应的函数。这里子类和父类中虚函数的关系,我们称为重写/覆盖。
虚函数的两个例外:1.子类中虚函数前可以不写virtual。一种解释是不加virtual子类也继承并重写这个函数的实现,符合接口继承的思想。2.返回值可以不同,但必须是父子关系的引用或指针。比如A中func返回一个A*,B中返回一个B*。返回其他的父子类或者都返回父类也可以。这叫协变。
上面我们提到了接口继承。接口可以理解为函数声明。那么接口继承就是把函数体上面那一行全继承下来,诸如返回值,函数名和参数列表。
总结多态条件:1.虚函数的重写。2.父类指针或引用调用该虚函数。
测试题:
class A
{
public:virtual void func(int val = 1){cout << "A->" << val << endl;}virtual void test() { func(); }
};
class B:public A
{
public:virtual void func(int val = 0){cout << "B->" << val << endl;}
};
void func(A& x)
{x.func();
}
int main()
{B* p = new B;p->test();
}
选择:A.A->0 B.B->0 C.B->1 D.A->1 E.编译报错 F.以上全错
答案:C
解释:p调用继承下来的test中,依然是A* 类型的this指针去调用func,又虚函数重写,所以多态。多态时,虽然this是父类的,但整体对象是子类,所以走子类的func。又因为接口继承(可以理解为把父类中的func接口照搬下来),所以val是1。
如果用A*指针接收B呢?当然还是C。因为整体对象还是B。
变式:
class A
{
public:virtual void func(int val = 1){cout << "A->" << val << endl;}
};
class B:public A
{
public:virtual void func(int val = 0){cout << "B->" << val << endl;}virtual void test() { func(); }
};
void func(A& x)
{x.func();
}
int main()
{B* p = new B;p->test();
}
选项一样。
答案:B。因为子类指针调用func,无多态。所以没有重写、接口继承的概念,就是个普通函数调用。
重载和重写和重定义的区别?
函数重载:1.两函数在在同一作用域。2.名同,参数不同。
重写(覆盖):1.分别在父类和子类的作用域。2.都是虚函数。3.三同
重定义(隐藏):1.分别在父类和子类的作用域。2.名同。3.不是重写,就是重定义。
final和override(C++11)
- final有两个用途,一个是用于类名后,该类不能被继承,称为最终类。另一个是在虚函数后面,表示不能被重写。
- override是在子类虚函数接口后面,用于检查虚函数是否重写,没有就报错。
纯虚函数&抽象类
- 在虚函数接口后加上=0,就成了纯虚函数。
- 包含纯虚函数的类叫抽象类。抽象类永远无法实例化出对象,而且如果被继承的类不重写纯虚函数,那它也无法实例化出对象来。继承抽象类的子类只有重写纯虚函数,才能实例化。
- 可见,它可以强制重写。
done
多态的原理
首先,虚函数的存在会让类的size多出4/8字节,一个指针,我们称为虚函数表指针(virtual function table pointer),简称虚表指针。它指向的是虚函数表,里面存的是虚函数指针数组。下面是测试代码:
class A
{
public:virtual void func(){cout << "A->func" << endl;}//virtual void func2(){}
};
class B:public A
{
public:virtual void func(){cout << "B->func" << endl;}
};
void func(A* x)
{x->func();
}
int main()
{A* a = new A;B* b = new B;func(a);func(b);
}
我们会发现,父类和子类对象中都会有一个虚表指针,指向各自实现的虚函数。所以分别调用各自的虚函数即可。那么多态时,即使是父类的指针/引用,那它指向的完整对象中的虚表中指向的虚函数还是子类的实现,如果指向的是父类对象,那调的就是父类中虚表指向的虚函数了,这不就多态了嘛。注意,虚表是属于类的,因为类定义好了,虚函数也就定了,虚表就出来了。所以,同一个类的对象共用一个虚表。
- 所以为什么多态条件之一是父类引用/指针?
因为指针/引用跟普通类型不同,既可以指向/接收父类(的虚表),又可以指向/接收子类(的虚表),而且(后面个人理解)便于操作去找虚表,引用底层也是指针。另外,如果是普通对象接收,它切片时会发生拷贝,那它敢拷贝子类的虚表吗?不敢,父类里的虚表是子类的虚表,不乱套了?所以切片时,只拷成员,不拷虚表,也就没法多态了。- 为什么多态另一个条件重写,又叫覆盖?
我们在上面代码中去掉注释,即在A中再写一个虚函数而B不重写,见下图,可以发现,B类的虚表中func的地址与a中不同,但func2的地址一样。说明子类的虚表是照搬(继承)父类的,我们不重写的话,就不变,重写就覆盖掉。
打印虚表(在vs2022中)
前情提要:
- 虚表指针在类对象的起始位置存放,无论32位/64位。
- 在我的vs2022编译器中,会在虚表中虚函数指针的末尾补一个nullptr。
下见代码:
class Base
{
public:virtual void func1(){cout << "Base::func1()" << endl;}virtual void func2(){cout << "Base::func2()" << endl;}
};
class Derive :public Base
{
public:virtual void func1(){cout << "Derive::func1()" << endl;}virtual void func2(){cout << "Derive::func2()" << endl;}
};
typedef void(*VF_PTR)();
void PrintVFTable(VF_PTR* table)
{for (int i = 0; table[i]!=nullptr; i++){printf("[%d]:%p->", i, table[i]);table[i]();//(*table[i])()也行}
}
int main()
{Base b;Derive d;//下面是先取出b的虚表指针VF_PTR,再强转成VF_PTR*//PrintVFTable((VF_PTR*)(*(int*)&b));//在64位下要换成long longPrintVFTable(*(VF_PTR**)(&b));PrintVFTable(*(VF_PTR**)(&d));return 0;
}
打印结果(32位下也类似,但是vs切换平台时,记得重新生成解决方案)
问1:虚表是在什么阶段生成的?
答1:编译阶段。编译阶段就有虚函数地址了。
问2:对象中虚表指针什么时候初始化?
答2:构造函数的初始化列表。
问3:虚表存在哪里?
答3:首先排除栈。堆是给程序员动态申请的,不合理。只剩数据段(静态区)和代码段(常量区)了。见下图,可见vs2022下,虚表存在常量区。虚表出来后就不会被修改,有道理。但静态区也可,看编译器。
多继承下的虚表
前情提要:多继承时子类对象结构be like:
首先,既然子类的虚表是照搬、覆盖父类的虚表,那么继承了几个父类,就有几张虚表。
那么如果一个子类中有一个没重写的虚函数,它在哪?
测试代码:
class Base1
{
public:virtual void func1(){cout << "Base1::func1()" << endl;}virtual void func2(){cout << "Base1::func2()" << endl;}
};
class Base2
{
public:virtual void func1(){cout << "Base2::func1()" << endl;}virtual void func2(){cout << "Base2::func2()" << endl;}
};
class Derive :public Base1,public Base2
{
public:virtual void func1(){cout << "Derive::func1()" << endl;}virtual void func3(){cout << "Derive::func3()" << endl;}
};
typedef void(*VF_PTR)();
void PrintVFTable(VF_PTR* table)
{for (int i = 0; table[i] != nullptr; i++){printf("[%d]:%p->", i, table[i]);table[i]();}
}
int main()
{Derive d;PrintVFTable(*(VF_PTR**)(&d));//PrintVFTable(*(VF_PTR**)(char*)&d + sizeof(Base1));//手动偏移Base2* b2 = &d;//切片偏移PrintVFTable(*(VF_PTR**)b2);/*下去看着汇编调试Base1* b1 = &d;Base2* b2 = &d;b1->func1();b2->func1();*/return 0;
}
结果是存在先声明(继承)的子类虚表中。
但是,新问题来了,为什么上图中两张虚表中func1的地址不一样?不是说覆盖吗,都是Derive的func1实现,地址怎么会不一样?
解析:首先,调用的函数是一样的,因为我实现的PrintVFTable中决定了只有调用的都是func1才能打出"Derive::func1()"。那么为什么虚表中地址不一样呢?此处就直接揭晓了,因为如果是切片后指向d中b2部分的指针(也就是传过去的this指针)去调用func1,多态使它应该调用d中第二张虚表里的func1,但它的this指针指向是错的。this应该指向d的起始,而此处指向d中Base2部分的起始。所以在jump到func1地址前,要完成this指针的修正工作,即减一个Base1的size。才能正确调用d的成员函数func1。
下图是调用func1前this的修正,ecx在调用成员函数时存的是this指针,减的4是Base1的大小。
建议,下去调试跟着汇编看一眼,理解一下修正this指针,也可以改变一下Base1的大小之类的。
菱形虚继承中埋的坑
前面我提到虚基表中前四字节为多态所用。首先,菱形继承有三份虚表,B、C各一份用来存没重写的虚函数指针,A一份存D重写的虚函数指针(注意是BC都重写了,但只存D的,避免歧义)。可以发现,菱形继承的代码添加上虚函数,那四字节在32位就变成了-4,64位是-8。所以,虚基表前四字节盲猜是存储的虚表指针相对虚基表指针的偏移量。
静态多态与动态多态
- 静态多态,又称静态绑定/前期绑定/早绑定,典型的有函数重载。它是在编译时就确定好要call的函数地址,比如cout根据后面跟的数据类型,编译时就决定调哪个cout。
- 动态多态,又称动态绑定/后期绑定/晚绑定,也就是这里的虚函数重写的多态。它是在运行时才确定要call的函数地址,比如虚函数的多态是在运行时去虚表里找地址(但上面说过这个虚函数地址是在编译时就有了的)。
done
我对虚函数和普通成员函数调用区别的理解
普通成员函数直接在编译时就能拿到函数地址,直接调即可。
虚函数要先用(修正后的)传入的this指针找到虚表指针,再通过后者找到虚表里虚函数地址,再去调用虚函数。
问答题
- 什么是多态?分为静态多态和动态多态。前者有重载,在编译时拿到函数地址;后者有虚函数的多态,在运行时拿到函数地址。
- 什么是重载、重写(覆盖)、重定义(隐藏)?见多态之形结尾部分。
- 多态的实现原理?虚表中存各自的虚函数地址,调用时即可根据对象调用不同的函数。
- inline函数可以是虚函数吗?理论上不可以,但编译时可以通过。inline函数无地址不能放进虚表,但是如果写virtual编译器可能把它当作虚函数,而拒绝inline的请求。
- 静态成员可以是虚函数吗?不能,静态成员函数无this指针,如果用类名调用就访问不到虚表。
- 构造函数可以是虚函数吗?不行,虚表指针在初始化列表才初始化,这不就是蛋鸡相生的哲学问题吗?注意,virtual赋值重载函数可编过,但不应该,赋值重载和构造都要调用父类的对应函数,这不符合虚函数重写“非父即子”的理念。
- 析构函数可以是虚函数吗?可以且建议。
- 访问普通成员函数快还是虚函数快?普通对象调用是一样快的,因为不走多态;指针或引用对象调用,构成多态,去虚表找虚函数地址,虚函数就慢了。
- 虚函数表存在哪里,什么阶段生成?
- 菱形继承问题?怎么解决?
小细节1:virtual只在类内声明时加,类外定义时不加。
小细节2:下图中第二个指针调用func是普通函数调用吗?
class Base
{
public:virtual void func(){}
};
int main()
{Base b;b.func();Base* pb = &b;pb->func();return 0;
}
自己下去分析汇编,结果是多态调用。这里没有重写虚函数,也没人继承Base,为什么走多态调用呢?这是因为编译器在遇到对象指针/引用去调用虚函数时,无论是否重写,都走多态的话,成本最低,最稳妥(万一别的c文件里继承了Base重写了func呢)。
结语
OK,完工。整了4~5天吧,累了,麻了,没想到东西这么多。哎,继续前进吧,为C++献出心脏!后面还有大boss呢,红黑树。。。