0%

从虚函数的角度聊聊 C++ 中的 protected 继承

众所周知

  • public 继承时,所有的基类成员的访问属性在派生类中不会改变。派生类中只能访问基类的 publicprotected 成员,不能访问 private 成员;在外部派生类对象只能访问 public 的成员;
  • protected 继承时,基类的 public 成员到派生类中变成 protected,其余成员的属性不变。派生类只能访问基类的 publicprotected 成员;在类的外面,派生类无法访问基类的任何成员。

但覆写抽象类(形状)的纯虚函数(求面积)时,想在派生类(三角形)的外面调用成员(求面积)。如果基类求面积的方法位于 protected 域,此时需要把覆写的成员移动到 public 域下面,这样好吗?还是说,采取某些手段,仍然保持覆写的成员在 protected 域下面?

如果看不懂本文提到的概念,请回去补C++基础。

派生类的同名函数

先引入一丢丢其它的知识,在循序渐进到本文开篇的问题。假设一个场景,许多派生类源自一个基类,但每个类的展示信息是不一样的。现在写一个叫 print 的函数,用于打印不同类的信息。利用继承中的重定义函数,注意不是重载,来隐藏基类中的同名函数:

1
2
3
4
5
6
7
8
9
10
11
12
class Base{
string show() {return "I am base";}
}
class A : public Base{
string show() {return "I am A";}
}
class B : public Base{
string show() {return "I am B";}
}
class C : public Base{
string show() {return "I am C";}
}

什么?你要每个类中功能一致的函数不同名?不嫌累么,之后会讲覆写,同名函数会简化程序的开发流程。

假设我要打印信息了。如果直接利用类型信息来重载函数,每个类型都需要写一个函数,有多少类就需要写多少函数(这里是重载),但这样会很麻烦:

1
2
3
4
5
6
7
8
9
10
11
12
void print(Base* b){
cout << b->show() << endl;
}
void print(A* a){
cout << a->show() << endl;
}
void print(B* b){
cout << b->show() << endl;
}
void print(C* c){
cout << c->show() << endl;
}

虚函数

为避免上述情况的发生,可以借助虚函数来简化代码。虚函数的用途就是:支持动态联编,可以访问指针指向的对象的成员。在基类中通过关键字 virtual 来声明虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Base{
public:
virtual string show(){
return "Base";
}
};
class B : public Base{
public:
// 关键字可写可不写
// 指明覆写,函数内容一致,避免语法错误
virtual string show() override{
return "B";
}
};

class C : public Base{
public:
virtual string show(){
return "C";
}
};

此时的 print 函数会变得很简单,避免大量无聊的重复代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 引用传参 发生了动态类型的隐式转换
void print(Base& p){
cout << p.show() << endl;
}

int main(){

Base a; B b; C c;
print(a);
print(b);
print(c);

return 0;
}

此时,不同类型的实体对同一消息有不同的响应,这就是多态。上述程序在多态期间会发生一个叫联编的过程,即将源代码中的函数调用解释为执行特定的函数代码块,也就是说,确定到底该执行哪个函数。分为静态联编和动态联编:

  • 静态联编:编译器查看参数和参数名,如函数重载、非虚方法根据指针类型确定调用函数等,在编译时期确定该执行哪个方法,上面展示的一堆 print 函数就是静态联编;
  • 动态联编:运行时才能确定调用哪个函数,使用虚方法实现,程序调用基类对象的指针或引用指向对象的对应同名虚函数

这时我们在派生类中重定义一个与基类中虚函数同名的虚函数,称为覆写。建议写上关键字 override,这会对函数名、类型、参数进行检查,防止低级错误的出现。当然虚函数不是必须需要覆写的,纯虚函数才需要。关于虚方法涉及的联编,在细节一下:

  • 如果一个方法不是虚方法,那么将根据引用类型或指针类型选择执行的方法,静态联编
  • 如果一个方法是虚方法,将根据指针或引用指向对象的类型选择执行的方法,动态联编

虚函数的注意事项

对于指针或引用该调用哪个虚函数,这里有一些注意事项:

  • 基类对象的指针或引用指向派生类,这叫向上强制类型转换;父类有的子类一定有,允许转换
  • 派生类对象的指针或医用指向基类,这叫向下强制类型转换;子类有的父类不一定有,不允许转换
  • 只有成员才能是虚函数,所以友元函数等非成员函数不能是虚函数
  • 基类的构造函数不能是虚函数;而基类的析构函数推荐写成虚函数
  • 调用的函数不由指针类型决定,由指针所指的实际对象的类型决定该调用哪个类的函数
  • 运行时,检查指针所指对象的类型,如果该类型重定义了虚函数,则使用,否则使用基类的虚函数
  • 可以使用纯虚函数将类声明为抽象类,不能定义函数,也不能实例化抽象类
  • 虚函数的重定义不会生成函数的重载版本,子类的同名函数会隐藏基类的函数。所以,继承中重定义虚函数时,要保证除返回值以外的其它函数原型相同,这称为返回型协变
  • 如果想实现虚函数的重载,那么要从基类开始。如果基类的某个虚函数是重载的,那么子类要重定义所有版本,否则无法使用。如果不需要重定义,只需要调用基类的版本:void A::show() {Base::show();}
  • 虚函数的传递性:如果基类定义了虚函数,那么派生类的同名函数自动变为虚函数,所以派生类可以不写 virtual 关键字,写上会更清楚
  • 如果一不小心写错了,且没有关键字 override 早检查,比如派生类中 show 写成了 Show,那么不执行自己的虚函数,转而执行基类的虚函数

override 是标志符,指定虚函数覆写另一个虚函数。注意事项:

  • void foo() const override 不能覆写 void foo(),类型不匹配
  • 不能覆写非虚函数
  • override 避免写出 bug 的代码,参考上述内容最后一条

纯虚函数

上文提到了一下纯虚函数,多年不写差点忘了纯虚函数,2022 年 5 月 22 日复习回来补充:

  • 类里如果声明了虚函数,它的作用就是为了能让这个函数在它的子类里面可以被覆盖,这样的话,编译器就可以使用后期绑定来达到多态了。
  • 纯虚函数只是一个接口,是个函数的声明而已,只能留到子类里去实现。
  • 虚函数在子类里面可以不重写;但纯虚函数必须在子类实现才可以实例化子类。
  • 虚函数继承接口的同时也继承了父类的实现;纯虚函数关注的是接口的统一性,实现由子类完成。
  • 带纯虚函数的类叫抽象类,这种类不能直接生成对象,而只有被继承,并重写其虚函数后,才能使用。抽象类被继承后,子类可以继续是抽象类,也可以是普通类。

如何实现虚函数

如图所示:

编译器给每个对象添加一个隐藏成员,隐藏成员中保存一个指向函数地址的数组的指针,这种数组就是虚函数表。虚函数表存储了类声明的虚函数的地址。如 Base 类,包含一个指针,指向这个虚函数表。派生类也有指向独立地址表的指针,如果派生类提供了虚函数的定义,那么该虚函数表就保存为新的函数的地址,如 A 函数;如果没有定义,就保留基类的虚函数的地址,如 B 函数。

因此我们可以知道静态联编和动态联编各有优缺点:

  • 动态联编是程序在运行阶段决策,造成额外的开销,如虚函数表等,涉及的类、对象会增大,函数调用时的查表也会造成开销;
  • 静态联编效率高,可以在编译时期进行优化,是 C++ 的默认选择

如果不需要覆写基类的任何方法,就不需要动态联编,因此仅将预期的方法定义为虚的。

protected 继承

回到最初的问题,假设有个抽象类 shape 形状,包括三角形、矩形、圆形等。形状类提供两个纯虚函数,一个是求面积,一个是展示信息。当声明为纯虚函数时,其子类必须覆写纯虚函数的实现:

1
2
3
4
5
class Shape{
public:
virtual double getArea() const = 0;
virtual void show() const = 0;
};

公有继承时,可以这么写,十分简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Circle : public Shape{
private:
double radius = 1.0;
public:
Circle()=default;
Circle(double r){
this->radius = r;
}
double getArea() const override{
return this->radius * this->radius * 3.14;
}
void show() const override{
cout << "Circle" << endl;
}
};

如果是保护继承,那么是保持覆写函数的所在域的一致还是不一致呢?一致的话是在 protected 域内,不一致是在 public 域内,两种写法:

1
2
3
4
5
6
7
8
9
class Circle : protected Shape{
public:
double getArea() const override{
return this->radius * this->radius * 3.14;
}
void show() const override{
cout << "Circle" << endl;
}
};

放到 protected 域内时,这样保持了 protected 继承的一致性。但考虑到在类外调用,需要改写一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Circle : protected Shape{
public:
double getCircle() const{
return this->getArea();
}
void showCircle() const{
return this->show();
}
protected:
double getArea() const override{
return this->radius * this->radius * 3.14;
}
void show() const override{
cout << "Circle" << endl;
}
};

这样,如果我们在写 print 展示信息的函数,那么又要回到每个类都需要单独写一个函数的繁琐场景,不能使用动态联编的优良特性,这并不推荐。所以,个人建议,在 protected 继承时,如果要覆写函数且需要在外部调用,那么将需要覆写的函数放到 public 域下,这样会更简洁。给个完整代码感受一下:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <iostream>
using std::endl;
using std::cout;

class Shape{
public:
virtual double getArea() const = 0;
virtual void show() const = 0;
};

class Circle : public Shape{
private:
double radius = 1.0;
public:
Circle()=default;
Circle(double r){
this->radius = r;
}
double getArea() const override{
return this->radius * this->radius * 3.14;
}
void show() const override{
cout << "Circle" << endl;
}
};

class Rectangle : public Shape{
private:
double side = 1.0;
public:
Rectangle()=default;
Rectangle(double s){
this->side = s;
}
double getArea() const override{
return this->side * this->side;
}
void show() const override{
cout << "Rectangle" << endl;
}
};

// 只需要写一次 调用 show 函数
void print(Shape& p){
p.show();
}

int main(){
// 错误 抽象类不能实例化
// Shape s;
Circle c{3.2};
cout << c.getArea() << endl;

// Shape* p = &c;
Circle* p = &c;
cout << p->getArea() << endl;

Rectangle r{2.0};
cout << r.getArea() << endl;

print(c);
print(r);

return 0;
}

或者,直接 public 继承多省事:

感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章