从一段代码谈起

看看下面这段代码:

#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<class T>
    void get(size_t row, size_t col, T &val) const
    {
        lockRead();
        // ...
        unlock();
    }
    
    template<class T>
    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关键字,强制该类不允许继承。

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352