面向对象设计(C++)

0. 程序设计哲学

尽量把我们的代码建筑在已有代码的基础上(避免Code Duplication),如果你的程序有很多一模一样的代码,显然出错了修改起来会很麻烦。

栈——>堆栈,堆——>堆

1. 面向对象的基本概念和原理

1.1 什么是对象

数据(Data):表示对象的属性或状态。例如:杯子是一个对象,杯子的高度、重量、口径、颜色等都是杯子这个对象的数据。数据也可分动态和静态的,比如装了多少水是可以改变的,但是杯子的材质、颜色一般是固定的。

操作(Operation):表示对象对外能够提供的服务。例如:杯子能够盛水。

  • 什么是面向对象
    • 一种组织方式
      • 设计:解决问题的思路
      • 实现:用代码实现
    • 对象,而不是控制或数据流,是设计和实现的主要焦点。
    • 专注于事情,而不是操作。

1.2 面向对象编程中的基本理念

理念1:对象发送和接收消息(objects do things! )

对象发送消息,消息是:① 由发件人撰写;② 由接收方翻译;③ 通过方法实现。
消息:① 可能导致接收器状态改变;② 可能返回结果。

理念2:对象(Object) VS 类(Class)

对象(例如:猫):代表事物、事件或概念,是一个实体,在运行时响应消息。
类(例如:猫这个种类):定义实例的属性,是一个概念,像C++中的类型一样行事。

理念3:OOP特性

  1. 一切都是Object;
  2. 程序是一堆对象,通过发送消息来告诉彼此该做什么;
  3. 每个对象都有自己的内存,这个对象(可以)再由其他对象组成
  4. 每个对象都有一个类型(即必须先定义类Class,然后对象由类实例化出来);
  5. 特定类型的所有对象都可以接收相同的消息。

对象具有接口,接口是它接收消息的方式,它在对象所属的类中定义。接口的功能:Communication(交流)和Protection(保护)。

理念4:隐藏实现(The Hidden lmplementation )

对象的内部、表示其状态的数据成员以及消息被rcvd时所采取的操作都是隐藏的。

  • 类创建者与客户端程序员
    • 让客户程序员的手远离他们不应该接触的部分。
    • 允许类创建者更改类的内部工作,而不必担心它会如何影响客户端程序员。

理念5:封装

将数据和处理这些数据的方法捆绑在一个对象中,隐藏数据和操作的详细信息,仅限制对公开方法的访问。

1.3 自动售票机的例子

售票机:当顾客为他们的票价投入正确的钱时,售票机会打印一张票。

我们的售票机的工作原理是:客户将钱插入其中,然后要求打印门票。一台机器在运行过程中不断计算它所收集的资金总额。

简要分析,如下图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TicketMachine
{
public:
void showPrompt();
void getMoney();
void printTicket();
void showBalance();
void printError();

private:
const int PRICE;
int balance; // 外汇balance指的就是账户资金的变化
int total;
}

::的解释:。。。

1.4 头文件

1.4.1 类的定义

在C++中,使用分开的.h和.cpp文件来定义一个类。
该类中的类声明和原型位于头文件(.h)中。
这些函数的所有主体都在源文件(.cpp)中。

1.4.2 头文件

如果在头文件中声明了一个函数,则必须在使用该函数和定义该函数的所有位置都包含该头文件。
如果在头文件中声明了一个类,则必须在使用该类和定义类成员函数的所有位置都包含该头文件

  • Declarations VS Definitions(声明 VS 定义)
    • .cpp文件是一个编译单元
    • .h中只允许包含声明
      • extern变量
      • 功能原型
      • 类/结构体声明

标准头文件结构

1
2
3
4
#ifndef HEADER_FLAG
#define HEADER_FLAG
// Type declaration here...
#endif // HEADER_FLAG
  • .h文件编写提示
      1. 每个头文件一个类声明
      1. 与文件名前缀相同的一个源文件相关联。
      1. 头文件的内容以#ifndef#define#endif包围。

1.5 时钟的例子

1.5.1 问题描述

通过编写程序,来仿真一个时钟。

如果甲方只跟你说:我现在要一个钟,要求这个钟会一分钟一分钟地运行。

面对这么简单地要求,我们很容易想到写个函数,用个循环结构实现即可。但是我们现在在学c++,所以要去想办法从这里面用面向对象的角度去看这件事:即我们不去关注它的过程是怎么样的,我们关注的是有什么东西(对象)。

从一个时钟中你看到什么东西,你能不能看到它里面有什么样的东西,你能不能看到这个东西是什么样的东西组成的。当然整个时钟肯定是一个对象,那么这个时钟里面的再分能分成什么样的对象

  • 第一种分法:分成小时对象+分钟对象
  • 第二种分法:将时钟的数字分成一个个数码管对象

上面提到的这两种分法就是你在做一个对象的定义的设计的时候,你以什么样的角度去看这个问题。

再比如:如果我现在要划分一下一个人的身体构造的时候,你是把这个人的身体划分成一个个不同功能的器官,还是直接划分成一个个细胞。这是两种不同的看待问题/编写程序的思想

1.5.2 抽象的概念

下面就要介绍一个术语叫做Abstract(抽象)

抽象是一种忽略零件细节的能力,将注意力集中在更高层次的问题上。
模块化是指将一个整体分解为定义明确的部分,这些部分可以分别构建和检查,并且以定义明确的方式相互交互。

image.png

Implementation - ClockDisplay

1
2
3
4
5
6
7
8
9
10
11
class ClockDisplay {
NumberDisplay hours;
NumberDisplay minutes;
Constructor and methods omitted.
}

class NumberDisplay {
int limit;
int value;
Constructor and methods omitted.
}

2. 面向对象的组成

2.1 局部变量(Local Variables)和成员变量(Fields)

局部变量在方法内部定义,其作用域仅限于它们所属的方法。

注意:与成员变量(Fields)同名的局部变量将阻止从方法内访问该成员变量。

1
2
3
4
5
6
7
int TicketMachine::refundBalance()
{
int amountToRefund; // 局部变量
amountToRefund = balance;
balance = 0; // 这是自动售票机中的成员变量
return amountToRefund;
}
  • 成员变量/字段(Fields)、参数(Parameters)、局部变量(Local Variables),这三种变量都能够存储适合其定义类型的值。
1
2
3
4
5
6
7
参数(parameters)和本地变量(local variables)是完全相同的东西:

(1) 它们的存储属性都是本地存储(进入函数之前它们都不存在,进入函数之后他们才会存在);两种变量都会放在名为“堆栈(stack)”的地方,但是在堆栈中的具体位置还是有所不同的。

(2) 形式参数和本地变量仅在构造函数或方法执行期间持续存在。它们的生存期仅相当于一次调用,因此在调用之间会丢失它们的值。因此,它们担当临时存储而不是永久存储。

(3) 形式参数在构造函数或方法的头部定义。它们从外部接收其值,由来自构造函数或方法调用部分的实际参数值初始化。

2.2 组成类的两种要素:成员变量和成员函数

  • 字段(Fields)在构造函数和方法之外定义的,是类的成员变量

    • 全局变量的声明(添加 extern 修饰),只是在告诉编译器“我知道有一个全局变量,但是我不知道它在哪”;而字段就是这样的,它是一种成员变量,成员变量是写在类的声明里面的。
  • 字段(Fields)用于存储在对象生命周期内持续存在的数据。因此,它们保持对象的当前状态。它们的寿命与物体的寿命一样长。
  • 字段(Fields)具有类作用域:它们的可访问性扩展了整个类,因此它们可以在定义它们的类的任何构造函数或方法中使用。
  • 字段(Fields)只在实例化的时候才“真正存在”,在类Class中的字段(Fields)可以理解为只是一种“声明”(某个地方有这个成员变量),但是类并拥有成员变量,类的实例化对象才拥有成员变量。
  • 方法/函数是属于类的,不是属于实例化对象的。

C++中类定义的形式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class 类名
{
访问范围说明符:
成员变量1;
成员变量2;
...
成员函数1声明;
成员函数1定义;
...
访问范围说明符:
更多成员变量;
更多成员函数声明或定义;
...
};

成员函数1类型 类名::成员函数1(参数列表)
{

}

成员变量是在类中声明的变量;同样地,成员函数是指在类中声明的函数。如上类的形式所示,成员函数可以在类中定义,也可以在类外定义。在类外定义的函数需要用类名和作用域运算符类名::)限定函数所属的类。

而成员变量的定义在实例化对象,给变量分配内存的时候才会发生。
此外关于类的声明和定义可参考如下文章。简而言之,定义类只是在定义一个自己的数据类型;这与我们平时理解的声明和定义基本类型不同:类的声明和定义都不会分配内存,只有在实例化对象的时候才会分配内存。

2.3 C/C++中.h与.cpp文件

2.3.1 头文件(.h文件)

一般来说,头文件仅仅用于声明,相应的定义要放在对应的cpp文件中。声明的内容一般可以是:

  1. 类定义体;(这里可参考”头文件为什么只声明不定义,而类定义又可以放在头文件中“ 以及”关于C++的变量和类的声明和定义“)
  2. 类中的成员函数;
  3. 类外的函数(free函数);
  4. 类外的变量;

  5. 类型;

一个文件(比如main.cpp)包含(#include)了一个头文件(比如item.h),就相当于声明了Item.h中声明的所有内容。

但是const常量、inline函数、static函数都可以在头文件中定义(如果是初次学习C++,这点目前仅作了解,之后会慢慢学到)。

2.3.2 源文件(.cpp文件)

.cpp文件用于定义,定义的内容一般可以是:

  1. 类的成员函数;
  2. 类的静态成员变量;
  3. 类外的函数(free函数);
  4. 类外的变量;
2.3.3 类内成员组成

类:类一般只在头文件中定义,在cpp中实现其成员函数的定义;类中的成员包括:普通成员函数、static成员函数、普通成员变量、static成员变量、const成员变量、static const成员变量等。

  • 普通成员函数 —— 在类内部声明;可以在“类内部/头文件中的类外部”定义(均看作inline);也可以放在cpp中定义(非inline)。(这点讲到《内联函数(Inline functions)》一章会展开)。
  • static成员函数 —— 类内部声明;可以在“类内部/cpp中”定义,不能在“头文件中的类外部”定义。在类外部定义的时候要去掉static关键字,因为类里面的static表示该成员属于类,而文件中的static表示文件作用域,这是完全两回事。(这点在下半部分学习笔记的《静态成员(static member)》小节会展开)。
  • 普通成员变量 —— 类内部声明和定义;只能在构造函数的初始化列表中初始化,用户可以不进行初始化(编译器将默认构造)。(这点讲到《构造和析构(Constructor & Destructor)》一章会展开讲)。
  • static成员变量 —— 类内部声明;只能在cpp中的各方法(函数)外部定义(且不能加static关键词,原因同static成员函数),定义时可以不进行初始化,这时默认为0(也可以不定义,但若使用到了该成员变量,则必须定义,否则连接出错) 。(这点在下半部分学习笔记的《静态成员(static member)》小节会展开)。
  • const成员变量 —— 类内部声明;只能在构造函数的初始化列表中初始化,而且用户必须自行初始化。(这点讲到《Const》一章会展开)。
  • static const成员变量 —— 基本同static;特别之处在于,static const成员变量是唯一可以在定义的时候(即类内部)直接初始化的类成员变量;注:static和static const不能在构造函数初始化列表中初始化,因为static关键字表明,它属于类,而不是属于对象;

2.4 this关键字

类是抽象的、是虚的,更像是一种概念,不是实体,它不拥有它声明的任何一个变量,只有类的对象才是实体,才拥有那些变量。类和对象间变量的关系有点类似于C语言中结构体与结构体变量之间变量的关系。但是定义在类中的函数是属于类的,而不属于类的任何一个对象。总结为一句话就是:类拥有函数而不拥有变量;对象拥有变量而不拥有函数。

由上述关系可知:假设一个类Class的成员函数f()要对字段进行操作,C++是如何知道Class的不同对象调用f()时是谁在调用f( ),以便对各自的字段进行操作的呢?

在C语言中,为了实现上述操作通常需要传递指针给函数f( ),在C++中,这个指针通过this关键字实现。

this:隐藏参数,这是类的所有成员函数的隐藏参数,不需要手动定义,具有类的类型。

在成员函数里面,当它去用到成员变量的时候,实际上所有的成员变量前面都可以看作是有this->成员变量

this指向调用者的指针,在成员函数内部,您可以使用this作为指向调用函数的变量的指针。这是所有不能定义但可以直接使用的类成员函数的自然局部变量。

2.5 构造和析构(Constructor & Destructor)

2.5.1 构造函数

考虑到效率,C++没有规范地约束在生成对象时必须对其初始化(其他一些OOP语言比如Java存在此约束)。如果程序员自己写 init() 函数则需要依赖于他的自觉性(即有没有在生成对象后立刻调用init(),否则就会出问题),所以我们需要一种机制来确保生成对象后一定会被初始化,这便是constructor构造函数的由来。

  • 构造函数名字必须与类名相同(包括大小写);
  • 构造函数没有返回类型(void也不算);
  • 构造函数会在类的对象被创建时自动被调用;(所以一般构造函数用于初始化操作)
  • 构造函数可以有参数,对应在创建对象时也需要传个参数;
1
2
Tree(int i) {...}    //构造函数
Tree t(12); //创建对象

只要不带参数的构造函数都称为“default constructor”;而编译器给你的的构造函数称为“auto default constructor”,当你定义了带参构造函数却没有在生成对象时正确调用,编译器会去寻找default constructor调用。

2.5.2 析构函数
  • 析构函数即在构造函数名字前加一个波浪号(~)
  • 析构函数会在对象要被“消灭”掉的时候自动被调用;(比如在一个大括号内创建栈对象的话,离开大括号范围的时候对象就会被“消灭”掉)
  • 根据析构函数的特性,我们一般会用析构函数来释放掉对象生存期间申请的资源,保证这些资源不会随着对象被“消灭”掉之后一并被带到“棺材”里面去;
  • 析构函数也没有返回类型,而且不能有参数;
1
2
3
4
class Y {
public:
~Y(); // 析构函数
};
2.5.3 初始化列表(Initializer list)

除了使用构造函数来做初始化,C++还提供了另一种初始化方法:初始化列表

  • 在构造函数的圆括号后面加上冒号,冒号后面跟上成员变量的名字,最后用括号给出初始值;
  • 初始化列表可以初始化任何类型的数据;
  • 初始化列表会早于构造函数被执行;
  • 严格来说,初始化列表做的工作才是初始化;而构造函数做的工作可以称作为“赋值”,构造函数会做两件事:1. 初始化(这个时候你没有明确告诉编译器用什么内容来初始化);2. 赋值;
1
2
3
4
5
6
7
class Point {
private:
const float x, y;
Point(float xa = 0.0, float ya = 0.0) : y(ya), x(xa){
...
}
};
2.5.4 存储分配

编译器在作用域的左括号处为该作用域分配所有存储空间,但是构造函数调用直到运行到定义对象的那一行才发生。

2.6 new & delete(动态地制造对象)

在C语言中,我们通过mallocfree动态地申请和释放内存;在C++,则是用两个新的运算符关键字newdelete来制造和收回一个对象的。

如果new一个变量,则只需做一件事情:分配一个变量的空间;但是如果new一个对象,则new会做两件事情:① 分配一个对象的空间② 调用构造函数;当然最后作为一个运算符会返回分配给对象的地址。而delete做的事情与free类似,你给它一个地址,然后它delete掉。对于delete一个对象:① 调用析构函数② 收回内存空间

1
2
3
4
5
6
7
8
9
10
11
12
// new
new int;
new Stash;
new int[10];

// delete
delete p;
delete[] p;

int *psome = new int[10]; // new运算符返回块的第一个元素的地址。

delete [] psome; // 括号的出现告诉程序应该释放整个数组,而不仅仅是元素
  • new出来的东西会放在里面;
  • 动态申请的内存完全由开发者自行负责管理,开发者对堆对象的生存周期具有完全的支配权(在何时申请内存,分配多少内存,并在何时释放该内存);
  • 由上一条可知:在堆中的对象不会自动被消灭,内存不会自动回收,new出来的对象在程序运行过程中会一直占用内存空间,直到开发者在代码中主动delete掉它或者程序进程整体退出;
  • 程序进程退出时的内存回收是系统级的,系统会回收分配给该进程的所有内存。但那个时候系统就并不关心你程序里面是如何使用它的了;也就是说系统仅仅是回收内存,不会再帮你调用析构函数了;
  • 我们知道new作为一个运算符会返回分配给对象的地址。如果我们把new返回的地址交给局部指针变量,根据第一章成员变量的秘密我们知道局部变量担任临时存储,那么局部指针变量一但离开局部空间后被销毁,我们就再也无法访问到new申请的内存了。下面是一个代码例:
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
#include <iostream>

using namespace std;

class A
{
public:
A()
{
cout << "A::A()" << endl;
}
~A()
{
cout << "A::~A()" << endl;
}
void print(string str)
{
cout << str << endl;
}
};

int main()
{
{
A* p = new A(); //把new返回的地址交给局部指针变量
}
p->print("Hello World"); //此时指针变量p已经被销毁,但new申请的内存还没有回收
delete p;
return 0;
}

会给出如下错误:

  • newdelete的使用小技巧
    • 不要用delete去释放不是new分配出来的空间;
    • 不要连续两次用delete释放同一块空间;
    • 如果用new []分配了一块数组,请用delete [];同样地如果用new分配了单个实体,请用delete
    • 如果new []了之后用delete释放的话,仅会调用delete指针指向的对象的析构函数,虽然同样回收所有空间,但是会报错。(例如下方贴的代码)
    • delete一个空指针是安全的;
    • new出来的对象在使用完毕后不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
#include <iostream>

using namespace std;

class A
{
private:
int i;
public:
A() { i = 0; cout << "A::A()" << endl; } //构造函数
~A() { cout << "A::~A(),i=" << i << endl; } //析构函数
void set(int i) { this->i = i; }
//根据就近原则,成员变量i会被参数i屏蔽,需要加this指针表示“调用这个函数的对象的i”
};


int main()
{
A* p = new A[10];
for( int i=0; i<10; i++)
{
p[i].set(i);
}
delete p;
//尝试用delete回收new []分配的空间,若要正确运行请改为“delete[] p;”

return 0;
}

2.7 访问限制(Setting limits)

在OOP理论阶段学习过:对象应该是被封装起来的受保护的,对象里面的数据是不被别人直接访问的。别人能访问的只有你的函数,可以通过你的函数要求你做事情;但是这个函数具体怎么做?会对你的数据产生什么样的影响?是由你的代码决定的。

所以我们需要一种机制,使得使用你的类的人不会随心所欲地访问内部的东西;同时设计类的人可以去修改内部的东西而不至于影响到使用者。对C++来说,所有的成员可以有三种访问属性:public、private以及protected。

  • public没什么好说的,任何人都能访问;
  • private只有自己(这个类中的成员函数)能访问,private可以修饰变量与函数;
    • 注意private私有性是对类来说的,而不是对象同一个类的不同对象之间是可以互相访问私有的成员变量的。(如下面的代码示例)
    • 此外,C++对private权限的限制仅仅存在于编译时刻,到了运行时刻就没人管这件事了,原因是C++的OOP特性只在源代码层面体现,编译完后生成的.o文件同C语言、汇编语言、Pascal生成的.o文件是一模一样的。所以只要有办法过了编译那一关,剩下的事情就可以为所欲为了。
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
#include <iostream>

using namespace std;

class A {
private:
int i;
public:
A() { i = 0; cout << "A::A()" << endl; } //构造函数
~A() { cout << "A::~A(),i=" << i << endl; } //析构函数
void set(int i) { this->i = i; cout << "this->i=" << this->i << endl; }
void g(A* q) { cout << "A::g(),q->i=" << q->i << endl; }
};


int main()
{
A a;
a.set(50);
A b;
b.set(100);
a.g(&b); //尝试用a中的成员函数g(A* q)访问b中的私有成员变量i;

return 0;
}

C++还有个破坏OOP原则的东西叫做friends:你可以声明别人(可以是别的类,可以是别的不属于任何类的free函数,也可以是别的类里的某个函数)是你的friend(朋友),一旦声明过后,他就可以访问你的private的东西了;

但是不能是你声明你是别人的朋友,然后去访问别人的私有的东西。就像啊我声明:“我是小明的朋友,所以我可以用他的钱”,这是不行的,不是这么玩的。是由类自己决定谁可以访问自己的成员的。

同样的friend的授权是在编译时刻检查的。

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
///
///演示代码,无法运行
///
struct X; // 前向声明

struct Y
{
void f(X*);
};

struct X
{
private:
int i;
public:
void initialize();
friend void g(X*, int); // free函数是friend
friend void Y::f(X*); // 结构体成员函数是friend
friend struct Z; // 整个结构体是friend
friend void h();
};

void X::initialize() {
i = 0;
}

void g(X* x, int i) { // free函数是friend
x->i = i;
}

void Y::f(X* x) { // 结构体成员函数是friend
x->i = 47;
}

struct Z {
private:
int j;
};
  • protected表示只有这个类自己以及它的“子子孙孙”(子类以及再往下的这些)可以访问;
  • 在设计中,我们一般按下面的方式规划所有东西的访问限制:
    • 所有的数据(一般指成员变量)都是private的,外界及子类都不能直接访问;
    • 提供给所有人(包括外界和子类)使用的东西是public的;
    • 留给子类protected的接口以访问父类中private的数据;

3. 对象组合(Object composition)

OOP三大特性即:封装、继承、多态性。但是从另外一个角度来说“继承是OOP对软件重用的回答”或者说“继承是OOP实现软件重用的一种方式”。这一章要讲的就是“重用的实现”(Reusing the implementation),但这里先不讲继承。在C++里面,我们还可以以另外一种方式实现软件重用,即“组合”(composition):用现有的对象构造新对象(把已有的对象组合成新的对象)。

组合的关系是一种has a的关系;

比如说谈到一辆车,我们会说这辆车has a引擎,has a方向盘,has a空调,has a 轮胎……如果我们已经有了引擎、方向盘、空调、轮胎……的对象,我们把它们放在一起,再加一些其他的细节,以这种方式来实现软件的重用,于是我们组合出了一辆车对象。

“组合”其实在谈OOP的五条原则五条原则时提到过,即“对象里面还是对象”;反映到C++的代码上即我们在设计一个类的时候它的成员变量可以是另外一个类的对象。在实际设计中,C++提供了两种不同的内存模型:fully & by reference

fully表示“那个别的类的对象就是我这个类里的一部分(成员变量是对象本身)” ;

by reference表示“那个别的类的对象我知道在哪里,我可以访问到它(成员变量是指针),我可以调用它的方法,但它并不是我这个类里的一部分” ;

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
class A{
private:
...
public:
...
};

class B{
private:
A a; //fully
A* aa; //by reference

B* bb;
/*by reference允许成员变量的类型是其本身,而fully无法做到这一点(会陷入无限循环)
因为指针对编译器来说就仅仅是一个指针而已,无论指针所指的类型是什么,
它永远都只是那4个字节(32位系统),编译器不需要知道指针的细节,
指针的细节只有到用的时候才需要,所以不会陷入死循环。
在C语言中的“链表”就是这么干的*/

public:
...
};

/*实际设计中,采用fully还是by reference是根据语义来的,
你认为合适把那个对象直接放在你的类里面,你就用fully,不合适就用by reference
比如你设计一个“同学”对象:他的“大脑”对象显然应该放在“身体”(类)里面(fully),
而他的“书包”对象就不太适合放在“身体”里面(by reference)。

或者以设计一个“收音机”对象为例,作为收音机你肯定是可以听电台嘛,但是在设计“收音机”类
的时候你不应该直接把一个“电台”对象(包括什么录音室、主持人、热线电话、接线员等等等等)
直接放(fully)进你的类里面吧,往往都是通过固定频段(比如FM101.7)去访问电台对象吧,
这个固定频段就可以理解为指针嘛(by reference)*/
  • 在组合中我们并不希望破坏对象的边界,于是更好的做法便是各个对象的初始化由初始化列表调用各自的构造函数完成,初始化列表也就是用来干这个的。以下面这段代码为例:
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
/*
*银行储蓄账户对象实例,类中包含人和货币对象(演示代码不能运行)
*/

class Person { ... };
class Currency { ... };
class SavingAccount {
public:
SavingAccount( const char* name, const char* address,
int cents ) : m_saver(name, address), m_balance(0, cents) { ... } //构造函数
//我们在初始化列表中调用了m_saver和m_balance的构造函数,然后把相应的参数传给他们

~SavingAccount() { ... } //析构函数

void prints(){
m_saver.print();
m_balance.print();
}
private:
Person m_saver; //fully
Currency m_balance; //fully
};

/*
*附加思考题:
*这里组合进来的两个对象m_saver和m_balance的属性是private
*假如我们把他俩放到public里面去会怎样呢?
*那我们就有可能做这样的事情:
* SavingAccount account
* account.m_saver.set_name("Fred");(假设Person类有set_name())
*虽然m_saver和m_balance是对象,但是他俩也是SavingAccount类的成员变量;
*在OOP理论阶段学习过成员变量作为数据应该是包裹起来不被外界直接访问的。
*所以这显然不是OOP喜欢的,因为他突破了边界,外界可以直接访问里面的数据了
*/

如果我们将构造函数编写为(假设我们已经为子对象设置了访问器):

1
2
3
4
5
6
SavingAccount( const char* name, const char* address, int cents )
{
m_saver.set_name(name);
m_saver.set_address(address);
m_balance.set cents(cents);
}

将调用默认构造函数,效率低。

再举一个可运行的简单代码例子 :

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

using namespace std;

class A {
private:
int i;
public:
A(int i) { this->i = i; cout << "A::A(),A::i=" << this->i << endl; }
};

class B {
private:
A a;
int j;
public:
B(int j) : a(j+1) { this->j = j; cout << "B::B(),B::j=" << this->j << endl; }
//在B的初始化列表中调用了a的构造函数,并将j+1作为参数传给了他
};

int main() {
B b(10);

return 0;
}

4. 继承(Inheritance)

4.1 介绍

不同于对象组合,继承是拿已有的类克隆一份,然后对复制品在已有的基础上增添一些细节或者做一些改造,得到一个新的类。 在《This关键字的出现》那我们提到过“类是虚的,对象才是实的”,所以我们可以理解为C++里继承是玩虚的,组合是玩实的。

  • 继承是C++语言一门重要的技术,也是面向对象设计方法的重要组成部分;
  • 继承使得我们可以共享设计中的:成员数据、成员函数、接口(Interface,一个类中对外公开的部分称之为“接口”)
  • 继承是将一个类的行为或实现定义为另一个类的超集(superset)的能力;
  • 对于继承来说,类之间的关系是一种“is a”的关系;以下图为例,我们可以说:A student is a person. Student is a superset of Person.

  • C++继承语法为:类名后面冒号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
#include <iostream>

using namespace std;

class A {
private:
int i;

public:
A():i(0) { cout << "A::A()" << endl; } //构造函数
~A() { cout << "A::~A()" << endl; } //析构函数
void print() {cout << "A::print(),i=" << i << endl; }

protected: //protected访问属性详情参考《访问限制(Setting limits)》一章
void set(int ii) { i = ii; }
};

class B : public A{ //C++继承语法,表示B类是A类的子类
public:
void f() {
set(20); print(); //子类中新增的函数可以调用父类中public的函数
//i = 30; //子类不能直接访问父类中private的成员变量
}
//需要注意的是,子类虽然拥有父类private的东西,但是不能直接访问他们
};

int main() {
B b; //子类拥有父类的包括public和private的所有东西
//b.set(10); //protected访问限制main里面(外界)不能调用set函数
b.print();
b.f(); //子类还可以在父类基础上拓展新东西

return 0;
}
  • 父类、子类、用户类(外界)三者的关系可由下图表示:

4.2 父类子类的关系

在《对象组合(Object composition)》一章中我们提到过:各个对象的初始化由初始化列表调用各自的构造函数完成。当时给出的理由是为了避免破坏对象的边界,但是却没有尝试如果不这样做会怎么样。

由《继承(Inheritance)》一章中我们得知子类拥有父类的所有东西,这其实可以看作是父类整个“fully”进子类了。所以,在创建子类的对象时,父类的构造函数会自动被调用。《继承(Inheritance)》一章中的演示代码可以看到类A有一个default constructor,为了演示初始化列表的重要性,这里我们对演示代码做一些小改动,如下所示:

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 A {
private:
int i;

public:
A(int ii):i(ii) { cout << "A::A()" << endl; } //A的构造函数改为带参构造函数
~A() { cout << "A::~A()" << endl; }
void print() {cout << "A::f(),i=" << i << endl; }
void set(int ii) { i = ii; }
};

class B : public A{ //C++继承语法,表示B类是A类的子类
public:
void f() { set(20); print(); }
};
//B类没有自己的构造函数

int main() {
B b; //生成子类对象

return 0;
}

该代码会产生错误,具体请复制到编译器查看。

同样B类中都没有构造函数,为什么仅仅把A类的构造函数改成带参构造函数就无法运行呢?而且这个错误信息看起来毫无头绪。

这就要提到C++里继承的本质了。我们在创建B的对象时,首先会分配一块空间给B,随后进行初始化。而B的对象里面有A的所有东西,所以要初始化B的对象,那么B里面的A类的对象的那部分也要被初始化。而显然,A的带参构造函数没有被正确调用,那么编译器会去寻找默认构造函数去调用。这一点在《构造和析构(Constructor & Destructor)》一章中提到过。

所以,无论是组合还是继承这一点都是一样的:当你的身体里有其他类的对象的时候,你不懂怎么去初始化他,必须把初始化的工作交给他们自己去做。这样做对象的边界仍然是清晰的,也可以避免一些莫名其妙的错误。

所以,上面的代码你必须得想办法去调用A的构造函数传参给它,而我们知道构造函数都是创建对象时自动调用的,而我们没法主动调用它。怎么做呢?答案就是:初始化列表。所以,我们只需要给B加个构造函数,比如下面这样:

1
2
3
4
5
class B : public A{		
public:
B () : A (15) {} // 初始化列表
void f() { set(20); print(); }
};

最后提一下:当父类和子类都有自己的构造函数析构函数时,创建子类对象会先构造父类,再构造子类;退出时先析构子类,再析构父类(先进后出) 。

4.3 名字隐藏(Name hiding)

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

using namespace std;

class A {
private:
int i;

public:
A(int ii) :i(ii) { cout << "A::A()" << endl; }
~A() { cout << "A::~A()" << endl; }

void print() { cout << "A::print()" << endl; }
void print(int i) { cout << i; print(); } //父类中有函数重载

void set(int ii) { i = ii; }
};

class B : public A {
public:
B() : A(15) { cout << "B::B()" << endl; }
~B() { cout << "B::~B()" << endl; }

void print() { cout << "B::print()" << endl; }
//同时子类中出现了与父类重复的函数(函数名相同,参数表相同)

void f() { set(20); print(); }
};

int main() {
B b;
b.set(10);
b.print();
b.f();
b.print(200); //ERROR
//子类拥有父类的所有东西,所以按理说B的对象b应该是有父类中print(int i)函数的

return 0;
}

该代码会产生错误,具体请复制到编译器查看。

这件事就称之为“名字隐藏” ,简单来说:假设父类中有一组重载函数,子类在继承父类时如果”覆盖”了这组重载函数中的任意一个,则其余没有被”覆盖”的同名函数在子类中是不可见的。只有C++是这么干的,其他OOP语言都不会出现这种情况。那么,C++为什么会这么干呢?

这其实还和另外一件事情只有C++这么干的有关系:以上面代码为例,对C++来说,子类中的print()函数跟父类中的print()函数其实是没有关系的;其他OOP语言在同样的情况下两个print()函数会构成一种关系:override(覆盖)。而C++认为他俩没关系的,只是碰巧重名了;而同时正因为他俩没关系,所以父类中的所有重载函数也必须得和子类没关系才行,要不然就乱套了。

如果你还是想要调用那个print(int i),那句错误代码就要改成下面这样:

1
b.A::print(200);

5 函数重载和缺省参数(Function overloading & Default arguments)

5.1 Function overloading

所谓函数重载是指一些函数可以具有相同的函数名,却拥有不同的参数表,他们之间便构成了重载的关系。请注意返回类型不是构成重载的条件(如果两个函数拥有相同的名称和参数列表,但是返回类型不同,是不构成重载关系的) 。

5.2 缺省参数

所谓“缺省参数”,你可以在函数声明中预先给函数的参数表里部分或全部参数一个值,如果没有在函数调用中提供值,编译器将自动插入预先给的值;

  • 声明一个有参数列表的函数时,缺省参数必须从右到左添加;
  • 缺省参数是写在头文件(.h)里的,并且不能在.cpp里面重复一遍;
1
2
void f(int m, int n, short i = 6, double j = 1.23);
//void f(int m, int n, short i = 6, double j) {} //ERROR:默认实参不在形参列表的结尾

有一点不要忘了:C/C++中#include的本质。编译器在编译之前有一个“预处理”过程,在预处理过程中,.h的内容会被展开到.cpp文件里面去。所以当你没有#include a.h的时候直接在main.cpp文件里把a.h的内容复制进来同样可以运行。看起来你好像把缺省参数写在.cpp文件里了,实际上你只是帮助编译器完成了“预处理”这一步的工作。

6 内联函数(Inline functions)

  • 函数调用的额外开销:在执行命令之前,设备所需的处理时间
    • push参数进栈
    • push返回地址
    • 准备返回值(x86汇编一般会用AX(accumulator)累加寄存器存放返回值)
    • pop all pushed(把push进的都要pop出来)

C++提供了一个手段以避免上面这些额外开销:内联函数。如果这个函数是内联的,当我们去调用该函数时,C++不会真的去调用函数,去做那些“Push、Prepare、Call、Pop、Return”等等动作;而是把那个函数的代码嵌入到调用它的地方去,并且同时还会保持函数的独立性(函数有自己的空间:比如函数有自己的局部/本地变量,进去的时候存在,出来就不存在了;或者调用函数时需要对参数进行检查等这些事情都还是保留的)。

其实,内联函数有点类似于宏定义函数。但是宏是不能做类型检查的;而inline作为函数是可以由编译器做类型检查的。

内联前:

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
int f(int i) {
return i * 2;
}

int main() {
int a = 4;
int b = f(a);

return 0;
}

/*对应汇编代码:
* _f_int:
* add ax,@sp[-8],@sp[-8]
* ret
* _main:
* add sp,#8
* mov ax,#4
* mov @sp[-8],ax
* mov ax,@sp[-8]
* push ax
* call _f_int
* mov @sp[-4],ax
* pop ax
*/

内联后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
inline int f(int i) {   //加上inline关键字
return i * 2;
}

int main() {
int a = 4;
int b = f(a); //实际生成的代码:int b = a+a;

return 0;
}

/*对应汇编代码:
* _main:
* add sp,#8
* mov ax,#4
* mov @sp[-8],ax
* add ax,@sp[-8],@sp[-8]
* mov @sp[-4],ax
*/
//可以看到最终生成的可执行代码里面是没有那个内联函数的,省去了很多工作
  • 一个内联函数在.obj文件(Linux平台下为.o文件)里可能不会生成任何代码;
  • 缺省参数不同的是,内联函数要求在声明和定义的时候都需要重复“inline”关键字,我们简化一下缺省参数里面那个代码例子;

————————————————

内联函数的使用需要对编译器进行一系列调整,否则会出现一些错误,具体请参考参考链接的介绍。

对于内联函数来说,.cpp文件是完全不需要的,在.h里面把所有内联函数的“body”放进去就可以了;

————————————————

  • 综合以上所有,以下几种情况建议内联:
    • 只有两到三行的小函数;
    • 频繁调用的函数(比如函数调用处在循环里,就会被频繁调用);
  • 以下几种情况不建议内联:
    • 非常大的函数(比如超过20行的函数);
    • 递归函数;

注意:你在类的声明(declaration)中定义(define)的任何函数都默认为内联函数;(就是在.h文件中本来只在类中声明成员方法,但是现在不仅声明了,还在.h文件中把成员函数的函数体也写出来了,那么这个成员方法就是内联函数)。

10 Const

10.1 Const基础

在C语言中,我们已经学习过一次const了,意思是const的变量被初始化之后不能被赋值,不过对于C++来说,const的变量仍然是变量,而不是常数,这是不一样的。因为对编译器来说,变量意味着它真的要在内存里面给你分配地址的,而常数意味着这只是编译器在编译过程中记在自己内存表里的一个实体。

而且,const的变量仍然遵循范围规则(scope rule),如果是本地变量,即便const了,还是进函数才有,出函数就没了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const int bufsize = 1024;

// 这是一个编译时刻知道值的const;
// 它的值必须初始化;
// 它可以用来定义数组长度(int buf[bufsize];)
// 除非你添加“extern”显式声明;


int x;
cin >> x;
const int bufsize = x;

// 这是一个编译时刻不知道值的const;
// 不同于上面,它不可以用来定义数组长度


extern const int bufsize;

// 编译器不会允许你修改其值;
// 这句代码意思是“bufsize是定义在某处的全局变量,同时这个全局变量是const”。对编译器来说:你说它是const,那么我就要求这个bufsize你可以用,但是你不能修改 。这和bufsize本身是不是真的是const没有关系;
// 它同样不可以用来定义数组长度;

此外还有用const修饰指针:详情请移步至:浅谈const int ,int const 与int *const

1
2
3
4
void f(const int* x)
/*表示可以给这个函数任何int变量(无论是不是const),
对调用f函数的人来说,这表示你传给f函数的虽然是指针,
但f函数保证不会对你的东西做任何修改*/
  • const可以修饰成员函数,表示该函数不会改变类的成员变量,也不会在该函数中调用类中其他非const的成员函数

    • 在声明和定义的时候都要重复const关键字;
    • 实质上是表明thisconst(这一点会在下一章详细讲);
  • const可以修饰函数返回值,不过我们知道像返回int这种基本类型数据实际上是在返回一个值,返回值的函数不能作左值

    • 除非你函数返回一个指针,但如果返回的指针是const那么它带星号 *(f()) 也不能作左值了。
      对一个函数传进传出整个对象时可能会造成很大的开销(传参需要在堆栈里分配空间,意味着在堆栈里要花很多时间空间做拷贝工作),往往更好的办法是传一个地址。但是传地址我们又会很不放心别人会不会通过指针修改我们的原始数据。
      这个时候,const修饰指针的作用就来了。我们在前面加上 const 表明我们以一个const的方式传一个对象进去,这样就可以保证我们的数据是安全的。
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 A {
private:
int i;
public:
void set_i(int ii) { i = ii; get_i(); cout << "i = " << i << endl; }
int get_i() const { return i; }
A() :i(0) { cout << "Now in A::A(),i=" << i << endl; }
~A() {i = 20; cout << "Now in A::~A(), i=" << i << endl; }
};

void set_i(const A* a) {
int test = 9;

a->get_i();
//get_i()是const修饰的成员函数,表示不会动成员变量,所以可以调用

//a->set_i(test);
//C2662 “void A::set_i(int)”: 不能将“this”指针从“const A”转换为“A& ”

}

int main() {
A* a = new A;

set_i(a);

delete a;

return 0;
}
  • const可以修饰整个对象,表明对象里的值是不能被修改的(常量对象),这其实就是和const int、const char等是一回事(别忘了面向对象的5条原则之“万事万物皆是对象”,一个int、一个char都是对象)。一旦将对象定义为const之后,该对象的任何非 const 成员函数都不能被调用,因为任何非 const 成员函数可能会修改对象的数据(编译器也会这样假设),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 A {
private:
int i; //私有的非const成员变量i
public:
int j; //公开的非const成员变量j
const int k = 6; //公开的const成员变量k
void set_i(int ii) { i = ii; cout << "i=" << i << endl; }
int get_j() const { return j; }
A() { i = 0; j = 1; cout << "Now in A::A(),i=" << i << endl; }
~A() { i = 20; j = 21; cout << "Now in A::~A(), i=" << i << endl; }
};

int main() {
const A* a = new A;
//const修饰A类的对象指针a,当然可以直接const对象“const A a;”(类似于const int a;)

//a->set_i(10); //无法调用非const成员函数
cout << "j=" << a->get_j() << endl; //可以调用const的成员函数
cout << "j=" << a->j << endl;
cout << "k=" << a->k << endl; //可以访问const or 非const的公开的成员变量

delete a; //new了记得delete

return 0;
}

const在函数前后的意义 - 51博客

10.2 补充:字符串字面值(String literals)

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

using namespace std;

int main() {
char *s = "Hello world";
cout << s << endl;

s[0] = 'B';
cout << s << endl;

return 0;
}

上面的代码在Visual Studio里无法运行,给出了错误。

而我们修改下代码,就可以成功运行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

using namespace std;

int main() {
char s[] = "Hello world";
cout << s << endl;

s[0] = 'B';
cout << s << endl;

return 0;
}

这是怎么一回事呢?

问题出在s是本地变量,存放在堆栈里面。

当s作为指针,指向了一块内存,这块内存放了个字符串;而”Hello world”是个常量且编译器会认为这是个const的东西。这其实是一个“ const char* s ”不过是编译器接受不加“ const ”的写法,所以它是放在“代码段”里面的。这个时候s只是存放了”Hello world”所在的代码段的地址,而代码段是不可写的。

而当s作为一个数组,整个数组都存放在堆栈里面。这个时候代码会变成它要对”Hello world”整个做一个拷贝,它会把代码段里的”Hello world”拷贝到堆栈里面来。后续的修改也是对拷贝过来的副本做的修改。

实际上我们可以证明这件事的:分别打印s1、s2、main函数的地址;结果显示:显然,s1和main函数处在一个段(代码段);s2处在堆栈段。

11 引用/别名

11.1 引用的基本概念

C++提供了非常多的内存模型:

  • 提供了许多存放对象的地方(堆栈、堆、全局数据区);
  • 提供了许多可以访问对象的方式(直接“fully”那个对象、通过指针访问对象、通过引用访问对象)

引用是C++中一种新的数据类型

1
2
3
4
5
6
char i;            //i is a character(i是一个字符)
char* p = &i; //p is a pointer to a character(指针)
char& r = i; //r is a reference to a character(引用)

/*一般引用都需要在定义的时候给个初始值,以表明r是i的引用;
并且初始值得是一个可以作左值的东西*/

引用还有另外一个名字:alias(别名)

由上面两个名字可以看出:引用其实就是当我们需要用i的时候,我们可以用r;用r就是在用i它们只是一个东西的两个名字

基本语法:type& refname = name

只有作为成员变量或者放在(函数)参数表里面才可以不用给初始值,其他都要给。

const char& r = i:表示无法通过r改变i

与c不同,引用的这种“绑定”关系是不可变的:

1
2
3
4
5
6
int main() {
int x = 1
int y = 2;
int& r = x; //r是x的引用,绑定关系是永久的
r = y; //这句代码就只是纯粹的赋值,而不是将r转变为y的引用
}

引用可以作左值(”做左值”是”引用”的必要条件),所以返回引用的函数也可以作左值(我们知道返回基本类型的函数不能作左值)

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;

int x;

int* f() {
return &x;
}

int& g() {
return x; //返回x的引用(没有提供引用名)
}

int& h(int& y) { //参数表中的引用可以不用给初始值,在调用时会用实参的值初始化引用
y++; //对引用的操作就是对被引用者的操作
cout << "in h() y = " << y << endl;
return y; //return引用就是return被引用者,而函数返回类型还是引用,所以还是返回被引用者的引用
}



int main() {
int& i = x;
int j = 0;
double k = 0.0;

*f() = 8;
cout << "x=" << x << endl;

g() = 16; //g函数返回x的引用,给引用赋值就是给x赋值
cout << "x=" << x << endl;

h(i); //因为"引用"的出现,给函数传参就不一定只是传值了
cout << "x=" << x << endl;
/*类似于h(i)这种看起来像传值调用但实际上是可以改变全局变量x的值的,
所以我们一定要去检查源代码*/

h(j) = 7; // 可以做左值
cout << "x=" << x << ", j = " << j << endl;
/*给h传递引用或变量进去都是可以的,可以做引用的引用这种事情
int& a = x;
int& b = a;
这一点指针无法做到:当参数要指针时就只能传地址不能传变量*/

//h(k); //ERROR:无法用"double"类型的值初始化"int &"类型的引用(非常量限定)

return 0;
}

11.2 引用 VS 指针

指针 引用
可以为空;
指针独立于现有的对象;
一个指针可以指向不同的地址;
不能为空,一定要有初始值;
依附于现有变量,是现有变量的一个“别名”;
引用的这种“绑定”关系是永久的;

C++里引用其实就是通过指针实现的(这一点无法验证,我们无法获取到引用的地址,实际上会得到被引用变量的地址,不过这并不妨碍我们理解引用的本质),引用本质上就是个const指针(int *const p,你可以理解为引用r实质上就是那个*p);设计“引用”这个东西出来是为了让代码少一点”*”号,使代码看上去简洁美观。(不过还是不要弄混了,引用在C++内部是通过指针实现,但这不代表引用变量的类型是指针;引用本身就是一种新的数据类型了)

Java则是以另一种方式解决这个问题:Java设计成只能通过指针去访问对象。正因为只有这一种访问对象的方式,所以Java可以把那个”*”号取消掉;然后对外宣称这不是指针,是“引用”。但实际上Java中的“引用”跟C++中的引用不是一回事,它更像C++中的指针。

1.3 一些引用限制(Restrictions):

  1. C++没有引用的引用(但编译器可能会帮你做到这点);
  2. 引用变量共享被引变量的内存,它是“别名”,故理论上不分配内存;
  3. 引用的变量或表达式一定是分配了内存的有址左值;
  4. 由此可知,一个引用变量不能引用自己;
  5. Visual Studio允许引用的引用,显然:”引用的引用”和”引用”和”被引用者”它们三个都是一个东西;
  6. 没有指向引用的指针;
1
int&* p;          //illegal(非法的)
  1. 但是可以有指针的引用;
1
2
3
int* p = &x;        

int*& r = p; //it's OK!
  1. 没有引用的数组。

这里补充一点:离变量名最近的那个符号,决定了它的基本类型

12 向上造型(Upcasting)

向上造型是将子类的引用或指针转变为父类的引用或指针的一种行为。也就是说如果B继承自A,那么你就可以在任何能够使用A的场合去使用B,B相比A多出来的那些东西可以当作不存在。

1
2
3
4
5
class student : public person {...};    //student继承自person
student jack; //student对象jack

person* pp = &jack; //it's Upcast
person& pr = jack; //it's Upcast

同时,在上半部分学习笔记中我们提到过“名字隐藏”,但如果你通过”pp”或者”pr”去调用函数时,实际上会调用父类的对应函数,也就不会有名字隐藏的问题出现。以下面这段代码为例:

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 A {
public:
A() {}
void f() { cout << "A::f() with no parameter" << endl; }
void f(int i) { cout << "A::f() with one parameter" << endl; }
void f(int i,int j) { cout << "A::f() with two parameters" << endl; }
void f(int i,int j,int k) { cout << "A::f() with three parameters" << endl; }
};

class B : public A {
public:
void f(int i, int j) { cout << "B::f() with two parameters" << endl; }
};

int main() {
B b;
int i = 0, j = 0, k = 0;

A* p = &b; //Upcast,将b的引用交给了指向A的对象的指针p
p->f(i); //调用父类的f函数
//b.f(i); //ERROR: Name hiding

return 0;
}

不扯远了,回到向上造型本身:从内部结构上来说,子类的对象拥有父类对象的所有东西(包括私有和公共的);从实际内存存储上来说,存储B对象的那块内存里面确实存储了一整块A对象而且是放在那块内存顶部的,连A对象里面数据的排列顺序都是完全一致的。所以,B完全可以当作A来看待和使用。以下面这段代码为例:

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 A {
public:
int i;
int j;
A() : i(1), j(2) {}
};

class B : public A {
private:
int k;
public:
B() : k(3) {}
int get_k() { return k; }
};

int main() {
A a;
B b;
int* p = (int*)&a; //将a的地址取出来强制类型转换为指向int的指针后交给指向int的指针p

cout << "a.i=" << a.i << ";" << "a.j=" << a.j << endl;
cout << "b.i=" << b.i << ";" << "b.j=" << b.j << ";" << "b.k=" << b.get_k() << endl << endl;

cout << "Sizeof(a) = " << sizeof(a) << " byte" << endl;
//a实际只存储着它的两个int变量,所以它大小是8(byte)
cout << "Sizeof(b) = " << sizeof(b) << " byte" << endl << endl;
//b存储着a的所有东西以及自己的int变量k,所以b的大小是12(byte)

*p = 10; //和C类似,C++在获取到地址之后也可以直接访问最底层的内存并做些修改
cout << "p=" << p << endl;
cout << "*p=" << *p << endl;
cout << "a.i=" << a.i << ";" << "a.j=" << a.j << endl << endl;

p = (int*)&b;
cout << "p=" << p << endl;
cout << "*p=" << *p << endl; //按顺序访问b的内存的每一个int看看b究竟是怎么个顺序存储它的数据的
p++;
cout << "*p=" << *p << endl;
p++;
cout << "*p=" << *p << endl; //通过指针就可以随心所欲直接访问b的private的k
*p = 30;
cout << "modify *p=" << *p << endl;
cout << "b.i=" << b.i << ";" << "b.j=" << b.j << ";" << "b.k=" << b.get_k() << endl;

return 0;
}

由上面代码以及运行结果可以看出:当我们获取到对象的指针时,可以直接通过指针看看对象里面是什么样的。b的大小是12(byte),也证明了在b(对象)里面是没有成员函数的,只有成员变量,这跟C语言中的结构体是一样的(实际上类的成员函数存放在代码段的)

同时子类拥有父类的所有东西,连数据存储顺序都是一样的。以上面代码为例,不会说b的k是插在ij中间存储的。所以b完全可以当作a来使用,但b还是b,不会因此真的变成a,只是我们把它看作是a了。

最后再提一点是:相反地,有向上造型也有向下造型,即把父类的对象当作子类的对象看待。但向下造型是有风险的!

13 多态性

13.1 多态的介绍

现在我们要设计一个画图程序

程序可以画三种不同的图形:矩形、圆形、椭圆。 他们拥有相同的数据:center(中心点坐标);可以做三种相同的操作:render(渲染图形)、move(移动图形)、resize(改变大小)。

为了实现上述要求,我们可以以一个类型来定义另一个类型:

  • 一个ellipse是一种shape;

  • 一个circle是一种特殊的ellipse;

  • 一个rectangle是另外一种不同的shape;

  • rectangle、circle、ellipse拥有一些共同的:属性(成员变量)和服务(成员函数);

  • 但它们三个也不是完全相同的;

于是,它们构成了如下图所示的联系:

center 和 move() 只在Shape里面定义了,对大家来说center和move()要做的事情都是一样的,所以其他四个类里面就不需要再定义了;而不同图形的 render() 是不一样的,而且不同的图形类里面可能有自己的数据(比如Ellipse类有”长轴”、”短轴”数据),同时不同图形的render()和Shape的render()得是存在某种联系的。

下面来设计代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Shape类
class XYPos {...}; //x,y point
class Shape{
public:
Shape();
virtual ~Shape(); //析构函数也用了virtual修饰
virtual void render();
/*"virtual"关键字意思是“虚的”,表示如果将来Shape类的子类里面重写了render(),
那么重写的render()跟这个render()是有联系的!!
这跟我们在“Name hiding”中提到的“子类中的print()函数跟父类中的print()函数是没有关系的”
是不一样的!!*/
void move(const XYPos&);
virtual void resize();
protected:
XYPos center;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Ellipse类
class Ellipse : public Shape {
public:
Ellipse(float maj, float minr);
virtual void render();
/*will define own, "virtual"可以不加,因为只要一个类中某个函数是virtual的,
那么这个类的子子孙孙的那个函数都是virtual了,无论前面是否加了virtual修饰;
当然加上"virtual"是个好习惯,这样别人不用去翻祖先类就知道这个函数是virtual了*/
protected:
float major_axis, minor_axis //长轴和短轴
};

// Circle类
class Circle : public Ellipse {
public:
Circle(float radius) : Ellipse(radius, radius) {}
virtual void render();
};

之所以搞得这么麻烦是为了实现如下面这个应用实例中的这样的render()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void render(Shape* p) {
p->render(); //calls correct render function
} //for given Shape!
/*render函数接受一个Shape的指针作为输入,然后让指针指向的那个对象去做render
render函数是个通用函数,对任何Shape类的子类都是适用的(包括Shape自己),
也就是说这个render函数是用于将来的新出现的Shape的子类的对象。而现在,
我还不知道将来Shape还会有什么样的子类,但这个函数写在这里对将来Shape可能出现的子类也是通用的*/

void func() {
Ellipse ell(10, 20);
ell.render(); //调用Ellipse的render()
Circle circ(40);
circ.render(); //调用Circle的render()
render(&ell); //Upcast向上造型,但由于virtual的关系故不会调用父类中的render(),而是调用Ellipse自己的render()
render(&circ);
}

13.2 多态定义

“virtual”是在告诉编译器当一个函数是virtual时,且对这个函数的调用如果是通过指针或引用的话,编译器就不能相信它一定是什么类型的。需要到运行时刻确定这个指针所指的那个对象到底是什么类型,再去调用那个类型的该函数;
而上面提到的这些事情,就称为“多态性”。对于上面的应用实例代码,p就是多态的,有的地方也称p为“多态对象”。因为p指向什么类型的对象,通过p做的动作就是那个类型的对象做的。p指向谁就变成谁的“形态”,故称p是“多态对象”。

由上面可知“多态性”是构筑在两件事情上的:

向上造型(Upcast)

1
2
3
4
5
6
7
8
9
render(Shape *p){
p->render();
}

...

render(&ell);

//仅看参数表里面的内容,其实就是"Shape *p = &ell",显然是Upcast

② 动态绑定(Dynamic binding);

1
2
3
所谓绑定:是指调用时应该调用哪个函数;
静态绑定:调用的函数是确定的(编译时刻就确定的);
动态绑定:需要到运行时刻才知道到底要调用哪个函数;

13.3 多态的实现:

C++到底是怎样实现在运行时刻动态地绑定那个函数,在运行时刻知道p所指的那个对象到底是什么类型的继而去调用正确的函数的呢?又回到了上半部分学习笔记中的那句话:Bjame Sgoustrup 在1979年刚开始研发C++的时候,他的手段仅仅只有C,他是怎么用C语言来实现C++的多态性呢?而且实现方式也不会很复杂,毕竟C++的运行效率也是很高的,太复杂了效率就会低。

首先,任何一个类如果有虚函数(virtual function),这个类的对象就会比正常的”大”一点。

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

using namespace std;

class A {
public:
int i;
A() : i(1) {}
virtual void f() { cout << "A::f(),i = " << i << endl; }
};

int main()
{
A a;
a.f();
cout << "sizeof(a) = " << sizeof(a) << endl;

int* p = (int*)&a;
cout << "*p = " << *p << endl; //探查第一个int
p++;
cout << "*p = " << *p << endl; //探查第二个int
p++;
cout << "*p = " << *p << endl; //探查(如果有)第三个int
return 0;
}

可以看到实际占用都不止4个字节了,以Win32运行结果为例:实际上 a 的成员变量 i 是存储在第二个int的。而第一个int大小的东西我们不知道是什么;
那个不知道是什么的东西其实是个指针,叫做vptr。所有有virtual的类的对象里面最顶部都会自动加上这个隐藏的vptr,它指向一张表,表叫做vtable

  • vtable里面存放的是这个的所有的virtual函数的地址。所以vtable是属于这个类的,所以这个类的所有的vptr的值都是一样的,这是可以验证的:
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 A {
public:
int i;
A() : i(1) {}
virtual void f() { cout << "A::f(),i = " << i << endl; } //A类有虚函数
};

int main()
{
A a;
A aa; //做两个对象出来

cout << "sizeof(a) = " << sizeof(a) << "byte" << endl;
cout << "sizeof(aa) = " << sizeof(aa) << "byte" << endl;

int* p = (int*)&a; //取a的地址出来强制类型转换为int型指针并交给int型指针p
cout << "*p(a) = " << *p << endl; //取第一个int出来
p = (int*)&aa;
cout << "*p(aa) = " << *p << endl; //同样取第一个int出来,结果显示和上面是一样的

return 0;
}
  • 另外我们还可以做一些“邪恶”的事情。我们知道 p 是指向vtable的指针(就是vtable的地址) 的指针,所以 p 表示指针所指的地方即vtable的地址,我们可以把 p 交给另外一个指针x,那么x就会指向vtable,它们之间的关系如下图所示:

我们验证一下上面的关系:

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 A {
public:
int i;
A() : i(1) {}
virtual void f() { cout << "A::f(),i = " << i << endl; } //A类有虚函数
};

int main()
{
A a;

cout << "sizeof(a) = " << sizeof(a) << "byte" << endl;

int* p = (int*)&a;
cout << "*p(a) = " << *p << endl; //*p的内容是vtable的地址
int* x = (int*)*p;
cout << "x = " << x << endl; //x的内容应该也是vtable的地址

return 0;
}

而我们要做的“邪恶”的事情就是既然拿到指针了,我们就可以通过*x看到vtable的内容了,现在我们尝试打印下vtable的第一个int(因为x是指向int的指针嘛):

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 A {
public:
int i;
A() : i(1) {}
virtual void f() { cout << "A::f(),i = " << i << endl; } //A类有虚函数
};

int main()
{
A a;

cout << "sizeof(a) = " << sizeof(a) << "byte" << endl;

int* p = (int*)&a;
cout << "*p(a) = " << *p << endl;
int* x = (int*)*p;
cout << "*x = " << *x << endl;

return 0;
}
  • 还有一点是上面我们提到过”一个有virtual函数的类的不同对象vptr是指向同一个vtable的(也就是这个类的vtable)”;而当这个类有子类的时候,子类的不同对象当然也会有vptr,但是它们会指向子类自己的vtable;而不是父类的vtable

例如:Ellipse对象的vptr会指向Ellipse的vtable ,而不是其父类Shape的vtable

可以看到:子类Ellipse的vtable的结构跟父类是一样的(第一个是析构函数dtor()、第二个render()、第三个resize()),不过里面的值(地址)是不一样的。Ellipse的析构、render()是自己的;而它的resize()是Shape的,因为Ellipse没有写自己的resize()。不过析构是特别的,即便Ellipse没有写自己的析构,编译器也会给Ellipse制造一个析构出来,所以vtable里存的是Ellipse自己的析构。同样的,还有Ellipse的子类Circle:

通过上面那么大篇幅介绍的方式,我们终于摸清了C++实现动态绑定的方式: 只需要通过修改vtable表里的地址。当函数:

1
2
3
void render(Shape* p) {
p->render();
}

中说”p->render()“的时候,实际发生的事情是让p所指的对象的第一个地址取出来,从该地址访问到了vtable,然后从vtable+1“得到了那个 render() 的地址,然后调用那个地址上的 render() 函数就可以了。

在之前我们都还没有提到过vptrvtable的类型。

vtable:vtable的类型可以表达为uintptr_t*,表示vtable中每个元素类型都是uintptr_t

vptrvptr指向vtable,因此vptr的类型是uintptr_t**,表示指针vtpr指向的类型是uintptr_t*

经验证64位编译模式下uintptr_tuintptr_t*uintptr_t**都占用8个字节,所以同样的从vtable”+1”(代码层面的 指针 +1)对应地址”+8”(物理内存层面的 地址 +8)。

在上面我们把p交给了int型指针x,若想要x指向那个 render() 函数,我们得让x”*+?“呢?

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 A {
public:
int i;
A() : i(1) {}
virtual void f() { cout << "A::f(),i = " << i << endl; } //A类有虚函数
virtual void g() { cout << "A::g(),i = " << i << endl; }
};

int main()
{
A a;

cout << "sizeof(a) = " << sizeof(a) << "byte" << endl;

int* p = (int*)&a;
int* x = (int*)*p;

cout << "sizeof(x) = " << sizeof(x) << endl;
cout << "sizeof(int) = " << sizeof(int) << endl;

return 0;
}

可以看到这种实现动态绑定的方式是高效的,程序在运行时刻根本无需知道对象的类型是什么,只是在顺着vptr找到了vtable,然后找到了应该调用的正确函数的地址而已。

在“多态性(Polymorphism)” 一章我们提到过,” “动态绑定”是要通过指针或者引用调用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
#include <iostream>

using namespace std;

class A {
public:
int i;
A() : i(1) {}
virtual void f() { cout << "A::f(),i = " << i << endl; } //A类有虚函数
};

class B : public A {
public:
int j;
B() : j(2) {}
virtual void f() { cout << "B::f(),j = " << j << endl; } //B重写了虚函数
};

int main()
{
A a;
B b;

A* p = &b; //Upcast but virtual
p->f(); //虽然向上造型了但是因为virtual存在还是会调用子类的f

a = b; //直接把b赋给a
a.f(); //通过a.f()到底是调用a的f还是b的f呢?
return 0;
}

显然,只有通过指针或者引用调用virtual函数时才会去“动态绑定”,通过”.“去调用时并不会做这样的事情;可是我们明明把b赋给a了呀!这一点好像被完全无视了?

我们再修改下程序,这次让指针去调用virtual函数 f() :

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 A {
public:
int i;
A() : i(1) {}
virtual void f() { cout << "A::f(),i = " << i << endl; } //A类有虚函数
};

class B : public A {
public:
int j;
B() : j(2) {}
virtual void f() { cout << "B::f(),j = " << j << endl; } //B重写了虚函数
};

int main()
{
A a;
B b;

A* p = &b; //Upcast but virtual
p->f(); //虽然向上造型了但是因为virtual存在还是会调用子类的f

a = b; //直接把b赋给a

p = &a; //这次我们通过指针去调用virtual函数,已知指针调用时会去做动态绑定的工作
p->f(); //那么这次又会调用a的f还是b的f呢?答案:还是调用的a的f函数
return 0;
}

为什么还是调用的a的f()函数呢?

因为我们在做赋值操作(a = b)时只是把b的值给了a(你可以试试在上面这段代码中B的构造函数里修改A的成员变量i的值,然后在赋值操作完成前后打印a.i,就能观察到b是把i的值赋过去了的),所以a还是a的;所以进行赋值操作时,b的区域是被“切掉”了的,只有符合a的那部分才会被赋值过去。而且在赋值过程中vptr是不传递的。所以自然调用的a的f函数。

但如果是指针的赋值的话,那显然原本的a就被覆盖丢失了…(因为指针不代表任何事情嘛,指针就是一个地址而已)

1
2
3
4
5
A* a = new A();
B* b = new B();

a = b;
a->f(); //B::f() is called

13.4 虚析构函数(Virtual destructors)

在《多态性(Polymorphism)》一章中我们设计Shape类的代码里可以看到析构函数被设计为了虚函数,为什么要设计成virtual的?我们来看看下面这个代码:

1
2
3
4
5
6
7
8
9
10
Shape *p = new Ellipse(100.0F, 200.0F); 
/*在学习《向上造型(Upcasting)》时我们说"person* pp = &jack;"是Upcast,
那上面这句代码是不是Upcast呢?
这也是Upcast,我们做了个Ellipse的对象交给了父类Shape的指针p:在上半部
分学习《new & delete》时提到过new作为一个运算符会返回分配给对象的地址,所
以这句代码还是向上造型,向上造型本质是:把子类对象当成父类对象用*/

...

delete p;

我们知道:当delete p时会自动调用析构函数,如果析构函数不是virtual的,意味着此时Shape的析构会被调用。所以,我们需要让析构函数是virtual的。

如果我们设计的类中有一个virtual函数,我们就必须把析构函数也设计成virtual的,这样可以避免可能出现的麻烦。这件事情的关键在于即便现在我们的类没有子类,我们也无法预知将来别人会怎么修改我们的程序。比如我们想象一下下面这种场景:

我们设计了一个类,类里面有一些virtual函数但析构函数不是virtual的。这个时候别人写了个新类继承自我们的这个类,他知道我们的类里有virtual函数所以选择Upcast以实现动态绑定。然后由于别人写的新类里面申请了一些资源,所以别人重写了我们的析构函数用于归还申请的资源(这一点在上半部分学习笔记中的《构造和析构(Constructor & Destructor)》中谈析构的用处时提到过)。而当别人new了一个他写的类的对象之后再去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
#include <iostream>

using namespace std;

class A {
private:
int i;
public:
A() : i(0) { }
virtual void f() { cout << "A::f()" << endl; } //virtual f()
~A() { cout << "A::~A()" << endl; }
};

class B : public A {
public:
virtual void f() { cout << "B::f()" << endl; }
~B() { cout << "B::~B()" << endl; }
};

void f(A* p) {
p->f();
}

int main() {
B b;
/*b不是new出来的,在上半部分学习笔记《父类子类的关系》中我们说过:
"退出时会先析构子类,再析构父类"
所以离开大括号范围时会先调用~B(),再调用~A()。*/

A* p = new B(); //Upcast

p->f(); //but f() is virtual,所以动态绑定
f(&b); //也是Upcast but virtual,所以也会动态绑定

cout << endl << "Before delete p" << endl;
delete p; //此时会调用~A()(这不是我们期望的结果)
cout << "After delete p" << endl << endl;

return 0;
} //此时会调用~B()、~A()

13.5 覆盖(Override)

如果父类和子类的两个函数是virtual的,名称相同,参数列表也相同。那它们构成一种关系叫做”Override”。中文可以称作“覆盖”、“覆写”、“重写”或者“改写”。还记得在上半部分学习笔记中的《名字隐藏(Name hiding)》里我们说”子类中的print()函数跟父类中的print()函数是其实是没有关系的”,现在有了virtual,就构成了Override关系了。

在Override中如果我们想要调用父类的那个函数,可以这么写:

1
2
3
4
void Derived::func() {
cout << "In Derived::func!" << endl;
Base::func(); //call to base class
}

下面举一个可以运行的例子:

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 A {
public:
A() {}
virtual void f() { cout << "A::f()" << endl; }
};

class B : public A {
public:
virtual void f() {
cout << "Now in B::f(), I'm trying to call A::f():" << endl;
A::f();
}
};

void f(A* p) {
p->f();
}

int main() {
B b;
A* p = new B();

p->f();
f(&b);

delete p;
return 0;
}

13.6 返回类型放松(Return types relaxation):

  • 假如B继承自A,那么C++允许B::f()返回A::f()返回类型的子类;
  • 适用于指针和引用类型;

比如A::f()如果返回了一个A类自己的的指针,而且B::f()Override它了;那么B::f()就可以返回B类的指针;

1
2
3
4
5
6
7
8
9
10
11
12
13
class A {
public:
virtual A* f();
virtual A& g();
virtual A h();
};

class B : public A {
public:
virtual B* f(); //it's OK!
virtual B& g(); //it's OK!
virtual B h(); //ERROR! Only applies to pointer and reference types
};

因为只有通过指针或者引用才构成Upcast关系嘛,才能够发生多态性(polymorphism)嘛。这点在之前多次提到过了。

13.7 同时有重载(Overload)和覆盖(Override):

现在父类中有一组virtual重载(Overload)函数,如果你覆盖(Override)了其中一个,你就必须把所有的重载给覆盖掉,否则依然会发生名字隐藏那样的事情。

14 引用再研究

在《引用(Declaring reference)》一章我们提到过”引用作为成员变量或者放在参数表里面才可以不用给初始值”,而这其中当引用作为成员变量没有给初始值时我们就必须(也只能)在初始化列表里面给出初始值来。

1
2
3
4
5
6
7
class X {
public:
int& m_y; //现在还不知道将来构造X的对象时m_y要与谁建立引用关系,没有办法在声明时给初始值
X(int& a); //构造声明
};

X::X(int& a) : m_y(a) {} //构造定义

我们就必须在初始化列表中建立引用关系,因为如果在大括号{}里面写”m_y = a;”那就是在赋值了(把a赋给m_y所“绑定”的那个变量)。比如下面这样:

1
2
3
4
5
6
7
class X {
public:
int& m_y;
X(int& a); //构造声明
};

X::X(int& a) { m_y = a; } //构造定义,会报错ERROR

14.1 函数返回引用(Returning references):

同返回指针,函数也可以返回引用,而且也不能返回本地变量的引用

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

using namespace std;

const int SIZE = 32;
double myarray[SIZE];
double& subscript(const int i) {
return myarray[i];
/*返回第i个元素变量,因为一般会在调用该函数的地方会将该变量“绑定”给别的reference
就像:
int f() {
...
}
int a = f();
这样,我们也可以:
int& f() {
...
}
int& a = f();
区别在于int a = f()只是在赋值,而int& a = f()是把f()返回的变量“绑定”给a了
*/
}

int main() {

for (int count = 0; count < SIZE; count++) {
myarray[count] = count + 1;
}

double& FirstElement = subscript(0);
//把返回值“绑定”给另外一个reference
double FirstElement_value = subscript(0);
/*把返回的「值」赋给了一个double变量(这个时候发生了"dereference")。
很容易理解:引用就是别名嘛,把"引用赋给变量"不就跟"把变量赋给变量"一样的嘛,
"i = j;"这种事不是经常做嘛,实际上就是传值嘛*/

cout << "FirstElement =" << FirstElement << endl;
cout << "FirstElement_value =" << FirstElement << endl << endl;

FirstElement_value = 3.1415926; //修改double变量对myarray数组没有影响
cout << "After modify double myarray[0] = " << myarray[0] << endl;
FirstElement = 2.7182818; //修改引用的值即修改被引用者的值
cout << "After modify double& myarray[0]= " << myarray[0] << endl;
}

上面这段代码没有演示返回引用的函数作”左值”的情况,感兴趣的小伙伴可以自己试一试(比如subscript(7) = 2.58;)。因为函数返回reference,所以返回的reference可以作为变量来使用。

14.2 Const reference parameters

在上半部分学习笔记的《Const》一章中我们说过:

“对一个函数传进传出整个对象时可能会造成很大的开销,往往更好的办法是传一个地址。但是传地址我们又会很不放心别人会不会通过指针修改我们的原始数据。这个时候,const修饰指针的作用就来了。我们在前面加上 const 表明我们以一个const的方式传一个对象进去,这样就可以保证我们的数据是安全的。”

现在我们有了引用(reference),我们可以选择用更好的reference,因为reference意味着我们就可以不用在那个函数里面用很多”*”号了,这也是C++开发工作中更推荐的。

14.2.1 中间结果是const(Temporary values are const)

我们先来看下下面这段代码:

1
2
3
4
void func(int &);
func (i * 3); //ERROR:无法将参数从"int"转换为"int &"

//我们都知道reference必须能做左值,显然i*3不能作左值,当然报错

还有种解释是编译器在编译时会生成下面这样的代码:

1
2
3
void func(int &);
const int tmp@ = i * 3; //编译器产生了const int的临时变量tmp@
func(tmp@); //因为tmp@是const,就不能传给int &(const不能作左值嘛)

那现在我们修改函数的参数为const reference parameter,试试看能不能把tmp@传进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;

void f(const int& i)
{
cout << i << endl;
}

int main() {
int i = 3;
f(i * 3);

return 0;
}

没有报错可以正常运行,验证了第二种解释。

14.2.2 函数返回值有const(const in Function returns)

(一)函数return了一个const value

  • 对于用户定义类型,这意味着“防止作为左值使用”(现代的编译器貌似不用加const都不能作左值了,详见下面的代码);
  • 对于内置的,它没有任何意义(就像int f(),我们都知道实质上返回的是值,而不是一个变量。值本来就不能作左值,const int f()就无意义了);

我们来测试一些返回用户定义类型的代码:

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 A {
public:
int i;
A() : i(0) {}
};

A f() {
A a;
return a;
}

int main() {
A b;
//f().i = 10; //ERROR:表达式必须是可修改的左值
/*上面这句代码理论上应该是行得通的,可能是Visual Studio的编译器现在不允许这种行为了吧*/

b.i = 20;
f() = b;
cout << "Now f().i = " << f().i << endl;
cout << "Now b.i = " << b.i << endl;

return 0;
}

哦哟,这很奇怪哦,我们明明做了f() = b这件事,但是f().i好像没啥变化啊?是不是因为局部变量的关系呢?我们在上面代码的基础上加一些监测然后调试试一下:

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

class A {
public:
int i;
A() : i(0) { cout << "Now in A::A()" << endl; }
~A() { cout << "Now in A::~A()" << endl; }
};

A f() {
A a;
cout << "After A a" << endl;
return a;
}

int main() {
A b;
cout << "After A b" << endl;
//f().i = 10; //ERROR:表达式必须是可修改的左值
/*上面这句代码理论上应该是行得通的,可能是Visual Studio的编译器现在不允许这种行为了吧*/

b.i = 20;
f() = b;
cout << "After f() = b;" << endl;
cout << "Now f().i = " << f().i << endl;
cout << "Now b.i = " << b.i << endl;

return 0;
}

当你一步步地调试时,你就可以发现:f()确实返回了一个对象,在return语句执行时会去析构局部变量a,所以这个时候显然返回的不是a对象本身了,现在我们推测函数返回的是一个a的”副本”,而且此时我们并不知道这个”副本”变量的名字和地址

然后程序继续执行”f() = b;”这句代码,此刻这个”副本”变量还没有被析构(通过调试过程看得出来),而显然我们没有办法去访问到这个返回的对象的任何东西,因为此时我们并不知道这个”副本”变量的名字和地址。换句话说,没有任何人知道”副本”在哪,没有人此刻掌握着”它”。
所以”它”就不存在了,”它”消失了。这句话好像有点哲学哈,我们可以理解为“当世界上没有任何一个人能够观察到你的存在时,你是否还真的存在于这个世界上呢?”Think about it!
所以在程序执行完”f() = b;”后,这个”副本”对象也被一起析构了,所以发生了我们目前还无法理解的”两次析构被调用”。
感兴趣的小伙伴可以构造一个A的对象来掌握返回的那个”副本”对象。这样就直到 掌握着那个”副本”的 对象的 生命周期结束才会调用析构了。比如”A c = f(); c = b;”。
这一段实际发生的事情涉及到马上要讲的《拷贝构造(Copy the structure)》,所以现在不理解也不必担心。

(二)函数return一个const pointer or reference

这取决于你期望使用你的类的人会拿着这个返回结果做啥

在14.1函数返回引用(Returning references)小节开头我们就说”函数不能把本地变量作引用给返回”,但其实我们可以尝试下做这种”邪恶”的事情的,编译器不会报错。
下面举一些返回引用的代码例子,这段代码推荐除main函数内的内容以外全部复制进你的IDE内,然后自行测试你能想象到的所有可能:

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

class A {
public:
int i;
A() : i(0) { cout << "Now in A::A()" << endl; }
~A() { cout << "Now in A::~A()" << endl; }
};

int i; //全局变量i

int& f() {
return i; //返回全局变量的引用
}

int& ff() {
int a = i;
return a; //返回本地变量的引用
}

const int& g() {
return i; //返回全局变量的引用且是const的引用
}

A& h() {
A a;
return a; //返回本地对象变量的引用
}

A b; //全局对象变量b;
const A& m() {
return b; //返回全局对象变量的引用且是const的引用
}

int main() {
f() = 4; //我们知道引用和被引用者是一个东西嘛,所以这和 i = 4; 没有区别
int j = f(); //这和int j = i;没有区别
cout << "j=" << j << endl;
cout << "return of f() =" << f() << endl;
//和直接打印全局变量i没有区别,因为f()函数就是拿全局变量i的引用返回的;

ff() = 44;
/*但是返回本地变量的引用就有问题了,因为本地变量的生存周期只有函数内部,
所以这样的引用实际上是非法的,不过编译器仍然通过了*/
cout << "return of ff() =" << ff() << endl;
//作为一个不存在的变量的引用,它的值当然是不确定的
//g() = 5; //返回全局变量的引用是const时编译器则不允许修改其值了

h().i = 6;
//当然返回本地对象变量的引用也存在同样的问题,在退出h()函数时本地对象会被析构
cout << "h().i=" << h().i << endl;
/*这里是第二次调用h()了,看似我们是在返回h().i = 6; 的h().i,
实际上会重新构造本地对象,然后退出时析构;
而作为已经被析构掉的本地对象变量的引用,它的成员变量i的值同样是不确定的。
这个时候我们可能会问了:我们构造一个新的对象来掌握h()返回的那个引用呢?就像下面这样:*/
A aa = h();
aa.i = 66;
cout << "aa.i=" << aa.i << endl;
/*好像没有问题了哈?可h()显然是多余的呀,直接A aa; aa.i = 66;不就可以了吗?*/

//m().i = 7; //返回全局对象的引用则不能通过引用修改值
cout << "b.i=" << b.i << endl;

return 0;
}

如果函数返回在函数中创建的临时对象(别忘了一切事物皆是对象),则不要使用引用。因为当函数结束时,临时对象将消失,因此这种引用是非法的。这点我们在5.1小节《函数返回引用(Returning references)》开篇就提到了;
如果函数返回本地对象的引用我们还把它交给了另外一个引用时,这是非常非常危险的行为!!因为那个引用会成为不存在的对象的引用,引用指向的那块内存是没有人在使用的内存即”空闲内存”,而空闲内存是随时都可能有人会用的!也就是说我们随时可能在不经意间使用该引用破坏了别人的内存!下面举一个简单例子:

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

int& f() {
int i;
return i;
}

void g() {
int j;
cout << "&j =" << &j << endl;
}


int main() {
int& r = f();
cout << "&r =" << &r << endl;
g();
/*我们知道本地变量放在堆栈区(stack),f()执行完后它的本地变量i消失了;
而g()的本地变量j就会被存放在i刚刚存放的位置*/

return 0;
}

如果是全局对象作引用并返回时,const的作用和之前一样是告诉编译器”禁止用户使用返回的引用去修改其值”;
以上三点在函数返回指针时也是一样的(在《引用(Declaring reference)》一章里我们说过”引用本质上就是个const指针(int *const p,可以理解为引用r就是那个*p)”嘛);

如果上面”return by const pointer or reference”讲过的内容还是不好理解的话,也不要钻牛角尖了。这件事情不要想麻烦了,归根结底就两件事:

① 函数肯定不能返回本地的对象嘛(不管是返回本地变量本身 or 本地变量作为引用并返回 or 本地变量的地址),这是非法的,这一点毋庸置疑,我们在5.1小节《函数返回引用(Returning references)》开篇就提到了;
② 如果是返回全局对象的引用或指针且是const的,那这就和我们之前理解的作用是一样的:都是在告诉编译器”禁止通过返回的这个引用或指针去改变全局对象的内容”;

15 拷贝构造(Copy the structure)

我们做一个函数func(A a),函数的参数是一个A的对象(不是引用或指针哦)。然后我们构造一个A的对象aa,然后调用func时把aa给它……

1
2
3
4
5
6
7
8
void func(A a) {
cout << "a.i = " << a.i << endl;
}

...

A aa;
func(aa); //aa is copied into a

现在我们都知道a是func里面的对象,和外面的aa是没有关系的,在调用时会直接把a拷贝一份到堆栈里。那么这个时候到底发生的是:

1
A a = aa;    //初始化(Initialization) 

还是

1
a = aa;     //赋值(Assignment)

呢?在C++这两种有什么区别?(在C++这两件事会有很大区别的,这个往后学会逐渐了解的)

还记得在14.2.2 函数返回值有const小节发生了一件当时我们无法理解的事情吗:”调用了两次析构函数“。这其实还不算夸张的,下面来个更夸张的:

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

static int ObjectCount = 0; //对象计数器

class A {
public:
A() {
ObjectCount++;
//在之前学习中我们知道每构造一个A的对象就会调用一次构造函数,所以我们让对象计数器++
cout << "Now in A::A(), ObjectCount = " << ObjectCount << endl;
}

~A() {
ObjectCount--;
cout << "Now in A::~A(), ObjectCount = " << ObjectCount << endl;
}
};

A func(A a) {
cout << "Now in func(A a)" << endl;
return a;
}

int main() {
A aa;
cout << "After construct aa" << endl << endl;

func(aa);
cout << "After func(aa)" << endl << endl;

A a = aa;
cout << "After A a = aa" << endl << endl;

return 0;
}

可以看到对象计数器直接给弄成-3了,程序好像只调用了一次构造却调用了4次析构?因此我们居然好像”欠”了程序3个对象?而且A a = aa这句代码好像没有构造就结束了?

先来研究A a = aa,我们先来看看如果加一个带参构造函数再来构造一些对象是否会正常调用构造函数:

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;

static int ObjectCount = 0;

class A {
public:
A() {
ObjectCount++;
cout << "Now in A::A(), ObjectCount = " << ObjectCount << endl;
}
A(int i) {
ObjectCount++;
cout << "Now in A::A(int i), ObjectCount = " << ObjectCount << endl;
}
A(const A& a) {
ObjectCount++;
cout << "Now in A::A(const A& a), ObjectCount = " << ObjectCount << endl;
}

~A() {
ObjectCount--;
cout << "Now in A::~A(), ObjectCount = " << ObjectCount << endl;
}
};

int main() {
A aa;
cout << "After construct a" << endl << endl;

A a = aa;
/*当a类只有默认构造函数的时候,我们知道这句代码好像没有正常去调用A的默认构造函数;
但现在我们有一个接受A的对象的const引用的带参构造函数,
(其实构造函数参数是A* a也可以,初始化就得换成A a = &aa;)
是否能够捕捉到A a = aa这个过程中的初始化呢?*/
cout << "After A a = aa" << endl << endl;

A b(10);
cout << "After A b(10)" << endl << endl;

A c = 20;
/*tips:在C++用圆括号或者等号初始化变量是等价的,也就是说这句等价于"A c(20)"
尽管这看起来像是在把一个整数赋给一个对象,类型不匹配不能就这么等起来;
但由于A有个要1个int的带参构造函数,所以这么写是可以的*/
cout << "After A c = 20" << endl << endl;

return 0;
}

可以看到,通过新增一个接受A的对象的const引用的带参构造函数,我们成功捕获到了A a = aa;的初始化过程。在这基础上感兴趣的同学还可以把第一段代码里的func()加到第二段代码里去,你会发现func()也能正常调用相应的构造函数了:

以上内容了解完后我们可以做总结了:

如果你有一个构造函数的参数是自己类型的const引用,这种构造函数就会在我们用另外一个该类的对象来初始化某个该类的对象(比如A a = aa;还有仅看func()函数的参数表和调用时传的内容,其实发生的也是A a = aa;嘛)时被调用。这样的一个构造函数我们用一个特殊的名字——“拷贝构造”,来称呼它。拷贝构造有如下特点:

  • 形式唯一:T::T(const T&)
  • 你可以使用自己写的拷贝构造函数来决定哪些要拷贝,哪些不用,或者完成一些特殊操作;
  • 如果你没有提供拷贝构造,C++会自己提供一个,而且它会:
    • 拷贝每一个成员(不是简单的一个字节by一个字节地拷贝);(因为如果成员变量中有其他对象,它会让那个类的拷贝构造来拷贝那个对象)
    • 拷贝每一个指针,当然引用也是;

正因为本章开始那段代码我们没有提供自己写的拷贝构造,C++会自己提供一个然后去构造;我们才会看到只调用了1次我们的默认构造函数却调用了4次析构。背后发生的事情就是程序还调用了3次自动提供的拷贝构造,只是我们当时不知道而已;
上面说自动提供的那个拷贝构造会去拷贝每一个指针,那显然新的对象里的指针会和老的对象里的指针是相同的,我们来验证一下这件事:

「面向对象程序设计-C++」学习笔记(上半部分)- YMGogre - CSDN
「面向对象程序设计-C++」学习笔记(下半部分)- YMGogre - CSDN

N. 什么时候该写函数,什么时候该写类

N.1 理论上的基本规则

把重复的代码写成单独的函数,如果有许多重复顺序的函数调用,就再组织成一个函数。如果这些函数有共同的数据,可组织成一个类。(其实数据才是灵魂,函数本身是空洞无物的,是表象、外在接口和服务工具。调用Winapi看上去可以立即实现某些功能,实际上也是这个函数修改了OS的内部数据才实现了相应的功能)

转载于:https://www.cnblogs.com/alleyonline/p/4679219.html

N.2 类和函数傻傻分不清楚?三个例子讲明白

N.2.1 前言

前两天一位小伙伴问了这样一个问题:虽然已经使用python一年多了,也用python写过很多脚本,代码量从几十行到上千行的也有,但从未使用过类(class),似乎用函数(def)就能解决所有问题,使用类有什么好处?我什么时候该用类呢?

关于这个问题,算是困惑了许多刚接触python的同学,那么本文就尝试从多个角度来解读这个问题。首先还是先来看看官方给出类与函数的解释。

类提供了一种组合数据和功能的方法。 创建一个新类意味着创建一个新的对象类型,从而允许创建一个该类型的新实例 。每个类的实例可以拥有保存自己状态的属性。 一个类的实例也可以有改变自己状态的(定义在类中的)方法。

函数的本质就是一段有特定功能、可以重复使用的代码,这段代码已经被提前编写好了,并且为其起一个“好听”的名字。在后续编写程序过程中,如果需要同样的功能,直接通过起好的名字就可以调用这段代码。

很显然,这样的答案并没有让人搞明白类和函数到底不一样在哪里。但是里面提到了类是创建一个对象,所以类是面向对象程序设计(Object Oriented Programming)。也就是我们常说的OOP。而OOP高度关注的是代码的组织,可重用性和封装。

N.2.2 第一个例子

上面的官方解释上去还是很抽象,那么我们开始说人话。简单来说当Python中没有可以完全表达我们要表示的内容的数据类型时,那么就需要使用一个类。来看下面的例子。

若计算某人的年龄,则只需使用int,因为它可以满足我的需求。如果我们需要在游戏中表示像敌人之类的东西,则可以创建一个类则可以创建一个类Enemy,其中包含诸如health和armor的数据,并包含诸如fire_weapon射击时的功能。然后,我们还可以创建另一个类FlyingEnemy,Enemy该类从该类继承所有内容,但又具有一个fly方法,因此具有其他功能。

N.2.3 第二个例子

我们再来看一个例子。假设我们需要编写一个音乐播放器。在这个播放器中,我们有关于不同类型数据的信息,如歌曲、专辑、艺术家和播放列表。还有一些可以播放歌曲、播放专辑、播放艺术家或播放播放列表的功能。我们将每种数据存储在字典中,不同类型的数据有不同的字段名,因为每个play_xxxx函数需要做不同的事情,所以我们就有四个不同的函数:

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
some_song = {
"title": "Yellow Submarine",
"artist": the_beatles, # 指向到包含该艺术家的词典
"album": yellow_submarine_album, # 指向包含此相册的dict的链接
"duration": insert_time_object_here,
"filepath": "path/to/file/on/disk"
}

# 其他数据类型的结构也类似

# 一些函数
def play_song(song):
# 获取歌的路径
path = song["filepath"]
# 播放路径
call_some_library_function(path)

def play_album(album):
# 找到专辑里所有的歌曲
# 分别调用play_song

def play_artist(artist):
# 找到这位艺术家所有的专辑
# 分别调用play_album

def play_playlist(playlist):
# 找到播放列表中的所有歌曲
# 分别调用play_song

这样写有什么不好?我们有四个非常相似的函数,每个函数都与特定类型的数据相关。你必须把它们叫做不同的东西,而不仅仅是play,你必须确保你把正确的数据传递给它们。虽然这四种不同的类型都可以“播放”,但是没有一种通用的方法可以在不知道它是什么的情况下播放任何东西。

那么在OOP下,怎么实现呢:

1
2
3
4
5
6
7
8
9
10
11
class Song:
def __init__(self, title, artist, album, duration, filepath):
self.title = title
self.artist = artist
self.album = album
self.duration = duration
self.filepath = filepath

def play(self):
path = self.filepath
call_some_library_function(path)

这样就定义了如何创建一个新的Song对象。该方法将字段值作为参数,并将它们作为对象的属性赋值。self是一个特殊参数(名称不保留;它可以被称为任何东西),它是对对象本身的引用。是一种从同一对象的其他方法内部访问属性和方法的方法。当我们从对象外部访问它们时(要使用play方法时将执行此操作),则可以使用在该范围内为对象指定的任何名称。

那么在定义class之前

1
2
# some_song是上面定义的歌
play_song(some_song)

在使用class之后:

1
2
3
4
5
6
7
8
# self参数没有在这里传递;它会自动添加
some_song = Song("Yellow Submarine",
the_beatles,
yellow_submarine_album,
insert_time_object_here,
"path/to/file/on/disk"
)
some_song.play()

为什么这样更好?如果我们有一个对象,则不必知道它是什么就可以播放,因为现在播放任何内容的语法都是相同的:anyobject.play(),即对象“知道”如何使用“自己的”数据进行处理的设计思想。无需从外部检查对象是否具有某些字段并决定如何处理这些内部字段,而是调用play对象提供的方法,并在每个类内部定义该类型的对象应如何实现此功能。

N.2.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
class Student(object):
def __init__(self, name, age, gender, level, grades=None):
self.name = name
self.age = age
self.gender = gender
self.level = level
self.grades = grades or {}

def setGrade(self, course, grade):
self.grades[course] = grade

def getGrade(self, course):
return self.grades[course]

def getGPA(self):
return sum(self.grades.values())/len(self.grades)

# 定义一些学生
john = Student("John", 12, "male", 6, {"math":3.3})
jane = Student("Jane", 12, "female", 6, {"math":3.5})

# 现在我们可以很容易地得到分数
print(john.getGPA())
print(jane.getGPA())

再来看看用函数怎么实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def calculateGPA(gradeDict):
return sum(gradeDict.values())/len(gradeDict)

students = {}
name, age, gender, level, grades = "name", "age", "gender", "level", "grades"
john, jane = "john", "jane"
math = "math"
students[john] = {}
students[john][age] = 12
students[john][gender] = "male"
students[john][level] = 6
students[john][grades] = {math:3.3}

students[jane] = {}
students[jane][age] = 12
students[jane][gender] = "female"
students[jane][level] = 6
students[jane][grades] = {math:3.5}

print(calculateGPA(students[john][grades]))
print(calculateGPA(students[jane][grades]))

这两段代码都实现了输出学生的成绩,但是在使用函数的时候,我们需要记住学生是谁,成绩存储在哪里,似乎不是很困难(如果需要输出的学生更多呢),但是OOP避免了这一点。并且代码也更加pythonic。

N.2.5 结束语

最后,让我们回到刚开始的问题上来,上面说了这么多类的好处所以我们就应该更多的去使用类吗?并不是!

其实从某种意义上来说,类并不比函数更好。只是在某些情况下使用类能够更好的帮助我们写代码。所以如果发现自己使用各种数据集调用some_function(data),那么将其用类表示为data.some_function()可能提高我们的效率。至于到底在何时使用类,我们来看看其他程序员的理解:

  • 当我们拥有一堆共享状态的函数,或者将相同的参数传递给每个函数时,我们可以重新考虑代码使用类。
  • 类的“可重用性”意味着我们可以在其他应用程序中重用之前的代码。如果我们在自己的文件中编写了类,则只需将其放在另一个项目中即可使其工作。
  • 函数对于小型项目非常有用,但是一旦项目开始变大,仅使用函数就可能变得混乱。类是组织和简化代码的一种非常好的方法
  • 通常,如果在函数内部找到自写函数,则应考虑编写类。如果我们在一个类中只有一个函数,那么请坚持只写一个函数。
  • 如果需要在函数调用之间保留一些状态,那么最好使用带有该函数的类作为方法

原文链接:https://blog.csdn.net/weixin_41846769/article/details/104892293

  • Copyrights © 2015-2025 wjh
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信