C++基础教程面向对象(学习笔记(82))

智能指针简介和移动语义

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

void someFunction()
{
    Resource *ptr = new Resource; // Resource是一个结构或类
 
    // 在这里做ptr的东西
 
    delete ptr;
}

虽然上面的代码看起来相当简单,但忘记释放ptr却相当容易。即使您确实记得在函数末尾删除ptr,如果函数提前退出,也有多种方法可能无法删除ptr。这可以通过提前退货来实现:

#include 
 
void someFunction()
{
    Resource *ptr = new Resource;
 
    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;
 
    if (x == 0)
        return; //函数提前返回,ptr不会被删除!
 
    // 在这里做ptr的东西
 
    delete ptr;
}

或通过抛出的异常:

#include 
 
void someFunction()
{
    Resource *ptr = new Resource;
 
    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;
 
    if (x == 0)
        throw 0; // 函数提前返回,ptr不会被删除!
 
    //在这里做ptr的东西
 
    delete ptr;
}

在上面的两个程序中,早期的return或throw语句执行,导致函数终止而不删除变量ptr。因此,为变量ptr分配的内存现在被泄露(每次调用此函数时它将再次泄漏并提前返回)。

从本质上讲,出现这类问题是因为指针变量没有固有的机制可以自行清理。

智能指针类的帮助?

关于类的最好的事情之一是它们包含析构函数,当类的对象超出范围时,它们会自动执行。所以,如果您分配(或回收)存储你的构造函数,你可以释放它在你的析构函数,并保证当类对象被销毁的内存将被释放(不管它是否超出范围,被显式删除,等等…)。这是我们在前面析构函数中讨论过的RAII编程范例的核心。

那么我们可以使用一个类来帮助我们管理和清理指针吗?答案是我们可以!

考虑一个类,其唯一的工作是保持并“拥有”传递给它的指针,然后在类对象超出范围时释放该指针。只要该类的对象仅作为局部变量创建,我们就可以保证该类适当地超出范围(无论我们的函数何时或如何终止)并且拥有的指针将被销毁。

以下是该想法的初稿:

#include 
 
template
class Auto_ptr1
{
	T* m_ptr;
public:
	// 通过构造函数传入指向“own”的指针
	Auto_ptr1(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}
	
	// 析构函数将确保它被解除分配
	~Auto_ptr1()
	{
		delete m_ptr;
	}
 
	// 重载dereference和operator->所以我们可以像使用m_ptr一样使用Auto_ptr1。
	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 res(new Resource); // 注意这里的内存分配
 
        // ... 而没有显式删除的必要
 
	// 另请注意,角度大括号中的资源不需要*符号,因为它是由模板提供的
 
	return 0;
} // res超出范围,并为我们销毁分配的资源

该程序打印:
Resource acquired
Resource destroyed
考虑一下这个程序和类如何工作。首先,我们动态创建一个Resource,并将其作为参数传递给我们模板化的Auto_ptr1类。从那时起,我们的Auto_ptr1变量res拥有该Resource对象(Auto_ptr1与m_ptr具有组合关系)。因为res被声明为局部变量并且具有块范围,所以当块结束时它将超出范围并被销毁(不用担心忘记取消分配它)。因为它是一个类,当它被销毁时,将调用Auto_ptr1析构函数。该析构函数将确保它所持有的资源指针被删除!

只要Auto_ptr1被定义为局部变量(具有自动持续时间,因此是类名的“Auto”部分),资源将保证在声明它的块的末尾被销毁,无论如何函数终止(即使它提前终止)。

这样的类称为智能指针。一个智能指针是当智能指针对象超出范围,旨在管理动态分配的内存,并确保内存被删除。(相关地,内置指针有时被称为“哑指针”,因为它们自身无法清理)。

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

#include 
 
template
class Auto_ptr1
{
	T* m_ptr;
public:
	//通过构造函数传入指向“own”的指针
	Auto_ptr1(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}
	
	// 析构函数将确保它被解除分配
	~Auto_ptr1()
	{
		delete m_ptr;
	}
 
	// 重载dereference和operator->所以我们可以像使用m_ptr一样使用Auto_ptr1。
	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"; }
    void sayHi() { std::cout << "Hi\n"; }
};
 
void someFunction()
{
    Auto_ptr1 ptr(new Resource); // ptr现在拥有Resource
 
    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;
 
    if (x == 0)
        return; //该函数提前返回
 
    // 在这里做ptr的东西
    ptr->sayHi();
}
 
int main()
{
    someFunction();
 
    return 0;
}

如果用户输入非零整数,则上述程序将打印:

Resource acquired
Hi!
Resource destroyed
如果用户输入零,上述程序将提前终止,打印:

Resource acquired
Resource destroyed

请注意,即使在用户输入零并且函数提前终止的情况下,仍然可以正确地释放资源。

因为ptr变量是局部变量,所以当函数终止时,ptr将被销毁(无论它如何终止)。并且因为Auto_ptr1析构函数将清理资源,所以我们确信资源将被正确清理。

一个关键的bug

Auto_ptr1类在一些自动生成的代码背后隐藏着一个关键缺陷。在进一步阅读之前,看看你是否能够确定它是什么。我们等一下…

(提示:如果您不提供类的哪些部分会自动生成)

(前方危险…)

好的,时间到了。

我们会告诉你,而不是不告诉你。考虑以下程序:

#include 
 
// Same as above
template
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 res1(new Resource);
	Auto_ptr1 res2(res1); // Alternatively, don't initialize res2 and then assign res2 = res1;
 
	return 0;
}

该程序打印:

Resource acquired
Resource destroyed
Resource destroyed
很可能(但不一定)你的程序将在此时崩溃。现在看到问题?因为我们没有提供复制构造函数或赋值运算符,所以C ++为我们提供了一个。它提供的功能是浅拷贝。因此,当我们用res1初始化res2时,两个Auto_ptr1变量都指向同一个Resource。当res2超出范围时,它会删除资源,使res1保留悬空指针。当res1去删除它(已经删除)的资源时,崩溃!

你会遇到类似这样的函数的类似问题:

void passByValue(Auto_ptr1 res)
{
}
 
int main()
{
	Auto_ptr1 res1(new Resource);
	passByValue(res1)
 
	return 0;
}

在此程序中,res1将按值复制到passByValue的参数res中,从而导致资源指针重复。崩溃!

很明显,这并不好。我们怎么解决这个问题呢?

好吧,我们可以做的一件事是明确定义和删除复制构造函数和赋值运算符,从而防止首先制作任何副本。这会阻止传递值的情况(这很好,我们可能不应该通过值传递这些值)。

但是那么我们如何将一个函数的Auto_ptr1返回给调用者呢?

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

我们不能通过引用返回我们的Auto_ptr1,因为本地Auto_ptr1将在函数结束时被销毁,并且调用者将留下悬空引用。按地址返回有同样的问题。我们可以通过地址返回指针r,但之后我们可能会忘记删除r,这是首先使用智能指针的重点。所以那就是了。按值返回Auto_ptr1是唯一有意义的选项 - 但最后我们会得到浅拷贝,重复指针和崩溃。

另一种选择是覆盖复制构造函数和赋值运算符以进行深层复制。这样,我们至少可以保证避免重复指向同一个对象的指针。但是复制可能很昂贵(并且可能不太可取或甚至不可能),并且我们不想仅仅为了从函数返回Auto_ptr1而制作不必要的对象副本。另外,分配或初始化哑指针不会复制指向的对象,那么为什么我们希望智能指针的行为方式不同?

我们做什么?

移动语义

如果我们不是让我们的复制构造函数和赋值运算符复制指针(“复制语义”),而是将指针的所有权从源传输/移动到目标对象,该怎么办?这是移动语义背后的核心思想。 移动语义意味着类将转移对象的所有权而不是复制。

让我们更新我们的Auto_ptr1类来展示如何做到这一点:

#include 
 
template
class Auto_ptr2
{
	T* m_ptr;
public:
	Auto_ptr2(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}
	
	~Auto_ptr2()
	{
		delete m_ptr;
	}
 
	// 实现移动语义的复制生成器
	Auto_ptr2(Auto_ptr2& a) // note: not const
	{
		m_ptr = a.m_ptr; // 将我们的哑指针从源传输到我们的本地对象
		a.m_ptr = nullptr; // 确保源不再拥有指针
	}
	
	// 实现移动语义的赋值运算符
	Auto_ptr2& operator=(Auto_ptr2& a) // note: not const
	{
		if (&a == this)
			return *this;
 
		delete m_ptr; // 确保我们释放目标已经占据的任何指针
		m_ptr = a.m_ptr; // 然后将我们的哑指针从源传输到本地对象
		a.m_ptr = nullptr; //确保源不再拥有指针
		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 res1(new Resource);
	Auto_ptr2 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假设所有权,res1设置为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;
}

该程序打印:

Resource acquired
res1 is not null
res2 is null
Ownership transferred
res1 is null
res2 is not null
Resource destroyed

请注意,我们的重载operator=将m_ptr的所有权从res1赋予res2!因此,我们最终没有指针的重复副本,并且所有内容都得到了整齐清理。

std :: auto_ptr,以及为什么要避免它

现在是讨论std :: auto_ptr的合适时机。在C ++ 98中引入的std :: auto_ptr是C ++首次尝试使用标准化的智能指针。std :: auto_ptr选择实现移动语义,就像Auto_ptr2类一样。

但是,std :: auto_ptr(和我们的Auto_ptr2类)有许多问题使得使用它很危险。

首先,因为std :: auto_ptr通过复制构造函数和赋值运算符实现移动语义,所以通过值将std :: auto_ptr传递给函数将导致您的资源被移动到函数参数(并在函数末尾被销毁)当函数参数超出范围时)。然后当你从调用者访问你的auto_ptr参数(没有意识到它被传输和删除)时,你突然取消引用空指针。崩溃!

其次,std :: auto_ptr总是使用非数组删其内容。这意味着auto_ptr将无法与动态分配的数组一起正常工作,因为它使用了错误的解除分配方式。更糟糕的是,它不会阻止你将动态数组传递给它,然后它会管理不善,从而导致内存泄漏。

最后,auto_ptr与标准库中的许多其他类不兼容,包括大多数容器和算法。发生这种情况是因为那些标准库类假定当它们复制一个项目时,它实际上是一个副本,而不是执行一个移动。

由于上述缺点,std :: auto_ptr在C ++ 11中已被弃用,因此不应使用它。实际上,std :: auto_ptr将作为C ++ 17的一部分从标准库中完全删除!

规则:不推荐使用std :: auto_ptr,不应使用它。(改为使用std :: unique_ptr或std :: shared_ptr)。。

向前进

设计std :: auto_ptr的核心问题是,在C ++ 11之前,C ++语言根本没有机制来区分“复制语义”和“移动语义”。重写复制语义以实现移动语义会导致奇怪的边缘情况和无意的错误。例如,您可以编写res1 = res2并且不知道res2是否会被更改!

因此,在C ++ 11中,“移动”的概念被正式定义,并且“移动语义”被添加到语言中以正确区分复制和移动。现在我们已经为移动语义的有用性设置了阶段,我们将在本章的其余部分探讨移动语义的主题。我们还将使用移动语义修复Auto_ptr2类。

在C ++ 11中,std :: auto_ptr已被一堆其他类型的“移动感知”智能指针所取代:std :: scoped_ptr,std :: unique_ptr,std :: weak_ptr和std :: shared_ptr。我们还将探索其中最受欢迎的两个:unique_ptr(它是auto_ptr的直接替代品)和shared_ptr。

你可能感兴趣的:(2018.12.3)