C++ Pirmer 中文版(第五版)——第1-4章笔记

C++ Pirmer 中文版(第五版)——第1-4章笔记

第 1 章 开始 1

1.1 编写一个简单的 C++ 程序 2
1.1.1 编译、运行程序 3
1.2 初识输入输出 5
1.3 注释简介 8
1.4 控制流 10
1.5 类简介 17
1.6 书店程序 21
第 Ⅰ 部分 C++ 基础 27

第 2 章 变量和基本类型 29

2.1 基本内置类型 30
2.2 变量 38
2.3 复合类型 45
2.4 const 限定符 53
2.5 处理类型 60
2.6 自定义数据结构 64

第 3 章 字符串、向量和数组 73

3.1 命名空间的 using 声明 74
3.2 标准库类型 string 75
3.3 标准库类型 vector 86
3.4 迭代器介绍 95
3.5 数组 101
3.6 多维数组 112

第 4 章 表达式 119

4.1 基础 120
4.2 算术运算符 124
4.3 逻辑和关系运算符 126
4.4 赋值运算符 129
4.5 递增和递减运算符 131
4.6 成员访问运算符 133
4.7 条件运算符 134
4.8 位运算符 135
4.9 sizeof 运算符 139
4.10 逗号运算符 140
4.11 类型转换 141
4.12 运算符优先级表 147

Chapter1 开始

初识输入输出(IO)

  • C++并未定义IO语句,而是用标准库(standard library)来提供IO机制

  • 输入输出流中的流(stream)想表达的是,随着时间的推移,字符是顺序生成或消耗的。

  • 标准库定义了4个IO对象

    • istream类型
      • cin——标准输入
    • ostream类型
      • cout——标准输出
      • cerr——标准错误
      • clog——标准日志?
  • 代码详解:

#include 
int main() {
    std::cout << "Enter two numbers:" << std::endl;
    int v1 = 0, v2 = 0;
    std::cin >> v1 >> v2;
    std::cout << "The sum of " << v1 << " and " << v2 << " is " << v1 + v2 << std::endl;
    return 0;
}

输出运算符<<接受两个运算对象,左侧的运算对象必须是一个ostream对象(比如main函数体中第一行的cout,右侧对象是要打印的值,输出运算符<<返回的结果是左侧的对象,即仍然是ostream对象,那么后面第二个输出运算符<<仍然会把std::endl写到ostream对象中,输入运算符>>类似,为了方便理解,加上括号:

(std::cout << "Enter two numbers:") << std::endl;

操纵符(manipulator)endl的效果是结束当前行,并将缓冲区(buffer)中的内容刷新到设备中,缓冲刷新能保证内容真正写入到输出流中,而不是在内存中等待写入流

在调试时经常添加打印语句,一定要保证“一直”刷新流

命名空间(namespace)避免不经意的名字冲突,标准库定义的所有名字都在命名空间std中,作用域运算符::指示了在命名空间std中的名字cout

也可以用 using namespace的方法简化代码

类定义了一种类型,也定义了这种类型可以执行的所有动作

程序员自己定义的类,为了与C++的内置类型区别开来,它们通常被称为类类型(class type)

Chapter2 C++基础

如何选择算术类型

  • 明确知道数值不可能为负时,用无符号
  • 使用int执行整数运算,在实际应用中,short常常显得太小而long一般和int有一样的尺寸,如果数值超过了int的表示范围,选用long long
  • 在算术表达式中不要用char或bool
  • 执行浮点数运算时用double,因为float精度不够,而且double的计算代价与float相差无几

字面值常量(literal)

“字面值” 是指只能用它的值称呼它,“常量” 是指其值不能修改。每个字面值都有相应的类型,3.14 是 double 型,2 是 int 型。只有内置类型存在字面值。

变量(variable)

变量与对象(object)一般可以互换使用

定义变量的基本形式:类型说明符(type specifier),紧跟一个或多个变量名组成的列表,用逗号分隔,最后用分号结束。定义时还可以为一个或多个变量赋初值。

初始值

初始化不是赋值!初始化时指在创建变量时赋予一个初始值,而赋值的含义是把对象当前值擦除,而以一个新值替代

列表初始化(list initialization)

下面四种方式都可以初始化int变量:

int units_sold = 0;
int units_sold = {0};
int units_sold{0};
int units_sold(0);

花括号来初始化变量,这是C++11新标准的一部分,称为列表初始化,但是如果初始值存在丢失信息的风险,则编译器将会报错!

默认初始化

如果内置类型的变量未被显式初始化,它的值将会由定义的位置决定,定义于函数体之外的变量初始化为0,函数体内的变量将不被初始化化(unintialized),这很容易引发错误!

每个类各自决定初始化对象的方式,是否不经初始化就定义对象也可以自己决定,也可以自己决定对象的初始值的是什么

建议:初始化每一个内置类型的变量

变量声明(declaration)与定义(definition)的关系

C++支持分离式编译(separatecompilation):允许将程序分割为若干个文件,每个文件可以独立编译

声明使得名字为程序所知,一个文件如果想使用别处定义的名字必须包含对那个名字的声明,定义负责创建与名字关联的实体。

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

如果声明的同时也赋初值,那么会抵消extern的作用,而使得这行代码变成定义

变量可以多次声明,但只能一次定义

C++是一种静态类型(statically typed)的语言,其含义是在编译阶段检查类型,称为类型检查(type checking)

作用域(scope)

作用域以花括号分隔

块作用域(block scope),全局作用域(global scope)

建议:当第一次使用变量时,再定义它,定义语句和第一次使用的语句离的很近时,有助于易读性

嵌套的作用域:内层作用域(inner scope)、外层作用域(outer scope)

局部变量会覆盖全局作用域中相同名字的变量,如果想显式地调用全局作用域的变量,可以加上作用域操作符::,因为全局作用域没有名字,所以当作用域操作符::左侧为空时,即请求全局作用域

建议:如果函数由可能用到全局变量,不应该再定义一个相同名字的局部变量!

复合类型(compound type)

引用(reference)

引用为对象起了另外一个名字,引用类型 引用(refer to)另外一种类型

int ival = 1024;
int &refVal = ival // refVal 指向ival(是ival的另一个名字)
int $refVal2; // 报错:引用必须被初始化!

一般初始化变量时,初始值会被拷贝到新建的对象中;然而定义引用时,程序把引用和它的初始值绑定(bind)在一起,而不是将初始值拷贝给引用,无法将引用重新绑定到另外一个对象,所以:引用必须初始化(注意不是赋值,见上文)

为引用赋值,实际上是把值付给了与引用绑定的对象;获取引用的值,实际上是获取了引用绑定的对象的值;以引用作为初始值,实际上是以与引用的绑定的对象作为初始值

//接上代码块
refVal = 2; // 把2赋值给了refVal所指向的对象,即把2赋值给了ival
int ii = refVal; // 与ii == ival所执行的效果一样
int &refVal3 = refVal; // 正确,refVal3绑定到了那个与refVal绑定的对象上,也就是与&refVal3 = ival效果相同
int i = refVal; // 正确,i被初始化为ival的值

总结:

  • 引用并非对象,它只是起了另外一个名字(与指针区分)

  • 引用只能绑定再对象上,而不能与字面值或某个表达式的计算结果绑定在一起

  • 引用的类型都要和与之绑定的对象严格匹配(有一些特殊情况以后会涉及)

  • 更准确地说,这种引用是左值引用(lvalue reference),C++11新增了右值引用(rvalue reference)

指针(pointer)

指针是指向(point to)另外一种类型的复合类型

指针与引用不一样,指针本身也是一个对象,允许对指针赋值和拷贝;其二,指针须在定义时赋初值

int *ip1, *ip2; //ip1和ip2都是指向int型对象的指针
double dp, *dp2; //dp2是指向double型对象的指针,dp是double型对象
获取对象的地址

指针存的是某个对象的地址,要想获取该地址,需要使用取地址符&

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

一般情况下,指针的类型都要和它所指向的类型严格匹配,但也有特殊情况,以后说

指针的值(即地址)应属于下列四种状态之一

  • 指向一个对象
  • 指向紧邻对象所占空间的下一个位置(指针没有指向具体对象,访问指针的值会引起无法预计的后果
  • 空指针(访问空指针的值也会引起无法预计的后果
  • 无效指针,上述情况之外的其他值(试图拷贝或其它方式访问无效指针的值都会引起无法预计的后果!
利用指针访问对象

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

访问:

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

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

赋值:

*p = 0; //
cout << *p; // output 0
赋值与指针

指针可以不限次数地改变指向的对象,给指针赋值就是令它存放一个新的地址,从而指向一个新的对象

int i = 42;
int *pi = 0; // pi被初始化,但没有指向任何对象
int *pi2 = &i; // pi2被初始化,存有i的地址
int *pi3; // 如果pi3定义于块内,则值是无法确定的

pi3 = pi2; // pi3和pi2指向同一个对象i
pi2 = 0; // 现在pi2不指向任何对象了

技巧:有时搞清楚到底是改变了指针的值还是指针所指向的对象的值很难,最好的办法就是记住赋值永远改变的是等号左侧的对象

空指针(null pointer)

空指针不指向任何对象,以下是生成空指针的几种方法

int *p1 = nullptr; // C++11才引入的方法,等价于 int *p1 = 0; 
int *p2 = 0; // 直接将p2初始化为字面值常亮0
// 需要首先 #include cstdlib
int *p3 = NULL; // 等价于 int *p3 = 0;

NULL是预处理(preprocessor variable)变量,在头文件cstdlib中定义,它的值就是0

C++11之后最好使用nullptr,同时尽量避免使用NULL

建议:初始化所有的指针,尽量等对象定义后再定义指向它的指针,如果实在不清楚指针指向何处,就把它初始化为nullptr或者0

任何非零指针对应的条件值都是true

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

int *pi;
int zero = 0;
pi = zero; // ERROR!
void*指针

void*是一种特殊的指针类型,可用于存放任意对象的地址,我们对指向的对象具体是什么类型并不了解

以void*的视角来看,内存空间也就仅仅是内存空间,没办法访问内存空间中所存的对象

分辨引用&、指针*、解引用符*、取地址符&

# include 
int main(){
    int i = 42;  
    int &r = i; // &紧随类型名出现,因此是声明的一部分,r是一个引用
    std::cout << r << std::endl; // 输出42
    int *p; // *紧随类型名出现,因此是声明的一部分,p是一个指针
    p = &i; // &出现在表达式中,是一个取地址符
    *p = i; // *出现在表达式中,是一个解引用符
    std::cout << p << std::endl; // 输出0x7ffeefbff538,一个地址
    std::cout << *p << std::endl; // 输出42
    int &r2 = *p; // &是声明的一部分,*是一个解引用符
    std::cout << r2 << std::endl // 输出42
}

理解符合类型的声明

涉及指针或引用的声明,一般有两种写法:

第一种是把修饰符和变量标识符写在一起,强调变量具有的复合类型

int *p1, *p2;

第二种是把修饰符和类型名写在一起,并且每句只定义一个变量,强调本次声明定义了一种复合类型

int* p1;
int* p2;

两种方法都对,关键是选择其中一种,并坚持这种写法!

指向指针的指针

通过*的个数可以区分指针的级别,**表示指向指针的指针,***表示指向指针的指针的指针

int ival = 1024;
int *pi = &ival;
int **ppi = π // ppi指向一个int型的指针

// 以下三种输出相同
cout << ival 
cout << *pi
cout << **ppi

指向指针的引用

因为引用本身不是一个对象,所以没法定义一个指向引用的指针,但指针是对象,所以存在指向指针的引用

int i = 42;
int *p; // p是一个int型指针
int *&r = p; // r是一个对指针的引用

r = &i; // r引用了一个指针,因此给r赋值,也就是给指针p赋值&i,即令p指向i
*r = 0; // 解引用r也就是解引用p,得到p指向的对象i,即把0赋值给i

技巧(超有用):要理解r的类型到底是什么,最简单的办法就是从右往左阅读r的定义,首先是&,所以r是一个引用,然后是符号*,即指针,即r引用的是一个指针,再然后是int,即r引用的是一个int型指针(r引用的是一个指向int型的指针)

const限定符

利用const对变量加以限定,这种变量的值不能被改变(写),但可以访问(读)const变量

因为const对象一旦创建后其值就不能再改变,所以const对象必须初始化

const int i = get_size(); // success
const int j = 42; // success
const int k; // ERROR!

编译器在编译过程把const变量都替换成对应的初始值

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

但是如果确实想让const对象在各个文件中共享,添加extern关键字即可,这样只需定义一次,在其他文件可以声明并使用它

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

const引用("常量引用")

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

const int ci = 1024;
const int &r1 = ci; // success 引用及其绑定的对象都是const int型
r1 = 42; // ERROR: Cannot assign to variable 'r1' with const-qualified type 'const int &'
int &r2 = ci; // ERROR: Binding value of type 'const int' to reference to type 'int' drops 'const' qualifier,试图让一个

因为ci是一个int型常量,所以不能通过引用去改变它,所以不能让int型的引用指向它(第四行),只能让const int型的引用指向它(第二行)

特例:允许为一个常量引用绑定非常量的对象、字面值,甚至是一个一般表达式(但是不允许通过常量引用改变所绑定对象的值

int i = 42;
const int &r = 0; // 正确
const int &r1 = i; // 正确,允许将const int *绑定到一个普通int型对象上
const int &r2 = 42; // 正确,r2是一个常量引用
const int &r3 = r1 *2; // 正确,r3是一个常量引用
int &r4 = r1 * 2; // 错误,r4是一个普通的非常量引用

另外一种情况:int型常量引用被绑定到了一个double型常量,C++认为这种行为是非法的

double dval = 3.14;
const int &ri = dval;
// 编译器把上述代码转换了如下形式
const int temp = dval;
const int &ri = temp;

这时,ri绑定了一个临时量(temporary),因为我们让ri引用dval,就是想通过ri改变dval的值,否则干什么要给ri赋值呢?如此看来,既然大家基本上不会想着把引用绑定到临时量上,C++也就把这种行为归为非法

最后说说为什么要在“常量引用“上打上双引号,因为严格来说没有常量引用,因为常量是相对于对象而言的,而引用本身不是对象!但是引用在字面意义上面的确是“常量“的(即不可变的),所以C++程序员们经常把 对常量的引用简称为“常量引用“

指向常量的指针

与引用一样,指针也可以指向常量或非常量,指向常量的指针(pointer to const)不能用于改变其所指对象的值,要想存放常量对象的地址,只能使用指向常量的指针(这与引用是一样的)

const double pi = 3.14;
double *ptr = π // ERROR: ptr是一个double型指针
const double *cptr = π // success: cptr是一个const double型指针
*cptr = 42; // ERROR: 不能给*cptr赋值

特例:允许一个指向常量的指针指向一个非常量(与引用的特例是一样的),指向常量的指针仅仅要求不能通过该指针改变对象的值,而没有规定那个对象的值不能通过其他方式改变

试试这样想把,所谓指向常量的引用or指针,不过是“自以为是“罢了,它们觉得自己指向了常量,所以自觉地不去改变所指对象的值

const指针(常量指针)

指针也是对象,所以允许把指针本身定义为常量,常量指针(const pointer)必须初始化,而且之后不能变,把*放在const关键字前用以说明指针是一个常量,即不变的是指针本身而不是指针所指向的那个值

int errNumb = 0;
int *const curErr = &errNumb; // curErr将一直指向errNumb
const double pi = 3.14;
const double *const pip = π // pip 是一个指向double型常量对象的常量指针

技巧(超有用):要弄清楚这些定义到底是什么意思,最简单的方法就是从右往左读,pip往左,第一个是const,说明pip是一个常量对象,然后是*,说明pip是一个常量指针,再然后是const double,说明pip是一个指向const double 型的常量指针!

练习

int &r = 0; // ERROR:非常量引用r不能引用字面值常量0
const int &r = 0; // SUCCESS
const int *const p3 = &i2; // SUCCESS:p3是一个常量指针,p3的值永远不变(永远指向i2),同时p3指向的是int型常量,即不能通过p3改变所指对象的值
const int *p1 = &i2; // SUCCESS: p1指向一个int型常量,即我们不能通过p1改变所指对象的值
const int &const r2; // ERROR:引用本身不是对象,不能用const限定符修饰引用
const int i2 = i, &r = i; // SUCCESS: i2是一个常量,r是一个常量引用

int i , *const cp; // ERROR: cp是一个常量指针,因其值不能被改变,所以必须被初始化!
int *p1, *const p2; // ERROR: 同上
const int ic, &r = ic; // ERROR: ic 是一个常量,因其值不能被改变, 所以必须被初始化
const int *const p3; // ERROR: p3是一个常量指针,其余同上
const int *p; // SUCCESS: p 是一个指向int型常量的指针,但是并没有指向任何实际的对象

i = ic; // SUCCESS: 常量ic的值赋给了i
p1 = p3; // ERROR: p1是指向int型的指针,p3是指向int型常量的常量指针,两者指向不匹配(Assigning to 'int *' from incompatible type 'const int *const')
p1 = ⁣ // ERROR: p1是指向int型的指针,ic是int型常量,两者不匹配
p3 = &ic // ERROR: p3 是一个常量指针,不能被赋值,只能被初始化
p2 = p1; // ERROR: p2同上
ic = *p3; // ERROR: ic 是一个常量,同上

顶层const与底层const

指针本身是一个对象,它又可以指向另一个对象,所以指针是不是常量以及指针所指的对象是不是常量是两个相互独立的问题,用名词 顶层const(top-level const)表示指针本身是个常量,用名词 底层const(low-level const)表示指针所指对象是一个常量

更一般的,顶层const可以表示任意对象是常量,如算数类型、类、指针等,底层const则与指针和引用等复合类型的基本类型有关,比较特殊的是,指针既可以是顶层const又可以是底层const

int i = 0;
int *const p1 = &i; // 不能改变p1的值,是顶层const
const int ci = 42;  // 不能改变ci的值,是顶层const
const int *p2 = &ci;// 不能通过p2改变ci的值(但允许通过其他方法改变ci的值,比如直接给ci赋值,或者通过引用给改变ci的值),是底层const
const int *const p3 = p2; // 靠右的是顶层const,靠左的是底层const
cosnt int &r = ci; // 用于声明引用的都是底层const(因为引用不是对象!)

执行拷贝操作的时候,顶层const不受什么影响:

i = ci; // SUCCESS: 把int型常量ci的值赋给i,ci是顶层const,对此无影响
p2 = p3; // SUCCESS: p2是const int型指针(底层const),p3是const int型常量指针(顶层const+底层const),顶层cosnt无影响

但是底层const的限制不容忽视,一般必须具有相同底层const资格,或者两个对象的数据类型必须能够转换,一般来说,非常量可以转化为常量,反之则不行:

int *p = p3; // ERROR:p3包含底层const的定义,而p没有
p2 = p3; // SUCCESS:p2和p3都包含底层const的定义
p2 = &i; // SUCCESS:int*可以转换为const int*(指向常量的指针那节有提到过:const int型指针可以指向int型对象)
int &r = ci; // ERROR:普通的int&不能绑定到int型常量上(常量不能转化为非常量)
const int &r2 = i; // SUCCESS:const int&可以绑定到int型上(非常量可以转化为常量)

感觉顶层const和底层const的出现,是为了系统地定下规则,这部分有点繁琐,要好好消化

常量表达式(const experssion)

常量表达式是指值不会改变,且在编译过程就能得到计算结果的表达式

const int max_files = 20; // 是
const int limit = max_files + 1; // 是
int staff_size = 27; // 不是
const int sz = get_size(); // 不是,尽管sz本身是一个常量,但是必须得等到运行时才能获取到值

constexpr变量

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

constexpr int mf = 20; // 是
constexpr int limit = mf + 1; // 是
constexpr int sz = size(); // 只有当size函数是一个constexpr函数时,这才是一条正确的声明语句

建议:一般来说,如果你认定变量是一个常量表达式,那就把它声明为constexpr

字面值类型

因为常量表达式的值需要在编译时就得到计算,所以得用字面值类型

尽管指针和引用都能定义成constexpr,但初始值受到严格限制,一个constexpr指针的初始值必须会nullpt或者0,或者是存储于某个固定地址中的对象,定义于所有函数体之外的对象其地址不变,可用于初始化constexpr指针

指针与constexpr

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

const int *p = nullptr; // p是一个指向int型常量的指针
constexpr int *q = nullptr; // q是一个指向int型的常量指针

简单来说,constexpr把它所定义的对象置为了顶层const

constexpr int *np = nullptr; // np是一个指向int型的常量指针,其值为空
int j = 0;
constexpr int i = 42; //i是一个int型常量
constexpr const int *p = &i; //p是一个指向int型常量i的常量指针
constexpr int *p1 = &j; // p1是一个指向int型j的常量指针

处理类型

类型别名(type alias)

类型别名是一个名字,是某种类型的同义词,可以让程序易读性更好,有两种方法可以实现类型别名

第一种是传统方法,使用关键字typedef

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

第二种是新标准的别名声明(alias declaration)

using SI = Sales_item; // SI 是Sales_item的同义词

类型别名对于指针和常量要格外小心

typedef char *pstring;
const pstring cstr = 0; // cstr是一个指向char的常量指针
const pstring *ps; // ps是一个指针,它指向一个指向char的常量指针(便于理解,可以认为ps指向cstr,这实际上是指向指针的指针)

当用const修饰类型别名时,如果直接替换,会产生意想不到的后果

const pstring cstr = 0; // cstr是一个指向char的常量指针
const char *cstr = 0; // cstr是一个指向const char的指针,与上行语句完全不同!

auto类型说明符

C++11新标准引入了auto类型说明符,用它就能让编译器我们去分析表达式所属的类型

auto item = val1 + val2; // item初始化为val1和val2相加的结果

auto i = 0, *p = &i; // SUCCESS:i是整数,p是int型指针
auto sz = 0, pi = 3.14; // ERROR: sz和pi的类型不一样,无法auto!

容易理解,当引用作为初始值时,编译器会以引用对象的类型作为auto的类型其

其次,auto一般会忽略顶层const,同时底层const一般会保留下来,比如当初始值是一个指向常量的指针时

int i  = 0;
const int ci = i, &cr = ci;
auto b = ci; // b是一个整数,ci的顶层const特性被忽略了
auto c = cr; // c是一个整数,cr是ci的别名,ci本身是一个顶层const
auto d = &i; // d是一个整形指针,
auto e = &ci;// e是一个指向整数常量的指针,对常量对象取地址是一种底层const

如果需要推断出的auto类型是一个顶层const,需要明确指出

const auto f = ci; // ci的推演类型是int,f是const int

auto &g = ci; // g是一个整形常量引用,绑定到ci
auto &h = 42; // ERROR:不能为非常量引用绑定字面值
const auto &j = 42; // SUCCESS:可以为常量引用绑定字面值

decltype类型指示符

有了auto,可以让编译器通过表达式自动推断出变量的类型了,但是有时并不想让该表达式初始化变量(同时也不会实际计算表达式的值),为此,C++11引入了decltype,它的作用是返回操作数的数据类型,decltype处理顶层const和引用的方式与auto有些许不同,如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用在内)

const int ci = 0, &cj = ci;
decltype(cj) x = 0; // x的类型是const int
decltype(cj) y = x; // y的类型是const int&,y绑定到const int变量x
decltype(cj) z; // ERROR:z是一个引用,必须初始化

因为cj是一个引用,decltype(cj)的结果就是引用类型,因此作为引用的z必须被初始化,一般来说,引用从来都是作为其所引用对象的同义词出现,只有用在decltype时是个例外

那如果想让结果类型是r所指的对象类型,可以把r作为表达式的一部分,如r+0,显然这个表达式的结果将是一个具体值而非引用

int i = 42, *p = &i, &r = i;
decltype(r + 0) b; // 正确:加法的结果是int,因此b是一个未初始化的int


另一方面,如果表达式的内容是解引用操作,则decltype将得到引用类型,这符合逻辑:解引用指针可以得到指针所指的对象,可以为对象赋值,而用这样的方法decltype一个变量,与所指对象有某种特殊关系,这种特殊关系部就是引用吗

decltype(*p) c; // 错误:c是int*,必须初始化

最后一点,decltype后面括号里的变量如果再加上一对括号,这就与不加括号时有所不同,decltype((variable))的结果永远是引用,decltype(variable)的结果只有当variable本身是一个引用时才是引用

decltype((i)) d; // ERROR: d是int&,必须初始化
decltype(i) e; // 正确:e是一个未经初始化的int

auto与decltype的异同

区别

  • auto类型说明符用编译器计算变量的初始值来推断其类型,而decltype虽然也让编译器分析表达式并得到它的类型,但是不实际计算表达式的值
  • 编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。例如,auto会忽略顶层const而保留底层const,而decltype会保留顶层const
  • decltype的结果类型与表达式的形式密切相关,如果变量加了括号,则得到的类型就与不加括号时完全不同

自定义数据结构

结构体(struct)

把一些数据元素组织到一起,

struct Sales_data{
    std::string bookNo; // 默认初始值,空字符串
    unsigned units_sold = 0; // 类内初始值
    double revenue = 0.0; // 类内初始值
}; // 注意这里引号不能少!

struct Sales_data{...} accum, trans, *salesptr; //定义结构体的同时,也定义了该结构体的对象

//与上条等价,但可能更好一点
struct Sales_data{...};
Sales_data accum, trans, *saleptr; 

类数据成员(data member)

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

头文件

为了确保各个文件中类的定义一致,类通常被定义在头文件中,而且类所在头文件的名字应与类的名字一样

头文件通常包含那些只能被定义一次的实体,如类、const和constexpr

头文件也经常用到其他头文件的功能,所以经常在一个头文件中 包含 另一个头文件

预处理器(preprocessor)

确保头文件多次包含仍能安全工作的常用技术是预处理器(proprocessor),继承自C语言,预处理器是编译之前执行的一段程序,可以部分地改变我们所写的程序,比如之前见到过的#include

还有一项预处理功能经常用到:头文件保护符(header guard),依赖于预处理变量,预处理变量有两种状态,已定义和未定义,#define指令把一个名字设定为预处理变量,#ifdef当且仅当变量已定义时为真,#ifndef当且仅当变量未定义时为真。一旦结果为真,则执行后续代码直到碰到#endif

整个程序中预处理变量包括头文件保护符必须唯一,通常的做法是基于头文件中类的名字来构建保护符的名字,以确保其唯一性,为了避免与其他实体名字冲突,预处理变量的名字一般大写

#ifndef SALES_DATA_H
#define SALES_DATA_H
#include 
struct Sales_data{
    std::string bookNo;
    unsigned units_sold = 0;
    double revenue = 0.0;
};
#endif

建议:头文件即使(目前)没有被包含在任何其他头文件中,也应该设置保护符,因为设置起来非常简单,只要习惯性地加上就可以了,没必要在乎你的程序到底需不需要

Chapter3 字符串、向量和数组

前面介绍了一些内置类型,它们体现大多数计算机硬件本身具备的能力,C++还定义了一个丰富的抽象数据类型库,其中string和vector是两种最重要的标准库类型,前者支持可变长字符串,后者则表示可变长的集合

还有一种是迭代器,它是string和vector的配套类型,常被用于访问string中的字符或vector中的元素

内置数组是一种更基础的类型,string和vector都是对它的某种抽象

命名空间的using声明

有了using声明就无须专门的前缀(形如命名空间::)也能使用所需的名字了,格式如下

using namespace::name;

注意,每个名字都需要独立的using声明

#include 
using std::cin; // 如果没声明cin,则函数体里使用cin时不能省略std::
using std::cout; using std::endl; //也可以放在一行声明,但是必须用分号隔开
int main(){
    int i;
    cin >> i; // 正确
    cout << i; // 正确
    std::cout << i; // 正确,显式地从std中使用cout
}

建议:头文件不应该包含using声明,因为头文件会被包含到其他文件里去,有可能会产生始料未及的名字冲突

标准库类型string

string表示可变长的序列,想使用必须包含string头文件,作为标准库的一部分,其定义在命名空间std里,为了简化代码,下文都假定包含下面的代码

#include 
using std::string;

定义与初始化string

string s1; // 默认初始化,s1是空字符串
string s2(s1); //s2是s1的副本
string s2 = s1; //同上
string s3("value"); //s3是字符串字面值"value"的副本,除了字面值最后那个空字符外
string s3 = "value"; //同上
string s4(10, 'c'); //s4初始化为连续10个字符c组成的字符串

有等号的叫做拷贝初始化(copy initialization),相反,不使用等号的是直接初始化(direct initialization)

string对象上的操作

读写string对象

可以用IO操作符读写string对象

string s;
cin >> s; // 遇到空白停止
cout << s << endl;

string对象会自动忽略开头的空白(即空格符、换行符、制表符等),并从第一个真正的字符开始读起,直到遇见下一处空白为止,例如如果输入" Hello World! ",则输出的是”Hello“,没有任何空格

和内置类型的输入输出操作一样,string对象的此类操作也是返回运算符左侧的运算对象作为其结果

string s1, s2;
cin >> s1 >> s2;
cout << s1 << s2 << endl;

如果输入同样的内容,则输出的是"HelloWorld!",还是没有任何空格!

读取未知数量的string对象
string word;
while(cin >> word){ //反复读取,直到文件末尾或非法输入
    cout << word << endl; //逐个输出单词,每个单词后面紧跟一个换行
}

使用getline读取一整行

>>读到空格就会停下来,导致读不到空格,有时我们需要读取一整行,碰到换行符才停下来,这时可以用getline函数,注意这时换行符会读到,但是不会存入到string对象里去,而且如果第一个输入的就是换行,那么所得的结果就是个空字符串!

同样的,该函数也会返回它的流参数(即cin),所以也可以用while读取位置数量的string对象

string line;
while(getline(cin, line)){
    cout << line << endl;
}

因为line不包含换行符,所以加上手动加上endl换行

使用cin.get()读取特殊字符

因为>>会忽略输入的特殊符号,如空格、制表符、换行符,读取特殊字符需要用cin.get()

char ch;
while(cin.get(ch)){
    if(ch == ' '){
        //空格
    }
    if(ch == '\t'){
        //制表符
    }
    if(ch == '\n'){
        //换行符
    }
}

empty和size操作

empty函数根据string对象是否为空来返回一个对应的布尔值,使用点操作符即可调用

//每次读入一整行,遇到空行直接跳过!
while(getline(cin, line)){
    if(!line.empty()){
        cout << line << endl;
    }
}

size函数返回string对象的长度(即string对象中字符的个数)

//每次读入一整行,输出其中超过80个字符的行
while(getline(cin, line)){
    if(line.size() > 80){
        cout << line << endl;
    }
}

string::size_type类型

string.size()返回的并不是int或者是unsigned类型,而是一个叫做string::size_type类型的值,它是一个无符号的值,而且足够大以至于能够存放任何string对象的大小,而且,所有用于存放string.size()的返回值的变量,都应该是string::size_type类型!

C++11可以通过auto或者decltype来推断变量的类型

auto len = line.size(); // len的类型是string::size_type

比较string对象

==or!=可用来检验两个string对象是否相等或不相等,< ≤ > ≥用来比较大小,按照两个string对象对应字符的字典顺序比较,对大小写敏感

  • 如果两个string对象长度不同,且较短string的每个字符都与较长string的对应位置上的字符相等,则较短string < 较长string
  • 如果两个string对象在某些位置上不一致,则取决于第一个不同字符的比较结果

总之就是从头开始,依次比较,按字典顺序比较,非空字符总比空字符的要大

相加与追加

+串接两个string形成一个新string,+=把右边string追加到左边string上

string s1 = "hello", s2 = "world\n";
string s3 = s1 + s2; //s3的内容是hello, world\n
s1 += s2; // 等价于s1= s1 + s2

string不仅可与string相加,还可以与字面值相加,但一定要保证+两边有一个是string对象!

string s4 = s1 + ","; // success
string s5 = "hello" + ", "; // ERROR!
string s6 = s1 + ", " + "world";
string s7 = "hello" + ", " + s2; // ERROR!

警告⚠️:由于历史原因,也为了与C语言兼容,C++的字符串字面值并不是标准库类型string的对象!切记,字符串字面值和string对象是不同的对象!

处理string中的字符

要想知道或改变某个字符的特性,可以用cctype这个头文件中的标准库函数

cctype头文件中的函数 作用
isalnum(c) c是字母or数字时为真
isalpha(c) 字母
iscntrl(c) 控制字符
isdigit(c) 数字
isgraph(c) 不是空格但可打印
islower(c) 小写字母
isprint(c) 可打印字符(空格或具有可视形式)
ispunct(c) 标点符号
isspace(c) 空白(空格、横向制表符、纵向制表符、回车符、换行符、进纸符
isupper(c) 大写字母
isxdigit(c) 十六进制数字
tolower(c) 如果c是大写字母,输出小写的c;否则输出c
toupper(c) 小写变大写

建议:使用C++版本的C标准库头文件,为了兼容C语言中形如name.h的头文件,C++将它们命名为cname(没有后缀名),这里的c表示这是属于C语言的标准库的头文件,因为name.h与cname内容完全相同,所以用cname会更易读,一看就知道是来自于C语言的头文件

使用基于范围的for语句处理每个字符

C++新加了范围for(range for)语句,可以遍历给定序列中的每个元素

for (declaration: expression)
    statement

举例,可以把string中每个字符独占一行输出

string str("some string");
for (auto c : str){
    cout << c << endl; 
}

例子:统计string对象中标点符号的个数

string s("Hello World!!!");
decltype(s.size()) punct_cnt = 0;
for(auto c : s){
    if(ispunct(c))
        ++punct_cnt;
}
cout << punct_cnt << " punctuation characters in " << s << endl;

通过for处理字符串所有字符

可以把循环定义成引用类型,因为引用只是对象的别名,所以当引用作为循环控制变量时,这个变量实际上被依次绑定到序列的每个元素上,使用这个引用,我们就能改变它绑定的字符

例子:把string中所有字符全部大写

string s("Hello World!!!");
for (auto &c : s){
    c = toupper(c); // c是一个引用,因此每次赋值都会改变s中对应字符的值
}
cout << s << endl; // 输出结果是: HELLO WORLD!!!

通过下标处理字符串单个字符

下标运算符[]接收的输入参数是string::size_type类型的值,string的下标从0开始计算,string[s.size()-1]是最后一个字符,下标必须大于等于0而小于size(),不在这个范围将报错,由此可知,下标访问空字符串也会报错

严格来说,string不属于容器

标准库类型vector

vector表示对象的集合,其中所有对象的类型都相同,集合中每个对象都有一个与之对应的索引,索引用于访问对象,因为vector”容纳“其他对象,所以它常被称为容器(container)

与string一样,假设下文代码都包含了

#include 
using std::vector;

vector是一个类模板(class template),与类模板相对的是函数模板

模板本身不是类或函数,而是编译器生成类或函数编写的一份说明,编译器根据模板创建类或函数的过程称为实例化(instantiation)

vector ivec; //ivec保存int类型的对象
vector Sales_vec; //保存Sales_item类型的对象
vector> file; //该向量的元素是vector元素,这些vector的元素都是int型对象

注意:在早期C++标准中,如果vector的元素还是vector,则需要在外层vector的右尖括号和其元素类型之间添加一个空格,如

vector > 

某些老式的编译器有可能也需要这样的语句

定义和初始化vector

vector v1 // v1是一个空vector,它潜在元素是T类型,执行默认初始化
vector v2(v1) // v2包含有v1所有元素的副本,注意v2和v1的类型必须相同!
vector v2 = v1 // 同上
vector v3(n, val) // v3包含了n个重复元素,每个元素的值都是val
vector v4(n) // v4包含了n个重复地执行了值初始化的T类型对象
vector v5{a, b, c...} // v5包含了a、b、c...等元素,每个元素默认初始化
vector v5={a, b, c...} // 同上

C++提供了列表初始化,用花括号括起来的0个或多个初始元素值被赋给了vector对象,如上面最后两行代码

值初始化(value-initialized):根据元素的类型,自定设置值,如int设为0,string设为空string

注意区分花括号和圆括号,花括号用来列表初始化(list initialize),圆括号用来构造(construct)vector对象

vector v1(10); // v1有10个int型元素,每个元素都默认初始值为0
vector v2{10}; // v2有1个int型元素,其值为10
vector v3(10, 1); // v3有10个int型元素,每个元素都为1
vector v4{10, 1}; // v4有2个int型元素,值分别为10和1

注意:花括号的使用有个例外,初始化使用了花括号的形式但是提供的值又不能用来列表初始化,就要考虑用这样的值来构造vector对象,具体请看下面的例子

vector v5{"hi"}; // 列表初始化,v5只有"hi"这一个string元素
vector v6("hi"); // ERROR: 不能用字符串字面值来初始化vector对象
vector v7{10};   // 本意是想列表初始化,但是10不是string,于是把10理解为个数,最后的结果是10个string对象,默认初始值(空string)
vector v8{10, "hi"}; // 本意是想列表初始化,同上,v8有10个值为"hi"的元素

向vector对象添加元素

利用vector的成员函数push_back向其中添加元素,push_back负责把一个值当成vector对象的尾元素”压到(push)“vector对象的”尾端(back)

vector v2; 
for(int i = 0; i != 100; ++i){
    v2.push_back(i);
} // 循环结束后v2有100个元素,其值从0到99

C++习惯先创建空的vector对象,在运行时再动态添加元素,这一做法与C语言或Java恰恰相反,它们习惯在创建的时候顺便指定其容量

警告⚠️:范围for语句体内不应改变其所遍历序列的大小!

其他vector操作

范围for遍历vector里的每一个元素

vector v{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
for(auto &i : v){
    i *= i; // 通过引用改变v中每个元素的值,使其平方
}
for(auto i: v){
    cout << i << " "; // 输出1 4 9 16 25 36 49 64 81 100 
}

empty与size函数的用法与string完全相同,但有一点要注意:vector对象的类型总是包含其元素类型

vector::size_type // SUCCESS
vector::size_type      // ERROR

相等性与大小关系判断与string一样,但注意,只有当vector中的元素可比较的时候,vector才能比较,比如vector,其中Sales_item是自定义的,并不支持相等性判断或关系运算等操作,所以两个vector不能被比较

下标/索引的使用与string也差不多

注意:vector和string都可以通过下标的方式访问已存在的元素,但不能用于添加元素

警告⚠️:试图用下标的形式去访问一个不存在的元素将会引发错误,但这种错误不会被编译器知晓,而是在运行时产生一个不可预知的值!所谓的缓冲区溢出(buffer overflow)指的就是这类错误

建议:确保下标合法的一个有效手段就是尽可能使用范围for语句

迭代器(iterator)

所有标准库容器都可以使用迭代器,但是只有其中少数几种才支持下标运算符,严格来说,string不属于容器,但支持下标运算符与迭代器

获取迭代器不是使用取地址符什么的,而是调用有迭代器类型的beginend的成员,其中begin成员负责返回指向第一个元素(或第一个字符)的迭代器

auto b = v.begin(), e = v.end(); // b和e类型相同

end成员返回指定容器(或string对象)”尾元素的下一位置(one past the end)“的迭代器,这样做其实没什么实际意义,仅是个标记而已,表示我们已经处理完了容器中所有元素,end成员返回的迭代器通常称为 尾后迭代器(off-the-end iterator)或简称 尾迭代器(end iterator),如果容器为空,那么begin和end返回的是同一个迭代器,都是尾后迭代器

标准容器迭代器的运算符 作用
*iter 返回迭代器iter所指元素的引用
iter->mem 解引用iter并获取该元素名为mem的成员,等价于(*iter).mem
++iter 令iter指示容器的下一个元素
--iter 令iter指示容器的上一个元素
iter1 == iter2 判断两个迭代器是否相等,如果指示的是同一个元素或同一个容器的尾后迭代器,则相等
iter1 != iter2 不相等

举例

string s("some string");
if(s.begin() != s.end()){ // 确保s非空
    auto it = s.begin();    //it表示s的第一个字符
}
cout << s << endl;              // 输出结果是 Some string

for(auto it = s.begin(); it != s.end && !isspace(*it); ++it)
  *it = toupper(*it);
cout << s << endl;              // 将所有字符改成大写形式,直至字符串结束或遇到空白

建议:泛型编程,C语言和Java习惯在for循环里用 作为判断的条件,但是在C++里经常使用!=作为判断的条件,这是因为容器的迭代器都定了==or!=,但是并不是都定义了<。因此当我们养成了用迭代器和!=的习惯,就不用在意到底是哪种容器类型

迭代器类型

拥有迭代器的标准库类型使用iterator和const_iterator来表示迭代器的类型,如果vector/string是常量,则必须使用const_Iterator,如果不是常量,则两者都可以用

vector::iterator it; // it能读写vector的元素
string::iterator it2;     // it2能读写string对象中的字符
vector::const_iterator it3; // it3只能读vector的元素,不能写
string::const_iterator it4;      // it4只能读string对象的字符,不能写

术语:迭代器与迭代器类型

迭代器有三个含义:迭代器概念本身,也可能指容器定义的迭代器类型,还可能指某个迭代器对象

我们认定某个类型是迭代器,当且仅当它支持一套操作,这套操作使得我们能访问容器的元素或者从某个元素移动到另外一个元素

每个容器定义了一个名为iterator的类型,该类型支持迭代器概念所规定的的一套操作

C++11引入了两个新函数:cbegin和cend,无论容器是否是常量,这两个函数都会返回常量迭代器const_iterator(只能读,不能写)

结合解引用和成员变量的访问操作——箭头运算符->

it是迭代器,如果想直到当前所指元素是否为空,可以用

(*it).empty();// 正确,解引用it,然后调用结果对象的empty成员
*it.empty() ; // 错误,试图访问it的名为empty的成员,但it是个迭代器,没有empty成员

为了简化操作,可以用箭头运算符->把解引用和成员变量的操作结合在一起

it->empty();  // 正确,同上上

警告⚠️:但凡使用了迭代器的循环体,都不要向迭代器所属的容器添加元素!

迭代器运算(iterator arithmetic)

vector和string支持的迭代器运算 作用
iter + n
iter - n
iter += n
iter -= n
iter1 - iter2 两个迭代器相减的结果是它们的距离
>、>=、<、<= 某迭代器指示的容器位置在另一个迭代器所指位置之前,则说前者小于后者

距离是指右侧迭代器向前移动多少位置就能追上左侧迭代器,其类型为difference_type的带符号整数,string和vector都定义了difference_type,因为距离可正可负,所以difference_type是带符号整数

用二分搜索举例说明迭代器运算

int sought = 3; //待搜索元素
vector text{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; //必须有序
auto begin = text.begin(), end = text.end();
auto mid = text.begin() + (end - begin)/2; // 初始状态下的中间值
while(mid != end && *mid != sought){
    if(sought < *mid){                  //待搜索元素在前半部分吗
        end = mid;   
    }else{                                                          //待搜索元素在后半部分
        begin = mid + 1;
    }
    mid = begin + (end - begin)/2;       //新的中间点
}

警告⚠️:计算中间值时,不能写成mid = (ivec.cbegin() + ivec.cend())/2,因为没有定义两个迭代器的加法运算

数组

数组与vector的异同

  • 同:存放类型相同的对象的容器,这些对象本身没有名字,需要通过其所在位置访问
  • 异:数组的大小确定不变,而vector是可变的!虽然数组有些时候运行性能较好,但是损失了灵活性

定义与初始化数组

数组的元素应为对象,所以不能存引用

定义数组时必须指定数组的类型,不允许用auto关键字

定义数组时若没有指定其大小,则编译器会根据初始值数量推测大小

定义数组时若指定的大小比提供的初始值维度要大,则会数组中靠后的元素默认初始化

定义数组时若指定大小,必须为字面值(比如说10),或者常量表达式(变量为常量或constexpr),但是有些编译器不会报错,这是因为编译器支持了拓展

const unsigned sz = 3;,
int ia1[sz] = {0, 1, 2}; //含有三个元素的数组
int a2[] = {0, 1, 2};        //含有三个元素的数组
int a3[5] = {0, 1, 2};   //等价于a3[] = {0, 1, 2, 0, 0} 默认初始化
string a4[3] = {"hi", "bye"}; // 等价于 string a4[] = {"hi", "bye", ""};
int a5[2] = {0, 1, 2};   // ERROR: Excess elements in array initializer

字符数组的特殊性

用字符串字面值初始化字符数组时,要注意结尾还有一个空字符\0也会拷贝进去

char a1[] = {'c', '+', '+'}; //列表初始化,三个字符,没有空字符
char a2[] = {'c', '+', '+', '\0'}; //列表初始化,包括空字符有四个字符
char a3[] = "c++"; //初始化后,a3有包括结尾空字符在内的四个字符
const char a4[6] = "Daniel"; //ERROR: 没有空间存放空字符!

不允许拷贝和赋值!
int a[] = {0, 1, 2};
int a2[] = a;           //ERROR:不允许使用一个数组初始化另一个数组!
a2 = a;                                 //ERROR:不能把一个数组直接赋值给另一个数组!

理解复杂的数组声明

默认情况下,类型修饰符从右往左依次绑定,如有括号,则先由内向外,再从右往左,比如下面的Parray,*Parray表示指针,然后看右边,可知Parray是个指向大小为10的数组的指针,最后观察左边,知道数组中元素是int

int *ptrs[10];
int &refs[10] = /* ? */;        // ERROR:不存在引用的数组
int (*Parray)[10] = &arr;           // Parray指向一个函数10个int型元素的数组
int (&arrRef)[10] = arr;            // arrRef引用一个含有10个int型元素的数组

当然,对修饰符的数量并没有特殊限制

int *(&array)[10] = ptrs; // array是数组的引用,该数组含有10个指针,每个指针都是指向int型整数

访问数组元素

数组的元素可用范围for语句或下标运算符来访问,在使用下标时,通常将其定义为size_t类型,这是一种与机器相关无符号类型,它被设计的足够大以便能表示内存中任意对象的大小

下标是否越界应该由程序员检查!

指针和数组

指针和数组紧密联系,使用数组的时候编译器一般会把它转换成指针

使用取地址符来获取指向某个对象的指针,取地址符可以用于任何对象,而数组的元素也是对象

string nums[] = {"one", "two", "three"}; // 数组的元素是string对象
string *p = &nums[0];                                        // p指向nums的第一个元素

实际上,在很多用到数组名字的地方,编译器会自动地将其替换为一个指向数组首元素的指针

string *p2 = nums;  // 等价于string p2 = &nums[0]

对数组的操作,很多时候是对指针的操作,比如下面的auto

int ia[] = {0,1,2,3,4,5,6,7,8,9};
auto ia2(ia);   //ia2是一个int型指针,指向ia的第一个元素,相当于auto ia2(&ia[0])
ia2 = 42;           //ERROR,ia2是一个指针,不能把int赋值给指针

但是用decltype则不一样

decltype(ia) ia3 = {0,1,2,3,4,5,6,7,8,9};
ia3 = p;                //ERROR:不能用整形指针给数组赋值
ia3[4] = i;         //SUCCESS:把i的值赋给ia3的一个元素

指针也是迭代器

int arr[] = {0,1,2,3,4,5,6,7,8,9};
int *p = arr;
++p;   //这时p指向arr[1]

C++引入了begin和end函数,它们与容器中的两个同名函数功能类似,但因为数组不是类类型,所以这两个函数不是成员函数,正确形式如下

int *begin = begin(arr); //指向数组首元素
int *end = end(arr);         //指向尾元素的下一位置,注意,这是不存在的元素,不能解引用或递增!

指针运算

指针加or减某数,结果仍是指针,但一定要满足新指针指向同一数组的其他元素或尾元素的下一位置

两个指针相减的结果的类型是ptrdiff_t标准库类型,因为差值有可能为负,所以是带符号类型

只要两个指针指向同一数组的元素,它们就可以比较,> ≥ < ≤等等

下标与指针

因为数组名字其实是指针,所以对数组使用下标运算符,实际上进行了指针的运算,进一步地,如果指针指向了数组元素,指针也可以进行下标运算

int ia[] = {0,2,4,6,8};
int i = ia[2];          //ia是数组首元素的指针,ia[2]得到(ia+2)所指的元素
int *p = ia;                //p指向ia的首元素
int *p = &ia[2];        //p指向数组索引为2的元素(第3个元素)
int j = p[1];               //p[1]等价于*(p+1),就是ia[3]的那个元素
int k = p[-2];          //指ia[3]

警告⚠️:内置的下标运算符所用的索引值不是无符号类型(如上面最后一行为负数),这和vector or string 不一样!

C风格字符串

建议:尽管C++支持,但最好还是不要使用它们,因为不仅不方便,还容易引发漏洞

字面值字符串就是C++由C继承而来的C风格字符串,按此习惯书写的字符串存放在字符数组中并以空字符结束(null terminated)

C语言标准库提供的函数:

strlen(p) 返回p的长度,空字符不计算在内
strcmp(p1, p2) 比较p1和p2的相等性,若相等则返回0;若大于则返回一个正值;若小于则返回一个负值
strcat(p1, p2) 将p2附加到p1之后,返回p1
strcpy(p1, p2) 将p2拷贝给p1,返回p1

注意,传入此类函数的数组必须以空字符作为结束!

char ca[] = {'C', '+', '+'};
cout << strlen(ca) << endl; // 严重错误:ca没有以空字符结束(但拓展的编译器能通过)

比较字符串

比较标准库string对象时,用的是普通关系运算符和相等性运算符,但因为数组的名字实际上是指针,没法直接比较两个指针的值(也就是无法比较两个地址的值),而应该用strcmp函数比较字符串字面值!

目标字符串的大小由调用者指定

拼接两个string对象很简单

string largeStr = s1 + " " + s2;

但是用C风格字符串的方式拼接两个字符串字面值很容易出错

strcpy(largeStr, ca1);
strcat(largeStr, " ");
strcat(largeStr, ca2);

largeStr必须足够大以便容纳两个字符串和空格,若不够大,则很引发严重错误,而且估算largeStr的大小时总是不够准确,多了浪费,少了报错,所以最好不要用C风格的字符串!

与旧代码的借口

混用string对象和C风格字符串

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

  • 允许以空字符结束的字符数组来初始化string对象或为string对象赋值
  • 在string对象的加法运算中允许使用以空字符结束的字符数组作为其中一个运算对象(不能两个运算对象都是);在string对象的复合赋值运算中允许使用以空字符结束的字符数组作为右侧的运算对象

但是反过来不成立了,如果需要C风格字符串,不能直接用string对象代替它,但是可以用string的成员函数c_str来完成

char *str = s;   //错误:不能用string对象初始化char*
const char *str = s.c_str(); //正确

但是如果后续代码改变了s的值,那么str的值也会改变,为了使str不改变,应该将其重新拷贝一份

使用数组初始化vector对象

只需指明要拷贝区域的首元素地址和尾后地址即可

int int_arr[] = {0, 1, 2, 3, 4, 5};
vector ivec(begin(int_arr), end(int_arr)); //ivec有6个元素,0,1,2,3,4,5,6
vector subVec(int_arr + 1, int_arr + 4);   //ivec有3个元素,分别是1,2,3

注意:没法用vector对象初始化数组

建议:尽量使用标准库类型

尽量使用vector和迭代器,而避免使用内置数组和指针,应该尽量使用string,而避免使用C风格的基于数组的字符串

多维数组

严格来说没有多维数组,通常说的多维数组其实是数组的数组

对于二维数组,习惯把第一个维度称为行,第二个维度称为列

int ia[3][4];                               //ia是大小为3的数组,每个元素都是一个大小为4的数组
int arr[10][20][30] = {0};  //所有元素初始化为0

多维数组的初始化

int ia[3][4] = {
    {0, 1, 2, 3},
    {4, 5, 6, 7},
    {8, 9, 10, 11}
};                                                                                      //初始化数组所有元素
int ia[3][4] = {0,1,2,3,4,5,6,7,8,9,10,11};     //初始化数组所有元素
int ia[3][4] = {{0}, {4}, {8}};         //初始化数组每行的第一个元素分别为0,4,8
int ia[3][4] = {0, 3, 6, 9};                //显式初始化数组的第一行,其余默认初始化

使用for循环来处理多维数组是很常见的,C++11引入的范围for语句进一步简化了多重for循环的代码,但是要注意,除了最内层的范围for循环外,其他所有循环的控制变量都应该是引用,因为数组的名字其实是一个指针,内层循环对一个指针进行遍历显然是不可取的!

// SUCCESS
for(auto &row : ia){
    for(auto col : row){
        cout << col << endl;
    }
}

// ERROR
for(auto row : ia){
    for(auto col : row){
        cout << col << endl;
    }
}

当代码使用多维数组的名字时,也会自动将其转换为指向数组首元素的指针

int ia[3][4];
int (*p)[4] = ia;    //p指向含有4个整数的数组,ia指向ia数组的一个元素(即第一行)
p = &ia[2];                  //p指向ia数组的尾元素(即最后一行

利用指针与数组的关系,也可以通过指针遍历二维数组,仔细领会内外层for循环的差别

int ia[3][4] = {/*...*/};
//p指向ia数组的首元素,即p指向一个含有4个整数的数组,每次递增p,指向ia数组的下一元素(仍是一个4元素数组)
for (auto p = ia; p != ia + 3; ++p){   
  //(*p)就是一个含有4个整数的数组,q指向这个数组,每次递增q,指向这个4元素数组的下一元素
    for (auto q = *p; q != *p + 4; ++q){ 
        cout << *q << ' ';
    }
    cout << endl;
}

用标准库函数begin()和end()可以使上述代码更简洁,以及避免类似数组长度3、4这种硬编码

如果不用auto关键字,指针的类型要搞清楚,这里很容易出错

for(int (*p)[4] = ia; p != ia + 3; ++p){
    for(int *q = *p; q != *p + 4; ++q){
        cout << *q << ' ';
    }
    cout << endl;
}

练习3.43

/*
 * Exercise 3.43
 * You are not allowed to use alias, auto or decltype
 */
void traverseArray(){
    int ia[3][4] = {0,1,2,3,4,5,6,7,8,9,10,11};
    // using range for
    for (int (&row)[4] : ia){
        for (int col : row){
            cout << col;
        }
    }
    // using normal for with subscript
    for (int i = 0 ; i != 3; ++i){
        for (int j = 0 ; j != 4; ++j){
            cout << ia[i][j];
        }
    }
    // using normal for with pointer
    for (int (*pr)[4] = ia ; pr != ia + 3; ++pr){
        for (int *pc = *pr ; pc != *pr + 4; ++pc){
            cout << *pc;
        }
    }
}

Chapter4 表达式

关系:表达式由一个或多个运算对象(operand)组成,对表达式求值可以得到一个结果(result),字面值和变量时最简单的表达式(expression),其结果就是字面值和变量的值,把一个运算符(operator)和一个或多个运算对象组合起来可以生成较复杂的表达式

基础

运算符可分为一元运算符(unary)二元运算符(binary)和三元运算符,顾名思义,作用于一个运算对象的运算符是一元运算符,以此类推,有些运算符可一元可二元,比如*作一元是解引用,作二元是乘法,要根据上下文推测

对于多个运算符的复杂表达式而言,要理解含义首先要理解运算符的优先级(precedence)结合律(associativity)以及运算对象的求值顺序(order of evaluation)

有时运算对象并不都是一样的类型,有些情况下可以转换类型

重载运算符(overloaded operator):作用于类类型的运算对象时,可自定义运算符,包括运算对象的类型和返回值类型,但是运算对象的个数、运算符的优先级和结合律都是无法改变的!

左值(lvalue)和右值(rvalue)

从C语言继承过来的,原本是为了帮助记忆:左值可以放在赋值语句的左侧,右值则不能

但是C++里左值和右值比较复杂,一个左值表达式的求值结果是一个对象或一个函数,但一些以常量对象为代表的某些左值不能作为赋值语句的左侧运算对象,归纳:当一个对象被用作右值时,用的是对象的值(内容);当对象被用作左值时,用的是对象的身份(在内存中的位置)

有的运算符需要左值运算对象,有些需要右值运算对象;返回值也有的是左值,有的是右值。原则:在需要右值的地方可以用左值代替,但不能把右值当做左值(位置)使用

这里比较难懂,下次再复习!

求值顺序(order of evaluation)

很多运算符满足左结合律,这意味着同优先级时按照从左向右的顺序进行组合

但是,优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值(不一定从左至右),在大多数情况下,不会明确指定求值的顺序,比如下面无法知道f1先调用还是f2先调用

int i = f1() * f2();

如果两个表达式无关,它们不会改变同一对象的状态也不执行IO任务,那么这么写是无关大雅的,但是如果表达式指定并修改了同一个对象,那么将会引起混乱,如下面的代码有肯恩输出1 1 有可能是输出0 1,不可预知

int i = 0;
cout << i << " " << ++i << endl; //未定义的

建议:处理复合表达式

  • 最好用括号强制让表达式的组合关系符合逻辑要求

  • 如果改变了某个运算对象的值,在表达式其他地方就不要再使用这个运算对象

但有一点例外,就是当改变运算对象的子表达式本身就是另外一个子表达式的运算对象时,该规则无效,例如*++iter,递增运算符改变iter的值,iter(已经改变)的值又是解引用运算符的运算对象,此时不会造成困扰,因为递增必须先执行,然后才轮到解引用

算术运算符

这里很简单了,加减乘除取余,正号负号,

除法/直接舍弃小数部分取整,两个运算对象同符号时商为正(如果不为0的话),早期C++版本允许结果为负值的商向上或向下取整,C++11新标准规定一律舍弃小数部分取整

取余%得到两个整数相除所得的余数

逻辑和关系运算符

短路求值(short-circuit evaluation):先求左侧运算对象的值再求右侧运算对象的值

  • 对于逻辑与&&:当且仅当左侧运算对象为真时才对右侧运算对象求值
  • 对于逻辑或||:当且仅当左侧运算对象为假时才对右侧运算对象求值
  • 这两个运算符是C++中少有规定了求值顺序(order of evaluation)的运算符

逻辑非运算符!

关系运算符是二元操作符,结果是一个布尔值,所以不能连写

if(i

相等性测试与布尔字面值

相等性运算符的两个运算对象都需要求值,C++没有规定其求值顺序

如果想测试一个算术对象或指针对象的真值,最直接的方法就是将其作为if语句的条件

if(val)     //如果val为任意非0值,条件为真
if(!val)    //如果val是0,条件为真
if(val == true) //只有当val=1时,才为真
if(val == 1)    //当val不是布尔值时,上面的表达式会转换成这个表达式,所以要谨慎!

建议:进行比较运算时,除非比较对象是布尔类型,否则不要把true或false作为运算对象

赋值运算符

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

int i = 0, j = 0, k = 0; //初始化而非赋值
const int ci = i;                //初始化而非赋值
1024 = k;                           //ERROR:字面值是右值
i + j = k;                              //ERROR:算术表达式是右值
ci = k;                                     //ERROR:ci是常量(不可修改)左值

赋值的结果就是它左侧的运算对象,并且是一个左值,类型也是左侧运算对象的类型,如果左右类型不一样,则右侧运算对象的类型将转换成左侧的类型

赋值运算符满足右结合律

int ival, jval;
ival = jval = 0; //SUCCESS,都被赋值为0

对于多重赋值语句中的每个对象,它的类型或者与右边对象类型相同,或者可由右边对象的类型转换得到

int ival, *pval;
ival = pval = 0;            //ERROR:不能把指针的赋给int
string s1, s2;
s1 = s2 = "OK";             //SUCCESS:字符串字面值"OK"赋给string对象

其实pval=0是合法的(0可以赋值给任意对象),但因为ival是int型,pval是int*型,所以不能赋值

赋值运算符优先级较低,在表达式中应该加上括号

while((i=get_value()) != 42)

切勿混淆相等运算符和赋值运算符

递增和递减运算符

递增和递减运算符有两种形式:前置版本和后置版本

int i = 0, j;
j = ++i;            //j=1,i=1:前置版本得到递增之后的值
j = i++;            //j=1,i=2:后置版本得到递增之前的值

注意:这两种版本都必须作用于左值运算对象,前置版本将对象本身作为左值返回,后置版本将对象原始值的副本作为右值返回

建议:除非必须,否则不用后置版本,因为后置版本需要将原始值存储下来以便于返回这个未修改的内容,而如果我们不需要修改前的值,那么后置版本的操作就是一种浪费

在一条语句中混用解引用和递增运算符

如果我们想在一条复合表达式既将变量+1or-1又能使用它原来的值,这时就可以使用递增和递减运算符的后置版本

auto pbeg = v.begin();
while(pbeg != v.end() && *beg >= 0){
    cout << *pbeg++ << endl;                        //输出当前值并将pbeg向前移动一个元素
}

仔细分析一下pbeg,后置递增运算符的优先级高于解引用运算符,因此pbeg++等价于(pbeg++),pbeg++把pbeg的值加1,然后返回pbeg的初始值的副本作为其求值结果,此时解引用运算符的运算对象是pbeg未增加之前的值,最终,这条语句输出pbeg开始时指向的那个元素,并将指针向前移动一个位置

成员访问运算符

点运算符和箭头运算符都可以用于访问成员,其中点运算符获取类对象的一个成员;箭头运算符与点运算符有关

string s1 = "a string", *p = &s1;
auto n = s1.size();         //运行s1对象的size成员函数
n = (*p).size();                //运行p所指对象的size成员函数
n = p->size();                  //同上

因为解引用优先级低于点运算符,所以上面第三行一定要加括号

n = *p.size();                  //访问对象p的size成员函数,然后再解引用

箭头运算符作用于一个指针,结果是左值

点运算符则取决于成员所属对象是左值还是右值

条件运算符

把简单的if-else逻辑嵌入到单个表达式中,先求cond的值,如果为真,则执行expr1并返回结果,如果为假,则执行expr2并返回结果,条件运算符只对一个expr1或expr2中的一个求值

cond ? expr1 : expr2;
finalgrade = (grade > 90) ? "fail" : "pass";
finalgrade = (grade > 90) ? "high pass" : (grade < 60) ? "fail" : "pass"; // 嵌套条件运算符

条件运算符满足右结合律,所以运算对象从右往左顺序组合,子条件运算符既可有作cond也可以作expr1/expr2

思考一下:如果条件运算符是满足左结合律的,嵌套条件运算符是什么顺序求值?

finalgrade = ((grade > 90) ? "high pass" : (grade < 60)) ? "fail" : "pass"; // 嵌套条件运算符

回答:先考察grade>90是否成立,若成立则表达式求值为”high pass“,否则求值为grade<60,它的结果是布尔值,而条件运算符要求两个运算对象类型相同或可转换,所以这里会报错,Incompatible operand types ('const char *' and 'bool')

条件运算符的优先级非常低,所以一定要加括号!

cout << ((grade < 60) ? "fail" : "pass"); //输出pass或者fail
cout << (grade < 60) ? "fail" : "pass");  //输出1或者0?
cout << grade < 60 ? "fail" : "pass");    //错误:试图比较cout和60

上面代码第一行是正确写法,cout << xxx 的结果仍然是cout,所以第二行可以分解成下面这样

cout << (grade < 60); //输出1或者0,取决于true或者false
cout ? "fail" : "pass";     //根据cout的值是true还是false返回"fail"or"pass"

第三行可以分解成下面这样

cout << grade;                              //小于运算符优先级低于输出运算符
cout < 60 ? "fail" : "pass";    //然后比较cout和60,根据结果输出"fail"or"pass"

建议:嵌套层数增加,可读性急剧下降,建议最好别超过两到三层!

位运算符

位运算符作用于整数类型的运算对象,并把运算对象看成二进制位的集合

关于符号位如何处理没有明确规定,所以强烈建议仅将位运算符用于处理无符号类型

一般来说,小整型会被自动提升成较大的整数类型,比如char提升成int

移位运算符

<<>>,对其运算对象执行基于二进制位的移动操作,令左侧运算对象的内容按照右侧运算对象的要求移动指定位数,然后将移动的(可能还进行了提升(promoted))左侧运算对象的拷贝作为求值结果,右侧运算对象一定不能为负,移出边界的位就被舍弃掉了

位求反运算符

~,1变0,0变1

位与、位或、位异或

&|^

移位运算符(又叫IO运算符)满足左结合律

IO运算符其实是重载了移位运算符,重载运算符的优先级和结合律与其内置版本一样,而移位运算符的优先级不高不低,介于中间:比算术运算符优先级低,比关系运算符、赋值运算符和条件运算符优先级高

sizeof运算符

sizeof返回一条表达式或一个类型名字所占的字节数,满足右结合律,所得的值是一个size_t类型的常量表达式

sizeof type;
sizeof expr;

Sales_data data, *p;
sizeof(Sales_data); //存储Sales_data类型的对象所占空间大小
sizeof data;                //data的类型的大小,同上
sizeof p;                       //指针所占空间的大小
sizeof *p;                  //p所指类型的空间大小,即sizeof(Sales_data)
sizeof data.revenue;     //Sales_data的revenue成员对应类型的大小
sizeof Sales_data::revenue; //同上

注意:sizeo运算符不会实际求运算对象的值,对于sizeof *p;,即使p是空指针也没关系,这仍然安全,因为sizeof不会真正解引用p

C++11允许通过域运算符::来获取类成员的大小,通常情况下只有通过类的对象才能访问到类的成员,但是sizeof运算符无须我们提供一个具体的对象,因为要想知道类成员的大小无须真的获取该成员

sizeof的结果部分地依赖其作用的类型

  • 对char或者类型为char的表达式,结果得1
  • 对引用,得到被引用对象所占空间的大小
  • 对指针,得到指针本身所占空间的大小
  • 对解引用指针,得到指针所指对象所占空间大小,指针不需要有效(不会真正解引用
  • 对数组,得到整个数组所占空间的大小,等价于把数组所有元素各执行sizeof再求和,这时数组名字不会当做指针处理
  • 对string或vector,只返回该类型固定部分的大小,不会计算的对象中的元素占用了多少空间

注意:在 C/C++ 中并没有提供直接获取数组长度的函数,其中一种间接方法是使用 sizeof (array) /sizeof (array [0]),因为单个元素的大小都是一样,所以总大小除以单个元素大小就等于数组元素的数量

在 C 语言中习惯上在使用时都把它定义成一个宏,比如:

define GET_ARRAY_LEN(array,len) {len = (sizeof(array) / sizeof(array[0]));} 

逗号运算符

逗号运算符(comma operator)含有两个运算对象,按照从左至右的顺序依次求值,类似逻辑与、逻辑或以及条件运算符,逗号运算符规定了求职顺序(order of evaluation)

先对左侧运算对象求值,然后丢掉,再对右侧运算对象求值,逗号运算符的结果是右侧表达式的结果,如果是左值,那么最终结果也是左值

类型转换

如果两种类型可以相互转换(conversion),那它们就是相互关联的

int ival = 3.541 + 3; //编译器有可能会警告损失精度,但编译成功,ival=6

像上面叫做隐式转换(implicit conversion),无须程序员介入,甚至不需要了解

算术之间的隐式转换会尽可能避免损失精度,int+double会等于double,但如果赋值给int型,则会忽略小数部分

double ival = 3.541 + 3; //编译成功,ival=6.541

何时发生隐式转换

  • 大多数表达式中,比int类型晓得整型值首先提升为较大的整数类型
  • 条件中,非布尔值转换为布尔值
  • 初始化时,初始值转换成变量的类型;赋值语句中,右侧运算对象转换为左侧运算对象
  • 算术运算或关系运算的运算对象有多重类型时,需要转换为同一类型
  • 函数调用时

算术转换(arithmetic conversion)

整形提升(intergral promotion)把小整型转换成较大整数类型,对于bool、char、signed char、unsigned char、short和unsigned short等类型来说,只要它们所有可能的值能存在int里,它们就会提升为int类型;否则,提升为unsigned int;比如false提升为0,true提升为1

这里知识点太繁琐了,不记了,需要再查

显式转换

强制类型转换(cast),虽然有时不得不用,但这种方法本质上是非常危险的

命名的强制类型转换

一个命名的强制类型转换具有如下形式

cast-name(expression);

其中,type是转换的目标类型而expression是要转换的值,如果type是引用类型,则结果是左值

cast-name是static_cast、dynamic_cast、const_cast、和reinterpret_cast中的一种

static_cast(普通就用这个)

任何具有明确定义的类型转换,只要不包括底层const,都可以使用static_cast,例如,强制转换成double型以执行浮点数除法

double slope = static_cast(j) / i;

static_cast隐含了:我们知道并且不在乎潜在的精度损失

const_cast(涉及到底层const就用这个)

const_cast只能改变运算对象的底层const

const char *pc;
char *p = const_cast(pc); //正确,但通过p写值是未定义的行为

这种行为一般称之为“去掉const性质(cast away the const)”,一旦去掉某对象的const性质,编译器就不再组织我们对该对象进行写操作,但是如果对象本身是一个常量,再使用const_cast执行写操作就会产生未定义的后果

reinterpret_cast与dynamic_cast

感觉太深入了,不看

你可能感兴趣的:(C++ Pirmer 中文版(第五版)——第1-4章笔记)