补充知识
我们在第1点的最后提到的要补充的小知识,从这里开始:
(前情说明:这里我本来预料的结果应该是:当我删去函数的返回值类型MyInteger&
中的&
时,编译器不会报任何错误。但是没想到编译器居然报错了,开始的想法是编译既然报错了,不是自己预料中的事情,怕等下展开讲的时候讲不清楚…于是想着这部分的知识拓展要不然就不做了,以免让自己这篇已经写得自我感觉有点成就感的小博客《左移运算符重载》就因为这里的这部分的内容解释不清楚而有些尴尬,从而导致整篇博客就因为这里的一点小插曲而留下一些小污点之类的…但是本着立足事实,深究本质的初衷,我决定还是就事论事,将实际产生的问题展示出来,然后看看能不能查明原因予以解决(就算查不明也能让大家一起来思考这个问题嘛,也说不定自己就真的查明了捏?嘿嘿…做以下这部分内容的展示之前,我的确是这么安慰自己的…所以下面的部分与其说是知识的补充,倒不如说是我对问题产生和尝试解决的记录,我会如实记录我的思考过程。如果大家对这部分内容不感兴趣可以直接跳回原文:,继续顺着知识主线往下看我们的主干知识;如果大家是直接看到了这篇博客,不知道这篇博客的因果关系的,可以回到原文先看一下《左移运算符重载》)
我一开始想着给大家演示:如果我们不是以引用MyInteger&
的方式返回,而是以MyInteger
的方式返回,即运算符操作函数是这样子的:
MyInteger operator++() // 不以MyInteger&的方式返回,而是以MyInteger的方式返回
{
value++;
return *this;
}
那么此时我们根据"拷贝构造函数的调用时机"的知识可以知道,此时return *this
语句执行时,就会调用拷贝构造函数拷贝一份*this
,然后回到测试函数中:
(注意这里"拷贝一份*this
"的意思:编译器在内存在开辟另一块独立于*this
,但与*this
大小、布局相同的新的空间作为拷贝体的内存空间,并且会将*this
中所有的属性(值)拷贝一份放到这个属于拷贝体中,比如此时*this
中的value = 1
,那么就会拷贝一个value = 1
给这个拷贝体,也就是说这个拷贝体的value
也是1
,但是注意:*this
与这个拷贝体的内存空间地址是不一样的,他们是在内存中占用的不是同一块内存空间,这就是拷贝构造函数调用时干的事:把属性都拷贝了,但是创建了一个新的空间来存储这些拷贝)
此时图中第68行代码的++myInt1
就是*this
的拷贝体的变量名:大家可以这样理解,这里红圈部分代码就相当于这段代码:MyInterger ++myInt1 = MyInterger(*this)
(隐式转换下的拷贝构造函数调用,根据*this
拷贝一份完全相同的变量/对象,然后用++myInt1
来命名)
所以这里++myInt1
也是一个MyInteger
类型的变量,当它执行cout << ++myInt1
时应该没有问题才对,因为cout << ++myInt1
执行时是调用我们前面写的<<
运算符操作函数:ostream& operator<<(ostream& cout, MyInteger& myInt)
,很明显我们定义这个函数的时候第二个形参就是MyInteger
类型,此时我们的cout << ++myInt1
调用这个函数的时候,第二个参数传入的也的的确确就是MyInteger
类型,但是编译器就是报错了,就如上图中第68行代码中<<
下方的波浪线所示
其报错的说明是:
对于这个报错,我不诧异,我是非常诧异!!!
明明编译器报错的提示也是说识别到的++myInt1
是一个MyInteger
类型,那不就没问题了吗?你编译器直接拿去调用<<
运算符操作函数不就好了嘛…
我在参考了这篇博客:C++中“非常量引用的初始值必须是左值”的处理方法之后得到了启发,博客中说明了一个问题:
当一个函数的形参是引用时,比如我们案例中的左移运算符重载函数,函数的第二个形参是MyInteger &myInt
,即表明我们在调用这个函数时,给第二个形参传入的参数在这个函数中会被起一个别名:myInt
ostream& operator<<(ostream &cout, MyInteger &myInt)
{
cout << "<<运算符操作函数调用" << endl;
cout << myInt.value;
return cout;
}
则在调用该函数时,不能传递一个常量给这个形参。原因是这样的,我们用上面提到的那篇博客中博主举的一个例子:
如果我们定义一个函数如下,函数的形参是声明为引用的
void fun(int& x)
{
x += 10;
}
当我们调用这个函数时,如果是这样的:
fun(10)
这里我们先不把代码放到编译器中看会出现什么情况,我们先自己来思考会发生什么:
首先我们在调用fun时给传入了一个常量10
作为参数,这个常量传给fun
的形参int& x
接收时,相当于给这个常量起了个别名叫x
,然后在函数中,我们用这个别名来给这个常量,使得其进行+= 10
的操作,这时就相当于是在给一个常量修改赋值,显然这是不能够被允许的,因为常量是不能修改赋值的!所以编译器肯定会报错!当然我们将以上代码实际地方放到编译器里头,看看编译器会报什么错:
报错:非常量引用的初始值必须为左值
这个报错的意思是说fun
函数在调用时传给形参int& x
必须是一个左值(至于什么是左值,什么是右值,我同样参考了C++中“非常量引用的初始值必须是左值”的处理方法这篇博客。简单地说,左值就是变量,右值是常量),即要求传给这个非常量引用(形参)int& x
必须是一个变量,而不是一个常量(右值),而上图所示的程序中我们就是传入了一个常量,从而导致编译器报错,即对于表明为引用的函数形参,其函数在调用时传递的参数不能是常量。(或者说要求传入的参数是常量的可能性必须为0!)
而且即使我们上面的例子将fun
中赋值修改的语句x += 10
去掉,编译器也是会报错,因为编译器不会理会你是否会对一个传入的可能是常量的参数进行修改,编译器只会认为这种可能性存在,所以它压根不给你这样做的机会,即使你已经把x += 10
去掉了,你想以此来向编译器说明你不会利用这个引用对可能传入的常量进行赋值或修改,但编译器不会理会你此举的含义,它会直接在源头扼杀你,不允许你在调用fun
的时候传入常量。
另外如果我们将上图所示程序中fun
的形参加上一个const
修饰,那么情况变成这样
此时编译器的红色下划波浪线来到了
fun
中的x
下方,main
函数中fun(10)
对函数的调用便不再报错。
解释为何fun(10)
对函数的调用不再报错:因为加上const
修饰之后,编译器认为你即使传入的是常量,但是在fun
的内部因为这个const
修饰x
的缘故,导致在fun
内部无法对x
所命名的不管是常量也好变量也好,都无法进行修改,所以编译器可以放心地让你传入常量来调用fun
(编译器再也不用担心你会在fun
内部把一个传入的常量给赋值修改了,因为有const
在,编译器很放心~),当然你这时传入一个常量也是没有问题的。
那么回到我们最开始的案例中(如下图),为什么这里会报错呢?
我们把cout << ++myInt1
对左移运算符操作函数的调用的本质写出来:operator<<(cout, ++myInt)
,由前面的解释我们知道++myInt
是++
运算符操作函数调用后返回的一个MyInterger
变量,++
运算符操作函数如下
MyInteger operator++() // 不以MyInteger&的方式返回,而是以MyInteger的方式返回
{
value++;
return *this;
}
++myInt
就是由++
运算符操作函数调用结束后return
返回的,我们这里返回的是*this
,但是编译器这里认为我们return
的有可能是变量,也有可能是常量,如果return
的是一个常量,也即++myInt
在++
运算符操作函数调用结束后是一个常量,常量作为参数传递到<<
运算符操作函数中,考虑到MyInterger
类的<<
运算符操作函数定义如下
ostream& operator<<(ostream &cout, MyInteger &myInt)
{
cout << "<<运算符操作函数调用" << endl;
cout << myInt.value;
return cout;
}
所以我们可以知道,++myInt
对应传入的形参是一个非常量引用MyInteger &myInt
,那么就会出现类似的问题:对于表明为引用的函数形参,其函数在调用时传递的参数不能是常量。(或者说要求传入的参数是常量的可能性必须为0!),编译器认为这里的++myInt
有可能是一个常量,所以出现了报错,当然这里编译器的报错也是不够明确,报的是“没有与这些操作数匹配的"<<"运算符”,应该报“非常量引用的初始值必须为左值”才对,当然这是知识水平有限的我给出的一个观点,不一定是正确的,或许编译器是有它的更好想法,只是我没有理解罢了。
同样的如果我们将<<运算符操作函数的形参MyInteger &myInt
由非常量引用修改为常量引用:const MyInteger &myInt
,如下
注意这里修改之后,在
MyInteger
类定义中的友元说明也要进行相应的修改,保持统一
这里的
const
由于我们只是在函数中对myInt
进行读取,所以在myInt
没有修改需求的情况下,我们就把const
留在这里了
经过修改过后,test02
的中报错的红色下滑波浪线也消失了,如下
当然上述代码还有另一种修改方式,就是将形参MyInteger &myInt
中的引用声明去掉,让形参变成MyInteger myInt
,此时也是没有问题的,因为这样一来不论是变量还是常量就都可以传给myInt
,编译器也就同样在函数调用时不会报错了。
那大家肯定又有疑问了,为什么如果++
运算符操作函数返回的是引用类型的时候,即
MyInteger& operator++()
{
value++;
return *this;
}
此时<<
运算符操作函数的形参不加const
却不报错呢?这里我给出的解释是这样的,我们通过一个很简单的案例来说明:
有代码如下
这里当我们在外部调用fun2()
时,调用完成后相当于在调用fun2()
的地方执行了这么一段代码int &fun2() = x
,即在函数调用执行完成后,fun2()
相当于是x
变量的一个别名,使用fun2()
就是在使用x
,也就是说我们这里给变量x
起了个别名叫fun2()
,那么如果我们将上述代码中的return x
修改为return 10
,编译器会出现这样的情况
报错:非常量引用的初始值必须为左值
报错的说明跟我们前面的案例一样,这里相信大家看过前面的内容一下就能知道是什么原因了,就是因为这里相当于是在fun2()
执行完成后,给常量10
起了一个别名叫fun2()
,那么有可能我们会通过fun2()
去修改这个常量,所以编译器报错,不允许有这样的事情发生。
换句话说,当我们函数的返回值类型是引用时,编译器上述的做法(报错非常量引用的初始值必须为左值)保证了函数执行完成后返回的内容是一个变量,而不会是常量。
既然返回的内容可以保证是一个变量,那么对于<<
运算符操作函数ostream& operator<<(ostream &cout, MyInteger &myInt)
的形参MyInteger &myInt
,编译器能够明确传给这个形参的参数是一个变量,也就不会因为担心程序员在这里传入的参数可能是一个常量而报错了。
好,到这里我们就把上面这些知识科普完了,接下来我们回过头来讲:
为什么如果
++
运算符操作函数不采用返回引用MyInteger&
的方式,而是直接返回MyInteger
就会导致++(++myInt)
操作的结果与int
类型的变量在执行相同操作++(++a)
时产生的结果存在差异呢?
根据我们的基础编程知识可知++(++a)
执行完成后,a
肯定是原来的a
,或者说a
和++(++a)
内存是同一块,在执行++
操作时,这个操作始终在a
所在的这一块内存进行;
为什么如果++
运算符操作函数不采用返回引用MyInteger&
的方式,而是直接返回MyInteger
就会导致++(++myInt)操作的结果与int
类型的变量在执行相同操作++(++a)
时产生的结果存在差异呢?++
运算符操作函数,++(++myInt)
的内存调用过程中我们知道,由于引用的存在,所以++
操作始终都是在同一块内存中进行(也即链式编程),但如果++
运算符操作函数的返回值类型为MyInteger
,那内存调用过程就不是这么回事了,我们来看看
给出整体测试代码如下
#include <iostream>
#include<string>
#include<stdlib.h>
using namespace std;
class MyInteger
{
friend ostream& operator<<(ostream& cout, const MyInteger& myInt);
public:
/// <summary>
/// MyInteger类的无参构造:为了确保程序中定义MyInteger类型的变量(对象)且无赋初值的情况下,
/// (默认)使得其属性value值为0
/// </summary>
MyInteger()
{
cout << "无参构造调用" << endl;
value = 0;
}
/// <summary>
/// MyInteger类的有参构造:在程序中定义MyInteger类型的变量(对象)且同时赋初值的情况下,
/// 使用该初值for_value作为其属性value的值
/// </summary>
/// <param name="for_value">
/// :MyInteger类型的变量(对象)定义的同时要赋值给value的初值
/// </param>
MyInteger(int for_value)
{
cout << "有参构造调用" << endl;
this->value = for_value;
}
MyInteger operator++()
{
cout << "++运算符操作函数调用" << endl;
value++; // 写成value = value + 1、value += 1、++value都没有问题,保证这行代码执行结束后value增加1即可
return *this;
}
private:
int value;
};
ostream& operator<<(ostream &cout, const MyInteger &myInt)
{
cout << "<<运算符操作函数调用" << endl;
cout << myInt.value;
return cout;
}
// 测试函数
void test02()
{
MyInteger myInt1 = 0;
cout << ++myInt1 << endl; // 1
cout << myInt1 << endl;
MyInteger myInt2 = 0;
//cout << myInt2++ << endl; // 0
//cout << myInt2 << endl; // 1
}
int main()
{
test02();
system("pause");
return 0;
}
对于测试函数test02()
我们修改为如下
// 测试函数
void test02()
{
MyInteger myInt1 = 0;
cout << &(++myInt1) << endl; // 取地址
cout << &myInt1 << endl; // 取地址
MyInteger myInt2 = 0;
//cout << myInt2++ << endl;
//cout << myInt2 << endl;
}
我们进行取地址操作,来看看两块内存空间的是否相同
但是编译器出现了一个意料之外的报错,如下
报错:"&"要求左值
左值即变量,也就是说取地址操作要求取的必须是一个变量,由于这里++myInt
在调用++
运算符操作函数后,编译器认为该函数的返回值有可能是一个常量,所以报了上述错误。可以看出这样报错的原因也是跟我们前面的案例是相类似的。
取地址的这种方式不可行,我想了其他方法,但是苦于技术有限,没有想到能够直接验证的方法(如果看到这里的读者如果有更好地方法来验证++myInt
和myInt
的内存是否相同,欢迎评论区留言交流~),于是我采取了一种间接的方法来验证:
我把MyInteger
类的拷贝构造函数自己实现出来,并且加上相应的调用提示,如下
MyInteger(const MyInteger& myInt)
{
cout << "拷贝构造调用" << endl;
this->value = myInt.value;
}
注意这里由于这个拷贝构造函数是写在
MyInteger
类内部的,所以在访问私有属性value
,即this->value
和myInt.value
时,不需要友元说明(也无法进行友元说明,那样做的话会报错)
最终的测试代码如下:
#include <iostream>
#include<string>
#include<stdlib.h>
using namespace std;
class MyInteger
{
friend ostream& operator<<(ostream& cout, const MyInteger& myInt);
public:
/// <summary>
/// MyInteger类的无参构造:为了确保程序中定义MyInteger类型的变量(对象)且无赋初值的情况下,
/// (默认)使得其属性value值为0
/// </summary>
MyInteger()
{
cout << "无参构造调用" << endl;
value = 0;
}
/// <summary>
/// MyInteger类的有参构造:在程序中定义MyInteger类型的变量(对象)且同时赋初值的情况下,
/// 使用该初值for_value作为其属性value的值
/// </summary>
/// <param name="for_value">
/// :MyInteger类型的变量(对象)定义的同时要赋值给value的初值
/// </param>
MyInteger(int for_value)
{
cout << "有参构造调用" << endl;
this->value = for_value;
}
/// <summary>
/// MyInteger类的拷贝构造。在实现默认拷贝构造功能的基础上,通过cout输出相关的函数调用提示信息
/// </summary>
/// <param name="myInt">
/// :MyInteger类型的变量(对象)定义的同时用于参考拷贝的变量(对象)
/// </param>
MyInteger(const MyInteger& myInt)
{
cout << "拷贝构造调用" << endl;
this->value = myInt.value;
}
MyInteger operator++()
{
cout << "++运算符操作函数调用" << endl;
value++; // 写成value = value + 1、value += 1、++value都没有问题,保证这行代码执行结束后value增加1即可
return *this;
}
private:
int value;
};
ostream& operator<<(ostream& cout, const MyInteger& myInt)
{
cout << "<<运算符操作函数调用" << endl;
cout << myInt.value;
return cout;
}
// 测试函数
void test02()
{
MyInteger myInt1 = 0;
cout << ++myInt1 << endl; // 取地址
cout << myInt1 << endl; // 取地址
MyInteger myInt2 = 0;
//cout << myInt2++ << endl;
//cout << myInt2 << endl;
}
int main()
{
test02();
system("pause");
return 0;
}
程序运行结果如下:
从中我们可以看到第70行代码在++
运算符操作函数执行完成后执行了一次拷贝构造函数,这是由于返回值类型非引用所导致的,在函数执行完成return
返回的时候,根据myInt1
拷贝了一份拷贝体,所以我们可以看到DOS窗口显示了"拷贝构造调用",并且这个return
是回到++myInt1
上的,所以这里的++myInt1
就相当于是在++
运算符操作函数执行完成后用来命名这份拷贝体的(或者说++myInt1
就是这份拷贝体),因此当我们去深究++myInt1
和myInt1
的地址时,二者肯定是不一样的(一份是原体,另一份是拷贝体,占用两块内存空间)。
此时我们如果对++myInt1
再一次进行++
操作,即++(++myInt1)
,那么可想而知又会产生一份拷贝体,此时的++(++myInt1)
、++myInt1
和myInt1
占用三块不同内存,地址各不相同。所以这就能解释我们上面的那个问题:
为什么如果
++
运算符操作函数不采用引用MyInteger&
的方式返回,而是直接返回MyInteger
就会导致++(++myInt)
操作的结果与int
类型的变量在执行相同操作++(++a)
时产生的结果存在差异呢?
++
运算符操作函数直接返回MyInteger
的话会导致每一次++
运算完成后都拷贝创建一份新的内存,而非一直使用原来的那块内存,这一点是与int
类型的变量在执行相同操作++(++a)
时产生的结果存在差异的,因为int
类型的变量执行此操作时,一直都只有a
这一块内存空间。但是尽管++
运算符操作函数直接返回MyInteger
的情况下会创建拷贝体,其程序执行结果与int
类型的变量在执行相同操作++(++a)
时产生的结果还是无异的(因为虽然是创建了拷贝体,但是每次的拷贝都是在value
执行了++
之后才进行的拷贝,++
操作是实实在在地被执行过的,并且是能在创建拷贝体的过程中被一次次保留下去的,所以使得其产生的结果与++(++a)
无异),单凭值打印是看不出来的,只有分析它们的内存我们才能看得出差异。
当然我们还可以采用另一种方式进行验证,我们写一个test01()
测试函数
void test01()
{
int a = 0;
cout << ++(++a) << endl;
cout << a << endl;
}
该函数的运行结果是
如果是++
运算符操作函数返回值类型为MyInteger
的情况下,让MyInteger
变量执行上述类似的操作,结果又如何呢?如下:
void test03()
{
MyInteger myInt = 0;
cout << ++(++myInt) << endl;
cout << myInt << endl;
}
该函数的运行结果是
由此我们也可以看出myInt
和++(++myInt)
是不同的(占用不同内存空间),++myInt
是对myInt
自增后的拷贝,value
为1,而++(++myInt)
则是对++myInt
自增后的拷贝,value
为2
;++(++myInt)
括号外的++
是对++myInt
的内存空间进行,而不是myInt
,只有括号内的++
才是对myInt
开展的。
综上,++
运算符重载函数的返回值类型为引用MyInteger&
是为了一直对同一个变量(内存)进行递增操作,而不会产生拷贝体。
当然当我们将++
运算符重载函数的返回值类型修改为引用MyInteger&
之后,上图所示代码的执行结果如下
此时程序便能正确地模仿我们int
类型的++(++a)
操作了!