C++程式設計易範的錯誤

shwenwen發表於2007-11-07

C++程式設計易範的錯誤
摘自《windows 95程式設計指南》,[美]Stephen R.Davis 著

C/C++語言中有許多對初學者(甚至是有經驗的程式設計人員)來說很容易範的錯誤。通曉這樣的錯誤可使你免於陷入其中。

[@more@]

C++程式設計易範的錯誤
摘自《windows 95程式設計指南》,[美]Stephen R.Davis 著

C/C++語言中有許多對初學者(甚至是有經驗的程式設計人員)來說很容易範的錯誤。通曉這樣的錯誤可使你免於陷入其中。

忘記初始化指標

這種錯誤只是一般"忘記初始化變數"錯誤的一個特殊形式(C/C++中變數不會自動初始化,而Basic可以)。使這種錯誤更糟糕的原因是它的後果往往更加糟糕:
void SomeFunction()
{
int *pnVar
int nVal;
nVal = *pnVar; // Bad enough.
*pnVar = nVal; // Much worse.
}
在這個例子中,指標變數pnVar從未被賦值。因此你必須假設它含有的是雜亂的資料,從一個混亂資訊指標中讀數糟糕的很,因為結果肯定是雜亂資料,向一個混亂資訊指標寫資料更糟,因為它將導致一些不知道什麼地方的資料被重寫。
如果被重寫的區域無用,這到沒什麼危害。如果被重寫的區域有用,資料就會丟失。這種型別的錯誤那麼難找,是因為直到程式企圖使用已丟失的資料時問題才會呈現出來。這種問題可能是在資料丟失後好久才發生的。
由於這一問題手工判斷很困難,Visual C++編譯器就透過一些努力來避免它的發生。例如,當你編譯上述函式時就會產生一個警告。在這種情況下,編譯器會告訴你變數在使用前未被賦值。在很多情況下,它不可能告訴你。
Windows 95操作系統試圖用保護儲存器在一定程度上幫助解決難題:如果應用程式企圖從不屬於它的儲存器讀或寫,Windows通常能截獲該請求,並立即終止該程式。可惜,Windows 95不能截獲對應用程式擁有的儲存器的無效訪問,它也不能截獲所有非法訪問,因為必須保留某些缺口,以與Windows 3.1的相容性名義開放。

忘記釋放堆記憶體

請記住從堆獲得分配的任何記憶體都必須要釋放。如果你用完記憶體以後,忘記釋放它,系統記憶體就會變得愈來愈小,直到最後你的程式不能執行而崩潰。
這個問題會出現在諸如下列的一些情況中:
Car* GetAnewCar(int nOccupants)
{
Car* pCar;
if(nOccupants < 4)
{
pCar = new Car(2); // get a two-door.
}
else
{
pCar = new Car(4); // otherwise, a four-door.
}
return pCar;
}
void GoToTheStore(int nOccupants)
{
// get a car。
Car* pCar = GetAnewCar(nOccupants);
// Now drive to the store。
if(pCar)
{
pCar->Drive(Store);
}
}
在此例中,函式GoToTheStore()首先分配一輛新車來開——這有點浪費,但你肯定會同意這種演算法可以正常工作。只要分配了新車,它就會開到有呼叫pCar->Drive(Store)所指向的商店。
問題是在它安全到達目的地之後,函式不破壞Car物件。它只是簡單地退出,從而使記憶體丟失。
通常,當物件pCar出了程式中的作用域時,程式設計師應該依靠解構函式~Car釋放記憶體。但這裡辦不到,因為pCar的型別不是Car而是Car*,當pCar出了作用域時不會呼叫解構函式。
修正的函式如下:
void GoToTheStore(int nOccupants)
{
// get a car。
Car* pCar = GetAnewCar(nOccupants);
// Now drive to the store。
if(pCar)
{
pCar->Drive(Store);
}
// Now delete the object,returning the memory.
delete pCar;
}
使用new運算子構造的物件都應該用delete運算子刪除,這一點必須牢記。

返回對區域性記憶體的引用

另一個常見的與記憶體有關的問題是從函式返回區域性記憶體物件的地址。當函式返回時,物件不再有效。下一次呼叫某函式時,這個記憶體地址可能會被這個新函式使用。繼續使用這個記憶體指標就有可能會寫入新函式的區域性記憶體。
這個常見問題以這種方式出現:
Car* GetAnewCar(int nOccupants)
{
Car* pCar;
if(nOccupants < 4)
{
pCar = &Car(2); // get a two-door.
}
else
{
pCar = &Car(4); // otherwise, a four-door.
}
return pCar;
}
請注意指標pCar怎樣被賦予由建構函式Car()建立的未命名物件的區域性地址的。到目前為止,沒有問題。然而一旦函式返回這個地址,問題就產生了,因為在封閉的大括號處臨時物件會被析構。

使運算子混亂

C++從它的前輩C那裡繼承了一套含義相當混亂模糊的運算子。再加上語法規則的靈活性,就使它很容易對程式設計師造成混亂,使程式設計師去使用錯誤的運算子。
這個情況的最出名的例子如下:
if(nVal = 0)
{
// do something if nVal is nonzero.
}
程式設計師顯然想要寫if(nVal == 0)。不幸的是,上述語句是完全合法的,雖然沒有什麼意義,C++語句將nVal賦值為0,然後檢查結果看看是否為非零(這是不可能發生的)。結果是大括號內的程式碼永遠不會被執行
其它幾對容易弄錯的運算子是&和&&,以及/和//。

0的四種面孔

根據使用它的方式,常數0有四種可能的含義:
☆ 整數0
☆ 不能是物件地址的地址
☆ 邏輯FALSE
☆ 字串的終結符
我可以向你證明這些含義的差別是很實際的。例如,下列賦值是合法的:
int *pInt;
pInt = 0;// this is leagal.
而下列賦值是不合法的:
int *pInt;
pInt = 1;// this is not.
第一個賦值是合法的,因為表中的第二定義:常數0可以是地址,然而常數1則不行。
這個含義的多重效能導致一些難以發現的錯誤:
// copy a string from psource to pTarget -- incorrect version.
while(pSource)
{
*pTarget++ = *pSource++;
}
此例中的while迴圈試圖把由pSource指向的源字串複製到由pTarget指向的記憶體塊。但不幸的是,條件寫錯了,它應這樣寫出:
// copy a string from pSource to pTarget -- incorrect version.
while(*pSource)
{
*pTarget++ = *pSource++;
}
你可以看到,當由pSource指向的字元為NULL時,終止條件出現。這是0的第四定義。然而,這裡寫出的程式碼卻是去檢視地址pSource是否為零,這是第二定義。
最終結果是while()迴圈繼續寫入記憶體直到程式崩潰。
0的其他定義之間也可能產生混亂。唯一的解決辦法就是當你使用常數0的時候小心一點。

宣告的混亂處

複合宣告是非常混亂的,但C++——以它的熱忱保持了與C的反向相容性——但也產生了一些宣告間的矛盾,你必須避免這種矛盾。
class Myclass
{
public:
Myclass(int nArg1 = 0,int nArg2 = 0);
};
Myclass mcA(1,2);
Myclass mcB(1);
Myclass mcC();
mcA是引數1和2構成的物件,而mcB是引數1和0構成的物件。因此你可能認為mcC是引數0和0構成的物件,然而情況不是這樣。而mcC()是一個不帶引數的函式,它用數值返回類Myclass的物件。
另一個混亂產生於初始化運算子=的使用:
Myclass mcB = nA; // same as Myclass mcB(nA)
為了增強與C的相容性,允許這樣使用=;然而你應該避免這種結構,因為它不是一貫適用的。例如下列程式就不會有預期的效果:
Myclass mcA = nA,nB;
這說明一個物件mcA(nA),它後面有一個獨立的使用預設構造符的物件nB,而不是說明一個物件mcA(nA,nB)。
堅持使用C++格式——這是最安全的。

計算順序混亂

C和C++運算子的先後順序,使你能夠知道怎樣計算諸如下列表示式
a = b * c + d;
然而先後次序不會影響子表示式的計算順序。讓我們以看上去不重要的方式改變示例的表示式:
a = b() * c() + d();
現在的問題是,在這個表示式中以什麼樣的順序呼叫函式b(),c()和d()?答案是,順序是完全不確定的。更糟的是,順序不能借助圓括號的使用而確定。所以下列表示式沒有作用:
a = (b() * c()) + d();
函式計算順序通常不值得去關心。然而,如果這些函式有副作用,以某種方式彼此影響(稱為相互副作用),那麼順序就是重要的了。例如,如果這些函式改變相同的全域性變數,則結果就是不同的,這取決於其中函式被呼叫的順序。
甚至當不涉及函式呼叫時,相互副作用也會產生影響:
int nI = 0;
cout< 這個表示式的問題是單個表示式包含有相互副作用的兩個子表示式——變數nI是增量。哪個nA[nI++]首先被執行,左邊的nA[nI++]還是右邊的nA[nI++]?沒法說,上述程式碼可能會以預期的方式工作,但也可能不會。

說明虛擬成員函式

為了在子類中過載虛擬成員函式,必須用和基本類中函式一樣的形式說明子類中函式的引數和返回型別。這並不總是清楚的。例如,下列程式碼似乎講得通:
class Base
{
public:
virtual void AFunc(Base *pB);
};
class Subclass:public Base
{
public:
virtual void AFunc(Subclass *pS);
};
這個程式碼會編譯透過,但不會有遲後聯編。函式Base::AFunc()的引數是Base*型別的,而函式Subclass::AFunc()的引數是Subclass*,它們是不同的。
這個規則的唯一例外是下面的例子,它符合ANSI C++標準:
class Base
{
public:
virtual void Base* AFunc();
};
class Subclass:public Base
{
public:
virtual void Subclass* AFunc();
};
在此例中,每個函式返回其固有型別物件的地址。這種技術很通用,所以標準委員會決定承認它。

從建構函式內呼叫虛擬成員函式

從構造符內呼叫虛擬函式是前期聯編的,這樣,它就短路掉了那些原本可能的簡潔的能力:
class Base
{
public:
Base();
virtual void BuildSection();
};
class Subclass:public Base
{
public:
Subclass();
virtual void BuildSection();
};
Base::Base()
{
BuildSection();
};
在此例中,程式設計師希望建構函式能夠多型地呼叫BuildSection(),當正在構造的物件是Base物件時呼叫Base::BuildSection(),當物件是類Subclass物件時呼叫Subclass::BuildSection()。
由於下列簡單的原因這個例子不起作用:當呼叫BuildSection()完成時,正在構造的物件僅僅是一個Base物件。即使物件最終成為Subclass物件,也要等到Subclass的建構函式把它過一遍以後。在這些情況下呼叫Subclass::BuildSection()可能是致命的。即使物件將最終成為Subclass物件,但在呼叫BuildSection()的時候,物件只不過是Base物件,而且,這個呼叫必須要前期聯編到函式Base::BuildSection()。

指標對準

當你在80x86處理器(例如,你的PC機的晶片)上執行你的程式時,這個問題不是致命的,但對其他的絕大多數晶片來說,這就是致命的了。它還會對你的應用程式移植到某個其他環境的能力產生影響。此外,甚至對於intel 處理器來說,這個問題也將導致低於標準的效能。
當你的指標從一種型別轉換到另一種型別的時候,就有可能產生一個非對準指標(misaligned pointer)。處理器一般要求記憶體塊的地址要與一個和這個記憶體塊的尺寸匹配的邊界對齊。例如,字只能在字邊界上被訪問(地址是二的倍數),雙字只能在雙字邊界上被訪問(地址是四的倍數),依次類推。
編譯器通常確保監視這個規則。但是當你的指標型別從一種型別轉換成較大型別時,你就可以很容易地違反這個規則:
char cA;
char* pC = &cA;
int* pI;
pI = (int*)pC;
*pI = 0; // this may be fatal.
因為字元僅僅是一個位元組長,所以地址&cA可能有任意值,包括奇數值。可是,pI應只包含四的倍數的地址。透過轉換,允許把pC賦給pI,但是如果地址不是四的倍數,則接著發生的賦值可能使程式崩潰。
對於Intel處理器來說,甚至當pC值為奇數時,該賦值也不是致命的;雖然佔用的時間要長得多,但是賦值還是能夠正常執行。請你謹防非對準指標。
這種情況只在你正在把你的指標從指向一種型別轉換成指向較大型別時才會出現。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/143526/viewspace-981099/,如需轉載,請註明出處,否則將追究法律責任。

相關文章