翻譯:左輕侯
(譯注:本文的翻譯相當艱苦。Bjarne Stroustrup不愧是創立C++語言的一代大師,不但思想博大精深,而且在遣詞造句上,也非常精微深奧。有很多地方,譯者反復斟酌,都不能取得理想的效果,只能盡力而為。
Html格式的文檔見譯者主頁:http://www.wushuang.net
如果你對這個翻譯稿有任何意見和建議,請發信給譯者:onekey@163.com。
原文的地址為:http://www.research.att.com/~bs/bs_faq2.html)
(Bjarne Stroustrup博士,1950年出生于丹麥,先后畢業于丹麥阿魯斯大學和英國劍撟大學,AT&T大規模程序設計研究部門負責人,AT&T 貝爾實驗室和ACM成員。1979年,B. S開始開發一種語言,當時稱為"C with Class",后來演化為C++。1998年,ANSI/ISO C++標準建立,同年,B. S推出其經典著作The C++ Programming Language的第三版。)
這是一些人們經常向我問起的有關C++的風格與技巧的問題。如果你能提出更好的問題,或者對這些答案有所建議,請務必發Email給我(bs@research.att.com)。請記住,我不能把全部的時間都花在更新我的主頁上面。
更多的問題請參見我的general FAQ。
關于術語和概念,請參見我的C++術語表(C++ glossary.)。
請注意,這僅僅是一個常見問題與解答的列表。它不能代替一本優秀教科書中那些經過精心挑選的范例與解釋。它也不能象一本參考手冊或語言標準那樣,提供詳細和準確的說明。有關C++的設計的問題,請參見《C++語言的設計和演變》(The Design and Evolution of C++)。關于C++語言與標準庫的使用,請參見《C++程序設計語言》(The C++ Programming Language)。
目錄:
我如何寫這個非常簡單的程序?
為什么編譯要花這么長的時間?
為什么一個空類的大小不為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::f()將會試圖將一個還沒有初始化的字符串s賦予它的參數。結果很可能是導致立即崩潰。
析構函數在"繼承類先于基類"的機制下運行,因此虛擬機制的行為和構造函數一樣:只有本地定義(local definitions)被使用——不會調用虛擬函數,以免觸及對象中的(現在已經被銷毀的)繼承類的部分。
更多的細節,參見《C++語言的設計和演變》13.2.4.2和《C++程序設計語言》15.4.3。
有人暗示,這只是一條實現時的人為制造的規則。不是這樣的。事實上,要實現這種不安全的方法倒是非常容易的:在構造函數中直接調用虛擬函數,就象調用其它函數一樣。但是,這樣就意味著,任何虛擬函數都無法編寫了,因為它們需要依靠基類的固定的創建(invariants established by base classes)。這將會導致一片混亂。
有沒有"指定位置刪除"(
placement delete)?
沒有,不過如果你需要的話,可以自己寫一個。
看看這個指定位置創建(
placementnew),它將對象放進了一系列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;
// ...
但是,以后怎樣正確地銷毀這些對象呢?沒有對應于這種"
placementnew"的內建的"
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:

D() 不能訪問
// 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++中實現這種通用約束的困難進行了分析。從那以來,出現了很多方法,來讓約束類變得更加容易編寫,同時仍然能觸發良好的錯誤信息。例如,我信任我在Can_copy中使用的函數指針的方式,它源自Alex Stepanov和Jeremy Siek。我并不認為Can_copy()已經可以標準化了——它需要更多的使用。同樣,在C++社區中,各種不同的約束方式被使用;到底是哪一種約束模板在廣泛的使用中被證明是最有效的,還沒有達成一致的意見。
但是,這種方式非常普遍,比語言提供的專門用于約束檢查的機制更加普遍。無論如何,當我們編寫一個模板時,我們擁有了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*>();
}
// 典型的"元素必須繼承自Mybase*"約束:
template<class T> class Container : Derived_from<T,Mybase> {
// ...
};
事實上,Derived_from并不檢查來源(derivation),而僅僅檢查轉換(conversion),不過這往往是一個更好的約束。為約束想一個好名字是很難的。
既然已經有了優秀的qsort()函數,為什么還需要一個sort()?
對于初學者來說,
qsort(array,asize,sizeof(elem),elem_compare);
看上去太古怪了,而且比這個更難理解:
sort(vec.begin(),vec.end());
對于專家來說,在元素與比較方式(comparison criteria)都相同的情況下,sort()比qsort()更快,這是很重要的。而且,qsort()是通用的,所以它可以用于不同容器類型、元素類型、比較方式的任意有意義的組合。舉例來說:
struct Record {
string name;
// ...
};
struct name_compare { // 使用"name"作為鍵比較Record
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()是因為它是類型安全的,使用它不需要進行造型(cast),沒有人必須去為基本類型寫一個compare()函數。
更多的細節,參見我的文章《將標準C++作為一種新的語言來學習》(Learning C++ as a
New language),可以從我的文章列表中找到。
sort()勝過qsort()的主要原因是,比較操作在內聯(inlines)上做得更好。
什么是函數對象(function object)?
顧名思義,就是在某種方式上表現得象一個函數的對象。典型地,它是指一個類的實例,這個類定義了應用操作符operator()。
函數對象是比函數更加通用的概念,因為函數對象可以定義跨越多次調用的可持久的部分(類似靜態局部變量),同時又能夠從對象的外面進行初始化和檢查(和靜態局部變量不同)。例如:
class Sum {
int val;
public:
Sum(int i) :val(i) { }
operator int() const { return val; } // 取得值
int operator()(int i) { return val+=i; } // 應用
};
void f(vector v)
{
Sum s = 0; // initial value 0
s = for_each(v.begin(), v.end(), s); // 求所有元素的和
cout << "the sum is " << s << "\n";
//或者甚至:
cout << "the sum is " << for_each(v.begin(), v.end(), Sum(0)) << "\n";
}
注意一個擁有應用操作符的函數對象可以被完美地內聯化(inline),因為它沒有涉及到任何指針,后者可能導致拒絕優化。與之形成對比的是,現有的優化器幾乎不能(或者完全不能?)將一個通過函數指針的調用內聯化。
在標準庫中,函數對象被廣泛地使用以獲得彈性。
我應該如何對付內存泄漏?
寫出那些不會導致任何內存泄漏的代碼。很明顯,當你的代碼中到處充滿了
new 操作、delete操作和指針運算的話,你將會在某個地方搞暈了頭,導致內存泄漏,指針引用錯誤,以及諸如此類的問題。這和你如何小心地對待內存分配工作其實完全沒有關系:代碼的復雜性最終總是會超過你能夠付出的時間和努力。于是隨后產生了一些成功的技巧,它們依賴于將內存分配(allocations)與重新分配(deallocation)工作隱藏在易于管理的類型之后。標準容器(standard containers)是一個優秀的例子。它們不是通過你而是自己為元素管理內存,從而避免了產生糟糕的結果。想象一下,沒有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';
}
你有多少機會在第一次就得到正確的結果?你又怎么知道你沒有導致內存泄漏呢?
注意,沒有出現顯式的內存管理,宏,造型,溢出檢查,顯式的長度限制,以及指針。通過使用函數對象和標準算法(standard algorithm),我可以避免使用指針——例如使用迭代子(iterator),不過對于一個這么小的程序來說有點小題大作了。
這些技巧并不完美,要系統化地使用它們也并不總是那么容易。但是,應用它們產生了驚人的差異,而且通過減少顯式的內存分配與重新分配的次數,你甚至可以使余下的例子更加容易被跟蹤。早在1981年,我就指出,通過將我必須顯式地跟蹤的對象的數量從幾萬個減少到幾打,為了使程序正確運行而付出的努力從可怕的苦工,變成了應付一些可管理的對象,甚至更加簡單了。
如果你的程序還沒有包含將顯式內存管理減少到最小限度的庫,那么要讓你程序完成和正確運行的話,最快的途徑也許就是先建立一個這樣的庫。
模板和標準庫實現了容器、資源句柄以及諸如此類的東西,更早的使用甚至在多年以前。異常的使用使之更加完善。
如果你實在不能將內存分配/重新分配的操作隱藏到你需要的對象中時,你可以使用資源句柄(resource handle),以將內存泄漏的可能性降至最低。這里有個例子:我需要通過一個函數,在空閑內存中建立一個對象并返回它。這時候可能忘記釋放這個對象。畢竟,我們不能說,僅僅關注當這個指針要被釋放的時候,誰將負責去做。使用資源句柄,這里用了標準庫中的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; // 誰該負責釋放這個S?
};
auto_ptr<S> g()
{
return auto_ptr<S>(
new S); // 顯式傳遞負責釋放這個S
}
int main()
{
cout << "start main\n";
S* p = f();
cout << "after f() before g()\n";
// S* q = g(); // 將被編譯器捕捉
auto_ptr<S> q = g();
cout << "exit main\n";
// *p產生了內存泄漏
// *q被自動釋放
}
在更一般的意義上考慮資源,而不僅僅是內存。
如果在你的環境中不能系統地應用這些技巧(例如,你必須使用別的地方的代碼,或者你的程序的另一部分簡直是原始人類(譯注:原文是Neanderthals,尼安德特人,舊石器時代廣泛分布在歐洲的猿人)寫的,如此等等),那么注意使用一個內存泄漏檢測器作為開發過程的一部分,或者插入一個垃圾收集器(garbage collector)。
我為什么在捕獲一個異常之后就不能繼續?
換句話說,C++為什么不提供一種簡單的方式,讓程序能夠回到異常拋出點之后,并繼續執行?
主要的原因是,如果從異常處理之后繼續,那么無法預知擲出點之后的代碼如何對待異常處理,是否僅僅繼續執行,就象什么也沒有發生一樣。異常處理者無法知道,在繼續之前,有關的上下文環境(context)是否是"正確"的。要讓這樣的代碼正確執行,拋出異常的編寫者與捕獲異常的編寫者必須對彼此的代碼與上下文環境都非常熟悉才行。這樣會產生非常復雜的依賴性,因此無論在什么情況下,都會導致一系列嚴重的維護問題。
當我設計C++的異常處理機制時,我曾經認真地考慮過允許這種繼續的可能性,而且在標準化的過程中,這個問題被非常詳細地討論過。請參見《C++語言的設計和演變》中的異常處理章節。
在一次新聞組的討論中,我曾經以一種稍微不同的方式回答過這個問題。
為什么C++中沒有相當于realloc()的函數?
如果你需要,你當然可以使用realloc()。但是,realloc()僅僅保證能工作于這樣的數組之上:它們被malloc()(或者類似的函數)分配,包含一些沒有用戶定義的復制構造函數(copy constructors)的對象。而且,要記住,與通常的期望相反,realloc()有時也必須復制它的參數數組。
在C++中,處理內存重新分配的更好的方法是,使用標準庫中的容器,例如vector,并讓它自我增長。
如何使用異常?
參見《C++程序設計語言》第4章,第8.3節,以及附錄E。這個附錄針對的是如何在要求苛刻的程序中寫出異常安全的代碼的技巧,而不是針對初學者的。一個關鍵的技術是"資源獲得即初始化"(resource acquisiton is initialization),它使用一些有析構函數的類,來實現強制的資源管理。
怎樣從輸入中讀取一個字符串?
你可以用這種方式讀取一個單獨的以空格結束的詞:
#include<iostream>
#include<string>
using namespace std;
int main()
{
cout << "Please enter a word:\n";
string s;
cin>>s;
cout << "You entered " << s << '\n';
}
注意,這里沒有顯式的內存管理,也沒有可能導致溢出的固定大小的緩沖區。
如果你確實想得到一行而不是一個單獨的詞,可以這樣做:
#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';
}
在《C++程序設計語言》(可在線獲得)的第3章,可以找到一個對諸如字符串與流這樣的標準庫工具的簡介。對于使用C與C++進行簡單輸入輸出的詳細比較,參見我的文章《將標準C++作為一種新的語言來學習》(Learning Standard C++ as a
New Language),你可以在本人著作列表(my publications list)中下載到它。
為什么C++不提供"finally"的構造?
因為C++提供了另外一種方法,它幾乎總是更好的:"資源獲得即初始化"(resource acquisiton is initialization)技術。基本的思路是,通過一個局部對象來表現資源,于是局部對象的析構函數將會釋放資源。這樣,程序員就不會忘記釋放資源了。舉例來說:
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"); //打開fn進行讀寫
// 通過f使用文件
}
在一個系統中,需要為每一個資源都使用一個"資源句柄"類。無論如何,我們不需要為每一個資源獲得都寫出"finally"語句。在實時系統中,資源獲得要遠遠多于資源的種類,因此和使用"finally"構造相比,"資源獲得即初始化"技術會產生少得多的代碼。
什么是自動指針(auto_ptr),為什么沒有自動數組(auto_array)?
auto_ptr是一個非常簡單的句柄類的例子,在<memory>中定義,通過"資源獲得即初始化"技術支持異常安全。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++; // 象一個指針一樣使用p
q->m++;
// ...
delete q;
}
如果在...部分拋出了一個異常,p持有的對象將被auto_ptr的析構函數正確地釋放,而q指向的X對象則產生了內存泄漏。更多的細節,參見《C++程序設計語言》14.4.2節。
auto_ptr是一個非常簡單的類。特別地,它不是一個引用計數(reference counted)的指針。如果你將一個auto_ptr賦值給另一個,那么被賦值的auto_ptr將持有指針,而原來的auto_ptr將持有0。舉例:
#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";
}
將會打印出一個指向0的指針和一個指向非0的指針。例如:
p 0x0 q 0x378d0
auto_ptr::get()返回那個輔助的指針。
這種"轉移"語義不同于通常的"復制"語義,這是令人驚訝的。特別地,永遠不要使用auto_ptr作為一個標準容器的成員。標準容器需要通常的"復制"語義。例如:
std::vector<auto_ptr<X> >v; // 錯誤
auto_ptr只持有指向一個單獨元素的指針,而不是指向一個數組的指針:
void f(int n)
{
auto_ptr<X> p(
new X[n]); //錯誤
// ...
}
這是錯誤的,因為析構函數會調用delete而不是delete[]來釋放指針,這樣就不會調用余下的n-1個X的析構函數。
那么我們需要一個auto_array來持有數組嗎?不。沒有auto_array。原因是根本沒有這種需要。更好的解決方案是使用vector:
void f(int n)
{
vector<X> v(n);
// ...
}
當...部分發生異常時,v的析構函數會被正確地調用。
可以混合使用C風格與C++風格的內存分派與重新分配嗎?
在這種意義上是可以的:你可以在同一個程序中使用malloc()和
new。
在這種意義上是不行的:你不能使用malloc()來建立一個對象,又通過delete來釋放它。你也不能用
new建立一個新的對象,然后通過free()來釋放它,或者通過realloc()在數組中再建立一個新的。
C++中的
new和delete操作可以保證正確的構造和析構:構造函數和析構函數在需要它們的時候被調用。C風格的函數alloc(), calloc(), free(), 和realloc()卻不能保證這一點。此外,用
new和delete來獲得和釋放的原始內存,并不一定能保證與malloc()和free()兼容。如果這種混合的風格在你的系統中能夠運用,只能說是你走運——暫時的。
如果你覺得需要使用realloc()——或者要做更多——考慮使用標準庫中的vector。例如:
// 從輸入中將詞讀取到一個字符串vector中
vector<string> words;
string s;
while (cin>>s && s!=".") words.push_back(s);
vector會視需要自動增長。
更多的例子與討論,參見我的文章《將標準C++作為一種新的語言來學習》(Learning Standard C++ as a
New Language),你可以在本人著作列表(my publications list)中下載到它。
我為什么必須使用一個造型來轉換*void?
在C語言中,你可以隱式地將*void轉換為*T。這是不安全的??紤]一下:
#include<stdio.h>
int main()
{
char i = 0;
char j = 0;
char* p = &i;
void* q = p;
int* pp = q; /* 不安全的,在C中可以,C++不行 */
printf("%d %d\n",i,j);
*pp = -1; /* 覆蓋了從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操作符還提供了勝過malloc()的新特性:
new不會偶然分配錯誤的內存數量;
new會隱式地檢查內存耗盡情況,而且
new提供了初始化。
舉例:
typedef std::complex<double> cmplx;
/* C風格: */
cmplx* p = (cmplx*)malloc(sizeof(int)); /* 錯誤:類型不正確 */
/* 忘記測試p==0 */
if (*p == 7) { /* ... */ } /* 糟糕,忘記了初始化*p */
// C++風格:
cmplx* q =
new cmplx(1,2); // 如果內存耗盡,將拋出一個bad_alloc異常
if (*q == 7) { /* ... */ }
我如何定義一個類內部(in-class)的常量?
如果你需要一個通過常量表達式來定義的常量,例如數組的范圍,你有兩種選擇:
class X {
static const int c1 = 7;
enum { c2 = 19 };
char v1[c1];
char v2[c2];
// ...
};
乍看起來,c1的聲明要更加清晰,但是要注意的是,使用這種類內部的初始化語法的時候,常量必須是被一個常量表達式初始化的整型或枚舉類型,而且必須是static和const形式。這是很嚴重的限制:
class Y {
const int c3 = 7; // 錯誤:不是static
static int c4 = 7; // 錯誤:不是const
static const float c5 = 7; // 錯誤:不是整型
};
我傾向使用枚舉的方式,因為它更加方便,而且不會誘使我去使用不
規范的類內初始化語法。
那么,為什么會存在這種不方便的限制呢?一般來說,類在一個頭文件中被聲明,而頭文件被包含到許多互相調用的單元去。但是,為了避免復雜的編譯器規則,C++要求每一個對象只有一個單獨的定義。如果C++允許在類內部定義一個和對象一樣占據內存的實體的話,這種規則就被破壞了。對于C++在這個設計上的權衡,請參見《C++語言的設計和演變》。
如果你不需要用常量表達式來初始化它,那么可以獲得更大的彈性:
class Z {
static char* p; // 在定義中初始化
const int i; // 在構造函數中初始化
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; // 定義
int f()
{
const int* p1 = &AE::c6; // 錯誤:c6沒有左值
const int* p2 = &AE::c7; // ok
// ...
}
為什么delete不會將操作數置0?
考慮一下:
delete p;
// ...
delete p;
如果在...部分沒有涉及到p的話,那么第二個"delete p;"將是一個嚴重的錯誤,因為C++的實現(譯注:原文為a C++ implementation,當指VC++這樣的實現了C++標準的具體工具)不能有效地防止這一點(除非通過非正式的預防手段)。既然delete 0從定義上來說是無害的,那么一個簡單的解決方案就是,不管在什么地方執行了"delete p;",隨后都執行"p=0;"。但是,C++并不能保證這一點。
一個原因是,delete的操作數并不需要一個左值(lvalue)??紤]一下:
delete p+1;
delete f(x);
在這里,被執行的delete并沒有擁有一個可以被賦予0的指針。這些例子可能很少見,但它們的確指出了,為什么保證"任何指向被刪除對象的指針都為0"是不可能的。繞過這條"規則"的一個簡單的方法是,有兩個指針指向同一個對象:
T* p =
new T;
T* q = p;
delete p;
delete q; // 糟糕!
C++顯式地允許delete操作將操作數左值置0,而且我曾經希望C++的實現能夠做到這一點,但這種思想看來并沒有在C++的實現中變得流行。
如果你認為指針置0很重要,考慮使用一個銷毀的函數:
template<class T> inline void destroy(T*& p) { delete p; p = 0; }
考慮一下,這也是為什么需要依靠標準庫的容器、句柄等等,來將對
new和delete的顯式調用降到最低限度的另一個原因。
注意,通過引用來傳遞指針(以允許指針被置0)有一個額外的好處,能防止destroy()在右值上(rvalue)被調用:
int* f();
int* p;
// ...
destroy(f()); // 錯誤:應該使用一個非常量(non-const)的引用傳遞右值
destroy(p+1); // 錯誤:應該使用一個非常量(non-const)的引用傳遞右值
我能夠寫"void main()"嗎?
這種定義:
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類型。main()返回的int值,是程序返回一個值給調用它的系統的方式。在那些不具備這種方式的系統中,返回值被忽略了,但這并不使"void main()"在C++或C中成為合法的。即使你的編譯器接受了"void main()",也要避免使用它,否則你將冒著被C和C++程序員視為無知的風險。
在C++中,main()并不需要包含顯式的return語句。在這種情況下,返回值是0,表示執行成功。例如:
#include<iostream>
int main()
{
std::cout << "This program returns the integer value 0\n";
}
注意,無論是ISO C++還是C99,都不允許在聲明中漏掉類型。那就是說,與C89和ARM C++形成對照,當聲明中缺少類型時,并不會保證是"int"。于是:
#include<iostream>
main() { /* ... */ }
是錯誤的,因為缺少main()的返回類型。
為什么我不能重載點符號,::,sizeof,等等?
大多數的運算符能夠被程序員重載。例外的是:
. (點符號) :: ?: sizeof
并沒有什么根本的原因要禁止重載?:。僅僅是因為,我沒有發現有哪種特殊的情況需要重載一個三元運算符。注意一個重載了 表達式1?表達式2:表達式3 的函數,不能夠保證表達式2:表達式3中只有一個會被執行。
Sizeof不能夠被重載是因為內建的操作(built-in operations),諸如對一個指向數組的指針進行增量操作,必須依靠它??紤]一下:
X a[10];
X* p = &a[3];
X* q = &a[3];
p++; // p指向a[4]
// 那么p的整型值必須比q的整型值大出一個sizeof(X)
所以,sizeof(X)不能由程序員來賦予一個不同的新意義,以免違反基本的語法。
在N::m中,無論N還是m都不是值的表達式;N和m是編譯器知道的名字,::執行一個(編譯期的)范圍解析,而不是表達式求值。你可以想象一下,允許重載x::y的話,x可能是一個對象而不是一個名字空間(namespace)或者一個類,這樣就會導致——與原來的表現相反——產生新的語法(允許 表達式1::表達式2)。很明顯,這種復雜性不會帶來任何好處。
理論上來說,.(點運算符)可以通過使用和->一樣的技術來進行重載。但是,這樣做會導致一個問題,那就是無法確定操作的是重載了.的對象呢,還是通過.引用的一個對象。例如:
class Y {
public:
void f();
// ...
};
class X { // 假設你能重載.
Y* p;
Y& operator.() { return *p; }
void f();
// ...
};
void g(X& x)
{
x.f(); // X::f還是Y::f還是錯誤?
}
這個問題能夠用幾種不同的方法解決。在標準化的時候,哪種方法最好還沒有定論。更多的細節,請參見《C++語言的設計和演變》。
怎樣將一個整型值轉換為一個字符串?
最簡單的方法是使用一個字符串流(stringstream):
#include<iostream>
#include<string>
#include<sstream>
using namespace std;
string itos(int i) // 將int轉換成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";
}
自然地,這種技術能夠將任何使用<<輸出的類型轉換為字符串。對于字符串流的更多說明,參見《C++程序設計語言》21.5.3節。
"int* p"正確還是"int *p"正確?
二者都是正確的,因為二者在C和C++中都是有效的,而且意義完全一樣。就語言的定義與相關的編譯器來說,我們還可以說"int*p"或者"int * p"。
在"int* p"和"int *p"之間的選擇與正確或錯誤無關,而只關乎風格與側重點。C側重表達式;對聲明往往比可能帶來的問題考慮得更多。另一方面,C++則非常重視類型。
一個"典型的C程序員"寫成"int *p",并且解釋說"*p表示一個什么樣的int"以強調語法,而且可能指出C(與C++)的語法來證明這種風格的正確性。是的,在語法上*被綁定到名字p上。
一個"典型的C++程序員"寫成"int* p",并且解釋說"p是一個指向int的指針類型"以強調類型。是的,p是一個指向int的指針類型。我明確地傾向于這種側重方向,而且認為對于學好更多的高級C++這是很重要的。
嚴重的混亂(僅僅)發生在當人們試圖在一條聲明中聲明幾個指針的時候:
int* p, p1; // 也許是錯的:p1不是一個int*
把*放到名字這一邊,看來也不能有效地減少這種錯誤:
int *p, p1; // 也許是錯的?
為每一個名字寫一條聲明最大程度地解決了問題——特別是當我們初始化變量的時候。人們幾乎不會這樣寫:
int* p = &i;
int p1 = p; // 錯誤:int用一個int*初始化了
如果他們真的這么干了,編譯器也會指出。
每當事情可以有兩種方法完成,有人就會迷惑。每當事情僅僅是一個風格的問題,爭論就會沒完沒了。為每一個指針寫一條聲明,而且永遠都要初始化變量,這樣,混亂之源就消失了。更多的關于C的聲明語法的討論,參見《C++語言的設計和演變》。
對于我的代碼,哪一種布局風格(layout style)是最好的?
這種風格問題屬于個人的愛好。人們往往對布局風格的問題持有強烈的意見,不過,也許一貫性比某種特定的風格更加重要。象大多數人一樣,我花了很長的時間,來為我的偏好作出一個固定的結論。
我個人使用通常稱為"K&R"的風格。當使用C語言沒有的構造函數時,需要增加新的習慣,這樣就變成了一種有時被稱為"Stroustrup"的風格。例如:
class C : public B {
public:
// ...
};
void f(int* p, int max)
{
if (p) {
// ...
}
for (int i = 0; i<max; ++i) {
// ...
}
}
比大多數布局風格更好,這種風格保留了垂直的空格,我喜歡盡可能地在合理的情況下對齊屏幕。對函數開頭的大括弧的放置,有助于我第一眼就分別出類的定義和函數的定義。
縮進是非常重要的。
設計問題,諸如作為主要接口的抽象基類的使用,使用模板以表現有彈性的類型安全的抽象,以及正確地使用異常以表現錯誤,比布局風格的選擇要重要得多。
我應該將"const"放在類型之前還是之后?
我把它放在前面,但那僅僅是個人愛好問題。"const T"和"T const"總是都被允許的,而且是等效的。例如:
const int a = 1; // ok
int const b = 2; // also ok
我猜想第一種版本可能會讓少數(更加固守語法
規范)的程序員感到迷惑。
為什么?當我發明"const"(最初的名稱叫做"readonly",并且有一個對應的"writeonly")的時候,我就允許它出現在類型之前或之后,因為這樣做不會帶來任何不明確。標準之前的C和C++規定了很少的(如果有的話)特定的順序
規范。
我不記得當時有過任何有關順序問題的深入思考或討論。那時,早期的一些使用者——特別是我——僅僅喜歡這種樣子:
const int c = 10;
看起來比這種更好:
int const c = 10;
也許我也受了這種影響:在我最早的一些使用"readonly"的例子中
readonly int c = 10;
比這個更具有可讀性:
int readonly c = 10;
我創造的那些最早的使用"const"的(C或C++)代碼,看來已經在全球范圍內取代了"readonly"。
我記得這個語法的選擇在幾個人——例如Dennis Ritchie——當中討論過,但我不記得當時我傾向于哪種語言了。
注意在固定指針(const pointer)中,"const"永遠出現在"*"之后。例如:
int *const p1 = q; // 指向int變量的固定指針
int const* p2 = q; //指向int常量的指針
const int* p3 = q; //指向int常量的指針
使用宏有什么問題?
宏不
遵循C++中關于范圍和類型的規則。這經常導致一些微妙的或不那么微妙的問題。因此,C++提供更適合其他的C++(譯注:原文為the rest of C++,當指C++除了兼容C以外的部分)的替代品,例如內聯函數、模板與名字空間。
考慮一下:
#include "someheader.h"
struct S {
int alpha;
int beta;
};
如果某人(不明智地)地寫了一個叫"alpha"或"beta"的宏,那么它將不會被編譯,或者被錯誤地編譯,產生不可預知的結果。例如,"someheader.h"可能包含:
#define alpha 'a'
#define beta b[2]
將宏(而且僅僅是宏)全部大寫的習慣,會有所幫助,但是對于宏并沒有語言層次上的保護機制。例如,雖然成員的名字包含在結構體的內部,但這無濟于事:在編譯器能夠正確地辨別這一點之前,宏已經將程序作為一個字符流進行了處理。順便說一句,這是C和C++程序開發環境和工具能夠被簡化的一個主要原因:人與編譯器看到的是不同的東西。
不幸的是,你不能假設別的程序員總是能夠避免這種你認為"相當白癡"的事情。例如,最近有人報告我,他們遇到了一個包含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); // 好
square(i++); // 糟糕:這表示 (i++*i++)
square(d+1); //糟糕:這表示(d+1*d+1); 也就是 (d+d+1)
// ...
}
"d+1"的問題,可以通過在"調用"時或宏定義時添加一對圓括號來解決:
#define square(x) ((x)*(x)) /*這樣更好 */
但是, i++被執行了兩次(可能并不是有意要這么做)的問題仍然存在。
是的,我確實知道有些特殊的宏并不會導致C/C++預處理宏這樣的問題。但是,我無心去發展C++中的宏。作為替代,我推薦使用C++語言中合適的工具,例如內聯函數,模板,構造函數(用來初始化),析構函數(用來清除),異常(用來退出上下文環境),等等。