C++标签派发技术

《通过返回值’重载’函数》 中提到这样一个需求:设计一个计算函数耗时的接口,针对有返回值的函数,这个接口返回耗时和函数的返回值;对于没有返回值的函数,这个接口只返回耗时。

期望的的调用方式如下:

1
2
(cost, ret) = costTimeMs(funcA, a, b);
cost = costTimeMs(funcB, c, d, e);

改造costTimeMs

在这不讨论这个需求和接口的合理性,上述链接中通过std::enable_if实现了这个需求。最近学习了另一种技巧 – tag dispatch即标签派发,本文会通过标签派发实现这个需求。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct has_return_tag {};
struct no_return_tag {};

template <typename T>
struct return_tag {
    using tag = has_return_tag;
};
template <>
struct return_tag<void> {
    using tag = no_return_tag;
};

template<typename F, typename ...Args>
std::pair<double, typename std::invoke_result_t<F, Args...>>
costTimeMs(has_return_tag, F&& f, Args&&... args) {
    const auto start = std::chrono::high_resolution_clock::now();
    const auto ret = std::forward<F>(f)(std::forward<Args>(args)...);
    const auto end = std::chrono::high_resolution_clock::now();
    return {std::chrono::duration<double>(end - start).count(), ret};
}

template<typename F, typename ...Args>
double
costTimeMs(no_return_tag, F&& f, Args&&...args) {
    const auto start = std::chrono::high_resolution_clock::now();
    std::forward<F>(f)(std::forward<Args>(args)...);
    const auto end = std::chrono::high_resolution_clock::now();
    return std::chrono::duration<double>(end - start).count();
}

template<typename F, typename ...Args>
auto costTimeMs(F&& f, Args&&... args) {
    using tag = typename return_tag<typename std::invoke_result_t<F, Args...>>::tag;
    return costTimeMs(tag{}, std::forward<F>(f), std::forward<Args>(args)...);
}

// ...
auto foo1 = []() -> int32_t {return 1;};
auto foo2 = []() -> void {};
static_assert(sizeof(costTimeMs(foo1)) > 8);
static_assert(sizeof(costTimeMs(foo2)) == 8);

标签类型需要放到可变参的前面,否则可能被当作可变参的一部分。

并不像concept适用与C++20,这个技巧也适用于C++11,替换上述实现中与C++17相关的部分,这样实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
template<typename F, typename ...Args>
std::pair<double, typename std::result_of<F(Args...)>::type>
costTimeMs(has_return_tag, F&& f, Args&&... args) {
    const auto start = std::chrono::high_resolution_clock::now();
    const auto ret = std::forward<F>(f)(std::forward<Args>(args)...);
    const auto end = std::chrono::high_resolution_clock::now();
    return {std::chrono::duration<double>(end - start).count(), ret};
}

template<typename F, typename ...Args>
double
costTimeMs(no_return_tag, F&& f, Args&&...args) {
    const auto start = std::chrono::high_resolution_clock::now();
    std::forward<F>(f)(std::forward<Args>(args)...);
    const auto end = std::chrono::high_resolution_clock::now();
    return std::chrono::duration<double>(end - start).count();
}

template<typename F, typename ...Args,
         typename result_t = typename std::result_of<F(Args...)>::type,
         typename tag_t = typename return_tag<result_t>::tag>
auto costTimeMs(F&& f, Args&&... args)
-> typename std::conditional<std::is_same<result_t, void>::value,
                             double,
                             std::pair<double, result_t>>::type {
    return costTimeMs(tag_t{}, std::forward<F>(f), std::forward<Args>(args)...);
}

仅有invoke_result_tauto返回值推导的变化。

一般实现

总结上述实现,标签派发技术看起来有几个关键点:

  • 定义不同的标签类型,一般是空结构体,比如has_return_tagno_return_tag
  • 定义一个可以取得不同标签的接口,比如return_tag
  • 根据不同的标签类型,实现不同的函数重载(注意是重载,而不是SFINAE
  • 实现用户接口,屏蔽标签类型的细节

最后一点也并非必须,因为关注到了std::adopt_lock_t等类型,实际上就是一个标签类型,用于给用户选择。

暴露标签有什么好处呢?比如std::unique_lock的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
template<typename _Mutex>
class unique_lock
{
public:
    typedef _Mutex mutex_type;

    unique_lock() noexcept
    : _M_device(0), _M_owns(false)
    { }

    explicit unique_lock(mutex_type& __m)
    : _M_device(std::__addressof(__m)), _M_owns(false)
    {
lock();
_M_owns = true;
    }

    unique_lock(mutex_type& __m, defer_lock_t) noexcept
    : _M_device(std::__addressof(__m)), _M_owns(false)
    { }

    unique_lock(mutex_type& __m, try_to_lock_t)
    : _M_device(std::__addressof(__m)), _M_owns(_M_device->try_lock())
    { }

    unique_lock(mutex_type& __m, adopt_lock_t) noexcept
    : _M_device(std::__addressof(__m)), _M_owns(true)
    {
// XXX calling thread owns mutex
    }
//...
};

可以根据不同的lock_tag进行不同的构造,如果让我实现,考虑有几种方式:

  • 模板
    • 考虑偏特化问题???
  • if constexpr条件判断
    • 使用constexpr可以在编译期确定,个人认为还不错,但是在unique_lock这个具体的例子上也不好,这样就无法使用构造初始化,只能赋值初始化
    • 另一个原因是注意到try_to_lock_t并没有noexcept保证,我们得保留这个特性
  • tag dispatch
    • 重载的方式,目前看起来还不错

cppreference也给出过建议,优先考虑if constexprtag dispatch,最后是SFINAE。

Where applicable, tag dispatch, if constexpr(since C++17), and concepts (since C++20) are usually preferred over use of SFINAE.

更简单的方式

另外,在接受这个接口需求设计的前提下,抛开tag dispatch,使用C++17则可以有更简单的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
template<typename F, typename ...Args>
auto costTimeMs(F&& f, Args&&... args) {
    using result_t = typename std::invoke_result_t<F, Args...>;
    if constexpr (std::is_same_v<result_t, void>) {
        const auto start = std::chrono::high_resolution_clock::now();
        std::forward<F>(f)(std::forward<Args>(args)...);
        const auto end = std::chrono::high_resolution_clock::now();
        return std::chrono::duration<double>(end - start).count();
    } else {
        const auto start = std::chrono::high_resolution_clock::now();
        const auto ret = std::forward<F>(f)(std::forward<Args>(args)...);
        const auto end = std::chrono::high_resolution_clock::now();
        return std::pair{std::chrono::duration<double>(end - start).count(), ret};
    }
}

参考链接

参考链接如下,其中我是在《CppCoreGuidelines-zh-CN》接触到这个概念的。这些技术(技巧)就像《tagged-pointer-让指针包含更多信息》一样,让我眼前一亮,原来还可以这么玩;不过始终是要用起来了才是好的。