Motivation of std::function_ref

std::function_ref 是轻量级的 std::function,能够引用任意形式的可调用对象。在此之前,C++ 便已存在许多同类问题下的解决策略,如函数指针、仿函数、模板、std::functionstd::move_only_function……为何又要引入 std::function_ref 呢?

对于函数指针,虽然在需要保存函数的场景下能够最大化性能,但是它无法持有状态,也无法使用 Lambda 和 std::bind 等常见方式传参。采用这种策略,便舍弃了灵活性、通用性和安全性,通常是得不偿失。

对于仿函数,它能够持有状态,可用性更好,只是简洁性不足。Lambda 弥补了这个缺点,可以直接原地构造一个携带状态的函数,而无须再去创建一个额外的仿函数。Lambda 与模板配合使用,不会存在额外的开销,性能与函数指针基本持平。只是,相比起来,模板这种策略实现起来稍显复杂,需要对模板参数施加函数签名约束,并且无法分离声明与实现,微小变化也会导致重新编译。同时,由于 Lambda 函数的类型无法被存储,如果想要存储具体的可调用对象的类型,这种策略便无能为力,它在通用性和灵活性方面仍有不足。

对于 std::functionstd::move_only_function,它们易于使用,能够存储任意形式的可调用对象,通用性和灵活性更强,但可用性又落了下风。主要表现在两个方面:一是它们分别要求目标对象具有拷贝或移动构造函数,二是开销较大。

std::function_ref 解决了以上策略的不足,其在简洁性、可用性和通用性等方面都具有较好的表现。下表对比了各种策略在不同设计目标下的表现:

策略 简洁性 美感 可用性 通用性
函数指针
模板
std::(moveonly)function
std::function_ref

Using std::function_ref

std::function_ref 具有以下 5 种形式的构造函数:

template<class F>
function_ref(F* f) noexcept; // (1)

template<class F>
function_ref(F&& f) noexcept; // (2)

template<auto f>
function_ref(std::nontype_t<f>) noexcept; // (3)

template<auto f, class U>
function_ref(std::nontype_t<f>, U&& obj) noexcept; // (4)

template<auto f, class T>
function_ref(std::nontype_t<f>, /*cv*/ T* obj) noexcept; // (5)

本节只看前两种形式,其他形式放在后续的小节中讨论。

With Free Functions

第一种形式的构造函数用于支持普通函数,例如:

int get_value() {
    return 42;
}

function_ref<int()> fr = get_value;
auto val = fr(); // calls (1)

get_value 函数类型可以隐式转换为函数指针,匹配第一种构造重载。

With Lambda

get_value 由普通函数改为 Lambda,就可以匹配第二种构造函数:

auto get_value = [] { return 42; };
function_ref<int()> fr = get_value;
auto val = fr(); // calls (2)

此处的 get_value 是一个左值,而第二种构造函数的参数类型是 Forwarding References,能够同时接受左值和右值。

With Member Functions

成员函数必须绑定一个对象才能被调用,借助 Lambda 或者 std::bind 可以达到这一目的:

struct S {
    int get_value() {
        return 42;
    }
};

S s;
// auto mem_fn = [&s] { return s.get_value(); };
auto mem_fn = std::bind(&S::get_value, &s);
function_ref<int()> fr = mem_fn;
auto val = fr(); // calls (2)

此处的 mem_fn 依旧是一个左值,不会产生任何问题。

只是,每次调用都需要额外地提供一个临时变量,丧失了简洁性,若是对第二种构造函数传入右值会发生什么呢?

The Lifetime Trap of std::function_ref

依赖过往使用 std::function 所产生的惯性,用户的第一反应是直接编写这样的代码:

function_ref<int()> fr = [] { return 42; };
auto val = fr(); // UB! fr refers to a destroyed temporary.

然而,std::function_ref 并不会像 std::function 那样持有函数资源,它仅仅是对可调用对象的引用。因此,如果传入临时对象,就有可能产生 Dangling References,std::function_ref 引入的临时对象的生命周期已经结束,再访问该对象将产生未定义行为。

由于示例较小,运行后也许仍能够得到预期的结果,这只不过是因为刚释放的临时对象的内存还没有被新的内容覆盖。一种检测方式是追踪指向对象的生命周期,如:

struct lam {
    lam() { std::cout << "lam()\n"; }
    ~lam() { std::cout << "~lam()\n"; }
    auto operator()() const {
        return 42;
    }
};

function_ref<int()> fr = lam{};
std::cout << "before calling fr\n";
auto val = fr(); // UB!
std::cout << "after calling fr\n";

将 Lambda 对象显式地写出来,便可以添加日志来追踪它的生命周期。输出为:

lam()
~lam()
before calling fr
after calling fr

可见,在调用 fr() 之前,引用的对象已经失效了,所以调用的行为是未定义的,只是恰好与预期行为一致而已。

另一种检测方式是借助 AddressSanitizer,在编译程序时,GCC 和 Clang 可以通过添加 -fsanitize=address 编译选项来开启,MSVC 对应的编译选项为 /fsanitize=address。如果成功检测到问题,则会产生以下错误:

==1==ERROR: AddressSanitizer: stack-use-after-scope on address 0x...

不过,太过简单的示例还是有可能无法检测出来,而带状态的 Lambda 能够成功检测出 Dangling References 问题:

S s;
function_ref<int()> fr = [&s] { return s.get_value(); };
auto val = fr(); // UB!

这种潜在的生命周期陷阱难以察觉,如果只是这样,那么 std::function_ref 的易用性、安全性和效率连已有的策略都不如,大概相当于:

策略 简洁性 美感 可用性 通用性
std::function_ref

因此,此时的 std::function_ref 在简洁性、美感和可用性等方面仍有提升空间。

The Arrival of std::nontype_t

std::function_ref 通过增加后三种构造函数来解决这些缺陷,其中的关键是 std::nontype_t 类型。

初听 std::nontype_t,直观感受是这是一个不是类型的类型,名称颇具迷惑性。其实,它不过是一个编译期的参数值。在 C++ Generative Metaprogramming 一书的第 8.7 节,我介绍过两种强制保证编译期参数的方式,这里的 std::nontype_t 就相当于第一种方式中的 compile_time_param

template<auto>
struct compile_time_param {};

template<auto V> inline constexpr auto compile_time_cast =
    compile_time_param<V>{};

而下面是 std::nonetype_t 相关组件的实现:

template<auto V>
struct nontype_t {
    explicit nontype_t() = default;
};

template<auto V> inline constexpr nontype_t<V> nontype{};

nontype 变量模板则与 compile_time_cast 变量模板对应。

该技巧的来龙去脉在书中已经完整地介绍过,不明白的可以重新读一下第 8 章,此处不再重提。简而言之,std::nontype_t 解决问题的思路就是将临时对象的生命周期转移到了编译期,临时对象从右值被转换成了左值,不会再立即释放,也就间接避免了 Dangling References 问题。

借助 std::nontype 能够保证 std::function_ref 的调用是安全的,下面是一些示例:

// All of the calls are safe.
S s;
function_ref<int()> fr = { nontype<&S::get_value>, s };
function_ref<int()> fr = { nontype<[](S& s) { return s. get_value(); }>, s };
function_ref<int()> fr = { nontype<[]() { return 42; }>};

除了普通函数不存在潜在的生命周期问题,std::function_ref 引用其他类型的函数都有可能会产生 Dangling References,因为建议全部采用 std::nontype 调用以保持一致性和安全性。

Examples

std::function_ref 只是引用可调用对象,不真正拥有资源,因此传递的开销很低。

下面是一个使用示例:

int retry(size_t times, function_ref<int()> action) {
    if (times--) {
        return retry(times, action);
    }

    return action();
}

auto status = retry(0, nontype<[] { return 200; }>);

涉及递归,便需要多次传递同一个可调用对象,旧有的多种策略都有各种各样的缺陷,而 std::function_ref 可以比较直观而高效地完成这项工作。

Summary

std::function_ref 可以持久化存储一个函数的引用,是轻量级的可调用对象包覆工具。

因为是引用已有的可调用对象,所以如何保证可调用对象的生命周期成为了一个关键的问题。std::nontype 变量模板是一种强制保证编译期执行的工具,能够把临时对象转换成编译期变量,使右值变成左值,避免临时对象产生的 Dangling References 问题。

这个新的工具虽然不大,但具备一定的使用场景,凡是想避免 std::function 产生的额外开销时,就可以尝试以 std::function_ref 替代。

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.