一個庫接口實例-摘自《C++沉思錄》Andrew Koenig

       爲什麼向不熟悉的人解釋抽象數據類型(ADT)會是一件很有挑戰性的事情呢?因爲很難找到一個與實際情況一樣複雜,但又小巧易懂的例子。畢竟,數據抽象的目的就是控制複雜度,所以要找一個簡單的例子簡直是自相矛盾。

       儘管如此,還是有一個很好的例子,就是一個得到廣泛應用的C庫例程集,它能夠檢查文件系統目錄的內容。C程序可以在種類繁多的操作系統中運行,而且大多數操作系統關於文件和目錄的概念都相似。因此,我們可以泛泛的討論這些概念,而不必具體針對某種操作系統。

       這個例子的意義在於着重說明我們如何對那些不直接支持數據抽象的語言中十分通用的約定,使用數據抽象來自動進行管理通過在類中隱藏這些約定,我們可以使用戶免於處理它們。這樣做不僅使類更簡單,也增強了類的健壯性

       要知道C庫例程是如何工作的,最簡單的方法就是觀察一個使用這些例程的程序:

/* 這是一個 C 程序 */
#include <stdio.h>
#include <dirent.h>

int main() {
	DIR *dp = opendir(".");
	struct dirent *d;

	while (d = readdir(dp))
		printf("%s\n", d->d_name);

	closedir(dp);
	return 0;
}

       C 程序通過兩個分別叫做 DIR 和 struct dirent 的類型與庫進行通信。指向 DIR 對象的指針被當作神奇的 cookies(magic cookies)--一種具有超自然力的、能使程序作某些事情的小對象。

       我們並不打算弄清楚 DIR 對象裏面有什麼東西。調用 opendir 就會獲得一個 DIR 指針,我們把這個指針傳遞給 readdir 去讀取一個目錄條目,還把它傳給 closedir 去釋放由 opendir 分配的資源。

       對 readdir 的調用將返回一個指向 struct dirent 的指針,該 struct dirent 表示剛纔讀取的目錄條目。另外,我們也不想知道 struct dirent 的完整內容,而只要知道它的一個成員是一個以 null 結尾的、包含了這個目錄條目名字的字符數組,該數組叫做 d_name。

       這個範例程序工作的方式就是調用 opendir 來獲得表示當前目錄的神奇 cookies;反覆調用 readdir 來從這個目錄中取出並打印目錄條目;最後調用 closedir 來清理內存。

       儘管這個小程序沒有用到它們,但是爲了完整性,我還是應該說說另外兩個庫函數。telldir 函數獲得表示目錄的 DIR 指針,並返回一個表示目錄當前位置的 long 。函數 seekdir 獲得 DIR 指針和由 telldir 返回的值,並將指針移動至指定位置。

 

1.   複雜問題

       所有這些看上去都非常簡單,但是果真如此嗎?先前的非正式描述忽略了幾個真正的問題,它們會給實際程序員編寫代碼帶來麻煩。下面我們來看看一些更重要的問題

       ×   如果目錄不存在會怎樣? 如果給 opendir 的是一個不存在的目錄名,它不能直接死機了之--它必須做點什麼。這種情況下通常返回一個空指針。這樣做有助於程序檢查到底有沒有打開。想法還不錯是吧,可是就帶來了下一個問題。

       ×   如果傳給 readdir 的參數是一個空指針會怎樣? 如果在前面的程序中目錄“.”不存在,就會出現這種情況。對於這個問題至少有兩種答案。readdir 可能找不到空參數,此時我們很自然就會預料到內核轉儲或者其他的災難性後果;或者,readdir 可以實施某種檢查,並進行相應處理。後一種情況再次引起一個新問題。

       ×   如果傳給 readdir 的參數既不是一個空指針也不是一個由 opendir 函數返回的值又會怎樣? 這種錯誤是很難察覺的;要檢測到這種錯誤需要構建存放有效 DIR 對象的表,每次調用 readdir 時都對該表進行搜索。這太複雜,並且相應的消耗也太大,所以C庫例程通常不這樣做。於是衍生出 readdir 返回結果的一個問題。

       × 對 readdir 的調用返回指向由庫分配的內存塊指針。什麼時候釋放這塊內存?在這個例子中,對 readdir 的每次調用都返回一個保存在 d 中的指針值。如果程序照下面這樣寫會發生什麼情況呢?

       d1 = readdir(dp1);

       d2 = readdir(dp2);

       print("%s\n", d1->d_name);

       我們怎樣才能知道調用 readdir(dp2) 後指針 d1 是否還指向一個有效的位置?是否只有當 dp1 != dp2 時 d1 纔有效?還是另有某個其他規則?弄清楚這段代碼是否有效的唯一方法就是弄清楚哪些操作會使 d1 所指向的值無效,以及我們的實際做法如何。

 

2.   接口優化

       現在先不回答這些問題,我們先重新設計C++中的接口,以便在可能的地方不必考慮這些問題。我們將用對象取代那個神奇 cookie,並取消對指針的使用,從而實現對接口的重新設計。

       在 C 版本中我們看到的第一個神奇 cookie 是 DIR 指針;讓我們把這個指針放到一個取名爲 Dir 的類中。Dir 對象表示對目錄的一次查看;C 版本中除了兩個控制 DIR 指針的函數外,其他所有函數都應該變成 Dir 類的成員函數。那兩個控制指針的函數分別是 opendir 和 closedir,必須對應於構造函數和析構函數。那麼,類定義就與下面的類似:

class Dir {
public:
	Dir(const char*);
	~Dir();
	// 關於 read、seek 和 tell 的聲明
};

       read、seek 和 tell 成員函數的參數及結果類型是什麼?我們先解決 seek 和 tell,因爲它們最簡單;由於 C 版本採用了神奇 cookie,所以 C++ 版本應該用一個小型類來表示偏移量。這個類的對象表示目錄內的偏移量,所以我們稱之爲 Dir_offset:

class Dir_offset {
	friend class Dir;
private:
	long l;
	Dir_offset(long n) { l = n; }
	operator long() { return l; }
};

       注意這個類沒有公共數據。尤其是其構造函數也是私有的。因此,創建 Dir_offset 對象的唯一方法就是調用知道如何創建它的函數--推薦設爲 Dir 類的成員函數。一旦我們有了這樣的對象,當然就可以複製它,但是由於這個類的定義方式,用戶不能直接探查該類的對象。

       Dir_offset 對象的唯一數據成員是一個對應於 telldir 返回的值的 long 對象。現在該討論 read 函數來。由於一個非常重要的原因,C 版本返回一個指向 struct dirent 的指針;這樣就可以通過返回一個空指針來標識到達目錄尾部了。我們在這兒沒有費力封裝 dirent 結構體,而是改變 Dir 讀取它的方法。通過使用 C++ 中...,我們可以以不同的方式檢測是否到達目錄尾部;爲 read 提供一個表示可以放入其結果中的對象的參數,並讓它返回一個表示讀取是否成功的“布爾值”(實際上是一個整數)。

#include <dirent.h>

class Dir {
public:
	Dir(const char*);
	~Dir();
	int read(dirent &);
	void seek(Dir_offset);
	Dir_offset tell() const;
};

       有了這個接口,我們現在就可以如下所示重寫範例程序:

#include <iostream>
#include <dirlib.h>

int main() {
	Dir dp(".");
	dirent d;

	while (dp.read(d))
		cout << d.d_name << endl;
}

       這裏,頭文件 dirlib.h 包括關於 Dir 和 Dir_offset 的聲明。

 

3.   溫故知新

       因爲還沒有用成員函數定義來充實 DIr 類,所以還不能運行這個程序。但是,我們已經知道一些如何改進程序的方法。

       首先,注意這個庫的 C 版本在全局名稱空間中加入了 7 個名字:DIR、dirent、opendir、closedir、readdir、seekdir、和 telldir。相反,C++版本只用了 Dir、dirent 和 Dir_offset。

       其次,我們發現程序的 C++ 版本根本不包括指針變量。特別的,d 是一個表示目錄條目的對象,而不像 C 版本中那樣是一個指向這樣的對象的指針。因此,我們就去掉了一個可能有問題的類;沒有使用指針的程序不會導致由於爲定義指針而引起的崩潰。

       再次,因爲 C++ 不需要在聲明對象時在前面加上 struct 或者 class 關鍵字,所以關於 d 的聲明就變得更簡潔了。

       最後,C++ 版本回答了 C 版本沒有回答的問題:

       1.   如果目錄不存在會怎樣?我們還是必須處理這個問題。實際上,使用 C++ 令我們更明確了要解決這個問題,因爲類似下面的程序

       Dir d(some directory);

       d.read(somewhere);

必須做些有意義的事情:即使打開目錄失敗,d 也是一個對象。要注意確保 Dir 構造函數將它的對象置於一種恆定的狀態,即使下一次對 opendir 底層調用失敗也是如此。如果在庫中一次性把這件事解決,使用這個庫的人就不必擔驚受怕的顧慮這個問題。

       另一種做法就是,如果我們被要求創建一個 Dir 對象,該對象指向一個不存在的目錄,可以拋出一個異常。再一種可行的辦法是允許創建 Dir 對象,但是對於讀取它的請求要拋出異常。

       2.   如果傳給 readdir 的參數是一個空指針會怎樣?這在 C++ 版本中不再是問題;我們必須對某個對象調用 read,而那個對象必須是已經被創建的。

       3.   如果傳給 readdir 的參數既不是一個空指針也不是一個由 opendir 函數返回的值又會怎樣?這也不是問題,理由同上。

       4.   對 readdir 的調用返回一個指向由庫分配的內存的指針。什麼時候釋放這些內存?我們讓 read 讀取用戶提供的對象,而不是返回一個指針。這樣就把內存分配的職責交給用戶了,但是我們通過使用 read 來讀取局部變量減輕了這個負擔。

       顯然,我們僅僅通過把這些例程改寫成 C++ 的,就使它們更加健壯了,這主要是因爲我們嘗試將 C 接口中的底層概念轉化成了 C++ 接口中的顯式對象。

 

4.   編寫代碼

       應該注意的是我們已經明確了接口,設計它的實現應該不難。Dir 類封裝了一個 DIR 指針,所以我們將在 Dir 類的私有數據中包括這個指針。我們還將通過賦值和初始化私有化使對 Dir 對象的複製無效:

class Dir {
public:
	Dir(const char*);
	~Dir();
	int read(dirent &);
	void seek(Dir_offset);
	Dir_offset tell() const;

private:
	DIR* dp;

	// 禁止複製
	Dir(const Dir &);
	Dir& operator=(const Dir &);
};

       我們不希望允許複製 Dir 對象,因爲對一個對象進行讀操作會影響另一個對象的狀態。另外,複製一個 Dir 對象後,原來的 Dir 對象和副本都不得不被銷燬。我們可以設計一個更復雜的 Dir 對象,使它適用於這種可能的情況;如何實現就留作讀者自己聯繫。

       現在我們可以寫成員函數了。構造函數調用 opendir:

       Dir::Dir(const char* file): dp(opendir(file)) {}

       因此,我們必須弄清楚如果 opendir 失敗會發生什麼情況。答案當然是 dp 將爲空;我們必須記得在其他成員函數中檢測這種情況,並做出相應處理。

       析構函數很簡單--我們調用 closedir,除非打開失敗:

Dir::~Dir() {
	if (dp)
		closedir(dp);
}

       如果打開失敗,dp 將爲 0.檢測 dp 就是爲了弄清楚打開是否成功,這樣我們就不依賴底層 C 庫是否正確的允許我們對一個沒有指向打開的 Dir 的 Dir 指針調用 closedir。

       seek 和 tell 函數也簡單;我們調用 seekdir 或者 telldir。唯一的問題就是如果打開失敗,從 tell 返回什麼。幸運的是,返回什麼無關緊要,因爲任何相應的 seek 都不會針對錯誤的發現做任何反應:

void Dir::seek(Dir_offset pos) {
	if (dp)
		seekdir(dp, pos);
}

Dir_offset Dir::tell() const {
	if (dp)
		return telldir(dp);
	return -1;
}

       最後,我們就有了 read 成員。這是所有成員函數裏面最複雜的一個,但還是相當簡單:

int Dir::read(dirent & d) {
	if (dp) {
		dirent* r = readdir(dp);
		if (r) {
			d = *r;
			return 1;
		}
	}
	return 0;
}

       我們遵循了對於錯誤返回 0 以及對於成功返回 1 的約定。這段代碼首先檢查打開是否失敗,如果失敗立即返回  0。然後調用 readdir 來讀取一個目錄條目;如果得到一個,則馬上覆制這個條目到調用者提供的 dirent 對象中。於是我們就回答了前面的問題;讀取一個不存在的目錄的行爲類似於該目錄根本沒有條目。

       將 *r 的值複製到用戶的空間就使用戶不必在擔心 *r 的生存期,這不僅因爲當讀取關於 struct dirent (*r 類型)的描述時,我們知道它不依賴於任何位於該結構體外的任何成員。如果不是這種情況,就必須在 C++ 中用一個動態字符串類定義一個獨立的 dirent 類,而不是用 C 版本的 dirent 結構體。

       如果有個函數能夠顯式的檢查 Dir 對象是否成功的打開了它的底層目錄就更好了。這個函數--並不比我們已經在這裏見過的函數難--就留作練習。

       順便提醒一點,就是可以通過內聯 Dir 成員函數減小這個接口原本已經很小的開銷

 

5.   結論

       這個庫的 C++ 接口是對 C 接口以一種很有效的方法稍微加以改進得到的。所有這些改進都得益於數據抽象的觀念;如果對某個類對象的所有單個操作都將對象置於一種合理的狀態,那麼對象的狀態就會始終保持合理

       C 接口具有幾種我們前面的問題暴露出來的隱藏約定。不遵守這些約定的程序運行起來可能會出現奇怪的情況從而導致失敗。使這些約定顯式的作爲接口的一部分,我們可以更早檢測到錯誤,程序員工作起來也會更有信心。

 

 

 

 

 

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