文章目录
前言
本文继C++继承之后讲解C++多态。
一、什么是多态?
单单从概念入手不好理解,应该深入理解多态的实现后再回过头来讲解。
现在简单举个例子:我们在购买高铁票时,往往会有成人票全价,学生票半价的优惠,针对不同的人群给予不同的优惠,这个就是多态(多种形态)。
二、多态的构成条件
多态的两个构成条件为:
2.1什么是虚函数?
被virtual关键字修饰的类成员函数就是虚函数。
在继承中,子类要想重写父类,父类的成员函数必须是虚函数,而子类的成员函数可以不加virtual,但一般建议加上比较合适。
2.2虚函数的重写
虚函数的重写是:对父类的虚函数的实现进行覆盖,覆盖的内容是子类虚函数的实现。
满足重写的条件:三同。
2.3 什么是虚函数表?
虚函数表是继承体系中,如果一个函数是虚函数,则该函数的地址会存储在一张虚函数表中,而不是存储在对象中,该虚函数表的地址才存储在对象中。通过虚函数表可以找到对应的虚函数,从而能够进一步实现多态。
有虚函数的对象的大小
class A
{
public:
virtual void func1()
{}
virtual void func2()
{}
protected:
int _a;
};
int main()
{
cout << sizeof(A) << endl;
return 0;
}
请计算上面的代码中,A这个类的大小。
A有两个虚函数,一个成员,实际上大小为8
虚函数表本质上是一个函数指针数组。
一般建议:如果不实现多态,就不要设置成虚函数
2.4普通对象调用和实现多态后的对象调用
普通对象调用成员函数是在编译期间就确定了地址,而实现多态后的函数是在运行期间才确定地址。因为子类继承父类,子类先拷贝父类的虚函数表的地址,如果某个函数是虚函数,则会在子类的虚函数表中重写改函数的地址。
所以编译器在遇到父类的指针或引用调用子类的函数时,编译器在编译期间无法确定到底调用谁的函数,只能运行起来去子类的虚函数表中查找,是谁的地址就调用谁。
三、多态的原理
我们通过以下例子来看待多态。
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
return 0;
}
当我们用父类的指针或引用调用子类的虚函数时,会去访问子类的虚函数表,当我们调用父类的虚函数时,会访问父类的虚函数表,这样就实现了父类指针指向父类对象就调用父类的虚函数表,指向子类对象就调用子类的虚函数表。
所以多态就是:我们想调用父类的函数就传递父类对象给父类指针/引用,想调用子类的函数就传递子类对象给父类指针/引用。
经典题
这里有一道非常经典的坑人题目:
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;
}
回到多态的两个条件
多态的两个条件:
(1)为什么不能是子类的对象赋值给父类的对象,而是子类对象赋值给父类的指针/引用?
因为子类对象赋值给父类对象,切片过程中不会拷贝虚表。所以父类对象只能调用父类的虚表,子类对象才能调用子类的虚表,满足不了多态。
(2)为什么子类的对象赋值给父类的对象不会拷贝虚表?
因为如果拷贝虚表,使用子类对象会调用子类的虚函数,使用父类对象也会拷贝子类的虚函数,就乱套了。
(3)为什么不能是子类的指针或引用?因为父类是子类的一部分,父类赋值给子类指针不会切片,就不能获取父类的虚函数表。
需要注意的以下几点:
void test_where()
{
Person p;
Student s;
//栈
int a;
printf("栈->[%p]\n", &a);
//堆区
int* ptr = new int;
printf("堆->[%p]\n", ptr);
//数据段(静态区)
static int b;
printf("静态->[%p]\n", &b);
//代码段(常量区)
const char* str = "Hello World";
printf("常量区->[%p]\n", str);
printf("虚函数表1->[%p]\n", *((int*)&p));
printf("虚函数表2->[%p]\n", *((int*)&s));
}
int main()
{
test_where();
return 0;
}
思路:获取父类或子类对象的虚函数表指针,也就是取出父类/子类在内存中的前4个字节,将该地址与内存中的栈区,堆区,静态区(数据段),常量区(代码段)的地址进行对比,跟谁的地址比较近,就大致在哪个区域。
多态条件的两个特例:(这一点是C++的大坑)
(1)协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
(这一点是C++的大坑)
(2)析构函数的重写(父类和子类的析构函数名不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
3.1动态绑定和静态绑定
多态可以分为动态多态和静态多态,动态多态也叫做动态绑定,静态多态也叫做静态绑定。
四、默认成员函数和虚函数的关系
4.1构造函数可以设置成虚函数吗?
4.2 析构函数可以设置成虚函数吗?
如果不设置成虚函数,假如有以下的场景,会出问题:
class Person
{
public:
~Person()
{
cout << "father:~Person" << endl;
}
};
class Student : public Person
{
public:
~Student()
{
cout << "son:~Student" << endl;
delete [] ptr;
}
protected:
int* ptr = new int[10];
//new ==> 构造 + operator new()
};
int main()
{
Person* p = new Person;
delete p;
p = new Student;
delete p;
return 0;
}
delete p
会处理成以下方式:
p->destructor() + operator delete( p )
普通对象,看当前者的类型。
多态对象,看指向对象的类型。
p是父类对象,不管指向的对象是父类还是子类,都会调用父类的析构函数,在子类申请的空间就得不到释放,会造成内存泄露问题。
如果设置成虚函数,就能够实现虚函数的重写,从而实现多态。
一道经典面试题
设计一个不想被继承的类,如何创建?
(1)构造函数设置成私有
class A
{
private:
A()
{}
};
class B : public A
{
public:
B()
{}
};
原理:在继承体系中,子类的构造函数必须先去调用父类的构造函数,这里父类构造设置成私有子类就无法调用了。
不过这里出现一个问题,子类无法调用父类的构造,父类也无法调用自己的构造了。
我们可以写一个函数,在函数里面创建一个父类。
class A
{
private:
A Createobj()
{
return A();
}
A()
{}
};
但是又有一个问题:如何调用这个函数呢?因为调用该函数创建对象,而创建对象又需要在函数里面创建。
这里我们可以加一个static解决
class A
{
private:
static A Createobj()
{
return A();
}
A()
{}
};
int main()
{
A a = A::Createobj();
}
这样就可以通过指定类域访问该函数解决。
五、单继承和多继承关系中的虚函数表
5.1单继承关系中的虚函数表
单继承关系中,子类会拷贝父类的虚函数表,如果子类还有自己的虚函数,则该虚函数的地址会放在虚函数表的最后。
我们通过调试窗口无法看到子类的虚函数。
不过我们可以通过获取虚表的地址来打印虚表的各个虚函数的地址。
typedef void(*VFPTR)();
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a = 1;
};
class Derive :public Base {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b = 2;
};
void PrintVfptr(VFPTR* arr)
{
printf("虚表地址是:[%p]\n", arr);
for (int i = 0; arr[i] != nullptr; i++)
{
printf("第%d个虚函数地址是:[%p]\n", i, arr[i]);
}
printf("\n");
}
int main()
{
Base b;
Derive d;
int vfptrb = *((int*)&b);
int vfptrd = *((int*)&d);
PrintVfptr((VFPTR*)vfptrb);
PrintVfptr((VFPTR*)vfptrd);
return 0;
}
- 思路:1.先获取虚表的地址,取子类对象的地址,强转成int*,再进行解引用,就取到了子类对象的前4个字节,也就是虚表指针。
- 2.再强转成(VFPTR*)通过打印该指针指向的内容,即可打印虚表的内容。
5.2多继承关系中的虚函数表
这里有几种猜测,子类的未重写的虚函数会放在第一个继承的父类的虚表中,或者放在其他的父类的虚表中。
我们也可以通过打印地址的方式确定。
typedef void(*VFPTR)();
class Base1
{
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int b1 = 1;
};
class Base2
{
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Derive::func2" << endl; }
private:
int b2 = 2;
};
class Derive1 :public Base1, public Base2
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d = 2;
};
void PrintVfptr(VFPTR* arr)
{
printf("虚表地址是:[%p]\n", arr);
for (int i = 0; arr[i] != nullptr; i++)
{
printf("第%d个虚函数地址是:[%p]\n", i, arr[i]);
}
printf("\n");
}
int main()
{
Derive1 d;
int vfptrb1 = *((int*)&d);
Base2* p2 = &d;
//自动切片,p2就指向Base2对象的首地址
int vfptrb2 = *(int*)p2;
//int vfptrb2 = *((int*)((char*)&d + sizeof(Base1)));
PrintVfptr((VFPTR*)vfptrb1);
PrintVfptr((VFPTR*)vfptrb2);
return 0;
}
通过打印可以看到,子类中未重写的虚函数放在第一个继承的父类的虚表中。
但是这里有一个问题:为什么重写了func1,在Base1的func1的地址和Base2的func1的地址不一样?
在ptr1和ptr2调用func1的过程中,调用的是Derive的func1函数,因为func1已经被重写了。
而在内存中,ptr1和ptr2指向的地址如下:
因为ptr1指向的地址刚好是d对象的首地址,ptr1和this指针是重叠的,无需偏移,而ptr2需要偏移Base1字节才与this指针重叠。
这就导致在调用func1函数前,ptr2需要先偏移,才能调用。我们看到的是在偏移之前的ptr2的地址,这就是为什么看到的func1的地址不同,调用的确实同一个函数的原因,实际上ptr2会偏移。
六、抽象类
class
{
public:
virtual void func1() = 0
{}
};
比如上面这个就是抽象类。
抽象类的特性:
- 1.不能实例化出对象,子类继承后也不能实例化出对象,只有重写虚函数才能实例化出对象。
- 2.可以定义指针或引用.
普通的对象是实现继承,而实现多态的对象是接口继承。
抽象类的作用:间接强制子类虚函数必须重写,否则无法实例化对象。
写在最后的面试题
总结
多态内容就讲到这里。