unique函数_C++智能指针2:(虚?)析构函数(标准与实现的差异)

只要是有良心的 C++ 教材,总是会苦心孤诣地告诫初学者,在继承关系中,应该把父类的析构函数定义成虚函数。这已然成为一条铁律,如果违背,不仅有内存泄漏的风险,在多继承情况下甚至会出现未定义行为。因此,无论在任何情况下,都必须遵循。

然而使用智能指针操作时,似乎会有些有趣的现象。

测试案例std::unique_ptr

class B {
public:
    ~B() {
        std::cout << "~Bn";
    }
};

class D : public B {
public:
    ~D() {
        std::cout << "~Dn";
    }
};

int main() {
    B* raw_pointer = new D();
    delete raw_pointer;

    std::unique_ptr<B> unique_pointer = std::unique_ptr<B>(new D());
    unique_pointer = nullptr;

    std::unique_ptr<B> unique_pointer_use_make_unique = std::make_unique<D>();
    unique_pointer_use_make_unique = nullptr;

    return 0;
}

直观分析:继承关系中没有虚析构函数,那毫无疑问,3个指针析构时打印的都是~B。事实也是如此,MSVCGCCClang的结果与我们的分析一致。

再来看std::shared_ptr

int main() {
    std::shared_ptr<B> shared_pointer = std::shared_ptr<B>(new D());
    shared_pointer = nullptr;

    std::shared_ptr<B> shared_pointer_use_make_shared = std::make_shared<D>();
    shared_pointer_use_make_shared = nullptr;

    return 0;
}

直观分析:这2个指针析构时打印的都是~B。然而,三个编译器的行为一致,输出了:

~D
~B
~D
~B

突然就怀疑人生了。显然,析构时调用的是子类的析构函数。而再去调用父类的析构函数是析构子类时默认的行为。

std::shared_ptr在没有virtual的情况下居然拥有多态的能力?这当然不是,没有virtual就没有虚表项,函数表现为静态类型行为。

如果析构函数带上virtual,使用delete操作裸指针,即可触发多态。然而,如果直接调用静态类型的析构函数而不是操作裸指针,那有无virtual是无所谓的。

那为什么std::shared_ptr表现如此“优秀”?是标准定义的行为?

参考ISO/IEC 14882 C++17标准文档,23.11.2.2.2 [util.smartptr.shared.dest]规定了~std::shared_ptr()的行为:

(1.1) If *this is empty or shares ownership with another shared_ptr instance (use_count() > 1), there are no side effects.
(1.2) — Otherwise, if *this owns an object p and a deleter d, d(p) is called.
(1.3) — Otherwise, *this owns a pointer p, and delete p is called.

很自然的定义,并没有任何奇怪的地方。既然使用了delete,在没有virtual的情况下,操作的对象肯定是子类的指针。

分别看看两个构造的MSVC的实现。

[1] 直接使用 std::shared_ptr 构造:

(这部分涉及沉重的模板推导,不想深究细节可以直接跳到清晰版本

template <class _Ty>
class shared_ptr : public _Ptr_base<_Ty> 
{
    // ...

    template <class _Ux,
              enable_if_t<
                conjunction_v<
                    conditional_t<is_array_v<_Ty>, _Can_array_delete<_Ux>, _Can_scalar_delete<_Ux>>, 
                    _SP_convertible<_Ux, _Ty>>,
              int> = 0>
    explicit shared_ptr(_Ux* _Px) { // construct shared_ptr object that owns _Px
        if constexpr (is_array_v<_Ty>) {
            _Setpd(_Px, default_delete<_Ux[]>{});
        } else {
            _Temporary_owner<_Ux> _Owner(_Px);
            _Set_ptr_rep_and_enable_shared(_Owner._Ptr, new _Ref_count<_Ux>(_Owner._Ptr));
            _Owner._Ptr = nullptr;
        }
    }

    //...
}

代入std::shared_ptr<B>(new D())_Ty -> B, -Ux -> D,先分析下可怕的enable_if_t

is_array_v<_Ty> -> is_array_v<B> -> false
_Can_array_delete<_Ux> -> _Can_array_delete<D>
_Can_scalar_delete<_Ux> -> _Can_scalar_delete<D>

conditional_t<false, _Can_array_delete<D>, _Can_scalar_delete<D>> 
-> _Can_scalar_delete<D>

_SP_convertible<_Ux, _Ty> -> _SP_convertible<D, B> 
-> is_convertible<D*, B*>::type
-> integral_constant<bool, true>

conjunction_v<_Can_scalar_delete<D>, integral_constant<bool, true>> 
-> true

enable_if_t<true, int> -> int

这个模板构造函数在 SFINAE 的肆虐中生存下来并由编译器相中。模板实例化为

/* template <D, int = 0> */
explicit shared_ptr(D* _Px) {
    // if constexpr (is_array_v<_Ty>) {
    //     _Setpd(_Px, default_delete<_Ux[]>{});
    // } else {
        _Temporary_owner<D> _Owner(_Px);
        _Set_ptr_rep_and_enable_shared(_Owner._Ptr, new _Ref_count<D>(_Owner._Ptr));
        _Owner._Ptr = nullptr;
    // }
}

所以构造时实际执行了以上未注释的三句。第一句:

template <class _Ux>
struct _Temporary_owner {
    _Ux* _Ptr;

    explicit _Temporary_owner(_Ux* const _Ptr_) noexcept : _Ptr(_Ptr_) {}
    _Temporary_owner(const _Temporary_owner&) = delete;
    _Temporary_owner& operator=(const _Temporary_owner&) = delete;
    ~_Temporary_owner() {
        delete _Ptr;
    }
};

这个类就是临时保存了指针,所以只剩下中间的一句

// _Px -> new D() -> 临时指针变量
_Set_ptr_rep_and_enable_shared(_Px, new _Ref_count<D>(_Px));

继续跟踪:

template <class _Ux>
void _Set_ptr_rep_and_enable_shared(_Ux* const _Px, _Ref_count_base* const _Rx) noexcept {
    this->_Set_ptr_rep(_Px, _Rx);
    if constexpr (conjunction_v<negation<is_array<_Ty>>, negation<is_volatile<_Ux>>, _Can_enable_shared<_Ux>>) {
        if (_Px && _Px->_Wptr.expired()) {
            _Px->_Wptr = shared_ptr<remove_cv_t<_Ux>>(*this, const_cast<remove_cv_t<_Ux>*>(_Px));
        }
    }
}

先替换

negation<is_array<_Ty>> -> negation<is_array<B>> 
negation<is_volatile<_Ux>> -> negation<is_volatile<D>>
_Can_enable_shared<_Ux>> -> _Can_enable_shared<D>>

conjunction_v<negation<is_array<B>>, negation<is_volatile<D>>, _Can_enable_shared<D>>>
-> false

值得一提的,最后那个结构与enable_shared_from_this有关,即判断D是否派生于该类。这是另外一个有趣的议题,与 CRTP 有关。

所以模板实例化为:

/* template <class D> */
void _Set_ptr_rep_and_enable_shared(D* const _Px, _Ref_count_base* const _Rx) noexcept {
    this->_Set_ptr_rep(_Px, _Rx);
    // if constexpr (conjunction_v<negation<is_array<_Ty>>, negation<is_volatile<_Ux>>, _Can_enable_shared<_Ux>>) {
    //     if (_Px && _Px->_Wptr.expired()) {
    //         _Px->_Wptr = shared_ptr<remove_cv_t<_Ux>>(*this, const_cast<remove_cv_t<_Ux>*>(_Px));
    //     }
    // }
}

实际这个就是设置了两个数据成员(在父类中定义)

void _Set_ptr_rep(element_type* _Other_ptr, _Ref_count_base* _Other_rep) noexcept { // take new resource
    _Ptr = _Other_ptr;
    _Rep = _Other_rep;
}

拨开重重的traits工具后,现在这一切看起来十分清晰,std::shared_ptr在构造函数中做了两件事,初始化了两个数据成员。因为要克服重重障碍,无法直接放在初始化列表中进行。这也就是引用计数与托管对象的分离实现案例。一些有趣的讨论可参考《Effective Modern C++》。

清晰版本:

template <>
class _Ptr_base<B> {
    //...

private:
    B* _Ptr;
    _Ref_count_base* _Rep;
}

template <>
class _Ref_count<D> : public _Ref_count_base {
public:
    explicit _Ref_count(D* _Px) : _Ref_count_base(), _Ptr(_Px) {}

private:
    // 最后析构函数会调用到这里
    virtual void _Destroy() noexcept override {
        delete _Ptr;
    }

    D* _Ptr;
};

template <>
class shared_ptr<B> : public _Ptr_base<B> {
    explicit shared_ptr(D* _Px) {
        _Ptr = _Px; // _Ptr 静态类型是 B,但保存 new D() 的裸指针
        _Rep = new _Ref_count<D>(_Px) // _Ref_count 是 _Ref_count_base 的子类,该类保存了一个指向 D 的指针
    }
}

到这一步也许有点眉目了,std::shared_ptr中保存了裸指针(没错,它的静态类型是B,但没有virtual是不能正确析构的),以及一个_Ref_count结构,它借由模板构造函数窃取到了实际的指针信息,即D的信息!构造函数结束,operator=做了移动构造过程,接着看析构函数。它是否如标准定义的那样?

// 析构函数调用链

~shared_ptr() noexcept {
    this->_Decref();
}

void _Ptr_base::_Decref() noexcept {
    if (_Rep) {
        _Rep->_Decref(); // 析构链转入 _Ref_count_base,和父类已经没关系了...
    }
}

void _Ref_count_base::_Decref() noexcept {
    if (_MT_DECR(_Uses) == 0) {
        _Destroy();
        _Decwref();
    }
}

virtual void _Ref_count::_Destroy() noexcept override {
    delete _Ptr; // 这是指向 D 的指针
}

水落石出!所以最后销毁的是子类的指针,即使没有virtual,但借由模板准确获取到了静态类型。可见虽然标准给了一份定义,但MSVC却有自己的实现,虽然最后使用的是delete,但静态类型却是最初new的类型。不过这种实现也会让人困惑,析构时居然完全不管_Ptr,只析构引用计数_Rep,其实原因在构造函数中很清楚,_Ptr的生命周期由引用计数托管。另外,_Ref_count_base::_Decref()中还有一个_Decwref();,这个是判断std::weak_ptr还在不在的。如果还在的话,虽然指针对象销毁了,引用计数对象_Ref_count还在的,因为得让std::weak_ptr查询,如果连std::weak_ptr都没有了,引用计数就把自己也销毁。

[2] 使用 std::make_shared 构造

由于直接用std::make_shared会将指针和引用计数捆绑在一起,底层管理与使用new再包裹std::shared_ptr有所不同,但构造析构的思路是一致的。

template <class _Ty, class... _Types>
inline shared_ptr<_Ty> make_shared(_Types&&... _Args) {
    const auto _Rx = new _Ref_count_obj<_Ty>(_STD forward<_Types>(_Args)...);

    shared_ptr<_Ty> _Ret;
    _Ret._Set_ptr_rep_and_enable_shared(_Rx->_Getptr(), _Rx);
    return _Ret;
}

需要注意的是第一句,因为是用std::make_shared<D>()构造的,自始至终就没有父类B的影子。

template <class _Ty>
class _Ref_count_obj : public _Ref_count_base {
public:
    template <class... _Types>
    explicit _Ref_count_obj(_Types&&... _Args) : _Ref_count_base() {
        ::new (static_cast<void*>(&_Storage)) _Ty(_STD forward<_Types>(_Args)...);
    }

    _Ty* _Getptr() { // get pointer
        return reinterpret_cast<_Ty*>(&_Storage);
    }

private:
    virtual void _Destroy() noexcept override {
        _Getptr()->~_Ty();
    }

    aligned_union_t<1, _Ty> _Storage;
};

析构时,调用链最终会调用_Ref_count_obj::_Destroy(),这个直接调用析构函数,即~D(),所以仍然是析构静态类型。_Storage提供了托管对象的空间,用placement new填充数据。析构时再执行底层指针转换。

标准库中的引用计数如何做在 C++11 之前一直有争论,直到标准制定以及主流编译器实现,才终于尘埃落定。std::shared_ptr是个有趣的智能指针。由于引用计数的分离,为了正确托管这些对象,不得不添加很多设施来弥补,包括enable_shared_from_this,比如上面那个_Set_ptr_rep_and_enable_shared()。两种构造虽然是分开的,但都有_Set_ptr_rep_and_enable_shared这个函数,殊途同归得到外表一致的std::shared_ptr。而析构时,又在_Ref_count_base::_Decref()分道扬镳,指针与引用计数分离的去调用_Ref_count::_Destroy(),捆绑在一起的去调用_Ref_count_obj::_Destroy(),但最终都是销毁最初构造的静态类型。然而,std::unique_ptrstd::make_unique在内部实际是new完后用std::unique_ptr包裹,两种构造是一模一样的。

最后,绝对不能因为std::shared_ptr的这种实现就抱有侥幸,使用第一种构造时,如果构造前就将指针的静态类型改为父类,那同样会内存泄漏。因此,无论何时,虚析构函数的铁律应严格遵循。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值