C++11 右值引用与移动构造函数

本文根据众多互联网博客内容整理后形成,引用内容的版权归原始作者所有,仅限于学习研究使用,不得用于任何商业用途。

左值(赋值操作符“=”的左侧,通常是一个变量)与右值(赋值操作符“=”的右侧,通常是一个常数、表达式、函数调用)之间的差别可以追溯到 Christopher Strachey (C++的祖先语言CPL之父,指称语义学之父)时代。
在C++中,左值可被绑定到非const引用左值或者右值则可被绑定到const引用。但是却没有什么可以绑定到非const的右值(译注:即右值无法被非const的引用绑定),这是为了防止人们修改临时变量的值,这些临时变量在被赋予新的值之前,都会被销毁。例如:

void incr(int& a) { ++a; }
int i = 0;
incr(i);    // i变为1
//错误:0不是一个左值
incr(0);
//(译注:0不是左值,无法直接绑定到非const引用:int&。
// 假如可行,那么在调用时,将会产生一个值为0的临时变量,
// 用于绑定到int&中,但这个临时变量将在函数返回时被销毁,
// 因而,对于它的任何更改都是没有意义的,
// 所以编译器拒绝将临时变量绑定到非const引用,但对于const的引用,
// 则是可行的)

假设incr(0)合法,那么要么产生一个程序员不可见的临时变量并进行无意义的递增操作,要么发生更悲剧的情况:把字面常量“0”的实际值会变成1。尽管后者听起来是天方夜谭,但是对于早期Frotran等这一类把字面常量“0”也存到内存里的某个位置的编译器来说,这真的会变成一个bug。(译注:指的是假如把用于存储字面常量0的内存单元上的值从0修改为1以后,后续所有使用字面常数0的地方实际上都在使用“1”)。

到目前为止,一切都很美好。但考虑如下函数:

template<class T> swap(T& a, T& b) // 老式的swap函数
{
    T tmp(a);// 现在有两份"a"
    a = b;        // 现在有两份"b"
    b = tmp;    // 现在有两份tmp(值同a)
}

如果T是一个拷贝代价相当高昂的类型,例如string和vector,那么上述swap()操作也将煞费气力(不过对于标准库来说,我们已经针对string和vector的swap()进行了特化来处理这个问题)。注意这个奇怪的现象,我们的初衷其实并不是为了把这些变量拷来拷去,我是仅仅是想将变量a,b,tmp的值做一个“移动”(译注:即通过tmp来交换a,b的值)。

在C++11中,我们可以定义“移动构造函数(move constructors)”“移动赋值操作符(move assignments”来“移动”而非复制它们的参数:

template<class T> class vector {
        // …
        vector(const vector&);  // 拷贝构造函数
        vector(vector&&);   // 移动构造函数
        vector& operator= (const vector&); // 拷贝赋值函数
        vector& operator =(vector&&);  // 移动赋值函数
}; //注意:移动构造函数和移动赋值操作符接受
// 非const的右值引用参数,而且通常会对传入的右值引用参数作修改

”&&”表示“右值引用”右值引用可以绑定到右值(但不能绑定到左值):

X a;
X f();
X& r1 = a;        // 将r1绑定到a(一个左值)
X& r2 = f();    // 错误:f()的返回值是右值,无法绑定
X&& rr1 = f();    // OK:将rr1绑定到临时变量
X&& rr2 = a;    // 错误:不能将右值引用rr2绑定到左值a

移动赋值操作背后的思想是,“赋值”不一定要通过“拷贝”来做,还可以通过把源对象简单地“偷换”给目标对象来实现。例如对于表达式s1=s2,我们可以不从s2逐字拷贝,而是直接让s1“侵占”s2内部的数据存储(译注:比如char* p),并以某种方式“删除”s1中原有的数据存储(或者干脆把它扔给s2,因为大多情况下s2随后就会被析构)。(译注:仔细体会copy与move的区别。)

我们如何知道源对象能否“移动”呢?我们可以这样告诉编译器:(译注:通过move()操作符)

template <class T>
void swap(T& a, T& b)  //“完美swap”(大多数情况下)
{
      T tmp = move(a);  // 变量a现在失效(译注:内部数据被move到tmp中了)
      a = move(b);      // 变量b现在失效(译注:内部数据被move到a中了,变量a现在“满血复活”了)
      b = move(tmp);    // 变量tmp现在失效(译注:内部数据被move到b中了,变量b现在“满血复活”了)
}

move(x) 意味着“你可以把x当做一个右值”,把move()改名为rval()或许会更好,但是事到如今,move()已经使用很多年了。在C++11中,move()模板函数(参考“brief introduction”),以及右值引用被正式引入。

右值引用同时也可以用作完美转发(perfect forwarding)。(译注:比如某个接口函数什么也不做,只是将工作“委派”给其他工作函数)

在C++11的标准库中,所有的容器都提供了移动构造函数和移动赋值操作符,那些插入新元素的操作,如insert()和push_back(), 也都有了可以接受右值引用的版本。最终的结果是,在没有用户干预的情况下,标准容器和算法的性能都提升了,而这些都应归功于拷贝操作的减少。

右值引用
为了解决移动语义及完美转发问题,C++11标准引入了右值引用(rvalue reference)这一重要的新概念。右值引用采用T&&这一语法形式,比传统的引用T&(如今被称作左值引用 lvalue reference)多一个&。
如果把经由T&&这一语法形式所产生的引用类型都叫做右值引用,那么这种广义的右值引用又可分为以下三种类型:

  • 无名右值引用
  • 具名右值引用
  • 转发型引用

无名右值引用和具名右值引用的引入主要是为了解决移动语义问题。转发型引用的引入主要是为了解决完美转发问题。

无名右值引用
无名右值引用(unnamed rvalue reference)是指由右值引用相关操作所产生的引用类型。
无名右值引用主要通过返回右值引用的类型转换操作产生, 其语法形式如下:
static_cast<T&&>(t)
标准规定该语法形式将把表达式 t 转换为T类型的无名右值引用。
无名右值引用是右值,标准规定无名右值引用和传统的右值一样具有潜在的可移动性,即它所占有的资源可以被移动(窃取)。

std::move()
由于无名右值引用是右值,借助于类型转换操作产生无名右值引用这一手段,左值表达式就可以被转换成右值表达式。为了便于利用这一重要的转换操作,标准库为我们提供了封装这一操作的函数,这就是std::move()。
假设左值表达式 t 的类型为T&,利用以下函数调用就可以把左值表达式 t 转换为T类型的无名右值引用(右值,类型为T&&)。
std::move(t)

具名右值引用
如果某个变量或参数被声明为T&&类型,并且T无需推导即可确定,那么这个变量或参数就是一个具名右值引用(named rvalue reference)。
具名右值引用是左值,因为具名右值引用有名字,和传统的左值引用一样可以用操作符&取地址。
与广义的右值引用相对应,狭义的右值引用仅限指具名右值引用。
传统的左值引用可以绑定左值,在某些情况下也可绑定右值。与此不同的是,右值引用只能绑定右值。
右值引用和左值引用统称为引用(reference),它们具有引用的共性,比如都必须在初始化时绑定值,都是左值等等。

struct X {};  
X a;  
X&& b = static_cast<X&&>(a);  
X&& c = std::move(a);  
//static_cast<X&&>(a) 和 std::move(a) 是无名右值引用,是右值  
//b 和 c 是具名右值引用,是左值  
X& d = a;  
X& e = b;  
const X& f = c;  
const X& g = X();  
X&& h = X();  
//左值引用d和e只能绑定左值(包括传统左值:变量a以及新型左值:右值引用b)  
//const左值引用f和g可以绑定左值(右值引用c),也可以绑定右值(临时对象X())  
//右值引用b,c和h只能绑定右值(包括新型右值:无名右值引用std::move(a)以及传统右值:临时对象X())  

左右值重载策略
有时我们需要在函数中区分参数的左右值属性,根据参数左右值属性的不同做出不同的处理。适当地采用左右值重载策略,借助于左右值引用参数不同的绑定特性,我们可以利用函数重载来做到这一点。常见的左右值重载策略如下:

struct X {};  
//左值版本  
void f(const X& param1){/*处理左值参数param1*/}  
//右值版本  
void f(X&& param2){/*处理右值参数param2*/}  
  
X a;  
f(a);            //调用左值版本  
f(X());          //调用右值版本  
f(std::move(a)); //调用右值版本  

即在函数重载中分别重载const左值引用和右值引用。
重载const左值引用的为左值版本,这是因为const左值引用参数能绑定左值,而右值引用参数不能绑定左值。
重载右值引用的为右值版本,这是因为虽然const左值引用参数和右值引用参数都能绑定右值,但标准规定右值引用参数的绑定优先度要高于const左值引用参数。

移动构造器和移动赋值运算符
在类的构造器和赋值运算符中运用上述左右值重载策略,就会产生两个新的特殊成员函数:移动构造器(move constructor)和移动赋值运算符(move assignment operator)。

struct X  
{  
    X();                         //缺省构造器  
    X(const X& that);            //拷贝构造器  
    X(X&& that);                 //移动构造器  
    X& operator=(const X& that); //拷贝赋值运算符  
    X& operator=(X&& that);      //移动赋值运算符  
};  
  
X a;                             //调用缺省构造器  
X b = a;                         //调用拷贝构造器  
X c = std::move(b);              //调用移动构造器  
b = a;                           //调用拷贝赋值运算符  
c = std::move(b);                //调用移动赋值运算符  

移动语义
无名右值引用和具名右值引用的引入主要是为了解决移动语义问题。
移动语义问题是指在某些特定情况下(比如用右值来赋值或构造对象时)如何采用廉价的移动语义替换昂贵的拷贝语义的问题。
移动语义(move semantics)是指某个对象接管另一个对象所拥有的外部资源的所有权。移动语义需要通过移动(窃取)其他对象所拥有的资源来完成。移动语义的具体实现(即一次that对象到this对象的移动(move))通常包含以下若干步骤:

  • 如果this对象自身也拥有资源,释放该资源
  • 将this对象的指针或句柄指向that对象所拥有的资源
  • 将that对象原本指向该资源的指针或句柄设为空值

上述步骤可简单概括为①释放this(this非空时)②移动that。
移动语义通常在移动构造器和移动赋值运算符中得以具体实现。两者的区别在于移动构造对象时this对象为空,因而①释放this无须进行。

与移动语义相对,传统的拷贝语义(copy semantics)是指某个对象拷贝(复制)另一个对象所拥有的外部资源并获得新生资源的所有权。拷贝语义的具体实现(即一次that对象到this对象的拷贝(copy))通常包含以下若干步骤:

  • 如果this对象自身也拥有资源,释放该资源
  • 拷贝(复制)that对象所拥有的资源
  • 将this对象的指针或句柄指向新生的资源
  • 如果that对象为临时对象(右值),那么拷贝完成之后that对象所拥有的资源将会因that对象被销毁而即刻得以释放

上述步骤可简单概括为①释放this(this非空时)②拷贝that③释放that(that为右值时)
拷贝语义通常在拷贝构造器和拷贝赋值运算符中得以具体实现。两者的区别在于拷贝构造对象时this对象为空,因而①释放this无须进行。

比较移动语义与拷贝语义的具体步骤可知,在赋值或构造对象时,

  • 如果源对象that为左值,由于两者效果不同(移动that ≠ 拷贝that),此时移动语义不能用来替换拷贝语义。
  • 如果源对象that为右值,由于两者效果相同(移动that = 拷贝that + 释放that),此时廉价的移动语义(通过指针操作来移动资源)便可以用来替换昂贵的拷贝语义(生成,拷贝然后释放资源)。

由此可知,只要在进行相关操作(比如赋值或构造)时,采取适当的左右值重载策略区分源对象的左右值属性,根据其左右值属性分别采用拷贝语义和移动语义,移动语义问题便可以得到解决。

下面用MemoryBlock这个自我管理内存块的类来具体说明移动语义问题。

#include <iostream>  
  
class MemoryBlock  
{  
public:  
  
    // 构造器(初始化资源)  
    explicit MemoryBlock(size_t length)  
        : _length(length)  
        , _data(new int[length])  
    {  
    }  
  
    // 析构器(释放资源)  
    ~MemoryBlock()  
    {  
        if (_data != nullptr)  
        {  
            delete[] _data;  
        }  
    }  
  
    // 拷贝构造器(实现拷贝语义:拷贝that)  
    MemoryBlock(const MemoryBlock& that)  
        // 拷贝that对象所拥有的资源  
        : _length(that._length)  
        , _data(new int[that._length])  
    {  
        std::copy(that._data, that._data + _length, _data);  
    }  
  
    // 拷贝赋值运算符(实现拷贝语义:释放this + 拷贝that)  
    MemoryBlock& operator=(const MemoryBlock& that)  
    {  
        if (this != &that)  
        {  
            // 释放自身的资源  
            delete[] _data;  
  
            // 拷贝that对象所拥有的资源  
            _length = that._length;  
            _data = new int[_length];  
            std::copy(that._data, that._data + _length, _data);  
        }  
        return *this;  
    }  
  
    // 移动构造器(实现移动语义:移动that)  
    MemoryBlock(MemoryBlock&& that)  
        // 将自身的资源指针指向that对象所拥有的资源  
        : _length(that._length)  
        , _data(that._data)  
    {  
        // 将that对象原本指向该资源的指针设为空值  
        that._data = nullptr;  
        that._length = 0;  
    }  
  
    // 移动赋值运算符(实现移动语义:释放this + 移动that)  
    MemoryBlock& operator=(MemoryBlock&& that)  
    {  
        if (this != &that)  
        {  
            // 释放自身的资源  
            delete[] _data;  
  
            // 将自身的资源指针指向that对象所拥有的资源  
            _data = that._data;  
            _length = that._length;  
  
            // 将that对象原本指向该资源的指针设为空值  
            that._data = nullptr;  
            that._length = 0;  
        }  
        return *this;  
    }  
private:  
    size_t _length; // 资源的长度  
    int* _data; // 指向资源的指针,代表资源本身  
};  
  
MemoryBlock f() { return MemoryBlock(50); }  
  
int main()  
{  
    MemoryBlock a = f();            // 调用移动构造器,移动语义  
    MemoryBlock b = a;              // 调用拷贝构造器,拷贝语义  
    MemoryBlock c = std::move(a);   // 调用移动构造器,移动语义  
    a = f();                        // 调用移动赋值运算符,移动语义  
    b = a;                          // 调用拷贝赋值运算符,拷贝语义  
    c = std::move(a);               // 调用移动赋值运算符,移动语义  
}  

转发型引用
如果某个变量或参数被声明为T&&类型,并且T需要经过推导才可确定,那么这个变量或参数就是一个转发型引用(forwarding reference)。
转发型引用由以下两种语法形式产生,

  • 如果某个变量被声明为auto&&类型,那么这个变量就是一个转发型引用
  • 在函数模板中,如果某个参数被声明为T&&类型,并且T是一个需要经过推导才可确定的模板参数类型,那么这个参数就是一个转发型引用

转发型引用是不稳定的,它的实际类型由它所绑定的值来确定。
转发型引用既可以绑定左值,也可以绑定右值。
如果绑定左值,转发型引用就成了左值引用。
如果绑定右值,转发型引用就成了右值引用。
转发型引用在被C++标准所承认之前曾经被称作万能引用(universal reference)。万能引用这一术语的发明者,Effective C++系列的作者Scott Meyers认为,如此异常灵活的引用类型不属于右值引用,它应该拥有自己的名字。
对于某个转发型引用类型的变量(auto&&类型)来说,

  • 如果初始化表达式为左值(类型为U&),该变量将成为左值引用(类型为U&)。
  • 如果初始化表达式为右值(类型为U&&),该变量将成为右值引用(类型为U&&)。

对于函数模板中的某个转发型引用类型的形参(T&&类型)来说,

  • 如果对应的实参为左值(类型为U&),模板参数T将被推导为引用类型U&,该形参将成为左值引用(类型为U&)。
  • 如果对应的实参为右值(类型为U&&),模板参数T将被推导为非引用类型U,该形参将成为右值引用(类型为U&&)。
struct X {};  
X&& var1 = X();                            // var1是右值引用,只能绑定右值X()  
auto&& var2 = var1;                        // var2是转发型引用,可以绑定左值var1  
                                           // var2的实际类型等同于左值var1,即X&  
auto&& var3 = X();                         // var3是转发型引用,可以绑定右值X()  
                                           // var3的实际类型等同于右值X(),即X&&  
template<typename T>  
void g(std::vector<typename T>&& param1);  // param1是右值引用  
template<typename T>  
void f(T&& param2);                        // param2是转发型引用  
  
X a;  
f(a);                // 模板函数f()的形参param2是转发型引用,可以绑定左值a  
                     // 在此次调用中模板参数T将被推导为引用类型X&  
                     // 而形参param2的实际类型将等同于左值a,即X&  
f(X());              // 模板函数f()的形参param2是转发型引用,可以绑定右值X()  
                     // 在此次调用中模板参数T将被推导为非引用类型X  
                     // 而形参param2的实际类型将等同于右值X(),即X&&  
  
// 更多右值引用和转发型引用  
const auto&& var4 = 10;                           // 右值引用  
template<typename T>  
void h(const T&& param1);                         // 右值引用  
template <typename T/*, class Allocator = allocator*/>  
class vector  
{  
public:  
    void push_back( T&& t );                      // 右值引用  
    template <typename Args...>  
    void emplace_back( Args&&... args );          // 转发型引用  
};  

完美转发
完美转发(perfect forwarding)问题是指函数模板在向其他函数转发(传递)自身参数(形参)时该如何保留该参数(实参)的左右值属性的问题。也就是说函数模板在向其他函数转发(传递)自身形参时,如果相应实参是左值,它就应该被转发为左值;同样如果相应实参是右值,它就应该被转发为右值。这样做是为了保留在其他函数针对转发而来的参数的左右值属性进行不同处理(比如参数为左值时实施拷贝语义;参数为右值时实施移动语义)的可能性。如果将自身参数不分左右值一律转发为左值,其他函数就只能将转发而来的参数视为左值,从而失去针对该参数的左右值属性进行不同处理的可能性。

转发型引用的引入主要是为了解决完美转发问题。在函数模板中需要保留左右值属性的参数,也就是要被完美转发的参数须被声明为转发型引用类型,即参数必须被声明为T&&类型,而T必须被包含在函数模板的模板参数列表之中。按照转发型引用类型形参的特点,该形参将根据所对应的实参的左右值属性而分别蜕变成左右值引用。但无论该形参成为左值引用还是右值引用,该形参在函数模板内都将成为左值。这是因为该形参有名字,左值引用是左值,具名右值引用也同样是左值。如果在函数模板内照原样转发该形参,其他函数就只能将转发而来的参数视为左值,完美转发任务将会失败。

#include<iostream>  
using namespace std;  
  
struct X {};  
void inner(const X&) {cout << "inner(const X&)" << endl;}  
void inner(X&&) {cout << "inner(X&&)" << endl;}  
template<typename T>  
void outer(T&& t) {inner(t);}  
  
int main()  
{  
    X a;  
    outer(a);  
    outer(X());  
}  
//inner(const X&)  
//inner(const X&)  

std::forward()
要在函数模板中完成完美转发转发型引用类型形参的任务,我们必须在相应实参为左值,该形参成为左值引用时把它转发成左值,在相应实参为右值,该形参成为右值引用时把它转发成右值。此时我们需要标准库函数std::forward()。
标准库函数 std::forward<T>(t) 有两个参数:模板参数 T 与 函数参数 t。函数功能如下:

  • 当T为左值引用类型U&时,t 将被转换为无名左值引用(左值,类型为U&)。
  • 当T为非引用类型U或右值引用类型U&&时,t 将被转换为无名右值引用(右值,类型为U&&)。

使用此函数,我们在函数模板中转发类型为T&&的转发型引用参数 t 时,只需将参数 t 替换为std::forward<T>(t)即可完成完美转发任务。这是因为,

  • 如果 t 对应的实参为左值(类型为U&),模板参数T将被推导为引用类型U&,t 成为具名左值引用(类型为U&),std::forward<T>(t)就会把 t 转换成无名左值引用(左值,类型为U&)。
  • 如果 t 对应的实参为右值(类型为U&&),模板参数T将被推导为非引用类型U,t 成为具名右值引用(类型为U&&),std::forward<T>(t)就会把 t 转换成无名右值引用(右值,类型为U&&)。
#include<iostream>  
using namespace std;  
  
struct X {};  
void inner(const X&) {cout << "inner(const X&)" << endl;}  
void inner(X&&) {cout << "inner(X&&)" << endl;}  
template<typename T>  
void outer(T&& t) {inner(forward<T>(t));}  
  
int main()  
{  
    X a;  
    outer(a);  
    outer(X());  
}  
//inner(const X&)  
//inner(X&&)  

参考资料
右值引用与移动构造函数 | cpp11新特性详解与应用
C++11尝鲜:右值引用和转发型引用
右值引用 | c++11 FAQ 中文版
cpp11 sniper

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容