slacr_

Just to record my life and thoughts.
笔记/编程/杂乱/极简

[C++笔记]多态

Apr 29, 2023C_C++5159 words in 34 min

C++ 笔记

多态概述

多态是指同样的消息被不同类型的对象接收时导致不同的行为。
面向对象的多态性可以分为四类:重载多态、强制多态、包含多态和参数多态。前面两种统称为专用多态,而后面两种称为通用多态。
强制多态是指将一个变元的类型加以变化,以符合一个函数或者操作的要求.
包含多态是类族中定义于不同类中的同名成员函数的多态行为,主要是通过虚函数来实现。
参数多态与类模板相关联,在使用时必须赋予实际的类型才可以实例化。

多态从实现的角度来讲可以划分为两类:编译时的多态和运行时的多态。前者是在编译的过程中确定了同名操作的具体操作对象,而后者则是在程序运行过程中才动态地确定操作所针对的具体对象。这种确定操作的具体对象的过程就是绑定 (binding) 。绑定是指计算机程序自身彼此关联的过程,也就是把一个标识符名和一个存储地址联系在一起的过程;用面向对象的术语讲,就是把一条消息和一个对象的方法相结合的过程。按照绑定进行的阶段的不同,可以分为两种不同的绑定方法:静态绑定和动态绑定.
绑定工作在编译连接阶段完成的情况称为静态绑定。其同名操作的具体对象能够在编译、连接阶段确定,通过静态绑定解决,比如重载、强剧和参数多态。
绑定工作在程序运行阶段完成的情况称为动态绑定, 在编译、连接过程中无法解决的绑定问题,包含多态操作对象的确定就是通过动态绑定完成的。

运算符重载

运算符重载的实质就是函数重载。对已有的运算符赋予多重含义,使同一个运算符作用于不同类型的数据时导致不同的行为。

运算符重载的规则:

(1) c++ 中的运算符除了少数几个之外. * :: ?,全部可以重载,而且只能重载 c++ 中已经有的运算符。
(2) 重载之后运算符的优先级和结合性都不会改变。
(3) 运算符重载是针对新类型数据的实际需要,对原有运算符进行适当的改造。一般来讲,重载的功能应当与原有功能相类似,不能改变原运算符的操作对象个数,同时至少要有一个操作对象是自定义类型。
运算符的重载形式有两种,即重载为类的非静态成员函数和重载为非成员函数

当运算符重载为类的成员函数时,函数的参数个数比原来的操作数个数要少一个(后置"++“,”–"除外)
重载为非成员函数时,参数个数与原操作数个数相同。

重载为类的成员函数时,第一个操作数会被作为函数调用的目的对象,因此无须出现在参数表中,函数体中可以直接访问第一个操作数的成员;而重载为非成员函数时,运算符的所有操作数必须显式通过参数传递。

重载成员函数

重载双目运算符

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
#include <iostream>
using namespace std;

class Test {
public:
int x, y;
Test():x(0), y(0) {}
Test(int x, int y) : x(x), y(y){}
void operator+(Test t) {
this->x += t.x;
this->y += t.y;
}
// 重载双目运算符, 第一个参数类的对象
void display() {
cout << "[" << x << "," << y << "]" << endl;
}
};

int main() {
Test t1(1, 2);
t1 + Test(2, 3); // 相当于 t1.operator+(Test(2, 3))
t1.display(); // [3, 5]
t1.operator+(Test(4, 4));
t1.display(); // [7, 9]
return 0;
}

重载单目运算符

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 Test {
public:
int x, y;
Test() :x(0), y(0) {}
Test(int x, int y) : x(x), y(y) {}
// 重载前置++
void operator++() {
this->x++;
this->y++;
}

// 重载后置++
void operator++(int) {
this->x += 2;
this->y += 2;
}

void display() {
cout << "[" << x << "," << y << "]" << endl;
}


};

int main() {
Test t1(1, 2);
++t1;
t1.display(); // [2, 3]
t1.operator++();
t1.display(); // [3, 4]

t1++;
t1.display(); // [5, 6]
t1.operator++(1); // 加个参数以区分后置, 参数仅起区分作用
t1.display(); // [7, 8]

return 0;
}

重载非成员函数

运算所需要的操作数都需要通过函数的形参表来传递,在形参表中形参从左到右的顺序就是运算符操作数的顺序。如果需要访问
运算符参数对象的私有成员,可以将该函数声明为类的友元函数。

重载单目运算符

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
#include<iostream>
using namespace std;

class Test {
public:
int x, y;
Test() :x(0), y(0) {}
Test(int x, int y) : x(x), y(y) {}

void display() {
cout << "[" << x << "," << y << "]" << endl;
}
};

// 重载左++
void operator++(Test& t) {
t.x++;
t.y++;
}


// 重载右++
void operator++(Test& t, int ) {
t.x += 2;
t.y += 2;
}

int main() {
Test t1(1, 2);
++t1;
t1.display(); // [2, 3]
t1++;
t1.display(); // [4, 5]
return 0;
}

重载双目运算符

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
#include <iostream>
using namespace std;
class P {
public:
int x, y;
P() : x(0), y(0){}
P(int x, int y) : x(x), y(y) {}

};

ostream& operator<<(ostream& o, P p) {
// 重载<< , 由于 << 左移运算符的第一个操作数只能是一个 ostream& 类型, 故只能使用普通函数重载
o << "[" << p.x << "," << p.y << "]";
return o;
}

P& operator+ (P p1, P p2) {
P p(p1.x + p2.x, p1.y + p2.y);
return p;
}

int main() {
cout << P(1, 1) << P(2, 2) << endl;
cout << P(1, 1) + P(2, 2) + P(3, 3) << endl;
return 0;
}

虚函数

虚函数的实现

虚函数是动态绑定的基础。虚函数必须是非静态的成员函数。虚函数经过派生之后,在类族中就可以实现运行过程中的多态。

根据赋值兼容规则,可以使用派生类的对象代替基类对象。如果用基类类型的指针指向派生类对象,就可以通过这个指针来访问该对象,问题是访问到的只是从基类继承来的同名成员。解决这一问题的办法是:如果需要通过基类的指针指向派生类的对象,并
访问某个与基类同名的成员,那么首先在基类中将这个同名函数说明为虚函数。这样,通过基类类型的指针,就可以便属于不同派生类的不同对象产生不同的行为,从而实现运行过程的多态。

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
#include <iostream>
using namespace std;

class Base {
public:
virtual void go(){}
virtual void say() {
cout << "Base" << endl;
}
// 虚函数
int test;
};

class Derived_1 : public Base {
public:
virtual void say() { // 这里的virtual可加可不加, 不加编译器会自动判断
cout << "Derived_1" << endl;
}
};

class Derived_2 : public Base {
public:
void say() {
cout << "Derived_2" << endl;
}
};

void say(Base* b) {
b->say();
}

int main() {
Base* b = new Base();
say(b); // Base

Derived_1* d_1 = new Derived_1();
say(d_1); // Derived_1

Derived_2* d_2 = new Derived_2();
say(d_2);


Base* base = d_1;
base->say(); // Derived_1
base->Base::say(); // Base ,可以加作用域以限制调用类作用域下的函数

return 0;
}

基类多了一个虚函数表指针vfptr, 指向一张虚函数表vftable, 子类继承了该虚函数后会根据函数覆盖(重写)的关系重新初始化其中的虚函数表和虚函数表指针. 虚函数表中记录了该类中每个虚函数正确指向的函数地址.

此时再当派生类指针/引用初始化基类指针/引用时(对象不行, 会发生调用基类复制构造, 对象切片转换得到一个基类对象), 基类指针任然只能访问派生类继承下来的基类部分, 但其中的vfptr和vbtable已经被改写, 从而可以实现基类指针/引用调用虚函数的动态绑定.

只有虚函数是动态绑定的,如果派生类需要修改基类的行为(即重写与基类函数同名的函数) ,就应该在基类中将相应的函数声明为虚函数。而基类中声明的非虚函数,通常代表那些不希望被派生类改变的功能,也是不能实现多态的。一般不要重写继承而来的非虚函数(虽然语法对此没有强行限制) ,因为那会导致通过基类指针和派生类的指针或对象调用同名函数时,产生不同的结果,从而引起混乱。

虚析构函数

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
#include <iostream>
using namespace std;

class Base {
public:
virtual ~Base();
};
Base::~Base() {
cout << "Base destructor" << endl;
}

class Derived : public Base {
public:
int* item = new int();
virtual ~Derived();
};
Derived::~Derived() {
delete item;
cout << "Derived destructor" << endl;
}

int main() {
Base* b = new Derived();
// 派生类指针初始化基类指针
// delete b; 没有声明基类虚析构时 Base destructor, 派生类内存泄漏
delete b; // 声明虚析构后, 调用派生类析构再基类析构, 内存正确释放
return 0;
}

纯虚函数

纯虚函数是一个在基类中声明的虚函数,它在该基类中没有定义具体的操作内容,要求各派生类根据实际需要给出各自的定义。
声明为纯虚函数之后,基类中就可以不再给出函鼓的实现部分。纯虚函数的函数体由派生类给出。

基类中仍然允许对纯虚函数给出实现,但即使给出实现,也必须由派生类覆盖,否则无法实例化。在基类中对纯虚函数定义的函数体的调用,必须通过"基类名::函数名(参数表)"的形式。如果将析构函数声明为纯虚函数,必须给出它的实现,因为派生类的析构函数体执行完后需要调用基类的纯虚函数。

纯虚函数不同于函数体为空的虚函数:纯虚函数根本就没有函数体,而空的虚函数的函数体为空;前者所在的类是抽象类,不能直接进行实例化,而后者所在的类是可以实例化的。它们共同的特点是都可以派生出新的类,然后在新类中给出虚函数新的实现,而且这种新的实现可以具有多态特征。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
using namespace std;

class Base {
public:
virtual void say() = 0;
};
void Base::say() {
cout << "base" << endl;
}

class Derived : public Base{
public:
virtual void say() {
cout << "derived" << endl;
}
};

int main() {
Derived d;
d.say(); // base
d.Base::say(); // derived
return 0;
}

抽象类

带有纯虚函数的类是抽象类。抽象类的主要作用是通过它为一个类族建立一个公共的接口,使它们能够更有效地发挥多态特性。抽象类声明了一个类族派生类的共同接口,而接口的完整实现,即纯虚函数的函数体,要由派生类自己定义。

抽象类派生出新的类之后,如果派生类给出所有纯虚函数的函数实现,这个派生类就可以定义自己的对象,因而不再是抽象类;反之,如果派生类没有给出全部纯虚函数的实现,这时的派生类仍然是个抽象类。

抽象类不能实例化,即不能定义一个抽象类的对象,但是可以定义一个抽象类的指针和引用。通过指针或引用,就可以指向并访问派生类的对象,进而访问派生类的成员,这种访问是具有多态特征的。

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
#include <iostream>
using namespace std;

class Person {
public:
virtual void showCapability() const = 0;
};

class Farmer : public Person {
public:
string seed;
Farmer(string seed) : seed(seed){}
virtual void showCapability() const {
cout << "I plant " << seed << " on my land" << endl;
}
};

class Programmer : public Person {
public:
string lang;
Programmer(string lang) : lang (lang){}
virtual void showCapability() const {
cout << "I write " << lang << " earning living" << endl;
}
};
int main() {
Person* p = NULL;
p = new Farmer("carrots");
p->showCapability();
p = new Programmer("C++");
p->showCapability(); // I write C++ earning living
return 0;
}

多态类型与非多态类型

c++ 的类类型分为两类一一多态类型和非多态类型。多态类型是指有虚函数的类类型,非多态类型是指所有的其他类型.

基类的指针可以指向派生类的对象。如果该基类是多态类型,那么通过该指针调用基类的虚函数时,实际执行的操作是由派生类决定的。从这个意义上讲,派生类只是继承了基类的接口,但不必继承基类中虚函数的实现,对基类虚函数的调用可以反映派生类的特殊性。

设计多态类型的一个重要原则是,把多态类型的析构函数设定为虚函数。

对非多态类的公有继承,应当慎重,而且一般没有太大必要。

dynamic_cast

dynamic_cast 是与 static_cast. const_cast , reinterpret_cast 并列的 种类型转换操作符之一。它可以将基类的指针显式转换为派生类的指针,或将基类的引用显式转换为派生类的引用。与 static_cast 不同的是,它执行的不是无条件的转换,它在转换前会检查指针(或引用)所指向对象的实际类型是否与转换的目的类型兼容,如果兼容转换才会发生,才能得到派生类的指针(或引用). 否则:

  • 如果执行的是指针类型的转换,会得到空指针。
  • 如果执行的是引用类型的转换,会抛出异常
    另外,转换前类型必须是指向多态类型的指针,或多态类型的引用,而不能是指向非多态类型的指针或非多态类型的引用,这是因为 c++ 只为多态类型在运行时保存用于运行时类型识别的信息。这从另一个方面说明了非多态类型为什么不宜被公有继承。

当原始类型为多态类型的指针时,目的类型除了是派生类指针外,还可以是void 指针,例如 dynamic_cast< void *>(p)。这时所执行的实际操作是,先将 指针转换为它所指向的对象的实际类型的指针,再将其转换为 void 指针。换句话说,就是得到所指向对象的首地址(请注意,在多继承存在的情况下,基类指针存储的地址未必是对象的首地址)。

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
#include <iostream>
using namespace std;

class Base {
public:
virtual void say() {
cout << "Base" << endl;
}
};

class Derived : public Base {
public:
void say() {
cout << "Derived" << endl;
}
};

int main() {
Derived* pd1 = dynamic_cast<Derived*>(new Base());
if (pd1 != NULL) {
pd1->say();
}
// Base指针直接转换成Derived指针 , 失败

Base* b = new Derived();
Derived* pd2 = dynamic_cast<Derived*>(b);
if (pd2 != NULL) {
pd2->say();
}
// Base指针用Derived初始化, 再转成derived成功
// 只有实际类型与目的类型兼容, 转换才能发生, 比如转换成实际类型或子类型
return 0;
}

typeid

typeid C++ 的一个关键字,用它可以获得一个类型的相关信息。

通过 typeid 得到的是一个 type_info 类型的常引用。 type_info c++ 标准库中的一个类,专用于在运行时表示类型信息,它定义在 typeinfo 头文件中。

如果 typeid 所作用于的表达式具有多态类型,那么这个表达式会被求值,用 typeid得到的是用于描述表达式求值结果的运行时类型(动态类型)的 type_info 对象的常引用。而如果表达式具有非多态类型,那么用 typeid 得到的是表达式的静态类型,由于这个静态类型在编译时就能确定,这时表达式不会被求值。因此,虽然 typeid 可以作用于任何类型的表达式,但只有它作用于多态类型的表达式时,进行的才是运行时类型识别,否则只是简单的静态类型信息的获取。

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
#include <iostream>
using namespace std;

class Base {
public:
virtual ~Base(){}
};

class Derived : public Base {};

void fun(Base* b) {
const type_info& info_1 = typeid(b);
const type_info& info_2 = typeid(*b);
cout << info_1.name() << endl;
cout << info_2.name() << endl;
if (info_2 == typeid(Base)) {
cout << "Base Class" << endl;
} else {
cout << "Non Base Class" << endl;
}
}

int main() {
Base b;
fun(&b);
Derived d;
fun(&d);
return 0;
}

// 指针b不是动态类型, 故得到结果相同.
// *b对象是动态类型, typeid(*b)得到具体类型

虚函数动态绑定的实现原理

每个类各有一个虚表,虚表的内容是由编译器安排的。派生类的虚表中,基类声明的虚函数对应的指针放在前面,派生类新增的虚函数的对应指针放在后面,这样一个虚函数的指针在基类虚表和派生类虚表中具有相同的位置。每个多态类型的对象中都有一个指向当前类型的虚表的指针,该指针在构造函数中被赋值。当通过基类的指针或引用调用一个虚函数时,就可以通过虚表指针找到该对象的虚表,进而找到存放该虚函数的指针的虚表条目。将该条目中存放的指针读出后,就可获得应当被调用的函数的人口地址,然后调用该虚函数,虚函数的动态绑定就是这样完成的。

执行一个类的构造函数时,首先被执行的是基类的构造函数,因此构造一个派生类的对象时,该对象的虚表指针首先会被指向基类的虚表。只有当基类构造函数执行完后,虚表指针才会被指向派生类的虚表,这就是基类构造函数调用虚函数时不会调用派生类的虚函数的原因。

在多继承时,情况会更加复杂,因为每个基类都有各自的虚函数,每个基类也会有各自的虚表,这样继承了多个基类的派生类需要多个虚表(或一个虚表分为多段,每个基类的虚表指针指向其中一段的首地址)。

事实上,一个类的虚表中存放的不只是虚函数的指针,用于支持运行时类型识别的对象的运行时类型信息也需要通过虚表来访问。只有多态类型有虚表,因此只有多态类型支持运行时类型识别。

参考

  1. 《C++语言程序设计(第4版)》 IBSN 9787302227984
  2. C++参考手册
  3. Microsoft C++文档
  • Author:

    slacr_

  • Copyright:

  • Published:

    April 29, 2023

  • Updated:

    April 29, 2023

Buy me a cup of coffee ☕.

1000000