C++ Style and Technique FAQ (中文版)
Bjarne Stroustrup 著, 紫云英 譯
A: 常常有人問我一些簡單的程序該如何寫,這在學期之初時尤甚。一個典型的問題是:如何讀入一些數(shù)字,做些處理(比如數(shù)學運算),然后輸出……好吧好吧,這里我給出一個“通用示范程序”:
#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代碼來處理。
- 主函數(shù)main()要返回一個整數(shù)。[譯注:有些編譯器也支持void main()的定義,但這是非標準做法]
- 將輸入讀入標準庫提供的vector容器可以保證你不會犯“緩沖區(qū)溢出”之類錯誤——對于初學者來說,硬是要求“把輸入讀到一個數(shù)組之中,不許犯任何‘愚蠢的錯誤’”似乎有點過份了——如果你真能達到這樣的要求,那你也不能算完全的初學者了。如果你不相信我的這個論斷,那么請看看我寫的《Learning Standard C++ as a New Language》一文。 [譯注:CSDN文檔區(qū)有該文中譯。]
- 代碼中“ !cin.eof() ”是用來測試輸入流的格式的。具體而言,它測試讀輸入流的循環(huán)是否因遇到EOF而終止。如果不是,那說明輸入格式不對(不全是數(shù)字)。還有細節(jié)地方不清楚,可以參看你使用的教材中關于“流狀態(tài)”的章節(jié)。
- Vector是知道它自己的大小的,所以不必自己清點輸入了多少元素。
- 這個程序不含任何顯式內(nèi)存管理代碼,也不會產(chǎn)生內(nèi)存泄漏。Vector會自動配置內(nèi)存,所以用戶不必為此煩心。
- 關于如何讀入字符串,請參閱后面的“我如何從標準輸入中讀取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),導致系統(tǒng)無法識別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 》第三版中關于標準庫的章節(jié)里有更多更詳細例子,你可以通過它們學會如何使用標準庫來“輕松搞定簡單任務”。
Q: 為何我編譯一個程序要花那么多時間?
A: 也許是你的編譯器有點不太對頭——它是不是年紀太大了,或者沒有安裝正確?也可能你的電腦該進博物館了……對于這樣的問題我可真是愛莫能助了。
不過,也有可能原因在于你的程序——看看你的程序設計還能不能改進?編譯器是不是為了順利產(chǎn)出正確的二進制碼而不得不吃進成百個頭文件、幾萬行的源代碼?原則上,只要對源碼適當優(yōu)化一下,編譯緩慢的問題應該可以解決。如果癥結在于你的類庫供應商,那么你大概除了“換一家類庫供應商”外確實沒什么可做的了;但如果問題在于你自己的代碼,那么完全可以通過重構(refactoring)來讓你的代碼更為結構化,從而使源碼一旦有更改時需重編譯的代碼量最小。這樣的代碼往往是更好的設計:因為它的藕合程度較低,可維護性較佳。
我們來看一個OOP的經(jīng)典例子:
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”作為所有形狀的一個屬性(從而在基類中聲明)似乎是天經(jīng)地義的,但因此而要在基類中時時維護三角形的中心坐標是很麻煩的,還不如只在需要時才計算——這樣可以減少開銷。
- 和抽象的公共界面不同,保護成員可能會依賴實作細節(jié),而這是Shape類的使用者所不愿見到的。例如,絕大部分使用Shape的代碼應該邏輯上和color無關;但只要color的聲明在Shape類中出現(xiàn)了,就往往會導致編譯器將定義了“該操作系統(tǒng)中顏色表示”的頭文件讀入、展開、編譯。這都需要時間!
- 當基類中保護成員(比如前面說的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) ]
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++中有一條有趣的規(guī)則——空基類并不需要另外一個字節(jié)來表示:
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相等,那么說明編譯器作了優(yōu)化。這樣的優(yōu)化是安全的,而且非常有用。它允許程序員用空類來表示非常簡單的概念,而不需為此付出額外的(空間)代價。一些現(xiàn)代編譯器提供了這種“虛基類優(yōu)化”功能。
A: 沒人強迫你這么做。如果你不希望界面中有數(shù)據(jù),那么就不要把它放在定義界面的類中,放到繼承類中好了。參看“為何我編譯一個程序要花那么多時間”條目。[譯注:本FAQ中凡原文為declare/declaration的均譯為聲明;define/definition均譯為定義。兩者涵義之基本差別參見后面“‘int* p;’和‘int *p;’到底哪個正確”條目中的譯注。通常而言,我們還是將下面的示例代碼稱為complex類的定義,而將單單一行“class complex;”稱作聲明。]
但也有的時候你確實需要把數(shù)據(jù)放到類聲明里面,比如下面的復數(shù)類的例子:
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(復數(shù))類是被設計成像C++內(nèi)置類型那樣使用的,所以數(shù)據(jù)表示必須出現(xiàn)在聲明之中,以便可以建立真正的本地對象(即在堆棧上分配的對象,而非在堆中分配),這同時也確保了簡單操作能被正確內(nèi)聯(lián)化?!氨镜貙ο蟆焙汀皟?nèi)聯(lián)”這兩點很重要,因為這樣才可以使我們的復數(shù)類達到和內(nèi)置復數(shù)類型的語言相當?shù)男省?
[譯注:我覺得Bjarne的這段回答有點“逃避問題”之嫌。我想,提問者的真實意圖或許是想知道如何用C++將“界面”與“實作”完全分離。不幸的是,C++語言和類機制本身不提供這種方式。我們都知道,類的“界面”部分往往被定義為公有(一般是一些虛函數(shù));“實作”部分則往往定義為保護或私有(包括函數(shù)和數(shù)據(jù));但無論是“public”段還是“protected”、“private”段都必須出現(xiàn)在類的聲明中,隨類聲明所在的頭文件一起提供。想來這就是“為何數(shù)據(jù)必須放到類聲明中”問題的由來吧。為了解決這個問題,我們有個變通的辦法:使用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對象的“存根”,而無實作內(nèi)容。Implementer類可以如下聲明:
class Implementer {
public:
// implementation details, including data members
};
上述代碼中的注釋處可以存放提問者所說的“數(shù)據(jù)”,而Implementer的聲明代碼不需暴露給用戶。不過,Proxy模式也不是十全十美的——Interface通過impl指針間接調(diào)用實作代碼帶來了額外的開銷?;蛟S讀者會說,C++不是有內(nèi)聯(lián)機制嗎?這個開銷能通過內(nèi)聯(lián)定義而彌補吧。但別忘了,此處運用Proxy模式的目的就是把“實作”部分隱藏起來,這“隱藏”往往就意味著“實作代碼”以鏈接庫中的二進制代碼形式存在。目前的C++編譯器和鏈接器能做到既“代碼內(nèi)聯(lián)”又“二進制隱藏”嗎?或許可以。那么Proxy模式又能否和C++的模板機制“合作愉快”呢?(換句話說,如果前面代碼中Interface和Implementer的聲明均不是class,而是template,又如何呢?)關鍵在于,編譯器對內(nèi)聯(lián)和模板的支持之實作是否需要進行源碼拷貝,還是可以進行二進制碼拷貝。目前而言,C#的泛型支持之實作是在Intermediate Language層面上的,而C++則是源碼層面上的。Bjarne給出的復數(shù)類聲明代碼稱“數(shù)據(jù)必須出現(xiàn)在類聲明中”也是部分出于這種考慮。呵呵,扯遠了……畢竟,這段文字只是FAQ的“譯注”而已,此處不作更多探討,有興趣的讀者可以自己去尋找答案 :O) ]
A: 因為許多類不是被用來做基類的。[譯注:用來做基類的類常類似于其它語言中的interface概念——它們的作用是為一組類定義一個公共介面。但C++中的類顯然還有許多其他用途——比如表示一個具體的擴展類型。] 例如,復數(shù)類就是如此。
另外,有虛函數(shù)的類有虛機制的開銷[譯注:指存放vtable帶來的空間開銷和通過vtable中的指針間接調(diào)用帶來的時間開銷],通常而言每個對象增加的空間開銷是一個字長。這個開銷可不小,而且會造成和其他語言(比如C,F(xiàn)ortran)的不兼容性——有虛函數(shù)的類的內(nèi)存數(shù)據(jù)布局和普通的類是很不一樣的。[譯注:這種內(nèi)存數(shù)據(jù)布局的兼容性問題會給多語言混合編程帶來麻煩。]
《The Design and Evolution of C++》 中有更多關于設計理念的細節(jié)。
A: 哈,你大概知道我要說什么了 :O) 仍然是因為——許多類不是被用來做基類的。只有在類被作為interface使用時虛函數(shù)才有意義。(這樣的類常常在內(nèi)存堆上實例化對象并通過指針或引用訪問。)
那么,何時我該讓析構函數(shù)為虛呢?哦,答案是——當類有其它虛函數(shù)的時候,你就應該讓析構函數(shù)為虛。有其它虛函數(shù),就意味著這個類要被繼承,就意味著它有點“interface”的味道了。這樣一來,程序員就可能會以基類指針來指向由它的繼承類所實例化而來的對象,而能否通過基類指針來正常釋放這樣的對象就要看析構函數(shù)是否為虛了。 例如:
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的析構函數(shù)不是虛的,那么Derived的析構函數(shù)就不會被調(diào)用——這常常會帶來惡果:比如,Derived中分配的資源沒有被釋放。
Q: C++中為何沒有虛擬構造函數(shù)?
A: 虛擬機制的設計目的是使程序員在不完全了解細節(jié)(比如只知該類實現(xiàn)了某個界面,而不知該類確切是什么東東)的情況下也能使用對象。但是,要建立一個對象,可不能只知道“這大體上是什么”就完事——你必須完全了解全部細節(jié),清楚地知道你要建立的對象是究竟什么。所以,構造函數(shù)當然不能是虛的了。
不過有時在建立對象時也需要一定的間接性,這就需要用點技巧來實現(xiàn)了。(詳見《The C++ Programming Language》,第三版,15.6.2)這樣的技巧有時也被稱作“虛擬構造函數(shù)”。我這里舉個使用抽象類來“虛擬構造對象”的例子:
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這些類一無所知。(嘿嘿,有時無知有無知的好處 ^_^)
A: 這個問題常常是由這樣的例子中產(chǎn)生的:
#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之間沒有重載發(fā)生。你調(diào)用了pd->f(),編譯器就在D的名字域里找啊找,找到double f(double)后就調(diào)用它了。編譯器懶得再到B的名字域里去看看有沒有哪個函數(shù)更符合要求。記住,在C++中,沒有跨域重載——繼承類和基類雖然關系很親密,但也不能壞了這條規(guī)矩。詳見《The Design and Evolution of C++》或者《The C++ Programming Language》第三版。
不過,如果你非得要跨域重載,也不是沒有變通的方法——你就把那些函數(shù)弄到同一個域里來好了。使用一個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
重載發(fā)生了——因為D中的那句 using B::f 明確告訴編譯器,要把B域中的f引入當前域,請編譯器“一視同仁”。
A: 可以。不過你得悠著點。當你這樣做時,也許你自己都不知道自己在干什么!在構造函數(shù)中,虛擬機制尚未發(fā)生作用,因為此時overriding尚未發(fā)生。萬丈高樓平地起,總得先打地基吧?對象的建立也是這樣——先把基類構造完畢,然后在此基礎上構造派生類。
看看這個例子:
#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");
}
這段程序經(jīng)編譯運行,得到這樣的結果:
B constructor
B::f
D constructor
注意,輸出不是D::f 。 究竟發(fā)生了什么?f()是在B::B()中調(diào)用的。如果構造函數(shù)中調(diào)用虛函數(shù)的規(guī)則不是如前文所述那樣,
而是如一些人希望的那樣去調(diào)用D::f()。那么因為構造函數(shù)D::D()尚未運行,字符串s還未初始化,所以當D::f()試圖將參數(shù)
賦給s時,結果多半是——立馬當機。
析構則正相反,遵循從繼承類到基類的順序(拆房子總得從上往下拆吧?),所以其調(diào)用虛函數(shù)的行為和在構造函數(shù)中一樣:虛函數(shù)此時此刻被綁定到哪里(當然應該是基類啦——因為繼承類已經(jīng)被“拆”了——析構了?。?,調(diào)用的就是哪個函數(shù)。
更多細節(jié)請見《The Design and Evolution of C++》,13.2.4.2 或者《The C++ Programming Language》第三版,15.4.3 。
有時,這條規(guī)則被解釋為是由于編譯器的實作造成的。[譯注:從實作角度可以這樣解釋:在許多編譯器中,直到構造函數(shù)調(diào)用完畢,vtable才被建立,此時虛函數(shù)才被動態(tài)綁定至繼承類的同名函數(shù)。] 但事實上不是這么一回事——讓編譯器實作成“構造函數(shù)中調(diào)用虛函數(shù)也和從其他函數(shù)中調(diào)用一樣”是很簡單的[譯注:只要把vtable的建立移至構造函數(shù)調(diào)用之前即可]。關鍵還在于語言設計時的考量——讓虛函數(shù)可以求助于基類提供的通用代碼。[譯注:先有雞還是先有蛋?Bjarne實際上是在告訴你,不是“先有實作再有規(guī)則”,而是“如此實作,因為規(guī)則如此”。]
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);
現(xiàn)在我們可以寫:
X* p1 = new(a1) X;
Y* p2 = new(a1) Y;
Z* p3 = new(a2) Z;
// ...
但之后我們?nèi)绾握_刪除這些對象?沒有內(nèi)置“placement delete”的理由是,沒辦法提供一個通用的placement delete。C++的類型系統(tǒng)沒辦法讓我們推斷出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()函數(shù) ,把“保證無錯”的監(jiān)控任務交給Arena,而不是自己承擔。
如何在類繼承體系中定義配對的operator new() 和 operator delete() 可以參看 《The C++ Programming Language》,Special Edition,15.6節(jié) ,《The Design and Evolution of C++》,10.4節(jié),以及《The C++ Programming Language》,Special Edition,19.4.5節(jié)。[譯注:此處按原文照譯。前面有提到“參見《The C++ Programming Language》第三版”的,實際上特別版(Special Edition)和較近重印的第三版沒什么區(qū)別。]
A: 可以的,但何必呢?好吧,也許有兩個理由:
- 出于效率考慮——不希望我的函數(shù)調(diào)用是虛的
- 出于安全考慮——確保我的類不被用作基類(這樣我拷貝對象時就不用擔心對象被切割(slicing)了)[譯注:“對象切割”指,將派生類對象賦給基類變量時,根據(jù)C++的類型轉換機制,只有包括在派生類中的基類部分被拷貝,其余部分被“切割”掉了。]
根據(jù)我的經(jīng)驗,“效率考慮”常常純屬多余。在C++中,虛函數(shù)調(diào)用如此之快,和普通函數(shù)調(diào)用并沒有太多的區(qū)別。請注意,只有通過指針或者引用調(diào)用時才會啟用虛擬機制;如果你指名道姓地調(diào)用一個對象,C++編譯器會自動優(yōu)化,去除任何的額外開銷。
如果為了和“虛函數(shù)調(diào)用”說byebye,那么確實有給類繼承體系“封頂”的需要。在設計前,不訪先問問自己,這些函數(shù)為何要被設計成虛的。我確實見過這樣的例子:性能要求苛刻的函數(shù)被設計成虛的,僅僅因為“我們習慣這樣做”!
好了,無論如何,說了那么多,畢竟你只是想知道,為了某種合理的理由,你能不能防止別人繼承你的類。答案是可以的??上В@里給出的解決之道不夠干凈利落。你不得不在在你的“封頂類”中虛擬繼承一個無法構造的輔助基類。還是讓例子來告訴我們一切吧:
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節(jié))
A: 呃,其實你是可以的。而且這種做法并不難,也不需要什么超出常規(guī)的技巧。
讓我們來看這段代碼:
template<class Container>
void draw_all(Container& c)
{
for_each(c.begin(),c.end(),mem_fun(&Shape::draw));
}
如果c不符合constraints,出現(xiàn)了類型錯誤,那么錯誤將發(fā)生在相當復雜的for_each解析之中。比如說,參數(shù)化的類型被要求實例化int型,那么我們無法為之調(diào)用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,那么就大多數(shù)現(xiàn)代編譯器而言,我們都可以得到一條含義清晰的出錯信息。這樣的技巧在所有語言中都很常見,而且對于所有“不同尋常的構造”都不得不如此。[譯注:意指對于任何語言,當我們開始探及極限,那么不得不寫一些高度技巧性的代碼。]
不過這樣做不是最好。如果要我來寫實際代碼,我也許會這樣寫:
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));
}
這就使代碼通用且明顯地體現(xiàn)出我的意圖——我在使用斷言[譯注:即明確斷言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()的實現(xiàn)已經(jīng)基本上是最優(yōu)化的了:一行代碼用來指明需要檢查的constraints[譯注:指第1行代碼;constraints為T2],和要對其做這個檢查的類型[譯注:要作檢查的類型為T1] ;一行代碼用來精確列出所要檢查是否滿足的constraints(constraints()函數(shù)) [譯注:第2行之所以要有2個子句并不是重復,而是有原因的。如果T1,T2均是用戶自定義的類,那么T2 c = a; 檢測能否缺省構造;b = a; 檢測能否拷貝構造] ;一行代碼用來提供執(zhí)行這些檢查的機會 [譯注:指第3行。Can_copy是一個模板類;constraints是其成員函數(shù),第2行只是定義,而未執(zhí)行] 。
[譯注:這里constraints實現(xiàn)的關鍵是依賴C++強大的類型系統(tǒng),特別是類的多態(tài)機制。第2行代碼中T2 c = a; b = a; 能夠正常通過編譯的條件是:T1實現(xiàn)了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。現(xiàn)在這部分譯文給出了通過使用一些技巧,將OOP和GP的方法結合,從而在C++中巧妙實現(xiàn)constrained genericity的方法。對于愛好C++的讀者,這種技巧是值得細細品味的。不過也不要因為太執(zhí)著于各種細枝末節(jié)的代碼技巧而喪失了全局眼光。有時語言支持方面的欠缺可以在設計層面(而非代碼層面)更優(yōu)雅地彌補。另外,這能不能算“C++的template支持constrained genericity”,我保留意見。正如,用C通過一些技巧也可以OOP,但我們不說C語言支持OOP。]
請大家再注意,現(xiàn)在我們的定義具備了這些我們需要的特性:
- 你可以不通過定義/拷貝變量就表達出constraints[譯注:實則定義/拷貝變量的工作被封裝在Can_copy模板中了] ,從而可以不必作任何“那個類型是這樣被初始化”之類假設,也不用去管對象能否被拷貝、銷毀(除非這正是constraints所在)。[譯注:即——除非constraints正是“可拷貝”、“可銷毀”。如果用易理解的偽碼描述,就是template <typename T as Copy_Enabled> xxx,template <typename T as Destructible> xxx 。]
- 如果使用現(xiàn)代編譯器,constraints不會帶來任何額外代碼
- 定義或者使用constraints均不需使用宏定義
- 如果constraints沒有被滿足,編譯器給出的錯誤消息是容易理解的。事實上,給出的錯誤消息包括了單詞“constraints” (這樣,編碼者就能從中得到提示)、constraints的名稱、具體的出錯原因(比如“cannot initialize Shape* by double*”)
既然如此,我們干嗎不干脆在C++語言本身中定義類似Can_copy()或者更優(yōu)雅簡潔的語法呢?The Design and Evolution of C++分析了此做法帶來的困難。已經(jīng)有許許多多設計理念浮出水面,只為了讓含constraints的模板類易于撰寫,同時還要讓編譯器在constraints不被滿足時給出容易理解的出錯消息。比方說,我在Can_copy中“使用函數(shù)指針”的設計就來自于Alex Stepanov和Jeremy Siek。我認為我的Can_copy()實作還不到可以標準化的程度——它需要更多實踐的檢驗。另外,C++使用者會遭遇許多不同類型的constraints,目前看來還沒有哪種形式的帶constraints的模板獲得壓倒多數(shù)的支持。
已有不少關于constraints的“內(nèi)置語言支持”方案被提議和實作。但其實要表述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起個好名字也是件需細細考量的活兒。
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()函數(shù),省事不少。如果想看更詳盡的解釋,參看我的《Learning Standard C++ as a New Language》一文。
另外,為何sort()要比qsort()快?因為它更好地利用了C++的內(nèi)聯(lián)語法語義。
A: Function object是一個對象,不過它的行為表現(xiàn)像函數(shù)。一般而言,它是由一個重載了operator()的類所實例化得來的對象。
Function object的涵義比通常意義上的函數(shù)更廣泛,因為它可以在多次調(diào)用之間保持某種“狀態(tài)”——這和靜態(tài)局部變量有異曲同工之妙;不過這種“狀態(tài)”還可以被初始化,還可以從外面來檢測,這可要比靜態(tài)局部變量強了。我們來看一個例子:
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可被漂亮地內(nèi)聯(lián)化(inlining),因為對于編譯器而言,沒有討厭的指針來混淆視聽,所以這樣的優(yōu)化很容易進行。[譯注:這指的是將operator()定義為內(nèi)聯(lián)函數(shù),可以帶來效率的提高。] 作為對比,編譯器幾乎不可能通過優(yōu)化將“通過函數(shù)指針調(diào)用函數(shù)”這一步驟所花的開銷省掉,至少目前如此。
在標準庫中function objects被廣泛使用,這給標準庫帶來了極大的靈活性和可擴展性。
[譯注:C++是一個博采眾長的語言,function object的概念就是從functional programming中借來的;而C++本身的強大和表現(xiàn)力的豐富也使這種“拿來主義”成為可能。一般而言,在使用function object的地方也??梢允褂煤瘮?shù)指針;在我們還不熟悉function object的時候我們也常常是使用指針的。但定義一個函數(shù)指針的語法可不是太簡單明了,而且在C++中指針早已背上了“錯誤之源”的惡名。更何況,通過指針調(diào)用函數(shù)增加了間接開銷。所以,無論為了語法的優(yōu)美還是效率的提高,都應該提倡使用function objects。
下面我們再從設計模式的角度來更深入地理解function objects:這是Visitor模式的典型應用。當我們要對某個/某些對象施加某種操作,但又不想將這種操作限定死,那么就可以采用Visitor模式。在Design Patterns一書中,作者把這種模式實作為:通過一個Visitor類來提供這種操作(在前面Bjarne Stroustrup的代碼中,Sum就是一個Visitor的變體),用Visitor類實例化一個visitor對象(當然,在前面的代碼中對應的是s);然后在Iterator的迭代過程中,為每一個對象調(diào)用visitor.visit()。這里visit()是Visitor類的一個成員函數(shù),作用相當于Sum類中那個“特殊的成員函數(shù)”——operator();visit()也完全可以被定義為內(nèi)聯(lián)函數(shù),以去除間接性,提高性能。在此提請讀者注意,C++把重載的操作符也看作函數(shù),只不過是具有特殊函數(shù)名的函數(shù)。所以實際上Design Patterns一書中Visitor模式的示范實作和這里function object的實作大體上是等價的。一個function object也就是一個特殊的Visitor。]
A: 很簡單,只要寫“不漏”的代碼就完事了啊。顯然,如果你的代碼到處是new、delete、指針運算,那你想讓它“不漏”都難。不管你有多么小心謹慎,君為人,非神也,錯誤在所難免。最終你會被自己越來越復雜的代碼逼瘋的——你將投身于與內(nèi)存泄漏的奮斗之中,對bug們不離不棄,直至山峰沒有棱角,地球不再轉動。而能讓你避免這樣困境的技巧也不復雜:你只要倚重隱含在幕后的分配機制——構造和析構,讓C++的強大的類系統(tǒng)來助你一臂之力就OK了。標準庫中的那些容器就是很好的實例。它們讓你不必化費大量的時間精力也能輕松愜意地管理內(nèi)存。我們來看看下面的示例代碼——設想一下,如果沒有了string和vector,世界將會怎樣?如果不用它們,你能第一次就寫出毫無內(nèi)存錯誤的同樣功能代碼嗎?
#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';
}
請注意這里沒有顯式的內(nèi)存管理代碼。沒有宏,沒有類型轉換,沒有溢出檢測,沒有強制的大小限制,也沒有指針。如果使用function object和標準算法[譯注:指標準庫中提供的泛型算法],我連Iterator也可以不用。不過這畢竟只是一個小程序,殺雞焉用牛刀?
當然,這些方法也并非無懈可擊,而且說起來容易做起來難,要系統(tǒng)地使用它們也并不總是很簡單。不過,無論如何,它們的廣泛適用性令人驚訝,而且通過移去大量的顯式內(nèi)存分配/釋放代碼,它們確實增強了代碼的可讀性和可管理性。早在1981年,我就指出通過大幅度減少需要顯式加以管理的對象數(shù)量,使用C++“將事情做對”將不再是一件極其費神的艱巨任務。
如果你的應用領域沒有能在內(nèi)存管理方面助你一臂之力的類庫,那么如果你還想讓你的軟件開發(fā)變得既快捷又能輕松得到正確結果,最好是先建立這樣一個庫。
如果你無法讓內(nèi)存分配和釋放成為對象的“自然行為”,那么至少你可以通過使用資源句柄來盡量避免內(nèi)存泄漏。這里是一個示例:假設你需要從函數(shù)返回一個對象,這個對象是在自由內(nèi)存堆上分配的;你可能會忘記釋放那個對象——畢竟我們無法通過檢查指針來確定其指向的對象是否需要被釋放,我們也無法得知誰應該負責釋放它。那么,就用資源句柄吧。比如,標準庫中的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
}
這里只是內(nèi)存資源管理的例子;至于其它類型的資源管理,可以如法炮制。
如果在你的開發(fā)環(huán)境中無法系統(tǒng)地使用這種方法(比方說,你使用了第三方提供的古董代碼,或者遠古“穴居人”參與了你的項目開發(fā)),那么你在開發(fā)過程中可千萬要記住使用內(nèi)存防漏檢測程序,或者干脆使用垃圾收集器(Garbage Collector)。
A: 這個問題,換句話說也就是:為什么C++不提供這樣一個原語,能使你處理異常過后返回到異常拋出處繼續(xù)往下執(zhí)行?[譯注:比如,一個簡單的resume語句,用法和已有的return語句類似,只不過必須放在exception handler的最后。]
嗯,從異常處理代碼返回到異常拋出處繼續(xù)執(zhí)行后面的代碼的想法很好[譯注:現(xiàn)行異常機制的設計是:當異常被拋出和處理后,從處理代碼所在的那個catch塊往下執(zhí)行],但主要問題在于——exception handler不可能知道為了讓后面的代碼正常運行,需要做多少清除異常的工作[譯注:畢竟,當有異常發(fā)生,事情就有點不太對勁了,不是嗎;更何況收拾爛攤子永遠是件麻煩的事],所以,如果要讓“繼續(xù)執(zhí)行”能夠正常工作,寫throw代碼的人和寫catch代碼的人必須對彼此的代碼都很熟悉,而這就帶來了復雜的相互依賴關系[譯注:既指開發(fā)人員之間的“相互依賴”,也指代碼間的相互依賴——緊耦合的代碼可不是好代碼哦 :O) ],會帶來很多麻煩的維護問題。
在我設計C++的異常處理機制的時候,我曾認真地考慮過這個問題;在C++標準化的過程中,這個問題也被詳細地討論過。(參見《The Design and Evolution of C++》中關于異常處理的章節(jié))如果你想試試看在拋出異常之前能不能解決問題然后繼續(xù)往下執(zhí)行,你可以先調(diào)用一個“檢查—恢復”函數(shù),然后,如果還是不能解決問題,再把異常拋出。一個這樣的例子是new_handler。
A: 如果你一定想要的話,你當然可以使用realloc()。不過,realloc() 只和通過malloc()之類C函數(shù)分配得到的內(nèi)存“合作愉快”,在分配的內(nèi)存中不能有具備用戶自定義構造函數(shù)的對象。請記住:與某些天真的人們的想象相反,realloc()必要時是會拷貝大塊的內(nèi)存到新分配的連續(xù)空間中的。所以,realloc沒什么好的 ^_^
在C++中,處理內(nèi)存重分配的較好辦法是使用標準庫中的容器,比如vector。[譯注:這些容器會自己管理需要的內(nèi)存,在必要時會“增長尺寸”——進行重分配。]
A: 參見《The C++ Programming Language》14章8.3節(jié),以及附錄E。附錄E主要闡述如何撰寫“exception-safe”代碼,這個附錄可不是寫給初學者看的。一個關鍵技巧是“資源分配即初始化”——這種技巧通過“類的析構函數(shù)”給易造成混亂的“資源管理”帶來了“秩序的曙光”。
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';
}
請注意,這里沒有顯式的內(nèi)存管理代碼,也沒有限制尺寸而可能會不小心溢出的緩沖區(qū)。 [譯注:似乎Bjarne常驕傲地宣稱這點——因為這是string乃至整個標準庫帶來的重大好處之一,確實值得自豪;而在老的C語言中,最讓程序員抱怨的也是內(nèi)置字符串類型的缺乏以及由此引起的“操作字符串所需要之復雜內(nèi)存管理措施”所帶來的麻煩。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》一文。
A: 因為C++提供了另一種機制,完全可以取代finally,而且這種機制幾乎總要比finally工作得更好:就是——“分配資源即初始化”。(見《The C++ Programming Language》14.4節(jié))基本的想法是,用一個局部對象來封裝一個資源,這樣一來局部對象的析構函數(shù)就可以自動釋放資源。這樣,程序員就不會“忘記釋放資源”了。 [譯注:因為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
}
在一個系統(tǒng)中,每一樣資源都需要一個“資源局柄”對象,但我們不必為每一個資源都寫一個“finally”語句。在實作的系統(tǒng)中,資源的獲取和釋放的次數(shù)遠遠多于資源的種類,所以“資源分配即初始化”機制產(chǎn)生的代碼要比“finally”機制少。
[譯注:Object Pascal,Java,C#等語言都有finally語句塊,常用于發(fā)生異常時對被分配資源的資源的處理——這意味著有多少次分配資源就有多少finally語句塊(少了一個finally就意味著有一些資源分配不是“exception safe”的);而“資源分配即初始化”機制將原本放在finally塊中的代碼移到了類的析構函數(shù)中。我們只需為每一類資源提供一個封裝類即可。需代碼量孰多孰少?除非你的系統(tǒng)中每一類資源都只被使用一次——這種情況下代碼量是相等的;否則永遠是前者多于后者 :O) ]
另外,請看看《The C++ Programming Language》附錄E中的資源管理例子。
A: 哦,auto_ptr是一個很簡單的資源封裝類,是在<memory>頭文件中定義的。它使用“資源分配即初始化”技術來保證資源在發(fā)生異常時也能被安全釋放(“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的析構函數(shù)頭上。不過,q指
向的X類型對象就沒有被釋放(因為不是用auto_ptr定義的)。詳情請見《The C++ Programming Language》14.4.2節(jié)。
Auto_ptr是一個輕量級的類,沒有引入引用計數(shù)機制。如果你把一個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作為標準容器的參數(shù)——標準容器要求通常的拷貝語義。例如:
std::vector<auto_ptr<X> >v; // error
一個auto_ptr只能持有指向單個元素的指針,而不是數(shù)組指針:
void f(int n)
{
auto_ptr<X> p(new X[n]); // error
// ...
}
上述代碼會出錯,因為析構函數(shù)是使用delete而非delete[]來釋放指針的,所以后面的n-1個X沒有被釋放。
那么,看來我們應該用一個使用delete[]來釋放指針的,叫auto_array的類似東東來放數(shù)組了?哦,不,不,沒有什么auto_array。理由是,不需要有啊——我們完全可以用vector嘛:
void f(int n)
{
vector<X> v(n);
// ...
}
如果在 // ... 部分發(fā)生了異常,v的析構函數(shù)會被自動調(diào)用。
A: 可以——從你可在一個程序中同時使用malloc()和new的意義上而言。
不可以——從你無法delete一個以malloc()分配而來之對象的意義上而言。你也無法free()或realloc()一個由new分配而來的對象。
C++的new和delete運算符確保構造和析構正常發(fā)生,但C風格的malloc()、calloc()、free()和realloc()可不保證這點。而且,沒有任何人能向你擔保,new/delete和malloc/free所掌控的內(nèi)存是相互“兼容”的。如果在你的代碼中,兩種風格混用而沒有給你造成麻煩,那我只能說:直到目前為止,你是非常幸運的 :O)
如果你因為思念“美好的老realloc()”(許多人都思念她)而無法割舍整個古老的C內(nèi)存分配機制(愛屋及烏?),那么考慮使用標準庫中的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》一文中給出了其它例子,可以參考。
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中一類最常見的不安全換型發(fā)生在將malloc()分配而來的內(nèi)存賦給某個指針之時,例如:
int* p = malloc(sizeof(int));
在C++中,應該使用類型安全的new操作符:
int* p = new int;
而且,new還有附帶的好處:
- new不會“偶然”地分配錯誤大小的內(nèi)存
- new自動檢查內(nèi)存是否已枯竭
- 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) { /* ... */ }
Q: 如果你想得到一個可用于常量表達式中的常量,例如數(shù)組大小的定義,那么你有兩種選擇:
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戲法”,因為這種定義可移植性好,而且不會引誘我去使用非標準的“類內(nèi)初始化”擴展語法。
那么,為何要有這些不方便的限制?因為類通常聲明在頭文件中,而頭文件往往被許多單元所包含。[所以,類可能會被重復聲明。]但是,為了避免鏈接器設計的復雜化,C++要求每個對象都只能被定義一次。如果C++允許類內(nèi)定義要作為對象被存在內(nèi)存中的實體,那么這項要求就無法滿足了。關于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
// ...
}
A: 嗯,問得挺有道理的。我們來看:
delete p;
// ...
delete p;
如果代碼中的//...部分沒有再次給p分配內(nèi)存,那么這段代碼就對同一片內(nèi)存釋放了兩次。這是個嚴重的錯誤,可惜C++無法有效地阻止你寫這種代碼。不過,我們都知道,釋放空指針是無危害的,所以如果在每一個delete p;后面都緊接一個p = 0;,那么兩次釋放同一片內(nèi)存的錯誤就不會發(fā)生了。盡管如此,在C++中沒有任何語法可以強制程序員在釋放指針后立刻將該指針歸零。所以,看來避免犯這樣的錯誤的重任只能全落在程序員肩上了?;蛟S,delete自動把指針歸零真是個好主意?
哦,不不,這個主意不夠“好”。一個理由是,被delete的指針未必是左值。我們來看:
delete p+1;
delete f(x);
你讓delete把什么自動置零?也許這樣的例子不常見,但足可證明“delete自動把指針歸零”并不保險。[譯注:事實上,我們真正想要的是:“任何指向被釋放的內(nèi)存區(qū)域的指針都被自動歸零”——但可惜除了Garbage Collector外沒什么東東可以做到這點。] 再來看個簡單例子:
T* p = new T;
T* q = p;
delete p;
delete q; // ouch!
C++標準其實允許編譯器實作為“自動把傳給delete的左值置零”,我也希望編譯器廠商這樣做,但看來廠商們并不喜歡這樣。一個理由就是上述例子——第3行語句如果delete把p自動置零了又如何呢?q又沒有被自動置零,第4行照樣出錯。
如果你覺得釋放內(nèi)存時把指針置零很重要,那么不妨寫這樣一個destroy函數(shù):
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
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是返回給你的程序的調(diào)用者的,這是種“負責”的做法,“什么都不返回”可不大好哦。如果你程序的調(diào)用者不支持用“返回值”來交流,這個值會被自動忽略——但這也不能使void main()成為合法的C++或C代碼。即使你的編譯器支持這種定義,最好也不要養(yǎng)成這種習慣——否則你可能被其他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()函數(shù)缺少返回類型。
A: 大部分的操作符是可以被重載的,例外的只有“.”、“::”、“?:”和“sizeof”。沒有什么非禁止operator?:重載的理由,只不過沒有必要而已。另外,expr1?expr2:expr3的重載函數(shù)無法保證expr2和expr3中只有一個被執(zhí)行。
而“sizeof”無法被重載是因為不少內(nèi)部操作,比如指針加法,都依賴于它,例如:
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)無法在不違背基本語言規(guī)則的前提下表達什么新的語義。
在N::m中,N和m都不是表達式,它們只是編譯器“認識”的名字,“::”執(zhí)行的實際操作是編譯時的名字域解析,并沒有表達式的運算牽涉在內(nèi)。或許有人會覺得重載一個“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++標準化之時,何種方案為佳并不明顯。細節(jié)請參見《The Design and Evolution of C++》。
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流的細節(jié)嗎?參見《The C++ Programming Language》,21.5.3節(jié)。
A: 如果讓計算機來讀,兩者完全等同,都是正確的。我們還可以聲明成“int * p”或“int*p”。編譯器不會理會你是不是在哪里多放了幾個空格。
不過如果讓人來讀,兩者的含義就有所不同了。代碼的書寫風格是很重要的。C風格的表達式和聲明式常被看作比“necessary evil”[譯注:“必要之惡”,意指為了達到某種目的而不得不付出的代價。例如有人認為環(huán)境的破壞是經(jīng)濟發(fā)展帶來的“necessary evil”]更糟的東西,而C++則很強調(diào)類型。所以,“int *p”和“int* p”之間并無對錯之分,只有風格之爭。
一個典型的C程序員會寫“int *p”,而且振振有詞地告訴你“這表示‘*p是一個int’”——聽上去挺有道理的。這里,*和p綁在了一起——這就是C的風格。這種風格強調(diào)的是語法。
而一個典型的C++程序員會寫“int* p”,并告訴你“p是一個指向int的指針,p的類型是int*”。這種風格強調(diào)的是類型。當然,我喜歡這種風格 :O) 而且,我認為,類型是非常重要的概念,我們應該注重類型。它的重要性絲毫不亞于C++語言中的其它“較為高級的部分”。[譯注:諸如RTTI,各種cast,template機制等,可稱為“較高級的部分”了吧,但它們其實也是類型概念的擴展和運用。我曾寫過兩篇談到C++和OOP的文章發(fā)表在本刊上,文中都強調(diào)了理解“類型”之重要性。我還曾譯過Object Unencapsulated (這本書由作者先前所著在網(wǎng)上廣為流傳的C++?? A Critique修訂而來)中講類型的章節(jié),這本書的作者甚至稱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均譯為定義。通常認為,兩者涵義之基本差別是:“聲明”只是為編譯器提供信息,讓編譯器在符號表中為被聲明的符號(比如類型名,變量名,函數(shù)名等)保留位置,而不用指明該符號所對應的具體語義——即:沒有任何內(nèi)存空間的分配或者實際二進制代碼的生成。而“定義”則須指明語義——如果把“聲明”比作在辭典中為一個新詞保留條目;那么“定義”就好比在條目中對這個詞的意思、用法給出詳細解釋。當我們說一個C++語句是“定義”,那么編譯器必定會為該語句產(chǎn)生對應的機器指令或者分配內(nèi)存,而被稱為“聲明”的語句則不會被編譯出任何實際代碼。從這個角度而言,原文中有些地方雖作者寫的是“對象、類、類型的聲明(declaration)”,但或許改譯為“定義”較符合我們的理解。不過本譯文還是采用忠于原文的譯法,并不按照我的理解而加以更改。特此說明。另外,譯文中凡涉及我個人對原文的理解、補充之部分均以譯注形式給出,供讀者參考。]人們一般不太可能寫出像這樣的代碼:
int* p = &i;
int p1 = p; // error: int initialized by int*
如果真的有人這樣寫,編譯器也不會同意——它會報錯的。
每當達到某種目的有兩條以上途徑,就會有些人被搞糊涂;每當一些選擇是出于個人喜好,爭論就會無休無止。堅持一次只聲明一個指針并在聲明時順便初始化,困擾我們已久的混淆之源就會隨風逝去。如果你想了解有關C的聲明語法的更多討論,參見《The Design and Evolution of C++》 。
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) {
// ...
}
}
這種風格比較節(jié)省“垂直空間”——我喜歡讓盡量多的內(nèi)容可以顯示在一屏上 :O) 而函數(shù)定義開始的花括號之所以如此放置,是因為這樣一來就和類定義區(qū)分開來,我就可以一眼看出:噢,這是函數(shù)!
正確的縮進非常重要。
一些設計問題,比如使用抽象類來表示重要的界面、使用模板來表示靈活而可擴展的類型安全抽象、正確使用“異常”來表示錯誤,遠遠要比代碼風格重要。
[譯注:《The Practice of Programming》中有一章對“代碼風格”問題作了詳細的闡述。]
A: 我是喜歡寫在前面的。不過這只是個人口味的問題?!癱onst T”和“T const”均是允許的,而且它們是等價的。例如:
const int a = 1; // ok
int const b = 2; // also ok
我想,使用第一種寫法更合乎語言習慣,比較不容易讓人迷惑 :O)
為什么會這樣?當我發(fā)明“const”(最早是被命名為“readonly”且有一個叫“writeonly”的對應物)時,我讓它在前面和后面都行,因為這不會帶來二義性。當時的C/C++編譯器對修飾符很少有強加的語序規(guī)則。
我不記得當時有過什么關于語序的深思熟慮或相關的爭論。一些早期的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++的作用域和類型規(guī)則,這會帶來許多麻煩。因此,C++提供了能和語言其它部分“合作愉快”的替代機制,比如內(nèi)聯(lián)函數(shù)、模板、名字空間機制。讓我們來看這樣的代碼:
#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++程序設計環(huán)境的欠缺之處:計算機和電腦眼中的源文件的涵義是不同的。
不幸的是,你無法確保其他程序員不犯你所認為的“愚蠢的”錯誤。比方說,近來有人告訴我,他們遇到一個含“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;
}
如果你是一個負責維護的程序員,這樣的代碼被提交到你面前,而宏定義(為了給這個“戲法”增加難度而)被藏到了一個頭文件中(這種情況并非罕見),你作何感想?是不是一頭霧水?
一個常見而微妙的問題是,函數(shù)風格的宏不遵守函數(shù)參數(shù)調(diào)用規(guī)則。例如:
#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++”被執(zhí)行兩次的問題仍然沒有解決。
我知道有些(其它語言中)被稱為“宏”的東西并不象C/C++預處理器所處理的“宏”那樣缺陷多多、麻煩重重,但我并不想改進C++的宏,而是建議你正確使用C++語言中的其他機制,比如內(nèi)聯(lián)函數(shù)、模板、構造函數(shù)、析構函數(shù)、異常處理等。
[譯注:以上是Bjarne Stroustrup的C++ Style and Technique FAQ的全文翻譯。Bjarne是丹麥人,他寫的英文文章可不好讀,技術文章尤甚。本譯文或許錯誤偏頗之處不少,歡迎廣大讀者指正。我的email是 zmelody@sohu.com 。]