很多人抱怨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)個。這在寫開放代碼,例如庫的實現尤爲重要。
現在讓那些成天大叫隱晦的人去抱怨吧,我們享受着他們認爲“變態的”和“噁心的”語法。