2016年3月18日 星期五

無異常(non-exception)之下的RAII

作者: Eli Bendersky
原文網址: http://eli.thegreenplace.net/2016/c-raii-without-exceptions/

我已讀遍了"RAII只能用在異常(exception)機制上"相關琳瑯滿目的文章,但我忍受不了了


TL; DR 這篇文章並不探討異常機制(exception)到底是好還是壞。而是關於RAII這類的動態資源管理技術在有無異常機制底下,都能做出有用的自我管理。尤其我想解釋在你的C++程式碼中即使關閉了異常機制,為何RAII還是很有用


基礎
我們寫個RAII能包裹住 FILE*並自動做出關閉檔案的handle
class FileHandle {
  public:
    FileHandle(const char* name, const char* mode) {
      f_ = fopen(name, mode);
    }

    FILE* file() {
      return f_;
    }

    ~FileHandle() {
      if (f_ != nullptr) {
        fclose(f_);
      }
    }

  private:
    FILE* f_;
};
我們有個範例能利用它

std::string do_stuff_with_file(std::string filename) {
  FileHandle handle(filename.c_str(), "r");
  int firstchar = fgetc(handle.file());

  if (firstchar != '$') {
    return "bad bad bad";
  }

  return std::string(1, firstchar);
}
請記得這裡沒有異常機制了-已事先使用-fno-exceptions 且無try敘述
FileHandle這類RAII仍很重要do_stuff_with_file有兩個脫離點(譯可立即離開函式的觸發點),此檔案能在任一點關閉起來do_stuff_with_file是個又短又簡單的函式。在大型函式中會有很多個脫離點,這使得管理資源的釋放容易出錯,RAII技術在此變得極為重要

RAII本質上用堆疊配置物件(譯區域物件也是此類物件)的建構式來獲取一些資源,並由解構式來釋放資源。不論引發異常或是函式返回的情況下編譯器保證離開這類物件的作用域時物件的解構式會做出正確的資源釋放

RAII並不是指你得在建構式中配置或增加任何東西。而是指操控邏輯執行」所需的"鬆綁"參考計數器(reference counting)是個好例子。很多資料庫跟軟體庫擁有"游標"這類抽象來藉此提供資料存取。這裡我們做出如何安全地前進(increase)和退回(decrease )游標的參考計數

class CursorGuard {
public:
  CursorGuard(Cursor* cursor) : cursor_(cursor) {
    cursor_->incref();
  }

  Cursor* cursor() {
    return cursor_;
  }

  ~CursorGuard() {
    cursor_->decref();
  }

private:
  Cursor* cursor_;
};


void work_with_cursor(Cursor* cursor) {
  CursorGuard cursor_guard(cursor);

  if (cursor_guard.cursor()->do_stuff()) {
    // ... do something
    return;
  }

  // ... do something else
  return;
}
再一次地RAII確保了work_with_cursor不會讓游標的參考計數器出現脫軌的情況:一旦前進(incref( ) )後,無論函式從哪意外返回,保證一定會有退回(decref( ))動作

標準程式庫裡的RAII
像"哨兵"一樣的RAII classes是極普遍且實用的技術,也紮根在標準程式庫中。如C++11執行緒程式庫針對mutex鎖定而設計的lock_guard,如下例子
void safe_data_munge(std::mutex& shared_mutex, Data* shared_data) {
  std::lock_guard<std::mutex> lock(shared_mutex);
  shared_data->munge();

  if (...) {
    shared_data();
    return;
  }

  shared_data->munge_less();
  return;
}
std::lock_guard的建構式鎖定mutex,然後在解構式中解鎖mutex,確保在safe_data_munge裡能從頭到尾保護好共享資料的存取完整性,真正的解鎖動作也總是確實執行

RAII與C++11
有關係到標準程式庫的話,我不得不提最具RAII特色的型別std::unique_ptr。在C和C++中資源管理(Resource management)是個龐大複雜的課題在C++普遍常見的資源管理都跟堆積(heap)記憶體有關。前面提到的C++11中,有很多靈活指標(smart pointers)這類的第三方解決方案,加上C++11搬移語意(move semantics)最後讓語言有了以RAII方式所創造出堅固的靈活指標

void using_big_data() {
  std::unique_ptr<VeryVeryBigData> data(new VeryVeryBigData);

  data->do_stuff();

  if (data->do_other_stuff(42)) {
    return;
  }

  data->do_stuff();
  return;
}
我們無論怎樣使用data,無論怎樣返回函式,所配置的記憶體都將釋放回去。假如你的編譯器支援C++14,在創造指標那行使用std::make_unique會變得更簡潔

// Good usage of 'auto': removes the need to repeat a (potentially long)
// type name, and the actual type assigned to 'data' is trivially obvious.
auto data = std::make_unique<VeryVeryBigData>();
std::unique_ptr功能多且還有其他應用 ,但這裡我只關注堆積記憶體上的RAII"如值(value)一般"的處理方式
這裡強調C++11為何對RAII而言如此的重要:假如沒有搬移語意會導致copy語意跟可觀的開銷那我們只能為"靈活"的指標而寫出令人瞠目結舌的東西。有了搬移語意,就能從函式中輕易"轉移物件的擁有權"到其他地方而不需要多大的開銷。過去以來C++程式員時常為了從程式碼中擠出一些效能,大部份都選擇讓自己在艱困的環境中處理原始(raw)指標。使用C++11和std::unique_ptr的話,會得到更有效率的搬移且無需額外記憶體佔用,使得這問題不太需顧慮嚴謹跟安全,不用得付出效能的代價

其他語言的RAII理念
一個常被問到的C++問題:"為何C++不能像其他語言(Java,C#,Python)一樣享有finally建構方式呢?"Stroustrup給的答案RAII是更好的替代方案Stroustrup認為(怒我直言這就是現實) 現實程式碼中獲取跟釋放資源的出現次數遠高於明確區別各類資源,所以RAII就使程式碼更簡短了。除此之外,你一旦用RAII把程式碼包裹住後,將不容易出錯,也不需手動釋放資源。下面的work_with_cursor以假設的finally方式來重寫前面提過的例子(這不是真實的C++程式碼)

// Warning: this is not real C++
void work_with_cursor(Cursor* cursor) {
  try {
    cursor->incref();

    if (cursor->do_stuff()) {
      // ... do something
      return;
    }

    // ... do something else
    return;
  }
  finally {
    cursor->decref();
  }
}
是的,它使得程式碼變得多餘。較重要是必須記得使用cursor->decref()。大型程式不斷改變資源的取捨,在實踐上你將用try...finally區塊來當作每個涵式本體的骨架,然後得記住要做出釋放資源的動作。假使是用前面的CursorGuard來當幫手的話,只需定義一次哨兵class(CursorGuard)就能省下先前所付出的全部開銷

另一個好例子是在Python語言。即使Python有finally的建構方式現代的Python程式碼則偏好用途更多廣的with敘述。with支援 "context managers",非常類似C++的RAII。with比finally好用且更具通用性,這就是為何你在程式碼中能大量看到它們的身影

那RAII如果是在異常機制底下呢?
講到這即使是在非異常機制下,我希望你已信服RAII在C++中是重要且實用的技術
RAII跟異常之間的親密關係已經受到人們的認可,不管怎樣,因為撰寫異常安全性(exception-safe)程式碼卻不用RAII是幾乎不可能地。在啟用異常機制下,我們不會只檢查函式中每個return敘述來診斷出何處會導致資源洩漏。每一行程式碼都可能藏有地雷。函式或方法的呼叫處呢?也是會丟出異常。在堆疊區建構非POD物件呢?也是會丟出異常。copy物件到別處呢?也是一樣...丟出異常a + b?可藉由+operator丟出異常

另外異常跟建構式中的RAII也有緊密的關係。由於建構式無法返回數值(value)因此,建構式遇到錯誤的情況下,你可以丟出異常或製造物件內部的錯誤狀態。後者有它自身的問題(非異常機制的程式碼中使用此建構方法是較好的選擇),所以丟出異常是最常見的手段。自此RAII對異常機制是如此重要,也是因為RAII跟建構式常常相伴而行(記住-RAII在物件被建構時就開始了),這種緊密的關係就烙印在C++學習者心中

RAII不只在異常下有用處。它更在C++資源管理技術中扮演重要角色。因此,去假設有RAII就表示某種程度上你的程式碼是把異常(exception)給泛濫亂用的地方,這是無意義的。甚至真的使用了異常也是如此。批判C++的異常安全性是情有可原,但批判RAII就說不過去了,因為RAII只是種解決手段,而非問題的源頭所在

最後,在我個人筆記將加上,我並不是C++的異常機制的粉絲,而是RAII的大粉絲。當我用C++時,我寧可不用異常機制,或至少把它給約束和限制在程式裡的極小區域內。可用RAII的話,我會採用標準庫中的class像std::unique_ptr或者是自己撰寫的RAII程式碼。RAII在我心目中是優良且具實用性的C++特色之一,幫助維持大型程式碼的穩健跟安全

沒有留言:

張貼留言