多态由浅入深详细总结(上)

简介: 多态由浅入深详细总结

一、多态的概念

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

举个栗子:比如买票这个行为,当普通人买票时,是全价;学生买票时,是半价;军人买票时是优先买票。再举个例子:想必大家都参与过支付宝的扫红包-支付-给奖励金的活动,那么大家想一想为什么有人扫的红包金额很大8块、10块,而有的人扫出来的红包金额都是1毛,5毛。其实这背后就是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、或者你没有经常的使用支付宝等等,那么你需要被鼓励使用支付宝,那么你扫码的金额就 = random % 99;如果你是经常使用支付宝支付或者支付宝账户中常年有钱,那么就不需要太鼓励你去使用支付宝,那么你的扫码金额就 = random % 1。总结一下:同样是扫码动作,不同的用户去扫得到不一样的红包,这也是一种多态行为。

二、多态的定义及实现

2.1 多态的构成条件

多态是在不同继承关系的类对象,去调用同一个函数,产生了不同的行为。比如 Student 继承了 Person。Person 对象买票全价,Student 对象买票半价。因此多态的前提是要在继承体系中,在继承中要构成多态还有两个条件:

  • 必须通过基类的指针或者引用调用虚函数
  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
class Person
{
public:
  virtual void BuyTicket() const//虚函数
  {
    cout << "买全价票" << endl;
  }
};
class Student : public Person
{
public:
  virtual void BuyTicket() const//虚函数
  {
    cout << "买半价票" << endl;
  }
};
void Func(const Person& people)//基类的引用
{
  people.BuyTicket();
}
int main()
{
  Person Jack;//普通人
  Func(Jack);
  Student Mike;//学生
  Func(Mike);
  return 0;
}

小Tips:多态调用看的是基类指针或引用指向的对象,基类的指针或引用如果指向一个基类对象,那就调用基类的成员函数,如果指向派生类对象就调用派生类的成员函数。

class Person
{
public:
  virtual void BuyTicket() const
  {
    cout << "买全价票" << endl;
  }
};
class Student : public Person
{
public:
  virtual void BuyTicket() const
  {
    cout << "买半价票" << endl;
  }
};
void Func(const Person people)
{
  people.BuyTicket();
}
int main()
{
  Person Jack;//普通人
  Func(Jack);
  Student Mike;//学生
  Func(Mike);
  return 0;
}

小Tips:上面这段代码中 Func 函数的形参变成了一个普通的基类对象 people,在函数体中通过 people 去调用成员函数 BuyTicket,此时因为 people 不是基类的指针或引用,因此 people.BuyTicket(); 函数调用不满足多态调的条件,此时无论传进来的是基类对象还是派生类对象,调用的都是基类中的 BuyTicket,因为在不满足多态的条件下,调用成员函数取决于当前调用对象的类型,当前的 people 是一个基类对象,这就意味着它只能调用基类中的成员函数,所以我们不管是传基类对象 Jack 还是派生类对象 Mike,最终打印结果都是“买全价票”。传派生类对象 Mike 的时候,会发生切片,会用 Mike 对象中继承自基类的那部分成员变量去构造基类对象 people。如果 Func 函数的形参 people 就是基类的指针或者引用,去掉基类中 BuyTicket 函数前面的 virtual,此时还是不满足多态的条件,无论传基类对象还是派生类对象,最终调用的都是基类中的 BuyTicke,因为 people 的类型是基类。总结:多态的两个构成条件缺一不可。

2.2 虚函数

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

class Person
{
public:
  virtual void BuyTicket() const
  {
    cout << "买全价票" << endl;
  }
};

小Tips:只能是类的成员函数才能变成虚函数,在全局函数前面是不能加 virtual 的。

2.3 虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。

class Person
{
public:
  virtual void BuyTicket() const//虚函数
  {
    cout << "买全价票" << endl;
  }
};
class Student : public Person
{
public:
  virtual void BuyTicket() const//虚函数
  {
    cout << "买半价票" << endl;
  }
};
void Func(const Person& people)
{
  people.BuyTicket();
}
int main()
{
  Person Jack;//普通人
  Func(Jack);
  Student Mike;//学生
  Func(Mike);
  return 0;
}

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

2.4 虚函数重写的两个例外

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

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类(也可以是其他继承体系中的基类)对象的指针或者引用,派生类虚函数返回派生类(也可以是其他继承体系中的派生类)对象的指针或者引用,这就称作协变。返回值类型必须同时是指针或者引用,不能一个是指针,一个是引用。

class A 
{};
class B : public A 
{};
class Person 
{
public:
  virtual A* f() 
  { 
    return new A; 
  }
};
class Student : public Person 
{
public:
  virtual B* f() 
  { 
    return new B; 
  }
};

2.4.2 析构函数的重写(基类与派生类析构函数的名字不同)

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

class Person 
{
public:
  virtual ~Person() 
  { 
    cout << "~Person()" << endl; 
  }
};
class Student : public Person 
{
public:
  virtual ~Student() 
  { 
    cout << "~Student()" << endl;
    delete[] pi;
    pi = nullptr;
  }
protect:
  int* pi = new int[10];
};
void Test()
{
  Person* p1 = new Person;
  Person* p2 = new Student;
  delete p1;
  delete p2;
}
int main()
{
  Test();
  return 0;
}

小Tips:编译器之所以将所有类的析构函数都统一处理成 destructor,目的是为了让父子类的析构函数构成重写,只有派生类 Student 的析构函数重写了 Person 的析构函数,上面代码中 delete 对象,才能构成多态,才能保证 p1 和 p2 指向的对象正确的调用析构函数。假如子类中并没有重写父类的析构函数,那么 delete p2; 就会出问题,它就调不到派生类 Student 的析构函数。因为 delete 分两步,先去调用析构函数,再去调用 operator delete,而这里 p2 是一个基类 Person 的对象,最终 delete p2 就是变成:p2->destructor + operator delete(p2)。如果派生类 Student 没有重写基类 Person 的析构函数,那 p2->destructor 就不构成多态调用,就是普通的调用成员函数,此时会根据调用对象的类型去判断到底是调用基类中的成员函数还是调用派生类中的成员函数(具体规则是基类对象调用基类的成员函数,派生类对象调用派生类中的成员函数),这里的 p2 是一个基类对象的指针,所以 p2->destructor 调用的一定是基类的析构函数,但是当前 p2 指向一个派生类 Student 的对象,而我们希望调用派生类 Student 的析构函数去清理该派生类 Student 对象中的资源。 这种情况下,我们希望的是 p2 指向谁,就去调用谁的析构,这不就是多态嘛。所以我们要让基类的析构函数变成虚函数,然后派生类去重写虚函数,这样才能满足多态的条件,重写编译器已经帮我们实现了(编译器将析构函数统一处理成同名函数,且析构函数没有返回值和参数,完美的满足三通),我们只需要在基类析构函数的前面加上 virtual,让析构函数变成虚函数即可。这里建议大家在写代码的过程中,对于可能会被继承的类最好在它的析构函数前面加上 virtual,让它变成一个虚函数。

2.5 C++11 override 和 final

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

2.5.1 final:修饰虚函数,表示该虚函数不能再被重写

final:修饰虚函数,表示该虚函数不能再被重写

class Car
{
public:
  virtual void Drive() final 
  {}
};
class Benz :public Car
{
public:
  virtual void Drive() 
  { 
    cout << "Benz-舒适" << endl; 
  }
};

小Tips:虚函数如果不能被重写是没有什么意义的。这里在补充一个知识点,一个类不想被继承该怎么做?C++98 中的方法:将该类的构造函数私有,私有在子类中是不可见的,而派生类的构造函数又必须调用父类的构造函数。但是这种做法会导致创建该类对象时也无法调用构造函数了,私有在类外面不可见但是在类里面是可见的,所以此时可以在该类里面写一个静态成员函数专门用来创建对象。在 C++11 引入 final 关键字后,对于一个类如果不想让它被继承,我们可以在该类的后面加上 final 关键字进行修饰。

2.5.2 override

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

class Car
{
public:
  virtual void Drive() 
  {}
};
class Benz :public Car
{
public:
  virtual void Drive() override
  { 
    cout << "Benz-舒适" << endl; 
  }
};

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

四、多态的原理

4.1 虚函数表

// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
  virtual void Func1()
  {
    cout << "Func1()" << endl;
  }
private:
  int _b = 1;
};
int main()
{
  cout << sizeof(Base) << endl;
  return 0;
}

小Tips:通过上面的打印结果和调试,我们发现一个 Base 对象是 8 bytes,除了 _b 成员,还多了一个 _vfptr 放在对象成员变量的前面(注意有些平台可能会放到对象成的最后面,这个跟平台有关系)。_vfptr 本质上是一个指针,这个指针我们叫做虚函数表指针(v 代表 virtual,f 代表 function)。一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中(虚函数本质上是存在代码段的),虚函数表也简称虚表。

4.2 派生类对象中的虚函数表

上面我们看了一个普通类对象中的虚表,下面我们再来看看派生类中的虚表又是怎样的。

// 针对上面的代码我们做出以下改造
// 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()
  {}
private:
  int _d = 2;
};
int main()
{
  Base b;
  Derive d;
  return 0;
}

通过监视窗口我们发现了以下几个问题:

  • 派生类对象 d 中也有一个虚表,这个虚表是作为基类成员的一部分被继承下来的。总的来说,d 对象由两部分构成,一部分是父类继承下来的成员,d 对象中虚表指针就是就是这部分成员中的一个。另一部分则是自己的成员。
  • 基类 b 对象和派生类 d 对象的虚表是不一样的,上面的代码中 Func1 完成了重写,所以 d 的虚表中存的是重写后的 Derive::Func1,所以虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法层面的叫法,覆盖是原理层面的叫法。
  • 另外 Func2 继承下来后是虚函数,所以放进了虚表,Func3 也继承下来了,但是不是虚函数,所以不会放进虚表。
  • 虚函数表本质上是一个存虚函数地址的函数指针数组,一般情况下这个数组最后面放了一个 nullptr。
  • 总结一下派生类虚表的生成:
  1. 先将基类中的虚表内容拷贝一份到派生类虚表中。
  2. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
  3. 派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。(在 VS 监视窗口显示的虚表中是看不见的,下面将通过程序带大家来验证)
  • 这里还有一个比较容易混淆的问题:虚函数存在哪?虚表存在哪?很多小伙伴会觉得:虚函数存在虚表,虚表存在对象中,注意这种回答是错的。这里再次强调:虚表存的是虚函数的地址,不是虚函数,虚函数和普通的成员函数一样,都是存在代码段的,只是它的地址又存到了虚表中。另外,对象中存的不是虚表,存的是虚表的地址。那虚表是存在哪儿呢?通过验证,在 VS 下虚表是存在代码段的。Linux g++ 下大家可以自己去验证。
  • 同一个程序中,同一类型的对象共用一个虚表。

4.2.1 编写程序去访问虚函数表

上面提到派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。但是在 VS 的监视窗口中是看不到,以下面的代码为例:

class Person
{
public:
  virtual void func1() const
  {
    cout << "virtual void Person::fun1()" << endl;
  }
  virtual void func2() const
  {
    cout << "virtual void Person::fun2()" << endl;
  }
  virtual void func3() const
  {
    cout << "virtual void Person::fun3()" << endl;
  }
  //protected:
  int _a = 1;
};
class Student : public Person
{
public:
  virtual void func1() const
  {
    cout << "virtual void Student::fun1()" << endl;
  }
  virtual void func3() const
  {
    cout << "virtual void Student::fun3()" << endl;
  }
  virtual void func4() const
  {
    cout << "virtual void Student::fun4()" << endl;
  }
  //protected:
  int _b = 2;
};
int main()
{
  Person Mike;
  Student Jack;
}

小Tips:监视窗口中展现的派生类对象的虚函数表中并没有派生类自己的虚函数 func4。但是我们从内存窗口可以看到第四个地址,我们可以大胆的猜测这个就是派生类自己的虚函数 func4 的地址,但是口说无凭,下面我们来写一段代码验证一下我们的猜想。

typedef void (*VFPTR) ();//VFPTR是一个函数指针
//vf是一个函数指针数组,vf就是指向虚表
//虚表本质上就是一个函数指针数组
void PrintVfptr(VFPTR* vf)
{
  for (int i = 0; vf[i] != nullptr; i++)
  {
    printf("vfptr[%d]:%p----->", i, vf[i]);
    VFPTR f = vf[i];//函数指针和函数名是一样的,可以去调用该函数
    f();
  }
  printf("\n");
}
int main()
{
  Person Mike;
  int vfp1 = *(int*)&Mike;
  PrintVfptr((VFPTR*)vfp1);
  Student Jack;
  int vfp2 = *(int*)&Jack;
  PrintVfptr((VFPTR*)vfp2);
  return 0;
}

小Tips:通过上图可以看出我们程序打印出来的地址和监视窗口中显示的地址是一样的,并且成功的调用了派生类中的虚函数 func4,上图显示的结果完美的验证了我们的猜想。这里也说明了一个问题,VS 的监视窗口是存在 Bug 的,以后我们在调试代码过程中也不能完全相信监视窗口展现给我们的内容,比起监视窗口我们更应该相信内存窗口展现给我们的内容。这里也侧面反映了一个问题,只要我们能拿到函数的地址就能去调用该函数,正常情况下,我们只能通过派生类对象去调用虚函数 func4,这里我们直接拿到了这个函数的地址去调用,这里的问题在于函数的隐藏形参 this 指针接收不到实参,因为不是派生类对象去调用该函数。函数中如果去访问了成员变量,那么我们这种调用方式就会出问题。

4.2.2 虚表存储位置的验证

//虚表存储位置的验证
class Person
{
public:
  virtual void func1() const
  {
    cout << "virtual void Person::fun1()" << endl;
  }
//protected:
  int _a = 1;
};
class Student : public Person
{
public:
  virtual void func1() const
  {
    cout << "virtual void Student::fun1()" << endl;
  }
//protected:
  int _b = 2;
};
int main()
{
  Person Mike;
  Student Jack;
  //栈区
  int a = 10;
  printf("栈区:%p\n", &a);
  //堆区
  int* pa = new int(9);
  printf("堆区:%p\n", pa);
  //静态区(数据段)
  static int sa = 8;
  printf("静态区(数据段):%p\n", &sa);
  //常量区(代码段)
  const char* pc = "hello word!";
  printf("常量区(代码段):%p\n", pc);
  //虚表
  printf("基类的虚表:%p\n", (void*)*(int*)&Mike);
  printf("派生类的虚表:%p\n", (void*)*(int*)&Jack);
}

小Tips:上面取虚表地址是通过强制类型转化来实现的,通过上面的监视窗口我们可以看出,虚表的地址永远是存储在对象的前四个字节,所以这里我们先取到对象的地址,然后将其强转为 int* 类型,为什么要强转为 int* 呢?因为,一个 int 型的大小就是四个字节,而指针的类型决定了该指针能够访问到内存空间的大小,一个 int* 的指针就能够访问到四个字节,再对 int* 解引用,这样就能访问到内存空间中前四个字节的数据,这样就能取道虚表的地址啦。通过打印结果我们可以看出,虚表的地址和常量区(代码段)的地址最为接近,因此我们可以大胆的猜测,虚表就是存储在常量区(代码段)的。

4.3 多态的原理

上面说了这么多,那多态的原理究竟是什么呢?

小Tips:此时再来分析下上面这个图,当 people 指向基类对象 Jack 时,people.BuyTicket() 在 Jack 的虚表中找到的虚函数是 Person::BuyTicket();当 people 指向派生类对象 Mike 时,people.BuyTicket() 在 Mike 的虚表中找到的虚函数是 Student::BuyTicket()。这样就实现了不同对象去完成同一行为时,展现出不同的形态。其次,通过对汇编代码的分析,可以发现,满足多态的函数调用,不是在编译时确定的,是在运行起来以后到对象中取的。而不满足多态的函数调用则是在编译时就确定好了。

小Tips:通过上面两张图可以看出,在满足多态的条件下,无论传递的是基类对象还是派生类对象,最终转化成汇编代码都是一样的。最终的函数调用是在代码运行起来后去对象里面取的。

小Tips:普通函数调用在编译时就确定好了直接去 call 那个函数。call 的这个函数和调用该函数对象的类型有关,这里调用 BuyTicket 的对象是一个 Person 类型,这就决定了调用的 BuyTicket 函数一定是基类中的。

目录
相关文章
|
7月前
|
存储 设计模式 编译器
C++进阶之多态(下)
通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫
|
2天前
|
存储 C++
【C++进阶(九)】C++多态深度剖析
【C++进阶(九)】C++多态深度剖析
|
2天前
|
C++
关于C++多态 的基本知识 与 底层原理
关于C++多态 的基本知识 与 底层原理
|
2天前
|
存储 算法 编译器
【C++入门到精通】C++入门 —— 多态(抽象类和虚函数的魅力)
多态是面向对象编程中的一个重要概念,指的是同一个消息被不同类型的对象接收时产生不同的行为。通俗来说,**就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态**。
45 0
|
6月前
|
存储 编译器 C++
多态由浅入深详细总结(下)
多态由浅入深详细总结
13 0
|
7月前
|
编译器 C++
C++进阶之多态(上)
多态的概念:通俗来说,去完成某个行为,当不同的对象去完成时会产生出不同的状态 。
|
10月前
|
Java
【Java面向对象】多态的详细介绍,简单易懂,看这一篇就够了
【Java面向对象】多态的详细介绍,简单易懂,看这一篇就够了
115 0
|
11月前
|
存储 编译器 C++
【C++知识点】多态
【C++知识点】多态
62 0
|
12月前
|
存储 编译器 C++
[C++]:万字超详细讲解多态以及多态的实现原理(面试的必考的c++考点)
[C++]:万字超详细讲解多态以及多态的实现原理(面试的必考的c++考点)
238 0
|
安全 Java 编译器
Java编程最佳实践之多态
多态是面向对象编程语言中,继数据抽象和继承之外的第三个重要特性。 多态提供了另一个维度的接口与实现分离,以解耦做什么和怎么做。多态不仅能改善代码的组织,提高代码的可读性,而且能创建有扩展性的程序——无论在最初创建项目时还是在添加新特性时都可以“生长”的程序。
85 0