C++|智能指针模板类

2023-05-03 14:48:13 浏览数 (1)

智能指针?

智能指针是一种封装了指针的数据类型,可以自动管理动态内存的分配和释放。智能指针可以跟踪其所指向的资源是否被引用,以及何时能够被释放。

考虑下面这段代码:

代码语言:javascript复制
void demo1(std::string &str)
{
    std::string* ps = new std::string(str);
    .....
    if (func())
    {
        throw exception();             // #1
    }
    str = *ps;
    delete ps;                         // #2
    
    return;
}

在使用过程中,如果我们忘记了#2这一步,没有通过delete释放内存以及当出现#1程序抛出异常时,后续的#2将不被执行,这也可能导致内存泄漏的问题。这就是人为手动管理内存的一个弊端,写代码的时候脑子里总是想着对内存的管理一定要用完释放!用完释放!用完释放!结果写完发现:"马什么梅?"。

所以为了避免这种情况的出现,C 提供了智能指针模板类,专门用来自动管理内存。


智能指针初探

常见的智能指针有auto_ptrunique_ptrshared_ptrweak_ptrunique_ptr是独占所有权的智能指针,只能有一个指向同一块内存;shared_ptr则可以多个指针共享同一块内存,会维护一个引用计数器;weak_ptr则是一种弱引用的智能指针,不会增加引用计数,只能用于判断所指向的资源是否有效。其中,auto_ptr是C 98中提供的,在新版的C 11中已经被摒弃,但为了对比理解,这里还会提到auto_ptr指针。

智能指针的使用:

代码语言:javascript复制
#include <iostream>
#include<string>
#include<memory> //使用智能指针必须引入的头文件
class Report
{
private:
    std::string str;
public:
    // 构造函数,初始化成员变量 str,并输出一句话表示对象被创建
    Report(const std::string s) : str(s){std::cout <<"Object created!n";}
    ~Report(){std::cout << "Object deleted!n";} //析构函数,输出一句话表示对象被销毁
    void comment() const{std::cout <<str << "n";} // 成员函数 comment,用于输出成员变量 str
};
int main()
{
    {
        std::auto_ptr<Report> ps(new Report("using auto_ptr"));
        ps->comment();
    }
    {
        std::shared_ptr<Report> ps(new Report("using shared_ptr"));
        ps->comment();
    }
    {
        std::unique_ptr<Report> ps(new Report("using unique_ptr"));
        ps->comment();
    }

    return 0;
}

析构函数:

析构函数是一种特殊的成员函数,用于在对象被销毁时执行一些清理操作。在本代码中,Report 类的析构函数负责输出一句话来表示对象被销毁,以便于观察对象的生命周期。

main 函数中每一个对象的创建都使用了一对花括号 {} 来包围,这是为了控制对象的生命周期,使得每个对象都在其对应的作用域内被创建和销毁,防止对象的生命周期超出其作用域而导致未定义的行为。

在每一对花括号内,都会创建一个新的作用域。在这个作用域内,声明的变量和对象只在这个作用域内可见,出了这个作用域就会被销毁。因此,在本代码中,每个智能指针都被包含在一个花括号内,当这个花括号结束时,智能指针就会被销毁,并自动释放指向的对象。

如果不使用花括号来限制作用域,而是直接在 main 函数中创建智能指针,那么这些智能指针就会在 main 函数结束时才被销毁,这样就会导致智能指针指向的对象的生命周期超出其作用域,可能引发未定义行为和内存泄漏等问题。因此,使用花括号来限制作用域是一种良好的编程习惯。


为何摒弃auto_ptr?

这个还得从一段代码开始讲起...

代码语言:javascript复制
auto_ptr<string> ps(new string("I reigned lonly as a cloud"));
auto_ptr<string> vocation;
vocation  = ps;

上述的语句中,两个指针将同时指向同一个string对象,这显然是不能被接受的,因为程序试图删除同一个对象两次,分别发生在psvication过期时,要解决这个问题,可以考虑下面几种方案:

  • 定义赋值运算符,使之指向深复制,这样两个指针将指向不同的对象,其中一个对象是另外一个的副本。
  • 建立所有权概念。对于特定的对象,只能有一个智能指针可以拥有它,这样只有拥有对象的智能指针的构造函数会删除该对象。然后,让赋值操作转让所有权。其中,auto_ptrunique_ptr采用的就是这种策略,但unique_ptr的策略会更加的严格。
  • 创建更加智能的智能指针,跟踪引用特定对象的智能指针数。这称为引用计数。例如,在赋值时,计数器 1,而指针过期时计数器-1,仅当最后一个指针过期时(计数器为0)才调用delete释放内存,这便是shared_ptr指针采用的策略。

下面举一个不适合使用auto_ptr的例子:

代码语言:javascript复制
#include<iostream>
#include<memory>
#include <string>
int main()
{
    using namespace std;

    auto_ptr<string> films[5] =
            {
                auto_ptr<string>(new string("Java!")),
                auto_ptr<string>(new string("C  ")),
                auto_ptr<string>(new string("python!")),
                auto_ptr<string>(new string("JavaScript")),
                auto_ptr<string>(new string("PHP天下第一")),
            };
    auto_ptr<string> pwin;
    pwin = films[2];
    cout <<"年度编程语言前五位:n";
    for(int i=0;i<5;  i)
    {
        cout <<*films[i] << endl;
    }
    cout <<"冠军获得者是: " << *pwin <<"n";
    cin.get();

    return 0;
}

上面的程序执行出错的原因是下面的语句将所有权转交从film[2]转给了pwin,这导致film[2]不再引用该字符串。

代码语言:javascript复制
pwin = films[2];

如果auto_ptr放弃对象的所有权后,再次使用film[2]指向的字符串时,会发现这是一个空指针,并不存在正常的引用,显然这是非法的。

相对的,如果我们修改上面的代码,使用shared_ptr来代替auto_ptr,那么程序将会正常运行。

代码语言:javascript复制
#include<iostream>
#include<memory>
#include <string>
int main()
{
    using namespace std;
    // 使用shared_ptr指针
    shared_ptr<string> films[5] = {
            shared_ptr<string>(new string("Java!")),
            shared_ptr<string>(new string("C  ")),
            shared_ptr<string>(new string("python!")),
            shared_ptr<string>(new string("JavaScript")),
            shared_ptr<string>(new string("PHP天下第一"))
    };
    shared_ptr<string>pwin;
    pwin = films[2];
    cout <<"年度编程语言前五位:n";
    for(int i=0;i<5;  i)
    {
        cout <<*films[i] << endl;
    }
    cout <<"冠军获得者是: " << *pwin <<"n";
    cin.get();

    return 0;
}

修改后的程序中,pwinfilm[2]指向了同一个对象,引用计数器增加为2.在程序的末尾,后声明的pwin首先调用其析构函数,此时计数器将-1,然后shared_ptr数组成员被释放,对于film[2]调用析构函数时,引用计数器会再次-1达到0并释放之前分配的空间,完成动态内存的自动管理。

但如果使用unique_ptr来修改上述的代码,程序不会在运行阶段崩溃,而是通过编译器在pwin = film[2]这行代码处抛异常。


unique_ptr为何优于auto_ptr?

批话少说,代码掏出来看看!

代码语言:javascript复制
auto_ptr<string> p1(new string("auto")); 	//#1
auto_ptr<string> p2;                       //#2
p2 = p1;                                   //#3

#3中,p2接管了p1的所有权后,p1的所有权将被剥夺,这代表着如果p1再次被使用,则会导致程序发生错误,因为此时的p1已经不再指向任何有效的数据,但这样做的好处在于避免了p1p2的析构函数同时删除同一个对象。

同样的代码,如果使用unique_ptr来代替auto_ptr那么相对会安全些,至少不会导致程序直接崩溃,而是在编码期间就能看到编译器给出的非法提示,这可以让程序员警惕到两只耳朵竖起来,重新审视自己的屎山代码!

这里引出一个概念:悬挂指针

  • 悬挂指针(Dangling Pointer)是指一个指针指向了已被释放的内存空间或者未被分配的内存空间。在C 中,当一个指针指向的内存空间被释放后,该指针依然存在,但指向的内存空间已经无效,使用该指针将导致程序崩溃或者产生未知的结果。
  • 悬挂指针通常是由于程序员未正确管理内存或者释放内存时出现错误造成的。为了避免悬挂指针的出现,程序员应该注意内存的分配和释放,确保指针指向的内存空间是有效的。

相比于auto_ptrunique_ptr还有一个优点。他是一个可用于释放数组内存的指针,一般情况下,我们必须将newdelete配对使用,new[]delete[]配对使用。

auto_ptr使用的是没有[]的配对策略,因此不能和new[]一起使用。但unique_ptr比较牛逼,兼容两种配对模式。比如下面的这段代码:

代码语言:javascript复制
std::unique_ptr<double[]> pda(new double(5));

警告!

  • 使用new分配内存时,才能使用auto_ptrshared_ptr,当然的,不使用new分配内存时也不可以使用这俩智能指针。
  • 使用new[]分配内存时,不能使用auto_ptrshared_ptr。同上理,反之亦然。

如何选择智能指针?
  • 如果程序要使用多个指针指向同一个对象,应该选择shared_ptr指针。
  • 很多STL容器中的算法都支持复制和赋值操作,这些操作可以用于shared_ptr,但不能用其他两个。
  • 如果程序不需要使用多个指向同一个对象的指针,则可以使用unique_ptr
  • 结合上面的警告内容理解。
番外:将一个智能指针赋给另外一个一定会引起错误吗?

批话少说,代码掏出来看看!

代码语言:javascript复制
unique_ptr<string> demo(const char* s);
{
    unique_ptr<string> temp(new string(s));
    return temp;
}

int main()
{
    ...
    unique_ptr<string> ps;
    ps = demo("Hello C  !");
    ...
}

上面的程序中,方法demo()返回一个临时变量temp,然后ps接管了原本归还的unique_ptr所有的对象,而后返回的unique_ptr被销毁,这是正确的,没什么问题。

因为ps拥有了string对象的所有权。也就是说,通过demo()返回的temp临时unique_ptr对象会很快的被销毁掉,没有机会在其他地方使用,与前面说的赋值不同,这是被编译器所允许的赋值操作,要细品!

总结一下就是:

如果程序试图将一个unique_ptr赋给另外一个时,如果源unique_ptr是一个临时右值,编译器允许这样的操作,相反,如果这个unique_ptr会存在一定的时间,那么这将会被编译器禁止!

通过下面的例子再深入的理解体会下,细思极恐。

代码语言:javascript复制
using namespace std;
unique_ptr<string> pu1(new string("Hi Java!"));
unique_ptr<string> pu2;
pu2 = pu1;                                        //#1
unique_ptr<string> pu3;
pu3 = unique_ptr<string>(new string("Hi C!"));   // #2

语句#1的操作是不被允许的,而#2却屁事没有。

因为#1的赋值操作将会留下一个悬挂指针unique_ptr即(pu1)。那为什么#2不会呢?它不也进行了赋值操作吗?

因为它调用unique_ptr的构造函数,该函数创建的临时对象在其所有权转让给pu3后就被立即销毁了,并不会长时间停留,也就是不会挂在哪儿。这也说明了unique_ptr是优于允许两种赋值操作的auto_ptr的,所以,可选的情况下,该使用哪个智能指针心里有点B数了吧!

注意:

如果容器算法试图对包含unique_ptr的容器指向类似于#1的操作,这将会导致编译错误!

如果实在需要这种赋值操作,安全的重用这种指针,可以给他赋新值,这就引出了另外一个标准函数库中的函数:std::move()通过它,你可以实现将unique_ptr赋值给另外一个。

代码语言:javascript复制
using namespace std;
unique_ptr<string> ps1,ps2;
ps1 = demo("Hi Java!");
ps2 = move(ps1);
ps = demo("add more");
cout <<*ps2 <<*ps1 <<"n";

为什么unique_ptr能区分安全和不安全的用法呢?

因为它使用了C 11中新增的移动构造函数和右值引用。这部分内容后续更新!

引用&参考:《C Primer Plus》

0 人点赞