从一段代码谈起

看看下面这段代码:

#include "RW_Thread_Mutex.h"

/// @class RWLock
/// @brief 读写锁
class RWLock
{
public:
    /// 构造函数
    RWLock()
    {
        m_Lock = NULL;
    }

    /// 析构函数
    virtual ~RWLock()
    {
        if (m_Lock)
            delete m_Lock;
        m_Lock = NULL;
    }

public:
    /// 创建锁,确保该函数在单线程环境中被调用
    void createLock()
    {
        if (NULL == m_Lock)
            m_Lock = new ACE_RW_Thread_Mutex;
    }

    /// 读上锁
    void lockRead()
    {
        if (NULL != m_Lock)
            m_Lock->acquire_read();
    }

    /// 写上锁
    void lockWrite()
    {
        if (NULL != m_Lock)
            m_Lock->acquire_write();
    }

    /// 解锁
    void unlock()
    {
        if (NULL != m_Lock)
            m_Lock->release();
    }

private:
    /// ACE读写锁
    ACE_RW_Thread_Mutex *m_Lock;
};

这是工作上合作单位给的一段代码,来自一位从业近10年的C++程序员。功能很简单,就是对读写锁的一个封装,但以我看来,这段代码暴露了诸多问题:

人为拆分了构造的过程

第一次使用这个类,相信很多人都会跟我一样:

// 构造一个读写锁
RWLock rwlock;

// 加读锁
rwlock.lockRead();

// 解锁
rwlock.unlock();

毕竟是很常用的一个功能,但如果这样去使用,那么就可能在运行时出现乱七八糟的问题。
在类的定义中,有一个方法,叫做createLock,看它的注释,真是让人不明所以:

创建锁,确保该函数在单线程环境中被调用

再看看它的实现,这才恍然大悟。原来仅仅调用类的构造函数,还不能真正的构造出这个对象,必须再调用一次createLock函数,才能完成构造过程。
在createLock函数中,作者倒很贴心地给我们添加了“非空判断”:

if (NULL == m_Lock)
    m_Lock = new ACE_RW_Thread_Mutex;

这样,无论调用多少次createLock函数,都只会构造一次锁,是一种“懒加载”,但实际情况真正如此吗?
假设两个线程A和B同时需要抢占这把锁,那么可能会出现下面的情况:

  1. 线程A调用createLock函数,此时,由于成员m_Lock为NULL,那么它会继续调用成员m_Lock的构造函数,构造这把锁,在构造的过程中,线程B抢占了执行权;
  2. 由于线程B获取了执行权,它也调用createLock函数,此时,由于线程A还未构造成功m_Lock,因此m_Lock的值还为NULL,那么线程B也会去构造一遍成员m_Lock。
  3. 这样,线程A和线程B都去构造了一遍m_Lock,并且后完成构造的成员会覆盖掉先一步构造的的成员,造成了内存泄漏。

更严重的,假设此时线程A重新获取了执行权,那么它将会完成成员m_Lock的构造,如果这时候它调用了加锁的方法,而线程B又将成员覆盖掉,那么线程A的锁将永远无法得到释放,因为,创建的锁已经被泄漏掉了。与之相比,内存泄漏倒不是什么大问题了。

好像这个问题发生的概率很低,但千万不要认为低概率的问题不会发生。个人认为,实际情况中,小概率的事件一定会发生,极小概率的事件很有可能会发生。这些问题一旦被触发,那么一定要花费非常多的时间去排查错误,真是得不偿失。

这个方法的注释倒是明确表示请“确保该函数在单线程环境中被调用”,但是别忘了,使用读写锁,那几乎是在多线程的环境下,所谓“单线程”下调用的场景,可以说并不存在。

其实,完全可以避免这个问题。只需要移除这个莫名其妙的createLock函数,并将构造的过程在构造函数中实现就行了。构造函数就是应该完成对象的构造,否则这个函数也不应该叫构造函数了。把构造的过程人为地拆分成两个步骤,不仅仅会让使用者摸不着头脑,而且可能会造成十分严重的后果。

还是那句话,不要过早地去优化代码。

在不应该继承的场景下允许继承

这段代码还有一个神奇的地方,它的析构函数是一个虚函数。

我们知道,在C++中,如果不把析构函数声明为虚函数,那么可能会造成内存泄漏的危险。所以,当声明一个可以被继承的类时,咱们一般会声明一个虚析构函数,哪怕是一个空实现。反之,如果我们不想让自己地类被继承,那么析构函数就应该是非虚拟的。

在C++ 11之前,我们可以将构造函数设置为私有,去强制阻止该类的继承,但也会造成很大的变扭,因为无法使用构造函数去构造对象了。所以一些比较旧的代码,会使用“将析构函数设置为非虚函数”的方式,人为地去限制继承。当使用者看到带有非虚析构函数的类时,就应该考虑考虑这些类被创建时的意图,是否类的编写者不希望自己的类被继承。STL中的vector和map等都没有提供非虚拟的析构函数,你会去继承vector或map吗?我想不会吧。

久而久之,似乎大家约定俗成,带有非虚析构函数的类是不应该被继承的

回头看一看这个类,它是一个读写锁的实现,而非接口,虽然它的命名很有疑惑性。或许你希望使用别的方式去实现一个读写锁,那么你应该将这个类的函数提取出一个接口,继承那个接口,而非这个类。想想看,你使用一个类去继承另一个类时,需要重写该类几乎所有的函数,那么这个继承还有什么意义呢?前提时你写得不是一个代理类,但是代理类也应该继承接口,而非实现类。

接口是一种约定的对象使用方式,是使用的说明书,而实现类则代表了使用某一种具体的方法,去完成这一接口的使用方式。使用一种实现方式,去继承使用另一种实现方式的实现类,本身在逻辑上就难以说通。

在翻看别的代码时,我惊讶地发现了为什么将这个类的析构函数设置为虚拟函数的原因,看看另一个需要使用读写锁的类:

/// 内存表
public class MemTable : public RWLock
{
    // ......
    
    template
    void get(size_t row, size_t col, T &val) const
    {
        lockRead();
        // ...
        unlock();
    }
    
    template
    void set(size_t row, size_t col, const T &val)
    {
        lockWrite();
        // ...
        unlock();
    }
    
    // ......
};

原来设置虚析构函数的目的,真的是为了继承啊!

我们知道,继承代表了一种“is a”的关系,即子类(或接口)是基类(或接口)的一种。好比鸟是一种动物,那么鸟就应该继承自动物。当这里的对象是一种读写锁吗?显然不是。只是这个对象在实际使用中,需要用到读写锁而已。

明明是一种“has a”的包含关系,偏偏要使用“is a”的继承关系,这种“继承依赖症”可以说是很差劲了。这种代码,出自从业近十年的C++程序员,可能会对新手造成非常大的不良影响。

总结

经过修改后的代码成了这样:

#include "RW_Thread_Mutex.h"

/// @class RWLock
/// @brief 读写锁
class RWLock final
{
public:
    /// 构造函数
    RWLock()
    {
        m_Lock = new ACE_RW_Thread_Mutex;
    }

    /// 析构函数
    ~RWLock()
    {
        delete m_Lock;
    }

public:
    /// 读上锁
    void lockRead()
    {
        m_Lock->acquire_read();
    }

    /// 写上锁
    void lockWrite()
    {
        m_Lock->acquire_write();
    }

    /// 解锁
    void unlock()
    {
        m_Lock->release();
    }

private:
    /// ACE读写锁
    ACE_RW_Thread_Mutex *m_Lock;
};

添加了一个final关键字,强制该类不允许继承。

其实我想说的,不仅仅是这几个粗浅的问题而已。绝大多数老程序员的水平都很高,但也有很多号称经验丰富的老程序员,其实并不是特别厉害,他们在长期的编码过程中,骄傲自负,早已不知学习为何物,凡事不讲究个刨根问底,代码往往可以运行就满足了,问题越积越深。新手也不要盲目地相信年限经验,多思考代码为何这样写,有没有隐藏的问题,这样才能真正地提高。

所以,永远不要停止学习的脚步,否则真的是不进则退。

你可能感兴趣的:(从一段代码谈起)