程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> 一、從Java、C#到C++ (為什麼C++比較難)

一、從Java、C#到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::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 
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 >高,同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++如此復雜,征服它會不會很有成就感!

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved