美文网首页登堂入室C++
登堂入室C++之C指针

登堂入室C++之C指针

作者: 杜凌霄 | 来源:发表于2023-03-04 21:27 被阅读0次

    C语言中的指针

    C语言和C++中的指针是否一致

    在C语言中,指针变量是用来存储地址的变量。这里面地址分几种类型,我们下面会细说,但是无论怎样,指针都是用来存储地址的。

    但是在C++中,指针变量存储的不一定是地址,有可能是其它更加复杂的内容。这里的不一定和可能是因为C语言的指针类型在C++中是完全有效的。除了C语言中的指针类型,C++扩展了指针的表达范围,比如我们有指向成员函数的指针,也有指向成员变量的指针。这样的指针和C语言中的指针存储地址不同,存储了完全不同的信息。比如一个指向类里面虚函数的指针,它没有办法存地址,因为只有runtime的时候才知道具体的类型是哪一个,存地址的话根本就不知道存继承链路里面哪一个类对应的函数实现的地址。所以它只能存给定的类型以及这个函数在虚表(vtable)里面的偏移量。所以C++里面的指针除了兼容C里面的那部分指针的含义和C语言里面的指针一样之外,其它的含义是不一样的。

    这篇文章主要分享的就是C语言里面的指针这一部分,C++里面扩展的指针部分会在后面的文章进行分享。

    C语言里面的指针

    指针变量首先是一个变量,是变量就会有对应的内存。就跟int b;定义的变量b对应一块内存,内存对应的位置存储的是一个整形的数一样,int *p;中p也是一个变量,系统会给它分配一块内存,只不过这块内存储存的是一个地址而已。

    取指针对应的地址对应的值叫做解引用(dereferencing),不同类型的指针解引用的模式不一样。

    指向变量的指针

    我们可以定义一个指针,它的值存储的是一个已经定义的变量的地址。

    int a = 10;
    int *pA = &a;
    
    c-pointer-to-var.png

    从上面图可以清晰看出pA存储的是变量a在内存中的地址。

    其实说存储了变量的地址这句话也不是十分准确,因为如果变量对应的类型比较大,那么是需要很多地址空间才能存储的。所以更加准确地说指针存储的是变量所在内存的首地址

    要想取到指针对应的值,我们可以解引用

    int b = *pA;
    

    这时候b的值也是10,因为*pA返回地址0xA0243529出的值,也就是10。

    指向函数的指针

    指针既然可以用来存储地址,那么函数也有对应的地址,我们自然也可以用指针来存储对应的函数的地址。

    对于一个函数,如果它有以下的签名

    return_type function_name(parameter_type_1 param1, parameter_type_2 param2, ...)
    

    那么它对应的函数指有如下签名

    return_type (*function_pointer)(parameter_type_1 param1, parameter_type_2 param2, ...);
    

    比如我们有一个函数

    void f() {}
    

    我们可以定一个可以存储它地址的函数指针

    void (*funcPtr)() = f;
    

    或者

    void (*funcPtr)() = &f;
    

    也就是对于函数指针,赋值的时候对应的函数名前面是否使用取地址符是没有关系的,两者都是合法的,并且结果也是一样的。

    指向函数的指针和指向变量的指针都是存储的地址,如果非要说有什么地方不一样,那么如果变量在stack里面,那么指向变量的指针指向的是stack内存的一个位置;如果变量实在heap里面,那么对应的指针指向的是heap内存的一个位置。而指向函数的指针存储的是代码区的某个地址。

    那指向函数的指针有什么用呢?可以看一个简单的例子

    int add(int a, int b)
    {
      return a + b;
    }
    
    int mul(int a, int b)
    {
      return a * b;
    }
    
    int eval(int a, int b, char opcode)
    {
      int (*op)(int, int) = NULL;
      
      if (opCode == '+')
      {
        op = add;
      }
      else {
        op = mul;
      }
      
      return op(a, b);
    }  
    
    int main(int argc, char* argv[])
    {   
        printf("1 + 2 = %d\n", eval(1, 2, '+'));
        printf("1 * 2 = %d\n", eval(1, 2, '*'));
      
        return 0
    }
    

    对函数指针进行解引用也有两种方式,比如

    op(1, 2);
    (*op)(1, 2);
    

    这两种模式都是可以的。

    指向数组的指针

    在没有分享左值和右值的概念之前,我们依然不打算详细说数组名的具体含义。但是这这个地方我们已经可以澄清一个事情了:

    数组名不是一个指针。

    为什么呢?因为指针在内存中是有对应的内存的,只是内存里面存的数据是地址而已。而在前面讲数组的文章里面我们已经从反汇编看到了,数组是有对应的内存的,但是数组名并没有。数组名只是数组对应的内存的一个label而已。从这点我们就已经可以说:数组名不是一个指针。

    但是我们是可以有指向数组的指针的,毕竟,数组是有对应的内存地址的,而指针就是存储地址的。

    回忆一下我们在[数组]()一文中提到的代码

    int main()
    {
        int scores[2][3][4];
        
        print_type<decltype(&scores)>();
        print_type<decltype(scores)>();
        print_type<decltype(scores[0])>();
        print_type<decltype(scores[0][0])>();
        print_type<decltype(scores[0][0][0])>();
        
        return 0;
    }
    // 输出
    //int (*)[2][3][4]
    //int[2][3][4]
    //int (&)[3][4]
    //int (&)[4]
    //int &
    

    所以如果我们要指向一个数组,如果数组的签名是

    element_type array_name[dim1][dim2][...]
    

    那么对应的指向这个数组的指针就是

    element_type (*ptr)[dim1][dim2][...]
    

    这个跟指向函数的指针的类型非常类似,都是把对应的数组名或者函数名换成(*pointer_name)就可以了。

    比如我们可以有

    int (*arrayPtr)[2][3][4] = &scores;
    int (*subArrayPtr)[3][4] = scores;
    

    这里面需要注意的是C和C++都是强类型的语言,所以对象都需要有明确的类型。上面的两个指针都存储了scores这个数组的首地址,从数值上看是一样的,但是这两个指针有完全不同的类型。而这个不同的类型就决定了使用这个指针做算术运算和解引用都有不同的结果。

    比如:

    int (*addedPtr)[2][3][4] = arrayPtr + 1;
    

    假设scores数组的首地址是a,那么addedPtr的值是多少呢?

    a + 2*3*4* sizeof(int)

    而且addedPtr的类型是int (*)[2][3][4]

    int (*anotherAddedPtr)[3][4] = scores + 1;
    

    anotherAddedPtr的值是多少呢?

    a + 3 * 4 * sizeof(int)

    所以虽然arrayPtr, subArrayPtr存储的值是一样的,但是由于本身类型的不同,进行算术运算的时候就会有截然不同的结果。

    对于解引用,它们之间也是不一样的

    arrayPtr[0][0][0]
    subArrayPtr[0][0]
    

    这两个都会取到scores[0][0][0]的值,但是可以看到取值的模式是不一样的。

    指向内存的指针

    我们上面讲的指针都是已经有名称了,比如变量名,函数名,数组名,然后使用指针指向这些名称代表的内存。我们当然也可以用指针直接来存储没有特定名称的内存了,毕竟只要是地址指针就可以存储。这就是我们经常说到的指向malloc对应的内存。

    int *ptr = (int*)malloc(sizeof(int));
    

    这里需要注意的是,对于之前提到的指针,指针本身并不需要去管对应的内存的生存期。比如

    int a = 10;
    int *ptr = &a;
    

    变量a已经管理了对应的内存的生存期,是不需要指针ptr再去做任何操作的。

    但是对于直接指向malloc分配的内存的指针,用户需要通过这个指针在适当的时候释放掉对应的内存。

    free(ptr);
    ptr = NULL;
    

    否则的话会造成内存泄漏。

    指向指针的指针

    很多人很恐惧指针,就是有类似指针的指针这样的类型。其实只需要记住指针本身有类型,存储的都是地址这一句就好了。既然指针变量是一个变量,是有对应的内存的,那自然我们也可以使用另外一个指针变量来存储这个指针对应的内存的首地址。

    int a = 10;
    int *ptrA = &a;
    int **pptrToptrA = &ptrA;
    

    里面核心点就是ptrA这个变量是有对应的内存的,有内存就有对应的内存地址,有内存地址就可以有另外的指针(这里就是pptrToptrA)来存储ptrA这个变量的内存地址。

    指针使用需要注意的一些问题

    多个变量定义

    int *a = NULL, b;
    

    这种定义其实只有a是指针类型,b只是普通的指针类型。我们有两个办法来解决这个问题,第一种是

    int *a = NULL, *b = NULL;
    

    第二种是使用typedef

    typedef int* IntPtr;
    
    IntPtr a = NULL, b = NULL;
    

    建议使用typedef这种模式。

    不恰当释放内存

    这里我们需要搞清楚一件事情:变量本身的释放是系统管理的。一个指针变量是一个变量,这个变量本身系统是知道管理它的生存周期的。

    比如在一个函数内部定义的一个局部变量,在函数返回之前系统就会把对应变量的内存收回去。这个是不需要我们程序员去管的。比如

    void f()
    {
      int a = 10; // 系统会在stack为这个int变量分配一块内存
      int *ptrA = &a; // 系统会在stack为这个int指针变量分配一块内存
      ... // 使用
        
     // 在这个位置,虽然没有显式的代码,但是编译器会处理回收a和ptrA内存的
     // 的事情
    }  
    

    但是,系统(或者说编译器)并不会处理指针指向的内存的回收。比如上面的ptrA指向的内存&a,编译器并不会去处理它的回收,它的回收是通过处理变量a的回收处理的。

    这里就涉及到一个问题了:指向malloc这样直接向系统请求的内存谁来释放?

    答案就是程序员需要处理。

    所以,已经有系统处理的内存,程序员不能去释放;系统没有处理的,程序员一定要去释放。

    比如上面代码里面,如果我们这么写就会出问题

    void f()
    {
      int a = 10;
      int *ptrA = &a;
      
      ...
        
      free(ptrA);
      ptrA = NULL;
    }
    

    因为ptrA对应的变量是在处理变量a的时候就会处理,我们不能去手动释放。或者换句话说,对于指向显式名称的指针,我们不要去释放对应的内存。

    那对于直接指向malloc分配的内存的指针,我们就一定需要释放对应的内存

    void f()
    {
        int *ptr = (int*)malloc(10 * sizeof(int));
        ....
        free(ptr);
      ptr = NULL;
    }
    

    变量ptr的内存是系统会处理回收的,但是它指向的内存需要我们手动释放。

    那对于手动释放,我们也需要非常注意多个指针指向指向同一块内存的情况,必须保证只释放一次。

    void f()
    {
      int *ptr1 = (int*)malloc(10 * sizeof(int));
      int *ptr2 = ptr1;
      ...
      ...
      free(ptr1);
      ptr1 = NULL;
      free(ptr2);
      ptr2 = NULL
    }
    

    上面代码就会出现问题,同样的一块内存被释放了两次。我们需要尽量不要使用这种alias的指针,出了问题的时候不容易判断问题出在什么地方。

    悬垂指针

    还有一种情况是指针对应的内存已经被释放了,但是我们依然还在使用这个指针。这种指针称为悬垂指针(dangling pointer)。

    经常出现的一种情况就是先释放后使用:

    int *ptr = (int*)malloc(10 * sizeof(10));
    ...
    free(ptr);
    ...
    int b = ptr[10]; // 出问题,对应的内存已经释放
    

    上面最后的访问是否会引起崩溃不一定,但是系统的表现肯定不是我们预期的。我们一定要避免这种情况。

    另外一种就是使用了从函数返回的指向函数内部数组的指针,比如

    int* f()
    {
      int scores[4] = {1,2,3,4};
      int *ptr = &scores[0];
      
      return ptr;
    } 
    
    int *scorePtr = f();
    ....
    

    上面scoresPtr虽然得到了一个内存地址,但是对应的内存在函数返回的时候已经被回收,所以指针指向的位置已经不是一个有效的地址。这时候的指针也成了悬垂指针。

    上面两种悬垂指针的情况我们都要避免,不然程序会出现问题。

    未初始化的指针

    还有一种问题是使用未初始化的指针,比如

    void f()
    {
        int *ptr;
        int b = *ptr;
    }
    

    因为ptr没有初始化就是用,这时候它的值是随机的,指向的内存也不知道是什么内容。对它的访问可能造成访问越界或者其它的未定义的情况。所以这种情况也要避免。一个好的习惯就是一开始就赋值NULL,访问的时候判断是否为NULL再进行操作

    void f()
    {
      int *ptr = NULL;
      ...
      int b = 0;
      if (NULL != ptr)
      {
        b = *ptr;
      }
    }
    

    GNU有一个扩展也可以处理这种情况,它提供了一个macro叫做RAII_VARIABLE,定义如下

    #define RAII_VARIABLE(vartype,varname,initval,dtor) \
    void _dtor_ ## varname (vartype * v) { dtor(*v); } \
    vartype varname __attribute__((cleanup(_dtor_ ## varname))) = (initval)
    

    然后可以如下使用

    void f()
    {
      RAII_VARIABLE(int*, ptr, (int*)malloc(10* sizeof(int), free)
      ....
    }
    

    跟指针相关的几个类型

    • size_t: 用来存储平台相关的可取地址的区域的大小
    • ptrdiff_t: 用来处理指针的算术运算;
    • intptr_tuintptr_t: 存储指针地址

    size_t

    它用作sizeof操作符的返回值,也用做类似malloc这样函数参数。用来安全地表示系统可以操作的内存的大小。在stdio.hstdlib.h中定义如下

    #ifndef __SIZE_T
    #define __SIZE_T
    typedef unsigned int size_t
    #endif
    

    size_t一般来说可能得最大值是SIZE_MAX

    一般使用%zu, %u, $lu来打印size_t的值,因为它是无符号的,避免使用%d打印。

    ptrdiff_t

    用来做指针运算的,比如

    int *ptr = (int*)malloc(10 * sizeof(int));
    ptrdiff_t offset = 3;
    int *elePtr = ptr + offset;
    

    intptr_tuintptr_t

    当你想把指针存的地址当做有符号整形使用的时候,你可以使用intptr_t;当你想把其当成无符号整数使用的时候,你可以使用uintptr_t。比如

    int *ptr = NULL;
    ...
    uintptr_t uptr = (uintptr_t)ptr;
    uintptr_t rptr = uptr & 0x1;
    ...
    

    更多文章可以关注公众号”探知轩“, 第一时间可以看到。如果对C++各方面感兴趣也可以私聊进c++群一起讨论。

    相关文章

      网友评论

        本文标题:登堂入室C++之C指针

        本文链接:https://www.haomeiwen.com/subject/mdnyldtx.html