[重新理解 C++] TMP(3): Type deduction 和一些運用


前言

如果有看過前面文章的話,可能會有一些人對這段 code 感到困惑

#include <iostream>
#include <vector>

template<class T>
void print_vector(const std::vector<T>& v) {
    std::cout << v << std::endl;
}

int main() {
    return 0;
}

你可能一眼就可以看的出來這段 code 的行為和意圖,
但是第一次見到這種寫法難免會覺得頭上冒圈圈很奇異
而且不太確定 print_vector 要怎麼使用

這一篇文章主要就是要講這部分的觀念

然而 type deduction 的水真的很深,所以這次就講一些常見的運用就好了

function template deduction

#include <iostream>
#include <vector>

template<class T>
void print_vector(const std::vector<T>& v) {
    // std::cout << v << std::endl;
}

int main() {
    std::vector<int> x;
    print_vector(x);
    return 0;
}

你會發現你不需要在呼叫 print_vector 當下明示 T 的型別就能夠使用 print_vector
即便這個 T 是包裝在 vector 內部的型別參數
所以你可以想像,某種意義上 C++ 的 template type deduction 有點類似於一種型別的pattern match

一個簡單的範例: optional

Optional 是一個 C++17 的新 STL library,用來表示一個 object 是否存在一個值
可能有人會認為這件事好像用 pointer 就能表示
事實上他們的確差不多
但是 optional 提供了更嚴謹的設計
這部分未來有機會會提到
至於要不要用就見仁見智

使用 optional<T> 會遇到一個問題就是因為 T 被 optional 封裝了,
所以我們沒有辦法直接使用本來 T 可以使用的任何函數,包括各種 operator
這有點不夠直覺,所以這個範例我們希望設計一種方式讓 optional<T> 封裝過的型別可以使用 T 本來定義過的 operator 和 function

這裡我們可以從一個簡單的起點出發,先思考 operator+ 要怎麼處理

template<class T>
std::optional<T> add(std::optional<T> a, std::optional<T> b) {
    if(!a.has_value()) return std::nullopt;
    if(!b.has_value()) return std::nullopt;
    return std::optional<T>(a.value() + b.value());
}

這段 code 的邏輯大意就是當加法運算的兩個參數其中一者沒有值的話,運算結果就是沒有值的 optional
反之就是回傳一個兩個參數中封裝的型別相加的結果,也就是說在這個範例裡,int + int 或 string + string 都會 work 的。

這個看起來不錯,但是想像一下 T 可以是任何型別,運算可以是 +-*/ 也可以是 member function call
這樣想起來好像這種 code 要真正的支援所有型別對所有運算不太實際

但是其實是有機會做得好看一些的
同樣也是利用各種 function type deduction 來做這件事

#include <optional>
#include <iostream>
template<class T, class Func>
auto bind(std::optional<T> val, Func access) {
    if(val.has_value()) {
        return access(val.value());
    }
    return std::nullopt;
}

int main() {
  std::optional<int> a = 23;
  std::optional<int> b = 10;
  auto c = bind(a, [&](auto av) {
    return bind(b, [&](auto bv) {
      return std::optional<int>(av + bv);
    });
  });

  std::cout << c.value() << std::endl;  // just log it
}

這個範例中,bind 做的事情就是提供一個管道可以讓 caller 直接 access 到 optional 中的型別 從而避免直接 access optional.value() 觸發 throw exception 的可能性

而 bind 的 access 函數必須是一個接收 T 回傳 optional<T> 的函數
如此一來,bind 便可以巢狀也可以串接
儘管看起來還有點醜,但是它已經做到基本的保護目的

你可以想像,如果你總是使用 bind 來操作 optional, 在最內層的 context 你總是可以得到所有 optional 內部的 value 並且進行最直觀的運算,同時保證不會發生 throw exception

...

等等,這段 code 編譯無法通過
不管你用哪個 compiler
錯誤訊息基本會指出 std::nullopt_toptional<int> 型別不相容

是的,我們還少做了一些事情
還記得上一篇提到的編譯路徑問題嗎?
在這個 bind function 中,多個 return 路徑並沒有統一一種型別
在這裡optional<T> 和 nullopt_t 並不是同一種型別
你可能會想到那我就把 return type 改成 optional<T> 就好了不是嗎?

這是錯誤的,因為 accecc function 並沒有保證回傳 optional<T>

事實上,我們希望它可以回傳任何 optional 包裝的型別
所以這裡我們必須用 auto 來表示 return type

那麼怎麼解決這個問題呢?

窮人的 reflection: decltype

這裡要開始走進 concept 的第一步,decltype
decltype 的功能基本上就是接收一個值,回傳這個值的型別

在 bind 範例中,我們想要的是 access function 的 return type(假設他總是為 optional 包裝的型別)
同時我們知道 access function 總是接收一個 T 為參數

所以這個問題就很好用 decltype 處理,我們稍微修改 bind 的實作

template<class T, class Func>
auto bind(std::optional<T> val, Func access) {
    using ResType = decltype(access(T{}));
    if(val.has_value()) {
        return access(val.value());
    }
    return ResType(std::nullopt);
}

在這個修改中,我們利用 T{} 創造了一個 T 的 value 並且傳進 access 然後再用 decltype 接收 access 的 result
最後用 using ResType 來接收回傳型別

此時你可能會想到,access 多呼叫了一次是不是會有效能代價? T{} 直接這樣傳進 access 是不是會又非預期的執行結果?

答案是完全沒有

因為被 decltype 框住的內容,完全只用於編譯期的型別推導而不會對執行期產生任何作用
所以 access 也不會多一次呼叫,T{} 也沒有實際建構任何物件

這是最好的做法嗎?

其實不是
事實上,我們沒有對 access 做任何型別檢查
雖然 caller 如果胡亂傳入非我們預期的型別,大概都不會通過編譯
但是它的編譯訊息會非常之醜陋,而且總有一些意外可以讓一些不希望的出現的型別通過編譯

其次是 auto return 其實是應該要謹慎使用的寫法

在絕大多數你能預期 return type, 而且 type 符號簡單的情況下,你應該盡可能 specify return type 的具體型別
也就是說,最理想的情況應該用類似這種表示法 std::optional<auto> 來作為 return type,
然而很遺憾的,這個語法曾一度被提出但卻沒有被 C++ standard 接受

但對等的 C++ 還是有對這種情境給出妥善的處理方式
無奈本人時間有限,本系列文提供的知識尚無法妥善處理這件事情

之後提到 enable_if, concept 的時候就會有一個更好的解決方式。

Summary

如果你可以完全理解這篇文章提供的範例
那其實你也已經想通了一種在其他語言中會出現的一個名詞叫做 Monad 中一部分最重要的概念
而 bind 就是你可能會看到的一種常出現在範例的符號: >>=

使用方式大概是這樣

a >>= b >>= c >>= d

你可以想像,如果 bind 的呼叫方式是 a bind access0 bind access1 bind access2
那就會在形式上跟那些範例一樣

而巧的是 C++ 有 operator overloading
所以你隨便挑一種 operator 把 bind 的實作搬進去就可以達到這種中綴呼叫的效果了。

剩下的其實就是一些語言支援 curry function 後就能達到的事情,這部分就不是那麼重要

老實說,很難想像那些語言的教學文章居然要花那麼大的力氣去解釋這個如此簡單的概念
然後看的人還不見得看得懂...

所以這篇文章就藉著解釋 C++ type deduction 的各種寫法順便把這個概念帶過了
如果你有看懂,那麼就恭喜你一石二鳥了

#meta programming







Related Posts

[ 紀錄 ] 實戰練習 - 留言版 (實作前端)

[ 紀錄 ] 實戰練習 - 留言版 (實作前端)

Selenium with JS and infinite scroll

Selenium with JS and infinite scroll

Day 122

Day 122






Comments