Modern C++ 的一些惯用手法

资源获得即初始化 (RAII)

在程序中,经常涉及到对一个资源进行获取、使用然后销毁的过程。最简单的例子就是从堆上获取(new)一片内存构造对象,使用完成之后需要归还内存(delete)。

1
2
3
4
5
6
void useResource() {
Person* p = new Person("xx", 12); // name, age
p.printName();
p.printAge();
delete p;
}

上面是一个简单的例子(只是用于举例,现代 C++ 程序中不鼓励使用裸指针而是鼓励使用智能指针,智能指针实际上就使用了RAII手法),我们在最开始从堆上获取了资源,使用之后进行了归还(销毁)。这看起来十分的简单清楚,但是一旦我们在使用资源之后忘记销毁,则会导致一些不可设想的后果,这个例子是导致内存泄漏。

再举一个在 windows 平台下进行 socket 操作的例子,和 Unix 平台下不同,在 windows 下操作 socket 需要进行一些初始化和资源销毁。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include<winsock2.h>
#include<iostream>
#pragma comment(lib,"ws2_32.lib")

int main() {
//初始化DLL
WORD sockVersion = MAKEWORD(2, 2);
WSADATA wsdata;
if (WSAStartup(sockVersion, &wsdata) != 0)
{
return 1;
}

//创建套接字
SOCKET serverSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (serverSocket == INVALID_SOCKET)
{
WSACleanup();
return 1;
}


//绑定套接字
sockaddr_in sockAddr;
sockAddr.sin_family = AF_INET;
sockAddr.sin_port = htons(8888);
sockAddr.sin_addr.S_un.S_addr = INADDR_ANY;

if (bind(serverSocket, (sockaddr*)&sockAddr, sizeof(sockAddr)) == SOCKET_ERROR){
WSACleanup();
return 1;
}

//开始监听
if (listen(serverSocket, 10) == SOCKET_ERROR){
WSACleanup();
return 1;
....

closesocket(serverSocket);
WSACleanup();
}

注意到程序中存在大量的不满足条件则清理资源的重复语句,在实际开发中,很容易忘记对使用完的资源进行销毁。我们知道,局部变量是在运行时栈上进行分配,在退出当前栈时局部变量会失效,也就是说局部对象会在恰当的时候自动销毁。利用这一点,可以将资源的获取放在对象的构造函数中,而将资源的销毁放在其析构函数中,这样一旦局部对象推出其作用域,资源会随着对象的销毁而销毁。这是 C++ 中的常用手法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Group {
public:
Group() {
p = new Person("xx", 12);
}
~Group() {
delete p;
}
private:
Person* p;
}

void useResource() {
Group g;
...
}

这样从 useResource 函数中返回时资源被自动销毁。

RAII 在多线程程序的同步机制中发挥很大的作用,如有下面一个简单的 MutexLock 类对 POSIX 标准中的互斥锁进行简单封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MutexLock : NonCopyable {
public:
MutexLock() {
int ret = pthread_mutex_init(&mutex_, nullptr);
assert(ret == 0);
}
~MutexLock() {
int ret = pthread_mutex_destroy(&mutex_);
assert(ret == 0);
}
void lock() {
pthread_mutex_lock(&mutex_);
}
void unlock() {
pthread_mutex_unlock(&mutex_);
}
pthread_mutex_t* getMutex() {
return &mutex_;
}
private:
pthread_mutex_t mutex_;
};

可以调用 lock()unlock() 方法进行加解锁,利用 RAII 手法,可以定义一个 MutexLockGuard 类,在该类的构造函数中进行加锁,在其析构函数中进行解锁。这样的话,MutexLockGuard 对象的生命周期就和临界区高度相关了。

1
2
3
4
5
6
7
8
9
10
11
class MutexLockGuard : NonCopyable {
public:
explicit MutexLockGuard(MutexLock& mutex) : mutexLock_(mutex) {
mutexLock_.lock();
}
~MutexLockGuard() {
mutexLock_.unlock();
}
private:
MutexLock& mutexLock_;
};

使用的时候只需要在临界区定义一个 MutexLockGuard 对象:

1
2
3
4
5
6
7
8
9
10
11
12
void* threadFunc(void* args) {
...

{ /** 临界区开始 **/
MutexLockGuard guard;
++count;
...
} /** 临界区结束 **/

// do something else
}

实际上,在 C++ 11之后,标准库中就有上述两个类的实现,分别是 std::mutex 和 std::lock_guard,都是从 boost 库中借鉴来的。

pImpl

pImpl 意为:“pointer to implementation”,指向实现的指针,也是 C++ 中常用的手法,用于减少项目构建时间。

考虑编写一个串口通信类,在 Serial.h 头文件中:

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
26
27
28
29
30
31
32
33
34
35
36
37
#include<string>
#include<vector>

class Serial {
public:
// 禁止拷贝
Serial(const Serial&) = delete;
Serial& operator=(const Serial&) = delete;
explicit Serial(const char* port,
unsigned long baudRate = 115200,
bytesize_t byteSize = eightBits,
parity_t parity = parityNone,
stopbits_t stopBits = stopBitsOne);
~Serial();

void open();
void close();
bool isOpen() const;
size_t available() const;
size_t read(uint8_t *buf, size_t size = 1);
size_t write(const uint8_t *data, size_t length);
void flush();

private:
string port_;
int fd_;
bool isOpen_;
unsigned long baudRate_;
std::vector<uint8_t> buf;

// 奇偶校验
parity_t parity_;
// 字节大小
bytesize_t byteSize_;
// 停止位
stopbits_t stopBits_;
};

上面的头文件至少必须 #include <string>, <vector>等头文件,实际上还有自己定义的一些数据类型(parity_t等)所在的头文件,这增加了 Serial 类客户的编译时间,且客户也会依赖这些头文件,一旦这些头文件的内容发生改变,客户也需要重新构建他们的代码。此外,这个头文件中体现了很多实现的细节,也就是这些成员变量。

可以使用 pImpl 手法进行重构,前向声明 一个实现类,然后将实现类放到 Serial.cpp 文件中,Serial 类中只定义一个指向实现的指针(由于编译器知道一个指针所占的内存大小是固定的,因此可以定义指向不完整类型的指针),Serial.h 变成这样:

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
26
// 前向声明
class Impl;

class Serial {
public:
// 禁止拷贝
Serial(const Serial&) = delete;
Serial& operator=(const Serial&) = delete;
explicit Serial(const char* port,
unsigned long baudRate = 115200,
bytesize_t byteSize = eightBits,
parity_t parity = parityNone,
stopbits_t stopBits = stopBitsOne);
~Serial();

void open();
void close();
bool isOpen() const;
size_t available() const;
size_t read(uint8_t *buf, size_t size = 1);
size_t write(const uint8_t *data, size_t length);
void flush();

private:
Impl* impl; // 指向不完整类型 ok
}

将 Impl 类的实现放到 Serial.cpp 文件中,这样将依赖关系转移到了 Serial.cpp 文件中:

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
26
27
28
29
30
31
32
33
34
35
36
37
#include <string>
#include <vector>

class Impl {
public:
Impl(const char* port,
unsigned long baudRate = 115200,
bytesize_t byteSize = eightBits,
parity_t parity = parityNone,
stopbits_t stopBits = stopBitsOne) {
...
}
~Impl() {
...
}

public:
string port_;
int fd_;
bool isOpen_;
unsigned long baudRate_;
std::vector<uint8_t> buf;

// 奇偶校验
parity_t parity_;
// 字节大小
bytesize_t byteSize_;
// 停止位
stopbits_t stopBits_;
}

Serial::Serial(...) {
impl = new Impl(...);
}
Serial::~Serial() {
delete impl;
}

上面就是 pImpl 手法的典型使用,实际使用时一般会将 Impl 类定义成 Serial 的私有内部类:

1
2
3
4
5
6
7
8
class Serial {
public:
...

private:
class Impl;
Impl* impl; // 指向不完整类型 ok
}

还有一个问题就是上面使用的都是裸指针,需要在 Serial 类的析构函数中 delete资源, Modern C++ 推荐使用智能指针对资源进行管理,最适合的就是 std::unique_ptr,因为享有对其实现类的专属所有权,然后进行一些简单的改写:

Serial.h:

1
2
3
4
5
6
7
8
class Serial {
public:
...

private:
class Impl;
std::unique_ptr<Impl> impl;
}

Serial.cpp:

1
2
3
4
5
Serial::Serial(...) {
impl.reset(new Impl(...)); // 或者在初始化列表中使用 make_unique<Impl>
}

Serial::~Serial() = default;

这样就大功告成了。还有一点需要提醒的是,Serial 类的特殊成员函数(构造函数、析构函数、拷贝复制等)都需要放到 Serial.cpp 中实现,哪怕是像上面的默认实现,否则编译器看不到 Impl 类的实现,无法通过编译(关于这一点在 《Effective Modern C++》一书中有专门条款介绍)。

总结

本文主要是介绍了两种 C++ 编程中的惯用法,对实际编写程序和阅读他人代码有一定帮助。实际上,我认为 pImpl 方法增加了程序的复杂程度,但这也是对抗 C++ 过于缓慢的构建速度的一种方法。

参考文献

  1. 《C++ 服务器开发精髓》
  2. 《Effective Modern C++》