這篇短文是討論一個大多數程式設計師都感興趣的一個話題:錯誤處理。錯誤處理是程式設計的一個“黑暗面”。它既是應用程式的“現實世界”的關鍵點,也是一個你想隱藏的複雜業務。
在早期的C程式設計生涯中,我知道三種錯誤處理的方式。
C語言的方式:返回錯誤碼
C語言風格的錯誤處理是最簡單的,但是並不完美。
C語言風格的錯誤處理依賴於“當程式遇到錯誤時返回一個錯誤碼”。這裡是一個簡單的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
int find_slash(const char *str) { int i = 0; while (str[i] && str[i] != '/') i++; if (str[i] == '\0') return -1; //Error code //True value return i; } // . . . if (find_slash(string) == -1) { //error handling } |
使用這種方式的有什麼好處?
你可以在呼叫函式之後直接處理錯誤碼(在C語言中,你也會這樣處理),顯示一個錯誤訊息或者直接終止程式。或者僅僅恢復程式最近的一個狀態,終止計算。
當你找不到錯誤處理在哪裡的時候,你只需要後頭看看函式呼叫,錯誤處理就在那個附近。
使用這種方式有什麼不好?
有人可能會告訴你,這種異常/錯誤處理方式和“執行邏輯”混在了一起。當你順序地閱讀這些程式碼的時候就行程式執行一樣,你看到了一會錯誤處理,一會程式執行。這樣很糟糕,你可能更喜歡只讀程式執行邏輯或者錯誤處理邏輯。
並且你被限定使用錯誤碼,如果你想要提供更多的資訊,你需要建立一些功能函式比如:errstr或者提供全域性變數。
使用C++的方式
C++作為對C的增強,引入了一種新的錯誤處理方式——異常。異常通過丟擲一個錯誤的方式來中斷正常程式碼執行邏輯,並可以被其他地方所捕獲。下面是一個簡單的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
int find_slash(const char *str) { int i = 0; while (str[i] && str[i] != '/') i++; if (str[i] == '\0') throw AnException("Error message"); //True value return i; } // . . . try { find_slash(string); } catch(AnException& e) { //Handle exception } |
這樣做的好處?
程式邏輯和錯誤處理分離了。一邊你可以看到函式是如何工作的,而另一邊你可以看到函式失敗時候是怎麼處理的。這樣做很完美,可以很容易看出錯誤處理和正常程式邏輯。
另外,現在你可以為你的錯誤提供你需要的儘可能多的資訊,因為你可以將需要的內容填充在自定義異常物件裡。
這樣做的壞處
編寫詳盡的異常處理變得很冗。你需要一個異常樹,但是最好不要太大,這樣,你可以選擇捕獲感興趣的異常。同時,內部需要提供錯誤碼,來獲知究竟發生了什麼,同時需要檢索一些錯誤訊息,等等。編寫寫異常類通常都是冗長,這是將資訊嵌入到錯誤裡來靈活處理更多的資訊的成本。
這裡的錯誤處理哲學是將錯誤儘可能推遲到需要處理的地方再處理,當你不知道程式執行過程究竟哪裡會產生一個錯誤,你需要跳過不同的檔案和功能函式來查詢,這通常都是困難的,如果你在一個很深的呼叫樹(這裡意思是當你將函式呼叫繪製出一個圖形,其形狀類似一棵樹)上引發了一個異常,你需要指定在哪裡來處理這個異常,當它被處理的時候,它又是在哪裡發生的。特別是當你的程式很大,又是很早之前編寫,有恰巧設計不夠良好的時候,就更加顯得困難。而大多數商業專案都是這樣。
所以我覺得“異常是危險的”。雖然它提供了一種良好的方式來處理錯誤——僅限於一些小專案,並且這裡的呼叫圖簡單且易於掌握時候。
錯誤封裝的模式
我這裡把它叫做一種模式,所以人們不必害怕擔心。後面,我會給它一種更好的命名,所以請不要著急。
錯誤封裝的主旨是建立一種封裝來包含錯誤訊息或者錯誤的返回值。我們通常會選擇字串而不是其他,因為這也並不容易實現。我們盡力保證語法的可讀性,可理解,並且容易應用。我們不處理拷貝構造或者多引數函式及返回值,這裡僅給出一個儘可能簡單的例子。
讓我們以下面的例子開始:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
E<int> find_slash(const char* str) { int i = 0; while (str[i] && str[i] != '/') i++; if (str[i] == '\0') return fail<int>("Error message"); //True value return ret(i); } // . . . auto v = find_slash(string); if(!v) { //Handle exception } |
乍一看,這裡有點類似C語言的風格,但是不是,為表明這一點,請看接下來的多個函式呼叫例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
E<int> find_slash(const char*); E<int> do_some_arithmetic(int); E<std::string> format(int); E<void> display(std::string); auto v = ret(string) .bind(find_slash) .bind(do_some_arithmetic) .bind(format) .bind(display); if(!v) { //Handle error } |
好了,這裡發生了什麼?bind是一個成員函式來繫結你的函式呼叫,試著去應用它。如果錯誤裝箱裡面含有一個值,那麼它就應用於函式呼叫,繼續返回一個錯誤裝箱(編譯器不允許你返回一個不帶錯誤裝箱的函式)。
所以,我們鏈式呼叫了find_slashe,do_some_arithmetic, format和display.它們都不處理錯誤裝箱,由於bind函式的作用,我們將函式E<something_out> f(something_in)
返回結果給E<something_out> f(E<something_in>)
函式做引數。
這裡的好處是什麼?
再一次,函式邏輯(呼叫鏈)和錯誤處理分離了。和異常一樣,我們可以簡單讀一下函式呼叫鏈來了解程式碼邏輯,而不用關心執行是在哪裡被中斷的。事實上,函式呼叫鏈可以在任何呼叫時被中斷。但是我們可以認為沒有錯誤發生,如果我們的邏輯是正確的,可以很快速檢查。
當然,型別推導會阻止你在呼叫display之後繼續進行繫結。所以我們也沒有失去型別能力。
注意,我們沒有在其他地方呼叫這些函式,我們在最後將這些方法組裝在一起。這裡是關鍵,你應該編寫一些小的模組函式(另外,注意:你應該編寫模板函式使其工作)接收一個值,然後計算一個新值或者返回失敗。在每一步中,你都不需要考慮可能出現錯誤導致你的控制流中斷,並且校驗你是否在一個有效的狀態上(異常安全基於查詢每個函式呼叫,指出函式是否中斷你的控制流程,如果出現異常會發生什麼),基於這一點,這樣做更安全。
和異常一樣,我們可以處理很詳細的資訊,儘管這裡我們編寫的是一個偏模板函式,所以也容易理解一些。
我們可以很容易放置異常處理邏輯,把它放在函式呼叫鏈之後(除非這個返回值還需要進一步被連結)。現在,我們有一個大的的執行流,沒有中斷,使用小的函式處理流程,容易定位。當需要新增一個新的錯誤時,你只需找到那些函式,通過函式呼叫鏈,你可以直接定位到處理位置,並根據需要新增。大型專案變得更加的線性化,並且更易讀。
這樣做有什麼不足?
首先,這是一個新的處理方式,並且和C++的方式不相容。這不是一個標準處理方法,當你使用stl時,你仍然需要使用異常。
對於我來說,這樣做還是有點冗長。需要顯式編寫fail<int>(…)的模板推導顯得有點怪異,如果你有個多型錯誤型別就更糟了,你不得不這樣寫fail<return_type, error_type>("...")
.
當函式有多個引數時編寫也很困難,在其他一些語言中,可以使用適用型別和抽象型別很好地解決這個問題,不過這在C++中不會提供。我想更適合使用bind2(E<a>, E<b>, f)
和bind3(E<a>, E<b>, E<c>, f)
,可變模板引數功能更有用。
為獲取封裝錯誤中的值,我們需要檢查這個值是否是有效值,接著呼叫一個“to_value”方法。我們沒辦法不通過檢查來做到這一點。我們希望的是“解構”一個物件,不過這在C++中不支援,這也不是一些可以說“我們把它加入到下一個標準”的特性。
目前為止,我不知道讀者是否有方法將其適配到成員函式中,如果你有想法,請測試一下,如果可以,請告知我們。
實現原子錯誤處理
我實現了它,我定義了這個黑魔法的名字——“原子化”,你可以認為“原子化”是一個對值和錯誤上下文的裝箱,比如,一個box包含一個值或者什麼也不包含是一個原子組(這裡作為一個練習,你可以試著實現一下)。
有點奇怪的是,從某個角度來說佇列是一個原子組,他們擁有一個上下文的值。
讓我們從上面的E模版類實現開始,這裡使用了C++11標準中的decltype和auto -> decltype型別,允許自動推導得到表示式的型別,這非常有用。
這裡的bind函式有點怪異,但是他實現了我剛才提到的內容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
/* This is the "Either String" monad, as a way to handle errors. */ template <typename T> class E { private: //The value stored T m_value; //The error message stored std::string m_error; //A state. True it's a value, false it's the message. bool m_valid; E() {} public: //Encapsulate the value static E ret(T v) { E box; box.m_value = v; box.m_valid = true; return box; } //Encapsulate an error static E fail(std::string str) { E box; box.m_error = str; box.m_valid = false; return box; } //True if it's a valid value operator bool() const { return m_valid; } //Deconstruct an E to a value T to_value() const { //It's a programmer error, it shouldn't happen. if (!*this) { std::cerr << "You can't deconstruct to a value from an error" << std::endl; std::terminate(); } return m_value; } //Deconstruct an E to an error std::string to_error() const { //It's a programmer error, it shouldn't happen. { std::cerr << "You can't deconstruct to an error from a value" << std::endl; std::terminate(); } return m_error; } friend std::ostream& operator<< (std::ostream& oss, const E<T>& box) { if (box) oss << box.m_value; else oss << box.m_error; return oss; } template<typename F> inline auto bind(F f) -> decltype(f(m_value)) { using type = decltype(f(m_value)); if (*this) return f(m_value); else return type::fail(m_error); } }; |
這裡,我過載了<<運算子,所以匯出裝箱中的內容更容易一些。我們並不是一定需要它,在“真”值時去掉這一點也更好一些。
這裡的例子,我們需要一個“E<void>”型別,但是它可能不一定使用。我們需要為void實現一個特別的過載,這裡其實也是一樣的,只不過期望的值是一個“空箱”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
/* Special instance for void */ template<> class E<void> { private: std::string m_error; bool m_valid; E() {} public: //Encapsulate the value static E ret() { E box; box.m_valid = true; return box; } //Encapsulate an error static E fail(std::string str) { E box; box.m_error = str; box.m_valid = false; return box; } //True if it's a valid value operator bool() const { return m_valid; } //Déconstruct an E to a value void to_value() const { //It's a programmer error, it shouldn't happen. if (!*this) { std::cerr << "You can't deconstruct to a value from an error" << std::endl; std::terminate(); } } //Deconstruct an E to an error std::string to_error() const { //It's a programmer error, it shouldn't happen. if (*this) { std::cerr << "You can't deconstruct to an error from a value" << std::endl; std::terminate(); } return m_error; } friend std::ostream& operator<< (std::ostream& oss, const E<void>& box) { if (box) oss << "()"; else oss << box.m_error; return oss; } template<typename F> inline auto bind(F f) -> decltype(f()) { using type = decltype(f()); if (*this) return f(); else return type::fail(m_error); } }; |
我們沒有提到ret和fail方法,事實上,它們只是對xxx::fail和xxx::ret函式的封裝。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
/* Then, I introduced those simple functions, to reduce the call to something readable/writable */ template <typename T> inline E<T> ret(T v) { return E<T>::ret(v); } template <typename T> inline E<T> fail(std::string err) { return E<T>::fail(err); } |
這裡,你可以編譯並執行一下上面的程式碼。
如果你想要更多的,可以試試下面這個更具體一點的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
/* Here come a case of use. */ // What a user would see: //Return a value in an error context template <typename T> inline E<T> ret(T v); //Fail in an error context of type T template <typename T> inline E<T> fail(std::string err); // What a user would write: typedef std::vector<std::string> vs; typedef std::string str; //Parse a +- formated string. //If a letter is prefixed by +, then the function toupper is applied. //'' tolower is applied. //Non alphabetical (+ and - excepted) aren't alowed. //Words are cut on each space ' '. Other blank characters aren't alowed. E<std::vector<std::string>> parse(std::string str) { int mode = 0; vs vec; if (str.empty()) return fail<vs>("Empty string aren't allowed"); std::string stack; for(int i = 0; str[i] != '\0'; i++) { switch(str[i]) { case '-': mode = 1; break; case '+': mode = 2; break; case ' ': { if(!stack.empty()) vec.push_back(stack); stack.resize(0); mode = 0; break; } default: { if (!isalpha(str[i])) return fail<vs>("Only alpha characters are allowed"); if (mode == 1) stack.push_back(tolower(str[i])); else if (mode == 2) stack.push_back(toupper(str[i])); else stack.push_back(str[i]); mode = 0; break; } } } if(!stack.empty()) vec.push_back(stack); return ret(vec); } //Take the first word and append it to the begining of all other words. //Vec should contain at least one element. E<std::vector<std::string>> prefixy(std::vector<std::string> vec) { if (vec.empty()) return fail<vs>("Can't add prefixes on an empty table"); std::string prefix = vec.front(); vs out; for (auto s : vec) { if (prefix == s) continue; out.push_back(prefix + s + "^"); } return ret(out); } //Concatenate all strings as a big string. Vec should contain data. E<std::string> concat(std::vector<std::string> vec) { std::string output; if (vec.empty()) return fail<str>("Empty vectors aren't allowed"); for (auto s : vec) output += s; if (output.empty()) return fail<str>("No data found"); return ret(output); } int main() { typedef std::string s; //Parse some string, show how error interrupt computation of the "chain". std::cout << ret((s)"+hello -WORLD").bind(parse).bind(prefixy).bind(concat) << std::endl; std::cout << ret((s)"+hello Hello Hello").bind(parse).bind(prefixy).bind(concat) << std::endl; std::cout << ret((s)"+ ").bind(parse).bind(prefixy).bind(concat) << std::endl; std::cout << ret((s)"+hi").bind(parse).bind(prefixy).bind(concat) << std::endl; //Play with lambda to "replace" a value if it's not an error. std::cout << ret((s)"Some string").bind([](const std::string&) {return fail<s>("Failed");}); std::cout << ret(23).bind([](const int) {return ret(42);}); std::cout << fail<int>("NaN").bind([](const int) {return ret(42);}); return 0; } |