CUJ:標準庫:基於檔案的容器 (轉)

worldblog發表於2007-12-14
CUJ:標準庫:基於檔案的容器 (轉)[@more@]

The Standard Librarian: File-Based Containers

Matt Austern:namespace prefix = o ns = "urn:schemas--com::office" />

http://www.cuj.com/experts/1907/austern.htm?topic=experts

--------------------------------------------------------------------------------

  找一個方法來建立一個基於的容器?你可能要在標準C++執行庫之外進行尋找,而對映(memory map)可能就是答案。

  標準C++執行庫很容易將檔案I/O與泛型演算法進行組合。對於總是讀出(或寫入)相同的資料型別的結構化檔案,你能將檔案當作一個值序列,並透過istream_iterator(或ostream_iterator) 進行訪問。當將檔案當作字元序列時,你能用istreambuf_iterator(或ostreambuf_iterator)來訪問它。於是,舉例來說,你能將treambuf_iterator與一個STL泛型演算法組合以“y to k”的轉換: 

std::ifstream in("f1");

std::ofstream out("f2");

std::replace_copy

  (std::istreambuf_iterator(in),

  std::istreambuf_iterator(),

  std::ostreabuf_iterator(out),

  'y', 'k');

  當轉向更加複雜的(和較少人為的)例子時,你開始遇到問題。你能夠使用std::find()以尋找字元c第一次出現的位置,但是假設你需要在多位元組字串中進行尋找時怎麼辦?那正是std::search()的功能,但你無法使用它:它的引數必須是Forward Iterator,而istreambuf_iterator只是Input Iterator 。

  這不是一個無端的限制。想一下你會如何實現std::search()。如果你正在一個輸入序列中尋找一個字串,並發現一個部分匹配,你不能只是簡單地在你第一次注意到失配的地方重新開始:通常,你必須後退一些字元再重新開始。(考慮一下在“abababac”裡尋找 “ababac”。)有聰明的演算法來避免不必要的回退,但不能完全避免回退。儲存“書籤”這件事正好是你使用類似於istreambuf_iterator這樣的 Input Iterator所不能完成的工作:input每次產生一個字元,而你一旦讀了一個字元,你就已經讀出它了。

  這是I/O的通病:它時常造成你直到已經讀出太多否則就無法知道已經讀夠了。如果你正在讀一個整數,而且輸入包含“123x”,直到你已經消耗了不期望的字元“x”,否則你不能知道已經達到整數的結尾。也有一些常見的解決方法。

  第一種,你可以改造演算法--透過使用輔助區,或改詢問稍有不同的問題,或接受在引數或錯誤處理上作出限制--使得它不再要求比Input Iteator更多的語義要求。舉例來說,標準C++執行庫中的std::time_get(),讀day和month時使用一個基於Input Iterator的字串匹配版本。它的靈活性或健壯性不如std::search(),但對它的目的來說足夠了。

  第二種,你可以簡單地讀出比所需更多的字元,然後將不期望的字元再放回輸入流。如果正在透過streambuf讀出字元,你可以使用 streambuf::sputbackc()以“unread”一個字元。這個技巧通常很容易和高效,但有一些限制(你不能依賴於能放回多於一個字元),而且沒有方法將它整合到istream_iterator或istreambuf_iterator的iterator介面中。

  第三種,你能將輸入檔案的一部分讀到一個內部的buffer,然後對這個buffer進行處理而不再是直接面對輸入流。你必須在跨越buffer的邊界時非常小心,但是那不總是必需的:比如,常可能每次只處理一行。

  所有這些技術都是有用的,但從將I/O與泛型演算法進行組合的方面看,沒有一個令人完全滿意。它們全部都需要演算法的變化,其中之一甚至讓你不能使用iterator的介面。你也許能夠將基於putback的演算法表述得與基於Forward Iterator的演算法一模一樣,但你不得不將那個演算法寫兩次。

  使用內部buffer很容易,如果你不需要跨越buffer邊界的話;並且,如果使用一個足夠大得包容整個檔案的buffer的話,你就能肯定這一點。這隻需要很少幾行程式碼:

std::ifstream in("f1");

std::istreambuf_iterator first

  (in);

std::istreambuf_iterator last;

std::vector buf(first, last);

  這個buf提供了Ran Iterator。但是這個技術也不是很令人滿意:在f1是個小檔案時很好,一旦檔案變大了就不可行了。

  位於上的檔案 (在絕大多數的操作上)只是字元序列--看起來很象一個容器。我們為什麼不能找個法子把它表述成一個容器,而不需要將所有東西都讀到記憶體?

記憶體對映

我們當然能。現代的提供了比C或C++標準庫中可用的更豐富的I/O原語集。其中的一些可被改造得適合C++執行庫的。

現今絕大多數的作業系統允許memory-mapped I/O:將一個檔案與一塊記憶體關聯在一起,於是對這塊記憶體讀出(寫入)將被轉換為對檔案的讀出(些入)。對而言,這塊記憶體和其它的記憶體看起來是一樣的。你用一個指標指向它,並可對它使用任何通常指標所能作的操作。反引用一個指向memory-mapped區域內部的指標將得到檔案中相應的值,對它寫操作將改變檔案中相應的值。

  不應該驚訝於這樣做是可能的:它和操作虛擬記憶體的方法沒有太大的不同。當你在程式中對一個地址進行寫操作時,它有時轉換到一個實體記憶體的地址,有時則指向已被頁切換到磁碟的記憶體。作業系統將記憶體頁從實體記憶體換入換出,並不需要程式設計師的干涉。你可以將記憶體對映考慮成暴露了這此中部分細節的一個介面。

  為什麼作業系統提供了記憶體對映?根本性的答案是。

l  記憶體對映可以節約空間。將檔案對映入記憶體可以讓你以好象已經將它讀到一個陣列一樣來訪問這個檔案--它是你的程式的記憶體空間的一部分--但你不必為那個陣列分配實體記憶體。

l  記憶體對映可以節省時間。當你使用istream::read()或ostream::write()之類的做普通的檔案I/O時,作業系統使用它自己的和物理關聯的某些內部buffer,但是然後必須要在這個buffer和我們的程式中的另外一個buffer間進行複製。記憶體對映避免了這個額外的複製過程,因為它暴露了作業系統內部的buffer。對簡單的檔案複製過程作的測試中,Stevens[注1]發現記憶體對映有時能提供factor-of-two的提速。

  除了效能,memory-mapped I/O還給了我們簡單的類似於容器的介面,而這正是我們想要的。記憶體對映讓我們透過指標訪問檔案中的內容。指標是iterator,於是我們必須要做的就是寫一個包裝類,封裝記憶體對映並提供滿足容器語法需求的介面(C++標準的Table 65--加上Table 66,因為我們的容器將提供隨機訪問的iterator)。

  Table 65中的第一個需求是每個容器類X必須包含一個typedef,X::value_type,它指明瞭容器中元素的型別。我們應該為現在這個容器選擇什麼樣的value_type?回答可能是,根據std::vector和std::list的例子,我們應該透過寫成模板化的容器類而允許完全一般的value_type。然而,對於本例,這個選擇將會帶來問題。

l  具有nontrivial建構函式的類將會是問題。畢竟,建構函式的賣點就是不能將看作位元組序列。舉例來說,通常,你不能用memcpy()複製物件。

l  即使拋開建構函式,含有指標的類也是問題。當你記憶體對映一個檔案時,作業系統選擇基址。當你將一個物件寫到磁碟並將它再次記憶體對映回來時,指標將不再會指向正確的位置。

l  即使是基本資料型別,象long和float,也是問題。基本資料型別的二進位制表示隨系統變化而不同;將float以原生位元組序列的形式寫入磁碟,然後在不同的系統上再將它讀出來,是不太可能給你正確答案的。

  當然,所有這些問題可以並且已經被解決:關鍵在於將物件存入基於memory-mapped的容器時要某種序列化(比如,用偏移量代替指標) ,不是隻是位元組對位元組的複製。但序列化和檔案I/O是分開的議題;在低層的I/O庫之上構建持久儲存體系更有意義,而不是將它們混在一起並圍繞序列化設計整個庫。檔案是一個位元組序列,所以這就是我們的容器將要提供的。它將是一個非模板類,它的value_type將是char。

  下一個問題是怎麼實現讀寫操作。當開啟一個檔案的時候,你提供一個標誌以明確read/write還是read-only方式。這個區別並不在型別上反映出來:你能試圖寫一個以只讀方式開啟的檔案,而這個嘗試將會在執行期失敗。然而,對於容器,這不是一個合理的選擇。容器提供iterator,而iterator要麼是mutable的要麼是const的。我們不能提供指向只讀檔案內部的mutable的iterator。

  也就是說,如果我們同時支援只讀和讀/寫方式,就需要兩個具有不同型別申明的類。現在,我只討論只讀的類,ro_file。

  一旦我們已經作出這些決斷,就很容易滿足Table 65所要求的每一件事了。首先是typedef:ro_file::value_type是char,ro_file::iterator和ro_file::const_iterator都是const char *,ro_file::reverse_iterator是std::reverse_iterator,等等。下一步是提供資料訪問。我們儲存一個指向檔案開始處的指標(在將檔案對映入記憶體時所獲得的基址),檔案的長度;begin()返回基址,end()返回基址+長度。類的完整申明在Listing 1中。

  所有的實際工作都在建構函式中完成,其它的成員函式都是建立在私有的成員變數base和length上的。這些成員函式都不是內聯的,所以它們沒有出現在類申明中。這些成員函式也是高度系統依賴的。 和(以及其它作業系統)支援記憶體對映,但是它們是以不同的方式支援的。我將展示一個基於Unix的實現,它在下測試過。

  通常的主意很簡單:建構函式接受一個檔名,用低層的系統呼叫open()開啟檔案,找到檔案的長度,然後將整個檔案用mmap()對映入記憶體。所有的這些操作都可能失敗,因此我們檢查返回值並在需要時丟擲一個異常。最後,我們關閉檔案。在Unxi下,一個被記憶體對映的檔案不需要一直保持開啟:對映不會丟失,除非你呼叫munmap()。

  唯一的麻煩是賦值和複製。它們應該是什麼語義?回答可能是賦值應該用一個檔案的內容替換另一個,複製構造應該建立並開啟一個新檔案。但這些回答不十分正確:在其它方面都是隻讀的類裡有一個可變的操作將會很令人奇怪,更怪的是你有一個檔案複製操作卻還不能命名為copy()。我們將會提供複製和賦值--但複製和賦值的是控制程式碼而不是檔案。當複製一個ro_file的時候,我們將會第二次對映一個檔案。它們將會有相同的內容。(這接近了標準對容器的需求所允許的邊緣,因為我們沒被准許在兩個不同的容器之間共享相同的物件,但是它確實幾乎不合法。我們的容器是不可修改的,其value_type是char;沒辦法指出兩個不同的不可修改的char物件與一個不可修改的char物件卻有兩個不同的地址這兩種情況間的區別。)

  完整的Unix下的實現見於Listing 2。

限制

記憶體對映是一個有用的技術,但是它不總是合適。

ro_file最明顯的限制就在名字上:它是隻讀的。rw_file類看起來像什麼?不完全清楚。賦值和複製可能必須被改變:對不可變的容器來說將相同的檔案對映兩次是合理的,但對可變的容器來說就不怎麼合理了。(如果改變容器C1中的一個值將導致看起來無關的容器C2中發生改變,這將會非常令人奇怪。)同樣不清楚rw_file有多大用處。我們可以使用ro_file避免在從一個檔案讀出時對Input Iterator的限制,並避免核心空間與空間之間的額外複製的開銷,但這兩點對輸出沒有對輸入時相同的強制。

  第二個限制比較不明顯。Unix程式設計師習慣於把一個檔案當做簡單的字元序列,並且習慣於把一行當作就是兩個“n”字元之間的區域,但生活不總是這麼簡單。一些作業系統的文字檔案有著更多的結構。 (比如,一些大型機作業系統用一個固定的行長來支援文字檔案。) C++標準,與它之前的C標準一樣,區別以文字方式開啟檔案和以二進位方式開啟檔案;以文字方式開啟檔案意味著標準執行庫自動考慮這些格式上的需求。

  當記憶體對映一個檔案時,你將精確獲得被儲存在磁碟上的位元組序列。也就是說,記憶體對映有力地限制在二進位制的I/O上。如果期望一個檔案被以文字方式開啟,你就必須理解你的作業系統如何處理文字檔案的[注2],並且必須自己做轉換。

  第三,這個類沒有使用記憶體對映的所有功能。特別地,它不支援使用記憶體對映完成程式間通訊的常見技術。

  最後,記憶體對映的錯誤通報很不友好。既然I/O操作被寫為指標反引用,I/O錯誤就表現為記憶體訪問違例。這對輸出的影響比輸入多,但即使是輸入也會失敗--比如,設想一下,當你從檔案讀出時,其它程式開啟並截短了它。記憶體對映最適合於你知道這種情形不會出現或你能捕獲表現為signal的錯誤的情況。(寫一個類以封裝指標的反引用、捕獲signal並將它們轉換為C++異常,是可能的。然而,開銷將構成限制。如果你需要這種形式的錯誤通報,你最好使用memory-maped I/O以外的東西。) 

總結

C++標準執行庫中的檔案I/O使用了一個簡單的讀寫模型。對於許多目的而言,這個簡單模型足夠了。然而,現代的作業系統提供了更豐富的操作集:asynchronous I/O,signal-driven I/O, multiplexing,memory mapping。 所有這些技術都被實際的程式使用,但它們全都不在標準C++執行庫之內。

記憶體對映是一個適應標準C++執行庫框架的技術,因為被記憶體對映的檔案看起來非常象一個容器--並且,對很多泛型演算法,把檔案當作容器比每次訪問其中一個字元方便多了。如何在C++執行庫的架構下支援其它種類的高階I/O是一個未決問題。

Listing 1: Header file for class ro_file

#include

#include

#include

#include

 

class ro_file {

public:

  ro_file(const std::string& name);

  ro_file(const ro_file&);

  ro_file& operator=(const ro_file&);

  ~ro_file();

 

public:

  typedef char  value_type;

  typedef const char* pointer;

  typedef const char* const_pointer;

  typedef const char& reference;

  typedef const char& const_reference;

 

  typedef std::ptrdiff_t difference_type;

  typedef std::size_t size_type;

 

  typedef const char* iterator;

  typedef const char* const_iterator;

  typedef std::reverse_iterator

  reverse_iterator;

  typedef std::reverse_iterator

  const_reverse_iterator;

 

  const_iterator begin() const { return base; }

  const_iterator end() const { return base + length; }

 

  const_reverse_iterator rbegin() const

  { return const_reverse_iterator(end()); }

  const_reverse_iterator rend() const

   { return const_reverse_iterator(begin()); }

 

  const_reference operator[](size_type n) const {

  return base[n];

  }

  const_reference at(size_type n) const {

  if (n >= size())

  throw std::out_of_range("ro_file");

  return base[n];

  }

 

  size_type size() const { return length; }

  size_type max_size() const { return length; }

  bool empty() const { return size() != 0; }

 

  void s(ro_file&);

 

private:

  std::string file;

  char* base;

  size_type length;

};

 

bool operator==(const ro_file&, const ro_file&);

bool operator

 

inline bool operator!=(const ro_file& x, const ro_file& y)

  { return !(x == y); }

inline bool operator> (const ro_file& x, const ro_file& y)

  { return y < x; }

inline bool operator<=(const ro_file& x, const ro_file& y)

  { return !(y < x); }

inline bool operator>=(const ro_file& x, const ro_file& y)

  { return !(x < y); }

— End of Listing —

Listing 2: Unix implementation of class ro_file

#include "ro_file.h"

#include

#include

#include

#include

#include

#include

#include

#include

 

namespace {

  std::pair initialize(const std::string& name)

  {

  int fd = open(name.c_str(), O_RDONLY);

  if (fd == -1)

  throw std::runtime_error("Can't open " + name + ": "

  + strerror(errno));

 

  off_t n = lseek(fd, 0, SEEK_END);

  void* p = MAP_FAILED;

  if (n != off_t(-1))

  p = mmap(0, (std::size_t) n, PROT_READ, MAP_PRIVATE, fd, 0);

 

  close(fd);

 

  if (p == MAP_FAILED)

  throw std::runtime_error("Can't map " + name + ": "

  + strerror(errno));

 

  return std::make_pair(static_cast(p),

  static_cast<:size_t>(n));

  }

}

 

ro_file::ro_file(const std::string& name)

  : file(name), base(0), length(0)

{

  std::pair p = initialize(file);

  base = p.first;

  length = p.second;

}

 

ro_file::ro_file(const ro_file& C)

  : file(C.file), base(0), length(0)

{

  std::pair p = initialize(file);

  base = p.first;

  length = p.second;

}

 

ro_file& ro_file::operator=(const ro_file& C)

{

  if (C != *this) {

  std::string tmp = C.file;

  std::pair p = initialize(C.file);

 

  munmap(base, length);

  file.swap(tmp);

  base = p.first;

  length = p.second;

  }

 

  return *this;

}

 

ro_file::~ro_file()

{

  munmap(base, length);

}

 

void ro_file::swap(ro_file& C)

{

  std::swap(file, C.file);

  std::swap(base, C.base);

  std::swap(length, C.length);

}

 

bool operator==(const ro_file& x, const ro_file& y) {

  return x.size() == y.size() &&

  std::equal(x.begin(), x.end(), y.begin());

}

 

bool operator

  return std::lexicographical_compare(x.begin(), x.end(),

  y.begin(), y.end());

}

— End of Listing —

 

注和參考

[1] W. Richard Stevens. Advanced Programming in the Unix Environment (Addison-Wesley, 1992).

 

[2] Fortunately, the answer is simple in some popular operating systems. In Unix, there is no distinction between text and binary files, and in Windows the only issue to worry about is that, if you open a text file in binary mode, you’ll see that the lines end with the two-character sequence "rn".

 


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

相關文章