C语言 - 细说C指针

前言

说来惭愧, 本人做开发时间也不算短了, 虽然空闲时间一直有做笔记, 但从来都没写过技术博客。最近看了一些文章让我意识到了记笔记和写博客的差距, 交流使人进步, 禁闭使人落后! 决定不找任何借口, 突破自己, 并坚持下去。
因为之前记笔记的出发点是留着给自己进行知识梳理的, 没想过要发布出来, 有些内容吸收了各种博客加以自己的理解, 可能无法完整的列出参考链接, 在这里先感谢大佬们的付出和包涵, 欢迎批评斧正。

先来一篇非常基础但很关键的C指针。

1、指针的概念

C是一门古老而强大的语言, 之所以强大, 很大部分体现在其灵活的指针运用上。因此, 说指针是C语言的灵魂, 一点都不为过。
那么, 什么是指针? 先了解一下概念。

与int、float、double等其他数据类型一样, 指针也是一种数据类型, 但这种数据类型所声明的变量---即指针变量, 是一种特殊的变量, 是专门用来存储内存地址的。
一个指针变量有两个属性: 地址值和指针类型。地址值用来标识指针所指向的变量的首地址; 指针类型告诉编译器, 应该以什么数据类型对指定的内存区域进行访问。

2、指针的声明

概念清楚了, 指针又是如何使用的呢?
就像其他变量或常量一样, 在使用指针之前, 必须先对其进行声明。
指针变量声明的一般形式为:

type *varName;

type 是指针的基类型, 它必须是一个有效的 C 数据类型, 如 int、char等;
type * 就是这个指针变量的数据类型;
varName是指针变量的名称。
声明指针变量时, 如果没有确切的地址可以赋值, 为指针变量赋一个 NULL 值是一个良好的编程习惯。如: int *p = NULL;

2.1 关于空指针 NULL

上面说声明指针变量时, 如果暂时不能确定其指向,可以先赋值为NULL。关于NULL在这里做几点扩展:
NULL 是一个定义在标准库中的值为零的指针常量。赋为 NULL 的指针被称为空指针。

#define  NULL  ((void *)0)   // NULL的宏定义

外层的括号是为了防止宏定义歧义; 里层的括号则是强制类型转换, 把0转换成void * 类型, 本来void * 型就是用来存放地址的, 那么这里的0自然就是地址0了。
空指针是有指向的指针, 但它指向的地址是很小的地址, 约定俗成为地址0x0, 是程序的起始, 这个地址不保存数据, 同时不允许程序访问。所以空指针不能操作该地址, 我们就理解为“指针指向了空, 无法操作了”。

2.2 关于无确定类型指针 void *

那么NULL宏定义里面的void 型指针又是什么呢? 这个从“字面”上看起来更像空指针啊!
然而void * 型指针并不是空指针,这个类型的指针指向了实实在在的存放数据的地址, 但是该地址存放的数据的数据类型我们暂时不知道, 可以理解为无确定类型指针。void
类型可以通过类型转换强制转换为任何其它类型的指针。
我们通过动态内存分配的例子加以理解:

char *str = (char *)malloc(sizeof(char)*10); // 动态内存分配
void *malloc(size_t __size); // 函数库中malloc函数的声明

malloc的全称是memory allocation, 中文叫动态内存分配, 用于申请一块连续的指定大小的内存块区域, 它以void *类型返回分配的内存地址, 然后通过强制转换改变为其他类型的指针。
需要特别注意的是:

  • void *型指针变量不能进行算数运算, 因为编译器不能识别void *类型的长度;
  • void *型指针变量也不能使用*进行取值操作, 因为编译器不知道要取出的数据具体是什么类型, 想取值必须转换为其它类型。

3、指针的初始化/赋值

声明了一个变量, 要给他初始化才有意义。 给指针变量初始化/赋值其实就是让指针指向某个地址。

int a = 10, c = 20; // 定义并初始化两个整形变量

// 定义一个指针变量p, 并初始化其值为变量a的地址(指向a的地址)
// 注意, 这个指针变量的变量名是p, 而不是 *p;
// 指针的类型是int *, 指针所指向的类型是int;
// 这里注意: 指针的类型(即指针本身的类型)和指针所指向的类型是两个概念。
int *p = &a;
printf("a==%d, *p==%d\n", a, *p); // 输出: a==10, *p==10

// 允许修改指针变量p所指向的地址(给指针变量重新赋值)
p = &c;
printf("c==%d, *p==%d\n", c, *p); // 输出: c==20, *p==20

// 允许修改指针变量指向的内存地址的内容
// 此时p所指向的地址是变量c所在的内存地址, 修改此地址的内容也就是给该地址所存储的变量c重新赋值
*p = 30; 
printf("c==%d, *p==%d\n", c, *p); // 输出: c==30, *p==30

p = a; // Error: 指针变量只能存储地址, 给指针赋值其他类型的数据显然不对
*p = &a; // Error: *在操作时表示指向操作, 操作指针指向的那个地址的内容; 也就是说, 若p是一个有效指针, *p即为p所指向的地址的值, 此处*p是int类型, 不能将地址赋值给int

给初学者一点解惑: 声明定义变量时的 * 和后面操作变量时的 * 以及&如何理解?

  • * 在声明定义指针变量时 : 是指针声明符, 说明定义的这个变量是指针。如int *p;
  • * 在操作指针变量时: 是取值符, 取出指针所指向地址的值。如: *p = 30;
  • & 写在变量前面: 是取址符, 任何变量都是放在内存中的, 取址符&就是获得变量在内存中地址。

3.1 指针的值

指针的值是指针变量本身所存储的数值, 这个值将被编译器当作一个地址, 而不是一个一般的数值。
前面说, 给指针变量初始化/赋值其实就是让指针指向某个地址。那么指针被初始化/赋值之后, 这个指针的值就是一个地址, 我们称为指针所指向的内存地址。实际上, 一个有效指针的值只能是一个地址。
在64位机器里, 所有类型的指针的值都是一个64位二进制, 因为64位机器里内存地址全都是64位。

4、指针和地址

既然指针的值就是地址, 那直接用地址就行了呗, 要什么指针, 搞得这么麻烦?
看过很多文章把他们混为一谈, 认为指针就是地址。其中有这样的说法: 变量的地址称为变量的指针。存放指针的变量, 称为指针变量。
我认为这种说法, 很容易让初学者混淆概念, 让人越看越糊涂。我的理解是:
变量的地址就是内存地址, 是系统给每个存储单元拟定的编号;
存放地址的变量, 称为指针变量。

4.1 地址是什么?

内存中, 以8位二进制(1个字节)作为1个存储单元, 每个存储单元都有一个地址, 是一个整数编号, 一般用十六进制数表示。
比如在IDE控制台打印某个变量的地址, 会输出类似0x7fff5fbff6ac这样的十六进制数, 这个十六进制数, 就是这个变量在内存中的地址, 如果这个变量占有多个字节, 那么这个编号就是该变量的首地址(连续内存中最前面的存储单元的编号)
我们通过这个地址, 就能找到内存中对应的存储单元。找到了存储单元, 也就能访问到内存中所存储的数据。
一个存储单元对应一个字节, 每个字节有8个二进制位, 即能表示的最大的数是11111111(十进制的255), 转换为十六进制是0xFF。
反过来讲, 一个存储单元最多能存储8位二进制数(2位十六进制数)。

一点扩展: 机器语言指令中出现的内存地址, 都是逻辑地址, 需要转换成线性地址, 再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。

4.2 指针和地址的关系

指针是由地址值和指针类型两部分构成的, 指向数据的指针不仅记录该数据的在内存中的存放的地址, 还记录该数据的类型, 即在内存中占用几个字节, 这是地址所不具有的。
正因为地址是没有类型的, 所以不能对地址进行算术操作。
这些可以从5.2中的例子得到佐证。

5、指针的算术运算(指针移动)

4.2讲到, 地址不能进行算数运算, 而指针可以。
指针可以进行四种算数运算: ++、--、+、-, 不过要注意的是必须是整数才行。
这种运算的意义和通常的数值的加减运算是不一样的, 指针的算数运算以其所指向的类型所占字节长度为单位, 也就是sizeof(指针所指向的类型)。
算数运算之后指针的类型不变, 指针所指向的类型也不变。加N, 就会向高地址移动; 减N, 则向低地址移动, 长度是 N*sizeof(指针所指向的类型)。

举个栗子:

int a = 10, c = 20;
printf("%p, %p, %p\n", &a, &c, &c+1); // 输出: 0x7fff5fbff6ec, 0x7fff5fbff6e8, 0x7fff5fbff6ec
&c = &a; // 是非法的

先说说 &a 是一个指针还是一个地址呢? 
我的理解: 它是一个指针常量。 原因如下:
1) IDE中可以看到&a 是有类型的, 其类型是int *。而地址没有类型。
2) &a可以进行算数运算, 地址不能进行算数运算。
3) 给&a赋值是非法的, 也就是说不能改变&a的指向, 所以它是一个常量。

关于这个问题, 目前没有找到权威的说法, 暂且认为&a是指针常量, 基本可以解释的通, 如有不同见解, 欢迎讨论。

从print输出可以看到, &a与&c+1相等, 都比&c大4, 而sizeof(int)==4;
也就是说, a的地址比c的地址高了4个字节, 那为什么a比c的地址高呢?
因为栈空间是从高地址往低地址扩展的, 先声明的a, 那a的地址自然比c的地址高, 高出的大小刚好是变量c所占内存空间的大小。如此, &c+1得到的也就是变量a的地址。

5.1 指针变量之间的算数运算:

两个同一类型的指针变量是可以相减的, 他们的意义表示两个指针指向的内存位置之间相隔多少个元素(注意是元素, 并不是字节数), 一般应用在数组元素之间。
两个指针不能进行加法运算, 这是非法操作, 因为进行加法后, 得到的结果指向一个不知所向的地方, 而且毫无意义。

5.2 指针变量之间的比较

指针可以用关系运算符进行比较, 如 ==、< 和 >。其本质也就是指针变量之间的相减运算。

6、指针的类型 和 指针所指向的类型:

上面提到了一个注意点: 指针的类型和指针所指向的类型是两个概念。这点很关键, 不可混淆。

6.1 先说说什么是指针的类型?

任何变量都有自己的数据类型, 指针变量也有其数据类型, 指针的类型是指针变量本身所具有的数据类型。
就如同 int a = 10; 那么变量a的数据类型就是int。可以大致理解为: 用什么声明这个变量, 那么这个变量的类型就是什么。从语法上看, 把指针声明语句里的指针变量名去掉, 剩下的部分就是这个指针的类型。
下面列举几个例子:

1) int *ptr; // 指针的类型是int* (用 int *声明的变量ptr)
2) char *ptr; // 指针的类型是char*
3) int **ptr; // 指针的类型是int**  (用 int **声明的变量ptr)
4) int(*ptr)[3]; // 指针的类型是int(*)[3]
5) int *(*ptr)[4]; // 指针的类型是int *(*)[4]

6.2 什么是指针指向的类型?

我们常说, 指针变量p指向变量xx的地址, 那么变量xx的数据类型, 就是指针变量p所指向的类型。指针所指向的类型决定了编译器将把那块内存区里的数据当做什么来看待。
从语法上看, 把指针声明语句中的指针名及其左边的第一个指针声明符*去掉, 剩下的就是指针所指向的类型。
还是用上面的例子:

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

7、指针与数组

指针可以存放int类型变量的地址, 指向int类型的变量; 同理, 指针也可以指向数组。
举个栗子:

int arr[] = {0, 1, 2, 3, 4, 5};
int *p;
p = arr; 

数组名arr是指向 &arr[0] 的指针, 即指向数组首元素的地址。
但是arr并不是指针变量, 而是一个指针常量, 它的类型是int [6]。
即arr这个指针是个常量, arr指向的数值(arr[0])可以改变, 而arr所保存的地址不能改变。
也就是说 arr[0] = 6;是完全合法的, 而给arr赋值, 如: arr = p; 就不合法。

p是个指针变量, 指向了arr的值, 也就是数组arr的首元素的地址。
p和arr都具有指针值, 都可以进行间接访问和下标引用操作:

printf("%d--%d--%d--%d\n", *(arr+1), arr[1], *(p+1), p[1]); // 输出: 1--1--1–1

但p是变量, 我们可以使用p++来遍历数组元素, 这就是p和arr最大的不同:

for (int i=0; i<6; i++) printf("%d\n", *p++);

指针牵扯到数组, 还会引申出一系列的问题, 如字符串、二级指针、二维数组、指针数组、数组指针等等, 这里限于篇幅, 暂不讨论, 后面会单独写一篇关于指针和数组的详解。

8、指针与结构体

就像数组指针一样, 指向结构体的指针存储了结构体第一个元素的内存地址。结构体的指针必须声明和结构体类型保持一致, 或者声明为void类型。

8.1 如何定义指向结构体变量的指针?

  1. 拷贝结构体类型 和 结构体变量名称
  2. 在类型和名称中间加上*
    当指针指向结构体之后如何利用指针访问结构体的成员变量?
  3. 结构体变量名称.属性;
  4. (*结构体指针变量名称).属性;
  5. 使用指向运算符->, 结构体指针变量名称->属性;
举例说明: 
struct Person { 
    int age;
    char *name;
    double height;
}; // 定义结构体类型Person

struct Person sp = {26, "xiaoming", 1.80}; // 定义结构体变量sp
struct Person *sip = &sp; // 定义指向结构体的指针sip, *sip === sp
sp.name = "xiaohong";
(*sip).name = "xiaomei"; // 运算符.的优先级比*高, 加上()先取值再查找
sip->name = "laowang";

关于结构体这里也暂不做过多讨论。

9、指针与函数

9.1 函数指针

函数也会占用一块存储空间, 所以函数也有自己的地址, 那么指向这块地址的指针变量就是函数指针。
函数指针有两个用途: 调用函数、做函数的参数(回调函数)。

9.1.1 函数指针的声明方式:

返回值类型 (* 指针变量名) (形参列表);
注: “返回值类型”说明函数的返回类型,“(指针变量名 )”中的括号不能省,括号改变了运算符的优先级。

// 方法1 直接声明
void (*p_func)(int, int, float) = NULL;

// 方法2 利用typedef取别名, 再声明
typedef void (*tp_func)(int, int, float);
tp_func p_func = NULL;
9.1.2 利用函数指针调用函数
利用函数指针调用函数实例:
int max(int x, int y) {
    return x > y ? x : y;
}
 
int main(void) {
    // 定义函数指针p指向函数max的地址
    int (* p)(int, int) = & max; // &可以省略, 函数名就是地址, 可以将它赋值给指向函数的指针
    int a, b, c, d;
    printf("请输入三个数字:");
    scanf("%d %d %d", & a, & b, & c);
    d = p(p(a, b), c); // 与直接调用函数等价, d = max(max(a, b), c)
    printf("最大的数字是: %d\n", d);
    return 0;
}
编译执行, 输出结果如下: 请输入三个数字, 假设输入了1 2 3
则打印输出, 最大的数字是: 3
9.1.3 函数指针与回调函数

回调函数就是一个通过函数指针调用的函数。如果你把函数的指针作为参数传递给另一个函数, 当这个指针被用来调用其所指向的函数时, 我们就说这是回调函数。
回调函数不是由该函数的实现方直接调用, 而是在特定的事件或条件发生时由另外的一方调用的, 用于对该事件或条件进行响应。
因为可以把调用者与被调用者分开, 所以调用者不关心谁是被调用者。它只需知道存在一个具有特定原型和限制条件的被调用函数。简而言之, 回调函数就是允许用户把需要调用的方法的指针作为参数传递给一个函数, 以便该函数在处理相似事件的时候可以灵活的使用不同的方法。

回调函数实例:
// 函数A, 形参需要传入 int型数组, 数组大小, 函数指针
void populate_array(int *array, size_t arraySize, int (*getNextValue)(void)) {
    for (size_t i=0; i<arraySize; i++)
        array[i] = getNextValue();
}
 
// 函数B, 函数指针所指向的函数
int getNextRandomValue(void) {
    return rand(); // 获取随机值
}

int main(void) {
    int myarray[10];
    // 使用函数指针回调
    populate_array(myarray, 10, getNextRandomValue);
    for(int i = 0; i < 10; i++) {
        printf("%d", myarray[i]);
    }
    printf("\n");
    return 0;
}

9.2 指针函数

还有一个概念叫做指针函数: 即返回值是指针类型的函数, 这篇主要说指针, 也不多赘述

指针函数的定义格式:
类型名 *函数名(函数参数列表);
实例:
int *pfun(int, int);

暂时先写这些, 以后再补充。
ps: 第一次写简书, 有点紧张😆, 排版都排不好, 大神勿喷。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容