5019 字
25 分钟
【专题】三篇文章搞定C指针——其一

前言#

本期专题:《三篇文章搞定 C 指针》

专题地址(打不开就是没写完或者在修改):

这一期是 C 语言的指针专题,其实大部分内容也同样适用于 C++ 中,后续有的指针补充将会另出专题。

在这一篇文章中介绍了什么是指针和指针的基本操作,但没有设计指针与数组的关系,指针与函数的关系,指针与字符串等等其他指针操作。

[warning]本专题文章内容相对基础,过于专业的部分会省略。[/warning]

本专题参考文献或网站如下(References):

  • 《C Primer Plus》

  • 《Pointer on C》

  • Akaedu

  • runoob.com

本文文案: @Hoyue


介绍#

我们要理解指针是什么,首先我们应该先知道地址的概念。

地址#

我们可以把计算机的内存看作是一条长街上的一排房屋。每座房子都可以容纳数据,并通过一个房号来标识。如图:

但计算机的内存由数以亿万计的位(bit)组成,每个位只可以容纳值 0 或 1,所以单独的位用处不大,通常许多位合成一组作为一个单位,这样就可以存储范围较大的值。

地址具有这样的性质:

1.内存中的每个位置由一个独一无二的地址标识。

2.内存中的每个位置都包含一个值。

我们硬件上本质就是访问地址,只是在编程语言中,我们使用变量来标记这些地址,然后编译器帮助翻译为计算机的地址。


指针#

在计算机中,地址我们使用指针来存储,指针也就是内存地址,指针变量是用来存放内存地址的变量。

而指针变量的本质:把一个变量所在的内存单元的地址保存在另外一个内存单元中,保存地址的这个内存单元称为指针,通过指针和间接寻址访问变量,这种指针在 C 语言中可以用一个指针类型的变量表示。

像其他变量或常量一样,必须在使用指针存储其他变量地址之前,对其进行声明,例如:

int *a;

用来声明指针的星号 * 与乘法中使用的星号是相同的。但是,在这个语句中,星号是用来指定一个变量是指针。

所有实际数据类型,不管是整型、浮点型、字符型,还是其他的数据类型,对应指针的值的类型都是一样的,都是一个代表内存地址的长的 十六进制数

不同数据类型的指针之间唯一的不同是,指针所指向的变量或常量的数据类型不同。

那指针变量和其他变量有什么区别呢?例如:

int i;
int *pi = &i;
char c;
char *pc = &c;

这几个变量的内存布局如下图所示,在初学阶段经常要借助于这样的图来理解指针。

这里的 & 是取地址运算符(Address Operator),&i 表示取变量 i 的地址,int *pi = &i; 表示定义一个指向 int 型的指针变量 pi,并用 i 的地址来初始化 pi

关于取地址符之后会讲的。
那么我们可以把指针看成一种新的类型,要搞清一个指针需要搞清指针的四方面的内容,这些在之后会讲:

  1. 指针的类型
  2. 指针所指向的类型
  3. 指针的值或指针所指向的内存区
  4. 指针变量的内容

指针的类型#

从语法的角度看,你只要把指针声明语句里的指针名字去掉,剩下的部分就是这个指针的类型。例如:

int *ptr;
char *ptr;
int **ptr;
int (*ptr)[3];
int *(*ptr)[4];
  • 1、int *ptr; : 指针的类型是 int*
  • 2、char *ptr; : 指针的类型是 char*
  • 3、int **ptr; : 指针的类型是 int** // 此时这个 ** 为二重指针,之后在指针的指针位置有讲
  • 4、int (*ptr)[3]; : 指针的类型是 int(*)[3]
  • 5、int *(*ptr)[4]; : 指针的类型是 int*(*)[4]

所以简单来说就是去掉变量名的部分就是指针的类型。

大家也许会疑惑为什么这个指针的类型还包括了 *,我们接着看。


指针所指向的类型#

当你通过指针来访问指针所指向的内存区时,指针所指向的类型决定了编译器将把那片内存区里的内容当做什么来看待。从语法上看,你只须把指针声明语句中的指针名字和名字左边的指针声明符 * 去掉,剩下的就是指针所指向的类型。

  • 1、int *ptr; : 指针所指向的类型是 int
  • 2、char *ptr; : 指针所指向的类型是 char
  • 3、int **ptr; : 指针所指向的类型是 int*
  • 4、int (*ptr)[3]; : 指针所指向的类型是 int()[3]
  • 5、int *(*ptr)[4]; : 指针所指向的类型是 int*()[4]

简单来说就是指针的类型,再去掉 *。


指针的值#

指针的值是指针本身存储的数值,这个值将被编译器当作一个地址,而不是一个一般的数值。一般来说,指针的值是一个十六进制数。我们说一个指针的值是 XX,就相当于说该指针指向了以 XX 为首地址的一片内存区域;我们说一个指针指向了某块内存区域,就相当于说该指针的值是这块内存区域的首地址。

因此,为了得到正确的答案,对值进行正确的使用是非常重要的。


指针变量的内容#

指针存的是一串十六进制数,我们平常肯定不会想要去得到这些十六进制数,那我们该怎么识别它呢?

例如:

int a=100;
int *b;
b=&a;
int c;
c=*b;

此时 a=100 这是显而易见的,b 是一个整数型的指针变量,b 里存的是地址,故 b=a 的地址。而 c=*b,表示 c=b 地址代表的值。
这里就有两个运算符 & 和 * 的运用了,接下来我们就来讲这两个运算符。


& 和 *#

首先,它们是两种指针运算符,一种是取地址运算符 &,一种是间接寻址运算符 *。

& 是一元运算符,返回操作数的内存地址。如果一个变量 var 是一个的类型为 T 的变量,则 &var 是它的地址。因此 & 读作**“取地址运算符”,这意味着,&var** 读作 “var 的地址”。

相反的,当已具有一个地址,并且希望获取它所引用的对象时,使用间接运算符 *。通过一个指针访问它所指向的地址的过程称为间接访问(indirection)或解引用指针(dereferencing the pointer)。这个用于执行间接访问的操作符是单目操作符 *。

例如有一个指针变量 ptr,我同样定义为类型 T。即:T *ptr; ,那么我们让它和 var 产生联系:

ptr = &var;//令指针指向 var

则此时的 *ptr 就表示了 var 的值了。

总结一下就是:指针变量不加 * 表示地址,加了 * 表示指针指向位置的值。

// 定义 var
T var;
// 获取 var 的地址
ptr = &var;
// 获取 ptr 的地址所对应的值,即 var 的值
T val = *ptr;

由上方的代码可以看出,& 和 * 其实有互补性,那么以下代码是合法的:

// For any expression E, such that &E is valid (Example &5 is invalid)
*(&E) == E // * 和 & 抵消了

如果 date 是一个数组名,以下三种形式是一样的:

  1. *(&dates)
  2. dates
  3. &dates[0]

都表示首元素的地址。


NULL 空指针#

在变量声明的时候,如果没有确切的地址可以赋值,为指针变量赋一个 NULL 值是一个良好的编程习惯。赋为 NULL 值的指针被称为空指针。

NULL 指针是一个定义在标准库中的值为零的常量。例如:

int *a = NULL;

此时:a=0x0,是一个 0 地址。

那我们为什么要定义零指针呢?

我们创建了一个名叫 a 的指针变量,如果没有对它赋初值(初始化),我们就没有办法预测 12 这个值将存储于什么地方。

从这一点看,指针变量和其他变量并无区别。如果变量是静态的,它会被初始化为 0;但如果变量是自动的,它根本不会被初始化。无论是哪种情况,声明一个指向整型的指针都不会“创建”用于存储整型值的内存空间。所以,如果程序执行这个赋值操作,会发生什么情况呢?如果你运气好,a 的初始值会是个非法地址,这样 赋值语句将会出错,从而终止程序


指针常量#

假定变量 a 存储于位置 100,我们有:

*100 = 25;

它看上去像是把 25 赋值给 a,因为 a 是位置 100 所存储的变量。但是,这是非法的,因为字面值 100 的类型是整型,而间接访问操作只能作用于指针类型表达式。

这时就要涉及指针常量了,但是当涉及指针变量时,可能有两样东西都有成为常量——指针变量和它所指向的实体。下面是几个声明的例子:

int *pi; // pi 是一个普通的指向整型的指针。
const int *pci; // pci 是一个指向整型常量的指针。可以修改指针的值,但不能修改它所指向的值。
int *const cpi; // cpi 为一个指向整型的常量指针。指针是常量,不能改为指向别处,但可修改所指向整型的值。
const int* const cpci; // 无论指针本身还是它所指向的值都是常量,不允许修改。

指针的指针#

这个名字也许有点拗口,我们看下面这个例子:

int a = 12;
int *b = &a;

此时 a 和 b 的关系就是这样的:

若我们还有一个变量 c,使 c = &b; 那么这个变量 c 又是什么类型的呢?

它们的关系如下:

变量 b 是一个“指向整型的 指针”,所以任何指向 b 的类型必须是指向“指向整型的指针”的 指针,更通俗地说,是一个指针的指针。所以 c 是 b 的指针,是 a 的指针的指针。

故 c 应该是这样声明的:int **c; 故上面的可以写成:int **c=&b;

* 操作符具有从右向左的结合性,所以这个表达式相当于 *(*c),我们必须从里向外逐层求值。*c 访问 c 所指向的位置,我们知道这是变量 b。第 2 个间接访问操作符访问这个位置所指向的地址,也就是变量 a。

故它们的值我们用一个表来呈现:


间接访问和左值#

涉及指针的表达式能不能作为左值?如果能,又是哪些呢?例如我们有:

int a;
int *d = &a;

指针变量可以作为左值,并不是因为它们是指针,而是因为它们是变量。对指针变量进行间接访问表示我们应该访问指针所指向的位置。间接访问指定了一个特定的内存位置,这样我们可以把间接访问表达式的结果作为左值使用。

因此我们可以进行指针运算,但在这之前,我们还需要看看指针表达式的相应特点。


指针表达式#

首先,我们就有了两个变量,它们初始化如下:

char ch = 'a';
char *cp = &ch;

ch 作为右值时,表达式的值为 'a',但是,当这个表达式作为左值使用时,它是这个内存的地址而不是该地址所包含的值。

这些是 ch 的情况,接下来是 &ch 的情况。

[warning]接下来的图中:粗椭圆表示变量的值就是表达式的值,粗方框表示这个位置就是表达式的结果。[/warning]

如图:

作为右值,这个表达式的值是变量 ch 的地址。& 操作符的结果是个右值,它不能当作左值使用。

当表达式 &ch 进行求值时,它的结果应该存储于计算机的某个地方,但你无法知道它位于何处。这个表达式并未标识任何机器内存的特定位置,所以它不是一个合法的左值。

接下来到 cp

它的右值如图所示就是 cp 的值(非 ch 的值)。它的左值就是 cp(本身) 所处的内存位置,由于这个表达式并不进行间接访问操作,所以你不必依箭头所示进行间接访问。

这个例子与 &ch 类似,不过我们这次所取的是指针变量的地址。这个结果的类型是指向字符的指针的指针。同样,这个值的存储位置并未清晰定义,所以这个表达式不是一个合法的左值。

了解了这些以后,我们就来看指针的运算。


指针运算#

C 的指针算术运算只限于两种形式。指针 ± 整数 和 指针 - 指针。

指针 ± 整数#

标准定义这种形式 只能用于指向数组中某个元素的指针,数组中的元素存储于连续的内存位置中,后面元素的地址大于前面元素的地址。因此,我们很容易看出,对一个指针加 1 使它指向数组中下一个元素,加 5 使它向右移动 5 个元素的位置,依次类推。把一个指针减去 3 使它向左移动 3 个元素的位置。对整数进行扩展保证对指针执行加法运算能产生这种结果,而不管数组元素的长度如何。

同样的,我们有两个声明:

char ch = 'a';
char *cp = &ch;

基础加减法#

如图:

这里有两个操作符(*+)。* 的优先级高于 +,所以首先执行间接访问操作(如图中 cp 到 ch 的实线箭头所示),我们可以得到它的值(如虚线椭圆所示)。

我们取得这个值的一份拷贝并把它与 1 相加,表达式的最终结果为字符 'b'(因为在计算机中,字符是用数字 ASCII 码存储的,一类字符是连续的)。

那么如果先执行 + 会怎么样?

这个括号使表达式先执行加法运算,就是把 1 和 cp 中所存储的地址相加。此时的结果值是图中虚线椭圆所示的指针。接下来的间接访问操作随着箭头访问紧随 ch 之后的内存位置。

这个表达式的右值就是这个位置的值,而它的左值是这个位置本身。

注意指针加法运算的结果是个右值,因为它的存储位置并未清晰定义。如果没有间接访问操作,这个表达式将不是一个合法的左值。

那么我们对比一下这两种加法:

  • *cp+1 其实就是:(*cp)+1,而 *cp 算出了 ch 的值,即 'a',再加 1,ASCII 码 +1,即 'b'
  • *(cp+1) 其实就是 cp 的地址加上 1,再取到这个地址所对应的值,即 'b'
  • 它们也许计算结果是一样的,但计算本质不同。不能认为这两个表达式没有区别。

++ 运算#

++-- 操作符在指针变量中使用得相当频繁,所以在这种上下文环境中理解它们是非常重要的。

在展示指针的 ++ -- 运算前,我们先来复习一下前缀形式和后缀形式的区别。

很多书上会说:

i++ 是先赋值,然后再自增;++i 是先自增,后赋值。

但实际上我们使用这两个时需要看情况:

  1. 单独拿出来说 ++ii++,意思都是一样的,就是 i=i+1

  2. 当做运算符时(需要返回值时),例如 a=i++ 或者 a=++i 这样的形式:a=i++,这个运算的意思是先把 i 的值赋予 a,然后再执行 i=i+1a=++i,这个的意思是先执行 i=i+1,然后再把 i 的值赋予 a

  3. i++ 返回原来的值,++i 返回加 1 后的值。

  4. i++ 不能作为左值,而 ++i 可以。

  5. 对于 C 而言,不用作返回值,这两个没区别。对于 C++ 中,可能出现运算符重载,会有效率的区别。

接下来回归正题,我们来看有返回值的两种运算在指针下的区别:

后缀 ++ 操作符增加 cp 的值,它先返回 cp 值的一份拷贝然后再增加 cp 的值。

前缀 ++ 先增加它的操作数的值再返回这个结果。

前面两个表达式的值都不是合法的左值。但如果我们在表达式中增加了间接访问操作符,它们就可以成为合法的左值,如下面的两个表达式所示。

前缀形式中间接访问操作符作用于增值后的指针的拷贝上,所以它的右值是 ch 后面那个内存地址的值,而它的左值就是那个位置本身。

使用后缀 ++ 操作符所产生的结果不同:它的右值和左值分别是变量 ch 的值和 ch 的内存位置,也就是 cp 原先所指。同样,后缀 ++ 操作符在周围的表达式中使用其原先操作数的值。

指针 - 指针#

只有当两个指针都指向同一个数组中的元素时,才允许从一个指针减去另一个指针。

两个指针相减的结果的类型是 ptrdiff_t,它是一种有符号整数类型。减法运算的值是两个指针在内存中的距离(以数组元素的长度为单位,而不是以字节为单位),因为减法运算的结果将除以数组元素类型的长度。例如,如果 p1 指向 array[i]p2 指向 array[j],那么 p2-p1 的值就是 j-i 的值。

当它作用于其他的类型时,例如假定前图中数组元素的类型为 float,每个元素占据 4 个字节的内存空间。如果数组的起始位置为 1000,p1 的值是 1004,p2 的值是 1024,但表达式 p2-p1 的结果值将是 5,因为两个指针的差值(20)将除以每个元素的长度(4)。

如果两个指针所指向的不是同一个数组中的元素,那么它们之间相减的结果是未定义的。就像如果你把两个位于不同街道的房子的门牌号码相减不可能获得这两所房子间的房子数一样。程序员无从知道两个数组在内存中的相对位置,如果不知道这一点,两个指针之间的距离就毫无意义。

其他关系运算#

除了两种算术运算以外,我们可以使用关系运算,例如:
< <= > >=
用法没什么区别,就是是地址的运算而已。


后记#

三篇文章搞定 C 指针——其一就到此结束了,这篇文章介绍了基础的指针知识。在下一篇文章中,我们将介绍一些指针在数组和函数中的运用,之后的篇幅应该不会有这一篇这么长了,感谢各位的观看。

【专题】三篇文章搞定C指针——其一
https://hoyue.fun/pointer_c1.html
作者
Hoyue
发布于
2021-12-25
最后更新于
2024-01-14
许可协议
CC BY-NC-SA 4.0
评论