2018年8月24日

[C++] Lazy Evaluation for Function Argument

C++ 的 function call 在傳參數進去時基於一些因素會把所有參數都拿到最後的結果後才進入 function 本體去執行。比方說,傳進去的參數是一串四則運算時,會先算出最後的結果;如果也是個 function call,那也會先把 function call 執行完畢拿到結果。

在某些情況下如果我們知道其實進入 function 後根本不會用到這些參數的內容時,做這些計算其實就會變成浪費時間了。因此,最容易想到的解法是,有沒有辦法把這些運算的過程延遲到我真的需要時才讓他執行?



整個故事大概是這樣的:

之前幫忙寫了公司某個 project 的 debug log,其功用是一般情況下甚麼事都不會發生,觸發特定條件時就讓他發揮作用並且吐出 log file 以便 debug。舉例來說,像是下面這個 function:

print_debug_msg(XXX);

雖然呼叫了這個 function,但是只要沒有觸發特定條件 (像是設定了特定的環境變數) 就甚麼事都不會發生,一旦程式運行過程中發現條件滿足了就會把 XXX 的資訊印出來。

不過後來遇上一個問題,因為我實作這類 function 時為了方便使用,採用了 C++11 才有的 variadic template,像下面這樣:

template <typename ... Args>
void print(Args&& ... args)
{
    constexpr bool can_print = false;
    if (can_print) {
        (cout << ... << args) << '\n';
    }
}

can_print 因為是範例所以才這樣寫,實際上會根據想要設定的條件在執行期才知道結果。

一切看似美好,可以吃任意數量且任意型態的參數,只要他可以用 stream 輸出即可,換言之就算是自定義型別只要有 overload operator<< 基本上也能用,只是問題就出在傳進來的參數。

假設今天傳進來的參數是一個 function call:costly_func(),所以像下面這樣:

print(costly_func());

costly_func() 會回傳一個可以用 std::cout 輸出的東西,只是這個過程很耗時。當然我們會希望如果 can_print 是 false 時不會去執行 costly_func() 以免影響到效能,只是現在的寫法無法避免這點,因為在呼叫 print() 前基於 sequence point 的考量,所有的參數會在呼叫 print() 前全部執行完畢,換言之,costly_func() 必定會被呼叫,就算最後不會把它的結果印出來。很明顯的,以效能考量來看這點絕對是要避免的。

要避免的方法說來其實也不難,只要我們確定 can_print 是 true 時才真的去執行 costly_func() 就好,問題在於,怎麼做?幾個比較容易想到的方法是這樣的:
  1. 寫個 function ,假設叫 CanPrint(),把 can_print 包出去,呼叫 print 前先固定呼叫 CanPrint() 先確認是否真的有需要,確定需要的話才呼叫 print()
    1. 延伸這個做法,可以設定一個 Macro 代為做到這件事即可
  2. C++ 其實有辦法做到類似 lazy evaluation 的機制,只是要對 template 有一定的熟悉度 XD
其實我在公司的程式中有看到不少第 1 點的寫法,一來是這些 code 可能是用 C 寫的,二來則是這樣其實簡化問題非常多。再加上當時因為要支援多種不同的平台,程式寫法上似乎非常保守,這算是最不容易出狀況的寫法了。只是我個人其實不太喜歡在會被到處 include 的 header 中放 macro ,因為實在太容易遇到因為有 user-defined macro 導致跟一些既有 library 互衝的狀況 (前幾天才有同事遇到要用 C++11 的 regular expression 結果被 macro 搞到的問題)。取而代之的,我就只好想想看第 2 點能怎麼做。

其實 google 後會發現已經有一些類似的文章可以當參考了,核心概念其實很簡單:
把參數用個 lambda expression 包起來就好
像下面這樣:

print([]() { return costly_func();});

雖然只是小小的差異,但就能達到想要的效果了。原因在於 lambda expression 只有在你用 function call 時才會真的去執行,比方說:

auto LF = []() { return costly_func(); }; // 這時候不會執行 costly_func();
LF(); // 這邊才會真的去執行 costly_func();

原因其實不難想像,簡單來說可以把 lambda expression 想像成一個有 overload operator() 的 template class 就好,你寫在 lambda expression 中的東西就是當你呼叫到了這個 template class 的 opeterator() 才會被執行到。

只是這樣會遇上下一個問題:原本 print() 中的 fold expression

(cout << ... << args) << '\n';

必須改成

(cout << ... << args()) << '\n';

但是這樣又會有下一個問題:下面的 print() 會有 compilation error

print(1);

原因也不難理解:畢竟 1 這個參數不能當成 function 呼叫阿!

OK,所以綜合以上,如果要一個相對彈性一點的方案,我們最好讓 print 的參數可以根據是不是 function object 來採取不同的做法。最後結果如下:

template <bool is_func> struct Proxy;

template <>
struct Proxy<false> {
    template <typename T>
    auto&& print(T&& args) { return std::forward<T>(args); }
};

template <>
struct Proxy<true> {
    template <typename T>
    auto print(T&& args) { return args(); }
};

template <typename ... Args>
void PRINT(Args&& ... args)
{
    constexpr bool can_print = false;
    if (can_print) {
        (cout << ... << Proxy<std::is_invocable<Args>::value>().print(args)) << '\n';
    }
}

std::is_invocable 是 C++17 新增的 type traits,可以用來檢查指定的型別能不能當成 function 使用。利用這點,加上設計一個 Proxy structure 就能依據傳進來的參數是否可以當成 function 使用而有不同的行為,所以下面的寫法就變成可行的:

#define LAZY_ARG(e) [&]() { return e; }
int add(int x, int y)
{
    cout << "<call add>\n";
    return (x + y);
}
PRINT(1, 2, "abc", LAZY_ARG(add(3, 4)), LAZY_ARG(3+4));


雖然依舊有缺點,像是:
  1. std::invocable 是 C++17 才有的東西,compiler 不支援就只能自己想辦法 hack 或是用 boost
  2. 必須要自己在想要有 lazy evaluation 的參數自己加上類日 LAZY_ARG() 這類的 wrapper,難保 user 哪邊忘記加了
不過也是有優點:
  1. 彈性極大,而且其實有不少事情都是在 compile time 做掉的
  2. 適用於 can_print 之類的判斷難以外包出去時的情況 (比方說 object-base 的架構,判斷邏輯必須才需相依於 object 本身),這時候也不容易用 macro 達到類似效果

如果想要在 C++11/14 做到類似 std::is_invocable 的結果,在這個例子中可以用 SFINAE 簡單的達到,畢竟原本 std::is_invocable 的效用更強大,這邊我們只是想知道能不能當成 function 呼叫的話其實沒那麼複雜。

以下是最後的實作結果,有 C++11/14 & C++17 的兩種版本,其實不難發現如果支援 C++17 的話簡化超級多...

沒有留言:

張貼留言