【C++基本语法】系列用来记录C++常用基本语法,惯用法。
本文主要回忆左值,右值,move, forward等一组概念,我的理解还比较浅,这里结合几篇文章做个总结。
1. 左值和右值
关于左右值的概念众说纷纭,是个好上手但不好说明的概念。这里列举一些见过的说法(不一定对),以供参考:
左值:在等号左边可以被赋值,在内存中必须存在实体;表达式结束后仍然存在的持久对象,使用的是它的地址
右值:在等号右边,可以在内存中也可以在CPU寄存器;表达式结束后就不再存在的临时对象,不可以取地址,没有名字
对于右值不可以取地址这个说法
有意思的是这篇文章C++ / 左值、右值、将亡值 | 谈谈我对它们的深入理解,提供了这样一个视角:认为区分左右值的依据应该是是否能在语言层面上修改数据,能修改的数据就是左值,不能修改的数据就是右值
同样是基于这篇文章,我们看看下面这几个概念。
2. 左值引用和右值引用以及将亡值
引用是别名,不是变量的别名,而是地址的别名,是与地址建立的一种映射关系
引用基本规则:
- 声明引用的时候必须初始化,绑定后不可再绑定到其他对象;即引用必须初始化,不能对引用重定义
- 对引用的一切操作,就相当于对原对象的操作
左值引用:type &引用名 = 左值表达式
右值引用:type &&引用名 = 右值表达式
2.1 引用和指针
在高级语言的层面上,引用是对地址的一种封装,而指针呢?指针没有封装地址,直接保存了地址,将地址暴露了出来。
2.2 为什么要有引用?
这篇文章认为,语言的设计者鼓励我们多使用引用,而少使用或者不使用指针,就是为了减少使用者对底层结构(地址)的直接接触,将使用者与地址解耦,减少直接接触地址可能存在的风险。
2.3 右值引用是右值吗?对于它的理解错误会陷入常见的陷阱
根据改文章的结论,右值引用不是右值,是左值。通过下面的代码可以发现:用右值引用作为实参不能调用形参为右值引用的函数
template<typename T>
void print(T & t)
{
cout<<"lvaule: "<< t <<endl;
}
template<typename T>
void print(T&& t)
{
cout<<"rvalue: "<< t <<endl;
}
void test_rval_ref()
{
int a = 10;
int&& x = 1;
print(a);
print(x); //这里本意是调用print(T&&), 但调的是print(T&)
print(std::move(x)); // 需要通过move才能进入print(T&&)
}
int main()
{
test_rval_ref();
return 0;
}
结果
lvaule: 10
lvaule: 1
rvalue: 1
所以我们在工程中,定义了这样的函数func(T&& t),即使它的实参是右值引用,也需要通过std::move做一次类型转换才调用到该函数。我理解是,由于右值引用作为参数传递时,由于实参中有了命名(有左值承接),所以继续往下传或者调用其他函数时,根据C++ 标准的定义,这个参数变成了一个左值。那么他永远不会调用接下来函数的右值版本。
上述文章中进一步描述了int &&x = 1,编译器的工作是:先在可写数据区创建只读数据(1)的一份拷贝,再把这份拷贝的地址给引用x,让引用封装这个地址,所以x引用的并不是只读数据 1 的真正地址,而是它的一份拷贝。感兴趣的可以再深入研究。
2.4 将亡值
一般认为将亡值是右值。常见的将亡值有函数的临时返回值,比如auto x = foo(3);中的foo(3);还有表达式,比如(x+y)。
C++11提出了右值引用,将亡值这一概念才会被提出,两者的关系非常紧密,将亡值是右值,右值引用可以引用右值,当然也能引用将亡值,但是引用后的右值引用却是一个左值,我们可以通过右值引用修改将亡值的数据。虽然将亡值被释放了,但是它的资源却给了右值引用,一般在构造函数中,我们将右值引用得到的将亡值资源转移到我们自己对象上。这里提到移动构造的写法,正确的写出这各种构造和赋值也不是容易的。
3. 引用折叠
听起来复杂,相对好理解,需要自己写个例子试试。摘抄网上的描述:
- 所有右值引用折叠到右值引用上仍然是一个右值引用。(A&& && 变成 A&&)
- 所有的其他引用类型之间的折叠都将变成左值引用。 (A& & 变成 A&; A& && 变成 A&; A&& & 变成 A&)
3. std:: move
用的很多但不好说清楚的接口,一般都说其等价于static_cast<T&&>(lvalue),它是将一个左值强制转化为右值引用,继而可以通过右值引用使用该值。从下面的文件可以找到其定义(Ubuntu): /usr/include/c++/8/bits/move.h
/**
* @brief Convert a value to an rvalue.
* @param __t A thing of arbitrary type.
* @return The parameter cast to an rvalue-reference to allow moving it.
*/
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{ return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }
- std::remove_reference依靠模板特化的方式分离出参数的基本类型,比如int& 和int&&都能提取出int类型来。
- move的参数类型是T&&, 加上引用折叠,可以匹配任何类型实参
所以,某种意义上来说,std::move(lvalue) 就约等于 static_cast<T&&>(lvalue),(但其实这是std::forward的实现)即将左值强制转换为右值。而 std::move 中封装了一个类型提取器 std::remove_reference。(std::remove_reference<_Tp>::type的意思是:对于类型_Tp,移除其引用修饰符后得到的类型。如果_Tp不是一个引用类型,std::remove_reference<_Tp>::type和_Tp是相同的。如果_Tp是一个引用类型,比如int&,那么std::remove_reference<_Tp>::type就是int。
这是C++11标准引入的类型特征(type traits)的一部分,用于在编译时获取类型的信息。)
4. std::forward
一个右值引用参数作为函数的形参,在函数内部再转发该参数的时候它已经变成一个左值,并不是他原来的类型。如果我们需要一种方法能够按照参数原来的类型转发到另一个函数,这种转发类型称为完美转发。
/**
* @brief Forward an lvalue.
* @return The parameter cast to the specified type.
*
* This function is used to implement "perfect forwarding".
*/
template<typename _Tp>
constexpr _Tp&&
forward(typename std::remove_reference<_Tp>::type& __t) noexcept
{ return static_cast<_Tp&&>(__t); }
4.1 std::forward 示例1- 一个可以调用一切接口的函数
自己写测试代码的时候可能会用的,模板函数,可以调用任何类型的接口。
// 实现一个可以调用一切接口的函数
template <class T, class ... Args>
auto execAnyfunc(T&& t, Args ... args){
cout<<"execute function: "<<__func__<<endl;
auto func = std::bind(std::forward<T>(t), std::forward<Args>(args)...);
func();
}
4.2 std::forward 示例2 - threadpool Enqueue函数
下面是常见的一种线程池enqueue函数实现,里面用forward构造仿函数。
在这里std::forward的作用是确保参数传递给std::bind时,这些参数的左值或右值性质与原始调用者传递给enqueue函数的性质相同。这对于保持效率和正确性尤其重要,特别是当处理那些对左值和右值有特殊操作或优化的类型时。
如果不使用std::forward,所有的参数都会被当作左值处理,即使它们原本是可以被移动的右值。这可能会导致不必要的拷贝,降低效率,特别是对于大型对象或者是只能移动(不能拷贝)的类型,如std::unique_ptr,这样做可能还会导致编译错误。
template<typename F, typename... Args>
auto enqueue(F &&f, Args &&...args) {
using return_type = std::invoke_result_t<F, Args...>;
auto task = std::make_shared<std::packaged_task<return_type()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...));// 完美转发,构造任务仿函数的指针
std::future<return_type> res = task->get_future(); // 获得函数执行的future返回
{
std::unique_lock<std::mutex> lock(queue_mutex);
if (stop) {
throw std::runtime_error("enqueue on stopped Thread pool");
}
tasks.emplace([task = std::move(task)]() { (*task)(); });// 塞入任务队列
} // 入队列后即可解锁
condition.notify_one(); // 仅唤醒一个线程,避免无意义的竞争
return res;
}
题外话
这几个知识点是面试中最经常问的,但是其实大多数人都是会用,但说不出来。即使总结了这么多,还是感觉一知半解。
参考链接
https://blog.csdn.net/p942005405/article/details/84644069
https://blog.csdn.net/qq_45698148/article/details/120680378
https://blog.csdn.net/p942005405/article/details/84644101
https://zhuanlan.zhihu.com/p/620583555