构造函数和析构函数
前言
正常的情况来说,构造函数和析构函数应该是消耗性能比较少的地方,我们在学习基础的c++编程的时候,书上基本都会写构造函数和析构函数需要尽量的简洁,不能承担复杂的计算工作或者是逻辑工作。事实也确实如此,如果将大量的计算放到构造函数和析构函数,整个类都会比较臃肿,其他类引用时也会有更多的不方便。所以,构造函数和析构函数大多数的情况下主要是负责的初始化和清除资源的操作。
一、继承
继承和合成都是在对象设计过程中把类联系在一起的方式,这一节说明的就是继承与构造、析构之间的联系。主要的举例是一个线程同步构造的实现。
线程同步的三种常见的方式应该都很熟悉了,互斥、信号、临界区。这个例子就是以共享队列为例,队列的元素个数有enqueue()和dequeue()例程共同管理。
在以下的实例中,我们看到加锁和解锁似乎都是很容易看出。但是在大型的项目中,一个团队的人共同维护这样一份代码,可能有时候就会漏掉解锁的操作导致问题发生;或者还有一种情况就是在程序持有锁的情况下发生异常,这时其他的线程可能以为没有退出一直等待导致问题,这种情况下只能在捕获异常的时候加上释放锁的操作。
以上两个问题其实在c++中都有了很好的解决方案。为了不用手动的加锁解锁,c++中可以将锁封装为一个类,在构造的时候加锁,析构的时候解锁。这样的话多次返回就不需要担心,编译器会在每条返回的语句前加上对带锁的析构函数的调用。具体的实现可以参考class Lock。
// 元素弹出队列
Type& dequeue()
{
get_the_lock(&queuelock);
...
NumberOfElements--;
...
release_the_lock(&queuelock);
}
// 元素压进队列
void enqueue()
{
get_the_lock(&queuelock);
...
NumberOfElements++;
...
release_the_lock(&queuelock);
}
// 获取锁
void get_the_lock(CSLock)
{
// Critical section begins
...// Protected computation
// Critical section ends
}
// Lock类原型,构造上锁,析构函数解锁
class Lock
{
public:
Lock(pthread_mutex_t& key)
: theKey(key)
{
pthread_mutex_lock(&theKey);
}
~Lock()
{
pthread_mutex_unlock(&theKey);
}
private:
pthread_mutex_t theKey;
};
其实编程环境的不同还会有许多不同的变体,主要是取决于并发级别、嵌套锁是否有影响、是否需要在资源可用时通知、读/写锁、内核和用户空间何时有效、进程间还是进程内有效等等。因此这些变体需要满足不同的需求,很容易就联想到继承的方法实现。BaseLock如下:
class BaseLock
{
BaseLock(pthread_mutex_lock& key, LogSource& src) {};
virtual ~BaseLock();
};
MutexLock是继承于BaseLock,满足互斥锁的需求:
class MutexLock: public BaseLock
{
private:
pthread_mutex_t &theKey;
LogSource &src;
public:
MutexLock(pthread_mutex_lock& akey, LogSource& source);
~MutexLock();
};
MutexLock::MutexLock(pthread_mutex_lock& akey, LogSource& source)
: BaseLock(akey, source)
, theKey(akey)
, src(source)
{
pthread_mutex_lock(&theKey);
}
MutexLock::~MutexLock()
{
pthread_mutex_unlock(&theKey);
}
但是,没有一件事是完美的,如果我们使用继承的方式意味着我们需要花费时间构造和析构父类和子类,这样和传统的直接调用pthread_mutex_lock来实现锁又是多了一层性能上的损耗。因此我们在设计和实现的时候首先需要考虑到这样实现的优点和弊端。如果是在一个性能要求极其严格的场景下,锁需要被频繁的加锁和释放,显然第一种方式不是很友好。就比如我们在后端服务中的数据被多个客户端使用,频繁的同步调用这样就需要性能比较好的锁方式。
锁对象的优点:包含多个返回点的程序的维护、从异常中恢复、锁操作的多态。
最后书中还对互斥锁的三种形态进行了一次试验:
// 版本一
int main()
{
// ... start time
int shareCount = 0;
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&theKey);
shareCount++;
pthread_mutex_unlock(&theKey);
}
// ... stop time
}
// 版本二无继承
int main()
{
// ... start time
int shareCount = 0;
for (int i = 0; i < 1000000; i++) {
SimpleMutex m(key);
shareCount++;
}
// ... stop time
}
// 版本三继承
int main()
{
// ... start time
int shareCount = 0;
for (int i = 0; i < 1000000; i++) {
MutexLock mk(key);
shareCount++;
}
// ... stop time
}
运行了100万次循环之后的结果是版本一和版本二消耗的时间均为1.01s,但是版本三耗费1.62s。我们从这组数据中直观的看到锁对象带来的性能的负担。因此如何取舍需要程序员自己判断
二、合成
合成中同样会存在性能的问题,当A类中包含了B类的时候,A类构造的时候就会初始化B类,B类中如果包含C类,那又会带动C类的初始化,这样就存在一个递归的关系。可以想象如果在一个巨大的调用链中,层次会非常的复杂。一种比较好的方法是在类中调用指针而不是调用对象,这样的话可以更好的控制对象的创建和清除。可以在初始化时就初始化为有意义的值,或者是先为null,等到需要的时候再new出对象。
三、缓式构造
性能优化经常会打破竞争因素之间的微妙平衡。性能的最大化通常需要丢失掉一些软件其他的特性,比如灵活性、比如复用性、可维护性等。就比如我们在最开始学习编程的时候都知道我们需要将变量定义在最开头的地方,但是在有些场景下,预先定义变量就是一种资源的浪费。可能某个变量只在很少一部分的场景下用到,但是一开始就定义的话就会一直占据程序的资源。因此c++中会有这样一个类DataPacket,用于分配和回收内存:
class DataPacket
{
public:
DataPacket(char* data, int size)
:size(size)
{
buffer = new char[size];
}
~DataPacket() {
delete[] buffer;
}
private:
char *buffer;
// ...
};
这样的类如果在一个程序中使用的频率为50%,另外50%的时间不需要使用,那么如果在开头就创建这样一个类的话,无疑就会占用大量的资源。因此最好的方法就是在需要使用的时候去创建使用,使用完及时释放。
四、冗余构造
沿着简单带有严重的编码错误的路线,我们再来看下这个例子:
class Person
{
public:
Person(const char* name) {name = s;}
private:
string name;
}
代码中可以看到Person没有在默认参数列表中初始化string对象,而是在构造中采用赋值的方式。这样的话构造函数的时候实际上对name这个string对象已经初始化过一次,再调用赋值构造就相当于之前的初始化没有起到任何的作用,这显然是一种浪费。因此我们在平时编码的过程中需要注意这些可以修改的性能提高的方法。
总结
这一节主要讲的就是c++的构造和析构函数对于整个程序性能的影响。对象的创建包含父类和子类的创建,销毁也是同理,因此我们需要关注这方面的开销。对于代码的编写我们应该尽量做到创建对象在需要使用的时候再创建,这样可以减少无谓的开销。在性能和其他方面的权衡需要程序员自己斟酌,我们建议程序员多进入所使用的类,而不是都使用OOP的方式。最后重要的就是需要在构造的时候就初始化被包含的对象降低随后赋值操作的开销。