C++26 function_ref and nontype_t
Motivation of std::function_ref
std::function_ref
是轻量级的 std::function
,能够引用任意形式的可调用对象。在此之前,C++ 便已存在许多同类问题下的解决策略,如函数指针、仿函数、模板、std::function
、std::move_only_function
……为何又要引入 std::function_ref
呢?
对于函数指针,虽然在需要保存函数的场景下能够最大化性能,但是它无法持有状态,也无法使用 Lambda 和 std::bind
等常见方式传参。采用这种策略,便舍弃了灵活性、通用性和安全性,通常是得不偿失。
对于仿函数,它能够持有状态,可用性更好,只是简洁性不足。Lambda 弥补了这个缺点,可以直接原地构造一个携带状态的函数,而无须再去创建一个额外的仿函数。Lambda 与模板配合使用,不会存在额外的开销,性能与函数指针基本持平。只是,相比起来,模板这种策略实现起来稍显复杂,需要对模板参数施加函数签名约束,并且无法分离声明与实现,微小变化也会导致重新编译。同时,由于 Lambda 函数的类型无法被存储,如果想要存储具体的可调用对象的类型,这种策略便无能为力,它在通用性和灵活性方面仍有不足。
对于 std::function
和 std::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
替代。