很多初学 C 的编程者呢往往会忽略指针模块的知识点,都认为指针很难很难,但笔者想告诉各位指针其实没有想像的那么难(前提理解本质),笔者所写的模块对于学习是很有帮助的,同时也可以作为查阅知识的 “字典” 。
前言
在大家学习 C 语言的路途中会常听到一句话: 学习 C 语言不学内存 , 指针,就相当于没学 C !
可想 C 中的 指针 在这个语言的重要性 ,C 语言呢其实就是玩内存 ,当我们有了一定的知识储备以后会发现 哇!! 原来这么有趣 ,真是大开眼界 !! 以下呢 笔者将会带领大家进入 C 的内存世界 , 领略 C 内部的一些神奇而又美妙的地方!
一、初始指针
• 内存和地址(必看)
想要学好指针 ,那么我们就要理解其本质,内存在我们学习指针过程很重要 ,笔者先带领学者了解内存和编址。
1. 内存
在现实生活中呢我们所住的房子 , 日常快递 ... 都会存在 编号 ,地址 的问题 ,不妨想想我们要去朋友家,肯定会根据朋友提供的地址和门牌编号去寻找 ,这样很快就能找到。
那么我们的内存也同样 , 想要快速找到内存呢 , C 语言中对内存开放了内存单元 , 每个单元的大小存储一个字节 , 也同时给每个单元也编了号 , 那么这个编号在 C 语言中我们称为 :地址。
其中的 0xFFFFFFFF 就是 地址 == 内存单元的编号 ,这个地址就是我们常说的指针。
所以我们就可以得出一个结论 : 内存单元的编号 == 地址 == 指针
2. 编址
CPU访问内存中的某个字节空间,必须知道这个字节空间在内存的什么位置,然而因为内存中字节
很多,所以需要给内存进行编址(就相当于我们快递的单号一样)。然而计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。
首先,必须理解,计算机内是有很多的硬件单元,而硬件单元是要互相协同⼯作的。所谓的协
同,至少相互之间要能够进⾏数据传递。但是硬件硬件之间是互相独⽴的,那么如何通信呢?其实很简单 , 就是通过 ‘线’ 来串联起来 。因为CPU和内存之间也是有大量的数据交互的,所以,两者必须也用 线 连起来。
这就是硬件的分布情况!!!!
涉及指针笔者就介绍 “地址总线 ” 。32位机器有32根地址总线,每根线只有两态,表示 0,1【电脉冲有⽆】,那么⼀根线,就能表⽰2种含义,2根线就能表⽰4种含义,依次类推。32根地址线,就能表⽰2^32种含义,每⼀种含义都代表一个地址。地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据在通过数据总线传入CPU内寄存器。
3.小结
1. 通过以上讲的内容各位要清楚, “ 指针 ” 其实是个地址 ,这个地址并不是存在就即可 ,而是要发挥作用 ,那么就要通过 计算机内的 “ 硬件 ”来完成想要完成的事情。 内存会分配单元 , 每个单元存放一个字节。
2. 内存编号 == 地址 == 指针
以上我们对指针到底是什么作了简单的了解 , 接着笔者将会正式讲解指针的相关内容!!
• 指针变量和相关操作符
1. 取地址操作符 -- &
提到 ‘ & ’ 操作符 ,各位一定很熟悉了 ,在没有学 ‘ 指针 ’内容之前 ,我们 scanf 读写数据的时候就必须用 ' & ' 符号 ,在之前呢可能会想为什么??
C 语言规定 : 取出一个变量的地址就必须用 " & " 操作符(数组 , 指针除外)。
补充 : sacnf 函数
变量前⾯必须加上 & 运算符(指针变量除外),因为 scanf() 传递的不是值,而是地址,
即 : 将变量 i 的地址指向用户输⼊的值 , 接着通过数据总线就可以将值传递到 CPU 。
在 C 语言中我们只要创造一个变量编译器就会自动生成一块空间来供我们使用。
#include <stdio.h> int main() { int a = 4; printf("%p\n", &a); return 0; }
这里我创建了变量 ' a ' ,同时编译器开辟了一个空间来储存 a 的值。
2. 指针变量和解引用操作符(*)
1.指针变量
顾名思义,存放指针(地址)的变量 ,如果我们把一个地址存放到一个变量中 ,这个变量就是一个指针变量 .
#include <stdio.h> int main() { int a = 1; int* p = &a; return 0; }
其中这个 " p " 变量就是指针变量。
2. 指针类型的理解
#include <stdio.h> int main() { int a = 1; int* p = &a; return 0; }
就以上这个代码例子 , 首先 p 是一个指针变量 , 那么有的人就会问了,这怎么还有 int* ??
首先这颗星 " * " 代表指针的意思 , 那么这个 int 代表 p 这个变量所指向的内容就是
int 类型的。
3. 解引用操作符(*)
如果我们要使用被存放的地址 , 就要用到 " * " 操作符 。
有这样一道题: a 初始值为1 ,通过代码将 a 的值改为 3.
可能你会这样做:
#include <stdio.h> int main() { int a = 1; int b = 3; printf("a 被改前:%d\n", a); a = b; printf("a 被改后:%d\n", a); return 0; }
但当我们学了指针就可以这样做:
#include <stdio.h>
int main()
{
int a = 1;
int* p = &a;
printf("a 被改前:%d\n", a);
//对指针解引用可以得到被指向的那个变量本身
*p = 3;
printf("a 被改后:%d\n", a);
return 0;
}
这样的做法呢我们既可以得到变量 a 原来的地址 , 也可以改变其值,当我们想要使用 a 原来的地址时就很方便。
• 指针变量的大小
• 32位平台下地址是32个 bit 位,指针变量大小是 4个字节。
• 64位平台下地址是64个 bit 位,指针变量大小是 8个字节。
那么为什么呢?前⾯的内容我们了解到,32位机器假设有32根地址总线,每根地址线出来的电信号转换成数字信号后是1或者0,那我们把32根地址线产⽣的2进制序列当做⼀个地址,那么⼀个地址就是32个bit位,需要4个字节才能存储。如果指针变量是⽤来存放地址的,那么指针变量的大小就得是4个字节的空间才可以,那么指针变量的大小就是 4 个字节。
同理64位机器,假设有64根地址线,⼀个地址就是64个⼆进制位组成的⼆进制序列,存储起来就需要8个字节的空间,指针变的大小就是8个字节。
指针变量的大小与类型无关 ,与所在的平台有关!!!!!!!
#include <stdio.h>
int main()
{
printf("%zd\n", sizeof(int*));
printf("%zd\n", sizeof(char*));
printf("%zd\n", sizeof(short*));
printf("%zd\n", sizeof(long long int*));
printf("%zd\n", sizeof(int*));
printf("%zd\n", sizeof(float*));
return 0;
}
这是不同平台下的运行结果。
补充: sizeof -- 的返回类型是 size_t 类型,所以用 %zd 打印更靠谱。
• 指针变量类型存在的意义
以上讲到了指针变量的大小和类型无关,只要是指针变量,在同⼀个平台下,大小都是⼀样的,为什么还要有各种各样的指针类型呢?其实这些类型是有很大作用的。
笔者通过一段代码的调试来举例:
给这样一段代码:
#include <stdio.h>
int main()
{
int a = 0x11223344;
int* pa = &a;
*pa = 0;
return 0;
}
通过以上的代码的调试可以发现 ,pa是指针变量 , 指向的是 int 类型 , int 在内存中的存储是 4
个字节 ,当对 pa这个指针变量解引用时就找到了pa指向的变量本身 ( *pa ) = 0 ,可以发现 4 个字
节都被改变。
#include <stdio.h>
int main()
{
int a = 0x11223344;
char* pa = &a;
*pa = 0;
return 0;
}
通过这个代码我们又可以发现 pa 是一个指针变量 , 指向的是 char 类型的,char 在内存中存储的是 一个 字节 ,( *pa ) =0 , 就会访问一个字节 ,同时就改掉一个字节的数据 。
综上 , 指针变量的类型是很重要的。
二、指针的进阶(一)
• 指针的运算
1. 指针 + - 整数
前面讲到了指针类型对于指针的作用 ,基于这个我们在深挖一点 ,以下一个代码展示:
#include <stdio.h>
int main()
{
int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
int* p = &arr[0]; // 数组首元素的地址放到 p 中
printf("*p = %d\n", *(p));
printf("*(p+1) = %d\n", *(p + 1));
printf("*(p+2) = %d\n", *(p + 2));
return 0;
}
p 是一个指针变量 , 指向的 int 类型的 , int 为 4个字节 ,所以 +1 就访问 4 个字节 就到了第二个元素.....
那么同理 , 指针 - 也是一样看指针变量的类型。
练习:
打印一个整型数组中的每个元素 , 通过指针的方式来完成。(请各位独立完成!)
2. 指针 - 指针
前面也提到了数组,有这样一个代码我们不妨求一求:
#include <stdio.h>
int main()
{
char ch[] = "abcdef";// a b c d e f '\0'
char* p1 = &ch[0];//首元素的地址
char* p2 = &ch[0]+6;//指向 '\0'
int len = strlen(ch);
printf("%d\n", p2 - p1);
printf("%d\n", len);
return 0;
}
通过调试可看到 ch 数组中存入的是 " a b c d e f '\0' " 这几个元素 ,&ch[0] 是数据首元素的地址 ,那么 &ch[0] + 6 就是 ' \0 ' 处的地址。
补充: 数组名是数组首元素的地址 , 所以 &ch[0] 和 ch 的地址是一样的。
#include <stdio.h> int main() { int arr[] = { 1,2,3,4,5 }; int* p1 = &arr[0]; int* p2 = arr; printf("&arr[0] = %p\n", p1); printf(" arr = %p\n", p2); return 0; }
可以看到地址是一摸一样的 , 所以可以直接写 ch 就代表了首元素的地址.
通过以上代码可以发现 两个指针相减 和 strlen 求出来的字符串长度是一样的 , 这不是偶然!
结论: 指针 - 指针 = 两个指针之间的元素个数 。
• 野指针
1.野指针成因
• 指针未初始化
#include <stdio.h> int main() { int* p; *p = 20; return 0; }
这样的代码就运行是就会发现编译器会这样报错 , p 首先是局部变量 ,局部变量未初始化就会生成随机值。 可以这样想 : p 所指的对象不明确 ,想要解引用改掉 p 所指对象的值是做不到的 , 这就是 “ 野指针 ”
• 指针越界访问
代码:
#include <stdio.h> int main() { int arr[8] = { 1,2,3,4,5,6,7,8 }; // 0 1 2 3 4 5 6 7 int* p = arr; for (int i = 0; i <=8; i++) { printf("%d ", *(p + i)); } return 0; }
这个代码可以看到 指针访问数组是越界的 , 数组下标是 从 0 开始 ,也就意味着 i == 7 访问的是 数组里的 8 这个元素 ,而 * (p+8) 就越界了 ,没有了明确的指向了 , 那么 p+8 这个指针就是野指针 !!
• 指针所指的空间被释放
#include <stdio.h>
int* test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d\n", *p);
return 0;
}
这样的代码虽然看上去没什么问题 ,但是其实 p 为野指针了 , 原因:test 函数中的 n 变量是 局部变量,是在 test 这个函数中起作用的 , 局部变量一但出了函数就会被销毁 ,所以即使主函数中对 p 解引用 ,实质上 p 所指的对象已经不明确了 , 所以 p 指针就是 野指针了。
• const 修饰指针
1. const 修饰指针变量本身
#include <stdio.h>
int main()
{
int a = 5;
int b = 2;
int* p = &a;
p = &b;
*p = 1;
printf("%d\n", b);
return 0;
}
#include <stdio.h> int main() { int a = 5; int b = 2; int* const p = &a; p = &b; return 0; }
对比以上 2 个代码可以发现: 当我们加上 const 时 ,再一次对 p 赋一个地址发现就会报错 , 这就是 const 的一种用法 , 当 const 修饰 指针变量本身时 , 指针变量就不能被修改了 , 也就意味着被修饰的内容具有了常属性 。
2. const 修饰指针变量指向的内容
#include <stdio.h>
int main()
{
int a = 5;
int b = 2;
int* p = &a;
p = &b;
*p = 1;
printf("%d\n", b);
return 0;
}
#include <stdio.h> int main() { int a = 5; int b = 2; int const * p = &a; p = &b; *p = 20; return 0; }
const 修饰的是 *p 也就是修饰的是 指针变量所指向的内容 , 所以指针变量所指向的内容具有了常属性.
结论:
• const 修饰谁 , 谁就不能被改变。
• assert 断言
assert.h 头⽂件定义了宏 assert() ,⽤于在运⾏时确保程序符合指定条件,如果不符合,就报
错终⽌运⾏。这个宏常常被称为“断⾔”。
例如: assert( p != NULL );
当代码执行到这句代码时 ,如果 p == NULL 时程序就会终止运行。
assert( ) 宏接受⼀个表达式作为参数。如果该表达式为真(返回值非零), assert( ) 不会产⽣
任何作⽤,程序继续运行。如果该表达式为假(返回值为零), assert() 就会报错,在标准错误
流 stderr 中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的⽂件名和行号。
#include <stdio.h> #include <assert.h> void test(void* p) { assert( p != NULL); printf("%p\n", p); } int main() { test(NULL); return 0; }
• 指针的使用和传址调用
1.strlen 函数
strlenhttps://legacy.cplusplus.com/reference/cstring/strlen/?kw=strlenstrlen 函数是专门用来求字符串中字符个数的,其函数求的是 ' \0 ' 之前的字符个数 。返回值的类型为 size_t 类型。参数 str 接收⼀个字符串的起始地址,然后开始统计字符串中 \0 之前的字符个数,最终返回⻓度。
到 \0 就停⽌。
求下段代码 ch1 , ch2 的长度。
#include <stdio.h> int main() { char ch1[] = "abcdef"; char ch2[] = { 'a','b','c','d','e','f' }; return 0; }
有的人看到这样的代码就会想这两个字符数组有什么区别 , 不都是 6 个元素?压根不需要求!
那真的是这样吗?
但当我们调试代码就可以看到:
会发现 ch1 字符数组是有 ' \0 ' ,而 ch2 字符数组是没有 ' \0 ' 的。strlen 求的是 ' \0 ' 之前的字符长度呀, 所以: ch1 = 6 , ch2 是一个随机值。
#include <stdio.h>
#include <string.h>
int main()
{
char ch1[] = "abcdef";
char ch2[] = { 'a','b','c','d','e','f' };
int len1 = strlen(ch1);
printf("len1 = %d\n", len1);
int len2 = strlen(ch2);
printf("len2 = %d\n", len2);
return 0;
}
通过以上代码便可验证。
2. strlen 函数的模拟实现
以上笔者对于 strlen 的应用作了讲解 , 那么我们不妨深挖一下 , 自己把 strlen 实现。
分析:
首先 ,strlen 求的是字符串中 ' \0 ' 之前的字符个数 , 那么首先我们要传参的字符数组肯定要有 ' \0 ' 吧 , 其次 ,数组名是数组首元素的地址 ,前面也讲到了指针 ,那么传参就可以用指针的形式来得到所求的数组 。那么基本的思路就有了 ,再有就是怎么得到这个长度呢,笔者讲解俩种实现的方法。
方法一:通过计数器实现
#include <stdio.h> #include <assert.h> size_t my_strlen(const char* str) { assert(str != NULL); int count = 0; // 计数器 while (*str != '\0') { count++; str++; } return count; } int main() { char ch1[] = "abcdef"; //a b c d e f '\0' char ch2[] = { 'a','b','c','d','e','f' }; size_t len1 = my_strlen(ch1); size_t len2 = my_strlen(ch2); //数组名就是数组首元素的地址 printf("ch1 = %zd\n", len1); printf("ch2 = %zd\n", len2); return 0; }
可以发现我们也同样实现了 strlen 的功能。
代码解析:
首先传参传的是数组首元素的地址 , 地址就是指针 , 那么这个指针要指向这个数组的内容 ,所以接收时用的是 char* 。 那么思路就是 : 当 str 这个指针指向的不是 ' \0 ' 时,统计个数,当 str 这个指针指向了 ' \0 ' 那么就统计结束 , 最终把统计的个数返回即可。所以:
while( *str != '\0 ' ) 保证了 str 指向的内容不是 ' \0 ' , 那么就计数 , 但计数需要一个变量,那就定义一个计数器让其统计个数 ,统计完一个就要统计下一个 ,那么就让 str ++ , 地址往后走 , 指向的元素才会有不同 (类型是 char* 所以每一次 +1 就会访问一个字节 ,正好就是下一个元素)。
const char* str -- const 修饰的是 str 指向的元素 ,因为我们只希望求出长度即可 , 而不希望原来的数组被他人改变 , assert 保证不为 NULL , 这样我们的代码会有更强的可执行性。
方法二:通过 指针 - 指针 实现
前面也讲到了: 指针 - 指针 == 二者之前的元素的个数 ,我们要求的正是个数呀, 那么这个思路也就明确了。
#include <stdio.h>
#include <assert.h>
size_t my_strlen(const char* str)
{
assert(str != NULL);
char* start = str;
while (*str != '\0')
str++;
return str - start;
}
int main()
{
char ch1[] = "abcdef";
//a b c d e f '\0'
char ch2[] = { 'a','b','c','d','e','f' };
size_t len1 = my_strlen(ch1);
size_t len2 = my_strlen(ch2);
//数组名就是数组首元素的地址
printf("ch1 = %zd\n", len1);
printf("ch2 = %zd\n", len2);
return 0;
}
提示: str ++ 会改变 , 已经不是起始位置的地址 , 所以还要一个指针变量来记录这个位置!
3. 传值调用和传址调用
学习指针的目的是使⽤指针解决问题,那什么问题,非指针不可呢?
给这样一道题 , 写一个函数交换两个整型的值。
你可能会这样写:
#include <stdio.h>
void Swap(int a, int b)
{
int tmp = 0;
tmp = a;
a = b;
b = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a = %d b = %d\n", a, b);
Swap(a,b);
printf("交换后:a = %d b = %d\n", a, b);
return 0;
}
运行就会发现 ,怎么没交换??
这时我们不妨调试起来看一看:
通过调试可以看到 , a , x ; b , y ; 它们的地址并不相同 , 但是 x = 1 , y = 2 确实是把两个变量的值传递了过去 , 那么为什么就没有交换呢? 因为: x , y 有 它们自己的独立空间 ,交换值只会在x , y 它们的空间来进行交换 , 变换的是 x , y ; 显然 , a , b 是不会有变化的 , 那么 x ,y 也不会影响到 a , b 的值 。 这就得出了一个结论: 形参只是实参的一份临时拷贝 , 不会影响实参的变化。 那么这种传参就是 “ 传值调用 ” 。
想要达到题目的效果就只能用 “传址调用” 。
#include <stdio.h>
void Swap(int* x, int* y)
{
int* tmp = 0;
tmp = *x;
*x = *y;
*y = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a = %d b = %d\n", a, b);
Swap(&a,&b);
printf("交换后:a = %d b = %d\n", a, b);
return 0;
}
以上就会很发现成功的交换了两个变量的值 , 这个就是 " 传址调用 "
总结
以上就是指针的初始和进阶(一),通过这些知识相信各位一定会对指针有所收获 , 其实指针没有想象的那么难 ,希望对各位有所帮助 , 后续笔者还会继续进步 ,带领各位深挖指针 ,玩转指针!!!