实现Cpp的智能指针

0 阅读12分钟

什么是智能指针?

智能指针是 C++ 标准库基于 RAII 思想设计的 “类裸指针封装器” ,它用栈对象的生命周期绑定堆内存的生命周期,自动化完成堆内存的分配、释放、所有权转移等操作,从根源上避免手动管理内存的三大致命问题:内存泄漏、重复释放、异常安全缺失

回顾

class shape_wrapper {
public:
  explicit shape_wrapper(
    shape* ptr = nullptr)
    : ptr_(ptr) {}
  ~shape_wrapper()
  {
    delete ptr_;
  }
  shape* get() const { return ptr_; }

private:
  shape* ptr_;
};

这个类可以完成智能指针的最基本的功能:对超出作用域的对象进行释放。但它缺了点东西:

  • 这个类只适用于 shape 类
  • 该类对象的行为不够像指针
  • 拷贝该类对象会引发程序行为异常

模板化和易用性

要让这个类能够包装任意类型的指针,需要把它变成一个类模板。这实际上相当容易:

template <typename T>
class smart_ptr {
public:
  explicit smart_ptr(T* ptr = nullptr)
    : ptr_(ptr) {}
  ~smart_ptr()
  {
    delete ptr_;
  }
  T* get() const { return ptr_; }
private:
  T* ptr_;
};

目前这个 smart_ptr 的行为还是和指针有点差异的:

  • 它不能用 * 运算符解引用
  • 它不能用 -> 运算符指向对象成员
  • 它不能像指针一样用在布尔表达式里

不过,这些问题也相当容易解决,加几个成员函数就可以:

template <typename T>
class smart_ptr {
public:
  …
  T& operator*() const { return *ptr_; }
  T* operator->() const { return ptr_; }
  operator bool() const { return ptr_; }
}

拷贝构造和赋值

这里先介绍一下C++ 中一个经典的异常安全、代码复用的赋值运算符实现惯用法拷贝并交换(Copy and Swap) 。赋值操作并不是天然分为 “拷贝” 和 “交换”,而是人们为了解决手动实现赋值运算符时的三大痛点(自赋值安全、异常安全、代码复用),主动设计出了这种 “分两步走” 的最佳实践。

  1. 第一步:拷贝(利用拷贝构造函数)

    不直接修改当前对象,而是先利用拷贝构造函数生成一个 other临时副本

    • 这一步把 “可能抛异常的操作”(分配内存、拷贝数据)都放在了临时对象里,如果失败,当前对象完全不受影响,临时对象也会自动清理。
    • 完美复用了拷贝构造函数的代码,不用重复写。
  2. 第二步:交换(Swap)

    把当前对象的资源(指针、大小等)和刚才生成的临时副本的资源进行浅交换(只交换成员变量的值,不做深拷贝)。

    • 交换操作是不抛异常的(只是交换几个指针和整数),绝对安全。
    • 交换后,临时对象持有了当前对象原来的旧资源,当临时对象离开作用域析构时,会自动释放旧资源。
    • 天然解决了自赋值问题(即使是自赋值,拷贝自己再交换自己,也不会出错)。

代码实现实例如下:

class StringBuffer {
public:
    // ... 之前的构造、拷贝构造、析构函数不变 ...

    // 【关键1】添加 swap 成员函数:不抛异常,仅交换成员
    void swap(StringBuffer& other) noexcept {
        using std::swap; // ADL(参数依赖查找),如果有自定义 swap 会优先调用
        swap(data_, other.data_); // 只交换指针(浅交换,极快)
        swap(size_, other.size_); // 只交换大小
    }

    // 【关键2】赋值运算符:拷贝并交换
    // 注意:参数是按值传递(pass by value),这一步自动触发了“第一步:拷贝构造”
    StringBuffer& operator=(StringBuffer other) { 
        swap(other); // 第二步:和临时拷贝交换
        return *this;
    } // 函数结束,other 临时对象析构,自动释放旧资源
};

// 【可选】提供非成员 swap,方便 STL 算法调用
void swap(StringBuffer& a, StringBuffer& b) noexcept {
    a.swap(b);
}

a = b为例,下面是流程中发生的操作:

  • 调用 operator=(StringBuffer other)

    • 因为参数是按值传递,编译器会自动调用 StringBuffer拷贝构造函数,用 b 拷贝构造出临时对象 other
    • 这一步完成了 “深拷贝”,如果 new 失败抛异常,a 还没被碰过,完全安全。
  • 执行 swap(other)

    • adata_size_ 和临时对象 other 的对应成员交换
    • 现在 a 持有了 b 的数据(通过交换拿到了 other 的深拷贝结果),other 持有了 a 原来的旧数据。
    • 这一步只是交换指针,不抛异常,绝对安全。
  • 函数结束,other 析构

    • 临时对象 other 离开作用域,析构函数被调用,delete[] 了它持有的旧数据(也就是 a 原来的旧资源)。
    • 旧资源被自动、安全地释放了

拷贝构造和赋值,暂且简称为拷贝,这是个比较复杂的问题了。关键还不是实现问题,而是如何定义其行为。假设有下面的代码:

smart_ptr<shape> ptr1{create_shape(shape_type::circle)};
smart_ptr<shape> ptr2{ptr1};

对于第二行,究竟应当让编译时发生错误,还是可以有一个更合理的行为? 这是一个非常经典的智能指针所有权设计问题,答案取决于你希望这个 smart_ptr 实现的是 「独占所有权」 还是 「共享所有权」— 而现代 C++ 的最佳实践告诉我们:默认应当让编译时发生错误(禁止拷贝) ,如果确实需要共享所有权,再通过专门的「共享型智能指针」(如 std::shared_ptr)来实现。

拷贝模拟移动

template <typename T>
class smart_ptr {
  …
  smart_ptr(smart_ptr& other)
  {
    ptr_ = other.release();
  }
  smart_ptr& operator=(smart_ptr& rhs)
  {
    smart_ptr(rhs).swap(*this);
    return *this;
  }
  …
  T* release()
  {
    T* ptr = ptr_;
    ptr_ = nullptr;
    return ptr;
  }
  void swap(smart_ptr& rhs)
  {
    using std::swap;
    swap(ptr_, rhs.ptr_);
  }
  …
};

这段代码是 C++11 之前,人们为了在没有右值引用和移动语义的情况下,模拟 “转移所有权” 行为而设计的一种 “另类智能指针” 实现。它的核心特点是:用 “拷贝构造 / 赋值” 的语法,实现 “移动语义” 的效果

release() 成员函数:资源转移的核心工具

T* release() {
    T* ptr = ptr_;
    ptr_ = nullptr;
    return ptr;
}

这和 std::unique_ptr::release() 完全一致:

  • 保存当前的原始指针 ptr_
  • 将自身的 ptr_ 置为 nullptr(放弃所有权);
  • 返回原来的原始指针(供新的所有者接管)。

它的作用是安全地剥离资源,保证源对象析构时不会重复释放。

swap() 成员函数:资源交换的工具

void swap(smart_ptr& rhs) {
    using std::swap;
    swap(ptr_, rhs.ptr_);
}

这就是上面 “拷贝并交换” 惯用法的配套工具:

  • 用 ADL(参数依赖查找)调用 std::swap
  • 仅交换两个 smart_ptr 的内部原始指针 ptr_,不做深拷贝,成本极低。

【核心】拷贝构造函数:用 “拷贝” 语法实现 “转移”

smart_ptr(smart_ptr& other) { // 注意:参数是 non-const 左值引用!
    ptr_ = other.release();
}

这是这段代码最 “另类” 的地方 ——传统拷贝构造函数的参数是 const smart_ptr&(常量左值引用),保证不修改源对象;但这里的参数是 smart_ptr&(非常量左值引用),可以修改源对象

  • 接受一个非常量的源对象 other
  • 调用 other.release(),把 other 的资源 “偷” 过来(other.ptr_ 被置空);
  • 将偷来的指针赋值给当前对象的 ptr_

当你写 smart_ptr<shape> ptr2(ptr1); 时:你可能以为是 “拷贝 ptr1ptr2,两者都指向资源”;但实际效果是:ptr2 接管了资源,ptr1 变成了 nullptr!这完全不符合传统 “拷贝” 的语义,是 C++11 之前没有移动语义时的 “无奈之举”。

【核心】拷贝赋值运算符:用 “拷贝并交换” 实现转移

smart_ptr& operator=(smart_ptr& rhs) { // 参数也是 non-const 左值引用
    smart_ptr(rhs).swap(*this); // 先构造临时对象(转移 rhs 的资源),再交换
    return *this;
}

这是 “拷贝并交换” 惯用法的变体,但结合了上面的 “转移型拷贝构造”:

  • 构造临时对象 smart_ptr(rhs):调用上面的 “转移型拷贝构造”,把 rhs 的资源 “偷” 给临时对象;此时 rhs.ptr_ 被置空,临时对象持有原来的资源。

  • 调用 swap(*this):把临时对象的资源和当前对象 *this 的资源交换;此时 *this 拿到了原来 rhs 的资源,临时对象持有 *this 原来的旧资源。

  • 临时对象析构:函数结束,临时对象离开作用域,析构函数释放它持有的旧资源(即 *this 原来的资源)。

  • 返回 *this:当前对象已经成功接管了 rhs 的资源,rhs 保持空状态。

在 C++11 引入右值引用(&&)和移动语义之前,人们无法明确区分 “拷贝” 和 “转移”。为了避免浅拷贝导致的重复释放,同时实现资源的高效转移,就设计了这种 “用非常量左值引用的拷贝构造来模拟转移” 的手法。你可能听说过的 std::auto_ptr(C++98 引入,C++17 已移除),就是这种设计的典型代表 —— 这段代码本质上就是一个简化版的 auto_ptr

移动指针

template <typename T>
class smart_ptr {
  …
  smart_ptr(smart_ptr&& other)
  {
    ptr_ = other.release();
  }
  smart_ptr& operator=(smart_ptr rhs)
  {
    rhs.swap(*this);
    return *this;
  }
  …
};

这段代码是现代 C++(C++11+)智能指针的标准、简洁且安全的实现思路,完美结合了移动语义拷贝并交换(Copy and Swap)惯用法,可以看作是 std::unique_ptr 核心逻辑的简化版。它解决了之前旧版本(如 std::auto_ptr)的 “语义混淆” 问题,同时保持了代码的简洁性、安全性和高效性。

【核心 1】移动构造函数:明确转移所有权

smart_ptr(smart_ptr&& other) { // 参数是右值引用 &&
    ptr_ = other.release();
}

参数 smart_ptr&& other:右值引用 && 明确表示这个构造函数只接受临时对象、即将销毁的对象(如 std::move 转换后的左值) 。语义上清晰告诉用户:“我们要转移 other 的资源,而不是拷贝”。

ptr_ = other.release():调用 other.release() 让源对象 other 放弃资源所有权(other.ptr_ 被置空),并返回原始指针。将返回的指针赋值给当前对象的 ptr_,完成资源转移。比直接 ptr_ = other.ptr_; other.ptr_ = nullptr; 更简洁,且复用了 release() 的安全逻辑。

【核心 2】赋值运算符:一个函数同时支持拷贝和移动

smart_ptr& operator=(smart_ptr rhs) { // 参数是按值传递!
    rhs.swap(*this);
    return *this;
}

这是 “拷贝并交换” 惯用法的现代升级版 ——按值传递的参数让它能自动根据实参类型选择 “拷贝构造” 或 “移动构造”,从而用一个函数同时实现 “拷贝赋值” 和 “移动赋值”。

参数 smart_ptr rhs(按值传递) :这是最巧妙的地方,编译器会根据实参的类型自动选择构造方式。

如果实参是左值(比如 ptr1 = ptr2ptr2 是有名字的对象):用 ptr2 调用拷贝构造函数生成临时对象 rhs(深拷贝资源,前提是你实现了拷贝构造,或者像 unique_ptr 一样禁止拷贝)。

如果实参是右值(比如 ptr1 = create_circle()ptr1 = std::move(ptr2)):用实参调用移动构造函数生成临时对象 rhs(零成本转移资源)。

子类指针向基类指针的转换

一个 circle* 是可以隐式转换成 shape* 的,但上面的 smart_ptr<circle> 却无法自动转换成 smart_ptr<shape>。这个行为显然还是不够“自然”。不过,只需要额外加一点模板代码,就能实现这一行为。在目前给出的实现里,只需要增加一个构造函数即可——这也算是我们让赋值函数利用构造函数的好处了。

  template <typename U>
  smart_ptr(smart_ptr<U>&& other)
  {
    ptr_ = other.release();
  }

这样,我们自然而然利用了指针的转换特性:现在 smart_ptr<circle> 可以移动给 smart_ptr<shape>,但不能移动给 smart_ptr<triangle>。不正确的转换会在代码编译时直接报错。

引用计数

unique_ptr 算是一种较为安全的智能指针了。但是,一个对象只能被单个 unique_ptr 所拥有,这显然不能满足所有使用场合的需求。一种常见的情况是,多个智能指针同时拥有一个对象;当它们全部都失效时,这个对象也同时会被删除。这也就是 shared_ptr 了。

unique_ptrshared_ptr 的主要区别如下图所示:

image.png

多个不同的 shared_ptr 不仅可以共享一个对象,在共享同一对象时也需要同时共享同一个计数。当最后一个指向对象(和共享计数)的 shared_ptr 析构时,它需要删除对象和共享计数。

指针类型转换

C++ 里的不同的类型强制转换:

  • static_cast
  • reinterpret_cast
  • const_cast
  • dynamic_cast

它们是 C++ 为了替代危险的 C 风格强转 (Type)value 设计的专用转换工具,各司其职、安全可控、编译器能做严格检查。

1. const_cast:只修改 “只读权限”,不改类型

唯一用途:添加 / 移除 const / volatile 限定符不能改变变量的类型,不能做指针类型转换。

适用场景:

  • 函数需要非 const 参数,但你只有 const 对象
  • 底层接口要求非 const 指针

2. static_cast:日常开发 90% 用它(最安全、最常用)

编译期静态转换,做合法、有意义的类型转换,编译器会严格检查。

适用场景:

  • 基本数据类型转换
  • 继承体系中【向上转型】(安全)
  • void* 转具体类型指针
  • 显式调用构造函数的类型转换

3. reinterpret_cast:暴力二进制重解释(极度危险)

直接把内存二进制重新解释,相当于告诉编译器:“别管类型对不对,直接把这块内存当成另一种类型看!”

适用场景

  • 底层开发:驱动、硬件操作、网络协议、指针转整数、整数转指针
  • 正常业务代码永远不要用!

4. dynamic_cast:多态继承的【安全向下转型】

唯一在运行时检查类型的转换,专门用于:基类指针 / 引用 → 派生类指针 / 引用(向下转型)