概述

C++指针与被指对象的生命周期探讨:在使用指针时,一定要保证被指变量的生命周期大于指针的生命周期,否则会导致意外的错误。

指针的图解表示

我们知道,变量在内存空间中是由“地址”加“值”进行存储的,特别像我们去超市的存包柜。地址代表变量在内存中的位置(第几号柜门),值代表变量本身的取值(该柜门内存放的物品)。

  • 一级指针:要操作的数据存放在某个变量中,该变量的地址存放在指针变量中,该指针就是一级指针。
  • 二级指针:在上述基础上,将一级指针的地址也存放在另外一个指针中,该指针就是二级指针(“指针的指针”)。

悬挂指针和悬挂引用

悬挂指针,是指针指向了已经被释放的内存。造成悬挂指针的原因是被指变量的生命周期先于它的指针结束,但是这时并没有将指针置为空,因此指向了被释放的内存,称为悬挂指针。通过悬挂指针操纵内存的行为是非常危险的、未定义的。解决办法:在被指对象释放时将所有指向它的指针置空,或者使用智能指针。

类似地,如果一个对象的引用的生命周期比该对象本身要长,那么在该对象销毁之后,该引用就成为了“悬挂引用”。悬挂引用同悬挂指针一样,直接访问了被释放的内存,可能会造成非常严重的后果,应该被避免。

错误案例

  1. 一级悬挂指针:(g++编译不会报错,valgrind也检测不出来)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <iostream>
    using namespace std;
    int main(){
    int *p;
    {
    int a = 5;
    p = &a;
    }
    *p = 6; // 错误,a已经被释放,*p成为悬挂指针
    return 0;
    }
  2. 二级悬挂指针:(g++编译不会报错,valgrind也检测不出来)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include <iostream>
    using namespace std;
    int main(){
    int **pp;
    {
    int *p;
    int a = 5;
    p = &a;
    pp = &p;
    }
    cout << *pp << endl; // 错误,a和p已经被释放,**pp成为悬挂指针
    return 0;
    }
  3. 悬挂引用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include <iostream>
    using namespace std;

    int& generateInt(){
    int x = 10;
    return x;
    }

    int main(){
    int y = generateInt(); // y是一个局部变量x的引用,在x销毁之后,y变成了悬挂引用
    cout << y << endl; // 虽然能打印出10,但是这块内存已经free,可能会被其他变量复用,而y将会直接操纵这块内存,后果危险
    return 0;
    }

    当然,上面的例子错误太明显,一般的编译器能检查到错误,例如g++编译会报错:引用一个局部变量是不正确的。

    1
    2
    3
    4
    5
    6
    7
    test.cpp: In function ‘int& generateInt()’:
    test.cpp:6:12: warning: reference to local variable ‘x’ returned [-Wreturn-local-addr]
    6 | return x;
    | ^
    test.cpp:5:9: note: declared here
    5 | int x = 10;
    | ^

    但是也有一些比较隐晦的悬挂引用情况,编译器和valgrind都检查不出来。例如将引用放在类的成员中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    #include <iostream>
    using namespace std;

    class Obj
    {
    public:
    void setVal(int& x){ // 注意这里是传入引用哦!
    _val = x;
    }
    void print(){
    cout << _val << endl;
    }
    int _val;
    };

    int main(){
    Obj o;
    {
    int a = 5;
    o.setVal(a);
    } // 局部变量a被释放,但是a的引用作为o._val还存在,因此o._val成为了悬挂引用!
    o.print();
    return 0;
    }

    在上面的例子中,局部变量a的生命周期先于它的引用o._val结束,因此在a释放之后,o._val成为了悬挂引用,将会直接操控已经释放的内存,这将是非常危险的行为,应该避免!

总结

  • 使用指针或者引用时,要保证被指/被引用的对象生命周期不能先于它的指针/引用结束,否则会导致悬挂指针和悬挂引用。
  • 悬挂指针/悬挂引用将会直接操控系统中已经被释放的内存,这是非常危险的行为,一个合格的程序员要严格避免写出悬挂指针/引用的代码。
  • g++编译器,甚至valgrind对悬挂指针和悬挂引用的检测能力都非常有限,这就导致了通常只有悬挂引用的不正确操作导致程序跑挂了才会被发现。但是debug的过程又非常艰难,想要知道某一块内存是哪个悬挂指针/悬挂引用破坏的是非常困难的事情。
  • 因此,最好的办法就是养成良好的编程习惯,在使用指针和引用时,一定要密切关注它指向/所引对象的生命周期,一定要晚于指针/引用结束。当然,也可以使用智能指针。