不知道一般人覺得下面這段 C++ 程式碼,執行會是什麼結果?
int i = 0; std::cout << ++i << "/" << ++i << "/" << ++i << std::endl;
很直覺地,感覺應該會是「1/2/3」吧?
但是實際上,如果使用 g++ 6 以前的版本、或是 Visual Studio 2019 的話,應該會很訝異地發現,結果是「3/3/3」!
為什麼會這樣呢?實際上這不是 bug,而是在 C++17 以前,C++ 並沒有針對一行程式(一個 expression)裡的執行順序、並沒有去做任何規範。
上面的例子或許不好看的出來到底是怎麼一回事,用下面的程式碼,應該會比較容易知道是什麼狀況:
#include <iostream> int test(int iVal) { std::cout << "[" << iVal << "]" << std::endl; return iVal; } int main() { std::cout << test(1) << "n" << test(2) << "n" << test(3) << std::endl; }
這段程式,在 g++6 以前的版本、或是 Visual C++ 2019 使用 C++14 來編譯,執行的結果會是:
[3] [2] [1] 1 2 3
也就是說,實際上在執行的時候,系統會先將 test(3)、test(2)、test(1) 都執行完之後,再將結果依序透過 cout 輸出。
把這個想法帶回最初 ++i 的例子,就可以想像成他是把三次的 ++i 都算完之後,再把 i 輸出,所以就會變成 3/3/3 了~
而到了 C++17,針對這邊有較嚴謹的執行順序的定義,所以以 Visual C++2019 來說,如果使用 /std:c++17 來建置的話,結果會變成:
[1] 1 [2] 2 [3] 3
算是有和想像的一樣、由左到右依序執行了~
而同樣的,最初 ++i 的例子,在 C++17 的情況下,其結果也會是預期的 1/2/3 的。
而實際上,cout 在使用 << 輸出的時候,是去呼叫 operator<<() 這個函式。
所以一開始的 std::cout << ++i << “/“ << ++i << “/“ << ++i << std::endl; 基本上就是呼叫了三次 operator<<()。
目前很多函式庫為了方便使用、也都有類似的連續呼叫作法。
像是 Qt 的 QStringList 也是使用 operator<<() 來做(參考);而 Boost 的 program options、在 option_description 的部分則是使用 operator()() 來實作這類的連續呼叫(參考)。
所以上面的問題要說會不會碰到?個人會覺得,其實還真的有機會…
下面是另一個例子:
#include <iostream> #include <vector> #include <sstream> class MyVec : public std::vector<char> { public: MyVec& push(const char& iVal) { push_back(iVal); return *this; } }; int main() { std::istringstream ds("abcde"); MyVec vData; vData.push(ds.get()).push(ds.get()).push(ds.get()); for (auto& v : vData) std::cout << v << "n"; }
這個例子,理想上是從 ds 這個 istringstream 資料來源裡,透過 get() 依序讀取三個字元出來、然後透過 push() 這個自己擴增的函式放進 vData 裡。
理想上,vData 裡的資料應該要是「abc」,但是由於這三次 ds.get() 的執行順序在 C++17 以前都沒有定義,所以在部分編譯器,就可能會出現結果和預期不一樣的狀況了。
例如透過 Visual C++ 2019 以 C++14 編譯、執行的結果就會是:
> cl .test.cpp /std:c++14 /nologo /EHsc; .test.exe test.cpp c b a
可以看到,結果和想像的是相反的,vData 裡的資料變成是「cba」了。
而如果切換到 C++17 的話,結果就會和預期的一樣了。
> cl .test.cpp /std:c++17 /nologo /EHsc; .test.exe test.cpp a b c
所以,C++17 這樣的修改,應該還是有好一點的。
不過,另一方面,如果是函式的引數的話,即使在 C++17 也還是沒有規範它的執行順序的。
像是下面的程式:
#include <iostream> int fa() { std::cout << "fa" << std::endl; return 1; } int fb() { std::cout << "fb" << std::endl; return 2; } int fc() { std::cout << "fc" << std::endl; return 3; } void test(int a, int b, int c) { std::cout << a << "-" << b << "-" << c << std::endl; } int main() { test(fa(), fb(), fc()); }
在 Visual C++ 或是 g++ 不管是 C++14 還是 C++17,結果都是:
fc fb fa 1-2-3
也就是他產生引數的三個函式都是由右到左執行回來的。
(BTW,clang 的順序似乎是由左到右)
而這樣可能有什麼問題呢?下面是一個例子:
#include <iostream> #include <vector> #include <sstream> class vec3 { public: vec3( char v1, char v2, char v3) { a = v1; b = v2; c = v3; } char a; char b; char c; }; int main() { std::istringstream ds("abcdef"); std::vector<vec3> vData; vData.push_back(vec3(ds.get(), ds.get(), ds.get())); vData.push_back(vec3(ds.get(), ds.get(), ds.get())); for (auto v : vData) { std::cout << v.a << "/" << v.b << "/" << v.c << "n"; } }
這個例子裡是透過 get() 來取得 ds 的一個字元,並透過這個方法來建立 vec3 的物件。
理想上,這邊會建立出兩個 vec3,內容分別是「a/b/c」和「d/e/f」。
但是實際上,以現在的 Visual C++ 和 g++,結果都會和預期不一樣,會是「c/b/a」和「f/e/d」。
這點就算到 C++17 還是一樣的、執行順序都是未定義,所以在寫的時候,還是要注意、需要避開這種寫法的。
雖然感覺在函式引數的執行順序上,還是有可能和預期的不一樣,不過實際上,C++17 在這部分還是有一些調整。
下面是一個很特別的例子:
#include <iostream> #include <exception> class CTest { public: CTest() { std::cout << " > CTest created" << std::endl; } ~CTest() { std::cout << " > CTest deleted" << std::endl; } }; int func_ctest(CTest* pTest) { std::cout << " > func_ctest() called" << std::endl; delete pTest; return 0; } int func_int(bool bThrow) { std::cout << " > func_int() called" << std::endl; if(bThrow) throw std::runtime_error("stop"); return 0; } void test(int, int) { std::cout << "test() called" << std::endl; } int main() { try { test(func_ctest(new CTest()), func_int(false)); } catch (std::exception e) { std::cout << e.what() << std::endl; } }
在 Visual C++ 2019、C++14 的模式下,它的執行結果會是:
> CTest created > func_int() called > func_ctest() called > CTest deleted test() called
可以看到,很有趣的是,他在執行完「new CTest()」後,就跳去執行 func_int() 了!?然後之後,才會回來執行 func_ctest() 的內容。
在這樣的執行順序下,如果把 func_int() 的引數從 false 改成 true、讓他丟出例外的話,就會造成 func_ctest() 不會被執行到、進而造成 memory leak 了。
這時候的結果會是:
> CTest created > func_int() called stop
而 C++17 則是保證每一個引述都會完整處理完、才去處理下一個,所以可以避免產生這樣 memory leak 的問題。 在沒丟例外的狀況下,他的執行結果會是:
> func_int() called > CTest created > func_ctest() called > CTest deleted test() called
所以就算 func_int() 都出了例外,由於 CTest 還沒有建立出來,所以也不會有 memory leak 的狀況。
相較之下,C++17 還是比較安全的。
目前 C++17 的處理順序的規範,應該是 P0145R3 這篇 paper 裡所提到的:
下列的 expression 會以 a -> b 的順序來處理
a.b
a->b
a->*b
a(b1, b2, b3) // b1, b2, b3 - in any order
b @= a // '@' means any operator
a[b]
a << b
a >> b
而實際上,比較好的作法,應該還是要盡可能避免這種可能會有相依性、但是順序不一定的寫法了。
在 C++ Core Guidelines 裡也有建議:
- ES.43: Avoid expressions with undefined order of evaluation
- ES.44: Don’t depend on order of evaluation of function arguments
這篇主要是看到《Stricter Expression Evaluation Order in C++17》這篇文章,想到自己很久以前有踩到這類的地雷,所以想說來寫一下了。