C++学习笔记(5):语句和表达式

表达式

表达式概念

表达式、运算对象和运算符

表达式由一个或多个运算对象组成,一个运算对象本身即可成为表达式,一个运算对象(字面值和变量)也可有运算符,多个运算对象必须由运算符连接。

对表达式求值将得到一个结果,即表达式一定是有值的

运算符按照运算对象的个数主要分为一元运算符二元运算符三元运算符,此外函数调用也是一种特殊的运算符,它的运算对象个数没有限制,依函数所需的参数而定。

有些运算符有多种含义,具体表示为何种含义由上下文而定,在表示不同含义时,同一运算符可能有不同优先级和结合律。

优先级、结合律和求值顺序

运算符优先级规定了在复杂的表达式中,哪些运算会首先进行运算,我们可以使用括号运算符使某些运算首先进行,从而改变运算顺序。

结合律规定了运算符和运算对象的结合方向。

求值顺序决定了一个运算符的若干运算对象中哪个先进行求值,如

1
int i = f1()*f2();

中,我们可以确定的是f1和f2两个函数都在乘法进行之前求值,但是我们并不知道两个函数中谁先进行求值,这就是由求值顺序规定的。

左移运算符<<未规定对象的求值顺序,那么以下代码的输出结果就是未定义的:

1
2
int i = 0;
cout << i << " " << ++i << endl;

因为我们不知道i和++i哪个先求值,在编写代码时应当避免这种表达方式。

实际上,C++的大部分运算符都没有规定求值顺序,只有四种运算符明确规定了运算对象的求值顺序:

  • 逻辑与运算符&&:从左到右求值(方便实现短路逻辑)。
  • 逻辑或运算符||:从左到右求值。
  • 条件运算符:先求值条件,再根据分支求值相应的表达式。
  • 逗号运算符:从左到右求值。

运算符

算术运算符

运算符 说明 结合顺序
+ 一元正号 从右到左
- 一元负号 从右到左
* 乘法 从左到右
/ 除法 从左到右
% 求余 从左到右
+ 加法 从左到右
- 减法 从左到右

不同类型的数据在同一个表达式中进行复合算术运算时,所有运算对象都隐式转换为同一类型。

一元正号运算符一般用于创建一个变量的副本(虽然好像没有什么用)。

布尔值不应当参与算术运算,其结果往往比较出人意料,如:

1
2
bool b1 = true;
bool b2 = -b1;

b2的结果仍然是true,原因是这样的:b1首先被转换为int参与运算,真值转换为1,取负后是-1,这时候再将它赋值给b2转换为布尔值,得到的还是true(非0值都是真)。

求余运算符%只能作用于整数,它的两个运算对象都只能是整数,对于负数的情况:

  • m%(-n)等于m%n。
  • (-m)%n等于-(m%n)。

关系运算符

运算符 说明 结合顺序
! 逻辑非 从右到左
< 小于 从左到右
<= 小于等于 从左到右
> 大于 从左到右
>= 大于等于 从左到右
== 相等 从左到右
!= 不相等 从左到右
&& 逻辑与 从左到右
|| 逻辑或 从左到右

关系运算符都是从左到右的结合方向,要注意比较时不能连续比较。

关系运算符在运算时也会进行隐式类型转换,那么就意味着在进行布尔值比较时要格外注意,假如我们要判断val的值是不是非0,我们可以直接这样写:

1
if(val) …;

但是如果我们这样表达:

1
if(val==true) …;

看起来没什么区别,但实际上区别可大了。在比较val==true时,会进行类型转换,按照往大类型转换的原则,假如val是整数,那么比较过程中会把true转换为1,所以实际上比较的是val==1,这样就与我们的本意(判断val是否非0)相违背了。

逻辑运算符

短路逻辑

  • 逻辑与运算符&&当左侧表达式为假时将不会再验证右侧表达式的值,直接返回假,只有当左侧表达式为真时才会验证右侧。
  • 逻辑或运算符||当左侧表达式为真时将不会再验证右侧表达式的值,直接返回真,只有当左侧表达式为假时才会验证右侧。

短路逻辑使得程序运行的效率更高,并且可以进行较为复杂的判断(右侧表达式有可能允许为不合法的,此时左侧表达式是为了确保右侧表达式的正确性和安全性)。

位运算符

运算符 说明 结合顺序
~ 按位取反 从右到左
<< 左移 从左到右
>> 右移 从左到右
& 按位与 从左到右
^ 按位异或 从左到右
| 按位或 从左到右

在使用位运算时,如果运算对象是“小整型”,则它的值会被自动提升成较大的整数类型。

运算对象可以是带符号的,也可以是无符号的,如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器,这是一种未定义的行为。因此建议对无符号类型使用移位运算。

移位运算符右侧的运算对象一定不能为负,并且其值要严格小于结果的位数,否则会产生未定义行为。

在对char类型进行运算时,会将其提升为int类型,高位补0。

移位运算符满足从左到右的结合顺序。

赋值运算符

赋值运算符的左侧运算对象应当是一个可修改的左值,表达式的值是赋值完成后左侧运算对象的值。

C++11标准允许使用列表作为赋值运算符的右侧运算对象,同时如果左侧运算对象为内置类型,列表中最多只能包含一个值,并且如果发生类型转换,不能有精度损失;如果左侧运算对象为内置容器,可以像列表初始化一样给它赋值:

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

可以这样做的原因是vector重载了赋值运算符,并且可以接收初始值列表,如果vi并不是空的,它的元素将被右侧列表中的值替换。

无论左侧运算对象是什么类型,初始值都可以为空,此时编译器将创建一个经过了值初始化的临时变量并赋值给左侧运算对象。

赋值运算满足从右到左的结合顺序,这和大部分二元运算符不同,因此这使得连续赋值成为了可能:

1
a=b=1;

对于算术运算和位运算,提供了复合赋值运算符,包括+=、-=、*=、/=、%=、<<=、>>=、&=、^=、|=。

复合运算符除了性能稍优于普通赋值之外,并没有太大区别。

递增递减运算符

递增运算符和递减运算符为对象的加1和减1操作提供了一种简洁的书写形式,同时这两个运算符还可应用于迭代器

递增递减运算符可作为前置运算符也可作为后置运算符,前置将对象本身作为左值返回,后置则将对象原始值的副本作为右值返回,如++i将会把i的值自增1并返回i,即先自增再使用,而i++则会保存一个i的副本,再让i自增1,表达式返回没有自增前的副本,也就是先使用,再自增。前置返回的是一个左值,而后置返回的是一个临时的副本,因此是个不可操作的右值。

除非必须,否则建议不用后置的递增递减运算符,因为后置的性能不如前置,它需要用到一个临时的副本。

对于指针和迭代器,我们在循环时往往需要在循环的结束对其进行自增自减,而如果对其的操作较为简单的话,我们可以在操作的同时顺带使其自增自减,如:

1
2
3
auto pbeg = v.begin();
while (pbeg != v.end() && *beg >=0)
cout << *pbeg++ << endl;

其中*pbeg++等价于*(pbeg++),也就是把迭代器自增之后,使用其自增之前的值,可以这样做的原因是自增运算符++的优先级要高于解引用运算符*。

成员访问运算符

成员访问运算符有两种,分别是点运算符箭头运算符,点运算符直接对对象使用,箭头运算符对指针或迭代器使用,即p->m等价于(*p).m。

注意由于解引用运算符*和成员访问运算符.的优先级问题,(*p).m和*p.m是截然不同的含义。

强制类型转换运算符

强制类型转换的方式如下:

1
cast-name<type>(expression);

其中,type是转换的目标类型,而expression是要转换的值。如果type是引用类型,则结果是左值。
cast-name是static_cast、dynamic_cast、const_cast和reinterpret_cast中的一种。
dynamic_cast支持运行时类型识别,此处先按下不提。

cast-name指定了执行的是哪种转换:

  • static_cast:具有明确定义的类型转换,但不能包含底层const,如常规的想把int转换为double。使用static_cast时,意味着我们告诉编译器大胆地进行类型转换,我们不在乎精度的损失,这时编译器不会因为类型不匹配而发出警告信息。此外,也可以使用static_cast将void *转换回原来的指针类型,从而找回存放于void *中的值。
  • const_cast:用于去掉对象的底层const,可以将常量对象转换为非常量对象,也就是去掉const性质,如将常量指针pc转换为普通指针p,就可以通过p修改指向的值了,这样就使用强制类型转换获得了写权限。
  • reinterpret_cast:运算对象的位模式提供较低层次上的重新解释。比如把一个整型指针转换为字符型指针,这样的转换是危险的,只能在特殊的情况下使用。

由于C++对于C语言兼容,因此C风格的类型转换也是可以使用的,但是还是建议使用C++的类型转换方式以控制风险。

sizeof运算符

sizeof运算符返回一条表达式或一个类型名字所占的字节数

两种使用方式:sizeof(type)或sizeof expr,sizeof并不实际计算表达式的值,只是进行了类型判断,然后返回类型的尺寸。

sizeof运算符满足右结合律,其所得的值是一个size_t类型的常量表达式

此外,sizeof运算符还有以下特点:

  • 对char或者类型为char的表达式执行sizeof运算,结果得1。
  • 对引用类型执行sizeof运算得到被引用对象所占空间的大小。
  • 对指针执行sizeof运算得到指针本身所占空间的大小。
  • 对解引用指针执行sizeof运算得到指针指向的对象所占空间的大小,指针不需有效。
  • 对数组执行sizeof运算得到整个数组所占空间的大小,等价于对数组中所有的元素各执行一次sizeof运算并将所得结果求和。注意,sizeof运算不会把数组转换成指针来处理。
  • 对string对象或vector对象执行sizeof运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。

因为执行sizeof运算能得到整个数组的大小,所以可以用数组的大小除以单个元素的大小得到数组中元素的个数。又由于sizeof的返回值是一个常量表达式,所以我们可以用sizeof的结果声明数组的维度

1
2
constexpr size_t sz = sizeof(ia)/sizeof(*ia);
int arr2[sz];

其他运算符

(1)条件运算符(?:):条件运算符允许我们把简单的条件判断嵌入到一个表达式中。当条件运算符的两个表达式都是左值或者能转换成同一种左值类型时,运算的结果是左值,否则是右值。使用时要注意优先级,保险起见,可以添加额外的括号。

(2)逗号运算符:可以将多条表达式连接成一个表达式,各个表达式从左到右求值,最后整个表达式的值是最后一个表达式的值,如果最后一个表达式的值为左值,那么最终的结果也是左值。

运算符优先级表

语句

简单语句

表达式语句

在表达式的末尾加上表示语句结束的分号就成为了表达式语句。

表达式语句的作用就是执行表达式并丢掉其结果,因此只求值,没有副作用的表达式语句是无意义的。

由于表达式的本质使命是(通过运算)得出一个值,如a+5将得到变量a加上5的值,但是如果直接给这个表达式加上分号,那么这个值不被使用就成了完全无意义的,因此这种只发挥了表达式本职使命(运算求值)的表达式语句是没有意义的,于是另一种“不务正业”的表达式语句就有了意义,也就是这类表达式具有除了运算求值之外的副作用。

如果表达式在执行的过程中除了得到表达式的运算结果值之外,还产生了一些别的影响,那么这类表达式就是有意义的,这种影响被称为副作用,如:

1
2
3
int a = 5, b;
b = a + 1;
cout << b << endl;

我们可以看到其中b = a + 1作为表达式其结果值为6,但是这个表达式在求解结果的同时还使得b的值改变了,这种改变了b的值的影响就叫做副作用;同样地,cout << b << endl作为表达式,其结果是cout,但是它还产生了其他影响,即在屏幕上输出内容,这也是它的副作用。这两条语句都是非单纯求值的表达式,都产生了副作用,因此它们都是有意义的表达式语句。

空语句

空语句是最简单的语句,它只由一个分号组成,通常用于占位,用来解决语法上需要语句但是逻辑上不需要语句的情况,如循环体为空的空循环。

复合语句

使用一对花括号括起来的一系列语句为复合语句,复合语句可视为一条语句,在复合语句的语句块中引入的名字只在块内(及其子块内)可见。

条件语句

if条件语句

条件语句一般有以下形式:

  • if语句:单一条件。
  • if-else语句:如果-否则,双向跳转。
  • if-(else if)-else语句:多重判断,多向跳转,中间可有任意多个else if,最后的else可无。

条件语句可嵌套,嵌套的条件语句的else配对遵循就近原则,else和它前方最近的一个未配对if进行配对。

switch条件语句

switch语句能使我们在若干固定选项中做出选择。

switch将求出括号中的表达式的值,并将该值转换为整数类型,与每个case比较,跳转到命中的case标签处开始往后执行,直到遇到一个break或到了switch末尾。

因此一般情况下会在每个case结束时加上一条break语句,并且不建议使用小数等非整数类型进行判断。

多个case可以连续使用。default是一种特殊的case标签,将作为缺省case使用。

case进行跳转时要注意变量定义的问题,不要在switch中定义变量,因为从变量的定义域之外不经该变量的定义过程跳转到该变量的定义域内是非法行为

循环语句

while循环

条件成立则进入循环,即先判断再循环,当条件一开始就不成立时可能一次也不循环,即循环0次。

do-while循环

直接进入循环,每次循环后进行判断,条件成立则循环继续,即先循环再判断,无论如何也会进行至少一次循环。

while和do-while循环一般用于不确定循环次数但是确定边界条件时。

传统for循环

格式为

1
for(A; B; C){ … }

其中A为初始化语句,B为条件表达式语句,C为后处理表达式语句。

A语句在整个循环开始前执行,可以是任意表达式语句,也可以是变量定义及初始化。

B为用作判断的表达式语句,在每次循环开始之前执行,当其值为真或为等价于真的值时才会进入循环,否则直接结束循环。

C为每次循环结束后的处理表达式,在每次循环后执行。

A、B、C中任意个语句可以为空,视情况而定。

范围for循环

C++11提供的新特性,格式为

1
for(declaration: expr){ … }

其中expr必须是一个可迭代序列,如花括号括起来的列表、数组、vector等支持返回begin和end成员的容器等,其中declaration是一个用于迭代的临时变量,该变量要与要迭代的序列中的元素类型相容,最简单的办法是定义一个auto变量。

如果需要对序列中的元素进行写操作,要定义临时变量为引用类型。

使用for循环时都不应该增减序列中的元素,for循环更适合用来固定次数的循环。

控制语句

break语句

break语句将终止直接包含它的while、do while、for、switch语句,并从其后继续执行。

continue语句

用于循环,立即结束本次迭代并进入下一次迭代,不会直接终止循环。

for循环被continue的话,后处理语句还会照常进行的。

goto语句

无条件跳转语句。说是无条件,实际上还是有条件的:它只能在同一个函数中跳转,并和switch的case标签一样,不能跨变量定义跳转。

goto语句需要配合标签使用,标签是独立于其他标识符之外的名字,因此可以与其他标识符重名,只要标签之间互不重名就行。

异常语句

捕捉异常

通过try语句捕捉异常:

1
2
3
4
5
6
7
8
9
try {
// 可能出现异常的语句
}
catch (exception-declaration){

}
catch(exception-declaration){

} // ...

将可能出现异常的语句放在try语句块中,如果执行正常那么什么也不会发生,就像没有try语句一样,而如果出现了异常,就会转入异常捕捉流程。

try语句后跟着一个或多个catch子句,catch子句括号中是异常声明,声明一个异常对象,并接收出现的该类异常,如runtime _error err,然后在catch子句中进行一些处理。

C++的异常类定义在四个标准库头文件中:

  • exception头文件定义了最通用的异常类exception,它只报告异常的发生,不提供任何额外信息。
  • stdexcept头文件定义了几种常用的异常类。
  • new头文件定义了bad_alloc异常类型,这种类型将在后面介绍。
  • type_info头文件定义了bad_cast异常类型,这种类型也将在后面介绍。

较为常用的是stdexcept头文件,其中定义的异常类有:

  • exception:通用异常类。
  • runtime_error:只有在运行时才能检测出的问题。
  • range_error:运行时错误,生成的结果超出了有意义的值域范围。
  • overflow_error:运行时错误,计算上溢。
  • underflow_error:运行时错误,计算下溢。
  • logic_error:程序逻辑错误。
  • domain_error:逻辑错误,参数对应的结果值不存在。
  • invalid_argument:逻辑错误,无效参数。
  • length_error:逻辑错误,试图创建一个超出该类型最大长度的对象。
  • out_of_range:逻辑错误,使用一个超出有效范围的值。

我们只能在catch子句的括号里以默认初始化的方式来定义这些类的对象,不能给它们提供初始值。

异常类型只定义了一个名为what的成员函数,该函数没有任何参数,返回值是一个指向C风格字符串的const char*,该字符串的目的是提供关于异常的一些文本信息。

what函数返回的C风格字符串的内容与异常对象的类型有关,如果异常类型有一个字符串初始值,则what返回该字符串,对于其他无初始值的异常类型来说,what返回的内容由编译器决定。

抛出异常

我们需要人为抛出异常时使用throw表达式,从而抛出一个异常对象,如:

1
throw runtime_error ( "Data must refer to same ISBN");



* 你好,我是大森。如果文章内容帮到了你,你可通过下方付款二维码支持作者 *