22-1 智能指针与移动语义介绍

考虑一个我们动态分配值的函数:

void someFunction()
{
    Resource* ptr = new Resource(); // Resource is a struct or class

    // do stuff with ptr here

    delete ptr;
}

尽管上述代码看似相当简单,但很容易忘记释放ptr的内存。即使你在函数末尾记得删除ptr,如果函数提前退出,ptr仍可能因各种原因未被释放。这可能通过提前返回实现:

#include <iostream>

void someFunction()
{
    Resource* ptr = new Resource();

    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;

    if (x == 0)
        return; // the function returns early, and ptr won’t be deleted!

    // do stuff with ptr here

    delete ptr;
}

或通过抛出异常:

#include <iostream>

void someFunction()
{
    Resource* ptr = new Resource();

    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;

    if (x == 0)
        throw 0; // the function returns early, and ptr won’t be deleted!

    // do stuff with ptr here

    delete ptr;
}

在上面的两个程序中,过早的return或throw语句被执行,导致函数终止时变量ptr未被销毁。因此,为变量ptr分配的内存现在泄漏了(并且每次调用该函数并过早返回时都会再次泄漏)。

从根本上说,这类问题发生的原因在于指针变量本身缺乏自我清理的机制。


智能指针类来救场?

类最棒的特性之一在于它们包含析构函数,当类对象超出作用域时会自动执行。因此若在构造函数中分配(或获取)内存,即可在析构函数中释放内存,并确保当类对象被销毁时内存必定被释放(无论其是超出作用域、被显式删除等情况)。这正是第19.3节 析构函数中探讨的RAII编程范式核心原理。

那么能否利用类来管理和清理指针?当然可以!

设想一个专职持有并“拥有”传入指针的类,当类对象作用域结束时自动释放该指针。只要该类对象仅作为局部变量创建,就能确保类在正确退出作用域时(无论函数何时或如何终止),其拥有的指针都会被销毁。

以下是该思路的初稿:

#include <iostream>

template <typename T>
class Auto_ptr1
{
	T* m_ptr {};
public:
	// Pass in a pointer to "own" via the constructor
	Auto_ptr1(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}

	// The destructor will make sure it gets deallocated
	~Auto_ptr1()
	{
		delete m_ptr;
	}

	// Overload dereference and operator-> so we can use Auto_ptr1 like m_ptr.
	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
};

// A sample class to prove the above works
class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
	Auto_ptr1<Resource> res(new Resource()); // Note the allocation of memory here

        // ... but no explicit delete needed

	// Also note that we use <Resource>, not <Resource*>
        // This is because we've defined m_ptr to have type T* (not T)

	return 0;
} // res goes out of scope here, and destroys the allocated Resource for us

该程序输出:

image

考虑这个程序和类的运作方式。首先,我们动态创建一个资源对象,并将其作为参数传递给模板化的Auto_ptr1类。从此时起,我们的Auto_ptr1变量res就拥有该资源对象(Auto_ptr1与m_ptr存在组合关系)。由于 res 被声明为局部变量且具有块作用域,当代码块结束时它将脱离作用域并被销毁(无需担心忘记释放内存)。由于它是类对象,销毁时将调用 Auto_ptr1 的析构函数。该析构函数会确保其持有的 Resource 指针被删除!

只要 Auto_ptr1 被定义为局部变量(具有自动存续期,故类名含“Auto”),无论函数如何终止(即使提前终止),该 Resource 对象都保证在其声明的代码块结束时被销毁。

此类结构称为智能指针Smart pointer。智能指针是一种组合类,旨在管理动态分配的内存,并确保当智能指针对象作用域结束时内存被释放。(相关地,内置指针有时被称为“笨指针dumb pointers”,因为它们无法自行清理)。

现在让我们回到上文的someFunction()示例,展示智能指针类如何解决我们的难题:

#include <iostream>

template <typename T>
class Auto_ptr1
{
	T* m_ptr {};
public:
	// Pass in a pointer to "own" via the constructor
	Auto_ptr1(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}

	// The destructor will make sure it gets deallocated
	~Auto_ptr1()
	{
		delete m_ptr;
	}

	// Overload dereference and operator-> so we can use Auto_ptr1 like m_ptr.
	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
};

// A sample class to prove the above works
class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
    void sayHi() { std::cout << "Hi!\n"; }
};

void someFunction()
{
    Auto_ptr1<Resource> ptr(new Resource()); // ptr now owns the Resource

    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;

    if (x == 0)
        return; // the function returns early

    // do stuff with ptr here
    ptr->sayHi();
}

int main()
{
    someFunction();

    return 0;
}

如果用户输入一个非零整数,上述程序将输出:

image

如果用户输入零,上述程序将提前终止,并打印:

image

请注意,即使用户输入零导致函数提前终止,资源仍会被正确释放。

由于ptr变量是局部变量,函数终止时(无论如何终止)ptr都会被销毁。而由于Auto_ptr1的析构函数会清理资源,因此我们可确保资源得到妥善清理。


一个关键缺陷

Auto_ptr1 类在某些自动生成的代码背后隐藏着一个关键缺陷。继续阅读前,请尝试找出问题所在。我们等着看……

(提示:思考若未提供自定义实现,类中哪些部分会被自动生成)

(危险边缘音乐)

好,时间到。

与其直接告知,不如展示实例。请看以下程序:

#include <iostream>

// Same as above
template <typename T>
class Auto_ptr1
{
	T* m_ptr {};
public:
	Auto_ptr1(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}

	~Auto_ptr1()
	{
		delete m_ptr;
	}

	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
};

class Resource
{
public:
	Resource() { std::cout << "Resource acquired\n"; }
	~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
	Auto_ptr1<Resource> res1(new Resource());
	Auto_ptr1<Resource> res2(res1); // Alternatively, don't initialize res2 and then assign res2 = res1;

	return 0;
} // res1 and res2 go out of scope here

该程序输出:

image

此时程序极有可能(但并非必然)崩溃。看出问题所在了吗?由于我们未提供复制构造函数或赋值运算符,C++会自动生成默认实现。而它提供的函数执行的是浅拷贝操作。因此当我们用 res1 初始化 res2 时,两个 Auto_ptr1 变量都指向同一个 Resource。当 res2 离开作用域时,它会删除该资源,导致 res1 持有悬空指针。当 res1 试图删除其(已被删除的)Resource 时,就会引发未定义行为(很可能导致崩溃)!

使用如下函数时也会遇到类似问题:

void passByValue(Auto_ptr1<Resource> res)
{
}

int main()
{
	Auto_ptr1<Resource> res1(new Resource());
	passByValue(res1);

	return 0;
}

在此程序中,res1将按值复制到参数res中,因此res1.m_ptr和res.m_ptr将指向相同地址。

当函数结束时res被销毁,res1.m_ptr便成为悬空指针。若后续删除res1.m_ptr,将导致未定义行为。

显然这种做法不可取。如何解决这个问题?

一种方案是显式定义并删除复制构造函数和赋值运算符,从而从源头阻止任何复制操作。这将避免值传递的情况(这是好事,我们本就不该通过值传递这类对象)。

但问题来了:如何将Auto_ptr1从函数返回给调用方?

??? generateResource()
{
     Resource* r{ new Resource() };
     return Auto_ptr1(r);
}

我们不能通过引用返回 Auto_ptr1,因为局部变量 Auto_ptr1 在函数结束时会被销毁,调用方将获得一个悬空引用。虽然可以将指针r作为Resource*返回,但这可能导致后续忘记删除r——这恰恰违背了使用智能指针的初衷。因此该方案不可行。按值返回Auto_ptr1看似合理,却会产生浅拷贝、重复指针及崩溃问题。

另一种方案是重载复制构造函数和赋值运算符以实现深拷贝。这样至少能保证避免指向同一对象的重复指针。但复制操作代价高昂(且可能不可取甚至不可行),我们不愿为函数返回Auto_ptr1而进行多余的对象复制。况且普通指针的赋值或初始化并不会复制被指向的对象,为何要期待智能指针有不同表现?

我们该如何处理?


移动语义

如果我们不让复制构造函数和赋值运算符复制指针(即“复制语义copy semantics”),而是将指针的所有权从源对象转移/移动到目标对象呢?这正是移动语义的核心思想。移动语义Move semantics意味着类将转移对象的所有权,而非创建副本。

让我们更新 Auto_ptr1 类来展示如何实现:

#include <iostream>

template <typename T>
class Auto_ptr2
{
	T* m_ptr {};
public:
	Auto_ptr2(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}

	~Auto_ptr2()
	{
		delete m_ptr;
	}

	// A copy constructor that implements move semantics
	Auto_ptr2(Auto_ptr2& a) // note: not const
	{
		// We don't need to delete m_ptr here.  This constructor is only called when we're creating a new object, and m_ptr can't be set prior to this.
		m_ptr = a.m_ptr; // transfer our dumb pointer from the source to our local object
		a.m_ptr = nullptr; // make sure the source no longer owns the pointer
	}

	// An assignment operator that implements move semantics
	Auto_ptr2& operator=(Auto_ptr2& a) // note: not const
	{
		if (&a == this)
			return *this;

		delete m_ptr; // make sure we deallocate any pointer the destination is already holding first
		m_ptr = a.m_ptr; // then transfer our dumb pointer from the source to the local object
		a.m_ptr = nullptr; // make sure the source no longer owns the pointer
		return *this;
	}

	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
	bool isNull() const { return m_ptr == nullptr; }
};

class Resource
{
public:
	Resource() { std::cout << "Resource acquired\n"; }
	~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
	Auto_ptr2<Resource> res1(new Resource());
	Auto_ptr2<Resource> res2; // Start as nullptr

	std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
	std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

	res2 = res1; // res2 assumes ownership, res1 is set to null

	std::cout << "Ownership transferred\n";

	std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
	std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

	return 0;
}

该程序输出:

image

请注意,我们重载的赋值运算符将 m_ptr 的所有权从 res1 转移到了 res2!因此,我们最终不会得到指针的重复副本,所有内容都会被整洁地清理。

提醒:
删除 nullptr 是安全的,因为它不会执行任何操作。


std::auto_ptr及其设计缺陷

现在是讨论std::auto_ptr的合适时机。这个在C++98中引入、C++17中移除的类型,是C++首次尝试实现标准化智能指针。std::auto_ptr选择采用移动语义实现,这与Auto_ptr2类的设计思路一致。

然而,std::auto_ptr(以及我们的Auto_ptr2类)存在诸多问题,导致其使用存在风险。

首先,由于std::auto_ptr通过复制构造函数和赋值运算符实现移动语义,若将std::auto_ptr按值传递给函数,资源会被移动到函数参数中(并在函数结束时随参数作用域结束而被销毁)。当调用方试图访问该 auto_ptr 参数时(未意识到其已被转移并销毁),将突然解引用为空指针。崩溃!

其次,std::auto_ptr始终使用非数组删除机制销毁内容。这意味着它无法正确处理动态分配的数组——因其采用错误的释放方式。更糟的是,它不会阻止你传递动态数组,导致内存管理失误并引发内存泄漏。

最后,auto_ptr 与标准库中众多类(包括多数容器和算法类)存在兼容性问题。这是因为这些标准库类默认复制项时会执行真正的复制操作,而非移动复制。

鉴于上述缺陷,std::auto_ptr 已在 C++11 中被弃用,并在 C++17 中正式移除。


保持向前

std::auto_ptr 设计的核心问题在于,在 C++11 之前,C++ 语言根本没有机制来区分“复制语义”和“移动语义”。通过覆盖复制语义来实现移动语义会导致奇怪的边界情况和意外错误。例如,当你编写 res1 = res2 时,根本无法确定 res2 是否会被修改!

正因如此,C++11正式引入“移动”概念,并通过“移动语义”机制明确区分复制与移动操作。在阐明移动语义的价值后,本章后续内容将深入探讨该主题,并运用移动语义重构Auto_ptr2类。

在C++11中,std::auto_ptr已被一系列支持移动操作的智能指针取代:std::unique_ptr、std::weak_ptr和std::shared_ptr。我们将重点探讨其中最常用的两种:unique_ptr(直接替代auto_ptr)和shared_ptr。

posted @ 2026-01-27 06:12  游翔  阅读(0)  评论(0)    收藏  举报