Skip to content

Commit c94a586

Browse files
committed
Updated to Chapter 10, Section 2
1 parent cfeff56 commit c94a586

File tree

9 files changed

+262
-65
lines changed

9 files changed

+262
-65
lines changed

Structure.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@
677677

678678
基类指针可以指向派生类对象;基类引用可以引向派生类对象。它们能操作哪些成员。
679679

680-
### 虚函数`virtual`与多态
680+
### 动态类型转换与多态
681681

682682
#### 为何要向下类型转换?
683683

@@ -687,9 +687,7 @@
687687

688688
`dynamic_cast` 需要依赖多态的基类,我们要靠虚函数来实现。
689689

690-
#### 编程示例:不同动物的叫声
691-
692-
`animal`类继承出若干派生类`dog`, `cat`等,用同一个函数`roar`来表示叫声,对不同的派生类有不同的叫声,比如`"wolf"`, `"meow"`等。
690+
#### 编程示例:不同形状几何图形的面积
693691

694692
### 抽象基类与纯虚函数
695693

generalized_parts/09_class_inheritance/04_sequential_inheriance.tex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
\section{多级继承}
2-
任何一个自定义类型都可以作为基类,被其它类继承,从而成为其它类的一部分。对于公有继承来说,这相当于是对基类对象所拥有的共性,添加了一些派生类对象额外的特性;对于私有继承来说,这相当于是把一个基类的对象内嵌到派生类中,作为它的一个成员。\par
2+
任何一个自定义类型都可以作为基类,被其它类继承,从而成为其它类的一部分\footnote{有一种例外:标注为 \lstinline@final@ 的类不可以被继承。但本书不打算讲解 \lstinline@final@ 的相关内容。}。对于公有继承来说,这相当于是对基类对象所拥有的共性,添加了一些派生类对象额外的特性;对于私有继承来说,这相当于是把一个基类的对象内嵌到派生类中,作为它的一个成员。\par
33
接下来我们还可以把派生类再作为基类,用它来继承新的类。我们把这种操作叫作\textbf{多级继承(Multilevel inheritance)}。
44
\begin{lstlisting}
55
struct A { /*...*/ };
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
\section{虚函数与多态}
2+
在上一节中我们遗留了有关动态类型转换的问题。不过请读者先不要迷失在语法之中,我们先把思绪拉回来,思考一下我们使用向下类型转换的目的。\par
3+
\subsection*{为何要向下类型转换?}
4+
我们说过,如果一个基类指针原本就指向一个基类对象的话,那么向下类型转换没有任何意义;只有当基类指针原本就指向一个派生类对象时,我们使用向下类型转换才有意义。\par
5+
但是,你有没有在心里纳闷过哪怕一秒——这么转上去再转下来有意思吗?如果我们一开始就有一个派生类的指针/对象,那么为什么要先把它向上转换成基类的指针/引用,再向下转回去呢?我们直接用原来的指针/引用不就完了?
6+
\begin{lstlisting}
7+
void fun(Base *pb) {
8+
Derived *pd {static_cast<Derived*>(pb)}; //Base*类的pb再转换成Derived*
9+
//...
10+
}
11+
int main() {
12+
Derived de;
13+
fun(&de); //传入参数时&de被转换成Base*
14+
}
15+
\end{lstlisting}
16+
这是何苦呢?如果只是为了完成这个功能,我们直接把形参改成 \lstinline@Derived*@ 就好了。
17+
\begin{lstlisting}
18+
void fun(Derived *pd) { //更省事了
19+
//...
20+
}
21+
int main() {
22+
Derived de;
23+
fun(&de);
24+
}
25+
\end{lstlisting}
26+
所以如果单纯是为了使用某个派生类的对象,那么咱犯不着还要通过基类指针/引用倒一遍。\par
27+
那么向下转换是为了使用某个派生类独有的成员对象吗?问题同样在于,如果这个成员对象是派生类独有的,那么我们应该传入派生类的指针/引用,而不是拿基类来做文章;如果这个成员对象是所有派生类都有的呢,我们应该在设计之初就把它写到基类的成员对象当中,使它成为所有派生类的共性,而不是像刚才说的那样,把它写成每个派生类``共同的独特性''
28+
\begin{lstlisting}
29+
class Base {
30+
protected:
31+
int common; //共性,使用它不需要向下类型转换
32+
};
33+
class Derived : public Base {
34+
int special; //独特性,如果是为了使用它,就不应当传入基类指针
35+
};
36+
\end{lstlisting}
37+
我们发现,这个时候也是用不到向下类型转换的——不是不能用,而是太没必要了。\par
38+
读者可能想到了另一种情况——静态成员。确实,不同的派生类可能有同名的静态成员。如果我们希望这个静态成员在不同派生类中有不同的值(举个例子吧,\lstinline@_object_number@ 表示这个类的对象数),那么我们就不能把它定义在基类当中——因为静态成员对象有内部链接,同一个类只能有同一个静态成员。那么不同派生类的同名静态成员就要定义在各自的派生类中。
39+
\begin{lstlisting}
40+
class Base {
41+
public:
42+
static std::size_t _object_number; //Base类的静态成员对象
43+
protected:
44+
int common; //共性
45+
}
46+
class Derived : public Base {
47+
public:
48+
static std::size_t _object_number; //Derived类的静态成员对象
49+
}
50+
\end{lstlisting}
51+
这里候如果我们还想要根据 \lstinline@Base*@ 指针来访问派生类独有成员的话,就需要用向下类型转换了。\par
52+
但是——实际上,如果我们想要访问静态成员的话,可以直接用作用域解析操作来完成。
53+
\begin{lstlisting}
54+
std::cout << Derived::_object_number;
55+
\end{lstlisting}
56+
所以说这里其实也没有类型转换什么事。\par
57+
最后还有一种可能,那就是成员函数。试想一种情况——我们需要为不同的派生类写同样名字的函数;但是,这些函数的定义又有所不同,所以我们不能把它们作为共性,合并到基类当中。
58+
\begin{lstlisting}
59+
struct Shape { //这个类用来表示各种几何图形
60+
static constexpr double Pi {3.1415926}; //常量表达式Pi
61+
static constexpr double Deg2Rad(double deg) { //常成员函数,用于角度转弧度
62+
return deg * Pi / 180;
63+
}
64+
static constexpr double Rad2Deg(double rad) { //常成员函数,用于弧度转角度
65+
return rad * 180 / Pi;
66+
}
67+
};
68+
class Triangle : public Shape { //三角形是一类几何图形
69+
double _a;
70+
double _b;
71+
double _c;
72+
public:
73+
Triangle(double a, double b, double c)
74+
: _a {a}, _b {b}, _c {c} {}
75+
double perimeter()const { return _a + _b + _c; } //周长
76+
double area()const { //面积
77+
double s {(_a + _b + _c) / 2};
78+
return std::sqrt(s * (s - _a) * (s - _b) * (s - _c));
79+
}
80+
};
81+
class Circle : public Shape { //圆形是一类几何图形
82+
double _r;
83+
public:
84+
Circle(double r) : _r {r} {}
85+
double perimeter()const { return 2 * Pi * _r; } //周长
86+
double area()const { return Pi * _r * _r; } //面积
87+
};
88+
class Parallelogram : public Shape { // 平行四边形是一类几何图形
89+
double _a;
90+
double _b;
91+
double _theta; //表示一个夹角的角度值
92+
public:
93+
Parallelogram(double a, double b, double theta)
94+
: _a {a}, _b {b}, _theta {theta} {}
95+
double perimeter()const { return 2 * (_a + _b); } //周长
96+
double area()const { return _a * _b * std::sin(Deg2Rad(_theta)); } //面积
97+
//注意C++标准库中三角函数接收的参数都是弧度值
98+
};
99+
\end{lstlisting}
100+
读者可以看到,这里的三个派生类,每个类的 \lstinline@perimeter@ 函数和 \lstinline@area@ 函数都不尽相同——所以我们不能直接把这些函数写到 \lstinline@Shape@ 类中。所以在面对基类指针/引用时,我们进行向下类型转换就是必要的了。\par
101+
\subsection*{多态与动态类型转换}
102+
基类指针/引用到派生类指针/引用的动态类型转换有一个前置条件,即基类必须是\textbf{多态(Polymorphism)}的。在继承场合下,多态意味着一个基类指针会指向它的派生类对象,并且允许程序在运行时判断这个指针的实际指向,进而转换为指向其派生类的指针。
103+
\begin{lstlisting}
104+
Shape *ps[3] {
105+
new Triangle{2,3,2},
106+
new Circle{2},
107+
new Parallelogram{1,5,90}
108+
}; //定义一个指针数组用来分配新内容
109+
std::cout << dynamic_cast<Triangle*>(ps[0])->area() << std::endl;
110+
std::cout << dynamic_cast<Circle&>(*ps[1]).area() << std::endl;
111+
for (auto p : ps)
112+
delete p; //记得回收
113+
\end{lstlisting}
114+
保证了基类的多态性后,我们就可以通过动态类型转换来实现这个功能了。无论指针还是引用均可以如此。\par
115+
最简单的多态化方法就是把基类的析构函数变成 \lstinline@virtual@ 的虚函数。
116+
\begin{lstlisting}
117+
virtual ~Shape() {} //虚函数
118+
\end{lstlisting}
119+
只要有哪怕一个虚函数,这个类就是多态的——所以把析构函数定义成虚函数是我所认为的最佳方法。我们稍后再讲虚函数的有关细节。\par
120+
如果我们需要写一个函数,它可以输出任何一个 \lstinline@Shape@ 派生类对象的面积信息,目前的方案就是把这个派生类对象的地址通过向上类型转换变成 \lstinline@Shape*@——这样我们只需要写一个函数就可以了,不用写一大堆重载。
121+
\begin{lstlisting}
122+
void output_shape_info(const Shape *sh) {
123+
//...待补充
124+
}
125+
\end{lstlisting}
126+
这个方法还有点粗糙,因为我们需要在函数体中再判断它到底指向哪个派生类。为此我们就不得不再写一大堆代码。
127+
\begin{lstlisting}
128+
double shape_area(const Shape *sh) {
129+
if (dynamic_cast<Triangle*>(sh)) //如果sh不指向Triangle对象,返回值为nullptr
130+
return dynamic_cast<Triangle*>(sh).area(); //调用Triangle::area()
131+
if (dynamic_cast<Circle*>(sh)) //如果sh不指向Circle对象,返回值为nullptr
132+
return dynamic_cast<Circle*>(sh).area(); //调用Circle::area()
133+
if (dynamic_cast<Parallelogram*>(sh)) //同上
134+
return dynamic_cast<Parallelogram*>(sh).area(); //同上
135+
}
136+
\end{lstlisting}
137+
这样看上去……好像和我们分别写三个重载的工作量也没大差啊。
138+
\begin{lstlisting}
139+
void shape_area(const Triangle &tri) {
140+
return tri.area();
141+
}
142+
//还有两个重载
143+
\end{lstlisting}\par
144+
这样单纯地用动态类型转换并没有让我们写代码变得多么方便;反而,\lstinline@dynamic_cast@ 加上各种信息和判断就够我们喝一壶的了。所以我们还需要寻求更简化的方法。这也就是虚函数最大的妙用,我们现在就来讲它。\par
145+
\subsection*{\texttt{virtual}虚函数}
146+
让我们从头开始思考关于 \lstinline@shape_area@ 函数的问题。\par
147+
显然,因为不同派生类的 \lstinline@area@ 函数彼此有一些差异(而且它们多少用到了派生类当中的成员),所以我们不能把这些函数定义在基类中。这也就导致我们不能用 \lstinline@sh->area()@ 之类的写法——基类当中没有这个函数的定义。\par
148+
那么我们在基类中添加一个 \lstinline@area@ 函数的定义,可否?答案是好像不行,因为这样一来,我们在使用 \lstinline@sh->area()@ 时也只能做到调用 \lstinline@Shape::area()@,而根本就不是在调用 \lstinline@Triangle::area()@ 或者别的派生类成员函数。\par
149+
而虚函数为我们提供了这种可能性——当我们用基类的指针/引用调用基类的虚函数时,程序会进行运行时检测,判断这个指针实际指向哪个类型的对象,然后调用这个类中的成员函数(如果存在的话)。
150+
还是以 \lstinline@Shape@ 为例,我们可以在 \lstinline@Shape@ 当中声明虚函数 \lstinline@perimeter@ 和 \lstinline@area@。这样,我们就可以用基类的指针调用 \lstinline@area@ 函数;而在运行时,程序会根据这个指针指向对象的类型,判断该用 \lstinline@Triangle::area()@, \lstinline@Circle::area()@ 还是 \lstinline@Parallelogram::area()@。
151+
\begin{lstlisting}
152+
struct Shape { //这个类用来表示各种几何图形
153+
//...
154+
virtual double perimeter()const { return 0; }
155+
virtual double area()const { return 0; }
156+
//虚函数,如果通过指针/引用调用它,程序会进行运行时类型判断,决定调用哪个函数
157+
virtual ~Shape() {}
158+
};
159+
\end{lstlisting}
160+
那么我们就可以用基类的虚函数作为媒介,通过基类指针调用派生类的成员函数。
161+
\begin{lstlisting}
162+
Shape *ps[3] {
163+
new Triangle{2,3,2},
164+
new Circle{2},
165+
new Parallelogram{1,5,90}
166+
};
167+
std::cout << ps[0]->area() << std::endl;
168+
//ps[0]指向Triangle对象,所以调用Triangle::area()
169+
std::cout << ps[1]->area() << std::endl;
170+
//ps[1]指向Circle对象,所以调用Circle::area()
171+
for (auto p : ps)
172+
delete p;
173+
\end{lstlisting}
174+
瞧,连动态类型转换都可以省了。我们不再需要自行判断基类指针究竟指向什么,程序会帮我们判断。\par
175+
\subsection*{虚函数的性质}
176+
在没有虚函数的情况下,编译器根据调用成员函数的对象/指针/引用就知道要调用哪个类的成员函数。举个例子,对于一个 \lstinline@std::string@ 类的对象 \lstinline@str@ 来说,当我们调用 \lstinline@size()@ 成员函数时,编译器根据对象的类型就可以推测出它要调用的是 \lstinline@std::string::size()@,而不是 \lstinline@std::vector<int>::size@ 或者别的。\par
177+
编译器知道了要调用哪个成员函数之后,它就会把这个函数与该语句绑定(或者说,链接)起来,这样程序就知道该调用什么函数了。\par
178+
对于指针来说亦如此。如果没有虚函数的话,一切都很简单:对于 \lstinline@Type*@ 类型的指针,当我们使用 \lstinline@->@ 进行指针的成员访问时,编译器会把 \lstinline@Type@ 类型的成员与其绑定。这种绑定是在编译期完成的,我们把这种方式称为\textbf{早绑定(Early binding)}。\par
179+
虚函数为我们提供了另一种可能:用派生类的成员函数覆盖基类的成员函数。如果基类指针指向派生类对象,并且这个派生类有同名的成员函数,那么程序可以使用派生类成员覆盖基类成员。那么这两个条件——是否有同名函数,这是可以在编译时确定下来的;但是,基类指针究竟指向什么,这是不可能在编译时确定下来的\footnote{除非这个基类指针是 \lstinline@constexpr@ 之类的}。正因如此,这个调用不能在编译期进行早绑定,而必须在运行期根据实际情况绑定到相应的成员函数中。我们把这种方式称为\textbf{迟绑定(Late binding)}。
180+
\lstinline@virtual@ 声明的虚函数就拥有这种性质,它允许被派生类的同名成员函数覆盖。这样一来,当我们使用指针/引用来调用这个函数时,它就会被迟绑定。\footnote{注意,如果我们使用基类的对象调用这个成员函数,那么它照样会被早绑定;只有用指针/引用才可以进行迟绑定。}\par
181+
虚函数要求基类和派生类中的成员函数同名,所以构造函数不能被定义为虚函数。\par
182+
然而,析构函数能被定义成虚函数。当我们用基类指针调用析构函数,或者是用 \lstinline@delete@ 回收基类指针指向的派生类内存空间时,派生类与基类的析构函数会被先后调用\footnote{注意顺序。对于这种情况来说,派生类的析构函数先调用,然后才是基类的析构函数。这个顺序与派生类对象的析构很相似。}。如果基类的析构函数不是虚函数,那么在回收内存空间时,程序将不会调用派生类的析构函数——这可能暗藏危险。所以我的建议是:只要你想写一个多态的基类,就请务必定义一个虚析构函数。\par
183+
静态成员函数不能定义成虚函数。\par
184+
除构造函数、析构函数和静态成员函数之外的成员函数都可以用 \lstinline@virtual@ 关键字定义成虚函数。只要是虚函数,它就有了被派生类对象覆盖的可能\footnote{虚析构函数不是``被覆盖''了。它只是先调用了派生类的析构函数而已。}。前文定义的 \lstinline@area@ 成员函数便是如此。\par
185+
虚函数描述的是单个成员函数的性质——某个成员函数是虚函数,并不意味着该类的其它成员函数都是虚函数。但是只要这个类有一个虚函数,它就是一个多态的类,其对象可以进行动态类型转换。\par
186+
虚函数的性质可以顺着继承关系传递给派生类的同名函数。换句话说,只要 \lstinline@Shape::area()@ 是虚函数,那么 \lstinline@Triangle::area()@, \lstinline@Circle::area@ 和 \lstinline@Parallelogram::area()@ 都是虚函数,而无论我们是否使用了 \lstinline@virtual@ 关键字。虽说如此,但是我依然建议读者在这些类中也加上 \lstinline@virtual@ 关键字,这样可以提高代码的可读性,避免我们在不知情的状况下犯一些不易查出的错误。\par

0 commit comments

Comments
 (0)