C++ 快速理解“右值引用”的意义
最近我在学 LLVM,想攒一个属于我自己的编程语言,也就是手搓编译器。LLVM 的教程里涉及到了很多智能指针,同时也涉及到了右值引用。我之前对于右值引用的理解程度就是“我可能懂右值引用,但我懂右值引用有点不可能”。编译器这种注重性能的工具,想绕开右值引用那肯定是不可能。因此,我就花了十几分钟问了 Copilot 右值引用是什么、有什么用,总算是把这个晦涩的概念搞懂了。这里,我就用我自己对于右值引用的理解,把右值引用的意义复述一遍,希望大家也能搞懂。
如果这篇文章有什么错误的地方,也请各位海涵。无论如何,这里的解释至少结果是对的,开发中按照这个理解去用是不会有问题的。
如果你已明白“右值”、“浅拷贝”和“深拷贝”是什么意思,请跳到文章的“右值引用”部分,前面的都是铺垫。
“右值”和“右值引用”
在我看来,“右值”和“右值引用”是两个完全不同的东西,跟容易理解的“左值”和“左值引用”是不同的。简单来说,“右值”就是代码中的字面量,以及赋值表达式算一半时的临时数据。“右值引用”则是一种把内存中的数据重新划分给别人,以此省去数据“深拷贝”时间的方法。这篇文章中,主要讲“右值引用”,因为“右值”是很好懂的概念。
“浅拷贝”和“深拷贝”
在讲“右值引用”之前,我们要知道:为什么需要“右值引用”?
一般来说,把一个对象的数据复制给另一个对象,有“浅拷贝”和“深拷贝”两种方法。
浅拷贝
“浅拷贝”是把一个对象的数据“差不多地”赋值给另一个对象,基本类型的数据会被复制过去,而其他类型的数据则会被引用过去。也就是说,“浅拷贝”后的两个对象,其非基本类型的数据实际上是共享的。这两个对象只要其中一个修改了非基本类型的数据,另一个对象对应的数据也会跟着变。在很多情况下,这种事情是不允许出现的。
注:默认的拷贝构造函数和赋值运算符通常都是浅拷贝。
跟这个有点像的是 std::string
的 .c_str()
方法,例如:
const char* foo() {
std::string str("Hello");
str += " World!";
return str.c_str();
}
void bar() {
const char* hello_world = foo();
std::cout << hello_world << std::endl;
}
这个代码片段一眼看过去似乎很合理,foo()
把自己的 str
对应的 C 风格字符串指针传给 bar()
,然后 bar()
调用 std::cout
输出字符串。但事实上,str
被存储在栈内存,foo()
结束后,str
对应的数据就会被回收,从而使返回的指针变成野指针。因此,这种情况一般都会把 str
的数据复制到堆内存,这样 foo()
结束后,字符串指针所指向的地址就是有效的,因为堆内存不会因函数退出而被回收(这种情况下是)。
深拷贝
“深拷贝”是把一个对象的数据非常详尽地复制给另一个对象,基本类型的数据复制过去,非基本类型的数据则会新开一块内存,复制过去,让新对象的变量指向那块新的内存。这种方式可以解决浅拷贝带来的问题,因为这种方式下,两个对象使用两块完全不同的内存。
但同时这种方式带来了性能问题,因为它要把数据完整的复制一份出来。这两个对象后面都要用的话还好,如果我只想用后一个,前一个复制完数据就丢掉呢?这个时候,就需要“右值引用”了。
右值引用
“右值引用”简单来说,就是把原对象的内存空间,直接划分给新对象。基本类型的数据会被复制过去,其他类型的数据则会被引用过去。并且,原对象的数据和指针会被清零,不再指向原有的内存地址。这个就叫做“移动构造”。这个方法完美地解决了“用一个丢一个”时的性能问题,因为只涉及到了基本类型数据的复制。
在我看来,“右值引用”还是很好理解的。但这只是概念,要用右值引用,还要知道它怎么用。
“右值引用”怎么用
当一个对象本身已经没有意义,而它指向的数据我们还要用时(比如刚才“浅拷贝”那块提到的 C 风格字符串指针),就该使用“右值引用”,把它的数据交给别人了。使用 std::move()
操作一个对象,可以得到那个对象的“右值引用”。此时,只要拿这个引用给另一个对象赋值,“移动构造”就算完成了。
值得注意的是,std::move()
不会立刻把对象的数据给别人,因为它不知道谁需要,只知道这个对象不需要。相当于 std::move()
是标记这个对象的数据可以被拿走,等待“有缘人”随时取走用。因此在使用右值引用进行移动构造时,通常需要注意“尝试多次移动”的问题。被“标记”的对象,其数据只能被取走一次,先到先得。被拿走之后,后来的对象就什么都拿不到,发生未定义行为。此外,如果一个对象被 std::move()
“标记”了,无论再“标记”几次都一样,无非是多几个右值引用,最终还是只能移动构造一次。
可能你会提出一个问题:栈内存中的对象不是会在函数退出后被回收吗?这就是为什么右值引用还常被说“可以延长对象的生命周期”,因为一个对象有了右值引用之后,这个对象的生命周期就和这个右值引用一样长。什么时候移动构造,什么时候对象被回收。
接下来是几个示例,以便大家了解怎么用。但需要注意,很多时候移动构造函数是要自己写的。
示例一
class MyClass {
public:
MyClass(std::unique_ptr<std::string> ptr) : string(std::move(ptr)) {
}
private:
std::unique_ptr<std::string> string;
}
std::unique_ptr<std::string> foo() {
auto str_ptr = std::make_unique<std::string>("Hello World!");
return std::move(str_ptr);
}
std::unique_ptr<std::string> bar() {
return foo();
}
int main() {
MyClass myClass(bar());
return 0;
}
这种情况下 foo()
返回一个右值引用,str_ptr
继续存在。bar()
拿到右值引用后交给 main()
,以便其移动构造 myClass
的成员变量。整个程序中,std::string
只会在程序开始时构建,并在程序结束时析构,省去了深拷贝的多次构造析构(复制内存创建新对象需要构造,回收不用的原对象要析构),提高了程序性能。
示例二
std::string str = "Hello World!";
std::string&& rvalueRef = std::move(str);
std::string newStr = rvalueRef;
这个示例中,rvalueRef
是 str
的右值引用,std::string&&
则是其右值引用的类型。用 rvalueRef
可以移动构造 newStr
。函数要返回右值引用时,返回值可以写 std::string&&
,这样可以更加清晰的知道返回的是一个右值引用。
提交评论