C++ Primer 第一部分

4/8/2024 C++

该笔记是C++ Primer 第一部分,笔记参考了https://github.com/czs108/C++-Primer-5th-Notes-CN/

# C++ Primer One

# 第一章 开始

# 读取数量不定的输入数据

可以使用while循环以及if判断来实现

#include <iostream>
int main() 
{
    Sales_item total; // variable to hold data for the next transaction

    // read the first transaction and ensure that there are data to process
    if (std::cin >> total) {
		Sales_item trans; // variable to hold the running sum
        // read and process the remaining transactions
        while (std::cin >> trans) {
			// if we're still processing the same book
            if (total.isbn() == trans.isbn()) 
                total += trans; // update the running total 
            else {              
		        // print results for the previous book 
                std::cout << total << std::endl;  
                total = trans;  // total now refers to the next book
            }
		}
        std::cout << total << std::endl; // print the last transaction
    } else {
        // no input! warn the user
        std::cerr << "No data?!" << std::endl;
        return -1;  // indicate failure
    }

    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

# 缓冲区

一文带你读懂C/C++语言输入输出流与缓存区 - 知乎 (zhihu.com) (opens new window)

如果程序崩溃时 ,输出缓冲区不会被刷新

# 术语

cerr:一个ostream对象,通常输出错误信息的时候用的是cerr而不是cout,不用经过缓冲区

clog:一个ostream对象,和cerr相同,同为错误的输出流,但是这个会经过缓冲区

# 区分「类」「类型」「类型类」「类类型」

# 第二章 变量和基础类型

# 2.1基本内置类型

​ 整型用int, 不够直接用long long,因为一般将intlong类型看作是一样的大小

char只用于存放单个字符, 它存整数有一个风险, 不同机器A对char归类signedunsigned不一致, 在A机器上char(用unsigned诠释) smallNum=200可以过, 在B机器上(用signed诠释)就会溢出

​ 浮点数一律用double

# 带符号类型和无符号类型

​ 除去布尔型和扩展的字符型之外,其他整型可以划分为带符号的 (signed) 和无符号的(unsigned)两种。带符号类型可以表示正数、负数或 0,无符号类型则仅能表示大于等于0的值。 类型 intshortlonglong long 都是带符号的,通过在这些类型名前添加unsigned就可以得到无符号类型,例如unsigned long。类型unsigned int 可以缩写为unsigned

# 转义字符

image-20240124160823900

# 2.2变量

# 初始值&初始化

https://zh.C++reference.com/w/C++/language/list_initialization

初始化不是赋值,初始化的会义是创建变量时赋予其一个初始值,而赋值的会义是把对象的当前值擦除,而以一个新值来替代。

# 列表初始化

可以简单的理解为带有花括号的赋值如定一个名为a的int变量并初始化为0

int a = {0}
int a {0}
1
2

该方法使得初始化更加安全[避免了类型转换的精度损失的危险]

# 变量声明和定义的关系

​ 为了支持分离式编译,C++语言将声明和定义区分开来。声明(declaration)使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义(definition)负责创建与名字关联的实体。

​ 如果想声明一个变量而非定义它,就在变量名前添加关键字extern,而且不要显式地初始化变量:

extern int i;//声明i而非定义i
int j;//声明并定义了
1
2

任何包含了显式初始化的声明即成为定义。我们能给由extern关键字标记的变量赋一个初始值,但是这么做也就抵消了extern 的作用。extern 语句如果包含初始值就不再是声明,而变成定义了:

extern double pi=3.1416;//定义
1

# 2.3复合类型

# 引用

C++之引用的详解_C++引用-CSDN博客 (opens new window)

​ 一般在初始化变量时,初始值会被拷贝到新建的对象中。然而定义引用时,程序把引用和它的初始值**绑定(bind)**在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定到另外一个对象,因此引用必须初始化。

#include <iostream>
using namespace std;
int main(){
	int a = 0;
	int &b = a;
	cout << a << endl;
	cout << b << endl;
	b = 1;
	cout << a << endl;
	cout << b << endl;
	return 0;
} 
1
2
3
4
5
6
7
8
9
10
11
12

引用类型初始值必须是一个对象,且该对象的类型要和引用严格匹配以下定义方法是错误的

int &a = 10;//错误

double a = 10;
int &b = 10;//错误
1
2
3
4

# 指针

​ **指针(pointer)**是“指向(point to)”另外一种类型的复合类型。与引用类似,指针也实现了对其他对象的间接访问。然而指针与引用相比又有很多不同点。其一,指针本身就是一个对象,允许对指针赋值和拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象。其二,指针无须在定义时赋初值。和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值。

​ 指针存放某个对象的地址,要想获取该地址,需要使用取地址符(操作符&):

int ival = 42;
int *p = &ival; //p存放变量ival的地址,或者说p是指向变量ival的指针
1
2

​ 第二条语句把p定义为一个指向 int 的指针,随后初始化p令其指向名为 ival的int对象。因为引用不是对象,没有实际地址,所以不能定义指向引用的指针。

# 利用指针访问对象

如果指针指向了一个对象,则允许使用解引用符(操作符*)来访问该对象:

int ival = 42;
int *p = &ival; // p存放着变量ival的地址,或者说p是指向变量ival的指针
cout <<*p;// 由符号*得到指针p所指的对象,输出42
1
2
3

对指针解引用会得出所指的对象,因此如果给解引用的结果赋值,实际上也就是给指针所指的对象赋值:

*p=0;//由符号*得到指针p所指的对象,即可经由p为变量ival赋值
cout<<*p;//输出0
1
2

如上述程序所示,为*p 赋值实际上是为p所指的对象赋值

# 空指针

生成空指针的方法

int*pl=nullptr;//等价于int*p1=0;
int*p2=0;// 直接将p2初始化为字面常量 0
// 需要首先#include cstdlib
int*p3=NULL;//等价于int*p3=0;
1
2
3
4

把int 变量直接赋给指针是错误的操作,即使int变量的值恰好等于也不行。

int zero=0;
pi=zero;//错误:不能把int 变量直接赋给指针
1
2

# 赋值和指针

​ 指针和引用都能提供对其他对象的间接访问,然而在具体实现细节上二者有很大不同,其中最重要的一点就是引用本身并非一个对象。一旦定义了引用,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。指针和它存放的地址之间就没有这种限制了。和其他任何变量(只要不是引用)一样给指针赋值就是令它存放一个新的地址,从而指向一个新的对象。

​ 有时候要想搞清楚一条赋值语句到底是改变了指针的值还是改变了指针所指对象的值不太容易,最好的办法就是记住赋值永远改变的是等号左侧的对象。当写出如下语句时

pi=&ival;//pi的值被改变,现在 pi 指向了ival
1

​ 意思是为 pi 赋一个新的值,也就是改变了那个存放在 pi 内的地址值。相反的,如果写出如下语句,

*pi = 0; //ival的值被改变,指针pi并没有改变
1

​ 则*pi(也就是指针pi 指向的那个对象)发生改变

# void* 指针

void* 是一种特殊的指针类型,可用于存放任意对象的地址。一个void* 指针存放着一个地址,这一点和其他指针类似。不同的是,我们对该地址中到底是个什么类型的对象并不了解:

double obj=3.14*pd=&obj;//正确:void*能存放任意类型对象的地址
void*pv=&obj;			//obi可以是任意类型的对象
pv=pd;				   //pv可以存放任意类型的指针
1
2
3

​ 利用 void* 指针能做的事儿比较有限:拿它和别的指针比较、作为函数的输入或输出,或者赋给另外一个 void* 指针。不能直接操作 void*指针所指的对象,因为我们并不知道这个对象到底是什么类型,也就无法确定能在这个对象上做哪些操作。

# 指向指针的指针

​ 通过 * 的个数可以区分指针的级别。也就是说,* * 表示指向指针的指针,* * * 表示指向指针的指针的指针,以此类推:

int ival = 1024;
int *pi = &ival;// pi指向一个int型的数
int **ppi = &pi;//ppi指向一个int型的指针
1
2
3

​ 此处pi 是指向 int 型数的指针,而 ppi 是指向 nt 型指针的指针,下图描述了它们之、间的关系。

image-20240124211311768

# 指向指针的引用

引用本身不是一个对象,因此不能定义指向引用的指针。但指针是对象,所以存在对 指针的引用:

int i=42;
int *p;        //p是一个int型指针
int *&r= p;    //r是一个对指针p的引用
r = &i;        //r引用了一个指针,因此给r赋值&i就是令p指向 i
*r = 0;        //解引用r得到i,也就是p指向的对象,将i的值改为0
1
2
3
4
5

# 2.4const限定符

const限定符修饰的常量可以在编译时初始化也可以在运行时初始化

const int i = get size();//正确:运行时初始化
const int j = 42;//正确:编译时初始化
const int k;// 错误:k是一个未经初始化的常量
1
2
3

# 默认状态下,const对象仅在文件内有效

​ 默认情况下,const 对象被设定为仅在文件内有效。当多个文件中出现了同名的 const 变量时,其实等同于在不同文件中分别定义了独立的变量。

​ 若是希望被const对象在多个文件中声明并使用它,解决的办法就是,对于const变量不管是声明还是定义都添加extern关键字,这样只需要定义一次const变量即可

// file 1.cc定义并初始化了一个常量,该常量能被其他文件访问
extern const int bufSize = fcn();
//filel.h头文件
extern const int bufSize;//与file 1.cc中定义的bufSize是同一个
1
2
3
4

# cosnt引用

​ 可以把引用绑定到 const 对象上,就像绑定到其他对象上一样,我们称之为对常量的引用(reference toconst)。与普通引用不同的是,对常量的引用不能被用作修改它所绑定的对象:

const int ci = 1024;
const int &rl = ci; //正确:引用及其对应的对象都是常量
r1 = 42;//错误:r是对常量的引用
int &r2 = ci;// 错误:试图让一个非常量引用指向一个常量对象
1
2
3
4

​ 可以把对const的引用简称为常量引用,尽管这种说法并不正确。因为引用不是一个对象,所以我们没法让引用本身恒定不变。引用的对象是常量还是非常量可以决定其所能参与的操作,却无论如何都不会影响到引用和对象的绑定关系本身。

# 初始化和对const的引用

​ 在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。尤其,允许为一个常量引用绑定非常量的对象、字面值,甚至是个一般表达式:

int i=42;
const int &r1 = i;//允许将const int&绑定到一个普通int 对象上
const int &r2 = 42;//正确:r1是一个常量引用
const int &r3 = rl * 2;// 正确:r3是一个常量引用
int &r4 = rl *2;//错误:r4是一个普通的非常量引用
1
2
3
4
5

​ 一般情况下,引用类型必须与其所引用对象的类型保持一致,但是对const的引用是一个例外。下列将展示一个常量引用绑定到另一个类型上面会发生什么

double dval=3.14;
const int &ri =dval;
//在编译器中会把上述的代码转换为
const int temp = dval;//由双精度浮点数生成一个临时的整型常量
const int &ri = temp;//让ri绑定这个临时量
1
2
3
4
5

​ 在这种情况下,ri绑定了一个**临时量 (temporary)**对象。所谓临时量对象就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。

# 对const的引用可能引用一个并非const的对象

必须认识到,常量引用仅对引用可参与的操作做出了限定,对于引用的对象本身是不是一个常量未作限定。因为对象也可能是个非常量,所以允许通过其他途径改变它的值:

int i = 42;
int &rl = i;//引用ri绑定对象i
const int &r2//r2也绑定对象i,但是不允许通过 r2修改i的值
r1 = 0;//r1并非常量,i的值修改为0
r2 = 0;//错误:r2是一个常量引用
1
2
3
4
5

# 指针和const

​ 指向常量的指针不能用于改变其所指对象的值。要想存放常量的地址,只能使用指向常量的指针:

const double pi = 3.14;// pi 是个常量,它的值不能改变
double *ptr = &pi;//错误:ptr 是一个普通指针
const double *cptr = &pi;//正确:cptr 可以指向一个双精度常量
*cptr = 42;//错误:不能给*cptr赋值
1
2
3
4

​ 指针的类型必须与其所指对象的类型一致,但是有两个例外。第一种例外情况是允许令一个指向常量的指针指向一个非常量对象:

double dval =3.14;// dval是一个双精度浮点数,它的值可以改变
cptr = &dval;//正确:但是不能通过 cptr 改变 dval的值
1
2

​ 和常量引用一样,指向常量的指针也没有规定其所指的对象必须是一个常量。所谓指向常量的指针仅仅要求不能通过该指针改变对象的值,而没有规定那个对象的值不能通过其他途径改变。

可以简单的理解引用和指针在被const修饰的时候,两者都觉得指向了常量,所以自觉地不去改变所指对象的值。

# const指针

常量指针必须初始化,而且一旦初始化成功,则它的值(也就是存放在指针中的那个地址)就是不再改变了。

int errNumb = 0;
int *const curErr = &errNumb;//curErr将一直指向errNumb

const double pi = 3.14159;
const double *const pip = &pi;// pip 是一个指向常量对象的常量指针
1
2
3
4
5

​ 上下两种的区别是,上面那个可以改变存在该地址中的值,但是能改变地址;下面那个是既不能改变地址中的值也不能改变地址。

# 顶层const

​ 顶层const表示指针本身是个常量,底层表示指针所指觉得对象是一个常量。

int i = 0;
int *const pl = &i;//不能改变pl的值,这是一个顶层const
const int ci = 42;//不能改变ci的值,这是一个顶层const
const int *p2 = &ci;//允许改变p2的值,这是一个底层const
const int *const p3 = p2;// 靠右的const是顶层const,靠左的是底层const
const int &r = ci;//用于声明引用的const都是底层const
1
2
3
4
5
6

​ 当执行对象的拷贝操作时,常量是顶层 const 还是底层 const 区别明显。其中,顶层const 不受什么影响:

i = ci;//正确:拷贝ci的值,ci是一个顶层const,对此操作无影响
p2 = P3;//正确:p2和p3指向的对象类型相同,p3 顶层const 的部分不影响
1
2

​ 另一方面,底层 const 的限制却不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层 const 资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行:

int*p = p3;//错误:p3包含底层const 的定义,而p没有
p2 = p3;//正确:p2和p3都是底层const
p2 = &i;//正确:int*能转换成const int*
int &r = ci;//错误:普通的 int&不能绑定到 int 常量上
const int &r2 = i;//正确:const int&可以绑定到一个普通int 上
1
2
3
4
5

# constexpr和常量表达式

​ 常量表达式是指值不会改变并且在编译阶段就能得到计算结果的表达式。

C++11 constexpr和const的区别详解_在C++ 98/03标准中,只存在 const 关键字,并且其在实际使用中经常会表现出两种不同的语义-CSDN博客 (opens new window)

# constexpr变量

​ C++11新标准规定,允许将变量声明为 constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为 constexpr 的变量一定是一个常量,而且必须用常量表达式初始化:

constexpr int mf=20;// 20是常量表达式
constexpr int limit =mf + l;//mf +1是常量表达式
constexpr int sz = size();//只有当 size是一个constexpr函数时
						// 才是一条正确的声明语句
1
2
3
4

​ 一般来说,如果你认定变量是一个常量表达式,那就把它声明成constexpr类型

# 字面值类型

​ 常量表达式的值需要在编译时就得到计算,因此对声明constexpr 时用到的类型必须有所限制。因为这些类型一般比较简单,值也显而易见、容易得到,就把它们称为**“字面值类型”(literaltype)**。

​ 数据类型中,算术类型、引用和指针都是属于字面值类型。尽管指针和引用都能定义成 constexpr,但它们的初始值却受到严格限制。一个constexpr指针的初始值必须是nullptr或者0或者是存储于某个固定地址中的对象

# 指针和constexpr

​ 必须明确一点,在constexpr声明中如果定义了一个指针,限定符 constexpr仅对指针有效,与指针所指的对象无关:

const int *p = nullptr;//P是一个指向整型常量的指针
const exprint *q = nullptr; //q是一个指向整数的常量指针
1
2

pq的类型相差甚远,p 是一个指向常量的指针,而q 是一个常量指针,其中的关键在于constexpr 把它所定义的对象置为了顶层const

与其他常量指针类似,constexpr 指针既可以指向常量也可以指向一个非常量:

constexpr int *np=nullptr; // np是一个指向整数的常量指针,其值为空
int i=0;
constexpr int i= 42;// i的类型是整型常量
// i和都必须定义在函数体之外
constexpr const int *p =&i;//p是常量指针,指向整型常量i
constexpr int *pl = &j;//p1是常量指针,指向整数 i
1
2
3
4
5
6

# 2.5处理类型

# 类型别名

类型别名是一个名字,它是某种类型的同义词。

​ 有两种方法可以用于定义类型别名。第一种是传统方法使用关键字**typedef**

typedef double wages;//wages是double的同义词
typedef wages base,*p;//base是double的同义词,p是double*的同义词
1
2

​ 第二种是新标准规定的一种新的方法,使用别名声明

using SI = Sales_item; //SI是Sales_item的同义词
1
# 指针、常量和类型别名
typedef char *pstring;
const pstring *ps;// cstr是指向char的常量指针
const pstringcstr=0; // ps 是一个指针,它的对象是指向 char的常量指针
1
2
3
const chat *cstr = 0; //是对const pstring cstr的错误理解
1

​ 前后两种声明含义截然不同,前者声明了一个指向 char的常量指针,改写后的形式则声明了一个指向const char 的指针。

# auto类型说明符

​ C++11 新标准引入了**auto**类型说明符,用它就能让编译器替我们去分析表达式所属的型。和原来那些只对应一种特定类型的说明符 (比如 double) 不同,auto 让编译器过初始值来推算变量的类型。显然,auto定义的变量必须有初始值:

​ 使用auto也能在一条语句中声明多个变量。因为一条声明语句只能有一个基本数据类型,所以该语句中所有变量的初始基本数据类型都必须一样:

auto i = 0*p = &i;//正确:i是整数、P 是整型指针
auto sz=0,pi=3.14; // 错误:sz和pi的类型不一致
1
2

# decltype类型指示符

C++11新增decltype类型指示符,作用是选择并返回操作数的数据类型,此过程中编译器不实际计算表达式的值。

decltype(f()) sum = x;  // sum has whatever type f returns
1

decltype处理顶层const和引用的方式与auto有些不同,如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用)。

const int ci = 0, &cj = ci;
decltype(ci) x = 0;     // x has type const int
decltype(cj) y = x;     // y has type const int& and is bound to x
decltype(cj) z;     // error: z is a reference and must be initialized
1
2
3
4

如果decltype使用的表达式不是一个变量,则decltype返回表达式结果对应的类型。如果表达式的内容是解引用操作,则decltype将得到引用类型。如果decltype使用的是一个不加括号的变量,则得到的结果就是该变量的类型;如果给变量加上了一层或多层括号,则decltype会得到引用类型,因为变量是一种可以作为赋值语句左值的特殊表达式。

decltype((var))的结果永远是引用,而decltype(var)的结果只有当var本身是一个引用时才会是引用。

# 自定义数据结构

C++11规定可以为类的数据成员(data member)提供一个类内初始值(in-class initializer)。创建对象时,类内初始值将用于初始化数据成员,没有初始值的成员将被默认初始化。

类内初始值不能使用圆括号。

类定义的最后应该加上分号。

头文件(header file)通常包含那些只能被定义一次的实体,如类、constconstexpr变量。

头文件一旦改变,相关的源文件必须重新编译以获取更新之后的声明。

头文件保护符(header guard)依赖于预处理变量(preprocessor variable)。预处理变量有两种状态:已定义和未定义。##define指令把一个名字设定为预处理变量。##ifdef指令当且仅当变量已定义时为真,##ifndef指令当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到##endif指令为止。

##ifndef SALES_DATA_H
##define SALES_DATA_H
#include <string>
struct Sales_data
{
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};
##endif
1
2
3
4
5
6
7
8
9
10

在高级版本的IDE环境中,可以直接使用##pragma once命令来防止头文件的重复包含。

预处理变量无视C++语言中关于作用域的规则。

整个程序中的预处理变量,包括头文件保护符必须唯一。预处理变量的名字一般均为大写。

头文件即使目前还没有被包含在任何其他头文件中,也应该设置保护符。

# 2.6自定义数据结构

# 编写头文件

# 预处理器概述

​ C++程序还会用到的一项预处理功能是头文件保护符(header guard),头文件保护符依赖于预处理变量。预处理变量有两种状态:已定义和未定义。##define 指令把一个名字设定为预处理变量,另外两个指令则分别检查某个指定的预处理变量是否已经定义:##ifdef 当且仅当变量已定义时为真,##ifndef 当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到##endif 指令为止。

使用这些功能就能有效地防止重复包含的发生:

##ifndef SALES_DATA_H
##define SALES_DATA_H
#include <string>
struct Sales data{
    std::string bookNo;
    unsigned units sold = 0;
    double revenue=0.0;
};
##endif
1
2
3
4
5
6
7
8
9

​ 第一次包含 Sales data.h 时,##ifndef 的检查结果为真,预处理器将顺序执行后面的操作直至遇到##endif 为止。此时,预处理变量 SALES DATA H 的值将变为已定义,而日sales data.h也会被拷贝到我们的程序中来。后面如果再一次包含Sales data.h,则##ifndef的检查结果将为假,编译器将忽略##ifndef##endif之间的部分。

# 第三章 字符串、向量和数组

string表示可变长的字符序列,vector存放的是某种给定的类型对象的可变长序列。

​ 定长字符串

  1. 有固定的极大长度

  2. 不管是否达到了这个极大值都使用同样的数量的内存

​ 变长字符串

  1. 它的长度不是专断固定的
  2. 依赖于实际的大小使用可变的数量的内存

# 3.1 命名空间的using声明

​ 有了using声明就无须专门的前缀(形如命名空间 : : )也能使用岁序的名字了。using声明具有如下的形式:

using namespace::name;

#include <iostream>
// using 声明,当我们使用名宇 cin 时,从命名空间 std 中获取它
using std::cin;
int main()
{
    int i;
    cin >>i; // 正确:cin和std::cin 含义相同
    cout << i; //错误:没有对应的 using 声明,必须使用完整的名字
    std::cout << i; // 正确:显式地从 std中使用 cout
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11

程序中使用的每个名字都需要用独立的using声明引入。

头文件中通常不应该包含using声明。

# 3.2 标准库类型string

# 定义和初始化string对象

初始化string的方式:

方式 含义
string s1 s1被默认初始化为空串
string s2(s1)
string s2 = s1
s2s1的拷贝
string s3("val")
string s3 = "val"
s3"value"的拷贝
string s4(n, 'c') s4被初始化为连续n个字符c组成的串

​ 如果使用等号初始化一个变量,实际上执行的是拷贝初始化(copy initialization),编译器把等号右侧的初始值拷贝到新创建的对象中去。如果不使用等号,则执行的是直接初始化(direct initialization)。

# string对象上的操作

操作 含义
os << s s写入输出流os,返回os
is >> s 从输入流is读取字符串至s,字符串以空白分隔,返回is
getline(is, s) 从输入流is读取一行至s,返回is
s.empty() s为空则返回true
s.size() 返回s中的字符个数
s[n] 返回s中第n个字符的引用
s1 + s2 返回s1s2拼接后的串
s1 = s2 s2赋值给s1
s1 == s2
s1 != s2
判断s1s2中的字符是否完全一样
<<=>>= 以字典顺序比较s1s2
  1. 在执行读取操作时,string对象会自动忽略开头的空白(空格符、换行符、制表符等)并从第一个真正的字符开始读取,直到遇见下一处空白为止。
int main()
{
    string word;
    while (cin >> word)// 反复读取,直至到达文件末尾
    	cout << word << endl; // 逐个输出单词,每个单词后面紧跟一个换行
    return 0;
}
//读单词
1
2
3
4
5
6
7
8
  1. 使用getline函数可以读取一整行字符。该函数只要遇到换行符就结束读取并返回结果,如果输入的开始就是一个换行符,则得到空string。触发getline函数返回的那个换行符实际上被丢弃掉了,得到的string对象中并不包含该换行符。
int main()
{
    string line;
    // 每次读入一整行,直至到达文件末尾
    while (getline(cin, line))
    	cout << line << endl;
    return 0;
}
//读句子
1
2
3
4
5
6
7
8
9

string的empty和size操作

  1. empty用来判断string是否为空

  2. size用来判断string的大小

size函数返回string对象的长度,返回值是string::size_type类型,这是一种无符号类型。要使用size_type,必须先指定它是由哪种类型定义的。

​ 如果一个表达式中已经有了size()函数就不要再使用int了,这样可以避免混用intunsigned int可能带来的问题。

string::size_type类型

size_type体现了标准库类型与机器无关的特性。这是一种无符号数,所以要避免和有符号数一起出现在表达式中,这样往往会出现一些问题。例如:假设n是一个有符号位的负数,那么表达式s.size()<n几乎都是true。因为一个有符号位的复数转化为无符号位的数时,会转为为一个比较大的无符号值。

比较string对象

​ 两个string对象进行比较,两种情况:

  1. 两个string对象一长一短,且较短string对象的每个字符都与较长对象对应位置上的值相同。则较长对象大于较短对象。

  2. 两个string对象对应位置不同,则从前往后进行比较。比较第一个有差异的字母,根据字典集进行更改。

    string s1 = "Hello";
    string s2 = "Hello world";
    string s3 = "Hiya";
    //s1 < s2
    //s1 < s3
    //s2 < s3
    
    1
    2
    3
    4
    5
    6

    字面值和string对象相加

    ​ 首先要注意的是,字符字面值、字符串面值和string是三个东西,但是字符字面值和字符串面值可以转换为string类型

    string s1 = "hello";
    string s2 = "world";
    string s3 = "hello" + ",";//错误 两个字符串不能直接相加赋值给string
    string s4 = "hello"+","+s2;//错误 错误原因和上面的相同
    string s5 = s1+","+"world";//正确
    //可以将其视为(s1+",")+"world"
    
    1
    2
    3
    4
    5
    6

# 处理string对象中的字符

头文件cctype中的字符操作函数:

操作 含义
isalnum(c) c是字母或数字时返回true
isalpha(c) c是字母时返回true
iscntrl(c) c是控制字符时返回true
isdigit(c) c是数字时返回true
isgraph(c) c不是空格但可打印时返回true
islower(c) c是小写字母时返回true
isprint(c) c是可打印字符时返回true
ispunct(c) c是标点符号时返回true
isspace(c) c是空白时返回true
isupper(c) c是大写字母时返回true
isxdigit(c) c是十六进制数字时返回true
tolower(c) c转变为小写字母或原样返回
toupper(c) c转变为大写字母或原样返回

注意:由于C++兼容了c的标准库。c标准库中的name.h,在C++中为cname,如c中的ctype.h在C++中为cctype内容是一样的,但是在使用的过程中,建议使用风格一致。这样可以避免程序员混淆继承关系。

处理每个字符,基于for语句

在C++11中规定了一种新的for使用方法

for(declaration : expression)
    statement

string str("some string")
//每行输出str中的一个字符
for(auto c : str)
    cout << c << endl;
1
2
3
4
5
6
7

使用范围for语句改变字符串中的字符

​ 若是要改变string对象中的值,必须要将循环变量定义为引用类型。

string s("hello world");
for(auto &c : s)
{
    c = toupper(c);//toupper是将字符串中的小写的字母转化为大写
}
cout << s << endl;
//HELLO WORLD
1
2
3
4
5
6
7

通过下标访问String

​ 可以直接通过下标对string中对应的下标(索引)值进行更改

string s("some string")
if(s.empty())//在对String进行操作的时候最好进行判断
	s[0] = toupper(s[0])
// Some string
1
2
3
4

​ 也可以使用迭代对string中的元素进行更改

for(decltype(s.size()) index = 0; index != s.size() && !isspace(s[index]); index++)
{
    s[index] = toupper(s[index]);
}
//SOME string
//解释:decltype(s.size())返回的是 string::size_type类型 是一种无符号数。index != s.size() && !isspace(s[index]):循环的条件。index 不能超过字符串 s 的长度,并且当前字符不是空白字符。
1
2
3
4
5
6

# 3.3 标准库类型vector

​ 常将vector称为容器。头文件是#include <vector>。在C++中即存在类模板,也存在函数模板。而编译器根据模板创建类或函数的过程称为实例化。

vector能容纳绝大多类型的对象作为其元素

vector<int> ivec;
vector<Sales_item> Sales_vec;
vector<vector<int>> file;
//在C++11前如果vector的元素还为vector,则其定义的形式为vector<vector<int> > file;
1
2
3
4

# 定义和初始化vector对象

​ 初始化vector的方法与string类似。

​ 初始化vector对象时如果使用圆括号,可以说提供的值是用来构造(construct)vector对象的;如果使用的是花括号,则是在列表初始化(list initialize)该vector对象。

vector<int> v1(10);//v1有10个元素,每个值都是0
vector<int> v2{10};//v2有10个元素,每个值都是1
1
2

​ 可以只提供vector对象容纳的元素数量而省略初始值,此时会创建一个值初始化(value-initialized)的元素初值,并把它赋给容器中的所有元素。这个初值由vector对象中的元素类型决定。

vector<int> v1(10);//v1有10个元素,每个值都初始化为0
vector<string> v2{10};//v2有10个元素,每个都是空string对象
1
2

# 向vector对象中添加元素

push_back函数可以把一个值添加到vector的尾端。

vector<int> v2;        
for (int i = 0; i != 100; ++i)
    v2.push_back(i);    
1
2
3

范围for语句体内不应该改变其所遍历序列的大小。

# 其他vector操作

vector支持的操作:

操作 含义
v.empty() v为空则返回true
v.size() 返回v中的元素个数
v.push_back(t) t添加至的v尾部
v[n] 返回v中第n个元素的引用
v = {a, b, c} v赋值为{a, b, c}的拷贝

size函数返回vector对象中元素的个数,返回值是由vector定义的size_type类型。vector对象的类型包含其中元素的类型。

vector<int>::size_type  // 正确
vector::size_type       // 错误
1
2

vectorstring对象的下标运算符只能用来访问已经存在的元素,而不能用来添加元素。

vector<int> ivec;   // empty vector
for (decltype(ivec.size()) ix = 0; ix != 10; ++ix)
{
    ivec[ix] = ix;  // 错误:ivec不包含元素
    ivec.push_back(ix); // 正确:添加一个新元素,该元素的值是ix
}
1
2
3
4
5
6

# 3.4 迭代器介绍

​ 可以使用下标对stringvector进行访问,迭代器同样也可以做到。迭代器类似于指针,有有效迭代器和无效迭代器。有效迭代器或者指向某个元素,或者指向容器中尾元素的下一个位置。

# 使用迭代器

​ 迭代器的作用和下标类似,但是更加通用。所有标准库容器都可以使用迭代器,但是其中只有少数几种同时支持下标运算符。

​ 定义了迭代器的类型都拥有beginend两个成员函数。begin函数返回指向第一个元素的迭代器,end函数返回指向容器“尾元素的下一位置”的迭代器,通常被称作尾后迭代器(或者简称为尾迭代器。尾后迭代器仅是个标记,表示程序已经处理完了容器中的所有元素。迭代器一般为iterator类型。(可以先简单的理解迭代器的类型为auto)

标准容器迭代器的运算符:

运算符 含义
*iter 解引用iter
iter->mem 等价于(*iter).mem(可以理解为指向自己)
++iter 递增iter使其指向容器的下一个元素
--iter 递减iter使其指向容器的前一个元素
iter1 == iter2 iter1 != iter2 比较iter1iter2。若他们指向相同元素或都是尾后迭代器,则两者相等

​ 迭代器和指针类似,可以用解引用迭代器来获取所指的元素。

​ 迭代器和指针的区别:C++迭代器和指针区别_C++迭代器和指针的区别-CSDN博客 (opens new window)

string s("some string")
if(s.begin() != s.end())//确保非空
{
    auto it != s.begin();
    *it = toupper(*it);
}	
1
2
3
4
5
6

​ 注意:在for或者其他循环语句的判断条件中,最好使用!=而不是<。所有标准库容器的迭代器都定义了==!=,但是只有其中少数同时定义了<运算符。

迭代器的类型

迭代器中使用iteratorconst_iterator

如果vectorstring对象是常量,则只能使用const_iterator迭代器,该迭代器只能读元素,不能写元素。

begin和end运算符

beginend返回的迭代器具体类型由对象是否是常量决定,如果对象是常量,则返回const_iterator;如果对象不是常量,则返回iterator

vector<int> v;
const vector<int> cv;
auto it1 = v.begin();   // it1 的类型是 vector<int>::iterator
auto it2 = cv.begin();  // it2 的类型是 vector<int>::const_iterator
1
2
3
4

C++11新增了cbegincend函数,不论vectorstring对象是否为常量,都返回const_iterator迭代器。

auto it3 = v.cbegin() // it3的类型是 vector<int>::const_iterator
1

结合解引用和成员访问操作

​ C++中定义了箭头运算符(->),(*it).men可以简化为it->men。(需要关注到的是(*it).men中的括号是必不可少的,建议遇到解引用时都加上括号)。

for(auto it = text.cbegin(); it != text.cend() && !it->empty(); ++it)
{
    cout << *it << endl;
}
1
2
3
4

某些对vector对象的操作会使迭代器失效

任何可能改变容器对象容量的操作,都会使该对象的迭代器失效。

# 迭代器运算

操作 含义
iter + n iter - n iter向前或向后移动n个元素后指向某个元素或尾后位置
iter1 - iter2 获得iter1iter2的间距
>>=<<= 比较两个迭代器的位置

difference_type类型用来表示两个迭代器间的距离,这是一种带符号整数类型。

# 3.5数组

# 定义和初始化内置数组

​ 在不确定大小时用vector。定义数组时不能使用auto,一定要是确定的类型。默认情况下,数组的元素会被默认初始化。

​ 数组中有一种特殊的字符数组,当用字符串数组初始化时,在数组的最后会存有空字符。

char a1[] = {'C', '+', '+'};        // 列表初始化,不含有显式的空字符
char a2[] = {'C', '+', '+', '\0'};  // 列表初始化,含有显式的空字符
char a3[] = "C++";      // 自动添加表示字符结束的空字符
const char a4[6] = "Daniel";    // error: 没有空间可存空字符
1
2
3
4

​ 数组是不允许拷贝的。

​ 从数组的名字开始由内向外阅读有助于理解复杂数组声明的含义。

int *ptrs[10];              // ptrs是含有10个整形指针的数组
int &refs[10] = /* ? */;    // error: 不存在引用的数组
int (*Parray)[10] = &arr;   // Parray指向一个含有10个整数的数组
int (&arrRef)[10] = arr;    // arrRef引用一个含有10个整数的数组
int *(&arry)[10] = ptrs;    //arry是数组的引用,该数组含有10个指针
1
2
3
4
5

# 访问数组元素

​ 数组也可以使用下标来进行访问。使用数组下标时,一般将其定义为size_t ( 一种机器相关的无符号类型,存在cstddf头文件中 )。

​ 数组的操作和vector基本相同,在遍历的时候推荐使用范围for形式来遍历。

​ 在使用数组的过程中要特别注意下标。大多数常见的安全问题都源于缓冲区溢出错误。当数组或其他类似数据结构的下标越界并试图访问非法内存区域时,就会产生此类错误。

# 指针和数组

​ 指针和数组有紧密的联系,对数组的操作在很多时候其实时指针的操作。

stirng num = {"one","twe","three"} 
string *p = num;//指针指向的是数组第一个元素的地址
1
2

使用autodecltype关键字时,对于数组的表现并不相同。

int ia[] = {1,2,3} ;
auto ia2(ia);//此时ia2是一个整形指针,指向的时ia中的第一个元素
ia2 = 42;//错误,不能把一个常量赋值给ia2

decltype(ia) ia3 = {1,2,3}; //ia3是一个含有10个整形的数组
1
2
3
4
5

指针也是迭代器

​ 就像是迭代器遍历vector对象一样,也可以使用指针遍历数组元素。

int *e = &arr[10];//e指向的是数组尾部元素的下一个位置
for(int *b = arr; b != e; ++b)
{
    cout << *b << endl;
}
//不推荐用int *e = &arr[10];很容易出错
1
2
3
4
5
6

标准库函数begin和end

int ia[] = {0,1,2,3,4,5,6,7,8,9};   // ia是一个含有10个整数的数组
int *beg = begin(ia);   // 指向ia首元素的指针
int *last = end(ia);    // 指向arr尾元素的下一个位置的指针
1
2
3

指针运算

​ 两个指针相减的结果类型是ptrdiff_t,这是一种定义在头文件cstddef中的带符号类型。

​ 只有当两个指针指向同一个数组时才能进行关系比较

int *b = arr,*e = arr +sz;
while(b < e){
	++b;
}
1
2
3
4

解引用和指针运算的交互

​ 指针加上一个整数所得的结果还是一个指针。假设结果指针指向了一个元素,则允许解引用该结果指针。

​ 需要注意的是如果表达式中含有解引用运算符和点运算符,最好要加上括号

int ia[] = {0,2,5,6,8};

int last = *(ia + 4);// 将last初始化为ia[4]的值,也就是8
last = *ia + 4;//等价于ia[0] + 4,也就是4
1
2
3
4

# C风格的字符串

​ C风格字符串是将字符串存放在字符数组中,并以空字符结束(null terminated)。这不是一种类型,而是一种为了表达和使用字符串而形成的书写方法。

​ C++标准支持C风格字符串,但是最好不要在C++程序中使用它们。对大多数程序来说,使用标准库string要比使用C风格字符串更加安全和高效。

C风格字符串的函数:

操作 含义
strlen(p) 返回p的长度
strcmp(p1, p2) 比较p1p2。若p1 == p2,返回0;若p1 > p2,返回正数;若p1 < p2,返回负数
strcat(p1, p2) p2拼接至p1
strcpy(p1, p2) p2拷贝至p1

C风格字符串函数不负责验证其参数的正确性,传入此类函数的指针必须指向以空字符作为结尾的数组。

char ca[] = {'C','+','+'};
cout << strlen(ca) << endl;//错误,ca没有以空字符结尾
1
2

# 与旧代码的接口

​ 任何出现字符串字面值的地方都可以用以空字符结束的字符数组来代替:

  • 允许使用以空字符结束的字符数组来初始化string对象或为string对象赋值。

  • string对象的加法运算中,允许使用以空字符结束的字符数组作为其中一个运算对象(不能两个运算对象都是)。

  • string对象的复合赋值运算中,允许使用以空字符结束的字符数组作为右侧运算对象。

    ​ 不能用string对象直接初始化指向字符的指针。为了实现该功能,string提供了一个名为c_str的成员函数,返回const char*类型的指针,指向一个以空字符结束的字符数组,数组的数据和string对象一样。

string s("Hello World");    // s holds Hello World
char *str = s;  // error: can't initialize a char* from a string
const char *str = s.c_str();    // ok
1
2
3

​ 针对string对象的后续操作有可能会让c_str函数之前返回的数组失去作用,如果程序想一直都能使用其返回的数组,最好将该数组重新拷贝一份。

​ 可以使用数组来初始化vector对象,但是需要指明要拷贝区域的首元素地址和尾后地址。

int int_arr[] = {0, 1, 2, 3, 4, 5};
// ivec有6个元素,分别是int_arr中对应元素的副本
vector<int> ivec(begin(int_arr), end(int_arr));
1
2
3

​ 在新版本的C++程序中应该尽量使用vectorstring和迭代器,避免使用内置数组、C风格字符串和指针。

# 3.6多维数组

​ 严格来说,在C++中并不存在多维数组,所谓的多为数组,其实是数组的数组。

int ia[3][4];//应该理解为:首先ia是一个大小为3的数组,每个元素都要是大小为4的数组
1

多维数组初始化

​ 多维数组初始化的几种方式:

int ia[3][4] =
{  
    {0, 1, 2, 3},   
    {4, 5, 6, 7},  
    {8, 9, 10, 11}  
};
int ib[3][4] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
// 显式初始化每行的首字母
int ic[3][4] = {{ 0 }, { 4 }, { 8 }};
// 显式初始化了第1行的元素
int id[3][4] = {0, 3, 6, 9};
1
2
3
4
5
6
7
8
9
10
11

多维数组的下标引用

​ 可以使用下标访问多维数组的元素,数组的每个维度对应一个下标运算符。如果表达式中下标运算符的数量和数组维度一样多,则表达式的结果是给定类型的元素。如果下标运算符数量比数组维度小,则表达式的结果是给定索引处的一个内层数组。

// 用arr的首元素为ia最后一个元素赋值
ia[2][3] = arr[0][0][0];
int (&row)[4] = ia[1];  // 把row绑定到ia的第二个4元素数组上
1
2
3

使用范围for语句处理多维数组

​ 使用范围for语句处理多维数组时,为了避免数组被自动转换成指针,语句中的外层循环控制变量必须声明成引用类型。

for (const auto &row : ia)  // 对外层的每一个函数
    for (auto col : row)    // 对内层的每一个函数
        cout << col << endl;
1
2
3

​ 如果row不是引用类型,编译器初始化row时会自动将数组形式的元素转换成指向该数组内首元素的指针,这样得到的row就是int*类型,而之后的内层循环则试图在一个int*内遍历,程序将无法通过编译。

for (auto row : ia)
    for (auto col : row)
1
2

​ 使用范围for语句处理多维数组时,除了最内层的循环,其他所有外层循环的控制变量都应该定义成引用类型。

因为多维数组实际上是数组的数组,所以由多维数组名称转换得到的指针指向第一个内层数组。

多维数组和指针

int ia[3][4];       // 大小为3的数组,每个元素是含有4个整数的数组
int (*p)[4] = ia;   // p指向含有4个整数
p = &ia[2];         // p指向ia的尾元素
1
2
3

​ 声明指向数组类型的指针时,必须带有圆括号。

int *ip[4];     // 正型指针的数组
int (*ip)[4];   // 指向含有4个整数的数组
1
2

​ 使用autodecltype能省略复杂的指针定义。

// 输出ia中每个元素的值,每个内层数组各占一行
// p指向含有4个整形的数组
for (auto p = ia; p != ia + 3; ++p)
{
    // q指向4个整形数组的首元素,也就是说,q指向一个整数
    for (auto q = *p; q != *p + 4; ++q)
        cout << *q << ' ';
    cout << endl;
}
1
2
3
4
5
6
7
8
9

# 第四章 表达式

# 4.1基础

# 基本概念

​ C++定义了n元运算符,很多的符号既能表示一元运算符也能表示二元运算符。

​ 对于含有多个运算符的表达式,要注意运算符的优先级。在运算的过程中要注意运算对象的类型转化。

​ 我们可以重载运算符,包括运算对象的类型和返回值的类型,都是由该运算符定义的;但是运算对象的个数、运算符的优先级和结合律都是无法改变的。

左值和右值

  • 右值: 使用的是它的值(内容), 并忽视"指纹"(这个右值的对象属性, 内存地址什么的)
  • 左值: 使用的是它的内存地址, 此时它的值和内存地址同样重要

​ 在左值和右值的使用过程中,有一个重要的原则,在需要右值的地方可以用左值来代替,但是不能把右值当作左值运算(也就是位置)使用。

以下有几种我们熟悉的运算符要用到左值

  • 赋值运算符需要一个非常量左值作为其左侧运算对象,返回结果也是一个左值。
  • 取地址符作用于左值运算对象,返回指向该运算对象的指针,该指针是一个右值。
  • 内置解引用运算符、下标运算符、迭代器解引用运算符、stringvector的下标运算符都返回左值。
  • 内置类型和迭代器的递增递减运算符作用于左值运算对象。前置版本返回左值,后置版本返回右值。

​ 使用关键字 decltype的时候,左值和右值也有所不同。如果表达式的求值结果是左值,decltype作用于该表达式(不是变量)得到一个引用类型。举个例子,假定p的类型是 int*,因为解引用运算符生成左值,所以decltype(*p)的结果是int&。另一方面,因为取地址运算符生成右值,所以 decltype(&p)的结果是int**,也就是说,结果是一个指向整型指针的指针。

# 优先级和结合律

​ 复合表达式(compound expression)指含有两个或多个运算符的表达式。优先级与结合律决定了运算对象的组合方式。

​ 括号无视优先级与结合律,表达式中括号括起来的部分被当成一个单元来求值,然后再与其他部分一起按照优先级组合。

# 求值顺序

​ 优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值。在大多数情况下,不会明确指定求值的顺序。也就是说虽然有优先级的存在,但是程序在运算的过程中,如果优先级存在同级,并不能明确的知道顺序。或者并没有明确规定优先级的,如"<<"

int i = 0;
cout << i << " " << ++i << endl;//未定义的
//有可能式1 1,也可以是 0 1
//因为不知道编译器是先运算"<<"还是"++"
1
2
3
4

有四种运算符明确规定了运算对象:

  1. 逻辑与(&&)运算符,它规定先求左侧运算对象的值。只有当左侧对象的值为真时才继续进行右侧的运算
  2. 逻辑或(||)运算符,当且仅当左侧运算对象为假时才对右侧运算对象求值
  3. 条件(?:)运算符,详见4.7。
  4. 逗号(,)运算符,含有两个运算对象,按照从左向右的顺序依次求值,最后返回右侧表达式的值。

处理复合表达式时建议遵循以下两点:

  • 不确定求值顺序时,使用括号来强制让表达式的组合关系符合程序逻辑的要求。
  • 如果表达式改变了某个运算对象的值,则在表达式的其他位置不要再使用这个运算对象。

​ 当改变运算对象的子表达式本身就是另一个子表达式的运算对象时,第二条规则无效。如*++iter,递增运算符改变了iter的值,而改变后的iter又是解引用运算符的运算对象。类似情况下,求值的顺序不会成为问题。

# 4.2算术运算符

算术运算符(左结合律):

运算符 功能 用法
+ 一元正号 +expr
- 一元负号 -expr
* expr * expr
/ expr / expr
% 取余 expr % expr
+ expr + expr
- expr - expr

在除法运算中,C++语言的早期版本允许结果为负数的商向上或向下取整,C++11新标准则规定商一律向0取整(即直接去除小数部分)。

# 4.3逻辑和关系运算符

​ 关系运算符作用于算术类型和指针类型,逻辑运算符作用于任意能转换成布尔值的类型。逻辑运算符和关系运算符的返回值都是布尔类型。

结合律 运算符 功能 用法
! 逻辑非 !expr
< 小于 expr < expr
<= 小于等于 expr <= expr
> 大于 expr > expr
>= 大于等于 expr >= expr
== 相等 expr == expr
!= 不相等 expr != expr
&& 逻辑与 expr && expr
|| 逻辑或 expr || expr

逻辑与(logical AND)运算符&&和逻辑或(logical OR)运算符||都是先计算左侧运算对象的值再计算右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果时才会去计算右侧运算对象的值。这种策略称为短路求值(short-circuit evaluation)。

  • 对于逻辑与运算符来说,当且仅当左侧运算对象为真时才对右侧运算对象求值。
  • 对于逻辑或运算符来说,当且仅当左侧运算对象为假时才对右侧运算对象求值。

进行比较运算时,除非比较的对象是布尔类型,否则不要使用布尔字面值truefalse作为运算对象。

# 4.4赋值运算符

赋值运算符=的左侧运算对象必须是一个可修改的左值。

C++11新标准允许使用花括号括起来的初始值列表作为赋值语句的右侧运算对象。

vector<int> vi;
vi = {0,1,2,3,4,5,6,7,8,9}; 
1
2

赋值运算符满足右结合律。

int ival, jval;
ival = jval = 0;    // 正确,都被赋值为0.但是我认为这种赋值方法不可取
1
2

因为赋值运算符的优先级低于关系运算符的优先级,所以在条件语句中,赋值部分通常应该加上括号。

不要混淆相等运算符==和赋值运算符=

复合赋值运算符包括+=-=*=/=%=<<=>>=&=^=|=。任意一种复合运算都完全等价于a = a op b

# 4.5递增和递减运算符

递增(++)和递减(--)运算符是为对象加1或减1的简洁书写形式。很多不支持算术运算的迭代器,可以使用递增和递减运算符。

递增和递减运算符分为前置版本和后置版本:

  • 前置版本首先将运算对象加1(或减1),然后将改变后的对象作为求值结果。
  • 后置版本也会将运算对象加1(或减1),但求值结果是运算对象改变前的值的副本。
int i = 0, j;
j = ++i;    // j = 1, i = 1
j = i++;    // j = 1, i = 2
1
2
3

​ 除非必须,否则不应该使用递增或递减运算符的后置版本。后置版本需要将原始值存储下来以便于返回修改前的内容,如果我们不需要这个值,那么后置版本的操作就是一种浪费。

在某些语句中混用解引用和递增运算符可以使程序更简洁。

cout << *iter++ << endl;//输出当前的值,并把iter指针往后移动一位
1

# 4.6成员访问运算符

点运算符.和箭头运算符->都可以用来访问成员,表达式ptr->mem等价于(*ptr).mem

string s1 = "a string", *p = &s1;
auto n = s1.size();        //运行string对象
n = (*p).size();           //运行p所指对象的size成员
n = p->size();             //等价于(*p).size()
1
2
3
4

# 4.7条件运算符

条件运算符的使用形式如下:

cond ? expr1 : expr2;
1

其中cond是判断条件的表达式,如果cond为真则对expr1求值并返回该值,否则对expr2求值并返回该值。

只有当条件运算符的两个表达式都是左值或者能转换成同一种左值类型时,运算的结果才是左值,否则运算的结果就是右值。

条件运算符可以嵌套,但是考虑到代码的可读性,运算的嵌套层数最好不要超过两到三层。

条件运算符的优先级非常低,因此当一个长表达式中嵌套了条件运算子表达式时,通常需要在它两端加上括号。

# 4.8位运算符

位运算符(左结合律):

运算符 功能 用法
~ 位求反 ~expr
<< 左移 expr << expr
>> 右移 expr >> expr
& 位与 expr & expr
^ 位异或 expr ^ expr
| 位或 expr | expr

在位运算中符号位如何处理并没有明确的规定,所以建议仅将位运算符用于无符号类型的处理。

​ 左移运算符<<在运算对象右侧插入值为0的二进制位。右移运算符>>的行为依赖于其左侧运算对象的类型:如果该运算对象是无符号类型,在其左侧插入值为0的二进制位;如果是带符号类型,在其左侧插入符号位的副本或者值为0的二进制位,如何选择视具体环境而定。

# 4.9sizeof运算符

sizeof运算符返回一个表达式或一个类型名字所占的字节数,返回值是size_t类型。

sizeof的运算对象中解引用一个无效指针仍然是一种安全的行为,因为指针实际上并没有被真正使用。

sizeof运算符的结果部分依赖于其作用的类型:

  • char或者类型为char的表达式执行sizeof运算,返回值为1。
  • 对引用类型执行sizeof运算得到被引用对象所占空间的大小。
  • 对指针执行sizeof运算得到指针本身所占空间的大小。
  • 对解引用指针执行sizeof运算得到指针指向的对象所占空间的大小,指针不需要有效。
  • 对数组执行sizeof运算得到整个数组所占空间的大小。
  • stringvector对象执行sizeof运算只返回该类型固定部分的大小,不会计算对象中元素所占空间的大小。

# 4.10逗号运算符

逗号运算符,含有两个运算对象,按照从左向右的顺序依次求值,最后返回右侧表达式的值。逗号运算符经常用在for循环中。

vector<int>::size_type cnt = ivec.size();
// 将把从size到1的值赋给ivec的元素
for(vector<int>::size_type ix = 0; ix != ivec.size(); ++ix, --cnt)
    ivec[ix] = cnt;
1
2
3
4

# 4.11类型转换

​ 无须程序员介入,会自动执行的类型转换叫做隐式转换。

# 算术转换

​ 把一种算术类型转换成另一种算术类型叫做算术转换。

​ 整型提升,负责把小整数类型转换成较大的整数类型。

# 其他隐式类型转换

​ 在大多数表达式中,数组名字自动转换成指向数组首元素的指针。

​ 常量整数值0或字面值nullptr能转换成任意指针类型;指向任意非常量的指针能转换成void*;指向任意对象的指针能转换成const void*

​ 任意一种算术类型或指针类型都能转换成布尔类型。如果指针或算术类型的值为0,转换结果是false,否则是true

​ 指向非常量类型的指针能转换成指向相应的常量类型的指针。

# 显式转换

​ 显式类型转换也叫做强制类型转换(cast)。虽然有时不得不使用强制类型转换,但这种方法本质上是非常危险的。建议尽量避免强制类型转换。

​ 命名的强制类型转换(named cast)形式如下:

cast-name<type>(expression);
1

​ 其中type是转换的目标类型,expression是要转换的值。如果type是引用类型,则转换结果是左值。cast-namestatic_castdynamic_castconst_castreinterpret_cast中的一种,用来指定转换的方式。

  • dynamic_cast支持运行时类型识别。

  • 任何具有明确定义的类型转换,只要不包含底层const,都能使用static_cast

  • const_cast只能改变运算对象的底层const,不能改变表达式的类型。同时也只有const_cast能改变表达式的常量属性。const_cast常常用于函数重载。

  • reinterpret_cast通常为运算对象的位模式提供底层上的重新解释。

    早期版本的C++语言中,显式类型转换包含两种形式:

type (expression);    // 函数形式的强制类型转化
(type) expression;    // c语言风格的强制类型转化
1
2

# 第五章 语句

# 5.1简单语句

​ 一个表达式语句以分号结尾。复合语句用花括号框起来。

​ 使用空语句时应该加上注释,从而令读这段代码的人知道该语句是有意省略的。

# 5.2语句作用域

​ 可以在ifswitchwhilefor语句的控制结构内定义变量,这些变量只在相应语句的内部可见,一旦语句结束,变量也就超出了其作用范围。

# 5.3条件语句

# if语句

if语句的形式:

if (condition)
{
    statement;
}
1
2
3
4

if-else语句的形式:

if (condition)
{
    statement 1;
}
else
{
    statement 2;
}
1
2
3
4
5
6
7
8

其中condition是判断条件,可以是一个表达式或者初始化了的变量声明。condition必须用圆括号括起来。

  • 如果condition为真,则执行statement。执行完成后,程序继续执行if语句后面的其他语句。
  • 如果condition为假,则跳过statement。对于简单if语句来说,程序直接执行if语句后面的其他语句;对于if-else语句来说,程序先执行statement2,再执行if语句后面的其他语句。

if语句可以嵌套,其中else与离它最近的尚未匹配的if相匹配。

# switch语句

switch语句的形式:

switch (number)
{
    case 1:
    {
        statement 1;
        break;
    }
    case 2:
    {
        statement 2;
        break;
    }
    default:
    {
        statement 3;
        break;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

switch语句先对括号里的表达式求值,值转换成整数类型后再与每个case标签(case label)的值进行比较。如果表达式的值和某个case标签匹配,程序从该标签之后的第一条语句开始执行,直到到达switch的结尾或者遇到break语句为止。case标签必须是整型常量表达式(存疑,认为字符型常量也行,单字符应该是会自动转int)。

​ 通常情况下每个case分支后都有break语句。如果确实不应该出现break语句,最好写一段注释说明程序的逻辑。

​ 尽管switch语句没有强制要求在最后一个case标签后写上break,但为了安全起见,最好添加break。这样即使以后增加了新的case分支,也不用再在前面补充break语句了。

switch语句中可以添加一个default标签,如果没有任何一个case标签能匹配上switch表达式的值,程序将执行default标签后的语句。

​ 即使不准备在default标签下做任何操作,程序中也应该定义一个default标签。其目的在于告诉他人我们已经考虑到了默认情况,只是目前不需要实际操作。

​ 不允许跨过变量的初始化语句直接跳转到该变量作用域内的另一个位置。如果需要为switch的某个case分支定义并初始化一个变量,则应该把变量定义在块内。

​ 允许三种情况

  1. 可在default中定义;
  2. 可在最后一个case中定义;
  3. 在某个特殊的case中定义变量,但必须引入块语句。
case true:
 switch (a)
 {
   case 1:
    int m = 1;//(1)编译出错
   break;
   case 2:
  {
   int n = 3;//(2)编译通过,引入块语句
  }
  break;
  case 3:
   int c =3;//(3)编译通过,最后一个case
  cout<<"ss"<<endl;//输出ss
  break;
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  1. switch后面的括号里面只能是自动类型转换成int,(short、 char、int、byte)
  2. case后面只能跟自动类型转换成int的常量或者常量 表达式
  3. case后面的常量必须唯一
  4. case和default顺序可以交换,前提是case和default加加了break
  5. 在case后面如果有多条语句,可以不用加花括号

# 5.4迭代语句

# while语句

while语句的形式:

while (condition)
{
    statement;
}
1
2
3
4

​ 只要condition的求值结果为true,就一直执行statement(通常是一个块)。condition不能为空,如果condition第一次求值就是falsestatement一次都不会执行。

​ 定义在while条件部分或者循环体内的变量每次迭代都经历从创建到销毁的过程。

​ 在不确定迭代次数,或者想在循环结束后访问循环控制变量时,使用while比较合适。

# 传统的for语句

for语句的形式:

for (initializer; condition; expression)
{
    statement;
}
1
2
3
4

​ 一般情况下,initializer负责初始化一个值,这个值会随着循环的进行而改变。condition作为循环控制的条件,只要condition的求值结果为true,就执行一次statement。执行后再由expression负责修改initializer初始化的变量,这个变量就是condition检查的对象。如果condition第一次求值就是falsestatement一次都不会执行。initializer中也可以定义多个对象,但是只能有一条声明语句,因此所有变量的基础类型必须相同。

for语句头中定义的对象只在for循环体内可见。

# 范围for语句

范围for语句的形式:

for (declaration : expression)
{
    statement;
}
1
2
3
4

​ 其中expression表示一个序列,拥有能返回迭代器的beginend成员。declaration定义一个变量,序列中的每个元素都应该能转换成该变量的类型(可以使用auto)。如果需要对序列中的元素执行写操作,循环变量必须声明成引用类型。每次迭代都会重新定义循环控制变量,并将其初始化为序列中的下一个值,之后才会执行statement

# do-while语句

do-while语句的形式:

do
{
    statement;
}
while (condition);
1
2
3
4
5

​ 计算condition的值之前会先执行一次statementcondition不能为空。如果condition的值为false,循环终止,否则重复执行statement

​ 因为do-while语句先执行语句或块,再判断条件,所以不允许在条件部分定义变量。

# 5.5跳转语句

跳转语句中断当前的执行过程。

# break语句

break语句只能出现在迭代语句或者switch语句的内部,负责终止离它最近的whiledo-whilefor或者switch语句,并从这些语句之后的第一条语句开始执行。

# continue语句

continue语句只能出现在迭代语句的内部,负责终止离它最近的循环的当前一次迭代并立即开始下一次迭代。和break语句不同的是,只有当switch语句嵌套在迭代语句内部时,才能在switch中使用continue

continue语句中断当前迭代后,具体操作视迭代语句类型而定:

  • 对于whiledo-while语句来说,继续判断条件的值。
  • 对于传统的for语句来说,继续执行for语句头中的第三部分,之后判断条件的值。
  • 对于范围for语句来说,是用序列中的下一个元素初始化循环变量。

# try语句块和异常处理

异常(exception)是指程序运行时的反常行为,这些行为超出了函数正常功能的范围。当程序的某一部分检测到一个它无法处理的问题时,需要使用异常处理(exception handling)。

异常处理机制包括throw表达式(throw expression)、try语句块(try block)和异常类(exception class)。

  • 异常检测部分使用throw表达式表示它遇到了无法处理的问题(throw引发了异常)。
  • 异常处理部分使用try语句块处理异常。try语句块以关键字try开始,并以一个或多个catch子句(catch clause)结束。try语句块中代码抛出的异常通常会被某个catch子句处理,catch子句也被称作异常处理代码(exception handler)。
  • 异常类用于在throw表达式和相关的catch子句之间传递异常的具体信息。

# throw表达式

throw表达式包含关键字throw和紧随其后的一个表达式,其中表达式的类型就是抛出的异常类型。

# try语句块

try语句块的通用形式:

try
{
    program-statements;
}
catch (exception-declaration)
{
    handler-statements;
}
catch (exception-declaration)
{
    handler-statements;
}
1
2
3
4
5
6
7
8
9
10
11
12

try语句块中的program-statements组成程序的正常逻辑,其内部声明的变量在块外无法访问,即使在catch子句中也不行。catch子句包含关键字catch、括号内一个对象的声明(异常声明,exception declaration)和一个块。当选中了某个catch子句处理异常后,执行与之对应的块。catch一旦完成,程序会跳过剩余的所有catch子句,继续执行后面的语句。

​ 如果最终没能找到与异常相匹配的catch子句,程序会执行名为terminate的标准库函数。该函数的行为与系统有关,一般情况下,执行该函数将导致程序非正常退出。类似的,如果一段程序没有try语句块且发生了异常,系统也会调用terminate函数并终止当前程序的执行。

# 标准异常

异常类分别定义在4个头文件中:

  • 头文件exception定义了最通用的异常类exception。它只报告异常的发生,不提供任何额外信息。

  • 头文件stdexcept定义了几种常用的异常类。

    类型 含义
    exception 最常见的错误
    runtime_error 运行时错误
    overflow_error 计算上溢
    underflow_error 计算下溢
    range_error 数值超出有意义的值域
    logic_error 程序逻辑错误
    domain_error 参数值不存在
    invalid_argument 无效参数
    out_of_range 试图创建超出该类型最大长度的对象
    length_error 数值超出有效范围
  • 头文件new定义了bad_alloc异常类。

  • 头文件type_info定义了bad_cast异常类。

image-20240328214641176

# 第六章 函数

# 6.1 函数基础

函数执行=已定义好的函数+函数调用符

函数调用符(括号)

{1.作用于调用表达式

(调用表达式:2.调用表达式是函数或者函数指针)

3.括号内是实参列表,实参列表可初始化函数形参

}

形参和实参

​ 实参是形参的初始值。第一个实参初始化第一个形参,第二个实参初始化第二个形参,以此类推。尽管存在实参和形参的对应关系,但是并没有规定实参的求职顺序

int a = f1() * f2();
//并不能确定先计算哪个函数
1
2

函数的形参列表

​ 在C++中要想定义一个不带形参的函数,最常用的方法是书写一个空的形参列表。但是为了与C兼容,也可以使用关键字void表示函数没有形参:

void f1()//隐式地定义空形参列表

void f2(void)//显示地定义形参列表
1
2
3

​ 函数体内部仍可以写大括号, 只属于最大的那个括号而不属于其他任何一个括号的地区就是最外层作用域(也就是说, 在函数内部的与形参同名的变量定义时必须要用大括号藏起来)

最外层作用域就是函数括号下

void f(int a)           // function has a parameter
{                       // beginning of function scope
    int b;              // OK: local variable
    {                   // beginning of inner block
        int a;          // OK: hides parameter
        int b;          // OK: hides outer variable
    }                   // end of inner block
    int a;              // Error: can't have same name as parameter
}
1
2
3
4
5
6
7
8
9

https://stackoverflow.com/questions/30125671/what-does-local-variables-at-the-outermost-scope-of-the-function-may-not-use-th

函数返回值

C++函数不能返回数组或者函数(Haskell可以)

作为补偿, C++可以返回指向数组或者函数的指针(或引用)

# 局部对象

​ C++中名字有作用域,对象有生命周期。

  • 名字的作用域是程序文本的一部分,名字在其中可见。
  • 对象的生命周期是程序执行过程中对该对象存在的一段时间

形参和函数体内部定义的变量统称为局部变量。

局部变量隐藏外层作用域的特性

  • 作用域包括: loops作用域;(loops内部生成一个局部变量) 函数中的内部作用域(函数中是可以写大括号来增加一个作用域的), 它与函数内部是相隔绝的, 有点像盒子中的另一个盒
  • 隐藏行为: 当变量间出现重名的情况下,作用域小的屏蔽作用域大的

自动对象

​ 我们把只存在于块执行期间的对象称为自动对象。

C++ 局部变量和自动对象有什么区别? - 知乎 (zhihu.com) (opens new window)

局部静态变量

​ 可以将局部变量定义成static类型从而获得这样的对象。局部静态对象在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。

# 函数声明

​ 函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。函数声明也称作函数原型。

在头文件中进行函数声明

​ 我们建议变量在头文件中声明,在源文件中定义。与之类似,函数也应该在头文件中声明而在源文件中定义。

# 分离式编译

​ 分离式编译允许我们把程序按照逻辑关系分割到几个文件中去,每个文件独立编译。这一过程通常会产生后缀名是.obj.o的文件,该文件包含对象代码。之后编译器把对象文件链接在一起形成可执行文件。

# 6.2参数传递

形参初始化的机理与变量初始化一样。

形参的类型决定了形参和实参交互的方式:

  • 当形参是引用类型时,它对应的实参被引用传递,函数被传引用调用。引用形参是它对应实参的别名。
  • 当形参不是引用类型时,形参和实参是两个相互独立的对象,实参的值会被拷贝给形参(值传递),函数被传值调用。

# 传值参数

​ 当初始化一个非引用类型的变量时,初始值被拷贝给变量。

指针形参

​ 指针的行为和其他非引用类型一样。当执行拷贝操作时,拷贝的是指针的值。

int n=0,i=42;
int *p= &n,*q= &i;// P指向n;q指向i
*p42;// n的值改变;p不变
p=q;// p现在指向了i;但是i和n的值都不变
1
2
3
4

# 传引用参数

​ 使用引用传参,函数可以改变实参的值。

使用引用避免拷贝

​ 拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括 IO 类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。

​ 如果函数无须改变引用形参的值,最好将其声明为常量引用

使用引用形参返回额外信息

​ 一个函数只能返回一个值,但利用引用形参可以使函数返回额外信息。

# const形参和实参

​ 当形参有顶层const时,传递给它常量对象或非常量对象都是可以的。

void fcn(const int i)//fcn能够读取i,但是不能向i写值
//调用fcn函数时,既可以传入const int也可以传入int
1
2

​ 在C++中,允许我们定义若干具有相同名字的函数,不过前提时不同函数的形参列表应该有明显区别。

​ 可以使用非常量对象初始化一个底层const形参,但是反过来不行。

int i = 42;
const int *cp=si;// 正确:但是cp 不能改变i
const int &r=; // 正确:但是r不能改变
const int &r2=42;// 正确
int *p=cp;// 错误:p的类型和 cp 的类型不匹配
int &r3 = r;// 错误:r3 的类型和r的类型不匹配
int &r4 = 42;// 错误:不能用宇面值初始化一个非常量引用
//------------------------------------------------
//将同样的初始化规则应用到参数传递上可得到如下形式
int i = 0;
const int ci = i;
string::size type ctr = 0;
reset(&i);// 调用形参类型是 int*的 reset 函数
reset(&ci);// 错误:不能用指向 const int 对象的指针初始化 int*
reset(i);// 调用形参类型是 int&的 reset 函数
reset(ci);// 错误:不能把普通引用绑定到 const 对象 ci 上
reset(42);// 错误:不能把普通应用绑定到字面值上
reset(ctr);// 错误:类型不匹配,ctr 是无符号类型
// 正确:find char 的第一个形参是对常量的引用
find char("Hello World!", o',ctr);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

尽量使用常量引用

​ 把函数不会改变的形参定义成普通引用会极大地限制函数所能接受的实参类型,同时也会给别人一种误导,即函数可以修改实参的值。

# 数组形参

​ 因为不能拷贝数组,所以无法以值传递的方式使用数组参数,但是可以把形参写成类似数组的形式。

// 每个函数都有一个const int*类型的形参
void print(const int*);
void print(const int[]);    // 可以看出来,函数的意图是作用于一个数组
void print(const int[10]);  // 这里的维度表示我们期望数组含有多少元素,实际不一定
1
2
3
4

​ 因为数组会被转换成指针,所以当我们传递给函数一个数组时,实际上传递的是指向数组首元素的指针。

​ 因为数组是以指针的形式传递给函数的,所以一开始函数并不知道数组的确切尺寸,调用者应该为此提供一些额外信息。

​ 以数组作为形参的函数必须确保使用数组时不会越界。

​ 三种给函数提供数组的确切尺寸:

  1. 使用标记指定数组长度。如:C风格字符数组中最后一个字符后面跟着一个空字符。
  2. 使用标准库规范。传递指向数组首元素和尾后元素的指针。
  3. 显示传递一个表示数组大小的形参。即在传参时直接传入数组的长度。

数组引用形参

​ C++语言允许将变量定义成数组的引用基于同样的道理形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上:

void print(int (&arr)[10])//两端的括号是必不可少的
{
    for(auto elem : arr)
        cout << elem << endl;
}
1
2
3
4
5

传递多维数组

​ C++中没有真正意义上的多维数组,所谓的多维数组也就是在数组中存了数组,所有在传递多维数组时真正传递的时指向数组首元素的指针。

//matrix指向数组的首元素,该数组的元素时由10个整数构成的数组
void print(int (*matrix)[10], int rowSize)
1
2

# main:处理命令行选项

可以在命令行中向main函数传递参数,形式如下:

int main(int argc, char *argv[]) { /*...*/ }
int main(int argc, char **argv) { /*...*/ }
1
2

第二个形参argv是一个数组,数组元素是指向C风格字符串的指针;第一个形参argc表示数组中字符串的数量。

当实参传递给main函数后,argv的第一个元素指向程序的名字或者一个空字符串,接下来的元素依次传递命令行提供的实参。最后一个指针之后的元素值保证为0。

# 含有可变形参的函数

C++11新标准提供了两种主要方法处理实参数量不定的函数。

  • 如果实参类型相同,可以使用initializer_list标准库类型。

    void error_msg(initializer_list<string> il)
    {
        for (auto beg = il.begin(); beg != il.end(); ++beg)
        cout << *beg << " " ;
        cout << endl;
    }
    
    1
    2
    3
    4
    5
    6
  • 如果实参类型不同,可以定义可变参数模板。

​ C++还可以使用省略符形参传递可变数量的实参,但这种功能一般只用在与C函数交换的接口程序中。

initializer_list是一种标准库类型,定义在头文件initializer_list中,表示某种特定类型的值的数组。

initializer_list提供的操作:

操作 含义
initializer_list<T> lst lst被默认初始化为T类型元素的空列表
initializer_list<T> lst {a, b, c} lst中的元素为{a, b, c}const拷贝
initializer_list<T> lst2(lst1) lst2 = lst1 拷贝或赋值initializer_list并不会拷贝其中的元素,它们被lst1lst2共享
lst.size() 返回lst中的元素数量
lst.begin() 返回指向lst中首元素的指针
lst.end() 返回指向lst中尾元素下一位置的指针

​ 拷贝或赋值一个initializer_list对象不会拷贝列表中的元素。拷贝后,原始列表和副本共享元素。

initializer_list对象中的元素永远是常量值。

​ 如果想向initializer_list形参传递一个值的序列,则必须把序列放在一对花括号内。

if (expected != actual)
    error_msg(ErrCode(42), {"functionX", expected, actual});
else
    error_msg(ErrCode(0), {"functionX", "okay"});
1
2
3
4

​ 因为initializer_list包含beginend成员,所以可以使用范围for循环处理其中的元素。

​ 省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。通常,省略符形参不应该用于其他目的。

​ 省略符形参应该仅仅用于C和C++通用的类型,大多数类类型的对象在传递给省略符形参时都无法正确拷贝。

# 6.3返回类型和return语句

# 无返回值函数

​ 没有返回值的return语句只能用在返回类型时void的函数中。

​ 通常情况下,如果void函数想在其中间位置提前退出,可以使用return语句。

​ 一个返回类型是void的函数也能使用return语句的第二种形式,不过此时return语句的expression必须是另一个返回void的函数。

​ 强行令void函数返回其他类型的表达式将产生编译错误。

# 有返回值函数

​ return 语句的第二种形式提供了函数的结果。只要函数的返回类型不是 void,则该函数内的每条 return 语句必须返回一个值。return 语句返回值的类型必须与函数的返回类型相,或者能隐式地转换成函数的返回类型。

​ 在含有return 语句的循环后面应该也有一条 return 语句,如果没有的话该 程序就是错误的。很多编译器都无法发现此类错误。

值时如何被返回的

​ 返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用一个临时量,该临时量就是函数调用的结果。

​ 如果函数返回引用类型,则该引用仅仅是它所引用对象的一个别名。

不要返回局部对象的引用或指针

​ 函数不应该返回局部对象的指针或引用,因为一旦函数完成,局部对象将被释放。

// 严重错误:这个函数试图返回局部对象的引用
const string &manip()
{
    string ret;
    // 以某种方式改变一下ret
    if (!ret.empty())
        return ret;   // 错误:返回局部对象的引用
    else
        return "Empty";   // 错误:“Empty”是一个局部临时量
}
1
2
3
4
5
6
7
8
9
10

返回类类型的函数和调用运算符

​ 和其他运算符一样,调用运算符也有优先级和结合律。如果函数返回指针、引用或类的对象,则可以使用函数调用的结果访问结果对象的成员。

引用返回左值

​ 调用一个返回引用的函数会得到左值,其他返回类型得到右值。

列表初始化返回值

​ C++11规定,函数可以返回用花括号包围的值的列表。同其他返回类型一样,列表也用于初始化表示函数调用结果的临时量。如果列表为空,临时量执行值初始化;否则返回的值由函数的返回类型决定。

  • 如果函数返回内置类型,则列表内最多包含一个值,且该值所占空间不应该大于目标类型的空间。

  • 如果函数返回类类型,由类本身定义初始值如何使用。

    vector<string> process()
    {
        // . . .
        // expected 和 actual 是 string 对象
        if (expected.empty())
            return {};  // 返回一个空vector对象
        else if (expected == actual)
            return {"functionX", "okay"}; // 返回列表初始化的vector对象
        else
            return {"functionX", expected, actual}
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

主函数main的返回值

main函数可以没有return语句直接结束。如果控制流到达了main函数的结尾处并且没有return语句,编译器会隐式地插入一条返回0的return语句。

main函数的返回值可以看作是状态指示器。返回0表示执行成功,返回其他值表示执行失败,其中非0值的具体含义依机器而定。

​ 为了使main函数的返回值与机器无关,头文件cstdlib定义了EXIT_SUCCESSEXIT_FAILURE这两个预处理变量,分别表示执行成功和失败。

int main()
{
    if (some_failure)
        return EXIT_FAILURE; // 定义在cstdlib
    else
        return EXIT_SUCCESS; // 定义在cstdlib
}
1
2
3
4
5
6
7

​ 建议使用预处理变量EXIT_SUCCESSEXIT_FAILURE表示main函数的执行结果。

递归

​ 如果一个函数调用了它自身,不管这种调用是直接的还是间接的,都称该函数为递归函数(recursive function)。

​ 在递归函数中,一定有某条路径是不包含递归调用的,否则函数会一直递归下去,直到程序栈空间耗尽为止。

​ 相对于循环迭代,递归的效率较低。但在某些情况下使用递归可以增加代码的可读性。循环迭代适合处理线性问题(如链表,每个节点有唯一前驱、唯一后 继),而递归适合处理非线性问题(如树,每个节点的前驱、后继不唯一)。

main函数不能调用它自身。

# 返回数组指针

因为数组不能被拷贝,所以函数不能返回数组,但可以返回数组的指针或引用。

typedef int arrT[10];	//arrT是一个类型的别名,它表示的类型是含有10个帧数的数组
1

声明一个返回数组指针的函数

​ 要想在声明 func 时不使用类型别名,我们必须牢记被定义的名字后面数组的维度:

int arr[10];// arr 是一个含有 10 个整数的数组
int *pl[10];// p1是一个含有10 个指针的数组
int (*p2)[10] = &arr;// p2 是一个指针,它指向含有 10 个整数的数组
1
2
3

返回数组指针的函数形式如下:

Type (*function(parameter_list))[dimension]
1

举个具体点的例子

int (*func(int i)[10];

  • func(int i)表示调用 func 函数时需要一个 int 类型的实参。
  • (*func(int i))意味着我们可以对函数调用的结果执行解引用操作。
  • (*func(int i))[10]表示解引用 func 的调用将得到一个大小是 10 的数组。
  • int (*func(int i))[10]表示数组中的元素是 int 类型

使用尾置返回类型

​ C++11允许使用尾置返回类型简化复杂函数声明。尾置返回类型跟在形参列表后面,并以一个->符号开头。为了表示函数真正的返回类型在形参列表之后,需要在本应出现返回类型的地方添加auto关键字。

// func 接受一个int类型的实参,返回一个指针,该指针指向含有10个整数的数组
auto func(int i) -> int(*)[10];
1
2

​ 任何函数的定义都能使用尾置返回类型,但是这种形式更适用于返回类型比较复杂的函数。

使用decltype

​ 如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型。但decltype并不会把数组类型转换成指针类型,所以还要在函数声明中添加一个*符号。

int odd[] = {1,3,5,7,9};
int even[] = {0,2,4,6,8};
// 返回一个指针,该指针指向含有5个整数的数组
decltype(odd) *arrPtr(int i)
{
    return (i % 2) ? &odd : &even;  // 返回一个指向数组的指针
}
1
2
3
4
5
6
7

# 6.4函数重载

​ 如果同一作用域内的几个函数名字相同但形参列表不同,我们称为重载函数。函数接受的形参类型不一样,当调用这些函数时,编译器会根据传递的实参类型推断想要的时哪个函数。

void print(const char *cp)
void print(const int *beg, const int *end) ;
void print(const int ia[], sizet size);

int j[2] =(0,1);
print("Hello World");// 调用 print(const char*)
print(j,end(j) - begin(j));// 调用 print(const int*, size t)
print(begin(j)end(j));// 调用 print(const int*,const int*)
1
2
3
4
5
6
7
8

main函数不能重载

定义重载函数

​ 对于重载的函数来说,它们应该在形参数量或形参类型上有所不同。不允许两个函数除了返回类型外其他所有要素都相同。

判断两个形参的类型是否相异

有时候两个形参列表看起来不一样,但实际上是相同的:

// 每对声明的是同一个函数
Record lookup(const Account &acct);
Record lookup(const Account&);// 省略了形参的名字

typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&);// Telno 和 Phone 的类型相同
1
2
3
4
5
6
7

重载和const形参

​ 顶层const不影响传入函数的对象。一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来:

Record lookup(Phone);
Record lookup(const Phone);// 重复声明了 Record lookup(Phone)

Record lookup(Phone*);
Record lookup(Phone* const);// 重复声明了 Record lookup(Phone*)
1
2
3
4
5

​ 另一方面,如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的 const 是底层的:

// 对于接受引用或指针的函数来说,对象是常量还是非常量对应的形参不同
// 定义了 4 个独立的重载函数
Record lookup(Account&);// 函数作用于Account 的引用
Record lookup(const Account&);// 新函数,作用于常量引用


Record lookup(const Account*);// 新函数,作用于指向 Account 的指针
Record lookup(Account*);// 新函数,作用于指向常量的指针
1
2
3
4
5
6
7
8

​ 当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数

const_cast和重载

// 比较两个 string 对象的长度,返回较短的那个引用
const string &shorterString(const string &sl,const string &s2)
{
    return sl.size() <= s2.size() ? sl :s2;
}
1
2
3
4
5

​ 这个函数的参数和返回类型都是 const string 的引用。我们可以对两个非常量的string 实参调用这个函数,但返回的结果仍然是 const string 的引用。因此我们需要一种新的 shorterString 函数,当它的实参不是常量时,得到的结果是一个普通的引用,使用 const_cast 可以做到这一点:

string
&shorterString(string &s1, string &s2)
{
    auto &r = shorterString(const cast<const string&>(sl),
						const cast<const string&>(s2));
	return const cast<string&>(r);
}
1
2
3
4
5
6
7

# 重载与作用域

​ 一般来说,将函数声明置于局部作用域内不是一个明智的选择。但是为了说明作用域和重载的相互关系,我们将暂时违反这一原则而使用局部函数声明。

string read();
void print(const string &);
void print(double); // 重载 print 函数
void fooBar(int ival)
{
    bool read= false;// 新作用域:隐藏了外层的 read
	string s = read()// 错误:read 是一个布尔值,而非函数
   	// 不好的习惯:通常来说,在局部作用域中声明函数不是一个好的选择
    void print(int);// 新作用域:隐藏了之前的 print
    print("Value:");;//错误:print(const string &)被隐藏掉了
    print(ival);// 正确:当前 print(int)可见
	print(3.14);// 正确:调用 print(int);print (double)被隐藏掉了
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 6.5特殊用途语言特性

# 默认实参

​ 某些函数有这样一种形参,在函数的很多次调用中它们都被赋予一个相同的值,此时,我们把这个反复出现的值称为函数的默认实参。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。

​ 我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。

typedef string::size_type sz;
string screen(sz ht = 24,sz wid =80char backgrnd ='');
1
2

使用默认实参调用函数

​ 我们可以选择数量不同个实参调用函数,但是只能省略尾部的实参

string window;
window = screen();				//等价于screen(24,80,'')
window = screen(66);			//等价于screen(66,80,'')
window = screen(66256);	    // screen(66,256,'')
window = screen(66256'##');   // screen(66,256,'##')

window = screen(,,'?');		   //错误:只能省略尾部的实参
window = screen('?');			// 调用 screen('?',80,'')
1
2
3
4
5
6
7
8

​ 当设计含有默认实参的函数时,其中一项任务是合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面。

默认实参声明

​ 对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。

//表示高度和宽度的形参没有默认值
string screen(sz,sz,char = ' ');

//我们不能修改一个已经存在的默认值:
string screen(sz,sz,char = '*');

//但是可以按照如下形式添加默认实参
string screen(sz = 24, sz = 80, char);
1
2
3
4
5
6
7
8

默认实参初始化

​ 局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参:

// wd、def和 ht 的声明必须出现在函数之外
szwd=80;
char def ='';
sz ht();
string screen(sz = ht(), sz = wd, char = def);
1
2
3
4
5

# 内联函数和constexpr函数

​ 把较小的操作定义为函数有很多的好处,但是也存在缺点。调用函数一般比求等价表达式的值要慢一些。

内联函数可以避免函数调用的开销

​ 将函数指定为内联函数,通常就是将它在每个调用点上“内联地”展开

​ 在函数的返回类型前面加上关键字 inline,这样就可以将它声明成内联函数了

// 内联版本:寻找两个 string 对象中较短的那个
inline const string &
shorterString(const string &sl, const string &s2)
{
    return sl.size() <= s2.size() ? sl : s2;
}
1
2
3
4
5
6

​ 内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。

​ 一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数。

C++ 内联函数 | 菜鸟教程 (runoob.com) (opens new window)(注意看下方的评论)

constexpr函数

constexpr函数是指能用于常量表达式的函数。constexpr函数的返回类型及所有形参的类型都得是字面值类型。另外C++11标准要求constexpr函数体中必须有且只有一条return语句,但是此限制在C++14标准中被删除。

constexpr int new_sz()
{
    return 42;
}

constexpr int foo = new_sz();   // ok: foo是一个常量表达式
1
2
3
4
5
6

constexpr函数的返回值可以不是一个常量。

// 如果 arg 是常量表达式,则 scale(arg)也是常量表达式
constexpr size_t scale(size_t cnt)
{
    return new_sz() * cnt;
}

int arr[scale(2)];  // ok: scale(2)是常量表达式
int i = 2;          // i不是常量表达式
int a2[scale(i)];   // 错误:scale(i)不是常量表达式
1
2
3
4
5
6
7
8
9

constexpr函数被隐式地指定为内联函数。

C++中constexpr函数-CSDN博客 (opens new window)

把内联函数和constexpr 函数放在头文件内

​ 和其他函数不同,内联函数和constexpr函数可以在程序中多次定义。因为在编译过程中,编译器需要函数的定义来随时展开函数。对于某个给定的内联函数或constexpr函数,它的多个定义必须完全一致。因此内联函数和constexpr函数通常定义在头文件中。

# 调试帮助

assert预处理宏

assert是一种预处理宏。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。

assert(expr);
//对expr求值,若为假assert输出信息并终止程序执行,如果为真assert什么都不做
1
2

​ 在实际编程的过程中,即使我们没有包含cassert头文件,也最好不要为了其他目的使用assert。很多头文件都包含了cassert,这就意味着即使你没有直接包含cassert,它也很有可能通过其他途径包含在你的程序中。

NDEBUG预处理变量

assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了 NDEBUG,则assert 什么也不做。默认状态下没有定义NDEBUG,此时 assert 将执行运行时检查。 ​ 我们可以使用一个##define 语句定义 NDEBUG,从而关闭调试状态。同时,很多编译器都提供了一个命令行选项使我们可以定义预处理变量

​ 除了用于assert 外,也可以使用 NDEBUG编写自己的条件调试代码。如果 NDEBUG未定义,将执行##ifndef##endif 之间的代码:如果定义了NDEBUG,这些代码将被忽略掉:

void print(const int ia[], size t size)
{
##ifndef NDEBUG
    //__func__是编译器定义的一个局部静态变量,用于存放函数的名字
    cerr << __func__ << ": array size is " << size << endl;
}

##endif
//...
1
2
3
4
5
6
7
8
9

​ 除了 C++编译器定义的__func__之外,预处理器还定义了另外4个对于程序调试很有用的名字:

变量名称 内容
__func__ 当前函数名称
__FILE__ 当前文件名称
__LINE__ 当前行号
__TIME__ 文件编译时间
__DATE__ 文件编译日期

# 6.6函数匹配

确定候选函数和可行函数

​ 函数实参类型与形参类型越接近,它们匹配得越好。

​ 重载函数集中的函数称为候选函数。

​ 可行函数的形参数量与函数调用所提供的实参数量相等,并且每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。

​ 调用重载函数时应该尽量避免强制类型转换。

# 实参类型转换

​ 为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成几个等级,具体排序如下所示:

  1. 精确匹配,包括以下情况:
  • 实参类型和形参类型相同。

  • 实参从数组类型或函数类型转换成对应的指针类型

  • 向实参添加顶层 const 或者从实参中删除顶层 const。

  1. 通过 const 转换实现的匹配。
  2. 通过类型提升实现的匹配。
  3. 通过算术类型转换或指针转换实现的匹配。
  4. 通过类类型转换实现的匹配

需要类型提升和算术类型转换的匹配

​ 所有算术类型转换的级别都一样

void ff(int);
void ff(short);
ff('a');// char 提升成 int;调用 f(int)

void manip(long);
void manip(float);
manip(3.14);// 错误:二义性调用
1
2
3
4
5
6
7

函数匹配和const 实参

​ 如果载函数的区别在于它们的引用或指针类型的形参是否含有底层const,则调用发生时编译器通过实参是否是常量来决定函数的版本。

Record lookup(Account&);    // 函数的参数是Account的引用
Record lookup(const Account&);  // 函数的参数是一个常量引用

const Account a;
Account b;

lookup(a);  // 调用lookup(const Account&)
lookup(b);  // 调用lookup(Account&)
1
2
3
4
5
6
7
8

# 6.7函数指针

​ 要想声明一个可以指向某种函数的指针,只需要用指针替换函数名称即可。

// 比较两个string对象的长度
bool lengthCompare(const string &, const string &);
// pf指向一个函数,该函数的参数是两个const string的引用
bool (*pf)(const string &, const string &); // 未初始化
1
2
3
4

使用函数指针

​ 可以直接使用指向函数的指针来调用函数,无须提前解引用指针。

pf = lengthCompare; // pf指向名为lengthCompare的函数
pf = &lengthCompare; // 等价的赋值语句:取地址符是可选的 

bool b1 = pf("hello", "goodbye");       //调用lengthCompare
bool b2 = (*pf)("hello", "goodbye");    //一个等价调用
bool b3 = lengthCompare("hello", "goodbye"); //另一个等价调用
1
2
3
4
5
6

重载函数指针

​ 对于重载函数,编译器通过指针类型决定函数版本,指针类型必须与重载函数中的某一个精确匹配。

void ff(int*);
void ff(unsigned int);
void (*pf1)(unsigned int) = ff; // pf1指向ff(unsigned)
1
2
3

函数指针形参

​ 可以把函数的形参定义成指向函数的指针。调用时允许直接把函数名当作实参使用,它会自动转换成指针。

// 第三个形参是函数类型,它会自动地转换成指向函数的指针
void useBigger(const string &s1, const string &s2, 
				bool pf(const string &, const string &));
// 等价的声明:显式地将形参定义成指向函数的指针
void useBigger(const string &s1, const string &s2, 					
bool (*pf)(const string &, const string &));

// 自动将函数 lengthCompare 转换成指向该函数的指针
useBigger(s1, s2, lengthCompare);
1
2
3
4
5
6
7
8
9

​ 关键字decltype作用于函数时,返回的是函数类型,而不是函数指针类型,所以只用在结果前面加上*才能得到指针。

返回指向函数的指针

​ 函数可以返回指向函数的指针。但返回类型不会像函数类型的形参一样自动地转换成指针,必须显式地将其指定为指针类型。

将auto 和 decltype 用于函数指针类型

​ 它返回函数类型而非指针类型。因此,我们显式地加上*以表明我们需要返回指针,而非函数本身。

# 第七章 类

​ 类的基本思想是数据抽象和封装。数据抽象是一种依赖接口和实现的分离的编程技术。类要想实现数据的抽象和封装,需要首先定义一个抽象数据类型。

# 7.1定义抽象数据类型

# 设计Sales_data类

​ 类的用户是程序员,而非应用程序的最终使用者。C++程序员们无须刻意区分应用程序的用户以及类的用户。

# 定义改进的Sales_data类

​ 定义个声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。定义在类内部的函数是隐式的inline函数。

定义成员函数

​ 尽管所有成员必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。

引入this

​ 成员函数通过一个名为this的额外的隐式参数来访问调用它的哪个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this

total.isbn()
// 伪代码,用于说明调用成员函数的实际执行过程
// 调用Sales_data的isbn成员时传入了total的地址
Sales_data::isbn(&total)

1
2
3
4
5

​ 任何对类成员的直接访问都被看作this的隐式引用。this形参时隐式定义。任何自定义名为this的参数或变量的行为都是非法的。我们可以在成员函数体内使用this,尽管没有必要,但我们还是能把isbn定义为一下形式

// 两者表示的含义相同
std::string isbn() const { return this->bookNo; }
std::string isbn() const { return bookNo; }
1
2
3

​ 因为 this 的目的总是指向“这个”对象,所以 this 是一个常量指针,我们不允许改变 this 中保存的地址。

引入const成员函数

isbn 函数的另一个关键之处是紧随参数列表之后的 const 关键字,这里,const的作用是修改隐式 this 指针的类型。默认情况下,this的类型是指向类类型非常量版本的常量指针。尽管 this 是隐式的,但它仍然需要遵循初始化规则,意味着(在默认情况下)我们不能把 this 绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。

​ C++语言中应许把const关键字放在成员函数的参数列表之后此时,紧跟在参数列表后面的 const 表示 this 是一个指向常量的指针。像这样使用const 的成员函数被称作常量成员函数

// 伪代码,说明隐式的 this 指针是如何使用的
// 下面的代码是非法的:因为我们不能显式地定义自己的 this 指针
// 谨记此处的 this 是一个指向常量的指针,因为 isbn 是一个常量成员
std::string Sales_data::isbn(const Sales_data *const this)
{
    return this->isbn;
}
1
2
3
4
5
6
7

​ 因为 this 是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。在上例中,isbn 可以读取调用它的对象的数据成员,但是不能写入新值。

​ 常量对象和指向常量对象的引用或指针都只能调用常量成员函数。

类作用域和成员函数

​ 编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

在类的外部定义的成员函数

​ 在类的外部定义成员函数时,成员函数的定义必须与它的声明相匹配。如果成员函数被声明为常量成员函数,那么它的定义也必须在参数列表后面指定const属性。同时,类外部定义的成员名字必须包含它所属的类名。

double Sales_data::avg_price() const
{
    if (units_sold)
        return revenue / units_sold;
    else
        return 0;
}
1
2
3
4
5
6
7

定义一个返回this对象的函数

​ 可以定义返回this对象的成员函数。

Sales_data& Sales_data::combine(const Sales_data &rhs)
{
    units_sold += rhs.units_sold;   // 把 rhs 的成员加到 this 对象的成员上
    revenue += rhs.revenue; 
    //我们无须使用隐式的 this 指针访问函数调用者的某个具体成员,而是需要把调用函数的对象当成一个整体来访问:
    return *this;       // 返回调用该函数的对象
}
1
2
3
4
5
6
7

# 定义类相关的非成员函数

​ 类的作者通常会定义一些辅助函数,尽管这些函数从概念上来说属于类接口的组成部分,但实际上它们并不属于类本身。

​ 一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。

C++ 类:类相关的非成员函数、构造函数_C++ 非成员函数-CSDN博客 (opens new window)

第十三篇:成员函数与非成员函数的选择 - 穆晨 - 博客园 (cnblogs.com) (opens new window)

# 构造函数

​ 每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

​ 构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的) 参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。

​ 不同于其他成员函数,构造函数不能被声明成 const 的。但是,构造函数在 const 对象的构造过程中可以向其写值。

合成的默认构造函数

​ 类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数。默认构造函数无须任何实参。

​ 如果我们的类没有显式地定义构造函数,那么编译器会为我们隐式的定义一个默认构造函数。

​ 对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用它来初始化成员
  • 否则,默认初始化该成员

某些类不能依赖于合成的默认构造函数

​ 对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:

  1. 只有当类没有声明任何构造函数时,编译器才会自动生成默认构造函数。一旦类定义了其他构造函数,那么除非再显式地定义一个默认的构造函数,否则类将没有默认构造函数。
  2. 如果类包含内置类型或者复合类型的成员,则只有当这些成员全部存在类内初始值时,这个类才适合使用合成的默认构造函数。否则用户在创建类的对象时就可能得到未定义的值。
  3. 编译器不能为某些类合成默认构造函数。例如类中包含一个其他类类型的成员,且该类型没有默认构造函数,那么编译器将无法初始化该成员。

= default 的含义

​ 在C++11中,如果类需要默认的函数行为,可以通过在参数列表后面添加=default来要求编译器生成构造函数。其中=default既可以和函数声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果=default在类的内部,则默认构造函数是内联的。

Sales_data() = default;
1

构造函数初始值列表

​ 冒号后的部分被称为构造函数初始值列表,它负责为新创建的对象的一个或几个数据成员赋初始值。

Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p):
    bookNo(s), units_sold(n), revenue(p*n) { }
1
2
3

​ 当某个数据成员被构造函数初始值列表忽略时,它会以与合成默认构造函数相同的方式隐式初始化。

// 与上面定义的那个构造函数效果相同(第一个)
Sales_data(const std::string &s):
    bookNo(s), units_sold(0), revenue(0) { }
1
2
3

​ 构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

在类的外部定义构造函数

如何在C++中定义类外的构造函数?|极客笔记 (deepinout.com) (opens new window)

# 拷贝、赋值和析构

​ 编译器能合成拷贝、赋值和析构函数,但是对于某些类来说合成的版本无法正常工作。特别是当类需要分配类对象之外的资源时,合成的版本通常会失效。

​ 在学习第 13 章关于如何自定义操作的知识之前,类中所有分配的资源都应该直接以类的数据成员的形式存储。

# 7.2访问控制与封装

​ 在C++中,我们使用访问说明符加强类的封装性:

  • 定义在public说明符之后的成员在整个程序内都可以被访问。public成员定义类的接口。
  • 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问。private部分封装了类的实现细节。

​ 每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现在下一个访问说明符或者到达类的结尾处为止。

使用class或struct关键字

​ 如果我们使用 struct 关键字,则定义在第一个访问说明符之前的成员是 public的;相反,如果我们使用 class 关键字,则这些成员是 private 的。

# 友元

​ 类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元 (friend)。如果类想把一个函数作为它的友元,只需要增加一条以 friend 关键字开始的函数声明语句即可:

​ 友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。但是,一般来说,最好在类定义开始或结束前的位置集中声明友元。

class Sales_data
{
    // 为Sales_data的非成员函数所做的友元声明
    friend Sales_data add(const Sales_data&, const Sales_data&);
    friend std::istream &read(std::istream&, Sales_data&);
    friend std::ostream &print(std::ostream&, const Sales_data&);

    // 其他成员及访问说明符与之前一致
public:
    Sales_data() = default;
    Sales_data(const std::string &s, unsigned n, double p):
    bookNo(s), units_sold(n), revenue(p*n) { }
    Sales_data(const std::string &s): bookNo(s) { }
    Sales_data(std::istream&);
    std::string isbn() const { return bookNo; }
    Sales_data &combine(const Sales_data&);

private:
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};

// Sales_data接口的非成员组成部分的声明
Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
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

封装的好处:

  • 确保用户代码不会无意间破坏封装对象的状态。
  • 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。

友元的声明

​ 友元声明仅仅指定了访问权限,而并非一个通常意义上的函数声明。如果希望类的用户能调用某个友元函数,就必须在友元声明之外再专门对函数进行一次声明(部分编译器没有该限制)。

​ 为了使友元对类的用户可见,通常会把友元的声明(类的外部)与类本身放在同一个头文件中。

# 7.3类的其他特性

# 类成员再探

定义一个类型成员

class Screen (
public:
	typedef std::string::size_type pos;
     using pos = std::string::size_type;//上下两种含义相同,都是取了别名
private:
	pos cursor = 0;
	pos height = 0,width = 0;
	std::string contents;
}
1
2
3
4
5
6
7
8
9

Screen类的成员函数

class Screen{
public:
    typedef std::string::size type pos;
    Screen() = default; // 因为 Screen 有另一个构造函数
    				  // 所以本函数是必需的
    // cursor 被其类内初始值初始化为 0
    Screen(pos ht,pos wd,char c): height(ht)width(wd),
    contents(ht * wd,c){}
    char get() const   // 读取光标处的字符 
    	{return contents[cursor] ;}//隐式内联
    inline char get(pos ht,pos wd) const;//显式内联
    Screen &move(pos r,pos c); // 能在之后被设为内联  
private:
    pos cursor = 0;
    pos height = 0,width = 0;
    std::string contents;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

令成员作为内联函数

​ 最好只在类外部定义的地方说明inline,这样可以时类更容易理解。

​ inline成员函数也应该与相对应的类定义在同一个头文件中。

重载成员函数

​ 和非成员函数一样,成员函数也可以被重载,只要函数之在参数的数量和/或类型上有所区别就行。成员函数的函数匹配过程同样与非成员函数非常类似。

Screen myscreen;
char ch = myscreen.get();// 调用 Screen::get()
ch = myscreen.get(0,0);// 调用 Screen::get(pos,pos)
1
2
3

可变数据成员

​ 改变某个数据成员,我们可以通过在变量的声明中加入mutable关键字做到这一点(即使是一个const成员函数)。

​ 一个可变数据成员永远不会是const,即使它时const对象的成员。

class Screen
{
public:
    void some_member() const;
private:
    mutable size_t access_ctr;  // 即使在一个const对象内也可以被修改
    // 其他成员与之前的版本一致
};

void Screen::some_member() const
{
    ++access_ctr;   // 保存一个计数值,用于记录成员函数被调用的次数
    // 该成员需要完成的其他工作
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

类数据成员的初始值

​ 提供类内初始值时,必须使用=或花括号形式。

# 返回*this的成员函数

class Screen{
public:
    Screen &set(char);
    Screen &set(pos, pos, char);
    // 其他成员和之前的版本一致
}inline Screen &Screen::set(char c)
{
    contents[cursor] = c;// 设置当前光标所在位置的新值
	return *this;		// 将 this 对象作为左值返回
}
inline Screen &Screen:set(pos r, pos col, char ch)
{
    contents[r*width + col] = ch;//设置给定位置的新值
	return *this;                //将this 对象作为左值返回
}

// 把光标移动到一个指定位置,然后设置该位置的字符值
myScreen.move(4,0).set('##');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

从const成员函数返回*this

​ 一个 const 成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。

基于const的重载

const成员函数如果以引用形式返回*this,则返回类型是常量引用。

​ 通过区分成员函数是否为const的,可以对其进行重载。在常量对象上只能调用const版本的函数;在非常量对象上,尽管两个版本都能调用,但会选择非常量版本。

class Screen
{
public:
    // 根据对象是否是 const 重载了 display 函数
    Screen &display(std::ostream &os)
    { do_display(os); return *this; }
    const Screen &display(std::ostream &os) const
    { do_display(os); return *this; }

private:
    // 该函数负责显示 Screen 的内容
    void do_display(std::ostream &os) const
    { os << contents; }
    // 其他成员与之前的版本一致
};

Screen myScreen(5,3);
const Screen blank(5, 3);
myScreen.set('##').display(cout);    // 调用非常量版本
blank.display(cout);    // 调用常量版本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 类类型

​ 即使两个类的成员列表完全一致,它们也是不同的类型。对于一个类来说,它的成员和其他任何类(或者任何其他作用域)的成员都不是一回事儿

​ 我们可以把类名作为类型的名字使用,从而直接指向类类型

Sales data iteml;// 默认初始化 Sales data 类型的对象
class Sales data iteml;// 一条等价的声明
// 后者C风格,在C++语言中也是合法的
1
2
3

类的声明

​ 可以仅仅声明一个类而暂时不定义它。这种声明被称作前向声明,用于引入类的名字。在类声明之后定义之前都是一个不完全类型。

class Screen;   // Screen类的声明
1

可以定义指向不完全类型的指针或引用,也可以声明(不能定义)以不完全类型作为参数或返回类型的函数。

只有当类全部完成后才算被定义,所以一个类的成员类型不能是该类本身。但是一旦类的名字出现,就可以被认为是声明过了,因此类可以包含指向它自身类型的引用或指针。

class Link_screen
{
    Screen window;
    Link_screen *next;
    Link_screen *prev;
};
1
2
3
4
5
6

练习7.31:定义一对类X和Y,其中X包含一个指向Y的指针,而Y包含一个类型为X的对象。

class Y;
class X
{
	Y* y;
};
class Y
{
	X x;
};
1
2
3
4
5
6
7
8
9

友元再探

除了普通函数,类还可以把其他类或其他类的成员函数声明为友元。友元类的成员函数可以访问此类包括非公有成员在内的所有成员。

class Screen
{
    // Window_mgr 的成员可以访问 Screen 类的私有部分
    friend class Window_mgr;
};
1
2
3
4
5

​ 每个类负责控制自己的友元类或友元函数

令成员函数作为友元

class Screen
{
    // Window_mgr::clear 必须在Screen类之前被声明
    friend void Window_mgr::clear(ScreenIndex);
};
1
2
3
4
5

​ 要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:

  • 首先定义 window mgr 类,其中声明 clear 函数,但是不能定义它。在 clear使用 Screen的成员之前必须先声明 Screen。
  • 接下来定义 screen,包括对于 clear 的友元声明
  • 最后定义clear,此时它才可以使用 Screen 的成员。

函数重载和友元

​ 尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。

友元声明和作用域

​ 友元函数可以直接定义在类的内部,这种函数是隐式内联的。但是必须在类外部提供相应声明令函数可见。

​ 即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的:

struct X
{
    friend void f() { /*友元函数可以定义在类的内部 */ }
    X() { f(); }   // 错误:f没有被声明
    void g();
    void h();
};

void X::g() { return f(); }     // 错误:f没有被声明
void f();   				// 声明那个定义在x中的函数
void X::h() { return f(); }     // 正确:现在f的声明在作用域中了
1
2
3
4
5
6
7
8
9
10
11

# 7.4类的作用域

​ 每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对 象、引用或者指针使用成员访问运算符来访问。

Screen::pos ht = 24, wd = 80;	// 使用 Screen 定义的 pos 类型
Screen scr(ht,wd,'');
Screen *p = &scr;
char c = scr.get();				// 访问 scr 对象的 get 成员
c = P->get();					// 访问p所指对象的 get 成员
1
2
3
4
5

作用域和定义在类外部的成员

​ 当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员。

class Window_mgr
{
public:
    // 向窗口添加一个Screen,返回它的编号
    ScreenIndex addScreen(const Screen&);
};

// 首先处理返回类型,之后我们才进入Window_mgr的作用域
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s)
{
    screens.push_back(s);
    return screens.size() - 1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 名字查找与类的作用域

​ 学习至目前为止,名字查找的过程比较直截了当:

  • 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
  • 如果没找到,继续查找外层作用域。
  • 如果最终没有找到匹配的声明,则程序报错。

​ 对于定义在类中的成员函数分为两步处理

  • 首先,编译成员的声明
  • 直到类全部可见后才编译函数体

​ 编译器处理完类中的全部声明后才会处理成员函数的定义

用于类成员声明的名字查找

​ 编辑器会现在类中寻找,若是寻找不到会在成员函数前进行查找。

typedef double Money;
string bal;
class Account {
public:
	Money balance() { return bal;)
private:
	Money bal;
};
1
2
3
4
5
6
7
8

​ 编译器会先在Accout类中查找Money,若没有找到才会去balance上面找,并找到typedef定义的Money

类型名要特殊处理

​ 一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字:

typedef double Money;
class Account
{
public:
	Money balance(){ return bal;} // 使用外层作用域的 Money
private:
	typedef double Money;//错误:不能重新定义 Money
	Money bal;
}
1
2
3
4
5
6
7
8
9

​ 类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。

成员定义中的普通块作用域的名字查找

成员函数中使用的名字按照如下方式解析:

  • 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
  • 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
  • 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。
// 注意:这段代码仅为了说明而用,不是一段很好的代码
// 通常情况下不建议为参数和成员使用同样的名字
int height;   // 定义了一个名字,稍后将在 Screen 中使用
class Screen
{
public:
    typedef std::string::size_type pos;
    void dummy_fcn(pos height)
    {
        cursor = width * height;  // 哪个height?是那个参数
    }

private:
    pos cursor = 0;
    pos height = 0, width = 0;
};

// 注意:在此例子中隐藏了同名的成员
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

可以通过作用域运算符::或显式this指针来强制访问被隐藏的类成员。

// 不建议的写法:成员函数中的名字不应该隐藏同名的成员
void Screen::dummy_fcn(pos height)
{
    cursor = width * this->height;  // 成员 height
    // 另外一种表示该成员的方式
    cursor = width * Screen::height;  // 成员 height
}

// 建议的写法:不要把成员名字作为参数或其他局部变量使用
void Screen::dummy_fcn(pos ht)
{
    cursor = width * height;  // member height
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 7.5构造函数再探

# 构造函数初始值列表

​ 构造函数是否进行列表初始化这一区别带来的深层影响完全依赖于数据成员的类型。

构造函数的初始值有时必不可少

​ 如果成员是 const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。

建议:使用构造函数初始值

  • 在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员后者则先初始化再赋值。
  • 除了效率问题外更重要的是,一些数据成员必须被初始化。建议读者养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误,特别是遇到有的类含有需要构造函数初始值的成员时。

成员初始化的顺序

​ 构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。

​ 一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了。

class X (
	int i;
	int j;
public:
	// 未定义的:i在之前被初始化
	X(int val): j(val)i(j){}
}
1
2
3
4
5
6
7

​ 最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。

默认实参和构造函数

​ 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

class Sales_data
{
public:
    // 定义默认构造函数,令其与只接受一个 string 实参的构造函数功能相同
    Sales_data(std::string s = ""): bookNo(s) { }
    Sales_data(std::string s, unsigned cnt, double rev):
        bookNo(s), units_sold(cnt), revenue(rev*cnt) { }
    Sales_data(std::istream &is) { read(is, *this); }

}
1
2
3
4
5
6
7
8
9
10

# 委托构造函数

​ C++11 新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。

class Sales data
public:
// 非委托构造函数使用对应的实参初始化成员
	Sales data(std::string s,unsigned cnt, double price):
		bookNo(s),units sold(cnt), revenue(cnt*price) (
    // 其余构造函数全都委托给另一个构造函数
    Sales data(): Sales data("",0,0)
    Sales data(std::string s): Sales data(s,0,0)(
    Sales data(std::istream &is): Sales data()
        {read(is,*this);}
}
1
2
3
4
5
6
7
8
9
10
11

# 默认构造函数的作用

​ 当对象被默认初始化或值初始化时会自动执行默认构造函数。

​ 默认初始化的发生情况:

  • 在块作用域内不使用初始值定义非静态变量或数组。
  • 类本身含有类类型的成员且使用合成默认构造函数。
  • 类类型的成员没有在构造函数初始值列表中显式初始化。

​ 值初始化的发生情况:

  • 数组初始化时提供的初始值数量少于数组大小。
  • 不使用初始值定义局部静态变量。
  • 通过T()形式(T为类型)的表达式显式地请求值初始化。

​ 在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数

使用默认构造函数

​ 如果想定义一个使用默认构造函数进行初始化的对象,应该去掉对象名后的空括号对。

Sales_data obj();   // 声明了一个函数而非对象
Sales_data obj2;    // obj2是一个对象而被函数
1
2

# 隐式的类类型转换

​ 如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数

string null_book = "9-999-99999-9";
// 构造一个临时的Sales_data对象
// 该对象的units_sold和revenue等于0,bookNo等于bull_book
item.combine(null_book);
1
2
3
4

只允许一步类类型转换

// 错误:需要用户定义的两种转换:
//   (1) 把“9-999-99999-9”转换成 string
//   (2) 再把这个(临时的)string 转换成 Sales data
item.combine("9-999-99999-9");
// 正确:显式地转换成 string,隐式地转换成 Sales_data
item.combine(string("9-999-99999-9"));
// 正确:隐式地转换成 string,显式地转换成 Sales_data
item.combine(Sales_data("9-999-99999-9"));
1
2
3
4
5
6
7
8

类类型转换不是总有效

// 使用 istream 构造函数创建一个函数传递给 combine
item.combine(cin);
1
2

​ 这段代码隐式地把 cin 转换成 Sales_data,这个转换执行了接受一个 istreamSales_data 构造函数。该构造函数通过读取标准输入创建了一个(临时的)Sales_data对象,随后将得到的对象传递给 combine

抑制构造函数定义的隐式转换

​ 在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为 explicit 加以阻止

class Sales data
public:
    Sales data() = default;
    Sales data(const std::string &s,unsigned n, double p):
    bookNo(s), units sold(n)revenue(p*n) {}
    explicit Sales data(const std;:string &s): bookNo(s) (
    explicit Sales data(std::istreams);
    
};
1
2
3
4
5
6
7
8
9

explicit关键字只对接受一个实参的构造函数有效。

​ 只能在类内声明构造函数时使用explicit关键字,在类外定义时不能重复。

explicit构造函数只能用于直接初始化

​ 执行拷贝初始化时(使用=)会发生隐式转换,所以explicit构造函数只能用于直接初始化。

Sales_data item1 (null_book);   // 正确:直接初始化
// 错误:不能将 explicit 构造函数用于拷贝形式的初始化过程
Sales_data item2 = null_book;
1
2
3

为转换显式地使用构造函数

可以使用explicit构造函数显式地强制转换类型。

// 正确:实参是一个显式构造的 Sales data 对象
item.combine(Sales_data(null_book));
// 正确:static cast 可以使用 explicit 的构造函数
item.combine(static_cast<Sales_data>(cin));
1
2
3
4

标准库中含有显式构造函数的类

我们用过的一些标准库中的类含有单参数的构造函数:

  • 接受一个单参数的 const char*的 string 构造函数不是 explicit 的。
  • 接受一个容量参数的 vector 构造函数是explicit 的。

# 聚合类

​ 聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:

  • 所有成员都是public的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类。
  • 没有虚函数。
struct Data
{
    int ival;
    string s;
};
1
2
3
4
5

​ 我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:

// val1.ival = 0; val1.s = string("Anna")
Data val1 = { 0, "Anna" };

// 错误:不能使用”Anna"初始化ival,也不能使用1024初始化s
Data val2 = {"Anna"1024} ;
1
2
3
4
5

# 字面值常量类

​ 字面值类型的类可能含有 constexpr 函数成员。这样的成员必须符合 constexpr 函数的所有要求,它们是隐式 const 的。

​ 数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

  • 数据成员都是字面值类型。
  • 类至少含有一个constexpr构造函数。
  • 如果数据成员含有类内初始值,则内置类型成员的初始值必须是常量表达式。如果成员属于类类型,则初始值必须使用成员自己的constexpr构造函数。
  • 类必须使用析构函数的默认定义。

constexpr 构造函数

​ 构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr

constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型。综合以上可知,constexpr构造函数体一般来说应该是空的

constexpr构造函数必须初始化所有数据成员,初始值使用constexpr构造函数或常量表达式。

# 7.6类的静态成员

声明静态成员

​ 我们通过在成员的声明之前加上关键字 static 使得其与类关联在一起

class Account
{
public:
    void calculate() { amount += amount * interestRate; }
    static double rate() { return interestRate; }
    static void rate(double);

private:
    std::string owner;
    double amount;
    static double interestRate;
    static double initRate();
};
1
2
3
4
5
6
7
8
9
10
11
12
13

​ 静态成员函数也不与任何对象绑定在一起,它们不包含 this 指针。作为结果,静态成员函数不能声明成 const 的,而且我们也不能在 static 函数体内使用 this指针。这一限制既适用于 this 的显式使用,也对调用非静态成员的隐式使用有效。

使用类的静态成员

​ 使用作用域运算符直接访问静态成员。虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:

double r;
r = Account::rate();// 使用作用域运算符访问静态成员

Account ac1;
Account *ac2 = &acl;
// 调用静态成员函数 rate 的等价形式
r = acl.rate();// 通过 Account 的对象或引用
r = ac2->rate();// 通过指向 Account 对象的指针
1
2
3
4
5
6
7
8

成员函数不用通过作用域运算符就能直接使用静态成员。

定义静态成员

​ 和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static关键字则只出现在类内部的声明语句中。

​ 在类外部定义静态成员时,不能重复static关键字,其只能用于类内部的声明语句。

​ 由于静态数据成员不属于类的任何一个对象,因此它们并不是在创建类对象时被定义的。通常情况下,不应该在类内部初始化静态成员。而必须在类外部定义并初始化每个静态成员。一个静态成员只能被定义一次。一旦它被定义,就会一直存在于程序的整个生命周期中。

​ 要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。

静态成员的类内初始化

​ 通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供 const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr

class Account
{
public:
    static double rate() { return interestRate; }
    static void rate(double);
private:
    static constexpr int period = 30;  // period是常量表达式
    double daily_tbl[period];
};
1
2
3
4
5
6
7
8
9

​ 即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

静态成员能用于某些场景,而普通成员不能

​ 静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用。

class Bar
{
    static Bar mem1;   // 正确:静态成员可以是不完全类型
    Bar *mem2;    //正确:指针成员可以是不完全类型
    Bar mem3;   // 错误:数据成员必须是完全类型
}
1
2
3
4
5
6

​ 静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参

class Screen
{
public:
    // bkground 表示一个在类中稍后定义的静态成员
    Screen& clear(char = bkground);
private:
    static const char bkground;
};
1
2
3
4
5
6
7
8

​ 非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。