智能指针和移动语义

intro to smart pointer and move semantics

(翻译改写自https://www.learncpp.com/cpp-tutorial/15-1-intro-to-smart-pointers-move-semantics/)

1. 裸指针导致的内存泄漏问题

考虑下面这个函数,在这个函数中我们动态申请了一片内存。

void someFunction()
{
    Resource *ptr = new Resource; // Resource is a struct or class
    // do stuff with ptr here
    delete ptr;
}

这段代码看起来非常直白,但是存在一个问题:我们常常会忘了释放内存。即使我们始终记得释放内存,但是还存在一些case导致内存没有正确释放。
case1: 函数提前返回

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;
}

case2: 抛出异常

#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;
}

在上面两段代码中,由于函数提前返回或者抛出异常,导致内存泄漏,而且每一次这个函数被调用,都会导致新的内存泄漏。

导致以上问题的根本原因在于裸指针没有内在的内存清理机制。

2. 智能指针类可以解决这类问题吗?

类有一个很好的特性就是类有析构函数,当对象出作用域的时候,析构函数就会自动执行,释放其占有的内存。如果我们在构造函数中申请内存,并在析构函数中delete,那么就可以保证内存能够被正确释放。

那么我们可以用一个类来管理指针吗?答案是肯定的!
假设有一个类,它的唯一任务是持有并“拥有”一个传递给它的指针,然后在类对象超出作用域时释放该指针。只要类的对象仅作为局部变量创建,我们就可以保证类将正确地超出作用域(无论何时或如何终止函数),并且所拥有的指针将被销毁。

#include <iostream>
 
template<class 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 the Resource in angled braces doesn't need a * symbol, since that's supplied by the template
 
	return 0;
} // res goes out of scope here, and destroys the allocated Resource for us

这段代码的执行结果:

Resource acquired
Resource destroyed

看一下这段程序是如何运行的。首先,我们新建了一个Resource对象,并把指针作为参数传递给模版类Auto_ptr1的构造函数,从此时起,res变量就拥有了Resource对象。因为res是一个局部变量,作用域是main函数的一对打括号,当出了大括号,res变量就会被销毁。只要Auto_ptr1对象被定义为一个局部变量,不管函数如何结束,都可以保证Resource类被正确的析构。

像Auto_ptr1这种类称为smart pointer,智能指针是一个组合类,它被设计用来管理动态分配的内存,并确保当智能指针对象超出范围时内存被删除。(对应的,内置指针有时被称为"dumb pointer",因为它们不能自己清理)。

现在我们回到someFunction,看看智能指针如何解决内存泄漏的问题。

#include <iostream>
 
template<class 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;
}

当用户输入0时, 程序会提前退出,打印出:

Resource acquired
Resource destroyed

因为ptr是一个局部变量,函数结束时会自动调用ptr的析构函数,正常释放掉resource占用的内存。

3. Auto_ptr1的一个严重缺陷

Auto_ptr解决了裸指针导致的内存泄漏问题,但是它还存在一个严重的缺陷,来看一段代码。

#include <iostream>
 
// Same as above
template<class 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;
}

这段代码的执行结果是:

Resource acquired
Resource destroyed
Resource destroyed

程序大概率会在此时crash,看到问题所在了吗?因为我们没有提供拷贝构造函数,因此编译器给我们提供了一个默认的拷贝构造函数,这个默认的函数仅做浅拷贝。所以在main函数中,我们用res1来初始化res2之后,res1和res2指向同一个Resource对象。当res2出了作用域时,会释放掉resource对象占用的内存使res1称为一个野指针,当res1出了作用域时,它会尝试再次释放resource对象,导致程序crash。

下面这段代码也存在类似的问题

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

res1会传值给res,导致两个指针指向同一个资源,进而导致程序crash。

所以,如何修复这个问题呢?

有一个办法是我们可以显式定义并且将拷贝构造函数和赋值运算符置为delete。这样从一开始就阻止了任何拷贝,当然也阻止了函数调用时的参数传值。看起来似乎完美解决了问题,但是,如果我们向从一个函数返回Auto_ptr1呢?

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

我们不能返回引用,因为Auto_ptr1是局部变量,出了作用域,就会被销毁掉。返回地址也是一样。看来我们只能通过传值返回了。

另外一个办法是自定义拷贝构造函数和赋值运算符,在这两个函数中进行深拷贝。这种方式至少可以保证不存在多个指针指向同一个资源的问题。但是拷贝是非常耗时的操作(),,不是我们想要的甚至是不可能的),我们也不想仅仅因为需要从函数返回Auto_ptr而做一些毫无必要的拷贝。

似乎所有的路都堵死了, 还有别的办法吗?

4. Move semantics

其实,设计C++的大牛们已经为我们准备好了解决方案。如果我们不做拷贝,只是将指针的所有权从一个对象移动到另外一个对象,那又如何呢?移动而非拷贝,这就是move semantics背后的核心思想。
我们看看Auto_ptr的第二个版本如何实现移动而非拷贝。

#include <iostream>
 
template<class 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
	{
		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;
}

这段代码打印出:

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,因此不会出现指针副本,内存也能够清理干净!

5. std::auto_ptr以及为什么要避免使用它

现在是时候讨论一下std::auto_ptr了。std::auto_ptr是c++98引入的,这是c++首次尝试引入的第一个智能指针。std::auto_ptr实现移动语义的方式跟上面介绍的Auto_ptr2一样。

然而,事实证明std::auto_ptr(以及我们的Auto_ptr2)存在一系列问题,使得使用std::auto_ptr变成一件很危险的事情。
(由此可见,即便是设计C++的大牛们也会有考虑不周的时候。:)

首先,std::auto_ptr是通过拷贝构造函数和赋值运算符重载实现移动语义的,把一个std::auto_ptr传值给一个函数,会造成auto_ptr指向的资源被转移给了函数的参数。函数参数是一个局部变量,在函数执行完成之后就会被销毁,其指向的资源也会被销毁。然后调用者如果继续使用auto_ptr就会得到一个空指针,造成程序crash。

其次,std::auto_ptr释放内存总是用delete xxx,而不是delete[] xxx, 这就意味着auto_ptr不能正确释放动态分配的数组。更糟糕的是,如果你把指向数组的指针传给auto_ptr,它不会报任何错误或警告,这样看下来,就会导致内存泄漏问题。

最后,auto_ptr不能处理C++标准库中的其他类,包括大多数容器和算法类。这是因为这些类在做copy的时候确实是做了copy而不是move。

基于上述原因,auto_ptr在C++11不推荐使用,到了C++17,auto_ptr已经从标准库中被删除了。

6. 更进一步

auto_ptr设计的核心问题在于C++11之前,C++语言没有move semantics。重载拷贝构造函数和赋值运算符来实现移动语义会导致很多奇怪的case和bug。比如res2 = res1这行代码,你不知道res1是否会改变。
因为这些原因,C++11正式定义了move semantics, 并提供了三种智能指针,std::unique_ptr, std::weak_ptr, std::shared_ptr。这三种智能指针,后续继续讨论。

7. 参考资料

[1]. https://www.learncpp.com/cpp-tutorial/15-1-intro-to-smart-pointers-move-semantics/

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
84、智能指针的原理、常⽤的智能指针及实现 、智能指针的原理、常⽤的智能指针及实现 原理 智能指针是⼀个类,⽤来存储指向动态分配对象的指针,负责⾃动释放动态分配的对象,防⽌堆内存泄漏。动态分配的资源,交给⼀个类对 象去管理,当类对象声明周期结束时,⾃动调⽤析构函数释放资源 常⽤的智能指针 (1) shared_ptr 实现原理:采⽤引⽤计数器的⽅法,允许多个智能指针指向同⼀个对象,每当多⼀个指针指向该对象时,指向该对象的所有智能指针内部的 引⽤计数加1,每当减少⼀个智能指针指向对象时,引⽤计数会 减1,当计数为0的时候会⾃动的释放动态分配的资源。 a.智能指针将⼀个计数器与类指向的对象相关联,引⽤计数器跟踪共有多少个类对象共享同⼀指针 b.每次创建类的新对象时,初始化指针并将引⽤计数置为1 c.当对象作为另⼀对象的副本⽽创建时,拷贝构造函数拷贝指针并增加与之相应的引⽤计数 d.对⼀个对象进⾏赋值时,赋值操作符减少左操作数所指对象的引⽤计数(如果引⽤计数为减⾄0, 则删除对象),并增加右操作数所指对 象的引⽤计数 e.调⽤析构函数时,构造函数减少引⽤计数(如果引⽤计数减⾄0,则删除基础对象) (2) unique_ptr unique_ptr采⽤的是独享所有权语义,⼀个⾮空的unique_ptr总是拥有它所指向的资源。转移⼀个 unique_ptr将会把所有权全部从源指针转 移给⽬标指针,源指针被置空;所以unique_ptr不⽀持普通的 拷贝和赋值操作,不能⽤在STL标准容器中;局部变量的返回值除外(因为编 译器知道要返回的对象将 要被销毁);如果你拷贝⼀个unique_ptr,那么拷贝结束后,这两个unique_ptr都会指向相同的资源, 造成在结束 时对同⼀内存指针多次释放⽽导致程序崩溃。 (3) weak_ptr weak_ptr:弱引⽤。 引⽤计数有⼀个问题就是互相引⽤形成环(环形引⽤),这样两个指针指向的内 存都⽆法释放。需要使⽤weak_ptr打 破环形引⽤。weak_ptr是⼀个弱引⽤,它是为了配合shared_ptr ⽽引⼊的⼀种智能指针,它指向⼀个由shared_ptr管理的对象⽽不影响所指 对象的⽣命周期,也就是 说,它只引⽤,不计数。如果⼀块内存被shared_ptr和weak_ptr同时引⽤,当所有shared_ptr析构了之 后,不管还 有没有weak_ptr引⽤该内存,内存也会被释放。所以weak_ptr不保证它指向的内存⼀定是 有效的,在使⽤之前使⽤函数lock()检查weak_ptr 是否为空指针。 (4) auto_ptr 主要是为了解决"有异常抛出时发⽣内存泄漏"的问题 。因为发⽣异常⽽⽆法正常释放内存。 auto_ptr有拷贝语义,拷贝后源对象变得⽆效,这可能引发很严重的问题;⽽unique_ptr则⽆拷贝语 义,但提供了移动语义,这样的错误不 再可能发⽣,因为很明显必须使⽤std::move()进⾏转移。 auto_ptr不⽀持拷贝和赋值操作,不能⽤在STL标准容器中。STL容器中的元素经常要⽀持拷贝、赋值操 作,在这过程中auto_ptr会传递所有 权,所以不能在STL中使⽤。
智能指针是一种重要的C++特性,它可以帮助程序员管理动态分配的内存,以防止内存泄漏和悬挂指针等问题。 智能指针的用法非常简单,程序员只需要包含<memory>头文件,并使用std::shared_ptr、std::unique_ptr或std::weak_ptr等类来创建智能指针对象。 其中,std::shared_ptr是最常用的一种智能指针,它可以实现多个智能指针共享同一块内存。当最后一个shared_ptr离开其作用域时,内存会被自动释放。 std::unique_ptr是一种独占式智能指针,它不能被复制,只能通过移动语义传递到其他unique_ptr中。这样可以确保在内存释放时不会出现问题。 std::weak_ptr是一种弱引用智能指针,它可以用于解决循环引用问题。weak_ptr不会增加引用计数,但可以通过lock()方法获取一个shared_ptr来访问它所管理的对象。 使用智能指针的好处是它们可以自动管理内存释放,避免因忘记释放内存而导致的内存泄漏问题。此外,智能指针还提供了更安全的内存访问方式,避免了悬挂指针等问题。 在使用智能指针时,我们应该避免使用裸指针,尽量使用智能指针对象来管理动态分配的内存。另外,需要注意的是,智能指针的循环引用问题,如果存在循环引用,应该使用weak_ptr来打破引用环。 总之,智能指针是C++中非常实用的特性,可以帮助我们更方便、安全地管理内存,提高程序的健壮性和可维护性。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值