C语言篇:指针

1,807 阅读19分钟

注意:本篇不适合做入门教学,建议有一定基础的爱好者做深度学习。

指针是C语言的灵魂。并不是所有的高级语言都具有指针,C++虽然有指针,但是从设计上希望程序员尽可能少得使用指针,C则不然。C是追求效率的,所以有时不得不把安全问题交给程序员。正是指针赋予了C“直接操作内存”的能力,也正是指针使一些复杂的数据结构成为可能。

流畅且准确地使用指针,是掌握C语言精髓的关键。

什么是指针

在程序运行的过程中,所有的数据都是放在主存(内存)里的,主存按字节编址,每个字节对应一个编号,称为地址,计算机通过地址可以精确地存取数据。

在C语言中有一类数据类型,它保存主存地址,并可以通过运算符对该地址的数据进行操作。如果它保存的是某个变量的地址,可以形象地说它指向了某个变量,所以这类数据类型称为指针。

指针的存储方式

主存从0开始顺序编址,所以指针的值是非负整数,实际上,指针的存储结构与unsigned long相同,在32位机上指针是32位,而在64位机上指针是64位。

指针类型

前面已经提到,指针是一类数据类型,这是一个大家族,拥有非常多的指针类型,尤其是当指针与数组结合以后,其类型描述会变得非常复杂

也许会有疑问,既然存储方式已经确定,为什么还需要众多的指针类型?

其实,指针类型描述的不是自身数据的类型,而是其指向数据的类型。想象一下,一个int变量一般是4个字节,而指针储存的是1个字节的地址,怎样才能让指针指向这个变量。C的做法是,储存这个int变量的第一个字节的地址,即首地址。之后,我们根据指针存储的数据找到了该地址,这是1个字节的地址,该怎么表示这里有一个4字节的int变量。同样,我们把这个地址作为首地址,把这个字节和其后的3个字节一起作为一个int变量。显然,我们需要明确表示,这个指针指向一个int变量,否则无法使用指针进行任何操作。这就引出了指针类型。

同时,指针之间可以进行有限的运算,且指向不同类型数据的指针运算规则是不同的,这是指针类型的另一个用途。

& 和 * 运算符

  • & 运算符:又称为“取址运算符”,顾名思义,其作用是得到一个变量的地址。严格来说,它取的是变量的首地址,并且包含了变量类型信息,所以,它返回的是一个指针(请勿把指针和指针变量弄混),并且它是一个右值。& 运算符为一元运算符,右结合,优先级大于算术运算符,运算对象必须是变量(暂不引入引用的概念,并姑且把主存中的固定数据都称为变量),而不能是例如a + 1这样的表达式。

  • * 运算符:取值运算符,也称“解引用运算符”,返回指针所指的变量。* 运算符返回左值,如果a指向b,那么*ab是相同的,完全可以混用。* 运算符也是一元运算符,右结合,优先级与 & 运算符相同。* 运算符的运算对象可以是任何指针类型(除了void *)的值,而不一定是指针变量。

指针变量的声明

C变量声明的设计理念是:声明方式应该与使用方式相同

首先,我们需要指定指针所指变量的类型,以int为例。当我们使用指针时,需要在指针变量前使用 * 运算符,所以在声明时也应在变量名前加 * 。所以,声明一个指向int变量的指针应写为:

int *var;

若要声明一个“指向‘指向char变量的指针’的指针”,因为我们需要两次使用 * 运算符才能得到这个char变量,所以声明时也要写两个 * ,写为:

char **var;

值得一提的是,以下三种写法都是合法的:

int *var;
int* var;
int * var;

声明中 * 只对与它相邻的那个变量有效,也就是说,如果有以下声明:

int* a, b;

则声明了一个指针变量a和一个int变量b,如果想同时声明两个相同类型的指针,需要在每个变量名前加 * ,所以建议采用第一种写法。如下:

int *a, *b;

指针运算

指针运算赋予了指针更灵活的用法,在数组等操作中尤为明显。

指针运算旨在使指针偏移到相邻的对象,或指向一个确定的位置,也可以求出两个指针所指对象在主存中的距离。

赋值运算

将一个指针类型的值赋值给一个指针变量,最基本的用法如下:

int var = 0;
int *pt;
pt = &var;

一般情况下,赋值号两边的指针类型必须相同。

int var = 0;

int *pt1;
pt1 = &var;         // Correct

int *pt2;
pt2 = pt1;          // Correct

char *pt3;
pt3 = &var;         // Error

char *pt4;
pt4 = pt1;          // Error

char *pt5;
pt5 = (char *)pt1;  // Correct

char *pt6;
pt6 = (char *)1234; // Correct

当把一个int *类型的值赋值给一个char *变量时,一个负责的编译器应当会报错。如果确实需要这么做,可以通过显式类型转换使赋值号两边类型一致。(char *)1234虽然合法,但通常并没有意义。

从上面的例子中我们可以发现,& 运算符返回与运算对象相应的指针类型的值。对int变量取址则返回int *类型的指针。* 运算符同理。

算术运算

指针的算术运算与整数的算术运算不同,一般用在数组操作中,使指针偏移,或求两个对象的距离。

指针与整数的算术运算

指针可以与long int类型的整数进行 +- 运算,与 = 结合可以进行 +=-= 运算,进而也可以进行++--运算。如果是其他整数,则会提升或截断为long int再进行运算。

指针与整数运算的意义是使指针向整数个单位偏移。对于以下指针和数组:

int array[10] = { 0 };
int *pt = &array[3];

pt + 1 == &array[4];
pt - 2 == &array[1];

指针与整数的算术运算返回值为指针,类型与参与运算的指针一致。

运算表达式须满足以下条件:

  1. 进行 + 运算时,指针与整数的位置可以互换。
  2. 进行 - 运算时,必须指针在左,整数在右。

以上是从逻辑层面对指针与整数的运算进行解析,下面分析实现细节。

int类型的变量一般占用4个字节,而指针指向其首地址。从数组array下标3的元素到下标4的元素,指针偏移1个int类型的变量,指针的值增加了4。对于int *类型的指针,每增加整数1,指针的值就增加4,也就是int变量的存储大小。对于char *类型的指针,每增加整数1,指针的值就增加1,因为char变量的存储大小为1个字节。其他指针类型同理。

这就是指针的算术运算与整数运算不同的地方,对于不同的指针类型,与相同的整数做运算,值的变化是不同的,这也体现了指针类型的意义。

指针与指针的算术运算

指针与指针只能进行 - 运算,且参与运算的指针类型必须相同,返回值为long int类型的整数。

指针与指针运算的意义是求两个指针所指对象的距离。对以下指针和数组:

int array[10] = { 0 };
int *pt1 = &array[3];
int *pt2 = &array[7];

pt2 - pt1 == 4;
pt1 - pt2 == -4;

运算结果与相应下标的算术运算结果相同。

对于以下变量:

int var1 = 0;
int var2 = 0;

表达式&var1 - &var2虽然合法,但通常并没有意义。

和指针与整数的算术运算相同,指针与指针的算术运算也不是简单的数值相减,需要再除以指向类型的存储大小,从而得到”下标距离“。对int *类型的指针来说,就是把值相减的结果再除以4。

条件运算

相同类型的指针可以进行条件运算,结果为各自值进行相同条件运算的结果。比如对于以下指针:

int *pt1 = (int *)3;
int *pt2 = (int *)5;

表达式pt1 < pt2为真,因为3小于5,返回1。

特别地,任何类型的指针可以与整数常量0进行 ==!= 运算。

逻辑运算

指针可以作为整数参与逻辑运算,此时对类型没有要求,甚至另一个运算对象可以不是指针。比如对于以下指针:

int *pt1 = (int *)1;
int *pt2 = 0;

(pt1 && 1) == 1;
(pt1 || 0) == 1;
(pt2 && 1) == 0;
(pt2 || 1) == 1;
(!pt2) == 1;

以上表达式全为真。

void * 指针类型

void *是一种特殊的指针类型,表示没有指向类型。

因为没有指向类型,所以void *指针不能进行算术运算与取值运算,只能进行赋值等运算。而且void类型的赋值运算有以下特点:

  1. 任何类型的指针都可以不经过显式类型转换直接赋值给void *类型的指针变量
  2. 在C中,void *类型的指针可以不经过显式类型转换直接赋值给任何类型的指针变量,而C++取消了这一规则。

因为void *指针易于赋值的特点,它经常用作不同类型指针交流的中间变量,或者在不能确定类型的时候使用void *类型。

malloc()函数为例,其作用是申请一块给定大小的内存空间,并返回其首地址。然而函数不能确定调用者会如何使用这块空间,所以返回void *类型。

顶层const与底层const

对于普通变量,在申明时使用const关键字可以使其不允许修改。指针类似,但更为复杂,因为指针可以指定其所指对象的const属性。

对于以下申明:

const int *pt1;
int const *pt2;
int *const pt3;
const int *const pt4;
int const *const pt5;

pt1pt2相同,pt1可以修改,而*pt1不可以修改,这种对其所指对象施加的const称为底层const

pt3不可以修改,*pt3可以修改,这种对指针施加的const称为顶层const

pt4pt5相同,pt4*pt4都不可以修改,同时施加了顶层const与底层const

区别顶层const与底层const的依据是const相对 * 的位置。对于多层指针,位于所有 * 右边,与变量名相邻的const是最顶层的const,之后每向左跨过一个 * ,便向下一层,位于所有 * 左边的const是最底层的const

对于以下指针:

int ***const pt1;
int **const *pt2;
int *const **pt3;
int const ***pt4;
int const *const *const *const pt5;

pt1不可以修改,*pt1**pt1***pt1可以修改。
pt2可以修改,*pt2不可以修改,**pt2***pt2可以修改。
pt3*pt3可以修改,**pt3不可以修改,***pt3可以修改。
pt4*pt4**pt4可以修改,***pt4不可以修改 。
pt5*pt5**pt5***pt5都不可以修改。

简而言之,位于n个 * 前面的const,就意味着对指针进行n次取值后不可修改。

底层(仅限次顶层)非const类型的值可以赋值给底层(仅限次顶层)const类型的变量,反之不可以。对于以下指针:

int *pt1 = 0;
int const *pt2 = 0;

pt2 = pt1;  //Correct
pt1 = pt2;  //Error

加强版:

int ****pt1 = 0;
int ***const *pt2 = 0;

pt2 = pt1;  //Correct
pt1 = pt2;  //Error

此规则在函数参数传递时仍然适用,所以将形参指针申明为底层(仅限次顶层)const类型可以增加函数的适用性。

指针申明中的const只对通过该指针访问变量有效。对于以下变量和指针:

int var = 0;
int const *pt = &var;

*pt = 5;    //Error
var = 5;    //Correct

指针与数组

首先需要说明的是,数组不是指针

尽管数组名经常可以当作指针来使用,但二者是不同的。

数组名作为指针使用时,其值和本身的地址是一样的,而指针的值和地址一般没有关系。

在全局位置申明和定义时混用数组和指针,会产生严重后果。

数组在内存上是一片连续的空间,数组名经常可以当作其第一个元素的指针来使用。在赋值运算、算术运算、取值运算等以及函数参数中,数组名的表现与顶层const指针一致。但是如果分别对指针和数组使用sizeof运算符,结果是不一样的。

int array[10] = { 0 };
int *pt = array;
sizeof(array) == 40;
sizeof(pt) == 8;

对数组进行sizeof运算,返回值为整个数组占用的内存大小;对指针进行sizeof运算,返回值为指针的存储大小,在64位机上是8字节。

另外,对数组取地址,返回值类型为行指针;而对指针变量取地址,则返回该变量的指针。

数组名作为指针

数组的下标运算符[]其实是一种简写,array[n]*(array + n)的行为完全一致。(数组申明中的[]只是一种记号,不是运算符)

int array[5];

array[2] == *(array + 2);

数组名可以当作其第一个元素的指针,对其直接取值就可得到第一个元素。

*array == *(array + 0);
*(array + 0) == array[0];

*array == array[0];

这就是数组下标从0开始的原因。

多维数组同理。[]运算符为自左向右结合,所以

array[a][b][c] == ((array[a])[b])[c];
((array[a])[b])[c] == *(*(*(array + a) + b) + c);

array[a][b][c] == *(*(*(array + a) + b) + c);
array[0][0][0] == ***array;

由于指针与整数相加时二者的位置可以互换,所以

array[3] == *(array + 3);
*(array + 3) == *(3 + array);
*(3 + array) == 3[array];

array[3] == 3[array];

但是强烈不建议第二种可读性差的写法。

把数组名作为指针时,可以给相同类型的指针变量赋值。但是由于数组名的顶层const属性,无法给数组名赋值。

int array[5] = { 0 };
int *pt = 0;

pt = array;     //Correct
array = pt;     //Error

指针作为数组名

前面说到[]运算符只是一种简写,所以也可以对指针使用[]运算符。

int var = 0;
int *pt = &var;

*pt == pt[0];

当然也可以用指针冒充数组。

int array[5];
int *pt = array;

pt[3] == array[3];

对于多维数组,情况复杂一些。

int array[3][5];
int **pt = array;

数组array表示“包含3个'包含5个int变量的数组'的数组”(多维数组是元素为数组的数组),而指针pt表示“指向一个int *类型指针的指针”。

众所周知,多维数组在内存中是按列优先连续存储的,若array偏移1个单位,则相应的指针偏移5个int变量的距离;而pt偏移1个单位,只偏移1个int *变量的距离。

所以,如何申明这种拥有特殊偏移规则的指针?

C引入了“行指针”以自定义偏移规则,根据声明方式应该与使用方式相同的原则,行指针应该如下申明:

int (*p)[5];

因为最左边的[3]不影响偏移规则,所以只需表明[5]即可。

行指针可以冒充多维数组。

int array[3][5][7];
int (*pt)[5][7] = array;

pt[1][2][3] == array[1][2][3];

指针与数组在函数中的使用

函数在参数传递时只有值传递,所以不能将整个数组的副本传递给函数。

如果需要将数组传递给函数,只能将数组名作为第一个元素指针当作实参传递。同样,函数的形参也只能申明为指针,然后当作数组使用。

此外,因为值传递的限制,函数的返回值可以是指针,但不能是数组。

基本用法如下:

int fn(int *array);

int main(void) {
    int array[5] = { 0 };
    fn(array);

    return 0;
}

int fn(int *array) {
    return array[3];
}

如果传递多维数组,只需把指针申明为相应的行指针。

int fn(int (*array)[5]);

int main(void) {
    int array[3][5] = { 0 };
    fn(array);

    return 0;
}

int fn(int (*array)[5]) {
    return array[1][3];
}

还有一种更加直观的申明方式:

int fn(int (*array)[5][7]);

int main(void) {
    int array[3][5][7] = { 0 };
    fn(array);

    return 0;
}

int fn(int array[][5][7]) {
    return array[1][3][5];
}

在申明int array[][5][7]中,因为第一个[]的内容不影响偏移规则,所以可以为空或者任意值。这种申明方式更加直观,建议使用。

需要注意的是,即使函数形参如int array[3][5][7]看起来是数组,但它只是指针,对其使用sizeof运算符结果为8,即一个指针的存储大小。甚至,可以在定义和申明函数时混用两种申明方式,因为他们本质上是一致的。

指针与函数

指针与函数的关系跟指针与数组的关系类似。

在早期的C中,函数指针与函数名的概念是相对独立的,对函数取地址以后才可以当作函数指针,对函数指针取值后才可以当作函数名使用。而在现代C标准中,函数名与函数指针经常可以混用。

函数名作为指针

根据现代C标准,函数名经常可以直接作为顶层const函数指针来使用,同时也保留了之前的用法,对函数取地址之后类型与值都不变。所以函数名有以下三种合法的使用方法:

int fn(int);

fn(5);
(&fn)(5);
(*(&fn))(5);

实际上,函数名当作函数指针时,其值就是函数入口地址,()是函数调用运算符,可以对函数指针及括号内的实参进行运算,表现为调用函数。

函数名可以作为指针赋值给相同类型的函数指针。

如果需要向函数传递函数,可以将函数名作为函数指针当作实参。

指针作为函数名

根据早期C语言对函数指针的定义,以及声明方式应该与使用方式相同的原则,形成了函数指针的申明方式:

int fn(int, char);
int (*pt)(int, char) = fn;

以上分别申明了一个函数与其对应的函数指针。跟函数原型一样,申明函数指针时仅保留参数类型即可,形参名可以省略,当然也可以像函数申明一样完全省略参数。

现代C标准规定可以直接将函数指针当函数名使用,而无需先对其取值,同时也保留了之前的使用方法,对函数指针取值后类型和值都不变。对于上面申明的函数指针,以下两种用法都是合法的:

pt(5, 'a');
(*pt)(5, 'a');

如果需要向函数传递函数,只需将函数的形参申明为函数指针,并在函数调用时将函数指针作为实参。 比如以下程序:

#include <stdio.h>

void fn1(void);
void fn2(void (*)(void));

int main(void) {
    fn2(fn1);

    return 0;
}

void fn1(void) {
    printf("hello, world\n");
}

void fn2(void (*pt)(void)) {
    pt();
}

执行结果为输出字符串"hello, world\n"

指针与结构体/联合体

因为结构体和联合体在使用方式上一致,所以以下全部使用“结构体”代替“结构体/联合体”。

结构体与指针的关系普普通通,中规中矩,如果结构体指针显得复杂,是因为结构体和指针二者本来分别就很复杂。结构体指针通常用于复杂的数据结构,所以通常很抽象。

结构体和指针唯一的火花是->运算符,看起来像一个箭头,与其作用相应。

对于以下结构体和指针:

struct Type {
    int a;
    char b;
} var;

struct Type *pt = &var;

如果要通过指针pt访问var的成员a,按照指针的使用方法应写为(*pt).a。因为 . 运算符的优先级高于 * 运算符,所以必须在*pt两边加括号。在数据结构的操作中,经常需要通过指针访问结构体成员,而上述写法过于繁琐,于是C提供了一种简写pt->a(*pt).apt->a的行为完全一致。

同样,可以给结构体指针施加const属性,且规则与“顶层const与底层const”一节相同。

对结构体指针施加顶层const只限定指针值不可以修改。

对结构体指针施加底层const限定不可以修改结构体及其成员。

你知道吗

  • 整数常量0可以不经过显式类型转换直接赋值给任何类型的指针变量
  • NULL是宏,定义在头文件stddef.h中,常用的头文件如stdio.hstdlib.h都包含此头文件。
    在C中,NULL展开为((void *)0),而在C++中展开为0
    C++引入关键字nullptr替代NULL,其为(void *)0
  • 指针可以作为整数成为ifwhilefor等语句()内的判断条件

写在最后:

确定“指针”这个主题的时候,我没有想到内容竟然这么多,不仅编写用了大量时间,而且篇幅过长,影响阅读体验。今后写文章我会尽量缩短篇幅,或者写一些内容较少的“番外篇”,并采用一些更加生动的语言,在严谨与可读性方面做一些权衡。

写这篇文章的时候我做了大量的测试与查证,尽可能确保所有内容都真实可靠。但我相信还是有缺陷,如果发现有任何不足,还请指正。

最后,希望大家喜欢这篇文章。如果获得足够的支持,我会加快更新速度。