這篇短文是讨論一個大多數程式員都感興趣的一個話題:錯誤處理。錯誤處理是程式設計的一個“黑暗面”。它既是應用程式的“現實世界”的關鍵點,也是一個你想隐藏的複雜業務。
在早期的C程式設計生涯中,我知道三種錯誤處理的方式。
C語言的方式:傳回錯誤碼
C語言風格的錯誤處理是最簡單的,但是并不完美。
C語言風格的錯誤處理依賴于“當程式遇到錯誤時傳回一個錯誤碼”。這裡是一個簡單的例子:
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的增強,引入了一種新的錯誤處理方式——異常。異常通過抛出一個錯誤的方式來中斷正常代碼執行邏輯,并可以被其他地方所捕獲。下面是一個簡單的例子:
throw AnException("Error message");
try
find_slash(string);
catch(AnException& e)
//Handle exception
這樣做的好處?
程式邏輯和錯誤處理分離了。一邊你可以看到函數是如何工作的,而另一邊你可以看到函數失敗時候是怎麼處理的。這樣做很完美,可以很容易看出錯誤處理和正常程式邏輯。
另外,現在你可以為你的錯誤提供你需要的盡可能多的資訊,因為你可以将需要的内容填充在自定義異常對象裡。
這樣做的壞處
編寫詳盡的異常處理變得很冗。你需要一個異常樹,但是最好不要太大,這樣,你可以選擇捕獲感興趣的異常。同時,内部需要提供錯誤碼,來獲知究竟發生了什麼,同時需要檢索一些錯誤消息,等等。編寫寫異常類通常都是冗長,這是将資訊嵌入到錯誤裡來靈活處理更多的資訊的成本。
這裡的錯誤處理哲學是将錯誤盡可能推遲到需要處理的地方再處理,當你不知道程式執行過程究竟哪裡會産生一個錯誤,你需要跳過不同的檔案和功能函數來查找,這通常都是困難的,如果你在一個很深的調用樹(這裡意思是當你将函數調用繪制出一個圖形,其形狀類似一棵樹)上引發了一個異常,你需要指定在哪裡來處理這個異常,當它被處理的時候,它又是在哪裡發生的。特别是當你的程式很大,又是很早之前編寫,有恰巧設計不夠良好的時候,就更加顯得困難。而大多數商業項目都是這樣。
是以我覺得“異常是危險的”。雖然它提供了一種良好的方式來處理錯誤——僅限于一些小項目,并且這裡的調用圖簡單且易于掌握時候。
錯誤封裝的模式
我這裡把它叫做一種模式,是以人們不必害怕擔心。後面,我會給它一種更好的命名,是以請不要着急。
錯誤封裝的主旨是建立一種封裝來包含錯誤消息或者錯誤的傳回值。我們通常會選擇字元串而不是其他,因為這也并不容易實作。我們盡力保證文法的可讀性,可了解,并且容易應用。我們不處理拷貝構造或者多參數函數及傳回值,這裡僅給出一個盡可能簡單的例子。
讓我們以下面的例子開始:
E<int> find_slash(const char* str)
return fail<int>("Error message");
return ret(i);
auto v = find_slash(string);
if(!v)
//Handle exception
乍一看,這裡有點類似C語言的風格,但是不是,為表明這一點,請看接下來的多個函數調用例子:
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);
//Handle error
好了,這裡發生了什麼?bind是一個成員函數來綁定你的函數調用,試着去應用它。如果錯誤裝箱裡面含有一個值,那麼它就應用于函數調用,繼續傳回一個錯誤裝箱(編譯器不允許你傳回一個不帶錯誤裝箱的函數)。
是以,我們鍊式調用了find_slashe,do_some_arithmetic, format和display.它們都不處理錯誤裝箱,由于bind函數的作用,我們将函數E f(something_in)傳回結果給E f(E)函數做參數。
這裡的好處是什麼?
再一次,函數邏輯(調用鍊)和錯誤處理分離了。和異常一樣,我們可以簡單讀一下函數調用鍊來了解代碼邏輯,而不用關心執行是在哪裡被中斷的。事實上,函數調用鍊可以在任何調用時被中斷。但是我們可以認為沒有錯誤發生,如果我們的邏輯是正确的,可以很快速檢查。
當然,類型推導會阻止你在調用display之後繼續進行綁定。是以我們也沒有失去類型能力。
注意,我們沒有在其他地方調用這些函數,我們在最後将這些方法組裝在一起。這裡是關鍵,你應該編寫一些小的子產品函數(另外,注意:你應該編寫模闆函數使其工作)接收一個值,然後計算一個新值或者傳回失敗。在每一步中,你都不需要考慮可能出現錯誤導緻你的控制流中斷,并且校驗你是否在一個有效的狀态上(異常安全基于查詢每個函數調用,指出函數是否中斷你的控制流程,如果出現異常會發生什麼),基于這一點,這樣做更安全。
和異常一樣,我們可以處理很詳細的資訊,盡管這裡我們編寫的是一個偏模闆函數,是以也容易了解一些。
我們可以很容易放置異常處理邏輯,把它放在函數調用鍊之後(除非這個傳回值還需要進一步被連結)。現在,我們有一個大的的執行流,沒有中斷,使用小的函數處理流程,容易定位。當需要添加一個新的錯誤時,你隻需找到那些函數,通過函數調用鍊,你可以直接定位到處理位置,并根據需要添加。大型項目變得更加的線性化,并且更易讀。
這樣做有什麼不足?
首先,這是一個新的處理方式,并且和C++的方式不相容。這不是一個标準處理方法,當你使用stl時,你仍然需要使用異常。
對于我來說,這樣做還是有點冗長。需要顯式編寫fail(…)的模闆推導顯得有點怪異,如果你有個多态錯誤類型就更糟了,你不得不這樣寫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函數有點怪異,但是他實作了我剛才提到的内容。
/*
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)
box.m_error = str;
box.m_valid = false;
//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
std::cerr << "You can't deconstruct to an error from a value" << std::endl;
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);
return type::fail(m_error);
};
這裡,我重載了<<運算符,是以導出裝箱中的内容更容易一些。我們并不是一定需要它,在“真”值時去掉這一點也更好一些。
這裡的例子,我們需要一個“E”類型,但是它可能不一定使用。我們需要為void實作一個特别的重載,這裡其實也是一樣的,隻不過期望的值是一個“空箱”。
Special instance for void
template<>
class E<void>
static E ret()
//Déconstruct an E to a value
void to_value() const
friend std::ostream& operator<< (std::ostream& oss, const E<void>& box)
oss << "()";
auto bind(F f) -> decltype(f())
using type = decltype(f());
return f();
return type::fail(m_error);
我們沒有提到ret和fail方法,事實上,它們隻是對xxx::fail和xxx::ret函數的封裝。
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);
E<T> fail(std::string err)
return E<T>::fail(err);
這裡,你可以編譯并執行一下上面的代碼。
如果你想要更多的,可以試試下面這個更具體一點的例子:
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
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;
case ' ':
if(!stack.empty())
vec.push_back(stack);
stack.resize(0);
mode = 0;
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]);
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;
return fail<str>("Empty vectors aren't allowed");
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;