参考资料:C++ Primer 中文版(第5版)——[美] Stanley B. Lippman [美] Josée Lajoie [美] Barbara E. Moo 著 王刚 杨巨峰 译
代码编辑器:VS Code
文章目录
注:本文章仅总结相比于C语言C++中的新内容,详情还请参考C++ Primer原书。
一、表达式
1. 基础
1.1 重载运算符
C++语言定义了运算符作用于内置类型和复合类型的运算对象时所执行的操作。当运算符作用于类类型的运算对象时,用户可以自行定义其含义。因为这种自定义的过程事实上是为已存在的运算符赋予了另外一层含义,所以称之为重载运算符。IO库的>>
和<<
运算符以及string
对象、vector
对象和迭代器使用的运算符都是重载的运算符。
我们使用重载运算符时,其包括运算对象的类型和返回值的类型,都是由该运算符定义的;但是运算对象的个数、运算符的优先级和结合律都是无法改变的。
1.2 左值和右值
C++的表达式要不然是右值(rvalue),要不然就是左值(lvalue)。这两个名词时从C语言继承过来的,原本是为了帮助记忆:左值可以位于赋值语句的左侧,右值则不能。
但是,在 C++中,二者的区别就没那么简单了。一个左值表达式的求值结果是一个对象或者一个函数,然而以常量对象为代表的某些左值实际上不能作为赋值语句的左侧运算对象。此外,虽然某些表达式的求值结果是对象,但它们是右值而非左值。可以做一个简单的归纳:当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。
不同的运算符对运算对象的要求各不相同,有的需要左值运算对象、有的需要右值运算对象 ;返回值也有差异,有的得到左值结果、有的得到右值结果。一个重要的原则(例外情况在下面分割线中)是在需要右值的地方可以用左值来代替,但是不能把右值当成左值(也就是位置)使用。当一个左值被当成右值使用时,实际使用的是它的内容(值)。到目前为止,已经有几种我们熟悉的运算符是要用到左值的:
- 赋值运算符需要一个非常量左值作为其左侧运算对象,得到的结果也仍然是一个左值。
- 取地址符作用于一个左值运算对象,返回一个指向该运算对象的指针,这个指针是一个右值。
- 内置解引用运算符、下标运算符、迭代器解引用运算符、
string
、vector
的下标运算符的求值结果都是左值。 - 内置类型和迭代器的递增递减运算符作用于左值运算对象,其前置版本所得的结果也是左值。
例外情况(可选择跳过)
这里所谓的例外情况(跳过)即对象移动。
很多情况下都会发生对象拷贝。在其中某些情况下,对象拷贝后就立即被销毁了。在这些情况下,移动而非拷贝对象会大幅度提升性能。
使用移动而不是拷贝的另一个原因源于IO类或unique_ptr
这样的类。这些类都包含不能被共享的资源(如指针或IO缓冲)。因此,这些类型的对象不能拷贝但可以移动。
标准库容器、
string
和shared_ptr
类既支持移动也支持拷贝。IO类和unique_ptr
类可以移动但不能拷贝。
在旧C++标准中,没有直接的方法移动对象。因此,即使不必拷贝对象的情况下,我们也不得不拷贝。如果对象较大,或者是对象本身要求分配内存空间(如string
),进行不必要的拷贝代价非常高。类似的,在旧版本的标准库中,容器中所保存的类必须是可拷贝的。但在新标准中,我们可以用容器保存不可拷贝的类型,只要它们能被移动即可。
右值引用
为了支持移动操作,新标准引入了一种新的引用类型——右值引用。所谓右值引用就是必须绑定到右值的引用。我们通过&&
而不是&
来获得右值引用。如我们将要看到的,右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用地资源“移动”到另一个对象中。
如正文所述,左值和右值是表达式的属性。一些表达式生成或要求左值,而另外一些则生成或要求右值。一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
类似任何引用,一个右值引用,也不过是某个对象的另一个名字而已。如我们所知,对于常规引用(为了与右值引用区分开来,我们可以称之为左值饮用),我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特性,我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:
int i = 42;
int &r = i; //正确:r引用i
int &&rr = i; //错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42; //错误:i*42是一个右值(表达式)
const int &r3 = i * 42; //正确:可以将一个const的引用绑定到一个右值上
int &&rr2 = i * 42; //正确:将rr2绑定到乘法结果上
总结如下:
- 返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。我们可以将一个左值引用绑定到这类表达式的结果上。
- 返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个
const
的左值的引用或者一个右值引用绑定到这类表达式上。
左值持久;右值短暂
考察左值和右值表达式的列表,两者相互区别之处就很明显了:左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,我们得知:
- 所引用的对象将要被销毁
- 该对象没有其他用户
右值引用指向将要被销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态。
这两个特性意味着使用右值引用的代码可以自由的接管所引用的对象的资源。
变量是左值
变量可以看作只有一个运算对象而没有运算符的表达式,虽然我们很少这样看待变量。类似其他任何表达式,变量表达式也有左值/右值属性。变量表达式都是左值。带来的结果就是,我们不能将一个右值引用绑定到一个右值引用类型的变量上,这有些令人惊讶:
int &&rr1 = 42; //正确:字面常量是右值
int &&rr2 = rr1; //错误:表达式rr1是左值
其实有了右值表示临时对象这一观察结果,变量是左值这一特性并不令人惊讶。毕竟变量是持久的,直至离开作用域时才被销毁。
标准库
move
函数
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转化为对应的右值引用类型。我们还可以通过调用一个名为**move
**的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility
中。
//...
#inlude<utility>
//...
int &&rr1 = 42;
int &&rr3 = std::move(rr1); //ok
move
调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move
就意味着承诺:除了对rr1
赋值或销毁它之外,我们将不再使用它。在调用move
之后,我们不能对移后源对象的值做任何假设。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
好,例外情况介绍到这里,下面回到正文。
接下来在介绍运算符的时候,我们将会注明该运算符的运算对象是否必须是左值以及其求值结果是否是左值。
使用关键字decltype
的时候,左值和右值也有所不同。如果表达式的求值结果是左值,decltype
作用于该表达式(不是变量)得到一个引用类型。例子:
int arr[10];
int *p = arr;
/*因为解引用运算符生成左值,所以
decltype(*p)
的结果是 int&。
*/
/*另一方面,因为取地址运算符生成右值,所以
decltype(&p)
的结果是 int**,即一个指向整型指针的指针。
*/
1.3 求值顺序
int i = f1() * f2()
无法知道到底f1
在f2
之前调用还是f2
在f1
之前调用。
对于那些没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为。比如:
int i = 0;
cout << i << " " << ++i << endl; //未定义的
<<
运算符没有明确规定何时以及如何对运算对象求值,因此上面的表达式是未定义的。输出结果自然也无法确定。
2. 赋值运算符
赋值运算符的左侧运算对象必须是一个可修改的左值。
C++11新标准允许使用花括号括起来的初始值列表作为赋值语句的右侧运算对象:
int k = 0;
k = {3.14}; //错误:窄化转换
k = 3.14; //正确,但精度会损失
vector<int> vi; //初始为空
vi = {0,1,2,3,4,5,6,7,8,9};//vi现在含有10个元素了,值从0到9
如果左侧运算对象是内置类型,那么初始值列表最多只能包含一个值,而且该值即使转换的话其所占空间也不应该大于目标类型的空间。
对于类类型来说,赋值运算的细节由类本身决定。对于vector
来说,vector
模板重载了赋值运算符并且可以接收初始值列表,当赋值发生时用右侧运算对象替换左侧运算对象的元素。
无论左侧运算对象的类型是什么,初始值列表都可以为空。此时,编译器创建一个值初始化的临时量并将其赋给左侧运算对象。
赋值运算是满足右结合律的,且它返回的是赋值运算符左侧的运算对象。对于多重赋值语句中的每一个对象,它的类型或者与右边对象的类型相同、或者可由右边对象的类型转换得到:
int ival, *pval;
ival = pval = 0; //错误:不存在int*向int类型的转换,即不能把指针的值赋给int
string s1, s2;
s1 = s2 = "OK"; //正确:字符串字面值"OK"可以转换成string对象
还要注意:切勿混淆相等运算符和赋值运算符!(过来人告诉你,就因为少个等号,你可能会卡bug卡几个小时……别问我怎么知道的!)
3. 递增和递减运算符
可用于运算对象的加1和减1,还可应用于迭代器,因为很多迭代器本身不支持算术对象,所以此时递增结合递减运算符除了书写简洁外还是必须的。
这两个运算符都有两种形式:前置版本和后置版本。且这两种运算符必须作用于左值运算对象。
- 前置版本:得到递增(减)之后的值,将对象本身作为左值返回;
- 后置版本:得到递增(减)之前的值,将对象原始值的副本作为右值返回。
建议:除非必须,否则不用递增递减运算符的后置版本
原因非常简单:前置版本的递增运算符避免了不必要的工作,它把值加 1 后直接返回改变了的运算对象。与之相比,后置版本需要将原始值存储下来以便于返回这个未修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。
对于整数和指针类型来说,编译器可能对这种额外的工作进行一定的优化;但是对于相对复杂的迭代器类型,这种额外的工作就消耗巨大了。建议养成使用前置版本的习惯,这样不仅不需要担心性能的问题,而且更重要的是写出的代码会更符合编程的初衷。
当在一条语句中混用解引用和递增运算符时,就要注意:后置递增运算符的优先级高于解引用运算符,因此*pbeg++
等价于*(pbeg++)
。即pbeg++
先把pbeg
的值加 1 ,然后返回pbeg
的初始值的副本作为其求值结果,此时解引用运算符的运算对象是pbeg
未增加之前的值。最终,这条语句输出pbeg
开始时指向的那个元素,并将指针向前移动一个位置。
建议:简洁可以成为一种美德
形如
*pbeg++
的表达式是一种被广泛使用的、有效的写法。这要比拆成两条等价的语句更简洁、也更少出错。大多数C++程序追求简洁、摒弃冗长,因此C++程序员应该习惯于这种写法。
4. 条件运算符
条件运算符cond ? expr1 : expr2
的两个表达式都是左值或能转换成同一种左值类型时,运算结果是左值;否则运算的结果是右值。
-
条件运算符可以嵌套。即在
expr2
处再放一个条件运算符。但要注意为了保证代码的可读性,嵌套层数最后别超过两到三层。 -
条件运算符的优先级非常低,因此当一条长表达式中嵌套了条件运算子表达式时,通常需要在它两端加上括号。例如:
cout << ((grade < 60) ? "fail" : "pass"); //输出pass或者fail cout << (grade < 60) ? "fail" : "pass"; //输出 1 或者 0 ! cout << grade < 60 ? "fail" : "pass"; //错误:试图比较cout和60
第二条语句的解释:
//它等价于下面两条语句 cout << (grade < 60); //返回值是cout cout ? "fail" : "pass"; //根据cout的值是true还是false产生对于的字面值
第三条语句的解释:
//比较运算符 < 的优先级低于移位运算符 << ,因此它等价于下面两条语句 cout << grade; //先输出grade cout < 60 ? "fail" : "pass"; //然后试图比较cout和60,报错!
5. 位运算符
这里仅提一下移位运算符。
移位运算符(又叫IO运算符)是满足左结合律的。重载运算符的优先级和结合律都与它的内置版本一样,因此即使程序员用不到移位运算符的内置含义,也仍然有必要理解其优先级和结合律。
表达式cout << "hi" << "there" << endl
的执行过程实际上等同于:
((cout << "hi") << " there") << endl;
移位运算符的优先级不高不低,介于中间:比算术运算符的优先级低,但比关系运算符、赋值运算符和条件运算符的优先级高。因此在一次使用多个运算符时,有必要在适当的地方加上括号使其满足我们的要求。比如:
cout << 42 + 10; //正确:+ 优先于 << ,输出52
cout << (10 < 42); //正确:使用了括号,输出1
cout << 10 < 42; //错误:<< 优先于 < ,试图比较 cout 和 42
6. sizeof
运算符
sizeof
运算符返回一条表达式或一个类型名字所占的字节数。sizeof
运算符满足右结合律,其所得的值是一个size_t
类型的常量表达式。运算符的运算对象有两种形式:
sizeof(type)
sizeof expr //返回表达式结果类型的大小
与众不同的一点是,sizeof
并不实际计算其运算对象的值。所以,在sizeof
的运算对象中解引用一个无效指针仍然是一种安全的行为,因为指针实际上并没有被真正使用。sizeof
不需要真的解引用指针也能知道它所指对象的类型。
C++11新标准允许我们使用作用域运算符来获取类成员的大小。比如:
sizeof string::npos //获取string类的成员npos的大小
这里需要注意的一点就是sizeof
作用于指针和作用于数组的区别:
char arr[] = {'a', 'b', 'c', 'd', 'e'};
char *p = arr;
auto n1 = sizeof(arr); //n1 的值为 5
auto n2 = sizeof(p); //n2 的值为 8
sizeof
并不会把数组名转换成指针来处理,而是对数组中所以的元素各执行一次sizeof
运算,并将所得结果求和。
还要提醒的一点是,对string
对象或vector
对象执行sizeof
运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。
表达式部分就总结到这里,下面总结语句部分。
二、语句
1. 简单语句
复合语句是指用花括号括起来的语句和声明的序列,复合语句也被称作块。一个块就是一个作用域。注意:块不以分号作为结束。
- 空语句:
;
- 空块:
{}
空块的作用等价于空语句。
2. 语句作用域
可以在if
、switch
、while
和for
语句的控制结构内定义变量。定义在控制结构当中的变量只在相应语句的内部可见。
因为控制结构定义的对象的值马上要由结构本身使用,所以这些变量必须初始化。
3. 条件语句
C++语言提供了两种按条件执行的语句:if
语句和switch
语句。二者的用法和C语言中的用法相同,不再赘述。下面仅说明一下**switch
内部的变量定义需要注意的问题**:
正如我们所知道的那样,switch
的执行流程有可能会跨过某些case
标签。如果程序跳转到了某个特定的case
,则switch
结构中该case
标签之前的部分会被忽略掉。这种忽略行为自然而然地引出了一个有趣的问题:如果被略过的代码中含有变量的定义该怎么办?
答案是:如果对于作用域A
,A
的外部定义了带有初值(即被初始化过,或隐式,或显式)的变量x
,而且变量x
又恰好位于作用域A
中,那么从作用域A之外跳转到作用域A之中是非法行为。
比如说下面的程序:
#include<string>
int main(){
bool flag = true;
switch(flag){
case true:
std::string file_name; //错误:控制流绕过一个隐式初始化的变量
break;
case false:
int ival;
break;
default: break;
}
return 0;
}
编辑器报错:控制传输跳过的实例化
。这是因为string
对象file_name
进行了隐式初始化。如下图所示:
而其中第二个case
语句后面定义了变量ival
,这是合法的,因为ival
并没有初始化。
下面这种也是错误的:
#include<string>
int main(){
bool flag = true;
switch(flag){
case false:
int jval = 0; //错误:控制流绕过一个显式初始化的变量
break;
case true:
std::string file_name;
break;
}
return 0;
}
运行后报错:
为了避免这种错误,可以在case
语句后加上{}
使得它们成为语句块,像下面这样:
#include<string>
int main(){
bool flag = true;
switch(flag){
case false:{//正确:声明语句位于语句块内部
int jval = 0;
++jval;
}
break;
case true:
std::string file_name;
break;
}
return 0;
}
4. 迭代语句
迭代语句也就是循环语句。C++和C一样,也有三种循环语句:while
,for
,do while
。
当不确定迭代多少次时使用while
循环或do while
循环比较合适,确定迭代次数时使用for
循环比较合适。while
、do while
的用法和C语言中的几乎相同,不再赘述。而for
循环语句则在传统for
循环的基础上新添加了范围循环方式,下面着重介绍。
4.1 范围for
语句
这种语句可以遍历容器或其他序列的所有元素。范围for
语句的语法形式是:
for (declaration : expression)
statement
expression
表示的必须是一个序列,比如用花括号括起来的初始值列表、数组或者vector
或string
等类型的对象,这些对象的共同特点是拥有能返回迭代器的begin
和end
成员。
declaration
定义一个变量,序列中的每个元素都得能转换成该变量的类型。确保类型相容最简单的办法是使用auto
类型说明符,这个关键字可以令编译器帮助我们指定合适的类型。如果需要对序列中的元素执行写操作,循环变量必须声明成引用类型。
每次迭代都会重新定义循环控制变量,并将其初始化成序列中的下一个值,之后才会执行statement
(可以是一条单独的语句,也可以是一个块)。所有元素都处理完毕后循环终止。
请看下面的例子,对vector
对象使用范围for
语句:
#include<vector>
int main(){
//把vector对象中的每个元素翻倍
std::vector<int> v = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
for (auto &r : v){//r 必须定义成引用类型,才能对元素执行写操作
r *= 2;
}
return 0;
}
注意:不能通过范围for
语句增加vector
对象(或者其他容器)的元素。这是因为在范围for
语句中预存了end()
的值。一旦在序列中添加(删除)元素,end
函数的值就可能变得无效了。
5. 跳转语句
跳转语句中断当前的执行过程,C++语言提供了 4 种跳转语句:break
, continue
, goto
, return
。这里也只着重介绍一下goto
语句。
goto
语句的作用是从goto
语句无条件跳转到同一函数内的另一条语句。
这种无条件跳转破坏了结构化程序设计的思想,因此建议你不要再程序中使用goto
语句,因为它使得程序既难理解又难修改。
但你还是需要了解它的用法,因为说不定哪天你读别人写的代码就碰到它了O(∩_∩)O
goto
语句的语法形式是:
goto label;
其中,label
是用于标识一条语句的标示符。带标签语句是一种特殊的语句,在它之前有一个标示符以及一个冒号:
label: ...; //... 可以作为goto的目标
标签标示符独立于变量或其他标示符的名字,因此标签标示符可以和程序中其他实体的标示符使用同一个名字而不会相互干扰。goto
语句和控制权转向的那条带标签的语句必须位于同一个函数之内。
最后还要提醒的就是,和switch
语句类似,goto
语句也不能将程序的控制权从变量的作用域之外转移到作用域之内。比如下面的例子:
//...
goto end;
int ix = 10; //错误:goto语句绕过了一个带初始化的变量定义
end: ix = 42;
end
后的语句需要使用ix
,但是goto
语句绕过了它的声明,因此出现错误。
6. try
语句块和异常处理
这一部分单独成篇。
链接指路:【C++】try 语句块和异常处理
好了,表达式和语句部分就总结到这里吧(内心OS: 这部分总算是弄完了,东西太多了~ /(ㄒoㄒ)/~~)
下一篇预告:【C++】C++ 基础——函数
链接指路: