C++ 模板:以简御繁-3/5

 英文原文https://victor-istomin.github.io/c-with-crosses/posts/templates-are-easy/

C++ 模板:以简御繁-1/5

C++ 模板:以简御繁-2/5

C++ 模板:以简御繁-3/5

C++ 模板:以简御繁-4/5

C++ 模板:以简御繁-5/5

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 TypeTemplateNon-Template
const &const lvalue referenceconst lvalue reference
&lvalue referencelvalue 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 效果更好。

本博客上有一篇关于引用限定成员函数的另一个有趣用例的文章:引用限定成员函数重载的实际用法

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值