难度:
很多人抱怨C++有太多隐晦语法的问题。今天,来谈两个隐晦的语法问题。
一,关于Declarator。
有时候,我们会故意制造一个便于理解的编译错误。
template<bool>
class static_error
{
public:
template<typename T> static_error(T){}
};
template<>
class static_error<false>
{
class Static_Check_Error{};
public:
static_error(Static_Check_Error);
};
struct Error{};
现在,来使用这个static_error。假设一个环境,我们需要拷贝两个结构体,要比较两个类型的大小,如果源大小大于目标大小,则发生一个编译错误,某些情况下,编译错误比运行时返回错误的值更容易找到错误的根源。
template<typename T1, typename T2>
void safe_memcpy(T1 &to, const T2& from)
{
//这里我们进行判断,如果T2的大小大于T1则发生编译错误。
static_error<sizeof(T1) >= sizeof(T2)>(Error()); //#1
memcpy(&to, &from, sizeof(from));
}
我们的本意是在#1处加入一个判断T1和T2的大小,如果T2大于了T1则产生一个编译错误。具体过程是,让sizeof(T1) >= sizeof(T2)匹配static_error<false>这个模板,然后构造这个模板类的对象,用一个Error临时对象来初始化,而这个模板类有一个构造函数static_error<false>(Static_Check_Error),很显然地,Error类型不能转换到Static_Check_Error,因此发生编译错误。
但实际情况是这样的吗?并不是!#1处的代码看上去可以有两种解释。第一种就是上面说的,分别调用static_error<false>和Error的构造函数。第二种就是,这是一个函数类型,返回类型是static_cast<false>的函数,其参数是一个Error(*)()这样的函数指针。
这两种解释都是错误的。正确的应该是
static_error<sizeof(T1) >= sizeof(T2)> Error();
再简化就是
static_error<false> Error();
声明一个函数Error,无参数,返回一个static_error<false>。
现在大家开始惊呼了“WOW,C++的语法太恶心了,太隐晦了”。与其说是惊呼,不如说是不懂,或者说连C都不懂。很多书不讲这个问题,估计是害怕弄巧成拙。
现在来解释一下,为什么会是这样。从最基础的开始,如何声明一个整型变量呢?
int i;
这个声明语句就是
Type Declarator;
int就是Type,类型。
i就是一个标识符,在这语境下叫declarator,说明符,用来表示变量名字。
对于说明符,还支持另一种形式。
Type (Declarator)
也就是说。我们可以这样来定义变量和函数。下面的形式都是合法的。
int i;
int (n);
int (x) = 0;
int (a), (b), (c);
int foo1()
{
return 0;
}
int (foo2())
{
return 0;
}
int (foo3(int i))
{
return 0;
}
int (foo4(int(i)))
{
return 0;
}
虽然上面的代码很神奇,但是有人会问,为什么C++要支持Type (Declarator)的形式呢?不是多此一举,而且还弄得如此隐晦呢?其实不然,在一些复杂的情况,这种形式很有用,考虑如何声明一个数组的指针,如何声明一个函数指针。
int (*pa)[10];
int (*pf)();
这两行代码,等同于
typedef int Array[10];
Array *pa;
typedef int Func();
Func *pf;
如果不用Type (Declarator)的形式,pa和pf的类型就变了。
现在我们有了这些基础知识,在回过头来思考static_error的问题。
static_error<false>(Error());
方便起见,写成这样的形式
typedef static_error<false> T;
T(Error());
好了,利用刚才的知识,来分析这个代码。其实就和上面foo2的形式一样。
Error()是一个Declarator,说明Error是一个无参数的函数,返回值类型就是T。
这时,或许有一点迷惑了,因为我们常常在调用一个函数的时候,就是用的这样的形式
void foo(T);
foo(T());
看不出来这与T(Error())在写法上的不同。对于C++,不仅仅只有形式决定表达式的,还要包含各个名字的含义。这种表达式,首先会从声明的角度来解析,如果失败就从其他角度去解析。例如foo(T()),首先foo并不是类型,不能满足Type (Declarator)的语法要求,然后foo在当前域下有函数的定义,因此被解析成函数调用。
有时候,我们确实需要写T(Error())的形式,但又不能让他是声明一个函数,怎么办?
是加入一个名字,以便不让它成为一个临时对象吗?
T a(Error());
其实这样还是一个声明。declarator就是a(Error()),意思就是a是一个函数返回T类型,参数是Error(*)()。天啊,难道没有办法了吗?
其实我们可以这样。
(T(Error()));
因为在声明的时候,外面不能有括号。(Type Declarator)就是非法的。
在当前语境下,T(Error())就不在是声明了,而被解析成分别调用两个两个类型的构造函数,并用Error临时对象初始化T对象。
我们再来扩展问题。定义两个类型
- struct A{};
- struct B
- {
- operator A();
- };
- sizeof A(B());
- 和
- sizeof(A(B()));
哪个是错误的?
第一个sizeof 是正确,A(B())不是声明一个函数。而是在调用A的拷贝构造函数。也就是计算A对象的大小。
第二个sizeof 是错误的。函数类型是不能取得大小的,纳闷了,外面不就是多一对括号吗?为什么不被解析成调用T的构造函数计算T对象的大小呢?我们来看看sizeof的语法。
sizeof 一元表达式
sizeof(类型)
也就是说,sizeof()里面会被解析为类型。A(B())就是类型,这里也不是声明B的函数。这个类型就是返回为A 参数为B(*)()函数指针 的函数类型。函数类型不能计算大小。
在实际的开发中,我们真的会遇到这个问题。
class foo;
void foo();
int main()
{
foo(); //函数调用,在全局域中找到foo的函数定义。
class foo a; //定义class foo类型对象a。
class foo b(); //定义返回foo对象无参函数b。
class foo(c()); //显然了,定义返回foo对象的无参函数c
using namespace std;
vector<int> f(istream_iterator<int>(cin), istream_iterator<int>());
vector<int> v1((istream_iterator<int>(cin)), istream_iterator<int>());
vector<int> v2(istream_iterator<int>(cin), (istream_iterator<int>()));
vector<int> v3((istream_iterator<int>(cin)), (istream_iterator<int>()));
}
C++的语法不能只看形式,这也是其复杂的原因,例如不能把typedef简单地看作类似宏的替换,有些语言试图消除这种形式上的二义性,也是会付出代价的,更多的关键字和更多的语法糖。在进入范型编程的时代,这种灵活的语法给模板的编写带来了极大的好处。有人又会说这是“茴”字的写法问题,其实不然,我们提倡用简单的形式来编写代码,但是不能保证不会写出这种二义性的代码,如果实在是简单到跟这些语法一点边也沾不到,那试问一下,你是在用C++吗?
二,关于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)个。这在写开放代码,例如库的实现尤为重要。