1.前言
在讲 C 语言指针和数组进阶内容前,首先强调一遍,C 语言的核心内容是指针、数组和内存管理,它们之间的联系才是 C 语言真正的灵魂,而要真正做到从 “了解 C” 迈向 “熟悉 C”,便得掌握数组和指针的内容。对于这部分内容而言,我们之前所学的只是基础,在实际的开发场景中,这些基础内容很多时候难以直接派上用场,那些只适合做些小程序,效率不高而且代码量大。
那么我们该如何学习这块内容呢?首先,我们必须得多虚拟画图,从手绘到脑绘是一个过程,尤其是遇到一些一时间不好口述的情况,这时画图往往可以让你找到答案(本人在学习过程中也是跟着我的老师一步步进行图文结合来学习的)。然后是做一些相关的题目,提升你对这块内容的印象,以及深度去思考指针、数组和内存之间的联系。
接下来,我们便正式进入本章关于数组和指针的学习,成为一名 “熟悉 C” 的程序员。
2.数组进阶
2.1 数组名含义
数组名在C语言程序中出现时,有两种含义:
1) 代表整个数组
2) 代表数组首元素的地址
以下数组名出现时,代表整个数组的情况有:
1) 在数组定义中
2) 在 sizeof 运算表达式中
3) 在取址符&中(前面讲过)
示例代码:
#include <stdio.h>
int main(int argc, char const *argv[])
{
// (1)、以下数组名出现时,代表整个数组的情况有
// 1、在数组定义中
int buf1[5] = {0};
// 数组在内存中不能随便移动位置(数组名不能进行加减操作:buf1=buf+1)(数组名相当于一个常量指针)
// 2、在 sizeof 运算表达式中
printf("数组buf1的大小 == %lu\n", sizeof(buf1));
/*
解析:
sizeof是将其当成一个指针来对待吗??
如果将其当成一个指针来对待的话,那么sizeof(buf1)的大小就应该为系统位数的大小
所以,sizeof(buf1)其实是算这个buf1数组的大小,而非指针的大小
*/
// 3、在取址符&中
printf("数组buf1的首元素的地址 == %p\n", buf1) ;
printf("数组buf1的首元素的地址+1 == %p\n", buf1+1);
/*
解析:
数组buf1的首元素的地址 == 0x7ffe387f2510
数组buf1的首元素的地址+1 == 0x7ffe387f2514
地址移动了4个位置,所以是4个字节,所以其buf1的作用
范围是4个字节(数组元素的作用范围)
*/
printf("整个数组buf1的地址 == %p\n", &buf1) ;
printf("整个数组buf1的地址+1 == %p\n", &buf1+1);
/*
解析:
整个数组buf1的地址 == 0x7ffc202c2650
整个数组buf1的地址+1 == 0x7ffc202c2664
地址移动了20个位置,所以是20个字节,所以其&buf1的
作用范围是20个字节(整个数组的作用范围)
*/
return 0;
}
以下数组名出现时,代表数组首元素的地址的情况有:
1) 函数传参时
2) 指针指向该数组首元素的地址时
3) 使用scanf函数
4) 直接使用数组名(前面讲过)
示例代码:
#include <stdio.h>
// 函数传参时,数组代表其首元素地址(相当于一个指针)
// 传参,不论是指针还是数组,都会将其当成指针来对待
void upper_case(char *p) // char *p = str;
{
printf("sizeof(p) == %lu\n", sizeof(p));
printf("sizeof(p[0]) == %lu\n", sizeof(p[0]));
int step = 'a' - 'A'; // 32 a-32 == 'A'
// sizeof(p) == sizeof(指针): 由系统位数决定(32位:4个字节 64位:8个字节)
// sizeof(p[0]) == sizeof(数组里面的元素): 1个字节(char型数组元素)
for(int i = 0; i<sizeof(p)/sizeof(p[0]); i++)
{
if((p[i] >='a') && (p[i]<='z') ) // 判断其是否是小写字符
p[i] -= step; // 转换小写变为大写
}
}
int main()
{
// (2)- 当出现其他情形时,数组代表其首元素地址。
// 1、指针指向该数组首元素的地址时
int buf2[128] = {0};
int *p1 = buf2;
int *p2 = &buf2[0];
printf("数组buf2的首元素地址 == %p\n", buf2);
printf("数组buf2的首元素地址+1 == %p\n", buf2+1);
printf("p1的变量里面存放的地址 == %p\n", p1);
printf("p1的变量+1后里面存放的地址 == %p\n", p1+1);
printf("p2的变量里面存放的地址 == %p\n", p2);
printf("p2的变量+1后里面存放的地址 == %p\n", p2+1);
/*
解析:
数组buf2的首元素地址 == 0x7ffda56858d0
数组buf2的首元素地址+1 == 0x7ffda56858d4
p1的变量里面存放的地址 == 0x7ffda56858d0
p1的变量+1后里面存放的地址 == 0x7ffda56858d4
p2的变量里面存放的地址 == 0x7ffda56858d0
p2的变量+1后里面存放的地址 == 0x7ffda56858d4
地址一样,且+1后移动的地址也相同,所以其作用范围都一致
*/
// 2、函数传参时
char str[] = "abcdefghijklnmopqrstuvwxyz";
printf("原数组:%s\n", str);
upper_case(str);
// 传进去的数组,在另一个函数中,由一个相同类型的指针变量,将其数组的首元素地址获取了,后续计算的只是指针变量,而非数组。
printf("转换后:%s\n", str);
/*
解析:
原数组:abcdefghijklnmopqrstuvwxyz
转换后:ABCDEFGHijklnmopqrstuvwxyz
此处打印出来的数据是只有前8个字符做了改变,而其它字符没有改
说明函数里的:sizeof(p)/sizeof(p[0]),算的p变量不是整个数组的大小,而是指针的大小
*/
// 3、使用scanf函数
char buf3[128] = {0};
printf("请输入你要输入的数据:\n");
scanf("%s", buf3);
// buf3表示的是数组的首元素的地址(&buf3表示就是整个数组的地址,切记不要写错了)
// 由于buf3是首地址,所以不用加&,直接使用buf3
// 4、直接使用数组名
char buf4[128] = "来一根超级棒棒糖";
printf("数组buf4的首地址 == %p\n", buf4); // 将数组的首元素的地址给打印出来
printf("数组buf4的首地址+1 == %p\n", buf4+1);
printf("buf4数组里面的字符串 == %s\n", buf4); // 把数组里面的内存给打印出来
return 0;
}
- 重点信息:
- 指针的大小取决于系统位数
- 传参时,传的是数组的首地址,函数可以通过这个地址去指向数组后面的地址
- 正常情况下 sizeof(buf)/sizeof(buf[0]) 计算的是数组元素的个数
2.2 数组下标运算符[ ]
数组运算,等价于指针运算
数组下标实际上是编译系统的一种简写,其等价形式是:
第一种平时用的比较多的,也是最方便的,但是第二种我个人觉得是比较清晰的,能够很清楚的知道数组赋值的原理。
除此在外,还有一些别的等价形式,但还是推荐第一种和第二种
2.3 字符串常量
概念:字符串常量在内存中的存储,实质是一个匿名数组
匿名数组,同样满足数组两种涵义的规定
格式:字符串常量后面默认带有一个'\0'结束符标志
示例代码:
#include <stdio.h>
int main(int argc, char const *argv[])
{
// (1)、字符串常量也符号数组的特性(匿名数组),同样满足数组两种涵义的规定
// 1、使用sizeof进行获取大小
printf("nihao字符串的大小为 == %lu\n", sizeof("nihao"));
// 双引号里面的字符串,默认带一个结束符'\0'
// 2、代表首元素的地址(该字符串首字符的地址)
printf("shijienameda代表的地址 == %p\n", "shijienameda"); // 字符串首字符的地址
printf("shijienameda代表的地址+1 == %p\n", "shijienameda"+1);
/*
解析:
shijienameda代表的地址 == 0x100403023
shijienameda代表的地址+1 == 0x100403024
地址只移动了一个地址,也就是一个字节,所以shijienameda的作用范围是1字节,确实也是代表字符串的首字符的地址
*/
printf("&shijienameda代表的地址 == %p\n", &"shijienameda"); // 整个字符串的地址
printf("&shijienameda代表的地址+1 == %p\n", &"shijienameda"+1);
/*
解析:
&shijienameda代表的地址 == 0x100403023
&shijienameda代表的地址+1 == 0x100403030
地址移动了13个地址,也就是13个字节,所以&shijienameda的作用范围是13字节,确实也是代表整个字符串的地址
*/
// 3、数组的表示方法:
printf("woxiangqukankan[3] == %c\n", "woxiangqukankan"[3]);
// 4、赋值给指针来使用
char *p1 = "woshifantuan"+2; // 移动指针/读取指针指向的内容
printf("p1 == %s\n", p1);
// (2)、上篇文章说过的题(通过指针进行赋值)
// 栈区(可读可写) 常量区(可读不可写)
char *p2 = "aihewahaha";
*(p2 + 0) = 'a';
// 不可以,此处是通过p2指针,去修改常量区的数据,所以不行
char buf[128] = "aihewahaha";
*(buf + 0) = 'a';
// 可以,此处是直接修改buf数组里面的内容(常量区的内容被赋复制到了buf数组中,不是常量区的内容
return 0;
}
2.4 特殊数组
2.4.1 零长数组
概念:长度为0的数组,比如 int data[0]; // 相当于一个地址入门,但是没有内存空间
用途:放在结构体的末尾,作为可变长度数据的入口
示例代码(看之前建议简单了解一下该代码下面的补充点(后面会细讲)):
#include <stdio.h>
#include <stdlib.h>
// 定义一个包含零长数组的结构体
typedef struct
{
int length; // 用于记录数组的实际长度
int data[0]; // 零长数组,不占用结构体的实际空间
} ZeroLengthArray;
int main(int argc, char const *argv[])
{
// (1)、零长数组的使用
// 零长数组本身不占用结构体的空间,但是可以通过动态分配内存来使用
// 假设我们要创建一个长度为 5 的数组
int size = 5;
// 为结构体和数组数据部分分配内存
ZeroLengthArray *zeroArray = (ZeroLengthArray *)malloc(sizeof(ZeroLengthArray) + size * sizeof(int));
if (zeroArray == NULL)
{
printf("内存分配失败!\n");
return 1;
}
// 设置数组的实际长度
zeroArray->length = size;
// 给数组赋值
for (int i = 0; i < size; i++)
{
zeroArray->data[i] = i + 1;
}
// 输出数组的值
printf("零长数组的值:");
for (int i = 0; i < size; i++)
{
printf("%d ", zeroArray->data[i]);
}
printf("\n");
// 释放分配的内存
free(zeroArray);
return 0;
}
补充点:
结构体:
结构体标签: 用来区分各个不同的结构体。
成员: 是包含在结构体内部的数据,可以是任意的数据类型。
结构体基本定义:
struct 结构体标签
{
成员1;
成员2;
...
}; // 此处有;号
堆内存的申请(自定义内存):
申请堆内存:malloc() / calloc()
清零堆内存:bzero()
释放堆内存:free()
int main()
{
// 定义并初始化
char *p_buf = malloc(sizeof(char)*5); // 申请定义个堆空间
/*
通过指针*p_buf访问堆内存
一般使用sizeof(数据类型)*数量来确定申请的堆空间大小,因为会更加精准,不浪费空间
也可以使用malloc(100)来申请空间,但是可能会存放浪费空间的现象
*/
// 清空堆内存
bzero(p_buf, sizeof(char)*5);
// 通过p_buf指针去到堆空间,将其内存全部清零
// (malloc申请的空间里面没有初始化,可能有乱码)
// 释放堆空间
free(p_buf);
}
2.4.2 变长数组
概念:定义时,使用变量作为元素个数的数组。
要点:变长数组仅仅指元素个数在定义时是变量,而绝非指数组的长度可长可短。实际上,不管是普通数组还是所谓的变长数组,数组一旦定义完毕,其长度则不可改变。
示例代码:
#include <stdio.h>
int main(int argc, char const *argv[])
{
// (2)、变长数组(变量长度数组)
// 1、普通数组
int buf1[5] = {1,2,3,4,5};
// 2、变量长度数组
// a、一维数组
int num = 5;
int buf2[num]; // 决定数组元素个数的是变量num,然后其不能初始化
// 为什么?是因为初始化是发生在程序运行前,num还不是具体的数值,所以数组无法知道要赋值多少?
// b、二维数组
int x = 2;
int y = 3;
int b1[x][y]; // 数组元素个数x,y是变量,因此数组b1是变长数组
int b2[2][y]; // 数组元素个数y是变量,因此数组b2是变长数组
int b3[x][2]; // 数组元素个数x是变量,因此数组b3是变长数组
// c、不能这么定义:
int num1; // 不能这么写,因为其范围不确定,所以其合法边界不确定
int buf3[num1];
// d、变长数组的用途
// 显示图片的长宽(使用数组来存放的, 但是你不知道图片的长和宽的值是多少,所以你需要使用变长数组)
return 0;
}
3.指针进阶
3.1 指针语法剖析
指针的命名由两部分组成:
- 第1部分:指针所指向的数据类型,可以是任意的类型
- 第2部分:指针的名字(定义)
指针语法剖析图解:
3.2 特殊指针
3.2.1 char型指针
char型指针实质上跟别的类型的指针并无本质区别,但由于C语言中的字符串以字符数组的方式存储,而数组在大多数场合又会表现为指针,因此字符串在绝大多数场合就表现为char型指针。
示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main()
{
// 1、字符串内存在常量区
char *p1 = "programming is fun";
// 尝试修改常量区的字符串,这会导致段错误
// *(p1 + 2) = 'A';
printf("常量区字符串: %s\n", p1);
// 2、字符串内存在栈区
char buf1[50] = "learning new skills";
char *p2 = buf1;
// 修改栈区字符串的内容
*(p2 + 4) = 'X';
printf("修改后的栈区字符串: %s\n", p2);
// 3、字符串内存在堆区
char *p3 = (char *)malloc(50 * sizeof(char));
if (p3 == NULL)
{
printf("内存分配失败\n");
return 1;
}
strcpy(p3, "exploring the world");
// 修改堆区字符串的内容
*(p3 + 7) = 'Y';
printf("修改后的堆区字符串: %s\n", p3);
// 释放堆区分配的内存
free(p3);
return 0;
}
3.2.2 多级指针
如果一个指针变量 p1 存储的地址,是另一个普通变量 a 的地址,那么称 p1 为一级指针
如果一个指针变量 p2 存储的地址,是指针变量 p1 的地址,那么称 p2 为二级指针
如果一个指针变量 p3 存储的地址,是指针变量 p2 的地址,那么称 p3 为三级指针
以此类推,p2、p3等指针被称为多级指针
图解:
示例代码:
int num = 100;
int *p4 = # // 一级指针
int **p5 = &p4; // 二级指针
int ***p6 = &p5; // 三级指针
// num的地址和值
printf("\n");
printf("num的地址 == %p\n", &num);
printf("num的地址 == %p\n", p4);
printf("num的地址 == %p\n", *p5);
printf("num的地址 == %p\n", **p6);
printf("num的值 == %d\n", num);
printf("num的值 == %d\n", *p4);
printf("num的值 == %d\n", **p5);
printf("num的值 == %d\n", ***p6);
// p4的地址
printf("\n");
printf("p4的地址 == %p\n", &p4);
printf("p4的地址 == %p\n", p5);
printf("p4的地址 == %p\n", *p6);
// p5的地址
printf("\n");
printf("p5的地址 == %p\n", &p5);
printf("p5的地址 == %p\n", p6);
// p6的地址
printf("\n");
printf("p6的地址 == %p\n", &p6);
3.2.3 void型指针
概念:无法明确指针所指向的数据类型时,可以将指针定义为 void 型指针
要点:void 型指针无法直接索引目标,必须将其转换为一种具体类型的指针方可索引目标
void 型指针无法进行加减法运算
void关键字的三个作用:
1. 修饰指针,表示指针指向一个类型未知的数据。 ---- 因此一般我们称之为万能指针类型(你可以强转为任何类型)
如:void *malloc(size_t size);,其返回的指针可以是任意类型(如:char *p = malloc(100), int *p = malloc(100))
2. 修饰函数参数列表,表示函数不接收任何参数。
如:int func(void); 直接用func(),没有参数
3. 修饰函数返回类型,表示函数不返回任何数据。
如:void func(int); 调用该函数时,没有返回值给你知道函数的运行情况,通常发生在初始化的时候
3.2.4 const型指针
const型指针有两种形式:
1.常量指针:const修饰指针本身,表示指针变量本身无法修改。
记忆方法:const 后面为指针p,代表p不可被修改,p为常量指针。
2.常目标指针(指针常量):const修饰指针的目标,表示无法通过该指针修改其目标。
记忆方法:const 后面为 *p,代表指针p存放的内容不可以被修改,p为常目标指针。
3.2.5 函数指针
概念:函数指针是一种指向函数的指针变量,它存储着函数在内存中的入口地址。通过函数指针,可以像调用普通函数一样调用它所指向的函数。
特点:
- 函数指针占用的内存空间大小和普通指针相同,在 32 位系统中通常是 4 字节,在 64 位系统中通常是 8 字节。
- 函数指针的类型由它所指向的函数的返回值类型和参数列表共同决定。
- 可以将相同类型的函数的地址赋值给函数指针,之后通过该函数指针间接调用这些函数。
// 普通函数(void (void)类型)
void func1(void) // 函数名:其实就是地址(跳转地址)
{
printf("func1...........\n");
return;
}
// 主函数
int main(int argc, char const *argv[])
{
// 1、定义或初始化
void (*p_f)(void) = NULL; // 定义一个函数指针
void (*p_f2)(void) = NULL;
p_f = func1; // 函数的跳转地址赋值给函数指针p_f
p_f2 = &func1;
/*
说明:函数指针的&、*和本身都表示一个意思,同一性质
p_f == *p_f == &p_f
*/
// 2、调用(调用函数的时候,后面需要加())
func1(); // 传统方式调用(直接调用)
p_f(); // 通过函数指针间接调用
p_f2();
(*p_f)();
// 通过函数指针间接调用(在取址和索引时,取址符号&和索引符号*都可以省略)
return 0;
}
4.练习题
低难度
题目 1:指针基础操作
#include <stdio.h>
int main()
{
int arr[] = {10, 20, 30, 40, 50};
int *ptr = arr;
printf("%d\n", *(ptr + 2));
return 0;
}
问题:上述代码的输出结果是什么?请解释原因。
题目 2:数组元素求和
编写一个函数,接收一个整数数组和数组的长度作为参数,使用指针遍历数组并计算数组中所有元素的和。
题目 3:字符串长度计算
编写一个函数,接收一个 char
类型的指针(指向字符串),使用指针计算该字符串的长度,不使用 strlen
函数。
中难度
题目 4:交换数组元素
编写一个函数,接收一个整数数组和数组的长度作为参数,使用指针交换数组中第一个元素和最后一个元素的位置。
题目 5:二维数组指针操作
#include <stdio.h>
int main()
{
int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
int (*ptr)[3] = arr;
printf("%d\n", *(*(ptr + 1) + 2));
return 0;
}
问题:上述代码的输出结果是什么?请详细解释指针的运算过程。
题目 6:动态数组创建与操作
编写一个程序,动态分配一个长度为 n
的整数数组(n
由用户输入),使用指针为数组元素赋值(从 1 到 n
),然后输出数组中的所有元素,最后释放动态分配的内存。
高难度
题目 7:指针数组排序
有一个指针数组,每个指针指向一个字符串。编写一个函数,对这些字符串进行排序(按字典序),可以使用任何排序算法。
题目 8:多级指针与动态二维数组
编写一个程序,使用多级指针动态创建一个 m x n
的二维整数数组(m
和 n
由用户输入),使用指针为数组元素赋值(从 1 到 m * n
),然后输出数组中的所有元素,最后释放动态分配的内存。
题目 9:函数指针应用
有三个函数:add
用于计算两个整数的和,sub
用于计算两个整数的差,mul
用于计算两个整数的积。编写一个函数,接收两个整数和一个函数指针作为参数,根据函数指针调用相应的函数进行计算,并返回结果。
5.总结
学到现在数组和指针的内容已经学习的差不多了,当你将这块内容掌握了,就说明你步入“熟悉C”的境界了,这块内容真正理解起来还是比较难的,所以想真正掌握还得多加练习。
每一篇文章都是作者辛苦整理出来的笔记,如果对你有帮助可以留下来你的点赞和收藏,要是有想继续阅读我之后的文章,可以给我点个关注。
接下来我会继续总结内存管理、函数、结构体等C语言相关内容,包括别的内容,例如数据结构、Linux、C++等方面的。