多态【C++】

简介: 多态【C++】

1. 概念

多态,就是多种形态。在C++中,产生多态的主体是对象,方式是通过函数。即不同的对象调用同一个函数(完全相同)会有不同的效果。

例如,生活中的多态有:军人优先买票,学生优惠,其他人全价。

2. 定义和实现

2.1 多态的构成条件

多态是不同继承关系的类对象,调用同一个函数,产生不同的行为。

在继承中,构成多态必须满足三个条件,缺一不可:

  1. 被调用的函数必须是虚函数;
  2. 必须通过父类指针或引用调用虚函数;
  3. 子类必须对继承自父类的虚函数重写。

简单地说,可以概括为两个条件:

  1. 子类重写虚函数;
  2. 父类指针或引用调用虚函数。

2.2 虚函数

被virtual修饰的类成员函数被称为虚函数:

class Person
{
public:
  virtual void BuyTicket() // 被virtual修饰的类成员函数
  {
    cout << "买票-全价" << endl;
  }
};

【注意】

  • 虚函数和虚继承使用了同一个关键字(可能大佬不想增加负担…),然而它们之间没有关系;
  • 普通函数不能被virtual关键字修饰,只有类的非静态成员函数前可以加virtual,且在函数声明的最前面。

2.3 虚函数的重写/覆盖

虚函数的重写也叫做虚函数的覆盖,若子类中有一个和父类完全相同的虚函数(返回值类型、函数名和参数列表相同),此时我们称该子类的虚函数重写了父类的虚函数,至于为什么叫覆盖,稍后解释。

不符合重写才是隐藏。

例如:

//父类
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;
  }
};

测试:分别父类指针调用父类和两个子类的同一个函数

int main()
{
    Person* p = new Person;
    p->BuyTicket();
    Person* p1 = new Student;
    p1->BuyTicket();
    Person* p2 = new Soldier;
    p2->BuyTicket();
    return 0;
}

输出

买票-全价

买票-半价

优先-买票

是否实现多态,必须牢记构成多态的两个条件,缺一不可。

【注意】

  • 如果把子类的virtual去掉,依然还是虚函数。因为子类从父类继承,把这个virtual属性也继承了,也就是继承了虚函数的声明。对于子类来说,重写只是修改了这个虚函数的实现而已。
  • 子类继承虚函数中的缺省值,是无法改变的。
class Base{
public:
    virtual void Show(int n = 10)const{    //提供缺省参数值
        std::cout << "Base:" << n << std::endl;
    }
};
class Base1 : public Base{
public:
    virtual void Show(int n = 20)const{     //重新定义继承而来的缺省参数值
        std::cout << "Base1:" << n << std::endl;
    }
};
int main(){
    Base* p1 = new Base1;
    p1->Show(5);           //#1
    p1->Show();            //#2
    return 0;
}
  • 正常情况下,如果Base的Show函数的参数是这样的Show(int n),那么输出是5和10。但为什么这里也输出5和10呢?目前可以这样理解:子类继承父类的虚函数,只会继承它的声明,缺省值属于函数的属性,是作为参数列表的一部分,所以子类不能改变。

2.4 虚函数重写的两个例外

协变

  • 指父类和子类虚函数的返回值类型不同

重写的协变:返回值可以不同,但是要求「返回值」必须是一个父子关系的指针或引用,这个「父子关系」很宽泛,可以是任意父子类型。

例如,下面的父子关系就是由一个A类和B类构成的,它们和Person类和Student类无关系:

class A{};  // 父类
class B : public A{};  // 子类
class Person
{
public:
    //返回父类A的指针
    virtual A* fun()
    {
        cout << "A* Person::fun()" << endl;
        return new A;
    }
};
class Student : public Person
{
public:
    //返回子类B的指针
    virtual B* fun()
    {
        cout << "B* Student::fun()" << endl;
        return new B;
    }
};
int main()
{
    Person* ptr1 = new Person;
    Person* ptr2 = new Student;
    ptr1->fun(); //A* Person::fun()
    ptr2->fun(); //B* Student::fun()
    return 0;
}

可以认为,重写的协变就是完全相同的函数的一个特例,只要满足返回值是父子类关系的指针或引用,也可以认为这个函数是相同的,只要其他构成多态的条件成立,就能构成多态。

析构函数重写

如果父类的析构函数为虚函数,无论是否加virtual关键字,子类析构函数只要定义,都与父类的析构函数构成重写,虽然父类与子类析构函数名字不同。

看起来不满足构成多态的条件,但实际上编译器为了满足某些场景的需要,会将所有被virtual修饰的析构函数(即使子类不加virtual继承的也是)的函数名统一处理为destructor。

场景:new出来的对象可能是父类类型的指针,也可能是子类类型的指针。

上面子类不加virtual这个规则也是为了这里做铺垫的。

首先针对这个特殊场景,分别用父类指针指向父类和子类对象,并分别delete这两个父类指针。

//父类
class Person
{
public:
    virtual ~Person()
    {
        cout << "~Person()" << endl;
    }
};
//子类
class Student : public Person
{
public:
    ~Student()
    {
        //cout << "~Student()" << endl;
    }
};
int main()
{
    // 父类指针指向父类和子类对象,并分别调用析构函数
    Person* parent = new Person;
    delete parent;
    Person* child = new Student;
    delete child;
    return 0;
}

输出:

~Person()

~Person()

结果表明,如果在这种场景下,只要子类没有重写析构函数,父类不论是指针指向的是父类对象还是子类对象,delete父类指针,只会调用父类的析构函数。那么对于子类,就缺少调用子类的析构函数,会出现内存泄露的问题。

解决:重写析构函数:

class Student : public Person
{
public:
    ~Student()
    {
        // 子类析构函数完成重写,符合构成多态的条件之一
        cout << "~Student()" << endl;
    }
};

输出:

~Person()

~Student()

~Person()

【补充】

虽然析构函数的函数名是和类名保持一致的,但是编译器为了构成析构函数的多态,实现virtual修饰的析构函数的重写,在编译后不论是否被virtual修饰,都会将析构函数的函数名统一为destructor。所以就函数名而言,在继承中,父子类析构函数构成隐藏。

2.5 override和final(了解)

override

  • 只能修饰子类的虚函数,检查子类虚函数是否完成重写,否则会报错。
class A
{
public:
    virtual void fun()
    {
        cout << "A" << endl;
    }
};
class B : public A
{
public:
    virtual void fun(int i) override
    {
        cout << "B" << endl;
    }
};
int main()
{
    A* p = new B;
    p->fun();
    return 0;
}

错误:

final

  • 修饰虚函数,表示该虚函数不能再被重写。
class A
{
public:
    virtual void fun() final // 用final修饰
    {
        cout << "A" << endl;
    }
};
class B : public A
{
public:
    virtual void fun()
    {
        cout << "B" << endl;
    }
};
int main()
{
    A* p = new B;
    p->fun();
    return 0;
}

错误:

2.6 重载、重写(覆盖)和隐藏(重定义)的对比

重载

  • 两个函数在同一作用域
  • 函数名相同,参数不同

重写(覆盖)

  • 两个函数分别在父类和子类的作用域
  • 函数名、参数、返回值都必须相同(协变除外)
  • 两个函数都必须是虚函数

重定义(隐藏)

  • 两个函数分别在基类和派生类的作用域
  • 函数名相同
  • 两个父类和子类的同名函数不构成重写就是重定义

3. 抽象类

3.1 纯虚函数

在虚函数的声明后加上=0,表明这个函数是纯虚函数。

包含纯虚函数的类叫做抽象类,也叫接口类,抽象类不能实例化出对象,

class A
{
public:
    virtual void fun() = 0;
};
int main()
{
    A a;
    return 0;
}

错误:

即使抽象类被继承,它的子类也不能实例化对象。只有重写了纯虚函数,子类才能实例化出对象。就像植物不能实例化出一个具体的植物。

class A
{
public:
    virtual void fun() = 0;
};
class B : public A
{
    virtual void fun() // 子类重写了父类的纯虚函数
    {
        cout << "B" << endl;
    }
};
int main()
{
    A* b = new B;
    b->fun();
    return 0;
}

输出:

B

抽象类的意义

虚函数生来就是要被调用的。抽象类间接让子类强制重写纯虚函数。

3.2 接口继承和实现继承

  • 接口继承:虚函数的继承是一种接口继承,子类继承的是父类虚函数的接口,以便能够重写构成多态;这也是子类不用加virtual的原因,因为相当于继承了父类虚函数的接口(声明:包括参数返回值和缺省值)部分,而不是实现部分。
  • 实现继承:普通函数的继承就是一种实现继承,普通类的成员函数继承就是实现继承,声明和定义都继承,就是继承整个函数体。子类继承了父类函数的实现(public),就可以使用父类的函数。

背景:

接口,就是函数的代称。举个例子:在实际项目中,A和B先商量好函数的参数,返回值和功能,然后A实现类,包括类的函数(接口),B调用类的接口。

如果不使用多态,就不要把函数定义成虚函数。

4. 多态的原理

4.2 虚函数表

一道常考的笔试题:

class Base
{
public:
    virtual void Func()
    {
        cout << "Func()" << endl;
    }
private:
    int _b = 1;
};
int main()
{
  Base b;
  cout << sizeof(b) << endl; //8
  return 0;
}

在32位环境下,输出8。

下面都以 32位的Visual Studio 2019为例。

实际上,对于Base对象的大小,并不包含虚函数的大小。因为编译器只会将虚函数的地址存在对象中,虚函数的地址存放在一个指针数组中,叫做虚函数表(VisualFuntionTable),简称虚表(VFtable);虚函数的地址称为虚表指针(VFptr)。

可以通过调试查看对象中虚表的存在:

可以看到,对象b的第一部分是虚函数表的地址,后面的是b对象自己的成员:func1和_b。

VS的处理方式是将虚表的地址放在这个模型到最前面,这个行为取决于编译器。

用Derive类继承父类Base,并重写父类中的虚函数:

#include <iostream>
using namespace std;
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 b1;
    Base b2;
    Derive d1;
    Derive d2;
    return 0;
}

通过调试监视窗口,可以看到四个对象的结构,我们可以发现:

父类Base有两个被virtual修饰的虚函数,所以父类的虚表指针指向的虚表存的是这两个虚函数。子类Derive继承父类Base,同样会把虚表继承下来,但注意不是同一个虚表。子类继承父类的虚表,会拷贝一份父类的虚表用作自己的虚表。如果子类重写了虚表中的虚函数,那么子类自己的虚表只要修改重写后的虚函数的地址即可。(就像上图中标红的部分)

这也是重写叫做覆盖的原因,因为覆盖的是虚表存放的地址。重新回顾一下,多态的构成条件是父类指针或引用调用子类对象中被重写的虚函数,重写后的虚函数的地址覆盖了原来虚函数的地址。

  • 重写是语法上的叫法,覆盖是原理层的叫法。

【注意】

  • 虚函数表即虚表,只会存储被virtual修饰的虚函数的地址;
  • 虚表本质是一个函数指针数组,在数组最后会用nullptr表示结束。
  • 注意区分上面表述的虚表指针和虚表。虚表指针是虚表的地址,而对象中存储的就是虚表指针,占4个字节(32位)。

如果子类自己也有新增的虚函数呢?

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;
    }
    // 子类新增虚函数Func2
    virtual void Func2()
    {
        cout << "Derive::Func2()" << endl;
    }
private:
    int _d = 2;
};
int main()
{
    Derive d;
    return 0;
}

可以看到,子类自己新增的虚函数的地址也会添加到子类自己的虚表中。


子类生成虚表的步骤

  1. 子类先拷贝一份父类的虚表作为自己的虚表;
  2. 将子类重写后的虚函数的地址覆盖对应原来父类中虚函数的地址;
  3. 子类自己新增的虚函数的地址会按它在子类中的声明顺序追加到子类虚表中。

虚表的内容(了解)

上文已经说明,虚表中存的是虚函数的地址,而通过监视窗口和内存窗口也不能很好地查看虚虚表中的内容,下面用一个程序打印并调用虚表中地址对应的虚函数。

class Base {
public:
  virtual void func1() { cout << "Base::func1" << endl; }
  virtual void func2() { cout << "Base::func2" << endl; }
private:
  int b1 = 1;
};
class Derive : public Base{
public:
  virtual void func1() { cout << "Derive::func1" << endl; }
  virtual void func2() { cout << "Derive::func2" << endl; }
private:
  int d = 2;
};
typedef void(*VFPTR)();
void PrintVFTable(VFPTR table[])
{
    for (size_t i = 0; table[i] != nullptr; i++)
    {
        printf("VFT[%d]:%p-->", i, table[i]);
        VFPTR pf = table[i];
        pf();
    }
} 
int main()
{
  Base b;
  Derive d;
  PrintVFTable((VFPTR*)(*(int*)&b)); // 父类虚表 
  PrintVFTable((VFPTR*)(*(int*)&d)); // 子类虚表 
  return 0;
}

对于函数PrintVFTable,它的原理是:

  1. 首先用typedef定义一个函数指针类型,用法是一样的:
  • void(ptr*)(),类型是void*
  • typedef void(*VFPTR)()
  1. 函数参数接收一个函数指针数组table;
  2. 在函数体内打印函数指针数组的内容的同时调用地址对应的函数:
  • 只要有函数指针,那么通过函数指针()就能直接调用函数

我们知道,对象的前4个字节的内容就是虚表的地址,所以我们要取到对象前四个字节,然后把它作为参数传入PrintVFTable函数中。

使用指针之间强制类型转换,在学习指针的时候,我们知道指针的本质就是它对指向的内存的访问权限。例如char*指针只能访问一个字节,int*指针能访问它指向的内存往后4个字节。指针可以互相转换而不影响它指向的内容。

所以可以先将对象的地址转为int*,先限制等下能访问到前4个字节,然后再对它解引用,就得到了前四个字节的内容,最后再强转为VFPTR*型,传入函数中。

注意不能直接将对象转为int型,这样做会发生数据的截断,而且没有使用到指针之间的强转。

补充:

  1. 对于参数而言,没有数组类型,因为数组传参都是传指针,相当于指针退化了;
  2. 最后一步,关于int类型转为指针类型:从int转为VFPTR*,因为我们取到的前四个字节本身就是一个「地址」而不是单纯的一个int类型的「数字」,所以把它转为指针类型才能作为参数传入。如果是一个int类型的变量,把它转为指针类型,系统会认为它是一个地址。
  3. 实际上传入的是一个二级指针,因为是通过对象地址找到虚表的地址,然后取虚表地址的前4个字节,这4个字节转换成VFPTR,也是一个指针类型。

从上面的结果来看,不论是否重写,所有虚函数的地址都要被虚表保存。

了解即可:相当于刚才写的打印虚表是从更底层的地址层面直接取到虚表的地址打印并调用函数,所以语法限制不了它,因为没有通过类去调用函数,也就没有走语法检查,所以只要虚表有它们的地址,都可以调用。

虚表的位置(了解)

我们知道,虚表本身是一个函数指针数组,那么它是什么时候初始化的呢?而虚函数又存在何处?虚表又在哪里?

  • 虚表在构造函数初始化列表阶段初始化的;
  • 虚函数和普通函数一样,是存储在代码区的,只是虚函数的地址被保存在了虚表中;
  • 至于虚表存储在何处,可以通过以下代码推断:
int j = 0;
int main()
{
  Base b;
  Base* p = &b;
  printf("vfptr:%p\n", *((int*)p));
  int i = 0;
  printf("栈上地址:%p\n", &i);
  printf("数据段地址:%p\n", &j);
  int* k = new int;
  printf("堆上地址:%p\n", k);
  char cp[] = "hello world";
  printf("代码段地址:%p\n", cp);
  return 0;
}

可以看到,虚表的地址和数据段地址相近,可以认为是虚表储存在数据段中。

在gcc编译器的实现中,虚函数表存放在可执行文件的只读数据段。而VS2013可能存放在代码段中,个人认为这个行为取决于编译器和平台。

不过这两个位置的数据都是具有常性的,从这里可以推断出:虚函数表不是属于某一个对象特有的,是一个类所有对象共有的。

小结

从上面的分析可以知道多态的原理:

  • 对于父类对象,对象中第一部分为虚表的地址,父类对象的所有虚函数的地址都存放在虚表中;
  • 对于子类对象,对象中第一部分为虚表的地址,指向的虚表继承自父类,拷贝而来的,子类的虚函数包括继承自父类的和自己新增的两部分,它们也都存放在子类自己的虚表中。

构成多态条件之一:子类重写父类虚函数,完成虚函数的覆盖,也就是更新了子类自己的虚表。注意:只会更新被重写的虚函数的地址,其他虚函数的地址是不会被覆盖的。

构成多态条件之二:父类指针或引用调用子类对象的虚函数。其实就是取出子类对象中的虚函数表的地址以后,再找到虚函数表,调用重写(覆盖)以后的虚函数。

这样就实现了不同对象调用同一个函数(参数列表和返回值都相同),得到不同的结果。

为什么一定要父类指针或引用指向子类对象呢?反过来不可以吗?

原理和切片是一样的,或者说,原理就是切片。因为子类继承了父类所有的数据,至于能不能访问,那是由public这些限定符在语法上限制的,如果知道了父类私有成员的地址,我们依然能够访问它们。

子类对象有父类完整的那一部分,父类指针或引用指向子类对象,指向的内容就是子类对象中父类的那一部分,反之则不一定。

为什么一定要父类指针或引用指向子类对象呢?如果想只用父类对象调用子类对象虚函数呢?

class Base {
public:
  virtual void func1() { cout << "Base::func1" << endl; }
  virtual void func2() { cout << "Base::func2" << endl; }
private:
  int b1 = 1;
};
class Derive : public Base{
public:
  virtual void func1() { cout << "Derive::func1" << endl; }
  virtual void func2() { cout << "Derive::func2" << endl; }
private:
  int d = 2;
};
int main()
{
  Base b1;
  Base b2;
  PrintVFTable((VFPTR*)(*(int*)&b1));
  PrintVFTable((VFPTR*)(*(int*)&b2));
  Derive1 d1;
  Derive2 d2;
  b1 = d1;
  b2 = d2;  
  PrintVFTable((VFPTR*)(*(int*)&b1));
  PrintVFTable((VFPTR*)(*(int*)&b2));
  //b1.func1();
  //b1.func2();
  //b2.func1();
  //b2.func2();
  return 0;
}

对于main函数中的第三行代码,看起来很像父类对象“指向”子类,但不要忘记这是对象之间的赋值操作,我们知道,子类对象赋值给父类对象,叫做切片。会自动调用父类对象的拷贝构造,将Derive中Base的那部分拷贝给Base类对象。

通过打印父类对象被子类对象赋值前后的虚表的内容,可以知道看到,不管是赋值前后,父类对象的虚表都是同一个虚表。这我们能够理解,因为切片切割子类对象出来,就相当于是一个父类对象,同类型对象共用同一个虚表。

总地来说:

  • 构成多态:指向谁就调用谁的虚函数,与对象有关(=号右边);
  • 不构成多态:对象类型是谁就调用谁的虚函数,与类型有关(=号左边)。

所以多态最重要的是看能不能满足构成多态的两个条件。

4.3 单继承和多继承的虚表

单继承

在4.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;
};

先来看看三个类的虚表的内容,依然使用上面的PrintVFTable函数打印:

int main()
{
  Base1 b1;
  Base2 b2;
  Derive d;
  cout << "父类Base1的虚表" << endl;
  PrintVFTable((VFPTR*)(*(int*)&b1));
  cout << "父类Base2的虚表" << endl;
  PrintVFTable((VFPTR*)(*(int*)&b2));
  cout << "子类第一个虚表" << endl;
  PrintVFTable((VFPTR*)(*(int*)&d));
  cout << "子类第二个虚表" << endl;
  PrintVFTable((VFPTR*)(*(int*)((char*)&d + sizeof(Base1))));
  return 0;
}

从打印的结果来看,对于父类,依然是简单地将虚函数的地址放入它的虚表中。而对于多继承的子类对象,它继承了Base1和Base2两个父类的虚函数,所以拷贝了两份虚表给自己。从结果可以看出,子类自己新增的虚函数func3是尾增到第一个虚表中的,而对于两个父类中相同函数func1和func2,func1被子类Derive重写,所以两个虚表的func1都被覆盖;而func2没有被子类Derive重写,所以两个虚表中的func2的地址都分别和父类Base1和Base2相同。

对于多继承,在VS编译器下,虚表和其他成员的存储模型是这样的:

在多继承关系中,子类的虚表也是一样的,只不过是两个虚表。因此而不同的是,子类自己新增的虚函数的地址是存放在第一个虚表中的。

上面使用PrintVFTable,因为已经提前知道Derive中有两个虚表,所以它的内容的前4个字节是第一个虚表的地址,我们能直接用int*取到;而5-8是第二个虚表的地址,我们既可以用sizeof(Base1)跳过Base1那一部分,也可以这么做:

Base1* ptr1 = &d;
PrintVFTable((VFPTR*)(*(int*)(ptr1)));
Base2* ptr2 = &d;
PrintVFTable((VFPTR*)(*(int*)(ptr2)));

在VS编译器中使用PrintVFTable函数打印时,经常会崩溃,原因是编译器对虚表处理不当,在数组最后没有放nullptr,此时只要“生成”->“重新生成解决方案”即可。

菱形继承、菱形虚拟继承

菱形继承和菱形虚拟继承,在实际应用中并不广泛,而且更复杂,更容易出问题,效率也会因此降低。

关于菱形继承和虚拟继承,在面试中主要是理解继承。

巨佬博客:C++ 虚函数表解析C++ 对象的内存布局

5. 常见面试题

概念

  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:虚函数可以是一个static型的函数
  6. 关于虚表说法正确的是( )
    A:一个类只能有一张虚表
    B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
    C:虚表是在运行期间动态生成的
    D:一个类的不同对象共享该类的虚表
  7. 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( )
    A:A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址
    B:A类对象和B类对象前4个字节存储的都是虚基表的地址
    C:A类对象和B类对象前4个字节存储的虚表地址相同
    D:A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表
  8. 下面程序输出结果是什么? ()
#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) :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;
}
  1. 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
  2. 多继承中指针偏移问题:下面说法正确的是( )
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;
}
  1. A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3
  2. 以下程序输出结果是什么()
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. A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

参考答案:

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

问答题

  1. 什么是多态?

多态:不同数据类型的实体提供统一的接口,或使用一个单一的符号来表示不同的类型。在继承关系中,也就是不同类的对象,调用同一个函数(参数列表和返回值),产生不同的行为。

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

重载:在同一作用域下函数名相同,参数不同;

重写(覆盖):子类继承了父类的虚函数的声明,参数返回值完全相同(除了协变),子类重新实现它;

重定义(隐藏):子类和父类中的函数名相同。父类和子类的同名函数如果不构成重写,就是重定义。

  1. 多态的实现原理?

如果父类有虚函数,子类(公有地)继承了它,子类和父类实例化对象时,子类也会继承父类的虚表,各自在对象中用一个地址保存它们各自的虚表;如果子类重写了父类的某个虚函数,那么子类就会修改虚表中对应虚函数的地址;其他未被重写的虚函数的地址依旧是不变的,是和父类的虚表中对应的虚函数的地址是一样的。

当父类指针或引用调用子类对象的虚函数,父类指针或引用会通过子类对象中的虚表指针找到虚表,然后调用虚表中的虚函数。如果调用的虚函数被子类覆盖(重写了),那么就是调用子类重写的那个虚函数。如果父类指针或引用指向父类,自然只会调用父类自己的函数。

通过父类指针或引用指向不同的对象,调用同一个函数,实现多态。

  1. inline函数可以是虚函数吗?

个人感觉这个问题怪怪的,可以理解成:inline函数可以被virtual修饰吗?

可以。因为inline是一个建议性的关键字,对于编译器而言,inline不inline取决于编译器的决策。事实是编译器不会把虚函数作为内联函数。

原因:内联函数会在调用的地方直接展开,所以内联函数也就没有地址可言,但是虚函数是一定要把地址存到虚表中的,所以编译器会忽略虚函数的内联属性。

  1. 静态成员可以是虚函数吗?

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

  1. 构造函数可以是虚函数吗?

不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。

  1. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

可以,并且最好把基类的析构函数定义成虚函数。

原因:有这样的场景:用父类指针分别指向两个被new出来的父类对象和子类对象。如果使用delete释放对象资源,只有当父类的析构函数是虚函数时才能调用父类和子类的析构函数分别对父类和子类对象进行清理;否则 使用delete清理父类指针指向的对象,只能调用到父类自己的析构函数。

  1. 对象访问普通函数快还是虚函数更快?

如果是普通对象,一样快。

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

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

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

  1. C++菱形继承的问题?虚继承的原理?

菱形虚拟继承因为子类对象当中会有两份父类的成员,导致数据冗余和二义性的问题。

虚继承对于相同的虚基类在对象当中只会存储一份,若要访问虚基类的成员需要通过虚基表找到到偏移量,进而找到对应的虚基类成员,解决了数据冗余和二义性的问题。

  1. 什么是抽象类?抽象类的作用?

抽象类体现了虚函数的继承是一种接口继承(也就是声明的部分),强制子类去抽象纯虚函数。如果子类也不实现继承下来的纯虚函数,那么子类也是一个抽象类,也不能实例化出对象。抽象类也可以表示现实世界中没有实例对象对应的抽象类型,比如:植物、人、动物等。

补充

实际上,C++在语法上并没有规定虚函数应该存在哪,也没有规定虚函数表应该怎样处理虚函数,上面的许多存储模型(包括之前多继承的模型)都是编译器的行为,由于这可能是最优的方案之一,所以也就成了不是标准的标准,不同的编译器的处理方式都是类似的。

目录
相关文章
|
22天前
|
存储 编译器 数据安全/隐私保护
【C++】多态
多态是面向对象编程中的重要特性,允许通过基类引用调用派生类的具体方法,实现代码的灵活性和扩展性。其核心机制包括虚函数、动态绑定及继承。通过声明虚函数并让派生类重写这些函数,可以在运行时决定具体调用哪个版本的方法。此外,多态还涉及虚函数表(vtable)的使用,其中存储了虚函数的指针,确保调用正确的实现。为了防止资源泄露,基类的析构函数应声明为虚函数。多态的底层实现涉及对象内部的虚函数表指针,指向特定于类的虚函数表,支持动态方法解析。
29 1
|
3月前
|
Java 编译器
多态
多态
17 2
|
7月前
|
编译器 C++
C++之多态
C++之多态
|
C++
多态(C++)下
多态(C++)
81 0
|
7月前
深入理解多态
深入理解多态
|
7月前
|
存储 C++
C++:多态
C++:多态
49 0
|
7月前
|
编译器 C++
【C++】:多态
【C++】:多态
62 0
|
编译器 C++
|
编译器 C++
多态(C++)上
多态(C++)
45 0