深入理解C指针

深入理解C指针

个人认为,在没有任何计算机基础知识的情况下,就强行学习C/C++语言,是一件极为痛苦的事情。更重要的是,语言其实都是和计算机底层对应的。学习如何用只是让你知其然,在了解一些计算机基础知识之后,才能更好地知其所以然。指针就是最好的体现。没有一些内存的基础知识,使用指针往往容易出错。

  1. 认识指针

指针是一个变量,其值为另一个变量的内存地址。使用指针的好处:

  • 写出高效快速的代码
  • 支持动态内存分配
  • 使表达式变得紧凑和简洁
  • 用指针传递数据结构不会带来庞大的开销
    当然,指针艰深难难懂,而且使用不当的话,会出现很多问题。但是,了解计算机内存相关知识之后,对指针会有更好的理解。

1.1 指针和内存

  • C程序使用内存的三种形式:
    • 静态/全局内存
      这些变量在程序开始运行时分配 ,直到程序终止时才消失。所有函数都能访问全局变量,静态变量的作用于局限于定义他们的函数内部。
    • 自动内存
      自动变量在函数内部声明,并且在函数被调用时才创建。他们的作用域局限于函数内部,生命周期限制在函数的执行时间内。
    • 动态内存
      动态内存分配在堆上,一般使用malloc函数动态申请,使用free函数释放。作用域局限于引用内存的指针。
  • 指针的内容应该是一个整数变量的地址。
  • 没有被初始化的指针包含的是垃圾数据。这样的指针指向的可能不是一个合法的地址,就算是一个合法的地址,这个地址也可能没有包含合法的数据。程序没有权限访问不合法地址,否则在大部分平台上会造成程序终止。
  • 解引用未初始化的指针可能引发严重的内存问题。
  • 指针的实现中没有内部信息表明自己指向的是什么类型的数据或者内容是否合法。
  • 指针的阅读:从右向左阅读。
  • 使用地址操作符 &初始化指针,使用间接引用操作符*解引用指针。
  • 使用%p打印指针的地址。指针打印出的地址不是指针所指的真实地址,而是虚拟内存中的地址。
  • 声明一个指向函数的指针:return_type (*ptr)(arg1, arg2)。ptr是一个指针,指向一个函数,函数的参数是arg,函数的返回类型是return_type。
  • null指针

    • null概念
      是指指针指向了一个特殊的值,他没有指向任何内存区域。两个null指针总是相等的。
    • NULL宏
      NULL宏是强制类型转换为void指针的整数常量0,在很多库中定义如下:

      define NULL ((void *) 0)

    • ASCII字符NUL
      ASCII字符NUL定义为全0的字节 。

    • null字符串
      null字符串是空字符串,不包含任何字符。
    • null指针
      p = NULL等价于p = 0。这样的指针已经被初始化,但是包含NULL的指针不会引用内存中的任何地址。任何时候都不应该对null指针进行解引,因为它不包含合法地址,执行这样的代码会导致程序终止。
  • void指针
    void指针是通用指针,可以用来存放任何数据类型的引用。任何指针都可以被赋值给void指针,他可以被转换为原来的指针类型 ,转换之后,指针的值和原指针的值 是相等的。void指针只能用作数据指针,不能用作函数指针。void指针具有与char指针相同的内存对齐方式。void指针和别的指针永远不会相等,不过,两个赋值为NULL的void指针是相等的。

1.2 指针的长度和类型

数据指针的长度通常是一样的,但是数据指针的长度和函数指针的长度是不一样的。

指针相关的预定义类型

  • size_t
    安全的表示指针长度,是无符号数。
  • ptrdiff_t
    处理指针算数运算。
  • intptr_t 和 uintptr_t
    用于存储指针地址。intptr_t存放带符号数的地址,uintptr_t存放无符号数的地址。

1.3 指针操作符

1.3.1 指针的算术运算

  • 给指针加上/减去整数
    给指针加上/减去整数,实际上加/减的是这个整数和指针数据类型对应字节数的乘积。当指针与数组结合时,加上/减去整数需要注意的问题是数组越界问题。

  • 两个指针相减
    两个指针相减会得到两个地址之间的差值。这个差值通常用于判断数组中的元素顺序。指针之间的差值,是他们之间相差的单位数,ptrdiff_t类型用来表示两个指针差值的可移植方式。

  • 比较指针
    把指针和数组元素比较时,比较结果可以用来判断数组元素的相对顺序。
    int vector[] = {28,41, 7};
    int *p0 = vector;
    int *p1 = vector + 1;
    int *p2 = vector + 2;

    printf(“p2 > p0: %d “, p2 > p0); // p2 > p0 : 1
    printf(“p1 < p2: %d”, p1 < p2); // p1 < p2 : 0

  • void指针和加法
    给void指针做加减法是标准C不允许的行为。

1.4 指针的常见用法

1.4.1 多层间接引用

多层间接引用也叫作双重指针。一个很好的例子就是使用argc和 argv 传递参数。

char *title[] = {
    "hello world",
    "hello java"
};

1.4.2常量与指针

指向常量的指针

指向常量指针,意味着不能通过指针来修改它所引用的值。

指向非常量的常量指针

指向非常量的常量指针,意味着指针不可变,但是,指针指向的数据是可以变换的。

2 . C的动态内存管理

指针的强大很大程度上源于他们能追踪动态分配的内存。C程序在运行时环境中执行,这通常是由操作系统提供的环境,支持栈和堆以及其他的程序行为。

2.1 动态内存分配

动态内存分配步骤:

  • 用malloc类的函数分配内存。
  • 用这些内存支持应用程序。
  • 用free函数释放内存。

如果不再使用已分配的内存却不将他释放,那么就会发生内存泄露,导致内存泄露的情况可能如下:

  • 丢失内存地址。
  • 应该调动free函数却没有调用(有时称为隐式泄露)。
    内存泄露的一个问题就是无法回收内存并重复利用,堆管理器可用的内存越来越少,在内存用光的极端情况下,操作系统可能崩溃。
    丢失地址
    int p = (int ) malloc (sizeod(int));
    *p = 5;

    // 再次对p分配地址,会导致丢失地址
    p = (int *) malloc (sizeof(int)); //丢失地址

    // 地址丢失的另一个例子
    char name = (char)malloc(strlen(“Susan”) + 1);
    strcpy(name, “Susan”);
    while(*name != 0)
    {
    printf(“%c”, *name);
    name++; //丢失地址
    }

    隐式内存泄露
    如果程序应该释放内存然而实际上却没有释放,就会发生内存泄露。如果我们不再需要某个对象,但他任然在堆上,就会发生内存泄露。最常见的错误就是忘记free动态分配的内存。

2.2 动态内存分配函数

动态类型会根据指针的数据类型对齐。系统并不保证内存的连续性。动态内存分配函数有:

  • malloc
    从堆上分配内存。
  • realloc
    在之前分配的内存块的基础上,将内存重新分配为更大或者更小的部分。不改变原有内存块的数据。

    请求的大小如果比当前分配的字节数小,那么多余的内存会还给堆,不能保证多余的内存会被清空;如果比当前分配的内存大,那么可能的话,就在紧挨着当前分配内存的区域分配新的内存,否则会在堆的其他区域分配并把旧的内存复制到新区域。

    如果大小是0并且指针非空,那么就释放内存。如果无法分配空间,那么原来的内存块就保持不变,不过返回的指针是空指针,而且errno会设置为ENOMEN。

  • alloca
    alloca函数在函数的栈帧上分配内存。函数返回后自动释放内存。但是这是个不标准的函数,应该尽量避免使用。

  • calloc

从堆上分配内存并清零。

// calloc 的典型用法
int *p = calloc (5, sizeof(int));

//上述表达式等价于下面这句
int *p = malloc(5 * sizeof(int));
memset(p, 0, 5 * sizeof(int));

2.3 用free释放内存

free函数应该指向由malloc类函数分配的内存地址,这块内存会被返还给堆。尽管指针任然指向这块区域,但是我们应该将它看成指向垃圾数据。

如果传递给free函数的参数是空指针,通常它什么也不做。如果传入的指针不是由malloc函数分配的,那么该函数的行为将是未定义的。

以下是几个使用free时会出现的问题:

  • 将已释放的指针赋值为NULL
    已释放的指针任然可能造成问题,如果我们试图解引用一个已释放的指针,其行为将是未定义的。避免该未定义行为的方法之一就是,将该指针显式的赋值为NULL,后续载使用这种指针时会造成运行时异常。使用这种技术的目的是为了解决迷途指针问题(迷途指针接下来解释)。

    int p = (itn )malloc(5*sizeof(int));

    free(p);
    p = NULL;

  • 重复释放
    重复释放就是两次释放同一块内存。
    int p = (itn )malloc(sizeof(int));
    *p = 5;
    free(p);

    free(p); //二次释放

  • 程序结束前释放内存
    异常终止的程序可能无法做清理工作,而内存损坏可能正是应用程序终止的原因。是否应该在程序结束前释放内存取决于具体的应用程序。

2.4 迷途指针

如果内存已经释放,而指针还在引用原始内存,这样的指针称为迷途指针。迷途指针没有指向有效对象,有时候也称为过早释放。

迷途指针

使用迷途指针会引发一系列问题,包括:

  • 如果访问内存,则行为不可预期
  • 如果内存不可访问,则是段错误
  • 潜在的安全隐患

导致这几类问题的情况可能如下:

  • 访问已释放的内存
  • 返回的指针是上次函数调用中的自动变量

迷途指针示例:

int *p = (int *)malloc(sizeof(int));
*p = 5;
free(p);    // 指针已被释放
...
*p = 10;    //迷途指针

执行free函数后将释放p所指向的内存,此后不该再使用这块内存了。但是大部分运行时系统都不会阻止后续的访问或修改。我们还是可以向这个位置写入数据,但是这么做的情况是不可预期的。

还有一种迷途指针的情况,这种情况很难被察觉:一个以上的指针指向同一块内存区域(内存别名),而其中一个指针被释放了。示例如下:

int *p = (int *)malloc(sizeof(int));
*p = 5;
int *p2 =  p;
free(p2);   //p2所指向的内存已经被释放,但是p还指向该处
*p = 10;    // 迷途指针

处理迷途指针

可以使用以下方法对付迷途指针:

  • 释放指针后置为NULL。后续使用这个指针会终止程序。
  • 写一个特殊的函数代替free函数。
  • 有些系统会在释放后覆写数据。在不抛出异常的情况下,如果程序员在预期之外的地方看到这些值,那么就可以认为程序在访问已释放的内存。
  • 借助于第三方工具。

2.5 动态内存分配技术

C的垃圾回收是手动的,所谓的垃圾回收,是指将不再使用的内存收集起来已被后续使用,释放的内存称为垃圾,因此,垃圾回收指的就是这个过程。可以使用下面的技术更好的使用动态内存。

资源获取即初始化

资源获取即初始化(Resources Acquisition Is Initialization, RAII),用于解决C++中资源的分配和释放。即使有 异常发生,这种技术也能保证资源的初始化和后续的释放,分配的资源最终会得到释放。

使用异常处理函数

在finally块中放入free函数不管有没有发生异常,都会执行finally块,因此一定会执行free函数。

  1. 指针和函数

3.1 指针的堆和栈

要理解函数及其和指针的结合使用,需要理解程序栈,大部分现在的跨结构语言,比如C,都用到了程序栈来支持函数执行。调用函数时,创建函数的栈帧并将其推到程序栈上。函数返回时,其栈帧从程序栈上弹出。

栈帧由以下几种元素组成:

  • 返回地址
    函数完成后要返回的程序内存地址。
  • 局部数据存储
    为局部变量分配的内存。
  • 参数存储
    为函数参数分配的内存。有指定的几个寄存器用来存储参数。
  • 栈指针和基指针
    运行时系统用来管理栈的指针。栈指针指向函数顶部,基指针通常存在并且指向栈帧内部的地址,比如返回地址,用来协助访问栈帧内部的元素。

3.2 通过指针传递和返回数据

void test_func1(int *a, int *b)     // 通过指针传递数据
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

int *sum(int a, int b)
{
    static int *s;
    int sum = a + b;
    s = ∑
    return s;   
}

从函数返回指针时需要注意的几个问题:

  • 返回未初始化的指针。
  • 返回指向无效地址的指针。
  • 返回局部变量的指针
  • 返回指针但是没有释放内存。

3.3 函数指针

int *sum(int a, int b)  //函数
{
    static int *s;
    int sum = a + b;
    s = ∑
    return s;   
}


// 声明一个函数指针
int* ((*ptr)(int a, int b));
/*
* 函数指针解读:
*       ptr是一个指针,指向一个函数,函数的参数是a和b,函数的返回值是指向int的指针
*/

// 使用函数指针
ptr = sum;
/*
或者
ptr = ∑
*/
int a =3;
int b = 4;
int sum;
sum = *(ptr(&a, &b));
  1. 指针和字符串

C中有两种类型的字符串。

  • 单字节字符串
    由char数据类型组成的序列。
  • 宽字符串
    由wchar_t数据类型组成的序列

wchar_t用来表示宽字符,要么是16位宽,要么是32位宽。这两种字符串都以NUL结尾,在string.h找到单字节字符串函数,在wchar.h找到宽字符串函数。创建宽字符串主要用于支持非拉丁字符。

4.1 标准字符串操作

  • 声明字符串

声明字符串的方式有三种:字面量、字符数组和字符指针。

// 初始化方式1
char header[] = "Media Player"; 
/* 

上式等价于:
char header[13];
strcpy(header, "Media Player");

*/


// 初始化方式2
char *header = (char *)malloc(strlen("Media Player")+1);
strcpy(header, "Media Player");
  • 字符串字面量池

定义字面量时通常会将其分配在字面量池中,这个内存区域保存了组成字符串的字符序列。多次用到同一个字面量时,字面量池中通常只有一份副本。大部分编译器都有关闭字面量池的选项,一旦关闭,字面量可能生成多个副本,每个副本拥有自己的地址。

通常认为字符串是不可变的。但是这取决于具体的编译器,比如在GCC中,字符串字面值就是可以修改的。

  • 字符串之间的比较

字符串之间的比较使用strcmp函数,函数返回值如下

  • 负数
    如果按字典序s1 比s2小就返回负数
  • 0
    两个字符串相等就返回0
  • 正数
    如果按字典序s1比s2大就返回正数。

比较两个字符串有几种不正确的写法,第一种试图用赋值操作符作比较:

char command[16];
scanf("%s", command);
if (command = "Quit")       // 不正确写法

这不是作比较,而且会导致类型不兼容的错误,我们不能把字符串字面量地址复制给数组名字。另一种写法是相等操作符:

char command[16];
scanf("%s", command);
if (command == "Quit")      // 不正确写法

这样会得到假,因为我们比较的是command的地址和字符串字面量的地址。在本例中,我们试图把字符串字面量的地址赋给 command。command是数组,不用数组下标就把一个值赋值给这个变量是不可能的。

  • 复制字符串
    赋值字符串使用strcpy函数:
    char *strcpy(char *s1, char *s2)
  • 拼接字符串
    char *strcat(char *s1, char *s2)
    此函数把第二个字符串拼接到第一个的末尾,第二个字符串通常以常量char指针的形式传递。

4.2 传递字符串

最常用的传递字符串的方式就是给应用程序传递参数:

int main(int argc, char *argv[])

4.3 返回字符串

函数返回字符串时,实际返回的是字符串的地址。要返回合法的地址,可以返回一下三种对象之一的引用:

  • 字面量
  • 动态分配的内存
  • 本地字符串变量

像下面这样返回字面量的地址:

char *returnChar(int code)
{
    switch(code)
    {
        case 100:
            return "C";
        case 101:
            return "C++";
        case 102:
            return "Java";
    }
}

以上这段代码会工作得很好。另一种方式就是返回静态字面量:

char *returnChar(int code)
{
    static char* p1 = "C";
    static char* p2 = "C++";
    static char* p2 = "Java";

    switch(code)
    {
        case 100:
            return p1;
        case 101:
            return p2;
        case 102:
            return p3;
    }
}

也可以在堆上分配字符串的内存然后返回其地址,但是要记得显式释放动态分配的内存。

  1. 安全问题和指针误用

    • 使用指针之前要进行初始化。未初始化之前使用指针会导致运行时错误,有时称这种指针为野指针。
    • 使用malloc类函数时一定要检查返回值,测试NULL。
    • 数组访问越界问题
    • 错误计算数组长度。申请动态内存时,记得加上结尾的\0。

你可能感兴趣的:(C/C++)