构造、析构期间被调虚函数发生的惨案,长教训!
最近有个问题出现长达一个月,经过两次修改未能解决,大致场景如下:
一个多态对象Children被注册回调(m_observer对象位于基类Base中),正好在析构函数里面回调,导致crash。
第一次修改是通过在基类的base里面对observable对象取消回调订阅,来避免回调时对象不存在。
后来发现每个包含m_observer的类都需要这么干,这样就多了很多重复代码,不够简洁,于是考虑进一步优化,干脆在Observer析构函数里面去统一取消回调订阅好了。这样析构函数啥代码也不用写:
结果出现了这种场景,在Children对象析构时正好发生回调,这时候底层Observable拿到了m_observer对象的计数,导致m_observer没有去执行析构,这时候回调对象刚好不存在了,导致crash。
这里再延伸一些,这里的Observable持有的是Observer对象的弱指针,从而实现弱回调,也就是说,Observable通过弱指针提升到强指针来判断对方Observer是否还活着,如果活着就调对方的注册的回调函数,否则不调。理想是很美好的,实际由于组合模式打破了这种原则,因为通过组合模式,持有的仅仅是Observer,当外层对象析构时候发生回调,相当于Observer被Observable偷走了,这时候回调外层对象已经不存在了,如果采用继承Observer接口的方式,那么就不会存在这个问题,因为对象是个完整的Observer对象。
这也是多继承一个Observer接口的优势,对象是完整的,只要拿到了Observer强指针,就能保证对象还活着。
当然我写这个不是为了鼓吹什么多继承,批判组合模式,只是双方都有应用场景罢了,不能一概而论,得出组合优于继承的结论。
问题还是要解决的,回调最初的方案,是不是在Base里面手动解绑回调就能解决问题了呢?
分析一下:
假设回调在m_observer->Register(nullptr)之前发生,那么由于Register接口带锁保护,就会等待回调结束后在执行m_observer->Register(nullptr)语句,这个期间可以保证对象是活着的。
假设回调在m_observer->Register(nullptr)之后发生,由于回调被取消了,所以不会发生回调,这也很安全。
实际运行过程中还是会crash。这就有点不可思议了,继续分析问题,发现注册的回调是子类的虚函数:
在上述情况1的时候发生回调,调的是子类的虚函数callback,而每次调用栈的顶端永远是空地址:
函数地址为空,只有虚函数可能发生了,我写了原型程序验证了一下,模拟情况1发生的行为:
果然crash了,看看调用栈如下:
再看看阶段(1)对象c的虚函数表:
在阶段(2),对象的虚函数表如下:
可以得出,在基类的析构期间,子类的虚函数表已经清空,这时候调子类的虚函数已经是不安全的了,虽然这时候对象还活着,但不完整。所以得通过加接口,在析构函数之前去释放回调,这样才是安全的了。
科目二,《Effective C++》也指出,不能在构造、析构函数中调虚函数,原因是这期间虚函数没有多态性,所以即使编码遵守原则,在多线程场景下,也防不住有析构期间被调用虚函数的情况,特别是被调的时候。
更新ing。。
前面说通过加接口,在析构函数之前去释放回调,这样不够优雅,因为子类重写得记得多了这么一个接口需要调用,所以继续重构,达到了如下完美的方案:
本质来说,回调传递this指针是不安全的,所有裸指针都是有风险的,如果用智能指针封装,就能保证对象的完整性了,在这个场景下,只需要将this转换成智能指针,这时候std::enable_share_from_this就派上用场了。
版权声明: 本文为 InfoQ 作者【华为云开发者社区】的原创文章。
原文链接:【http://xie.infoq.cn/article/197648758cd8f936f7653009d】。文章转载请联系作者。
评论