从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

目录
相关文章
|
9天前
|
存储 算法 编译器
C++面试题其一
C++文件编译与执行的四个阶段 预处理:处理#include、#define等预处理指令。 编译:将源码翻译为目标代码。 汇编:将目标代码转换为机器指令。 链接:将目标文件和库文件合并生成可执行文件。 STL中的vector的实现,是怎么扩容的? vector通过动态数组实现,当容量不足时,分配更大的内存(通常是原来的两倍),复制旧数据到新内存,并释放旧内存。
27 2
|
9天前
|
存储 程序员 编译器
C++面试题其二
extern "C" 用于告诉编译器按照C语言的链接方式处理代码,通常用于C++代码与C代码混合编程,以防止因名字修饰(name mangling)引起的链接错误。例如: extern "C" { void c_function(); } 通过这些问题的深入理解和解答,能够更好地掌握C++编程的核心概念和实际应用,为面试做好充分的准备。
24 1
|
21天前
|
C++
C++中的封装、继承与多态:深入理解与应用
C++中的封装、继承与多态:深入理解与应用
25 1
|
4天前
|
C++
C++ 是一种面向对象的编程语言,它支持对象、类、继承、多态等面向对象的特性
C++ 是一种面向对象的编程语言,它支持对象、类、继承、多态等面向对象的特性
|
9天前
|
Java
Java基础7-一文搞懂抽象类和接口,从基础到面试题,揭秘其本质区别(二)
Java基础7-一文搞懂抽象类和接口,从基础到面试题,揭秘其本质区别(二)
12 0
|
9天前
|
设计模式 Java 内存技术
Java基础7-一文搞懂抽象类和接口,从基础到面试题,揭秘其本质区别(一)
Java基础7-一文搞懂抽象类和接口,从基础到面试题,揭秘其本质区别(一)
14 0
|
9天前
|
安全 算法 C++
C++面试题其三
继续上篇博客的解答,我们将进一步探讨C++中的一些关键概念和常见面试问题。
14 0
|
15天前
|
算法 编译器 C++
C++多态与虚拟:函数重载(Function Overloading)
重载(Overloading)是C++中的一个特性,允许不同函数实体共享同一名称但通过参数差异来区分。例如,在类`CPoint`中,有两个成员函数`x()`,一个返回`float`,另一个是设置`float`值。通过函数重载,我们可以为不同数据类型(如`int`、`float`、`double`)定义同名函数`Add`,编译器会根据传入参数自动选择正确实现。不过,仅返回类型不同而参数相同的函数不能重载,这在编译时会导致错误。重载适用于成员和全局函数,而模板是另一种处理类型多样性的方式,将在后续讨论中介绍。
|
1天前
|
C++
C++一分钟之-类与对象初步
【6月更文挑战第20天】C++的类是对象的蓝图,封装数据和操作。对象是类的实例。关注访问权限、构造析构函数的使用,以及内存管理(深拷贝VS浅拷贝)。示例展示了如何创建和使用`Point`类对象。通过实践和理解原理,掌握面向对象编程基础。
29 2
C++一分钟之-类与对象初步
|
2天前
|
存储 编译器 C++