C++ 工程實踐(7):iostream 的用途與侷限

weixin_34219944發表於2011-07-17

陳碩 (giantchen_AT_gmail)

http://blog.csdn.net/Solstice  http://weibo.com/giantchen

陳碩關於 C++ 工程實踐的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx

陳碩部落格文章合集下載: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx

本作品採用“Creative Commons 署名-非商業性使用-禁止演繹 3.0 Unported 許可協議(cc by-nc-nd)”進行許可。http://creativecommons.org/licenses/by-nc-nd/3.0/

本文主要考慮 x86 Linux 平臺,不考慮跨平臺的可移植性,也不考慮國際化(i18n),但是要考慮 32-bit 和 64-bit 的相容性。本文以 stdio 指代 C 語言的 scanf/printf 系列格式化輸入輸出函式。本文注意區分“程式設計初學者”和“C++初學者”,二者含義不同。

摘要:C++ iostream 的主要作用是讓初學者有一個方便的命令列輸入輸出試驗環境,在真實的專案中很少用到 iostream,因此不必把精力花在深究 iostream 的格式化與 manipulator。iostream 的設計初衷是提供一個可擴充套件的型別安全的 IO 機制,但是後來莫名其妙地加入了 locale 和 facet 等累贅。其整個設計複雜不堪,多重+虛擬繼承的結構也很巴洛克,效能方面幾無亮點。iostream 在實際專案中的用處非常有限,為此投入過多學習精力實在不值。

stdio 格式化輸入輸出的缺點

1. 對程式設計初學者不友好

看看下面這段簡單的輸入輸出程式碼。

#include <stdio.h>

int main()
{
  int i;
  short s;
  float f;
  double d;
  char name[80];

  scanf("%d %hd %f %lf %s", &i, &s, &f, &d, name);
  printf("%d %d %f %f %s", i, s, f, d, name);
}

注意到其中

  • 輸入和輸出用的格式字串不一樣。輸入 short 要用 %hd,輸出用 %d;輸入 double 要用 %lf,輸出用 %f。
  • 輸入的引數不統一。對於 i、s、f、d 等變數,在傳入 scanf() 的時候要取地址(&),而對於 name,則不用取地址。

讀者可以試一試如何用幾句話向剛開始學程式設計的初學者解釋上面兩條背後原因(涉及到傳遞函式不定引數時的型別轉換,函式呼叫棧的記憶體佈局,指標的意義,字元陣列退化為字元指標等等),如果一開始解釋不清,只好告訴學生“這是規定”。

  • 緩衝區溢位的危險。上面的例子在讀入 name 的時候沒有指定大小,這是用 C 語言程式設計的安全漏洞的主要來源。應該在一開始就強調正確的做法,避免養成錯誤的習慣。正確而安全的做法如 Bjarne Stroustrup 在《Learning Standard C++ as a New Language》所示:
#include <stdio.h>

int main()
{
  const int max = 80;
  char name[max];

  char fmt[10];
  sprintf(fmt, "%%%ds", max - 1);
  scanf(fmt, name);
  printf("%s\n", name);
}

這個動態構造格式化字串的做法恐怕更難向初學者解釋。

2. 安全性(security)

C 語言的安全性問題近十幾年來引起了廣泛的注意,C99 增加了 snprintf() 等能夠指定輸出緩衝區大小的函式,輸出方面的安全性問題已經得到解決;輸入方面似乎沒有太大進展,還要靠程式設計師自己動手。

考慮一個簡單的程式設計任務:從檔案或標準輸入讀入一行字串,行的長度不確定。我發現沒有哪個 C 語言標準庫函式能完成這個任務,除非 roll your own。

首先,gets() 是錯誤的,因為不能指定緩衝區的長度。

其次,fgets() 也有問題。它能指定緩衝區的長度,所以是安全的。但是程式必須預設一個長度的最大值,這不滿足題目要求“行的長度不確定”。另外,程式無法判斷 fgets() 到底讀了多少個位元組。為什麼?考慮一個檔案的內容是 9 個位元組的字串 "Chen\000Shuo",注意中間出現了 '\0' 字元,如果用 fgets() 來讀取,客戶端如何知道 "\000Shuo" 也是輸入的一部分?畢竟 strlen() 只返回 4,而且整個字串裡沒有 '\n' 字元。

最後,可以用 glibc 定義的 getline(3) 函式來讀取不定長的“行”。這個函式能正確處理各種情況,不過它返回的是 malloc() 分配的記憶體,要求呼叫端自己 free()。

3. 型別安全(type-safe)

如果 printf() 的整數引數型別是 int、long 等標準型別, 那麼 printf() 的格式化字串很容易寫。但是如果引數型別是 typedef 的型別呢?

如果你想在程式中用 printf 來列印日誌,你能一眼看出下面這些型別該用 "%d" "%ld" "%lld" 中的哪一個來輸出?你的選擇是否同時相容 32-bit 和 64-bit 平臺?

  • clock_t。這是 clock(3) 的返回型別
  • dev_t。這是 mknod(3) 的引數型別
  • in_addr_t、in_port_t。這是 struct sockaddr_in 的成員型別
  • nfds_t。這是 poll(2) 的引數型別
  • off_t。這是 lseek(2) 的引數型別,麻煩的是,這個型別與巨集定義 _FILE_OFFSET_BITS 有關。
  • pid_t、uid_t、gid_t。這是 getpid(2) getuid(2) getgid(2) 的返回型別
  • ptrdiff_t。printf() 專門定義了 "t" 字首來支援這一型別(即使用 "%td" 來列印)。
  • size_t、ssize_t。這兩個型別到處都在用。printf() 為此專門定義了 "z" 字首來支援這兩個型別(即使用 "%zu" 或 "%zd" 來列印)。
  • socklen_t。這是 bind(2) 和 connect(2) 的引數型別
  • time_t。這是 time(2) 的返回型別,也是 gettimeofday(2) 和 clock_gettime(2) 的輸出結構體的成員型別

如果在 C 程式裡要正確列印以上型別的整數,恐怕要費一番腦筋。《The Linux Programming Interface》的作者建議(3.6.2節)先統一轉換為 long 型別再用 "%ld" 來列印;對於某些型別仍然需要特殊處理,比如 off_t 的型別可能是 long long。

還有,int64_t 在 32-bit 和 64-bit 平臺上是不同的型別,為此,如果程式要列印 int64_t 變數,需要包含 <inttypes.h> 標頭檔案,並且使用 PRId64 巨集:

#include <stdio.h>
#define __STDC_FORMAT_MACROS
#include <inttypes.h>

int main()
{
  int64_t x = 100;
  printf("%" PRId64 "\n", x);
  printf("%06" PRId64 "\n", x);
}

muduo 的 Timestamp 使用了 PRId64 http://code.google.com/p/muduo/source/browse/trunk/muduo/base/Timestamp.cc#25

Google C++ 編碼規範也提到了 64-bit 相容性: http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#64-bit_Portability

這些問題在 C++ 裡都不存在,在這方面 iostream 是個進步。

C stdio 在型別安全方面原本還有一個缺點,即格式化字串與引數型別不匹配會造成難以發現的 bug,不過現在的編譯器已經能夠檢測很多這種錯誤:

int main()
{
  double d = 100.0;
  // warning: format '%d' expects type 'int', but argument 2 has type 'double'
  printf("%d\n", d);

  short s;
  // warning: format '%d' expects type 'int*', but argument 2 has type 'short int*'
  scanf("%d", &s);

  size_t sz = 1;
  // no warning
  printf("%zd\n", sz);
}

4. 不可擴充套件?

C stdio 的另外一個缺點是無法支援自定義的型別,比如我寫了一個 Date class,我無法像列印 int 那樣用 printf 來直接列印 Date 物件。

struct Date
{
  int year, month, day;
};

Date date;
printf("%D", &date);  // WRONG

Glibc 放寬了這個限制,允許使用者呼叫 register_printf_function(3) 註冊自己的型別,當然,前提是與現有的格式字元不衝突(這其實大大限制了這個功能的用處,現實中也幾乎沒有人真的去用它)。http://www.gnu.org/s/hello/manual/libc/Printf-Extension-Example.html  http://en.wikipedia.org/wiki/Printf#Custom_format_placeholders

5. 效能

C stdio 的效能方面有兩個弱點。

  1. 使用一種 little language (現在流行叫 DSL)來配置格式。固然有利於緊湊性和靈活性,但損失了一點點效率。每次列印一個整數都要先解析 "%d" 字串,大多數情況下不是問題,某些場合需要自己寫整數到字串的轉換。
  2. C locale 的負擔。locale 指的是不同語種對“什麼是空白”、“什麼是字母”,“什麼是小數點”有不同的定義(德語裡邊小數點是逗號,不是句點)。C 語言的 printf()、scanf()、isspace()、isalpha()、ispunct()、strtod() 等等函式都和 locale 有關,而且可以在執行時動態更改。就算是程式只使用預設的 "C" locale,任然要為這個靈活性付出代價。

iostream 的設計初衷

iostream 的設計初衷包括克服 C stdio 的缺點,提供一個高效的可擴充套件的型別安全的 IO 機制。“可擴充套件”有兩層意思,一是可以擴充套件到使用者自定義型別,而是通過繼承 iostream 來定義自己的 stream,本文把前一種稱為“型別可擴充套件”後一種稱為“功能可擴充套件”。

“型別可擴充套件”和“型別安全”都是通過函式過載來實現的。

iostream 對初學者很友好,用 iostream 重寫與前面同樣功能的程式碼:

#include <iostream>
#include <string>
using namespace std;

int main()
{
  int i;
  short s;
  float f;
  double d;
  string name;

  cin >> i >> s >> f >> d >> name;
  cout << i << " " << s << " " << f << " " << d << " " << name << endl;
}

這段程式碼恐怕比 scanf/printf 版本容易解釋得多,而且沒有安全性(security)方面的問題。

我們自己的型別也可以融入 iostream,使用起來與 built-in 型別沒有區別。這主要得力於 C++ 可以定義 non-member functions/operators。

#include <ostream>  // 是不是太重量級了?

class Date
{
 public:
  Date(int year, int month, int day)
    : year_(year), month_(month), day_(day)
  {
  }

  void writeTo(std::ostream& os) const
  {
    os << year_ << '-' << month_ << '-' << day_;
  }

 private:
  int year_, month_, day_;
};

std::ostream& operator<<(std::ostream& os, const Date& date)
{
  date.writeTo(os);
  return os;
}

int main()
{
  Date date(2011, 4, 3);
  std::cout << date << std::endl;
  // 輸出 2011-4-3
}

iostream 憑藉這兩點(型別安全和型別可擴充套件),基本克服了 stdio 在使用上的不便與不安全。如果 iostream 止步於此,那它將是一個非常便利的庫,可惜它前進了另外一步。

iostream 與標準庫其他元件的互動

不同於標準庫其他 class 的“值語意”,iostream 是“物件語意”,即 iostream 是 non-copyable。這是正確的,因為如果 fstream 代表一個檔案的話,拷貝一個 fstream 物件意味著什麼呢?表示開啟了兩個檔案嗎?如果銷燬一個 fstream 物件,它會關閉檔案控制程式碼,那麼另一個 fstream copy 物件會因此受影響嗎?

C++ 同時支援“資料抽象”和“物件導向程式設計”,其實主要就是“值語意”與“物件語意”的區別,我發現不是每個人都清楚這一點,這裡多說幾句。標準庫裡的 complex<> 、pair<>、vector<>、 string 等等都是值語意,拷貝之後就與原物件脫離關係,就跟拷貝一個 int 一樣。而我們自己寫的 Employee class、TcpConnection class 通常是物件語意,拷貝一個 Employee 物件是沒有意義的,一個僱員不會變成兩個僱員,他也不會領兩份薪水。拷貝 TcpConnection 物件也沒有意義,系統裡邊只有一個 TCP 連線,拷貝 TcpConnection  物件不會讓我們擁有兩個連線。因此如果在 C++ 裡做物件導向程式設計,寫的 class 通常應該禁用 copy constructor 和 assignment operator,比如可以繼承 boost::noncopyable。物件語意的型別不能直接作為標準容器庫的成員。另一方面,如果要寫一個圖形程式,其中用到三維空間的向量,那麼我們可以寫 Vector3D class,它應該是值語意的,允許拷貝,並且可以用作標準容器庫的成員,例如 vector<Vector3D> 表示一條三維的折線。

C stdio 的另外一個缺點是 FILE* 可以隨意拷貝,但是隻要關閉其中一個 copy,其他 copies 也都失效了,跟空懸指標一般。這其實不光是 C stdio 的缺點,整個 C 語言對待資源(malloc 得到的記憶體,open() 開啟的檔案,socket() 開啟的連線)都是這樣,用整數或指標來代表(即“控制程式碼”)。而整數和指標型別的“控制程式碼”是可以隨意拷貝的,很容易就造成重複釋放、遺漏釋放、使用已經釋放的資源等等常見錯誤。這是因為 C 語言錯誤地讓“物件語言”的東西變成了值語意。

iostream 禁止拷貝,利用物件的生命期來明確管理資源(如檔案),很自然地就避免了 C 語言易犯的錯誤。這就是 RAII,一種重要且獨特的 C++ 程式設計手法。

std::string

iostream 可以與 string 配合得很好。但是有一個問題:誰依賴誰?

std::string 的 operator << 和 operator >> 是如何宣告的?"string" 標頭檔案在宣告這兩個 operators 的時候要不要 include "iostream" ?

iostream 和 string 都可以單獨 include 來使用,顯然 iostream 標頭檔案裡不會定義 string 的 << 和 >> 操作。但是,如果"string"要include "iostream",豈不是讓 string 的使用者被迫也用了 iostream?編譯 iostream 標頭檔案可是相當的慢啊(因為 iostream 是 template,其實現程式碼都放到了標頭檔案中)。

標準庫的解決辦法是定義 iosfwd 標頭檔案,其中包含 istream 和 ostream 等的前向宣告 (forward declarations),這樣 "string" 標頭檔案在定義輸入輸出操作符時就可以不必包含 "iostream",只需要包含簡短得多的 "iosfwd"。我們自己寫程式也可藉此學習如何支援可選的功能。

值得注意的是,istream::getline() 成員函式的引數型別是 char*,因為 "istream" 沒有包含 "string",而我們常用的 std::getline() 函式是個 non-member function,定義在 "string" 裡邊。

std::complex

標準庫的複數類 complex 的情況比較複雜。使用 complex 會自動包含 sstream,後者會包含 istream 和 ostream,這是個不小的負擔。問題是,為什麼?

它的 operator >> 操作比 string 複雜得多,如何應對格式不正確的情況?輸入字串不會遇到格式不正確,但是輸入一個複數可能遇到各種問題,比如數字的格式不對等。我懷疑有誰會真的在產品專案裡用 operator >> 來讀入字元方式表示的複數,這樣的程式碼的健壯性如何保證。基於同樣的理由,我認為產品程式碼中應該避免用 istream 來讀取帶格式的內容,後面也不再談 istream 的缺點,它已經被秒殺。

它的 operator << 也很奇怪,它不是直接使用引數 ostream& os 物件來輸出,而是先構造 ostringstream,輸出到該 string stream,再把結果字串輸出到 ostream。簡化後的程式碼如下:

template<typename T>
std::ostream& operator<<(std::ostream& os, const std::complex<T>& x)
{
  std::ostringstream s;
  s << '(' << x.real() << ',' << x.imag() << ')';
  return os << s.str();
}

注意到 ostringstream 會用到動態分配記憶體,也就是說,每輸出一個 complex 物件就會分配釋放一次記憶體,效率堪憂。

根據以上分析,我認為 iostream 和 complex 配合得不好,但是它們耦合得更緊密(與 string/iostream 相比),這可能是個不得已的技術限制吧(complex 是 template,其 operator<< 必須在標頭檔案中定義,而這個定義又用到了 ostringstream,不得已包含了 iostream 的實現)。

如果程式要對 complex 做 IO,從效率和健壯性方面考慮,建議不要使用 iostream。

iostream 在使用方面的缺點

在簡單使用 iostream 的時候,它確實比 stdio 方便,但是深入一點就會發現,二者可說各擅勝場。下面談一談 iostream 在使用方面的缺點。

1. 格式化輸出很繁瑣

iostream 採用 manipulator 來格式化,如果我想按照 2010-04-03 的格式輸出前面定義的 Date class,那麼程式碼要改成:

--- 02-02.cc    2011-07-16 16:40:05.000000000 +0800
+++ 04-01.cc    2011-07-16 17:10:27.000000000 +0800
@@ -1,4 +1,5 @@
 #include <iostream>
+#include <iomanip>

 class Date
 {
@@ -10,7 +11,9 @@

   void writeTo(std::ostream& os) const
   {
-    os << year_ << '-' << month_ << '-' << day_;
+    os << year_ << '-'
+       << std::setw(2) << std::setfill('0') << month_ << '-'
+       << std::setw(2) << std::setfill('0') << day_;
   }

  private:

假如用 stdio,會簡短得多,因為 printf 採用了一種表達能力較強的小語言來描述輸出格式。

--- 04-01.cc    2011-07-16 17:03:22.000000000 +0800
+++ 04-02.cc    2011-07-16 17:04:21.000000000 +0800
@@ -1,5 +1,5 @@
 #include <iostream>
-#include <iomanip>
+#include <stdio.h>

 class Date
 {
@@ -11,9 +11,9 @@

   void writeTo(std::ostream& os) const
   {
-    os << year_ << '-' << month_ << '-' << day_;
+    char buf[32];
+    snprintf(buf, sizeof buf, 

"%d-%02d-%02d"

, year_, month_, day_);
+    os << buf;
   }

  private:

使用小語言來描述格式還帶來另外一個好處:外部可配置。

2. 外部可配置性

比方說,我想用一個外部的配置檔案來定義日期的格式。C stdio 很好辦,把格式字串 "%d-%02d-%02d" 儲存到配置裡就行。但是 iostream 呢?它的格式是寫死在程式碼裡的,靈活性大打折扣。

再舉一個例子,程式的 message 的多語言化。

  const char* name = "Shuo Chen";
  int age = 29;
  printf("My name is %1$s, I am %2$d years old.\n", name, age);
  cout << "My name is " << name << ", I am " << age << " years old." << endl;

對於 stdio,要讓這段程式支援中文的話,把程式碼中的"My name is %1$s, I am %2$d years old.\n",

替換為 "我叫%1$s,今年%2$d歲。\n" 即可。也可以把這段提示語做成資原始檔,在執行時讀入。而對於 iostream,恐怕沒有這麼方便,因為程式碼是支離破碎的。

C stdio 的格式化字串體現了重要的“資料就是程式碼”的思想,這種“資料”與“程式碼”之間的相互轉換是程式靈活性的根源,遠比 OO 更為靈活。

3. stream 的狀態

如果我想用 16 進位制方式輸出一個整數 x,那麼可以用 hex 操控符,但是這會改變 ostream 的狀態。比如說

  int x = 8888;
  cout << hex << showbase << 

x

 << endl;  // forgot to reset state
  cout << 123 << endl;

這這段程式碼會把 123 也按照 16 進位制方式輸出,這恐怕不是我們想要的。

再舉一個例子,setprecision() 也會造成持續影響:

  double d = 123.45;
  printf("%8.3f\n", d);
  cout << d << endl;
  cout << setw(8) << fixed << setprecision(3) << d << endl;
  cout << d << endl;

輸出是:

$ ./a.out
 123.450
123.45    # default cout format
 123.450  # our format
123.450   # side effects

可見程式碼中的 setprecision() 影響了後續輸出的精度。注意 setw() 不會造成影響,它只對下一個輸出有效。

這說明,如果使用 manipulator 來控制格式,需要時刻小心防止影響了後續程式碼。而使用 C stdio 就沒有這個問題,它是“上下文無關的”。

4. 知識的通用性

在 C 語言之外,有其他很多語言也支援 printf() 風格的格式化,例如 Java、Perl、Ruby 等等 (http://en.wikipedia.org/wiki/Printf#Programming_languages_with_printf)。學會 printf() 的格式化方法,這個知識還可以用到其他語言中。但是 C++ iostream 只此一家別無分店,反正都是格式化輸出,stdio 的投資回報率更高。

基於這點考慮,我認為不必深究 iostream 的格式化方法,只需要用好它最基本的型別安全輸出即可。在真的需要格式化的場合,可以考慮 snprintf() 列印到棧上緩衝,再用 ostream 輸出。

5. 執行緒安全與原子性

iostream 的另外一個問題是執行緒安全性。stdio 的函式是執行緒安全的,而且 C 語言還提供了 flockfile(3)/funlockfile(3) 之類的函式來明確控制 FILE* 的加鎖與解鎖。

iostream 線上程安全方面沒有保證,就算單個 operator<< 是執行緒安全的,也不能保證原子性。因為 cout << a << b; 是兩次函式呼叫,相當於 cout.operator<<(a).operator<<(b)。兩次呼叫中間可能會被打斷進行上下文切換,造成輸出內容不連續,插入了其他執行緒列印的字元。

而 fprintf(stdout, "%s %d", a, b); 是一次函式呼叫,而且是執行緒安全的,列印的內容不會受其他執行緒影響。

因此,iostream 並不適合在多執行緒程式中做 logging。

iostream 的侷限

根據以上分析,我們可以歸納 iostream 的侷限:

  • 輸入方面,istream 不適合輸入帶格式的資料,因為“糾錯”能力不強,進一步的分析請見孟巖寫的《契約思想的一個反面案例》,孟巖說“複雜的設計必然帶來複雜的使用規則,而面對複雜的使用規則,使用者是可以投票的,那就是你做你的,我不用!”可謂鞭辟入裡。如果要用 istream,我推薦的做法是用 getline() 讀入一行資料,然後用正規表示式來判斷內容正誤,並做分組,然後用 strtod/strtol 之類的函式做型別轉換。這樣似乎更容易寫出健壯的程式。
  • 輸出方面,ostream 的格式化輸出非常繁瑣,而且寫死在程式碼裡,不如 stdio 的小語言那麼靈活通用。建議只用作簡單的無格式輸出。
  • log 方面,由於 ostream 沒有辦法在多執行緒程式中保證一行輸出的完整性,建議不要直接用它來寫 log。如果是簡單的單執行緒程式,輸出資料量較少的情況下可以酌情使用。當然,產品程式碼應該用成熟的 logging 庫,而不要用其它東西來湊合。
  • in-memory 格式化方面,由於 ostringstream 會動態分配記憶體,它不適合效能要求較高的場合。
  • 檔案 IO 方面,如果用作文字檔案的輸入輸出,(i|o)fstream 有上述的缺點;如果用作二進位制資料輸入輸出,那麼自己簡單封裝一個 File class 似乎更好用,也不必為用不到的功能付出代價(後文還有具體例子)。ifstream 的一個用處是在程式啟動時讀入簡單的文字配置檔案。如果配置檔案是其他文字格式(XML 或 JSON),那麼用相應的庫來讀,也用不到 ifstream。
  • 效能方面,iostream 沒有兌現“高效性”諾言。iostream 在某些場合比 stdio 快,在某些場合比 stdio 慢,對於效能要求較高的場合,我們應該自己實現字串轉換(見後文的程式碼與測試)。iostream 效能方面的一個註腳:線上 ACM/ICPC 判題網站上,如果一個簡單的題目發生超時錯誤,那麼把其中 iostream 的輸入輸出換成 stdio,有時就能過關。

既然有這麼多侷限,iostream 在實際專案中的應用就大為受限了,在這上面投入太多的精力實在不值得。說實話,我沒有見過哪個 C++ 產品程式碼使用 iostream 來作為輸入輸出設施。 http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#Streams 

iostream 在設計方面的缺點

iostream 的設計有相當多的 WTFs,stackoverflow 有人吐槽說“If you had to judge by today's software engineering standards, would C++'s IOStreams still be considered well-designed?” http://stackoverflow.com/questions/2753060/who-architected-designed-cs-iostreams-and-would-it-still-be-considered-well

物件導向的設計

iostream 是個物件導向的 IO 類庫,本節簡單介紹它的繼承體系。

對 iostream 略有了解的人會知道它用了多重繼承和虛擬繼承,簡單地畫個類圖如下,是典型的菱形繼承:

simple

如果加深一點了解,會發現 iostream 現在是模板化的,同時支援窄字元和寬字元。下圖是現在的繼承體系,同時畫出了 fstreams 和 stringstreams。圖中方框的第二行是模板的具現化型別,也就是我們程式碼裡常用的具體型別(通過 typedef 定義)。

ios

這個繼承體系糅合了物件導向與泛型程式設計,但可惜它兩方面都不討好。

再進一步加深瞭解,發現還有一個平行的 streambuf 繼承體系,fstream 和 stringstream 的不同之處主要就在於它們使用了不同的 streambuf 具體型別。

buf

再把這兩個繼承體系畫到一幅圖裡:

full

注意到 basic_ios 持有了 streambuf 的指標;而 fstreams 和 stringstreams 則分別包含 filebuf 和 stringbuf 的物件。看上去有點像 Bridge 模式。

看了這樣巴洛克的設計,有沒有人還打算在自己的專案中想通過繼承 iostream 來實現自己的 stream,以實現功能擴充套件麼?

物件導向方面的設計缺陷

本節我們分析一下 iostream 的設計違反了哪些 OO 準則。

我們知道,物件導向中的 public 繼承需要滿足 Liskov 替換原則。(見《Effective C++ 第3版》條款32:確保你的 public 繼承模塑出 is-a 關係。《C++ 程式設計規範》條款 37:public 繼承意味可替換性。繼承非為複用,乃為被複用。)

在程式裡需要用到 ostream 的地方(例如 operator<< ),我傳入 ofstream 或 ostringstream 都應該能按預期工作,這就是 OO 繼承強調的“可替換性”,派生類的物件可以替換基類物件,從而被 operator<< 複用。

iostream 的繼承體系多次違反了 Liskov 原則,這些地方繼承的目的是為了複用基類的程式碼,下圖中我把違規的繼承關係用紅線標出。

correct

在現有的繼承體系中,合理的有:

  • ifstream is-aistream
  • istringstream is-aistream
  • ofstream is-aostream
  • ostringstream is-aostream
  • fstream is-aiostream
  • stringstream is-a iostream

我認為不怎麼合理的有:

  • ios 繼承 ios_base,有沒有哪種情況下程式程式碼期待 ios_base 物件,但是客戶可以傳入一個 ios 物件替代之?如果沒有,這裡用 public 繼承是不是違反 OO 原則?
  • istream 繼承 ios,有沒有哪種情況下程式程式碼期待 ios 物件,但是客戶可以傳入一個 istream 物件替代之?如果沒有,這裡用 public 繼承是不是違反 OO 原則?
  • ostream 繼承 ios,有沒有哪種情況下程式程式碼期待 ios 物件,但是客戶可以傳入一個 ostream 物件替代之?如果沒有,這裡用 public 繼承是不是違反 OO 原則?
  • iostream 多重繼承 istream 和 ostream。為什麼 iostream 要同時繼承兩個 non-interface class?這是介面繼承還是實現繼承?是不是可以用組合(composition)來替代?(見《Effective C++ 第3版》條款38:通過組合模塑出 has-a 或“以某物實現”。《C++ 程式設計規範》條款 34:儘可能以組合代替繼承。)

用組合替換繼承之後的體系:

myown

注意到在新的設計中,只有真正的 is-a 關係採用了 public 繼承,其他均以組合來代替,組合關係以紅線表示。新的設計沒有用的虛擬繼承或多重繼承。

其中 iostream 的新實現值得一提,程式碼結構如下:

class istream;
class ostream;

class iostream
{
 public:
  istream& get_istream();
  ostream& get_ostream();
  virtual ~iostream();
};

這樣一來,在需要 iostream 物件表現得像 istream 的地方,呼叫 get_istream() 函式返回一個 istream 的引用;在需要 iostream 物件表現得像 ostream 的地方,呼叫 get_ostream() 函式返回一個 ostream 的引用。功能不受影響,而且程式碼更清晰。(雖然我非常懷疑 iostream 的真正價值,一個東西既可讀又可寫,說明是個 sophisticated IO 物件,為什麼還用這麼厚的 OO 封裝?)

陽春的 locale

iostream 的故事還不止這些,它還包含一套陽春的 locale/facet 實現,這套實踐中沒人用的東西進一步增加了 iostream 的複雜度,而且不可避免地影響其效能。Nathan Myers 正是始作俑者 http://www.cantrip.org/locale.html

ostream 自身定義的針對整數和浮點數的 operator<< 成員函式的函式體是:

bool failed =
  use_facet<num_put>(getloc()).put(
    ostreambuf_iterator(*this), *this, fill(), val).failed();

它會轉而呼叫 num_put::put(),後者會呼叫 num_put::do_put(),而 do_put() 是個虛擬函式,沒辦法 inline。iostream 在效能方面的不足恐怕部分來自於此。這個虛擬函式白白浪費了把 template 的實現放到標頭檔案應得的好處,編譯和執行速度都快不起來。

我沒有深入挖掘其中的細節,感興趣的同學可以移步觀看 facet 的繼承體系:http://gcc.gnu.org/onlinedocs/libstdc++/libstdc++-html-USERS-4.4/a00431.html

據此分析,我不認為以 iostream 為基礎的上層程式庫(比方說那些克服 iostream 格式化方面的缺點的庫)有多大的實用價值。

臆造抽象

孟巖評價 “ iostream 最大的缺點是臆造抽象”,我非常贊同他老人家的觀點。

這個評價同樣適用於 Java 那一套疊床架屋的 InputStream/OutputStream/Reader/Writer 繼承體系,.NET 也搞了這麼一套繁文縟節。

乍看之下,用 input stream 表示一個可以“讀”的資料流,用 output stream 表示一個可以“寫”的資料流,遮蔽底層細節,面向介面程式設計,“符合物件導向原則”,似乎是一件美妙的事情。但是,真實的世界要殘酷得多。

IO 是個極度複雜的東西,就拿最常見的 memory stream、file stream、socket stream 來說,它們之間的差異極大:

  • 是單向 IO 還是雙向 IO。只讀或者只寫?還是既可讀又可寫?
  • 順序訪問還是隨機訪問。可不可以 seek?可不可以退回 n 位元組?
  • 文字資料還是二進位制資料。格式有誤怎麼辦?如何編寫健壯的處理輸入的程式碼?
  • 有無緩衝。write 500 位元組是否能保證完全寫入?有沒有可能只寫入了 300 位元組?餘下 200 位元組怎麼辦?
  • 是否阻塞。會不會返回 EWOULDBLOCK 錯誤?
  • 有哪些出錯的情況。這是最難的,memory stream 幾乎不可能出錯,file stream 和 socket stream 的出錯情況完全不同。socket stream 可能遇到對方斷開連線,file stream 可能遇到超出磁碟配額。

根據以上列舉的初步分析,我不認為有辦法設計一個公共的基類把各方面的情況都考慮周全。各種 IO 設施之間共性太小,差異太大,例外太多。如果硬要用物件導向來建模,基類要麼太瘦(只放共性,這個基類包含的 interface functions 沒多大用),要麼太肥(把各種 IO 設施的特性都包含進來,這個基類包含的 interface functions 很多,但是不是每一個都能呼叫)。

C 語言對此的解決辦法是用一個 int 表示 IO 物件(file 或 PIPE 或 socket),然後配以 read()/write()/lseek()/fcntl() 等一系列全域性函式,程式設計師自己搭配組合。這個做法我認為比物件導向的方案要簡潔高效。

iostream 在效能方面沒有比 stdio 高多少,在健壯性方面多半不如 stdio,在靈活性方面受制於本身的複雜設計而難以讓使用者自行擴充套件。目前看起來只適合一些簡單的要求不高的應用,但是又不得不為它的複雜設計付出執行時代價,總之其定位有點不上不下。

在實際的專案中,我們可以提煉出一些簡單高效的 strip-down 版本,在獲得便利性的同時避免付出不必要的代價。

一個 300 行的 memory buffer output stream

我認為以 operator<< 來輸出資料非常適合 logging,因此寫了一個簡單的 LogStream。程式碼不到 300行,完全獨立於 iostream。

這個 LogStream 做到了型別安全和型別可擴充套件。它不支援定製格式化、不支援 locale/facet、沒有繼承、buffer 也沒有繼承與虛擬函式、沒有動態分配記憶體、buffer 大小固定。簡單地說,適合 logging 以及簡單的字串轉換。

LogStream 的介面定義是

class LogStream : boost::noncopyable
{
  typedef LogStream self;
 public:
  typedef detail::FixedBuffer Buffer;
  LogStream();

  self& operator<<(bool);

  self& operator<<(short);
  self& operator<<(unsigned short);
  self& operator<<(int);
  self& operator<<(unsigned int);
  self& operator<<(long);
  self& operator<<(unsigned long);
  self& operator<<(long long);
  self& operator<<(unsigned long long);

  self& operator<<(const void*);

  self& operator<<(float);
  self& operator<<(double);
  // self& operator<<(long double);

  self& operator<<(char);
  // self& operator<<(signed char);
  // self& operator<<(unsigned char);

  self& operator<<(const char*);
  self& operator<<(const string&);

  const Buffer& buffer() const { return buffer_; }
  void resetBuffer() { buffer_.reset(); }

 private:
  Buffer buffer_;
};

LogStream 本身不是執行緒安全的,它不適合做全域性物件。正確的使用方式是每條 log 訊息構造一個 LogStream,用完就扔。LogStream 的成本極低,這麼做不會有什麼效能損失。

目前這個 logging 庫還在開發之中,只完成了 LogStream 這一部分。將來可能改用動態分配的 buffer,這樣方便線上程之間傳遞資料。

整數到字串的高效轉換

muduo::LogStream 的整數轉換是自己寫的,用的是 Matthew Wilson 的演算法,見 http://blog.csdn.net/solstice/article/details/5139302 。這個演算法比 stdio 和 iostream 都要快。

浮點數到字串的高效轉換

目前 muduo::LogStream 的浮點數格式化採用的是 snprintf() 所以從效能上與 stdio 持平,比 ostream 快一些。

浮點數到字串的轉換是個複雜的話題,這個領域 20 年以來沒有什麼進展(目前的實現大都基於 David M. Gay 在 1990 年的工作《Correctly Rounded Binary-Decimal and Decimal-Binary Conversions》,程式碼 http://netlib.org/fp/),直到 2010 年才有突破。

Florian Loitsch 發明了新的更快的演算法 Grisu3,他的論文《Printing floating-point numbers quickly and accurately with integers》發表在 PLDI 2010,程式碼見 Google V8 引擎,還有這裡 http://code.google.com/p/double-conversion/ 。有興趣的同學可以閱讀這篇部落格 http://www.serpentine.com/blog/2011/06/29/here-be-dragons-advances-in-problems-you-didnt-even-know-you-had/

將來 muduo::LogStream 可能會改用 Grisu3 演算法實現浮點數轉換。

效能對比

由於 muduo::LogStream 拋掉了很多負擔,可以預見它的效能好於 ostringstream 和 stdio。我做了一個簡單的效能測試,結果如下。

benchmark

從上表看出,ostreamstream 有時候比 snprintf 快,有時候比它慢,muduo::LogStream 比它們兩個都快得多(double 型別除外)。

泛型程式設計

其他程式庫如何使用 LogStream 作為輸出呢?辦法很簡單,用模板。

前面我們定義了 Date class 針對 std::ostream 的 operator<<,只要稍作修改就能同時適用於 std::ostream 和 LogStream。而且 Date 的標頭檔案不再需要 include <ostream>,降低了耦合。

 class Date
 {
  public:
   Date(int year, int month, int day)
     : year_(year), month_(month), day_(day)
   {
   }

-  void writeTo(std::ostream& os) const
+  template<typename OStream>
+  void writeTo(OStream& os) const
   {
     char buf[32];
     snprintf(buf, sizeof buf, "%d-%02d-%02d", year_, month_, day_);
     os << buf;
   }

  private:
   int year_, month_, day_;
 };

-std::ostream& operator<<(std::ostream& os, const Date& date)
+template<typename OStream>
+OStream& operator<<(OStream& os, const Date& date)
 {
   date.writeTo(os);
   return os;
 }

現實的 C++ 程式如何做檔案 IO

舉兩個例子, Kyoto CabinetGoogle leveldb

Google leveldb

Google leveldb 是一個高效的持久化 key-value db。

它定義了三個精簡的 interface:

介面函式如下

struct Slice {
  const char* data_;
  size_t size_;
};

// A file abstraction for reading sequentially through a file
class SequentialFile {
 public:
  SequentialFile() { }
  virtual ~SequentialFile();

  virtual Status Read(size_t n, Slice* result, char* scratch) = 0;
  virtual Status Skip(uint64_t n) = 0;
};

// A file abstraction for randomly reading the contents of a file.
class RandomAccessFile {
 public:
  RandomAccessFile() { }
  virtual ~RandomAccessFile();

  virtual Status Read(uint64_t offset, size_t n, Slice* result,
                      char* scratch) const = 0;
};

// A file abstraction for sequential writing.  The implementation
// must provide buffering since callers may append small fragments
// at a time to the file.
class WritableFile {
 public:
  WritableFile() { }
  virtual ~WritableFile();

  virtual Status Append(const Slice& data) = 0;
  virtual Status Close() = 0;
  virtual Status Flush() = 0;
  virtual Status Sync() = 0;
};

leveldb 明確區分 input 和 output,進一步它又把 input 分為 sequential 和 random access,然後提煉出了三個簡單的介面,每個介面只有屈指可數的幾個函式。這幾個介面在各個平臺下的實現也非常簡單明瞭(http://code.google.com/p/leveldb/source/browse/trunk/util/env_posix.cc#35  http://code.google.com/p/leveldb/source/browse/trunk/util/env_chromium.cc#176),一看就懂。

注意這三個介面使用了虛擬函式,我認為這是正當的,因為一次 IO 往往伴隨著 context switch,虛擬函式的開銷比起 context switch 來可以忽略不計。相反,iostream 每次 operator<<() 就呼叫虛擬函式,我認為不太明智。

Kyoto Cabinet

Kyoto Cabinet 也是一個 key-value db,是前幾年流行的 Tokyo Cabinet 的升級版。它採用了與 leveldb 不同的檔案抽象。

KC 定義了一個 File class,同時包含了讀寫操作,這是個 fat interface。http://fallabs.com/kyotocabinet/api/classkyotocabinet_1_1File.html

在具體實現方面,它沒有使用虛擬函式,而是採用 #ifdef 來區分不同的平臺(見 http://code.google.com/p/read-taobao-code/source/browse/trunk/tair/src/storage/kdb/kyotocabinet/kcfile.cc),等於把兩份獨立的程式碼寫到了同一個檔案裡邊。

相比之下,Google leveldb 的做法更高明一些。

小結

在 C++ 專案裡邊自己寫個 File class,把專案用到的檔案 IO 功能簡單封裝一下(以 RAII 手法封裝 FILE* 或者 file descriptor 都可以,視情況而定),通常就能滿足需要。記得把拷貝構造和賦值操作符禁用,在解構函式裡釋放資源,避免洩露內部的 handle,這樣就能自動避免很多 C 語言檔案操作的常見錯誤。

如果要用 stream 方式做 logging,可以拋開繁重的 iostream 自己寫一個簡單的 LogStream,過載幾個 operator<<,用起來一樣方便;而且可以用 stack buffer,輕鬆做到執行緒安全。

相關文章