1. 程式人生 > >一、從Java、C#到C++ (為什麼C++比較難)

一、從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*。如果我們要使用“多型”,那麼物件必須是引用或指標,因為給它們賦值不會觸發拷貝構造。

  3、介面與多重繼承

  C++中沒有介面的概念,但是有繼承的概念。繼承是面向物件程式設計中的一大特點。C++支援多重繼承,也支援各種訪問許可權的繼承;C#、Java不支援多重繼承,它們只能繼承於一個類,但是可以繼承多個介面。C++支援多重繼承的原因我想其中之一是因為C++中沒有“介面”的概念。介面是一個完全抽象的類,它只聲明瞭函式體,不能實現它們。C++中可以用抽象類來實現介面,因此,C++必須要支援多重繼承。例如,一隻小狗,它可以跑,可以吃東西,它是一隻動物,那麼在C++中,可以這樣宣告:class Dog : public Animal, public CanEat, public CanRun,而在Java中,則是這樣宣告:class Dog extends Animal implements CanEat, CanRun,且CanEat,CanRun必須為interface——它們不能實現自己的成員函式,只能宣告。   多重繼承會帶來一些麻煩,比方說父類們都繼承了同一個類,那麼此派生類會繼承那個類兩次,此時就要用虛基類來解決問題,另外,多重繼承也會出現一些重名的問題。把“介面”從類中分離出來是有好處的,它清楚地說明了類中一定會存在的方法,如果用一個純抽象類來代替介面,則可能會出現“Is a”這樣的混淆,即——Dog是Animal,並且同時是CanEat, CanRun這樣的混淆,而正確的看法應該是:Dog是animal,且存在CanEat、CanRun這樣的能力。   因此到現在,我都有個習慣,一個類最多繼承一個包含了成員或實現多個成員函式的類,並且可以繼承多個純抽象類(不包含成員欄位,且僅包含成員函式的宣告的類)。當然,這種習慣可能在某些情況下是有侷限性的。

  4、typedef和#define

  先說說define吧。巨集定義是提高程式效率的一種有效手段,因為它的時間消耗在了編譯器。我們可以把很多常用的函式寫成巨集定義,這樣執行它的時候引數就不會反覆出棧入棧影響效率了,C++中也可以把函式定義成inline來提高效率。define在一定程度上可以提高程式碼的可讀性,但是漫天的define會讓人疑惑。因此,在C#中define的功能被做了限制,即define不能定義一個巨集為一個值,它僅僅只能定義,以及判斷它是否定義了,最常見的用途就是#define DEBUG,來進行某些除錯。   typedef,以及C++11中常用的using,是給型別命別名的。它主要的作用,在我看來有兩個:一是提高程式碼的可讀性,例如,一個string型別的可讀性肯定比const char*高,一個strVectorWithVector的可讀性肯定比vector<vector<std::string> >高,同define一樣,漫天的typedef同樣會降低程式碼的可讀性。第二個作用是增加軟體的可移植性。在機器A上,int佔8個自己,B機器上,int佔16個位元組,那麼在A機器上定義的int a可能在B機器上都要改成long a,這樣的工作量巨大而且容易出錯。因此,在A機器上,用typedef將int命名為MyInt,在B機器上,將MyInt對應的型別改成long就實現了所有型別的改變。   不過,這樣的型別移植在C#和Java上應該出現得很少的,因為它們都有各自的“虛擬機器”——.NET庫有CLR,Java有Java虛擬機器,它們在不同的終端上會處理這樣的相容性問題的。

  5、記憶體管理

  記憶體管理是C++程式設計師的噩夢,雖然標準庫引入了boost中的shared_ptr,但是它仍然沒有一個垃圾回收機制,shared_ptr使用起來也有一定難度,不像“垃圾回收”那樣可以無腦使用,C++的設計者可能認為程式的所有控制權應該交給程式設計師,但是不是所有的程式設計師(或他們的老闆)都是那麼有耐心,一個完善的記憶體管理機制可能需要花費相當大的時間。ObjectiveC引入了autorelease垃圾回收機制,但我認為C++在下一次標準出來之前也不會有垃圾回收機制。   在C#和Java中,一個類的回收是不定時的,但是你可以定義它們在回收時應該要做些什麼,而在C++中,析構神馬的,都要自己寫。   以上列舉了幾點C++十分靈活的地方,它靈活的地方還有許多,如迭代器——C++中,如果要使用range for,則這個類必須擁有begin(),end(),它們返回一個迭代器,這個迭代器可以是自己定義的一個類,它必須實現解引用*運算子、!=運算子和++運算子。標準庫中提供了各種各樣複雜的迭代器,而在range for出來之前,QT中已經實現了foreach“語句”。在C#、Java中,如果要使用foreach,類必須要實現迭代器介面。   C++沒有“程式集”的概念,所以也就不存在反射機制,能夠使用decltype已經算是一種“突破”了。C++中,一個物件即一塊記憶體,在C#、Java中,我們可以用GetType或.class方法來獲取型別的資訊。   不得不說,C++中有很多“人為規定”,大部分操作、記憶體管理需要自己實現,對計算機的結構需要有很清晰地瞭解才能寫出好的程式。C++如此複雜,征服它會不會很有成就感!