1. 指针是什么?
指针理解的两个要点:
- 指针是内存中的一个最小单元的编号,也就是地址.
- 平时口语说的指针,通常指的是指针变量,是用来存放内存地址的变量
为了管理计算机内存空间,会把内存分为一个一个一个小的内存单元,每个内存单元占一个字节的空间.那么该怎么找到这些内存单元呢,这边每个内存单元都给一个编号,这些编号就是地址,也就是指针.
总结:指针就是地址,口语中说的指针通常指的是指针变量.
那我们就可以这样理解:
内存
指针变量
我们可以通过
&
(取地址操作符)得到一个变量的内存起始地址,把地址存放到这个变量中,这个变量就是指针变量.
C语言中创建的变量,数组等,都需要在内存开辟相对应的空间来存放数据.
#include <stdio.h>
int main(void)
{
int a = 10; //在内存中开辟一块空间
int* p = &a; //对变量a取地址,使用&操作符
//a变量占用4个字节的空间,这里将a的4个字节的第一个字节的地址存放在指针变量p的内存单元中
return 0;
}
通过&a
取出a
的地址,这是四个字节内存空间的首地址.
要想把a
的地址存放起来,就需要使用指针类型来存放,这边使用int *
.
int
刚好对应指针变量存放地址对应变量的数据类型,而*
表示这是一个指针变量.
通过调试看到a的地址是0x0000005F068FF804
,存放了10这个数据.
再看p所存放的值
正好存放了a的地址.
因为本机是采用小端模式,即低地址存放低位,所以会呈现如图所示的存储方式.
总结:
指针变量,用来存放地址的变量.(存放在指针中的值都被当作地址处理).
那这里的问题是:
- 一个小的单元到底是多大? (一个字节)
- 如何编址?
经过仔细地计算我们发现一个字节给一个对应的地址是比较合适的.
对于32位的机器,假设有32根地址线,那么假设每根地址线在寻址的时候产生高电平(高电压)和低电平(低电压)就是(1或者0).
那么32根地址线产生的地址就会是:
00000000 00000000 0000000 00000000
00000000 00000000 0000000 00000001
…
…
11111111 11111111 11111111 11111111
这里一共就有2的32次方个地址.
每个地址标识一个字节空间,那么
2^32 Byte = 2^22 KB = 2^12 MB = 2^2 GB = 4GB
就可以4GB
的空间进行编址.
如果是64位,就有2的64次方个地址,那么
2^64 Byte = ... = 16EB
就可以对16EB
的空间进行编址. 而市面上目前最大支持128GB的内存空间,这是完全够用的.
32或者64位只是代表管理内存的能力.
这里我们就明白了:
- 在32位的机器上,地址是32个0或者1组成二进制序列,那地址就得用4个字节的空间来存储,所以一个指针变量的大小就应该是4个字节.
- 那如果在64位机器上,如果有64根地址线,那一个指针变量的大小是8个字节,才能存放一个地址.
总结:
- 指针是用来存放地址的,地址是唯一表示一块地址空间的.
- 指针的大小在32位平台是4个字节,在64位平台是8个字节.
2. 指针和指针类型
我们都知道,变量有不同的类型,整型,浮点型等等.那指针有没有类型呢?
准确的说是有的.
char *pc = NULL;
int *pi = NULL;
short *ps = NULL;
long *pl = NULL;
float *pf = NULL;
double *pd = NULL;
这里可以看到,指针的定义方式就是:type + *
char *
类型的指针就是为了存放char
类型变量的地址.
int *
类型的指针就是为了存放int
类型变量的地址.
short *
类型的指针就是为了存放short
类型变量的地址.
通过使用sizeof
操作符看不同指针类型的大小,我们发现
#include <stdio.h>
int main(void)
{
printf("%d\n", sizeof(char*));
printf("%d\n", sizeof(short*));
printf("%d\n", sizeof(int*));
printf("%d\n", sizeof(long*));
printf("%d\n", sizeof(float *));
printf("%d\n", sizeof(double*));
return 0;
}
结果都是一致的:
那既然都是一样的大小,指针类型存在的意义是什么呢?
2.1 指针的解引用
通过调试下面这段代码,指针类型在解引用的时候影响了访问空间的大小.
#include <stdio.h>
int main(void)
{
int a = 0x11223344; //0x开头表示16进制数字
int* pi = &a; //int* 类型的指针变量指向了a
char* pc = &a; //char* 类型的指针变量指向了a
*pi = 0; //通过对指针变量的解引用进行修改
a = 0x11223344; //恢复原来的状态
*pc = 0; //再次通过对指针变量的解引用进行修改
return 0;
}
首先观察到a地址对应内存,存放了0x11223344
.
接着使用int*
类型的指针变量通过解引用对a的值进行修改,发现该地址后连续4个字节大小内存空间的内容都被修改成了0.
然后恢复a的值,使用char*
类型的指针变量通过解引用对a的值进行修改,发现该地址后只有1个字节大小内存空间的内容被修改成了0.
总结:
- 指针的类型决定了,对指针解引用的时候有多大的权限(能操作几个字节)
type *
的type
不仅代表指针指向变量的数据类型,还代表指针解引用一次能访问sizeof(type)
大小的内存空间
比如:char *
类型的指针解引用就只能访问一个字节,而int *
类型的指针解引用就能访问四个字节.
2.2 指针±整数
#include <stdio.h>
int main(void)
{
int n = 10;
char* pc = &n;
int* pi = &n;
printf("%p\n", &n);
printf("%p\n", pc);
printf("%p\n", pi);
printf("%p\n", pc + 1);
printf("%p\n", pi + 1);
return 0;
}
结果是:
总结:指针的类型决定了指针向前或者向后走一步有多大(距离):sizeof(type)
3. 野指针
概念:野指针就是指针指向的位置是不可知的(随机的,不正确的,没有明确限制的).
3.1 野指针成因
- 指针未初始化
指针变量未初始化,就和其他比如
int
类型的局部变量没有初始化一样,该变量存放的是随机值(在vs存放的是0xcccccccccccccccc),随机值被当作地址存放在指针变量中,这个位置是不可知的.
#include <stdio.h>
int main(void)
{
int* p; //局部变量未初始化,默认为随机值
*p = 20;
return 0;
}
编译器会直接报警这个行为是不合法的:
- 指针越界访问
#include <stdio.h>
int main(void)
{
int arr[10] = { 0, };
int* p = arr;
int i = 0;
for (i = 0; i < 11; i++)
{
//当指针指向的范围超出数组arr的范围时,p就是野指针
*(p++) = i;
}
return 0;
}
当p指向arr外的空间时,编译器报错了
- 指针指向的空间释放
#include <stdio.h>
int* test(void)
{
int a = 100;
return &a;
}
int main(void)
{
int* p = test();
return 0;
}
这里在函数内创建了一个局部变量a,返回了它的地址,但是在函数调用完后,a的空间被返回给了操作系统,这时候再通过p访问这块空间的话,是非法访问.
3.2 如何规避野指针
- 指针初始化
- 明确知道指针指向哪个变量所在的空间,直接将该变量的地址赋值给指针变量.
- 不明确的话,直接赋值为NULL
NULL
存在于头文件<stdio.h>
中,表示指针没有指向任何有效的空间.
include <stdio.h>
int main(void)
{
int* p = NULL;
return 0;
}
- 小心数组越界
这里给一个不使用下标访问数组,使用指针访问数组的一段代码.
#include <stdio.h>
int main(void)
{
int arr[10] = { 0, };
int* p = arr;
int i = 0;
int size = sizeof(arr) / sizeof(arr[0]);
for (i = 0; i < size; i++)
{
*p = i;
p++;
}
p = arr;
for (i = 0; i < size; i++)
{
printf("%d ", *(p + i));
}
return 0;
}
*(p + i) = arr[i] = *(arr + i) = *(i + arr) = i[arr]
这里[]
仅仅只是个操作符而已,操作数是i
和arr
,本质都是偏移量寻址.
3. 指针指向空间释放即置NULL
4. 避免返回局部变量的地址
5. 指针使用之前检查有效性
#include <stdio.h>
int main(void)
{
int* p = NULL;
int a = 10;
p = &a;
if (p != NULL)
{
*p = 20;
}
return 0;
}
4. 指针运算
- 指针 ± 整数
- 指针 - 指针
- 指针的关系运算
4.1 指针 ± 整数
指针存放地址±sizeof(type) * n
#define N_VALUES 5
float values[N_VALUES];
float* vp;
//指针+-整数;指针的关系运算
for (vp = &values[0]; vp < &values[N_VALUES];)
{
*vp++;
}
4.2 指针 - 指针
指针-指针的绝对值是两指针中间的元素个数
前提是在同一块内存空间中,否则就没有意义
可以用来实现strlen()
的功能
int my_strlen(char* s)
{
char *p = s;
while(*p != '\0')
{
p++;
}
return p-s;
}
4.3 指针的关系运算
for(vp = &values[N_VALUES]; vp > &values[0];)
{
*--vp = 0;
}
代码简化,将代码修改如下:
for(vp = &values[N_VALUES - 1]; vp >= &values[0]; vp--)
{
*vp = 0;
}
实际上在绝大部分的编译器撒谎给你是可以顺利完成任务的,然而我们还是应该比避免这样写,因为标准并不保证它可行.
标准规定:
允许指向数组元素的指针于指向数组的最后一个元素后面的哪个内存位置的指针比较,但是不允许与指向第一个元素之前的那个内存位置的指针进行比较.
5. 指针和数组
指针和数组有什么关系呢?
区别:
指针变量就是指针变量,不是数组,指针变量的大小是4/8个字节,专门用来存放地址的.
数组就是数组,不是指针,数组是一块连续的空间,可以存放1个或者多个类型相同的数据.
联系:
数组中,数组名就是数组首元素的地址,数组名 == 地址 == 指针
当我们知道数组首元素的地址的时候,而数组又是连续存放的,所以通过指针就可以遍历访问数组,数组可以通过指针来访问的.
#include <stdio.h>
int main(void)
{
int arr[10] = { 0, };
int size = sizeof(arr) / sizeof(arr[0]);
int* p = arr; //定义指针p指向数组首元素地址
int i = 0;
for (i = 0; i < size; i++)
{
printf("%p == %p\n", &arr[i], p + i);
}
return 0;
}
这里定义了一个指针指向数组首元素地址,通过遍历数组,发现
- 数组是一串连续的空间
- 通过指针也是可以遍历数组的,与通过数组索引来访问数组是一样的,
p+i
和arr[i]
是一样的.
总结:数组和指针的唯一联系就是,可以通过指针来访问数组.
6.二级指针
二级指针就是用来存放一级指针变量的地址的.
谈到二级指针,首先了解一下什么一级指针
#include <stdio.h>
int main(void)
{
int a = 0;
int* p = &a; //p是一级指针变量,指针变量也是变量,变量是在内存中开辟空间的,有变量就有地址
return 0;
}
指向非指针变量的指针,就是一级指针.
那么既然变量都有地址,也就是说指针变量也是有地址的,如果定义一个指针变量存放指针变量的地址,这个指针变量就是二级指针.二级指针变量就是用来存放一级指针变量的地址.
#include <stdio.h>
int main(void)
{
int a = 0;
int* p = &a; //p是一级指针变量,指针变量也是变量,变量是在内存中开辟空间的,有变量就有地址
int** pp = &p; //pp是二级指针变量
return 0;
}
通过调试得到以下结果:
- 这里的变量
p
是一个指针变量,前面我们学过int *
的*
表示这个变量是指针变量,int
表示这个指针变量指向的变量数据类型是int
类型`. - 同样的,这里的
pp
是int **
类型,第二个*
表示pp
是指针变量,int *
表示这个指针变量指向变量数据类型是int *
类型 - 甚至
int*** ppp = &pp
,也是一样的,ppp
是一个指向int **
类型的指针变量.
对二级指针解引用一次,可以找到一级指针.再解引用一次,可以找到一级指针指向的变量.
*(*pp) = 100;
这里就改变了一级指针p
所指向的变量a
的数值.
**pp = *(&p) = a
7.指针数组
指针数组本质是数组,只是数组存放的变量都是指针类型的
#include <stdio.h>
int main(void)
{
char arr1[] = "hello";
char arr2[] = "world";
char arr3[] = "!";
char* parr[] = { arr1, arr2, arr3 }; //定义一个指针数组,存放三个数组的首元素地址
char** p = parr; //定义一个二级指针,指向指针数组
int i = 0;
for (i = 0; i < 3; i++)
{
printf("%s\n", parr[i]);
}
return 0;
}
这里定义了一个指针数组,通过遍历指针数组访问到指针数组的内容.
8. 结构体的声明
8.1 结构的基础知识
结构是一些值的集合,这些值称为成员变量.结构的每个成员可以是不同类型的变量.
数组是一组相同类型元素的类型,而结构体则是一组不一定相同类型的元素.
- 当一些图书信息(包括价格,出版社,作者等等)等复杂的对象,不能用内置类型用来表示的,那么就需要用到结构体类型来描述复杂的类型
8.2 结构的声明
struct tag
{
member-list;
}variable-list;
例如描述一个学生:
#include <stdio.h>
struct Stu
{
//成员变量,是用来描述结构体对象的相关属性的
char name[20];
int age;
char sex[5]; //男 女 保密
};
int main(void)
{
//int a = 1;
struct Stu s1; //通过类型创建变量
return 0;
}
- 结构体类似于图纸,而创建结构体变量相当于对着图纸造房子.
8.3 结构成员的类型
结构的成员可以是标量,数组,指针,甚至是其他结构体.
8.4 结构体变量的定义和初始化
struct Point
{
int x;
int y;
}p1; //声明类型的同时定义变量p1
struct Point p2; //定义结构体变量p2
//初始化:定义变量的同时赋初值。
struct Point p3 = {x, y};
struct Stu //类型声明
{
char name[15];//名字
int age; //年龄
};
struct Stu s = {"zhangsan", 20};//初始化
struct Node
{
int data;
struct Point p;
struct Node* next;
}n1 = {10, {4,5}, NULL}; //结构体嵌套初始化
struct Node n2 = {20, {5, 6}, NULL};//结构体嵌套初始化
9.结构体成员的访问
- 结构体变量访问成员
结构体变量的成员是通过点操作符(.)来访问的,.
操作符接受两个操作符. - 结构体指针访问成员
结构体指针访问成员是通过->
来访问的
例如:
#include <stdio.h>
struct Stu
{
//成员变量,是用来描述结构体对象的相关属性的
char name[20];
int age;
char sex[5]; //男 女 保密
};
void print(struct Stu* ps)
{
//使用点操作符访问结构体成员
printf("name = %s, age = %d, sex = %s\n", (*ps).name, (*ps).age, (*ps).sex);
//使用->操作符访问结构体成员
printf("name = %s, age = %d, sex = %s\n", ps->name, ps->age, ps->sex);
}
int main(void)
{
//int a = 1;
struct Stu s1 = { "张三", 23, "男" }; //通过类型创建变量
print(&s1);
return 0;
}
结果如下:
上面的代码,我定义了一个print
函数用来打印结构体变量的内容,传递了一个结构体指针类型变量.
如果我需要使用点操作符(.
)来访问结构体成员,则需要先使用解引用操作符*
得到结构体指针所指向的结构体变量,然后使用.
操作符来访问结构体成员.
如果我不想用解引用操作符,也可以直接用->
操作符用来让结构体指针直接访问结构体成员.
10. 结构体传参
#include <stdio.h>
struct Stu
{
//成员变量,是用来描述结构体对象的相关属性的
char name[20];
int age;
char sex[5]; //男 女 保密
};
void print1(struct Stu s)
{
printf("name = %s, age = %d, sex = %s\n", s.name, s.age, s.sex);
}
void print2(struct Stu* ps)
{
printf("name = %s, age = %d, sex = %s\n", (*ps).name, (*ps).age, (*ps).sex);
}
int main(void)
{
struct Stu s1 = { "张三", 23, "男" }; //通过类型创建变量
print1(s1); //传值
print2(&s1); //传址
return 0;
}
上述代码有两个打印函数.
一个是传参函数,一个是传址函数.这两个函数完成的功能是一样的,那么哪一个更推荐呢?
答案是推荐传址函数.
- 因为我们自定义的结构体有可能非常大,包含了各种各样的类型,如果我们直接传参,就需要拷贝一份占据同样内存大小的结构体变量,这是需要时间和空间的.
- 而使用指针变量,无论该指针变量所指向的变量本身所占据多少内存空间,指针变量所占据的空间大小都是固定的4字节/8字节,这大大减小了函数参数压栈的时间和空间.
本章完.