本文仍为草稿

万能引用

引入

在上一篇有关右值引用的文章的开头, 我们提到了 n1 两个类型相同的表达式在某些地方表现的不同

除了在赋值语句中, 在重载函数调用推导的时候, 也会有不同的表现

考虑以下程序

void foo(int& x){
std::cout << "int& " << x << std::endl;
}

void foo(int&& x){
std::cout << "int&& " << x << std::endl;
}

int main(){
int a = 5;

foo(a); // int& 5
foo(1); // int&& 1
}

编译器符合预期的把作为左值的 a1 分别推导到了符合的, 相对应的重载函数上去, 但是我们删去第二个 foo 所在的那一行代码

void foo(int& x){
std::cout << "int& " << x << std::endl;
}

// void foo(int&& x){
// std::cout << "int&& " << x << std::endl;
// }

int main(){
int a = 5;

foo(a); // int& 5
foo(1); // cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'
}

这句报错翻译过来意思就是: 不能将作为左值参数的 x 绑定到作为右值的 1 上去

这也很好理解, 右值是没有自己所表征的内存的, 并不能用于左值绑定, 这也在上一篇文章中讨论过了

如果把 foo 接受的参数类型改成 const int&, 就可以接受右值了

void foo(const int& x){
std::cout << "int& " << x << std::endl;
}

// void foo(int&& x){
// std::cout << "int&& " << x << std::endl;
// }

int main(){
int a = 5;

foo(a); // int& 5
foo(1); // int& 5
}

这与我们当时说的 “右值本质是左值” 是一致的

避免指数级重载

考虑一个有 NN 个参数的函数, 假如要求函数调用时, 能对其任意一个参数独立调用左值 / 右值…

我们要编写 2N2^N 个重载函数!!!

这听起来就不可思议, 所以在 C++11 中引入了万能引用来避免这个事情的发生

顾名思义, 万能引用就是一种既能支持左值, 也能支持右值引用的引用, 标准格式如下

template<typename T>
void foo(T&& x);

这样的模板函数, 就能同时接受左值引用和右值引用作为参数, 我们称这样的行为为万能引用

甄别

接下来我们当一下语法警察, 对一些若是若非的 “万能引用” 作甄别

依然考虑之前给出的模板代码, 假如我们做出以下调用行为

template<typename T>
void foo(T&& x){
std::cout << x << std::endl;
}

int main(){
int v = 1;
int& r = v;
int&& rr = 2;

foo(1); // 1
foo(v); // 1
foo(r); // 1
foo(rr); // 2
}

这 4 种调用方法都是合法的, 但是值得注意的是这 4 种调用方式中, T 的类型推导结果是不同的, 请看下面的例子:

00974f5d4fa73b91f929.png

如果我们显式的实例化模板函数, 我们会发现万能引用似乎失效了

In fact, 万能引用实际上就是编译器, 为模板函数对一个确定的引用做出合理的推导

类似的, 以下写法也不属于万能引用

  1. 模板为参数推导, 此时只能是右值引用

    template<typename T>
    void foo(std::vector<T>&& vec);
  2. 常量右值引用

    template<typename T>
    void foo(const T&& x);
  3. 模板类中的成员函数, 由于实例化模板类的时候, T 的类型已经确定, 因此编译器在调用 Foo::foo() 的时候并没有进行类型推导, 因此这也不是一个万能引用

    template<typename T>
    struct Foo{
    void foo(T&& x);
    };

引用折叠

有可能你会好奇: T&& 是怎么既推导出 int&, 又推导出 int&&

实际上这里发生了引用折叠, 对于引用的引用, 编译器会将其折叠为单一的引用, 具体规则如下:

template<typename T>
void foo(T&& x);

int main(){
int i = 1;
foo(i); // i is lvalue, T = int&, int& && = int &
foo(0); // 0 is rvalue, T = int , int&& = int &&
}

C++ 允许模板类型推导出现引用的引用, 其实对于类型别名, 也是允许的

typedef int& lref;
typedef int&& rref;

int main(){
int n = 1;
lref & a1 = n;
lref && a2 = n;
rref & a3 = n;
rref && a4 = 1;
}

在这种情况下, 满足如下规则: 当且仅当右值引用的右值引用是折叠到右值引用, 其他情况均为左值引用

可变参数模板

曾几何时, 我们在学习 C语言的时候, 会好奇为什么 printf()scanf() 可以接收任意多个参数, 究竟什么样的写法可以支持这个行为呢?

在 C++11 中引入了可变参数模板 – 一种可以接受任意数量参数的写法, 很好的实现了上面的功能

什么? 你问我 C语言是怎么实现的? 我不知道(嘿嘿

C++ 用形象的 ... 表示可变参数

template<typename... Args>
struct Foo{
Foo(const Args&... args);
};

int main(){
Foo foo(1, 2, 3); // any number of parameters
}

这个程序定义了一个模板类, 将 typename... 的类型交由编译器自行推导, 并命名为 Args, 同时类的构造函数接受一个常量的 Args 引用 args 作为参数

对于第 7 行的实例化, 编译器显然将 Args 推导为 int

类似的函数也可以用可变参数模板

template<typename... Args>
void fn(Args... args);

可变参数模板不要求类型相同, 所以下面的写法是可以接受的

template<typename... Args>
void fn(Args... args){
// ...
}

int main(){
fn(1, 2, "ss");
}

递归展开

void my_printf(){
std::cout << std::endl;
}

template<typename T, typename... Args>
void my_printf(T first_item, Args... args){
std::cout << first_item << ' ';
my_printf(args...);
}

int main(){
my_printf(1, 2, "ss", 3.f);
}

注意我们定义的空参数函数, 这是递归边界

初始化容器展开

利用 C++11 的特性 initialize_list, 可以用参数包展开初始化一个 initialize_list, 再用这个 initialize_list 初始化容器, 之后我们就可以对这个容器做操作了

以升序输出任意长序列为例

template<typename T, typename... Args>
void print_sorted_seq(T fisrt, Args... args){
std::initializer_list init_list{args...};
std::vector<T> vec;
vec.push_back(fisrt);
for(auto i:init_list){
vec.push_back(i);
}
std::sort(vec.begin(), vec.end());
for(auto i:vec){
std::cout << i << ' ';
}
std::cout << std::endl;

}

int main(){
print_sorted_seq(1, 5, 2, 6, 9); // 1 2 5 6 9
}

这个函数通过把 args 展开, 存入 vec 的方式, 实现排序

折叠表达式展开

C++17 引入了更简洁的写法展开参数包, 我们成为折叠表达式

template<class... Args>
void print_sorted_seq(Args... args){
(std::cout << ... << args); // neccesary "()"
std::cout << std::endl;
}

int main(){
print_sorted_seq(1, 5, 2, 6, 9); // 15269
}

关于折叠表达式的介绍可以放在之后集体介绍 C++17 特性的时候, 现在就暂作了解

同时可以用格式化类进行格式化输出

template<typename T>
std::string format(T&& str){
std::stringstream strstr; // #include <sstream>
strstr << str << ' ';
return strstr.str();
}

template<typename... Args>
void print_sorted_seq(Args... args){
(std::cout << ... << format(args));
std::cout << std::endl;
}

int main(){
print_sorted_seq(1, 5, 2, 6, 9); // 1 5 2 6 9
}

emplace_back()

C++11 为顺序容器引入了一种新的元素插入方式: emplace_back()

考虑一个可以接受多个参数构造的类 Complex, 对于 push_backemplace_back 有不同的写法

struct Complex{
Complex() = default;
Complex(float r, float i) : r(r), i(i) {};
float r, i;
};

std::ostream& operator<<(std::ostream& os, const Complex& comp){
return os << comp.r << '+' << comp.i << 'i';
}

int main(){
std::vector<Complex> vec;
vec.push_back({
3, 5
});
vec.emplace_back(
2, 6
);
std::cout << vec[0] << ',' << vec[1] << std::endl;
// 3+5i,2+6i
}

在这里我们不深究两者的性能差距, 我们只需要关注为什么 emplace_back() 可以接受任意数量的参数?

打开 MSVC 的 vector 库源码可以发现 (已做可读性调整)

template <class T, class Alloc = allocator<T>>
class vector{
public:
template <class... Args>
inline decltype(auto) emplace_back(Args&&... args) {
// insert by perfectly forwarding into element at end, provide strong guarantee
T& result = _Emplace_one_at_back(::std::forward<Args>(args)...);
return result;
}

inline void push_back(const T& val) {
// insert element at end, provide strong guarantee
_Emplace_one_at_back(val);
}
}

相比于 push_back, emplace_back 多了一个模板 Args, 这正好和我们方才讨论的可变参数模板相似

同时值得注意的是, 注释中提到了 “perfectly forwarding”, 同时下面还用到一个函数 forward, 似乎这整个函数都是靠完美转发才能实现的, 接下来将深入介绍这个概念 – 完美转发

完美转发

auto 万能引用

在开始完美转发之前, 我们先对万能引用做一个补充

除了在函数模板中可以使用万能引用, 在变量声明的时候也可以使用万能引用, 格式如下

auto&&;
auto&&...;

与万能引用相似的是, auto&& 也能绑定到左值和右值引用上面

引入

完美转发解决的问题就是: 转发过程中, 引用类型丢失的问题; 换言之, 完美转发能同时转发引用和引用类型; 再换言之, 完美转发能让我们实现对左值和右值做出不一样的操作

我们的预期可以描述如下:

// to lvalue
T foo(Arg& a){
return T(a);
}

foo(i);

// to rvalue
T foo(Arg&& a){
return T(std::move(a));
}

foo(0);

万能引用的引入

首先, 我们的函数就要既可以接受左值, 又可以接受右值…

这不就是万能引用吗?

很好, 既然这个问题之前已解决了, 那我们就不再赘述, 定义我们的函数如下

template<typename Arg>
T foo(Arg&& a);

再考虑引用折叠, 先前的两个函数其实就可以分别推导为下面两个函数了

// Arg = int&, int& && = int&
foo<int&>(int& a);

foo(i);

// Arg = int, int&& = int&&
foo<int>(int&& a);

foo(0);

std::forward

接下来的问题就是: 我们怎么将被看作是左值的右值转回右值, 同时不改变原本就是左值的左值的?

再次应用引用折叠, 我们可以巧妙的给出下面的写法

template<typename Arg>
T foo(Arg&& a){
return T(static_cast<Arg&&>(a));
}

接下来让我好好解释一下这个写法的工作原理

  • a 是左值 (也就是 i), 通过类型推导, Arg 会被推导为 int&, 在函数体里面通过引用折叠, int& && 会被折叠成 int&, 左值 i 仍然被强制类型转换为左值
  • a 是右值 (也就是 0), 通过类型推导, Arg 会被推导为 int, 在函数体里面, a 就会直接被转化为右值

真的是太巧妙了

为了可读性, 和 std::move 类似, 这样的 static_cast, 我们给他一个名字: std::forward

其定义如下:

template <class T>
constexpr T&& forward(remove_reference_t<T>& arg) noexcept {
return static_cast<T&&>(arg);
}

可以看到, 这个函数本质上就是做了一个向 T&& 的强制类型转换, 因此我们要牢记: std::forward 实际上不转发任何东西, 就像 std::move 实际上不移动任何东西一样

这样我们可以把 foo 写成一个好看的形式:

template<typename Arg>
T foo(Arg&& a){
return T(std::forward<Arg>(a));
}

写到这里才发现之前没有给出 std::move 的实现, 补一下, 做个对比

template <class T>
constexpr remove_reference_t<T>&& move(T&& arg) noexcept {
return static_cast<remove_reference_t<T>&&>(arg);
}

remove_reference_t 的作用是移除 T 的引用类型(左值还是右值)