指针详解及常见应用

雨燕双飞 提交于 2020-01-13 17:35:39

指针详解及常见应用

先说下讲解这个专题的原由,这两天一个同事老是跟我抱怨,c的指针太恶心了,指针指来指去的都指懵逼了! 听了他的话,我深深感觉应该把指针好好讲讲。

开始前先大体说下什么是指针,这里说的肯定和大部分玩家说的不一样好好理解下

大部分玩家肯定会说怎么是指针,指针就是int x=9;int* p=&x;。但是我想说的是指针本质上就是变量,只不过这个变量比较特别,它存的是内存空间(系统的虚拟内存,不是硬件的物理内存)的地址,当我们去访问时就是去跳转到内存地址去读取存储空间存储的数据。ok!指针就是那么多东西,好了我讲完了!

NO! NO! NO! 还有应用呢?

好的我们继续,其实指针大体在使用中可以做分类,我一般按2个角度分.。
本身的性质:一级指针,二级指针,三级指针等等等等可以无限下去。有人说这个我最烦它了,一级二级的一看就乱,其实在使用中只需要搞清楚,对指针操作到底是操作的是地址还是存储的实际的值。这里一级指针我就不多说了一般不会有问题;二级指针例如如果我们创建一个函数,函数体中是malloc一个内存堆空间,malloc的返回值不是作为返回值返回,而是作为函数参数传递给调用者,那么一定需要使用二维指针,因为我们这里是要在实参的地址空间存malloc申请的堆空间的地址。废话不多说看代码:

#include <iostream>
using namespace std;
void get_space(int** p,int num)
{
	*p=(int*)malloc(sizeof(int)*num);
}
int main(int argc, char* argv[])
{
	int* p=NULL;
	get_space(&p,20);
}

首先需要创建一个指针变量,用于存等下申请的空间,然后将变量的地址传入函数(这里需要补充一个知识,函数的参数传递实际上是,参数值的复制),在函数体中通过 “*” 取值运算符取出变量用以存malloc返回的地址值;我们换个思路理解,其实这里我们在这里就是把int* p作为一个变量,然后,其实还是一级指针传参的效果。我们反向推理下,如果我们直接把int* p传递给函数,会怎么样?看代码:

#include <iostream>
using namespace std;
void get_space(int* p,int num)
{
	p=(int*)malloc(sizeof(int)*num);
}
int main(int argc, char* argv[])
{
	int* p=NULL;
	get_space(p,20);
}

首先我们创建了指针变量,然后把指针变量的值给了形参p,函数体中,形参p接收了malloc返回的内存地址,那么问题来了,这里返回的值并没有传递给main创建的实参。也就是说,传入的和赋值的不是一个变量,传入的只是实参的具体指。而在传二级指针的代码中,我们是把int* p变量的地址用&p取指传给函数,函数体中在调用*p去获取变量的内存位置,再把位置的值改为malloc返回的空间。
ok搞定!
.
.
按指针类型分类

类型 分类 使用场景
数据指针 1.一般变量;2.数组;3.结构体 参数,返回值,转型
函数指针 函数的指针 参数传递,返回值,地址强转型为函数

先说第一种数据

1.在指向变量时可以分别作为参数,返回,做转型取某个数据里的每个位。

指针作为参数和返回值的应用我就不多说了,只要注意是使用变量的指针还是使用指针的指针就好。
这里主要说一下指针转型,这个主要应用在嵌入式中比较多,用来通过串口传数据。

#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
	int x=1025;
	/**将int的四字节空间中每个字节存的值打出来这样子就可以按字节发出数据**/
	char* p=(char*)&x;
	int i=0;
	for(i=0;i<4;i++)
		printf("p[%d]:%02x\n",i,*(p+i));
	/**将转化为char*型的指针复原**/
	int* y=(int*)p;
	printf("%d\n",*y);
}

输出
p[0]:01
p[1]:04
p[2]:00
p[3]:00
1025
请按任意键继续. . .
这里我们知道0x0401=1025;这种变化方式,就是在创建变量时,申请内存空间,然后在四个字节的空间中存放了1024对应的二进制值0b100 0000 0001‬,在转化成字符指针时,在按照字节,一字节一个字节的读出。
从这里我们可以看出,在对内存地址中存的值进行解析时,是根据我们声明的类型

2.在指向数组时可以分别作为参数,返回,顺道说下字符串。

数组和指针本身在使用上有很多相似之处,数组可以用指针的形式访问,因为数组名代表数组的首地址,并且数组的内存空间是连续的,所以可以通过指针访问数组。但是数组有一个特有性质就是数组名不可以做左值,什么是左值呢,就是数组在声明后他的地址是不可以修改的,例如下面的代码,编译会报错(表达式必须是可修改的左值)。

#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
	int num[3];
	int num1[2];
	int x;
	num=num1;
	num=2;
	num=&x;
}

这就是数组特有的性质,本质上就类似int* conste p

然后在说下c中的字符串,在c的语法中是没有字符串类型的,我说的没有字符串类型,不是说没有字符串,c表示字符串使用字符数组表示的。有个细节我们要强调一下:

#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
	char* str="asadad";
	char* str1="adad";
	str=str1;
	printf("str:%s\n",str);
}

上面的代码是正确的,不是说字符串是用字符数组存的吗,为什么可以str=str1;,其实在创建时我们是把"asadad";当成字符数组存到内存,然后把地址给了char* str,我们其实是使用char*指针存地址。

3.在指向结构体时可以用来创建链表(顺道说下一些数据结构),参数,返回值。

以下一段是小编讲故事,不爱看的可以跳过。

说道结构体我对他有特别的感情,为什么呢?有一次参加一个公司的面试,公司是个大型的外包公司,我就是去练级的并不想去,但是却对我幼小的心灵产生深深的伤害。当时约好2点面试,然后,我到了之后被拉到一个小房间,开始等面试官等了有半个小时,我当时在想面试官会不会在偷偷用监控观察我,忽然想起是不是要看看我会不会捡地下纸屑(可能电视剧看太多),好大一会,面试官一脸严肃的进来,按照一般套路,先自我介绍下,我就随便说了几句,然后,面试官开口了,第一个问题就是什么是结构体?我在我的人肉硬盘快速搜索,结构体的含义,妈呀,一片空白!我总不能说不知道吧,我就说我知道怎么用但是概念我记不清楚了,面试官大人可不可以提示下。然后一脸不屑(可能我长得不好看)的看着我说你简历上的项目是不是你做的啊,我说是啊怎么了,你工作三年了怎么还不知道结构体是什么,好吧,提示你一下,结构体就是struct,好的你说下什么是结构体。what fuck!我心里一万只草泥马路过!
哎呀!扯远了,如果想知道我后面可以慢慢说。

言归正传:什么是结构体呢?结构体就是数据的集合,它是c语言的扩展,让程序员可以自己定义数据的类型。结构体可以用来做函数参数和返回值,这里就不在多说了,有开发过c这个都不难。我需要讲一个结构体在编译时自动补齐,结构体会根据声明的数据类型像四补齐。

struct temp
{
	int a;
	char b;
	char c;
	int e;
};
**这个占12个字节**

struct temp
{
	int a;
	char b;
};
**这个占8个字节**

另外:这里说下结构体中可以包含哪些数据:基本数据类型都可以包含,其中,加深难度的就是可以包含指针。指针可以指向基本数据类型地址;可以指向结构体自己地址和同类型机构体地址,以及其他类型的结构体地址;可以指向函数地址。
当结构体中包含指向其他结构体的指针是,这就是链表,链表是程序员虚拟出来的一种数据存储方式,当存在2个及以上的指针时,就可以形成树和图,在链表中最主要的是对数据的遍历以及增删改。
先说下:
单链表 :单链表就是把结构体的节点,用指针穿起来,访问都是从头结点开始。可想象他是糖葫芦串形式。
单链表和数组比优点就是动态增加数据长度插入数据比较块。但是,缺点就太明显了,查找数据时,一点要从数据开头找到数据结尾,万一,一个数据刚好在结尾,然后链表又很长,访问一个数据要查很久,为了解决这个问题,就出现了优化。
双向链表:双向链表就是在当前的节点中有2个指针一个指向当前节点的下一个节点,另一个指向当前节点的上一个节点。
双链表对比单链表优势在于,访问节点更加灵活,可以向下访问,也可以向上访问,这样就不会出现,时光一去不复返的情况,可以回头,在特定情况下可以优化访问。但是这种方式也有一个问题,就是必须要有头尾指针,那么在进行优化。
单循环链表:其实就是把尾节点的指针指向头结点。当然还有,双循环链表:就是头指向尾,尾指向头。
现在我们发现数组和链表各有优点,数组查的快,链表可以不定长,插入,删除快,有没有办法两种结合呢?
哈希表:数组中存有每个类型数据的头指针,用节点的特性计算哈希值查找节点。核心理念就是先对数据分类在用链表存储,取得时候,则是先到对应的类再去链表查,可以减少单个链表长度过长。
在上面的数据访问中,无论怎么访问,他每次访问的逻辑都是一样的,都是从头到尾。无法根据节点的性质查找数据,毕竟链表的排序很麻烦。这样子就引入了二叉树。
有序二叉树:有序二叉树怎么创建代码自己看链接,大致就是用递归。二叉树分为前序遍历,后序遍历,中序遍历。当我们用中序遍历时,就可以获得一个有序的数据。当查找时,可以根据所要查找节点的特性,找到对应的值,例如:
在这里插入图片描述
我们要查104对应的数据,我们只需要从头节点访问70<104,再去右子树查找,105>104再去左子树查找,依次类推就找到104节点了。
当节点间的关系不再是单线关系时,那就出现了图,怎么理解呢,就是A节点有B,C的指针;B有A,C;C有A,B。
图结构:我是很少用,基本也没有用到过,没有过多研究,感兴趣自己看,其中图遍历主要就2个算法BFS和DFS

第二种类型函数:在指向函数时可以用来参数传递,地址强转型为函数。(主要是回调:函数注册封装,库的去耦)

现在开始讲解函数指针,先看例子。

#include <iostream>
using namespace std;
typedef void (*fun)(int x);
void myshow(int num)
{
	cout<<num<<endl;
}
void show(int num,fun f)
{	
	f(num);
}
int main(int argc, char* argv[])
{
	show(10,myshow);
}

输出:
10
在例子中,我们把myshow的地址作为参数直接传递给了show函数中,show函数直接以函数指针调用了myshow函数,并将第一个参数传递给了myshow函数。原理就是:函数调用就是内存地址跳转,我们把函数指针传给函数,当调用函数时,会跳转到函数所在的地址。这种传函数地址的做法,在创建线程中用的很多,线程回调。
这样的例子可以有什么用呢?
1.注册函数:这个在驱动中比较多,先创建结构体(含有函数指针),在把实现函数的传给结构体,后面我们需要调用函数就只需要通过结构体访问,这样子就相当于抽象出了一个结构,注册不同函数同一调用方法就可以调用不同实现。

#include <iostream>
using namespace std;
typedef void (*fun)(void);
typedef struct
{
	fun f;
}FUN;
void myshow()
{
	cout<<"myshow"<<endl;
}
void show()
{	
	cout<<"show"<<endl;
}
void init_fun(FUN* st,fun f)
{
	st->f=f;
}
int main(int argc, char* argv[])
{
	FUN A;
	init_fun(&A,myshow);
	A.f();
	init_fun(&A,show);
	A.f();
}

输出结果:
myshow
show
请按任意键继续. . .
可以看出:第一次绑定myshow函数,A.f();会调用myshow,第二次绑定show(),就执行了show。这中做发其实很有用,可以向上封装接口。

2.回调:开始的案例就是说的回调。主要出现的场合就是当一个函数,需要调用另一个函数时,如果函数创建了就执行,没有创建就不执行,其实这样说也不对,在回调时,调用函数一定要判断函数指针是不是为NULL。回调实际的场合很多,可以让库去耦,界面设计的事件驱动原理,QT的信号槽机制等等。这里库的去耦的逻辑我说下,例如某一个库需要调用主框架的函数,把数据作为参数传递给主框架,那么库就需要调主框架的函数,那么库和主框架就需要放在一起同时编译,而不能先编译库,在编译框架时,包含库。显然后者是我们比较常见,并且合理的方法。并且按照实际的使用逻辑,库应该是一个函数的集合,应该是等着被别人调用,而不应该主动调别人的函数。那么我们可以做没做呢?那就是在框架主程序,调用到库的函数之前,通过库中暴露的注册函数,把库需要调用函数指针传递给库,然后库再去通过指针调用。这样做的好处,可想而知,首先,库中所有的函数都是有实现的,就不存在不能编译的情况;其次,库是被动的,他不会直接去调别人的函数;最后,也不会出现,主程序函数不实现,库函数无法运行。

地址值强转成函数执行

这种方式,一般在一些软件破解中,比较常见,一般不建议直接使用。但是在一个地方,一定是这样子使用的,那就是uboot执行到最后,运行内核的第一个程序时。

void (*theKernel)(int zero, int arch, uint params);
    image_header_t *hdr = &header;
    bd_t *bd = gd->bd;

#ifdef CONFIG_CMDLINE_TAG
    char *commandline = getenv ("bootargs");
#endif

    theKernel = (void (*)(int, int, uint))ntohl(hdr->ih_ep);

内核挂起运行的是theKernel函数;(void (*)(int, int, uint))是类型强转;ntohl(hdr->ih_ep)地址值,这个值就是内存分区的kernel段起始地址。在日常的程序编写中,千万不要用这个,除非是底层代码编写,因为我们应用层看到的地址是系统内核虚拟化出来的地址,我们操作地址时内核是要做地址映射的。uboot可以使用是因为他在使用的时候,内核还没启动,地址都是物理地址。
我们来玩一下把值转成函数。

#include <iostream>
using namespace std;
typedef void (*fun)(void);
void show()
{
	cout<<"myshow"<<endl;
}
int main(int argc, char* argv[])
{
	int p=0;
	printf("show地址:%p\n",show);
	p=(int)show;
	printf("强转为int后的值:%x\n",p);
	((fun)p)();
}

输出:
show地址:013C112C
强转为int后的值:13c112c
myshow
请按任意键继续. . .

最后补充一个好玩的东西:函数返回值作为左值,一般没人用

#include <iostream>
using namespace std;
int buf[10];
int* show()
{
	return buf;
}
void main()
{
	show()[0]=1;
	cout<<buf[0]<<endl;
	system("pause");
}

输出:1

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!