【C进阶】第十三篇——指针详解(一)

简介: 【C进阶】第十三篇——指针详解

指针的基本概念


堆栈有栈顶指针,队列有头指针和尾指针,这些概念中的"指针"本质上是一个整数,是数组的索引,通过指针访问数组中的某个元素,经过学习我们在间接寻址那里看到了另一个指针的概念,把一个变量所在的内存单元的地址保存在另外一个内存单元中,保存地址的这个内存单元称为指针,通过指针和间接寻址访问变量,这种指针在C语言中可以用一个指针类型的变量表示,例如某程序中定义了以下全局变量:

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

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

image.png

这里的&是取地址运算符,&i表示取变量i的地址,int *pi=&i;表示定义一个指向int型的指针变量pi,并用i的地址来初始化pi.我们讲过全局变量只能用常量表达式初始化,如果定义int p=i;就错了,因为i不是常量表达式,然而用i的地址来初始化一个指针却没有错,因为i的地址是在编译链接时能确定的,而不需要运行时才知道,&i是常量表达式。后面两行代码定义了一个字符型变量c和一个指向c的字符型指针pc,注意pi和pc虽然是不同类型的指针变量,但它们的内存单元都占4个字节,因为要保存32位的虚拟地址,同理,在64位平台上指针变量都占8个字节。

我们知道,在同一个语句中定义多个数组,每一个都要有[]号:int a[5],b[5];同样道理,在同一个语句中定义多个指针变量,每一个都要有*号,例如:

int *p, *q;

如果写成int* p,q;就错了,这样是定义了一个整型指针p和一个整型变量q,定义数组的[]号写在变量后面,而定义指针的*号写在变量前面,更容易看错。定义指针的*号前后空格都可以省,写成int*p,*q;也算对,但*号通常和类型int之间留空格而和变量名写在一起,这样看int *p, q;就很明显是定义一个指针和一个整型变量,就不容易看错了。

如果要让pi指向另一个整型变量j,可以重新对pi赋值:

pi = &j;

如果要改变pi所指向的整型变量的值,比如把变量j的值增加10,可以写:

*pi = *pi + 10;

这里的*号是指针间接寻址运算符,*pi表示取指针pi所指向的变量的值,也称为Dereference操作,指针有时称为变量的引用,所以根据指针找到变量称为Dereference.


&运算符的操作数必须是左值,因为只有左值才表示一个内存单元,才会有地址,运算结果是指针类型。*运算符的操作数必须是指针类型,运算结果可以做左值。所以,如果表达式E可以做左值,*&E和E等价,如果表达式E是指针类型,&*E和E等价。

指针之间可以相互赋值,也可以用一个指针初始化另一个指针,例如:

int *ptri=pi;

或者:

1. int *ptri;
2. ptri=pi;

表示pi指向哪就让ptri也指向哪,本质上就是把变量pi所保存的地址值赋给变量ptri.用一个指针给另一个指针赋值时要注意,两个指针必须是同一类型的。在我们的例子中,pi是int*型的,pc是char*型的,pi=pc;这样赋值就是错误的。但是可以先强制类型转换然后赋值:

pi = (int *)pc;

把char *指针的值赋给int *指针

image.png

现在pi指向的地址和pc一样,但是通过*pc只能访问到一个字节,而通过*pi可以访问到4个字节,后3个字节已经不属于变量c了,除非你很确定变量c的一个字节和后面3个字节组合而成的int值是有意义的,否则就不应该给pi这么赋值。因此使用指针要特别小心,很容易将指针指向错误的地址,访问这样的地址可能导致段错误,可能读到无意义的值,也可能意外改写了某些数据,使得程序在随后的运行中出错。有一种情况需要特别注意,定义一个指针类型的局部变量而没有初始化:

int main(void)
{
 int *p;
 ...
 *p = 0;
 ...
}

我们知道,在堆栈上分配的变量初始值是不确定的,也就是说指针p所指向的内存地址是不确定的,后面用*p访问不确定的地址就会导致不确定的后果,如果导致段错误还比较容易改正,如果意外改写了数据而导致随后的运行中出错,就很难找到错误原因了。像这种指向不确定地址的指针称为"野指针",为了避免野指针,在定义指针变量时就应该给它明确的初值,或者把它初始化位NULL:

int main(void)
{
 int *p = NULL;
 ...
 *p = 0;
 ...
}

NULL在C标准库的头文件stddef.h中定义:

#define NULL ((void *)0)

就是把地址0转换成指针类型,称为空指针,它的特殊之处在于,操作系统不会把任何数据保存在地址0及其附近,也不会把地址0-0xfff的页面映射到物理内存,所以任何对地址0的访问都会立刻导致段出错。*p=0;会导致段错误,就像放在眼前的炸弹一样很容易被找到,相比之下,野指针的错误就像埋下地雷一样,更难发现和排除,这次走过去没事,下次走过去就有十。


讲到这里就该讲一下void*类型了。在编程时经常需要一种通用指针,可以转换为任意其他类型的指针,任意其他类型的指针也可以转换为通用指针,最初C语言中没有void*类型,就把char*当通用指针,需要转换时就用类型转换运算符(),ANSI在将C语言标准化时引入了void*类型,void*指针与其他类型的指针之间可以隐式转换,而不必用类型转换运算符。注意,只能定义void*指针,而不能定义void型的变量,因为void*指针和别的指针一样都占4个字节,而如果定义void型变量,编译器不知道该分配几个字节给变量。同样道理,void*指针不能直接Dereference,而必须先转换成别的类型的指针再做Dereference.void*指针常用于函数接口。比如:

void func(void *pv)
{
 /* *pv = 'A' is illegal */
 char *pchar = pv;
 *pchar = 'A';
}
int main(void)
{
 char c;
 func(&c);
 printf("%c\n", c);
...

下一章讲函数接口时再详细介绍void *指针的用处。

指针类型的参数和返回值


首先看以下程序:

指针参数和返回值

#include <stdio.h>
int *swap(int *px, int *py)
{
 int temp;
 temp = *px;
 *px = *py;
 *py = temp;
 return px;
}
int main(void)
{
 int i = 10, j = 20;
 int *p = swap(&i, &j);
 printf("now i=%d j=%d *p=%d\n", i, j, *p);
 return 0;
}

我们知道,调用函数的传参过程相当于用实参定义并初始化形参,swap(&i,&j)这个调用相当于:

1. int *px = &i;
2. int *py = &j;

所以px和py分别指向main函数的局部变量i和j,在swap函数中读写*px和*py其实是读写main函数的i和j。尽管在swap函数的作用域中访问不到j和j这两个变量名,却可以通过地址访问它们,最终swap函数将i和j的值做了交换。

上面的例子还演示了函数返回值是指针的情况,return px;语句相当于定义了一个临时变量并用px初始化:

int *tmp = px;

然后临时变量tmp的值成为表达式swap(&i, &j)的值,然后在main函数中又把这个值赋给了p,相当

于:

int *p=tmp;

最后的结果是swap函数的px指向哪就让main函数的p指向哪。我们知道px指向i,所以p也指向i.

指针与数组


先看个例子,有如下语句:

1. int a[10];
2. int *pa = &a[0];
3. pa++;

首先指针pa指向a[0]的地址,注意后缀运算符的优先级高于单目运算符,所以是取a[0]的地址,而不是取a的地址。然后pa++让pa指向下一个元素(也就是a[1]),由于pa是int *指针,一个int型元素占4个字节,所以pa++使pa所指向的地址加4,注意不是加1.


下面画图理解。从前面的例子我们发现,地址的具体数值其实无关紧要,关键是要说明地址之间的关系(a[1]位于a[0]之后4个字节处)以及指针与变量之间的关系(指针保存的是变量的地址),现在我们换一种画法,省略地址的具体数值,用方框表示存储空间,用箭头表示指针和变量之间的关系。

image.png

既然指针可以用++运算符,当然也可以用+,-运算符,pa+2这个表达式也是有意义的,如上图所示,pa指向a[1],那么pa+2指向a[3]。事实上,E1[E2]这种写法和(*((E1)+(E2)))是等价的,*(pa+2)也可以写成pa[2],pa就像数组名一样,其实数组名也没有什么特殊的,a[2]之所以能取数组的第2个元素,是因为它等价于*(a+2),在数组那里讲过数组名做右值时自动转换成指向首元素的指针,所以a[2]和pa[2]本质上是一样的,都是通过指针间接寻址访问元素。由于(*((E1)+(E2)))显然可以写成(*((E2)+(E1))),所以E1[E2]也可以写成E2[E1],这意味着2[a],2[pa]这种写法也是对的,但一般不这么写。另外,由于a做右值使用时和&a[0]是一个意思,所以int *pa=&a[0];通常不这么写,而是写成更简单的形式int *pa=a;。


在数组那里讲过C语言允许数组下标是负数,现在你该明白为什么这样规定了。在上面的例子中,表达式pa[-1]是合法的,它和a[0]表示同一个元素。


现在猜一下,两个指针变量做比较运算表示什么意义?两个指针变量做减法运算又有什么什么意义?


你理解了指针和常数的加减的概念,再根据以往使用比较运算的经验,就应该猜到pa+2>pa,pa-1==a,所以指针指针的比较运算比的是地址,C语言正是这样规定的,不过C语言的规定更为严谨,只有指向同一个数组中元素的指针之间相互比较才有意义,否则没有意义。那么两个指针相减表示什么?pa-a等于几?因为pa-1==a,所以pa-a显然等于1,指针相减表示两个指针之间相差的元素的个数,同样只有指向同一个数组中的元素的指针之间相减才有意义。两个指针相加表示什么?想不出来它能有什么意义,因此C语言也规定两个指针不能相加。


在取数组元素时用数组名和用指针的语法一样,但如果把数组名做左值使用,和指针又有区别。例如pa++合法,但a++就不合法,pa=a+1是合法的,但是a=pa+1是不合法的。数组名做右值时转换成指向首元素的指针,但做左值仍然表示整个数组的存储空间,而不是首元素的存储空间,数组名做左值时还有一点特殊之处,不支持++,赋值这些运算符,但支持取地址运算符&,所以&a是合法的。


在函数原型中,如果参数是数组,则等价于参数是指针的形式,例如:

void func(int a[10])
{
 ...
}

等价于:

1. void func(int *a)
2. {
3.  ...
4. }

第一种形式方括号中的数字可以不写,仍然是等价的:

1. void func(int a[])
2. {
3.  ...
4. }

参数写成指针形式还是数组形式对编译器来说没区别,都表示这个参数是指针,之所以规定两种形式是为了给读代码的人提供有用的信息,如果这个参数指向一个元素,通常写成指针的形式,如果这个参数指向一串元素的首元素,则经常写成数组的形式。

指针与const限定符


const限定符和指针结合起来常见的情况有以下几种:

1. const int *a;
2. int const *a;

这两种写法是一样的,a是一个指向const int型的指针,a所指向的内存单元不可改写,所以(*a)++是不允许的,但a可以改写,所以a++是允许的。

int * const a;

a是一个指向int型的const指针,*a是可以改写的,但a不允许改写。

int const* const a;

a是一个指向const int型的const指针,因此*a和a都不允许改写。

指向非const变量的指针或者非const变量的地址可以传给指向const变量的指针,编译器可以做隐式类型转换,例如:

char c = 'a';
const char *pc = &c;

但是,指向const变量的指针或者const变量的地址不可以传给指向非const变量的指针,以免投过后者意外改写了前者所指向的内存单元,例如对下面的代码编译器会报警告:

1. const char c = 'a';
2. char *pc = &c;

即使不用const限定符也能写出正确的程序,但良好的编程习惯应该尽可能多的使用const,因为:


1.const给读代码的人传达非常有用的信息。比如一个函数的参数是const char *,你在调用这个函数时就可以放心地传给它char *或const char *指针,而不必担心指针所指的内存单元被改写。


2.尽可能多地使用const限定符,把不该变的都声明成只读,这样可以依靠编译器检查程序中的Bug,防止意外改写数据。


3.const对编译器优化是一个有用的提示,编译器也许会把const变量优化成常量。


字符串字面值通常分配在.rodata段,字符串字面值类似于数组名,做右值使用时自动转换成指向首元素的指针,这种指针应该是const char *型。我们知道printf函数原型的第一个参数是const char *型,可以把char *或const char *指针传给它,所以下面这些调用都是合法的:

const char *p = "abcd";
const char str1[5] = "abcd";
char str2[5] = "abcd";
printf(p);
printf(str1);
printf(str2);
printf("abcd");

注意上面第一行,如果要定义一个指针指向字符串字面值,这个指针应该是const char*型,如果写成char *p="abcd";就不好了,有隐患,例如:

int main(void)
{
 char *p = "abcd";
...
 *p = 'A';
...
}

*p指向.rodata段,不允许改写,但编译器不会报错,在运行时会出现段错误。


相关文章
|
7月前
|
存储 编译器 C语言
C语言内功修炼--指针详讲(进阶)
C语言内功修炼--指针详讲(进阶)
|
存储 算法
学C的第二十六天【指针的进阶(二)】
6 . 函数指针数组 (1). 含义: 函数指针数组 是一个数组,是用于存放 函数指针 的数组。 例如:
学C的第二十七天【指针的进阶(三)】-1
复习巩固: 数组名: 数组名是数组首元素的地址, 但是有两个例外:
【进阶C语言】指针的进阶(万字图文详解)(三)
【进阶C语言】指针的进阶(万字图文详解)(三)
|
存储 C语言 C++
【进阶C语言】指针的进阶(万字图文详解)(一)
【进阶C语言】指针的进阶(万字图文详解)(一)
指针的进阶【中篇】
指针的进阶【中篇】
67 0
指针的进阶【下篇】
指针的进阶【下篇】
64 0
|
存储 安全 编译器
【C++系列P2】引用——背刺指针的神秘刺客(精讲一篇过!)
【C++系列P2】引用——背刺指针的神秘刺客(精讲一篇过!)