算术表达式优化与副作用解析
1. 整数类型选择与强度削减风险
在编程中,当不需要处理负数时,应尽量使用无符号整数而非有符号整数。例如在进行有符号数除以4的操作时,若使用
sarl
操作,当数值为负时需要给其值加3;进行有符号取模操作时,若初始值为负,需先给其值加31。以下是相关代码示例:
leave
ret
.p2align 2,,3
; For signed division by four,
; using a sarl operation, we need
; to add 3 to i's value if i was
; negative.
.L5:
leal 3(%ecx), %eax
jmp .L3
.p2align 2,,3
; For signed % operation, we need to
; first add 31 to i's value if it was
; negative to begin with:
.L4:
leal 31(%eax), %eax
jmp .L2
手动进行强度削减是有风险的。虽然在大多数CPU上,某些操作(如除法)几乎总是比其他操作(如右移)慢,但许多强度削减优化在不同CPU之间不具有可移植性。例如,用左移操作替代乘法在不同CPU上编译时,并不总是能产生更快的代码。一些较旧的C程序中手动添加的强度削减,如今可能会导致程序运行变慢。所以,应让编译器来处理强度削减优化。
2. 归纳优化
在许多表达式中,尤其是循环中的表达式,一个变量的值可能完全依赖于另一个变量。以下是Pascal和C语言的示例:
-
Pascal示例
for i := 0 to 15 do begin
j := i * 2;
vector[ j ] := j;
vector[ j + 1 ] := j + 0.5;
end;
编译器优化器可能会识别出
j
完全依赖于
i
,并将代码重写为:
ij := 0; {ij is the combination of i and j from the previous code}
while( ij < 30 ) do
vector[ ij ] := j;
vector[ ij + 1 ] := ij + 0.5;
ij := ij + 2;
end;
这样的优化节省了循环中的计算量(具体是
j := i * 2
的计算)。
-
C语言示例
extern unsigned vector[16];
extern void someFunc( unsigned v[] );
extern void preventOptimization( int arg1, ... );
int main( void )
{
unsigned i, j;
someFunc( vector );
for( i = 0; i < 16; ++i )
{
j = i * 2;
vector[ j ] = j;
vector[ j + 1 ] = j + 1;
}
preventOptimization( vector[0], vector[15] );
return( 0 );
}
Microsoft的Visual C++编译器生成的MASM(80x86)代码如下:
_main PROC NEAR ; COMDAT
; File t.c
; Line 19 -- call someFunc to initialize vector:
push OFFSET FLAT:_vector
call _someFunc
add esp, 4
;ECX is roughly "j".
;EAX points at the current vector element.
xor ecx, ecx
mov eax, OFFSET FLAT:_vector+4
;This is what the for loop translates to:
$L403:
; Line 23
;vector[j] = j;
mov DWORD PTR [eax-4], ecx
; Line 24
;vector[ j + 1 ] = j + 1;
lea edx, DWORD PTR [ecx+1]
mov DWORD PTR [eax], edx
;Advance EAX (pointer to vector
; element) past two elements:
add eax, 8
;Bump up j by two:
add ecx, 2
;Repeat for each element of the array
; that we process:
cmp eax, OFFSET FLAT:_vector+132
jl SHORT $L403
; Line 26 - call preventOptimization:
mov eax, DWORD PTR _vector+60
mov ecx, DWORD PTR _vector
push eax
push ecx
call _preventOptimization
add esp, 8
; Line 27
xor eax, eax
; Line 28
ret 0
_main ENDP
从MASM输出可以看出,编译器识别出
i
在循环中未被使用,将其完全优化掉,并且没有进行
j = i * 2
的计算。编译器使用归纳法确定
j
每次迭代增加2,并直接发出相应代码。此外,编译器通过指针遍历数组,而不是对数组进行索引,从而产生了更快更短的代码序列。虽然可以手动进行归纳优化,但结果通常更难阅读和理解,只有在编译器优化器无法生成良好机器代码时才考虑手动优化。
3. 循环不变式优化
循环不变式是指在循环的每次迭代中都不改变的表达式。以下是Basic和C语言的示例:
-
Basic示例
i = 5;
for j = 1 to 10
k = i * 2
next j
在这个循环中,
k
的值在循环执行期间不会改变。可以将
k = i * 2
的计算移到循环之前:
i = 5;
k = i * 2
for j = 1 to 10
next j
这样,
k
的值与之前的示例相同,但只计算了一次。
-
C语言示例
extern unsigned someFunc( void );
extern void preventOptimization( unsigned arg1, ... );
int main( void )
{
unsigned i, j, k, m;
k = someFunc();
m = k;
for( i = 0; i < k; ++i )
{
j = k + 2; // Loop-invariant calculation
m += j + i;
}
preventOptimization( m, j, k, i );
return( 0 );
}
Visual C++编译器生成的80x86 MASM代码如下:
_main PROC NEAR ; COMDAT
; File t.c
; Line 5
push ecx
push esi
; Line 8
call _someFunc
; Line 10
xor ecx, ecx ; i = 0
test eax, eax ; see if k == 0
mov edx, eax ; m = k
jbe SHORT $L108
push edi
; Line 12
; Compute j = k + 2, but only execute this
; once (code was moved out of the loop):
lea esi, DWORD PTR [eax+2] ;j = k + 2
; Here's the loop the above code was moved
; out of:
$L99:
; Line 13
;m(edi) = j(esi) + i(ecx)
lea edi, DWORD PTR [esi+ecx]
add edx, edi
; ++i
inc ecx
;While i < k, repeat:
cmp ecx, eax
jb SHORT $L99
pop edi
; Line 15
;
; This is the code after the loop body:
push ecx
push eax
push esi
push edx
call _preventOptimization
add esp, 16 ; 00000010H
; Line 16
xor eax, eax
pop esi
; Line 17
pop ecx
ret 0
$L108:
; Line 10
mov esi, DWORD PTR _j$[esp+8]
; Line 15
push ecx
push eax
push esi
push edx
call _preventOptimization
add esp, 16 ; 00000010H
; Line 16
xor eax, eax
pop esi
; Line 17
pop ecx
ret 0
_main ENDP
从代码注释可以看出,循环不变式表达式
j = k + 2
被移出循环并在循环开始前执行,从而节省了每次迭代的执行时间。与大多数优化不同,应尽量将所有循环不变式计算移出循环,除非有合理的理由将其留在循环中。如果要将不变式代码留在循环中,应注释原因。
4. 不同类型程序员与编译器优化
根据对编译器优化的理解程度,可以将高级语言(HLL)程序员分为三组:
| 程序员类型 | 特点 |
| ---- | ---- |
| 第一组 | 不知道编译器优化如何工作,编写代码时不考虑代码组织对优化器的影响。 |
| 第二组 | 理解编译器优化的工作原理,编写代码时更注重可读性,相信编译器能正确优化代码。 |
| 第三组 | 了解编译器可以进行的优化类型,但不信任编译器,手动将优化融入代码。 |
编译器优化器实际上是为第一组不了解编译器操作的程序员设计的。一个好的编译器通常会为这三组程序员生成大致相同质量的代码(至少在算术表达式方面)。但这仅适用于具有良好优化能力的编译器。如果需要在大量编译器上编译代码,且不能确保所有编译器都有好的优化器,手动优化可能是实现一致良好性能的一种方法。可以通过一些网站(如www.willus.com/ccomp_benchmark.shtml)来比较不同编译器的优化能力。
5. 算术表达式中的副作用
在表达式中,副作用是指代码产生的直接结果之外对程序全局状态的任何修改。C、C++、C#、Java等基于C的语言尤其容易在算术表达式中产生副作用。以下是C和Pascal的示例:
-
C语言示例
i = i + *pi++ + (j = 2) * --k
这个表达式有四个副作用:
- 表达式末尾
k
的递减
- 使用
j
的值之前对
j
的赋值
- 解引用
pi
后
pi
指针的递增
- 对
i
的赋值
-
Pascal示例
var
k:integer;
m:integer;
n:integer;
function hasSideEffect( i:integer; var j:integer ):integer;
begin
k := k + 1;
hasSideEffect := i + j;
j = i;
end;
m := hasSideEffect( 5, n );
调用
hasSideEffect
函数产生两个副作用:
- 全局变量
k
的修改
- 按引用传递的参数
j
(实际参数是
n
)的修改
大多数语言不保证表达式各组成部分的计算顺序。例如,对于表达式
i := f(x) + g(x);
,编译器可能先调用
f
再调用
g
,也可能先调用
g
再调用
f
。如果
f
或
g
产生副作用,这两种调用顺序可能会产生完全不同的结果。虽然大多数语言对表达式副作用的产生顺序未作定义,但有一些常见规则:
- 表达式中的所有副作用会在语句执行完成之前发生。
- 赋值语句左边变量的赋值不会在该变量在表达式右边被使用之前发生。
由于大多数语言中表达式副作用的产生顺序未定义,以下Pascal代码的结果通常是未定义的:
function incN:integer;
begin
incN := n;
n := n + 1;
end;
n := 2;
writeln( incN + n * 2 );
编译器可以先调用
incN
函数(这样在执行子表达式
n * 2
之前
n
将包含3),也可以先计算
n * 2
再调用
incN
函数。因此,不同的编译可能会产生不同的输出。不要试图通过实验来确定计算顺序,因为不同编译器或同一编译器在不同上下文中可能会有不同的计算顺序。如果必须依赖计算顺序,应将计算分解为一系列更简单的语句,以控制计算顺序。例如,若需要程序在
i := f(x) + g(x);
中先调用
f
再调用
g
,可以将代码改写为:
temp1 := f(x);
temp2 := g(x);
i := temp1 + temp2;
综上所述,在编程中应合理利用编译器优化,同时注意表达式中的副作用,以确保代码的正确性和性能。
算术表达式优化与副作用解析
6. 副作用对表达式计算顺序的影响及应对策略
副作用在算术表达式中会给计算顺序带来不确定性。为了更清晰地说明这一点,我们再来看几个不同语言的例子。
- C语言示例
int a = 5, b = 3, c;
c = a++ + --b;
在这个表达式中,存在两个副作用:
a
的后置自增和
b
的前置自减。由于C语言没有明确规定子表达式的计算顺序,编译器可能先计算
a++
,也可能先计算
--b
。这就导致不同编译器或者同一编译器在不同优化级别下,最终
c
的值可能不同。
- Python示例
x = 2
y = (x := x + 1) + x
这里使用了Python 3.8引入的海象运算符
:=
,它在赋值的同时产生了副作用。同样,Python也没有规定子表达式的计算顺序,所以
y
的值可能因为计算顺序的不同而不同。
为了避免副作用带来的不确定性,我们可以采用以下策略:
1.
拆分表达式
:将复杂的表达式拆分成多个简单的语句,明确计算顺序。例如,对于上面的C语言示例,可以改写为:
int a = 5, b = 3, c;
int temp1 = a;
a = a + 1;
b = b - 1;
c = temp1 + b;
- 使用中间变量 :通过引入中间变量,将有副作用的操作单独处理。对于Python示例,可以改写为:
x = 2
x = x + 1
y = x + x
7. 不同语言对副作用的处理差异
不同的编程语言对副作用的处理方式存在差异。下面通过表格来对比几种常见语言:
| 语言 | 对副作用的支持程度 | 计算顺序规定 |
| ---- | ---- | ---- |
| C、C++ | 支持多种产生副作用的操作,如自增自减、指针操作等 | 未明确规定子表达式计算顺序 |
| Java | 支持一定的副作用操作,如对象属性修改 | 未明确规定子表达式计算顺序,但赋值语句有一定规则 |
| Python | 相对较少鼓励副作用,但支持海象运算符等产生副作用的操作 | 未明确规定子表达式计算顺序 |
| Haskell | 纯函数式语言,尽量避免副作用,表达式计算顺序由函数调用和求值策略决定 | 基于函数式编程的求值规则 |
从这个表格可以看出,不同语言在副作用处理上有不同的侧重点。函数式语言(如Haskell)更倾向于避免副作用,以保证代码的可预测性和可维护性;而命令式语言(如C、C++)则提供了更多产生副作用的手段,但也带来了计算顺序的不确定性。
8. 编译器优化与副作用的交互
编译器在进行优化时,副作用可能会干扰优化过程。例如,当编译器遇到有副作用的表达式时,可能无法对其进行一些常规的优化,因为副作用可能会影响程序的正确性。
下面是一个简单的示例:
int func(int *x) {
*x = *x + 1;
return *x * 2;
}
int main() {
int a = 5;
int result = func(&a) + func(&a);
return 0;
}
在这个例子中,
func
函数有副作用,它会修改传入指针所指向的值。编译器在优化
func(&a) + func(&a)
这个表达式时,不能简单地认为两次调用
func
函数的结果是相同的,因为第一次调用会修改
a
的值,从而影响第二次调用的结果。
为了让编译器能够更好地进行优化,我们应该尽量减少副作用的使用。如果无法避免副作用,应该在代码中清晰地标注,让编译器和其他开发者能够理解代码的行为。
9. 总结与建议
通过以上对算术表达式优化和副作用的分析,我们可以总结出以下几点建议:
1.
整数类型选择
:在不需要处理负数时,优先使用无符号整数,减少有符号数操作带来的额外计算。
2.
强度削减
:避免手动进行强度削减,让编译器来完成,除非编译器的优化能力不足。
3.
归纳优化
:在编译器无法生成良好机器代码时,才考虑手动进行归纳优化。
4.
循环不变式
:尽量将循环不变式计算移出循环,提高代码性能。
5.
副作用处理
:减少副作用的使用,明确计算顺序,避免因副作用导致的不确定性。
以下是一个简单的mermaid流程图,总结了优化和处理副作用的流程:
graph TD;
A[编写代码] --> B{是否需要处理负数};
B -- 否 --> C[使用无符号整数];
B -- 是 --> D[使用有符号整数];
D --> E{是否需要手动强度削减};
E -- 否 --> F[让编译器优化];
E -- 是 --> G[手动优化];
F --> H{是否有循环不变式};
H -- 是 --> I[移出循环];
H -- 否 --> J{是否有副作用};
I --> J;
J -- 是 --> K[拆分表达式或使用中间变量];
J -- 否 --> L[完成优化];
K --> L;
按照这些建议进行编程,可以提高代码的性能和可维护性,避免因副作用导致的错误。在实际开发中,我们应该根据具体情况灵活运用这些优化策略,不断提升代码质量。
超级会员免费看
24

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



