C++多态

简介: 多态就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

多态

一、多态的概念

多态就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。

再举个栗子: 最近为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包-支付-给奖励金的活动。那么大家想想为什么有人扫的红包又大又新鲜8块、10块...,而有人扫的红包都是1毛,5毛....。其实这背后也是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、比如你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你扫码金额 =random()%99;比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你去使用支付宝,那么就你扫码金额 = random()%1;总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。ps:支付宝红包问题纯属瞎编,大家仅供娱乐。


二、多态的定义及实现

1.多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价,Soldier对象优先买票。

那么在继承中要构成多态还有两个条件:

  • 1.必须通过基类的指针或者引用调用虚函数
  • 2.被调用的函数必须是虚函数(virtual),且派生类必须对基类的虚函数进行重写(函数名、返回值、参数均相同 + 虚函数)

\图示:**

image-20230221193616729

2.虚函数

  • 虚函数:即被virtual修饰的类成员函数称为虚函数
class Person {
    
    
public:
    virtual void BuyTicket() 
    {
    
     
        cout << "Person-买票-全价" << endl; 
    }
};
  • 注意:虚函数这里的virtual是为了实现多态,而虚继承的virtual是为了解决菱形继承的数据冗余和二义性。

3.虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
class Person
{
    
    
public:
    // 虚函数
    virtual void BuyTicket()
    {
    
    
        cout << "Person-买票-全价" << endl;
    }
};

// 虚函数的重写/覆盖
// 三同:函数名、参数、返回值
// 1.子类虚函数可以不加virtual (建议:父类子类虚函数都加上)
// 2.协变:三同中,返回值可以不同,但是要求返回值必须是一个父子类关系的指针或者引用

class Student : public Person
{
    
    
public:
    virtual void BuyTicket()
    {
    
    
        cout << "Student-买票-半价" << endl;
    }
};

class Soldier : public Person
{
    
    
public:
    virtual void BuyTicket()
    {
    
    
        cout << "Soldier-买票-优先" << endl;
    }
};

// 多态的条件
// 1.虚函数重写
// 2.父类的指针或者引用去调用虚函数
//void func(Person& People)
//{
    
    
//    People.BuyTicket();
//}
void func(Person* People)
{
    
    
    People->BuyTicket();
}

int main()
{
    
    
    Person pn;
    Student st;
    Soldier sr;

    /*func(pn);
    func(st);
    func(sr);*/
    func(&pn);
    func(&st);
    func(&sr);
}
  • 注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

4.虚函数重写的两个例外

  • 1、协变(基类与派生类虚函数返回值类型不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。通俗的说是虚函数返回值类型为父子关系指针和引用。

如下基类Person类虚函数f的返回值类型为基类A对象的指针,Student类虚函数f的返回值为派生类B对象的指针,且B是A的子类,这俩虚函数称为协变。

class A
{
    
    };

class B : public A
{
    
    };

class Person
{
    
    
public:
    // 虚函数
    virtual A* BuyTicket() {
    
     cout << "Person-买票-全价" << endl; return nullptr; }
};

/*协变:三同中,返回值可以不同,但是要求返回值必须是一个父子类关系的指针或者引用
    当然返回值也可以都是父类的指针或引用,但父类函数的返回值不能是子类的指针或引用*/
class Student : public Person
{
    
    
public:
    virtual B* BuyTicket() {
    
     cout << "Student-买票-半价" << endl; return nullptr; }
};

class Soldier : public Person
{
    
    
public:
    virtual A* BuyTicket() {
    
     cout << "Soldier-买票-优先" << endl; return nullptr; }
};

int main()
{
    
    
    Person p;
    Student s; 
    Person* ptr = &p;   
    //父类的指针指向父类对象,调用父类的虚函数
    ptr->BuyTicket();//virtual A* Person::BuyTicket()

    ptr = &s;
    //父类的指针指向子类对象,调用子类的虚函数
    ptr->BuyTicket();//virtual B* Student::BuyTicket()
}
  • 2、析构函数的重写(基类与派生类析构函数的名字不同)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。

示例:

class Person {
    
    
public:
    ~Person()
    {
    
     
        cout << "~Person()" << endl; 
    }
};
class Student : public Person {
    
    
public:
    ~Student() 
    {
    
    
        cout << "~Student()" << endl; 
    }
};
int main()
{
    
    
    Person* ptr = new Person;
    delete ptr;//~Person()
    ptr = new Student;
    delete ptr;//~Person()
    return 0;
}

此段代码中,我创建了一个父类指针,先指向父类Person,然后delete释放,再指向子类Student,再delete释放,期望的结果应该是指向父类的调用父类的析构函数,指向子类的调用子类的析构函数。可是结果却都是调用父类的析构函数,造成内存泄漏。可期望的delete函数是一个多态调用。

当把父类的析构函数变成虚函数,其它不变的时候

class Person {
    
    
public:
    virtual ~Person()
    {
    
     
        cout << "~Person()" << endl; 
    }
};
class Student : public Person {
    
    
public:
    ~Student() 
    {
    
    
        cout << "~Student()" << endl; 
    }
};

结果如下:

image-20230223201247136

这里很明显结果是我们所预期的,先释放指向父类的对象,再释放指向子类的对象(继承后的析构为先子后父),正是由于基类的析构函数加上了virtual变成虚函数,才得以让父类和子类的析构函数构成重写,完成多态调用。才能析构正确。

  • 结论:如果设计一个类,可能会作为基类,将基类的析构函数最好定义为虚函数。

5.override和final关键字(C++11)

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。

  • \1、final**:修饰虚函数,表示该虚函数不能再被重写,修饰类表示不能被继承。

这里我父类的虚函数Drive不想被其它人重写,在其后面加上final即可,此时子类就无法对Drive进行重写了,如下:

image-20230223201000912

final修饰一个类,让其不能被继承,如下:

image-20230223201052481

  • \2、override**: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

image-20230223200702293

6.重载、覆盖(重写)、隐藏(重定义)的对比

image-20230222194301008


三、抽象类

1.概念

在虚函数的后面写上=0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

示例:

//抽象类
class Car
{
    
    
public:
    //纯虚函数
    virtual void Drive() = 0;//声明,也可以定义出来,一般只给声明即可
};
class BMW :public Car
{
    
    };

int main()
{
    
    
    //Car c; error 抽象类不能实例化对象
    //BMW b; error 派生类继承后也不能实例化出对象
}

此例说明抽象类Car不能实例化出对象。派生类BMW继承后也不能实例化出对象,因为派生类继承了Car,纯虚函数跟着继承下来了,所以BMW也是抽象类。但是我重写纯虚函数,派生类就可以实例化出对象了。

//抽象类
class Car
{
    
    
public:
    //纯虚函数
    virtual void Drive() = 0;//声明,也可以定义出来,一般只给声明即可
};
class BMW :public Car
{
    
    
public:
    //重写纯虚函数
    virtual void Drive()
    {
    
    
        cout << "BMW-操控" << endl;
    }
};
class Benz :public Car
{
    
    
public:
    //重写纯虚函数
    virtual void Drive()
    {
    
    
        cout << "Benz-舒适" << endl;
    }
};
int main()
{
    
    
    //Car c;//抽象类不能实例化对象
    //重写纯虚函数,派生类就可以实例化出对象
    BMW b1;
    Benz b2;  
    //不同对象使用基类指针完成多态的行为
    Car* pBenz = new Benz;
    Car* pBMW = new BMW;
    pBenz->Drive();//Benz-舒适
    pBMW->Drive();//BMW-操控
 return 0;
}

由此可见纯虚函数的间接功能:要求子类需要重写,才能实例化出对象。

2.接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

3.一道非常坑的笔试/面试题

以下程序输出结果是什么()

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;
}

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

相信这道题可以难倒九成以上的同学,先说答案:B;解析如下:

1、main 函数中,p 指向一个 B 对象,然后 p 调用 test 函数,由于 B 继承 A,所以 B 中也会有 test 函数;但是由于 B 中没有对 test 函数进行隐藏或重写,所以 B 中的 test 和 A 中的 test 一模一样,包括隐藏的 this 指针;显示写出来如下:
//为了方便理解,我们将隐藏的 this 指针也写出来

class A {
public:
    virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
    virtual void test(A* this) { this->func(); }  //this的类型是A*
};

class B : public A {
public:
    void func(int val = 0) { std::cout << "B->" << val << std::endl; }
 //继承过来的test函数和A中的test一模一样
 virtual void test(A* this) { this->func(); }  //this的类型是A*
};

2、在此处,由于 p 是的类型是 B,而 test 函数的 this 指针类型是 A,所以发生切片;

3、然后,test 函数在其内部调用 func 函数,由于子类对 func 函数进行了重写,再加上 this 指针的类型是 A*,满足多态条件,所以这里是一个多态调用,多态调用看调用对象指向的类型,this 指向 p 中属于父类的那一部分,所以 this 指向的类型是 B,调用 B 的 func;

4、很多同学想到这一步之后可能直接就选 D 选项了,但是 D 是错误的,因为在多态调用中,虚函数的继承是接口继承,重写只是重写实现,所以 B 中 func 函数的接口应该使用从 A 中继承下来的接口,即 val 的缺省值为 1;至此,答案选 B。

总结:其实在绝大多数情况下,大家想到第三步就已经能够得到正确答案了,因为这里主要考察的知识点是多态的条件;而最后一步的接口继承说实话太坑了,一般都不会这样考。


四、多态的原理

1.虚函数表

  • 来看下面这道常考笔试题:sizeof(Base)是多少?
class Base
{
    
    
public:
    virtual void Func1()
    {
    
    
        cout << "Func1()" << endl;
    }
private:
    int _b = 1;
};

通过观察测试我们发现b对象在32位是8bytes,64位是16bytes。

int main()
{
    
    
    Base b;
    cout << sizeof(b) << endl;//8/16
}

除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关)。我们可以通过监视窗口看出:

image-20230325205152938

对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?我们接着往下分析。

针对上面的代码我们做出以下改造

  • 1、我们增加一个派生类Derive去继承Base
  • 2、Derive中重写Func1
  • 3、Base再增加一个虚函数Func2和一个普通函数Func3
class Base
{
    
    
public:
    virtual void Func1()
    {
    
    
        cout << "Base::Func1()" << endl;
    }
    virtual void Func2()
    {
    
    
        cout << "Base::Func2()" << endl;
    }
    void Func3()
    {
    
    
        cout << "Base::Func3()" << endl;
    }
private:
    int _b = 1;
};
class Derive : public Base
{
    
    
public:
    virtual void Func1()
    {
    
    
        cout << "Derive::Func1()" << endl;
    }
private:
    int _d = 2;
};
int main()
{
    
    
    Base b;
    Derive d;
    return 0;
}

依旧是通过监视窗口来看看父类对象b和子类对象d的内部组成结构:

image-20230325235818579

通过观察和测试,我们发现了以下几点问题:

  • 1、派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
  • 2、基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法(派生类对继承基类虚函数实现进行了重写),覆盖是原理层的叫法(子类的虚表,拷贝父类的虚表进行了修改,覆盖重写了那个虚函数)。
  • 3、另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
  • 4、虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
  • 5、总结一下派生类的虚表生成:
  1. 先将基类中的虚表内容拷贝一份到派生类虚表中

  2. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数

  3. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

  • 6、这里还有一个童鞋们很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的,Linux g++下大家自己去验证。

错题:

  • 子类有几个父类,如果父类有虚函数,则就会有几张虚表,自身子类不会产生多余的虚表
  • 子类自己的虚函数只会放到第一个父类的虚表后面,其他父类的虚表不需要存储,因为存储了也不能调用

拓展学习:写一个函数来找出虚表的存储位置

int main() 
{
    
    
    int a;
    cout << "栈:" << &a << endl;

    int* p = new int;
    cout << "堆:" << p << endl;

    const char* str = "hello world";
    cout << "代码段/常量区:" << (void*)str << endl;

    static int s;
    cout << "静态区/数据段:" << &s << endl;

    Base b;
    cout << "虚表:" << (void*)*((int*)&b) << endl;

    Derive d;
    cout << "虚表:" << (void*)*((int*)&d) << endl;
}

image-20230326000717031

如上,我们可以将位于各个区域的类型的变量的地址和虚表的地址进行比对,看虚表地址和哪个类型变量的地址最接近,那么虚表就存储在哪个区域 – 因为虚表地址存储在虚表指针 vfptr 中,所以我们设法得到 vfptr 即可,而 vfptr 是一个指针变量,32 位平台下指针 4 字节,所以我们将类对象的地址强转为 int*,然后对其解引用就能得到 vfptr 了。

需要注意的是,当我们将类对象地址强转为 int* 后,此时再对其解引用得到的就是一个整形,而整形不方便和地址进行对比,所以我们可以将其转换为 指针类型,在使用 cout 输出即可。

可以看到,虚表的地址和代码段中变量的地址最接近,所以虚表存储在代码段中;同时,由于虚表存储在代码段中,所以同一类型的虚表是共享的。

注意:有的老铁可以认为虚表如果在代码段是不是就不能对其进行覆盖了,其实不是的,子类的虚表是先拷贝父类虚表,然后进行覆盖,覆盖完毕后才存储到代码段中的。

2.多态的原理

多态是如何实现当父类指针指向父类对象时,调用父类的虚函数,而父类指针指向子类对象时,调用的就是子类的虚函数呢?

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;
}

image-20230303151459648

根据图示,我们可以观察到如下:

  • 1、观察上图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
  • 2、观察上图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。

这样就实现出了不同对象去完成同一行为时,展现出不同的形态。

反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数。反思一下为什么?再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是依靠运行时,去指向对象的虚表中查调用函数地址。不满足多态的函数调用时编译时确认好的。

而如果是下列调用方式呢?

void Func(Person* p)
{
    
    
    p->BuyTicket();
}
int main()
{
    
    
    Person mike;
    Func(&mike);//Person : 买票-全价
    mike.BuyTicket();//Person : 买票-全价
    return 0;
}

为什么这里的结果都是调用父类的虚函数呢?首先要区分多态调用和普通调用:

  • 多态调用:运行时决议 -- 运行时(查虚函数表)确定调用函数的地址
  • 普通调用:编译时决议 -- 编译时(符号表)确定调用函数的地址

接下来我从汇编代码中截取部分做解释:

// 以下汇编代码中跟你这个问题不相关的都被去掉了
void Func(Person* p)
{
    
    
    ...
    p->BuyTicket();
    // p中存的是mike对象的指针,将p移动到eax中
    001940DE mov eax,dword ptr [p]
    // [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
    001940E1 mov edx,dword ptr [eax]
    // [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
    00B823EE mov eax,dword ptr [edx]
    // call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来
    //以后到对象的中取找的。
    001940EA call eax
    00头1940EC cmp esi,esp
}
int main()
{
    
    
    ...
    // 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调
    // 用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
    mike.BuyTicket();
    00195182 lea ecx,[mike]
    00195185 call Person::BuyTicket (01914F6h)
    ...
}

对象切片的时候,子类只会拷贝成员给父类对象,不会拷贝虚表指针,如果拷贝就乱套了,且父类对象中到底是父类的虚表指针还是子类的虚表指针都有可能?因此子类对象赋给父类不能实现多态。

3.动态绑定和静态绑定

  • 静态绑定:又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
  • 动态绑定:又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

五、单继承和多继承关系的虚函数表

需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,没什么需要特别研究的,接下来看看派生类的虚表。

1.单继承中的虚函数表

以下列代码示例:

class Base {
    
    
public:
    virtual void func1() {
    
     cout << "Base::func1" << endl; }
    virtual void func2() {
    
     cout << "Base::func2" << endl; }
private:
    int a;
};
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;
};
int main()
{
    
    
    Base b;
    Derive d;
}

通过监视窗口得到的结果如下:

image-20230326002911487

首先子类d的虚表是拷贝了父类b的虚表,并且对子类自己重写的虚函数func1进行了覆盖,其次观察上图中的子类监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。是真的没有这两个函数吗?这里我们给出两种方法来观察这两个函数。

  • 1、通过内存监视窗口:

image-20230326003337455

  • 2、使用代码打印虚表内容:
//将返回值为void,参数为void的函数指针重命名为 VFPTR
typedef void(*VFPTR)();  

void PrintVTable(VFPTR vTable[])
{
    
    
    // 依次取虚表中的虚函数指针打印并调用,调用可以看出虚表中存的是哪个函数
    cout << " 虚表地址>" << vTable << endl;
    for (int i = 0; vTable[i] != nullptr; ++i)  
    {
    
    
        printf(" [%d]:%p,->", i, vTable[i]);
        vTable[i]();  
    }
    cout << endl;
}

image-20230326162942760

与监视、内存窗口对比如下:

image-20230327005730907

结论:在单继承中,子类首先会拷贝父类的虚表,然后进行重写,最后将自己特有的虚函数的地址填入虚表中

注意事项:

1、此函数仅保证能在 VS 下成功运行,因为在其他平台下虚表最后一个元素不一定是 nullptr
2、函数指针的 typedef 与其他类型的 typedef 不同,重命名的名字要放在括号里面作为函数名 (函数名就是函数指针)
3、使用函数指针调用函数是不必解引用,因为函数名就是函数指针。
4、由于编译器的某些原因,如果在 VS 下运行 PrintVTable 出现运行崩溃的情况,可以在生成选项中先清理解决方案,然后重新运行即可

  • 问:\虚表存在哪个区域?**(栈?、堆?、静态区?、常量区?……?)

同一个类型的对象共用一个虚表,所以需要一个长期存储的区域,放常量区更为合理,可以通过如下代码验证:

int c = 2;

通过观察,虚表的地址和常量区的地址更为接近。且虚表不可以修改,初始化的时候就有了,对象存在虚函数表就存在

2.多继承中的虚函数表

给出如下的测试用例:

class Base1 {
    
    
public:
    virtual void func1() {
    
     cout << "Base1::func1" << endl; }
    virtual void func2() {
    
     cout << "Base1::func2" << endl; }
private:
    int b1;
};
class Base2 {
    
    
public:
    virtual void func1() {
    
     cout << "Base2::func1" << endl; }
    virtual void func2() {
    
     cout << "Base2::func2" << endl; }
private:
    int b2;
};
class Derive : public Base1, public Base2 {
    
    
public:
    virtual void func1() {
    
     cout << "Derive::func1" << endl; }
    virtual void func3() {
    
     cout << "Derive::func3" << endl; }
private:
    int d1;
};
int main()
{
    
    
    Derive d;
    return 0;
}

这里Derive就是一个多继承,它继承了Base1和Base2,我们先通过监视窗口看看其虚表的结构如何:

image-20230326234304606

这里依旧是拷贝了父类Base1和Base2的虚表,进行修改,覆盖重写了那个虚函数。这里子类Derive的虚函数fun3依旧是没有在监视窗口中看到,很明显这又是监视窗口的一个美化。

理想中的状态如下图示:

image-20230326235219550

和单继承的虚表一样,我们有两种方法观察内部结构:

  • 1、通过内存窗口:

image-20230327004601403

  • 2、通过打印虚表地址的方法来看:
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
    cout << " 虚表地址>" << vTable << endl;
    for (int i = 0; vTable[i] != nullptr; ++i)
    {
        printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
        VFPTR f = vTable[i];
        f();
    }
    cout << endl;
}
int main()
{
    Derive d;
    PrintVTable((VFPTR*)(*(int*)&d));
    PrintVTable((VFPTR*)(*(int*)((char*)&d + sizeof(Base1))));
    return 0;
}

image-20230327004744239

  • 总结:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。

3.菱形继承、菱形虚拟继承

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。具体内容还请看陈皓大佬的博文:


六、继承和多态常见的面试问题

1.问答题

  1. 下面哪种面向对象的方法可以让你变得富有 ( )
    A: 继承 B: 封装 C: 多态 D: 抽象
  2. ( ) 是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,
    而对方法的调用则可以关联于具体的对象。
    A: 继承 B: 模板 C: 对象的自身引用 D: 动态绑定
  3. 面向对象设计中的继承和组合,下面说法错误的是?()
    A:继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复
    用,也称为白盒复用
    B:组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动
    态复用,也称为黑盒复用
    C:优先使用继承,而不是组合,是面向对象设计的第二原则
    D:继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封
    装性的表现
  4. 以下关于纯虚函数的说法,正确的是( )
    A:声明纯虚函数的类不能实例化对象 B:声明纯虚函数的类是虚基类
    C:子类必须实现基类的纯虚函数 D:纯虚函数必须是空函数
  5. 关于虚表说法正确的是( )
    A:一个类只能有一张虚表
    B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
    C:虚表是在运行期间动态生成的
    D:一个类的不同对象共享该类的虚表
  6. 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( )
    A:A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址
    B:A类对象和B类对象前4个字节存储的都是虚基表的地址
    C:A类对象和B类对象前4个字节存储的虚表地址相同
    D:A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表
  7. 下面程序输出结果是什么? ()
#include<iostream>
using 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) :C(s1, s3), B(s1, s2),  A(s1)
    {
    
    
        cout << s4 << endl;
    }
};
int main() {
    
    
    D* p = new D("class A", "class B", "class C", "class D");
    delete p;
    return 0;
}

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

解析:A B C D 是菱形虚拟继承关系,所以 D 对象中只存在一份 A,所以 A 只能调用一次构造函数,并且 A 对象应该由 D 对象来调用其构造;同时,变量初始化的顺序与变量在初始化列表出现的顺序无关,而与变量的声明顺序有关,对应到继承关系中,就是先被继承的先完成初始化。

变形:下面程序输出结果是什么? ()

#include<iostream>
using namespace std;
class A {
    
    
public:
    A(char* s) {
    
     cout << s << endl; }
    ~A() {
    
    }
};
class B : public A
{
    
    
public:
    B(char* s1, char* s2) :A(s1) {
    
     cout << s2 << endl; }
};
class C : 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) :C(s1, s3), B(s1, s2),  A(s1)
    {
    
    
        cout << s4 << endl;
    }
};
int main() {
    
    
    D* p = new D("class A", "class B", "class C", "class D");
    delete p;
    return 0;
}

解析:A B C D 为菱形继承,所以 D 对象中的属于 B 和 属于 C 的部分各有一个 A,即 A 应该调用两次构造;同时,变量初始化 的顺序与变量在初始化列表出现的顺序无关,而与变量的声明顺序有关,对应到继承关系中,就是先被继承的先完成初始化。

所以,输出顺序是:ABACD

8 .多继承中指针偏移问题?下面说法正确的是( )

class Base1 {
    
     public:  int _b1; };
class Base2 {
    
     public:  int _b2; };
class Derive : public Base1, public Base2 {
    
     public: int _d; };
int main() {
    
    
    Derive d;
    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;
    return 0;
}

A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3

变形:下面说法正确的是( )

class Base1 {
    
     public:  int _b1; };
class Base2 {
    
     public:  int _b2; };
class Derive : public Base2, public Base1 {
    
     public: int _d; };
int main() {
    
    
    Derive d;
    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;
    return 0;
}

解析:子类对象模型中先被继承的父类,其父类模型会被放在子类对象模型的上方;栈的使用规则是先使用高地址,再使用低地址,而对象模型内部是先使用低地址,再使用高地址,即先继承的父类其对象模型在子类模型的低地址处。

所以,大小关系为:p1 > p2 == p3

9 .以下程序输出结果是什么()

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;
}

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

解析:在上面抽象类那里讲了。

10 .下面函数输出结果是( )

class A {
    
    
public:
    virtual void f() {
    
    
        cout << "A::f()" << endl;
    }
};

class B : public A {
    
    
private:
    virtual void f() {
    
    
        cout << "B::f()" << endl;
    }
};

int main() {
    
    
    A* pa = (A*)new B;
    pa->f();
}

A.B::f() B.A::f(),因为子类的f()函数是私有的 C.A::f(),因为强制类型转化后,生成一个基类的临时对象,pa实际指向的是一个基类的临时对象 D.编译错误,私有的成员函数不能在类外调用

解析:虽然子类函数为私有,但是多态仅仅是用子类函数的地址覆盖虚表,最终调用的位置不变,只是执行函数发生变化。

参考答案:

  1. A 2. D 3. C 4. A 5. D 6. D 7. A 8. C 9. B 10. A

2.概念题

1、什么是多态?

  • 1、多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。
  • 2、必须通过基类的指针或者引用调用虚函数
  • 3、被调用的函数必须是虚函数(virtual),且派生类必须对基类的虚函数进行重写

2、什么是重载、重写(覆盖)、重定义(隐藏)?

  • 重载是指两个函数在同一作用域,这两个函数的函数名相同,参数不同。
  • 重定义(隐藏)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名相同。若两个基类和派生类的同名函数不构成重写就是重定义。
  • 重写(覆盖)是指两个函数分别在基类和派生类的作用域,这两个函数的函数名、参数、返回值都必须相同(协变例外),且这两个函数都是虚函数。

3、多态的实现原理?

父类对象和子类对象的成员中都包含一个虚表指针,这个虚表指针指向一个虚表,虚表当中存储的是该类对应的虚函数地址。因此父类指针指向父类对象时,会通过父类的虚表找到父类的虚函数地址,即可调用;当父类指针指向子类对象时,会通过子类的虚表中找到子类的虚函数地址进行调用。

4、inline函数可以是虚函数吗?

可以,不过内联函数是没有地址的,不会进入符号表。如果是普通调用倒没啥影响,如果是多态调用,编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。

5、静态成员可以是虚函数吗?

不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

6、构造函数可以是虚函数吗?

不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。而虚函数的意义是多态,多态调用时到虚函数表中去找,可构造函数之前还没初始化,如何去找?

7、析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

可以,并且最好把基类的析构函数定义成虚函数。参考本节课件内容

8、对象访问普通函数快还是虚函数更快?

首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

9、虚函数表是在什么阶段生成的,存在哪的?

虚函数表是在就编译阶段生成的,一般情况下存在代码段(常量区)的。

10、C++菱形继承的问题?虚继承的原理?

  • 问题:菱形继承会导致子类存放2份的父类成员,导致数据冗余和二义性。
  • 原理:而虚继承对于相同的虚基类在对象中只会存储一份,若要访问虚基类的成员则要通过虚机表指针访问到虚机表,而虚机表中存放的时偏移量,通过偏移量来找到公共父类成员的位置并对其进行操作。

注意:

  1. 继承里的虚机表存储的是偏移量,是为了解决数据冗余和二义性
  2. 多态里的虚函数表存的是虚函数的地址,为了的是实现多态。

11、什么是抽象类?抽象类的作用?

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

相关文章
|
3天前
|
C++
C++多态实现计算器
C++多态实现计算器
|
3天前
|
C++
9. C++虚函数与多态
9. C++虚函数与多态
33 0
|
3天前
|
存储 编译器 C++
C++:多态究竟是什么?为何能成为面向对象的重要手段之一?
C++:多态究竟是什么?为何能成为面向对象的重要手段之一?
52 0
|
3天前
|
存储 安全 算法
【C/C++ 数据发送结构设计】C++中的高效数据发送:多态、类型擦除与更多解决方案
【C/C++ 数据发送结构设计】C++中的高效数据发送:多态、类型擦除与更多解决方案
80 0
|
3天前
|
C++
【C++】从零开始认识多态(二)
面向对象技术(oop)的核心思想就是封装,继承和多态。通过之前的学习,我们了解了什么是封装,什么是继承。 封装就是对将一些属性装载到一个类对象中,不受外界的影响,比如:洗衣机就是对洗衣服功能,甩干功能,漂洗功能等的封装,其功能不会受到外界的微波炉影响。 继承就是可以将类对象进行继承,派生类会继承基类的功能与属性,类似父与子的关系。比如水果和苹果,苹果就有水果的特性。
24 1
|
3天前
|
C++
【C++】从零开始认识多态(一)
面向对象技术(oop)的核心思想就是封装,继承和多态。通过之前的学习,我们了解了什么是封装,什么是继承。 封装就是对将一些属性装载到一个类对象中,不受外界的影响,比如:洗衣机就是对洗衣服功能,甩干功能,漂洗功能等的封装,其功能不会受到外界的微波炉影响。 继承就是可以将类对象进行继承,派生类会继承基类的功能与属性,类似父与子的关系。比如水果和苹果,苹果就有水果的特性。
25 4
|
3天前
|
编译器 C++
c++的学习之路:22、多态(1)
c++的学习之路:22、多态(1)
21 0
c++的学习之路:22、多态(1)
|
3天前
|
存储 编译器 C++
【C++练级之路】【Lv.13】多态(你真的了解虚函数和虚函数表吗?)
【C++练级之路】【Lv.13】多态(你真的了解虚函数和虚函数表吗?)
|
3天前
|
C++ 编译器 存储
|
3天前
|
存储 C++
C++中的多态
C++中的多态
8 0