本文参考 C + + P r i m e r C++\ Primer C++ Primer, E f f e c t i v e C + + Effective\ C++ Effective C++
目录
资源管理
- 所谓资源就是,一旦用了它,将来必须还给系统。如果不这样,糟糕的事情就会发生
- C++程序中最常使用的资源就是动态分配内存(如果你分配内存却从来不曾归还它,会导致内存泄漏)。其他常见的资源还包括文件描述器(file descriptors) 、互斥锁(mutex locks) 、图形界面中的字型和笔刷、数据库连接、以及网络sockets
异常安全
异常安全函数
异常安全函数 (Exception-safe functions) 提供以下三个保证之一:
- 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。所有对象都处于一种内部前后一致的状态。然而程序的实际状态恐怕不可预料。例如,对象的状态可能是一个缺省状态,也可能保持原状态
- 强烈保证:如果函数失败(抛出异常),程序会回复到 “调用函数之前” 的状态
- 不抛掷 (nothrow) 保证,承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型的所有操作都提供 nothrow 保证
copy and swap
- copy and swap: 为你打算修改的对象(原件)做出一份副本,然后在那副本身上做一切必要修改。若有任何修改动作抛出异常,原对象仍保持未改变状态。待所有改变都成功后,再将修改过的那个副本和原对象在一个不抛出异常的操作中置换 (swap)
- copy and swap 可提供“异常安全”的 强烈保证
以对象管理资源
- 把资源放进对象内,我们便可依赖 C++ 的析构函数来确保资源被释放;也可使用智能指针来管理资源
如果用智能指针来管理资源,那么类中甚至都不需要析构函数;可以给智能指针提供自定义的删除器来自定义释放资源的操作
- 通常, 管理类外资源的类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数, 那么它几乎肯定也需要拷贝构造函数和拷贝赋值运算符
- 为了定义拷贝成员, 我们首先必须确定此类型对象的拷贝语义
- 类值对象:当我们拷贝一个像值的对象时,副本和原对象是完全独立的
- 类指针对象:行为像指针的类则共享状态。当我们拷贝一个这种类的对象时, 副本和原对象使用相同的底层数据
行为像值的类
class HasPtr {
public:
HasPtr(const std::string &s = std::string()) :
ps(new std::string(s)), i(0) {
}
// 对 ps 指向的 string, 每个 HasPtr 对象都有自己的拷贝
HasPtr(const HasPtr &p):
ps(new std::string(*p.ps)), i(p.i) {
}
HasPtr& operator=(const HasPtr &);
~HasPtr() {
delete ps; }
private:
std::string *ps; // 管理的资源为 string
int i;
}
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
// 通过先拷贝右侧运算对象来处理自赋值情况,同时代码也是“异常安全”的
auto newp = new string(*rhs.ps); // 拷贝底层 string
delete ps; // 释放旧内存
ps = newp;
i = rhs.i;
return *this;
}
行为像指针的类
- 对于行为类似指针的类, 我们需要为其定义拷贝构造函数和拷贝赋值运算符来拷贝指针成员本身而不是它指向的
string
- 我们的类仍然需要自己的析构函数来释放接受
string
参数的构造函数分配的内存。但是, 在本例中, 析构函数不能单方面地释放关联的string
。只有当最后一个指向string
的HasPtr
销毁时, 它才可以释放string
- 最好的解决方法是使用
shared_ptr
来管理类中的资源。拷贝(或赋值) 一个shared_ptr
会拷贝(赋值)shared_ptr
所指向的指针。当没有用户使用对象时,shared_ptr
类负责释放资源 - 但是, 有时我们希望直接管理资源。在这种情况下,使用 引用计数(reference count) 就很有用了
- 最好的解决方法是使用
引用计数
- 除了初始化对象外, 每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,计数器初始化为 1
- 拷贝构造函数不分配新的计数器, 而是拷贝给定对象的数据成员, 包括计数器。拷贝构造函数递增共享的计数器, 指出给定对象的状态又被一个新用户所共享
- 析构函数递减计数器, 指出共享状态的用户少了一个。如果计数器变为 0, 则析构函数释放状态
- 拷贝赋值运算符递增右侧运算对象的计数器, 递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0, 意味着它的共享状态没有用户了, 拷贝赋值运算符就必须销毁状态
- 唯一的难题是确定在哪里存放引用计数。一种方法是将计数器保存在动态内存中。当创建一个对象时, 我们也分配一个新的计数器。当拷贝或赋值对象时, 我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器
定义一个使用引用计数的类
class HasPtr {
public:
// 构造函数分配新的 string 和新的计数器, 将计数器置为 1
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0), use(new std::size_t(1)) {
}
// 拷贝构造函数拷贝所有三个数据成员, 并递增计数器
HasPtr(const HasPtr &p):
ps(p.ps), i(p.i), use(p.use) {
++*use; }
HasPtr& operator= (const HasPtr&);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; // 用来记录有多少个对象共享 *ps 的成员
};
HasPtr::~HasPtr()
{
if (--*use == 0) {
// 如果引用计数变为0
delete ps; // 释放string内存
delete use; // 释放计数器内存
}
}
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; // 递增右侧运算对象的引用计数
if (--*use == 0) {
// 然后递减本对象的引用计数
delete ps;
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}
补充:练习13.28
智能指针 和 资源管理
智能指针 和 异常
- 使用异常处理的程序能在异常发生后令程序流程继续,我们注意到, 这种程序需要确保在异常发生后资源能被正确地释放。一个简单的确保资源被释放的方法是使用智能指针
- 如果使用智能指针, 即使程序块过早结束, 智能指针类也能确保在内存不再需要时将其释放 (因为一定会调用其析构函数)
- 与之相对的, 当发生异常时, 我们直接管理的内存是不会自动释放的
void f()
{
shared_ptr<int> sp(new int(42)); //分配一个新对象
// 这段代码抛出一个异常, 且在f中未被捕荻
)
智能指针 和 哑类
- 包括所有标准库类在内的很多C++类都定义了析构函数,负责清理对象使用的资源。但是,不是所有的类都是这样良好定义的。特别是那些为C和C++两种语言设计的类, 通常都要求用户显式地释放所使用的任何资源
- 那些分配了资源, 而又没有定义析构函数来释放这些资源的类, 可能会遇到与使用动态内存相同的错误:程序员非常容易忘记释放资源。类似的, 如果在资源分配和释放之间发生了异常, 程序也会发生资源泄漏
- 与管理动态内存类似,我们通常可以使用智能指针来管理不具有良好定义的析构函数的类
- 例如,假定我们正在使用一个C和C++都使用的网络库,使用这个库的代码可能是这样的:
struct destination; // 表示我们正在连接什么
struct connection; // 使用连接所需的信息
connection connect(destination*); // 打开连接
void disconnect(connection); // 关闭给定的连接
void f(destination &d)
{
// 获得一个连接;记住使用完后要关闭它
connection c = connect(&d);
// 使用连接
// 如果我们在 f 退出前忘记调用 disconnect, 就无法关闭 c 了
}
- 如果
connection
有一个析构函数, 就可以在f
结束时由析构函数自动关闭连接。但是,connection
没有析构函数。使用shared_ptr
来保证connection
被正确关闭, 已被证明是一种有效的方法 - 为了用
shared_ptr
来管理一个connection
, 我们必须首先定义一个函数来代替delete
。这个删除器(deleter)函数必须能够完成对shared_ptr
中保存的指针进行释放的操作。在本例中, 我们的删除器必须接受单个类型为connection*
的参数:
void end_connection(connection *p) {
disconnect(*p); }
- 当我们创建一个
shared_ptr
时, 可以传递一个(可选的) 指向删除器函数的参数:
void f(destination &d)
{
connection c = connect(&d);
shared_ptr<connection> p(&c, end_connection);
}
实例:动态内存管理类
- 某些类需要在运行时分配可变大小的内存空间。这种类通常可以使用标准库容器来保存它们的数据。但是, 某些类需要自己进行内存分配。这些类一般来说必须定义自己的拷贝控制成员来管理所分配的内存
StrBlob
类
模板类
Blob
可参考这里
- 现在我们先定义一个管理
string
的类, 此版本命名为StrBlob
。它使用动态内存来让多个对象能共享相同的底层数据。在本例中,我们将使用vector
来保存元素- 但是,我们不能在一个
Blob
对象内直接保存vector
, 因为一个对象的成员在对象销毁时也会被销毁。例如,假定b1
和b2
是两个Blob
对象, 共享相同的vector
。如果此vector
保存在其中一个Blob
中,那么当b2
离开作用域时,此vector
也将被销毁。为了保证vector
中的元素继续存在,我们将vector
保存在动态内存中 - 为了实现我们所希望的数据共享,我们为每个
StrBlob
设置一个shared_ptr
来管理动态分配的vector
- 但是,我们不能在一个
class StrBlob {
public:
typedef std::vector<std::string>::size_type size_type;
// 默认构造函数分配一个空 `vector`
StrBlob() : data(std::make_shared<std::vector<std::string>>()) {
}
// 支持用列表初始化
StrBlob(std::initializer_list<std::string> il):
data(std::make_shared<std::vector<std::string>>(il)) {
}
size_type size() const {
return data->size(); }
bool empty() const {
return data->empty(); }
// 添加和删除元素
void push_back(const std::string &t) {
data->push_back(t); }
void pop_back(); // 需要检查元素个数,若为空则抛出异常
// 元素访问,若为空则抛出异常
std::string& front();
std::string& back();
private:
std::shared_ptr<std::vector<std::string>> data; // 指向共享资源
// 作为 pop_back、front、back 共用的工具函数,检查一个给定索引是否在合法范围内
// 如果 data[i] 不合法, 抛出一个异常 (msg 用来描述错误内容)
void check(size_type i, const std::string &msg) const;
) ;
void StrBlob::check(size_type i, const string &msg) const
{
if (i >= data->size())
throw out_of_range(msg);
}
string& StrBlob::front()
{
// 如果vector为空, check会抛出一个异常
check(0, "front on empty StrBlob");
return data->front();
}
string& StrBlob::back()
{
check(0, "back on empty StrBlob");
return data->back();
}
void StrBlob::pop_back()
{
check(0, "pop_back on empty StrBlob");
data->pop_back();
}
核查指针类
StrBlobPtr
会保存一个weak_ptr
, 指向StrBlob
的data
成员,这是初始化时提供给它的。通过使用weak_ptr
, 不会影响一个给定的StrBlob
所指向的vector
的生存期。但是, 可以阻止用户访问一个不再存在的vector
的企图
StrBlobPtr
会有两个数据成员
wptr
, 或者为空, 或者指向一个StrBlob
中的vector
curr
, 保存当前对象所表示的元素的下标。类似它的伴随类StrBlob
, 我们的指针类也有一个check
成员来检查解引用StrBlobPtr
是否安全:
class StrBlobPtr {
public:
// 将 curr 显式初始化为 0, 并将 wptr 隐式初始化为一个空 weak_ptr
StrBlobPtr(): curr(0) {
}
// 接受一个 StrBlob 引用和一个可选的索引值
StrBlobPtr(StrBlob &a, size_t sz = 0):
wptr(a.data), curr(sz) {
}
std::string& deref() const; // 解引用
StrBlobPtr& incr(); // 前缀递增
private:
// 若检查成功,check 返回一个指向 vector 的 shared_ptr
std::shared_ptr<std::vector<std::string>>
check(std::size_t, const std::string&) const;
// 保存一个 weak_ptr, 意味着底层 vector 可能会被销毁
std::weak_ptr<std::vector<std::string>> wptr;
std::size_t curr; // 在数组中的当前位置 (下标)
};
值得注意的是, 我们不能将
StrBlobPtr
绑定到一个const StrBlob
对象,因为构造函数接受一个非const StrBlob
对象的引用
std::shared_ptr<std::vector<std::string>>
StrBlobPtr::check(std::size_t i, const std::string &msg) const
{
auto ret = wptr.lock(); // 检查指针指向的`vector`是否还存在
if (!ret)
throw std::runtime_error("unbound StrBlobPtr");
if (i >= ret->size())
throw std::out_of_range(msg);
return ret; // 否则,返回指向 vector 的 shared_ptr
}
std::string& StrBlobPtr::deref() const
{
// p 为指向 vector 的 shared_ptr
auto p = check(curr, "dereference past end");
return (*p)[curr]; // (*p) 是对象所指向的 vector
}
// 前缀递增:返回递增后的对象的引用
StrBlobPtr& StrBlobPtr::incr()
{
// 如果curr已经指向容器的尾后位置, 就不能递增它
check(curr, "increment past end of StrBlobPtr");
++curr; // 推进当前位置
return *this;
}
- 我们还要为
StrBlob
类定义begin
和end
操作, 返回一个指向它自身的StrBlobPtr
:
// 对于StrBlob中的友元声明来说, 此前置声明是必要的
class StrBlobPtr;
class StrBlob {
friend class StrBlobPtr;
// 其他成员与之前的声明相同
// 返回指向首元素和尾后元素的 StrBlobPtr
StrBlobPtr begin() {
return StrBlobPtr(*this); }
StrBlobPtr end()
{
auto ret = StrBlobPtr(*this, data->size());
return ret; }
};
StrVec
类
- 我们将实现标准库
vector
类的一个简化版本。该类只用于string
。因此, 它被命名为StrVec
- 在本例中,我们不依靠
vector
,而是自己进行内存分配
StrVec
类的设计
- 我们在
StrVec
类中使用一个allocator
来获得原始内存,将其定义为静态成员,类型为allocator<string>
。每个StrVec
有三个指针成员指向其元素所使用的内存:elements
, 指向分配的内存中的首元素first_free
, 指向最后一个实际元素之后的位置cap
, 指向分配的内存末尾之后的位置
- 除此之外,
StrVec
还有 4 个工具函数:alloc_n_copy
会分配内存, 并拷贝一个给定范围中的元素free
会销毁构造的元素并释放内存chk_n_alloc
保证StrVec
至少有容纳一个新元素的空间。如果没有空间添加新元素, 则调用reallocate
来分配更多内存reallocate
用来分配新内存
StrVec
类的定义
class StrVec {
public:
StrVec(): // allocator 成员进行默认初始化
elements(nullptr), first_free(nullptr), cap(nullptr) {
}
StrVec(std::initializer_list<std::string> il);
StrVec(const StrVec&);
StrVec &operator=(const StrVec&);
~StrVec() {
free(); }
// 接口函数
void push_back(const std::string&);
size_t size() const {
return first_free - elements; }
size_t capacity() const {
return cap - elements; }
std::string *begin() const {
return elements; }
std::string *end() const {
return first_free; )
private:
static 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; // 指向数组尾后位置的指针
};
std::allocator<std::string> StrVec::alloc; // 定义 static 成员
void StrVec::push_back(const string& s)
{
chk_n_alloc(); // 确保有空间容纳新元素
// 使用原始内存,必须先调用 construct 构造对象
// 这里使用拷贝构造函数进行初始化
alloc.construct(first_free++, s);
}
// 分配足够的内存来保存给定范围的元素,并将这些元素拷贝到新分配的内存中
// 返回一个指针的`pair`, 两个指针分别指向新空间的开始位置和拷贝的尾后的位置
pair<string*, string*>
StrVec::alloc_n_copy(const string *b, const string *e)
{
// 分配空间保存给定范围中的元素
auto data = alloc.allocate(e - b);
return {
data, uninitialized_copy(b, e, data)};
}
inline
StrVec::StrVec(std::initializer_list<std::string> il)
{
auto newdata = alloc_n_data(il.begin(), il.end());
elements = newdata.first;
first_free = cap = newdata.second;
}
StrVec::StrVec(const StrVec &s)
{
// 调用 alloc_n_copy 分配空间以容纳与 s 中一样多的元素
auto newdata = alloc_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}
// 销毁元素 + 释放内存
void StrVec::free()
{
// 不能传递给 deallocate 一个空指针, 如果 elements 为 0, 函数什么也不做
if (elements) {
// 逆序销毁旧元素
for (auto p = first_free; p != elements; )
alloc.destroy(--p);
alloc.deallocate(elements, cap - elements);
}
}
free
中的for
循环也可以用for_each
和lambda
表达式来代替:
for_each(elements, first_free,
[](std::string &s) {
alloc.destroy(&s); });
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;
}
在重新分配内存的过程中移动而不是拷贝元素:
- 在编写
reallocate
成员函数之前, 我们稍微思考一下此函数应该做什么。它应该- 为一个新的、更大的
string
数组分配内存 - 在内存空间的前一部分构造对象, 保存现有元素
- 销毁原内存空间中的元素, 并释放这块内存
- 为一个新的、更大的
- 观察这个操作步骤,我们可以看出,为一个
StrVec
重新分配内存空间会引起从旧内存空间到新内存空间逐个拷贝string
。当拷贝一个string
时, 新string
和原string
是相互独立的。但是, 如果是reallocate
拷贝StrVec
中的string
, 则一旦将元素从旧空间拷贝到了新空间,我们就会立即销毁原string
。因此, 拷贝这些string
中的数据是多余的。在重新分配内存空间时, 如果我们能避免分配和释放string
的额外开销,StrVec
的性能会好得多
// 每次重新分配内存时都会将 StrVec 的容量加倍
// 如果 StrVec 为空,我们将分配容纳一个元素的空间
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;
}