前言
本次带来c语言指针的流程梳理。
一、内存和地址
在讲指针操作之前,我们首先要了解什么是内存和地址。举一个生活中的例子,假如有一天你的朋友要来你家找你玩,但你家这栋楼又恰好没有房间号,这时他如果想要找到你,就得挨个房间去找,这样显然效率很低。但如果我们给每个房间编上号,如:101,102,203,405......这样有了房间号,他就可以快速找到你。
我们将其代换到计算机中。计算机CPU在处理数据时候,从内存中读取数据,处理后再将数据放回内存中。而内存又被划分为一个个的内存单元,每个内存单元的大小为1个字节,这1个字节的空间可以存放8个比特位。这一个个的内存单元其实就相当于上述例子中的房间,每个内存单元的编号就相当于上述例子中的房间号,我们将其称为地址,而在c语言中我们又叫它指针。简单来说我们可以这样理解:内存单元的编号==地址==指针。
二、指针变量
1.&取地址操作符和*解引用操作符
在c语言中创建变量即是向内存申请空间,我们想要得到所创建变量的地址就需要&-取地址操作符,例:
上述代码中我们创建a变量,用&得到a的地址并打印输出。我们可以看到输出的a变量的地址和我们在VS调试中看到的a变量的地址保持一致。 &a取出的是a所占4个字节中地址较小的字节的地址。
我们现在知道如何得到a的地址,但如果后期需要用到这个地址,就需要将其存储起来。我们将其存放到指针变量中。
#include<stdio.h>
int main(){
int a = 10;
int* pa = &a;
return 0;
}
pa即为指针变量,而int*我们可以拆开来看,*表明pa是指针变量,int则是在说明pa指向的是整型(int)类型的对象。我们如果想要通过这个地址找到它所指向的对象即解引用,就需要用到解引用操作符(*),例:
#include<stdio.h>
int main(){
int a = 10;
int* pa = &a;
*pa = 0;
return 0;
}
*pa是通过pa所存放的地址,找到指向的空间即a变量。这里的*pa = 0就是将a变量存放的值改为0。
2.指针变量大小
我们如今用的计算机大多数是32位和64位的机器,32位即有32根地址总线,每根地址总线的电信号转换位数字信号是1或0,我们将32个1或0的二进制序列作为一个地址,则一个地址有32个bit位,即用4个字节存储。64位的地址同理,用8个字节存储。那么指针变量的大小就是4/8个字节。
#include<stdio.h>
int main() {
printf("%zd\n", sizeof(char*));
printf("%zd\n", sizeof(short*));
printf("%zd\n", sizeof(int*));
printf("%zd\n", sizeof(double*));
return 0;
}
上述代码我们在X64环境下测试可以看出 指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的。
3.指针变量类型的意义
既然指针变量的大小与类型无关,那不同指针类型存在的意义是什么?我们看下面两个代码的结果。
在调试的内存窗口中我们可以看到对一个4个字节的整型变量用int*的指针解引用能访问4个字节,将4个字节的数据都改为0;而用char*的指针解引用只能访问1个字节,只将1个字节的数据改为0。指针的类型决定了对指针解引用的时候有多大的权限(一次能操作几个字节)。
4.void*指针
void*指针是一种特殊的指针类型,它是一种没有具体类型的指针(或者叫泛型指针)。这种指针可以用来接受任意类型的地址,但是它也有局限性,void*类型的指针不能直接进行指针的解引用操作和+-操作。
5.const修饰指针变量
我们知道用const修饰变量之后,变量就具有常属性,之后不能被修改。那么对于指针变量又怎么样呢?首先我们先明白const修饰指针变量时候可以将const放在*的左边或者右边,但这两种写法意义是不一样的。我们具体分析一下:
//const放在*左边的情况
void test1() {
int a = 10;
int b = 20;
const int* p = &a;
*p = 20; //出错
p = &b; //ok
return 0;
}
//const放在*右边的情况
void test2() {
int a = 10;
int b = 20;
int* const p = &a;
*p = 20; //ok
p = &b; //出错
return 0;
}
//*左右都有const的情况
void test3() {
int a = 10;
int b = 20;
int const * const p = &a;
*p = 20; //出错
p = &b; //出错
return 0;
}
通过对上述三个函数测试,我们得出结论。
const在修饰指针变量的时候:
const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。 但是指针变量本⾝的内容可变。
const如果放在*的右边,修饰的是指针变量本⾝,保证了指针变量的内容不能修改,但是指针指 向的内容,可以通过指针改变。
6.野指针
野指针就是指针指向的位置是不可知的。其形成原因大致有三个方面:指针未初始化、指针越界访问、指针指向的空间释放。
局部变量指针未初始化,其默认为随机值,导致我们无法找到该指针指向位置。
int main() {
int* p;
*p = 10;
return 0;
}
for循环中越界访问数组,此时的p就为野指针。
int main()
{
int arr[10] = { 0 };
int* p = &arr[0];
int i = 0;
for (i = 0; i <= 11; i++)
{
*(p++) = i;
}
return 0;
}
函数内部创建的变量在出函数之后其申请的内存空间就被操作系统收回,再用指针变量去接受,这时p就是野指针。
int* test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d\n", *p);
return 0;
}
我们为了避免野指针的出现,应该对指针变量初始化,明确其指向地址,不确定地址就先将NULL赋给指针;小心指针越界;指针变量不再使用时及时将其置为NULL。
7.指针的使用和传址调用
我们为什么要学习指针,有什么问题时必须用指针解决呢,我们看一个简单的例子:
void swap(int x, int y) {
int tmp = x;
x = y;
y = tmp;
}
int main() {
int a = 10;
int b = 20;
printf("交换前:a = %d, b = %d\n", a, b);
swap(a, b);
printf("交换后:a = %d, b = %d\n", a, b);
return 0;
}
上述代码想用一个函数来实现交换a和b的值,但当我们运行代码之后发现a和b的值并没有交换。
我们用VS调试上述代码发现x,y的地址和a,b的地址并不相同。我们在调用swap函数时,swap内部创建了形参x和y来接收a和b的值,虽然他们确实地接收到了a和b的值,但是由于他们的地址并不相同,相当于x和y是另外两个独立的空间,在swap内部只是将x和y的值进行交换,自然不会影响到a和b的值。a和b只是将内存中存放的值传递给swap函数,这中调用方式叫传值调用。实参在传递给形参的时候,形参会单独创建一份临时空间来接受形参,对形参的修改不会影响到实参,因为这是两块不同的空间。
那么我们再来看下面的代码和运行结果:
void swap(int* pa, int* pb) {
int tmp = *pa;
*pa = *pb;
*pb = tmp;
}
int main() {
int a = 10;
int b = 20;
printf("交换前:a = %d, b = %d\n", a, b);
swap(&a, &b);
printf("交换后:a = %d, b = %d\n", a, b);
return 0;
}
这里我们是将a和b的地址传递给了swap函数,再通过指针解引用来修改变量的值,这里swap函数的形参接收的是实参传递过来的地址,在swap函数中交换pa和pb解引用后的值本质上就是修改a和b内存空间中存储的值进行修改,自然会改变a和b的值。这种调用方法叫做传址调用。
由此我们明白,在以后的函数如果只是需要实参的值来进行计算,那么就可以采用传值调用。如果需要修改实参的值,那么就需要采用传址调用。
三、数组和指针
1.数组名
一般来说,数组名是数组首元素的地址,但有两个例外:
•sizeof(数组名),sizeof中单独放数组名,这里的数组名表示整个数组,计算的是整个数组的大小,单位是字节。
•&数组名,这里的数组名表示整个数组,取出的是整个数组的地址。实际输出中整个数组的地址和数组首元素的地址在数值上是相同的,但实际上是有差别的。我们举一个例子来说明:
int main() {
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
printf("%p\n", &arr[0]);
printf("%p\n", &arr[0] + 1);
printf("%p\n", &arr);
printf("%p\n", &arr + 1);
return 0;
}
从上述代码结果可以看出对&arr[0]加1跳过的是一个数组元素,4个字节。而对&arr加1跳过的是整个数组,40个字节。
2.使用指针访问数组
学习指针之后,我们可以用指针来对数组进行访问,例如:
int main() {
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
int i = 0;
for (i = 0; i < 10; i++) {
printf("%d ", *(arr + i))
}
int* p = arr;
for (i = 0; i < 10; i++) {
printf("%d ", *(p + i));
}
return 0;
}
我们之前用的arr[i]其实质上就是*(arr+i)。arr表示首元素的地址,对其加i再解引用得到数组的每个元素。那么既然arr是首元素的地址,那么在进行数组传参的时候,其本质上传递的就是数组首元素的地址, 也就是指针。那么假设对一个整型数组传参,其函数的形参部分可以写成int arr[],也可以写成int* arr。
3.二级指针
既然指针变量存放的是变量的地址,那么指针变量也是变量的一种,理应也有地址,那么指针变量的地址存放在哪呢?我们这里引入一个新的概念---二级指针,写法如下:
int main() {
int a = 10;
int* pa = &a;
int** ppa = &pa;
return 0;
}
pa中存放a的地址,ppa中存放pa的地址,解引用ppa找到pa,再解引用pa找到a。
4.指针数组
指针数组是存放指针的数组,数组中的每个元素都是用来存放地址(指针)的,每个元素又可以指向一块区域。
既然指针数组的每个元素都是指针,那么我们可以用指针数组来模拟二维数组,如下:
int main() {
int arr1[] = { 1,2,3,4,5 };
int arr2[] = { 2,3,4,5,6 };
int arr3[] = { 3,4,5,6,7 };
int* arr[3] = { arr1, arr2, arr3 };
int i = 0;
for (i = 0; i < 3; i++) {
int j = 0;
for (j = 0; j < 5; j++) {
//printf("%d ", arr[i][j]);
printf("%d ", *(*(arr + i) + j));
}
printf("\n");
}
return 0;
}
有一点需要注意,上述代码只是模拟二维数组,其实质上构建的并不是二维数组,因为指针数组中arr1,arr2,arr3地址并非连续,即模拟的二维数组每行的地址并不是连续的。
5.各种指针变量
5.1 字符指针变量
字符指针变量的初始化有两种使用方法。如下:
int main() {
//第一种
char ch = 'a';
char* pc = &ch;
*pc = 'b';
//第二种
char* str = "hello world";
return 0;
}
上述两种方法都可以对字符指针变量初始化。但有一点要注意第二种str中存放的并非整个字符串,而是将首字符的地址放到str中。并且第二种的char* str实质上等于const char* str,"hello world"相当于常量字符串,不能通过*str解引用来修改。
我们再来看不同初始化方式对后续判断处理的影响,如下:
int main()
{
char str1[] = "hello bit.";
char str2[] = "hello bit.";
const char* str3 = "hello bit.";
const char* str4 = "hello bit.";
if (str1 == str2)
printf("str1 and str2 are same\n");
else
printf("str1 and str2 are not same\n");
if (str3 == str4)
printf("str3 and str4 are same\n");
else
printf("str3 and str4 are not same\n");
return 0;
}
对第一个判断来说,两个数组str1和str2的数组首元素地址不同,所以输出not same。而对第二个判断来说,由于创建的是两个相同的常量字符串,在c语言中相同的常量字符串整个程序中只有一份,所有指针都指向该字符串,所以输出same。这一点容易犯错,希望大家记住。
5.2 数组指针变量
我们知道整型指针变量存放的是整型数据的地址,字符指针变量存放的字符数据的地址,同理数组指针变量存放的是数组的地址。表示形式为:
int(*p)[5];
p先和*结合,说明p是⼀个指针变量,然后指针指向的是⼀个大小为5个整型的数组。所以p是 ⼀个指针,指向⼀个数组,叫做数组指针。int是p指向数组的元素数据类型,p是数组指针变量名,[5]是p指向数组的元素个数。既然数组指针存放的数组的地址,而二维数组中的每个元素又可以看成一个一维数组即二维数组可以看成一个数组元素都是一维数组的一维数组,其每一行的地址类型就是数组指针类型。那么进行二维数组传参时候,其形参就可以写成数组指针形式,例如:
void test(int(*p)[3], int r, int c) {
int i = 0;
for (i = 0; i < r; i++) {
int j = 0;
for (j = 0; j < c; j++) {
printf("%d ", *(*(p + i) + j));
}
printf("\n");
}
}
int main() {
int arr[2][3] = { 1,2,3,4,5,6 };
test(arr, 2, 3);
return 0;
}
5.3 函数指针变量
有了字符指针、整型指针、数组指针的学习经验,我们接下来学习函数指针变量。顾名思义,函数指针变量应该是来存放函数地址的,通过这个地址我们应当能调用函数。函数和数组有些类似,函数名就是函数的地址,也可以通过&函数名来得到函数的地址。我们来验证一下。
函数指针变量的写法和数组指针有些类似,数组指针变量写法位int(*p)[5],函数指针变量的写法位int(*p)(int, int)或者int(*p)(int x, int y),其中x和y可以省略。其中最左边的int是p指向函数的返回类型,p是函数指针变量名,(int x, int y)里面是p指向函数的参数类型和个数。接下来我们看函数指针变量的使用,我们可以通过函数指针调用指针指向的函数。
6. 转移表
在介绍转移表之前,我们首先介绍函数指针数组——存放函数的地址的数组就函数指针数组。其写法如下:
int (*parr1[3])(); // parr1[3]表示parr1是一个数组,int(*)()是函数指针类型
了解函数指针数组之后,我们再来看函数指针数组的应用——转移表。首先我们先看一段实现计算器的加减乘除运算的代码:
void test() {
printf("--------------------------\n");
printf("-------1.Add 2.Sub-----\n");
printf("-------3.Mul 4.Div-----\n");
printf("-------0.exit -----\n");
printf("--------------------------\n");
}
int Add(int x, int y) {
return x + y;
}
int Sub(int x, int y) {
return x - y;
}
int Mul(int x, int y) {
return x * y;
}
int Div(int x, int y) {
return x / y;
}
int main() {
int x = 0;
int y = 0;
int input = 1;
int ret = 0;
do {
test();
printf("请选择:>");
scanf("%d", &input);
switch (input) {
case 1:
printf("请输入操作数:>");
scanf("%d %d", &x, &y);
ret = Add(x, y);
printf("%d\n", ret);
break;
case 2:
printf("请输入操作数:>");
scanf("%d %d", &x, &y);
ret = Sub(x, y);
printf("%d\n", ret);
break;
case 3:
printf("请输入操作数:>");
scanf("%d %d", &x, &y);
ret = Mul(x, y);
printf("%d\n", ret);
break;
case 4:
printf("请输入操作数:>");
scanf("%d %d", &x, &y);
ret = Div(x, y);
printf("%d\n", ret);
break;
case 0:
printf("退出程序\n");
break;
default:
printf("选择错误\n");
break;
}
} while (input);
return 0;
}
我们可以看到switch里有很多冗余的代码,我们有什么办法减少这部分重复的代码呢?这时候需要转移表,如下:
void test() {
printf("--------------------------\n");
printf("-------1.Add 2.Sub-----\n");
printf("-------3.Mul 4.Div-----\n");
printf("-------0.exit -----\n");
printf("--------------------------\n");
}
int Add(int x, int y) {
return x + y;
}
int Sub(int x, int y) {
return x - y;
}
int Mul(int x, int y) {
return x * y;
}
int Div(int x, int y) {
return x / y;
}
int main() {
int x = 0;
int y = 0;
int input = 1;
int ret = 0;
int(*p[5])(int x, int y) = { 0, Add, Sub, Mul, Div };
do {
test();
printf("请选择:>");
scanf("%d", &input);
if (input >= 1 && input <= 4) {
printf("请输入操作数:>");
scanf("%d %d", &x, &y);
ret = (*p[input])(x, y);
printf("%d\n", ret);
}
} while (input);
return 0;
}
我们用一个函数指针数组来存放加减乘除函数的地址,通过数组下标来调用函数。这里将数组的第一个元素设为0是因为数组下标是从0开始的,为了让数组下标与test函数中1234相对应,加一个0让下标往后错一位。我们可以看到应用转移表的代码去除了原代码中的冗余部分,精简了代码。
四、回调函数
什么是回调函数?回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另⼀个函数,当这个指针被用来调用其所指向的函数时,被调用的函数就是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。
我们之前用转移表改写了实现计算器加减乘除的代码,下面我们看如何用回调函数来实现。如下:
void test() {
printf("--------------------------\n");
printf("-------1.Add 2.Sub-----\n");
printf("-------3.Mul 4.Div-----\n");
printf("-------0.exit -----\n");
printf("--------------------------\n");
}
int Add(int x, int y) {
return x + y;
}
int Sub(int x, int y) {
return x - y;
}
int Mul(int x, int y) {
return x * y;
}
int Div(int x, int y) {
return x / y;
}
void calc(int(*p)(int, int)) {
int ret = 0;
int x = 0;
int y = 0;
printf("输入操作数:>");
scanf("%d %d", &x, &y);
ret = p(x, y);
printf("%d\n", ret);
}
int main() {
int input = 1;
do {
test();
printf("请选择:>");
scanf("%d", &input);
switch (input) {
case 1:
calc(Add);
break;
case 2:
calc(Sub);
break;
case 3:
calc(Mul);
break;
case 4:
calc(Div);
break;
case 0:
printf("退出程序\n");
break;
default:
printf("选择错误\n");
break;
}
} while (input);
return 0;
}
我们创建了一个calc函数,形参设置为一个函数指针变量,我们在想进行加减乘除运算时,将函数地址传递给calc函数,最后由calc函数通过指针来调用加减乘除函数。上述程序中的Add、Sub、Mul、Div就是回调函数。
接下来我们再看一个例子,用回调函数模拟实现qsort函数。qsort函数是c语言中函数库中自带的排序函数,我们可以用qsort来排序整形数据、结构数据等。
//整型数据排序
int int_cmp(const void* p1, const void* p2) {
return (*(int*)p1 - *(int*)p2);
}
int main() {
int arr[] = { 1,3,5,7,9,2,4,6,8,10 };
int i = 0;
qsort(arr, sizeof(arr) / sizeof(arr[0]), sizeof(int), int_cmp);
for (i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
printf("%d ", arr[i]);
return 0;
}
qsort函数的参数列表如下:
void*base是指向待排序数组的第一个元素的指针,size_t num是base指向数组的元素个数,size_t width是base指向数组中一个元素的大小,单位是字节,最后一个参数是函数指针接收传递函数的地址。我们了解了qsort函数,接下来我们看如何用回调函数来模拟实现qsort。
int int_cmp(const void* p1, const void* p2) {
return (*(int*)p1 - *(int*)p2);
}
void _swap(void* p1, void* p2, int size) {
int i = 0;
//按字节交换
for (i = 0; i < size; i++) {
char tmp = *((char*)p1 + i);
*((char*)p1 + i) = *((char*)p2 + i);
*((char*)p2 + i) = tmp;
}
}
void bubble(void* base, int count, int size, int(*cmp)(void*, void*)) {
int i = 0;
int j = 0;
for (i = 0; i < count - 1; i++) {
for (j = 0; j < count - i - 1; j++) {
if (cmp((char*)base + j * size, (char*)base + (j + 1) * size) > 0)
_swap((char*)base + j * size, (char*)base + (j + 1) * size, size);
}
}
}
int main() {
int arr[] = { 1,3,5,7,9,2,4,6,8,10 };
int i = 0;
bubble(arr, sizeof(arr) / sizeof(arr[0]), sizeof(int), int_cmp);
for (i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
printf("%d ", arr[i]);
return 0;
}
上述代码模拟实现了qsort排序函数(以冒泡排序方式排序)。参数里的指针都采用void*形式来接收地址是因为qsort函数可以实现多种数据排序,我们在不确定要排序数据的数据类型时用void*指针来接受地址(注:void*可以指向任何类型的内存地址,但不能用*来直接访问void类型指针,要用对应的指针类型进行强制类型转换)。 这样当我们想要对另一种类型数据排序,仅需要修改回调函数int_cmp而不用挨个修改各个函数中的参数类型。
结语
哦吼,到此c语言指针内容梳理就结束了。由于本人才疏学浅,本文仅分享和记录自己的学习过程,如有错误敬请指正。当然如果本文对您的学习有些许帮助,倍感荣幸。
吾尽吾心,终亦不悔,天道酬勤,何事难为。