右值引用和移动语义
右值引用和移动语义都是C++11之后才加入的新特性,概括的说,这二者的加入很大程度上是为了减小深拷贝的开销,提高效率才加入的。
所谓的右值,顾名思义似乎就是等号右边的值,常常用等号的朋友都知道,等号的作用是把等号右边的值赋给左边,那么按理来说,左边的值作为接受值的值,当然就要求得有地址、是持久的;而右边的值更像一个“临时值”,所有对于所谓的左值和右值来说,区别就在于是不是有地址的值。
还记得深拷贝吗?所谓的深拷贝和浅拷贝的区别主要在于针对我们动态创建的临时对象,比如我们new了一个新的对象,然后我们去拷贝这个对象,为了不造成一些诸如悬空指针或者两个指针指向同一内存这种事(指针悬挂问题),我们需要进行深拷贝(不仅仅是对象本身,还包含对象中指向的内存),但是假如我们有这样一个情景:我们要不断地使用一个对象,但是我们需要在很多不同的作用域去使用它,如果按照深拷贝的逻辑,我们会不断地拷贝出新的复制件出来:这是非常大的开销,这个时候我们就会想,要是我们不用一直拷贝而只是不断地转移资源的占有权,这样在内存中真正存在的只有一个备份,这样是不是就大大减小了开销呢?
这个方法,这个转移资源所有权的方法就是我们的移动语义,他是通过我们写移动构造函数和移动赋值运算符重载实现的,而所谓的移动语义就必须是针对右值才可行,也就是临时变量。
那么问题来了,左值难道就不能移动语义了吗?要知道很多变量之类的东西可都是左值呀。能的兄弟能的,我们只需要使用一手move()函数即可:
这样的话无论左值还是右值都可以去实现移动语义了,具体的实现方法就像我上文所说的:通过移动构造函数和移动赋值运算符重载实现,那么具体来说怎么实现呢?
class A{public://拷贝构造函数A(A& a) : x(a.x){cout << "Copy Constructor" << endl;}//赋值运算符A& operator=(A& a){x = a.x;cout << "Copy Assignment operator" << endl;return *this;}//移动拷贝A(A&& a) : x(a.x){cout << "Move Constructor" << endl;}//移动赋值A& operator=(A&& a){x = a.x;cout << "Move Assignment operator" << endl;return *this;}private:int x;
}
这是一个拷贝构造函数、拷贝赋值运算符重载、移动构造函数、移动赋值运算符重载的示例,可以看到拷贝和移动最大的区别就是我们的参数中一个是&代表左值引用,一个是&&代表右值引用,这也是我们在实例化对象后决定具体是调用移动还是拷贝的重要判断条件:我们根据传入的参数具体是左值还是右值来决定是调用拷贝还是移动。
但是这里又会出现一个问题是:
有的时候我们的函数可能会修改参数的本来性质(左值或者右值),导致只调用移动或者只调用拷贝。这个时候就涉及到我们的另一个概念:完美转发了。完美转发的核心是所谓的引用折叠:我们只需要再在原引用的基础上加一个&&,那么我们的左值引用还会是左值,右值引用也还会是右值。在C++的标准库中提供了这个函数:forward()。
#include <iostream>
#include <vector>class ResourceHolder {
private:std::vector<int>* data;public:// 默认构造函数ResourceHolder() : data(new std::vector<int>(1000, 42)) {std::cout << "默认构造:分配资源\n";}// 拷贝构造函数(深拷贝)ResourceHolder(const ResourceHolder& other) : data(new std::vector<int>(*other.data)) {std::cout << "拷贝构造:深拷贝资源\n";}// 移动构造函数(资源转移)ResourceHolder(ResourceHolder&& other) noexcept : data(other.data) {other.data = nullptr; // 原对象资源置空std::cout << "移动构造:资源转移\n";}~ResourceHolder() {if (data) {delete data;std::cout << "析构:释放资源\n";} else {std::cout << "析构:资源已转移,无需释放\n";}}
};int main() {ResourceHolder obj1; // 默认构造ResourceHolder obj2(obj1); // 拷贝构造(深拷贝)ResourceHolder obj3(std::move(obj1)); // 移动构造,obj1转为右值// 后续操作:// ResourceHolder obj4(obj1); // 危险!obj1.data已被置空return 0;
}
可以看到在我们的代码中我们先传入一个左值调用拷贝构造函数之后我们通过move把obj1左值转换为右值之后调用移动构造函数,这个时候我们的obj1就已经被释放了,再把它作为参数传入就会报错。
#include <iostream>
#include <utility> // 包含 std::forward// 目标函数:区分左值和右值的重载版本
void processValue(int& x) {std::cout << "处理左值: " << x << std::endl;
}void processValue(int&& x) {std::cout << "处理右值: " << x << std::endl;
}// 模板包装函数:通过完美转发调用目标函数
template <typename T>
void perfectForwarder(T&& arg) {// 使用 std::forward<T> 保持参数的原始值类别processValue(std::forward<T>(arg));
}int main() {int a = 42;const int b = 100;// 测试左值转发perfectForwarder(a); // 调用 processValue(int&)perfectForwarder(b); // 调用 processValue(int&)(保留 const 属性)perfectForwarder(200); // 调用 processValue(int&&)// 测试右值转换后的转发perfectForwarder(std::move(a)); // 调用 processValue(int&&)return 0;
}
可知完美转发是在我们调用函数之前的一个处理步骤,我们针对区分左值和右值的重载函数用一个模板函数并用forward修饰其参数就可以实现完美转发。
#include <utility>class Data {
public:Data() = default;// 移动构造函数Data(Data&& other) noexcept { /* 转移资源 */ }
};template <typename T>
void processResource(T&& arg) {// 完美转发参数,可能触发移动语义Data data(std::forward<T>(arg));
}int main() {Data d1;processResource(d1); // 传递左值,调用拷贝构造函数processResource(Data()); // 传递右值,调用移动构造函数processResource(std::move(d1)); // 显式转为右值,调用移动构造函数
}
完美转发和移动语义是可以互补的,如上述代码所示。
在这里我们还要展开说的一个东西是:我们前文中的noexcept。
可以看到针对比如vector的扩容时我们就需要给移动构造函数声明noexcept,这样我们才会在扩容时优先调用移动构造。(原理很简单,当我们需要扩容时我们有两个选择:拷贝或者移动,可是一般默认都会是拷贝,如果不noexcept声明移动构造是安全的话系统会拒绝调用可能存在异常的方式)
总的来说,noexcept关键字最大的作用就是赋予不可能抛出异常的对象优先级以提高代码运行效率。
迭代器
迭代器作为我们STL中必不可少的一部分,是组成STL库的六大组件之一:
其中我们的迭代器就是我们容器与算法沟通的桥梁,我们必须用迭代器才能访问容器内的元素,从而在容器中实现算法。
刚开始学习时,我们总是会说:把迭代器看成一个指针就好,因为客观地说迭代器在容器中的作用就和指针在数组里的作用差不多,且迭代器比指针更安全,不会出现指针越界、悬空指针等问题。
一般来说,我们总是会在容器内来定义迭代器类,比如:
template <typename T>
class MyVector {
private:T* m_data; // 动态数组指针size_t m_size; // 当前元素数量size_t m_cap; // 总容量public:// 嵌套迭代器类模板template <bool IsConst>class Iterator {using ValueType = std::conditional_t<IsConst, const T, T>;ValueType* m_ptr;
...
}
可以看到我们定义了迭代器类(模板类),其中我们这里用了using 和 conditional_t两个函数。
using最常见的用法是引用命名空间,比如using namespace std;但其实还可以用来生成别名,比如在我们的代码中我们就用ValueType来给等号右边的值生成别名。
右边的conditional_t则是另一个用来判断的函数:如果IsConst为true则返回const T,否则为T。
然后我们先是生成一个指针。
到目前为止,我们的迭代器类里的内容和指针别无二致,但是接下来我们还要继续完善。
public:// STL迭代器类型特征(关键:兼容算法)using iterator_category = std::random_access_iterator_tag;using value_type = T;using difference_type = std::ptrdiff_t;using pointer = ValueType*;using reference = ValueType&;
我们在这里新生成了一堆别名,最基础的三个:类型、指针、引用比较容易理解,但是还有两个别名涉及到了两个函数:random_access_iterator_tag、ptrdiff_t。
简单地说,我们还添加了迭代器是否支持随机访问以及指针的差值。
explicit Iterator(ValueType* ptr) : m_ptr(ptr) {}// 元素访问运算符reference operator*() const { return *m_ptr; }pointer operator->() const { return m_ptr; }// 迭代器运算Iterator& operator++() { ++m_ptr; return *this; }Iterator operator++(int) { auto tmp = *this; ++m_ptr; return tmp; }Iterator operator+(difference_type n) const { return Iterator(m_ptr + n); }Iterator& operator+=(difference_type n) { m_ptr += n; return *this; }// 比较运算符bool operator==(const Iterator& other) const { return m_ptr == other.m_ptr; }bool operator!=(const Iterator& other) const { return !(*this == other); }// 输出迭代器指向的值friend ostream& operator<<(ostream& os, const Iterator& it) {os << *it; // 通过解引用输出元素值return os;}
构造函数我们依然用explicit来修饰,要求必须显式调用构造函数防止隐式转换。
然后是一系列的操作符重载,重载了*,->,++,+,+=,==和!=这几个运算符,这里有几个可以注意的点:
// 前置/后置递增
Iterator& operator++() { ++m_ptr; return *this; }
Iterator operator++(int) { Iterator tmp = *this; ++m_ptr; return tmp; }
这里我们重载的分别是前缀自增和后缀自增,可以看到前缀自增我们直接增加m_ptr即可,而后缀自增我们需要去先生成一个副本后再增加m_ptr后返回副本。
然后是我们对于<<的重载,这里可以看到我们用了friend关键字,因为我们重载<<运算符的目的就是为了输出(使用cout),啊这个过程会涉及到对类中成员的访问(private访问权限),如果不加friend关键字的话是访问不到的。
// 输出迭代器指向的值friend ostream& operator<<(ostream& os, const Iterator& it) {os << *it; // 通过解引用输出元素值return os;}
// 类型别名(解耦迭代器类型)using iterator = Iterator<false>;using const_iterator = Iterator<true>;// 构造函数与析构函数MyVector(size_t cap = 10) : m_data(new T[cap]), m_size(0), m_cap(cap) {}~MyVector() { delete[] m_data; }// 获取迭代器iterator begin() { return iterator(m_data); }iterator end() { return iterator(m_data + m_size); }const_iterator cbegin() const { return const_iterator(m_data); }const_iterator cend() const { return const_iterator(m_data + m_size); }
这里没什么好说的。
// 添加元素void push_back(const T& val) {if (m_size >= m_cap) resize();m_data[m_size++] = val;}private:void resize() {m_cap *= 2;T* new_data = new T[m_cap];std::copy(m_data, m_data + m_size, new_data);delete[] m_data;m_data = new_data;}
完善一下MyVector的push_back函数和resize函数即可。
我们来测试一下写的代码:
int main() {MyVector<int> vec(3); vec.push_back(1);vec.push_back(2);vec.push_back(3);for (auto it = vec.begin(); it != vec.end(); ++it) {cout << *it << " ";}cout << endl;}
结果如图:
Type traits
所谓的type traits,就是类型特征,或者说类型萃取,简单的说的话就是允许我们在运行时去检查我们的类型的一些属性,比如在之前迭代器的代码中我们定义的迭代器属性:
public:// STL迭代器类型特征(关键:兼容算法)using iterator_category = std::random_access_iterator_tag;using value_type = T;using difference_type = std::ptrdiff_t;using pointer = ValueType*;using reference = ValueType&;
分别是类型、指针、引用、指针差异和是否支持随机访问,那这个时候可能我们的算法在使用迭代器访问容器时,它可能就会想知道这个容器中的迭代器中的指针具体是什么类型的指针,引用具体是什么类型的引用,这个时候就需要我们的类型萃取了。
#include <iostream>
#include <type_traits>// 模板函数:根据类型特性选择处理方式
template <typename T>
void processValue(const T& val) {// 类型安全检查(编译时)static_assert(std::is_copy_constructible_v<T>,"T must be copyable");// 根据类型特性分支处理if constexpr (std::is_integral_v<T>) { // 如果是整型[1,4](@ref)std::cout << "[整数处理] " << val * 2 << std::endl;}else if constexpr (std::is_floating_point_v<T>) { // 如果是浮点型[1,4](@ref)std::cout << "[浮点数处理] " << val + 0.5 << std::endl;}else { // 其他类型std::cout << "[通用处理] " << val << std::endl;}
}int main() {processValue(42); // 输出: [整数处理] 84processValue(3.14); // 输出: [浮点数处理] 3.64processValue("hello"); // 输出: [通用处理] hello
}
这是一个简单的使用样例,我们在这里又看见了几个函数:
简单地说,static_assert负责用来进行断言检查,所谓的断言检查就是我们如果不满足给定的判断条件的话就会直接抛出异常并打印写好的语句;constexpr则是const的好搭档:const修饰变量不可更改,而constexpr则是可以检查参数是否为编译器常量,如果是的话我们就会直接在编译器计算出结果。
回到代码,我们可以看到我们中有两个判断条件:
if constexpr (std::is_integral_v<T>)
if constexpr (std::is_floating_point_v<T>)
这里的两个if()括号里的内容就是type traits库里的函数,含义也非常直白地通过名字告诉我们了:是否是整数、是否是浮点数。
总的来说,type traits就是: