Hello!各位同学们大家好!逗比老师最近说起来还是挺尴尬的,为什么这么说呢?因为以前我对自己的C++水平还是相当自信的,经常以“精通”来自我评价。但是最近发现自己好像对C++11当中很多特性还是一知半解,有的甚至完全没听过。实在是惭愧啊。虽然说C++11也是几乎10年前的产物了,但是由于这次大更新已经完全改变了C++这门语言的整体画风了,所以至今仍然值得我们去仔细研究。否则总感觉自己写的代码是C的感觉。

那么这次要给大家分享的是,C++11中的右值引用、移动构造函数以及成员函数的引用标识符。

那么首先,我们需要先来理清楚一个大家天天在用,但是可能一直忽略掉的一个概念——左值和右值。左值和右值的概念最早是在赋值符号上诞生的。假如我们由这样一个赋值表达式:

a = b;

那么在这个表达式中,a就是左值,b就是右值。我们可以从这个表达式中看到很多左值和右值的特点,比如说,经过了赋值表达式后,右值是一定不会发生改变的,而左值大概率是会发生改变的。这种性质也就决定了,右值可以是常量,也可以是变量,自然也可以是用const修饰的(更恰当的叫法应该叫只读)变量。但是左值就只能是可变的变量。脱离了赋值表达式以后,我们仍然用“左值”和“右值”的叫法来称呼其他地方的变量表示其特性。比如说函数实参就一定是右值(别急,我知道你想说什么),函数返回值也一定是右值(我知道,我知道,等一下嘛,别急)。

好了,我知道我这话说出来就已经有人迫不及待要反驳我了,那我加一个范围吧。C语言中的函数实参和返回值都一定是右值。这下没有异议了吧?那为什么这句话放到C++里就不成立了呢?答案很简单,因为C++有引用这种语法。

虽说引用本质上还是通过指针来实现的,但是在语义层面,我们可以认为,引用就是给变量起别名。比如这样:

int &b = a;

当然,这种说法还是有局限性的。更准确的说法是:普通的引用相当于给变量起别名。当然,直接定义一个引用这种做法还是很少见的,意义不大,我们主要是把引用用作函数参数作为出参,用来代替C语言当中传入指针的方法。比如这样:

void swap(int &a, int &b) {int tmp = a;a = b;b = tmp;
}

上面这个用来交换两个变量值的函数就是接受两个引用作为形参。其实效果和接收指针差不多,但是函数内部就省去了很多复杂的取址和解指针的表达式。

我刚才反复强调,普通的引用其实就是指针运算的语法糖,让我们感觉是给变量取了别名。这种说法如何验证呢?很简单,做个sizeof就可以了,但是注意,不能直接给引用取sizeof,因为这样取出来的是引用的变量的size。我们需要包装一下,才能看到这个引用的庐山真面目。

struct Test {char &a;
};int main(int argc, const char * argv[]) {std::cout << sizeof(Test) << std::endl;return 0;
}

上面这段代码在64位计算机中输出结果是8,因此,说明a其实占用的空间是一个指针的长度。什么?你说内存对齐啊?那简单,加一个变量就可以排除:

struct Test {char &a;char b;
};

这样的话,如果a确实占8字节,那么b就会到下一个对齐空间,Test的长度就应该是16;而如果a只占一个字节,那么b应该和a在同一个对齐空间中,Test的长度就应该还是8。运行之后我们验证了之前的说法,Test的长度是16,所以,a确确实实是一个指针的长度。

既然,普通的引用其实只是一个指针运算的语法糖,那么,我们就不能给一个常数取引用,因为常量不能取地址。比如下面的代码就会报错:

int &a = 5;

不过,C++里还是提供了可以绑定常量的引用的语法,我们称之为常引用。常引用这种语法非常特殊,要分两种情况来对待。第一种,当常引用绑定一个只读(const修饰的)变量的时候,效果和普通的引用一样,比如:

const int a = 8; // 只读变量
const int &b = a; // 用常引用绑定只读变量

第二种,当常引用绑定一个常量的时候,其效果相当于定义了一个普通的变量,比如:

const int &b = 8; // 常引用绑定常量

此时,b的空间是一个int的大小,效果和我们写一个const int b = 8;基本一致。

所以常引用这种特殊的语法更为普遍地用到了函数入参上。这样可以避免对象的复制,还可以防止函数内对引用对象的修改,与此同时,还可以处理常量。(之所以这样设计,大概是因为,既然通过常引用不能改变对象本身,那么常量也是不会改变的,我们就可以看作同一类事物处理。)

这个特性在字符串类的使用中更为普遍,比如:

void printString(const std::string &str) {std::cout << str << std::endl;
}

在函数内部我们不会修改字符串的值,因此str是作为入参的,但是,如果不用引用的话,就会造成字符串复制之后再传入函数内,影响效率,而使用非const引用的话,则无法处理常量。使用常引用就可以完美解决所有问题。

std::string str = "123";
printString(str); // OK
printString("abc") // OK

上面两种调用方式都是支持的。所以对于基本数据类型来说,仅仅是引用作为出参是有意义的,其他情况下拷贝指针那还不如拷贝自己本身的值,所以说其他的

好了,白话了这么这么多,都还没有说到我们今天的重点上。请大家看这样一个例子:

Str func1(Str str) {return str;
}

假如Str是我们自定义的一个类的话,调用这个函数会发生什么?

Str str1 = func1(str2);

首先,实参str2要赋值给形参str,然后str作为返回值的临时变量,返回值的临时变量在作为str1的参数进行构造。所以,前后一共调用了3次拷贝构造函数。当然,现在编译器基本都会对这种情况有优化,但是,这种优化并不是标准,我们为了看清本质,可以在编译时添加-fno-elide-constructors来关闭返回值优化。

我在这里给出一个示例:

class Str {
private:bool newMark; // 标记是否有new空间char *buffer;
public:Str(const char *str);Str(const Str &);Str &operator =(const Str &);~Str();
};Str::Str(const char *str): buffer(new char[strlen(str) + 1]), newMark(true) {strcpy(buffer, str);std::cout << this << "->普通构造函数" << std::endl;
}Str::Str(const Str &other): buffer(new char[strlen(other.buffer) + 1]), newMark(true) {strcpy(buffer, other.buffer);std::cout << this << "->拷贝构造函数" << std::endl;
}Str::~Str() {if (newMark) {delete [] buffer;}std::cout << this << "->析构函数" << std::endl;
}Str func1(Str str) {return str;
}int main(int argc, const char * argv[]) {Str str2 = "123";Str str1 = func1(str2);return 0;
}

我们把普通的构造函数、拷贝构造函数、赋值函数、析构函数都列出来,分别打印它们来观察调用方式。得到的运行结果如下:

0x7ffee1292af0->普通构造函数
0x7ffee1292b00->拷贝构造函数
0x7ffee1292af0->析构函数
0x7ffee1292ab0->拷贝构造函数
0x7ffee1292ac0->拷贝构造函数
0x7ffee1292ad0->拷贝构造函数
0x7ffee1292ac0->析构函数
0x7ffee1292ab0->析构函数
0x7ffee1292ad0->析构函数
0x7ffee1292b00->析构函数

可以看出,我们原本只是想复制一份对象,可是实际上却拷贝了4次对象,中间很多都是临时变量,仅仅是因为作用域限定问题在很无聊的复制释放。用引用优化一下会好一些,比如这样:

Str func1(const Str &str) {return str;
}
0x7ffeee0d2af0->普通构造函数
0x7ffeee0d2b00->拷贝构造函数
0x7ffeee0d2af0->析构函数
0x7ffeee0d2ac0->拷贝构造函数
0x7ffeee0d2ad0->拷贝构造函数
0x7ffeee0d2ac0->析构函数
0x7ffeee0d2ad0->析构函数
0x7ffeee0d2b00->析构函数

这样确实减少了一次从str2到str的复制,但是还是多了很多无意义的复制。怎么办?我们来分析一下,这个无效的复制在哪里。

str由于是常引用了,不需要复制,但是当函数返回的时候,会生成一个临时变量,来代替函数表达式。(Visual Basic中函数返回值的语法就非常能说明这个问题,因为VB里函数返回就是在函数中给函数名赋值来表示返回的。)所以,str对临时变量的复制,以及临时变量对str1的复制,这就是冗余的复制。

那可能会有同学说,我返回引用不就好了嘛!比如这样:

Str &func1(const Str &str) {return str;
}

这样看似没有问题,但是仔细想想问题很大。我们希望函数能够返回一个str2的复制,但是这里却得到了str2的引用,如果我直接操作这个返回值的话,str2就会发生改变,比如这样:

func1(str2).buffer = nullptr;

所以这是不安全的,我们不应该指望其他调用我们程序的人考虑这些细节,而是应该我们考虑好,限制好这种行为。

C++11在这里引入了右值引用的概念,语法是&&,注意两个引用符号不是引用的引用,而是右值引用,一个新的运算符。当我们这样调用函数的时候这样做的话:

Str func1(const Str &str) {return str;
}int main(int argc, const char * argv[]) {Str str2("123");Str &&str1 = func1(str2);return 0;
}
0x7ffee4b49b10->普通构造函数
0x7ffee4b49af8->拷贝构造函数
0x7ffee4b49af8->析构函数
0x7ffee4b49b10->析构函数

这个结果是我们想要的。右值引用的作用就是绑定一个右值。比如说这里func1的返回值在返回之后就会被析构,我们把这样的值称为将亡值,也就是说,正常情况下,赋值给str1后就被析构了,但是,如果我们是用右值引用来接收这个返回值的话,它的声明周期将会被延长,在新的生命周期中它有个了新的名字叫str1。这样也就避免了不必要的复制。

与常引用类似,右值引用也可以绑定一个常量,这时,它相当于一个普通的变量。比如这样:

int &&a = 8; // 右值引用绑定常量

不过需要注意的是,既然是右值引用,只能用来绑定右值。比如下面的语法就是错的:

int a = 5;
int &&b = a; // ERROR 不能用右值引用绑定左值

可能有同学会有这样的疑问,表达式中,a不是在赋值符号的右边吗?为什么还是左值呢?关于这一点,我们应当把C++当中“=”符号的不同用法分开来理解。

1. 作为赋值符号。比如 a = b; 这时a是左值,b是右值。

2.作为初始化符号。比如int a = 5; 这时5相当于a的初始化参数,期间没有赋值动作。

3.作为引用绑定符号。比如 int &a = b; 这时不存在赋值动作,只是表达a是用来绑定b的。

那么上面的代码应该是第3中情况,只是表示b是a的引用,不存在赋值,也就不改变a的左右值性质。

那么,再详细一点说就是,右值引用只可以绑定常量(此时相当于普通变量)以及返回值为非引用类型的函数的返回值(此时用来延长将亡对象的生命周期)。

如果刚才我在解释=的三种理解时第2中的时候你有关注的话,你可能会有这样的疑问,如果是非POD类型(就是除过基本数据类型或是无方法的结构体之外的,实实在在的对象类型)的情况呢?请看下面代码:

Str str = "123";

我们还是用上面的Str作为例子。在这句代码里,=是作为初始化还是作为赋值呢?同样在关闭编译器的返回值优化功能的情况下,我们看一下输出结果:

0x7ffeedea1b00->普通构造函数
0x7ffeedea1b10->拷贝构造函数
0x7ffeedea1b00->析构函数
0x7ffeedea1b10->析构函数

输出结果已经很说明问题了,这里的=同样是作为初始化参数的,因为赋值函数没有被调用。只不过,这里发生了隐式类型转换,也就是"123"原本是const char *类型,这里转化成了Str类型,换句话说,完整的写法应该是这样的:

Str str = Str("123");

首先我们将"123"作为参数创建了一个Str对象,调用了普通的构造函数,然后,将这个对象再作为str的参数,所以调用了拷贝构造函数。所以Str("123")这种形式创造出的对象就是个将亡值,当然,我们可以改用右值引用来接收,防止这种拷贝:

Str &&str = "123"; // 用右值引用来绑定将亡对象

但是这样代码可读性就降低了,总感觉是用了Str的引用绑定了const char *类型,所以这种只含一个参数的构造函数所导致的隐式类型转换,仔细想想也是非常可怕的,如果我们没有注意,没有用右值引用去绑定,而是用了普通的对象定义,那么,就会发生一次无意义的拷贝构造。C++11提供了一个关键字explicit,用该关键字声明的构造函数将不能进行隐式类型转换,只能显式调用,比如这样:

class Str {
private:bool newMark; // 标记是否有new空间char *buffer;
public:explicit Str(const char *str); // 声明必须显式调用Str(const Str &);Str &operator =(const Str &);~Str();
};int main(int argc, const char * argv[]) {Str &&str = "123"; // ERROR 不能隐式转换,所以Str &&不能绑定const char *Str str = "123"; // ERROR 不能隐式转换,所以const char *不能转换为StrStr str("123"); // OK,作为参数显式调用构造函数return 0;
}

接下来我们需要关注一下这句:

Str str = Str("123");

如果不用右值引用,这时调用的是拷贝构造函数,也就是相当于这样:

Str str(Str("123"));

但是有一个问题就是,拷贝构造函数里通常是要发生深复制的,也就是我们这里会吧这个Str("123")的buffer内容复制到str的buffer中。但是,Str("123")又会马上被释放掉(因为是将亡对象),所以,更好的方法并不是做深复制,而是浅复制。因为这样的话,我们可以直接利用将亡对象的buffer,而不用申请新的空间(因为反正将亡对象马上就析构了,不用担心互相干扰)。

在C++11中提供了一个“移动构造函数”就是这个目的,用将亡对象作为参数构造一个新的对象时将会调用移动构造函数。在移动构造函数里一般用浅复制。我们完善一下Str的代码,给出完整版本:

class Str {
private:bool newMark; // 标记是否有new空间char *buffer;
public:explicit Str(const char *str);Str(const Str &);Str(Str &&);Str &operator =(const Str &);~Str();
};Str::Str(const char *str): buffer(new char[strlen(str) + 1]), newMark(true) {strcpy(buffer, str);std::cout << this << "->普通构造函数" << std::endl;
}Str::Str(const Str &other): buffer(new char[strlen(other.buffer) + 1]), newMark(true) {strcpy(buffer, other.buffer);std::cout << this << "->拷贝构造函数" << std::endl;
}Str::Str(Str &&other): buffer(other.buffer), newMark(false) {std::cout << this << "->移动构造函数" << std::endl;
}Str::~Str() {if (newMark) {delete [] buffer;}std::cout << this << "->析构函数" << std::endl;
}Str func1(const Str &str) {return str;
}int main(int argc, const char * argv[]) {auto str = Str("123"); // 调用移动构造函数auto str2 = func1(str); // 调用移动构造函数return 0;
}

这里用右值引用作为参数的构造函数就是移动构造函数,只有当构造参数是将亡对象的时候才会调用移动构造函数,否则将会调用拷贝构造函数。什么?有人问我如果传常量呢?拜托!非POD类型的对象哪来的常量啊?哈哈哈!POD类型没有移动构造函数,它只有默认的位拷贝。所以,不存在这种情况。

以上是关于右值引用的相关说明。

不过既然提到了左右值,我们就再看一个例子,假如我们给Str新实现两个函数,一个用作拼接,一个用作清空,代码如下:

class Str {
private:bool newMark; // 标记是否有new空间char *buffer;
public:explicit Str(const char *str);Str(const Str &);Str(Str &&);Str &operator =(const Str &);~Str();Str operator+(const Str &); // 字符串连接void reset(); // 字符串清空
};Str Str::operator+(const Str &right) {strcat(buffer, right.buffer); // 为了简化代码,缓冲区大小的问题暂时不考虑了return *this;
}void Str::reset() {memset(buffer, 0, strlen(buffer) + 1);
}
// 其他函数的实现同上,省略

看起来好像没有问题,但是,如果这样来调用的话,会怎么样呢?

Str str1("123");
Str str2("456");(str1 + str2).reset();

大家注意一下调用这个reset()方法的对象,str1和str2拼接后返回的Str对象,其实是一个将亡对象,我们对这个将亡对象进行reset()处理其实是没有意义的。其实,我们对将亡对象做任何非读取的操作都是没有意义的。为了 避免这样的情况发生,C++11提供了成员函数的引用标识符,用来声明某一方法不能用于将亡对象,比如这样:

class Str {
private:bool newMark; // 标记是否有new空间char *buffer;
public:explicit Str(const char *str);Str(const Str &);Str(Str &&);Str &operator =(const Str &);~Str();Str operator+(const Str &); // 字符串连接void reset() &; // 字符串清空,声明为引用标识,不允许将亡对象调用
};void Str::reset() & {memset(buffer, 0, strlen(buffer) + 1);
}
// 其他函数实现省略int main(int argc, const char * argv[]) {Str str1("123");Str str2("456");(str1 + str2).reset(); // ERROR 将亡对象不能调用reset()方法Str str3 = str1 + str2; // 将亡值构造新对象,调用移动构造函数str3.reset(); // OK 普通的对象可以调用reset()return 0;
}

那么既然存在标识为&的函数,自然也就存在标识为&&的函数,也就是只有将亡对象可以调用的函数。不过这种函数貌似很少有使用的场景(至少本逗比想了很久很久都没有想到一个合适的应用场景,只想到一种勉强算数的,就是写两个同名的重载函数,一个用&标识,一个用&&标识,根据调用对象的不同进行不同的处理),用法和标识&的一样,不再赘述。

由此,C++的普通的成员函数(不考虑构造、析构、赋值、静态等)就分成了5类,我给大家列了个表格供参考:

  普通对象(Obj) 只读对象(const Obj) 将亡对象(Obj &&)
用&修饰的 可以调用 不可以调用 不可以调用
用&&修饰的 不可以调用 不可以调用 可以调用
用const &修饰的 可以调用 可以调用 可以调用
用const &&修饰的 不可以调用 不可以调用 可以调用
用const修饰的 可以调用 可以调用 可以调用
没有修饰 可以调用 不可以调用 可以调用

这里面需要注意的是,用const修饰的函数,内部不可以更改变量的值(只能取值)。用const &修饰和用const修饰基本是等价的(至少逗比目前没发现区别,如果有小伙伴发现了请一定要留言告诉我。)

用const &&修饰的同样不能在内部更改值。

总结一下就是,有const就不能更改值,有&的将亡对象不能用,有&&的只有将亡对象可以用。

好啦!蛮累的,算是把这个C++11里各种引用给解释完了。如果大家还有什么问题,欢迎留言,我们一起学习,共同进步。

【本文由逗比老师全权拥有,允许转载,但转载时务必注明转载处并附上原文链接。对任何恶意更改和赋值的,逗比老师保留追究的权利。】

C++11中的右值引用(对比左值引用和常引用)、移动构造函数和引用标识符相关推荐

  1. C++11中的右值引用

    http://www.cnblogs.com/yanqi0124/p/4723698.html 在C++98中有左值和右值的概念,不过这两个概念对于很多程序员并不关心,因为不知道这两个概念照样可以写出 ...

  2. C++ 11 中的右值引用

    C++ 11 中的右值引用 右值引用的功能 首先,我并不介绍什么是右值引用,而是以一个例子里来介绍一下右值引用的功能: #include <iostream>     #include & ...

  3. C++ 右值引用与左值引用

    意义:可以避免无谓的复制,提高程序的性能. 左值:表达式结束后依然存在的持久化对象 右值:表达式结束后不再存在的临时对象 所有的具名变量和对象都是左值,而右值不具名. 区分左值和右值的快捷方法: 看能 ...

  4. C++中“非常量引用的初始值必须是左值”的处理方法

    原文:https://blog.csdn.net/hou09tian/article/details/80565343 1 左值和右值 在C++中,左值可以出现在赋值语句的左边和右边:右值只能出现在赋 ...

  5. 2020-09-22C++学习笔记之引用1(1.引用(普通引用)2.引用做函数参数 3.引用的意义 4.引用本质5.引用结论 6.函数返回值是引用(引用当左值)7测试代码)

    2020-09-22C++学习笔记之引用1(1.引用(普通引用)2.引用做函数参数 3.引用的意义 4.引用本质5.引用结论 6.函数返回值是引用(引用当左值)7测试代码) 1.引用(普通引用) 变量 ...

  6. 左值、左值表达式、左值引用 C++

    左值和右值 1.左值:左值是一个对象或变量,可以代表着一个固定地址. int i = 3://此时,i是个变量,本质和对象一样,是一块内存区域,代表着一个固定的地址. 右值:不能作为左值的都是右值,要 ...

  7. C++11中的右值引用及move语义编程

    C++0x中加入了右值引用,和move函数.右值引用出现之前我们只能用const引用来关联临时对象(右值)(造孽的VS可以用非const引用关联临时对象,请忽略VS),所以我们不能修临时对象的内容,右 ...

  8. 的引用_左值、右值、左值引用、右值引用

    [导读]:本文主要详细介绍了左值.右值.左值引用.右值引用以及move.完美转发. 左值和右值 左值(left-values),缩写:lvalues 右值(right-values),缩写:rvalu ...

  9. java中的左值右值_利用左值右值实现树状结构

    image.png 1. 查询 1.1. 得到节点 Node 下的所有节点,并按树状排序 SELECT * FROM tree WHERE lft BETWEEN Node.Lft AND Node. ...

最新文章

  1. “面向对象就是一个错误!”
  2. 使用sui实现的选择控件【性别、日期、省市级联】
  3. 构建之法4、17章观后感
  4. thinkPHP5.0中使用header跳转没作用
  5. 小波降噪与重构例子 python
  6. TensorFlow学习笔记(一)安装、配置、基本用法
  7. 公众平台关注用户达到5万即可开通流量主功能 可以推广APP应用
  8. ssh(Spring+Spring mvc+hibernate)简单增删改查案例
  9. 聊聊身边的嵌入式,为什么老司机都爱后视镜
  10. VSCode配置ESLint
  11. python怎么定义一个变量为空列表_python – 为什么一个类变量没有在列表理解中定义,但另一个是?...
  12. 阿里云服务器创建历史功能介绍 快速创建云服务器
  13. vue教程1-03 v-for循环
  14. 计算机原理与系统结构教程,计算机组成原理与系统结构实验教程.docx
  15. 除了微软默认的ppt服务器外,微软如此解释这一新政。据了解,除了MSN与Skype有很多类似功能之外.ppt...
  16. 数据结构—哈夫曼编码
  17. xp系统蓝屏代码7b_电脑开机蓝屏错误代码0x0000007B的详细解决过程
  18. Flutter 基础布局之Stack
  19. JavaWeb学习笔记(上)
  20. 如何用python爬取下载微博视频_程序员徒手用python教你爬取新浪微博,一天可抓取 1300 万条数据...

热门文章

  1. oracle ebs 笔记
  2. “愉悦身心 伽倍健康”知识竞赛线上答题活动策划
  3. mxGraph添加流动的管道动画
  4. java中的时间戳sssss_Golang中使用Date进行日期格式化(沿用Java风格)
  5. GPB proto文件转C语言
  6. 视觉SLAM笔记(52) BA 与图优化
  7. 记一次基于mybatis的Springboot项目数据库从Mysql迁移至Oracle的全过程(超详细)
  8. 数组A=array[1..100,1..100]以行序为主序存储,设每个数据元素占2个存储单元,基地址为10,则LOC[5,5]应为
  9. ocelot和nginx比较_Ocelot一个优秀的.NET API网关框架
  10. 什么是Zachman框架?