C 06_C语言指针

一、指针(Pointer)的概念

1.1 指针(Pointer)简介

指针(Pointer) 就是内存的地址。

计算机中所有的数据都必须放在内存中,不同类型的数据占用的内存字节数不一样,例如 int 占用 4 个字节内存,char 占用 1 个字节内存。为了正确地访问这些数据,必须为每个内存字节都编上号码,就像门牌号、身份证号一样,每个内存字节的编号是唯一的,根据编号可以准确地找到某个内存字节。

下图是 4G 内存中每个字节的编号(以十六进制表示): Mem_addr

将内存中字节的编号称为 地址(Address)指针(Pointer)。地址从 0 开始依次增加,通常用十六进制表示;

  • 对于 x86架构 32 位环境,程序能够使用的内存为 4GB($2^{32}$),最小的地址为 0,最大的地址为 0XFFFFFFFF。
  • 对于 x86-64 架构 64 位环境,程序能够使用的内存为 256TB($2^{48}$),地址长度为 64 位,虚拟地址空间为 2 64 字节。按照当前芯片的实现情况,只能使用地址的低 48 位。 最小的地址为 0,最大的地址为 0XFFFFFFFF。

Tips: 在 x86 64bit 系统中,可以描述的最长地址空间为 16EB($2^{64}$),远远超过了目前主流内存卡的规格,所以在 Linux 中只使用了 48bit 长度,寻址空间为 256TB($2^{48}$),User Space 和 Kernel Space 各占 128T。寻址空间分别为:

  • User Space: 0x0000 0000 0000 0000~0x0000 7FFF FFFF F000,高 16bit 全 0。
  • Kernel Space: 0xFFFF 8000 0000 0000~0xFFFF FFFF FFFF FFFF,高 16bit 全 1。
  • Canonical Address Space: :0x0000 7FFF FFFF F000 - 0xFFFF 8000 0000 0000,无效地址空间。

C语言中有一个控制符 %p,专门用来以十六进制形式输出地址,不过 %p 的输出格式并不统一,有的编译器带0x前缀,有的不带。

C语言中 %#X 表示以十六进制形式输出,并附带前缀0X。

1.2 一切都是地址

在程序中定义的变量(普通变量、数组、指针变量、结构体变量、常量等)和函数,在编译时编译器就会给 变量函数 分配好 虚拟内存地址空间, 系统会根据变量存储类型在对应的内存段(data段、BBS段、堆、栈)上为变量分配数据类型对应长度的内存空间地址(结构体等自定义类型变量在分配虚拟内存空间时一般会进行内存对齐, 可能导致分配的内存空间大于所有成员所需的最大内存空间)、为函数在程序代码段分配内存空间地址。

C语言用变量来存储数据,用函数来定义一段可以重复使用的代码,它们最终都要放到内存中才能供 CPU 使用(执行)。

数据和代码都以二进制的形式存储在内存中,计算机无法从格式上区分某块内存到底存储的是数据还是代码。当程序被加载到内存后(进程的内存模型),操作系统会给不同的内存块指定不同的权限,拥有读取和执行权限的内存块就是代码(代码段),而拥有读取和写入权限(常量区只有读取权限)的内存块就是数据(Data段、BSS段、堆、栈)。

CPU 只能通过地址来取得内存中的 代码数据,程序在执行过程中会告知 CPU 要执行的代码以及要读写的数据的地址。如果程序不小心出错,或者开发者有意为之,在 CPU 要写入数据时给它一个代码区域的地址,就会发生内存访问错误,这种内存访问错误会被硬件和操作系统拦截,强制程序崩溃,程序员没有挽救的机会。

CPU 访问内存时需要的是地址,而不是 变量名 和 函数名,变量名 和 函数名 只是地址的一种助记符,当源文件被编译和链接成可执行程序后,它们都会被替换成地址。编译和链接过程的一项重要任务就是找到这些名称所对应的地址。

变量名、字符串名、 数组名 和 函数名在本质上是一样的,它们都是地址的助记符,但在编写代码的过程中,我们认为变量名表示的是数据本身,而函数名、字符串名 和 数组名表示的是代码块或数据块的首地址。数据、函数等在内存中的(首)地址也称为 指针(Pointer)

二、指针变量

2.1 指针变量

在C语言中,允许用一个变量来存放指针(内存地址),这种变量称为 指针变量指针变量 的值就是某份数据的地址,这样的一份数据可以是 数组、字符串、函数,也可以是另外的一个 变量 或 指针变量。

Tips: 人们往往不会区分指针指针变量 两者的概念,而是混淆在一起使用。

  1. 定义指针变量 和 赋值 定义指针变量与定义普通变量非常类似,不过要在变量名前面加星号 *, 表示这是一个指针变量。
1
2
3
4
5
6
datatype value;     // 定义 datatype 类型的变量 value
// 
datatype *name;     // 定义 datatype* 类型的指针变量 name
name = &value       // 取 value 的地址赋值给 name
// 或者
datatype *name = &value; // 定义 datatype* 类型的指针变量 name 同时初始化(第一次赋值)指针指向 value 

* 表示这是一个指针变量,datatype 表示该指针变量所指向的数据的类型, name 就是定义的指针变量名, valuedatatype 类型变量或常量的地址(通常使用 取地址符 & 获取常量、变量的地址)。

Tips:

  • 和普通变量一样,指针变量也可以被多次写入(赋值),只要你想,随时都能够改变指针变量的值
  • 定义指针变量时必须带 *,给指针变量赋值时不能带 *
  • datatypedatatype * 是完全不同的数据类型, 如 intint * 是不同类型
  • 不同 datatype 的指针也是完全不同的指针数据类型,如 int *float * 也是不同类型
  1. 通过指针变量 存、取 数据 指针变量存储了数据的地址,通过指针变量能够获得该地址上的数据,格式为:
1
*pointer; // *pointer 表示获取地址 pointer 上的数据

这里(变量前)的 * 称为指针运算符,用来 取得 或 修改 某个地址上的数据。

CPU 读写数据必须要知道数据在内存中的地址,普通变量 和 指针变量 都是地址的助记符,虽然通过 普通变量 和 指针变量 获取到的数据一样,但它们的运行过程稍有不同:普通变量 只需要一次运算就能够取得数据,而 指针变量 要经过两次运算,多了一层“间接”运算。

假设 普通变量a 和 指针变量p 的地址分别为 0X1000、0XF0A0,它们的指向关系如下图所示: C_pointer_var 程序被编译和链接后,a、p 被替换成相应的地址。使用 *p 的话,要先通过地址 0XF0A0 取得变量 p 本身的值,这个值是变量 a 的地址,然后再通过这个值取得变量 a 的数据,前后共有两次运算;而使用 a 的话,可以通过地址 0X1000 直接取得它的数据,只需要一步运算。

Tips: 也就是说,使用指针是间接获取数据,使用变量名是直接获取数据,前者比后者的代价要高。

* 在不同的场景下有不同的作用, 目前所学到的语法中,星号*主要有三种用途:

  • 表示乘法,例如 int a = 3, b = 5, c; c = a * b;,这是最容易理解的
  • 表示定义一个指针变量,以和普通变量区分开,例如 int a = 100; int *p = &a;
  • 表示获取指针指向的数据,是一种间接操作,例如 int a, b, *p = &a; *p = 100; b = *p;

Tips: 关于 * 和 & 的谜题 假设有一个 int 类型的变量 a,pa 是指向它的指针,那么 *&a&*pa 分别是什么意思呢?

  • *&a 可以理解为 *(&a)&a 表示取变量 a 的地址(等价于 pa),*(&a) 表示取这个地址上的数据(等价于 *pa),绕来绕去,又回到了原点,*&a 仍然等价于 a。
  • &*pa 可以理解为 &(*pa)*pa 表示取得 pa 指向的数据(等价于 a),&(*pa) 表示数据的地址(等价于 &a),所以 &*pa 等价于 pa。

指针的定义与操作示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int a = 100;
int b = 10;
int c = 1;
char ch = '@'

// 指针的定义 与 初始化(赋值)
int *p;         // 定义 int* 类型的指针变量 p
p = &a;         // 取 a 的地址赋值给指针变量 p(将指针 p 指向 变量 a)
char *p1 = &ch; // 定义 char* 类型的指针变量 p1 同时初始化(第一次赋值)指向变量 ch
char *p2;       // 定义 int* 类型的指针变量 p

int *pa, *pb, *pc;  // pa、pb、pc 的类型都是 int*
int *pd, b, c;       // pd 的类型都是 int*, b、c 的类型都是 int

// 指针变量赋值操作
pa = &a;        // 取 a 的地址赋值给指针变量 pc(将指针 pa 指向 变量 a), pa == p
pb = &b;        // 将指针 pb 指向 变量 b
pc = &c;
p = pb;         // 将指针 pb 的值(变量b的地址) 赋值给 指针 p(赋值前指针 p 指向变量 a, 赋值后指向 b)
p2 = p1;        // 将指针 p1 的值(变量b的地址) 赋值给 指针 p2(赋值后指针 p2 也指向变量 ch)

// 通过指针变量取得指向的普通变量数据
b = *p;         // 取 指针pc指向的变量(a)的值、赋值给变量b, 赋值后 变量 b 的值 与 a 的值相等、为100
a = *p + 100;   // 取 指针p指向的变量(b)的值(100)、加上 100 后赋值给变量a, 赋值后 变量 b 的值任为100, 变量 a 的值为200
b = *pa + *pc;  // 取 指针pa指向的变量(b)的值(200)、加上 指针pc指向的变量(v)的值(a) 后赋值给变量a, 赋值后 变量 b 的值为201, 变量 a == 200, 变量 c == 1 

// 通过指针变量修改指向的普通变量数据
*p = a;  // 取变量 a 的值(200)赋值给 指针p指向的变量(b),赋值后 b == 200
*pa = b + 10;
*pb = *pa;
*pc = *pa + 10; // 表示把 pa 指向的 变量 a 的内容(值) 与 10 相加后 赋给 pc 指向的变量c,*pa+10 相当于(*pa)+10
*p = *pa + *pc;

a = ++*pb;  //pb 的内容加上1之后赋给a,++*pb相当于++(*pb)
a = *pb++;  // 相当于a=*(pb++), 这是一个危险的操作, pb 指向变量b,pb++ 操作 相当于 pb = pb + 1 将使得 pb 指向 变量 b 的内存地址(是一个整数值)加上 b 变量的长度[sizeof(int)],实际是指向变量 b 后面一个内存空间,这个空间是未知的,这将导致内存越界访问, 这将在下一节介绍

三、指针变量的运算(加法、减法和比较运算)

3.1 指针变量的运算

指针变量 保存的是地址,而地址本质上是一个整数,所以指针变量可以进行部分运算(加法、减法、比较等)。

指针变量 需要先赋值 才能进行加减运算, 加减运算的结果跟数据类型的长度有关,通常 指针变量 加一个整数 n 表示 的是 指针变量(内存地址)的值 加上 n 个 数据类型的长度,如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int a = 100;
int n = 3;
int *p = &a;
int *pa;

p++;    // 与 p = p + 1 等效,将使得 p 指向 变量 a 的内存地址(是一个整数值)加上 a 变量的长度[sizeof(int)],实际是指向变量 b 后面一个内存空间,这个空间是未知的,这将导致内存越界访问,
p = p + n; // 将使得 p 指向 变量 a 的内存地址(是一个整数值)加上 n倍 a 变量的长度[sizeof(int)],实际是指向变量 b 后面你个长度为sizeof(int)的内存空间,这个空间是未知的,这将导致内存越界访问,

--pa;
pa--;
pa -= n;

指针变量 除了可以参与加减运算,还可以参与比较运算。当对指针变量进行比较运算时,比较的是指针变量本身的值(内存地址),也就是数据的地址。如果地址相等,那么两个指针就指向同一份数据,否则就指向不同的数据。

Tips: 需要说明的是,不能对指针变量进行乘法、除法、取余等其他运算,除了会发生语法错误,也没有实际的含义。

3.2 数组(Array)

数组(Array) 是一系列具有相同类型的数据的集合,每一个数据叫做一个数组元素(Element)。数组中的所有元素在内存中是连续顺序排列的,整个数组占用的是一块连续内存。

定义数组时,要给出数组名和数组长度,数组名是常量(第一个元素的地址),它的值不能改变,数组名可以认为是一个指针(是内存地址,与指针有一些相同特征),它指向数组的第 0 个元素。在C语言中,我们将第 0 个元素的地址称为数组的首地址。

Tips: 数组名的本意是表示整个数组,也就是表示多份数据的集合,但在使用过程中经常会转换为指向数组第 0 个元素的指针,所以上面使用了 “认为” 一词,表示数组名和数组首地址并不总是等价。

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdio.h>

int main(){
    int arr[] = { 99, 15, 100, 888, 252 };
    int len = sizeof(arr) / sizeof(int);  //求数组长度
    int i;
    for(i=0; i<len; i++){
        printf("%d  ", *(arr+i) );  //*(arr+i)等价于arr[i]
    }
    printf("\n");
    int *p = arr;
    for(i=0; i<len; i++){
        printf("%d  ", *(p+i) );  //*(p+i)等价于arr[i]
    }
    return 0;
}

arr 是数组名,指向数组的第 0 个元素, 数组在内存中只是数组元素的简单排列,没有开始和结束标志。

sizeof(arr) 会获得整个数组所占用的字节数,sizeof(int) 会获得一个数组元素所占用的字节数,它们相除的结果就是数组包含的元素个数,也即 数组长度

*(arr+i) 这个表达式,arr 是数组名,指向数组的第 0 个元素,表示数组首地址, arr+i 指向数组的第 i 个元素,*(arr+i) 表示取第 i 个元素的数据,它等价于 arr[i]。

Tips: arr 与 int* 类型的指针相同,每次加 1 时它自身的值会增加 sizeof(int),加 i 时自身的值会增加 sizeof(int) * i

arr 本身就是一个指针,可以直接赋值给指针变量 p。arr 是数组第 0 个元素的地址,所以int *p = arr;也可以写作int *p = &arr[0];。也就是说,arr、p、&arr[0] 这三种写法都是等价的,它们都指向数组第 0 个元素,或者说指向数组的开头。

Tips: “arr 本身就是一个指针”这种表述并不准确,严格来说应该是“arr 被转换成了一个指针”。

如果一个指针指向了数组,我们就称它为 数组指针(Array Pointer)

数组指针 指向的是数组中的一个具体元素,而不是整个数组,所以数组指针的类型 和 数组元素的类型有关,上面的例子中,p 指向的数组元素是 int 类型,所以 p 的类型必须也是int *。

反过来想,p 并不知道它指向的是一个数组,p 只知道它指向的是一个整数,究竟如何使用 p 取决于程序员的编码。

在求数组的长度时不能使用sizeof(p) / sizeof(int),因为 p 只是一个指向 int 类型的指针,编译器并不知道它指向的到底是一个整数还是数组(一系列整数),所以 sizeof(p) 求得的是 p 这个指针变量本身所占用的字节数,而不是整个数组占用的字节数。

也就是说,根据数组指针不能逆推出整个数组元素的个数,以及数组从哪里开始、到哪里结束等信息; 但是根据数组名 可以逆推出整个数组元素的个数(sizeof(arr) / sizeof(int))

对指针变量进行加法和减法运算时,是根据数据类型的长度来计算的。

引入数组指针后,我们就有两种方案来访问数组元素了,一种是使用下标,另外一种是使用指针。

  1. 使用下标 也就是采用 arr[i] 的形式访问数组元素。如果 p 是指向数组 arr 的指针,那么也可以使用 p[i] 来访问数组元素,它等价于 arr[i]。
  2. 使用指针 也就是使用 *(p+i) 的形式访问数组元素。另外数组名本身也是指针,也可以使用 *(arr+i) 来访问数组元素,它等价于 *(p+i)。

不管是数组名还是数组指针,都可以使用上面的两种方式来访问数组元素。不同的是:

  • 数组名是常量,它的值不能改变,
  • 而数组指针是变量(除非特别指明它是常量),它的值可以任意改变。

也就是说,数组名只能指向数组的开头,而数组指针可以先指向数组开头,再指向其他元素。

*p++ 应该理解为 *(p++),每次循环都会改变 p 的值(p++ 使得 p 自身的值增加),以使 p 指向下一个数组元素。该语句不能写为 *arr++,因为 arr 是常量,而 arr++ 会改变它的值,这显然是错误的。

关于数组指针的谜题: 假设 p 是指向数组 arr 中第 n 个元素的指针,那么 *++p++*p*p++(*p)++分别是什么意思呢?

  • *++p 等价于 (++p),会先进行 ++p 运算,使得 p 的值增加一个元素存储单元(指向下一个元素),整体上相当于 p=p+1; *p;,所以++p会获得第 n+1 个数组元素的值, 同时 p 指向第 n+1 个元素的。
  • ++*p 等价于 ++(*p),给 p 指向的内容(第n个数组元素的值)加上1, p指针不变,p指针指向的(第n个)元素的值 加1
  • *p++ 表示先取p指向的(第n个)元素的值,再将 p 指向下一个(第n+1个)元素。
  • (*p)++ 就非常简单了,取得第 n 个元素的值,对该元素的值加 1。

Tips: ++/– 在变量前面和后面是有区别的

  • ++ 在前面叫做 前自增,(例如 ++a)。前自增先进行自增运算,再进行其他操作。
  • ++ 在后面叫做 后自增,(例如 a++)。后自增先进行其他操作,再进行自增运算。
  • 自减(–)也一样,有 前自减后自减之分。 下面的例子能更好地说明前自增(前自减)和后自增(后自减)的区别:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdio.h>
int main()
{
   int a = 10, b = 20, c = 30, d = 40;
   int a1 = ++a, b1 = b++, c1 = --c, d1 = d--;

   printf("a=%d, a1=%d\n", a, a1);
   printf("b=%d, b1=%d\n", b, b1);
   printf("c=%d, c1=%d\n", c, c1);
   printf("d=%d, d1=%d\n", d, d1);

   return 0;
}

指针数组 和 数组指针 如果一个数组中的所有元素保存的都是指针,那么我们就称它为 指针数组。 对指针数组和数组指针的概念,相信很多C程序员都会混淆。下⾯通过两个简单的语句来分析⼀下⼆者之间的区别,示例代

1
2
int *p1[5]
int (*p2)[5]

,对于语句“intp1[5]”,因为“[]”的优先级要⽐“”要⾼,所以 p1 先与“[]”结合,构成⼀个数组的定义,数组名为 p1,⽽“ 内容,即数组的每个元素。也就是说,该数组包含 5 个指向 int 类型数据的指针,如图 1 所示,因此,它是⼀个指针数组。 对于语句“int(p2)[5]”,“()”的优先级⽐“[]”⾼,“”号和 p2 构成⼀个指针的定义,指针变量名为 p2,⽽ int 修饰的是数每个元素。也就是说,p2 是⼀个指针,它指向⼀个包含 5 个 int 类型数据的数组,如图 2 所示。很显然,它是⼀个数组指有名字,是个匿名数组。

Tips:

  • 对指针数组来说,⾸先它是⼀个数组,数组的元素都是指针,也就是说该数组存储的是指针,数组占多少个字节它就占多少字节。
  • 对数组指针来说,⾸先它是⼀个指针,它指向⼀个数组,也就是说它是指向数组的指针,在 32 位系统下永远占 4 字节,数组占多少字节,这个不能够确定,要看具体情况。

数组不是指针

数组 是⼀个固定⼤⼩的⽤来存储相同类型元素的顺序集合。在c语⾔中,数组属于构造数据类型。 有限个类型相同的变量的集合命名为 数组名。组成数组的各个变量称为数组的分量,也称为数组的元素下标变量。⽤于区分数组的各个元素的数字编号称为 下标

指针(Pointer) 是一种保存变量地址的变量,指针的值

数组名的本意是表示⼀组数据的集合,它和普通变量⼀样,都⽤来指代⼀块内存,但在使⽤过程中,数组名有时候会转换为指向数据集合的指针(地址),⽽不是表示数据集合本身,这在前⾯的例⼦中已经被多次证实。

数据集合包含了多份数据,直接使⽤⼀个集合没有明确的含义,将数组名转换为指向数组的指针后,可以很容易地访问其中任何一份数据,使⽤时的语义更加明确。

C语⾔标准规定,当数组名作为数组定义的标识符(也就是定义或声明数组时)、sizeof 或 & 的操作数时,它才表示整个数组本身,在其它的表达式中,数组名会被转换为指向第 0 个元素的指针(地址)。

指针结合数组使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
char str[20] = "blog.csdn.net";  
char *s1 = str; 
char *s2 = str+2;
char c1 = str[4];
char c2 = *str;
char c3 = *(str+4);
char c4 = *str+2;
char c5 = (str+1)[5];

long num2 = (long)str;
long num3 = (long)(str+2);

printf("%c\n", (str+2)[2]);  // 与 *(strs+2+2) 和 strs[2+2] 相同
  1. str 既是数组名称,也是一个指向字符串的指针;指针可以参加运算,加 1 相当于数组下标加 1。
  2. 指针可以参加运算,str+4 表示第 4 个字符的地址,c3 = *(str+4) 表示第4个字符,即 ‘a’。
  3. 其实,数组元素的访问形式可以看做 address[offset],address 为起始地址,offset 为偏移量:c1 = str[4]表示以地址 str 为起点,向后偏移4个字符,为 ‘a’;c5 = (str+1)[5]表示以地址 str+1 为起点,向后偏移5个字符,等价于str[6],为 ‘c’。
  4. 字符与整数运算时,先转换为整数(字符对应的ASCII码)。num1 与 c4 右边的表达式相同,对于 num1,*str+2 == ‘c’+2 == 99+2 == 101,即 num1 的值为 101,对于 c4,101 对应的字符为 ‘e’,所以 c4 的输出值为 ’e’。
  5. num2 和 num3 分别为字符串 str 的首地址和第 2 个元素的地址。

指向二维数组的指针 与 一维数组一样,二维数组名也是数组的首地址, 但是不同的是,二维数组名的基础类型不是数组元素类型、而是一维数组类型,因此二维数组名是一个行指针,例如二维数组 int a[3][4]; 的行指针、列指针 和 数组 如下图: 表中,二维数组a包含3个行元素:a[0],a[1],a[2],它们又都是一维数组名,因此也是地址常量,它们的类型与数组元素类型一致。 a+1的值是数组 a 的起始地址加上1行元素占据的字节数的和,即a[1]的地址。 a+i 的值是数组a的起始地址加上 i 行元素(4*i个整数)所占据的字节数的和,即 a[i] 的地址,所以称 a 为 行指针。 a[0]是第0行的首地址;a[1]是第1行的首地址;a[2]是第2行的首地址。

Tips: 行指针p是用来存放地址的变量。当p指向二维数组a中的数组元素 a[i][j] 时,p+1 将指向同列的下一行元素 a[i+1][j],所有行指针不能按照一般指针变量的方法定义。行指针定义时,必须说明数组每行元素的个数。

行指针定义的语法结构:数据类型名 (*指针变量名)[行元素个数]; 例如: int (*p) [3];

a[0]+1是数组元素a[0][1]的地址,a[0]+2是数组元素a[0][2]的地址,a[1]+1是数组元素a[1][1]的地址。以此类推,任意数组元素a[i][j]的地址是a[i]+j,所有称a[i]为 列指针

Tips: 二维数组是由若干行、若干列组成的。C语言中二维数组在内存中按照行顺序存放。因此在数组中,将一般简单变量的指针称作元素的指针。当元素指针p指向某一个数组元素时,p+1将指向的下一个元素刚好是同行的下一列元素。因此,在对二维数组操作时,经常将元素指针称作列指针(下一个元素就是同行的下一列元素)。用列指针操作二维数组,只要知道二维数组中数组元素在内存中的存放顺序即可。

四、函数相关的指针

4.1 指针变量作为函数参数

在C语言中,函数的参数不仅可以是整数、小数、字符等具体的数据,还可以是指向它们的指针。 用指针作为函数参数可以将函数外部的变量地址传递到函数内部,使得在函数内部可以操作函数外部的数据,并且这些数据不会随着函数的结束而被销毁。

像数组、字符串、动态分配的内存等都是一系列数据的集合,没有办法通过一个参数全部传入函数内部,只能传递它们的指针,在函数内部通过指针来访问这些数据集合。

有的时候,对于整数、小数、字符等基本类型数据的操作也必须要借助指针参数传递给函数,一个典型的例子就是交换两个变量的值。

4.2 用数组作函数参数

数组是一系列数据的集合,无法通过参数将它们一次性传递到函数内部,如果希望在函数内部操作数组,必须传递 数组指针

用数组做函数参数时(可以指明参数数组长度、也可以省略数组长度, 如: int intArr[10] 或 int intArr[]),可以接受任意长度的数组, 参数也能够以“真正”的数组形式给出。

Tips: 指明参数数组长度这种形式只能说明函数期望用户传递特定长度的数组,并不意味着数组只能传递特定长度的数组,真正传递的数组可以有少于或多于特定长度的数组也是可以的(因为穿的的是数组的首地址)。

实际上这两种形式的数组定义都是假象,不管是int intArr[10]还是int intArr[]都不会创建一个数组出来,编译器也不会为它们分配内存,实际的数组是不存在的,它们最终还是会转换为int *intArr这样的指针。这就意味着,两种形式都不能将数组的所有元素“一股脑”传递进来,大家还得规规矩矩使用数组指针。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int max(int intArr[6], int len){
    printf("max.intArr.sizeof: %lu/%lu,  address: %p\n", sizeof(intArr), sizeof(int *), intArr);  // 使用sizeof验证传输的intArr 是指针(),而不是数组
    int i, maxValue = intArr[0];  //假设第0个元素是最大值
    for(i=1; i<len; i++){
        if(maxValue < intArr[i]){
            maxValue = intArr[i];
        }
    }
    return maxValue;
}

Tips: 不管使用哪种方式传递数组,都不能在函数内部求得数组长度,因为 intArr 仅仅是一个指针,而不是真正的数组,所以必须要额外增加一个参数来传递数组长度。

C语言为什么不允许直接传递数组的所有元素,而必须传递数组指针呢? 参数的传递本质上是一次赋值的过程,赋值就是对内存进行拷贝。所谓内存拷贝,是指将一块内存上的数据复制到另一块内存上。

对于像 int、float、char 等基本类型的数据,它们占用的内存往往只有几个字节,对它们进行内存拷贝非常快速。而数组是一系列数据的集合,数据的数量没有限制,可能很少,也可能成千上万,对它们进行内存拷贝有可能是一个漫长的过程,会严重拖慢程序的效率,为了防止技艺不佳的程序员写出低效的代码,C语言没有从语法上支持数据集合的直接赋值。

除了C语言,C++、Java、Python 等其它语言也禁止对大块内存进行拷贝,在底层都使用类似指针的方式来实现。

4.3 指针作为函数返回值

C语言允许函数的返回值是一个指针(地址),我们将这样的函数称为 指针函数

用指针作为函数返回值时需要注意的是,所有类型的函数运行结束后会销毁(函数调用栈出栈)在它内部定义的所有局部(栈区)数据,包括局部变量、局部数组 和 形式参数,C语言没有任何机制来保证这些数据会一直有效,它们在后续使用过程中可能会引发运行时错误。函数返回的指针不应该指向这些在栈区分配(定义)的变量,而是应该指向通过调用 malloc 相关函数在堆上申请的内存单元。

函数运行结束后会销毁所有的局部数据,这里所谓的销毁并不是将局部数据所占用的内存全部抹掉,函数调用结束后放弃对函数栈区内存的使用权限,弃之不理,后面的代码可以随意使用这块内存。如果当前函数调用返回后继续运行的上一层函数定义变量 或 调用其它函数就会覆盖这块内存,得到的数据就失去了意义。

五、指向指针的指针

5.1 指向指针的指针

指针可以指向一份普通类型的数据,例如 intdoublechar 等,也可以指向一份指针类型的数据,例如 int *double *char * 等。

如果一个指针指向的是另外一个指针,我们就称它为 二级指针,或者 指向指针的指针。 假设有一个 int 类型的变量 a,p1是指向 a 的指针变量,p2 又是指向 p1 的指针变量,它们的关系如下图所示:

1
2
3
int a =100;
int *p1 = &a;
int **p2 = &p1;

指针变量 也是一种变量,也会占用存储空间,也可以使用&获取它的地址。C语言不限制指针的级数,每增加一级指针,在定义指针变量时就得增加一个星号*。p1 是一级指针,指向普通类型的数据,定义时有一个*;p2 是二级指针,指向一级指针 p1,定义时有两个*。如果再定义一个三级指针 p3,让它指向 p2,那么可以这样写:

1
2
3
intnt ***p3 = &p2; 
// 四级指针也是类似的道理:
int ****p4 = &p3;

实际开发中会经常使用一级指针和二级指针,几乎用不到高级指针。

想要获取指针指向的数据时,一级指针加一个*,二级指针加两个*,三级指针加三个*,以此类推。

以三级指针 p3 为例来分析上面的代码。***p3 等价于 *(*(*p3))*p3 得到的是 p2 的值,也即 p1 的地址;*(*p3) 得到的是 p1 的值,也即 a 的地址;经过三次“取值”操作后,*(*(*p3)) 得到的才是 a 的值。

假设 a、p1、p2、p3 的地址分别是 0X00A0、0X1000、0X2000、0X3000,它们之间的关系可以用下图来描述: 方框里面是变量本身的值,方框下面是变量的地址。

六、C语⾔ void* 指针 和 空指针NULL

6.1 void* 指针

void指针 表示 “不确定类型” 的指针,它可以指向任意类型数据的指针,也就是说,任何类型的指针都可以赋值给void* 指针,故称为 通⽤指针

不要直接给 void 指针* 进⾏解引⽤, 而是应该先将 void 指针* 转换为具体类型的指针后再进⾏解引⽤。

Tips: 不要把void 与 void* 混淆, void 是空类型,用在函数返回值的表述,表示函数不返回任何值。

6.2 空指针NULL

在C语言中,规定了地址 0(0X00000…) 不存储实际数据,用来表示空内容,这块空内容称为 NULL, 指向该块内容的指针称为 空指针

NULL⽤于指针和对象,表示指向⼀个不被使⽤的地址, 称为 空指针

Tips:

  • 当你还不清楚将指针初始化为什么地址时,请将它初始化NULL;
  • 对⼀个NULL指针进⾏解引⽤是⾮法的,会引起段错误。在对指针解引⽤时,先检查该指针是否为NULL。

NULL 不是 NUL 。NUL 是ASCII字符表中的第⼀个字符’\0’。

七、函数指针(指向函数的指针)

7.1 函数指针

一个函数总是占用一段连续的内存区域,函数名是函数入口的地址,这和数组名非常类似。把函数的这个首地址(或称入口地址)赋予一个指针变量,使指针变量指向函数所在的内存区域,然后通过指针变量就可以找到并调用该函数,这种指向函数地址的指针就是 函数指针

函数指针的定义形式为:

1
returnType (*pointerName)(param list);

returnType 为函数返回值类型,pointerName 为指针名称,param list 为函数参数列表。参数列表中可以同时给出参数的类型和名称,也可以只给出参数的类型,省略参数的名称,这一点和函数原型非常类似。

Tips: 注意 () 的优先级高于 *,第一个括号不能省略,如果写作returnType *pointerName(param list);就成了函数原型,它表明函数的返回值类型为returnType *

Licensed under CC BY-NC-SA 4.0