指针的基本概念与作用
什么是指针
在日常生活中,当我们置身于一个庞大的小区时,为了便于定位和寻访朋友的住所,我们会为每个房间赋予一个独一无二的编号,如101、201或304等。这些数字就像是房间在小区内的坐标,让来访者能够迅速找到目标位置。同样,在计算机内部的世界里,内存就如同一座座存放数据的“房间”,也需要一种高效的方式来管理和定位它们。
当计算机的核心——CPU需要处理数据时,它会在浩瀚的内存空间中进行读取和存储操作。然而,面对如此海量且连续分布的数据单元,逐一查找显然是低效而不可行的。因此,我们借鉴生活中的智慧,将内存划分为一系列大小相同的单位,每个单位就是一个字节。然后,对这些字节进行有序编号,就像给小区的每间房都编上号一样,形成了一个个具有唯一标识的内存地址。
而在C语言中,这种内存地址被赋予了一个更为形象的名字——指针。简单来说,指针就是指向内存中特定位置的地址标识,它如同一张详细的藏宝图,指引着CPU快速准确地找到并操作相应内存区域的数据。所以,我们可以这样理解:内存单元的编号与它的物理地址是同一概念,而在C语言中,这个地址则以“指针”的形式呈现出来,即“内存编号 == 地址 == 指针”。通过巧妙运用指针这一概念,程序员得以实现对内存资源的高效管理与灵活操控。
如何理解编址
恰如钢琴键盘上虽未将音符do、re、mi、fa等刻印其上,但演奏者依然能凭借约定俗成的规则准确无误地弹奏出每个音符,这源于在钢琴制造之初就已设定好的物理结构和音高对应关系。同样地,在计算机硬件的世界里,CPU内存管理也有着类似的内在机制。
内存空间的划分与编号,并非显式存储于硬件之中,而是由内存设计时所遵循的统一标准和规范来确立的。就像音乐家无需查看标记就能触键生音一样,CPU也依据预先设定的地址映射规则,能够直接通过指针访问特定的内存单元。这些内存地址并非实际写入到硬件中,而是作为逻辑上的标识符,为软件层提供了一种简洁而高效的寻址方式。
因此,我们可以诗意地阐述:内存地址犹如无形的乐谱,无声地指导着数据的存取舞步;它既不是刻在硬件上的铭文,也不是随风消逝的旋律,而是深深植根于电子脉络中的内在法则,一种技术共识,让信息世界里的每一次读写都能精准抵达预定的“琴键”,奏响数据流动的交响曲。
指针的申明与初始化
如何申明指针变量
在C语言中,声明一个指针变量以指向整型数据时,有两种规范写法:
int* p
int *p
尽管两者在语义上完全一致,但在编写代码时应遵循一种统一的风格,以提高代码的可读性和维护性。
如何取地址
说到取地址,大家一定不陌生,我们在用scanf函数的时候经常用到&操作符。
没错,&操作符就是取地址操作符,它用来取出变量的地址,那么有什么用呢?
我们不是讲到指针就是地址吗?那么我们就可以将一个变量的地址取出来并且赋值给这个指针变量,以下面这个代码为例:
int main()
{
int a = 10;
&a;
int* p = &a;
}
这样,我们就很好地把整型变量a的地址赋给指针p了
注意
这里并非指针变量p就是a的值,指针变量p和整型变量a存放在两个不同的地址里,只不过整型变量a中存放了10这个值,而指针变量p里存放了整型变量a的地址,用下图表示就是这样
上图可理解为指针p指向a变量
如何初始化指针
对于指针的初始化,有以下几种方式:
1.赋NULL空值
int* p = NULL
初始化一个指针并将其设置为NULL表示当前指针没有指向任何有效的内存地址。这是一种安全的做法,可以防止未初始化的指针导致意外行为。
2.指向静态或自动变量
int a = 10;
int* p = &a;
3.数组与指针的关系
int arr[5];
int* p = arr;
野指针
出现野指针的原因
1.指针变量未初始化
当一个指针没有进行初始化时,它默认指向的可能是随机值或者垃圾值,这意味着它可能指向任何地址空间,其中包括一些不可访问的地址空间。
2.释放后未置空
如果一个函数在已经调用结束之后已经释放了内存空间,而此时指针没有赋值为NULL,那么这个指针依然指向那块已经释放的空间,会导致之后使用这个指针的时候访问的地址是已经释放的内存区域。
3.数组越界访问
对于数组指针,如果超出数组边界进行访问,虽然指针本身不是野指针,但当其索引超过实际数组大小时,可能导致与野指针相关的错误行为。
4.作用域结束后任然使用
当一个指针指向一个栈上的局部变量时,如果这个局部变量的作用域已经结束,那么这个指针依旧指向这个局部变量,但因为这个局部变量已经销毁,就会出现野指针
指针操作与解引用
解引用(*)操作符的使用
指针作为一种特殊的变量类型,确实能够存储其他变量的内存地址。通过解引用操作,我们可以访问到该地址所指向的实际数据。解引用操作符 *
在此扮演着关键角色,它如同一把开启数据宝库的钥匙,让我们得以透过地址直达目标。
具体来说,当你声明并初始化了一个指针,例如:
int a = 10;
int* p = &a;
printf("%d",*p);
通过解引用指针,我们可以不仅读取变量的值,还可以修改该变量的值。接下来是示例代码:
int a = 10;
int* p = &a;
*p = 0;
确实,直接通过赋值语句`a = 0;`修改变量a的值是一种简洁且直观的方法。然而,在某些情况下,使用指针来修改变量的值能提供更大的灵活性和更多的编程可能性,例如:
动态内存操作:当变量是在堆上动态分配的内存时,我们无法直接通过变量名修改其值,只能通过指向该内存区域的指针进行修改。
函数参数传递:在C语言中,通过指针作为函数参数可以实现传址调用,这样函数可以直接修改实参的值,而非仅仅复制一份副本。
数据结构操作:复杂的数据结构如链表、树等,它们的节点通常通过指针连接。这时,我们必须通过解引用指针来访问和修改各个节点中的数据。
多级指针与间接寻址:在需要通过多个层级的指针才能到达实际数据的情况下,解引用就显得尤为重要。
总结来说,虽然在简单场景下直接修改变量的值更为便捷,但在涉及更复杂的程序设计和技术需求时,灵活运用指针和解引用操作符能够提供更强的控制力和更高的效率。随着对C语言及底层原理的深入理解,你会发现指针在许多高级特性和优化策略中都扮演着核心角色。
指针大小
在编译器中,我们可以输入以下代码来查看指针的大小:
#include <stdio.h>
int main()
{
printf("%zd", sizeof(int*));
printf("%zd", sizeof(char*));
printf("%zd", sizeof(long*));
printf("%zd", sizeof(double*));
printf("%zd", sizeof(short*));
printf("%zd", sizeof(float*));
return 0;
}
运行这段代码后,你会观察到一个显著现象:在32位系统环境下,所有类型的指针所占用的空间大小均为4个字节;而在64位系统中,这一数值则统一为8个字节。这表明,在不同位宽的计算机架构下,指针本质上是一种存储地址的数据类型,其大小与它所指向的变量的具体类型无关。
换句话说,无论是指向整型、字符型还是结构体等任何数据类型的指针,它们自身作为地址容器时,占用的内存空间是恒定的,并且由处理器体系结构决定(即32位系统下的4字节或64位系统下的8字节)。这一特性揭示了指针作为内存地址标识符的基本属性,它的值代表的是内存地址,而非所指向数据的大小或类型。
指针运算
指针+整数运算
我们先来看一段代码:
int a = 10;
int* p = &a;
char* pc = &a;
printf("%p\n", p);
printf("%p\n", p+1);
printf("%p\n", pc);
printf("%p\n", pc+1);
运行结果如下:
当在C/C++等编程语言中对指针执行加一操作时(如 `p+1`),实际上并非简单地在当前地址上加1个字节,而是根据指针所指向的数据类型来移动相应的字节数。这是因为指针加一的含义是将指针移向下一个相同类型数据的起始地址。
例如:
- 如果指针 `p` 指向的是一个 `char` 类型变量,由于 `char` 类型通常占用1个字节,那么执行 `p+1` 后,指针将向前移动1个字节。
- 若指针指向的是一个 `int` 类型变量,假设 `int` 类型在当前系统环境下占用4个字节,则执行 `p+1` 时,指针会向前移动4个字节。
所以,对于不同类型的指针进行自增运算时,它遵循的是“按数据类型大小递增”的原则,而不是简单的地址值加1。这一特性使得指针能够有效地遍历数组或连续内存区域中的元素。
void*指针
在C/C++编程语言中,有一种极具通用性的指针类型被称为“void*指针”,也常被形象地称为“万能指针”或“泛型指针”。这种类型的指针可以存储任何类型数据的内存地址,但同时也伴随着特定的约束和注意事项。
void*
指针的主要特点是其不受特定类型限制,能够与任意类型的数据进行间接关联。然而,由于编译器无法确定它指向的具体数据类型大小,所以直接对void*
指针进行算术运算(如加减)或解引用操作是不合法的。这是因为编译器不知道应该移动多少字节来访问下一个元素,或者从该地址读取/写入多大的数据块。
下面是一个简单的示例来展示如何使用void*
指针:
int main()
{
int a = 10;
int* p = &a;
char* pc = &a;
return 0;
}
运行时编译器会报出以下警告:
在C语言中,将int*
类型的变量强制转换为char*
时,虽然从内存层面来看可以实现(因为任何类型的数据在内存中都是一连串的字节),但这种转换可能引发类型不匹配的问题,尤其是在进行指针运算和解引用操作时。编译器可能会给出警告或错误提示,因为它无法确保数据以正确的方式解释。
然而,如果将int*
或其他任意类型的指针转换成void*
类型,则不会出现上述兼容性问题。void*
是一个通用指针类型,它可以指向任何类型的数据,但它不具备直接进行算术运算或解引用的能力,因为编译器不知道void*
所指向的确切数据类型大小。
int main()
{
int a = 10;
void* p = &a;
void* pc = &a;
return 0;
}
上述代码的解决方案如下:
const修饰指针
const修饰变量
我们知道如果不想让一个变量被修改,我们可以用const来限制,但const只能限制不能修改变量,但是我们还是可以通过修改指针地址所指向的值来间接修改变量,例如:
int main()
{
const int a = 10;
printf("%d\n", a);
int* p = &a;
*p = 0;
printf("%d\n", a);
return 0;
}
上述代码运行结果如下:
确实,通过修改指针变量指向的地址,可以间接地修改其指向的变量值。然而,在某些情况下,我们可能希望确保某个变量不被任何途径所修改,包括通过指针间接修改。为此,C语言提供了const关键字来修饰指针以实现这一目的。
const修饰指针变量
我们通过下面的代码来理解const修饰指针变量的过程:
void test1()
{
int a = 10;
printf("%d", a);
const int* p = &a;
*p = 0;
printf("%d", a);
}
int main()
{
test1();
return 0;
}
我们先来讨论const在*左边的情况,运行上述代码,编译器会报错:
这就说明了这个指针变量已经无法被修改了。
我们再来讨论const在*右边的情况
void test1()
{
int a = 10;
printf("%d", a);
int* const p = &a;
*p = 0;
printf("%d", a);
}
int main()
{
test1();
return 0;
}
此时我们运行,编译器不会报错
确实如此,`const`关键字在指针声明中的位置决定了哪些部分是不可修改的。下面是对不同情况下`const`修饰指针的详细解释:
1. 当`const`位于星号`*`左边时,表示该指针指向的是一个常量,即通过此指针无法修改所指向内存区域的值。
2. 当`const`位于星号`*`右侧时,意味着指针本身(即地址)是常量,但指针所指向的数据是可以被修改的。
3. 若`const`同时位于星号`*`两侧,则既说明指针本身也是常量,又表明它指向的值同样是不可修改的,这为数据提供了最严格的保护。
总结来说,在C语言中,`const`的位置对于限制指针可变性至关重要,它可以用来确保数据的安全性和程序的稳定性。
指针的使用和传址调用
strlen的模拟实现
我们可以通过代码来实现C语言库函数中strlen函数
代码如下:
#include <stdio.h>
int my_strlen(const char * a)
{
int count = 0;
while (*a)
{
a++;
count++;
}
return count;
}
int main()
{
int len = my_strlen("abcdef");
printf("%d", len);
return 0;
}
我们可以发现指针的用处还是非常大的
指针与数组的关系
通过指针遍历数组
在学习指针之前,我们通常通过遍历数组下标来访问和打印数组中的元素。然而,现在掌握了指针知识后,我们可以采用更高效且直观的方式来遍历数组。以下是使用指针遍历并打印数组内容的代码示例
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
void print(int* arr,int size)
{
int* p = arr;
for (int i = 0; i < size; i++)
{
printf("%d ", *(p + i));
}
}
int main()
{
int a[10] = { 1,2,3,4,5,6,7,8,9,10 };
int size = sizeof(a) / sizeof(a[0]);
print(a,size);
return 0;
}
此时我们发现可以通过指针来访问数组元素
指针数组
顾名思义,它的本质是数组,用来存放指针的数组
int* arr[10]
这里我们定义了一个数组,它的返回类型是指针,所以我们称之为指针数组,内部用来存放指针变量。
数组指针
顾名思义,它的本质是指针,内部用来存放数组
int (*p)[5];
int arr[5] = {0};
int (*p)[5] = &arr;
通过上述代码我们可以创建一个数组指针并且对其进行初始化
指针与函数的关系
函数指针变量的创建以及函数参数传递中指针的用法
类比前面学习的整型指针,数组指针,我们不难得出结论函数指针,顾名思义就是存放函数地址的指针,其本质还是指针,未来可以通过地址来调用该函数。
void (*fun)(int,int)
上述例子是对函数指针的一个初始化,我们会发现它和函数的定义基本上差不多,只是多了一个*号,void是返回类型,(int,int)是我们假设函数有两个int类型的参数,这样一来函数指针也变得非常好理解了
下面我们通过一个例子来说明如何通过地址来调用函数
#include <stdio.h>
int add(int x,int y)
{
int z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int (*ptr)(int, int) = add;
//int (*ptr)(int x, int y) = &add; //x和y写不写都可以的
printf("%d\n", ptr(a, b));
printf("%d\n", (*ptr)(a, b));//加不加*都可以的
return 0;
}
回调函数的使用
回调函数是一种编程概念,它是指一个函数(称为回调函数)作为参数传递给另一个函数,在特定的事件或条件发生时,由被调用方在内部调用这个函数来进行进一步的操作。这样设计的好处是允许底层代码在需要的时候执行高层代码提供的功能。
我们直接通过一个案例来讲述回调函数
案例分析
通过指针和回调函数写一个计算器
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
int add(int x, int y)
{
int z = x + y;
return z;
}
int sub(int x, int y)
{
int z = x - y;
return z;
}
int mul(int x, int y)
{
int z = x * y;
return z;
}
int div(int x, int y)
{
int z = x / y;
return z;
}
void calc(int (*ptr)(int,int))
{
int a = 0;
int b = 0;
printf("please enter two nums:");
scanf("%d %d", &a, &b);
int ret = ptr(a, b);
printf("%d\n", ret);
}
void menu()
{
int input = 0;
do
{
printf("**********************************\n");
printf("*****1.add 2.sub*****\n");
printf("*****3.mul 4.div*****\n");
printf("********** 0.exit **********\n");
printf("**********************************\n");
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("exit!!");
break;
default:
printf("error,input again.");
break;
}
} while (input);
}
int main()
{
menu();
return 0;
}
通过运用函数指针和回调机制,我们能够在编程过程中显著提升代码的复用性和效率,有效减少冗余代码。这一巧妙的设计使得底层逻辑能够灵活地调用高层定义的操作,实现了程序设计的高度解耦与模块化。
在本次讨论中,我们深入浅出地探讨了C语言指针的相关内容,包括指针数组的声明与使用,以及如何初始化和应用函数指针。此外,还通过实例展示了回调函数在实际编程中的重要作用。
希望这次关于C语言指针的分享能为各位的学习与实践带来实质性的帮助。让我们共同期待下一次更加精彩的知识探索之旅,下次再见!