C++ 程式執行的順序

| | 0 Comments| 13:15
Categories:

不知道一般人覺得下面這段 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 的順序來處理

  1. a.b
  2. a->b
  3. a->*b
  4. a(b1, b2, b3) // b1, b2, b3 - in any order
  5. b @= a // '@' means any operator
  6. a[b]
  7. a << b
  8. a >> b

    而實際上,比較好的作法,應該還是要盡可能避免這種可能會有相依性、但是順序不一定的寫法了。

    C++ Core Guidelines 裡也有建議:


    這篇主要是看到《Stricter Expression Evaluation Order in C++17》這篇文章,想到自己很久以前有踩到這類的地雷,所以想說來寫一下了。

    Leave a Reply

    發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *