天天看點

C++ Style and Technique FAQ (中文版) Bjarne Stroustrup 著, 紫雲英 譯C++ Style and Technique FAQ (中文版)

C++ Style and Technique FAQ (中文版)

Bjarne Stroustrup 著, 紫雲英 譯   [注: 本訪談錄之譯文經Stroustrup博士授權。如要轉載,請和我聯系: [email protected] ]

Q: 這個簡單的程式……我如何把它搞定?

A: 常常有人問我一些簡單的程式該如何寫,這在學期之初時尤甚。一個典型的問題是:如何讀入一些數字,做些處理(比如數學運算),然後輸出……好吧好吧,這裡我給出一個“通用示範程式”:

#include<iostream>
	#include<vector>
	#include<algorithm>
	using namespace std;

	int main()
	{
		vector<double> v;

		double d;
		while(cin>>d) v.push_back(d);	// read elements
		if (!cin.eof()) {		// check if input failed
			cerr << "format error/n";
			return 1;	// error return
		}

		cout << "read " << v.size() << " elements/n";

		reverse(v.begin(),v.end());
		cout << "elements in reverse order:/n";
		for (int i = 0; i<v.size(); ++i) cout << v[i] << '/n';

		return 0; // success return
	}
      

  程式很簡單,是吧。這裡是對它的一些“觀察報告”:

  • 這是一個用标準C++寫的程式,使用了标準庫[譯注:标準庫主要是将原來的C運作支援庫(Standard C Library)、iostream庫、STL(Standard Template Library,标準模闆庫)等标準化而得的] 。标準庫提供的功能都位于namespace std之中,使用标準庫所需包含的頭檔案是不含.h擴充名的。[譯注:有些編譯器廠商為了相容性也提供了含.h擴充名的頭檔案。]
  • 如果你在Windows下編譯,你需要把編譯選項設為“console application”。記住,你的源代碼檔案的擴充名必須為.cpp,否則編譯器可能會把它當作C代碼來處理。
  • 主函數main()要傳回一個整數。[譯注:有些編譯器也支援void main()的定義,但這是非标準做法]
  • 将輸入讀入标準庫提供的vector容器可以保證你不會犯“緩沖區溢出”之類錯誤——對于初學者來說,硬是要求“把輸入讀到一個數組之中,不許犯任何‘愚蠢的錯誤’”似乎有點過份了——如果你真能達到這樣的要求,那你也不能算完全的初學者了。如果你不相信我的這個論斷,那麼請看看我寫的《Learning Standard C++ as a New Language》一文。 [譯注:CSDN文檔區有該文中譯。]
  • 代碼中“ !cin.eof() ”是用來測試輸入流的格式的。具體而言,它測試讀輸入流的循環是否因遇到EOF而終止。如果不是,那說明輸入格式不對(不全是數字)。還有細節地方不清楚,可以參看你使用的教材中關于“流狀态”的章節。
  • Vector是知道它自己的大小的,是以不必自己清點輸入了多少元素。
  • 這個程式不含任何顯式記憶體管理代碼,也不會産生記憶體洩漏。Vector會自動配置記憶體,是以使用者不必為此煩心。
  • 關于如何讀入字元串,請參閱後面的“我如何從标準輸入中讀取string”條目。
  • 這個程式以EOF為輸入終止的标志。如果你在UNIX上運作這個程式,可以用Ctrl-D輸入EOF。但你用的Windows版本可能會含有一個bug(http://support.microsoft.com/support/kb/articles/Q156/2/58.asp?LN=EN-US&SD=gn&FR=0&qry=End of File&rnk=11&src=DHCS_MSPSS_gn_SRCH&SPR=NTW40),導緻系統無法識别EOF字元。如果是這樣,那麼也許下面這個有稍許改動的程式更适合你:這個程式以單詞“end”作為輸入終結的标志。
    #include<iostream>
    	#include<vector>
    	#include<algorithm>
    	#include<string>
    	using namespace std;
    
    	int main()
    	{
    		vector<double> v;
    
    		double d;
    		while(cin>>d) v.push_back(d);	// read elements
    		if (!cin.eof()) {		// check if input failed
    			cin.clear();		// clear error state
    			string s;
    			cin >> s;		// look for terminator string
    			if (s != "end") {
    				cerr << "format error/n";
    				return 1;	// error return
    			}
    		}
    
    		cout << "read " << v.size() << " elements/n";
    
    		reverse(v.begin(),v.end());
    		cout << "elements in reverse order:/n";
    		for (int i = 0; i<v.size(); ++i) cout << v[i] << '/n';
    
    		return 0; // success return
    	}
          

《The C++ Programming Language 》第三版中關于标準庫的章節裡有更多更詳細例子,你可以通過它們學會如何使用标準庫來“輕松搞定簡單任務”。  

Q: 為何我編譯一個程式要花那麼多時間?

A: 也許是你的編譯器有點不太對頭——它是不是年紀太大了,或者沒有安裝正确?也可能你的電腦該進博物館了……對于這樣的問題我可真是愛莫能助了。

不過,也有可能原因在于你的程式——看看你的程式設計還能不能改進?編譯器是不是為了順利産出正确的二進制碼而不得不吃進成百個頭檔案、幾萬行的源代碼?原則上,隻要對源碼适當優化一下,編譯緩慢的問題應該可以解決。如果症結在于你的類庫供應商,那麼你大概除了“換一家類庫供應商”外确實沒什麼可做的了;但如果問題在于你自己的代碼,那麼完全可以通過重構(refactoring)來讓你的代碼更為結構化,進而使源碼一旦有更改時需重編譯的代碼量最小。這樣的代碼往往是更好的設計:因為它的藕合程度較低,可維護性較佳。

我們來看一個OOP的經典例子:

class Shape {
	public:		// interface to users of Shapes
		virtual void draw() const;
		virtual void rotate(int degrees);
		// ...
	protected:	// common data (for implementers of Shapes)
		Point center;
		Color col;
		// ...
	};

	class Circle : public Shape {
	public:	
		void draw() const;
		void rotate(int) { }
		// ...
	protected:
		int radius;
		// ...
	};

	class Triangle : public Shape {
	public:	
		void draw() const;
		void rotate(int);
		// ...
	protected:
		Point a, b, c;
		// ...
	};	
      

  上述代碼展示的設計理念是:讓使用者通過Shape的公共界面來處理“各種形狀”;而Shape的保護成員提供了各繼承類(比如Circle,Triangle)共同需要的功能。也就是說:将各種形狀(shapes)的公共因素劃歸到基類Shape中去。這種理念看來很合理,不過我要提請你注意:

  • 要确認“哪些功能會被所有的繼承類用到,而應在基類中實作”可不是件簡單的事。是以,基類的保護成員或許會随着要求的變化而變化,其頻度遠高于公共界面之可能變化。例如,盡管我們把“center”作為所有形狀的一個屬性(進而在基類中聲明)似乎是天經地義的,但是以而要在基類中時時維護三角形的中心坐标是很麻煩的,還不如隻在需要時才計算——這樣可以減少開銷。
  • 和抽象的公共界面不同,保護成員可能會依賴實作細節,而這是Shape類的使用者所不願見到的。例如,絕大部分使用Shape的代碼應該邏輯上和color無關;但隻要color的聲明在Shape類中出現了,就往往會導緻編譯器将定義了“該作業系統中顔色表示”的頭檔案讀入、展開、編譯。這都需要時間!
  • 當基類中保護成員(比如前面說的center,color)的實作有所變化,那麼所有使用了Shape類的代碼都需要重新編譯——哪怕這些代碼中隻有很少是真正要用到基類中的那個“語義變化了的保護成員”。

是以,在基類中放一些“對于繼承類之實作有幫助”的功能或許是出于好意,但實則是麻煩的源泉。使用者的要求是多變的,是以實作代碼也是多變的。将多變的代碼放在許多繼承類都要用到的基類之中,那麼變化可就不是局部的了,這會造成全局影響的!具體而言就是:基類所倚賴的一個頭檔案變動了,那麼所有繼承類所在的檔案都需重新編譯。

這樣分析過後,解決之道就顯而易見了:僅僅把基類用作為抽象的公共界面,而将“對繼承類有用”的實作功能移出。

class Shape {
	public:		// interface to users of Shapes
		virtual void draw() const = 0;
		virtual void rotate(int degrees) = 0;
		virtual Point center() const = 0;
		// ...

		// no data
	};

	class Circle : public Shape {
	public:	
		void draw() const;
		void rotate(int) { }
		Point center() const { return center; }
		// ...
	protected:
		Point cent;
		Color col;
		int radius;
		// ...
	};

	class Triangle : public Shape {
	public:	
		void draw() const;
		void rotate(int);
		Point center() const;
		// ...
	protected:
		Color col;
		Point a, b, c;
		// ...
	};	
      

  這樣,繼承類的變化就被孤立起來了。由變化帶來的重編譯時間可以極為顯著地縮短。

但是,如果确實有一些功能是要被所有繼承類(或者僅僅幾個繼承類)共享的,又不想在每個繼承類中重複這些代碼,那怎麼辦?也好辦:把這些功能封裝成一個類,如果繼承類要用到這些功能,就讓它再繼承這個類:

class Shape {
	public:		// interface to users of Shapes
		virtual void draw() const = 0;
		virtual void rotate(int degrees) = 0;
		virtual Point center() const = 0;
		// ...

		// no data
	};

	struct Common {
		Color col;
		// ...
	};
		
	class Circle : public Shape, protected Common {
	public:	
		void draw() const;
		void rotate(int) { }
		Point center() const { return center; }
		// ...
	protected:
		Point cent;
		int radius;
	};

	class Triangle : public Shape, protected Common {
	public:	
		void draw() const;
		void rotate(int);
		Point center() const;
		// ...
	protected:
		Point a, b, c;
	};	
      

[譯注:這裡作者的思路就是孤立變化,減少耦合。從這個例子中讀者可以學到一點Refactoring的入門知識 :O) ]  

Q: 為何空類的大小不是零?

A: 為了確定兩個不同對象的位址不同,必須如此。也正因為如此,new傳回的指針總是指向不同的單個對象。我們還是來看代碼吧:

class Empty { };

	void f()
	{
		Empty a, b;
		if (&a == &b) cout << "impossible: report error to compiler supplier";

		Empty* p1 = new Empty;
		Empty* p2 = new Empty;
		if (p1 == p2) cout << "impossible: report error to compiler supplier";
	}	
      

  另外,C++中有一條有趣的規則——空基類并不需要另外一個位元組來表示:

struct X : Empty {
		int a;
		// ...
	};

	void f(X* p)
	{
		void* p1 = p;
		void* p2 = &p->a;
		if (p1 == p2) cout << "nice: good optimizer";
	}
      

  如果上述代碼中p1和p2相等,那麼說明編譯器作了優化。這樣的優化是安全的,而且非常有用。它允許程式員用空類來表示非常簡單的概念,而不需為此付出額外的(空間)代價。一些現代編譯器提供了這種“虛基類優化”功能。  

Q: 為什麼我必須把資料放到類的聲明之中?

A: 沒人強迫你這麼做。如果你不希望界面中有資料,那麼就不要把它放在定義界面的類中,放到繼承類中好了。參看“為何我編譯一個程式要花那麼多時間”條目。[譯注:本FAQ中凡原文為declare/declaration的均譯為聲明;define/definition均譯為定義。兩者涵義之基本差别參見後面“‘int* p;’和‘int *p;’到底哪個正确”條目中的譯注。通常而言,我們還是将下面的示例代碼稱為complex類的定義,而将單單一行“class complex;”稱作聲明。]   但也有的時候你确實需要把資料放到類聲明裡面,比如下面的複數類的例子:

template<class Scalar> class complex {
	public:
		complex() : re(0), im(0) { }
		complex(Scalar r) : re(r), im(0) { }
		complex(Scalar r, Scalar i) : re(r), im(i) { }
		// ...

		complex& operator+=(const complex& a)
			{ re+=a.re; im+=a.im; return *this; }
		// ...
	private:
		Scalar re, im;
	};
      

  這個complex(複數)類是被設計成像C++内置類型那樣使用的,是以資料表示必須出現在聲明之中,以便可以建立真正的本地對象(即在堆棧上配置設定的對象,而非在堆中配置設定),這同時也確定了簡單操作能被正确内聯化。“本地對象”和“内聯”這兩點很重要,因為這樣才可以使我們的複數類達到和内置複數類型的語言相當的效率。

[譯注:我覺得Bjarne的這段回答有點“逃避問題”之嫌。我想,提問者的真實意圖或許是想知道如何用C++将“界面”與“實作”完全分離。不幸的是,C++語言和類機制本身不提供這種方式。我們都知道,類的“界面”部分往往被定義為公有(一般是一些虛函數);“實作”部分則往往定義為保護或私有(包括函數和資料);但無論是“public”段還是“protected”、“private”段都必須出現在類的聲明中,随類聲明所在的頭檔案一起提供。想來這就是“為何資料必須放到類聲明中”問題的由來吧。為了解決這個問題,我們有個變通的辦法:使用Proxy模式(參見《Design Patterns : Elements of Reusable Object-Oriented Software》一書),我們可以将實作部分在proxy類中聲明(稱為“對象組合”),而不将proxy類的聲明暴露給使用者。例如:

class Implementer; // forward declaration      
class Interface {
	public:
		// interface      
private:
		Implementer impl;
	};      

在這個例子中,Implementer類就是proxy。在Interface中暴露給使用者的隻是一個impl對象的“存根”,而無實作内容。Implementer類可以如下聲明:

class Implementer {
	public:
		// implementation details, including data members
   
      };
   
   
    
    
    
    
    
    
    
    
    
    
    

    上述代碼中的注釋處可以存放提問者所說的“資料”,而Implementer的聲明代碼不需暴露給使用者。不過,Proxy模式也不是十全十美的——Interface通過impl指針間接調用實作代碼帶來了額外的開銷。或許讀者會說,C++不是有内聯機制嗎?這個開銷能通過内聯定義而彌補吧。但别忘了,此處運用Proxy模式的目的就是把“實作”部分隐藏起來,這“隐藏”往往就意味着“實作代碼”以連結庫中的二進制代碼形式存在。目前的C++編譯器和連結器能做到既“代碼内聯”又“二進制隐藏”嗎?或許可以。那麼Proxy模式又能否和C++的模闆機制“合作愉快”呢?(換句話說,如果前面代碼中Interface和Implementer的聲明均不是class,而是template,又如何呢?)關鍵在于,編譯器對内聯和模闆的支援之實作是否需要進行源碼拷貝,還是可以進行二進制碼拷貝。目前而言,C#的泛型支援之實作是在Intermediate Language層面上的,而C++則是源碼層面上的。Bjarne給出的複數類聲明代碼稱“資料必須出現在類聲明中”也是部分出于這種考慮。呵呵,扯遠了……畢竟,這段文字隻是FAQ的“譯注”而已,此處不作更多探讨,有興趣的讀者可以自己去尋找答案 :O) ]
   上述代碼中的注釋處可以存放提問者所說的“資料”,而Implementer的聲明代碼不需暴露給使用者。不過,Proxy模式也不是十全十美的——Interface通過impl指針間接調用實作代碼帶來了額外的開銷。或許讀者會說,C++不是有内聯機制嗎?這個開銷能通過内聯定義而彌補吧。但别忘了,此處運用Proxy模式的目的就是把“實作”部分隐藏起來,這“隐藏”往往就意味着“實作代碼”以連結庫中的二進制代碼形式存在。目前的C++編譯器和連結器能做到既“代碼内聯”又“二進制隐藏”嗎?或許可以。那麼Proxy模式又能否和C++的模闆機制“合作愉快”呢?(換句話說,如果前面代碼中Interface和Implementer的聲明均不是class,而是template,又如何呢?)關鍵在于,編譯器對内聯和模闆的支援之實作是否需要進行源碼拷貝,還是可以進行二進制碼拷貝。目前而言,C#的泛型支援之實作是在Intermediate Language層面上的,而C++則是源碼層面上的。Bjarne給出的複數類聲明代碼稱“資料必須出現在類聲明中”也是部分出于這種考慮。呵呵,扯遠了……畢竟,這段文字隻是FAQ的“譯注”而已,此處不作更多探讨,有興趣的讀者可以自己去尋找答案 :O) ]      

  };

上述代碼中的注釋處可以存放提問者所說的“資料”,而Implementer的聲明代碼不需暴露給使用者。不過,Proxy模式也不是十全十美的——Interface通過impl指針間接調用實作代碼帶來了額外的開銷。或許讀者會說,C++不是有内聯機制嗎?這個開銷能通過内聯定義而彌補吧。但别忘了,此處運用Proxy模式的目的就是把“實作”部分隐藏起來,這“隐藏”往往就意味着“實作代碼”以連結庫中的二進制代碼形式存在。目前的C++編譯器和連結器能做到既“代碼内聯”又“二進制隐藏”嗎?或許可以。那麼Proxy模式又能否和C++的模闆機制“合作愉快”呢?(換句話說,如果前面代碼中Interface和Implementer的聲明均不是class,而是template,又如何呢?)關鍵在于,編譯器對内聯和模闆的支援之實作是否需要進行源碼拷貝,還是可以進行二進制碼拷貝。目前而言,C#的泛型支援之實作是在Intermediate Language層面上的,而C++則是源碼層面上的。Bjarne給出的複數類聲明代碼稱“資料必須出現在類聲明中”也是部分出于這種考慮。呵呵,扯遠了……畢竟,這段文字隻是FAQ的“譯注”而已,此處不作更多探讨,有興趣的讀者可以自己去尋找答案 :O) ]  

Q: 為何成員函數不是預設為虛?

A: 因為許多類不是被用來做基類的。[譯注:用來做基類的類常類似于其它語言中的interface概念——它們的作用是為一組類定義一個公共介面。但C++中的類顯然還有許多其他用途——比如表示一個具體的擴充類型。] 例如,複數類就是如此。

另外,有虛函數的類有虛機制的開銷[譯注:指存放vtable帶來的空間開銷和通過vtable中的指針間接調用帶來的時間開銷],通常而言每個對象增加的空間開銷是一個字長。這個開銷可不小,而且會造成和其他語言(比如C,Fortran)的不相容性——有虛函數的類的記憶體資料布局和普通的類是很不一樣的。[譯注:這種記憶體資料布局的相容性問題會給多語言混合程式設計帶來麻煩。]

《The Design and Evolution of C++》 中有更多關于設計理念的細節。

Q: 為何析構函數不是預設為虛?

A: 哈,你大概知道我要說什麼了 :O) 仍然是因為——許多類不是被用來做基類的。隻有在類被作為interface使用時虛函數才有意義。(這樣的類常常在記憶體堆上執行個體化對象并通過指針或引用通路。)

那麼,何時我該讓析構函數為虛呢?哦,答案是——當類有其它虛函數的時候,你就應該讓析構函數為虛。有其它虛函數,就意味着這個類要被繼承,就意味着它有點“interface”的味道了。這樣一來,程式員就可能會以基類指針來指向由它的繼承類所執行個體化而來的對象,而能否通過基類指針來正常釋放這樣的對象就要看析構函數是否為虛了。 例如:

class Base {
		// ...
		virtual ~Base();
	};

	class Derived : public Base {
		// ...
		~Derived();
	};

	void f()
	{
		Base* p = new Derived;
		delete p;	// virtual destructor used to ensure that ~Derived is called
	}
      

  如果Base的析構函數不是虛的,那麼Derived的析構函數就不會被調用——這常常會帶來惡果:比如,Derived中配置設定的資源沒有被釋放。

Q: C++中為何沒有虛拟構造函數?

A: 虛拟機制的設計目的是使程式員在不完全了解細節(比如隻知該類實作了某個界面,而不知該類确切是什麼東東)的情況下也能使用對象。但是,要建立一個對象,可不能隻知道“這大體上是什麼”就完事——你必須完全了解全部細節,清楚地知道你要建立的對象是究竟什麼。是以,構造函數當然不能是虛的了。   不過有時在建立對象時也需要一定的間接性,這就需要用點技巧來實作了。(詳見《The C++ Programming Language》,第三版,15.6.2)這樣的技巧有時也被稱作“虛拟構造函數”。我這裡舉個使用抽象類來“虛拟構造對象”的例子:

struct F {	// interface to object creation functions
		virtual A* make_an_A() const = 0;
		virtual B* make_a_B() const = 0;
	};

	void user(const F& fac)
	{
		A* p = fac.make_an_A();	// make an A of the appropriate type
		B* q = fac.make_a_B();	// make a B of the appropriate type
		// ...
	}

	struct FX : F {
		A* make_an_A() const { return new AX();	} // AX is derived from A
		B* make_a_B() const { return new BX();	} // BX is derived from B
	
	};

	struct FY : F {
		A* make_an_A() const { return new AY();	} // AY is derived from A
		B* make_a_B() const { return new BY();	} // BY is derived from B

	};

	int main()
	{
		user(FX());	// this user makes AXs and BXs
		user(FY());	// this user makes AYs and BYs
		// ...
	}
      

  看明白了沒有?上述代碼其實運用了Factory模式的一個變體。關鍵之處是,user()被完全孤立開了——它對AX,AY這些類一無所知。(嘿嘿,有時無知有無知的好處 ^_^)  

Q: 為何無法在派生類中重載?

A: 這個問題常常是由這樣的例子中産生的:

#include<iostream>
	using namespace std;

	class B {
	public:
		int f(int i) { cout << "f(int): "; return i+1; }
		// ...
	};

	class D : public B {
	public:
		double f(double d) { cout << "f(double): "; return d+1.3; }
		// ...
	};

	int main()
	{
		D* pd = new D;

		cout << pd->f(2) << '/n';
		cout << pd->f(2.3) << '/n';
	}
      

  程式運作結果是:

f(double): 3.3
	f(double): 3.6      

而不是某些人(錯誤地)猜想的那樣:

f(int): 3
	f(double): 3.6      

換句話說,在D和B之間沒有重載發生。你調用了pd->f(),編譯器就在D的名字域裡找啊找,找到double f(double)後就調用它了。編譯器懶得再到B的名字域裡去看看有沒有哪個函數更符合要求。記住,在C++中,沒有跨域重載——繼承類和基類雖然關系很親密,但也不能壞了這條規矩。詳見《The Design and Evolution of C++》或者《The C++ Programming Language》第三版。

不過,如果你非得要跨域重載,也不是沒有變通的方法——你就把那些函數弄到同一個域裡來好了。使用一個using聲明就可以搞定。

class D : public B {
	public:
		using B::f;	// make every f from B available
		double f(double d) { cout << "f(double): "; return d+1.3; }
		// ...
	};

      

這樣一來,結果就是

f(int): 3
	f(double): 3.6      

重載發生了——因為D中的那句 using B::f 明确告訴編譯器,要把B域中的f引入目前域,請編譯器“一視同仁”。

Q: 我能從構造函數調用虛函數嗎?

A: 可以。不過你得悠着點。當你這樣做時,也許你自己都不知道自己在幹什麼!在構造函數中,虛拟機制尚未發生作用,因為此時overriding尚未發生。萬丈高樓平地起,總得先打地基吧?對象的建立也是這樣——先把基類構造完畢,然後在此基礎上構造派生類。   看看這個例子:

#include<string>
	#include<iostream>
	using namespace std;

	class B {
	public:
		B(const string& ss) { cout << "B constructor/n"; f(ss); }
		virtual void f(const string&) { cout << "B::f/n";}
	};

	class D : public B {
	public:
		D(const string & ss) :B(ss) { cout << "D constructor/n";}
		void f(const string& ss) { cout << "D::f/n"; s = ss; }
	private:
		string s;
	};

	int main()
	{
		D d("Hello");
	}
      

  這段程式經編譯運作,得到這樣的結果:

B constructor
	B::f
	D constructor      
注意,輸出不是D::f 。 究竟發生了什麼?f()是在B::B()中調用的。如果構造函數中調用虛函數的規則不是如前文所述那樣,而是如一些人希望的那樣去調用D::f()。那麼因為構造函數D::D()尚未運作,字元串s還未初始化,是以當D::f()試圖将參數賦給s時,結果多半是——立馬當機。      

析構則正相反,遵循從繼承類到基類的順序(拆房子總得從上往下拆吧?),是以其調用虛函數的行為和在構造函數中一樣:虛函數此時此刻被綁定到哪裡(當然應該是基類啦——因為繼承類已經被“拆”了——析構了!),調用的就是哪個函數。

更多細節請見《The Design and Evolution of C++》,13.2.4.2 或者《The C++ Programming Language》第三版,15.4.3 。

有時,這條規則被解釋為是由于編譯器的實作造成的。[譯注:從實作角度可以這樣解釋:在許多編譯器中,直到構造函數調用完畢,vtable才被建立,此時虛函數才被動态綁定至繼承類的同名函數。] 但事實上不是這麼一回事——讓編譯器實作成“構造函數中調用虛函數也和從其他函數中調用一樣”是很簡單的[譯注:隻要把vtable的建立移至構造函數調用之前即可]。關鍵還在于語言設計時的考量——讓虛函數可以求助于基類提供的通用代碼。[譯注:先有雞還是先有蛋?Bjarne實際上是在告訴你,不是“先有實作再有規則”,而是“如此實作,因為規則如此”。]

Q: 有"placement delete"嗎?

A: 沒有。不過如果你真的想要,你就說嘛——哦不,我的意思是——你可以自己寫一個。   我們來看看将對象放至某個指定場所的placement new:  

 	class Arena {
 	public:
	   	void* allocate(size_t);
  		void deallocate(void*);      
  		// ...
	};      
void* operator new(size_t sz, Arena& a)
	{
		return a.allocate(sz);
	}      
Arena a1(some arguments);
	Arena a2(some arguments);
      

  現在我們可以寫:

X* p1 = new(a1) X;
	Y* p2 = new(a1) Y;
	Z* p3 = new(a2) Z;
	// ...      

但之後我們如何正确删除這些對象?沒有内置“placement delete”的理由是,沒辦法提供一個通用的placement delete。C++的類型系統沒辦法讓我們推斷出p1是指向被放置在a1中的對象。即使我們能夠非常天才地推知這點,一個簡單的指針指派操作也會讓我們重陷茫然。不過,程式員本人應該知道在他自己的程式中什麼指向什麼,是以可以有解決方案:

template<class T> void destroy(T* p, Arena& a)
	{
		if (p) {
			p->~T();		// explicit destructor call
			a.deallocate(p);
		}
	}
      

  這樣我們就可以寫:     destroy(p1,a1);

  destroy(p2,a2);

  destroy(p3,a3);

  如果Arena自身跟蹤放置其中的對象,那麼你可以安全地寫出destroy()函數 ,把“保證無錯”的監控任務交給Arena,而不是自己承擔。

如何在類繼承體系中定義配對的operator new() 和 operator delete() 可以參看 《The C++ Programming Language》,Special Edition,15.6節 ,《The Design and Evolution of C++》,10.4節,以及《The C++ Programming Language》,Special Edition,19.4.5節。[譯注:此處按原文照譯。前面有提到“參見《The C++ Programming Language》第三版”的,實際上特别版(Special Edition)和較近重印的第三版沒什麼差別。]

  Q: 我能防止别人從我的類繼承嗎?

A: 可以的,但何必呢?好吧,也許有兩個理由:

  • 出于效率考慮——不希望我的函數調用是虛的
  • 出于安全考慮——確定我的類不被用作基類(這樣我拷貝對象時就不用擔心對象被切割(slicing)了)[譯注:“對象切割”指,将派生類對象賦給基類變量時,根據C++的類型轉換機制,隻有包括在派生類中的基類部分被拷貝,其餘部分被“切割”掉了。]

根據我的經驗,“效率考慮”常常純屬多餘。在C++中,虛函數調用如此之快,和普通函數調用并沒有太多的差別。請注意,隻有通過指針或者引用調用時才會啟用虛拟機制;如果你指名道姓地調用一個對象,C++編譯器會自動優化,去除任何的額外開銷。

如果為了和“虛函數調用”說byebye,那麼确實有給類繼承體系“封頂”的需要。在設計前,不訪先問問自己,這些函數為何要被設計成虛的。我确實見過這樣的例子:性能要求苛刻的函數被設計成虛的,僅僅因為“我們習慣這樣做”!

好了,無論如何,說了那麼多,畢竟你隻是想知道,為了某種合理的理由,你能不能防止别人繼承你的類。答案是可以的。可惜,這裡給出的解決之道不夠幹淨利落。你不得不在在你的“封頂類”中虛拟繼承一個無法構造的輔助基類。還是讓例子來告訴我們一切吧:

class Usable;      
class Usable_lock {
	friend class Usable;
	private:
		Usable_lock() {}
		Usable_lock(const Usable_lock&) {}
	};
        
	class Usable : public virtual Usable_lock {
	// ...
	public:
		Usable();
		Usable(char*);
		// ...
	};
        
	Usable a;
        
	class DD : public Usable { };
        
	DD dd;  // error: DD::DD() cannot access
		 // Usable_lock::Usable_lock(): private member
      

  (參見《The Design and Evolution of C++》,11.4.3節)

Q: 為什麼我無法限制模闆的參數?

A: 呃,其實你是可以的。而且這種做法并不難,也不需要什麼超出正常的技巧。

讓我們來看這段代碼:

template<class Container>
	void draw_all(Container& c)
	{
		for_each(c.begin(),c.end(),mem_fun(&Shape::draw));
	}      

如果c不符合constraints,出現了類型錯誤,那麼錯誤将發生在相當複雜的for_each解析之中。比如說,參數化的類型被要求執行個體化int型,那麼我們無法為之調用Shape::draw()。而我們從編譯器中得到的錯誤資訊是含糊而令人迷惑的——因為它和标準庫中複雜的for_each糾纏不清。

為了早點捕捉到這個錯誤,我們可以這樣寫代碼:

template<class Container>
	void draw_all(Container& c)
	{
		Shape* p = c.front(); // accept only containers of Shape*s

		for_each(c.begin(),c.end(),mem_fun(&Shape::draw));
	}      

我們注意到,前面加了一行Shape *p的定義(盡管就程式本身而言,p是無用的)。如果不可将c.front()賦給Shape *p,那麼就大多數現代編譯器而言,我們都可以得到一條含義清晰的出錯資訊。這樣的技巧在所有語言中都很常見,而且對于所有“不同尋常的構造”都不得不如此。[譯注:意指對于任何語言,當我們開始探及極限,那麼不得不寫一些高度技巧性的代碼。]   不過這樣做不是最好。如果要我來寫實際代碼,我也許會這樣寫:

template<class Container>
	void draw_all(Container& c)
	{
		typedef typename Container::value_type T;
		Can_copy<T,Shape*>(); // accept containers of only Shape*s

		for_each(c.begin(),c.end(),mem_fun(&Shape::draw));
	}
      

  這就使代碼通用且明顯地展現出我的意圖——我在使用斷言[譯注:即明确斷言typename Container是draw_all()所接受的容器類型,而不是令人迷惑地定義了一個Shape *指針,也不知道會不會在後面哪裡用到]。Can_copy()模闆可被這樣定義:

template<class T1, class T2> struct Can_copy {
		static void constraints(T1 a, T2 b) { T2 c = a; b = a; }
		Can_copy() { void(*p)(T1,T2) = constraints; }
	};      

Can_copy在編譯期間檢查确認T1可被賦于T2。Can_copy<T,Shape*>檢查确認T是一個Shape*類型,或者是一個指向Shape的公有繼承類的指針,或者是使用者自定義的可被轉型為Shape *的類型。注意,這裡Can_copy()的實作已經基本上是最優化的了:一行代碼用來指明需要檢查的constraints[譯注:指第1行代碼;constraints為T2],和要對其做這個檢查的類型[譯注:要作檢查的類型為T1] ;一行代碼用來精确列出所要檢查是否滿足的constraints(constraints()函數) [譯注:第2行之是以要有2個子句并不是重複,而是有原因的。如果T1,T2均是使用者自定義的類,那麼T2 c = a; 檢測能否預設構造;b = a; 檢測能否拷貝構造] ;一行代碼用來提供執行這些檢查的機會 [譯注:指第3行。Can_copy是一個模闆類;constraints是其成員函數,第2行隻是定義,而未執行] 。   [譯注:這裡constraints實作的關鍵是依賴C++強大的類型系統,特别是類的多态機制。第2行代碼中T2 c = a; b = a; 能夠正常通過編譯的條件是:T1實作了T2的接口。具體而言,可能是以下4種情況:(1) T1,T2 同類型 (2) 重載operator = (3) 提供了 cast operator (類型轉換運算符)(4) 派生類對象賦給基類指針。說到這裡,記起我曾在以前的一篇文章中說到,C++的genericity實作——template不支援constrained genericity,而Eiffel則從文法級别支援constrained genericity(即提供類似于template <typename T as Comparable> xxx 這樣的文法——其中Comparable即為一個constraint)。曾有讀者指出我這樣說是錯誤的,認為C++ template也支援constrained genericity。現在這部分譯文給出了通過使用一些技巧,将OOP和GP的方法結合,進而在C++中巧妙實作constrained genericity的方法。對于愛好C++的讀者,這種技巧是值得細細品味的。不過也不要因為太執著于各種細枝末節的代碼技巧而喪失了全局眼光。有時語言支援方面的欠缺可以在設計層面(而非代碼層面)更優雅地彌補。另外,這能不能算“C++的template支援constrained genericity”,我保留意見。正如,用C通過一些技巧也可以OOP,但我們不說C語言支援OOP。]   請大家再注意,現在我們的定義具備了這些我們需要的特性:

  • 你可以不通過定義/拷貝變量就表達出constraints[譯注:實則定義/拷貝變量的工作被封裝在Can_copy模闆中了] ,進而可以不必作任何“那個類型是這樣被初始化”之類假設,也不用去管對象能否被拷貝、銷毀(除非這正是constraints所在)。[譯注:即——除非constraints正是“可拷貝”、“可銷毀”。如果用易了解的僞碼描述,就是template <typename T as Copy_Enabled> xxx,template <typename T as Destructible> xxx 。]
  • 如果使用現代編譯器,constraints不會帶來任何額外代碼
  • 定義或者使用constraints均不需使用宏定義
  • 如果constraints沒有被滿足,編譯器給出的錯誤消息是容易了解的。事實上,給出的錯誤消息包括了單詞“constraints” (這樣,編碼者就能從中得到提示)、constraints的名稱、具體的出錯原因(比如“cannot initialize Shape* by double*”)

既然如此,我們幹嗎不幹脆在C++語言本身中定義類似Can_copy()或者更優雅簡潔的文法呢?The Design and Evolution of C++分析了此做法帶來的困難。已經有許許多多設計理念浮出水面,隻為了讓含constraints的模闆類易于撰寫,同時還要讓編譯器在constraints不被滿足時給出容易了解的出錯消息。比方說,我在Can_copy中“使用函數指針”的設計就來自于Alex Stepanov和Jeremy Siek。我認為我的Can_copy()實作還不到可以标準化的程度——它需要更多實踐的檢驗。另外,C++使用者會遭遇許多不同類型的constraints,目前看來還沒有哪種形式的帶constraints的模闆獲得壓倒多數的支援。

已有不少關于constraints的“内置語言支援”方案被提議和實作。但其實要表述constraint根本不需要什麼異乎尋常的東西:畢竟,當我們寫一個模闆時,我們擁有C++帶給我們的強有力的表達能力。讓代碼來為我的話作證吧:

template<class T, class B> struct Derived_from {
		static void constraints(T* p) { B* pb = p; }
		Derived_from() { void(*p)(T*) = constraints; }
	};

	template<class T1, class T2> struct Can_copy {
		static void constraints(T1 a, T2 b) { T2 c = a; b = a; }
		Can_copy() { void(*p)(T1,T2) = constraints; }
	};

	template<class T1, class T2 = T1> struct Can_compare {
		static void constraints(T1 a, T2 b) { a==b; a!=b; a<b; }
		Can_compare() { void(*p)(T1,T2) = constraints; }
	};

	template<class T1, class T2, class T3 = T1> struct Can_multiply {
		static void constraints(T1 a, T2 b, T3 c) { c = a*b; }
		Can_multiply() { void(*p)(T1,T2,T3) = constraints; }
	};

	struct B { };
	struct D : B { };
	struct DD : D { };
	struct X { };

	int main()
	{
		Derived_from<D,B>();
		Derived_from<DD,B>();
		Derived_from<X,B>();
		Derived_from<int,B>();
		Derived_from<X,int>();

		Can_compare<int,float>();
		Can_compare<X,B>();
		Can_multiply<int,float>();
		Can_multiply<int,float,double>();
		Can_multiply<B,X>();
	
		Can_copy<D*,B*>();
		Can_copy<D,B*>();
		Can_copy<int,B*>();
	}

	// the classical "elements must derived from Mybase*" constraint:

	template<class T> class Container : Derived_from<T,Mybase> {
		// ...
	};      

事實上Derived_from并不檢查繼承性,而是檢查可轉換性。不過Derive_from常常是一個更好的名字——有時給constraints起個好名字也是件需細細考量的活兒。

Q: 我們已經有了 "美好的老qsort()",為什麼還要用sort()?

A: 對于初學者而言,

qsort(array,asize,sizeof(elem),elem_compare);
      

  看上去有點古怪。還是

sort(vec.begin(),vec.end());
      

  比較好了解,是吧。那麼,這點理由就足夠讓你舍qsort而追求sort了。對于老手來說,sort()要比qsort()快的事實也會讓你心動不已。而且sort是泛型的,可以用于任何合理的容器組合、元素類型和比較算法。例如:

struct Record {
		string name;
		// ...
	};

	struct name_compare {	// compare Records using "name" as the key
		bool operator()(const Record& a, const Record& b) const
			{ return a.name<b.name; }
	};

	void f(vector<Record>& vs)
	{
		sort(vs.begin(), vs.end(), name_compare());
		// ...
	}	
      

另外,還有許多人欣賞sort()的類型安全性——要使用它可不需要任何強制的類型轉換。對于标準類型,也不必寫compare()函數,省事不少。如果想看更詳盡的解釋,參看我的《Learning Standard C++ as a New Language》一文。

另外,為何sort()要比qsort()快?因為它更好地利用了C++的内聯文法語義。

Q: 什麼是function object?

A: Function object是一個對象,不過它的行為表現像函數。一般而言,它是由一個重載了operator()的類所執行個體化得來的對象。

Function object的涵義比通常意義上的函數更廣泛,因為它可以在多次調用之間保持某種“狀态”——這和靜态局部變量有異曲同工之妙;不過這種“狀态”還可以被初始化,還可以從外面來檢測,這可要比靜态局部變量強了。我們來看一個例子:

class Sum {
		int val;
	public:
		Sum(int i) :val(i) { }
		operator int() const { return val; }		// extract value

		int operator()(int i) { return val+=i; }	// application
	};

	void f(vector
   v)
	{
		Sum s = 0;	// initial value 0
		s = for_each(v.begin(), v.end(), s);	// gather the sum of all elements
		cout << "the sum is " << s << "/n";
	
		// or even:
		cout << "the sum is " << for_each(v.begin(), v.end(), Sum(0)) << "/n";
	}
      

  這裡我要提請大家注意:一個function object可被漂亮地内聯化(inlining),因為對于編譯器而言,沒有讨厭的指針來混淆視聽,是以這樣的優化很容易進行。[譯注:這指的是将operator()定義為内聯函數,可以帶來效率的提高。] 作為對比,編譯器幾乎不可能通過優化将“通過函數指針調用函數”這一步驟所花的開銷省掉,至少目前如此。

在标準庫中function objects被廣泛使用,這給标準庫帶來了極大的靈活性和可擴充性。

[譯注:C++是一個博采衆長的語言,function object的概念就是從functional programming中借來的;而C++本身的強大和表現力的豐富也使這種“拿來主義”成為可能。一般而言,在使用function object的地方也常可以使用函數指針;在我們還不熟悉function object的時候我們也常常是使用指針的。但定義一個函數指針的文法可不是太簡單明了,而且在C++中指針早已背上了“錯誤之源”的惡名。更何況,通過指針調用函數增加了間接開銷。是以,無論為了文法的優美還是效率的提高,都應該提倡使用function objects。

下面我們再從設計模式的角度來更深入地了解function objects:這是Visitor模式的典型應用。當我們要對某個/某些對象施加某種操作,但又不想将這種操作限定死,那麼就可以采用Visitor模式。在Design Patterns一書中,作者把這種模式實作為:通過一個Visitor類來提供這種操作(在前面Bjarne Stroustrup的代碼中,Sum就是一個Visitor的變體),用Visitor類執行個體化一個visitor對象(當然,在前面的代碼中對應的是s);然後在Iterator的疊代過程中,為每一個對象調用visitor.visit()。這裡visit()是Visitor類的一個成員函數,作用相當于Sum類中那個“特殊的成員函數”——operator();visit()也完全可以被定義為内聯函數,以去除間接性,提高性能。在此提請讀者注意,C++把重載的操作符也看作函數,隻不過是具有特殊函數名的函數。是以實際上Design Patterns一書中Visitor模式的示範實作和這裡function object的實作大體上是等價的。一個function object也就是一個特殊的Visitor。]

Q: 我應該怎樣處理記憶體洩漏?

A: 很簡單,隻要寫“不漏”的代碼就完事了啊。顯然,如果你的代碼到處是new、delete、指針運算,那你想讓它“不漏”都難。不管你有多麼小心謹慎,君為人,非神也,錯誤在所難免。最終你會被自己越來越複雜的代碼逼瘋的——你将投身于與記憶體洩漏的奮鬥之中,對bug們不離不棄,直至山峰沒有棱角,地球不再轉動。而能讓你避免這樣困境的技巧也不複雜:你隻要倚重隐含在幕後的配置設定機制——構造和析構,讓C++的強大的類系統來助你一臂之力就OK了。标準庫中的那些容器就是很好的執行個體。它們讓你不必化費大量的時間精力也能輕松惬意地管理記憶體。我們來看看下面的示例代碼——設想一下,如果沒有了string和vector,世界将會怎樣?如果不用它們,你能第一次就寫出毫無記憶體錯誤的同樣功能代碼嗎?

#include<vector>
	#include<string>
	#include<iostream>
	#include<algorithm>
	using namespace std;

	int main()	// small program messing around with strings
	{
		cout << "enter some whitespace-separated words:/n";
		vector<string> v;
		string s;
		while (cin>>s) v.push_back(s);

		sort(v.begin(),v.end());

		string cat;
		typedef vector<string>::const_iterator Iter;
		for (Iter p = v.begin(); p!=v.end(); ++p) cat += *p+"+";
		cout << cat << '/n';
	}
      

請注意這裡沒有顯式的記憶體管理代碼。沒有宏,沒有類型轉換,沒有溢出檢測,沒有強制的大小限制,也沒有指針。如果使用function object和标準算法[譯注:名額準庫中提供的泛型算法],我連Iterator也可以不用。不過這畢竟隻是一個小程式,殺雞焉用牛刀?

當然,這些方法也并非無懈可擊,而且說起來容易做起來難,要系統地使用它們也并不總是很簡單。不過,無論如何,它們的廣泛适用性令人驚訝,而且通過移去大量的顯式記憶體配置設定/釋放代碼,它們确實增強了代碼的可讀性和可管理性。早在1981年,我就指出通過大幅度減少需要顯式加以管理的對象數量,使用C++“将事情做對”将不再是一件極其費神的艱巨任務。

如果你的應用領域沒有能在記憶體管理方面助你一臂之力的類庫,那麼如果你還想讓你的軟體開發變得既快捷又能輕松得到正确結果,最好是先建立這樣一個庫。

如果你無法讓記憶體配置設定和釋放成為對象的“自然行為”,那麼至少你可以通過使用資源句柄來盡量避免記憶體洩漏。這裡是一個示例:假設你需要從函數傳回一個對象,這個對象是在自由記憶體堆上配置設定的;你可能會忘記釋放那個對象——畢竟我們無法通過檢查指針來确定其指向的對象是否需要被釋放,我們也無法得知誰應該負責釋放它。那麼,就用資源句柄吧。比如,标準庫中的auto_ptr就可以幫助澄清:“釋放對象”責任究竟在誰。我們來看:

#include<memory>
	#include<iostream>
	using namespace std;

	struct S {
		S() { cout << "make an S/n"; }
		~S() { cout << "destroy an S/n"; }
		S(const S&) { cout << "copy initialize an S/n"; }
		S& operator=(const S&) { cout << "copy assign an S/n"; }
	};

	S* f()
	{
		return new S;	// who is responsible for deleting this S?
	};

	auto_ptr<S> g()
	{
		return auto_ptr<S>(new S);	// explicitly transfer responsibility for deleting this S
	}

	int main()
	{
		cout << "start main/n";
		S* p = f();
		cout << "after f() before g()/n";
	//	S* q = g();	// caught by compiler
		auto_ptr<S> q = g();
		cout << "exit main/n";
		// leaks *p
		// implicitly deletes *q
	}
      

這裡隻是記憶體資源管理的例子;至于其它類型的資源管理,可以如法炮制。

如果在你的開發環境中無法系統地使用這種方法(比方說,你使用了第三方提供的古董代碼,或者遠古“穴居人”參與了你的項目開發),那麼你在開發過程中可千萬要記住使用記憶體防漏檢測程式,或者幹脆使用垃圾收集器(Garbage Collector)。

Q: 為何捕捉到異常後不能繼續執行後面的代碼呢?

A: 這個問題,換句話說也就是:為什麼C++不提供這樣一個原語,能使你處理異常過後傳回到異常抛出處繼續往下執行?[譯注:比如,一個簡單的resume語句,用法和已有的return語句類似,隻不過必須放在exception handler的最後。]

嗯,從異常處理代碼傳回到異常抛出處繼續執行後面的代碼的想法很好[譯注:現行異常機制的設計是:當異常被抛出和處理後,從處理代碼所在的那個catch塊往下執行],但主要問題在于——exception handler不可能知道為了讓後面的代碼正常運作,需要做多少清除異常的工作[譯注:畢竟,當有異常發生,事情就有點不太對勁了,不是嗎;更何況收拾爛攤子永遠是件麻煩的事],是以,如果要讓“繼續執行”能夠正常工作,寫throw代碼的人和寫catch代碼的人必須對彼此的代碼都很熟悉,而這就帶來了複雜的互相依賴關系[譯注:既指開發人員之間的“互相依賴”,也指代碼間的互相依賴——緊耦合的代碼可不是好代碼哦 :O) ],會帶來很多麻煩的維護問題。

在我設計C++的異常處理機制的時候,我曾認真地考慮過這個問題;在C++标準化的過程中,這個問題也被詳細地讨論過。(參見《The Design and Evolution of C++》中關于異常處理的章節)如果你想試試看在抛出異常之前能不能解決問題然後繼續往下執行,你可以先調用一個“檢查—恢複”函數,然後,如果還是不能解決問題,再把異常抛出。一個這樣的例子是new_handler。

Q: 為何C++中沒有C中realloc()的對應物?

A: 如果你一定想要的話,你當然可以使用realloc()。不過,realloc() 隻和通過malloc()之類C函數配置設定得到的記憶體“合作愉快”,在配置設定的記憶體中不能有具備使用者自定義構造函數的對象。請記住:與某些天真的人們的想象相反,realloc()必要時是會拷貝大塊的記憶體到新配置設定的連續空間中的。是以,realloc沒什麼好的 ^_^

在C++中,處理記憶體重配置設定的較好辦法是使用标準庫中的容器,比如vector。[譯注:這些容器會自己管理需要的記憶體,在必要時會“增長尺寸”——進行重配置設定。]

Q: 我如何使用異常處理?

A: 參見《The C++ Programming Language》14章8.3節,以及附錄E。附錄E主要闡述如何撰寫“exception-safe”代碼,這個附錄可不是寫給初學者看的。一個關鍵技巧是“資源配置設定即初始化”——這種技巧通過“類的析構函數”給易造成混亂的“資源管理”帶來了“秩序的曙光”。

Q: 我如何從标準輸入中讀取string?

A: 如果要讀以空白結束的單個單詞,可以這樣:

#include<iostream>
	#include<string>
	using namespace std;

	int main()
	{
		cout << "Please enter a word:/n";

		string s;
		cin>>s;
	
		cout << "You entered " << s << '/n';
	}
      

  請注意,這裡沒有顯式的記憶體管理代碼,也沒有限制尺寸而可能會不小心溢出的緩沖區。 [譯注:似乎Bjarne常驕傲地宣稱這點——因為這是string乃至整個标準庫帶來的重大好處之一,确實值得自豪;而在老的C語言中,最讓程式員抱怨的也是内置字元串類型的缺乏以及由此引起的“操作字元串所需要之複雜記憶體管理措施”所帶來的麻煩。Bjarne一定在得意地想,“哈,我的叫C++的小baby終于長大了,趨向完美了!” :O) ]

如果你需要一次讀一整行,可以這樣:

#include<iostream>
	#include<string>
	using namespace std;

	int main()
	{
		cout << "Please enter a line:/n";

		string s;
		getline(cin, s);
	
		cout << "You entered " << s << '/n';
	}
      

  關于标準庫所提供之功能的簡介(諸如iostream,stream),參見《The C++ Programming Language》第三版的第三章。如果想看C和C++的輸入輸出功能使用之具體比較,參看我的《Learning Standard C++ as a New Language》一文。

Q: 為何C++不提供“finally”結構?

A: 因為C++提供了另一種機制,完全可以取代finally,而且這種機制幾乎總要比finally工作得更好:就是——“配置設定資源即初始化”。(見《The C++ Programming Language》14.4節)基本的想法是,用一個局部對象來封裝一個資源,這樣一來局部對象的析構函數就可以自動釋放資源。這樣,程式員就不會“忘記釋放資源”了。 [譯注:因為C++的對象“生命周期”機制替他記住了 :O) ] 下面是一個例子:

class File_handle {
		FILE* p;
	public:
		File_handle(const char* n, const char* a)
			{ p = fopen(n,a); if (p==0) throw Open_error(errno); }
		File_handle(FILE* pp)
			{ p = pp; if (p==0) throw Open_error(errno); }

		~File_handle() { fclose(p); }

		operator FILE*() { return p; }

		// ...
	};

	void f(const char* fn)
	{
		File_handle f(fn,"rw");	// open fn for reading and writing
		// use file through f
	}
      

  在一個系統中,每一樣資源都需要一個“資源局柄”對象,但我們不必為每一個資源都寫一個“finally”語句。在實作的系統中,資源的擷取和釋放的次數遠遠多于資源的種類,是以“資源配置設定即初始化”機制産生的代碼要比“finally”機制少。   [譯注:Object Pascal,Java,C#等語言都有finally語句塊,常用于發生異常時對被配置設定資源的資源的處理——這意味着有多少次配置設定資源就有多少finally語句塊(少了一個finally就意味着有一些資源配置設定不是“exception safe”的);而“資源配置設定即初始化”機制将原本放在finally塊中的代碼移到了類的析構函數中。我們隻需為每一類資源提供一個封裝類即可。需代碼量孰多孰少?除非你的系統中每一類資源都隻被使用一次——這種情況下代碼量是相等的;否則永遠是前者多于後者 :O) ]

另外,請看看《The C++ Programming Language》附錄E中的資源管理例子。

Q: 那個auto_ptr是什麼東東啊?為什麼沒有auto_array?

A: 哦,auto_ptr是一個很簡單的資源封裝類,是在<memory>頭檔案中定義的。它使用“資源配置設定即初始化”技術來保證資源在發生異常時也能被安全釋放(“exception safety”)。一個auto_ptr封裝了一個指針,也可以被當作指針來使用。當其生命周期到了盡頭,auto_ptr會自動釋放指針。例如:

#include<memory>
	using namespace std;

	struct X {
		int m;
		// ..
	};

	void f()
	{
		auto_ptr<X> p(new X);
		X* q = new X;

		p->m++;		// use p just like a pointer
		q->m++;
		// ...

		delete q;
	}      
如果在代碼用// ...标注的地方抛出異常,那麼p會被正常删除——這個功勞應該記在auto_ptr的析構函數頭上。不過,q指向的X類型對象就沒有被釋放(因為不是用auto_ptr定義的)。詳情請見《The C++ Programming Language》14.4.2節。      

Auto_ptr是一個輕量級的類,沒有引入引用計數機制。如果你把一個auto_ptr(比如,ap1)賦給另一個auto_ptr(比如,ap2),那麼ap2将持有實際指針,而ap1将持有零指針。例如:

#include<memory>
	#include<iostream>
	using namespace std;

	struct X {
		int m;
		// ..
	};

	int main()
	{
		auto_ptr<X> p(new X);
		auto_ptr<X> q(p);
		cout << "p " << p.get() << " q " << q.get() << "/n";
	}
      

  運作結果應該是先顯示一個零指針,然後才是一個實際指針,就像這樣:

p 0x0 q 0x378d0
      

  auto_ptr::get()傳回實際指針。

這裡,語義似乎是“轉移”,而非“拷貝”,這或許有點令人驚訝。特别要注意的是,不要把auto_ptr作為标準容器的參數——标準容器要求通常的拷貝語義。例如:

std::vector<auto_ptr<X> >v;	// error
      

一個auto_ptr隻能持有指向單個元素的指針,而不是數組指針:

void f(int n)
	{
		auto_ptr<X> p(new X[n]);	// error
		// ...
	}
      

  上述代碼會出錯,因為析構函數是使用delete而非delete[]來釋放指針的,是以後面的n-1個X沒有被釋放。

那麼,看來我們應該用一個使用delete[]來釋放指針的,叫auto_array的類似東東來放數組了?哦,不,不,沒有什麼auto_array。理由是,不需要有啊——我們完全可以用vector嘛:

void f(int n)
	{
		vector<X> v(n);
		// ...
	}
      

  如果在 // ... 部分發生了異常,v的析構函數會被自動調用。  

Q: C和C++風格的記憶體配置設定/釋放可以混用嗎?

A: 可以——從你可在一個程式中同時使用malloc()和new的意義上而言。

不可以——從你無法delete一個以malloc()配置設定而來之對象的意義上而言。你也無法free()或realloc()一個由new配置設定而來的對象。

C++的new和delete運算符確定構造和析構正常發生,但C風格的malloc()、calloc()、free()和realloc()可不保證這點。而且,沒有任何人能向你擔保,new/delete和malloc/free所掌控的記憶體是互相“相容”的。如果在你的代碼中,兩種風格混用而沒有給你造成麻煩,那我隻能說:直到目前為止,你是非常幸運的 :O)

如果你因為思念“美好的老realloc()”(許多人都思念她)而無法割舍整個古老的C記憶體配置設定機制(愛屋及烏?),那麼考慮使用标準庫中的vector吧。例如:

// read words from input into a vector of strings:

	vector<string> words;
	string s;
	while (cin>>s && s!=".") words.push_back(s);
      

  Vector會按需要自動增長的。

我的《Learning Standard C++ as a New Language》一文中給出了其它例子,可以參考。

Q: 我想從void *轉換,為什麼必須使用換型符?

A: 在C中,你可以隐式轉換,但這是不安全的,例如:

#include<stdio.h>

	int main()
	{
		char i = 0;
		char j = 0;
		char* p = &i;
		void* q = p;
		int* pp = q;	/* unsafe, legal C, not C++ */

		printf("%d %d/n",i,j);
		*pp = -1;	/* overwrite memory starting at &i */
		printf("%d %d/n",i,j);
	}
      

  如果你使用T*類型的指針,該指針卻不指向T類型的對象,後果可能是災難性的;是以在C++中如果你要将void*換型為T*,你必須使用顯式換型:

int* pp = (int*)q;
      

  或者,更好的是,使用新的換型符,以使換型操作更為醒目:

int* pp = static_cast<int*>(q);
      

  當然,最好的還是——不要換型。

在C中一類最常見的不安全換型發生在将malloc()配置設定而來的記憶體賦給某個指針之時,例如:

int* p = malloc(sizeof(int));
      

  在C++中,應該使用類型安全的new操作符:

int* p = new int;
      

  而且,new還有附帶的好處:

  • new不會“偶然”地配置設定錯誤大小的記憶體
  • new自動檢查記憶體是否已枯竭
  • new支援初始化

例如: 

typedef std::complex<double> cmplx;

	/* C style: */
	cmplx* p = (cmplx*)malloc(sizeof(int));	/* error: wrong size */
							/* forgot to test for p==0 */
	if (*p == 7) { /* ... */ }			/* oops: forgot to initialize *p */

	// C++ style:
	cmplx* q = new cmplx(1,2); // will throw bad_alloc if memory is exhausted
	if (*q == 7) { /* ... */ }
      

A: 如何在類中定義常量?

Q: 如果你想得到一個可用于常量表達式中的常量,例如數組大小的定義,那麼你有兩種選擇:

class X {
		static const int c1 = 7;
		enum { c2 = 19 };

		char v1[c1];
		char v2[c2];

		// ...
	};      
一眼望去,c1的定義似乎更加直截了當,但别忘了隻有static的整型或枚舉型量才能如此初始化。這就很有局限性,例如:      
class Y {
		const int c3 = 7;		// error: not static
		static int c4 = 7;		// error: not const
		static const float c5 = 7;	// error not integral
	};      
我還是更喜歡玩“enum戲法”,因為這種定義可移植性好,而且不會引誘我去使用非标準的“類内初始化”擴充文法。      

那麼,為何要有這些不友善的限制?因為類通常聲明在頭檔案中,而頭檔案往往被許多單元所包含。[是以,類可能會被重複聲明。]但是,為了避免連結器設計的複雜化,C++要求每個對象都隻能被定義一次。如果C++允許類内定義要作為對象被存在記憶體中的實體,那麼這項要求就無法滿足了。關于C++設計時的一些折衷,參見《The Design and Evolution of C++》。

如果這個常量不需要被用于常量表達式,那麼你的選擇餘地就比較大了:

class Z {
		static char* p;		// initialize in definition
		const int i;		// initialize in constructor
	public:
		Z(int ii) :i(ii) { }
	};

	char* Z::p = "hello, there";
      

  隻有當static成員是在類外被定義的,你才可以擷取它的位址,例如:

class AE {
		// ...
	public:
		static const int c6 = 7;
		static const int c7 = 31;
	};

	const int AE::c7;	// definition

	int f()
	{
		const int* p1 = &AE::c6;	// error: c6 not an lvalue
		const int* p2 = &AE::c7;	// ok
		// ...
	}
      

Q: 為何delete操作不把指針置零?

A: 嗯,問得挺有道理的。我們來看:

delete p;
	// ...
	delete p;
      

  如果代碼中的//...部分沒有再次給p配置設定記憶體,那麼這段代碼就對同一片記憶體釋放了兩次。這是個嚴重的錯誤,可惜C++無法有效地阻止你寫這種代碼。不過,我們都知道,釋放空指針是無危害的,是以如果在每一個delete p;後面都緊接一個p = 0;,那麼兩次釋放同一片記憶體的錯誤就不會發生了。盡管如此,在C++中沒有任何文法可以強制程式員在釋放指針後立刻将該指針歸零。是以,看來避免犯這樣的錯誤的重任隻能全落在程式員肩上了。或許,delete自動把指針歸零真是個好主意?

哦,不不,這個主意不夠“好”。一個理由是,被delete的指針未必是左值。我們來看:

delete p+1;
	delete f(x);
      

  你讓delete把什麼自動置零?也許這樣的例子不常見,但足可證明“delete自動把指針歸零”并不保險。[譯注:事實上,我們真正想要的是:“任何指向被釋放的記憶體區域的指針都被自動歸零”——但可惜除了Garbage Collector外沒什麼東東可以做到這點。] 再來看個簡單例子:

T* p = new T;
	T* q = p;
	delete p;
	delete q;	// ouch!
      

  C++标準其實允許編譯器實作為“自動把傳給delete的左值置零”,我也希望編譯器廠商這樣做,但看來廠商們并不喜歡這樣。一個理由就是上述例子——第3行語句如果delete把p自動置零了又如何呢?q又沒有被自動置零,第4行照樣出錯。

如果你覺得釋放記憶體時把指針置零很重要,那麼不妨寫這樣一個destroy函數:

template<class T> inline void destroy(T*& p) { delete p; p = 0; }
      

不妨把delete帶來的麻煩看作“盡量少用new/delete,多用标準庫中的容器”之另一條理由吧 :O)

請注意,把指針作為引用傳遞(以便delete可以把指針置零)會帶來額外的效益——防止右值被傳遞給destroy() :

int* f();
	int* p;
	// ...
	destroy(f());	// error: trying to pass an rvalue by non-const reference
	destroy(p+1);	// error: trying to pass an rvalue by non-const reference
      

Q: 我可以寫"void main()"嗎?

A: 這樣的定義

void main() { /* ... */ }
      

  不是C++,也不是C。(參見ISO C++ 标準 3.6.1[2] 或 ISO C 标準 5.1.2.2.1) 一個遵從标準的編譯器實作應該接受

int main() { /* ... */ }
      

和 

int main(int argc, char* argv[]) { /* ... */ }
      

  編譯器也可以提供main()的更多重載版本,不過它們都必須傳回int,這個int是傳回給你的程式的調用者的,這是種“負責”的做法,“什麼都不傳回”可不大好哦。如果你程式的調用者不支援用“傳回值”來交流,這個值會被自動忽略——但這也不能使void main()成為合法的C++或C代碼。即使你的編譯器支援這種定義,最好也不要養成這種習慣——否則你可能被其他C/C++認為淺薄無知哦。   在C++中,如果你嫌麻煩,可以不必顯式地寫出return語句。編譯器會自動傳回0。例如:

#include<iostream>

	int main()
	{
		std::cout << "This program returns the integer value 0/n";
	}
      

  麻煩嗎?不麻煩,int main()比void main()還少了一個字母呢 :O)另外,還要請你注意:無論是ISO C++還是C99都不允許你省略傳回類型定義。這也就是說,和C89及ARM C++[譯注:指Margaret Ellis和Bjarne Stroustrup于1990年合著的《The Annotated C++ Reference Manual》中描述的C++]不同,int并不是預設傳回值。是以,

#include<iostream>

	main() { /* ... */ }      

會出錯,因為main()函數缺少傳回類型。  

Q: 為何我不能重載“.”、“::”和“sizeof”等操作符?

A: 大部分的操作符是可以被重載的,例外的隻有“.”、“::”、“?:”和“sizeof”。沒有什麼非禁止operator?:重載的理由,隻不過沒有必要而已。另外,expr1?expr2:expr3的重載函數無法保證expr2和expr3中隻有一個被執行。

而“sizeof”無法被重載是因為不少内部操作,比如指針加法,都依賴于它,例如:

X a[10];
	X* p = &a[3];
	X* q = &a[3];
	p++;	// p points to a[4]
		// thus the integer value of p must be
		// sizeof(X) larger than the integer value of q
      

  這樣,sizeof(X)無法在不違背基本語言規則的前提下表達什麼新的語義。

在N::m中,N和m都不是表達式,它們隻是編譯器“認識”的名字,“::”執行的實際操作是編譯時的名字域解析,并沒有表達式的運算牽涉在内。或許有人會覺得重載一個“x::y”(其中x是實際對象,而非名字域或類名)是一個好主意,但這樣做引入了新的文法[譯注:重載的本意是讓操作符可以有新的語義,而不是更改文法——否則會引起混亂],我可不認為新文法帶來的複雜性會給我們什麼好處。

原則上來說,“.”運算符是可以被重載的,就像“->”一樣。不過,這會帶來語義的混淆——我們到底是想和“.”後面的對象打交道呢,還是“.”後面的東東所實際指向的實體打交道呢?看看這個例子(它假設“.”重載是可以的):

class Y {
	public:
		void f();
		// ...
	};

	class X {	// assume that you can overload .
		Y* p;
		Y& operator.() { return *p; }
		void f();
		// ...
	};

	void g(X& x)
	{
		x.f();	// X::f or Y::f or error?
	}
      

這個問題有好幾種解決方案。在C++标準化之時,何種方案為佳并不明顯。細節請參見《The Design and Evolution of C++》。

Q: 我怎樣才能把整數轉化為字元串?

A: 最簡單的方法是使用stringstream :

#include<iostream>
	#include<string>
	#include<sstream>
	using namespace std;

	string itos(int i)	// convert int to string
	{
		stringstream s;
		s << i;
		return s.str();
	}

	int main()
	{
		int i = 127;
		string ss = itos(i);
		const char* p = ss.c_str();

		cout << ss << " " << p << "/n";
	}
      

  當然,很自然地,你可以用這種方法來把任何可通過“<<”輸出的類型轉化為string。想知道string流的細節嗎?參見《The C++ Programming Language》,21.5.3節。  

Q: “int* p;”和“int *p;”,到底哪個正确?

A: 如果讓計算機來讀,兩者完全等同,都是正确的。我們還可以聲明成“int * p”或“int*p”。編譯器不會理會你是不是在哪裡多放了幾個空格。

不過如果讓人來讀,兩者的含義就有所不同了。代碼的書寫風格是很重要的。C風格的表達式和聲明式常被看作比“necessary evil”[譯注:“必要之惡”,意指為了達到某種目的而不得不付出的代價。例如有人認為環境的破壞是經濟發展帶來的“necessary evil”]更糟的東西,而C++則很強調類型。是以,“int *p”和“int* p”之間并無對錯之分,隻有風格之争。

一個典型的C程式員會寫“int *p”,而且振振有詞地告訴你“這表示‘*p是一個int’”——聽上去挺有道理的。這裡,*和p綁在了一起——這就是C的風格。這種風格強調的是文法。

而一個典型的C++程式員會寫“int* p”,并告訴你“p是一個指向int的指針,p的類型是int*”。這種風格強調的是類型。當然,我喜歡這種風格 :O) 而且,我認為,類型是非常重要的概念,我們應該注重類型。它的重要性絲毫不亞于C++語言中的其它“較為進階的部分”。[譯注:諸如RTTI,各種cast,template機制等,可稱為“較進階的部分”了吧,但它們其實也是類型概念的擴充和運用。我曾寫過兩篇談到C++和OOP的文章發表在本刊上,文中都強調了了解“類型”之重要性。我還曾譯過Object Unencapsulated (這本書由作者先前所著在網上廣為流傳的C++?? A Critique修訂而來)中講類型的章節,這本書的作者甚至稱Object Oriented Programming應該正名為Type Oriented Programming——“面向類型程式設計”!這有點矯枉過正了,但類型确是程式設計語言之核心部分。]

當聲明單個變量時,int *和int*的差别并不是特别突出,但當我們要一次聲明多個變量時,易混淆之處就全暴露出來了:

int* p, p1;	// probable error: p1 is not an int*
      

  這裡,p1的類型到底是int還是int *呢?把*放得離p近一點也同樣不能澄清問題:

int *p, p1;	// probable error?
      

  看來為了保險起見,隻好一次聲明一個變量了——特别是當聲明伴随着初始化之時。[譯注:本FAQ中凡原文為declare/declaration的均譯為聲明;define/definition均譯為定義。通常認為,兩者涵義之基本差别是:“聲明”隻是為編譯器提供資訊,讓編譯器在符号表中為被聲明的符号(比如類型名,變量名,函數名等)保留位置,而不用指明該符号所對應的具體語義——即:沒有任何記憶體空間的配置設定或者實際二進制代碼的生成。而“定義”則須指明語義——如果把“聲明”比作在辭典中為一個新詞保留條目;那麼“定義”就好比在條目中對這個詞的意思、用法給出詳細解釋。當我們說一個C++語句是“定義”,那麼編譯器必定會為該語句産生對應的機器指令或者配置設定記憶體,而被稱為“聲明”的語句則不會被編譯出任何實際代碼。從這個角度而言,原文中有些地方雖作者寫的是“對象、類、類型的聲明(declaration)”,但或許改譯為“定義”較符合我們的了解。不過本譯文還是采用忠于原文的譯法,并不按照我的了解而加以更改。特此說明。另外,譯文中凡涉及我個人對原文的了解、補充之部分均以譯注形式給出,供讀者參考。]人們一般不太可能寫出像這樣的代碼:

int* p = &i;
	int p1 = p;	// error: int initialized by int*
      

  如果真的有人這樣寫,編譯器也不會同意——它會報錯的。

每當達到某種目的有兩條以上途徑,就會有些人被搞糊塗;每當一些選擇是出于個人喜好,争論就會無休無止。堅持一次隻聲明一個指針并在聲明時順便初始化,困擾我們已久的混淆之源就會随風逝去。如果你想了解有關C的聲明文法的更多讨論,參見《The Design and Evolution of C++》 。

Q: 何種代碼布局風格為佳?

A: 哦,這是個人品味問題了。人們常常很重視代碼布局之風格,但或許風格的一緻性要比選擇何種風格更重要。如果非要我為我的個人偏好建立“邏輯證明”,和别人一樣,我會頭大的 :O)

我個人喜歡使用“K&R”風格,如果算上那些C語言中不存在的構造之使用慣例,那麼人們有時也稱之為“Stroustrup”風格。例如:

class C : public B {
	public:
		// ...
	};

	void f(int* p, int max)
	{
		if (p) {
			// ...
		}
	
		for (int i = 0; i<max; ++i) {
			// ...
		}
	}
      

  這種風格比較節省“垂直空間”——我喜歡讓盡量多的内容可以顯示在一屏上 :O) 而函數定義開始的花括号之是以如此放置,是因為這樣一來就和類定義區分開來,我就可以一眼看出:噢,這是函數!

正确的縮進非常重要。

一些設計問題,比如使用抽象類來表示重要的界面、使用模闆來表示靈活而可擴充的類型安全抽象、正确使用“異常”來表示錯誤,遠遠要比代碼風格重要。

[譯注:《The Practice of Programming》中有一章對“代碼風格”問題作了詳細的闡述。]

Q: 我該把const寫在類型前面還是後面?

A: 我是喜歡寫在前面的。不過這隻是個人口味的問題。“const T”和“T const”均是允許的,而且它們是等價的。例如:

const int a = 1;	// ok
	int const b = 2;	// also ok
      

  我想,使用第一種寫法更合乎語言習慣,比較不容易讓人迷惑 :O)

為什麼會這樣?當我發明“const”(最早是被命名為“readonly”且有一個叫“writeonly”的對應物)時,我讓它在前面和後面都行,因為這不會帶來二義性。當時的C/C++編譯器對修飾符很少有強加的語序規則。

我不記得當時有過什麼關于語序的深思熟慮或相關的争論。一些早期的C++使用者(特别是我)當時隻是單純地覺得const int c = 10;要比int const c = 10;好看而已。或許,我是受了這件事實的影響:許多我早年寫的例子是用“readonly”修飾的,而readonly int c = 10;确實看上去要比int readonly c = 10;舒服。而最早的使用“const”的C/C++代碼是我用全局查找替換功能把readonly換成const而來的。我還記得和幾個人讨論過關于文法“變體”問題,包括Dennis Ritchie。不過我不記得當時我們談的是哪幾種語言了。

另外,請注意:如果指針本身不可被修改,那麼const應該放在“*”的後面。例如:

int *const p1 = q;	// constant pointer to int variable
	int const* p2 = q;	// pointer to constant int
	const int* p3 = q;	// pointer to constant int
      

Q: 宏有什麼不好嗎?

A: 宏不遵循C++的作用域和類型規則,這會帶來許多麻煩。是以,C++提供了能和語言其它部分“合作愉快”的替代機制,比如内聯函數、模闆、名字空間機制。讓我們來看這樣的代碼:

#include "someheader.h"
	struct S {
 		int alpha;
	 	int beta;
	 };      
如果有人(不明智地)寫了一個叫“alpha”或者“beta”的宏,那麼這段代碼無法通過編譯,甚至可能更糟——編譯出一些你未曾預料的結果。比方說:如果“someheader.h”包含了如下定義:       
#define alpha 'a'
	#define beta b[2]      
那麼前面的代碼就完全背離本意了。      

把宏(而且隻有宏)的名稱全部用大寫字母表示确實有助于緩解問題,但宏是沒有語言級保護機制的。例如,在以上例子中alpha和beta在S的作用域中,是S的成員變量,但這對于宏毫無影響。宏的展開是在編譯前進行的,展開程式隻是把源檔案看作字元流而已。這也是C/C++程式設計環境的欠缺之處:計算機和電腦眼中的源檔案的涵義是不同的。

不幸的是,你無法確定其他程式員不犯你所認為的“愚蠢的”錯誤。比方說,近來有人告訴我,他們遇到一個含“goto”語句的宏。我見到過這樣的代碼,也聽到過這樣的論點——有時宏中的“goto”是有用的。例如:

#define prefix get_ready(); int ret__
	#define Return(i) ret__=i; do_something(); goto exit
	#define suffix exit: cleanup(); return ret__

	void f()
	{
		prefix;
		// ...
		Return(10);
		// ...
		Return(x++);
		//...
		suffix;
	}
      

  如果你是一個負責維護的程式員,這樣的代碼被送出到你面前,而宏定義(為了給這個“戲法”增加難度而)被藏到了一個頭檔案中(這種情況并非罕見),你作何感想?是不是一頭霧水?

一個常見而微妙的問題是,函數風格的宏不遵守函數參數調用規則。例如:

#define square(x) (x*x)

	void f(double d, int i)
	{
		square(d);	// fine
		square(i++);	// ouch: means (i++*i++)
		square(d+1);	// ouch: means (d+1*d+1); that is, (d+d+1)
		// ...
	}
      

  “d+1”的問題可以通過給宏定義加括号解決:

#define square(x) ((x)*(x))	/* better */
      

  但是,“i++”被執行兩次的問題仍然沒有解決。

我知道有些(其它語言中)被稱為“宏”的東西并不象C/C++預處理器所處理的“宏”那樣缺陷多多、麻煩重重,但我并不想改進C++的宏,而是建議你正确使用C++語言中的其他機制,比如内聯函數、模闆、構造函數、析構函數、異常處理等。 

[譯注:以上是Bjarne Stroustrup的C++ Style and Technique FAQ的全文翻譯。Bjarne是丹麥人,他寫的英文文章可不好讀,技術文章尤甚。本譯文或許錯誤偏頗之處不少,歡迎廣大讀者指正。我的email是 [email protected] 。]