0%

[Effective C++ 笔记]条款14. 在资源管理类中小心 copy 行为

简介

  • 复制 RAII 对象必须一并复制其所管理的资源,所以资源的 copying 行为决定了 RAII 对象的 copying 行为
  • 普遍的 RAII 类 copying 行为是: 抑制 copying、使用引用计数法管理;另外还有其他方法也可能实现。

引子

上一条款中我们推荐在使用动态分配的资源(也就是建立在 heap
上的资源)的时候使用智能指针来进行管理。但是对于普通的资源而言有时候还是有必要建立自己的资源管理类。考虑以下例子:

我们使用 Mutex 来对资源进行多线程协调,并用 lockunlock 两个函数对其进行加锁和解锁:

1
2
void lock(Mutex* pm);   //锁定 pm 所指的互斥锁
void unlock(Mutex* pm); // 将互斥锁解除锁定

另外,为了确保不会忘记在使用完资源后将被锁住的 Mutex 解开,我们会需要设计一个类来管理锁。这个类会遵循 RAII 守则,即 “资源在构造期间获得,在析构期间释放”,如下例子(完整代码见 ex1,书中例子中 Mutex 为自定类,这里为了方便演示使用 c++11 新引入的 std::mutexstd::thread):

1
2
3
4
5
6
7
8
9
class Lock {
public:
explicit Lock(std::mutex* pm) : p_mutex(pm) { p_mutex->lock(); }

~Lock() { p_mutex->unlock(); }

private:
std::mutex* p_mutex;
};

为了验证其功能,我们用以下函数来进行测试:

1
2
3
4
5
6
7
8
9
10
void printMessage(const std::string& msg) {

Lock lock(&m);
for (const char& c: msg) {
std::cout << c << "\n";
// print a letter then sleep 200s
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
std::cout << "\n";
}

测试如下,我们使用两个线程同时在 terminal 中输出,在不加锁的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void printMessage(const std::string& msg) {

for (const char& c: msg) {
std::cout << c << "\n";
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
std::cout << "\n";
}

int main() {

std::string msg = "Hello";
std::thread t1(printMessage, std::ref(msg));
printMessage(msg);
t1.join();

return 0;
}

编译运行输出如下:

1
2
3
4
5
6
7
8
9
10
HH

ee

l
l
l
l
o
o

可以发现,由于没有加锁,两个线程交错输出字母进terminal里。如果我们在 printMessage 中使用 Lock 如下:

1
2
3
4
5
6
7
8
9
void printMessage(const std::string& msg) {

Lock lock(&m);
for (const char& c : msg) {
std::cout << c << "\n";
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
std::cout << "\n";
}

编译输出如下:

1
2
3
4
5
6
7
8
9
10
11
H
e
l
l
o

H
e
l
l
o

所以我们的 Lock 可以完成其功能,达到我们想要的效果。

下面我们考虑如下,如果对 Lock 对象进行复制会出现什么情况呢,考虑以下例子(完整代码见 ex2):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 为了方便显示,在构造函数和析构函数中添加输出
class Lock {
public:
explicit Lock(std::mutex* pm) : p_mutex(pm) {
std::cout << "mutex locked!" << std::endl;
p_mutex->lock();
}

~Lock() {
std::cout << "mutex unlocked!" << std::endl;
p_mutex->unlock();
}

private:
std::mutex* p_mutex;
};

int main() {
std::mutex m;

Lock l1(&m);
Lock l2(l1);

return 0;
}

编译运行结果如下:

1
2
3
mutex locked!
mutex unlocked!
mutex unlocked!

可以发现,m 只被加锁了一次,却被解锁了两次,这样会导致出现未定义行为(undefined behavior)。原因在于我们只在默认构造函数中对 mutex 进行加锁,因此在进行拷贝构造时没有加锁,所以导致出现了只加锁了一次却解锁两次的行为。因此在我们使用资源管理类的时候需要额外注意对其进行复制的情况。

对资源管理类复制行为的处理方法

基于以上会出现的问题,我们有必要仔细思考当我们的资源管理类进行复制时应该怎么做。通常有两种做法:

禁止复制

大部分情况下,对 RAII 对象的复制操作本身就不合理的。对于像 Lock 这样对象,我们很少会需要对其进行复制。因此在这种情况下,一个比较简单的方法就是禁止对其进行复制。可以根据条款 6 的方法来禁止对资源管理类的复制,如下例所示(完整代码见 ex3, 代码根据书中原有例子所写,在 c++11 之后更为方便的方法应该是直接将拷贝构造函数声明为 delete):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Uncopyable {
public:
Uncopyable() {}

private:
Uncopyable(const Uncopyable& rhs);
};

class Lock : public Uncopyable {
public:
// ...

private:
std::mutex* p_mutex;
};

对底层资源使用引用计数法(reference-count)

另一种方法避免多次解锁的方法是利用引用计数,即跟踪管理对象的个数,只有当引用个数变为 0 时(即最后一个资源管理类析构时)才进行解锁,我们可以使用 shared_ptr 来对 mutex 进行管理。shared_ptr 的默认行为是当引用数为 0 时删除对象,这里我们可以通过指定删除器来规定引用计数为 0 时发生的行为,如如下例所示(完整例子见 ex4):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void lock(std::mutex* m) {
std::cout << "mutex locked!" << std::endl;
m->lock();
}

void unlock(std::mutex* m) {
std::cout << "mutex unlocked!" << std::endl;
m->unlock();
}

class Lock {
public:
explicit Lock(std::mutex* pm) : p_mutex(pm, unlock) { lock(pm); }

private:
std::shared_ptr<std::mutex> p_mutex;
};

注意这里由于使用了 shared_ptr 我们不需要在析构函数中进行解锁了,编译运行如下所示:

1
2
3
4
5
6
7
8
int main() {
std::mutex m;

Lock l1(&m);
Lock l2(l1);

return 0;
}
1
2
mutex locked!
mutex unlocked!

复制底部资源

在我们不需要限制资源副本数量的情况,为了避免多次释放(解锁),我们在复制资源管理类时,应该把其所管理的资源的也进行一次复制(如果使用指针管理,即复制指针指向的对象),也就是进行深复制(deep copy)。例如某些标准字符串是有指向对内存的指针组成。因此在复制字符串时并不是复制指针值,而是将指针指向的字符变量进行复制。

转移底部资源的拥有权

如果我们希望确保永远只有一个资源管理对象来管理资源,可以使用 auto_ptr 来对资源进行管理,当 RAII 对象被复制时,其所有权就进行了转移。(同样,根据上一条款所言,这种行为可能会造成野指针的出现,在 c++11 之后更好的方法使用 unique_ptr 来避免资源所有权被转移,同时也限制管理类的数量)。

结论

  • 复制 RAII 对象必须一并复制其所管理的资源,所以资源的 copying 行为决定了 RAII 对象的 copying 行为
  • 普遍的 RAII 类 copying 行为是: 抑制 copying、使用引用计数法管理;另外还有其他方法也可能实现。

其他