• C++自增/减运算符的原理以及前后缀形式的本质区别


    C++自增/减运算符的原理以及其前后缀形式的本质区别

    先上结论

    1. 后缀自增运算符的实质是,将运算对象进行拷贝(Copy)生成拷贝对象,然后对原操作对象进行增1操作,最后返回拷贝对象。因此,后缀自增返回的是右值。其原理可以通过如下函数模拟:

      const Type function_postfix_increment(Type& obj){
      	Type copyObj = obj;
      	obj += 1; // 假定Type支持+=整数的操作
      	return copyObj;
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
    2. 前缀自增运算符则直接对运算对象进行增1,然后直接返回,因而没有拷贝操作。因此,前缀自增返回的是左值。其原理可以通过如下函数模拟:

      Type& function_prefix_increment(Type& obj){
      	obj += 1; // 假定Type支持+=整数的操作
      	return obj;
      }
      
      • 1
      • 2
      • 3
      • 4
    3. 前/后缀自减运算符原理类似;

    4. 正因如此,一般能用前缀自增就不要用后缀自增,因为对于复杂的对象而言,后缀自增运算符的拷贝开销不能忽视;

    5. 值得一提的是,后缀自增/减的优先级比前缀自增/减的高。

    有价值的内容到此为止,后面就全是水了


    本文的知识来自于《C++ Primer (5th Edition)》,严格来说没有啥原创性的见解,然而笔者看到网上的很多文章,它们对于后缀自增运算符的解释多数如下:“先计算表达式,然后对运算符作用的对象进行自增 ”,这样的理解似乎没引发啥问题。(你可能会想,既然你这么说,难不成这种理解是有问题的?质疑.jpg)

    别急,我们先看一个例子,请告诉我下面程序的输出结果:

    int x = 3;
    std::cout << x++ * x++;
    
    • 1
    • 2

    有结果了吗,是9?似乎常见的编译器输出都是9。
    “我就说嘛,x为3,先按照3计算乘法,输出3*3的值,然后x自增至5。”

    那下面这个例子呢?

    int x = 3;
    x = x++;
    std::cout << x;
    
    • 1
    • 2
    • 3

    输出3?输出4?和你预期的一样吗?不过在我的机器上,这两种结果都存在,比如DevCpp编译器(C++11)下输出4,而在VS2017的编译器(C++17)下输出3。实际上,以上呈现的两种示例中的运算都是未定义行为(undefined behavior)。(不确定的话,在编译指令后面加个-Wall,就可以检测未定义行为了)

    不过按照逻辑,上面的这些未定义行为并不能说明以上对于后缀自增运算符的解释是错的(也不能说明是对的) —— 因为毕竟是未定义行为,由编译器而不是C++标准来决定行为方式,它的结果是否符合预期,都不能就自增运算符的功能给出断言。

    说实话,在C++标准定义的行为中,我也暂时没有找到不符合以上解释的例子。不过,如果后缀自增运算符真是按照“先完成表达式的计算,然后自增1”的方式作用,那不妨质问自己:大家都是运算符,凭什么后缀自增运算符这么“独特” ——除了作用于操作数,还知道什么时候表达式执行完?难不成它有这样一种魔力,即不需要任何额外的开销,就能知道表达式计算完成的时间,然后恰好在其后给自己的值加1? 如果没有额外开销,那就真成了不可思议的事了;但如果有额外开销(而且估计开销不小),那这个运算符的意义何在呢?

    实际上,后缀自增运算符自己非常清楚其中的奥秘,那就是:运算符本身可以看作关于运算对象(operand)的函数,至于函数的功能,具体来说,即后缀自增运算符对于运算对象首先进行了Copy操作,然后将原对象自增1,最后返回拷贝对象,以上过程写成重载运算符函数的形式如下:

    const Type function_postfix_increment(Type& obj){
    	Type copyObj = obj;
    	obj += 1; // 假定Type支持+=整数的操作
    	return copyObj;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这其实也解释了为什么后缀自增/减运算符的返回结果是右值——因为返回的是一个拷贝对象,其生命周期限于该函数之内,如果返回的是引用(左值),则将会在退出函数后引用一个已经被释放的对象,显然是不允许的。这里返回值用const修饰,则是为了与原定义保持一致,毕竟对于一个对象x,运算x++++是非法的(因为x++返回一个右值,对右值进行写操作是错误的)。

    这也顺便引出了前缀自增/减运算符的原理,即直接对运算对象进行自增1,最后返回原对象,以上过程写成重载运算符函数的形式如下:

    Type& function_prefix_increment(Type& obj){
    	obj += 1; // 假定Type支持+=整数的操作
    	return obj;
    }
    
    • 1
    • 2
    • 3
    • 4

    可见,前缀自增/减运算符的返回值是一个左值

    两者的区别还体现在性能上,由于后缀自增运算符存在拷贝过程,对于比较复杂的类型,其开销就不能忽视,甚至可能非常大。不过一般情况下不加以区别(编译器对之有优化)。

    至此,自增/减运算符的作用原理和区别就讨论完了。

    最后,再给一些优先级和结合性方面的小结论:后缀自增/减运算符比前缀的优先级高;自增/减运算符都是从右往左结合。

    最后的最后,给一个重载前/后缀自增/减运算符的例子,从中可以窥见优先级以及结合方式。

    #include
    using namespace std;
    
    
    struct Nint{
    	int x;
    	Nint(int _x): x(_x){
    	}
    	// 前缀 
    	const Nint operator++(int){ 
    		cout << "postfix ++ on Address " << this << " with value " << x << endl; 
    		Nint ret(x);
    		x+=1;
    		return ret;
    	}
    	const Nint operator--(int){ 
    		cout << "postfix -- on Address " << this << " with value " << x << endl; 
    		Nint ret(x);
    		x-=1;
    		return ret;
    	}
    	// 前缀 
    	Nint& operator++(){
    		cout << "prefix ++ on Address " << this << " with value " << x << endl; 
    		x+=1;
    		return *this;
    	}
    	Nint& operator--(){
    		cout << "prefix -- on Address " << this << " with value " << x << endl; 
    		x-=1;
    		return *this;
    	}
    	void print(){
    		cout << x << endl;
    	}
    };
    
    int main(){
    	Nint x(7);
    	// x++++; // erroneous: (x++)返回const Nint对象,常量对象不能调用非const成员函数 
    	// ++++x++; // erroneous: 后缀自增比前缀自增优先级高 
    	// &x++; // erroneous: taking address of temporary 
    	(++++x)++; // ok
    	cout << "Address of x is " << &x << endl;
    	x.print();	
    	cout << "=========Base Type=========" << endl;
    	int a = 7;
    	// a++++; // erroneous: lvalue required as increment operand 
    	// ++++a++; // same as above
    	// &a++; // erroneous: lvalue required as unary '&' operand
    	cout << "Address of a is " << &a << endl;
    	(++++a)++; // ok
    	cout << a << endl;
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    输出:

    prefix ++ on Address 0x6bfeec with value 7
    prefix ++ on Address 0x6bfeec with value 8
    postfix ++ on Address 0x6bfeec with value 9
    Address of x is 0x6bfeec
    10
    =========Base Type=========
    Address of a is 0x6bfee8
    10
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    从以上示例中可知,我们的重载运算符函数还是与原定义有所区别的(报错的原因不一样)。另外,读者可以尝试去掉后缀自增运算符重载函数返回值的const,看看会有什么变化。

    笔者水平有限,文中若有不当描述,恳请批评指正~

  • 相关阅读:
    从零开始Blazor Server(9)--修改Layout
    D. Xenia and Colorful Gems(二分+暴力)
    kotlin入门学习文档
    数据仓库性能测试方法论与工具集
    14 合并区间
    美国服务器能不能部署个人站或论坛站?
    RPA厂商有哪些?国内头部5家RPA厂商的分析对比
    二叉树的循环问题
    Redis缓存序列化配置
    一幅长文细学GaussDB(一)——一幅长文系列
  • 原文地址:https://blog.csdn.net/weixin_42430021/article/details/126858707