C++風格與技巧(Bjarne Stroustrup著)

目錄:

我如何寫這個非常簡單的程序?

爲什麼編譯要花這麼長的時間?

爲什麼一個空類的大小不爲0?

我必須在類聲明處賦予數據嗎?

爲什麼成員函數默認不是virtual的?

爲什麼析構函數默認不是virtual的?

爲什麼不能有虛擬構造函數?

爲什麼重載在繼承類中不工作?

我能夠在構造函數中調用一個虛擬函數嗎?

有沒有“指定位置刪除”(placement delete)?

我能防止別人繼承我自己的類嗎?

爲什麼不能爲模板參數定義約束(constraints)?

既然已經有了優秀的qsort()函數,爲什麼還需要一個sort()?

什麼是函數對象(function object)?

我應該如何對付內存泄漏?

我爲什麼在捕獲一個異常之後就不能繼續?

爲什麼C++中沒有相當於realloc()的函數?

如何使用異常?

怎樣從輸入中讀取一個字符串?

爲什麼C++不提供“finally”的構造?

什麼是自動指針(auto_ptr),爲什麼沒有自動數組(auto_array)?

可以混合使用C風格與C++風格的內存分派與重新分配嗎?

我爲什麼必須使用一個造型來轉換*void?

我如何定義一個類內部(in-class)的常量?

爲什麼delete不會將操作數置0?

我能夠寫“void main()”嗎?

爲什麼我不能重載點符號,::,sizeof,等等?

怎樣將一個整型值轉換爲一個字符串?

“int* p”正確還是“int *p”正確?

對於我的代碼,哪一種佈局風格(layout style)是最好的?

我應該將“const”放在類型之前還是之後?

使用宏有什麼問題?


我如何寫這個非常簡單的程序?


特別是在一個學期的開始,我常常收到許多關於編寫一個非常簡單的程序的詢問。這個問題有一個很具代表性的解決方法,那就是(在你的程序中)讀入幾個數字,對它們做一些處理,再把結果輸出。下面是一個這樣做的例子:


#include<iostream>

#include<vector>

#include<algorithm>

using namespace std;


int main()

{

vector<double> v;


double d;

while(cin>>d) v.push_back(d); // 讀入元素

if (!cin.eof()) { // 檢查輸入是否出錯

cerr << "format error/n";

return 1; // 返回一個錯誤

}


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; // 成功返回

}


對這段程序的觀察:


這是一段標準的ISO C++程序,使用了標準庫(standard library)。標準庫工具在命名空間std中聲明,封裝在沒有.h後綴的頭文件中。


如果你要在Windows下編譯它,你需要將它編譯成一個“控制檯程序”(console application)。記得將源文件加上.cpp後綴,否則編譯器可能會以爲它是一段C代碼而不是C++。


是的,main()函數返回一個int值。


讀到一個標準的向量(vector)中,可以避免在隨意確定大小的緩衝中溢出的錯誤。讀到一個數組(array)中,而不產生“簡單錯誤”(silly error),這已經超出了一個新手的能力——如果你做到了,那你已經不是一個新手了。如果你對此表示懷疑,我建議你閱讀我的文章“將標準C++作爲一種新的語言來學習”("Learning Standard C++ as a New Language"),你可以在本人著作列表(my publications list)中下載到它。


!cin.eof()是對流的格式的檢查。事實上,它檢查循環是否終結於發現一個end-of-file(如果不是這樣,那麼意味着輸入沒有按照給定的格式)。更多的說明,請參見你的C++教科書中的“流狀態”(stream state)部分。


vector知道它自己的大小,因此我不需要計算元素的數量。


這段程序沒有包含顯式的內存管理。Vector維護一個內存中的棧,以存放它的元素。當一個vector需要更多的內存時,它會分配一些;當它不再生存時,它會釋放內存。於是,使用者不需要再關心vector中元素的內存分配和釋放問題。


程序在遇到輸入一個“end-of-file”時結束。如果你在UNIX平臺下運行它,“end-of-file”等於鍵盤上的Ctrl+D。如果你在Windows平臺下,那麼由於一個BUG它無法辨別“end-of-file”字符,你可能傾向於使用下面這個稍稍複雜些的版本,它使用一個詞“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); // 讀入一個元素

if (!cin.eof()) { // 檢查輸入是否失敗

cin.clear(); // 清除錯誤狀態

string s;

cin >> s; // 查找結束字符

if (s != "end") {

cerr << "format error/n";

return 1; // 返回錯誤

}

}


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; // 成功返回

}


更多的關於使用標準庫將事情簡化的例子,請參見《C++程序設計語言》中的“漫遊標準庫”("Tour of the Standard Library")一章。


爲什麼編譯要花這麼長的時間?


你的編譯器可能有問題。也許它太老了,也許你安裝它的時候出了錯,也許你用的計算機已經是個古董。在諸如此類的問題上,我無法幫助你。


但是,這也是很可能的:你要編譯的程序設計得非常糟糕,以至於編譯器不得不檢查數以百計的頭文件和數萬行代碼。理論上來說,這是可以避免的。如果這是你購買的庫的設計問題,你對它無計可施(除了換一個更好的庫),但你可以將你自己的代碼組織得更好一些,以求得將修改代碼後的重新編譯工作降到最少。這樣的設計會更好,更有可維護性,因爲它們展示了更好的概念上的分離。


看看這個典型的面向對象的程序例子:


class Shape {

public: // 使用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的public接口來操縱它們,而派生類(例如Circle和Triangle)的實現部分則共享由protected成員表現的那部分實現(implementation)。


這不是一件容易的事情:確定哪些實現部分是對所有的派生類都有用的,並將之共享出來。因此,與public接口相比,protected成員往往要做多得多的改動。舉例來說,雖然理論上“中心”(center)對所有的圖形都是一個有效的概念,但當你要維護一個三角形的“中心”的時候,是一件非常麻煩的事情——對於三角形,當且僅當它確實被需要的時候,計算這個中心纔是有意義的。


protected成員很可能要依賴於實現部分的細節,而Shape的用戶(譯註:user此處譯爲用戶,指使用Shape類的代碼,下同)卻不見得必須依賴它們。舉例來說,很多(大多數?)使用Shape的代碼在邏輯上是與“顏色”無關的,但是由於Shape中“顏色”這個定義的存在,卻可能需要一堆複雜的頭文件,來結合操作系統的顏色概念。


當protected部分發生了改變時,使用Shape的代碼必須重新編譯——即使只有派生類的實現部分才能夠訪問protected成員。


於是,基類中的“實現相關的信息”(information helpful to implementers)對用戶來說變成了象接口一樣敏感的東西,它的存在導致了實現部分的不穩定,用戶代碼的無謂的重編譯(當實現部分發生改變時),以及將頭文件無節制地包含進用戶代碼中(因爲“實現相關的信息”需要它們)。有時這被稱爲“脆弱的基類問題”(brittle base class problem)。


一個很明顯的解決方案就是,忽略基類中那些象接口一樣被使用的“實現相關的信息”。換句話說,使用接口,純粹的接口。也就是說,用抽象基類的方式來表示接口:


class Shape {

public: //使用Shapes的用戶的接口

virtual void draw() const = 0;

virtual void rotate(int degrees) = 0;

virtual Point center() const = 0;

// ...


// 沒有數據

};


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: //使用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;

};


爲什麼一個空類的大小不爲0?


要清楚,兩個不同的對象的地址也是不同的。基於同樣的理由,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";

}


有一條有趣的規則:一個空的基類並不一定有分隔字節。

struct X : Empty {

int a;

// ...

};


void f(X* p)

{

void* p1 = p;

void* p2 = &p->a;

if (p1 == p2) cout << "nice: good optimizer";

}


這種優化是允許的,可以被廣泛使用。它允許程序員使用空類以表現一些簡單的概念。現在有些編譯器提供這種“空基類優化”(empty base class optimization)。


我必須在類聲明處賦予數據嗎?


不必須。如果一個接口不需要數據時,無須在作爲接口定義的類中賦予數據。代之以在派生類中給出它們。參見“爲什麼編譯要花這麼長的時間?”。


有時候,你必須在一個類中賦予數據。考慮一下複數類的情況:


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;

};


設計這種類型的目的是將它當做一個內建(built-in)類型一樣被使用。在聲明處賦值是必須的,以保證如下可能:建立真正的本地對象(genuinely local objects)(比如那些在棧中而不是在堆中分配的對象),或者使某些簡單操作被適當地inline化。對於那些支持內建的複合類型的語言來說,要獲得它們提供的效率,真正的本地對象和inline化都是必要的。


爲什麼成員函數默認不是virtual的?


因爲很多類並不是被設計作爲基類的。例如複數類。


而且,一個包含虛擬函數的類的對象,要佔用更多的空間以實現虛擬函數調用機制——往往是每個對象佔用一個字(word)。這個額外的字是非常可觀的,而且在涉及和其它語言的數據的兼容性時,可能導致麻煩(例如C或Fortran語言)。


要了解更多的設計原理,請參見《C++語言的設計和演變》(The Design and Evolution of C++)。


爲什麼析構函數默認不是virtual的?


因爲很多類並不是被設計作爲基類的。只有類在行爲上是它的派生類的接口時(這些派生類往往在堆中分配,通過指針或引用來訪問),虛擬函數纔有意義。


那麼什麼時候才應該將析構函數定義爲虛擬呢?當類至少擁有一個虛擬函數時。擁有虛擬函數意味着一個類是派生類的接口,在這種情況下,一個派生類的對象可能通過一個基類指針來銷燬。例如:


class Base {

// ...

virtual ~Base();

};


class Derived : public Base {

// ...

~Derived();

};


void f()

{

Base* p = new Derived;

delete p; // 虛擬析構函數保證~Derived函數被調用

}


如果基類的析構函數不是虛擬的,那麼派生類的析構函數將不會被調用——這可能產生糟糕的結果,例如派生類的資源不會被釋放。


爲什麼不能有虛擬構造函數?


虛擬調用是一種能夠在給定信息不完全(given partial information)的情況下工作的機制。特別地,虛擬允許我們調用某個函數,對於這個函數,僅僅知道它的接口,而不知道具體的對象類型。但是要建立一個對象,你必須擁有完全的信息。特別地,你需要知道要建立的對象的具體類型。因此,對構造函數的調用不可能是虛擬的。


當要求建立一個對象時,一種間接的技術常常被當作“虛擬構造函數”來使用。有關例子,請參見《C++程序設計語言》第三版15.6.2.節。


下面這個例子展示一種機制:如何使用一個抽象類來建立一個適當類型的對象。


struct F { // 對象建立函數的接口

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(); // 將A作爲合適的類型

B* q = fac.make_a_B(); // 將B作爲合適的類型

// ...

}


struct FX : F {

A* make_an_A() const { return new AX(); } // AX是A的派生

B* make_a_B() const { return new BX(); } // AX是B的派生

};


struct FY : F {

A* make_an_A() const { return new AY(); } // AY是A的派生

B* make_a_B() const { return new BY(); } // BY是B的派生


};


int main()

{

user(FX()); // 此用戶建立AX與BX

user(FY()); // 此用戶建立AY與BY

// ...

}


這是所謂的“工廠模式”(the factory pattern)的一個變形。關鍵在於,user函數與AX或AY這樣的類的信息被完全分離開來了。


爲什麼重載在繼承類中不工作?


這個問題(非常常見)往往出現於這樣的例子中:


#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


換句話說,在B和D之間並沒有發生重載的解析。編譯器在D的區域內尋找,找到了一個函數double f(double),並執行了它。它永遠不會涉及(被封裝的)B的區域。在C++中,沒有跨越區域的重載——對於這條規則,繼承類也不例外。更多的細節,參見《C++語言的設計和演變》和《C++程序設計語言》。


但是,如果我需要在基類和繼承類之間建立一組重載的f()函數呢?很簡單,使用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


這樣,在B的f()和D的f()之間,重載確實實現了,並且選擇了一個最合適的f()進行調用。


我能夠在構造函數中調用一個虛擬函數嗎?


可以,但是要小心。它可能不象你期望的那樣工作。在構造函數中,虛擬調用機制不起作用,因爲繼承類的重載還沒有發生。對象先從基類被創建,“基類先於繼承類(base before derived)”。


看看這個:


#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。設想一下,如果出於不同的規則,B::B()可以調用D::f()的話,會產生什麼樣的後果:因爲構造函數D::D()還沒有運行,D::f()將會試圖將一個還沒有初始化的字符串s賦予它的參數。結果很可能是導致立即崩潰。


析構函數在“繼承類先於基類”的機制下運行,因此虛擬機制的行爲和構造函數一樣:只有本地定義(local definitions)被使用——不會調用虛擬函數,以免觸及對象中的(現在已經被銷燬的)繼承類的部分。


更多的細節,參見《C++語言的設計和演變》13.2.4.2和《C++程序設計語言》15.4.3。


有人暗示,這只是一條實現時的人爲製造的規則。不是這樣的。事實上,要實現這種不安全的方法倒是非常容易的:在構造函數中直接調用虛擬函數,就象調用其它函數一樣。但是,這樣就意味着,任何虛擬函數都無法編寫了,因爲它們需要依靠基類的固定的創建(invariants established by base classes)。這將會導致一片混亂。


有沒有“指定位置刪除”(placement delete)?


沒有,不過如果你需要的話,可以自己寫一個。


看看這個指定位置創建(placement new),它將對象放進了一系列Arena中;


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 new”的內建的“placement delete”,原因是,沒有一種通用的方法可以保證它被正確地使用。在C++的類型系統中,沒有什麼東西可以讓我們確認,p1一定指向一個由Arena類型的a1分派的對象。p1可能指向任何東西分派的任何一塊地方。


然而,有時候程序員是知道的,所以這是一種方法:


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維護了它保存着的對象的線索,你甚至可以自己寫一個析構函數,以避免它發生錯誤。


這也是可能的:定義一對相互匹配的操作符new()和delete(),以維護《C++程序設計語言》15.6中的類繼承體系。參見《C++語言的設計和演變》10.4和《C++程序設計語言》19.4.5。


我能防止別人繼承我自己的類嗎?


可以,但你爲什麼要那麼做呢?這是兩個常見的回答:


效率:避免我的函數被虛擬調用

安全:保證我的類不被用作一個基類(例如,保證我能夠複製對象而不用擔心出事)


根據我的經驗,效率原因往往是不必要的擔心。在C++中,虛擬函數調用是如此之快,以致於它們在一個包含虛擬函數的類中被實際使用時,相比普通的函數調用,根本不會產生值得考慮的運行期開支。注意,僅僅通過指針或引用時,纔會使用虛擬調用機制。當直接通過對象名字調用一個函數時,虛擬函數調用的開支可以被很容易地優化掉。


如果確實有真正的需要,要將一個類封閉起來以防止虛擬調用,那麼可能首先應該問問爲什麼它們是虛擬的。我看見過一些例子,那些性能表現不佳的函數被設置爲虛擬,沒有其他原因,僅僅是因爲“我們習慣這麼幹”。


這個問題的另一個部分,由於邏輯上的原因如何防止類被繼承,有一個解決方案。不幸的是,這個方案並不完美。它建立在這樣一個事實的基礎之上,那就是:大多數的繼承類必須建立一個虛擬的基類。這是一個例子:


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; // 錯誤: DD::DD() 不能訪問

// Usable_lock::Usable_lock()是一個私有成員


(來自《C++語言的設計和演變》11.4.3)


爲什麼不能爲模板參數定義約束(constraints)?


可以的,而且方法非常簡單和通用。


看看這個:


template<class Container>

void draw_all(Container& c)

{

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

}


如果出現類型錯誤,可能是發生在相當複雜的for_each()調用時。例如,如果容器的元素類型是int,我們將得到一個和for_each()相關的含義模糊的錯誤(因爲不能夠對對一個int值調用Shape::draw的方法)。


爲了提前捕捉這個錯誤,我這樣寫:


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));

}


對於現在的大多數編譯器,中間變量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));

}


這樣就很清楚了,我在建立一個斷言(assertion)。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*類型的某個類型。注意這個定義被精簡到了最小:


一行命名要檢查的約束,和要檢查的類型

一行列出指定的要檢查的約束(constraints()函數)

一行提供觸發檢查的方法(通過構造函數)


注意這個定義有相當合理的性質:


你可以表達一個約束,而不用聲明或複製變量,因此約束的編寫者可以用不着去設想變量如何被初始化,對象是否能夠被複制,被銷燬,以及諸如此類的事情。(當然,約束要檢查這些屬性的情況時例外。)

使用現在的編譯器,不需要爲約束產生代碼

定義和使用約束,不需要使用宏

當約束失敗時,編譯器會給出可接受的錯誤信息,包括“constraints”這個詞(給用戶一個線索),約束的名字,以及導致約束失敗的詳細錯誤(例如“無法用double*初始化Shape*”)。


那麼,在C++語言中,有沒有類似於Can_copy——或者更好——的東西呢?在《C++語言的設計和演變》中,對於在C++中實現這種通用約束的困難進行了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章