算术与逻辑表达式的深入解析
1. 表达式求值顺序与序列点
在编写代码时,如果需要控制表达式内的求值顺序,就需要特别关注序列点。大多数语言保证在程序执行的某些特定点之前完成副作用的计算,这个特定点被称为序列点,语句结束处(分号)就是一个典型的序列点。
在C语言中,除了语句末尾的分号,以下运算符之间也定义了序列点:
| 运算符 | 示例 |
| ---- | ---- |
| 逗号运算符 |
expression1, expression2
|
| 逻辑与运算符 |
expression1 && expression2
|
| 逻辑或运算符 |
expression1 || expression2
|
| 条件表达式运算符 |
expression1 ? expression2 : expression3
|
C语言保证在这些示例中,
expression1
中的所有副作用在计算
expression2
或
expression3
之前完成。对于条件表达式,C语言只会计算
expression2
或
expression3
中的一个,因此在条件表达式的一次执行中,只有一个子表达式的副作用会发生。
下面是一个C语言的示例,展示了序列点对程序操作的影响:
int array[6] = {0, 0, 0, 0, 0, 0};
int i;
// ...
i = 0;
array[i] = i++;
在这个例子中,C语言没有在赋值运算符上定义序列点,所以编译器可以选择在索引数组之前或之后使用
i
的值。最后一条语句在语义上可能等价于以下两种情况之一:
array[0] = i++;
// -or-
array[1] = i++;
为了控制数组的赋值,需要确保表达式的任何部分都不依赖于其他部分的副作用。如果想使用递增前的
i
值作为数组索引,可以这样写代码:
array [i] = i; //<-分号标记序列点
++i;
如果想使用递增后的
i
值作为数组索引,可以这样写:
++i; //<-分号标记序列点
array[ i ] = i - 1;
也可以写成更易读的形式:
j = i;
++i; //<-分号标记序列点
array[ i ] = j;
需要注意的是,序列点并不指定计算何时发生,它只是保证在越过序列点之前完成任何未完成的副作用计算。编译器可以在不产生副作用的情况下,自由地提前或推迟子表达式的计算。
由于语句结束(分号)是大多数语言中的序列点,一种控制副作用计算的方法是将复杂表达式手动分解为类似三地址码的语句序列。例如,对于Pascal中的一个表达式:
{ 在Pascal中结果未定义的语句 }
i := f(x) + g(x);
{ 具有明确定义语义的对应语句 }
temp1 := f(x);
temp2 := g(x);
i := temp1 + temp2;
{ 另一个版本,也具有明确定义但不同的语义 }
temp1 := g(x);
temp2 := f(x);
i := temp2 + temp1;
运算符的优先级和结合性并不控制表达式中计算的发生时间,它们只是控制编译器如何安排计算以产生结果。只要最终计算结果符合优先级和结合性的预期,编译器可以自由地以任何顺序和时间计算子组件。
2. 避免副作用带来的问题
由于副作用对代码的影响往往难以察觉,因此尽量减少程序受副作用问题的影响是个好主意。虽然完全消除副作用不太现实,但可以通过遵循以下简单规则来减少副作用的意外后果:
- 避免在程序流控制语句(如
if
、
while
、
do..until
等)的布尔表达式中放置副作用。
- 如果赋值运算符右侧存在副作用,尝试将副作用移到赋值之前或之后的单独语句中(取决于赋值语句是使用对象应用副作用之前还是之后的值)。
- 避免在同一语句中进行多次赋值,将它们拆分为单独的语句。
- 避免在同一表达式中调用多个可能产生副作用的函数。
- 编写函数时,避免对全局对象进行修改(即副作用)。
- 始终彻底记录副作用。对于函数,应在函数文档中记录副作用,并在每次调用该函数时也记录副作用。
3. 强制特定的求值顺序
运算符的优先级和结合性并不控制编译器何时计算子表达式。例如,对于表达式
X / Y * Z
,编译器可以自由地先计算
Z
,再计算
Y
,最后计算
X
。编译器只需要在计算
X / Y
之前计算
X
和
Y
的值(顺序任意),并在计算
(X / Y) * Z
之前计算
X / Y
的值。
虽然编译器可以自由地以任何顺序计算子表达式,但通常会避免重新排列实际计算的顺序。例如,数学上
X / Y * Z
和
Z * X / Y
是等价的,但在有限精度的计算机算术运算中,它们可能会产生不同的结果。考虑
X = 5
、
Y = 2
、
Z = 3
的情况:
X / Y * Z
= 5 / 2 * 3
= 2 * 3
= 6
Z * X / Y
= 3 * 5 / 2
= 15 / 2
= 7
因此,编译器在代数上重新排列表达式时会很谨慎。
整数算术有其自身的规则,实数代数的规则并不总是适用。同样,浮点算术也会受到舍入、截断、溢出或下溢等问题的影响,因此对浮点表达式应用任意的实数算术变换可能会引入计算误差。
一般来说,如果必须控制表达式的求值顺序和子组件的计算时间,唯一的选择是使用汇编语言。在汇编代码中,可以精确指定软件何时计算表达式的各个组件。对于非常精确的计算,当求值顺序会影响结果时,汇编语言可能是最安全的方法。
4. 短路求值
某些算术和逻辑运算符具有这样的特性:如果表达式的一个组件具有特定的值,那么整个表达式的值就可以自动确定,而无需考虑组成表达式的其余组件的值。一个典型的例子是乘法运算符。如果有表达式
A * B
,并且知道
A
或
B
为零,那么就不需要计算另一个组件,因为结果已经为零。
虽然有一些算术运算可以采用短路求值,但检查短路求值的成本通常比完成计算的成本更高。例如,乘法可以使用短路求值来避免乘以零,但在实际程序中,乘以零的情况很少发生,因此在其他情况下与零比较的成本通常会超过避免乘以零所节省的成本。所以,很少会看到支持算术运算短路求值的语言系统。
5. 短路求值与布尔表达式
布尔表达式是可以从短路求值中受益的一种表达式类型。布尔表达式适合短路求值的原因有三个:
- 布尔表达式只产生两个结果,
True
和
False
,因此很有可能(假设随机分布,有50%的机会)出现短路“触发”值。
- 布尔表达式往往比较复杂。
- 布尔表达式在程序中频繁出现。
因此,许多编译器在处理布尔表达式时会使用短路求值。
考虑以下两个C语句:
A = B && C;
D = E || F;
如果
B
为
False
,则无论
C
的值如何,
A
都为
False
。同样,如果
E
为
True
,则无论
F
的值如何,
D
都为
True
。可以按以下方式计算
A
和
D
的值:
A = B;
if( A )
{
A = C;
}
D = E;
if( !D )
{
D = F;
}
如果
C
和
F
代表复杂的布尔表达式,并且
B
通常为
False
,
E
通常为
True
,那么这段代码序列可能会运行得更快。如果编译器完全支持短路求值,就不需要手动编写这样的代码。
短路求值的相反情况是完全布尔求值。在完全布尔求值中,编译器会生成始终计算布尔表达式每个子组件的代码。一些语言(如C、C++、C# 和 Java)指定使用短路求值,少数语言(如Ada)允许程序员指定使用短路求值还是完全布尔求值,大多数语言(如Pascal)没有定义表达式是使用短路求值还是完全布尔求值,而是由实现者决定。
考虑以下常见的C语句:
if( ptr != NULL && *ptr != '\0' )
{
// 处理ptr指向的字符串中的当前字符
}
如果使用完全布尔求值,这个例子可能会失败。当
ptr
变量包含
NULL
时,使用短路求值,程序不会计算子表达式
*ptr != '\0'
,因为程序知道结果总是
false
,控制会立即转移到
if
语句结束括号
}
之后的第一条语句。但如果使用完全布尔求值,程序会尝试解引用
ptr
,这可能会产生运行时错误。
短路求值和完全布尔求值在副作用方面也有语义差异。如果一个子表达式由于短路求值而不被执行,那么该子表达式不会产生任何副作用。这种行为既非常有用又存在内在危险,许多算法依赖于这个特性来正确运行。
6. 短路求值与完全布尔求值的对比实例分析
为了更清晰地理解短路求值和完全布尔求值的差异,我们可以通过一个具体的示例来进一步说明。假设有以下两个函数:
#include <stdio.h>
int func1() {
printf("func1 is called.\n");
return 0;
}
int func2() {
printf("func2 is called.\n");
return 1;
}
现在,我们使用这两个函数来构建布尔表达式,并分别观察短路求值和完全布尔求值的情况。
短路求值情况
int main() {
if (func1() && func2()) {
printf("Both functions return true.\n");
} else {
printf("At least one function returns false.\n");
}
return 0;
}
在这个例子中,由于
func1()
返回
0
(即
False
),根据短路求值的规则,
func2()
不会被调用。运行这段代码,输出结果为:
func1 is called.
At least one function returns false.
完全布尔求值情况(假设编译器支持)
如果编译器采用完全布尔求值,即使
func1()
返回
False
,
func2()
也会被调用。代码如下:
// 假设的完全布尔求值代码逻辑
int main() {
int result1 = func1();
int result2 = func2();
if (result1 && result2) {
printf("Both functions return true.\n");
} else {
printf("At least one function returns false.\n");
}
return 0;
}
运行这段代码,输出结果为:
func1 is called.
func2 is called.
At least one function returns false.
通过这个示例,我们可以清楚地看到短路求值和完全布尔求值在函数调用和副作用方面的差异。
7. 不同语言对布尔求值的规定总结
不同的编程语言对布尔表达式的求值方式有不同的规定,以下是一些常见语言的总结:
| 语言 | 布尔求值规定 |
| ---- | ---- |
| C、C++、C#、Java | 指定使用短路求值 |
| Ada | 允许程序员指定使用短路求值还是完全布尔求值 |
| Pascal | 未定义表达式是使用短路求值还是完全布尔求值,由实现者决定 |
在编写代码时,了解所使用语言的布尔求值规定非常重要,特别是当代码中包含可能产生副作用的子表达式时。
8. 利用短路求值优化代码
短路求值不仅可以提高代码的执行效率,还可以使代码更加健壮。例如,在检查数组索引是否越界时,可以利用短路求值来避免访问非法内存。以下是一个示例:
#include <stdio.h>
#define ARRAY_SIZE 10
int main() {
int array[ARRAY_SIZE] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int index = 15;
if (index >= 0 && index < ARRAY_SIZE && array[index] > 5) {
printf("The element at index %d is greater than 5.\n", index);
} else {
printf("Invalid index or element is not greater than 5.\n");
}
return 0;
}
在这个例子中,首先检查
index
是否大于等于
0
,如果不满足这个条件,后面的
index < ARRAY_SIZE
和
array[index] > 5
就不会被计算,从而避免了访问非法内存。
9. 总结
通过对算术与逻辑表达式的深入分析,我们了解了表达式求值顺序、序列点、副作用、短路求值等重要概念。这些概念对于编写高效、健壮的代码至关重要。
在实际编程中,我们应该遵循以下原则:
- 合理利用序列点来控制副作用的计算,避免因副作用导致的未定义行为。
- 尽量减少代码中副作用的使用,遵循避免副作用问题的规则。
- 在需要控制求值顺序时,考虑使用汇编语言,但也要权衡其可读性和可维护性。
- 了解所使用语言对布尔表达式求值的规定,充分利用短路求值的特性来优化代码。
通过掌握这些知识,我们可以更好地理解和控制代码的执行过程,提高代码的质量和性能。
下面是一个关于表达式求值相关概念的流程图,帮助大家梳理整个流程:
graph TD;
A[表达式求值] --> B{是否有副作用};
B -- 是 --> C{是否有序列点控制};
C -- 是 --> D[按序列点顺序计算];
C -- 否 --> E[可能产生未定义行为];
B -- 否 --> F{是否需要控制求值顺序};
F -- 是 --> G[考虑使用汇编语言];
F -- 否 --> H[编译器自由安排计算];
I[布尔表达式求值] --> J{是否使用短路求值};
J -- 是 --> K[根据左侧结果决定是否计算右侧];
J -- 否 --> L[计算所有子组件];
这个流程图展示了表达式求值过程中需要考虑的关键因素,以及布尔表达式求值的两种方式。希望通过上半部分和本部分的内容,大家能对算术与逻辑表达式有更深入的理解,并在实际编程中灵活运用这些知识。
超级会员免费看
1471

被折叠的 条评论
为什么被折叠?



