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對象。

我們再來擴展問題。
sizeof T(Error());

sizeof(T(Error()));
哪個是錯誤的?

第一個sizeof 是正確,T(Error())不是聲明一個函數。而是在調用T的構造函數。也就是計算T對象的大小。
第二個sizeof 是錯誤的。函數類型是不能取得大小的,納悶了,外面不是有sizeof的括號嗎?爲什麼不被解析成調用T的構造函數計算T對象的大小呢?我們來看看sizeof的語法。
sizeof 一元表達式
sizeof(類型)
也就是說,sizeof()裏面會被解析爲類型。T(Error())就是類型,這裏也不是聲明Error的函數。這個類型就是返回爲T 參數爲Error(*)()函數指針 的函數類型。函數類型不能計算大小。


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)個。這在寫開放代碼,例如庫的實現尤爲重要。

現在讓那些成天大叫隱晦的人去抱怨吧,我們享受着他們認爲“變態的”和“噁心的”語法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章