C++ 最佳实践:将仅用于调用(未存储)的 lambda 参数通过 const 引用或转发引用(也称通用引用)传递给函数。

6
什么是将lambda函数作为参数传递给仅使用(但不存储或转发)lambda函数的函数的最佳(即性能最佳,通用性最佳)方法?
选项1:通过const引用传递是否是正确的方法?
template <typename F>
void just_invoke_func(const F& func) {
    int i = 1;
    func(i);
}

选项2:或者将其作为转发引用(通用引用)传递是否更好?

template <typename F>
void just_invoke_func(F&& func) {
    int i = 1;
    func(i);
}

后者是否比前者更有优势?
这两种解决方案中是否存在任何危险?

编辑 #1:

选项 3:正如Yakk - Adam Nevraumont所指出的,使用mutable的lambda在选项一中将无法工作。因此,考虑使用一个接受非常量左值引用的另一个版本:

template <typename F>
void just_invoke_func(F& func) {
    int i = 1;
    func(i);
}

问题是:选项2是否比选项3有任何优势?
编辑#2:
选项4:另一种通过值获取lambda的版本已经被提出。 参见此处
template <typename F>
void just_invoke_func(F func) {
    int i = 1;
    func(i);
}

第二个是移动语义,即不同的东西。如果您通过const引用传递参数,并且在函数内部修改它,则对象将通过复制构造函数或复制赋值运算符进行复制。当您使用移动语义时,将使用移动构造函数或移动赋值运算符。 - Victor Gubin
2
不,第二个不一定是移动语义,因为它是一个转发引用,它是一个条件强制转换为右值引用。也就是说,只有用户提供了右值引用,它才会成为右值引用。但实际上,这正是我的问题所在:是否存在任何情况,使得将func作为右值引用传递的选项具有任何优势?我很难想出转发引用版本(第二个)相对于仅使用左值引用版本(第一个)的优势。 - j00hi
顺便提一下:这些引用的官方术语现在是转发引用。委员会没有接受Scott Meyer在这里最初的术语。 - Secundi
1
@Secundi 是的,没错。但并不是每个人都满意官方术语。有时你甚至可以发现一些叛乱运动。 :O - j00hi
1
@j00hi 谢谢!即使对于非初学者来说也很令人困惑。看起来委员会将来可能会再次更改措辞... - Secundi
3个回答

7
带有 `mutable` 关键字的 lambda 表达式具有非 const 的 `operator()`,在第一种情况下无法编译。
我认为没有理由阻止可变的 lambda 表达式。
Rvalue lambda 在非 const 左值引用的情况下无法编译。
老实说,考虑按值传递 lambda 表达式。如果调用方想要非本地状态,他们可以通过引用捕获,甚至传递 `std::ref`/`std::cref` 包装的 lambda(如果您进行不可尾递归优化的递归调用,则使用 rvalue 引用可能是明智的)。
template<class F>
void do_something1( F&& f );
template<class F>
void do_something2( F f );

调用do_something1(lambda)可以通过调用do_something2(std::cref(lambda))(如果是不可变的)和do_something2(std::ref(lambda))(如果是可变的)来模拟。
只有当lambda既可变又是rvalue时,这种方法才无法使用;这往往是一个错误(状态被处理的可变lambda很棘手),在剩下的1/10的情况下,您可以绕过此问题。
另一方面,您不能轻松地使用do_something1模仿do_something2do_something2的优点在于编译器在编写do_something2体时可以确切地知道lambda的所有状态位于何处。
[x = 0](int& y)mutable{y=++x;}

如果这个lambda被按值传递,编译器在本地知道谁有访问x的权限。如果这个lambda被按引用传递,编译器必须了解调用上下文和所有调用的代码,以便知道在自己代码中的任意两个表达式之间是否有其他人修改了x
编译器通常可以更好地优化值,而不是外部引用。
至于复制的成本,lambda通常不会很昂贵。如果它们是[&] -捕获,它们只是一堆引用状态。如果它们是值捕获,它们的状态很少是庞大且难以移动的。
在这些情况下,std::refstd::cref无论如何都可以消除这个成本。
std::refoperator()通过到lambda,因此函数永远不需要知道它被传递std::ref(lambda))。

我没有尝试过,但是我非常认为,在 lambda 的引用是 const 的情况下,它们会在第一个案例中失败。 - n314159
你对于mutable lambda的理解是正确的。谢谢。但是接下来就会有一个问题:为什么不直接将func作为非const左值引用传递呢?(请参见上面编辑过的问题。) - j00hi
嗯,谢谢你的建议。这些是一些很好的评论。也就是说,可以说通过转发引用和传值来传递参数是最通用/多才多艺的解决方案,但通过转发引用传递参数可能会更高效? - j00hi
如上所述,通过值传递的 std::ref(lambda)std::cref(lambda) 基本上是按引用传递。因此,我没有看到传递转发引用会在根本上更有效的证据。通过值传递可以使编译器更好地理解 lambda 的状态位于何处(即它是本地的,因此可以轻松地进行推理),而通过引用,编译器必须首先证明没有其他人有能力修改该引用状态(更难)。 - Yakk - Adam Nevraumont

4

如果您尝试不同的方式来使用不同的lambda表达式,您会发现const引用无法使用可变lambda表达式,而可变引用无法使用临时lambda表达式(因此您不能在现场定义lambda表达式)。

按值传递和按通用引用传递对所有类型的lambda表达式都有效。但是通用引用更加通用:如果您有一个想要多次使用但没有复制构造函数的lambda表达式,您不能按值传递它,因为您必须移动它并且不能在此之后使用它(或者至少不应该这样做)。

总的来说,我认为通用引用是最简单的方法。

补充说明:因为您在评论中询问性能问题:如果通过转发引用与按值传递相比有很大的差异,则您可能正在做错事情。函数对象通常应该是轻量级的。如果您有一个需要大量工作才能按值传递的lambda表达式,您应该重新设计它。

即使您可以使用转发引用等方法使其正常工作,但这似乎是一个很大的麻烦,您很容易犯错并且有昂贵的复制操作。 即使您做得完全正确并且在您的代码中从未进行过复制,也有两个地方可能会出现复制:

  • 其他人的代码:大多数人认为函数对象是轻量级的,因此传递它们时没有问题。
  • STL:查看算法标头。所有传递到那里的函数对象都是按值传递的(因为STL还假定函数对象是轻量级的)。 因此,如果您在任何地方与STL算法一起使用重的函数对象(而我们都知道:无论何时都应该使用它们),您将度过难关。

STL的模式确实很好!不过,我可以看到STL中常用的两个版本:按值传递(正如你所指出的),但也有通过const左值引用传递的版本。 - j00hi

0
从设计角度来看,我想添加一个可能有争议的方面。 委员会决定使用“转发引用”的措辞来强调它们的核心含义,请参见例如。

https://isocpp.org/files/papers/N4164.pdf

因此,在非转发上下文中使用它们(您仅应用函数/函子调用),看起来有点与基本含义相矛盾,至少如果您希望方法的签名产生清晰有意义的命名和类型分类关于其实际参数使用上下文。另一方面,如果您的函数体可能经常更改并且将来可能出现真正的转发,则可以更灵活地使用它们。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接