i++与++i

  自增运算符,是常用的单目运算符之一。但是前置++和后置++,经常让初学者感到困惑,当学习了类的运算符重载之后,可以自己重载实现自增运算符。那么,就可以从实现机制上理解两者的区别。
  题外话:今天,在阅读老师的代码时,我发现老师的for循环中的自增都使用了++i而不是i++,我便产生了疑惑。按道理,两者在for循环中的功能完全相同,为什么老师会使用前置的写法呢?虽然我看过很多人的代码都优先使用前置++,即便是与后置++并无逻辑上的区别的时候也会优先使用++i,这是为什么呢?思来想去,发觉应该是效率有所不同,具体原因见下文。

两者的区别

  对于变量i来说,如果使用前置++,写法为++i,可以简单记忆为“表达式先对i进行加一操作,再对外表示i的值”;如果使用后置++,写法为i++,可以简单记忆为“表达式整体先外表示为i的值,再对i进行加一操作”。这点可以用简单的程序显示两者的区别:

//i++与++i
#include<iostream>
using namespace std;
int main(){
    int i=5,j=5;
    cout<<"i   : "<<i<<endl;
    cout<<"i++ : "<<i++<<endl;
    cout<<"After i++, i : "<<i<<endl;
    cout<<"j   : "<<j<<endl;
    cout<<"++j : "<<++j<<endl;
    cout<<"After ++j, j : "<<j<<endl;
    return 0;
}

  运行结果如下:

i   : 5
i++ : 5
After i++, i : 6
j   : 5
++j : 6
After ++j, j : 6

实现机制

  从实现机制的角度说,++i实际上是直接对i自身进行自增;i++实际上是先保存了i的原始值,供外部使用,然后对i做自增处理,因此对外显示的值为原来i的值,但表达式执行过后i的值增加了1。下面我们用C++类进行演示,为类重载前置++和后置++运算符,模拟此机制的实现。

模拟实现机制

  创建一个名为Test的类,除了一些必要的函数定义外,给出自增运算符的重载实现并演示两者的区别:

//i++与++i
#include<iostream>
using namespace std;
class Test{
private:
    int num_;
public:
    Test(int n=0):num_(n){}
    Test(const Test& t):num_(t.num_){}
    Test& operator++();                 //前置++,最终返回的还是自身所以使用引用,速度更快
    Test operator++(int);               //后置++,后置单目运算符需要一个int形参与前置形成区分,
                                        //返回的是一个临时保存的未增加的类对象,所以返回值为类对象
    //重载输出
    friend ostream& operator<<(ostream& os,const Test& t);
};
Test& Test::operator++(){               //前置++
    num_+=1;                            //对自身+1并返回自身
    return *this;
}
Test Test::operator++(int){
    Test old(*this);                    //先用一个对象保存未增加的数值
    num_+=1;                            //对自身增加
    return old;                         //返回未增加的值
}
ostream& operator<<(ostream& os,const Test& t){
    os<<t.num_;
    return os;
}
int main(){
    Test t1(5);
    Test t2(5);
    cout<<"t1   : "<<t1<<endl;
    cout<<"t1++ : "<<t1++<<endl;
    cout<<"After t1++, t1 : "<<t1<<endl;
    cout<<"t2   : "<<t2<<endl;
    cout<<"++t2 : "<<++t2<<endl;
    cout<<"After ++t2, t2 : "<<t2<<endl;
    return 0;
}

  运行结果如下:

t1   : 5
t1++ : 5
After t1++, t1 : 6
t2   : 5
++t2 : 6
After ++t2, t2 : 6

小结

  从上面的机制实现可以发现,后置的写法i++应该会使用临时变量保存i未增加时的旧值,这个过程会有一定的开销,所以建议如果前置和后置的自增运算符在逻辑上没有区别的时候优先使用++i,以避免不必要的开销。同样的,单目自减运算符的机制同理。

再谈i++和++i

  如果只是从实现机制上来考虑前置和后置运算符,以上的分析似乎很有道理,但是实际使用时可能并非如此,因为编译器会对源代码进行优化,那么不妨看一看单独使用的前置和后置运算符的代码在编译成汇编的时候是否有实质性的区别。

测试代码

  分别在for循环的循环变量和for循环的循环体中使用前置和后置自增运算符,即以下两个源文件:
  源文件test1.cpp:

int main(){
    int i=0,j=0;
    for(i=0;i<5;i++)
        j++;                //使用i++和j++
    return 0;
}

  源文件test2.cpp:

int main(){
    int i=0,j=0;
    for(i=0;i<5;++i)
        ++j;                //使用++i和++j
    return 0;
}

生成汇编代码

  分别使用以下命令得到相应的.ii文件:

g++ -E test1.cpp -o test1.ii
g++ -E test2.cpp -o test2.ii

  再由以下命令得到不同优化方式的汇编文件:

# 不优化
g++ -S test1.ii -O0 -o test1_withO0.s
g++ -S test2.ii -O0 -o test2_withO0.s
# O1优化
g++ -S test1.ii -O1 -o test1_withO1.s
g++ -S test2.ii -O1 -o test2_withO1.s
# O2优化
g++ -S test1.ii -O2 -o test1_withO2.s
g++ -S test2.ii -O2 -o test2_withO2.s

  每个文件如下所示:
  test1_withO0.s

    .file   "test1.cpp"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    endbr64
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $0, -8(%rbp)
    movl    $0, -4(%rbp)
    movl    $0, -8(%rbp)
.L3:
    cmpl    $4, -8(%rbp)
    jg  .L2
    addl    $1, -4(%rbp)
    addl    $1, -8(%rbp)
    jmp .L3
.L2:
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
    .section    .note.GNU-stack,"",@progbits
    .section    .note.gnu.property,"a"
    .align 8
    .long    1f - 0f
    .long    4f - 1f
    .long    5
0:
    .string  "GNU"
1:
    .align 8
    .long    0xc0000002
    .long    3f - 2f
2:
    .long    0x3
3:
    .align 8
4:

  test1_withO1.s

    .file   "test1.cpp"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    endbr64
    movl    $0, %eax
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
    .section    .note.GNU-stack,"",@progbits
    .section    .note.gnu.property,"a"
    .align 8
    .long    1f - 0f
    .long    4f - 1f
    .long    5
0:
    .string  "GNU"
1:
    .align 8
    .long    0xc0000002
    .long    3f - 2f
2:
    .long    0x3
3:
    .align 8
4:

  test1_withO1.s

    .file   "test1.cpp"
    .text
    .section    .text.startup,"ax",@progbits
    .p2align 4
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    endbr64
    xorl    %eax, %eax
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
    .section    .note.GNU-stack,"",@progbits
    .section    .note.gnu.property,"a"
    .align 8
    .long    1f - 0f
    .long    4f - 1f
    .long    5
0:
    .string  "GNU"
1:
    .align 8
    .long    0xc0000002
    .long    3f - 2f
2:
    .long    0x3
3:
    .align 8
4:

比较分析

  其中,不同优化下的汇编代码大有不同,但是两个cpp文件不论何种优化方式最后得到的汇编代码,除了标识符中的.file字段的文件名不同以外,其他完全相同。这里仅展示其中一个文件,可以使用VS Code等编辑器的文件比较功能进行比对。
  test2_withO0.s

    .file   "test2.cpp"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    endbr64
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $0, -8(%rbp)
    movl    $0, -4(%rbp)
    movl    $0, -8(%rbp)
.L3:
    cmpl    $4, -8(%rbp)
    jg  .L2
    addl    $1, -4(%rbp)
    addl    $1, -8(%rbp)
    jmp .L3
.L2:
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
    .section    .note.GNU-stack,"",@progbits
    .section    .note.gnu.property,"a"
    .align 8
    .long    1f - 0f
    .long    4f - 1f
    .long    5
0:
    .string  "GNU"
1:
    .align 8
    .long    0xc0000002
    .long    3f - 2f
2:
    .long    0x3
3:
    .align 8
4:

结论

  综上所述,对基本类型单独使用前置和后置自增运算符的时候,最终得到的机器代码并无不同,所以两者此时可以认位时完全一样,可以任意使用。


当珍惜每一片时光~