Jinhao 2009年07月06日
加精
大家都开始C++0x了,我也来凑热闹,今天的主题是《调侃rvalue-reference》
和所有的故事一样,先来一个起因。

话说从C到C++,都有左值和右值的概念,来满足语义的需要。这与变量/对象无关,是用来解释一个表达式的类型。

int foo();
int *p = &foo(); //#1
p = &1; //#2


明显地,右值不能取地址。在C++中,只有const-ref才能绑定右值。例如

int &a = 0; //错误
const int &b = 0; //正确


这样看起来,右值是无法被修改的,但事实上,右值是允许被修改的,但是因为绑定到const-ref则失去修改的能力。


class T
{
public:
T():i(0){}

T& set()
{
i = 5;
return *this;
}

int value() const
{
return i;
}
private:
int i;
};

int x = T().set().value();



x得到5,在这个临时对象结束之前,我们修改了它的值,并正确得到了这个值,然后分号之后,这个临时对象被销毁。既然临时对象能被修改为什么不能用non-const-ref绑定呢?原因很简单。

int & r = int();
r = 5; //r引用的临时对象已经失效了,分号之后就已经销毁了。
const int &cr = int();
int x = cr;


此时cr引用的临时对象仍然存在,该临时对象的生命期已经延长到和cr相同。cr什么时候结束,这个临时对象就在什么时候被销毁。但有时候只能用 const-ref绑定临时对象实在是很痛苦的。例如std::auto_ptr就是一个典型的例子。两个auto_ptr对象赋值,实参对象会把资源转移到目标对象。

std::auto_ptr<int> a(new int);
std::auto_ptr<int> b = a;

之后,b将引用动态分配的int对象,a则断开拥有权。也就是说拷贝构造函数会修改参数对象。因此,auto_ptr的拷贝构造函数和赋值操作符的参数类型都是使用的auto_ptr&而不是const auto_ptr&。而对于

std::auto_ptr<int> foo()
{
return std::auto_ptr<int>(new int);
}

std::auto_ptr<int> p = foo();

拷贝构造函数只用auto_ptr&是不行的,因为不能绑定foo()产生的临时对象,如果用const auto_ptr&则无法修改这个参数,因为auto_ptr在赋值之后必须释放以前的拥有权。这里有两种方案,一种是用mutable成员。

template<typename T>
class auto_ptr
{
public:
auto_ptr(const auto_ptr& other) throw()
:ptr(other.safe_release())
{}

auto_ptr& operator=(const auto_ptr& other) throw();
private:
T* safe_release() const throw()
{
T * ret = ptr;
ptr = 0;
return ret;
}

private:
T * mutable ptr;
};


这是不被接受的方案,auto_ptr的状态和ptr这个成员紧密相连,而auto_ptr也应该在非const的情况下状态才会改变,因此这不被接受。第二种方案,也就是标准的做法。

template<typename T>
class auto_ptr
{
public:
auto_ptr(auto_ptr& other) throw();
auto_ptr(auto_ptr_ref ref) throw();

auto_ptr& operator=(auto_ptr& other) throw();
auto_ptr& operator=(auto_ptr_ref ref) throw();
...
};


用一个auto_ptr_ref来处理参数对象是右值的情况。这相当于弥补了一个语言缺陷。

对于这样的缺陷,C++加入一种新的引用类型来弥补这个问题,现在要说的就是右值引用。
右值引用主要用来绑定右值。

int foo();
const int cfoo();
int &&r = foo();
const int &&cr = cfoo();
//同样,也能绑定左值。
int i;
int &&r = i;
const int ci;
const int&& cr = ci;


右值引用的引入使C++变得更加复杂,难以学习,但是使用右值引用会让代码变得更简单,有时甚至是难以想象。对于隐晦论者,不知道怎么看待这样的问题。

首先,右值引用有一点很特殊。具名的右值引用被当作左值,无名的右值引用则仍然是右值。
例如,


int &&r = 0; //r被当作左值看待。

int&& foo();
foo(); //foo的返回类型是右值引用,其仍然是右值。


正是因为这个特性,使右值引用变得很复杂。但是其优点将在后面Perfect Forwarding部分介绍。


右值引用的引入确立了两东西,Move Semantics和Perfect Forwarding。英文上对于两词的表达对于我们来说尚为抽象,在适当时候我会用中文来表达。

1,Move Semantics(转移语义)
转移语义不同于拷贝语义,例如,两个auto_ptr对象的赋值操作,其实就是转移资源,而不是拷贝资源。用代码表达就是

class T
{
public:
T():p(new int){}

T(T& t)
:p(t.p)
{
t.p = 0;
}

T& operator=(T& t)
{
if(this != &t)
{
delete p;
p = t.p;
t.p = 0;
}
return *this;
}
private:
int *p;
};

T a;
T b(a);
T c;
c = b;

构造a的时候会动态分配一个int对象,然后a引用这个对象。构造b的时候,调用拷贝构造函数,这时a会将那个动态分配的int对象传递给b,则自己不再引用。然后c=b的赋值,b同样会把这个int对象转移给c,而自己则不在引用。这样,这个int对象,就从a转移到了b,再转移到c,而没有拷贝这个 int对象,这就是所谓的转移语义,auto_ptr也是如此。转移语义到底有什么作用?考虑一下这个情况。

std::vector<std::string> v;

v 里面保存了很多std::string对象,push_back操作会将buffer用完,然后重新分配更大的buffer,并将老buffer上的所有 std::string对象拷贝赋值到新buffer中,这个过程是很耗时的,因为每一个新的对象会被拷贝构造,然后分配内存,将老string对象的字符buffer复制到新的string对象里,然后老的被销毁,并释放字符buffer。如果std::string支持转移语义则情况大为改观,构造时,老的string对象只需要把字符buffer转移到新的string对象即可,没有了内存分配和释放的动作,性能也会大大提高。

有人纳闷了,如果std::string也支持转移语义,那就跟auto_ptr一样了,不能用在标准的STL容器里了。其实不然,因为现在C++不支持右值引用,它的拷贝构造函数并不是auto_ptr(const auto_ptr&),而STL容器则需要有拷贝语义,也就是需要元素有T(const T&)这样的拷贝构造函数。而如果让std::string支持转移语义并不会与现存的拷贝语义发生冲突。例如,加入转移语义的 std::string看起来就像是下面这样

template <
class CharType,
class Traits=char_traits<CharType>,
class Allocator=allocator<CharType>
>
class basic_string
{
public:
basic_string(const basic_string& _Right,
size_type _Roff = 0,
size_type _Count = npos,
const allocator_type& _Al = Allocator ( )
); //拷贝构造函数

basic_string(basic_string&& _Right,
size_type _Roff = 0,
size_type _Count = npos,
const allocator_type& _Al = Allocator ( )
); //转移构造函数

basic_string& operator=(const basic_string&); //拷贝赋值操作符
basic_string& operator=(basic_string&&); //转移赋值操作符
};

其中basic_string&&就是右值引用, 可以用来绑定右值。例如

std::string foo()
{
return "Hello, World";
}

std::string str;
str = foo(); //没有了字符串的拷贝动作。


细心的人在这里也许会发现一个缺陷。假如,我们定义一个具有转移语义的类,并在这个类里面使用具有转移语义的std::string。


class T
{
public:
T(const T& other) //拷贝构造
:text(other.text)
{}

T(T&& other) //转移构造
:text(other.text)
{}

T& operator=(const T& other) //拷贝赋值操作符
{
if(this != &other)
{
text = other.text;
}
return *this;
}

T& operator=(T&& other) //转移赋值操作符
{
if(this != &other)
{
text = other.text;
}
return *this;
}
private:
std::string text;
};


在前面介绍的右值引用的一个特性,发现有什么问题了吗?这里的text成员不会调用转移构造函数和转移赋值操作符。因为在T的转移构造函数和转移赋值操作符中,参数other是有名字的右值引用,因此它被当作了左值

T(T&& other) //转移构造
:text(other.text) //调用拷贝构造函数
{}

T& operator=(T&& other) //转移赋值操作符
{
if(this != &other)
{
text = other.text; //调用拷贝赋值操作符
}
return *this;
}


也许有人立马会站出来说这是极大的隐晦。其实不然,如果知道了右值引用的特性和重载解析就不会发生这样的错误。解决这个问题的办法就是让传递给text的参数变成右值。往回看,在讲右值引用之初已经提到了一点。标准库也提供了一个move函数用来做转换。

namespace std
{
template <typename T>
typename remove_reference<T>::type&&
move(T&& a)
{
return a;
}
}

remove_reference<T>::type就是得到一个解引用的类型。然后T的转移构造函数和转移赋值操作符就写成

T(T&& other) //转移构造
:text(std::move(other.text))
{}

T& operator=(T&& other) //转移赋值操作符
{
if(this != &other)
{
text = std::move(other.text);
}
return *this;
}

通过std::move一个间接调用,使实名的右值引用转换成无名的,这样就被当作右值处理。


2,Perfect Forwarding(精确转递)
有些时候我们会设计出一种管理器,用来保存所有的对象。例如窗口类

class window
{
//...
};

然后这个管理器会有一个接口用来创建指定类型对象。

template<typename Window>
window* factory()
{
return (new Window);
}

window* w = factory<Window>();

其实这样的factory是远远不够的。因为我们有时会从class window派生。例如

class msg_window
:public window
{
public:
msg_window(const std::string& text);
};

class input_window
: public window
{
public:
input_window(std::string& text);
};

factory就会写成
template<typename Window, typename T>
window* factory(const T&);

template<typename Window, typename T>
window* factory(T&);

如果派生类的构造函数有两个参数,那factory就要重载4个版本。这可不是容易的活。现在用右值引用可以方便地解决这个问题。对于一个参数的版本

namespace std
{
template<typename T> struct identity { typedef T type; };

template<typename T>
T&& forward(typename identity<T>::type&& t)
{
return t;
}
}

template<typename Window, typename T>
window* factory(T&& t)
{
return (new Window(std::forward<T>(t)));
}

window *msg = factory<msg_window>(std::string("Hello"));
std::string text;
window *input = factory<input_window>(text);

一个factory版本就能处理两种类型的参数。是不是很方便?这完全是依靠右值引用。在这里会涉及函数模板参数的推导,对于右值引用来说,这里有一个很重要的过程。例如下面的代码

template<typename T>
void f(T&& t);

int i;
f(i); //#1
f(2); //#2

#1推导结果就是f<int&>(i),这时f的参数t类型就是int&,这就是那个重要的地方。如果模板参数T是左值引用,那T&&的类型也是左值引用,例如#1推导出来的T是 int&,然后f的参数T&&也会被转换成int&。
#2推导结果就是f<int>(i),这时f的参数t类型就是int&&

在这里std::forward看上去跟std::move差不多,但为什么需要一个identity呢?这是为了防止模板参数的推导。现在我们不考虑identity的情况。

template<typename T>
T&& forward(T&& t)
{
return t;
}

和std::move完全一样,将实名的右值引用转换为无名右值引用。在调用forward的时候,模板参数T则被编译器自动推导,这就出现问题了,例如上面的factory,我们改成forward自动推导的版本。

template<typename Window, typename T>
window* factory(T&& t)
{
return (new Window(std::forward(t)));
}

t是实名的右值引用,因此被看作是左值,则std::forward返回的也是左值。对于这种情况,下面代码就能体现出一个错误

class test_window
: public window
{
public:
test_window(const std::string&);
test_window(std::string&);
};

factory<test_window>(std::string("Hello"));

会调用test_window(std::string&)来构造test_window对象,因为没有identity版本的forward将参数推导为左值,返回的也成了左值,这并不是我们期望的,std::string("Hello")创建的是临时对象。

那 identity在这里有什么用呢?为什么能解决这个问题呢?其实它只是起到一个显式指定模板参数的作用。在有identity的版本。直接写 std::forward(t)将会抱错,无法推导模板参数,而必须显式指定,从而避免了模板参数的推导。这里借用上面的test_window来解释这一点。

template<typename Window, typename T>
window* factory(T&& t)
{
return (new Window(std::forward<T>(t)));
}

std::string text;
factory<test_window>(text); //#1
factory<test_window>(std::string("Hello")); //#2

#1,text 是左值,则factory的模板参数T为std::string&,而T&&也就是std::string&,那么参数 t的类型也是std::string&,然后std::forward<std::string&>(t)也就返回的是 std::string&,仍然是左值,那么#1就会调用test_window(std::string&)来构造 test_window,符合本意。
#2,std::string("Hello")创建了一个临时对象,则factor的模板参数T为 std::string,而T&&则为std::string&&,然后 forward<std::string&&>(t)最后返回的仍然是右值。因此调用test_window(const std::string&)构造。同样符合本意。

这里std::forward<>就执行了一个Perfect forwarding,也就是精确转递,将参数t的类型精确地转递到Window的构造上。因此factory为每种参数个数的版本,只需实现一个,而不是(N^2 - 1)个。这在写开放代码,例如库的实现尤为重要。

-完-

觉得我的故事讲的不清晰的请举右手。觉得我将的清晰的请举脑袋。
http://blog.csdn.net/Jinhao/archive/2009/05/08/4159299.aspx
...全文
1446 点赞 收藏 215
写回复
215 条回复

还没有回复,快来抢沙发~

发动态
发帖子
C++ 语言
创建于2007-09-28

3.1w+

社区成员

24.8w+

社区内容

C++ 语言相关问题讨论,技术干货分享
社区公告
暂无公告