【C++】多态

简介: 【C++】多态

貌美不及玲珑心,贤妻扶我青云志。

12f59e986cdd41ca9d3b22776b914ac8.jpeg


一、继承和多态的概念


1.

多态是在继承的基础之上实现的,我们说继承是类设计层次的代码复用的一种手段,而多态则是在此基础上实现的多种形态,完成某一件事,可以由于对象的不同产生不同的完成结果,我们称这种现象为多态。


2.

对于买火车票这件事来说,学生可以享受半价,普通人则是全价,军人可以优先买票,这就是一种多态现象,例如person是一个基类,soldier和student是两个派生类,buyticket这个函数在被前面这三个类的对象调用时就应该返回不同的结果,这就是在继承基础上实现的多态。


二、多态调用和普通调用

1.虚函数的重写(覆盖)


1.

虚函数的定义较为简单,只需要在函数的接口部分加上virtual关键字即可,当虚函数所在类被继承时,派生类会隐含一个基类的虚函数,此时如果基类重新定义这个虚函数,并且和基类的虚函数的参数列表,返回值,函数名都一样,我们称派生类完成了虚函数的重写。


2.

重写这件事只针对于虚函数,普通函数并没有重写,所以重写的要求有两点:必须为虚函数,虚函数必须满足三同(函数名,参数列表,返回值)

class Person
{
public:
  virtual Person* BuyTicket() { cout << "Person:买票-全价" << endl; return this; }
};
class Student : public Person
{
public:
  // 重写/覆盖:
  //父类和子类的函数,同为虚函数,满足三同(函数名返回值参数都相同)
  // 隐藏/重定义:
  //父类和子类的函数,只要函数名相同就构成隐藏。
  //隐藏可以看作是重写的子集。
  Person* BuyTicket() { cout << "Student:买票-半价" << endl; return this; }
};


2.虚函数重写的特殊情况


1.

子类中继承基类的虚函数可以不加virtual关键字,虽然可以不加,但还是建议大家加上,代码风格更为规范一些。


2.

协变也是虚函数重写的特殊情况,三同中返回值可以不同,但是要求返回值必须是一个父子类关系的指针或引用,自己的父类或其他的父类都可以。实际并不常见,大家只要了解一下这个语法就够了。


class Soldier : public Person
{
public:
  Person* BuyTicket() { cout << "Soldier:买票-优先" << endl; return this; }
};
// 虚函数的重写/覆盖:
// 父类和子类的函数,同为虚函数,满足三同(函数名返回值参数都相同)
// 特殊情况:
// 1.子类虚函数可以不加virtual,继承后重写的是基类虚函数的实现,如果你不写BuyTicket则派生类也会继承父类的BuyTicket
//   建议父类子类都加上virtual
// 2.协变:三同中返回值可以不同,但是要求返回值必须是一个父子类关系的指针或引用,自己的父类或其他的父类都可以
//        实际中协变并不常见,这里只要了解一下语法就够了。
// 


3.

虚函数的返回值除本身的父子类继承关系中的类型外,还可以是其他继承关系中的父子类指针或引用,例如下面虚函数的返回值分别是A *和B *,这也是协变的另一种场景。

class A
{
};
class B :public A
{
};
class Person
{
public:
  virtual A* BuyTicket() { cout << "Person:买票-全价" << endl; return nullptr; }
};
class Student : public Person
{
public:
  // 重写/覆盖:
  //父类和子类的函数,同为虚函数,满足三同(函数名返回值参数都相同)
  // 隐藏/重定义:
  //父类和子类的函数,只要函数名相同就构成隐藏。
  //隐藏可以看作是重写的子集。
  B* BuyTicket() { cout << "Student:买票-半价" << endl; return nullptr; }
};
class Soldier : public Person
{
public:
  B* BuyTicket() { cout << "Soldier:买票-优先" << endl; return nullptr; }
};
//协变真不常用,只有某些特殊情景下可能用到协变


4.析构函数的重写也算虚函数重写的特殊情况,讲解部分放到下面,这里提前说一下。


3.多态调用 VS 普通调用


1.

满足多态调用,首先调用的函数必须是重写的虚函数(如果基类虚函数只是简单继承到父类,父类并没有显示写出来虚函数,则这样也不能算是重写的虚函数,不符合多态,具体细节放到多态原理部分讲解),更为重要的是必须是基类的指针或者引用调用重写的虚函数,只有同时满足这两点才算多态调用,其他情况下均为普通调用。


2.

例如下面的func1,虽然调用的函数BuyTicket确实满足了虚函数的重写,但是调用虚函数的并不是父类指针或引用,那么BuyTicket的调用就不算多态调用,仅仅只能算是普通调用。


3.

普通调用只和调用对象的类型有关,对象的类型是什么,就调对应类里面的函数,这个函数可能是普通函数,也可能是虚函数,因为虚函数如果不是父类指针或引用进行调用的话,那他和普通函数就没什么区别。

多态调用和指针或引用指向的对象类型有关,指向的是父类那就调用父类的虚函数,指向的是子类那就调用子类重写的虚函数,这里调用的函数只能是虚函数。

void Func1(Person p)
{
  p.BuyTicket();//和对象类型有关,不符合多态调用,这里只会调用父类Person的BuyTicket()
}
void Func2(Person& p)
{
  p.BuyTicket();
}
void Func3(Person* p)
{
  p->BuyTicket();
}
int main()
{
  Student st;
  Person pe;
  Soldier so;
  //Func1(pe);//引用就是引用的子类中父类的那一部分,指针指向的就是子类中父类的那一部分。
  //Func1(st);
  //Func1(so);
  Func2(pe);
  Func2(st);
  Func2(so);
  /*Func3(&pe);
  Func3(&st);
  Func3(&so);*/
  // 普通调用:跟调用对象的类型有关,
  // 多态调用:指针或引用指向的对象有关,指向子类调用子类的虚函数,指向父类调用父类的虚函数。
  //          假设你用传值引用,则形参对象永远都是基类对象,就算传的是派生类对象也不行,因为发生切片赋值,那么在调用函数
  //          的时候,永远调用的都是基类里面的函数,无法实现多态调用。
  //          而如果是指针或引用,他表面上看是基类指针,但他实际指向的对象是派生类对象,那么根据指针或引用指向的是派生类
  //          就可以实现派生类或基类函数的调用了,而不是像普通调用一样,永远指向的都是基类对象,只能调用基类函数。
  //
  //         
  return 0;
}


4.C++11的override和final

4.1 如何实现一个不能被继承的类?


1.

通过继承后的访问方式的改变,我们可以利用构造函数私有来设计出一个不能被继承的类,因为一个类最起码的功能就是要实例化出对象,如果连对象都实例化不出来,这个类就没有存在的必要。而派生类构造函数必须调用基类的构造进行基类部分成员的初始化,所以如果基类构造私有,则派生类无法实例化对象,自然派生类就没有什么用,此时基类就不能被继承,因为继承了也没有意义。


2.

C++11引入关键字final,被final修饰的类为最终类,表示该类不可以被继承。

如何实现一个不能被继承的类?
1.构造私有,C++98的方式。继承下来的成员不能自己初始化,所以如果父类构造函数私有,则不能被调用
2.类定义时加final,C++11的方式,表示类为最终类,不能被继承。
class A final
{
private:
  A()
  {}
};
class B :public A
{
};
int main()
{
  //B b;
  return 0;
}


4.2 override和final


1.

final也可以修饰虚函数,表示该虚函数不能被重写,这个语法其实就比较奇怪了,设计虚函数的意义就是为了让他在派生类里面发生重写,从而通过基类指针或引用完成多态调用,一个虚函数如果不能被重写,自然虚函数也就没什么意义了,而且虚函数还会进虚表,这还会平白无故的增加性能的损耗,所以这个语法比较奇怪,平常用的时候肯定也不多见,由此也可以看出来C++委员会是真的在摸鱼啊。


2.

override用于检测虚函数是否重写,如果并未重写则会发生报错,比如有的时候我们在重写虚函数时,不小心多加了个参数,或者函数名少写了一个字母等等,就会导致虚函数未完成重写的工作,此时override就会编译报错,帮我们检测出虚函数并未重写。

 1.final修饰类,类为最终类不能被继承。final修饰虚函数,虚函数不能被重写。
class Car
{
public:
  virtual void Drive() final {}
  //但是这里很怪,一般写出虚函数就是为了构成重写关系,从而满足多态调用,写了虚函数但又不构成重写,感觉没啥用
};
class Benz :public Car
{
public:
  virtual void Drive() { cout << "Benz-舒适" << endl; }
};
 2.override用于检查派生类是否重写了基类的某个虚函数,如果没有重写,则会编译报错。
class Car {
public:
  virtual void Drive(int x) {}//这样的基类虚函数,派生类就没有重写,override会检查出来并报错。如果不加override,构成隐藏
};
class Benz :public Car {
public:
  virtual void Drive() override { cout << "Benz-舒适" << endl; }
  //assert是运行时做的断言检查,override是编译时做的虚函数是否重写检查。
};
int main()
{
  Benz b;
}


5.重写,隐藏,重载的对比

1.重载:

两个函数必须在同一作用域里面,满足函数名相同,参数类型,个数,顺序不同时即为函数重载。


2.隐藏:

两个函数分别处于不同的作用域,只要函数名相同就构成隐藏,在访问时如果不指定基类作用域限定符,则默认调用的同名函数为派生类类域。


3.重写:

两个函数分别处于不同的作用域,两个函数必须都为虚函数,且需要满足三同,这样才可以构成重写。协变和析构函数算是重写的特殊情况,另外子类中的虚函数可以不加virtual关键字。

重写的要求严格,隐藏可以看作重写的条件的子集。



6.析构函数的多态调用

new和delete的底层行为


1.

当调用delete来释放对象资源时,delete的行为可以分成两步,首先delete针对对象这样的自定义类型会调用其析构函数,从而释放对象内部所包含的资源,然后delete会调用operator delete来释放对象所占有的空间资源。

当调用new来申请对象资源时,new的行为也可以分成两步,首先new会调用operator new来申请对象所占用的空间资源,然后new又针对对象这样的自定义类型又会调用其构造函数,用于完成对象占用空间中资源的初始化,以此来完成对象的构造。

operator delete底层和free调用的函数一样,都是free_dbg(),operator new底层实际是封装了malloc函数。


2.

先说结论,我们希望析构函数的调用不是普通调用,而是多态调用,所以析构尽量为虚函数。

情景1下,分别构造Person和Student的对象,在main函数调用即将结束时,会分别先调用Student和Person的析构函数,完成对象资源的清理,并且在进程结束后,对象所占的空间资源也会被OS回收,这一点问题都没有,可是情景2呢?


3.

情景2下,我们不再通过构造函数来构造对象,而是通过new来间接调用构造进行对象的创建,但是两个对象在new之后都是用基类指针进行接收的,这也很合理,因为基类指针既可以指向基类对象又可以指向派生类对象,但是delete的时候这里就会出问题,由于析构函数不是虚函数,则调用一定不是多态调用,那就是普通调用,普通调用只和调用对象类型有关,则new出来的Person和Student对象的析构都调用的是Person类的析构,如果派生类没有申请资源还好说,但只要申请资源,则Person的析构是无法完成Student对象资源的清理的,那在进程运行期间就会发生内存泄露,这就出大事情了。

所以我们期望析构函数的调用是多态调用,而不是普通调用,那么析构函数就应该是虚函数,子类完成析构虚函数的重写,以此来为多态调用铺路,有人可能会有疑问,析构函数的函数名都不相同,这不是不符合重写的定义了吗?实际上对于析构函数,编译器会做特殊处理,将他们的函数名都看作destructor,这样就满足重写的条件了,所以在析构函数这里编译器做了特殊处理,为的就是能够满足多态调用。当发生多态调用时,由于基类指针ptr1和ptr2指向的对象不同,则对于派生类就调用派生类的析构,基类就调用基类的析构,这样就可以分别完成对象的资源清理,不会有内存泄露的发生。

a71d123da12b4f6a8ef36496928d4923.png


class Person
{
public:
  virtual ~Person()
  {
    cout << "Person delete:" << _p << endl;
    delete[]_p;
  }
protected:
  int* _p = new int[10];
};
class Student : public Person
{
public:
  ~Student()
  {
    cout << "Student delete:" << _s << endl;
    delete[]_s;
  }
protected:
  int* _s = new int[20];
};
int main()
{
  // 情况1
  Person p;
  Student s;
  // 情况2
  Person* ptr1 = new Person;
  Person* ptr2 = new Student;
  delete ptr1;
  delete ptr2;
  return 0;
}


三、抽象类和接口继承

1.抽象类(接口类)的作用


1.

如果一个虚函数在接口后面加上=0,则这个虚函数为纯虚函数,纯虚函数所在的类为抽象类,抽象类是不可以被实例化出对象的,如果抽象类被继承,派生类里面天然的就会有纯虚函数,那么派生类也就变成了抽象类,一个类如果连对象都实例化不出来,那还有什么用呢?所以派生类必须重写纯虚函数,要不然派生类就没有用。


2.

所以抽象类的作用就是强制其派生类重写纯虚函数,比如Car他不是车的品牌,而Bmw和BenZ这些才是车的真正品牌,那么Car其实就是一个抽象类,他的作用就是强制Bmw和BenZ这样的类去重写纯虚函数。

另外抽象类也可以体现出来接口继承,重写的是虚函数的实现,继承的是虚函数的接口。


3.

这里强调一个比较容易混淆的点,只要虚函数被继承到派生类后,我们不显示写出来虚函数,那就不算虚函数重写,而写出来之后,就算虚函数和基类的一样,原封不动的拷贝似的,也没有关系,这样也算虚函数重写,所以要严格遵循重写定义,特殊情况除外。


//抽象类
class Car//抽象类---不能实例化出对象
{
public:
  //纯虚函数所在类为抽象类
  virtual void Drive() = 0 //一般纯虚函数不写实现,写了也没啥用,因为其所在类无法实例化出对象
  {
    cout << "endl;" << endl; 
  } ;
};
class Benz :public Car
{
public:
 //如果不重写纯虚函数,则自然继承下来之后,派生类也会变为抽象类,自然也不能实例化出对象。
 //只有对纯虚函数进行重写之后,函数就不算纯虚函数了,派生类就不再是抽象类,就可以实例化出对象。抽象类强制子类重写纯虚函数
  virtual void Drive()//重写纯虚函数
  {
    cout << "Benz-舒适" << endl;
  }
};
class BMW :public Car
{
public:
  virtual void Drive()
  {
    cout << "BMW-操控" << endl;
  }
};
int main()
{
  BMW b;//如果BMW没有重写纯虚函数,则继承下来的纯虚函数就是原生的,那么BMW就是抽象类,抽象类是不能实例化对象的。
  //抽象类从某种程度上说,就是强迫子类重写纯虚函数。
  //override是检查虚函数是否重写,抽象类是强迫派生类重写纯虚函数,否则派生类无法实例化出对象。
}


2.接口继承和实现继承(一道秒杀99%人的题)


1.

虚函数的继承是接口继承,目的就是让子类重写虚函数,重写的是虚函数的实现,因为在继承时继承的是虚函数的接口。

普通函数的继承是实现继承,将普通函数直接照搬到派生类里面,没有重写这样的情况发生。


2.

下面我们来看一道题,加深对于接口继承和多态调用的理解。

首先,对于非静态类成员函数的调用,无论是对象去调用函数,还是函数之间进行调用,他们本质都是通过隐含的this指针来完成调用的,那么B继承A中的test()后,并未显示给出,则不满足重写定义,所以test()的this指针类型还是原来基类类型的也就是A *,那么当test()里面调用func时,就是通过A *的this指针进行调用,而func又是虚函数的重写,所以此时就满足多态调用,并且A *this指针指向的对象是B对象,那么调用的func函数就是B类的func函数,而多态调用下的虚函数继承又是接口继承,所以使用的val是1,那么最终的打印结果就是B→1。


有人可能对于A*的this指针指向的对象是B对象,而不是A对象有些疑问,其实这很好理解,既然函数都被继承到B类中了,那么函数的指针指向对象就理应是B对象,只不过这个指针仅仅指向B对象中A类的成员部分,而不是指向B对象的整个部分而已,这块其实就是继承那篇博文里面的切片赋值,只不过指针和引用特殊一些,不用构造出新的对象,只需要指向派生类对象的基类成员部分即可

c2f33d38b1e2409680b2d6fd58e0b8c4.png

c8fb1216b4504d48bfa923926c4a5b39.png


3.

如果我们显示写出来test,即使test原封不动,那也算虚函数重写,则这时调用test时,test里面的this指针是B *,B *指针调用func就不符合多态调用,不符合多态调用那就是普通调用,将func看作普通函数,根据this指针类型进行调用,现在是B *类型,那么直接调用B类里面的func函数就可以,并且因为是普通调用直接使用val=0即可。

(其实如果从底层角度来看的话,多态调用和普通调用走的路子不一样,导致使用的虚函数接口也不一样,如果是多态调用,则先去虚表里找到func地址,然后根据地址到代码段里面拿到对应的func虚函数,这个虚函数的接口用的是基类接口,实现用的是派生类重写后的func。如果是普通调用,则发生静态绑定,直接去代码段里面拿func,但这个func的接口用的是派生类重写虚函数之后的接口,实现肯定和多态调用调到的func是一致的。所以代码段里面应该存放了两个func,分别用于多态调用和普通调用。)e17c03f071204fdfa4c0ef9c4164785c.png


6a3d4d35906840a6965d180e94a20a65.png


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; }
  //virtual void test() { func(); }
};
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 : 以上都不正确


四、多态原理

1.虚函数表和虚函数的覆盖


1.

当一个类里面出现虚函数时,这个类实例化出的对象模型就会发生改变,他的类成员除变量之外,还会多一个虚表指针,这个虚表指针指向一个数组,这个数组里面存放的是类里面虚函数的地址。我们将这个数组称为虚函数表,简称虚表,指向虚函数表的指针简称为虚表指针。5ed309309e7b4c76862852be63a68b55.png


2.

另外虚函数的重写其实是语法的叫法,覆盖才是底层的叫法,可以看到在重写func1过后,d对象里面的虚表的func1函数地址发生了改变,这就是我们所说的虚函数的覆盖。

bcc8eada93ac403aaa7bc780ee313d65.png


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;
  char _ch;
};
class Derive : public Base
{
public:
  virtual void Func1()//对func1进行重写
  {
    cout << "Derive::Func1()" << endl;
  }
  void Func3()
  {
    cout << "Derive::Func3()" << endl;
  }
private:
  int _d = 2;
};
int main()
{
  //当类里面有虚函数之后,类对象的存储模型和以前不一样了,类对象会多一个虚表指针。
  cout << sizeof(Base) << endl;
  Base b;
  Derive d;
  //普通调用 -- 编译时绑定,静态绑定/决议
  Base* ptr = &b;
  ptr->Func3();
  ptr = &d;
  ptr->Func3();
  //多态调用 -- 运行时绑定,动态绑定/决议
  ptr = &b;
  ptr->Func1();
  ptr = &d;
  ptr->Func1(); 
  Base b1;
  Base b2;
  return 0;
}


2.多态原理,动态绑定和静态绑定


1.

其实在知道虚函数表的存在之后,就可以理解多态的原理了,我们可以通过底层的汇编看一下多态调用和普通调用有什么区别,通过底层可以看到,如果是普通调用,汇编代码非常简单,仅仅call了一下函数的地址就完成了函数调用。如果是多态调用,可以看到汇编代码较为繁琐,最后还call了一下eax寄存器的内容,很是繁琐嘛。

84461c05a16d4786af7f21a1694565eb.png

2.

如果我们继续深究一下汇编可以看到,最后call的eax寄存器的值实际就是调用对应的虚函数的地址的值,当ptr指向基类时,调用的虚函数是基类里面的func1虚函数,当ptr指向派生类时,调用的虚函数是派生类里面的虚函数。

所以,在call寄存器之前的操作,就是去ptr指向的对应的类里面的虚函数表,去找对应的虚函数进行调用,这是多态调用和普通调用最本质的区别。普通调用在编译时通过调用对象的类型,从编译后产生的符号表确认了函数的地址(如果细分其实是在编译时进行符号汇总,汇编阶段形成符号表,等待链接阶段进行符号表的合并),直接call 调用函数的地址完成函数调用。多态调用编译时可确定不了函数的地址,因为无法根据调用者的类型确定函数地址,只能在运行时去当前指针所指对象的虚函数表里面找,找到对应的虚函数地址才可以进行调用。

b6588df085364c6784e0d161b2f563d0.png


3.

下面是我从上面代码的反汇编里面摘取的多态调用的汇编代码。

//多态调用 -- 运行时绑定,动态绑定/决议
  ptr = &b;
00206858  lea         eax,[b]  
0020685B  mov         dword ptr [ptr],eax  
  ptr->Func1();
0020685E  mov         eax,dword ptr [ptr]  
//ptr存的是对象头四字节内容,也就是将虚函数表地址存到eax寄存器里面
00206861  mov         edx,dword ptr [eax]  
//eax存的是虚表的地址,也就是将虚表的第一个虚函数地址放到edx寄存器里面
00206863  mov         esi,esp  
00206865  mov         ecx,dword ptr [ptr]  
00206868  mov         eax,dword ptr [edx]  
//edx存的是虚函数地址,将虚函数地址放到eax寄存器里面,直接call eax寄存器的值,就可以调用对应的虚函数
0020686A  call        eax  
0020686C  cmp         esi,esp  
0020686E  call        __RTC_CheckEsp (0201307h)  
  ptr = &d;
00206873  lea         eax,[d]  
00206876  mov         dword ptr [ptr],eax  
  ptr->Func1(); 
00206879  mov         eax,dword ptr [ptr]  
0020687C  mov         edx,dword ptr [eax]  
0020687E  mov         esi,esp  
00206880  mov         ecx,dword ptr [ptr]  
00206883  mov         eax,dword ptr [edx]  
00206885  call        eax  
00206887  cmp         esi,esp  
00206889  call        __RTC_CheckEsp (0201307h)  
  return 0;


4.

当我把func2进行虚函数重写之后,在汇编里面可以看到最后调用eax之前,还需要将edx的值+4,这其实就是偏移一下指针,edx原本存的是虚表第一个虚函数的地址,+4就变成第二个虚函数func2的地址了,如果我们对func3进行虚函数重写并调用的话,那传给eax寄存器的值就应该是edx+8.

5c992749fe714273b663515b1aa144eb.png


5.

在看完汇编之后,解释动静态绑定就很容易了,静态绑定又称编译时决议或前期绑定,早绑定,即在程序编译期间,即可确定程序行为,例如函数重载的调用,这就是静态多态,通过所传参数类型的不同,在编译期间就可确定调用的函数地址。

动态绑定又称运行时决议或后期绑定,,晚绑定,即在程序编译期间无法确定程序行为,例如多态调用,这就是动态多态,只能在程序运行期间,去指针或引用指向的虚表里面去找对应的虚函数地址。


3.虚表的位置,虚表是共享的


1.

我们可以通过对比地址的方式来确定虚表的位置,从代码运行结果就可以看出,虚表地址和代码段的地址较为相近,所以虚表位置极大可能性就是在代码段,另一方面去理解的话,虚函数本质不就是类成员函数吗?当时在学习类和对象的时候,类成员函数不就是放在公共代码段等待对象进行调用吗?那这里又有什么区别呢?只不过虚表里面存的是虚函数的地址。

//写一个程序验证虚表是存在哪里的?
int main()
{
  int a = 0;
  cout << "栈:" << &a << endl;
  int* p1 = new int;
  cout << "堆:" << p1 << endl;//自动识别类型,打印变量里面存储的值,p1存的是堆空间的地址
  const char* str = "hello world";
  cout << "常量区/代码段:" << (void*)str << endl;
  static int b = 0;
  cout << "静态区/数据段:" << &b << endl;
  Base be;
  Derive de;
  cout << "虚表:" << (void*)*((int*)(&be)) << endl;//拿到虚表的地址进行打印。对比虚表地址和上面那些空间的地址,看虚表在哪
  cout << "虚表:" << (void*)*((int*)(&de)) << endl;
  //拿到虚函数类的成员前4个字节的内容,内容就是虚表的地址,通过对比,来判断虚表的位置。
  Base b1;
  Base b2;
  //虚表是共享的,道理和类成员函数一样,放在公共代码段,等待对象调用。
}


84d9ff65d7664cd581fa139adbb8b582.png


2.

另外虚表是共享的,一个类无论实例化出多少对象,对象里面存的虚表指针都是一样的。需要说清楚的一个概念是,虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针


d7f68a84680d4966aefbc6cb4633d267.png


3.

有很多的文章都说g++平台下的虚表指针存在.rodata段,但也没人能够验证这个结论,我的老师告诉我g++虚表指针也是存在于代码段里面的,我个人也这么觉得,下面第一张图片便是程序在g++下的运行结果,可以看到虚表的位置和代码段非常的接近,所以可以认为,在g++下,虚表指针和虚表指针所指向空间里面的虚函数都存在于代码段里面。

d47a2553222047fc9934e44de976ebe1.png


(这张图片是从某篇文章拿出来的,不过这个问题不是很紧要,因为.rodata段和代码段的位置非常的接近,并且在常量区也更加的合理,因为虚表指针不能被修改,放在只读数据段或者代码段感觉都也行,我不是研究操作系统的,就是靠C++混饭吃,没必要把OS搞通透,我还是听我老师的,虚表指针在vs和g++下都存在代码段里面,也就是常量区)

3fea9ed14ca1499582c9d1968ca144db.png


4.单继承中的虚表


1.

其实派生类的虚表生成过程很简单,先将基类虚表内容拷贝到派生类的虚表里面,如果派生类发生了虚函数的重写,则将重写后的虚函数覆盖到虚表对应位置的虚函数上,最后如果派生类有自己的虚函数,则将虚函数按照声明的次序,依次放到虚表的尾部后面。


2.

但由于编译器对监视窗口的优化,我们无法看到派生类虚表中存放他自己的虚函数,所以这里有两种解决办法,一种是通过内存窗口进行观察,一种是直接打印虚表,看看虚表中存放的函数都有谁。

5f59b65e266041538d3ab1b4b99f87d2.png



3.

打印虚表这里对C语言指针的要求很高,我们可以通过类型转换(Derive *转成int *)发生截断,取到对象地址的头四个字节,这四个字节其实就是虚表指针变量,变量里面存放的是虚表指针,然后解引用这四个字节拿到虚表地址,由于解引用后拿到的是int类型,不方便当作地址观察,我们再将其转为函数指针类型,方便给PrintVfTable传参,进行虚表的打印。

这里能够打印虚表的原因是因为在我们所设计的继承关系里面,虚函数的指针类型都是一样的,所以我们可以通过一个for循环函数指针调用来进行虚表的打印,如果虚表里面虚函数的指针类型不完全相同,则无法循环打印出函数地址,这里就是函数回调,由于函数指针类型都相同,那我们就能进行多个函数的回调。此时只能通过地址偏移的方式,逐个拿到虚表中的虚函数,并逐个通过函数指针变量接收虚函数地址,然后再通过地址进行调用。


4.

但上面这样的方式只适用于32位平台下的4字节指针,如果我想让程序同时适应32位和64位呢?我们也有两种解决办法,一种就是在类型转换时将对象地址强制类型转换为二级指针,这样在解引用的时候,拿到的就是一级指针,一级指针的大小在32位和64位平台是不同的值,那么我再将指针转换为虚函数地址类型就可以了,这样就可以完全适应32位和64位了,而不是32位转成int,64位转成double这样比较挫的方式了。

另外一种方式就是通过分支语句判断,如果是32位我们就转成4字节,64位就转成8字节,以适应不同平台的指针字节大小。


5.

非常恶心的是vs2022的虚表中不是以nullptr结束标识符,所以在打印的时候,我们需要手动控制循环条件,打印出对应的虚表,如果控制不当,则会发生越界访问。


打印结果可以看出,单继承中,派生类的虚函数func3存放在虚表尾部。98dfbe9bf8a1418cb04d525106508112.png


98dfbe9bf8a1418cb04d525106508112.png

 单继承的虚函数表
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 << "Base::func1" << endl; }
  virtual void func3() { cout << "Derive::func3" << endl; }
  void func4() { cout << "Derive::func4" << endl; }
private:
  int b;
};
typedef void(*VFPtr)();
//typedef void(*)() VFPtr;//这样是编不过的
void PrintVfTable(VFPtr vft[])
{
  for (int i = 0; i < 3; ++i)//vs上面是这么设计的,linux上虚表不是以nullptr结束的。我的2022不是nullptr结束,2013是。
  {
    printf("[%d]:%p->", i, vft[i]);
    vft[i]();//调用函数指针指向的函数
  }
}
int main()
{
  Base be;
  //PrintVfTable((VFPtr*)(*(int*)(&be)));
  //PrintVfTable((VFPtr*)(*(void**)(&be)));//void**解引用就是看void*的大小,那就是4或8字节大小
  Derive de;
  PrintVfTable((VFPtr*)(*(void**)(&de)));
  //1.void**,解引用后拿到的是void*字节大小的内容,然后再强转成VFPtr*(只要是二级指针就可以,解引用拿到指针大小个字节)
  //2.条件判断一下也可以。
  if (sizeof(void*) == 4)
  {
    cout << "32位" << endl;
  }
  return 0;
}


5.多继承中的虚表(汇编角度:虚函数重写中隐含的封装思想)


1.

多继承之后的派生类有两张虚表,那派生类自己的虚函数会放在哪里呢?先说结论,派生类自己的虚函数会放在继承后的第一张虚表里面,第一张虚表是哪个类和继承的类的先后关系有关,先继承哪个类,则第一张虚表就是这个类的虚表内容拷贝过来的,如果有重写,则发生虚函数覆盖即可。


2.

但是这里有一个问题需要讲一下,当我们要打印派生类中第二张虚表时,传的指针肯定就不是第一张虚表的地址了,而是第二张虚表的地址,这个时候要解决,有两种办法。

我们可以通过指针+ - 整数挪动字节的方式来让指针偏移到第二张虚表的位置,但是挪动之前要将指针先强转成char *然后再加上基类Base1实例化出的对象的大小,否则默认的+ -整数挪动的是派生类对象的大小,则会发生越界访问。

这种方式较为繁琐,另一种方式就是利用切片赋值,我们直接让Base2类型指针指向派生类对象即可,这样默认的Base2类型指针指向的就是派生类中Base2的基类成员,这个时候只要取得指针的前4个字节,就可以取到第二张虚表的地址了。


bff456f40c0b432a94527ceb9bf27dbb.png


多继承的虚函数表
typedef void(*VFPtr)();
void PrintVfTable(VFPtr vft[])
{
  for (int i = 0; i < 3; ++i)//vs上面是这么设计的,linux上虚表不是以nullptr结束的
  {
    printf("[%d]:%p->", i, vft[i]);
    vft[i]();//调用函数指针指向的函数
  }
}
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()
{
  Base1 b1;
  Base2 b2;
  Derive d;
  /*Base1* ptr1 = &d;
  Base2* ptr2 = &d;
  ptr1->func1();
  ptr2->func1();*/
  PrintVfTable((VFPtr*)(*(int*)&d));
  PrintVfTable((VFPtr*)*(void**)((char*)&d + sizeof(Base1)));
  //Drive*+1加的是Drive的大小,这里会崩,需要改成char*,指针+-整数,跳过指针类型字节的大小
  //如果多继承的类有虚函数,他的虚函数会放到第一个类的虚表里面。
  //另一个思路就是进行切片赋值,然后取第二张虚表的地址
  Base2* ptr = &d;
  PrintVfTable((VFPtr*)*(void**)((char*)ptr));
  return 0;
}


3.

从下面的打印结果就可以验证派生类的虚函数放在第一张虚表里面,其实我们从内存窗口也可以看出来第一张虚表里面放着派生类自己的虚函数,这些都没有什么问题。


可是这里隐含存在着一个非常非常重要的现象,Base1和Base2的虚函数被派生类重写,但是为什么重写之后第一张和第二张虚表里面的func1虚函数地址不一样呢?发生重写是要进行覆盖的啊,重写肯定只重写了一次,那应该两张表里面重写后的func1虚函数的地址一样才对啊,这是怎么回事捏?


实际上内存里面只有一份被重写的虚函数func1,重写之后Base1虚表里的func1正好被覆盖,但是Base2里面的虚表的func1也要被覆盖啊,所以如果是Base2类型指针多态调用func1时,它会先调用一个被封装好的函数,这个函数内部会先做指针的偏移,将指针的地址进行-8,让指针指向第一张虚表的func1虚函数,然后调用这份真正被重写的虚函数,所以第二张虚表里面看起来被重写的func1并不是内存里真正的func1,而是被封装后的func1,间接调用内存里只有一份的虚函数func1.

75b6aa41492848bfa8175a9c70110148.png


五、常见面试题(很重要的题)

1.继承体系中基类的初始化顺序和什么有关?(类的继承顺序)


1.

下面这道题的输出结果是"class A", “class B”, “class C”, “class D”,首先这是一个菱形虚拟继承,那么A类的成员变量在D成员中只会出现一份,因为菱形虚拟继承可以解决数据冗余和二义性的问题,那么即使B和C和D里面都初始化了A,但A肯定只会被初始化一次,因为我们知道,赋值可以被赋值多次,但初始化只能初始化一次。那么该用BCD的谁来初始化A呢?答案是用D来初始化A,因为在继承体系里面,谁先被继承,则谁的类成员就会被先初始化。D的继承顺序是先B后C,但B里面又会先继承A,所以实际初始化顺序是先A后B再C,在B中打印class A 之后也就初始过A了,C中就不会再初始化A,C中仅仅打印”class C“后就会结束,所以最终答案为"class A", “class B”, “class C”, “class D”

5945e7649099438280304de511cf11aa.png

2.

初始化列表初始化的顺序和写出来的顺序没关系,如果是类成员变量初始化,那看的是类成员变量的声明顺序,如果是继承中类的初始化,那看的就是继承体系中类的继承顺序,谁先继承谁先初始化

继承里面谁先被继承,则谁先被初始化
class A {
public:
  A(const char* s) { cout << s << endl; }
  ~A() {}
};
class B : virtual public A
{
public:
  B(const char* s1, const  char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:
  C(const char* s1, const  char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C//继承顺序
{
public:
  D(const char* s1, const  char* s2, const  char* s3, const  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;
}


3.

选项里面没有答案,这道题也考察了关于基类成员初始化的顺序和基类的继承顺序有关的这一知识点,由于继承顺序是先继承Base2后继承Base1,那么指针p1肯定不等于p2,而p2等于p3。


bc23d52697db4541bdd5f73c08c02d4a.png

class Base1 { public:  int _b1;
public:
  inline virtual void func(){}//内联函数可以是虚函数吗?
};
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;
  //先继承2在继承1,所以p2==p3!=p1
  return 0;
}
//A:p1 == p2 == p3         B:p1 > p2 == p3      C:p1 == p3 != p2     D:p1 != p2 != p3


下面是内存中地址空间的划分,可以看到下面是低地址,上面是高地址。所以实际的对象模型是倒着放的,地址的使用习惯是先用低地址后用高地址。

6191586615324ef08a88c631b27833a4.png


2.面试题(不要背八股文!不要背八股文!不要背八股文!)


1.什么是多态?

多态可以细分为静态多态和动态多态,静态多态例如函数重载,通过所传参数类型的不同确定调用的具体函数,动态多态就是利用虚函数的重写使得基类指针调用虚函数时,能够达到指针指向谁就调用谁的虚函数,从而实现动态多态。


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

重载指的是同一作用域内出现的相同函数名,参数类型个数顺序不同的同名函数。

重写指的是在继承体系中,派生类继承基类的虚函数,并且虚函数的函数名参数列表返回值均与基类相同,重写的是虚函数的实现,这样的虚函数就称之为重写。重写有特殊情况,协变就是一种特殊情况,允许虚函数的返回值不同,但只能为继承体系中父子类类型的指针,子类虚函数也可以不加virtual关键字,对于析构函数来说也算一种特殊情况,函数名虽然不同,但编译器会将析构函数特殊处理为destructor函数名,以此来符合虚函数重写的条件。

重定义的要求较为宽松,指的是在继承体系中,基类和派生类中出现同名函数的情况,只要函数名相同就构成隐藏,在调用时,若不指定基类类域,默认访问的同名函数是派生类类域,编译器的就近原则,找近的对于编译器来说比较轻松嘛。


3.多态的实现原理?

多态的实现主要通过虚函数的重写和虚函数表来实现,当基类指针指向不同类型时,发生多态调用后会去基类指针指向的类型里面的虚表去找对应的虚函数进行调用,所以多态实现就是依靠虚表和虚函数重写来实现的,基类和派生类都有自己的虚表,多态调用会去虚表找对应的虚函数。


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

这里需要分情况,如果是普通调用,则虚函数和普通函数并没有任何区别,因为即使你虚函数重写了,但如果调用指针或引用并非基类类型,那么你还是和普通函数没有区别,并不会去虚表里面找虚函数,而是直接静态绑定,在编译后的符号表里面就确定了调用函数的地址,那么此时就可以是内联函数,如果代码体较小编译器一般会在调用的地方展开,如果代码体较大为了防止代码膨胀,编译器一般会忽略内联请求。

但如果发生多态调用,比如用基类指针调用重写的虚函数,此时情况就不一样了,编译器此时会忽略内联属性,直接去基类指针指向对象里面的虚函数表去找对应的虚函数,然后进行调用,这个时候就不会在调用的地方进行展开,而是开辟函数栈帧走正常的函数调用的路子,完成虚函数的调用。


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

当然是不可以的,从类和对象的本质上来说,无论你是用对象去调用成员函数,还是成员函数之间的调用,或是用类类型指针去去调用成员函数,本质都是通过隐含的this指针来进行调用的,所以前面说的所有成员函数都是非静态成员函数。

而静态成员函数没有this指针,他都可以通过指明类域进行访问,由此可见,他并不属于某个对象,而是属于整个类域。所以如果虚函数是静态成员函数的话,那就废了,多态调用不了了就,虚函数直接没有意义了,所以虚函数一定不可以是静态成员,编译器也不允许你这样做,只要你这样做编译器就会报错。


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

构造函数不可以是虚函数,在程序编译期间虚表内容实际早已被初始化好,但虚表指针并未初始化是个野指针,在构造函数初始化列表部分会完成虚表指针的初始化,给虚表指针分配一个属于当前进程管理的有效地址。所以构造函数不能是虚函数,因为此时虚表指针都为初始化好,你根本找不到虚表都,怎么把构造函数放进去?这就相当于,你明天要去和女朋友喝咖啡,但是你连女朋友都没有,你怎么跟女朋友喝咖啡啊?


7.析构函数可以是虚函数吗?

析构函数强烈建议无脑搞成虚函数,如果是一般场景下,函数栈帧销毁,对象跟着被自动析构,则不会出任何问题。但如果是基类类型指针指向new出来的基类和派生类对象时,此时分别通过基类指针调用析构函数完成对象所含资源的清理,如果派生类对象里没有自己资源的申请,则不会出事,但只要派生类自己申请了资源,就会发生内存泄露。

其本质就是因为如果析构函数不是虚函数,则一定不会发生多态调用,但我们期望在通过基类指针或引用调用析构函数时的行为是多态行为,而不是普通行为,所以就必须将析构函数搞成虚函数,为的就是能够满足多态调用的条件之一,防止潜在内存泄露问题的发生。


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

这里主要还是和调用的种类有关,和函数是否是普通函数或是虚函数并无关系,如果是普通调用,则普通函数和虚函数一样块,如果是多态调用,调用虚函数需要去虚表里面去找,所以普通函数更快一些,虚函数较慢。

当调用对象是基类类型指针或引用时,如果调用函数是普通函数,那也算普通调用。如果调用对象是基类对象或派生类对象时,即使调用的函数是虚函数,那也算普通调用。

只有多态调用时,虚函数才和普通函数有差别,虚函数走虚表的路子,普通函数走编译期间进符号表的路子。

如果是普通调用,虚函数和普通函数并没有大的差别,唯一较小的差别就可能是普通函数不进虚表,虚函数进一下虚表而已,但在调用结果上,虚函数和普通函数并无差别,都是静态时决议,即在程序编译后就可以通过符号表找到对应的函数地址进行调用。


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

虚函数表在程序编译期间生成,虚表指针在构造函数的初始化列表阶段进行初始化,vs上面虚表和虚函数都存在于代码段,g++上面虚表存在于数据段,细说的话那就在.rodata段,虚函数和vs一样都存在于代码段里面。


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

从内存角度和语法角度来看,菱形继承分别带来了数据冗余和二义性的问题,虚拟继承能够解决菱形继承的原理即通过虚基表的方式进行解决,内存中只存一份虚基类成员,腰部派生类访问时通过自身成员模型中的虚基表来进行访问,虚基表中存放着腰部类到虚基类成员的偏移量,如果腰部类想要访问虚基类成员时,则通过自身成员变量中的虚基表来进行虚基类成员的访问,这便解决了数据冗余的问题,在访问冗余数据时,也不会出现二义性了,无论你怎么访问,访问的都是内存中仅存一份的虚基类成员。

一般来说,虚基类成员都放在逻辑对象模型中成员的最下面,如果是在内存里面的话,他的位置应该是对象成员中的最高地址处。


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

纯虚函数所在的类称之为抽象类,抽象类会强制其派生类重写纯虚函数,因为如果不重写纯虚函数,派生类也无法实例化出对象,那就失去了其存在的意义。另外抽象类也可以体现出虚函数的接口继承,虚函数的重写,重写的是虚函数的实现。





































































相关文章
|
23天前
|
存储 编译器 数据安全/隐私保护
【C++】多态
多态是面向对象编程中的重要特性,允许通过基类引用调用派生类的具体方法,实现代码的灵活性和扩展性。其核心机制包括虚函数、动态绑定及继承。通过声明虚函数并让派生类重写这些函数,可以在运行时决定具体调用哪个版本的方法。此外,多态还涉及虚函数表(vtable)的使用,其中存储了虚函数的指针,确保调用正确的实现。为了防止资源泄露,基类的析构函数应声明为虚函数。多态的底层实现涉及对象内部的虚函数表指针,指向特定于类的虚函数表,支持动态方法解析。
29 1
|
2月前
|
编译器 C++
C++入门12——详解多态1
C++入门12——详解多态1
47 2
C++入门12——详解多态1
|
7月前
|
C++
C++中的封装、继承与多态:深入理解与应用
C++中的封装、继承与多态:深入理解与应用
166 1
|
2月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
87 1
|
4月前
|
存储 编译器 C++
|
5月前
|
存储 编译器 C++
【C++】深度解剖多态(下)
【C++】深度解剖多态(下)
57 1
【C++】深度解剖多态(下)
|
5月前
|
存储 编译器 C++
|
5月前
|
机器学习/深度学习 算法 C++
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
|
5月前
|
Java 编译器 C++
【C++】深度解剖多态(上)
【C++】深度解剖多态(上)
57 2
|
5月前
|
程序员 C++
【C++】揭开C++多态的神秘面纱
【C++】揭开C++多态的神秘面纱