實作中的 std::is_detected 和 Detection Idioms (C++17)

語言: CN / TW / HK

std::is_detected 和 Detection Idoms

本文鎖定於 C++17 範圍內談實作。

關於 std::is_detected

確切地説,是指 std::experimental::is_detected , std::experimental::detected_t , std::experimental::detected_or 。因為尚未被納入正式庫,所以在現行的編譯器中,它們通常至少需要 C++17 規範指定,幷包含專門的頭文件 <experimental/type_traits> 。參考這裏:cppref。

但是編譯器的支持也是參差不齊。所以我們一般都採用自定義的版本,同樣需要 C++17 規範(如果需要更低規範適配版本,請自行搜索),但表現能力更可靠、更可預測:

#if !defined(__TRAITS_VOIT_T_DEFINED)
#define __TRAITS_VOIT_T_DEFINED
// ------------------------- void_t
namespace cmdr::traits {
#if (__cplusplus > 201402L)
    using std::void_t; // C++17 or later
#else
    // template<class...>
    // using void_t = void;

    template<typename... T>
    struct make_void { using type = void; };
    template<typename... T>
    using void_t = typename make_void<T...>::type;
#endif
} // namespace cmdr::traits
#endif // __TRAITS_VOIT_T_DEFINED


#if !defined(__TRAITS_IS_DETECTED_DEFINED)
#define __TRAITS_IS_DETECTED_DEFINED
// ------------------------- is_detected
namespace cmdr::traits {
    template<class, template<class> class, class = void_t<>>
    struct detect : std::false_type {};

    template<class T, template<class> class Op>
    struct detect<T, Op, void_t<Op<T>>> : std::true_type {};

    template<class T, class Void, template<class...> class Op, class... Args>
    struct detector {
        using value_t = std::false_type;
        using type = T;
    };

    template<class T, template<class...> class Op, class... Args>
    struct detector<T, void_t<Op<Args...>>, Op, Args...> {
        using value_t = std::true_type;
        using type = Op<Args...>;
    };

    struct nonesuch final {
        nonesuch() = delete;
        ~nonesuch() = delete;
        nonesuch(const nonesuch &) = delete;
        void operator=(const nonesuch &) = delete;
    };

    template<class T, template<class...> class Op, class... Args>
    using detected_or = detector<T, void, Op, Args...>;

    template<class T, template<class...> class Op, class... Args>
    using detected_or_t = typename detected_or<T, Op, Args...>::type;

    template<template<class...> class Op, class... Args>
    using detected = detected_or<nonesuch, Op, Args...>;

    template<template<class...> class Op, class... Args>
    using detected_t = typename detected<Op, Args...>::type;

    /**
     * @brief another std::is_detected
     * @details For example:
     * @code{c++}
     * template<typename T>
     * using copy_assign_op = decltype(std::declval<T &>() = std::declval<const T &>());
     * 
     * template<typename T>
     * using is_copy_assignable = is_detected<copy_assign_op, T>;
     * 
     * template<typename T>
     * constexpr bool is_copy_assignable_v = is_copy_assignable<T>::value;
     * @endcode
     */
    template<template<class...> class Op, class... Args>
    using is_detected = typename detected<Op, Args...>::value_t;

    template<template<class...> class Op, class... Args>
    constexpr bool is_detected_v = is_detected<Op, Args...>::value;

    template<class T, template<class...> class Op, class... Args>
    using is_detected_exact = std::is_same<T, detected_t<Op, Args...>>;

    template<class To, template<class...> class Op, class... Args>
    using is_detected_convertible = std::is_convertible<detected_t<Op, Args...>, To>;

} // namespace cmdr::traits
#endif // __TRAITS_IS_DETECTED_DEFINED

當然,也涉及到 std::void_t,也是 C++17 才進入標準庫的。但你可以自己聲明,如同上面的 VOID_T 部分一樣。

但 is_detected 的低版本兼容部分我們就放棄了,太長了,我也不想加多單元測試負擔。

它的使用方式是這樣的:

#include <type_traits>
#include <string>

template<typename T>
using copy_assign_op = decltype(std::declval<T &>() = std::declval<const T &>());

template<typename T>
using is_copy_assignable = is_detected<copy_assign_op, T>;

template<typename T>
constexpr bool is_copy_assignable_v = is_copy_assignable<T>::value;

struct foo {};
struct bar {
  bar &operator=(const bar &) = delete;
};

int main() {
  static_assert(is_copy_assignable_v<foo>, "foo is copy assignable");
  static_assert(!is_copy_assignable_v<bar>, "bar is not copy assignable");
  return 0;
}

可以看到這是一種典型的 detection idioms 慣用法,能夠在編譯期測試出一個類型具有什麼特性。例如 is_chrono_duration, is_iterator, is_integer 等等,在標準庫中有大量預定義的檢測專用的 traits。

但在實際生活中我們通常還需要定義自己的。例如在我們的undo-cxx 中就有一組:

namespace undo_cxx {

  template<typename State,
  typename Context,
  typename BaseCmdT,
  template<class S, class B> typename RefCmdT,
  typename Cmd>
    class undoable_cmd_system_t {
      public:
      ~undoable_cmd_system_t() = default;

      using StateT = State;
      using ContextT = Context;
      using CmdT = Cmd;
      using CmdSP = std::shared_ptr<CmdT>;
      using Memento = typename CmdT::Memento;
      using MementoPtr = typename std::unique_ptr<Memento>;
      using Container = std::list<MementoPtr>;
      using Iterator = typename Container::iterator;

      using size_type = typename Container::size_type;

      template<typename T, typename = void>
      struct has_save_state : std::false_type {};
      template<typename T>
      struct has_save_state<T, decltype(void(std::declval<T &>().save_state()))> : std::true_type {};

      template<typename T, typename = void>
      struct has_undo : std::false_type {};
      template<typename T>
      struct has_undo<T, decltype(void(std::declval<T &>().undo()))> : std::true_type {};

      template<typename T, typename = void>
      struct has_redo : std::false_type {};
      template<typename T>
      struct has_redo<T, decltype(void(std::declval<T &>().redo()))> : std::true_type {};

      template<typename T, typename = void>
      struct has_can_be_memento : std::false_type {};
      template<typename T>
      struct has_can_be_memento<T, decltype(void(std::declval<T &>().can_be_memento()))> : std::true_type {};

      public:
      
      // ...
      
      void undo(CmdSP &undo_cmd) {
        if constexpr (has_undo<CmdT>::value) {
          // needs void undo_cmd::undo(sender, ctx, delta)
          undo_cmd->undo(undo_cmd, _ctx, 1);
          return;
        }

        if (undo_one()) {
          // undo ok
        }
      }
      
      // ...
    };
}

你可能注意到這個例子中壓根沒有用到 is_detected。

確實如此,Detection Idioms 包含一系列手法,並不是一定要用到哪一個工具模板,重點還是在於目標,它們都是為了在編譯期測試出某個類型的特性,以便針對性地進行特化、偏特化,或者用於完成其他任務。

所以這是個巨大無比的話題。

Detection Idioms

在提案 [WG21 N4436 - Proposing Standard Library Support for the C++ Detection Idiom [pdf]]( http://open-std.org/JTC1/SC22... ) 中,檢測慣用法被稱作 Detection Idiom。這個提案是在 C++20 中被加入,一部分原因在於它可以成為一個補全性的方案,另一方面則是因為 Concepts 一直提了十幾年卻都沒有定論。當然最後我們知道了,去年 C++20 定案之後 concepts 終於被加入了。

但是可以預見的是,未來直到 2023 年,工程當中使用 concepts 的可能性還是基本上為零。事實上 2023 年 C++17 如能成為工程應用主流的話就阿彌陀佛了,多數工程還是 C++11 的,而且那些遺留項目連 C++11 都不用呢。

作為一個提案的代表,Detection Idiom 是一個專有名詞。但檢測慣用法,卻是早已有之。或者説,traits 本來就是幹這個的。在本文中的檢測慣用法,會包含類型測試與約束,以及函數簽名檢測等等檢測方法。

在 C++11 之後,藉助於 SFINAE 我們有幾種選擇來完成類型約束與選擇:特化方式,添加 enable_if 測試的特化方式,藉助於 is_detected 的約束力。

普通的特化方式

模板參數的特化能力,對於一般情況的約束就是足夠的:

template<typename T>
bool max(T l, T r) { return l > r ? l : r; }

bool max(bool l, bool r) { return false; }

bool max(float l, float r) {
  return (l+0.000005f > r) ? l : (r+0.000005f>l) ? r : IS_NAN;
}

上面的例子沒有實際用處,只是用來展示特化的直接使用效果。

對於簡單的類型來説,這種特化能力就已經足夠了。不過遇到複雜的類型,特別是複合類型它的能力就比較短板了。

所以這種情況下我們需要藉助於 enable_if 來進行約束。

std::enable_if 方式

std::enable_if 可能的實現方法

std::enable_if 的實現方法可以比較簡單:

template <bool, typename T=void>
struct enable_if {};

template <typename T>
struct enable_if<true, T> {
  using type = T;
};

約束返回類型

對於函數返回類型來説,用法略有不同:

#include <iostream>
#include <type_traits>

class foo;
class bar;

template<class T>
struct is_bar {
    template<class Q = T>
    typename std::enable_if<std::is_same<Q, bar>::value, bool>::type check() { return true; }

    template<class Q = T>
    typename std::enable_if<!std::is_same<Q, bar>::value, bool>::type check() { return false; }
};

int main() {
    is_bar<foo> foo_is_bar;
    is_bar<bar> bar_is_bar;
    if (!foo_is_bar.check() && bar_is_bar.check())
        std::cout << "It works!" << std::endl;

    return 0;
}

這是測試類型並返回 bool 的用例,也是較為典型的如何編寫 traits 的用例。

不過為了真正説明函數返回類型模板化,還是要下面這個例子:

#include <iostream>
#include <type_traits>

namespace AAA {
    template<class T>
    class Y {
    public:
        template<typename Q = T>
        typename std::enable_if<std::is_same<Q, double>::value || std::is_same<Q, float>::value, Q>::type foo() {
            return 11;
        }
        template<typename Q = T>
        typename std::enable_if<!std::is_same<Q, double>::value && !std::is_same<Q, float>::value, Q>::type foo() {
            return 7;
        }
    };
} // namespace

int main(){
#define TestQ(typ)  std::cout << "T foo() : " << (AAA::Y<typ>{}).foo() << '\n'

    TestQ(short);
    TestQ(int);
    TestQ(long);
    TestQ(bool);
    TestQ(float);
    TestQ(double);  
}

輸出為:

T foo() : 7
T foo() : 7
T foo() : 7
T foo() : 1
T foo() : 11
T foo() : 11

這是一個實用化的用例,可以直接檢測 double 或者 float 類型。

在 traits 中使用

測試一個模板中是否有名為 value 的類型定義:

template <typename T, typename=void>
struct has_typed_value;

template <typename T>
struct has_typed_value<T, typename std::enable_if<T::value>::type> {
    static constexpr bool value = T::value;
};

template<class T>
inline constexpr bool has_typed_value_v = has_typed_value<T>::value;

static_assert(has_typed_value<std::is_same<bool, bool>>::value, "std::is_same<bool, bool>::value is valid");
static_assert(has_typed_value_v<std::is_same<bool, bool>>, "std::is_same<bool, bool>::value is valid");

類似地:

template <typename T> struct has_typed_type;
template <typename T>
struct has_typed_type<T, typename std::enable_if<T::value>::type> {
    static constexpr bool value = T::type;
};

template<class T>
inline constexpr bool has_typed_type_v = has_typed_type<T>::value;

使用 conjuction

C++17 之後就可以直接使用 std::conjuction ,它可以用於組合一組 detectors。

這裏只給出一個 sample 片段:

template <class T>
  using is_regular = std::conjunction<std::is_default_constructible<T>,
    std::is_copy_constructible<T>,
    supports_equality<T,T>,
    supports_inequality<T,T>, //assume impl
    supports_less_than<T,T>>; //ditto

更多的

檢測成員函數存在性

declval 方式

這個用例比較獨立自主,什麼都自己來,自己定義了 void_t,自己定義了名為 supports_foo 的 traits,目的是為了檢測類型 T 是不是有 T::get_foo() 函數簽名的存在。最後,calculate_foo_factor() 的特化目的就顯而易見,無需解釋了。

template <class... Ts>
using void_t = void;

template <class T, class=void>
struct supports_foo : std::false_type{};

template <class T>
struct supports_foo<T, void_t<decltype(std::declval<T>().get_foo())>>
: std::true_type{};

template <class T, 
          std::enable_if_t<supports_foo<T>::value>* = nullptr>
auto calculate_foo_factor (const T& t) {
  return t.get_foo();
}

template <class T, 
          std::enable_if_t<!supports_foo<T>::value>* = nullptr>
int calculate_foo_factor (const T& t) {
  // insert generic calculation here
  return 42;
}

它採用了 declval 的 偽造實例 的技術,對此可以參考我們的 std::declval 和 decltype 一文。

Is_detected 方式

改用 is_detected 方式:

template<typename T>
using to_string_t = decltype(std::declval<T &>().to_string());

template<typename T>
constexpr bool has_to_string = is_detected_v<to_string_t, T>;

struct AA {
  std::string to_string() const { return ""; }
};

struct BB{};

static_assert(has_to_string<AA>, "");
static_assert(!has_to_string<BB>, "");

這個沒什麼好説的,你可以用 std::experimental::is_detected,也可以使用我們前文中定義的 is_detected 工具。

作為一個補充

在沒有 std::enable_if 的時候,需要藉助於 struct char[] 方式,這種技巧被稱作Member Detector,是 C++11 之前的經典慣用法:

template<typename T>
class DetectX
{
    struct Fallback { int X; }; // add member name "X"
    struct Derived : T, Fallback { };

    template<typename U, U> struct Check;

    typedef char ArrayOfOne[1];  // typedef for an array of size one.
    typedef char ArrayOfTwo[2];  // typedef for an array of size two.

    template<typename U> 
    static ArrayOfOne & func(Check<int Fallback::*, &U::X> *);
    
    template<typename U> 
    static ArrayOfTwo & func(...);

  public:
    typedef DetectX type;
    enum { value = sizeof(func<Derived>(0)) == 2 };
};

而且還有配套的宏定義 GENERATE_HAS_MEMBER(member) ,所以在應用代碼中只需要:

GENERATE_HAS_MEMBER(att)  // Creates 'has_member_att'.
GENERATE_HAS_MEMBER(func) // Creates 'has_member_func'.

std::cout << std::boolalpha
  << "\n" "'att' in 'C' : "
  << has_member_att<C>::value // <type_traits>-like interface.
    << "\n" "'func' in 'C' : "
    << has_member_func<C>() // Implicitly convertible to 'bool'.
    << "\n";

也是很瘋狂。

進一步延伸

上一節提供的技術,僅僅對函數簽名中的函數名本身進行檢測。有時候,可能我們會在想對形參表或者返回類型做檢測並約束。有可能嗎?

確實是有的。

抽出函數返回類型

return_type_of_t<Callable> 可以抽出函數的返回類型:

namespace AA1 {
  template<typename Callable>
  using return_type_of_t =
  typename decltype(std::function{std::declval<Callable>()})::result_type;

  int foo(int a, int b, int c, int d) {
    return 1;
  }
  auto bar = [](){ return 1; };
  struct baz_ { 
    double operator()(){ return 0; } 
  } baz;

  void test_aa1() {
    using ReturnTypeOfFoo = return_type_of_t<decltype(foo)>;
    using ReturnTypeOfBar = return_type_of_t<decltype(bar)>;
    using ReturnTypeOfBaz = return_type_of_t<decltype(baz)>;

    // ...
  }
}

在這個基礎上你就可以運用 enable_if 或者 is_detected 了。具體檢測略略略……

檢測函數形參表

至於形參表的檢測問題,有點而麻煩,而且這一話題也很大,方向較多,所以我暫且給出一個方向供你參考,其他方向也就是萬變不離其宗了。

bar_t 可以以 variadic 參數的方式羅列出類型表,並用來檢測類型 T 是不是有一個函數 bar() 而且還有相應的形參表:

template<class T, typename... Arguments>
using bar_t = std::conditional_t<
        true,
        decltype(std::declval<T>().bar(std::declval<Arguments>()...)),
        std::integral_constant<
                decltype(std::declval<T>().bar(std::declval<Arguments>()...)) (T::*)(Arguments...),
                &T::bar>>;

struct foo1 {
    int const &bar(int &&) {
        static int vv_{0};
        return vv_;
    }
};

static_assert(dp::traits::is_detected_v<bar_t, foo1, int &&>, "not detected");

暫時我沒有想法將它通用化,所以你需要具體情況具體拷貝和修改。

誰要是有改進版,不妨通知我。

檢測 begin() 存在性並調用它

前文對成員函數存在性已經介紹過了,這裏是一個實用的片段:檢測成員函數存在與否,存在的話就調用它,否則的話調用我們的備用實現方案:

// test for `any begin() const`
template <typename T>
using begin_op = decltype(std::declval<T const&>().begin());

struct A_Container {
  template <typename T>
  void invoke_begin(T const& t){
    if constexpr(std::experimental::is_detected_v<begin_op, T>){
      t.begin();
    }else{
      my_begin(); // begin() not exists!!
    }
  }
  iterator my_begin() { ... }
};

為什麼不直接使用 SFINAE 技術呢?

因為 SFINAE 技術能夠讓我們做出 invoke_begin 的特化版本,但這有可能阻礙了我們的進一步拓展性。SFINAE 僅能在類型不匹配時起作用,但採用 begin_op 的方式我們可以:針對返回類型,針對形參表,針對 const or not,等等。

檢測 emplace(Args &&...)

這要用到我們前面的 bar_t 技法:

template<class T, typename... Arguments>
  using emplace_variadic_t = std::conditional_t<
  true,
decltype(std::declval<T>().emplace(std::declval<Arguments>()...)),
std::integral_constant<
  decltype(std::declval<T>().emplace(std::declval<Arguments>()...)) (T::*)(Arguments...),
&T::emplace>>;

/**
 * @brief test member function `emplace()` with variadic params
 * @tparam T 
 * @tparam Arguments 
 * @details For example:
 * @code{c++}
 * using C = std::list<int>;
 * static_assert(has_emplace_variadic_v<C, C::const_iterator, int &&>);
 * @endcode
 */
template<class T, typename... Arguments>
  constexpr bool has_emplace_variadic_v = is_detected_v<emplace_variadic_t, T, Arguments...>;

namespace detail {
  using C = std::list<int>;
  static_assert(has_emplace_variadic_v<C, C::const_iterator, int &&>);
} // namespace detail

More...

在cmdr-cxx 中有一組 detection traits,是用於檢測標準庫容器函數簽名的。在undo-cxx 中的 undoable_cmd_system_t<State> 中我們運用了檢測函數名存在性並調用它的技法。

關於 _t_v

在標準庫和標準化推行中, _t_v 後綴隱含着直接提供 ::type 或者 ::value 成員的意思。

但這並不影響我(們)慣於使用 _t 表示一個模板類。

在一個模板類被用做基礎類、被當成工具類使用時,我(們)喜歡為其增加 _t 後綴。

Refs

後記

檢測慣用法實在是太大了,本文只是導引,將基本工具提供出來。後續未來應該會繼續就此問題介紹我的經驗。