由於C++已經遺忘得差不多了,我翻起了最新初版的C++ Primer,打算深入了解一下C++這一門語言。C++ Primer第五版可謂是“重構”過的,融合了C++11的標准,並將它們全部在書中列舉了出來。
在學習的過程中,我會把C++與Java、C#等純面向對象的語言進行對比,中間的一些感悟,僅僅代表個人的意見,其中有對有錯,也可能會存在一些爭議。
差不多翻完了整本Primer C++,並了解完C++11標准後,我有了如下感慨:C++是一門靈活、強大、效率低下的語言。
所謂的“靈活”、“強大”,是指與Java、C#相比,而“效率低下”是指相對於C#、Java的開發效率,而不是程序的運行效率。同時,C++“約定俗成”了許多規則,這些規則難以全部記下來,因此在編程的時候手邊最好有一本C++的手冊。
我列舉幾個C++靈活的地方:
在C++中,幾乎所有的操作都可以被重載,例如+、-、new、delete等,哪怕你使用=賦值,其實都是在運行一個函數。如果你沒有定義這樣的函數,則編譯器會使用它的合成版本(也就是默認版本)。默認版本的拷貝其實就是把成員的值拷貝,如果成員是一個指針,則僅僅拷貝地址。舉例說明,如果有一個A類的實例a,成員中含有一個指針ptr,當用默認版本進行拷貝後,如A b = a;,那麼b和a中的ptr指向的是同一個地址,如果a、b其中之一,ptr所指向的對象被析構了,那麼當析構另外一個對象的時候就會發生錯誤(C++ Primer 第五版,P447),所以,需要析構的對象也需要一個拷貝和賦值的操作。
上述的例子說明了,作為類的設計者,你必須要把所有的情況考慮清楚,要對它的內存分配了如指掌,否則你設計出來的類很可能會有問題。
另一個例子是隱式轉換,諸如std::string str = "hello",它把C風格的const char*轉換為了std::string,這種轉換在我們的理解中是很直接的,但是有時候,這種轉換不僅難以理解,還會造成二義性導致編譯無法通過。
在我看來,操作符的重載對於一門語言不是必要的。在C++中我們可以輕易地想到兩個std::string相加相當於連接兩個字符串,而在Java、C#中,是禁止重載運算符的(C#中有個例外,就是它默認重載了String類的+),原因我猜想可能是防止程序結構太過於混亂。事實上,我是不太習慣於重載過多運算符,除非必須要重載(例如使用map類時,必須要重載<),因為它確實會增加閱讀代碼的難度。舉例說明,我想在C++和C#(或Java)中分別構造一個類,它們擁有“加法”運算符,在C++中可能是這樣:
class CanAdd{ public: int value; CanAdd& operator + (const CanAdd a){ value += a.value; return *this; } };
abstract class ICanAdd{ int value; abstract ICanAdd add(ICanAdd item); } class CanAdd extends ICanAdd{ public ICanAdd add(ICanAdd item){ value += item.value; return this; } }
另外,過分使用重載符的意義是比較含糊的,例如std::cout << std::endl;,cout是std命名空間的一個成員,但是endl卻是std中的一個函數,我個人認為如果一個程序中充斥著這樣的“約定俗成”的運算符,會過於難以理解。
C++的困難之處在於它的內存管理。在C#和Java中,有完善的垃圾回收機制,除了基本類型(以及C#中的struct),傳遞的方式都是引用(C#中也可以將一個值類型變量來引用傳遞)。不同的符號,如*、&在不同的位置有不同的含義,而且是很容易混淆的。例如:instance*t,它到底是表示instance乘以t,還是表示一個指向instance類的指針t呢。這一點在模板中尤為明顯,對於包含域運算符的類型,一定要加上typename,例如typename CLASS
左值引用、右值引用、值傳遞、引用傳遞是對編程人員提出的大挑戰,當我們用decltype、auto、const等關鍵字時尤為明顯。例如有語句int a,則decltype(a)返回的類型是int,而decltype((a))返回的類型是int&,這些規則,只能在實戰中慢慢記憶。
C++在定義變量的時候,和C#、Java不同。例如已經定義好了一個類A,在C++中,A a;表示,a已經被定義(已經被分配好了空間),如果你只想聲明它,要麼是extern A a;,要麼就是A* a;。在C#、Java中,A a;永遠是聲明,除非你用了A a = new A(),表示a已經被實例化。使用未被實例化的變量會引發異常。
C++雖然是一門“面向對象”的語言,但是我們卻不能直接進行“面向對象”來操作它。舉例如下:
#include程序運行的結果:using namespace std; class Base { public: virtual void Call(){ std::cout << "Base func called" << std::endl; } }; class Derived : public Base { public: void Call(){ std::cout << "Derived func called" << std::endl; } }; void call(Base b){ b.Call(); } int main(int argc, const char * argv[]) { Base base; Derived derived; call(base); call(derived); return 0; }
Base func called
Base fund called
根據多態的原則,call函數調用了成員函數Call,為什麼對於derived對象,仍然調用的是基類的Call呢?原因是,C++的多態性只體現在引用和指針上:當你傳入一個derived給call時,其實編譯器是按照Base的拷貝構造函數拷貝了一個參數b,則b的類型是Base,那麼再調用b.Call(),調用的肯定是Base.Call了。為了防止Base調用拷貝構造函數,我們給call傳的參數,要麼是Base&,要麼是Base*。如果我們要使用“多態”,那麼對象必須是引用或指針,因為給它們賦值不會觸發拷貝構造。