C++基础知识要点--表达式 (Primer C++ 第五版 · 阅读笔记)
目录
表达式
基础
- 当一个对象被用作右值的时候,用的是对象的 值(内容);
- 当对象被用作左值的时候,用的是对象的 身份(在内存中的位置)。
求值顺序
对于那些没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为。举个简单的例子,<<运算符没有明确规定何时以及如何对运算对象求值,因此下面的输出表达式是未定义的:
int i = 0 ;
cout << i << " " << ++i << endl; //未定义的,i 和 ++i 不知道谁先求
以下两条经验准则对书写复合表达式有益:
- 1.拿不准的时候最好用括号来强制让表达式的组合关系符合程序逻辑的要求。
- 2.如果改变了某个运算对象的值,在表达式的其他地方不要再使用这个运算对象。
算术运算符
一元运算符的优先级最高,接下来是乘法和除法,优先级最低的是加法和减法。下面的所有运算符都满足左结合律,意味着当优先级相同时按照从左向右的顺序进行组合。
在表达式求值之前,小整数类型的运算对象被提升成较大的整数类型,所有运算对象最终会转换成同一类型。
一元负号运算符对运算对象值取负后,返回其(提升后的)副本:
// k是-1024
bool b = true;
bool b2 = -b; // b2是true!
对大多数运算符来说,布尔类型的运算对象将被提升为int 类型。如上所示,布尔变量b的值为真,参与运算时将被提升成整数值1,对它求负后的结果是-1。将-1再转换回布尔值并将其作为b2的初始值,显然这个初始值不等于0,转换成布尔值后应该为1。所以,b2的值是真 !
逻辑和关系运算符
关系运算符作用于算术类型或指针类型,逻辑运算符作用于任意能转换成布尔值的类型。逻辑运算符和关系运算符的返回值都是布尔类型。值为0的运算对象(算术类型或指针类型)表示假,否则表示真。对于这两类运算符来说,运算对象和求值结果都是右值。
短路求值(short-circuit evaluation):
- 对于逻辑与运算符来说,当且仅当左侧运算对象为真时才对右侧运算对象求值。
- 对于逻辑或运算符来说,当且仅当左侧运算对象为假时才对右侧运算对象求值。
关系运算符
- 关系运算符都满足左结合律
因为关系运算符的求值结果是布尔值,所以将几个关系运算符连写在一起会产生意想不到的结果:
//哎哟!这个条件居然拿i<j的布尔值结果和k比较!
if (i < j < k) //若k 大于1则为真!
//正确:当i小于j并且j小于k时条件为真
if (i < j && j < k) {/* ...*/ }
相等性测试与布尔字面值
if (val) {/* ...*/ } //如果val是任意的非0值,条件为真
if (!val) {/* ...*/ } //如果val是0,条件为真
进行比较运算时除非比较的对象是布尔类型,否则不要使用布尔字面值true和false作为运算对象。
if (val == true){/* ...*/ } //只有当val等于1时条件才为真!
但是这种写法存在两个问题:
- 首先,与之前的代码相比,上面这种写法较长而且不太直接
- 更重要的一点是,如果 val 不是布尔值,这样的比较就失去了原来的意义。
赋值运算符
赋值运算满足右结合律
赋值运算符满足右结合律,这一点与其他二元运算符不太一样:
int ival,jval;
ival = jval = 0; //正确:都被赋值为0
因为赋值运算符的优先级低于关系运算符的优先级,所以在条件语句中,赋值部分通常应该加上括号。
int i;
//更好的写法:条件部分表达得更加清晰
while ((i = get_value())!= 42){
//其他处理….….
}
复合赋值运算符
每种运算符都有相应的复合赋值形式:
任意一种复合运算符都完全等价于
a = a op b;
- 唯一的区别是左侧运算对象的求值次数:使用复合运算符只求值一次,使用普通的运算符则求值两次。
- 这两次包括:一次是作为右边子表达式的一部分求值,另一次是作为赋值运算的左侧运算对象求值。
- 其实在很多地方,这种区别除了对程序性能有些许影响外几乎可以忽略不计。
递增和递减运算符
- 递增和递减运算符有两种形式:前置版本和后置版本。
- 前置版本,这种形式的运算符首先将运算对象加1(或减1),然后将改变后的对象作为求值结果。
- 后置版本也会将运算对象加1(或减1),但是求值结果是运算对象改变之前那个值的副本:
建议 : 除非必须,否则 不用 递增递减运算符的 后置版本
- 有C语言背景的读者可能对优先使用前置版本递增运算符有所疑问,其实原因非常简单:前置版本的递增运算符避免了不必要的工作,它把值加1后直接返回改变了的运算对象。与之相比,后置版本需要将原始值存储下来以便于返回这个未修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。
- 对于整数和指针类型来说,编译器可能对这种额外的工作进行一定的优化;但是对于相对复杂的迭代器类型,这种额外的工作就消耗巨大了。建议养成使用前置版本的习惯,这样不仅不需要担心性能的问题,而且更重要的是写出的代码会更符合编程的初衷。
在一条语句中混用解引用和递增运算符
可以使用后置的递增运算符来控制循环输出一个vector对象内容直至遇到(但不包括)第一个负值为止:
auto pbeg = v.begin();
//输出元素直至遇到第一个负值为止
while (pbeg != v.end() && *beg >= 0)
//简洁可以成为一种美德
cout << *pbeg++ << endl; // 输出当前值并将pbeg向前移动一个元素
后置递增运算符 的优先级 高于 解引用运算符,因此 *pbeg++等价于 *(pbeg++)。pbeg++把 pbeg的值加1,然后返回pbeg 的初始值的副本作为其求值结果,此时解引用运算符的运算对象是pbeg未增加之前的值。最终,这条语句输出pbeg开始时指向的那个元素,并将指针向前移动一个位置。
运算对象可按任意顺序求值
使用for循环将输入的第一个单词改成大写形式:
for (auto it = s.begin(); it != s.end() && !isspace(*it); ++it)
*it = toupper(*it); //将当前字符改成大写形式
//如果换成下面这种
//该循环的行为是未定义的!
while (beg != s.end () && !isspace(*beg) )
*beg = toupper(*beg++); //错误:该赋值语句未定义
将产生未定义的行为。问题在于:赋值运算符左右两端的运算对象都用到了beg,并且右侧的运算对象还改变了beg 的值,所以该赋值语句是未定义的。编译器可能按照下面的任意一种思路处理该表达式:
*beg = toupper(*beg); //如果先求左侧的值
*(beg + 1) = toupper(*beg); //如果先求右侧的值
成员访问运算符
点运算符 和 箭头运算符 都可用于访问成员,其中,点运算符获取类对象的一个成员; 箭头运算符与点运算符有关,
表达式 ptr->mem 等价于 (*ptr).mem :
string s1 = "a string", *p = &s1;
auto n = s1.size(); //运行string对象s1的size成员
n= (*p).size(); //运行p所指对象的size成员
n = p->size(); //等价于(*p).size()
因为解引用运算符的优先级低于 点运算符,所以执行解引用运算的子表达式两端必须加上括号。如果没加括号,代码的含义就大不相同了:
//运行p的size成员,然后解引用size的结果
*p.size(); //错误:p是一个指针,它没有名为size的成员
这条表达式试图访问对象p的size成员,但是p本身是一个指针且不包含任何成员,所以上述语句无法通过编译。
- 箭头运算符作用于一个指针类型的运算对象,结果是一个左值。
- 点运算符分成两种情况:如果成员所属的对象是左值,那么结果是左值;反之,如果成员所属的对象是右值,那么结果是右值。
条件运算符 ( ? : ) (? :) (?:)
条件运算符 ( ? : ) (?:) (?:) 允许我们把简单的 if-else 逻辑嵌入到单个表达式当中,条件运算符按照如下形式使用:
cond ? exprl : expr2;
- 其中 cond是判断条件的表达式,而exprl和 expr2是两个类型相同或可能转换为某个公共类型的表达式。
- 条件运算符的执行过程是:首先求cond 的值,如果条件为真对 exprl求值并返回该值,否则对expr2求值并返回该值。
举个例子,我们可以使用条件运算符判断成绩是否合格:
string finalgrade = (grade < 60)? "fail" : "pass" ;
- 条件部分判断成绩是否小于60。如果小于,表达式的结果是"fail",否则结果是"pass"。
- 当条件运算符的两个表达式都是左值或者能转换成同一种左值类型时,运算的结果是左值;否则运算的结果是右值。
嵌套条件运算符
允许在条件运算符的内部嵌套另外一个条件运算符。也就是说,条件表达式可以作为另外一个条件运算符的 cond 或 expr。举个例子,使用一对嵌套的条件运算符可以将成绩分成三档:优秀(high pass)、合格(pass)和不合格( fail):
finalgrade = (grade > 90) ? "high pass" : (grade < 60) ? "fail" : "pass";
第一个条件检查成绩是否在90 分以上,如果是,执行符号 ? 后面的表达式,得到 “highpass” ;如果否,执行符号 : 后面的分支。这个分支本身又是一个条件表达式,它检查成绩是否在60分以下,如果是,得到"fail";否则得到"pass"。
条件运算符满足右结合律,意味着运算对象(一般)按照从右向左的顺序组合。因此在上面的代码中,靠右边的条件运算(比较成绩是否小于60)构成了靠左边的条件运算的:分支。
在输出表达式中使用条件运算符
条件运算符的优先级非常低, 因此当一条长表达式中嵌套了条件运算子表达式时,通常需要在它两端加上括号。
cout << ((grade < 60) ? "fail" : "pass"); //输出pass或者fail
cout << (grade < 60) ? "fail" : "pass"; //输出1或者0!
cout << grade < 60 ? "fail" : "pass"; //错误:试图比较cout和60
在第二条表达式中, grade和60的比较结果是<<运算符的运算对象,因此如果grade<60为真输出1,否则输出0。<<运算符的返回值是cout,接下来 cout作为条件运算符的条件。也就是说,第二条表达式等价于
cout << (grade < 60); //输出1或者0
cout ? "fail" : "pass"; //根据cout 的值是true还是false产生对应的字面值
因为第三条表达式等价于下面的语句,所以它是错误的:
cout << grade; //小于运算符的优先级低于移位运算符,所以先输出grade
cout< 60 ? "fail" : "pass"; //然后比较cout和60!
位运算符
- 位运算符作用于整数类型的运算对象,并把运算对象看成是二进制位的集合。
- 位运算符提供检查和设置二进制位的功能,如一种名为bitset的标准库类型也可以表示任意大小的二进制位集合,所以位运算符同样能用于bitset类型。
一般来说,如果运算对象是“小整型”,则它的值会被自动提升成较大的整数类型。运算对象可以是带符号的,也可以是无符号的。
关于符号位如何处理没有明确的规定,所以强烈建议仅将位运算符用于处理无符号类型。
移位运算符
- 之前在处理输入和输出操作时,我们已经使用过标准IO库定义的<<运算符和>>运算符的重载版本。
- 这两种运算符的内置含义是对其运算对象执行基于二进制位的移动操作,首先令左侧运算对象的内容按照右侧运算对象的要求移动指定位数,然后将经过移动的(可能还进行了提升)左侧运算对象的拷贝作为求值结果。
- 其中,右侧的运算对象一定不能为负,而且值必须严格小于结果的位数,否则就会产生未定义的行为。二进制位或者向左移(<<)或者向右移( >>),移出边界之外的位就被舍弃掉了:
位求反运算符
- 位求反运算符(~)将运算对象逐位求反后生成一个新值,将1置为o、将0置为1;
char类型的运算对象首先提升成 int 类型,提升时运算对象原来的位保持不变,往高位(high order position)添加 0 即可。因此在本例中,首先将bits 提升成int类型,增加24个高位0,随后将提升后的值逐位求反。
位与、位或、位异或运算符
与(&)、或(|)、异或(^)运算符在两个运算对象上逐位执行相应的逻辑操作;
有一种常见的错误是把位运算符和逻辑运算符搞混了,比如位与( & )和逻辑与( && )、位或( | )和逻辑或( ll )、位求反( ~ )和逻辑非( ! )。
//使用
lUL << 27 //生成一个值,该值只有第27位为1
quizl |= lUL << 27; //表示学生27通过了测验
// |=运算符的工作原理和+=非常相似,它等价于
quizl = quizl | lUL << 27; //等价于quizl |= lUL<< 27;
bool status = quizl & (lUL << 27); //学生27是否通过了测验?
我们将 quizl 和一个只有第27位是1的值按位求与,如果quizl 的第27位是1,计算的结果就是非0(真);否则结果是0.
移位运算符(又叫IO运算符)满足左结合律
- 程序员用不到移位运算符的内置含义,也仍然有必要理解其优先级和结合律。
因为移位运算符满足左结合律,所以表达式
cout << "hi" << "there" << endl;
的执行过程实际上等同于
((cout << "hi") << "there" ) << endl;
在这条语句中,运算对象"hi"和第一个<<组合在一起,它的结果和第二个<<组合在一起,接下来的结果再和第三个<<组合在一起。
移位运算符的优先级不高不低,介于中间:
- 比算术运算符的优先级低,但比关系运算符、赋值运算符和条件运算符的优先级高。
- 因此在一次使用多个运算符时,有必要在适当的地方加上括号使其满足我们的要求。
cout << 42 + 10; //正确:+的优先级更高,因此输出求和结果
cout << (10 < 42); //正确:括号使运算对象按照我们的期望组合在一起,输出1
cout << 10 < 42; //错误:试图比较cout和42!
最后一个cout的含义其实是
(cout <<10) < 42;
也就是“把数字10写到cout,然后将结果(即 cout)与 42进行比较”。
sizeof 运算符
- sizeof运算符返回一条表达式或一个类型名字所占的 字节数。
- sizeof运算符满足右结合律,其所得的值是一个size_t类型的常量表达式。
运算符的运算对象有两种形式:
sizeof (type)
sizeof expr
在第二种形式中,sizeof返回的是表达式结果类型的大小。与众不同的一点是,sizeof并不实际计算其运算对象的值:
Sales_data data, *p;
sizeof (Sales_data) ; //存储sales_data类型的对象所占的空间大小
sizeof data; //data的类型的大小,即sizeof(Sales_data)
sizeof p; //指针所占的空间大小
sizeof *p; //p所指类型的空间大小,即 sizeof(Sales_data)
sizeof data.revenue; //sales_data 的revenue成员对应类型的大小
sizeof Sales_data::revenue; // 另一种获取revenue大小的方式
这些例子中最有趣的一个是 sizeof *p。首先,因为sizeof满足右结合律并且与*运算符的优先级一样,所以表达式按照从右向左的顺序组合。也就是说,它等价于sizeof(*p)。
C++11新标准允许我们使用作用域运算符来获取类成员的大小。通常情况下只有通过类的对象才能访问到类的成员,但是sizeof运算符无须我们提供一个具体的对象,因为要想知道类成员的大小无须真的获取该成员。
sizeof运算符的结果部分地依赖于其作用的类型:
- 对char或者类型为char的表达式执行sizeof运算,结果得1。
- 对引用类型执行sizeof运算得到被引用对象所占空间的大小。
- 对指针执行sizeof运算得到指针本身所占空间的大小。
- 对解引用指针执行sizeof运算得到指针指向的对象所占空间的大小,指针不需有效。
- 对数组执行sizeof运算得到整个数组所占空间的大小,等价于对数组中所有的元素各执行一次sizeof运算并将所得结果求和。注意,sizeof运算不会把数组转换成指针来处理。
- 对string 对象或vector对象执行sizeof运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。
因为执行sizeof运算能得到整个数组的大小,所以可以用数组的大小除以单个元素的大小得到数组中元素的个数:
// sizeof(ia)/sizeof(*ia)返回ia的元素数量
constexpr size_t sz = sizeof(ia) / sizeof (*ia);
int arr2[sz]; //正确: sizeof返回一个常量表达式
因为sizeof 的返回值是一个常量表达式,所以我们可以用sizeof的结果声明数组的维度。
类运算符
- 在C++语言中,某些类型之间有关联。如果两种类型有关联,那么当程序需要其中一种类型的运算对象时,可以用另一种关联类型的对象或值来替代。
算术类型之间的 隐式转换 被设计得尽可能避免损失精度。
int ival = 3.541 +3; //编译器可能会警告该运算损失了精度
上面的例子中,3转换成double类型,然后执行浮点数加法,所得结果的类型是double。,再由double向int 转换时忽略掉了小数部分,上面的表达式中,数值6被赋给了ival.
何时发生隐式类型转换
在下面这些情况下,编译器会自动地转换运算对象的类型:
- 在大多数表达式中,比int类型小的整型值首先提升为较大的整数类型。
- 在条件中,非布尔值转换成布尔类型。
- 初始化过程中,初始值转换成变量的类型;在赋值语句中,右侧运算对象转换成左侧运算对象的类型。
- 如果算术运算或关系运算的运算对象有多种类型,需要转换成同一种类型。
- 函数调用时也会发生类型转换。
算术转换
- 算术转换(arithmetic conversion)的含义是把一种算术类型转换成另外一种算术类型。
- 算术转换的规则定义了一套类型转换的层次,其中运算符的运算对象将转换成最宽的类型。
整型提升
整型提升( integral promotion)负责把小整数类型转换成较大的整数类型。
- 对于 bool、char、 signed char.unsigned char、short和unsigned short等类型来说,只要它们所有可能的值都能存在int 里,它们就会提升成int类型;否则,提升成unsigned int类型。就如我们所熟知的,布尔值false提升成0、true提升成1。
- 较大的char类型( wchar_t.char16_t、char32_t)提升成int.unsigned int、long、unsigned long、long long和 unsigned long long中最小的一种类型,前提是转换后的类型要能容纳原类型所有可能的值。
理解算术转换
要想理解算术转换,办法之一就是研究大量的例子:
bool flag; char cval;
short sval; unsignedshort usval;
int ival; unsigned int uival;
long lval; unsigned long ulval;
float fval; double dval;
3.14159L + 'a' ; //'a'提升成int,然后该int值转换成long double
dval + ival; // ival转换成double
dval + fval; //fval 转换成double
ival = dval; //dval转换成(切除小数部分后) int
flag = dval; //如果dval是0,则flag是false,否则flag是true
cval + fval; //cval提升成int,然后该int值转换成float
sval + cval; //sval和cval都提升成int
cval + lval; //cval转换成long
ival + ulval; //ival转换成unsigned long
usval + ival; //根据unsigned short和int所占空间的大小进行提升
uival + lval; //根据unsigned int和long所占空间的大小进行转换
其他隐式类型转换
除了算术转换之外还有几种隐式类型转换,包括如下几种。
数组转换成指针: 在大多数用到数组的表达式中,数组自动转换成指向数组首元素的指针:
int ia[10]; //含有10个整数的数组
int* ip = ia; //ia转换成指向数组首元素的指针
- 当数组被用作 decltype关键字的参数, 或者作为取地址符(&)、sizeof及 typeid 等运算符的运算对象时,上述转换不会发生。
- 同样的,如果用一个引用来初始化数组,上述转换也不会发生。
指针的转换: C++还规定了几种其他的指针转换方式,包括
- 常量整数值0或者字面值nullptr能转换成任意指针类型;
- 指向任意非常量的指针能转换成void*;
- 指向任意对象的指针能转换成const void*。
转换成布尔类型:存在一种从算术类型或指针类型向布尔类型自动转换的机制。
- 如果指针或算术类型的值为0,转换结果是false;否则转换结果是true:
char *cp = get_string ();
if (cp)/ * ...*/ //如果指针cp不是0,条件为真
while (*cp)/* ...* / //如果*cp 不是空字符,条件为真
转换成常量: 允许将指向 非常量类型 的指针转换成指向相应的 常量类型 的指针,对于引用也是这样。
也就是说,如果T是一种类型,我们就能将指向T的指针或引用分别转换成指向const T的指针或引用
int i;
const int &j = i; //非常量转换成const int的引用
const int *p = &i; //非常量的地址转换成const的地址
int &r = j, *q = p; //错误:不允许const转换成非常量
相反的转换并不存在,因为它试图删除掉底层const。
类类型定义的转换: 类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换。如果同时提出多个转换请求,这些请求将被拒绝。
显示转换(建议:避免强制类型转换)
有时我们希望显式地将对象强制转换成另外一种类型。虽然有时不得不使用强制类型转换,但这种方法本质上是非常危险的。
命名的强制类型转换
一个命名的强制类型转换具有如下形式:
cast-name<type>(expression);
- 其中,type是转换的目标类型而expression是要转换的值。如果 type是引用类型,则结果是左值。
- cast-name是 static_cast , dynamic_cast ,const_cast 和 reinterpret_cast 中的一种。dynamic_cast支持运行时类型识别。cast-name指定了执行的是哪种转换。
static_cast
- 任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast.
例如,通过将一个运算对象强制转换成double类型就能使表达式执行浮点数除法:
//进行强制类型转换以便执行浮点数除法
double slope = static_cast<double>(j) / i;
当需要把一个较大的算术类型赋值给较小的类型时,static_cast非常有用。此时,强制类型转换告诉程序的读者和编译器:我们知道并且不在乎潜在的精度损失。(一般来说,如果编译器发现一个较大的算术类型试图赋值给较小的类型,就会给出警告信息;但是当我们执行了显式的类型转换后,警告信息就会被关闭了。)
- static_cast对于编译器无法自动执行的类型转换也非常有用。
例如,我们可以使用static_cast 找回存在于void*指针中的值:
void* p = &d; //正确:任何非常量对象的地址都能存入void*
//正确:将void*转换回初始的指针类型
double *dp = static_cast<double*>(p);
当我们把指针存放在void*中,并且使用static_cast将其强制转换回原来的类型时,应该确保指针的值保持不变。也就是说,强制转换的结果将与原始的地址值相等,因此我们必须确保转换后所得的类型就是指针所指的类型。类型一旦不符,将产生未定义的后果。
const_cast
const_cast只能改变运算对象的底层const :
const char *pc;
char *p = const_cast<char*>(pc); //正确:但是通过p写值是未定义的行为
对于将常量对象转换成非常量对象的行为,我们一般称其为“去掉const性质( cast awaythe const)”。一旦我们去掉了某个对象的const性质,编译器就不再阻止我们对该对象进行写操作了。
如果对象本身不是一个常量,使用强制类型转换获得写权限是合法的行为。然而如果对象是一个常量,再使用const_cast执行写操作就会产生未定义的后果。
只有const_cast 能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误。同样的,也不能用const_cast改变表达式的类型:
const char *cp;
//错误: static_cast不能转换掉const性质
char *q = static cast<char*> (cp) ;
static_cast<string>(cp); //正确:字符串字面值转换成string类型
const_cast<string>(cp) ; //错误:const cast只改变常量属性
reinterpret_cast
reinterpret_cast通常为运算对象的位模式提供较低层次上的重新解释。
int *ip;
char *pc = reinterpret_cast<char*> (ip);
我们必须牢记pc所指的真实对象是一个int而非字符,如果把 pc当成普通的字符指针使用就可能在运行时发生错误。例如:
string str(pc);
可能导致异常的运行时行为。
reinterpret_cast本质上依赖于机器。要想安全地使用reinterpret_cast必须对涉及的类型和编译器实现转换的过程都非常了解。
旧式的强制类型转换
在早期版本的C++语言中,显式地进行强制类型转换包含两种形式:
type (expr); //函数形式的强制类型转换
(type) expr; //c语言风格的强制类型转换
- 根据所涉及的类型不同,旧式的强制类型转换分别具有与const_cast 、static_cast或reinterpret_cast相似的行为。
- 当我们在某处执行旧式的强制类型转换时,如果换成const_cast和 static_cast也合法,则其行为与对应的命名转换一致。
如果替换后不合法,则旧式强制类型转换执行与reinterpret_cast类似的功能:
char *pc = (char*) ip; //ip是指向整数的指针
的效果与使用reinterpret_cast一样。
与命名的强制类型转换相比,旧式的强制类型转换从表现形式上来说不那么清晰明了,容易被看漏,所以一旦转换过程出现问题,追踪起来也更加困难。
运算符优先级表