资源获得即初始化 (RAII)
在程序中,经常涉及到对一个资源进行获取、使用然后销毁的过程。最简单的例子就是从堆上获取(new)一片内存构造对象,使用完成之后需要归还内存(delete)。
1 2 3 4 5 6 void useResource () { Person* p = new Person ("xx" , 12 ); 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 () { 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; ... } }
实际上,在 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; }
将 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; }
还有一个问题就是上面使用的都是裸指针,需要在 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 (...)); } Serial::~Serial () = default ;
这样就大功告成了。还有一点需要提醒的是,Serial 类的特殊成员函数(构造函数、析构函数、拷贝复制等)都需要放到 Serial.cpp 中实现 ,哪怕是像上面的默认实现,否则编译器看不到 Impl 类的实现,无法通过编译(关于这一点在 《Effective Modern C++》一书中有专门条款介绍)。
总结
本文主要是介绍了两种 C++ 编程中的惯用法,对实际编写程序和阅读他人代码有一定帮助。实际上,我认为 pImpl 方法增加了程序的复杂程度,但这也是对抗 C++ 过于缓慢的构建速度的一种方法。
参考文献
《C++ 服务器开发精髓》
《Effective Modern C++》