最近我在学 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;

这个示例中,rvalueRefstr 的右值引用,std::string&& 则是其右值引用的类型。用 rvalueRef 可以移动构造 newStr。函数要返回右值引用时,返回值可以写 std::string&&,这样可以更加清晰的知道返回的是一个右值引用。

标签: 开发, C++