一、從Java、C#到C++ (為什麼C++比較難)
由於C++已經遺忘得差不多了,我翻起了最新初版的C++ Primer,打算深入瞭解一下C++這一門語言。C++ Primer第五版可謂是“重構”過的,融合了C++11的標準,並將它們全部在書中列舉了出來。
在學習的過程中,我會把C++與Java、C#等純面向物件的語言進行對比,中間的一些感悟,僅僅代表個人的意見,其中有對有錯,也可能會存在一些爭議。
差不多翻完了整本Primer C++,並瞭解完C++11標準後,我有了如下感慨:C++是一門靈活、強大、效率低下的語言。
所謂的“靈活”、“強大”,是指與Java、C#相比,而“效率低下”是指相對於C#、Java的開發效率,而不是程式的執行效率。同時,C++“約定俗成”了許多規則,這些規則難以全部記下來,因此在程式設計的時候手邊最好有一本C++的手冊。
我列舉幾個C++靈活的地方:
1、操作符過載,拷貝控制,隱式轉換等
在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;
}
};
對於某些需要用到+運算的模板類,將這個類傳入模板類中顯然是沒有問題的,但是模板類並不能保證傳入的類一定有+運算子。在C#或Java中,我們更喜歡這樣(Java程式碼):
abstract class ICanAdd{
int value;
abstract ICanAdd add(ICanAdd item);
}
class CanAdd extends ICanAdd{
public ICanAdd add(ICanAdd item){
value += item.value;
return this;
}
}
抽象類ICanAdd中明確包含了add方法,那麼所有的ICanAdd型別的物件,都是可以呼叫add的,至於呼叫怎樣的add由它們的基類決定。在C#、Java中,這種型別運用在泛型中是很安全的,因為我們可以約束這個泛型類一定要繼承ICanAdd,從而保證它一定能夠呼叫add,而模板類就不能這樣保證了,編譯器發現問題只有在模板例項化那一刻才知道,那麼,如果有問題,面臨著的可能是一大堆連結錯誤。
另外,過分使用過載符的意義是比較含糊的,例如std::cout << std::endl;,cout是std名稱空間的一個成員,但是endl卻是std中的一個函式,我個人認為如果一個程式中充斥著這樣的“約定俗成”的運算子,會過於難以理解。
2、指標、值、引用及多型性
C++的困難之處在於它的記憶體管理。在C#和Java中,有完善的垃圾回收機制,除了基本型別(以及C#中的struct),傳遞的方式都是引用(C#中也可以將一個值型別變數來引用傳遞)。不同的符號,如*、&在不同的位置有不同的含義,而且是很容易混淆的。例如:instance*t,它到底是表示instance乘以t,還是表示一個指向instance類的指標t呢。這一點在模板中尤為明顯,對於包含域運算子的型別,一定要加上typename,例如typename CLASS<T>::member,因為編譯器不知道member是一個型別還是一個成員變數。
左值引用、右值引用、值傳遞、引用傳遞是對程式設計人員提出的大挑戰,當我們用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 <iostream>
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*。如果我們要使用“多型”,那麼物件必須是引用或指標,因為給它們賦值不會觸發拷貝構造。