关于C++指针、智能指针和引用:所有权、析构和多态
2025年7月24日
编程
最近修改 [libgwmodel](https://github.com/GWmodel-Lab/libgwmodel) 项目的内存管理方式,想从裸指针迁移到智能指针,以明确对象所有权,避免段错误。然而,这个坑却比想象中的大。主要就是涉及四个概念:
- 原始指针
- 智能指针(包括 `unique_ptr` 和 `smart_ptr`)
- 引用
以及三个过程:
- 所有权管理
- 可析构性
- 基类到派生类转换
这篇博客就详解一下我所理解的部分。
# 指针和引用
首先还是简单总结一下指针和引用。
## 原始指针(Raw Pointer)
又称裸指针。对于一个类型 `T`,`T*` 就是指向这个类型的指针。指针是一类类型,当然也可以有指向这个指针的指针类型,也就是 `T**`,简称为「二级指针」。本文只讨论「一级指针」。
指针也可以使用常量修饰符,并且可以在两个位置上出现。
- `const T*` 是一个指向常量的可变指针,我们无法通过该指针对其所指对象进行任何非常量方法。然而该指针本身记录的地址是可以修改的,我们将这一过程成为“重定向”。
- `T* const` 是一个指向变量的不变指针,可以对该对象执行修改,但该指针无法重定向。
- `const T* const` 是一个指向常量的不便指针,我们既无法修改对象,又无法重定向指针。
指针虽然也可以引用(类型 `T*&`)但是似乎非常不常见,暂且不讨论了。
## 智能指针
C++ 引入了三个智能指针(但主要讨论两个),常用的是:`unique_ptr` 和 `smart_ptr`。为了讨论方便,前者称为「专属指针」,后者称为「共享指针」。
这两种指针的区别就在于「专属」和「共享」。专属指针无法共享,只能「转移」不能「复制」,共享指针可以「复制」。也就是说,`unique_ptr` 没有复制构造函数,只能使用移动构造函数。这个限制直接是编译器保证的,所以几乎没有性能开销。
共享指针各方面其实都更像原始指针,只是多了一个引用计数,当析构该指针时,如果没有其他引用,则真正销毁对象。智能指针也可以直接使用 `*_pointer_cast` 一系列函数直接转换类型。这个功能是专属指针不具备的。
## 引用
引用类型虽然看起来和取地址运算(`&`)一样,理解起来也一样,但还是有一些差别的:取地址得到的是指针,而引用就是引用。
引用类型的变量不是一种对象,不能构造,没有独立的存储空间。因此,无法构造形如 `vector<T&>` 的容器,必须借助 `reference_wrapper` 构造容器 `vector<reference_wrapper<T>>` 才可以。
引用还分为两种:左值引用和右值引用。如果使用模板,还会遇到一个万能引用。
左值引用形如 `T&`,绑定到**左值**。对左值引用的操作相当于操作原对象。右值引用形如 `T&&` 绑定到**右值**,通常用于移动语义和完美转发。
> **左值**:可以取地址、有名字、表达式结束后仍然存在的对象。
>
> **右值**:无法取地址的对象,如临时对象、字面量、即将被销毁的对象。
在模板中,如果 `T` 是模板参数,那么 `T&&` 是一个万能引用。例如
```cpp
template<typename T>
void func(T&& arg) { // 这里的T&&是万能引用
// 如果传入左值,T被推导为T&,arg类型为T&
// 如果传入右值,T被推导为T,arg类型为T&&
}
int x = 42;
func(x); // T推导为 int&,arg类型是 int&(左值引用)
func(5); // T推导为 int, arg类型是 int&&(右值引用)
```
但这就是另外一块的东西了,与本篇的主要内容无关。
# 所有权管理
可以看到,不论是原始指针、智能指针还是引用,都可以去操作一个原对象。那么,智能指针中所谓“所有权”,具体只什么呢?
其实指的是“能否销毁对象”。
用生活中的例子就是,有一样东西专属于小美,那么小美可以任意使用、丢弃、破坏这样东西。假设有一天,小帅找小美借来了这样东西,小帅可以使用,但不能丢弃和破坏。这样东西的所有权就专属于小美,小帅没有所有权。但如果小美把这样东西转让给了小帅,那么小帅就可以随意使用、丢弃、破坏这样东西,此时小美反而不能去丢弃和破坏了。
但如果小美和小帅共享一样东西,如果哪天小美不用了,小帅还在用,那小美就不能丢弃和破坏这样东西,除非小帅也不用了,他们任何一个人中最后同意丢弃的人就可以丢弃了。
这样就很好理解,为什么专属指针只能移动、不能复制。因为专属指针 a 保有对指涉对象 A 的所有权,并且在生命周期结束后要销毁 A,所以需要保证只有这个指针才能去销毁 A,其他指针都不能销毁 A,除非把 A 转让给另一个专属指针。
那么专属指针既然不能复制,就不能通过复制构造函数作为返回值传递。除非,直接在返回的位置构造专属指针。例如
```cpp
unique_ptr<int> create() {
// ...
return make_unique<int>(42);
}
```
这种通常称为“工厂函数”。现代编译器优化后,这个返回值就不会在传递过程中执行一次复制构造函数了。
如果要将专属指针通过类似于 get 函数的方法对外提供,一般也只会提供对这个专属指针的引用。由于引用过程不涉及复制构造,也不会移动构造,所以没有问题。
而原始指针和引用其实是不涉及所有权管理的,也就是在声明周期结束时不会对绑定对象做任何处理。然而,不自动不代表不能手动。这就带来了“析构”相关的问题。
# 可析构性
这里的可析构性指的是“能否调用 `delete` 析构指向或引用的对象”。
> 注意这里不是指的“能否调用析构函数”。因为 `delete` 除了调用析构函数之外,还会释放内存。
智能指针的析构,前面基本已经说完了。
- 对于专属指针而言,该指针析构时,如果指涉对象没有被移走,那么就会析构对象;否则不析构对象(因为无法析构了)。
- 对于共享指针而言,该指针析构时,如果没有其他指针指向该对象,那么就会析构对象;否则不析构对象。
而原始指针和引用在声明周期结束时都不会自动析构对象。需要分析的是能否手动析构的问题。
通常情况,这种析构只针对“堆”对象。因为栈对象是编译器管理的,所以通过取地址并调用 `delete` 去析构是不合法的。比如下面这段代码。
```cpp
Person* personHeap = new Person { 18, 1.75 };
const Person* personPtr = personHeap;
INFO("Going to delete personPtr, but it points to a heap object.");
delete personPtr; // 报错 SIGABRT - Abort (abnormal termination) signal
INFO("Check if deletion was successful.");
CAPTURE(personHeap->age);
```
所以我们这里只考虑栈对象。
## 原始指针
对于原始指针而言,无论有何种常量修饰符,都可以调用 `delete` 析构指针所指向的堆对象。比如下面的代码
```cpp
Person* personHeap = new Person { 18, 1.75 };
const Person* personPtr = personHeap;
// personPtr->age = 19; // 编译错误
INFO("Going to delete personPtr, but it points to a heap object.");
delete personPtr;
INFO("Check if deletion was successful.");
CHECK(personHeap->age == 18); // 0 == 18 Failed
```
上述代码看起来非常反直觉:虽然 `personPtr` 指针是前面有 `const` 修饰符,也就是说无法通过指针对数据进行任何修改,但是不妨碍可以通过该指针对原对象进行析构!这就非常离谱了。所以如果一个对象成功地被一个原始指针指涉了,那么这个对象就非常危险了,是有可能被析构的。
如果这个指针是从 `unique_ptr` 对象中通过 `get()` 方法得到的,那么依然可以被析构掉。
```cpp
unique_ptr<Person> personUptr = make_unique<Person>(18, 1.75);
const Person* personPtr = personUptr.get();
INFO("Going to delete personPtr, which is obtained from a unique_ptr.");
delete personPtr;
INFO("Check if deletion was successful.");
CHECK(personUptr->age == 18);
```
最后编译器会报错:Double free of object.
所以,使用原始指针是比较危险的行为。尤其是当某个类的内部资源直接暴露原始指针给外部对象时,就有可能被以外析构了。
可以说,只要得到了原始指针,就得到了所有权。
## 引用
与原始指针相比,引用可以说就没有所有权,只是“借用”这个对象。因为 `delete` 无法释放引用类型所引用的对象。
```cpp
Person* personHeap = new Person { 18, 1.75 };
Person& personRef = *personHeap;
INFO("Going to delete personRef, which is a reference to a heap object.");
delete personRef; // 编译错误 expression must be a pointer to a complete object type
```
所以这是行不通的。
也就是说,如果只是得到了一个对象的引用,那么并没有获得所有权。如果引用没有 `const` 修饰符,可以通过引用去修改对象,但不能析构该对象。
这样其实就实现了一个比较安全的效果。对于一个类而言,给出内部资源的引用,外部也无法对内部资源进行释放操作,相对就安全了许多。
也正是因为引用有这个特性,所以专属指针虽然一般不作为返回值对外提供,但是可以将专属指针的引用对外提供。外部依然可以通过这个专属指针的引用操作对象,只是不能销毁罢了。
# 基类到派生类转换
为什么要把内部资源给外部的?这其实是我最近遇到的一个多态相关的问题:将基类类型转换为派生类类型。
假设上述基类 `Person` 有两个派生类 `Teacher` 和 `Student`,是这样定义的。
```cpp
struct Person {
int age;
double height;
Person(int a = 0, double h = 0.0) : age(a), height(h) {}
virtual void speak() const {
cout << "Hello, I am " << age << " years old." << endl;
}
};
struct Teacher : public Person
{
void speak() const override {
cout << "Hello, I am a teacher and I am " << age << " years old." << endl;
}
void teach() const {
cout << "Teaching a class." << endl;
}
};
struct Student : public Person
{
void speak() const override {
cout << "Hello, I am a student and I am " << age << " years old." << endl;
}
void study() const {
cout << "Studying for exams." << endl;
}
}
```
如果有一个类 `Profile` 里面并不区分是哪个派生类,统一用 `Person` 这个类型进行管理,并明确所有权那么我们可以这样写(复制构造、移动构造什么的省略了,这是另外一系列问题)。
```cpp
class Profile {
public:
Profile() = default;
Profile(unique_ptr<Person>&& person) : mPerson(std::move(person)) {}
private:
unique_ptr<Person> mPerson;
};
```
然而,如果要通过这个类的指针 `person` 去执行派生类的特殊操作,需要将基类类型转换为派生类类型,应该怎么办么?实际上这个问题比想象中的要复杂得多。
如果还想使用专属指针,那么上述方法几乎是行不通的。因为 C++ 中并没有将基类的专属指针类型转换为派生类的专属指针类型的方法。原因也是显而易见的:这样会创造两个指向同一个对象的专属指针,造成所有权冲突。
当然办法也不是没有,就是创建一个 `Borrow<T,D>` 类型,先把所有权转移过来,转换成需要的类型,用完再把所有权转移回去,就实现了多态的目的。然而问题是这带来了额外的性能开销、优化负担、不确定性。
> 这里的“额外性能开销”,指的是除了类型转换以外的额外性能开销。毕竟要用派生类对象的话,不可避免要做类型转换。
如果使用共享指针,将 `Profile` 中的 `person` 的类型改为 `shared_ptr<Person>` ,很多问题就迎刃而解。比如可以使用 `*_pointer_cast` 等系列方法转换指针类型。然而,这会造成不必要的性能开销,毕竟多了一个“引用计数”。所以这其实也不是一个特别好的办法。
如果使用原始指针呢?原始指针几乎没有性能开销,也可以通过 `*_cast` 进行类型转换,甚至强制类型转换都可以,不会遇到多态的问题。但是正如上面所分析的,一旦给出了指针,就无法限制外部程序会不会析构对象了。这种析构可能不是故意的,可能隐含在了某个类型的析构函数中。所以,代价就是非常不安全。
最终的办法,就是使用“引用”。引用对象在生命周期结束的时候,被引用的对象不会被销毁。这样就保证了安全,解引用和类型转换的步骤也是必须的性能开销。
```cpp
template<typename T>
const T& person() const {
return dynamic_cast<const T&>(*mPerson.get());
}
```
而且,其实引用本身就是可以做多态的,而不是只有指针才能做多态。这一点,至少在我学C++的时候,很大程度上被忽略了。
# 后记
最终 [libgwmodel](https://github.com/GWmodel-Lab/libgwmodel) 就按照这样的方式进行改造了。主要涉及 `SpatialWeight` 这个类。原本这个类有两个成员变量,分别是 `Distance*` 和 `Weight*` 类型的两个原始指针。改造后变成了专属指针。带来的问题就是外部类要提供对 `Weight*` 指针所指对象的优化算法。之前是原始指针直接类型转换,虽然没有什么大问题,但总体是比较危险的。现在改成给外部提供一个引用,就安全多了。
这个过程中确实弄清楚了很多之前一直比较模糊的问题。比如常量原始指针能不能析构的问题,专属指针能不能转换为派生类的问题。包括到底什么是所有权,都是在这个过程中搞清楚的。
感谢您的阅读。本网站「地与码之间」对本文保留所有权利。