混沌IN C++::所谓的隐晦

难度:难度难度难度难度

 

很多人抱怨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对象。

我们再来扩展问题。定义两个类型

  1. struct A{};  
  2.   
  3. struct B  
  4.   
  5. {  
  6.   
  7.     operator A();  
  8.   
  9. };  
  10.   
  11. sizeof A(B());  
  12. 和  
  13. 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)个。这在写开放代码,例如库的实现尤为重要。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值