一、多态基础


虚函数

在函数前面加上 virtual 就是声明虚函数,virtual 说明符指定非静态成员函数为虚函数并支持动态调用派发。

它只能在非静态成员函数的首个声明(即当它在类定义中声明时)的 声明说明符序列 中出现。(声明说明符序列:在C语言中,声明说明符序列(declaration specifiers)是用来声明变量、函数或类型的一部分,它们定义了类型和属性。一个典型的声明说明符序列包括存储类说明符、类型说明符、类型限定符和函数说明符。)

虚函数调用在使用有限定名字查找(即函数名出现在作用域解析运算符 :: 的右侧)时被抑制。

虚函数的继承

虚函数的继承体现了接口继承

继承了接口等于继承了函数的壳,这个壳有返回值类型,函数名,参数列表,还包括了缺省参数

只需要重写/覆盖接口的实现(函数体)


虚类/虚基类

含有虚函数的类叫做虚类

是虚类且是基类的叫虚基类


重写/覆盖

条件:
三同:函数名,参数,返回值都要相同

概念:
重写/覆盖是指该函数是虚函数且函数的名字、类型、返回值完全一样的情况下,子类的函数体会替换掉继承下来的父类虚函数的函数体

体现接口继承

  • 重写/覆盖只有虚函数才有,非虚函数的是隐藏/重定义.注意区别

  • 重写/覆盖只对函数体有效,返回值类型,函数名,参数列表,和缺省参数都不能修改

  • 只要子类写上满足三同的虚函数都会触发重写.无论是否修改函数体

上面说的函数重写的条件中,如果函数 Derived::f 覆盖 Base::f,那么它的返回类型必须要么相同,要么为协变(covariant)。当满足以下所有要求时,两个类型为协变:

  • 两个类型都是到类的指针或引用(都是左值引用,或都是右值引用)。不允许多级指针。
  • Base::f() 的返回类型中被引用/指向的类,必须是 Derived::f() 的返回类型中被引用/指向的类的无歧义且可访问的直接或间接基类。
  • Derived::f() 的返回类型必须有相对于 Base::f() 的返回类型的相等或较少的 cv 限定。

Derived::f 的返回类型中的类必须要么是 Derived 自身,要么必须是在 Derived::f 声明点的某个完整类型。

进行虚函数调用时,最终覆盖函数的返回类型被隐式转换成所调用的被覆盖函数的返回类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Animal {};
class Dog : public Animal {};

class AnimalShelter {
public:
virtual Animal* adopt() {
return new Animal();
}
};

class DogShelter : public AnimalShelter {
public:
Dog* adopt() override { // 协变返回类型
return new Dog();
}
};

为什么要引入协变:
协变的引入主要是为了提高代码的灵活性和可重用性。它允许子类在重写方法时返回更具体的类型,从而提供更多的功能和类型信息,而不破坏多态性和继承的规则。

协变解决的问题:
类型安全:协变确保了类型系统的安全性,即在重写方法时,返回的类型依然是基类返回类型的子类型,保证了对象可以被正确识别和操作。

代码灵活性:协变使得子类方法可以返回更具体的类型,从而使得方法调用者可以利用更多的类型信息进行操作,而不需要进行类型转换。

提高代码可读性和可维护性:通过协变,代码变得更加直观和易于理解,因为方法返回类型直接反映了实际返回的对象类型。

总之,当虚函数返回值为基类类型的指针或引用时,编译器才会检查是否是协变类型.此时如果派生类虚函数返回值是基类或派生类的指针或引用,则判定为协变;否则不是协变


多态的条件

多态有两个条件,任何一个不满足都不能执行多态,分别是:

  1. 虚函数的重写
  2. 父类类型的指针或引用(接收父类对象或子类对象)的对象去调用虚函数

继承遗留问题解决

析构函数:

先看继承关系中直接实例对象的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {
public:
~Person() { std::cout << "~Person()" << "\n"; }
};

class Student :public Person {
public:
~Student() { std::cout << "~Student()" << "\n"; }
};

int main(){
Person per;
Student stu;
return 0;
}

打印结果是 ~Student() , ~Person() , ~Person() 。析构的结果和顺序没有问题。当对象被销毁时,析构函数的调用顺序是先调用子类的析构函数,然后再调用父类的析构函数。这个顺序确保子类特有的资源先被释放,然后父类的资源再被释放。

再看看指针切片样例:

1
2
3
4
5
6
7
8
int main(){
Person* ptr1 = new Person;
Person* ptr2 = new Student;

delete ptr1;
delete ptr2;
return 0;
}

结果打印 ~Person(), ~Person() 。显然没有正确地析构。这说明切片样例中的对象执行析构后只会执行对应切片类型的析构函数。

本意:根据指针(引用)指向的对象类型来选择对应的析构函数

结果:根据指针(引用)的类型的来选择对应的析构函数

我们当然是想根据指针(引用)指向的对象类型来选择对应的析构函数,这正好就是多态的理念.

因此,为了解决切片中这样的析构函数问题,我们选择将其转化成多态来解决.

此时我们已经满足多态构造的2个条件的其中之一:基类的指针或引用, 剩下的我们需要满足派生类的析构函数构成对基类析构函数的重写。而重写的条件是:返回值类型,函数名,参数列表都相同。对于析构函数,目前还缺的就是函数名相同,因此,析构函数的名称统一处理为 destructor.


题目一

1.以下程序输出结果是什么()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

正确答案选择B。

B*p = new B ;这里 p 是普通的指针,不满足多态.

p->test(); 这里调用了继承下来的 test();

test() 的实际原型是 test(A* this) ,因此函数体内即为 (A*)->func();

因为 test() 在 B 中, B 会将自己的 this 传参给 test(), 即父类类型指针接收子类类型指针.同时 func 也是虚函数.

因此满足多态,即 test() 中调用的是子类的 func().

又因为虚函数的继承是接口继承,只有函数体是子类的,其他都是父类的,缺省参数也是父类的,因此答案是 B->1


题目2

以下程序输出结果是什么()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
};

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: 以上都不正确

正确答案是 D


C++11 override和final

final

指定某个虚函数不能在派生类中被覆盖,或者某个类不能被派生。

当应用到成员函数时,标识符 final 在类定义中的成员函数声明或成员函数定义的语法中,紧随声明符之后出现。

当应用到类(包括结构体和联合体)时,标识符 final 在类定义的开头,紧跟类名之后出现,但不能在类声明中出现。

一般情况下,只有最终实现的情况下会使用final: 当你在一个派生类中实现了某个虚函数,并且认为这是该函数的“最终”或“最完善”的实现,不希望后续的派生类再次改变其行为。使用final关键字可以确保这一点,防止函数被进一步重写。

对虚函数使用final后,编译器可以做出一些优化,比如内联调用,因为它知道不会有其他版本的函数存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct A;
struct A final {}; // OK:结构体 A 的定义,而不是变量 final 的值初始化

struct X
{
struct C { constexpr operator int() { return 5; } };
struct B final : C{}; // OK:嵌套类 B 的定义,而不是位域成员 final 的声明
};

// 不正常的 final 用法。

struct final final // OK,名为 final 的结构体的定义,不能继承
{
};

// struct final final {}; // 错误:struct final 的重复定义,
// *并非* 使用详述类型说明符 `struct final` 跟着一个聚合体初始化
// 的变量 final 的定义

// struct override : final {}; // 错误:不能从 final 基类型派生;
// 给定语境中的 override 是正常的名字
void foo()
{
[[maybe_unused]]
final final; // OK,struct final 类型的名为 final 的变量的声明
}

struct final final; // OK,struct final 类型的名为 final 的变量的声明
// 使用详述类型说明符
int main()
{
}

override

C++对函数重写的要求是比较严格的.如果某些情况因为疏忽而导致函数没有进行重写,这种情况在编译期间是不会报错的,只有程序运行时没有得到预期结果才可能意识到出现了问题,等到这时再debug已经得不偿失了.

因此,C++11提供了 override 关键字,可以帮助用户检测是否完成重写

override(覆盖)关键字用于检查派生类虚函数是否重写了基类的某个虚函数,如果没有则无法通过编译。


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

image

纯虚函数

抽象类(abstract class)是面向对象编程中的一个重要概念。它提供了一种方式来定义类的共同行为和属性,而不必具体实现这些行为和属性。抽象类本身不能被实例化,它只能被继承。

抽象类的定义和特点

定义:抽象类是一个包含一个或多个抽象方法的类。抽象方法是只声明而没有实现的方法。
特点:

  • 抽象类不能被实例化。
  • 抽象类可以包含具体方法(有实现的方法)和抽象方法(没有实现的方法)。
  • 子类必须实现所有抽象方法,除非子类本身也是抽象类。

为什么要有抽象类

  1. 规范子类行为:抽象类可以定义一个标准或接口,强制子类实现特定的方法。这确保了所有子类具有一致的行为。
  2. 代码复用:抽象类可以包含一些通用的实现,这些实现可以被所有子类共享,减少代码重复。
  3. 设计灵活性:抽象类提供了一个框架,使得不同的具体实现可以共享相同的接口。这提高了代码的灵活性和可扩展性。

抽象类解决的问题

  1. 统一接口:抽象类可以定义一个统一的接口,确保所有子类都遵循相同的接口规范,从而使得代码更易于维护和扩展。
  2. 代码重用:通过在抽象类中实现一些通用的功能,可以减少子类的代码量,提高代码的可维护性。
  3. 隔离具体实现:抽象类将接口和实现分离,使得可以在不改变客户端代码的情况下更改具体实现。

接口继承和实现继承

从类中继承的函数包含两部分:一是”接口”(interface),二是 “实现” (implementation).

  • 接口就是函数的”壳”,是函数除了函数体外的所有组成.

  • 实现就是函数的函数体.

纯虚函数 => 继承的是:接口 (interface)

普通虚函数 => 继承的是:接口 + 缺省实现 (default implementation)

非虚成员函数 => 继承的是:接口 + 强制实现 (mandatory implementation) 

  • 普通函数的继承是一种实现继承,派生类继承了基类函数,继承的是函数的实现,目的是为了复用函数实现.
  • 普通虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口+缺省实现,目的是为了重写,达成多态.
  • 纯虚函数只继承了接口,要求用户必须要重写函数的实现.
  • 如果不实现多态,不要把函数定义成虚函数。