从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(上)

简介: 从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题

1. 多态(polymorphism)

多态,就是 "多种形态" 的意思。

说具体点就是:去完成某个行为,不同的对象去做会产生不同的结果(状态)。

比如说地铁站买票这个行为,普通人、学生、军人买票是不同的。

普通人必须买全价票,学生就可能可以买半价票,而军人可以优先购买到预留票:

比如有一个 BuyTicket 买票的成员函数,创建普通人、学生和军人三个对象,

他们调用该函数形态结果我们就要设计成不一样的。

这种”不一样“的情况还有各种VIP等等。

所以由此可见,我们需要一种特性来做到这种 "分类" 的操作,这时我们就可以将其实现成多态。

1.1 构成多态的两个条件

构成多态的两个条件:

1、虚函数重写(覆盖) ->  虚函数 + 三同:函数名、参数和返回值相同,不符合重写就是隐藏。

2、父类指针或者引用去调用虚函数

特例1:子类虚函数不加virtual,依旧构成重写 (实际最好加上)

特例2:重写的协变。返回值可以不同,要求必须是父子关系的的指针或者引用

1.2 虚函数重写(覆盖)

我们先用代码实现一下我们刚才的购票场景。将 Student 和 Soldier 继承自 Person:

class Person {};
class Student : public Person {};
class Soldier : public Person {};

这里用 virtual 虚函数,并且做到函数名、参数和返回值相同,就能够达到 "重写" 的效果:

(不符合重写,就是隐藏关系)

class Person 
{
public:
    virtual void BuyTicket() // virtual + 返回值 + 函数名+ 参数 相同 = 构成多态
    {
        cout << "Person: 买票-全价" << endl;
    }
};
 
class Student : public Person 
{
public:
    // 这里也都相同
    virtual void BuyTicket() 
    {
        cout << "Student: 买票-半价" << endl;
    }
};
 
class Soldier : public Person 
{
public:
    // 这里也都相同
    virtual void BuyTicket() 
    {
        cout << "Soldier: 优先买票" << endl;
    }
};

概念:重写也称为覆盖,重写即重新改写。

重写是为了将一个已有的事物进行某些改变以适应新的要求。

重写是子类对父类的允许访问的方法的实现过程进行重新编写,返回值和形参都不能改变。

最后我们再设计一个 Pay 函数去接收不同的身份,以调用对应的 BuyTicket 函数。

这里我们可以用指针和引用,这里我们用引用:

#include <iostream>
using namespace std;
 
class Person 
{
public:
    virtual void BuyTicket() // virtual + 返回值 + 函数名+ 参数 相同 = 构成多态
    {
        cout << "Person:  买票-全价" << endl;
    }
};
 
class Student : public Person 
{
public:
    // 这里也都相同
    virtual void BuyTicket() 
    {
        cout << "Student: 买票-半价" << endl;
    }
};
 
class Soldier : public Person 
{
public:
    // 这里也都相同
    virtual void BuyTicket() 
    {
        cout << "Soldier: 优先买票" << endl;
    }
};
 
void Pay(Person& p)
{
  p.BuyTicket();
}
 
int main()
{
  Person ps;
  Student st;
  Soldier sd;
 
  Pay(ps);
  Pay(st);
  Pay(sd);
  
  return 0;
}

再看多态两个条件:

1、虚函数重写(覆盖)

2、父类指针或者引用去调用虚函数

特例1:子类虚函数不加virtual,依旧构成重写 (实际最好加上)

特例2:重写的协变。返回值可以不同,要求必须是父子关系的的指针或者引用

这里我们就构成了多态。(如果把Pay函数的引用去掉就不是多态了,调的三个都是全价)

1.3 协变构成多态

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

派生类重写基类虚函数时,与基类虚函数返回值类型不同。

即基类虚函数返回基类对象的指针或者引用,

派生类虚函数返回派生类对象的指针或者引用时,称为协变。(了解)

观察下面的代码,并没有达到 "三同" 的标准,它的返回值是不同的,但依旧构成多态:

#include <iostream>
using namespace std;
class A {};
class B : public A {};
 
class Person 
{
public:
  virtual A* f() 
  {
    cout << "virtual A* Person::f()" << endl;
    return nullptr;
  }
};
 
class Student : public Person 
{
public:
  virtual B* f() 
  {
    cout << "virtual B* Student::f()" << endl;
    return nullptr;
  };
};
 
int main()
{
  Person p;
  Student s;
  Person* ptr = &p;
  ptr->f();
 
  ptr = &s;
  ptr->f();
 
  return 0;
}

但是协变也是有条件的,协变的类型必须是父子关系。

1.4 父虚子非虚构成多态

子类的虚函数没了却能构成多态:

#include <iostream>
using namespace std;
class A {};
class B : public A {};
 
class Person
{
public:
  virtual A* f()
  {
    cout << "virtual A* Person::f()" << endl;
    return nullptr;
  }
};
 
class Student : public Person
{
public:
  B* f() 
  {
    cout << "virtual B* Student:::f()" << endl;
    return nullptr;
  };
};
 
int main()
{
  Person p;
  Student s;
  Person* ptr = &p;
  ptr->f();
 
  ptr = &s;
  ptr->f();
 
  return 0;
}

这都不是虚函数了怎么也能构成多态呢?

解答:子类虚函数没有写 virtual,但 f 依旧是虚函数,是因为先继承了父类的函数接口声明。

子类继承父类的虚函数是一种接口继承,所以即使子类的 virtual 没写,它也是虚函数,

符合多态条件。这是重写父类虚函数的实现,也就是说父类有 virtual 的属性,子类也就有了。

最后,虽然子类虚函数可以不加 virtual,但是我们自己写的时候 子类虚函数建议加上 virtual。

总结:父类为虚函数,子类继承其父的情况下,即使不声明 virtual 也能构成多态。

1.5 析构函数的重写

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,

都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,

看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处

理,编译后析构函数的名称统一处理成destructor。

#include <iostream>
using namespace std;
 
class Person
{
public:
  ~Person() 
  {
    cout << "~Person()" << endl;
  }
};
 
class Student : public Person 
{
public:
  ~Student() 
  {
    cout << "~Student()" << endl;
  }
};
 
int main()
{
  Person p;
  Student s;
 
  return 0;
}

dd51fb691b18406499210fb6b264a059.png

第一行和第二行是 Student s 的,第三行是 Person p 的。我们来看看析构顺序,


Student s 是后定义的,析构顺序是后定义先析构。根据子类对象析构先子后父,


调用子类的析构函数结束后自动调用父类的析构函数,


所以第一行的 ~Student() 和第二行的 ~Person() 都是 Student 的,


随后第三行的 ~Person() 是 Person p 自己调的。


现在这两个析构函数默认是隐藏关系,


因为它们的函数名会被同一处理修改成 destructor 。


但是如果用 virtual 修饰 ~Person,我们知道,如果这加了不管 ~Student 加不加 virtual,


子类都会跟着父类变身成 virtual,那么现在这两个析构函数还是隐藏关系吗?


如果 Person 的析构函数加了 virtual,隐藏关系就变成了重写关系。


对普通对象(像上面的代码)来说,这里加 virtual 并不会带来什么改变,

再看另一段代码:

#include <iostream>
using namespace std;
 
class Person 
{
public:
  virtual ~Person() 
  {
    cout << "~Person()" << endl;
  }
};
 
class Student : public Person 
{
public:
  ~Student() // 隐藏(重定义)关系 -> 重写(覆盖) 关系
  {
    cout << "~Student()" << endl;
  }
};
 
int main()
{
  Person* ptr1 = new Person; // delete 调用 Person 的析构,对这个也没有影响
  delete ptr1;
 
  Person* ptr2 = new Student; // 但是对这样的场景会产生影响
  delete ptr2;
 
  return 0;
}

把父类的virtual 去掉:

刚才我们看到了,如果这里不加 virtual,~Student 是没有调用析构的。

你可能会想这有啥,那是因为这里没场景,这其实是非常致命的,是不经意间会发生的内存泄露。

比如下面这个场景,我们是希望 delete 谁调用的就是谁的析构:

#include <iostream>
using namespace std;
 
class Person
{
public:
  ~Person() 
  {
    cout << "~Person()" << endl;
  }
};
 
class Student : public Person
{
public:
  ~Student() // 隐藏(重定义)关系 -> 重写(覆盖) 关系
  {
    cout << "~Student()" << endl;
    delete[] _name;
    cout << "delete: " << (void*)_name << endl;
  }
 
private:
  char* _name = new char[10] { 'h', 'e', 'l', 'l', 'o' };
};
 
int main()
{
  // 我们期望 delete ptr 调用析构函数是一个多态调用
  Person* ptr = new Person;
  delete ptr;   // ptr->destructor() + operator delete(ptr)
 
  ptr = new Student;
  delete ptr;   // ptr->destructor() + operator delete(ptr)
 
  return 0;
}

但是结果让我们很失望,Student 没析构。我们加上 virtual 再试试:

结论:如果设计的类可能会作为父类,析构函数最好设计成虚函数,即加上 virtual。

像刚才这种场景不加上 virtual 就会发生内存泄露,可怕的是还是悄无声息的!

报错不可怕,怕的是这种悄无声息的,像这种内存泄露找起来可是相当的恶心。

1.6 final 和 override 关键字(C++11)

final 关键字(C++11)

上一篇提到:C++11提供了关键字 final 写在类的后面,表明这个类不能被继承。

如果我有个虚函数,但我不想让它被人重写:

也可以关键字 final 写在函数的后面让虚函数不能被重写

#include <iostream>
using namespace std;
 
class Car 
{
public:
  virtual void Drive() final 
  {}
};
 
class Benz : public Car 
{
public:
  virtual void Drive() // 错误  C3248 "Car::Drive": 声明为 "final" 的函数不能由 "Benz::Drive" 重写
  {
    cout << "Benz" << endl;
  }
};
 
int main()
{
  return 0;
}

总结:final 的两个作用

写在虚函数后面让虚函数不能被重写

写在类后面让类不能被继承


override 关键字(C++11)

相信大家也体会到了 C++ 对函数重写的要求是非常严格的,


但是人难免会犯错,有些时候可能会导致函数名次序写反而无法构成重载,


而这种错误在编译期间是不会报的,因此往往只有在程序运行时你发现没有得到预期结果,


去 debug 找个半天才能将问题找出,这会让人感到非常的不爽:C++11 为了增加容错率,

推出了 final 和 override,find 是禁止重写,override 是必须重写。

override 关键字可以帮助你检查重写:

#include <iostream>
using namespace std;
 
class Car 
{
public:
  virtual void Drive() {}
};
 
// override 写在子类中:要求严格检查是否完成重写,如果没有重写就报错
class Benz : public Car 
{
public:
  virtual void Drive() override 
  {
    cout << "Benz" << endl;
  }
};
 
int main()
{
  return 0;
}

把父类 virtual 去掉就报错:

#include <iostream>
using namespace std;
 
class Car 
{
public:
  void Drive() {}
};
 
// override 写在子类中:要求严格检查是否完成重写,如果没有重写就报错
class Benz : public Car 
{
public:
  virtual void Drive() override//错误 C3668 “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法
  {
    cout << "Benz" << endl;
  }
};
 
int main()
{
  return 0;
}

有了 override 修饰,像如果没有加 virtual 或参数不同就会报错。

当然,子类是可以省略 virtual 的,override 不会犯病报错放心使用,其在某些场景是非常有用的。

总结:override 写在子类中,会严格检查是否完成重写,如果没有就会报错提醒。

1.7 重载、覆盖、隐藏的对比

2. 抽象类(Abstract Class)

抽象在现实一般没有具体对应的实体,而不能实例化对象也就是没有实体,所以叫抽象类。

"抽象即不可名状,对应的是具象,具象即现实,抽象即虚拟。"

2.1 纯虚函数和抽象类

在虚函数的后面写上 =0,则我们称这个函数为 "纯虚函数"。

包含纯虚函数的类,就是 抽象类(abstract class),也叫接口类。

抽象类不能实例化出对象,子类即使在继承后也不能实例化出对象,只有重写纯虚函数,

子类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承

#include <iostream>
using namespace std;
 
class Car 
{
public:
  virtual void Drive() = 0;
};
 
class BMW : public Car // 如果父类是抽象类,子类必须重写才能实例化
{
public:
  virtual void Drive() // 重写 注释掉就会报错:错误 C2259 “BMW” : 无法实例化抽象类
  {
    cout << "BMW" << endl;
  }
};
 
int main()
{
  BMW b;
  b.Drive();
 
  return 0;
}

如果 override 是直接要求你重写,那设计成抽象类就是间接要求你重写。

override 是放在子类虚函数,检查重写,它们的功能其实是有一些重叠和相似的。

纯虚函数规范了子类必须重写,另外虚函数更体现出了接口继承。

总结:抽象类不能实例化出对象,子类即使在继承后也不能实例化出对象,除非子类重写。

2.2 抽象类指针

虽然父类是抽象类不能定义对象,但是可以定义指针。

定义指针时如果 new 父类对象因为是纯虚函数,自然是 new 不出来的,

但是可以 new 子类对象:

#include <iostream>
using namespace std;
 
class Car
 {
public:
  virtual void Drive() = 0;
};
 
class BMW : public Car 
{
public:
  virtual void Drive() 
  {
    cout << "BMW" << endl;
  }
};
 
int main()
{
  Car* BMW1 = new BMW;
  BMW1->Drive();
 
  BMW* BMW2 = new BMW;
  BMW2->Drive();
 
  return 0;
}


从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(中);https://developer.aliyun.com/article/1521916

目录
相关文章
|
1月前
|
网络协议 编译器 Linux
【C语言】结构体内存对齐:热门面试话题
【C语言】结构体内存对齐:热门面试话题
|
1月前
|
编译器 C++
C++入门12——详解多态1
C++入门12——详解多态1
34 2
C++入门12——详解多态1
|
1月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
73 1
|
1月前
|
Serverless 编译器 C语言
【C语言】指针篇- 深度解析Sizeof和Strlen:热门面试题探究(5/5)
【C语言】指针篇- 深度解析Sizeof和Strlen:热门面试题探究(5/5)
|
3月前
|
Java 开发者
【Java基础面试十五】、 说一说你对多态的理解
这篇文章解释了多态性的概念:在Java中,子类对象可以赋给父类引用变量,运行时表现出子类的行为特征,从而允许同一个类型的变量在调用同一方法时展现出不同的行为,增强了程序的可扩展性和代码的简洁性。
【Java基础面试十五】、 说一说你对多态的理解
|
3月前
|
Java
【Java基础面试十六】、Java中的多态是怎么实现的?
这篇文章解释了Java中多态的实现机制,主要是通过继承,允许将子类实例赋给父类引用,并在运行时表现出子类的行为特征,实现这一过程通常涉及普通类、抽象类或接口的使用。
|
3月前
|
C语言
C语言操作符(补充+面试)
C语言操作符(补充+面试)
45 6
|
3月前
|
存储 编译器 C++
|
3月前
|
算法 C语言
【面试题】【C语言】寻找两个正序数组的中位数
【面试题】【C语言】寻找两个正序数组的中位数
29 0
|
3月前
|
存储 编译器 C++
C++多态实现的原理:深入探索与实战应用
【8月更文挑战第21天】在C++的浩瀚宇宙中,多态性(Polymorphism)无疑是一颗璀璨的星辰,它赋予了程序高度的灵活性和可扩展性。多态允许我们通过基类指针或引用来调用派生类的成员函数,而具体调用哪个函数则取决于指针或引用所指向的对象的实际类型。本文将深入探讨C++多态实现的原理,并结合工作学习中的实际案例,分享其技术干货。
71 0