您应该通过 std::function 还是通过通用引用将可调用对象作为接收器参数传递?
Should you pass a callable object as a sink argument by std::function or by a universal reference?
考虑以下代码:
#include <functional>
#include <iostream>
struct S1
{
S1(const std::function<void()>& _func) : func{_func} {}
std::function<void()> func;
};
struct S2
{
template<typename F>
S2(F&& _func) : func{std::forward<F>(_func)} {}
std::function<void()> func;
};
int main()
{
S1 s1{[](){ std::cout << "S1 function called\n"; }};
S2 s2{[](){ std::cout << "S2 function called\n"; }};
s1.func();
s2.func();
return 0;
}
我试图了解这 2 个构造函数实现的设计 benefits/drawbacks。
另外,两者在性能上有什么区别吗
我的主要问题是,当知道可调用对象的签名并将其作为 std::function 存储在 class?
据我所知,通用引用是首选方法,因为它为编译器提供了进行内联的最佳机会。
但是,我可以想到使用通用引用的一些缺点:
- 它可能会与采用相同数量参数的其他构造函数发生冲突,因此应使用 SFINAE 或 C++20 概念加以保护。 (这是一个库问题,所以没什么大不了的)
- 它不太明确它期望什么样的可调用对象(也可以使用概念来约束)。 (这可以被认为是用户问题)
- 由于前面的缺点,unfitting callable 的错误点从调用站点移动到成员初始化站点,这更难理解。 (这绝对是用户问题)
我想我真正的问题是:
您是否应该在构造函数接口中公开 class 的 std::function 性质,或者这是一个必须隐藏的实现细节?
第一个例子总是会被复制,即使这可以避免,所以它不是最优的。
第二个例子有你提到的缺点。
推荐的方式是按值取值移动:
struct S3
{
S3(std::function<void()> _func) : func{std::move(_func)} {}
std::function<void()> func;
};
现在这看起来可能违反直觉,因为您按值获取复制成本可能很高的对象。但这不是问题。让我们分析一下可能出现的情况:
来电者无法给您可移动的物品。在这种情况下,副本是不可避免的。这导致复制后跟移动:
auto l = []() {};
S3 s4{l};
l();
来电者给了你一个xvalue。在这种情况下,涉及 2 个动作:
auto l = []() {};
S3 s4{std::move(l)};
// no more uses of l
来电者给了你一个纯右值。这导致 1 或 2 个动作。自从 C++17 有了新的临时物化规则,你就可以保证一步到位。
S3 s4{[]() {}};
如你所见,即使按值取值也不会做任何不必要的复制操作,前提是移动便宜。
TL;DR: 你应该使用带有概念的通用引用构造函数。
搬家并不总是便宜的。由于函子通常有大量的状态,移动等同于副本(没有 PImpl 惯用语)。有人可能会争辩说,在现代 C++ 中,很少使用仿函数,而当使用 PImpl 惯用语时,移动和复制都变得便宜。
但是,lambda 是幕后的仿函数,我认为用 lambda 实现 PImpl 惯用法并不容易或微不足道。这意味着 lambda 捕获的所有内容都是“移动的”,这对于原始类型和任何足够高效而不使用动态分配的内容(例如使用 small string optimization 的 std::string
实现)不是任何比副本便宜。
我的意思是,所有可以避免的动作和副本都应该避免。现在,在这个有限的示例中,您不需要状态。但是,许多 functors/lambdas 会这样做,您的示例将适用于他们。
虽然@bolov 的解决方案易于制作,但使用通用参考和概念可能更好。考虑以下因素:
template <class F, class R, class... Args >
concept invocable_r =
requires(F&& f, Args&&... args) {
{std::forward<F>(f)(std::forward<Args>(args)...)} -> std::same_as<R>;
};
struct S2
{
template<typename F> requires invocable_r<F, void>
S2(F&& _func) : func{std::forward<F>(_func)} {}
std::function<void()> func;
};
struct S1{
S1(std::function<void()> f): func{f}{}
std::function<void()> func;
};
虽然 S1 肯定不会输入那么多字符,但它几乎总是会产生额外的移动。
但是S2呢?它没有你提到的问题吗?好吧,让我们回顾一下:
it can conflict with other constructors taking the same number of
arguments, so it should be guarded using SFINAE or C++20 concepts. (This is a library problem, so not a big deal)
我不认为必须使用概念是个问题,尽管我将讨论 SFINAE 的问题。
it is less explicit about what kind of callables it expects (can also be constrained using concepts). (This could be considered a user problem)
通常,某物的定义或声明与其用法是分开的。此外,用户通常会查看代码文档,而不是实际代码本身。
as a result of the previous drawback, the point of error in case of unfitting callable is moved from the call site to the member initialization site, which is much harder to understand. (This is definitely a user problem).
现在没有概念,我完全同意你的看法。即使使用 SFINAE,一些错误消息也会变得 真的 令人讨厌。
但是请考虑以下代码行在 OnlineGDB 中生成的错误消息:
S1 s1{3};
main.cpp:34:12: error: no matching function for call to ‘S1::S1()’
34 | S1 s1{3};
| ^
main.cpp:29:5: note: candidate: ‘S1::S1(std::function)’
29 | S1(std::function<void()> f): func{f}{}
| ^~
main.cpp:29:30: note: no known conversion for argument 1 from ‘int’ to ‘std::function’
29 | S1(std::function<void()> f): func{f}{}
| ~~~~~~~~~~~~~~~~~~~~~~^
main.cpp:28:8: note: candidate: ‘S1::S1(const S1&)’
28 | struct S1{
| ^~
main.cpp:28:8: note: no known conversion for argument 1 from ‘int’ to ‘const S1&’
main.cpp:28:8: note: candidate: ‘S1::S1(S1&&)’
main.cpp:28:8: note: no known conversion for argument 1 from ‘int’ to ‘S1&&’
相比于:
S2 s2{3};
main.cpp:35:12: error: no matching function for call to ‘S2::S2()’
35 | S2 s2{3};
| ^
main.cpp:24:5: note: candidate: ‘S2::S2(F&&) [with F = int]’
24 | S2(F&& _func) : func{std::forward<F>(_func)} {}
| ^~
main.cpp:24:5: note: constraints not satisfied
main.cpp:16:9: note: within ‘template concept const bool invocable_r [with F = int; R = void; Args = {}]’
16 | concept invocable_r =
| ^~~~~~~~~~~
main.cpp:16:9: note: with ‘int&& f’
main.cpp:16:9: note: the required expression ‘same_as(f)((forward)(args)...)), R>’ would be ill-formed
main.cpp:21:8: note: candidate: ‘S2::S2(const S2&)’
21 | struct S2
| ^~
main.cpp:21:8: note: no known conversion for argument 1 from ‘int’ to ‘const S2&’
main.cpp:21:8: note: candidate: ‘S2::S2(S2&&)’
main.cpp:21:8: note: no known conversion for argument 1 from ‘int’ to ‘S2&&’
虽然第二部分稍大一些,但可以说,如果查看错误消息的第一部分,就会清楚得多:
main.cpp:35:12: error: no matching function for call to ‘S2::S2()’
35 | S2 s2{3};
| ^
main.cpp:24:5: note: candidate: ‘S2::S2(F&&) [with F = int]’
24 | S2(F&& _func) : func{std::forward<F>(_func)} {}
| ^~
main.cpp:24:5: note: constraints not satisfied
“约束不满足”确实很清楚。
考虑以下代码:
#include <functional>
#include <iostream>
struct S1
{
S1(const std::function<void()>& _func) : func{_func} {}
std::function<void()> func;
};
struct S2
{
template<typename F>
S2(F&& _func) : func{std::forward<F>(_func)} {}
std::function<void()> func;
};
int main()
{
S1 s1{[](){ std::cout << "S1 function called\n"; }};
S2 s2{[](){ std::cout << "S2 function called\n"; }};
s1.func();
s2.func();
return 0;
}
我试图了解这 2 个构造函数实现的设计 benefits/drawbacks。 另外,两者在性能上有什么区别吗
我的主要问题是,当知道可调用对象的签名并将其作为 std::function 存储在 class?
据我所知,通用引用是首选方法,因为它为编译器提供了进行内联的最佳机会。
但是,我可以想到使用通用引用的一些缺点:
- 它可能会与采用相同数量参数的其他构造函数发生冲突,因此应使用 SFINAE 或 C++20 概念加以保护。 (这是一个库问题,所以没什么大不了的)
- 它不太明确它期望什么样的可调用对象(也可以使用概念来约束)。 (这可以被认为是用户问题)
- 由于前面的缺点,unfitting callable 的错误点从调用站点移动到成员初始化站点,这更难理解。 (这绝对是用户问题)
我想我真正的问题是: 您是否应该在构造函数接口中公开 class 的 std::function 性质,或者这是一个必须隐藏的实现细节?
第一个例子总是会被复制,即使这可以避免,所以它不是最优的。
第二个例子有你提到的缺点。
推荐的方式是按值取值移动:
struct S3
{
S3(std::function<void()> _func) : func{std::move(_func)} {}
std::function<void()> func;
};
现在这看起来可能违反直觉,因为您按值获取复制成本可能很高的对象。但这不是问题。让我们分析一下可能出现的情况:
来电者无法给您可移动的物品。在这种情况下,副本是不可避免的。这导致复制后跟移动:
auto l = []() {}; S3 s4{l}; l();
来电者给了你一个xvalue。在这种情况下,涉及 2 个动作:
auto l = []() {}; S3 s4{std::move(l)}; // no more uses of l
来电者给了你一个纯右值。这导致 1 或 2 个动作。自从 C++17 有了新的临时物化规则,你就可以保证一步到位。
S3 s4{[]() {}};
如你所见,即使按值取值也不会做任何不必要的复制操作,前提是移动便宜。
TL;DR: 你应该使用带有概念的通用引用构造函数。
搬家并不总是便宜的。由于函子通常有大量的状态,移动等同于副本(没有 PImpl 惯用语)。有人可能会争辩说,在现代 C++ 中,很少使用仿函数,而当使用 PImpl 惯用语时,移动和复制都变得便宜。
但是,lambda 是幕后的仿函数,我认为用 lambda 实现 PImpl 惯用法并不容易或微不足道。这意味着 lambda 捕获的所有内容都是“移动的”,这对于原始类型和任何足够高效而不使用动态分配的内容(例如使用 small string optimization 的 std::string
实现)不是任何比副本便宜。
我的意思是,所有可以避免的动作和副本都应该避免。现在,在这个有限的示例中,您不需要状态。但是,许多 functors/lambdas 会这样做,您的示例将适用于他们。
虽然@bolov 的解决方案易于制作,但使用通用参考和概念可能更好。考虑以下因素:
template <class F, class R, class... Args >
concept invocable_r =
requires(F&& f, Args&&... args) {
{std::forward<F>(f)(std::forward<Args>(args)...)} -> std::same_as<R>;
};
struct S2
{
template<typename F> requires invocable_r<F, void>
S2(F&& _func) : func{std::forward<F>(_func)} {}
std::function<void()> func;
};
struct S1{
S1(std::function<void()> f): func{f}{}
std::function<void()> func;
};
虽然 S1 肯定不会输入那么多字符,但它几乎总是会产生额外的移动。
但是S2呢?它没有你提到的问题吗?好吧,让我们回顾一下:
it can conflict with other constructors taking the same number of arguments, so it should be guarded using SFINAE or C++20 concepts. (This is a library problem, so not a big deal)
我不认为必须使用概念是个问题,尽管我将讨论 SFINAE 的问题。
it is less explicit about what kind of callables it expects (can also be constrained using concepts). (This could be considered a user problem)
通常,某物的定义或声明与其用法是分开的。此外,用户通常会查看代码文档,而不是实际代码本身。
as a result of the previous drawback, the point of error in case of unfitting callable is moved from the call site to the member initialization site, which is much harder to understand. (This is definitely a user problem).
现在没有概念,我完全同意你的看法。即使使用 SFINAE,一些错误消息也会变得 真的 令人讨厌。
但是请考虑以下代码行在 OnlineGDB 中生成的错误消息:
S1 s1{3};
main.cpp:34:12: error: no matching function for call to ‘S1::S1()’
34 | S1 s1{3};
| ^
main.cpp:29:5: note: candidate: ‘S1::S1(std::function)’
29 | S1(std::function<void()> f): func{f}{}
| ^~
main.cpp:29:30: note: no known conversion for argument 1 from ‘int’ to ‘std::function’
29 | S1(std::function<void()> f): func{f}{}
| ~~~~~~~~~~~~~~~~~~~~~~^
main.cpp:28:8: note: candidate: ‘S1::S1(const S1&)’
28 | struct S1{
| ^~
main.cpp:28:8: note: no known conversion for argument 1 from ‘int’ to ‘const S1&’
main.cpp:28:8: note: candidate: ‘S1::S1(S1&&)’
main.cpp:28:8: note: no known conversion for argument 1 from ‘int’ to ‘S1&&’
相比于:
S2 s2{3};
main.cpp:35:12: error: no matching function for call to ‘S2::S2()’
35 | S2 s2{3};
| ^
main.cpp:24:5: note: candidate: ‘S2::S2(F&&) [with F = int]’
24 | S2(F&& _func) : func{std::forward<F>(_func)} {}
| ^~
main.cpp:24:5: note: constraints not satisfied
main.cpp:16:9: note: within ‘template concept const bool invocable_r [with F = int; R = void; Args = {}]’
16 | concept invocable_r =
| ^~~~~~~~~~~
main.cpp:16:9: note: with ‘int&& f’
main.cpp:16:9: note: the required expression ‘same_as(f)((forward)(args)...)), R>’ would be ill-formed
main.cpp:21:8: note: candidate: ‘S2::S2(const S2&)’
21 | struct S2
| ^~
main.cpp:21:8: note: no known conversion for argument 1 from ‘int’ to ‘const S2&’
main.cpp:21:8: note: candidate: ‘S2::S2(S2&&)’
main.cpp:21:8: note: no known conversion for argument 1 from ‘int’ to ‘S2&&’
虽然第二部分稍大一些,但可以说,如果查看错误消息的第一部分,就会清楚得多:
main.cpp:35:12: error: no matching function for call to ‘S2::S2()’
35 | S2 s2{3};
| ^
main.cpp:24:5: note: candidate: ‘S2::S2(F&&) [with F = int]’
24 | S2(F&& _func) : func{std::forward<F>(_func)} {}
| ^~
main.cpp:24:5: note: constraints not satisfied
“约束不满足”确实很清楚。