最近在用 for 循环的时候,发现了自增运算符几种不同的写法,有点好奇,就想复习回顾下。

1 自增/减运算符

1.1 初始印象

首先捋一下运算符在前后的差别,脑子中第一印象是这个

  • ++i,先加,再用
  • i++,先用,再加

1.2 深入剖析

除此之外,还有什么区别呢?我尝试在汇编代码中找一下区别,看下机器语言的执行过程

1.2.1 i++ 汇编实现

i++ 的测试代码如下

int fortest()
{
    int i = 0;
    return i++;
}
fortest():
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0    ; 初始化int变量
        mov     eax, DWORD PTR [rbp-4]  ; 先使用变量i的值,拷贝一次
        lea     edx, [rax+1]            ; 原变量值做一次自增操作
        mov     DWORD PTR [rbp-4], edx	; 将自增后的edx再拷贝给初始int变量
        pop     rbp
        ret

参考 ChatGPT 回答如下:

  1. push rbp: 将当前函数的基址指针(Base Pointer,BP)压入栈中,为当前函数的栈帧(Stack Frame)做准备。
  2. mov rbp, rsp: 将栈指针(Stack Pointer,SP)的值赋给基址指针,建立当前函数的栈帧。
  3. mov DWORD PTR [rbp-4], 0: 将值0存储到基址指针减4的内存位置,即在栈帧中分配一个4字节的空间,并将其初始化为0。
  4. mov eax, DWORD PTR [rbp-4]: 将栈帧中存储的值加载到寄存器eax中。
  5. lea edx, [rax+1]: 将寄存器eax中的值加1,并将结果存储到寄存器edx中。这里使用的是地址计算指令(LEA,Load Effective Address),实际上不会执行真正的加法操作,而是将地址计算的结果存储到目标寄存器中。
  6. mov DWORD PTR [rbp-4], edx: 将寄存器edx中的值存储到栈帧中原先存储值0的位置,相当于将原先的值加1。
  7. pop rbp: 函数结束时,恢复调用函数前的基址指针。
  8. ret: 返回,结束当前函数的执行。

1.2.2 ++i 汇编实现

同样的作为对比,看一下 ++i 的汇编代码

int fortestV2()
{
    int i = 0;
    return ++i;
}

对应汇编语言代码如下

fortestV2():
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0    ; 初始化
        add     DWORD PTR [rbp-4], 1    ; 变量自增
        mov     eax, DWORD PTR [rbp-4]  ; 返回自增后的临时变量
        pop     rbp
        ret

1.3 小结

在此之中发现了一点猫腻,可以看到后缀 i++ 运算符,多了一次拷贝操作。从机器语言的执行过程来看,多了一步操作,就会多一点耗时。

另外,与其他朋友交流的过程中,提到了现代编译器是否会对此代码做优化,暂未有结论,咨询 ChatGPT 结果看起来也是通用的,目前还没具体了解到会对类似代码做优化,记录一下,后续如果学习到了,再作补充完善。

附上 ChatGPT 回答:
现代编译器通常会进行优化,以尽可能地消除 i++++i 的性能差异。这种优化可能包括:

  1. 寄存器分配: 编译器可能会将循环中的计数器 i 分配到一个寄存器中,这样无论是前置递增还是后置递增,都可以在寄存器中执行,而不必每次都访问内存。
  2. 循环展开: 编译器可能会将循环展开为一系列简单的操作,以减少递增操作的次数。在这种情况下,i++++i 的性能差异可能会被进一步减小。
  3. 代码重排: 编译器可能会重新排列代码以利用处理器的流水线和缓存。这可能会导致 i++++i 的性能差异变得不那么明显。

总的来说,现代编译器会尽量优化代码,以提高性能并减少不必要的性能差异。因此,开发者通常不需要太过担心 i++++i 的性能问题,除非在非常性能敏感的场景下。