文章目录
- 一、表达式基础
- 1.表达式的值类别
- 2.表达式的类型转换
- 二、表达式详述
- 1.算术操作符
- 2.逻辑与关系操作符
- 3.位操作符
- 4.赋值操作符
- 5.自增与自减运算符
- 6.其他操作符
- 三、C++17对表达式的求值顺序的限定
一、表达式基础
表达式由一到多个操作数组成,可以求值并 ( 通常会 ) 返回求值结果。
-
最基本的表达式:变量、字面值
-
函数调用也是表达式
-
通常来说,表达式会包含操作符(运算符),
操作符的特性:
-
接收几个操作数:一元、二元、三元(3个操作数)
-
操作数的类型—类型转换
需要考虑操作符接收什么类型的操作数,当操作数类型不同或不满足要求时,可能涉及到操作数类型转换
-
操作数是左值还是右值
如:
x = 3
;x是左值,3是右值 -
表达式求值结果的类型
-
表达式求值结果结果是左值还是右值
-
优先级与结合性 ,可以通过小括号来改变运算顺序
操作符有优先级,如
*
的优先级比+
的优先级高具体可查看C++运算符优先级表
结合性:如果在一个表达式中包含多个操作符,且这些操作符有相同优先级
在C++中,表达式的结合性(associativity)决定了在没有括号指明操作顺序时,运算符如何应用到操作数上。C++中的运算符按照结合律可以分为左结合(left-associative)、右结合(right-associative)和非结合(non-associative)三类。
左结合(Left-Associative):
左结合运算符在表达式中,从左到右依次与操作数结合。例如:
- 乘法和除法(
*
,/
)是左结合的。 - 加法和减法(
+
,-
)也是左结合的。
int a = 1 - 3 - 5; // (1-3)-5
右结合(Right-Associative):
右结合运算符在表达式中,从右向左依次与操作数结合。在C++中,大多数右结合的运算符与赋值有关:
- 赋值运算符(
=
,+=
,-=
,*=
,/=
,%=
)是右结合的。
int a = 10; a += 20 += 30; // (20 += 30) += 10,先计算右侧的表达式,再赋值给左侧
非结合(Non-Associative):
非结合运算符不能应用结合律,它们在表达式中不能省略括号。C++中大多数比较和逻辑运算符是非结合的:
- 逻辑AND(
&&
)和逻辑OR(||
)是非结合的。 - 比较运算符(
<
,<=
,>
,>=
,==
,!=
)也是非结合的。
bool result = a < b && c < d; // a < b 必须先于 c < d 进行计算
注意事项:
-
在复杂的表达式中,为了清晰和避免歧义,推荐使用括号来明确指定运算的顺序。
-
运算符的优先级和结合性共同决定了表达式的求值顺序。当运算符优先级相同时,结合性决定求值顺序(同样优先级的运算符的结合性相同);当优先级不同时,优先级高的运算符先进行计算。
-
赋值运算符的右结合性意味着连续赋值需要从右向左进行。
- 乘法和除法(
-
操作符的重载–不改变接收操作数的个数、优先级与结合性
操作符的重载重点应用在类上,同样的操作符可以处理更多的类型,为操作符引入不同的含义,类章节会具体讨论
具体可参考operator overloading
-
-
操作数求值顺序的不确定性
在C++中,操作数求值顺序的不确定性是指在某些表达式中,标准并没有定义操作数被求值的顺序。这意味着编译器可以自由地选择在计算过程中先求值哪个操作数,这可能导致未定义行为(undefined behavior),特别是当表达式中包含对同一个地方的多次修改时。
安全的做法:
为了避免由于求值顺序不确定性带来的问题,C++程序员应该遵循以下安全做法:
- 避免在同一表达式中对同一个对象多次赋值。如果需要对同一对象赋值多次,应该使用不同的表达式。
- 使用括号明确求值顺序。虽然在大多数表达式中,括号内的表达式会先于其他操作数被求值,但在涉及不确定性求值顺序的情况下,这并不能完全保证安全。
- 使用序列点。C++中的序列点(sequence point)是程序执行中的一个点,在该点之前的所有操作都必须完成,之后的操作才开始。赋值操作、函数调用等都是序列点。
- 避免写依赖于求值顺序的代码。依赖于特定求值顺序的代码通常难以阅读和维护,并且可能在不同的编译器或不同的优化级别下有不同的行为。
1.表达式的值类别
所有的划分都是针对表达式的,不是针对对象或数值。
-
glvalue(泛左值):标识一个对象、位或函数
-
prvalue(纯右值):用于初始化对象或作为操作数
prvalue
是传统意义上的右值,它们是不可修改的,且没有自己的存储期。字面量和大多数表达式的计算结果都是prvalue
。 -
xvalue (将亡值):表示其资源可以被重新使用
C++11引入了
xvalue
,表示即将被移动的右值。xvalue
通常由使用std::move
函数的表达式产生,它们可以绑定到非const
引用上,并且可以用作移动语义的来源。std::unique_ptr<int> p1(new int(10)); std::unique_ptr<int> p2 = std::move(p1); // p1 现在是一个xvalue
-
lvalue(左值):左值是可以取得地址的表达式,它们代表内存中的具体位置。左值可以出现在赋值表达式的左侧,也可以出现在需要具体存储位置的上下文中,如函数参数或数组索引。
- 变量的名称
- 数组的名称
- 函数调用的结果(如果函数返回一个对象,而非按值返回)
- 通过解引用指针获得的值
-
rvalue(右值):右值是不具备存储持续时间的临时对象或值,它们不能有名称,也不能出现在赋值表达式的左侧。右值通常用在赋值表达式的右侧,或作为函数参数传递(在C++11及以后的版本中,通过右值引用可以改变这一限制)。
- 字面量(如整数、浮点数、字符)
- 表达式的临时结果(如
(a + b)
) - 被创建和使用在同一个表达式中的临时对象
具体参考:Value categories
注意事项:
- 在 C++ 中,左值也不一定能放在等号左边;右值也可能放在等号左边
- 左值和右值的概念在C++中非常重要,特别是在涉及到赋值、函数参数传递和返回类型时。
- 右值引用(
T&&
)允许程序员安全地利用临时对象,通过移动语义而不是复制语义来提高效率。std::move
函数可以将左值强制转换为xvalue
,使其可以被用作移动赋值的源。static_cast<T&&>
可以将一个表达式显式转换为xvalue
或prvalue
,这在模板编程中非常有用
左值与右值的转换:
-
左值转换为右值( lvalue to rvalue conversion )(在放置右值处放入左值,编译器自动转换为右值)
int x = 3; //x为左值 int y; y = x; //此时,x为右值
-
临时具体化( Temporary Materialization )
prvalue到xvalue的转化
#include <iostream> void fun(const int& par) { } int main() { fun(3); //prvalue到xvalue的转化 }
再讨论decltype:https://zh.cppreference.com/w/cpp/language/decltype
如果实参是类型为 T
的任何其他表达式,且
a) 如果 表达式 的值类别是xvalue
,则 decltype
产生 T&&
(右值引用);
b) 如果 表达式 的值类别是lvalue
,则 decltype
产生 T&
;
c) 如果 表达式 的值类别是prvalue
,则 decltype
产生 T
2.表达式的类型转换
一些操作符要求其操作数具有特定的类型,或者具有相同的类型,此时可能产生类型转换。
-
隐式类型转换
-
自动发生
并不是给一个源类型与一个目标类型,两者之间就会自动转换,转换是有限制的。比如:
int
类型可以自动转换成double
类型,但字符串类型就不能隐式转换成double
类型。这部分内容官网很复杂,这里主要叙述数值提升与数值转换
-
数值提升包括整型提升与浮点提升
- 整型提升:将小整型类型(如:
char
)的纯右值转换为较大整型类型(如:int
)的纯右值 - 浮点提升:
float
类型纯右值转换为double
类型的纯右值
- 整型提升:将小整型类型(如:
-
数值转换:不同于数值提升,数值转换可以更改值,而且有潜在的精度损失
-
-
实际上是一个(有限长度的)转型序列
-
-
显式类型转换(也称为强制类型转换,少使用显式类型转换)
在C++中,显式类型转换(也称为强制类型转换)是程序员明确指示的类型转换,与隐式类型转换(自动进行的转换)相对。显式类型转换允许你将一个表达式从一种类型转换为另一种类型,即使这种转换不是显而易见的或者编译器不会自动进行的。显式类型转换同样是有限制的,并不是任意类型之间都能相互转换。
-
static_cast(编译期完成,不会对运行期产生性能的影响)
语法:
static_cast<Type>(expression)
用于非多态类型之间的转换。它不允许包含访问权限改变或存在继承关系的转换。
double d = 3.14; int i = static_cast<int>(d); // 安全地转换为int类型
从static_cast中知道其支持的转换:
- 如果存在从表达式到目标类型 的隐式转换序列
- 如果存在从目标类型 到表达式 类型的标准转换序列,且它不包含左值到右值、数组到指针、函数到指针、空指针、空成员指针、函数指针 (C++17 起)或布尔转换,那么 static_cast 能进行该隐式转换的逆转换。
- 如果从表达式 到目标类型 的转换涉及左值到右值、数组到指针或函数到指针转换,那么 static_cast 可以显式执行该转换。
- 有作用域枚举类型能转换到整数或浮点类型
- 整数或枚举类型值可转换到任何完整的枚举类型。
- 浮点类型的纯右值可转换到任何其他浮点类型。
- 指向void 的指针类型的纯右值可以转换成指向对象类型
T
的另一指针。
-
dynamic_cast(运行期完成,安全性高。性能相比
static_cast
会差一些)dynamic_cast<Type>(expression)
dynamic_cast
主要用于处理多态性,允许你在存在继承关系的对象之间进行安全的向下转型。Base* basePtr = &derivedObj; Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
-
const_cast(改变变量的常量性,比较危险)
语法:
const_cast<Type>(expression)
const_cast
用于移除或添加const
或volatile
限定符。这种转换不涉及值的修改,仅用于改变表达式的值类别。const int* ci = new int(10); int* modifiable = const_cast<int*>(ci); // 移除const限定符 //在不同编译器中打印的结果可能不同(行为不确定),可能为3也可能为4 const int x = 3; const int& ref = x; int& ref2 = const_cast<int&>(ref); ref2 = 4; std::cout << x << std::endl;
-
reinterpret_cast(重新解释)
语法:
reinterpret_cast<Type>(expression)
reinterpret_cast
提供了最低级别的类型转换,它允许几乎任意的类型转换,包括指针和整型之间的转换。//转换之后可能导致值完全看不懂 int* p = new int(65); char* ch = reinterpret_cast<char*>(p); // 转换为char指针
-
C 形式的类型转换(C++中不建议使用这种类型转换,比较危险,建议在上述四种转换中选择)
语法:
(Type)expression
这是C语言中的类型转换方式,C++同样支持。它对于基本数据类型之间的转换是有效的,但不推荐使用,因为它缺乏类型安全。
double pi = 3.14159; int intPi = (int)pi; // C风格转换为int类型
遇到C 风格转换表达式 时,编译器会尝试按以下顺序将它解释成下列转换表达式:
a)
const_cast
<目标类型>(表达式);b)
static_cast
<目标类型>(表达式),带扩展:额外允许将到派生类的指针或引用转换成到无歧义基类的指针或引用(反之亦然),纵使基类不可访问也是如此(即此转换忽略 private 继承说明符)。同样适用于将成员指针转换到指向无歧义非虚基类的成员的指针;c) static_cast(带扩展)后随 const_cast;
d)
reinterpret_cast
<目标类型>(表达式);e) reinterpret_cast 后随 const_cast。
选择首个满足相应转换运算符要求的方式,即便它无法编译(见示例)。如果该转换能解释成多于一种 static_cast 后随 const_cast 的方式,那么它无法编译。
-
二、表达式详述
1.算术操作符
-
共分为三个优先级
- + , - (一元)
- * , / , %
- + , - (二元)
-
这些操作符均为左结合的
在C++中,操作符的结合性(associativity)描述了在没有括号的情况下,多个相同优先级的操作符如何应用到它们的操作数上。操作符可以是左结合(left-associative)或右结合(right-associative),也可以是既不左结合也不右结合(non-associative)。
左结合(Left-Associative):
左结合操作符意味着当多个相同类型的操作符连续出现时,它们从左到右依次与它们的操作数结合。在C++中,大多数二元操作符(如算术操作符、比较操作符、位操作符等)都是左结合的。
例如,加法操作符(
+
)是左结合的:int a = 1 + 2 + 3; // 等同于 (1 + 2) + 3
右结合(Right-Associative):
右结合操作符意味着当多个相同类型的操作符连续出现时,它们从右向左依次与它们的操作数结合。在C++中,大多数赋值操作符和条件表达式的操作符(如
=
,+=
,? :
等)是右结合的。例如,赋值操作符(
=
)是右结合的:int a = 1; a = 2 = 3; // 等同于 a = (2 = 3)
既不左结合也不右结合(Non-Associative):
既不左结合也不右结合的操作符意味着连续出现时不能省略括号,因为它们不符合结合律。在C++中,大多数比较操作符(如
<
,>
,==
,!=
等)和逻辑操作符(如&&
,||
等)都是既不左结合也不右结合的。例如,逻辑与操作符(
&&
)是既不左结合也不右结合的:bool a = true; bool b = false; bool c = a && b; // 错误:缺少括号,意图是 (a && b) bool c = (a && b); // 正确
注意事项:
- 在编写代码时,为了提高可读性和避免歧义,建议总是使用括号来明确表达你的意图,即使操作符是左结合或右结合的。
- 了解操作符的结合性对于正确理解复杂表达式的行为非常重要。
- 在C++中,
=
是右结合的,这意味着连续赋值是从右向左进行的。
-
通常来说,操作数与结果均为算数类型的右值;但加减法与一元 + 的操作数可接收指针
#include <iostream> int main() { int x[3] = {1, 2, 3}; int* ptr = x; ptr = ptr + 1; const auto& a = +x; //仅用于获取指针当前指向的值,+x == x[0] }
-
一元 + 操作符会产生 integral promotion(整型提升)
-
整数相除会产生整数,向 0 取整
4 / 3 == 1 -4 / 3 == -1
-
求余只能接收整数类型操作数,结果符号与第一个操作数相同
求余满足
( m / n ) ∗ n + m % n = = m (m / n) * n + m \% n == m (m/n)∗n+m%n==m
2.逻辑与关系操作符
-
优先级划分,可查看C++ Operator Precedence
-
!(逻辑非,优先级3)
-
<=>(关系操作符-三向比较符,优先级8,C++20引入)
-
<、<=、>、>=(关系操作符,优先级9)
-
==、!=(关系操作符,优先级10)
-
&&(逻辑与,优先级14)
C++中的逻辑与运算符具有短路(short-circuit)特性,即如果第一个操作数为假,那么整个表达式的结果已经确定为假,编译器将不会对第二个操作数求值,因为无论第二个操作数的值是什么,都不影响最终结果。
与位与运算符的区别:
逻辑与运算符
&&
与位与运算符&
不同。位与运算符&
是按位操作,对整数类型的两个操作数的每一位进行与操作,而逻辑与运算符&&
是布尔操作,只用于布尔值的逻辑运算。int x = 0b1100; // 二进制 12 int y = 0b1010; // 二进制 10 int bitAnd = x & y; // 按位与,结果为 0b1000 (8) bool logicAnd = x && y; // 逻辑与,结果为 true
-
||(逻辑或,优先级15)
短路特性:
C++中的逻辑或运算符
||
具有短路(short-circuit)特性,即如果第一个操作数为真,那么整个表达式的结果已经确定为真,编译器将不会对第二个操作数求值,因为无论第二个操作数的值是什么,都不影响最终结果。bool a = true; bool b = false; if (a || someCondition()) { // 这段代码会执行,因为 a 为 true,所以不会调用 someCondition() }
在上面的例子中,由于
a
为真,someCondition()
将不会被调用,因为整个if
条件的结果已经确定为真。与位或运算符的区别:
逻辑或运算符
||
与位或运算符|
不同。位或运算符|
是按位操作,对整数类型的两个操作数的每一位进行或操作,而逻辑或运算符||
是布尔操作,只用于布尔值的逻辑运算。int x = 0b1100; // 二进制 12 int y = 0b1010; // 二进制 10 int bitOr = x | y; // 按位或,结果为 0b1110 (14) bool logicOr = x || y; // 逻辑或,结果为 true,因为至少有一个操作数为非零值
-
-
关系操作符接收算术或指针类型操作数;逻辑操作符接收可转换为
bool
值的操作数 -
操作数与结果均为右值(通常结果类型为
bool
,除了<=>操作符 ) -
除逻辑非外,其它操作符都是左结合的
-
逻辑与、逻辑或具有短路特性
-
逻辑与的优先级高于逻辑或
-
通常来说,不能将多个关系操作符串连
#include <iostream> int main() { int a = 3; int b = 4; int c = 5; std::cout << (c>b>a) << std::endl; //这里实际结果为0,c>b为1, 1>3为0;但我们实际想要的是(c>b)&&(b>a) }
-
不要写出
val == true
这样的代码#include <iostream> int main() { int a = 3; if(a) //不要写成if(a==true),该表达式隐式转化为if(a == 1),不是我们想要的 { } }
-
Spaceship operator: <=>(C++20引入)
当比较的两个对象比较复杂时,使用该操作符
这个运算符的结果是一个
std::strong_ordering
、std::weak_ordering
或std::partial_ordering
类型的对象,分别对应于两个操作数之间的严格弱序、弱序或部分序关系。语法:
auto result = lhs <=> rhs;
3.位操作符
-
优先级划分,可查看C++ Operator Precedence
-
~(按位取反,优先级3)
在C++中,当你对一个整数按位取反(使用
~
运算符)后,得到的结果是一个补码表示的整数。在大多数现代计算机系统中,整数是以补码形式存储的#include <iostream> int main() { //二进制表示为00000011 signed char x = 3; //按位取反结果为:11111100,得到的是一个补码表示的整数, //其对应原码表示的整数为:10000100 == -4 std::cout<< ~x << std::endl; //-4 }
原码、反码、补码的关系:
- 正数的反码、补码与原码相同
- 负数的反码是其绝对值原码按位取反,负数的补码为其反码的末尾加1
转换补码到原码:
- 确定位数:确定补码表示的位数(例如,32位或64位)
- 识别符号:最左边的一位是符号位,0表示正数,1表示负数
- 取反并加1:对于负数,将除了符号位之外的所有位取反,然后加1
-
<< >> (移位操作,优先级7)
左移操作符
<<
将操作数的二进制表示向左移动指定的位数。移入的新位(最左边的位)通常是0。且无论时有符号整数还是无符号整数,最高位(有可能是符号位)也正常移动,因此,左移不能保证符号#include <iostream> int main() { unsigned int y = 0x80000001;(正数) std::cout << (y<<1) <<std::endl; //2 signed int y1 = 0x80000001;(负数) std::cout << (y1<<1) <<std::endl; //2 }
右移操作符
>>
将操作数的二进制表示向右移动指定的位数。移入的新位(最右边的位)取决于编译器的实现,但通常遵循以下规则:- 对于无符号整数,移入的新位是0。
- 对于有符号整数,移入的新位与原来的最左边的位(符号位)相同。
#include <iostream> int main() { signed int x = -4; //-4的补码为11111100(内存中存储形式) //移码后为11111000 -->其对应原码为-8 std::cout << (x<<1) << std::endl; //移码后为11111110 --> 其对应原码为-2 std::cout << (x>>1) <<std::endl; }
-
&(按位与,优先级11)
-
^(按位异或,优先级12)
在C++中,
^
是按位异或运算符(bitwise XOR operator)。按位异或运算符对两个操作数的对应位进行逐位比较,并根据以下规则进行运算:- 如果对应的两位相同,则结果为0。
- 如果对应的两位不同,则结果为1。
基本用法:
int a = 0b1100; // 二进制表示的12 int b = 0b1010; // 二进制表示的10 int result = a ^ b; // 按位异或,结果为 0b0110 (6)
在这个例子中,
a
和b
的二进制表示分别为1100
和1010
,进行按位异或操作后得到0110
,这是二进制表示的6
。 -
|(按位或,优先级13)
-
-
接收右值,进行位运算,返回右值
-
除取反外,其它运算符均为左结合的
-
注意:计算过程中可能会涉及到 integral promotion
为什么会涉及到 integral promotion呢?
因为
int
是对应到硬件系统中最经常使用的数据类型,将char
提升到int
在操作时,可能只需要一个指令就能完成位操作运算。 -
注意这里没有短路逻辑,不同于逻辑与于逻辑或
-
移位操作在一定情况下等价于乘(除) 2 的幂,但速度更快
-
注意整数的符号与位操作符的相关影响
-
integral promotion 会根据整数的符号影响其结果
#include <iostream> int main() { unsigned char x = 0xff; //11111111 //提升:0000...0000 1111 1111 //取反:1111...1111 0000 0000(补码表示) //其对应原码为:1000...0001 0000 0000 等于-256 auto y = ~x; std::cout << y << std::endl; //-2^8 == -256 signed char x1 = 0xff; //11111111,第一位为符号位 //提升:1111...1111 1111 1111 //补码:0000...0000 0000 0000 //其原码为0000...0000 0000 0000 等于0 auto y1 = ~x1; std::cout << y1 << std::endl; //0 }
-
右移保持符号,但左移不能保证
-
4.赋值操作符
-
优先级划分,可查看C++ Operator Precedence
-
=(优先级16)
int x = 5; //这里的=为初始化操作符 x = 6; //这里的=为赋值操作符
-
-
赋值操作符的左操作数为可修改左值;右操作数为右值,可以转换为左操作数的类型
-
赋值操作符是右结合的,求值结果为左操作数
x = y = 3; //等价于(y=3) == y; x = y; (x = 5) = 2;//等价于(x=5) == x; x = 2;
-
可以引入大括号(初始化列表)以防止收缩转换( narrowing conversion )
收缩转换:大的类型转换成小的类型。引入大括号后,如果发生收缩转换,编译器会报错
-
小心区分 = 与 ==
-
复合赋值运算符(右结合)
- +=、-=、*=、/=、%=、<<=、>>=、&=、^=、|=(优先级均为16,与=一样)
#include <iostream> int main() { int x = 2; int y = 3; x^=y^=x^=y; //x与y交换 }
5.自增与自减运算符
-
优先级划分,可查看C++ Operator Precedence
-
a++、a–(后缀,从左到右优先级为2)
-
++a、–a(前缀,优先级3)
++a 等价于 a = a + 1; --a 等价于 a = a - 1;
-
-
分前缀与后缀两种情况
-
操作数为左值;前缀时返回左值(返回更新后的值);后缀时返回右值(返回更新前的值,理论会用到临时变量,编译器有可能会进行优化)
-
建议使用前缀形式
因为后缀形式会用到临时变量,且还会有拷贝成本。
6.其他操作符
-
成员访问操作符:
.
与->
->
等价于(*).
.
的左操作数是左值(或右值),返回左值(或右值xvalue
)->
的左操作数指针,返回左值
-
条件操作符
-
唯一的三元操作符
三元操作符的基本语法如下:
condition ? expr_true : expr_false
这里,
condition
是一个布尔表达式,expr_true
是当条件为真(true
)时返回的表达式,而expr_false
是当条件为假(false
)时返回的表达式。 -
接收一个可转换为
bool
的表达式与两个类型相同的表达式,只有一个表达式会被求值 -
如果表达式均是左值,那么就返回左值,否则返回右值
-
右结合
#include <iostream> int main() { int score = 100; //右结合,先算(score == 0) ? 0 : -1;再算(score > 0) ? 1 : 结果 int res = (score > 0) ? 1 : (score == 0) ? 0 : -1; std::cout << res << std::endl; }
-
-
逗号操作符
- 确保操作数会被从左向右求值
- 求值结果为右操作数
- 左结合
-
sizeof
操作符- 操作数可以是一个类型或一个表达式
- 并不会实际求值,而是返回相应的尺寸(产生类型的对象表示的字节数)
-
其它操作符
- 域解析操作符
::
- 函数调用操作符
()
- 索引操作符
[]
- 抛出异常操作符
throw
- 域解析操作符
三、C++17对表达式的求值顺序的限定
以下表达式在 C++17 中,可以确保 e1
会先于 e2
被求值
e1[e2]
e1.e2
e1.*e2
e1→*e2
e1<<e2
e1>>e2
e2 = e1 / e2 += e1 / e2 *= e1…
(赋值及赋值相关的复合运算,等号右边的先求值)
new Type(e)
会确保 e 会在分配内存之后求值。