4.动态内存、拷贝控制

  2021-8-27 


动态内存

主要内容见书

  1. 可变容器是在堆上动态分配的,只不过STL已经为我们包装好了析构、allocator等, 不需要再手动new了。

    vector在栈上保留三个指向堆的指针,实际上的对象内容是在堆上,vector用栈上的指针来管理堆上的一大片内容

  2. 动态内存,静态内存

    静态内存在编译期分配,位于栈上;动态内存在运行时分配,位于堆上。

    栈上分配:static、局部作用域内的自动变量(基本类型;复杂类型如可变容器只是在栈上存了指针)

    堆上分配:用new、make_share等显式在堆上动态创建对象

  3. 什么时候需要动态内存

    ①程序不知道自己需要使用多少/多大对象,所以不知道要分配多少内存(静态内存在编译器完成内存分配,无法改变,所以如果程序在编译器不知道对象有多少,多大,那静态编译的时候就要开辟上限个对象所需的空间,即使它们可能并不会被使用,这显然吃不消)

    ②对象很大,栈空间不够

    ③程序需要在多个对象间共享数据(C++内存优化,多个对象使用同一个份底层数据节约内存)

    ④程序不知道所需对象的准确类型

  4. 对于普通的赋值操作,默认是底层元素拷贝+销毁(这时候可以通过传递指针/引用/共享动态内存的方式优化);实参->形参的传递实际上也是赋值操作,若传递shared_ptr,引用计数会+1(但显然退出函数/退栈的时候形参shared_ptr销毁,引用计数又会-1,也就是说执行完函数没变,对象不会这样就被错误销毁了)

    注意,实参到形参的传递也是赋值操作

  5. 不要使用动态数组,尽量用容器替代。(实际上STL可变容器就是动态的,见1,自己用动态数组还得自己定义操作,管理拷贝、复制、销毁对象的时候所关联的内存)

    动态数组实际上并不是数组,得到了一个数组元素类型的指针,它实际上就是动态开辟了n个元素的内存

  6. 当delete一个指针之后,虽然指针已经无效,但是实际上只是释放了指针所指向的内存。这个时候指针就变成了空悬指针,指向一块已经失效的内存。为了安全,delete之后要将这个指针设置为指向nullptr

  7. 如何用智能指针共享内存?我们在类(栈中)中使用make_shared/new初始化一块内存(堆中)来存储目标对象,并返回一个智能指针/指针(最好前者),在这个类中实际上只存储了这个指针。当我们在对类进行拷贝、销毁、赋值的时候,实际上只复制了智能指针,这样指向目标对象的引用计数+1,目标对象就不会被释放,于是就使用了同一个目标对象。

    比如对假设两个对象a,b都属于一个类Object,这个类的定义中只存储了指向目标对象的智能指针,那么第一个实例化对象a新建了目标对象,开辟了内存,但这个内存不属于对象a,只有一个a对象的成员指向这块内存,那么在将a赋值给b(Object b = a)的时候,a会被拷贝给b,那么b实际上就拷贝了一个指向目标对象的智能指针

  8. 不要混合使用智能指针和普通指针,典型的问题就是指针指针会+1引用计数可普通指针不会,导致内存被智能指针提前释放

  9. 混合声明周期管理出错——多次释放同一内存问题

    ①注意不要将同一块内存绑定到多个智能指针

    比如假设p、q都是智能指针,p.reset(q.get())错误!因为两个智能指针各自管理生命周期,导致内存被两次释放

    ②注意栈上分配的内存默认存在管理,所以不能reset给一个指向栈内存的指针,否则会重复释放内存(除非给reset传入自定义内存处理方法,否则默认是delete就会重复释放)

    (P.S. reset的参数必须是内置指针,要指向另一个智能指针用赋值即可,或者用另一个智能指针初始化)

    share_ptr<string> p1;
    string s = "hello";
    p1.reset(&s); //【报错:因为s是在栈上分配内存的,退栈的时候会销毁内存,智能指针再销毁就是重复销毁内存了】
    p1 = make_shared<string>(s); //通过:这里等于再以s的字面值在堆上分配了新内存
    
    string *s = new string("hello");
    p1.reset(s); //通过,令p1指向s
    p1 = make_shared<string>(s);  //报错:make_shared参数只接收对象或者对象构造器参数
    p1 = make_shared<string>(*s);  //通过:这里等于再以s所指向字符串的字面值在堆上分配了新内存
    share_ptr<string> new_p(s); //通过:new_p接管了内置指针s对对象的所有权(原指针依然可用)
  10. 建议使用make_share来初始化shared_ptr智能指针

    可以采用初始化,make_share,reset方式、赋值方式改变shared_ptr的值

    可以采用初始化的方式改变unique_ptr的值

    不能直接将内置指针赋值给智能指针

  11. new返回了一个内置指针

    delete不能删除智能指针

  12. reset()和release()

    不带参数的reset用于shared_ptr,相当于可以删除这个智能指针(引用计数-1,如果指向的内存引用计数为0则销毁)(带一个参数必须是内置指针,表明智能指针接管这个内置指针对对象的控制权,智能指针原指向对象引用计数-1)

    shared_ptr<string> np1 = make_shared<string>("test");
    shared_ptr<string> np2(np1);
    cout<<"np2 unique? "<<np2.unique()<<endl;  //np2 unique? 0
    np1.reset();
    cout<<"np2 unique? "<<np2.unique()<<endl;  //np2 unique? 1

    release用于unique_ptr,无参数,它可以切断unique_ptr与它原来管理对象的联系但内存不会自动释放!release返回一个内置指针,一般用于初始化给另一个智能指针,相当于完成了unique_ptr的切换。此时如果要释放内存,则delete release返回的那个指针即可。

    unique_ptr<string> np3(new string("test"));
    auto temp = np3.release();
    unique_ptr<string> np4(temp);
    cout<<*np4<<endl;   //test  
    cout<<*np3<<endl;   //报错:Segmentation fault (core dumped);因为这个指针已经不指向原内存了
  13. 对于指向没有定义析构函数的对象(哑类)的智能指针使用reset/初始化的时候要传入自定义删除器(这个只是为了兼容)

  14. weak_ptr 是为了辅助shared_ptr而引入的一种智能指针,它存在的意义就是协助shared_ptr更好的完成工作

    https://blog.csdn.net/LLZK_/article/details/52431404

    weak_ptr可以指向一个对象,但不会改变其引用计数,常调用weak_ptr.lock()来获取一个新的指向对象的shared_ptr,安全。

  15. allocator

    新建allocator类对象(是个模板),该对象可以将内存分配/销毁与对象构造/析构分离(new则是合并在一起了)

    该对象主要拥有以下函数

    内存分配(allocate)——>对象构造(construct)

    对象析构(destroy)——>内存销毁(deallocate)

    allocate会返回一个指向开辟的原始内存首地址的指针,要记录好这个头指针

    以下为分配内存+构建的demo

    allocator<string> alloc;
    auto const p = alloc.allocate(3);
    auto q = p;  //拷贝给一个新指针q,方便在内存上移动,构造对象
    alloc.construct(q++,"hello0"); //在q位置构造对象,然后q右移一位
    alloc.construct(q++,"hello1");
    alloc.construct(q++,"hello2");  //这时候q就到开辟内存的尾地址之后了
    auto w = p; //新建一个指向头的指针方便输出
    cout<<*w++<<" "<<*w++<<" "<<*w++<<endl;  
    //输出:hello0 hello1 hello2
    
    //同理通过首尾指针移动,destroy对象,然后最后再deallocate p指针指向开头的整片内存
  16. RAII

    RAIIResource Acquisition Is Initialization的简称,其翻译过来就是“资源获取即初始化”,即在构造函数中申请分配资源,在析构函数中释放资源,它是C++语言中的一种管理资源、避免泄漏的良好方法。

    C++语言的机制保证了,当创建一个类对象时,会自动调用构造函数,当对象超出作用域时会自动调用析构函数。RAII正是利用这种机制,利用类来管理资源,将资源与类对象的生命周期绑定,即在对象创建时获取对应的资源,在对象生命周期内控制对资源的访问,使之始终保持有效,最后在对象析构时,释放所获取的资源。

    总结起来说,RAII的核心思想是将资源或状态与类对象的生命周期绑定,通过C++语言机制,实现资源与状态的安全管理。

    链接:https://zhuanlan.zhihu.com/p/389300115

拷贝控制

移动、赋值构造函数也属于构造函数,只是参数表特殊的构造函数,实现了移动、赋值的语义;析构函数,重载赋值运算符不是构造函数

定义为删除指编译器不合成默认构造器,即使再定义为=default()也无法使用

一般来说,如果一个类定义了任何一个拷贝/析构/移动操作,那么它就应该定义所有五个操作

主要内容见书

  1. 如果一个类需要自定义析构函数,几乎可以肯定它也需要自定义拷贝赋值运算符和拷贝构造函数。

    有动态内存需要释放,才会用自定义析构函数。显然默认构造函数只会销毁栈中的指针,而不会销毁其指向堆内动态内存。

    对于类内成员指向动态内存点情况,拷贝构造函数、拷贝赋值函数,也都需要自己定义。。因为动态内存指针拷贝了,但内存并未拷贝,这种情况如果使用默认拷贝构造/赋值函数会导致多个指针指向同一个内存重复delete的问题!

    如果这个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值函数,反之亦然。

    但无论需要自定义拷贝还是赋值构造函数,都不一定需要析构函数

  2. 如果一个类的成员(如const/引用/不可拷贝赋值析构的对象)不能拷贝、赋值或析构,则对应的成员函数(如:成员不能析构则类析构函数被删除,etc)将被定义为删除的

  3. 拷贝赋值运算符 与 有成员管理动态内存的类—易错

    拷贝赋值运算符与拷贝构造不一样,比如a = b; 将b赋值给了a,那么a对象中的成员被覆盖,那么就要考虑到如果被赋值对象管理了动态内存,这些动态内存是否应该销毁

    对于行为像值的类:由于动态内存每个对象都各自管理一份,那么被赋值对象原来管理的动态内存必定需要在拷贝赋值重载函数中手动销毁(为了防止自赋值注销掉自己的动态内存,不能先销毁被赋值对象管理的原动态内存,应该先临时保存new的新对象的动态内存,再销毁,再赋值。)

    对于行为像指针的类:①对于使用内置指针管理动态内存的类,需要设置一个引用计数,在拷贝赋值重载函数中,每一次发生赋值就将被赋值对象原管理的动态内存引用计数-1,如果引用计数为0,则销毁(引用计数可以保存在动态内存中,所有类共享,注意引用计数指针也要拷贝);同时不但在a.赋值的时候要检查引用计数是否应该销毁动态成员(注意虽然左右对象都是同一类,但可能并不是共享同一动态内存的,所以存在要销毁左边对象的情况),b.析构函数中也要判断引用计数来决定是否销毁动态成员

    ②对于使用智能指针管理动态内存的类(建议!)只需要将被赋值对象的对应智能指针成员设为nullptr即可(或者被赋值),无需手动设置引用计数。智能指针会帮我们管理引用计数。

    #include <iostream>
    #include <memory>
    #include <utility>
    #include <string>
    using namespace std;
    
    //行为类似值传递的类
    struct Obj
    {
        Obj() = default;
        Obj(const Obj &obj)
        {
            sp = new string(*(obj.sp));
        }
        Obj& operator=(const Obj &obj)
        {
            auto new_sp = new string(*(obj.sp));  //为了防止自赋值情况,不能先销毁被赋值对象管理的原动态内存
            delete sp;  //手动释放被赋值对象原来所管理的动态内存
            sp = new_sp;
            return *this;
        }
        ~Obj()
        {
            delete sp;
        }
    
        string *sp = new string();
    };
    //行为类似指针的类-内置指针实现
    struct Objp
    {
        Objp()
        {
            *ref = 1; //构造的时候,将引用计数记为1
        }
        Objp(const Objp &objp)
        {
            sp = objp.sp;
            ++*(objp.ref);  //新动态内存对应的引用计数++
            ref = objp.ref;  //错点:然后将原动态内存的引用计数覆盖为新的动态内存的引用计数
        }
        Objp& operator=(const Objp &objp)
        {
            ++*(objp.ref); //赋值对象管理的动态内存的引用计数++   //先+后-,避免自赋值销毁内存
            --*ref; //被赋值对象原来管理的动态内存的引用计数-- (注意本对象就是被赋值的对象)
            if (!*ref)  //检查被赋值对象管理的动态内存的引用计数是否为0,若为0则手动释放内存
            {
                delete sp;
                delete ref; //要销毁sp了,则它的引用计数一起销毁了
            }
            sp = objp.sp;
            ref = objp.ref; //引用计数也要赋值!新动态内存和被赋值对象管理的旧动态内存是两个东西,引用计数当然不同
            return *this;
        }
        ~Objp()
        {
            --*ref;
            if(!*ref)
            {
                delete sp;
                delete ref;
            }
        }
    
        int *ref = new int(0);  //手动管理引用计数
        string *sp = new string();
    };
    //行为类似指针的类-智能指针实现
    struct Objsp
    {
        Objsp() = default;
        Objsp(const Objsp &objsp): sp(objsp.sp) {}
        Objsp& operator=(const Objsp &objsp)
        {
            sp = objsp.sp;  //智能指针自动将被赋值对象管理的动态内存的引用计数-1了,不需要自己负责
            return *this;
        }
        ~Objsp()
        {
            sp = nullptr; //智能指针析构设置为nullptr即可
        }
    
        shared_ptr<string> sp = make_shared<string>();
    };
    
    
    int main()
    {
        //test1-Obj
        Obj o1;
        *(o1.sp) = "dynamic string";
        Obj o2(o1);
        Obj o3 = o1;
        *(o1.sp) = "changed string";
        cout<<*(o2.sp)<<endl;  //"dynamic string"
        cout<<*(o3.sp)<<endl;  //"dynamic string"
    
        //test2-Objp pointer
        Objp op1;
        *(op1.sp) = "dynamic string";
        Objp op2(op1);
        Objp op3 = op1;
        *(op1.sp) = "changed";
        cout<<*(op2.sp)<<endl;  //"changed"
        cout<<*(op3.sp)<<endl;  //"changed"
    
        //test3-Objsp smart pointer
        Objsp osp1;
        *(osp1.sp) = "dynamic string";
        Objsp osp2(osp1);
        Objsp osp3 = osp1;
        *(osp1.sp) = "changed";
        cout<<*(osp2.sp)<<endl;  //"changed"
        cout<<*(osp3.sp)<<endl;  //"changed"
    }
  4. 赋值运算重载有返回值(return *this)

    各种构造函数无返回值,且各种构造函数的函数名和类名一致,只是参数表决定了它是什么构造函数

    (析构函数带~

    注意只有构造器能用列表初始化

    拷贝构造函数、赋值构造函数的本类对象参数可以不是const的,但是设置为const引用有好处:①不可以随意更改 ②既可以接收右值,也可以接收左值

  5. 移动语义

    对于带有资源托管的对象(如管理一块动态内存)需要使用拷贝构造的时候

    Q1.我们不能直接使用拷贝语义,那么这样实际上是拷贝了指针,这使得两个对象同时持有同一份资源,危险

    Q2.我们虽然可以使用引用语义/拷贝指针,但我们在新对象里修改了和原对象共享的内存,原对象使用者却不知道,危险

    所以我们选择对象移动

    对象移动:实质上就是新对象窃取原对象对资源的管理权:先获取右值对象(原对象),然后将右值对象管理的资源交给新对象,并切断右值对象与资源的联系。这看起来就像对象发生了移动,但实际上只切换了资源的管理权

    这里的资源就是对象所管理的动态内存,比如对象中有一个指针指向一块动态内存

    为什么要获取右值对象?如果我们只获取左值引用,那么这个引用的指针被拷贝之后,新对象原对象共享一个内存,就会出问题(即上面的Q2)。

    那么我们就想要获取一个“即将被销毁”的对象,我们向编译器确保将这个对象用来构造/赋值新对象之后一定会被销毁,以后就不使用这个对象了。而“一定即将被销毁”的对象,就是右值,它是临时的。但以前我们并没有办法直接获取右值。于是右值引用的出现,就是在语法上支持了“直接获取右值”这一语义

    ①通过std::move()就可以获取右值对象(底层本质调用static_cast完成了右值到左值的强制转换),②再将这个右值对象拿来/i.拷贝构造新对象/ii.赋值新对象/iii.不操作,之后就会自动销毁

    通过移动构造函数,移动赋值函数来设计如何进行右值对象的资源托管右值对象析构前准备。前者就是把原对象(右值对象)的成员拷贝给新对象,后者就是把资源管理指针设为nullptr,因为马上要销毁了,不设的话会释放掉移动了的资源!

    移动后的右值对象被销毁了,再通过原对象的左值引用/变量名访问就会报错

    特殊情况1:string对象的资源被move之后,该string变为空字符串,还可以访问

    特殊情况2:对于简单对象来说(比如int、double等),默认的移动就是拷贝

    实际上return就是用到了move,return返回了一个右值引用,它通过在返回前调用std::move获取。返回来右值引用后,函数栈内的内存全部销毁。如果函数外面接收return的对象定义了移动构造/赋值函数,就会采用移动构造/赋值。

    Tips:std:move一般不会抛出异常,所以为移动操作设置为noexcept可以避免标准库的额外工作(标准库容器会对异常发生时其自身的行为提供保障)

    再理解-搞清移动本质:实际上就是对于指针成员指向一片动态内存的对象,我们只拷贝指针,并且将原指针释放掉,这个行为看起来就像move了(新对象窃取了资源)

  6. 移动赋值运算符必须考虑自赋值(if是自赋值,则不发生任何事情,否则会自己释放掉自己的资源,出现致命错误),拷贝赋值运算符自赋值问题也不算很大(但是还是多了无谓的开销)

  7. 合成的移动构造函数/移动赋值函数,只有在类①没有定义任何自己版本的拷贝控制成员(拷贝构造函数、拷贝赋值函数,析构函数),且它②所有数据成员都能移动构造和移动赋值(const、引用不行),且③类的析构函数可以访问④移动构造函数和移动赋值函数任何一个都不能被显式定义为删除,编译器才会为它合成移动控制成员(移动构造函数,移动赋值函数),否则的合成移动控制成员被定义为删除的(即使=default,它也是删除的)

    如果一个类只有拷贝控制成员,没有移动控制成员,那么移动操作会被当做拷贝执行

    多个构造函数,通过函数匹配规则来确定使用哪个,最先选择精准匹配

    移动和拷贝的重载函数通常是拷贝控制接收const T&,移动控制接收T&。因为拷贝一般不需要改变原对象,是const的;而移动需要改变原对象,不能是const

  8. 如果一个类定义了自己版本的移动控制成员,那么这个类的合成拷贝控制成员将被定义为删除的

  9. 对于赋值重载,要考虑到自赋值的问题,注意安全

    主要问题在于,自赋值情况下两个对象可能共享同一片内存,不考虑自赋值可能会先把被赋值对象管理的原动态内存销毁了,那么赋值对象的动态内存也被销毁,就出现内存错误。

  10. 深刻理解右值的短暂性(重要)

    Type &&rref = std::move(obj); //正确
    Type &&lref = rref; //错误,rref是右值引用。右值引用也是个左值!

    std::move()获取的右值必须立即赋值或销毁,只有再被赋值之前它才是右值,经过任何中间层就会丢失右值属性(右值是短暂的,不能保存!)所以右值引用是个左值!

    所以实际上只有std::move(obj)本身是右值,它只存在于此时此刻,一旦经过任何处理让它能够长久被使用,那就是左值了

    所以右值作为实参传递给形参的时候,这个形参就已经是左值了(即使传入实参是右值,但要知道被初始化的右值引用就是个左值,它可以永久引用右值)。那么在移动构造中,实际上就是先通过std::move()强制将左值转换成右值,传入移动构造函数中,然后这个时候在移动构造函数定义资源如何托管,【实际上就是完成了新左值如何诞生的过程(托管资源,销毁原对象)】

    在传递右值的时候想要保持对象的右值属性,需要不断使用std::move()

  11. 右值和左值引用成员函数

    可以在类成员函数声明处的参数表后面(在const之后)添加一个引用限定符,可以是左值引用&或右值引用&&,分别指出this可以指向一个左值或右值。函数匹配的时候根据this是左值还是右值进行匹配。

    对于&限定的函数,我们只能在左值对象中使用;对于&&限定的函数,只能在右值对象中使用。

    这个的用处是:①可以限定运算符本对象的位置 ②可以根据传入参数是右值还是左值来调用不同的函数

  12. 五法则

    ①需要析构函数的类也需要拷贝构造函数和拷贝赋值函数

    ②需要拷贝操作的类也需要赋值操作,反之亦然

    ③析构函数不能是删除的

    ④如果一个类成员有删除的或不可访问的析构函数,那么其默认的拷贝构造函数会被定义为删除的

    ⑤如果一个类有const或引用成员,则不能使用合成的拷贝赋值操作。(无法默认构造的const成员的类 则该类就无默认构造函数)

    本质:当不可能拷贝、赋值、或销毁类的所有成员时,类的合成拷贝控制函数就被定义成删除的了

    一般来说,如果一个类定义了任何一个拷贝/析构/移动操作,那么它就应该定义所有五个操作

#include <iostream>
#include <utility>
#include <string>
#include <vector>
#include <memory>
using namespace std;
struct Obj
{
    //constuctor
    Obj() = default;
    //copy constructor
    Obj(const Obj &obj): p(obj.p),a(obj.a) {}
    //copy-assignment operator
    Obj& operator=(const Obj &obj)
    {
        p = obj.p;
        a = obj.a;
        return *this;
    }
    //move constructor
    Obj(Obj &&obj) noexcept : p(obj.p),a(obj.a)
    {
        obj.p = nullptr;
    }
    //move-assignment operator
    Obj& operator=(Obj &&obj) noexcept
    {
        obj.p = nullptr;
        return *this;
    }
    //deconstructor
    ~Obj()
    {
        p.reset();
    }

    //dynamic resource(vector) admin smart pointer 这么做是为了多个对象共享同一个vector
    shared_ptr<vector<int>> p = make_shared<vector<int>>();
    //statck memory element
    int a;

    void print()
    {
        for (auto element : *p)
           cout<<element<<" ";
        cout<<endl;
    }
};

int main()
{
    Obj o;
    o.p->push_back(1);
    o.p->push_back(2);
    o.a = 10;
    o.print();   //输出:1 2

    //copy constructor
    Obj o1(o);
    o.p->push_back(3); //改变原对象,拷贝构造的新对象也改变了,因为两个对象共享同一块内存
    o1.print();   //输出1 2 3

    //copy-assignment operator
    Obj o2 = o1;
    o2.print();   //输出1 2 3

    //move-assignment operator
    Obj o3 = std::move(o);
    o3.print();  //输出1 2 3
    o.print(); //Segmentation fault (core dumped) 原对象o move后已经被销毁,无法使用

    //move constructor
    Obj o4(std::move(o3));
    o4.print();   //输出 1 2 3
    o3.print();  //Segmentation fault (core dumped) 原对象 o3 move后已经被销毁,无法使用
}

且听风吟