C++完美转发

C++完美转发

ps. 本文引用了<<Effective Modern C++>>一书中的内容, 作为学习后的精炼和总结. 建议读原版书籍, 内容更丰富更精彩:)

完美转发(prefect forwarding)提供一种机制, 使传给模板函数的参数能以一致的形式转发给目标函数.
移动语义的发生需要右值和右值引用, 完美转发有些类似, 不过它需要的是std::forward和转发引用(forwarding reference), 直接举个例子尝尝鲜.

void func(const std::string& s); // 左值引用
void func(std::string&& s); // 右值引用

template<typename T>
void forward_to_func(T&& param) // ①此处是转发引用
{ 
    func(std::forward<T>(param)); // ②完美转发, std::forward将左值转发到左值引用, 将右值转发到右值引用
}

std::string s = "hello world."; 
forward_to_func(s); // ③s是左值, 调用 func(const std::string& s)
forward_to_func(std::move(s)); // ④std::move(s)是右值, 调用 func(std::string&& s)

此例中,
①中形如T&&的引用形式就是转发引用, 它和右值引用很像, 但实际有所不同, 后面讲.
②中func提供了两种版本, 一种接收左值, 一种接收右值, 应该使用何种版本? std::forward能够处理, 它将param参数转发到func函数, 当param是左值就转发到左值引用, 是右值就转发到右值引用! 这就是完美转发!
③中s是左值, 所以最终调用到 func(const std::string& s).
④中s经std::move强转为右值, 所以最终调用到 func(std::string&& s).

转发引用和std::forward使用了一种统一的形式, 将左值就转发到左值引用, 将右值就转发到右值引用, 使之能够同时处理左值和右值, 此之谓"完美转发".

转发引用(forwarding reference)

(转发引用又名万能引用universal reference, 但C++标准术语使用的转发引用.)
上面提到过, 转发引用和右值引用很像, 如何区分? 有两个条件:
1.必须是形如T&&的模板引用, 其中T是模板类型或auto.
2.必须涉及类型推导, 即1中的T会参与类型推导.

template<typename T>
void func(T&& param); // ①转发引用

template<typename T>
void func(std::vector<T>&& param); // ②右值引用

template<typename T>
void func(const T&& param); // ③右值引用

std::vector<int> v;
func(v); // ④调用 func(T&& param) 
func(std::move(v)); // ⑤调用 func(std::vector<T>&& param)

int i = 0;
func(i); // ⑥调用 func(T&& param) 
func(std::move(i)); // ⑦调用 func(T&& param), 此处优先匹配了转发引用的版本 

此例中,
①中是转发引用的标准格式.
②不满足T&&的格式, 因此此处是右值引用!
③不满足T&&的格式, 仅仅多加了const也不行! 此处仅说明情况, 从语法上说没错, 但对右值引用添加const的情况应该避免.
④中v是左值没有能够直接匹配的func, 但它可以匹配func(T&& param)转发引用的版本, 模板实例化后为func(std::vector& param).
⑤中std::move(v)是右值, 匹配func(std::vector&& param)右值引用版本(最佳匹配)比转发引用的版本更轻松, 模板实例化后为func(std::vector&& param).
⑥中i是左值没有能够直接匹配的func, 但它可以匹配func(T&& param)转发引用的版本, 模板实例化后为func(int& param).
⑦中std::move(i)是右值没有能够直接匹配的func, 但它可以匹配func(T&& param)转发引用的版本, 模板实例化后为func(int&& param).

template<class T>
class container
{
public:
    void push_back(T&& x); // ①!右值引用

    template <class... Args>
    void emplace_back(Args&&... args); // ②转发引用
};

container<int> c; // 构造c时得到模板类型为int, c.push_back不再需要推导参数类型

auto&& x1 = x; // ③转发引用

此例展示了更多关于转发引用的例子,
①中很像转发引用? 但它不是, 因为它不涉及类型推导! 在容器构造时就确定了模板T的类型, 所以此处是右值引用.
②中可变模板参, Args&&...展开后就是单独的Arg&&, 符合转发引用的格式, 也涉及类型推导, 所以此处是转发引用!
③auto&&肯定是转发引用, 在C++14后, auto可用于函数返回值和lambda函数参数, 它会很有用.

// 转发引用和重载同时使用会出现问题
std::multiset<std::string> names;

template<typename T>
void log_and_add(T&& name)
{
    names.emplace(std::forward<T>(name));
}

std::string name_from_idx(int idx);
void log_and_add(int idx)
{
    names.emplace(name_from_idx(idx));
}

std::string name("Darla");
log_and_add(name); // ①从模板实例化出 log_and_add(std::string&)
log_and_add(std::string("Persephone")); // ②从模板实例化出 log_and_add(std::string&&)
log_and_add("Patty Dog"); // ③从模板实例化出 log_and_add(const char(&)[10])

log_and_add(22); // ④调用log_and_add(int idx)

short name_idx = 22;
log_and_add(name_idx); // ⑤oops! 从模板实例化出 log_and_add(short&)

此例展示了关于使用转发引用存在的副作用, 当存在转发引用的重载版本时, 就要小心啦!
①②③它们工作的很好, 按设想模板实例化出来了想要的版本.
④调用了int的重载版本, 以便通过索引获取name后获得与模板一样的逻辑.
⑤传入一个short类型, 没有short的重载版本, 理想情况下它会默认转换然后调用int的版本? 不, 转发引用为它生成了一个更精确的版本log_and_add(short&), 随后施行转发引用版本的逻辑, 这会引发异常!

从这里我们了解到, 转发引用总是能产生一个精确的匹配, 在有重载的情况下, 有些调用可能会意想不到的采用到转发引用的版本.
知道转发引用如此贪婪后, 在代码实现上就要有所顾虑了, 我们希望不受限制的使用转发引用,重载这些技术, 同时又要想办法避免上面的情况发生. 如何做呢, 倒是有一些办法.

// 标签分派
template<typename T>
void log_and_add(T&& name); // 声明

template<typename T> 
void log_and_add_impl(T&& name, std::false_type) // ①非整型的实现
{   
    names.emplace(std::forward<T>(name));
}

std::string name_from_idx(int idx);
void log_and_add_impl(int idx, std::true_type) // ②整型的实现
{   
    log_and_add(name_from_idx(idx));
}

template<typename T>
void log_and_add(T&& name)
{
    log_and_add_impl(std::forward<T>(name),
        std::is_integral<std::remove_reference_t<T>>()); // ③根据是否是整型调用不同的实现
}

short name_idx = 22;
log_and_add(name_idx); // ④ok! 调用 log_and_add_impl(int idx, std::true_type)

此例中使用标签分派(tag dispatch)的设计来避免转发引用对重载版本的影响,
①非整型的实现, log_and_add将约束模板整型判断为std::false_type的版本分发到log_and_add_impl(T&& name, std::false_type).
②整型的实现, log_and_add会将模板整型判断为std::true_type的版本分发到log_and_add_impl(int idx, std::true_type).
③中std::is_integral用来判断给定的模板类型是否为整型(bool, char, int, size_t等都是整型), 另外此处使用了std::remove_reference_t用来移除模板的引用属性, 否则std::is_integral的判断会失效, 例如std::is_integral<int&>会判定为非整型. std::is_integral的判断结果为std::true_type/std::false_type.
④现在short版本可以正常工作了, 它将name_idx隐式转换为int后调用log_and_add_impl(int idx, std::true_type).

通过std::true_type和std::false_type这样的标签, 将整型特殊处理, 其他的交给转发引用, 很好, 它解决了问题, 但...似乎它不够灵活, 对于像这样的简单函数处理起来还好, 如果情形复杂起来要特殊处理多种类型将如何? 标签不够用啊. 别急, 下面还有招.

// C++11, 使用std::enable_if约束模板
template<typename T>
using enable_if_non_integral = 
    std::enable_if_t<!std::is_integral_v<std::remove_reference_t<T>>>; // _t, _v需要C++14

template<typename T, typename = enable_if_non_integral<T>> // ①约束模板T
void log_and_add(T&& name)
{
    names.emplace(std::forward<T>(name));
}

std::string name_from_idx(int idx);
void log_and_add(int idx)
{
    names.emplace(name_from_idx(idx));
}

short name_idx = 22;
log_and_add(name_idx); // ok! 调用 log_and_add(int idx)

此例中使用std::enable_if为模板添加约束,
①关于std::enable_if目前不宜展开来讲, 此处仅需要知道传给它的模板若不满足条件, 该模板就不会生成! 这里给的条件是模板需要是非整型, 因此short将不会生成对应的模板, 因此只能将name_idx隐式转换为int后调用log_and_add_impl(int idx, std::true_type).

// C++17, 使用编译期if(if constexpr)
template<typename T>
void log_and_add(T&& t)
{
    if constexpr (std::is_integral_v<std::remove_reference_t<T>>) // ①编译期if
        names.emplace(name_from_idx(t));
    else 
        names.emplace(std::forward<T>(t));
}

short name_idx = 22;
log_and_add(name_idx); // ok! 从模板实例化出 log_and_add(short& idx)

log_and_add(short& idx) // ②(猜测)模板实例化的结果
{
    names.emplace(name_from_idx(t));
}

此例中使用编译期if(if constexpr)来约束模板代码是否生成,
①if constexpr需要C++17的支持, 相比std::enable_if控制整个模板是否生成, if constexpr粒度更细, 它能够在编译期控制模板中的某些代码是否生成!
②是对模板实例化结果的猜测.

// C++20, concept
template<typename T> 
concept non_integral = !std::is_integral_v<std::remove_reference_t<T>>; // ①声明concept

template<non_integral T> // ②使用concept约束模板T
void log_and_add(T&& t)
{
    names.emplace(std::forward<T>(t));
}

void log_and_add(int idx)
{
    names.emplace(name_from_idx(idx));
}

short name_idx = 22;
log_and_add(name_idx); // ok! 调用 log_and_add(int idx)

此例中使用concept约束模板是否生成,
①乍一看和std::enable_if功能似乎差不多, 但其实不然, 此处声明了一个concept(std::enable_if的版本使用using取了别名).
②使用concept non_integral替代了typename, 约束模板T必须是非整型类型, 同上最后short会隐式转换后调用log_and_add(int idx)!

从std::enable_if到if constexpr, 再到concept实非目前关注的重点, 所以没有进行深入的阐述, 只是为解决转发引用遇到重载时能有更多的思路来解决相关的问题, 以及通过转发引用逐渐能深入到更多知识点, 本身也是一件很有趣的事. 下面将返回本文的主题来, 了解关于完美转发还应该知道的.

引用折叠(reference collapse)

转发引用如何能够识别左值还是右值? 其秘密藏在模板T中, 简单的讲, 对于左值, 转发引用中的T被推导为左值引用T&, 对于右值, 转发引用中的T被推导为无引用的T. 下面根据此规则, 模板实例化过程中出现的情况, 由此引入引用折叠的概念.

template<typename T>
void func(T&& param);

std::string s;
func(s); // ①s是左值, T被推导为std::string&, 模板实例化得 func(std::string& && param)
         // ②此时发生 引用折叠: func(std::string& && param) -> func(std::string& param)

func(std::move(s)); // ③std::move(s)是右值时, T被推导为std::string, 模板实例化得 func(std::string&& param)

此例中,
①s是左值, T被推导为std::string&, 模板实例化得 func(std::string& && param), 这里是左值引用到右值引用? 非也, std::string&可以看做是左值引用, 但后面的&&不能看作是右值引用, 要记得, 此处是转发引用的实例化阶段. 转发引用中像这样引用重叠在一起, 在模板推导过程中会发生引用折叠.
②中发生引用折叠后, 模板实例化会得到func(std::string& param), 它变成了一个左值引用!
③std::move(s)是左值, T被推导为std::string, 模板实例化得 func(std::string&& param), 毫无疑问它是一个右值引用.

转发引用会发生引用折叠, 也可以用来说明转发引用需要的两个条件:
1.必须是形如T&&的模板引用, 其中T是模板类型或auto. -- 若非此形式, 那么无法出现T& &&此种形式, 引用折叠不会发生.
2.必须涉及类型推导, 即1中的T会参与类型推导. -- 如果推导未发生, 那么T不会被推导为T&, 引用折叠不会发生.
引用折叠不会发生, 便无法同时处理左值右值, 便不是转发引用!

int x = 0;

// ①引用折叠可能发生在auto
auto&& x1 = x; // x1被推导为左值引用
auto&& x2 = std::move(x); // x2被推导为右值引用

// ②引用折叠可能发生在decltype
decltype(x1) && x3 = x; // x3被推导为左值引用
decltype(x2) && x4 = std::move(x); // x4被推导为右值引用

// ③引用折叠可能发生在typedef
template<class T>
class container
{
public:
    typedef T&& forward_reference;
};

container<int&>::forward_reference x5 = x; // x5被推导为左值引用
container<int&&>::forward_reference x6 = std::move(x); // x6被推导为右值引用

此例中了解更多可能发生引用折叠的场景,
①引用折叠可能发生在auto.
②引用折叠可能发生在decltype.
③引用折叠可能发生在typedef.

理解std::forward

和std::move一样, 直接去看源码, 实际上它们都平平无奇!

template<typename T>
T&& forward(std::remove_reference_t<T>& param) // ①转发左值和右值
{
    return static_cast<T&&>(param);
}

template<typename T>
T&& forward(std::remove_reference_t<T>&& param) // ②仅转发右值
{
    static_assert(!std::is_lvalue_reference_v<T>, "bad forward call");
    return static_cast<T&&>(param);
}

int x = 0;
auto&& x1 = x; // x1的类型为 int&
auto&& x2 = std::move(x); // x2的类型为 int&&

auto&& x3 = std::forward<decltype(x1)>(x1); // ③x3的类型为 int&
auto&& x4 = std::forward<decltype(x2)>(x2); // ③x4的类型为 int&&

auto&& x5 = std::forward<decltype(std::move(x))>(std::move(x)); // ④转发右值
auto&& x6 = std::move(x); // ④那为何不移动右值?

此例:
①中对于std::forward的实现大致如此, 它能够同时处理左值引用和右值引用, 实际上有这一个版本就足够了.
②中仍然添加了std::forward处理右值的版本, 以避免④中会出现报错, 仅仅如此.
③④std::forward返回了想要的类型, x3和x1类型一致为int&, x4和x2类型一致int&&.
④使用std::forward转发右值意欲何为? 它确实正常工作, 但如果明确想要移动x到x5, 那为何不直接使用std::move呢?

std::forward还是std::move?

当遇到转发引用时, 更多是存在于转发模板参数的情况, 此时应该使用std::forward, 因为我们不清楚传入的是左值还是右值, std::forward能够同时正确的处理这两种情况.
诚然, std::forward提供了处理右值的重载, 也能够得到和std::move一样的效果, 但当确定一个值是右值时, 并希望此时做移动操作, 就应该明确使用std::move来使语义更加明确. 再者使用std::move比使用std::forward也方便很多.

发表回复

电子邮件地址不会被公开。必填项已用 * 标注