C++ 入门基础之八

大纲

多继承

多继承概念

  • (a) 一个类有多个直接基类(父类)的继承关系称为多继承

  • (b) 类 C 可以根据访问控制同时继承类 A 和类 B 的成员,并添加自己的成员

  • (c) 多继承声明语法
1
2
3
4
class  派生类名 : 访问控制  基类名1 ,  访问控制  基类名2 ,  … , 访问控制  基类名n
{
数据成员和成员函数声明
};

多继承的简单使用

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
#include <iostream>

using namespace std;

class Base1 {

public:
Base1(int a) {
this->a = a;
}

void printA() {
cout << "a = " << a << endl;
}

private:
int a;
};

class Base2 {

public:
Base2(int b) {
this->b = b;
}

void printB() {
cout << "b = " << b << endl;
}

private:
int b;
};

class Base3 : public Base1, public Base2 {

public:
Base3(int a, int b, int c) : Base1(a), Base2(b) {
this->c = c;
}

void printC() {
cout << "c = " << c << endl;
}

private:
int c;
};

int main() {
Base3 base(1, 2, 3);
base.printA();
base.printB();
base.printC();
return 0;
}

程序运行的输出结果如下:

1
2
3
a = 1
b = 2
c = 3

派生类的构造函数和成员访问

在多继承的派生类中,其构造函数和成员访问的特性如下:

  • 拥有多个基类的派生类的构造函数,可以用初始化列表调用基类构造函数来初始化数据成员。
  • 执行顺序与单继承构造函数情况类似,多个直接基类构造函数执行顺序取决于定义派生类时声明的各个继承基类的顺序。
  • 一个派生类对象拥有多个直接或间接基类的成员。不同名成员访问不会出现二义性;如果不同的基类有同名成员,那么就会存在二义性,在派生类对象访问时应该加以识别,可以采用继承中的同名成员的处理方式来解决二义性。

虚继承

虚继承的概念

  • 虚继承概念呢
    • 虚继承的声明需要使用关键字 virtual
    • 虚继承的底层是靠虚基类指针(vbptr)和虚基类表来实现。
    • 如果一个派生类从多个基类继承,而这些基类又有一个共同的基类(公共基类),则在对该基类中声明的成员进行访问时,可能会产生二义性。
    • 如果在多条继承路径上有一个公共的基类,那么在继承路径的某处汇合点,这个公共基类就会在派生类的对象中产生多个基类子对象。要使这个公共基类在派生类中只产生一个子对象,必须对这个基类声明为虚继承,使这个基类成为 虚基类

虚继承的简单使用

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
66
67
68
69
70
71
72
73
74
75
76
#include <iostream>

using namespace std;

class Base {

public:
Base(int x) {
this->x = x;
cout << "Base 类的构造函数被调用" << endl;
}

void printX() {
cout << "x = " << x << endl;
}

private:
int x;
};

// 声明虚继承
class Base1 : virtual public Base {

public:
Base1(int a, int x) : Base(x) {
this->a = a;
}

void printA() {
cout << "a = " << a << endl;
}

private:
int a;
};

// 声明虚继承
class Base2 : virtual public Base {

public:
Base2(int b, int x) : Base(x) {
this->b = b;
}

void printB() {
cout << "b = " << b << endl;
}

private:
int b;
};

class Base3 : public Base1, public Base2 {

public:
// 由于父类和虚基类没有默认的无参构造函数,所以这里的派生类需要在初始化列表中,显式调用父类、虚基类的有参构造函数
Base3(int a, int b, int c, int x) : Base1(a, x), Base2(b, x), Base(x) {
this->c = c;
}

void printC() {
cout << "c = " << c << endl;
}

private:
int c;
};

int main() {
Base3 base(1, 2, 3, 4); // 虚基类Base的构造函数只会被调用一次
base.printA();
base.printB();
base.printC();
base.printX(); // 当不声明虚继承的时候,此写法会产生二义性,C++编译器会出现编译错误
return 0;
}

程序运行的输出结果如下:

1
2
3
4
5
Base 类的构造函数被调用
a = 1
b = 2
c = 3
x = 4

值得一提的是,如果虚基类声明了非默认形式的(即带参数的)构造函数,并且没有声明默认形式的(无参)构造函数,此时在整个继承关系中,直接或者间接继承虚基类的所有派生类,都必须在构造函数的成员初始化列表中列出对虚基类的初始化。因为涉及到多重继承和虚继承,为避免派生类因调用多个父类的构造函数后多次构造更上层虚基类,所以需要派生类自己显示调用继承而来的虚基类的构造函数,而继承链上其它所有对虚基类的构造函数调用将被忽略。简单一句话概况:父类不会帮子类调用虚基类的构造函数,子类在构造时必须自己初始化所有虚基类。

虚继承的使用场景

菱形继承的概述

两个派生类继承自同一个基类,而又有某个类同时继承自两个派生类,这种继承关系被称为菱形继承,或者叫钻石继承,如下图所示。这种继承关系带来了以下问题:

  • 马继承了马科的数据和函数,驴同样继承了马科的数据和函数,当骡子调用函数或者访问数据时,就会产生二义性。
  • 骡子继承了两份马科的数据和函数,其实应该清楚一点,这份数据和函数只需要一份就可以了。

适用场景的介绍

虚继承只适用于有共同基类(公共基类)的多继承场景(比如菱形继承),如下图所示:

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

#include <iostream>

using namespace std;

// 动物
class Animal {

public:
int m_Age;

};

// 羊,虚继承
class Sheep : virtual public Animal {

};

// 驼,虚继承
class Camel : virtual public Animal {

};

// 羊驼
class CamelSheep : public Sheep, public Camel {

};

int main() {
CamelSheep camelSheep;

camelSheep.Sheep::m_Age = 10;
camelSheep.Camel::m_Age = 20;

// 使用虚继承后,不再存在二义性,因为数据只有一份
cout << camelSheep.m_Age << endl;
cout << camelSheep.Sheep::m_Age << endl;
cout << camelSheep.Camel::m_Age << endl;

return 0;
}

程序运行的输出结果如下:

1
2
3
20
20
20

假设上述代码不使用虚继承,那么 camelSheep.m_Age 就会存在二义性,导致编译器执行编译时会出错,完整的代码如下:

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
#include <iostream>

using namespace std;

// 动物
class Animal {

public:
int m_Age;

};

// 羊
class Sheep : public Animal {

};

// 驼
class Camel : public Animal {

};

// 羊驼
class CamelSheep : public Sheep, public Camel {

};

int main() {
CamelSheep camelSheep;

camelSheep.Sheep::m_Age = 10;
camelSheep.Camel::m_Age = 20;

// 存在二义性,代码编译不通过
// cout << camelSheep.m_Age << endl;

cout << camelSheep.Sheep::m_Age << endl;
cout << camelSheep.Camel::m_Age << endl;

return 0;
}

程序运行的输出结果如下:

1
2
10
20

不适用场景的介绍

对于 V 字形的多继承场景,虚继承是没办法解决二义性问题的,如下图所示:

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
#include <iostream>

using namespace std;

class Base1 {

public:
Base1(int a) {
this->a = a;
}

void print() {
cout << "a = " << a << endl;
}

private:
int a;
};

class Base2 {

public:
Base2(int b) {
this->b = b;
}

void print() {
cout << "b = " << b << endl;
}

private:
int b;
};

class Base3 : virtual public Base1, virtual public Base2 {

public:
Base3(int a, int b) : Base1(a), Base2(b) {

}
};

int main() {
Base3 base(1, 2);

// 虚继承只适用于有共同基类(公共基类)的多继承场景(钻石菱形 ◇)
// 即使上面声明了虚继承,但此写法仍然会产生二义性,C++编译器会出现编译错误
// base.print();

base.Base1::print();
base.Base2::print();
return 0;
}

程序运行的输出结果如下:

1
2
a = 1
b = 2

虚基类的工作原理

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
#include <iostream>

using namespace std;

// 动物
class Animal {

public:
int m_Age;

};

// 羊,虚继承
class Sheep : virtual public Animal {

};

// 驼,虚继承
class Camel : virtual public Animal {

};

// 羊驼
class CamelSheep : public Sheep, public Camel {

};

int main() {
CamelSheep camelSheep;

camelSheep.Sheep::m_Age = 10;
camelSheep.Camel::m_Age = 20;

// 使用虚继承后,不再存在二义性,因为数据只有一份
cout << camelSheep.m_Age << endl;
cout << camelSheep.Sheep::m_Age << endl;
cout << camelSheep.Camel::m_Age << endl;

cout << "----------虚基类的工作原理分析-----------" << endl;

// 获取 Sheep 的内存偏移量
cout << *(int*)((int*)*(int*)&camelSheep + 1) << endl;

// 获取 Camel 的内存偏移量
cout << *((int*)((int*)*((int*)&camelSheep + 1) + 1)) << endl;

// 通过 Sheep 的内存偏移量得到 Age
cout << ((Animal*)((char*)&camelSheep + *(int*)((int*)*(int*)&camelSheep + 1)))->m_Age << endl;

return 0;
}

在 Visual Studio 中,程序运行的输出结果如下:

1
2
3
4
5
6
7
20
20
20
----------虚基类的工作原理分析-----------
8
4
20

使用 Visual Studio 工具分析 CamelSheep 类的内存布局:

1
cl /d1 reportSingleClassLayoutCamelSheep main.cpp

得到的内存布局结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CamelSheep        size(12):
+---
0 | +--- (base class Sheep)
0 | | {vbptr}
| +---
4 | +--- (base class Camel)
4 | | {vbptr}
| +---
+---
+--- (virtual base Animal)
8 | m_Age
+---

CamelSheep::$vbtable@Sheep@:
0 | 0
1 | 8 (CamelSheepd(Sheep+0)Animal)

CamelSheep::$vbtable@Camel@:
0 | 0
1 | 4 (CamelSheepd(Camel+0)Animal)
vbi: class offset o.vbptr o.vbte fVtorDisp
Animal 8 0 4 0

多态

多态是面向对象的三大概念之一(如下),按字面的意思就是多种形态。当类之间存在层次结构,并且类之间是通过继承关联时,就会使用到多态。C++ 的多态意味着调用成员函数时,会根据调用函数的对象的类型来执行不同的函数。值得一提的是,多态是设计模式的基础,同时也是框架的基石。

  • 封装:突破了 C 语言函数的概念。
  • 继承:提高了代码的可重用性。
  • 多态:多态是指在不同继承关系的类对象中,去调同一函数,产生了不同的行为。多态的一般使用方式,是使用一个父类的指针或引用去调用子类中被重写的方法。

C++ 支持编译时多态(静态多态)和运行时多态(动态多态),运算符重载和函数重载属于编译时多态,而派生类和虚函数属于运行时多态。静态多态和动态多态的区别就是函数地址是早绑定(静态联编)还是晚绑定(动态联编)。如果函数的调用,在编译阶段就可以确定函数的调用地址,并产生代码,那就属于静态多态(编译时多态),也就是说地址是早绑定的。如果函数的调用地址不能在编译期间确定,而是需要在运行时才能决定,这就属于晚绑定,即动态多态(运行时多态)。

总结

什么是多态?一句话概况,多态就是父类的引用或者指针指向子类的对象。

函数重写

函数重写的概念

  • 函数重写是指在子类中定义与父类中原型相同的函数
  • 父类中被重写的函数依然会继承给子类
  • 默认情况下,在子类中重写的函数将隐藏父类中的函数
  • 通过作用域限定运算符 :: 可以访问到父类中被隐藏的函数
  • 函数重写只发生在父类与子类之间,而函数重载只发生在同一个类中

函数重写的使用

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
#include <iostream>

using namespace std;

class Parent {

public:
Parent(int a) {
this->a = a;
}

void print() {
cout << "I'm parent, a = " << a << endl;
}

private:
int a;
};

class Child : public Parent {
public:

Child(int a, int c) : Parent(a) {
this->c = c;
}

// 子类重写父类中的函数
void print() {
cout << "I'm child, c = " << c << endl;
}

private:
int c;
};

int main() {
Child child(3, 7);

// 执行子类的函数,默认情况下子类中重写的函数将隐藏父类中的函数
child.print();

// 执行父类的函数,通过作用域限定运算符"::"可以访问到父类中被隐藏的函数
child.Parent::print();

return 0;
}

程序运行的输出结果如下:

1
2
I'm child, c = 7
I'm parent, a = 3

函数重写与函数重载的区别

  • 函数重载

    • 必须在同一个类中进行
    • 子类无法重载父类的函数,父类同名函数将被子类的覆盖(隐藏)
    • 重载是在编译期间根据参数类型、参数个数和参数顺序决定函数的调用
  • 函数重写

    • 必须发生于父类与子类之间
    • 父类与子类中的函数必须有完全相同的原型
    • 使用 virtual 关键字声明之后,能够产生多态。如果不使用 virtual 关键字声明,那叫重写(重定义)

虚函数

类型兼容原则遇上函数重写

类型兼容原则 遇上函数重写时,执行以下代码后会出现意外的现象,即被调用的永远是父类的函数。

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
#include <iostream>

using namespace std;

class Parent {

public:
Parent(int a) {
this->a = a;
}

void print() {
cout << "I'm parent, a = " << a << endl;
}

private:
int a;
};

class Child : public Parent {
public:

Child(int c) : Parent(c) {
this->c = c;
}

// 子类重写父类中的函数
void print() {
cout << "I'm child, c = " << c << endl;
}

private:
int c;
};

int main() {
Parent* p = NULL;
Parent parent(6);
Child child(5);

// 执行父类的函数
p = &parent;
p->print();

// 执行父类的函数
p = &child;
p->print();

return 0;
}

程序运行的输出结果如下:

1
2
I'm parent, a = 6
I'm parent, a = 5

C/C++ 是静态编译型语言,在执行编译时,编译器会自动根据指针的类型判断指向的是一个什么样的对象。但在编译 print() 函数的时候,编译器不可能知道指针 p 究竟指向了什么对象,因为程序还没有运行。同时编译译器没有理由报错,于是编译器认为最安全的做法是编译到父类的 print() 函数,因为父类和子类肯定都有相同的 print() 函数。这就是所谓的 静态多态静态联编,函数调用在程序执行之前就已经准备好了;有时候这也被称为 早绑定,因为 print() 函数在程序编译期间就已经设置好了。这就引出了面向对象新的需求,也就是希望根据实际的对象类型来判断重写函数的调用;如果父类指针指向的是父类对象则调用父类中定义的函数,如果父类指针指向的是子类对象则调用子类中定义的重写函数,如图所示。

虚函数的概念

在 C++ 中,虚函数是一种特殊的成员函数,可用于实现运行时多态(动态多态)。通过将基类中的成员函数声明为虚函数,可以允许派生类覆盖(Override)这些函数,并且在运行时根据实际对象的类型来调用相应的函数实现。在基类中声明虚函数时,使用的是 virtual 关键字。派生类可以通过相同的函数签名来重写基类中的虚函数,而不需要使用 virtual 关键字(即使使用也是可以的)。虚函数的调用是通过指向对象的指针或引用来实现的。在运行时,如果对象是派生类的实例,则调用派生类中的函数实现,否则调用基类中的函数实现。虚函数的使用允许更灵活的代码结构,提供了一种强大的方式来实现多态,即使在编译时并不知道实际对象的类型。

虚函数的应用

C++ 中通过 virtual 关键字对多态进行支持,使用 virtual 关键字声明的函数被重写后即可展现多态特性,一般称之为 虚函数

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
#include <iostream>

using namespace std;

class Parent {

public:
Parent(int a) {
this->a = a;
}

// 使用 "virtual" 关键字声明父类的函数
virtual void print() {
cout << "I'm parent, a = " << a << endl;
}

private:
int a;
};

class Child : public Parent {
public:

Child(int c) : Parent(c) {
this->c = c;
}

// 使用 "virtual" 关键字声明,并重写父类中的函数
// 只要父类中的函数有 "virtual" 关键字的声明,那么子类的 "virtual" 关键字声明可写可不写,一般建议都写上
virtual void print() override {
cout << "I'm child, c = " << c << endl;
}

private:
int c;
};

int main() {
Parent* p = NULL;
Parent parent(6);
Child child(5);

// 执行父类的函数
p = &parent;
p->print();

// 执行子类的函数
p = &child;
p->print();

return 0;
}

程序运行的输出结果如下:

1
2
I'm parent, a = 6
I'm child, c = 5

此时,编译器看的是指针的内容,而不是它的类型。因此,由于 ParentChild 类的对象的地址存储在 *p 中,所以会调用各自的 print() 函数。正如所看到的,父类 Parent 的每个子类都有一个 print() 函数的独立实现。这就是多态的一般使用方式,即使用一个父类的指针或引用去调用子类中被重写的方法。有了多态就可以有多个不同的实现类,它们都带有同一个名称但具有不同实现的函数,函数的参数甚至可以是相同的。

虚析构函数

虚析构函数的概念

虚析构函数的作用:为了避免内存泄漏,通过父类的指针,可以将所有子类对象的析构函数都执行一遍(释放所有的子类资源)。简而言之,虚析构函数使得在删除指向子类对象的父类指针时,可以调用子类的析构函数来实现释放子类中堆内存的目的,从而防止内存泄漏。

  • 析构函数可以是虚的,虚析构函数用于指引 delete 运算符正确析构动态对象。
  • 构造函数不能是虚函数,因为建立一个派生类对象时,必须从类层次的根开始,沿着继承路径逐个调用基类的构造函数。

虚析构函数的简单使用

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
#include <iostream>

using namespace std;

class A {

public:
A() {
this->p = new char[20];
strcpy(p, "Hello A");
cout << "A 类的构造函数被调用" << endl;
}

virtual ~A() {
delete[] this->p;
cout << "A 类的析构函数被调用" << endl;
}

private:
char* p;
};


class B : public A {

public:
B() {
this->p = new char[20];
strcpy(p, "Hello B");
cout << "B 类的构造函数被调用" << endl;
}

~B() {
delete[] this->p;
cout << "B 类的析构函数被调用" << endl;
}

private:
char* p;
};

int main() {
// 此写法,如果上面不使用 "virtual" 修饰A类(基类)的析构函数,派生类与所有基类的析构函数依然都会被自动调用一次
B* b = new B();
delete b;

cout << endl;

// 此写法,如果上面不使用 "virtual" 修饰A类(基类)的析构函数,那么只有A类(基类)的析构函数会被调用一次,B类(派生类)的析构函数不会被调用,这样就会造成内存泄漏
// 虚析构函数的作用是,通过父类的指针,可以将所有子类对象的析构函数都执行一遍(释放所有的子类资源)。
A* a = new B();
delete a;
return 0;
}

程序运行的输出结果如下:

1
2
3
4
5
6
7
8
9
A 类的构造函数被调用
B 类的构造函数被调用
B 类的析构函数被调用
A 类的析构函数被调用

A 类的构造函数被调用
B 类的构造函数被调用
B 类的析构函数被调用
A 类的析构函数被调用

虚析构函数的使用总结

  • (a) 如果基类的析构函数不加 virtual 关键字修饰,那么就是普通析构函数
    • 当基类中的析构函数没有声明为虚析构函数时,派生类开始从基类继承,基类的指针指向派生类的对象时,delete 基类的指针时,只会调用基类的析构函数,不会调用派生类的析构函数
  • (b) 如果基类的析构函数加 virtual 关键字修饰,那么就是虚析构函数
    • 当基类中的析构函数声明为虚析构函数时,派生类开始从基类继承,基类的指针指向派生类的对象时,delete 基类的指针时,先调用派生类的析构函数,再调用基类中的析构函数

多态的使用总结

多态的理论基础

多态的术语:

  • 联编:是指一个程序模块、代码之间互相关联的过程
  • 静态联编:是程序的匹配、连接在编译阶段实现,也称为早期联编(早绑定)
    • 函数重载属于静态联编
  • 动态联编:是指程序的联编推迟到运行时进行,所以又称为晚期联编(晚绑定)
    • 虚函数、switch 语句和 if 语句属于动态联编

多态的实际应用(代码示例):

  • C++ 与 C 相同,都是静态编译型语言
  • 在编译时,编译器会自动根据指针的类型判断指向的是一个什么样的对象,所以编译器认为父类指针指向的是父类对象
  • 由于程序没有运行,所以不可能知道父类指针指向的具体是父类对象还是子类对象
  • 从程序安全的角度,编译器假设父类指针只指向父类对象,因此编译的结果为调用父类的成员函数,这种特性就是 静态联编

多态的原理浅析

  • 多态的工作原理
    • 虚函数的底层是靠虚函数指针(vfptr)和虚函数表来实现,点击查看图解分析
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
#include <iostream>

using namespace std;

class Animal {

public :
virtual void speak() {
cout << "Animal is speaking" << endl;
}

};

class Cat : public Animal {

public:
// 重写父类的虚函数
virtual void speak() override {
cout << "Cat is speaking" << endl;
}

};

void doSpeak(Animal &animal) {
animal.speak();
}

void test01() {
cout << sizeof(Animal) << endl;
cout << sizeof(Cat) << endl;
}

void test02() {
// 父类的指针指向子类的对象,会发生多态
Animal *animal = new Cat();
animal->speak();

// 虚函数的底层实现原理,下面的 *(int *) *(int *) animal 是函数地址
((void (*)()) (*(int *) *(int *) animal))(); // 效果相当于 animal->speak();
}

int main() {
test01();
test02();
return 0;
}

程序运行的输出结果如下:

1
2
3
4
8
8
Cat is speaking
Cat is speaking

多态成立的三个必要条件

  • (1) 要有继承
  • (2) 要有子类重写父类的虚函数
    • (a) 返回值、函数名称、函数参数必须和父类完全一致(析构函数除外)
    • (b) 子类中的 virtual 关键字可写可不写,但一般建议都写上去
  • (3) 父类指针或引用指向子类对象

C++ 11 的 override 和 final

  • override 关键字:用来检查函数是否重写,在子类中的函数声明里加上该关键字 virtual void fun() override {},编译器就会自动检查对应的函数是否重写了父类中的函数。
  • final 关键字:在类的声明中加上该关键字 class A final {};,目的是为了不让这个类被继承。或者,在一个函数后加上该关键字,表示这个函数不能被重写 void fun() final {}