英文原文:https://victor-istomin.github.io/c-with-crosses/posts/templates-are-easy/
8. 完美转发:避免不必要的复制并允许使用不可复制的类型
在下面的代码中,getSomeString() 内部创建了一个临时变量,并将其绑定到一个左值引用参数。然后,被引用的字符串被复制到返回值。由于复制省略,没有进行进一步的复制,但最终还是发生了一次不必要的复制。
template <typename String>
auto makeString(const String& s)
-> std::enable_if_t< traits::isString<String>,
std::string >
{
return std::string(s);
}
// ...
std::string getSomeString();
std::cout << makeString(getSomeString());
除了性能欠佳之外,之前的方法还不允许使用不可复制的类型,例如 std::unique_ptr。然而,通过利用右值或转发引用,我们可以省略不必要的复制操作,从而避免对可复制类型的要求。这样,std::string 构造函数就可以高效地窃取其内容,而无需进行不必要的复制。
虽然非模板参数的唯一选项是右值重载,但模板参数可以使用通用(或转发)引用。本质上,转发引用接受与调用者传递的引用类型相同的引用类型(左值、右值、常量或非常量)。转发引用的语法为 TemplateType&& parameter。
我强烈建议您阅读上面的精彩文章以了解更多详细信息。
为了以防万一,我们先来简单总结一下,然后修复模板
| Parameter Type | Template | Non-Template |
|---|---|---|
| const & | const lvalue reference | const lvalue reference |
| & | lvalue reference | lvalue reference |
| && | the same as has been passed by the caller, may be: (const-) lvalue or rvalue | rvalue reference |
这样,对于 makeString.hpp 中的每个传递引用,我们将通过通用引用传递参数
// ...
template <typename Object>
auto makeString(Object&& object) -> decltype(object.to_string())
//...
template <typename Iterable>
auto makeString(Iterable&& iterable)
// ...
template <typename String>
auto makeString(String&& s)
// ...
是这样吗?还没有。函数参数在函数内部始终是左值。如果可能的话,我们必须提供一个右值,以便允许 std::string 窃取临时变量的内容
细致的开发人员可能会考虑所有可能的情况
- 传递给 makeString() 的左值或(const-)左值引用:作为适当的左值引用进一步传递,因为我们不应该通过从它移动而使左值无效;
- 传递给 makeString() 的临时或其他类型的右值:通过右值引用传递它以允许从中移动。
这听起来可能很复杂,但本质上,std::forward 会将函数参数强制转换为函数调用时的状态。因此,快速开发人员在进行可能的移动时,会使用 std::forward 进行通用引用。
8.1 转发陷阱:临时调用可能会被调用者“消耗”并使其无效
然而,谨慎的开发者会警告我们,任何使用 std::forward 的地方都可能发生转移。因此,切勿在转发后使用变量:
#include <string>
#include <cassert>
#include <vector>
template <typename Arg>
void foo(Arg&& a)
{
std::vector<std::string> v;
for(int i = 0; i < 2; i++)
v.push_back(std::forward<Arg>(a)); // surprise on the 2nd iteration
assert(v.front() == v.back()); // fail
}
int main()
{
auto getMessage = []() -> std::string { return "why does it fail?"; };
foo(getMessage());
}
上面的例子看似人为,但确实存在。考虑以下情况:
- 有一个使用完美转发接收参数的回调:logCallback(std::forward<Event>(logEvent));。看起来很好也很常规。
- 后来,开发人员在实现多个回调支持时忘记删除 std::forward:for (auto& sink : logSinks) sink.logCallback(std::forward<Event>(event));。这个疏忽造成了一个定时炸弹。
- 在仅使用单个回调或左值引用进行基本测试时,这个问题没有被注意到。
- 在极少数情况下,当多个回调涉及右值引用时,会发生未定义的行为,从而导致意外行为。
因此,一旦出现转发值有多个接收者的可能性,开发人员应该有强烈的直觉来删除 std::forward。
8.2 临时容器
一个比较棘手的情况是将右值容器作为参数处理。尽管容器可能是临时的,但其元素以通常的方式构造(例如,可以获取元素的地址),因此它们被视为左值。因此,应该使用强制的 std::move 来从临时容器中窃取元素的内容:
// will fail substitution if `traits::isString<Iterable>`
// or when can't evaluate the type of makeString(*begin(container))
template <typename Iterable>
auto makeString(Iterable&& iterable)
-> std::enable_if_t< !traits::isString<Iterable>,
decltype(makeString(*std::begin(iterable)))
>
{
std::string result;
for (auto&& i : iterable)
{
if (!result.empty())
result += ';';
// a constexpr if, so the compiler can omit unused branch and
// allow non-copyable types usage
if constexpr (std::is_rvalue_reference_v<decltype(iterable)>)
result += makeString(std::move(i));
else
result += makeString(i);
}
return result;
}
在这种情况下,“if constexpr”允许在编译时根据容器的值类别选择策略。与常规 if 语句不同,在模板实例化时,仅编译 constexpr if/else 的一个分支,从而允许使用可移动或可复制的类型。例子见上述代码。
std::vector::resize 的 MS STL利用这种方法在复制和移动策略之间进行选择。调整大小时,如果值类型具有“noexcept”移动构造函数,则将现有项移动到新缓冲区;否则,复制它们。
此外,还有其他方法。例如,引入一个单独的模板,将复制或移动决策封装到两个重载中,这在 cppreference 上的 std::move_if_noexcept 示例中得到了演示。
综合以上所有部分,我们来看看下面的完美转发 makeString:
// makeString.hpp
#pragma once
#include <string>
#include <type_traits>
namespace traits
{
template <typename T>
inline constexpr bool isString = std::is_constructible_v<std::string, T>;
}
template <typename Object>
auto makeString(Object&& object)
-> decltype(std::forward<Object>(object).to_string())
{
return std::forward<Object>(object).to_string(); // (see a note below)
}
template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
return std::to_string(value);
}
// will fail substituition if `!traits::isString<String>`
template <typename String>
auto makeString(String&& s)
-> std::enable_if_t< traits::isString<String>,
std::string >
{
return std::string(std::forward<String>(s));
}
// will fail substitution if `traits::isString<Iterable>`
// or when can't evaluate the type of makeString(*begin(container))
template <typename Iterable>
auto makeString(Iterable&& iterable)
-> std::enable_if_t< !traits::isString<Iterable>,
decltype(makeString(*std::begin(iterable)))
>
{
std::string result;
for (auto&& i : iterable)
{
if (!result.empty())
result += ';';
// a constexpr if, so the compiler can omit unused branch and
// allow non-copyable types usage
if constexpr (std::is_rvalue_reference_v<decltype(iterable)>)
result += makeString(std::move(i));
else
result += makeString(i);
}
return result;
}
还有一些新的测试
#include <iostream>
#include <vector>
#include <set>
#include "makeString.hpp"
struct A
{
std::string to_string() const { return "A"; }
};
struct B
{
int m_i = 0;
std::string to_string() const { return "B{" + std::to_string(m_i) + "}"; }
};
struct NonCopyable
{
std::string m_s;
NonCopyable(const char* s) : m_s(s) {}
NonCopyable(NonCopyable&&) = default;
NonCopyable(const NonCopyable&) = delete;
std::string to_string() const & { return m_s; }
std::string&& to_string() && { return std::move(m_s); }
};
int main()
{
A a;
B b = {1};
const std::vector<int> xs = {1, 2, 3};
const std::set<float> ys = {4, 5, 6};
const double zs[] = {7, 8, 9};
std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
<< "; pi: " << makeString(3.1415926) << std::endl
<< "xs: " << makeString(xs) << "; ys: " << makeString(ys)
<< "; zs: " << makeString(zs)
<< std::endl;
std::cout << makeString("Hello, ")
<< makeString(std::string_view("world"))
<< makeString(std::string("!!1"))
<< std::endl;
auto makeVector = []()
{
std::vector<NonCopyable> v;
v.emplace_back("two ");
v.emplace_back(" non-copyables");
return v;
};
std::cout << makeString(makeVector())
<< std::endl;
}
有人可能会问,为什么我在这里调用 to_string() 时会转发一个对象?NonCopyable::to_string 的奇怪语法又是什么呢?
template <typename Object>
auto makeString(Object&& object)
-> decltype(std::forward<Object>(object).to_string())
{
return std::forward<Object>(object).to_string(); // (see a note below)
}
// ...
struct NonCopyable
{
// ...
std::string to_string() const & { return m_s; }
std::string to_string() && { return std::move(m_s); }
};
此处的 std::forward 将对象转发至其 object.to_string() 成员函数,而相应的 NonCopyable::to_string 方法则具有对 *this 值类别的重载。这样,我们可以允许 NonCopyable::to_string 的右值重载窃取临时变量的内容,从而避免不必要的复制。
8.3 在引用限定符上重载成员函数
引用限制的成员函数允许编译器根据 *this 的引用类型选择特定的重载。以下是 cppreference 相应章节中的一个简单示例:
#include <iostream>
struct S
{
void f() & { std::cout << "lvalue\n"; }
void f() && { std::cout << "rvalue\n"; }
};
int main()
{
S s;
s.f(); // prints "lvalue"
std::move(s).f(); // prints "rvalue"
S().f(); // prints "rvalue"
}
关于 NonCopyable::to_string(),如果在临时对象上调用它,可以安全地假设该临时对象在调用后将被废弃,因此我们可以通过将 NonCopyable::m_s 移动到返回值中来窃取其内容。当然,我们仍然不需要在返回类型处使用 &&,因为返回值本身就是右值。
也许,那是自 2011 年以来我唯一一次看到合理使用 return std::move,因为如今我们可以依赖保证复制省略,而对于返回值 3,它通常比 std::move 效果更好。
本博客上有一篇关于引用限定成员函数的另一个有趣用例的文章:引用限定成员函数重载的实际用法
112

被折叠的 条评论
为什么被折叠?



