13.5 动态内存管理类
本节的目标是实现一个行为类似std::vector的类,名为StrVec。它不使用标准库容器,而是自己管理动态内存。这个类包含的关键元素:
1、使用new 和allocator分配原始内存。
2、使用拷贝控制成员(构造、析构、拷贝赋值)来正确管理这块内存的生存周期。
3、实现类似容器的操作,如push_back、size、capacity等。
节练习13.39 编写你自己版本的StrVec,包括自己版本的reserve、capacity(参见9.4节,第318页)和resize(参见9.3.5,第314页)
代码中出现的方法作用:
void push_back(const std::string &): 向StrVec中添加一个元素;
size_t size() : 当前StrVec中含有元素个数;
size_t capacity(): StrVec可以容纳的元素个数;
std::string *begin() : 返回指向StrVec首元素的指针;
std::string *end() : 返回指向数组第一个空闲元素的指针;
void reserve(size_t n): 确保容器至少能容纳n个元素,但不会改变已有元素的数量。如果请求的容量n大于当前容量(capacity()),就分配一块大小为n的新内存,然后将所有现有元素通过移动构造函数转移到新空间,最后释放旧内存。如果n小于等于当前容量,则什么也不做。
void resize(size_t n):
void resize(size_t n, const std::string &s): 改变容器中实际元素的个数。
// StrVec.h
#ifndef STRVEC_H
#define STRVEC_H
#include <memory>
class StrVec
{
public:
StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) {}
StrVec(const StrVec &); // 拷贝构造函数
StrVec &operator=(const StrVec &); // 拷贝赋值构造符
~StrVec(); // 析构函数
void push_back(const std::string &); // 拷贝元素
size_t size() const { return first_free - elements; }
std::string *begin() const { return elements; }
std::string *end() const { return first_free; }
// 练习13.39
size_t capacity() const { return cap - elements; }
void reserve(size_t n);
void resize(size_t n);
void resize(size_t n, const std::string &s);
private:
std::allocator<std::string> alloc; // 分配元素
// 被添加元素的函数所使用
void chk_n_alloc()
{
if (size() == capacity())
{
reallocate();
}
}
// 工具函数,被拷贝构造函数、赋值运算符和析构函数所使用
std::pair<std::string *, std::string *> alloc_n_copy(const std::string *, const std::string *);
void free(); // 销毁元素并释放内存
void reallocate(); // 获取更多内存并拷贝已有元素
std::string *elements; // 指向数组首元素的指针
std::string *first_free; // 指向数组第一个空闲元素的指针
std::string *cap; // 指向数组尾后位置的指针
};
#endif
// StrVec.cpp
#include "StrVec.h"
StrVec::StrVec(const StrVec& s) {
auto newData = alloc_n_copy(s.begin(), s.end());
elements = newData.first;
first_free = cap = newData.second;
}
// 拷贝赋值运算符在释放已有元素之前调用alloc_n_copy,这样就可以正确处理自赋值了
StrVec& StrVec::operator=(const StrVec& rhs) {
// 调用alloc_n_copy 分配内存,大小与rhs中元素占空空间一样多
auto data = alloc_n_copy(rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
StrVec::~StrVec() {
free();
}
void StrVec::free() {
// 不能传递给deallocator一个空指针,如果elements为0, 函数什么也不做
if (elements) {
// 逆序销毁旧元素
for (auto p = first_free; p != elements; /**空 */) {
alloc.destroy(--p);
}
alloc.deallocate(elements,cap - elements);
}
}
void StrVec::reallocate() {
// 我们将分配当前大小两倍的内存空间
auto newcapacity = size() ? 2 *size() : 1;
// 分配新内存
auto newdata = alloc.allocate(newcapacity);
// 将数据从旧内存移动到新内存
auto dest = newdata; // 指向新数组中下一个空闲位置
auto elem = elements; // 指向旧数组中下一个元素
for (size_t i = 0; i != size(); ++i) {
alloc.construct(dest++, std::move(*elem++));
}
free(); // 一旦我们移动完元素就释放旧内存空间
// 更新我们的数据结构,执行新元素
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
void StrVec::push_back(const std::string& s) {
chk_n_alloc(); // 确保有空间容纳新元素
// 在first_free指向的元素中构造s的副本
alloc.construct(first_free++, s);
}
std::pair<std::string*, std::string*> StrVec::alloc_n_copy (const std::string* b, const std::string* e) {
// 分配空间保存给定范围中的元素
auto data = alloc.allocate(e - b);
// 初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成
return {data, uninitialized_copy(b,e,data)};
}
// 练习13.39
void StrVec::reserve(size_t n) {
if (n <= capacity()) return;
auto newdata = alloc.allocate(n);
auto dest = newdata;
auto elem = elements;
for (size_t i = 0; i != size(); ++i) {
alloc.construct(dest++, std::move(*elem++));
}
free();
elements = newdata;
first_free = dest;
cap = elements + n;
}
void StrVec::resize(size_t n) {
resize(n, std::string()); // 委托给两个参数的版本
}
void StrVec::resize(size_t n, const std::string &s) {
if (n < size()) { // 缩小:销毁多余的元素
while (first_free != elements + n)
{
alloc.destroy(--first_free);
}
} else if (n > size()) { // 扩大: 添加新元素
if (n > capacity()) {
// 如果当前容量不足, 重新分配空间(可自定义增长策略,这里用n)
reserve(n);
} else {
// 填充新元素
while(first_free != elements + n) {
alloc.construct(first_free++, s);
}
}
}
}
uninitialized_copy 函数: 是C++标准库中的一个算法,定义在头文件中。它的作用是将一段已初始化内存中的元素拷贝到另一段尚未初始化的原始内存区域,并在目标位置通过拷贝构造的方式构造对象,而不是通过赋值。
template <class InputIterator, class ForwardIterator>
ForwardIterator uninitialized_copy(InputIterator first, InputIterator last, ForwardIterator result);
输入: 迭代器范围[first, last],表示源元素的范围。
输出: 迭代器result, 指向目标内存区域的起始位置(该区域必须足够大,且尚未被构造)。
返回值: 指向最后一个构造元素之后的位置的迭代器。
deallocate函数: 是C++ 标准库中std::allocator类的一个成员函数,用于释放先前由allocate分配的原始内存块。它只释放内存,不会调用任何析构函数(即不销毁对象)。
void deallocate(T* p, std::size_t n);
p:指向先前由allocate返回的内存起始地址的指针。
n:必须与调用allocate时请求的元素个数相同。
作用:1) 将allocate分配的内存归还给系统,避免内存泄漏。
2) 与allocate配对使用,用于手动管理动态内存的整个生命周期。
使用前提: 在调用deallocate之前,必须确保该内存区域中的所有对象都已经通过destroy销毁。deallocate仅释放内存,不负责析构对象。
练习13.40 为你的StrVec类添加一个构造函数,它接受一个initializer_list参数。
// StrVec.h
#include <initializer_list>
StrVec(std::initializer_list<std::string> il);
// StrVec.cpp
StrVec::StrVec(std::initializer_list<std::string> il) {
// 调用alloc_n_copy分配空间并拷贝元素
auto newData = alloc_n_copy(il.begin(), il.end());
elements = newData.first;
first_free = cap = newData.second;
}
std::initializer_listil 是一个轻量级对象,表示花括号初始化列表,例如{"hello","word"}
练习13.41
因为我们需要再first_free当前指向的位置构造一个新元素,然后将first_free向前移动一位,使其指向下一个空闲位置。后置递增运算符first_free++会先返回first_free的当前值(作为construct的目标地址),然后再将first_free加1,这完全符合我们的需求。
如果错误地写成前置递增++first_free,则会先将first_free加1,使其跳过当前的空闲位置,然后再新的位置(既原本的下一个空闲位置)构造元素。这会导致两个问题:
1) 原本的第一个空闲位置被跳过,变成未初始化的内存,造成"空洞"。
2) 如果此时容器已满(first_free原本等于cap),前置递增会使first_free超过cap,导致在未分配的内存上构造对象,引发未定义行为。
练习13.42
// chapter13.cpp
#include "StrVec.h"
#include "TextQuery.h"
#include "QueryResult.h"
#include <iostream>
#include <fstream>
using namespace std;
void runQueries(ifstream &infile) {
// infile 是一个ifstream, 指向我们要处理的文件
TextQuery tq(infile); // 保存文件并建立查询map
// 与用户交互:提示用户输入要查询的单词,完成查询并打印结果
while (true) {
cout << "1 enter word to look for, or q to quit: ";
string s;
if (!(cin >> s) || s == "q") break;
// 指向查询并打印结果
printf(cout, tq.query(s)) << endl;
}
}
int main(void) {
ifstream ifs("textQuery.txt");
runQueries(ifs);
return 0;
}
// QueryResult.h
#ifndef QUERYRESULT_H
#define QUERYRESULT_H
#include <string>
// #include <vector>
#include "StrVec.h"
#include <iostream>
#include <memory>
#include <set>
// using namespace std;
class QueryResult
{
friend std::ostream &printf(std::ostream &, const QueryResult &);
public:
QueryResult(std::string s, std::shared_ptr<std::set<int>> p, StrVec f);
private:
std::string sought;
std::shared_ptr<std::set<int>> lines;
StrVec file;
};
#endif
// QueryResult.cpp
#include "QueryResult.h"
using namespace std;
QueryResult::QueryResult(string s, shared_ptr<set<int>> l, StrVec f):
sought(s),lines(l),file(f)
{
}
ostream &printf(ostream &os, const QueryResult &qr) {
os << qr.sought << " occurs " << qr.lines->size() << " "
<< (qr.lines->size() > 1 ? "times" : "times" )<<endl;
for (auto num : *qr.lines) {
os << "(line " << num << ")" << (qr.file).at(num - 1)<< endl;
}
return os;
}
// TextQuery.h
#ifndef TEXTQUERY_H
#define TEXTQUERY_H
#include <fstream>
// #include <vector>
#include "StrVec.h"
#include <map>
#include <set>
#include <memory>
class QueryResult;
class TextQuery
{
public:
TextQuery(std::ifstream &is);
QueryResult query(const std::string &s) const;
private:
StrVec file;
std::map<std::string, std::shared_ptr< std::set<int>>> mFile;
};
#endif
// TextQuery.cpp
#include "TextQuery.h"
#include <iostream>
#include <sstream>
#include "QueryResult.h"
using namespace std;
QueryResult TextQuery::query(const string &s) const {
static shared_ptr<set<int>> nodata(new set<int>);
auto loc = mFile.find(s);
if (loc == mFile.end()) {
return QueryResult(s,nodata, file);
} else {
return QueryResult(s,loc->second, file);
}
return QueryResult(s,nodata, file);
}
TextQuery::TextQuery(ifstream &is) : file(StrVec()) {
if (!is) {
cout << "Error opening file" << endl;
}
string line;
// set<int> sLineNum;
while(getline(is,line)) {
file.push_back(line);
int lineNum = file.size();
istringstream iss(line);
string word;
while (iss >> word)
{
auto &lines = mFile[word];
if (!lines) { // 不存在
lines.reset(new set<int>);
}
if (word == "Once") {
cout << "lineNum = " <<lineNum << endl;
}
lines->insert(lineNum);
}
}
}
编译: g++ -std=c++11 -o chapter13 chapter13.cpp StrVec.cpp QueryResult.cpp TextQuery.cpp
练习13.43
void StrVec::free() {
// 不能传递给deallocator一个空指针,如果elements为0, 函数什么也不做
if (elements) {
// 逆序销毁旧元素
for_each(elements, first_free, [this] (std::string &p){alloc.destroy(&p);});
// for (auto p = first_free; p != elements; /**空 */) {
// alloc.destroy(--p);
// }
alloc.deallocate(elements,cap - elements);
}
}
关键点在于 lambda 表达式中需要捕获 this,因为 alloc 是类的数据成员,不是局部变量,无法直接访问-5。通过捕获 this,lambda 内部才能使用 alloc.destroy()。
练习13.44
// String.h
#ifndef STRING_H
#define STRING_H
#include <memory>
class String {
public:
String(): elements(nullptr),first_free(nullptr),cap(nullptr){}; // 默认构造函数
String(const char* ); // 接受c风格字符串指针参数的构造函数
String(const String &);// 拷贝构造函数
String& operator=(const String &);// 赋值构造函数
~String();// 析构函数
// 容器操作
size_t size() const { return first_free - elements; }
size_t capacity() const { return cap - elements; }
const char* c_str() const {return elements;} // 返回c风格字符串
// 迭代器操作
char *begin() const { return elements; }
char *end() const { return first_free; }
private:
std::allocator<char> alloc; // 用于分配内存的allocator
char *elements; // 指向字符串首元素的指针
char *first_free; // 指向字符串第一个空闲元素的指针
char *cap; // 指向字符串尾后位置的指针
// 工具函数,被拷贝构造函数、赋值运算符和析构函数所使用
std::pair<char*, char*> alloc_n_copy(const char*, const char*);
void free(); // 销毁元素并释放内存
void reallocate(size_t n); // 获取更多内存并拷贝已有元素
void chk_n_alloc(); // 检查存储空间并分配空间
};
#endif
// String.cpp
#include "String.h"
#include <cstring>
#include <iostream>
using namespace std;
// 接受c风格字符串指针参数的构造函数
String::String(const char *s)
{
cout << "String(const char*)" << endl;
size_t len = strlen(s); // 计算字符串长度
elements = alloc.allocate(len); // 分配原始内存
first_free = cap = elements + len; // 更新指针
// 构造字符(从输入字符串拷贝)
char *dest = elements;
for (size_t i = 0; i < len; ++i)
{
alloc.construct(dest++, s[i]);
}
}
// 拷贝构造函数
String::String(const String &rhs)
{
auto newData = alloc_n_copy(rhs.begin(), rhs.end());
elements = newData.first;
first_free = cap = newData.second;
}
// 赋值构造函数
String &String::operator=(const String &rhs)
{
// 调用alloc_n_copy 分配内存,大小与rhs中元素占空空间一样多
auto data = alloc_n_copy(rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
// 析构函数
String::~String()
{
free();
}
// 工具函数,被拷贝构造函数、赋值运算符和析构函数所使用
pair<char *, char *> String::alloc_n_copy(const char *b, const char *e)
{
// 分配空间保存给定范围中的元素
auto data = alloc.allocate(e - b);
// 初始化并返回一个pair,该pair由data和uninitialized_copy的返回值构成
return {data, uninitialized_copy(b, e, data)};
}
// 销毁元素并释放内存
void String::free()
{
// 不能传递给deallocator一个空指针,如果elements为0, 函数什么也不做
if (elements)
{
// 逆序销毁旧元素
for (char *p = first_free; p != elements; /**空 */)
{
alloc.destroy(--p);
}
alloc.deallocate(elements, cap - elements);
}
}
// 获取更多内存并拷贝已有元素(重新分配内存,用于扩展容量)
void String::reallocate(size_t n)
{
if (n <= capacity())
return;
// 分配新内存
auto newdata = alloc.allocate(n);
// 将数据从旧内存移动到新内存
auto dest = newdata; // 指向新数组中下一个空闲位置
auto elem = elements; // 指向旧数组中下一个元素
// 移动现有元素到新内存
for (size_t i = 0; i != size(); ++i)
{
alloc.construct(dest++, std::move(*elem++));
}
free(); // 一旦我们移动完元素就释放旧内存空间
// 更新我们的数据结构,执行新元素
elements = newdata;
first_free = dest;
cap = elements + n;
}
// 检查存储空间并分配空间
void String::chk_n_alloc()
{
if (size() == capacity())
{
size_t newcapacity = size() ? 2 * size() : 1;
reallocate(newcapacity);
}
}
13.6.1 右值引用
知识点:
1、移后源对象: 指的是被移动操作(移动构造函数或移动赋值运算符)"窃取"了资源后,处于有效但未指定状态的那个原始对象。
std::string s1 = "hello";
std::string s2 = std::move(s1); // s1 成为移后源对象
// 此时 s1 的内容是未指定的(通常为空,但不能依赖)
std::cout << s2; // 输出 "hello"
s1 = "world"; // 可以重新赋值,s1 重新变为有效状态
2、左值(lvalue) :通常指有名称、有持久存储、可以取地址的表达式。它们可以出现在赋值运算符左侧(因而得名),也可以绑定到左值引用。
1) 变量名 int a = 10; // a 是左值
2) 解引用指针 int* p = &a; *p = 20; // *p 是左值(可以赋值)
3) 字符串字面量 "hello" // 字符串字面量是左值(类型为 const char[6])
4) 返回左值引用的函数调用
int& getRef() { static int x; return x; }
getRef() = 42; // 返回左值引用,所以调用结果是左值
5) 成员访问(对象.成员),如果成员是数据成员且对象时左值,则结果是左值。
struct S { int m; };
S obj;
obj.m = 10; // obj.m 是左值
6) 内置赋值、复合赋值、自增/自减(前置)的结果
a = b; // 赋值表达式的结果是左值(返回左侧对象的引用)
++a; // 前置自增结果是左值
7) 下标运算(通常返回左值,例如arr[0])
3、右值(prvalue) : 通常指临时对象、字面量(除字符串字面量)、没有持久存储的表达式。它们不能获取地址(除非绑定到const右值引用),不能出现在赋值运算符左侧。
1) 字面量(除字符串字面量)
42 // 整数字面量是右值
true // 布尔字面量是右值
nullptr // 空指针字面量是右值
'c' // 字符字面量是右值
2) 算数、逻辑、关系等运算结果
a + b // 结果是右值
a > b // 结果是右值
!a // 结果是右值
3)返回非引用类型的函数调用
std::string getStr() { return "hello"; }
getStr() // 返回的临时对象是右值
4) 后置自增/自检的结果
a++ // 返回的是旧值的副本,是右值
a-- // 同理
5) 类型转换(显示或隐式)产生的临时对象 (int)3.14 // 结果是右值
6) lambda表达式(本身是右值,但可以转换为函数指针等)
7) 枚举值(如Color::Red)是纯右值。
4、将亡值(xvalue) : 通常通过std::move或返回右值引用的函数得到,表示资源可以被窃取的对象。
1) std::move(obj) 的结果 std::move(a) // 将 a 转换为右值引用,结果是 xvalue
2) 返回右值引用的函数调用。
int&& getRvalueRef() { return 42; }
getRvalueRef() // 结果是 xvalue
3) 成员访问,如果对象时xvalue,其成员也是xvalue(C++17起)
struct S { int m; };
S().m // 如果 S() 是临时对象(prvalue),但经过成员访问,结果是 xvalue
5、使用std::move()函数
通过调用move函数来获得绑定到左值上的右值引用。int &&rr3 = std::move(rr1),move调用告诉编译器,我们有一个左值rr1,但我们希望像一个右值一样处理它。
练习13.45 解释右值引用和左值引用的区别
1、绑定对象不同:
1) 左值引用(&): 必须绑定到左值。不能绑定到要求转换的表达式、字面常量或是返回右值的表达式(但const左值引用是个例外,它可以绑定到右值)
2)右值引用(&&):必须绑定到右值。它通过&&来获得,且只能绑定到一个将要销毁的对象。
2、所引用对象的性质不同:
1) 左值持久: 左值有持久的状态,是对象的身份(内存位置)。
2) 右值持久: 右值要么是字面常量,要么是表达式求值过程中创建的临时对象,他们短暂且即将被销毁。
3、用途不同:
右值引用的主要目的是实现移动语义。我们可以自由地将一个右值引用的资源"移动"到另一个对象中,从而避免不必要的拷贝。
练习13.46
右值引用 左值引用 左值引用 右值引用
练习13.47 13.48
// chapter13.cpp
#include "String.h"
#include <vector>
#include <iostream>
using namespace std;
int main(void) {
vector<String> v;
v.push_back("Hello");
cout << "=============================1111111" << endl;
v.push_back("Word");
cout << "=============================2222222" << endl;
v.push_back("Hello1");
// v.push_back("Hello");
// v.push_back("Hello");
return 0;
}
// 运行结果
c风格构造函数
拷贝构造函数
=============================1111111
c风格构造函数
拷贝构造函数
拷贝构造函数
=============================2222222
c风格构造函数
拷贝构造函数
拷贝构造函数
拷贝构造函数
13.6.2 移动构造函数和移动赋值运算符
知识点:
1、noexcept声明在移动构造函数和移动赋值运算符中的作用
1) 移动操作的本质不抛异常
移动操作(移动构造、移动赋值)通常只涉及指针交换或资源所有权的转移,不涉及动态内存分配或其他可能失败的操作,因此它们理论上不会抛出异常。
2) 标准库容器在重新分配时的行为。当std::vector需要扩容(重新分配内存)时,它必须将已有元素从旧内存移动到新内存。为了保证强异常安全(若过程中抛出异常,原容易状态保持不变),vector会执行一下策略:
如果移动操作被标记为noexcept:vector会放心地使用移动转移元素,效率高(仅交换指针)。
如果移动操作作为可能抛出异常: vector退化为使用拷贝操作来转移元素,因为拷贝失败时可以回滚,而移动失败后源对象状态可能已被破坏,无法保证原容器状态完整。
3) 异常安全与自治: 如果移动操作确实可能抛出异常(例如,移动时扔需要分配资源),则不应声明noexcept。但这类情况极少,通常应设计移动操作为不抛异常。
2、什么情况下,编译器会合成移动构造函数和移动赋值运算符
只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有非static数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
3、什么情况下,将合成的移动操作定义为删除的函数
1) 移动构造函数被定义为删除的函数的条件是: 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
2) 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的。
3) 如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的。
4) 如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的。
4、什么情况使用拷贝成员,什么情况使用移动成员
移动右值,拷贝左值。以StrVec为例,移动构造函数接受一个StrVec&&类型参数,因此只能用于实参是(非static)右值的情形。拷贝构造函数接受一个const StrVec的引用,因此,它可以用于在任何可以转换为StrVec的类型。如果一个类有拷贝构造函数没有移动构造函数时,即使参数是右值,也调用拷贝构造函数(将StrVec&& 转换为const StrVec &)。总结(左值拷贝、右值移动,有拷贝无移动,都是拷贝)。
5、HasPtr类添加移动构造函数和移动赋值运算符
// HasPtr.h
#include <iostream>
#include <string>
class HasPtr
{
friend void swap(HasPtr &lhs , HasPtr &rhs);
friend void printf(const HasPtr &hp);
friend bool operator<(const HasPtr &hp1, const HasPtr &hp2);
public:
HasPtr(const std::string &s = std::string(), const int &ii = 0) : ps(new std::string(s)), i(ii) {
std::cout << "普通构造函数..." << std::endl;
};
HasPtr(const HasPtr &hp) : ps(new std::string(*hp.ps)), i(hp.i) { // 拷贝构造函数
std::cout << "拷贝构造函数..." << std::endl;
};
HasPtr(HasPtr &&rhs) noexcept : ps(rhs.ps),i(rhs.i) { // 移动构造函数
std::cout << "移动构造函数..." << std::endl;
rhs.ps = nullptr;
rhs.i = 0;
}
HasPtr &operator=(const HasPtr &hp) // 拷贝赋值运算符
{
std::cout << "拷贝赋值运算符..." <<std::endl;
// 先拷贝右侧对象的内容到临时变量,以处理自我赋值
auto new_ps = new std::string(*hp.ps);
// 释放左侧对象原有的内存
delete ps;
// 将指针指向新拷贝的内存
ps = new_ps;
i = hp.i;
// std::cout << "operator=" << std::endl;
return *this; // 返回左侧对象的引用
};
// 移动赋值运算符
HasPtr& operator=(HasPtr &&rhs) noexcept {
std::cout << "移动赋值运算符"<<std::endl;
if (this != &rhs) {
delete ps;
ps = rhs.ps;
i = rhs.i;
rhs.ps = nullptr;
rhs.i = 0;
}
return *this;
};
// HasPtr &operator=(HasPtr hp) { // 赋值运算符
// std::cout << "4 HasPtr::operator=" <<std::endl;
// swap(*this,hp);
// return *this;
// }
// 还需要析构函数(否则会内存泄漏)
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
std::cout << "sawp..." << std::endl;
using std::swap;
swap(lhs.ps, rhs.ps);
swap(lhs.i, rhs.i);
}
inline void printf(const HasPtr &hp) {
std::cout << "ps :" << *hp.ps << ",i :" << hp.i << std::endl;
}
inline bool operator<(const HasPtr &hp1, const HasPtr &hp2) {
// std::cout << "operator<..." << std::endl;
return *hp1.ps < *hp2.ps;
}
// chapter13.cpp
#include "HasPtr.h"
#include <iostream>
using namespace std;
int main(void) {
HasPtr hp1, hp2;
cout << "==============="<<endl;
HasPtr hp3(hp1);
cout << "==============="<<endl;
hp3 = std::move(hp2);
cout << "==============="<<endl;
hp3 = hp1;
return 0;
}
// 运行结果
普通构造函数...
普通构造函数...
===============
拷贝构造函数...
===============
移动赋值运算符
===============
拷贝赋值运算符...
6、拷贝并交换赋值运算符和移动操作
"拷贝并交换"是一种经典的赋值运算符实现技术,它利用值传递和交换操作,将拷贝构造、析构、自赋值安全、异常安全等问题统一解决。当结合移动语义后,它还能同时实现拷贝赋值和移动赋值,只需一个函数就能处理两种场景。
HasPtr& operator=(HasPtr rhs) { // 1. 参数按值传递(拷贝或移动)
swap(*this, rhs); // 2. 交换内容
return *this; // rhs 析构时释放原资源
}
rhs是实参的一个副本。如果实参是左值,调用拷贝构造行数;如果是右值,调用移动构造函数。
// 使用拷贝并交换
// HasPtr.h
#include <iostream>
#include <string>
class HasPtr
{
friend void swap(HasPtr &lhs , HasPtr &rhs);
friend void printf(const HasPtr &hp);
friend bool operator<(const HasPtr &hp1, const HasPtr &hp2);
public:
HasPtr(const std::string &s = std::string(), const int &ii = 0) : ps(new std::string(s)), i(ii) {
std::cout << "普通构造函数..." << std::endl;
};
HasPtr(const HasPtr &hp) : ps(new std::string(*hp.ps)), i(hp.i) { // 拷贝构造函数
std::cout << "拷贝构造函数..." << std::endl;
};
HasPtr(HasPtr &&rhs) noexcept : ps(rhs.ps),i(rhs.i) { // 移动构造函数
std::cout << "移动构造函数..." << std::endl;
rhs.ps = nullptr;
rhs.i = 0;
}
// HasPtr &operator=(const HasPtr &hp) // 拷贝赋值运算符
// {
// std::cout << "拷贝赋值运算符..." <<std::endl;
// // 先拷贝右侧对象的内容到临时变量,以处理自我赋值
// auto new_ps = new std::string(*hp.ps);
// // 释放左侧对象原有的内存
// delete ps;
// // 将指针指向新拷贝的内存
// ps = new_ps;
// i = hp.i;
// // std::cout << "operator=" << std::endl;
// return *this; // 返回左侧对象的引用
// };
// 移动赋值运算符
// HasPtr& operator=(HasPtr &&rhs) noexcept {
// std::cout << "移动赋值运算符"<<std::endl;
// if (this != &rhs) {
// delete ps;
// ps = rhs.ps;
// i = rhs.i;
// rhs.ps = nullptr;
// rhs.i = 0;
// }
// return *this;
// };
HasPtr &operator=(HasPtr hp) { // 拷贝并交换赋值运算符
std::cout << "拷贝并交换..." <<std::endl;
swap(*this,hp);
return *this;
}
// 还需要析构函数(否则会内存泄漏)
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
std::cout << "sawp..." << std::endl;
using std::swap;
swap(lhs.ps, rhs.ps);
swap(lhs.i, rhs.i);
}
inline void printf(const HasPtr &hp) {
std::cout << "ps :" << *hp.ps << ",i :" << hp.i << std::endl;
}
inline bool operator<(const HasPtr &hp1, const HasPtr &hp2) {
// std::cout << "operator<..." << std::endl;
return *hp1.ps < *hp2.ps;
}
// chapter13.cpp
#include "HasPtr.h"
#include <iostream>
using namespace std;
int main(void) {
HasPtr hp1, hp2;
cout << "==============="<<endl;
HasPtr hp3(hp1);
cout << "==============="<<endl;
hp3 = std::move(hp2); // 右值
cout << "==============="<<endl;
hp3 = hp1; // 左值
return 0;
}
// 运行结果
普通构造函数...
普通构造函数...
===============
拷贝构造函数...
===============
移动构造函数...
拷贝并交换...
sawp...
===============
拷贝构造函数...
拷贝并交换...
sawp...
7、更新三/五法则
所有五个拷贝控制成员应该看作一个整体: 一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。
8、std::uninitialized_copy 方法
std::uninitialized_copy 是C++标准库中定义的算法,其作用是将一段已初始化的内存区域中的元素拷贝到另一段未初始化的原始内存区域,并在目标位置通过拷贝构造函数构造(construct)对象,而不是通过赋值运算符。在移动迭代器中,由于传递给uninitialized_copy的是移动迭代器,因此解引用运算符生成的是一个右值引用,这意味着construct将使用移动构造函数来构造元素。
template <class InputIt, class ForwardIt>
ForwardIt uninitialized_copy(InputIt first, InputIt last, ForwardIt d_first);
d_first: 目标内存区域的起始地址,该区域必须是未初始化的原始内存(例如通过allocator::allocate获得)。
返回值: 指向最后一个构造元素之后位置的迭代器(既d_first + (last - first))
9、建议
在移动构造函数和移动赋值运算符这些类实现代码之外的地方,只有当确信需要进行移动操作且移动操作是安全的,才可以使用std::move。
练习13.49
// StrVec.h
StrVec(StrVec &&s) noexcept; // 移动构造函数(不应抛出任何异常)
StrVec& operator=(StrVec &&s) noexcept; // 移动赋值运算符
// strVec.cpp
StrVec::StrVec(StrVec &&s) noexcept
// 成员初始化器接管s中的资源
: elements(s.elements),first_free(s.first_free),cap(s.cap){
// 令s进入这样的状态---对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}
StrVec& StrVec::operator=(StrVec &&rth) noexcept {
if (this != &rth) {
free();
elements = rth.elements;
first_free = rth.first_free;
cap = rth.cap;
rth.elements = rth.first_free = rth.cap = nullptr;
}
return *this;
}
// String.h
String(String &&) noexcept;// 移动构造函数
String& operator=(String &&rhs) noexcept; // 移动赋值构造函数
// String.cpp
String::String(String &&s) noexcept
: elements(s.elements),first_free(s.first_free),cap(s.cap){
s.elements = s.first_free = s.cap = nullptr;
}
String& String::operator=(String &&rhs) noexcept {
if (this != &rhs)
{
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
rhs.elements = rhs.first_free = rhs.cap;
}
return *this;
}
// Message.h
Message(Message &&m) noexcept; // 移动构造函数
Message &operator=(Message && rhs) noexcept; // 移动赋值构造函数
void move_Folders(Message* m); // 辅助函数:移动Message的簿记
// Message.cpp
Message::Message(Message &&m) noexcept : contents(std::move(m.contents)) {
move_Folders(&m); // 移动folders并更新Folder指针
}
Message &Message::operator=(Message && rhs) noexcept {
if (this != &rhs) {
remove_from_Folders();
contents = std::move(rhs.contents); // 移动赋值运算符
move_Folders(&rhs); // 重置Folder指向本Message
}
return *this;
}
void Message::move_Folders(Message* m) {
folders = std::move(m->folders); // 使用set的移动赋值运算符
for (auto f : folders) { // 对每个Folder
f->remMsg(m); // 从Folder中删除旧Message
f->addMsg(this); // 将本Message添加到Folder中
}
m->folders.clear(); // 确保销毁m是无害的
}
练习13.50
如果移动构造函数声明为noexcept,标准库容器在分配时会优先使用移动操作。
1、移动操作确实被调用,避免了深拷贝。
2、noexcept对于容器的高效移动至关重要。
3、在push_back过程中,临时对象(右值)会触发移动构造,而vector扩容时也会移动已有元素。
练习13.51
std::unique_ptr只有移动构造函数,没有拷贝构造函数。当函数返回一个局部unique_ptr对象时,该对象即将被销毁,编译器会将其视为右值,从而自动调用移动构造函数将所有权转移给返回值。因此按值返回unique_ptr是合法的。
问题: 解释为什么返回unique_ptr&& 会导致错误?
若返回unique_ptr&&(右值引用),则返回的是局部对象的引用,函数结束后该局部对象被销毁,返回的引用将悬空,导致未定义行为。同时,右值引用本身并不拥有资源,调用者也无法获得所有权。
练习13.52 详细解释第478页中的HasPtr对象的赋值发生了什么?特别是,一步一步描述hp、hp2以及HasPtr的赋值运算符中的参数rhs的值发生了什么变化?
HasPtr hp, hp2;
hp = hp2; // 左值赋值
hp = std::move(hp2); // 右值赋值
假设初始状态:
hp.ps指向字符串"A",hp.i = 1;
hp2.ps 指向字符串"B", hp2.i = 2;
1、类定义(简化版)
class HasPtr {
public:
// 构造函数
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
// 拷贝构造函数(深拷贝)
HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) {}
// 移动构造函数(窃取资源)
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = nullptr; }
// 析构函数
~HasPtr() { delete ps; }
// 拷贝并交换赋值运算符
HasPtr& operator=(HasPtr rhs) {
swap(*this, rhs);
return *this;
}
// 交换函数(通常为友元)
friend void swap(HasPtr &lhs, HasPtr &rhs) noexcept {
using std::swap;
swap(lhs.ps, rhs.ps); // 交换指针
swap(lhs.i, rhs.i); // 交换整型
}
private:
std::string *ps; // 指向动态分配的string
int i;
};
2、左值赋值hp = hp2
步骤1: 调用赋值运算符
hp.operator = hp2被调用,实参hp2是左值,因此按值传递参数rhs时,使用拷贝构造函数创建一个临时对象rhs。
拷贝构造rhs:rhs.ps = new std::string(*hp2.ps)->新分配的string内容为"B", rhs.i = hp2.i = 2;
此时内存状态:
hp.ps = "A", hp.i = 1;
hp2.ps = "B", hp2.i = 2;
rhs.ps = "B"(新副本), rhs.i = 2;
步骤2:交换*this和rhs
swap(*this,rhs)交换hp和rhs的内部成员。
交换指针: hp.ps与rhs.ps互换->hp.ps现在指向"B"的副本,rhs.ps指向"A"。
交换i: hp.i 变为2, rhs.i 变为1.
此时状态:
hp: ps = "B"(副本), i = 2;
hp2: ps = "B", i = 2; (不变)
rhs: ps = "A", i = 1.
步骤3:函数返回,rhs被销毁
调用rhs的析构函数: delete rhs.ps, 释放原来hp持有的"A"字符串。
最终状态:
hp: 拥有"B" 的独立副本,i = 2;
hp2 :保持不变,仍拥有自己的"B", i = 2.
总结: 左赋值执行了一次深拷贝(拷贝构造rhs),然后交换指针并释放旧资源。结果是hp获得了hp2的副本,两者独立。
3、右值赋值:hp = std::move(hp2)
步骤1:调用赋值运算符
实参srd::move(hp2)将hp2转换为右值引用,因此参数rhs使用移动构造函数构造。
移动构造rhs:rhs.ps = hp2.ps ->直接窃取hp2的指针; rhs.i = hp2.i = 2。然后将hp2.ps置为nullptr(hp2不再拥有资源)。
此时状态:
hp:ps = "A", i = 1;
hp2 : ps = nullptr, i = 2;
rhs:ps = "B" (原hp2的指针),i = 2;
步骤2:交换*this和rhs
swap(*this,rhs)交换hp和rhs的内部成员。
交换指针: hp.ps与rhs.ps互换->hp.ps现在指向"B",rhs.ps指向"A"。
交换i: hp.i 变为2, rhs.i 变为1.
此时状态:
hp:ps = "B", i = 2;
hp2 : ps = nullptr, i = 2;(仍然为空)
rhs:ps = "A" ,i = 1;
步骤3:函数返回,rhs被销毁
调用rhs的析构函数: delete rhs.ps, 释放原来hp持有的"A"字符串。
最终状态:
hp: 拥有了原hp2的"B"资源,i= 2;
hp2: 处于"有效但未指定"状态, ps = nullptr, i = 2(扔可安全析构或重新赋值)
总结: 右值赋值仅执行了一次移动构造(窃取资源)和一次指针交换,没有深拷贝,性能极高。移动后,源对象hp2不再用有任何资源(其指针被置空),但扔处于有效状态。
4、关键点回顾
按值传参使得赋值运算符既能处理左值(拷贝构造rhs)又能处理右值(移动构造rhs)
交换操作(通常noexcept)将新资源与旧资源互换,然后由rhs的析构函数释放旧资源。
该模式天然处理自赋值,提供强异常安全,并自动利用移动语义。
练习13.53: 从底层效率的角度看,HasPtr的赋值运算符并不理想,解释为什么。为HasPtr实现一个拷贝赋值运算符和一个移动赋值运算符,并比较你的新的移动赋值运算符中执行的操作和拷贝并交换版本中执行的操作。
假设执行 hp = std::move(hp2);,初始状态:hp 持有 "A",hp2 持有 "B"。
拷贝并交换版本(统一赋值运算符)和直接移动赋值版本在右值赋值时的操作对比:


拷贝并交换版本 :代码简洁,自动提供异常安全和自赋值安全,但在右值赋值时执行了多余的 swap 操作,且需要额外构造临时对象(即使是移动构造)。
直接移动赋值版本 :更贴近移动语义的本质,仅完成"释放旧资源→窃取新资源"两步,性能更高,且仍可保持 noexcept 保证。
在需要高频移动操作的场景(如容器元素移动),自定义移动赋值运算符能显著提升效率。
练习13.54:如果我们为HasPtr定义了移动赋值运算符,但未改变拷贝并交换运算符,会发生什么?编写代码验证你的答案。
编译错误:可能不知道应该匹配哪个方法,所以报错。
13.6.3 右值引用和成员函数
知识点:
1、类成员函数可以定义为两种参数模式----一个版本接受一个指向const的左值引用; 第二个版本接受一个指向非const的右值引用。
void push_back(const std::string &); // 拷贝元素
void push_back(std::string &&); // 移动元素
一般来说,我们不需要为函数操作定义接受一个const X&&或是一个(普通的)X&参数的版本。当我们希望从实参"窃取"数据时,通常传递一个右值引用。为了达到这一目的,实参不能是const的。类似的,从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个(普通的)X&参数的版本。
2、右值和左值 引用成员函数
在成员函数的参数列表后,可以添加 &或&&引用限定符。
&:表示该成员函数只能被左值对象调用;
&&:表示该成员函数只能被右值对象调用。
class Foo {
public:
void bar() & { /* 仅左值对象调用 */ }
void bar() && { /* 仅右值对象调用 */ }
};
一个函数可以同时用const和引用限定。在此情况下,引用限定符必须跟随在const限定符之后。
class Foo {
public:
void bar() const & { /* 仅左值对象调用 */ }
};
3、如果一个成员函数有引用限定符,则具有相同名字和相同参数列表的所有成员函数必须有引用限定符。
练习13.55 为你的StrBlob添加一个右值引用版本的push_back。
void push_back(std::string &&t) {
data->push_back(std::move(t));
}
练习13.56 如果sorted定义如下,会发生什么:
Foo Foo::sorted() const & {
Foo ret(*this);
return std::move(ret).sorted(); // 调用右值版本
}
无限递归。在 sorted() const & 中,ret 是一个左值对象。ret.sorted() 调用时,编译器会选择匹配的成员函数。由于 ret 是左值,因此会再次调用 sorted() const & 版本,而不是右值版本。这将导致函数不断调用自身,最终栈溢出。
练习13.57 如果sorted定义如下,会发生什么:
Foo Foo::sorted() const & {
return Foo(*this).sorted();
}
Foo(*this) 创建了一个当前对象的临时副本(右值)。该临时对象调用 sorted(),由于它是右值,因此会匹配右值引用版本的 sorted 成员函数(即 Foo sorted() &&)。右值版本对数据进行排序,并返回排序后的对象(可能通过移动)。最终,左值版本的 sorted 返回这个排序后的临时对象,避免了递归。不会导致无限递归,因为临时对象是右值,不会再次调用左值版本。