1.内存和地址:(1)首先,要想了解指针,必须要先了解内存和地址。举个例子:有一栋楼,里面有很多房间,你要去找一个人,如果没有门牌号这些的信息的话,要想找到这个人,你就得一间一间的去找才可以找到,这会非常麻烦,但是如果给每个房间编了号,这时候你要去找这个人,只要知道他在哪个房间号,就可以找到了,这样就提高了查找的效率。计算机上CPU(中央处理器)在处理数据的时候,需要的数据是从内存中读取的,处理后的数据也会放回内存当中。我们把内存划分为一个一个的内存单元,每个内存单元的大小取一个字节,并且会给每个内存单元编个号,有了这个号,CPU就可以快速找到一个内存空间,把门牌号称为地址,在计算机中也把内存单元的称为地址,C语言中给地址起了个新名字:指针。所以可以理解为内存单元的编号==地址==指针。
计算机能够识别、存储和运算的时候都是使用2进制位的,2进制只有0和1,要把这一个1或者0存起来,这个时候所需要的空间的大小就是1个bit,所以一个bit位里面存着的就是一个2进制位。再往上就是字节byte,1byte=8bit,1kb=1024byte,1mb=1024kb,然后就是gb、tb、pb,都是1024来转换的。1024是2的十次方。
(2)如何理解编制:计算机内有许多硬件单元,而硬件单元是要相互协同工作的,至少要相互之间能够进行数据的传递。为了实现传递数据,就用电线连起来。CPU从内存中获取数据叫做读操作或者输入操作,把数据传输到内存中叫做写操作或者输出操作,CPU和内存中有控制总线、数据总线和地址总线,在读取数据的时候,首先地址信号从CPU通过地址线传输给内存,内存知道要那个内存单元,然后再通过数据总线将内存单元的数据传到CPU去。控制总线则传的是这次在做什么。
跟钢琴一样,每个琴键代表一个音,而这些琴键对应的音符已经被制造商在乐器硬件层面上设计好了,并且所有的演奏者都知道,本质是一种约定出来的共识。硬件编址也是如此,关于地址线,电脑有分32位机器和64位机器的,在32机器中有32根电线,电线通电时,会产生0和1的信号,总共有32根,一次就有32个数字,就是一个二进制序列,这个二进制序列产生的数字作为一个编号的话,那么就对应一块空间,总共32根,一根有两种情况,那么就可以产生2的32次方个二进制序列。以后CPU在访问内存时就只需要传输地址,就可以对应到内存的相应位置,内存中的地址都是固定死的。我们在程序中编写出来的地址都叫虚拟地址,通过操作系统和硬件将其转换为物理地址才可以在内存中得到数据。
总结一下就是:内存会被划分为一个一个的内存单元,每个内存单元大小都是一个字节。每个内存单元都会给一个编号,也就是地址或者指针。
2.(1)指针和指针变量:变量创建的本质就是在内存中开辟一块空间。例如:int a,那么内存中有一个字节的空间是给a这个变量的,我让a=17,那么17放在这块空间里面。
红色部分为a的空间
调试一下:
看最左边,由此可知确实是分配了四个字节,且是连续的,但是并不是这四个地址都指向a,因为没有办法把a给分裂开,假如去监视窗口&a的时候,就有:
就发现只有一个,且还是第一个字节的地址,所以只要找到第一个字节的地址,就可以顺藤摸瓜找到后面的地址。
既然已经知道a的地址了,地址也是一个值,是一个常量,那么是不是可以将它存起来放到内存中去,那么就可以定义一个变量p,把a的地址赋值给p,p=&a,但是p在创建的时候有点不一样,因为存放的是有特殊意义的值,所以就有一个写法:int* p=&a。则p就是一个指针变量了,地址我们也称为指针,把地址放到p里面也可以说把指针放到p里面,由此也说明指针变量p是存放指针的变量,所以也要记住:指针变量就是用来存放地址的。
(2)指针变量的类型:int a=17即在内存中开辟四个字节的空间存放a这个变量,值为17,且a的第一个字节的地址&a放到int* p里面去,这样子又开辟一块空间存放p这个变量值为地址的值,这样通过p就可以找到a,就像是p指向a一样。p的类型为int*。关于int*,需要分开理解,这里的*是说明p是一个指针变量,前面的int则说明p指向的对象是int类型的。
(2)解引用操作符:得到了a的地址并把它存到指针变量p里面,现在想要通过p对a的值进行访问,就直接*p,这个操作叫做解引用操作,*叫做解引用操作符。这个时候*p等价于a,*p=17。
总结一下,指针其实就是地址,指针变量就是存放指针(地址)的变量。口头语中说的指针就是指针变量。
(4)指针变量的大小:指针变量需要多大空间,取决于地址的大小,地址的存放需要多大空间,指针变量的大小就是多少。在32位的机器上,地址是32个0或者1的二进制序列,一个数字是1个bit那么总共就要32个bit位,也就是四个字节,64位的机器就是八个字节。所以指针类型的大小和类型是无关的,只要是指针变量,在同一个平台下,大小都是一样的。
3.指针类型的意义:
(1)指针的解引用:
首先创建变量并调试:
继续往下调试:
可见当走到*pa=0时,a的值瞬间变为0,地址上面这四个数字都变成0。
接下来把int*变为char*并调试
f10继续走下去直到*pa=0,
发现只变了一个。由于指针类型的变化,解引用也发生了变化。由此可知,指针类型决定了指针进行解引用操作时访问多大的空间。int*解引用访问4个字节的空间,char*解引用访问1个字节的空间。
(2)指针加减:
首先写一段代码:
int main()
{
int a = 17;
int* pa = &a;
char* pc = &a;
printf("pa =%p\n", pa);
printf("pa+1=%p\n", pa + 1);
printf("pc =%p\n", pc);
printf("pc+1=%p\n", pc + 1);
return 0;
}
可见int*类型的+1地址加了4个字节,char*类型的+1地址加了1个字节。因为pa指向的是整型,+1就跳过一个整型,pc指向字符,所以+1跳过一个字符。
结论:指针类型决定了指针的步长,就是向前/向后走一步多大距离。type* p:p+i 是跳过i个type类型的数据。那么int* p,p+2就是跳过两个int类型的数据,就是8个字节。
(3)void*:这是一种特殊类型的指针,可以理解为无具体类型的指针(或者叫泛型指针)。这种类型的指针可以用来接受 任意类型的地址,但是它不能进行指针的加减整数和解引用操作。
4.const修饰指针:
(1)const修饰变量:
int main()
{
const int n = 10;
n = 10;
printf("%d\n", n);
return 0;
}
运行后发现编译错误:
const修饰了n,n就没有办法再去修改了,但是n还是变量,但是利用指针解引用操作就可以修改:
int main()
{
const int n = 10;
int* p = &n;
*p = 0;
printf("%d\n", n);
return 0;
}
加上了const是为了让n不被修改,但是通过指针却可以修改,这样做const失去了意义,所以应该让p拿到的地址也不被修改,于是就需要const修饰指针变量。
(2)const修饰指针变量:
一般来讲const修饰指针变量,可以放在*的左边,也可以放在右边。
创建一个指针变量p,里面存放的是别人的地址,但是它自己本身也是一个变量,所以它也有自己的地址。
关于这两种写法的区别:
最后结果是可以编译的,也就是说,当p去指向n的时候,但是const放左边限制了*p,所以没有办法去修改n,但是p也可以改变指向对象去修改m。放左边限制了*p,意思是不能通过p来改变p指向的对象,但是p可以指向其他变量。
当写在右边的时候,反而可以去修改n的值,但是没有办法去改变指向对象了。
5.指针运算:
(1)指针加减整数:访问int数组中的元素,可以用老方法,也可以用指针。由于数组在内存中是连续存放的,地址由低到高,所以只要找到起始元素的地址的话,就可以访问后面的。又由于int数组里面的元素是一个整型一个整型的,所以访问一个就跳过一个整型,就可以使用整型指针。那么就把首元素的地址传给整型指针,访问完一个之后就p++跳到下一个元素的地址,再使用循环就可以了。
#include <stdio.h>
int main()
{
int arr[5] = { 1,2,3,4,5 };
int sz = sizeof(arr) / sizeof(arr[0]);
int* p = &arr[0];
for (int i = 0; i < sz; i++)
{
printf("%d ", *p);
p++;
}
return 0;
}
(2)指针-指针:在日常生活中,有日期+-天数=日期,日期-日期=天数,日期和日期也可以比较大小,但是日期+日期意义不是很大。C语言中,指针可以+-整数得到另外的指针值,指针-指针就得到两者之间的元素个数。
#include <stdio.h>
int main()
{
int arr[5] = { 0 };
printf("%d\n", &arr[4] - &arr[0]);//指针-指针
printf("%d\n", &arr[0] - &arr[4]);
return 0;
}
所以指针-指针的绝对值是指针之间的元素个数,但是前提是两个指针指向同一块空间。
int main()
{
int arr[5] = { 0 };
printf("%d\n", &arr[4] - &arr[0]);//指针-指针
printf("%d\n", &arr[0] - &arr[4]);
char ch[5] = { 0 };
printf("%d\n", &arr[4] - &ch[0]);//err
return 0;
}
这样写是错误的,第一是因为这两个数组在内存中是不是连续的,中间有多大距离是不确定的,第二是因为一个是整型数组一个是字符数组,如果是求两者之间元素的个数的话,那么是求整型个数还是字符个数是不确定的。
例:模拟实现strlen函数:
int my_strlen(char* p)
{
int count = 0;
while (*p != '\0')
{
count++;
p++;
}
return count;
}
int main()
{
char arr[] = "abcdef";
int len = my_strlen(arr);
printf("%d", len);
return 0;
}
int my_strlen(char* p)
{
char* p1 = p;
while (*p != '\0')
{
p++;
}
return p - p1;
}
int main()
{
char arr[] = "abcdef";
int len = my_strlen(arr);
printf("%d", len);
return 0;
}
(3)指针的关系运算:指针可以比较大小,那么可以运用指针的关系运算来实现访问数组元素的操作:
int main()
{
int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
int sz = sizeof(arr) / sizeof(arr[0]);
int* p = &arr[0];
while (p < arr + sz)//指针的关系运算
{
printf("%d ", *p);
p++;
}
return 0;
}
6.野指针:就是指针指向的位置是不可知的(随机的、不正确的或没有明确限制的)。可以把它类比为野狗,没有人管着没有绳子拴着很危险。
(1)造成野指针的可能原因:
*指针未初始化:
p此时是个局部变量,局部变量不初始化的时候它的值是随机值,这样子指针去访问空间的时候会随便找个地址进去,你自己也不知道哪个地方的内存被放了个20进去。
*指针越界访问:
int main()
{
int arr[5] = { 0 };
int* p = &arr[0];
int i = 0;
for (i = 0; i <= 6; i++)
{
//当指针指向范围超过数组arr的范围时,p就是野指针
*(p++) = i;
}
}
*指针指向的空间释放:
int* test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d\n", *p);
return 0;
}
当函数调用结束后,n的空间就被回收了,最后想用p存的地址访问n这一块空间,似乎不太合适。
就比如你去外面住酒店,交了钱就住一晚上,然后你通知你的朋友你的房间号让他明天来找你,
结果你第二天就走了并且把房间退了,你的朋友到了且不知道你离开了,就直接进去了,是不是不太合适。
(2)避免野指针:既然如此,为了避免野指针,就应该不放上面的错误。
*指针初始化:当明确知道指针变量的指向是,就直接给地址了就好。如果不知道指向,就赋一个NULL(是0,被强制类型转换为void*,本质是0),就是给它赋值一个地址为0的地址,赋值NULL后也叫它空指针。但是赋值了NULL就不能进行解引用操作,
#include <stdio.h>
int main()
{
int* p = NULL;
*p = 20;
}
因为NULL这个地址是无法使用的,读写该地址也会报错。
*小心指针越界。
*指针不再使用的时候,及时将其置为空指针,使用之前检查有效性。
*避免返回局部变量的地址 。
7.assert断言:assert.h头文件定义了宏assert( ),用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行,这个宏常常被称为断言。
上面代码如果在程序运行到这一句时,验证p是否等于NULL,不是就继续运行,是就终止运行并给出报错信息提示。
assert( )宏接受一个表达式作为参数,表达式为真(返回值非0),assert不会产生任何作用,程序继续运行。如果为假(返回值为0)就会报错,并在标准错误流stderr中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的头文件名和行号。
#include <assert.h>
#include <stdio.h>
int main()
{
int n = 10;
int* p = &n;
assert(p != NULL);
*p = 20;
printf("%d ", n);
return 0;
}
#include <assert.h>
#include <stdio.h>
int main()
{
int n = 10;
int* p = NULL;
assert(p != NULL);
*p = 20;
printf("%d ", n);
return 0;
}
断言失败,p不等于空指针。
也可以不止断言指针:
#include <assert.h>
#include <stdio.h>
int main()
{
int n = 0;
scanf("%d", &n);
assert(n != 0);
printf("%d\n", n);
return 0;
}
assert( )的使用对程序员来说十分友好,不仅能自动标识文件和出问题行号,还有一种无需更改代码就能开启或关闭assert( )的机制,如果已经确认程序没有问题,不需要做断言,就在#include <assert.h> 语句前定义一个宏#difine NDEBUG,然后重新编译程序编译器会禁用文件中所有的assert语句。但是缺点是引入了额外的检查,增加程序运行时间。
一般可以在Debug中使用,在Release版本中选择禁用就可以了,在VS这样的集成开发环境中,在Release版本中,直接就是优化掉了。这样在Debug版本有利于程序员排查问题,在Release版本不影响用户使用程序时的效率。
8.指针的使用和传址调用:
(1)strlen的模拟实现:库函数strlen的功能是求字符串长度,统计的是\0之前的字符个数,虽然上面有一个,但是下面这个是优化的,更有东西。strlen传的值是数组名,也就是地址,模拟实现一个strlen函数的话,也需要传地址,用char*指针接受。
就可以断言一下,如果是空指针就报错。然后我们只是求字符串长度,不希望把里面的内容改掉,就可以加上const给s加上限制。strlen是求字符串长度,那么长度不可能是负数,所以返回值最好设置为无符号类型的整数。这样子代码就会更加安全严谨。
#include <stdio.h>
#include <assert.h>
size_t my_strlen(const char* s)
{
size_t count = 0;
assert(s != NULL);
while (*s != '\0')
{
count++;
s++;
}
return count;
}
int main()
{
char arr[] = "abcdef";
size_t len = my_strlen(arr);//假如这里有人传了空指针
printf("%zd\n", len);
return 0;
}
(2)传值调用和传址调用:
写一个函数,交换两个整型变量的值:
结果没有发生改变,那么调试一下看一下:
可以看到调试起来之后交换前是没有问题的,所以问题可能在交换函数那里,f11走进去,
可以看到x和y是有自己独立的空间的,并且完成交换了,但是函数调用完之后空间回收,a和b里面的值都不会受到影响,
所以不会交换。实参传给形参的时候,形参是实参的一份临时拷贝,形参是有自己独立的空间的,对形参的修改不会影响实参。
但是可以把实参的地址存给指针变量,两者建立起某种联系就可以对地址里面的值进行修改了。所以可以把a和b的地址传给形参,用指针变量来接收。
void Swap(int* x, int* y)
{
int z = 0;
z = *x;
*x = *y;
*y = z;
}
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;
}
修改后的方式是传址调用,对形参的修改会影响实参。以后如果只需要使用主调函数的值来计算就可以使用传值调用,若函数内部要修改主调函数中变量的值就需要使用传址调用 。
指针第一节到此结束。