C++ Primer 4_表达式

2021/9/30 20:12:47

本文主要是介绍C++ Primer 4_表达式,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

表达式


之前学过的基础性的东西我将不会在此再做重复,如果有一些我之前从未注意过或从未深思过的事情以及一些重点,我都将会在此写下。

C++ 的表达式要不然是左值,要不然就是右值。
C 语言中左值可以位于赋值语句的左侧,右值则不能。

C++语言中则与 C 语言有所不同:
当一个对象被用作右值的时候,用的是对象的值(内容);
当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。

值得一提的是,左值的英文简写为“lvalue”,右值的英文简写为“rvalue”。
很多人认为它们分别是"left value"、“right value” 的缩写,其实不然。
lvalue 是“loactor value”的缩写,可意为存储在内存中、有明确存储地址(可寻址)的数据,
而 rvalue 译为 “read value”,指的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)。

一个右值表达式表示的是一个对象的值,而一个左值表达式表示的是一个对象的身份。

一个重要原则是:
在需要右值的地方可以用左值来代替,但是不能把右值当成左值(位置)来使用。
当一个左值被当成右值使用时,实际使用的是他的内容(值)。
(特例:右值引用,右值引用必须绑定到右值,不可以用左值来代替)

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

左值引用:
左值引用,用 “&” 表示。
int num = 10;
int &b = num; //正确
int &c = 10; //错误
如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。
因此,C++98/03 标准中的引用又称为左值引用。
注意,虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值,例如:
int num = 10;
const int &b = num;
const int &c = 10;

右值引用:
C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。
需要注意的,和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:
int num = 10;
//int && a = num; //右值引用不能初始化为左值
int && a = 10;
和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:
int && a = 10;
a = 100;
cout << a << endl;
程序输出结果为 100。

另外值得一提的是,C++ 语法上是支持定义常量右值引用的,例如:
const int && a = 10; //编译器不会报错
但这种定义出来的右值引用并无实际用处。
一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;
其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。

其实,C++11 标准中对右值做了更细致的划分,
分别称为纯右值(Pure value,简称 pvalue)和将亡值(eXpiring value,简称 xvalue )。
其中纯右值就是 C++98/03 标准中的右值,
而将亡值则指的是和右值引用相关的表达式(比如某函数返回的 T && 类型的表达式)。
对于纯右值和将亡值,都属于右值,知道即可,不必深究。

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

也就是说,结果是一个指向整型指针的指针。

优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值。
在大多数情况下,不会明确指定求值的顺序。对于如下的表达式
int i = f1() * f2();
我们知道 f1 和 f2 一定会在执行乘法之前被调用,
因为毕竟相乘的是这两个函数的返回值。
但是我们无法知道到底 f1 在 f2 之前调用还是 f2 在 f1 之前调用。

对于那些没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为。举个简单的例子,<< 运算符没有明确规定何时以及如何对运算对象求值,因此下面的输出表达式是未定义的:
int i = 0;
cout << i << " , "<< ++i << endl; //未定义的
因为程序是未定义的,所以我们无法推断它的行为。
编译器可能先求 ++i 的值再求 i 的值,此时输出结果是 1 , 1;
也可能先求i的值再求 ++i 的值,输出结果是 0 , 1;
甚至编译器还可能做完全不同的操作。
因为此表达式的行为不可预知,因此不论编译器生成什么样的代码程序都是错误的。
(未定义(undefined) 即 C++ 语言没有明确规定的情况。不论是否有意为之,未定义行为都可能引发难以追踪的运行时错误、安全问题和可移植性问题。)

有 4 种运算符明确规定了运算对象的求值顺序:
逻辑与(&&)运算符(它规定先求左侧运算对象的值,只有当左侧运算对象的值为真时才继续求右侧运算对象的值)、
逻辑或( || )运算符、
条件(? :)运算符和
逗号(,)运算符。

运算对象的求值顺序与优先级和结合律无关,在一条形如 f() + g() * h() + j() 的表达式中:
·优先级规定,g() 的返回值和 h() 的返回值相乘。
·结合律规定,f () 的返回值先与 g() 和 h() 的乘积相加,所得结果再与 j() 的返回值相加。
·对于这些函数的调用顺序没有明确规定。
如果 f、g、h 和 j 是无关函数,
它们既不会改变同一对象的状态也不执行 IO 任务,那么函数的调用顺序不受限制。
反之,如果其中某几个函数影响同一对象,则它是一条错误的表达式,将产生未定义的行为。

算术运算符的运算对象和求值结果都是右值。

bool b = true;
bool b2 = -b; // b2的值为true
布尔值不应该参与运算,-b就是一个很好的例子。
对大多数运算符来说,布尔类型的运算对象将被提升为 int 类型。
如上所示,布尔变量 b 的值为真,参与运算时将被提升成整数值 1 ,对它求负后的结果是-1。
将 -1 再转换回布尔值并将其作为 b2 的初始值,
显然这个初始值不等于 0,转换成布尔值后应该为 1。
所以,b2 的值是真!

C++11 新标准规定两数相除所得的商一律向 0 取整,即直接切除小数部分

有时会试图将上面的真值测试写成如下形式:
if (val == true) { /* …*/ } // 只有当 val 等于 1 时条件才为真!
但是这种写法存在两个问题:
首先,与之前的代码相比,上面这种写法较长而且不太直接(尽管大家都认为缩写的形式对初学者来说有点难理解);
更重要的一点是,如果 val 不是布尔值,这样的比较就失去了原来的意义。

如果 val 不是布尔值,那么进行比较之前会首先把 true 转换成 val 的类型。
也就是说,如果 val 不是布尔值,则代码可以改写成如下形式:
if (val == 1) {/* …*/ }
正如我们已经非常熟悉的那样,当布尔值转换成其他算术类型时,false 转换成 0 而 true 转换成 1。
如果真想知道 val 的值是否是 1,应该直接写出 1 这个数值来,而不要与 true 比较。

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

赋值运算的结果是它的左侧运算对象,并且是一个左值。
相应的,结果的类型就是左侧运算对象的类型。
如果赋值运算符的左右两个运算对象类型不同,则右侧运算对象将转换成左侧运算对象的类型。

理解 *pdeg++ :
*pdeg++ 等价于 *(pdeg++),其中 pdeg++ 返回值为pdeg 的原值

*beg = toupper(*beg++) // 错误,该赋值语句未定义
赋值运算左右两端的运算对象都用到了 beg,
并且右侧的运算对象还改变了 beg 的值,所以该语句是未定义的。
编译器可能按照下列方法处理它:
*beg = toupper(*beg);
*(beg + 1) = toupper(*beg);
或者其他方法均有可能

因为解引用运算符的优先级低于点运算符,所以执行解引用运算的子表达式两端必须加上括号。
如果没加括号,代码的含义就大不相同了:
// 运行 p 的size成员,然后解引用 size 的结果
*p.size();
// 错误:p 是一个指针,它没有名为 size 的成员(解引用运算符的优先级低于点运算符)
这条表达式试图访问对象 p 的 size 成员,但是 p 本身是一个指针且不包含任何成员,
所以上述语句无法通过编译。
正确写法:(*p).size();

条件运算符的优先级非常低,因此当一条长表达式中嵌套了条件运算子表达式时,通常需要在它两端加上括号。
例如,有时需要根据条件值输出两个对象中的一个,
如果写这条语句时没把括号写全就有可能产生意想不到的结果:
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; //小于运算符的优先级低于移位运算符,所以先输出gradecout < 60 ? “fail” : “pass” ;
//然后比较cout和60!

位运算符提供检查和设置二进制位的功能。
位运算如何处理运算对象的“符号位”依赖于机器。
左移操作可能会改变符号位的值,因此是一种未定义的行为。

位运算中,char 类型的对象首先将提升至 int 类型然后才进行运算。

一般用位运算的重载版本进行 IO 操作,重载运算符的优先级和结合律都和它的内置版本一致。
移位运算符的优先级不高不低,介于中间:
比算术运算符的优先级低,但比关系运算符、赋值运算符和条件运算符的优先级高。
因此在一次使用多个运算符时,有必要在适当的地方加上括号使其满足我们的要求。
cout << 42 + 10; //正确: + 的优先级更高,因此输出求和结果
cout << (10 < 42); //正确: 括号使运算对象按照我们的期望组合在一起,输出 1
cout << 10 < 42; //错误: 试图比较 cout 和 42!
最后一个 cout 的含义其实是
(cout << 10) < 42;

sizeof 运算符返回一条表达式或者一个类型名字所占的字节数。
sizeof 运算符的运算对象有两种形式:
sizeof (类型名)
sizeof 变量名

sizeof *p 是最有趣的一个。
首先,因为 sizeof 满足右结合律并且与 * 运算符的优先级一样,
所以表达式按照从右向左的顺序组合。
也就是说,它等价于 sizeof(*p)。
其次,因为 sizeof 不会实际求运算对象的值,
所以即使 p 是一个无效(即未初始化)的指针也不会有什么影响。
在 sizeof 的运算对象中解引用一个无效指针仍然是一种安全的行为,
因为指针实际上并没有被真正使用。
sizeof 不需要真的解引用指针也能知道它所指对象的类型。

对数组执行 sizeof 运算得到整个数组所占空间的大小,
等价于对数组中所有的元素各执行一次 sizeof 运算并将所得结果求和。
注意,sizeof 运算不会把数组转换成指针来处理。

对 string 对象或 vector 对象执行 sizeof 运算只返回该类型固定部分的大小,
不会计算对象中的元素占用了多少空间。

可以用数组的大小除以单个元素的大小得到数组中元素的个数;
// sizeof(ia) / sizeof(*ia) 返回 ia 的元素数量
constexpr size_t sz = sizeof(ia) / sizeof(*ia) ;
int arr2[sz]; //正确: sizeof 返回一个常量表达式,
sizeof 的返回值是一个常量表达式,所以可以用 sizeof 的结果声明数组的维度。

对于逗号运算符来说,首先对左侧的表达式求值,然后把求值结果扔掉。
逗号运算符真正的结果是右侧表达式的值。

整型提升(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 中最小的一种类型,
前提是转换后的类型要能容纳原类型所有可能的值。

如果某个运算对象的类型是无符号类型,转换的结果依赖于机器中各个整数类型的相对大小。
像往常一样,首先执行整型提升。
• 如果结果的类型匹配,无须进行进一步的转换。
• 如果两个(提升后的)运算对象的类型要么都是带符号的、要么都是无符号的,则小类型的运算对象转换成较大的类型。
• 如果一个运算对象是无符号类型、另外一个运算对象是带符号类型,而且其中的无符号类型不小于带符号类型,那么带符号的运算对象转换成无符号的。
• 剩下的一种情况是带符号类型大于无符号类型,此时转换的结果依赖于机器。
如果无符号类型的所有值都能存在该带符号类型中,则无符号类型的运算对象转换成带符号类型。
如果不能,那么带符号类型的运算对象转换成无符号类型。
例如,如果两个运算对象的类型分别是 long 和 unsigned int,并且
int 和 long 的大小相同,则 long 类型的运算对象转换成 unsigned int 类型;
如果 long 类型占用的空间比 int 更多,则 unsigned int 类型的运算对象转换成long类型。

指针的转换(隐式转换):
• 转换成布尔类型
• 转换成常量:
允许将指向非常量类型的指针转换成指向相应的常量类型的指针,对于引用也是这样。
也就是说,如果 T 是一种类型,
我们就能将指向 T 的指针或引用分别转换成(或者定义成)指向 const T 的指针或引用:
int i;
const int &j= i; //非常量转换成 const int 的引用
const int *p = &i; //非常量的地址转换成 const 的地址
int &r= j, *q = p; //错误:不允许 const 转换成非常量
相反的转换并不存在,因为它试图删除掉底层 const。
• 类类型定义的转换:
类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换。
如果同时提出多个转换请求,这些请求将被拒绝。
我们之前的程序已经使用过类类型转换:
一处是在需要标准库 string 类型的地方使用 C 风格字符串;
另一处是在条件部分读入 istream:
string S,t = “a value”; //字符串字面值转换成string类型
while (cin >> s) // while 的条件部分把cin转换成布尔值
I0 库定义了从 istream 向布尔值转换的规则,根据这一规则,cin自动地转换成布尔值。
所得的布尔值到底是什么由输入流的状态决定。

显式转换:static_cast, dynamic_cast, const_cast, reinterpret_cast 和 旧式强制类型转换
static_cast:
任何具有明确定义的类型转换,只要不包含底层 const,都可以使用 static_cast。
例如,通过将一个运算对象强制转换成 double 类型就能使表达式执行浮点数除法:
//进行强制类型转换以便执行浮点数除法
double slope = static_cast(j) / i;

当需要把一个较大的算术类型赋值给较小的类型时,static_ cast 非常有用。
此时,强制类型转换告诉程序的读者和编译器:
我们知道并且不在乎潜在的精度损失。
一般来说,如果编译器发现一个较大的算术类型试图赋值给较小的类型,就会给出警告信息;
但是当我们执行了显式的类型转换后,警告信息就会被关闭了。

static_cast 对于编译器无法自动执行的类型转换也非常有用。
例如,我们可以使用 static_cast 找回存在于 void* 指针中的值:
void* p = &d; //正确:任何非常量对象的地址都能存入void*
double dp = static_cast<double> §; //正确:将void*转换回初始的指针类型

当我们把指针存放在 void* 中,并且使用 static_cast 将其强制转换回原来的类型时,应该确保指针的值保持不变。
也就是说,强制转换的结果将与原始的地址值相等,
因此我们必须确保转换后所得的类型就是指针所指的类型。
类型一旦不符,将产生未定义的后果。

dynamic_cast:
支持运行时类型识别
dynamic_cast 运算符(dynamic_cast operator)的使用形式如下所示:
dynamic_cast<type*> (e)
dynamic_cast<type&> (e)
dynamic_cast<type&&> (e)
其中,type 必须是一个类类型,并且通常情况下该类型应该含有虚函数。

在第一种形式中,e 必须是一个有效的指针;
在第二种形式中,e 必须是一个左值;
在第三种形式中,e 不能是左值。

在上面的所有形式中,e 的类型必须符合以下三个条件中的任意一个:
e 的类型是目标 type 的公有派生类、
e 的类型是目标 type 的公有基类或者
e 的类型就是目标 type 的类型。
如果符合,则类型转换可以成功。
否则,转换失败。
如果一条 dynamic_cast 语句的转换目标是指针类型并且失败了,则结果为 0。
如果转换目标是引用类型并且失败了,则 dynamic_cast 运算符将抛出一个 bad_cast 异常。

指针类型的 dynamic_cast
举个简单的例子,假定 Base 类至少含有一个虚函数,Derived 是 Base 的公有派生类。
如果有一个指向 Base 的指针 bp,则我们可以在运行时将它转换成指向 Derived 的指针,具体代码如下:
if (Derived dp = dynamic_cast<Derived> (bp) ) {
//使用dp指向的Derived对象
} else { // bp指向一个Base对象
//使用bp指向的Base对象
}
如果 bp 指向 Derived 对象,则上述的类型转换初始化 dp 并令其指向 bp 所指的 Derived 对象。
此时,if 语句内部使用 Derived 操作的代码是安全的。
否则,类型转换的结果为 0,dp 为 0 意味着if 语句的条件失败,此时 else 子句执行相应的 Base 操作。

我们可以对一个空指针执行 dynamic_cast,结果是所需类型的空指针。

值得注意的一点是,我们在条件部分定义了 dp,
这样做的好处是可以在一个操作中同时完成类型转换和条件检查两项任务。
而且,指针 dp 在 if 语句外部是不可访问的。
一旦转换失败,即使后续的代码忘了做相应判断,也不会接触到这个未绑定的指针,从而确保程序是安全的。

在条件部分执行 dynamic_cast 操作可以确保类型转换和结果检查在同一条表达式中完成。

引用类型的 dynamic_cast
引用类型的 dynamic_cast 与指针类型的 dynamic_cast 在表示错误发生的方式上略有不同。
因为不存在所谓的空引用,所以对于引用类型来说无法使用与指针类型完全相同的错误报告策略。
当对引用的类型转换失败时,程序抛出一个名为 std::bad_cast 的异常,
该异常定义在 typeinfo 标准库头文件中。
我们可以按照如下的形式改写之前的程序,令其使用引用类型:
void f (const Base &b) {
try {
const Derived &d = dynamic_cast<const Derived&> (b) ;
//使用b引用的Derived对象
} catch (bad_cast) {
//处理类型转换失败的情况
}
}

const_cast:
const_cast 只能改变运算对象的底层 const。
const char *pc;
char p = const_cast<char> (pc); // 正确:但是通过p写值是未定义的行为
对于将常量对象转换成非常量对象的行为,我们一般称其为“去掉 const 性质(cast away the const)”。
一旦我们去掉了某个对象的 const 性质,编译器就不再阻止我们对该对象进行写操作了。
如果对象本身不是一个常量,使用强制类型转换获得写权限是合法的行为。
然而如果对象是一个常量,再使用 const_cast 执行写操作就会产生未定义的后果。

只有 const_cast 能改变表达式的常量属性,
使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误。
同样的,也不能用 const_cast 改变表达式的类型:

const_cast 在重载函数的情景中最有用。
例如 shorterString 函数:
//比较两个string对象的长度,返回较短的那个引用
const string &shorterString(const string &sl, const string &s2) {
return s1.size() <= s2.size() ? s1 : s2;
}
这个函数的参数和返回类型都是 const string 的引用。
我们可以对两个非常量的 string 实参调用这个函数,但返回的结果仍然是 const string 的引用。
因此我们需要一种新的 shorterString 函数,
当它的实参不是常量时,得到的结果是一个普通的引用,
使用 const_cast 可以做到这一点:
string &shorterString(string &s1, string &s2) {
auto &r = shorterString(const_cast<const string&> (s1), const_cast<const string&> (s2)) ;
return const_cast<string&> ® ;
}

reinterpret_cast:
reinterpret_cast 通常为运算对象的位模式提供较低层次上的重新解释。
举个例子,假设有如下的转换
int *ip;
char pc = reinterpret_cast<char> (ip) ;

我们必须牢记 pc 所指的真实对象是一个 int 而非字符,
如果把 pc 当成普通的字符指针使用就可能在运行时发生错误。

例如:
string str (pc) ;
可能导致异常的运行时行为。
使用 reinterpret_ cast 是非常危险的,
用 pc 初始化 str 的例子很好地证明了这一点。
其中的关键问题是类型改变了,但编译器没有给出任何警告或者错误的提示信息。
当我们用一个 int 的地址初始化 pc 时,
由于显式地声称这种转换合法,所以编译器不会发出任何警告或错误信息。
接下来再使用 pc 时就会认定它的值是 char* 类型,
编译器没法知道它实际存放的是指向 int 的指针。
最终的结果就是,
在上面的例子中虽然用 pc 初始化 str 没什么实际意义,甚至还可能引发更糟糕的后果,
但仅从语法上而言这种操作无可指摘。
查找这类问题的原因非常困难,
如果将 ip 强制转换成 pc 的语句和用 pc 初始化 string 对象的语句分属不同文件就更是如此。

reinterpret_cast 本质上依赖于机器。
要想安全地使用 reinterpret_cast 必须对涉及的类型和编译器实现转换的过程都非常了解。

建议:避免强制类型转换
强制类型转换干扰了正常的类型检查,因此我们强烈建议程序员避免使用强制类型转换。
这个建议对于 reinterpret_cast 尤其适用,因为此类类型转换总是充满了风险。
在有重载函数的上下文中使用 const_cast 无可厚非,
但是在其他情况下使用 const_cast 也就意味着程序存在某种设计缺陷。
其他强制类型转换,比如 static_cast 和 dynamic_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 一样。

个人短见:善用括号可以无需记忆运算符优先级表

纯图片:
在这里插入图片描述



这篇关于C++ Primer 4_表达式的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程