[读书笔记]《Hands on Design Patterns with C++》—— 内存所有权,swap

第 3 章 内存所有权(memory ownership)

内存管理不当也是 C++ 里面比较常见的问题。有可能就会引起内存泄漏,访问非法内存,过量内存使用,或者一些其他比较难 debug 的问题。所以现代 C++ 提供了一些 idiom 来让程序设计者代码设计中清晰的表达他们的意图。这样更容易写出正确分配内存,访问,或者注销内存的代码。

What is memory ownership?

驻留在内存中对象的内存和生命周期的管理。内存也是一种资源,而一般是通过对象来拥有资源,所以也可以称为 对象所有权(object ownership)。等同于管理所拥有的对象。

What are the characteristics of well-designed resource ownership?

一个好的设计首先就是谁拥有什么对象是很清晰的。

struct MyValues {long a, b, c ,d;}
void Reset(MyValues* v) { // 不用关心谁拥有变量 v 的资源,只要不是函数拥有就可以
  v->a = v->b = v->c = v-> d = 0; 
}

第二点就是当取得一个对象的所有权时,它从哪里来的并不重要。 包括 shared_ptr 指针所管理的资源,我们也不用知道谁还拥有这个对象。

class A{
public:
  A(vector<int> && v) : v_(std::move(v)) {} // 可以从任何对象那里转让所有权
private:
  vector<int> v_; // 我们拥有的资源
};

书中的描述:

  • If a function or a class does not alter memory ownership in any way, this should be clear to every client of this function or class, as well as the implementer
  • If a function or a class takes exclusive ownership of some of the objects passed to it, this should be clear to the client (we assume that the implementer knows this already, since he/she has to write the code)
  • If a function or a class shares ownership of an object passed to it, this should be clear to the client (or anyone who reads the client code, for that matter)
  • For every object that is created, at every point that it’s used, it is clear whether this code is expected to delete it or not

Poorly designed memory ownership

与好的设计相对的,不好的设计则没办法直接从代码上判断当前的代码是否拥有相关的资源。

Widget * w = MakeWidget();

例如上面的使用方式,使用者不知道当这个指针不再使用的时候是不是需要自己删除,如果是,该怎样删除?如果删除方式错误,也会引发程序错误。

WidgetFactory WF;
Widget* w = WF.MakeAnother();

上面的例子同样无法清晰的传递出,WF 对象是否拥有指针 w,当 WF 对象被删除的时候指针 w 还是否可用,如果我们假设了 WF 会自动删除,但实际不会,我们可能就会造成内存泄漏。

Widget* w = MakeWidget();
Widget* w1 = Transmogrify(w);

这个例子中,Transmogrify 函数是否拥有 Widget 对象的所有权呢? w 指针在经过 Transmogrify 函数之后是否还可用?如果我们假设会自动删除,但实际不会,我们可能就会造成内存泄漏。

当然也不是所有不好的设计都是因为裸指针引起的。

void Double(std::shared_ptr<std::vector<int>> v) {
  for(auto &x : *v) {
    x *= 2;
  }
}

std::shared_ptr<std::vector<int>> v(...);
Double(v);

这里 Double 声明将会获得数组的 shared ownership,但这是完全没必要的。因为它仅仅是修改数组里面的每一个值,不会影响它们的生命周期,也不会转移这些值的 ownership。

When and how should we be agnostic about resource ownership?

How do we express non-ownership in C++?

non-ownership 代表一段代码不会分配内存,释放内存,构造对象,或者删除对象,仅仅是在已经创建好的对象上面做相关的操作。 创建和删除都是别的代码做的事情。 在 C++ 主要是通过裸指针或者是引用实现的。

void Transform(Widget* w) {...}
void Transform(Widget& w) {...}

因为引用也是一种指针,只是引用必须得初始化和不能为空,这样可以避免指针的一些检查,所以只要涉及到裸指针的情况,都是不会涉及到对象所有权问题的。

How do we express exclusive memory ownership in C++?

所有权独占,表示这个对象由我创建,也只能由我销毁,其他延长对象生命周期的操作都是不允许的。其实这种现象很常见,局部对象的创建和释放就是如此。

void work() {
	  Widget w; // 由 work 函数创建,也会由它销毁
    Transform(w);
    Draw(w);
}

如果一个对象不能创建在栈上,但是必须在堆上分配内存的情况,可以使用 unique_ptr 指针。 堆分配一般发生在 ownship 的 shared 或者是 transferred 时,因为栈对象在被包含的作用域之外就会被释放,而我们要延长它的生命周期,并且在堆上所有权也不能被共享或者被赠予。 另一个理由需要在堆上创建是在编译期并不知道对象的类型或者是 size,通常多态情况。这样我们用来表达独占的概念就可以使用 unique_ptr 指针。

class FancyWidget : public Widget {...};
std::unique_ptr<Widget> w(new FancyWidget); // new 返回一个裸指针

transfer of exclusive ownership

std::unique_ptr<Widget> w(WidgetFactory()); //  WidgetFactory() 需要什么类型的返回

如果这里 WidgetFactory() 返回的是一个裸指针,那么 unique_ptr 虽然可以使用,但是当把它的返回值传入一个 non-ownership 操作时,就没有人管它的内存,可能就会造成内存泄漏了。 所以我们这里需要一个 ownership transfer。

std::unique_ptr<Widget>  WidgetFactory() {
  Widget* new_w = new Widget;
  ...
  return std::unique_ptr<Wdiget>(new_w);
}

std::unique_ptr<Widget> w(WidgetFactory());

unique_ptr 是可移动的,这样就会转移相关对象的 ownership。 这样做的好处是明确表示了 WidgetFactory 期望其调用者获得对象的独占(或共享)所有权。否则编译就会不过。

void Transform(Widget* w);
Transform(WidgetFactory()); // 会编译失败

// 可以这么调用
std::unique_ptr<Widget> w(WidgetFactory());
Transform(&*w);

如果设计到栈对象,可以在其销毁前 transfer ownership 么,这取决于栈对象是否是 movable 的。这一般会用于返回值,但是也经常用于传参。这样的参数必须得声明为接受右值引用。

void Consume(Widget&& w) {auto new_w = std::move(w); ...}
Widget w, w1;
Consume(std::move(w)); // oK
Consume(w1); // 会编译错误,必须是右值

上面这种情况必须显式的用 std::move 来表示调用者已经明确放弃了 ownership,这也是这种用法的一种优势,要不然就跟普通函数看着一样,区别不出来是否有 ownership 的转移了。

How do we express shared memory ownership in C++?

可以通过 shared_ptr 来表示 shared memory,一般底层数据结构的对象可能会用。

当我们以共享指针对象作为函数的参数时,可以传达两个想法,它打算获得比函数调用本身持续时间更长的部分所有权 —— 将创建共享指针的副本。并且在上下文中,它可能表明该函数需要保护对象不被另外的一个线程删除,至少只要它正在执行。

shared_ptr 需要注意的两个问题就是一个是效率低,一个是可能循环引用。想 shared_ptr 线程安全也是需要花费一定功夫的。

第 4 章 Swap 操作

开篇问题:

How is swap used by the standard C++ library?

template <typename T>
void swap(T& x, T& y){
	 T tmp(x);
	 x = y;
	 y = tmp;
}

以上是一个常规的实现思路,但是上面的效率取决于 T 的数据类型或者是容器,就取决于包含的数据个数(vector 等),因为其中发生了 3 次拷贝, 有 2 次是不必要的。 还可能在这中间会占多多余的临时内存空间。(T 为 vector<int> 时),还有就是在申请多余的临时变量,或者构造、赋值构造时可能会报错。

STL 会做的是它们拥有数据的指针和数据尺寸信息,要 swap 这些容器对象,我们只需要交换数据指针,包括一些额外的辅助状态即可。真实数据还是在原先的内存在没有动。 要实现上面的功能一般 STL 容器会提供一个自己的 swap 函数,或者是有一个友元函数,一般是前者。

上述有两点好处,一个是因为只有容器的成员变量被交换,它们都已经之前分配好了内存,所以不会再发生内存的申请; 另一个就是拷贝指针和一些内置类型不会报错。所以整个 swap 过程也不会报错了。

更深层次的情况,交换成员变量时,需要进一步交换它们的比较函数(compare functions), 这样的函数可能是用户的一些库实现的,并不是指针指向它们,更不用说期望它们不会报错了。所以为了保证 STL 可交换成立,例如 std::map,必须保证可调用的对象也是可交换的。这里可交换的就表明交换过程中不会报错,前面交换相互的成员变量是不会报错的,这样保证了之后除非是交换比较对象会抛出异常。 std::vector 则没有这样的成员函数,是不用考虑这个问题的。

然后 STL 容器都可以配置 allocator,A 和 B 空间配置器不同,swap 之后最后释放内存肯定还是需要对应的空间配置器的,所以空间配置器也需要更换。在 C++11 之前是默认相同类型对象的空间配置器对象必须能够释放彼此的内存,这样就可以忽略这个问题。但是 C++11 之后,会有相关的 allocator_traits,去确认需要交换的两个空间配置器是不是交换,如果可以就会使用最朴素的 swap 函数(即非成员函数 的 swap), 如果不可以交换,那么就要求两个容器对象必须使用一样的 allocator。

What are the applications of swap?

template <typename T>
void swap(T& x, T& y);

template <typename T, size_t N>
void swap(T(&a)[N], T(&b) [N]); // since C++11

对于 STL 容器,std::swap 函数就是采用上述方式,中间变量的形式来实现的。这些临时变量必须是 copyable 的,否则直接使用 std::swap 会编译报错。C++11 之后使用了移动语义,能移动的会使用移动构造和移动赋值,不能移动的则乖乖使用拷贝构造和拷贝赋值。当然如果一个类型拷贝和移动的构造都 deleted 了,使用swap 就会报错了。

  • 一般支持交换操作的类,应该带有名为 swap 的成员函数,支持常数时间内进行交换操作。
  • 独立的 swap 非成员函数,应该支持所有类型
  • 交换两个对象时,不应该有任何 exceptions。

How can we write exception-safe code using swap?

exception-safe 是指当程序发生异常时,不会将程序置于一个未定义的状态,即已经申请的资源发生异常时都应该释放掉。

只在最后一行保证异常安全就可以了。使用中间变量,如果发生异常,则会自动释放。

现在假设有一个函数,对所有 vector 中的元素进行操作,然后存储到新的元素当中:

class C;
C trans(C x) {return C(...);} // 某些操作

void transmogrify(const std::vector<C>& in, std::vector<C>& out) {
    out.resize(0);
    out.reserve(in.size());
    for(const auto& x : in) {
        out.push_back(trans(x)); // 这里先创建一个新的对象,再 push
    }
}

在上面的例子中,存在了挺多可能会报错的点。首先是 reserve 函数,会做一个内存分配,这就可能会内存分配失败,因此 transmogrify() 就会异常退出,输出的 out 就会是一个空 vector。 第二点是在循环访问元素时可能报错异常,可能是新元素的拷贝构造函数, 也可能是在 trans函数中,这样遍历过程就会被打断,报错的元素不会再被 push 进 out,但是已经 push 进去的还是会存储在 out 里面。 这些都不是我们希望的。 这里 exception-safe 的一种实现方法是:

void transmogrify(const std::vector<C>& in, std::vector<C>& out) {
    std::vector<C> tmp;
    tmp.reserve(in.size());
    for(const auto& x : in) {
        tmp.push_back(transmogrify(x));
    }
    out.swap(tmp); // 前提是 swap 一定是不会报错的
}

这样通过函数的局部变量,并且直到最后转换全部完成才会输出到 out 中。即使中间有任何异常,临时对象会被自动删除,因为这些局部对象都是在栈中申请的,这样的实现在析构函数和 swap 成员函数是异常安全的情况下,整个 transmogrify 函数也是异常安全的了。 以上有时也被称为: copy-and-swap idiom。

还有一些 swap 的其他常见用法。

  • 快速清空对象
C c = ...;
// 方式1
{
    C tmp;
    c.swap(tmp);
}

// 方式2
C().swap(c);  // 这里顺序很重要,因为 swap 不能接受右值
  • 改变内容,但是不引入新的变量名称
std::vector<C> vec;
... // 向 vec 中写入部分数据

{
    std::vector<C> tmp;
    transmogrify(vec, tmp); //  承接上面的函数
    swap(vec, tmp);
}
... // 继续以 vec 来操作,但是数据已经变化了
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值