谈谈C++的左值右值,左右引用,移动语意及完美转发

转载自

谈谈C++的左值右值,左右引用,移动语意及完美转发 - 知乎 (zhihu.com)

 

题记

写了VS2019下STL剖析的std::any,std::bind,std::string,std::tuple四文,但tuple那一文没有多少人看,也没有什么赞。tuple我挺认真的写了,就觉得奇怪,问了几个朋友,说写的不够简单与浅出,STL源码中一些写法本身就看不懂,第一章节就抛出那么多技术点,一下就流失了,所以想试试写一篇基础一些的,尽量举例与通俗些。

为了更好理解语法,建议用clang++编译器,msvc容易魔改和强兼容,并关闭掉构造函数优化:-fno-elide-constructors,因为一些情况下编译器可以跳过拷贝,移动构造函数而直接创造对象。

为了更好理解语法,建议用clang++编译器,msvc容易魔改和强兼容,并关闭掉构造函数优化:-fno-elide-constructors,因为一些情况下编译器可以跳过拷贝,移动构造函数而直接创造对象。

  • 谈谈C++的左值右值,左右引用,移动语意及完美转发
    • 题记
    • 一、左值和右值
      • 1.1 讨论左右值
      • 1.2 认识左右值
    • 二、左值引用与右值引用
      • 2.1 普通左右引用
      • 2.2 特别的const T&
      • 2.3 左值转为右值引用
    • 三、移动语意与完美转发
      • 3.1 探讨深浅拷贝构造的不足
      • 3.2 引入C++11的移动语意
      • 3.3 std::move作用与源码中的引用折叠
      • 3.4 万能引用与std::forward完美转发
      • 3.5 评论区的static_cast<T&&>与std::forward替换讨论
    • 结语

一、左值和右值

1.1 讨论左右值

C++的表达式化分为三种,左值(lvalue),纯右值(prvaule),亡值(xvalue),一般而言将纯右值与亡值合一起称为右值。通常左值,右值是是针对表达式的

实际上左值与右值并不是什么新引入概念,以前就有,有的说是来自C语言时在等号左边是是左值,右边是右值,也有的说能取地址的是左值,不能取地址的(放寄存器中值)是右值,也有的说有名字的是左值,没有名字的是右值,但这几种说法都可以轻松被推翻。

1.反第一种说法:

1 int a = 3; // a是左值,3是右值
2 int b; // b是左值
3 // a在等号右边,但a是左值,可以取地址
4 b = a; 
5 // "Hello Word"在等号右边,字符串字面量,是左值
6 const char* pStr = "Hello Word";

 

 

 

 2.反第二种说法,首先右值也可以放内存中的,取地址这点还是有点争议的,看一下代码

 1 class TestClassA {
 2     int m_iSize;
 3     char* m_pkData;
 4 };
 5 
 6 // 注意要求传入的rValue是右值,函数体内右值引用实际是左值
 7 // 所以能对rValue取地址,这点后面第三章节完美转发还会再详细讨论
 8 TestClassA* operator& (TestClassA&& rValue) 
 9 {
10     return &rValue;
11 }
12 
13 // 本来TestClassA()生成临时变量,是右值,但被“&”操作了。
14 // 这里我们手动重载了&,所以标准说法是不能用内置&取地址
15 TestClassA* pkA = &TestClassA(100);

3.反第三种说法,看一下代码

 1 // 字符串字面量没有名字,是左值,
 2 // 我们可以这样直接原始取地址
 3 &("Hello World");
 4 
 5 enum eTestEnum
 6 {
 7     TE_ZERO = 0,
 8     TE_ONE,
 9     TE_TWO,
10 };
11 // 下面表达式,TE_TWO是一个枚举项,算是有名字,实际是右值
12 eTestEnum en1 = TE_TWO;

1.2 认识左右值

所以说了那么多,灵魂拷问一下:到底什么是左值?什么是右值?

我也想找一种特别有效的区分左右值的方法,找了很久,读了不少资料,暂时还没有发现特别行之有效的手段,甚至编译器对同样定义,左右值认定随版本都发生过变化。暂时理解认为左值是在内存的表达式,能够用内置的&进行取地址,其它可以算右值,右值更多的是一种“值”的表达,其中广义上右值又包含了纯右值与亡值,亡值一般就是我们说的生命周期即将结束的表达式,上面的TestClassA(100)是一个典型代表。参考已有资料,咱们也总结一些常见可能搞混的点。

容易错误的左值:

1.字符串字面量,如:"Hello"
2.内置的前++与前--,如:++a
3.变量类型是右值引用的表达式,如:TestClassA&& ra = TestClassA(1000);,ra这里是左值,这个特别重要,后面涉及万能引用与完美转发,第三章细讲
4.转型为左值引用的表达式,如:static_cast<double&>(fValue);
5.内置*解引用的表达式,如:*pkValue

容易错误的右值:

1.非字符串的字面量以及枚举项,如:nullptr,true
2.内置的后++与后--,如:a--
3.内置的算术,逻辑,比较表达式,如:a+ba&ba||ba<b
4.内置取地址表达式,this指针,如:&a
5.lamda表达式,如:[](int a){ return 2*a; }
6.转型为非引用的表达式,如:static_cast<double>(fValue)(float)42
7.转型为右值引用的表达式,如:static_cast<double&&>(fValue)std::move(x);

 

二、左值引用与右值引用

2.1 普通左右引用

引用是变量的别名,必初始化,C++引入的,C语言只有指针,没有引用。引用操作从反汇编层面看可以说完全指针一样,从使用层面来说的确降低大家都指针的理解成本,传参时引用本意减少拷贝,提高性能,但由于是编译器的内部转为为指针,有时比指针的灵活性弱一点点,像原来构造函数const T&左引用存在一定的缺陷,右值引用带来的移动语义就是来弥补。

指向左值的引用就是左引用,我们单个&来表示,C++11前一直使用的;对右值的引用是右引用,我们用&&来表示,如下面代码示例:

 1 int a1 = 100;
 2 // c1对左值a1引用,左引用
 3 int& c1 = a1;
 4 // b1对右值200的引用,右引用
 5 int&& b1 = 200;
 6 // rkTA1对右值TestClassA(1000)进行右引用
 7 TestClassA&& rkTA1 = TestClassA(1000);
 8 // 无法对右值( a1++)进行左引用,编译失败
 9 int& c2 = a1++; // error
10 // 无法对左值a1进行直接右引用,编译失败
11 int&& b2 = a1; // error
12 // 无法对右值TestClassA(1000)进行左引用,编译失败
13 TestClassA& rkTA2 = TestClassA(1000); // error

我们可以看到,正常情况下左右引用只能处理对应的左右值,不能随意配对,否则编译失败。

2.2 特别的const T&

有没有直接对右值进行左引用的?还真有,那就是const T&,常量左引用,能接受右值,对右值进行这种形式左引用写法也不少,其生命周期被延续。

1 // 对右值101的常量左引用
2 const int& a2 = 101;
3 // 对右值TestClassA(1000)进行常量左引用
4 const TestClassA& rkTA2 = TestClassA(1000);

以前老师傅对我们说,如果C++中,函数传参,不改变参数时,尤其是大数据,尽量使用const T&。我们常用的拷贝构造函数T(const T&)参数是这个形式,vector容器的函数push_back(const value_type& val)参数也是,有没有注意到,这类函数同时也是接受右值的,简单比如:

1 // 函数TestClassAFunc1参数为const T&形式,可以接受右值
2 void TestClassAFunc1(const TestClassA& refTA)
3 {
4     std::cout << "TestClassAFunc1" << refTA.m_iSize << std::endl;
5 }
6 // TestClassA(1001)是右值,能够编译
7 TestClassAFunc1(TestClassA(1001));

2.3 左值转为右值引用

对于右值来说,不能真正取地址,引用实质就是操作地址指针,理论上无法进行左引用。对于左值来说,可以取地址,虽然无法直接进行右引用,但可以间接进行右引用,如下方法。

 1 //可以将左值转为右值,再进行右引用
 2 TestClassA kTA2(1000);
 3 // 使用std::move转为右值引用
 4 TestClassA&& c3 = std::move(kTA2);
 5 // 使用static_cast转为右值引用
 6 TestClassA&& c4 = static_cast<TestClassA&&>(kTA2);
 7 // 使用C风格强转为右值引用
 8 TestClassA&& c5 = (TestClassA&&)kTA2;
 9 // 使用std::forwad<T&&>为右值引用
10 TestClassA&& c6 = std::forward<TestClassA&&>(kTA2);

三、移动语意与完美转发

前面都算是介绍一些概念,我们回到这些概念的更核心思考上。介绍前先说我理解的观点铺垫一下:移动语意对带有资源托管的对象,资源可以转移的且转移后能保证安全的,特别适合,还能降低析构复杂度,一些天生具有不可复制性对象也特别适合,比如unique_ptr。通俗理解就是一些带资源的对象需要拷贝时,想要有浅拷贝的效率,还想要深拷贝析构时安全的效果,正所谓鱼和熊掌不可兼得,那么被拷贝者资源权转给拷贝者,自己不再使用,来保证安全和高效。其它对象,移动语意可能就没有什么明显的优势

3.1 探讨深浅拷贝构造的不足

以TestClassA为例,讨论浅拷贝,深拷贝,移动语意的拷贝,先看一下C++11前的代码:

 

// TestClassA类
class TestClassA 
{
public:
    TestClassA(int iSize):m_iSize(iSize)
    {
        m_pkData = new char[m_iSize];
    }

    TestClassA(const TestClassA& rTA):m_iSize(rTA.m_iSize)
    {
        m_pkData = new char[m_iSize];
        memcpy(m_pkData,rTA.m_pkData,m_iSize);
    }

    ~TestClassA()
    {
        if(m_pkData)
        {
            delete []m_pkData;
            m_pkData = nullptr;
        }
    }

    int m_iSize;
    char* m_pkData;
};

这个例子是一个具有资源拖管的例子,何为是拖管,就是我们只记录资源Data的指针,而非记录资源全部信息到我们类对象里面。构造时我们从堆上用new申请内存供资源后面使用,析构时我们从堆上释放内存,回收掉资源,中间过程资源读写操作都通过指针m_pkData来完成。C++对象构造与析构函数的成对调用,也保证了安全性与不发生泄露。

1.先说浅拷贝,我们重写一下TestClassA

 
 1 // TestClassA类
 2 class TestClassA 
 3 {
 4 public:
 5     TestClassA(int iSize):m_iSize(iSize)
 6     {
 7         m_pkData = new char[m_iSize];
 8     }
 9 
10     TestClassA(const TestClassA& rTA):m_iSize(rTA.m_iSize)
11     {
12         m_pkData = new char[m_iSize];
13         memcpy(m_pkData,rTA.m_pkData,m_iSize);
14     }
15 
16     ~TestClassA()
17     {
18         if(m_pkData)
19         {
20             delete []m_pkData;
21             m_pkData = nullptr;
22         }
23     }
24 
25     int m_iSize;
26     char* m_pkData;
27 };

这个例子是一个具有资源拖管的例子,何为是拖管,就是我们只记录资源Data的指针,而非记录资源全部信息到我们类对象里面。构造时我们从堆上用new申请内存供资源后面使用,析构时我们从堆上释放内存,回收掉资源,中间过程资源读写操作都通过指针m_pkData来完成。C++对象构造与析构函数的成对调用,也保证了安全性与不发生泄露。

1.先说浅拷贝,我们重写一下TestClassA

 1 class TestClassA 
 2 {
 3 public:
 4     TestClassA(int iSize):m_iSize(iSize)
 5     {
 6         m_pkData = new char[m_iSize];
 7     }
 8     ~TestClassA()
 9     {
10         if(m_pkData)
11         {
12             delete []m_pkData;
13             m_pkData = nullptr;
14         }
15     }
16     int m_iSize;
17     char* m_pkData;
18 };

当我们写出执行代码时:

 1 TestClassA a1(999); 2 TestClassA a2(a1); 

用a1对a2进行拷贝初始化,我们没有手动写出拷贝构造函数,编译器会帮我们自动生成,直接将数据直接复制,称为浅拷贝。将a1中的m_iSize与m_pkData直接拷贝到a2中,a2的m_pkData并没有真正new内存,两者共用一块内存地址,当最后都析构时,这块内存会被析构两次,发生crash,这显然不是我们想要的,于是我们需要深拷贝来解决。

2.说说深拷贝,就是本章开头代码中手动实现的拷贝构造函数:TestClassA(const TestClassA& rTA)

执行TestClassA a2(a1);时,手写拷贝构造函数会被调用,a2会申请一块大小为a1.m_iSize新的内存,然后将
a1.m_pdData对应内存数据一个一个拷贝过来,就是完全深入的复制了一遍。在析构时,各自释放自己的内存,不会有安全问题,所以C++11之前都是这么做的。

这里假设m_iSize为100万,假设一个字节一个字节拷贝,那么就要进行100万次内存到寄存器再到内存的操作,这是很慢的,随m_iSize越大,我们付出的代价越高,假设我们a1内存不再访问,这种深拷贝是不是有点浪费?

3.2 引入C++11的移动语意

举个生活的例子,小明今年3岁了,爸妈响应号召,又给他添加了一个弟弟,现在4个月了,需要换一个66码的衣服,这时可以选择新买一件,也可以选择穿小明小时候留下来的。重新买需要花钱,有一定经济成本,相当于我们刚才的深拷贝,穿小明的,相当于废物利用,重新焕发价值,不花钱也不产生家庭经济压力,就相当于我们的移动语意,一个对象的资源在销毁前,我们将其转移给其它对象再用起来,这样能减少资源带来的构造开销,程序获得更高的效能。

在C++11前我们模拟一下这个过程,再加一个构造函数

 1 class TestClassA 
 2 {
 3 public:
 4     ......
 5     TestClassA(TestClassA& rTA,bool bMove = false):m_iSize(rTA.m_iSize)
 6     {
 7         if(bMove)
 8         {
 9             m_pkData = rTA.m_pkData;
10             rTA.m_pkData = nullptr;
11         }
12         else
13         {
14             m_pkData = new char[m_iSize];
15             memcpy(m_pkData,rTA.m_pkData,m_iSize);
16         }
17     }
18 };
19 
20 TestClassA a2(a1,true);

构造时如果需要“移动”构造,我们将第二个参数bMove设为true,将资源指针从a1直接转移到a2,然后置空a1的资源指针,a2没有进行内存申请与拷贝,保持高效;当不需要移动构造,直接传为false,走原来的深拷贝流程。功能没有问题,为了照顾“移动”构造,第一个参数不能为const T&引用,一旦加了const就不能将传入对象rTA的m_pkData指针置空,不加const又不能接受像TestClassA a2(TestClassA(1000),true);右值写法;同时这种写法还多了像尾巴一样的bool参数;另外不加const,a1是const对象也不能编译,这样我们还是再要写一个const T&的构造,这问题又变的糟乱了。

所以C++11直接统一,增加&&右值引用,简事情变简单,想要深拷贝的就T(const T&)实现;想要移动拷贝的就实现T(T&&)。两者都实现时,一旦传入是个右值,就优先触发移动拷贝构造调用,使用移动语意,转移资源,减少拷贝。

 1 class TestClassA 
 2 {
 3 public:
 4     ......
 5     // 增加移动构造,方便使用移动语意
 6     TestClassA(TestClassA&& rTA):
 7         m_iSize(rTA.m_iSize),
 8         m_pkData(rTA.m_pkData)
 9     {
10         rTA.m_pkData =nullptr;
11     }
12 };
13 // 用右值TestClassA(1000)来触发a1的移动构造
14 TestClassA a1(TestClassA(1000));

从C++11开始,如果我们没有手写移动拷贝构造函数,编译器会不会帮我们生成一个呢?只有下面4个函数同时没有手动定义时,编译器才会帮我们生成移动拷贝构造函数,原型为:T(const T& )

  • 1.复制构造函数
  • 2.复制赋值运算符
  • 3.移动赋值运算符
  • 4.析构函数

3.3 std::move作用与源码中的引用折叠

既然移动语意有时那么好,原来是个左值,在某些情景下又想后绪触发移动语意怎么办呢?用我们前面2.3小节中左值转为右值引用方法,std::move是典型的一种,构造时就能触发移动语意的调用了。这么看来原来C++11并不是为了有意增加复杂度的,而是为了某些情景下所做的功能与优化,也就是你不在意这些情景的情能消耗的话,完全可以选择不用。

1 // 用std::move(a2)将左值a2转换成右引用,从而
2 // 能触发a3的移动构造
3 TestClassA a2(1000);
4 TestClassA a3(std::move(a2));

2.3节我们也给了几个方法转为右值引用,我们来看看std::move实现,取自clang++里面源码

1 template<typename _Tp>

2 constexpr typename std::remove_reference<_Tp>::type&&

3 move(_Tp&& __t) noexcept

4 { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); } 

move是模板函数,模板参数_Tp,传入函数参数__t时,自然能推导出参数类型_Tp,不过其强转与返回值类型却是typename std::remove_reference<_Tp>::type&&为什么呢?因为C++的引用折叠机制,有下面4条规则

  • T& & 折叠成 T&
  • T& && 折叠成 T&
  • T&& & 折叠成 T&
  • T&& && 折叠成 T&&

如果传入的__t本身就是一个左引用,强转也会添加&&右引用,最后会折叠为左引用,显然不是我们想要的,一旦用remove_reference去掉引用后,我们再加上&&,就能明确是右引用了,是不错!里面函数体也很简单,就是直接static_cast强转,可以看出并没有做什么操作,也没有高大上的写法,只上转为右引用后,可以让我们后绪触发移动语意的相关函数,比如移动构造,移动赋值构造。move主要针对本身是左值的表达式转为右引用,如果本身是右值,加上也没有什么意义,到这std::move就说清了。

3.4 万能引用与std::forward完美转发

我们在1.2中说到一个右值引用,其表达式是左值,可能大家还没有建立深刻的印象,我们看下面代码:

 1 void TestLValueOrRValue(TestClassA& rA)
 2 {
 3     std::cout << "left Value " << rA.m_iSize << std::endl;
 4 }
 5 void TestLValueOrRValue(TestClassA&& rA)
 6 {
 7     std::cout << "right Value " << rA.m_iSize << std::endl;
 8 }
 9 void TestFun()
10 {
11     TestLValueOrRValue(TestClassA(1111));
12     TestClassA&& ra2222 = TestClassA(2222);
13     TestLValueOrRValue(ra2222);
14 }
15 // 执行TestFun
16 TestFun();

TestClassA(1111)本身是右值,会调用TestLValueOrRValue右值引用版本,所以第一处打印是right Value 1111。对于第二处,ra2222是右值TestClassA(2222)的引用,但ra2222这个表达式本身是个左值,所以会调用TestLValueOrRValue左值引用版本,打印为:left Value 2222。同理第一处时,在TestLValueOrRValue函数内部表达式rA实际也已经成了左值,如果在里面再调一次TestLValueOrRValue(rA)会打印:left Value 1111,有兴趣的可以试一下。

TestFun输出结果: right Value 1111 , left Value 2222

问题来了,如果函数体内还想保持右值引用,怎么办呢,老办法,std::move(rA)一下就可以了;但有时也需要保留传左值引用,如果用了move会将左引用改成右引用,想函数传进来是左引用,函数体内也保持左引用,传进来是右引用,函数体内也保持右引用,想要万金油啊,但有没有办法呢?答案是:还真有,模板函数的万能引用与完美转发来了。

万能引用写法如下:

 1 template<typename T>
 2 void func1(T&& param)
 3 {
 4     ......
 5 };
 6 template<typename ...T>
 7 void func2( T&&... params)
 8 {
 9     ......
10 };

且有下面规定:

1.万能限定必须是函数模板,可以模板参数是单个,也可以是多个模板参数,形式为T&&
2.万能引用可以接受左值,也可以接受右值,而不像前面普通函数TestLValueOrRValue一样,必须与左右引用形式对应
3.万能引用的T不能被再修饰,否则转为普通右值引用,不能被cv修饰限定
4.如果想在模板类中的模板函数使用万能引用,不能使用模板类的参数,否则转为普通右值引用,如下代码样例:
1 template<class T> 
2 struct Test 
3 {
4 // a参数来源T,源自模板类,不是万能引用,为右值引用
5 // b参数来源U,源自模板函数,是万能引用
6 template<class U>
7 A(T&& a, U&& b, int c);
8 }

有了前面的万能引用,实现了接受左右引用,函数体内要有个类似std::move()再保持原来引用关系就完美了,std::forward()来了,就是因为能完美正确的保持原来的左右引用关系并向下层次转化,不再因右引用表达式是左值从而变掉,所以叫完美转发,到这大家估计理解了为什么叫完美转发,这个中文翻译太棒了。

 1 // 处理左值作为左引用或者右引用
 2 template<typename _Tp>
 3 constexpr _Tp&&
 4 forward(typename std::remove_reference<_Tp>::type& __t) noexcept
 5 { return static_cast<_Tp&&>(__t); }
 6 
 7 // 处理右值作为右引用
 8 template<typename _Tp>
 9 constexpr _Tp&&
10 forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
11 {
12     static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
13         " substituting _Tp is an lvalue reference type");
14     return static_cast<_Tp&&>(__t);
15 }    

3.5 评论区的static_cast<T&&>与std::forward替换讨论

感谢评论区 

 提出一个问题,也很值得讨论。就是标准库里面的实现,都是static_cast<_Tp&&>(__t),我能不能直接代替呢? 这个问题个人感觉挺复杂的,我的观点是在模板这种万能引用作用范围内,基本上可以直接替换,但std::forward更安全,可以直接编译期检查出不合理隐式转换的代码,进行报错。因为这种情况下,涉及到对临时的右值进行左引用了,这是不安全的。不过我测试一下clang++和MSVC默认情况下对static_cast<_Tp&&>(__t)都给出了敬告:

 

clang++:warning: returning reference to local temporary object [-Wreturn-stack-address]
MSVC:warning C4172: returning address of local variable or temporary

看一下测试代码:

 1 template <class _Ty>
 2 constexpr _Ty&& MyForward(
 3     remove_reference_t<_Ty>& _Arg) noexcept {
 4     return static_cast<_Ty&&>(_Arg);
 5 }
 6 
 7 template <class _Ty>
 8 constexpr _Ty&& MyForward(remove_reference_t<_Ty>&& _Arg) noexcept { 
 9     //static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
10     return static_cast<_Ty&&>(_Arg);
11 }
12 
13 template<class T1, class T2>
14 T1&& StaticCastFun(T1&& a1, T2&& a2) 
15 {
16     return static_cast<T1&&>(a2);
17 }
18 
19 template<class T1, class T2>
20 T1&& ForwardCastFun(T1&& a1, T2&& a2) 
21 {
22     return MyForward<T1>(a2);
23 }
24 
25 struct TypeB{};
26 
27 struct TypeA
28 {
29     TypeA() { cout << "Default  Create" << endl;}
30     TypeA(const TypeA& ) { cout << "From TypeA  Create" << endl; }
31     ~TypeA() { cout << "Del" << endl;}
32     TypeA(const TypeB&) { cout << "From TypeB  Create" << endl; }
33 };
34 void ForwardTypeTest(const TypeA a,  const TypeB b)
35 {
36     cout << "StaticCastFun" << endl;
37     StaticCastFun(a, b);
38     cout << "ForwardCastFun" << endl;
39     ForwardCastFun(a, b);
40 }
41 
42 int main()
43 {
44     TypeA ta1;
45     TypeB tb1;
46     ForwardTypeTest(ta1,tb1);
47     return 0;
48 }

我将完美转发std::forward拷贝过来,改成MyForward,因为我们对隐式的对右值进行了左引用,不安全,直接触发了static_assert(!is_lvalue_reference_v<_Ty>编译期断言,为了编译过,测试代码注掉了这一行,从这里可以看出最好还是用std::forward。

略为分析一下代码:TypeA能用TypeB进行构造,主要靠TypeA(const TypeB&)这个带参的构造,没有这个转换就不能讨论这个话题了。在StaticCastFun中,直接用static_cast<T1&&>(a2)将TypeB对象构造出一个临时TypeA对象,并当左引用返回,这是很不安全的,只有一层,编译器也能发现。对于ForwardCastFun里面调了MyForward,这是一个显示的实例化模板函数,形式为MyForward<const TypeA&>(const TypeA&&),a2先临时构造出TypeA的对象,为一个亡值,也就是右值,再传入MyForward中,因些能触发static_assert断言,如果去掉断言能编译过,经过MyForwad函数绕了一层,编译器也无法发现这个错误了,想必标准库大佬以前发现过这个问题加上断言。

 

 

 

posted @ 2022-01-05 00:28  mcwhirr  阅读(485)  评论(0)    收藏  举报