松江企业网站建设,网站建设:上海珍岛,做免费推广的网站有哪些,企业管理软件排行指针详解
参考视频#xff1a;https://www.bilibili.com/video/BV1bo4y1Z7xf/#xff0c;感谢Bilibilifengmuzi2003的搬运翻译及后续勘误#xff0c;也感谢已故原作者Harsha Suryanarayana的讲解#xff0c;RIP。
学习完之后#xff0c;回看找特定的知识点#xff0c;善…指针详解
参考视频https://www.bilibili.com/video/BV1bo4y1Z7xf/感谢Bilibilifengmuzi2003的搬运翻译及后续勘误也感谢已故原作者Harsha Suryanarayana的讲解RIP。
学习完之后回看找特定的知识点善用目录 —
笔者亲测实验编译器版本 gcc版本 gcc (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0 Copyright © 2017 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 指针的基本介绍
数据在内存中的存储与访问
在内存中每一字节8位有一个地址。假设图中最下面的内存地址位0内存地址向上生长图中标识出的下面第一个字节的地址位201地址向上生长一直到图中最上面的地址208。 当我们在程序中声明一个变量时如int a系统会为这个变量分配一些内存空间具体分配多少空间则取决于该变量的数据类型和具体的编译器。常见的有int类型4字节char类型1字节float类型4字节等。其他的内建数据类型或用户定义的结构体和类的大小可通过sizeof来查看。
我们声明两个变量
int a;
char c;假如他们分别被分配到内存的204-207字节和209字节。则在程序中会有一张查找表图中右侧表中记录的各个条目是变量名变量类型和变量的首地址。
当我们为变量赋值时如a 5程序就会先查到 a 的类型及其首地址然后到这个地址把其中存放的值写为 5。
指针概念
我们能不能在程序中直接查看或者访问内存地址呢当然是可以的这就用到我们今天的主角——指针。
指针是一个变量它存放的是另一个变量的地址。 指针与它指向的变量 假设我们现在有一个整型变量a4存放在内存中的204地址处实际上应该是204-207四个字节中这里我们用首地址204表示。在内存中另外的某个地址处我们有另外一个变量 p它的类型是“指向整型的指针”它的值为204即整型变量a的地址这里的 p 就是指向整型变量 a 的指针。指针所占的内存空间 指针作为一种变量也需要占据一定的内存空间。由于指针的值是一个内存地址所以指针所占据的内存空间的大小与其指向的数据类型无关而与当前机器类型所能寻址的位数有关。具体来说在32位的机器上一个指针指向任意类型的大小为4个字节在64位的机器上则为8个字节。指针的修改 我们可以通过修改指针p的值来使它指向其他的内存地址。比如我们将 p 的值修改为 208则可以使它指向存放在208地址处的另一个整型变量 b。指向用户定义的数据类型 除了内建的数据类型之外指针也可以指向用户定义的结构体或者类。
指针的声明和引用 指针的声明 在C语言中我们通过 * 来声明一个指向某种数据类型的指针int *p。这个声明的含义即声明一个指针变量 p它指向一个整型变量。换句话说p 是一个可以存放整型变量的地址的变量。 取地址 如果我们想指定 p 指向某一个具体的整型变量 a我们可以p a。其中用到了取地址运算符 它得到的是一个变量的地址我们把这个地址赋值给 p即使得 p 指向该地址。 这时如果我们打印p, a, p的值会得到什么呢不难理解应该分别是20420464。 解引用 如果我们想得到一个指针变量所指向的地址存放的值该怎么办呢还是用 *放在指针变量 p 前面即 *p 注意这里的 * 就不再是声明指针的意思了而称为 解引用即把 p 所指向的对象的值读出来。 所以如果我们打印 *p则会得到其所指向的整型变量 a 的值5。 实际上我们还可以通过解引用直接改变某个地址的值。比如 *p 8我们就将204地址处的整型变量的值赋为8。此时再打印*p或者a则会得到8。
关于*,两个运算符的使用可参考博客指针(*)、取地址()、解引用(*)与引用()。
指针代码示例
指针的算术运算
实际上指针的唯一算术运算就是以整数值大小增加或减少指针值。如p1、p-2等
示例程序
考虑以下程序
#include stdio.hint main(){int a 10;int* p;p a;printf(%d\n, p);printf(%d\n, p1);return 0;
}初学者可能会好奇指针p 不是一个常规意义上的数字而是一个内存地址它能够直接被加1吗答案是可以的但是结果可能会和整数的加1结果不太一样。
输出
358010748
358010752可以看到p1比p大了4而不是我们加的1。
指针的加1
这是因为指针 p 是一个指向整型变量的指针而一个整型变量在内存中占4个字节 对 p 执行加1应该得到的是下一个整型数据的地址即在地址的数值上面应该加4。
相应地如果是p2的话则打印出的地址的数值应该加8。
危险
可能会造成危险的是C/C并不会为我们访问的地址进行检查也就是说我们可能通过指针访问一块未分配的内存但是没有任何报错。这可能会造成我们不知不觉地弄错了一些数值。
比如接着上面的例子我们试图打印 p 和 p1 所指向的地址所存放的值
#include stdio.hint main(){int a 10;int* p;p a;printf(Addresses:\n);printf(%d\n, p);printf(%d\n, p1);printf(Values:\n);printf(%d\n, *p);printf(%d\n, *(p1));return 0;
}输出
Addresses:
-428690420
-428690416
Values:
10
-428690420可以看到对指针进行加法访问 p1 所指向的地址的值是没有意义的但是C/C并不会禁止我们这么做这可能会带来一些难以察觉的错误。
指针的类型
明确指针的类型
首先要明确的是指针是强类型的即我们需要特定类型的指针来指向特定类型的变量的存放地址。如int*、char*等或者指向自定义结构体和类的指针。
指针不是只存放一个地址吗为什么指针必须要明确其指向的数据类型呢为什么不能有一个通用类型的指针来指向任意数据类型呢那样不是很方便吗
原因是我们不仅仅是用指针来存储内存地址同时也使用它来解引用这些内存地址的内容。而不同的数据类型在所占的内存大小是不一样的更关键的是除了大小之外不同的数据类型在存储信息的方式上也是不同的如整型和浮点型。
示例程序
考虑一下程序
#include stdio.hint main(){int a 1025;int *p;p a;printf(Size of integer is %d bytes\n, sizeof(int));printf(p\t Address %d, Value%d\n, p, *p);printf(p1\t Address %d, Value%d\n, p1, *(p1));char*p0;p0 (char*)p; // 强制类型转换printf(Size of char is %d bytes\n, sizeof(char));printf(p0\t Address %d, Value%d\n, p0, *p0);printf(p01\t Address %d, Value%d\n, p01, *(p01));return 0;// 1025 0000 0000 0100 0001
}输出
Size of integer is 4 bytes
p Address 1241147588, Value1025
p1 Address 1241147592, Value1241147588
Size of char is 1 bytes
p0 Address 1241147588, Value1
p01 Address 1241147589, Value4我们可以通过强制类型转换将指向整型的指针p 转为指向字符型的p0。由于指向了字符型p0在被解引用时只会找该地址一个字节的内容而整型1025的第一个字节的内容为0001第二个字节内容为0100所以会有上面程序的打印行为。
可以参考笔者画的内存示意图来理解这段测试程序其中v表示将该段内存解释为%d的值。 需要指出的是这里的指针的强制类型转换看似只会添乱毫无用处但是它实际上是有一些有用使用场景的会在后面介绍。
void *
我们这里首先对通用指针类型void *的一些基本特性做出说明后面会介绍一些具体的使用场景。 void *时通用指针类型它不针对某个特定的指针类型。在使用时将其赋值为指向某种特定的数据类型的指针时不需要做强制类型转换。 由于不知道它指向的类型因此不能直接对其进行解引用*p也不能对其进行算数运算p1。
指向指针的指针
我们之所以能够把整型变量 x 的地址存入 p 是因为 p 是一个指向整型变量的指针int*。那如果想要把指针的地址也存储到一个变量中这个变量就是一个指向指针的指针即int**。
这个逻辑说起来时挺清楚的在实际程序中则有可能会晕掉。我们来看一个示例程序开始套娃
#include stdio.hint main(){int x;int* p x;*p 6;int** q p;int*** r q;printf(%d\n, *p);printf(%d\n, *q);printf(%d\n, **q);printf(%d\n, **r);printf(%d\n, ***r);return 0;
}在这里我们不按编译器实际输出的地址值来进行分析因为这个地址值是不固定的且通常较大。笔者在这里画了一小段内存我们按图中的地址值来分析打印输出的内容。在图中红色字体是地址值青色块是该变量占据的地址空间其中的黑色字体是该变量的值。假设我们在32位机中即一个指针占4个字节。 在程序中x是整型变量p指向xq指向pr指向q这样x, p, q, r的数据类型分别是int, int*, int**, int***。
*p即对指针p的解引用应该是 x 存储的值即6。*q是对指向指针的指针q的解引用即其指向的地址 p 所存储的值235。同时这个值就是指针 p 的值指向整型变量 x 的地址。**q是对*q的解引用我们已经知道*q为235则**q即地址为235的位置的值是6。**r是对*r的解引用而*r就是q所以**r就是*q235。***r是对**r的解引用同样是235指向的值6。
我们编译运行该程序得到的输出是
6
-1672706964
6
-1672706964
6和我们分析的结果一致。
大家可以自己设计一些这种小示例程序试着分析一下再来查看程序运行的结果是否与预期一致。
函数传值 vs. 传引用
在执行一个C语言程序时此程序将拥有唯一的“内存四区”——栈区、堆区、全局区、代码区.
具体过程为操作系统把硬盘中的数据下载到内存并将内存划分成四个区域由操作系统找到main入口开始执行程序。
内存四区
堆区heap一般由程序员手动分配释放动态内存申请与释放若程序员不释放程序结束时可能由操作系统回收。栈区stack由编译器自动分配释放存放函数的形参、局部变量等。当函数执行完毕时自动释放。全局区global / stack用于存放全局变量和静态变量 里面细分有一个常量区一些常量存放在此。该区域是在程序结束后由操作系统释放。代码区code / text用于存放程序代码字符串常量也存放于此。 函数调用 在程序未执行结束时main()函数里分配的空间均可以被其他自定义函数访问。 自定义函数若在堆区malloc动态分配内存等或全局区常量等分配的内存即便此函数结束这些内存空间也不会被系统回收内存中的内容可以被其他自定义函数和main()函数使用。
函数传值 call by value
假设新手程序员Albert刚刚学习了关于函数的用法写了这样的程序
#include stdio.hvoid Incremnet(int a){a a 1;
}int main(){int a;a 10;Incremnet(a);printf(a %d\n, a);return 0;
}在该程序中Albert期望通过Increment()函数将a的值加1然后打印出a 11但是程序的实际运行结果却是a 10。问题出在哪里呢
实际上这种函数调用的方式称为值传递call by value这样在Increment()函数中临时变量local variable a会在该函数结束后立刻释放掉。也就是说Increment()函数中的a 和main() 函数中的 a 并不是同一个变量。我们可以分别在Increment()和main()两个函数内打印变量a的地址
printf(Address of a in Increment: %d, a);
printf(Address of a in main: %d, a); // 将这两句分别放在Increment函数和main函数中输出
Address of a in Increment: 2063177884
Address of a in main: 2063177908这里两个地址的具体值不重要重要的是他们是不一样的也就是说我们在两个函数中操作的a变量并不是同一个所以程序输出的是没有加1过的a的值。 笔者这里还是根据原视频作者的讲解通过画出内存的形式来分析值传递。
程序会为每个函数创造属于这个函数的栈帧我们首先调用main()函数其中的变量a一直存储在main()函数自己的栈帧中。在我们调用Increment()函数的时候会单独为其创造一个属于它的栈帧然后main()函数将实参a10传给Increment()作为形参a会在其中加1但是并没有被返回。在Increment()函数调用结束后它的栈帧被释放掉main()函数并不知道它做了什么main自己的变量值一直是10然后调用printf()函数将该值打印出来。
可以看到局部变量的值的生命周期随着被调用函数Increment()的结束而结束了而由于main()中的a和Incremet()中的a并不是同一个变量刚才已经看到二者并不在同一地址因此最终打印出的值还是10。
传引用 call by reference
那怎样才能实现Albert的预期呢我们刚才已经看到之所以最终在main()中打印的值没有加1就是因为加1的变量和最终打印的变量不是同一个变量。那我们只要使得最终打印的变量就是在Increment()中加过1的变量就可以了。这要怎么实现呢我们刚刚学过通过指针可以指向某个特定的变量并可以通过解引用的方式对该变量再进行赋值而又由于在程序未执行结束时main()函数里分配的空间均可以被其他自定义函数访问。因此我们可以将main()中的变量地址传给Increment()在其中对该地址的值进行加一这样最终打印的变量就会是加过1的了。 实现如下
#include stdio.hvoid Incremnet(int* p){*p *p 1;
}int main(){int a;a 10;Incremnet(a);printf(a %d\n, a);return 0;
}这种传地址的方式我们称之为call by reference。
它可以在原地直接修改传入的参数值。另外由于传的参数是一个指针无论被指向的对象有多么大这个指针也只占4个字节32位机因此这种方式也可以大大提高传参的效率。
指针与数组
指针和数组常常一起出现二者之间有着很强的联系。
数组的声明
当我们声明一个整型数组int A[5]时就会有五个整型变量A[0] - A[4]被连续地存储在内存空间中。 数组与指针算术运算
还记得我们在前面介绍过指针的算术运算时提到过指针的算术运算可能会导致访问到未知的内存因为我们定义一个指针时它指向的位置的邻居通常是未知的。而在数组中我们没有这个问题因为数组是一整块连续的内存空间我们确定旁边也存放着一些相同类型的变量。
int A[5];
int* p;
p A;printf(%d\n, p); // 200
printf(%d\n, *p); // 2
printf(%d\n, p1); // 204
printf(%d\n, *(p1)); // 4
// ...在数组中指针的算术运算就很有意义了。因为相邻位置的变量都是已知的我们可以通过这种偏移量的方式去访问它们。
数组名和指针
数组与指针的另一个联系是数组名就是指向数组首元素的指针。数组的首元素的地址也被称为数组的基地址。
这里还有一个要注意的小点数组名不能直接自加即不可A但是可以将其赋值给一个指针指针可以自加p
比如上面例程中我们写的p A。这样考虑以下例程的打印输出
printf(%d\n, A); // 200
printf(%d\n, *A); // 2
printf(%d\n, A3); // 212
printf(%d\n, *(A3)); // 8数组/指针 取值/取地址
对于第 iii 个元素
取地址A[i] or (Ai)取值 A[i] or *(Ai)
关于C/C中指针与数组的关系可参考博客C中数组和指针的关系区别详解笔者已将全文重要的一些知识点都总结好放在文章开头。
数组作为函数参数
注意我们可以通过sizeof函数获取到数组的元素个数sizeof(A) / sizeof(A[0])即用整个数组的大小除以首元素的大小由于我们的数组中存储的元素都是相同的数据类型因此可以通过此法获得数组的元素个数。
例程1
我们现在定义一个SumOfElements()函数用来计算传入的数组的元素求和该函数还需要传入参数size作为数组的元素个数。在main()函数中新建一个数组并通过sizeof来求得该数组的元素个数调用该函数求和。
#include stdio.hint SumOfElements(int A[], int size){int i, sum 0;for (i0; isize; i){sum A[i];}return sum;
}int main(){int A[] {1, 2, 3, 4, 5};int size sizeof(A) / sizeof(A[0]);int total SumOfElements(A, size);printf(Sum of elements %d\n, total);return 0;
}打印出的结果如我们所料为15
Sum of elements 15例程2
有人可能回想既然我们已经将数组传入函数了能不能进行进一步的封装将数组元素个数的计算也放到调用函数内来进行呢于是有了如下实现
#include stdio.hint SumOfElements(int A[]){int i, sum 0;int size sizeof(A) / sizeof(A[0]);for (i0; isize; i){sum A[i];}return sum;
}int main(){int A[] {1, 2, 3, 4, 5};int total SumOfElements(A);printf(Sum of elements %d\n, total);return 0;
}结果好像除了亿点点问题笔者注这里笔者的测试结果与原视频作者不同原结果1是由于笔者是在64位机上进行的测试一个指针大小为8字节而原作者使用的是32位机指针占4字节这在接下来的测试程序中也有体现
Sum of elements 3为了测试问题出在哪里让我们在main()和SumOfElements()函数中打印如下信息
printf(Main - Size of A %d, Size of A[0] %d\n, sizeof(A), sizeof(A[0]));
printf(SOE - Size of A %d, Size of A[0] %d\n, sizeof(A), sizeof(A[0])); // 将这两行分别添加到main和SumOfElements输出结果
SOE - Size of A 8, Size of A[0] 4
Main - Size of A 20, Size of A[0] 4
Sum of elements 3果然在SOE中传入的数组A的大小仅有8字节即一个指针的大小。
实际上在编译例程2时编译器会给我们一定的提示Warning
pointer.c: In function ‘SumOfElements’:
pointer.c:5:22: warning: ‘sizeof’ on array function parameter ‘A’ will return size of ‘int *’ [-Wsizeof-array-argument]int size sizeof(A) / sizeof(A[0]);可以看到还是比较准确地指出了可能存在的问题在被调函数中直接对数组名使用sizeof会返回指针的大小。
分析
我们还是要画出栈区来进行分析 我们期望的是向左边那样在main()函数将数组A作为参数传给SOE()之后会在SOE()的栈帧上拷贝一份完全相同的20字节的数组。但是在实际上编译器却并不是这么做的而是只把数组A的首地址赋值给一个指针作为SOE()的形参。也就是说SOE()的函数签名SumOfElements(int A[]) 其实是相当于SumOfElements(int* A)。这也就解释了为什么我们在其内部计算A的大小时得到的会是一个指针的大小。结合我们之前介绍过的值传递和地址传递的知识。可以这样讲数组作为函数参数时是传引用call by reference而非我们预期的值传递。
需要指出的是编译器的这种做法其实是合理的。因为通常来讲数组会是一个很长占内存空间很大的变量如果每次传参都按照值传递完整地拷贝一份的话效率极其低下。而如果采用传引用的方式需要传递的只有一个指针的大小。
指针与数组辨析
这里视频原作者做了许多解释笔者认为有一句话可以概括二者关系的本质数组名称和指针变量的唯一区别是不能改变数组名称指向的地址即数组名称可视为一个指向数组首元素地址的指针常量。也就是说数组名指针是定死在数组首元素地址的其指向不能被改变。比如数组名不允许自加A因为这会它是一个不可改变的指针常量而一般指针允许自加p还有常量不能被赋值即若有数组名 A指针 p则A p是非法的。详见博客C中数组和指针的关系区别详解。
指针与字符数组
当我们在C语言中谈论字符数组时通常就是在谈论字符串。
C语言中字符串的存储
在C语言中我们通常以字符数组的形式来存储字符串。对于一个有 nnn 个字符组成的字符串我们需要一个长度至少为 n1n1n1 的字符数组。例如要存储字符串JOHN我们需要一个长度至少为 5 的字符数组。
之所以字符数组的长度要比字符串中字符的个数至少多一个是因为我们需要符号来标志字符串的结束。在C语言中我们通过在字符数组的最后添加一个 \0 来标志字符串的结束。如下图。 在这个图中我们为了存储字符串JOHN我们使用了字符数组中的5个元素其中最后一个字符 \0用来标识字符串的结束。倘若没有这个标识的话程序就不知道这个字符串到哪里结束就可能会访问到56中一些未知的内容。
示例程序
#include stdio.hint main(){char C[4];C[0] J;C[1] O;C[2] H;C[3] N;printf(%s, C);return 0;
}这里原作者给出了这样一个示例程序并且测试得到的输出结果是JOHN几个乱码这是合理的因为如前所述没有设置 \0 来标识字符串的结束。
但是笔者在自己的机器上亲测编译器为gcc 7.5.0的时候打印输出是正常的JOHN字符串这是由于有些编译器会自动的为你补全\0。笔者也尝试了通过调整gcc的-O参数尝试了各种优化等级都可以正常打印字符串。
而通过 char C[20] JOHN 这种方式定义的字符串编译器一定会在其末尾添加一个 \0。 原作者强调这里编译器会强制要求声明的数组长度大于等于5也就是说char C[4] JOHN是无法通过编译的。但在笔者测试时这也是可行的但是 3 就肯定不行了哈。
通过引入头文件string.h可以使用strlen()函数获取到字符串的长度无论我们声明的字符数组有多长比如上面这个20该函数会找到第一个 \0并返回之前的元素个数也就是我们实际的字符串长度。有以下例程
#include stdio.h
#include string.hint main(){char C[20] JOHN;int len strlen(C);printf(%d, len);return 0;
}输出会是 4我们实际的字符串JOHN的长度。
字符串常量与常量指针
char[] 和 char*
char C[20] JOHN; // 字符串就会储存在分配给这个数组的内存空间中这种情形下它会被分配在栈上当向上面一样使用字符数组进行初始化时字符串就会储存在分配给这个数组的内存空间中这种情形下它会被分配在栈上。
而当使用 char* 的形式声明一个字符串时如下它会是一个字符串常量通常会被存放在代码区。
char* C JOHN; // 如此声明则为字符串常量存放在代码区其值不能被修改既然叫做常量那它的值肯定是不能更改的了即*C[0]A 是非法的操作。
常量指针
还记得我们之前提到过即数组名称可视为一个指向数组首元素地址的指针常量。指针常量的含义是指针的指向不能被修改如数组名看作指针时不能自加因为这会修改它的指向。
而本小节提到的常量指针则是指指针指向的值不能被修改。常量指针通常用在引用传参时如果某个函数要进行一些只读的操作如打印为了避免在函数体内部对数据进行了写操作而又因为是传引用则会破坏原数据。如以下打印字符串的函数由于打印字符串不需要改动原来的数据故可以在函数签名中加上const关键字来使得 C 是一个常量指针保证其指向的值不会被误操作修改。注意此处的 C 是常量指针而非指针常量即其指向可以改变因此函数体中的C是合法的操作。
void printString(const char* C){while (*C ! \0){printf(%c, *C);C;}printf(\n);
}指针与多维数组
指针与二维数组
二维数组概念
我们可以声明一个二维数组int B[2][3]实际上这相当于声明了一个数组的数组。如此例中B[0], B[1] 都是包含3个整型数据的一维数组。 如前所述数组名相当于是指向数组首元素地址的指针常量。在这里首元素不在是一个整型变量而是一个包含3个整型变量的一维数组。这时int* p B就是非法的了因为数组名B是一个指向一维数组的指针而非一个指向整型变量的指针。正确的写法应该是int (*p)[3] B。
而B[0]就相当于是一个一维数组名就像前几章的A也相当于一个指向整型的指针常量。
例程
我们通过一个例程来帮助自己分析理解二维数组和指针与上面的元素设定一致也假设地址就按上方蓝色字体每一组有虚线分隔开来每一组之内的含义是一样的。大家可以先不看注释中的解释与答案自己试着分析一下每一组是什么含义。后面会给出笔者的分析。
#include stdio.hint main(){int B[2][3] {2, 3, 6, 4, 5, 8};printf(-----------------------\n); // 指向一维数组的指针 400printf(%d\n, B);printf(%d\n, B[0]);printf(-----------------------\n); // 指向整型的指针 400printf(%d\n, *B);printf(%d\n, B[0]);printf(%d\n, B[0][0]);printf(-----------------------\n); // 指向一维数组的指针 412printf(%d\n, B1); printf(%d\n, B[1]);printf(-----------------------\n); // 指向整型的指针 412printf(%d\n, *(B1));printf(%d\n, B[1]);printf(%d\n, B[1][0]);printf(-----------------------\n); // 指向整型的指针 420 printf(%d\n, *(B1)2);printf(%d\n, B[1]2); printf(%d\n, B[1][2]); printf(-----------------------\n); // 整型 3printf(%d\n, *(*B1)); printf(-----------------------\n); return 0;
}第一组 BB[0]数组名B是一个指针其指向的元素是一个一维数组即二维数组第一个元素第一个一维数组的首地址。而B[0]就是二维数组的第一个元素即二维数组的第一个一维数组对其进行取地址运算故B[0]就是第一个一维数组的地址也即第一个指向第一个一位数组的指针。 所以说第一组是指向一位数组的指针其值为 400。 第二组*BB[0]B[0][0]对数组名B进行解引用得到的是其第一个元素第一个一维数组的值也就是一个一维数组名B[0]相当于前面几章的一维数组名A这个一维数组名就相当于是一个指向整型数据的指针常量。而B[0][0]是一个整型数据2对其进行取地址运算得到的是一个指向整型变量的指针。 所以说第二组是指向整型变量的指针其值也为400但与第一组指向的元素不同注意体会。 第三、四组与第一、二组类似关键区别在于加入了指针运算。这里需要注意的是对什么类型的指针进行运算是对指向一维数组的指针12还是对指向整型的指针4。在这两组中都是对指向一维数组的指针如二维数组名B进行运算所以地址要偏移12个字节。 第五组中开始有了对不同的指针类型进行指针运算的情况。在这一组中的1都是对指向一维数组的指针进行运算要(1*12)而2都是对指向整型变量的指针进行运算要(2*4)故最终结果是420。 最后一组只有一个值。但需要一步一步仔细分析。首先*B是对二位数组名进行解引用得到的是一个一位数组名也就是一个指向整型的指针常量。对其加1需要偏移4个字节即(*B1)是一个指向地址404处的整型变量的指针对其进行解引用直接拿出404地址处的值得到3。 大家可以考虑一下如果加一个括号*(*(B1))的值会是多少呢
小公式
对于二位数组和指针、指针运算的关系原作者给出了这样一个公式笔者同样写在下面供大家参考。希望大家不要死记硬背而是试着去理解它。
B[i][j] *(B[i]j) *(*(Bi)j)
指针与高维数组
前面我们已经看到多维数组的本质其实就是数组的数组。如果你已经对上一小节例程中的几组值得含义都已经完全搞清楚了那么理解高维数组也不难了。
以下我们以三维数组为例进行介绍开始套娃。
三维数组概念
我们可以这样声明一个三维数组int C[3][2][2]。三维数组中的每个元素都是二维数组 具体来说它是由三个二维数组组成的每个二维数组是由两个一维数组组成的每个一维数组含有两个整型变量。图示如下 类似地如果我们想将三维数组名C赋值给一个指针的话应该这样声明int (*p)[2][2] C。
小公式
同样给出三维数组的小公式如下
C[i][j][k] *(C[i][j]k) *(*(C[i]j)k) *(*(*(Ci)j)k)
这里笔者只简单分析一下。首先要明确在本例中一个整型变量占4个字节一个一维数组占2*48个字节一个二维数组占2*2*416个字节而整个三维数组占3*2*2*448个字节。
从右向左、从里向外看
C是三维数组名其值是三维数组中的第一个元素即第一个二维数组的起始地址800相当于指向二维数组的指针常量Ci是对指向二维数组的指针进行运算因此应该偏移i*16个字节而对其进行解引用*(Ci)得到的就是起始地址为800i*16处的那个二维数组其名为C[i]相当于B 而二维数组名是一个指向一位数组的指针常量然后C[i]j是对指向一维数组的指针进行运算偏移j*8个字节而对其进行解引用*(C[i]j)得到的是起始地址为800i*16j*8处的一维数组其名为C[i][j]相当于A 而一维数组名是一个指向整型变量的指针常量C[i][j]k是对指向整型变量的指针进行运算应该偏移k*4个字节而对其进行解引用*(C[i][j]k)得到的是起始地址为800i*16j*8k*4处的那个整型变量的值即C[i][j][k]。
大家可以试着分析一下*(C[1]1)和*(C[0][1]1)分别是多少这时作者给出的两个小测试题答案是824和9。
多位数组作为函数参数
一维数组作为参数需要注意是传引用另外在函数体内不修改数据时注意在函数签名中将数组名指针声明为常量指针。
二维数组做参数
void func(int (*A)[3]void func(int A[][3])
注意事项 注意多维数组做函数参数时数组的第一个维度可以省略但是其他维度必须指定。所以说对一个需要接收二维数组的参数将函数签名声明为void func(int **A) 是不可行的因为这样没有指定任何数组维度。 注意在调用时要正确地传递参数数组的类型。比如下面这样就是不可行的 void func1(int Arr[][3]){}
void func2(int Arr[][2][2]){}int main(){int A[2][2];int B[2][3];int C[3][2][2];int D[3][2][3];func1(A); // 错误func1(B); // 正确func2(C); // 正确func2(D); // 错误
}指针与动态内存
内存四区简介
内存被分为四个区分别是代码区静态/全局区栈区和堆区。
代码区存放指令。静态区 / 全局区存放静态或全局变量也就是不再函数中声明的变量它们的生命周期贯穿整个应用程序。栈区用来存放函数调用的所有信息和所有局部变量。堆区大小不固定可以由程序员自由地分配和释放动态内存申请与释放。 在整个程序运行期间代码区静态/全局区栈区的大小是不会增长的。
有一个小点要说明一下有堆、栈这两种数据结构也有堆、栈这两个内存分区内存中的栈基本是由数据结构中的栈实现的而内存中的堆和数据结构中的堆毫无关系。堆可以简单理解为一块大的、可供自由分配释放的内存空间。
之前我们已经介绍过在程序运行过程中代码区、静态/全局区和栈区是怎样运作的了特别是函数调用时栈区的工作方式我们特别进行了说明。
C/C中的动态内存分配
在C中我们需要使用四个函数进行动态内存分配malloc()calloc()realloc()free()。在C中我们需要使用两个操作符newdelete。另外由于C是C的超集兼容C故也可以用以上4个函数来进行动态内存分配。
malloc 和 free
#include stdio.h
#include stdlib.hint main(){int a;int* p;p (int*)malloc(sizeof(int));*p 10;free(p);return 0;
}malloc函数从堆上找到一块给定大小的空闲的内存并将指向起始地址的void *指针返回给程序程序员应当根据需要做适当的指针数据类型的转换。
向堆上写值的唯一方法就是使用解引用因为malloc返回的总是一个指针。如果malloc无法在堆区上找到足够大小的空闲内存则会返回NULL。
程序员用malloc在堆上申请的内存空间不会被程序自动释放因此程序员在堆上申请内存后一定要记得自己手动free释放。
free接收一个指向堆区某地址的指针作为参数并将对应的堆区的内存空间释放。 new 和 delete
在C中程序员们通常使用newdelete操作符来进行动态内存的分配和释放。以下是整型变量和整型数组的分配和释放例程。
p new int;
*p 10;
delete p;p new int[20]
delete[] p;注意数组delete时要有[]。
在C中不需要做指针数据类型的转换new和delete是类型安全的。它们是带类型的返回特定类型的指针。
malloc、calloc、realloc、free
malloc 函数签名void* malloc(size_t size)。函数接收一个参数size返回的void*指针指向了分配给我们的内存块中的第一个字节的地址。 void*类型的指针只能指明地址值但是无法用于解引用所以通常我们需要对返回的指针做强制类型转换转换成我们需要的指针类型。 通常我们不显式地给出参数size的值而是通过sizeof再乘上我们需要的元素个数计算出我们需要的内存空间的大小。 典型用法 int* p (int*)malloc(3 * sizeof(int));
*p 10;
*(p1) 3;
p[2] 2; // 之前学过的数组的形式calloc 函数签名void* calloc(size_t num, size_t size)。函数接收两个参数numsize分别表示特定类型的元素的数量和类型的大小。同样返回一个void*类型的指针。 典型用法 int *p (int*)calloc(3, sizeof(int));calloc与malloc的另一个区别是malloc分配完内存后不会对其进行初始化calloc分配完内存后会将值初始化位0。
realloc 函数签名void* realloc(void* ptr, size_t size)。函数接收两个参数第一个是指向已经分配的内存的起始地址的指针第二个是要新分配的内存大小。返回void*指针。可能扩展原来的内存块也可能另找一块大内存拷贝过去如果是缩小的话就会是原地缩小。 如果缩小或者拷贝到新的内存地址总之只要是由原来分配的内存地址不会再被用到realloc函数自己会将这些不被用到的地址释放掉。 以下这种情况使用realloc相当于free int* B (int*)realloc(A, 0);以下这种情况使用realloc相当于malloc int* B (int*)realloc(NULL, sizeof(int));free
在堆区动态分配的内存会一直占据着内存空间如果程序员不将其显式地释放程序是不会自动将其释放的直到整个程序结束。 已经没有用的堆区内存如果不进行手动释放会造成内存泄漏因此使用上面三个函数在动态分配的堆区内存的使命结束后程序员有责任记得将它们释放。
在C中我们使用free函数来进行堆区内存的释放。只需将要释放的内存的其实地址传入即可free(p)。
使用场景
当我们想要根据用户的输入来分配一个合适大小的数组如果写成如下这样
#include stdio.h
#include stdlib.hint main(){int n;printf(Please Enter the Size of Array You Want:\n);scanf(%d, n);int* A[n];return 0;
}作者将这样在运行时才知道数组的大小是不行的。但是笔者实验过发现是可以的这应该是C99支持的特性变长数组。
但是这并不妨碍我们试着练习用动态分配内存的方式来新建一个数组我们可以这样做
int* A (int*)malloc(n * sizeof(int));或者用calloc会自动将初始值赋为0
int* A (int*)calloc(n, sizeof(int));别忘了手动释放堆区内存。
free(A);注意
在C程序中只要我们知道某个内存的地址我们就能访问它C语言并不禁止我们的这种行为。但我们应当注意不要去试图读写未知的内存因为这将使我们的程序的行为不可预测可能某个存在非法读写的程序在某个机器上运行正常但是到了另一个环境、另一个机器上就会崩溃。最好的方法是只去读写为我们分配的内存而不要试图访问未知的内存。
内存泄漏
动态分配内存使用结束后不进行释放的行为可能会造成内存泄漏。乍看之下好像不进行内存释放”只是“多占了一些内存空间而已为什么会被称为内存泄漏呢而又为什么只有堆区的动态内存未被释放会造成内存泄漏呢本小节将介绍相关内容。
#include stdio.h
#include stdlib.hvoid allocate_stack(){int Arr[10000];
}void allocate_heap(){int* Arr (int*)malloc(10000 * sizeof(int));
}int main(){int c;while (1) {printf(0 to break, 1 to continue\n);scanf(%d, c);if (!c) break;else {int i 0;for (i0; i100; i){allocate_heap();allocate_stack();}}}return 0;
} 我们有在主函数上调用allocate_stack或者allocate_heap两者的区别是一个在栈上开辟一个数组并直接返回另一个在堆区开辟一个数组并且不释放返回。在主函数中死循环询问是否继续开辟数组得到继续开辟数组的命令后开辟100个数组。我们可以通过top命令清晰地看到allocate_stack的内存占用在每次开辟数组后骤增然后掉下去而allocate_heap的内存占用每次骤增后也不掉下去直到内存占用过大被操作系统kill掉。
allocate_stack 对于调用allocate_stack的程序在allocate_stack函数调用时每次将数组创建在栈区然后再函数返回时程序自动将其栈帧释放数组也被释放掉不会占用内存。
allocate_heap 对于调用allocate_heap的程序每次调用allocate_heap在堆区开辟一个数组Arr在栈上只创建了一个指针p来指向这个堆区数组但是堆区数组没有释放这样在allocate_heap函数返回之后函数栈帧上的p也被程序释放掉就再也没有办法去释放堆区的数组Arr。这样随着函数调用次数越来越多这些堆区的数组都处于已分配但无法引用也无法使用的状态。而堆区大小又是不固定的可以一直向操作系统申请终有一天会超过内存上限被系统这就是内存泄漏。
函数返回指针
指针本质上也是一种数据类型就像int、char其中存储了另外一个数据的地址因此将一个指针作为返回类型是完全可行的。但是需要考虑的是在什么情况下我们会需要函数返回一个指针类型呢
示例程序
考虑这样一个程序
#include stdio.h
#include stdlib.hint Add(int a, int b){int c a b;return c;
}int main(){int x 2, y 4;int z Add(x, y);printf(Sum %d\n, z);
}我们定义了一个加和函数Add它从main函数中接收两个参数并将二者求和的值返回给main。需要注意的是就像我们之前提到的那样这里的x,y,z都是栈区main函数栈帧里的局部变量而a, b 则都是栈区上Add函数栈帧中的局部变量。
并且这种函数传参的方式我们之前已经讲过成为值传递。
要将函数的传参方式改为地址传递只需改为以下程序
#include stdio.h
#include stdlib.hint Add(int* a, int* b){int c (*a) (*b);return c;
}int main(){int x 2, y 4;int z Add(x, y);printf(Sum %d\n, z);
}函数返回指针
上面关于传值和传引用的做法我们已经在前面介绍过了我们这一小节的重点是看看怎样让函数返回一个指针我们的第一个版本可能会是这样的
#include stdio.h
#include stdlib.hint* Add(int* a, int* b){int c (*a) (*b);return c;
}void printHello(){printf(Hello\n);
}int main(){int x 2, y 4;int* ptr Add(x, y);// printHello();printf(Sum %d\n, *ptr);
}这个版本在作者测试时是正常的但是如果在打印结果之前再多调用一个函数printHello则会导致输出错误。这究竟是怎么回事呢我们还是要借助栈区内存分析。 我们再次划出这个程序运行时的内存这里没有用到堆区就暂时先不画出来了。
我们看到在调用Add是有Add自己的栈帧其中存放两个传入的指向整型的指针ab指向main函数栈帧中的我们想要加和的两个整型变量xyAdd的栈帧中还有一个整型变量c是我们的计算结果按照上面的程序写法Add函数返回一个整型指针指向变量c即main中的ptr。
问题来了在Add函数返回之后它在栈区上的栈帧也被程序自动释放了这个时候原来存放整型变量c的150这个内存地址中的值就已经是未知的了我们之前说过访问未知的内存是极其危险的。
如果在Add函数返回之后没有调用其他任何函数直接对150解引用有可能能够打印出正确的结果如果编译器没有对释放的栈帧进行其他处理。但是如果调用了其他函数如printHello即使该函数中没有任何我们看得到的参数但也需要保存一些返回地址、寄存器现场等参数因此也会在栈区占用一块作为自己的栈帧。这时内存位置150几乎是肯定被重写了这时无法得到预期的结果。无论如何访问未知的内存地址是一定要杜绝的不能寄希望于偶然的正确结果。
另外需要指出的是main函数也通过传引用的形式将地址传递给了Add函数但这是没问题的因为Add函数调用时main函数的栈帧还是在原来的内存位置这是已知的我们可以进行访问。即栈底向上传参数是可以的从栈底向上传一个局部变量或者一个局部变量的地址是可以的。但是栈顶向下传参数是不可以的从栈顶向下传一个局部变量或者一个局部变量的地址是不可以的。可想而知C/C中的main函数是可以自由地向被调函数传引用的。
笔者自己在亲测这个程序编译时会报警告
pointer.c: In function ‘Add’:
pointer.c:6:12: warning: function returns address of local variable [-Wreturn-local-addr]return c;^~而运行时则会直接Core Dumped。应该是新版本的编译器直接禁止了这种返回已释放的栈区指针的行为。
使用场景
可以见到返回被调函数在栈区的局部变量的指针是危险的。通常我们可以安全地返回堆区或者全局区的内存指针因为它们不会被程序自动地释放。
我们尝试在堆区分配内存将上面的程序中的Add函数改为
int* Add(int* a, int* b){int* c (int*)malloc(sizeof(int));*c (*a) (*b);return c;
}这样程序就可以正常地工作了。 这样Add返回的指针所指向的地址存放在堆区不像栈区一样在函数返回之后被程序自动释放可以在main函数中正常地进行访问。堆区内存使用结束之后不要忘记释放。
函数指针
代码段
函数指针就像名字所描述的那样是用来存储函数的地址的。之前我们介绍的指针都是指向数据的本章我们将讨论指向函数的指针。我们可以使用函数指针来解引用和执行函数。 我们已经不止一次提过内存四区或者内存四区的某一部分了。但是有一部分我们在之前的讲述中一直没有提到过那就是代码区Code。我们知道虽然我们编写程序源代码时使用的大多是C、C等高级语言但是机器要真正的运行程序必须是运行二进制的机器代码。从C到机器代码的这个过程包括预处理、编译、汇编、链接是由编译器替我们完成的得到的二进制代码将被存放在可执行文件中。
应用程序的代码段就是用来存放从可执行文件拷贝过来到内存代码段的机器码或指令的。下面我们来仔细讨论一下代码区。 如上图所示假设一条指令占4个字节在内存中一个函数就是一块连续的内存其中存放的不是数据而是指令。指令通常都是顺序执行的直到发生跳转如函数调用函数返回会根据指令调到指定的地址执行。假设图中蓝色区域指令02 - 指令 05地址208 - 220是一个函数指令00是一条跳转指令调用了蓝色区域的函数程序就会从200跳转到208执行。函数的起始地址比如208被称为函数的入口点它是函数的第一条指令的地址。
函数指针的定义和使用
下面这个程序定义和使用了一个函数指针
#include stdio.hint Add(int a, int b){return a b;
}int main(){int c;int (*p)(int, int);p Add;c (*p)(2, 3);printf(%d\n, c);
}声明函数指针的语法是int (*p)(int, int)这条语句声明了一个接收两个整型变量作为参数并且返回一个整型变量的函数指针。注意函数指针可以指向一类函数即可以说指针p指向的类型是输入两整型输出一整型的这一类函数即所有满足这个签名的函数都可以赋值给p这个函数指针。 另外要注意指针要加括号。否则int *p(int, int)是声明一个函数名为p接收两个整型并返回一个整型指针的函数。 函数指针赋值p Add将函数名为Add的函数指针赋值给p。同样注意只要满足p声明时的函数签名的函数名都可以赋值给p。 函数指针的使用int c (*p)(2, 3)先对p解引用得到函数Add然后正常传参和返回即可。 还有一点在为函数指针赋值时可以不用取地址符号仅用函数名同样会返回正确的函数地址。与之匹配的在调用函数的时候也不需要再解引用。这种用法更加常见。 int (*p)(int, int);
p Add;
c p(2, 3);再强调一下注意函数指针可以指向一类函数即可以说指针p指向的类型是输入两整型输出一整型的这一类函数即所有满足这个签名的函数都可以赋值给p这个函数指针。用不同的函数签名来声明的函数指针不能指向这个函数。 如以下这些函数指针的声明都是不能指向Add函数的 void (*p)(int, int);
int (*p)(int);
int (*p)(int, char);函数指针的使用案例回调函数
回调函数的概念
这里使用函数指针的案例都围绕着这么一个概念函数指针可以用作函数参数而接收函数指针作为参数的这个函数可以回调函数指针所指向的那个函数。
#include stdio.hvoid A(){printf(Hello !\n);
}void B(void (*ptr)()){ptr();
}int main(){void (*p)() A;B(p);B(A); return 0;
}或者我们可以直接在主函数中B(A)而不需要上面那写两句先复制给p再调用p。
在上面的例程中将函数A()的函数指针传给B()B()在函数体内直接通过传入的函数指针调用函数A()这个过程成为回调。这里函数指针被传入另一个函数再被用函数指针进行回调的函数A()成为回调函数。
回调函数的实际使用场景
#include stdio.h
#include math.hvoid BubbleSort(int A[], int size){int i, j, temp;for (i0; isize; i){for (j0; jsize-1; j){if (A[j] A[j1]){temp A[j];A[j] A[j1];A[j1] temp;}}}
}int main(){int A[] {2, -4, -1, 3, 9, -5, 7};int size sizeof(A) / sizeof(A[0]);// BubbleSort(A, size, greater);BubbleSort(A, size, abs_greater);int i 0;for (i0; isize; i){printf(%d , A[i]);}printf(\n);
}输出排序结果
-5 -4 -1 2 3 7 9对于这个排序函数我们可能有时需要升序排序有时需要降序排序即我们可能会根据具体使用场景有不同的排序规则。而由于实现不同的排序函数时整个算法的逻辑是不变的只有排序的规则会不同总不至于为了不同的排序规则都单独写一个函数这时我们就可以借助函数指针作为参数来实现不同的排序规则的切换。
即实现如下
#include stdio.h
#include math.hvoid BubbleSort(int A[], int size, int (*compare)(int, int)){int i, j, temp;for (i0; isize; i){for (j0; jsize-1; j){if (compare(A[j], A[j1]) 0){temp A[j];A[j] A[j1];A[j1] temp;}}}
}int greater(int a, int b){if (a b) return 1;else return -1;
}int abs_greater(int a, int b){if (abs(a) abs(b)) return 1;else return -1;
}int main(){int A[] {2, -4, -1, 3, 9, -5, 7};int size sizeof(A) / sizeof(A[0]);BubbleSort(A, size);int i 0;for (i0; isize; i){printf(%d , A[i]);}printf(\n);
}我们在排序函数中接收一个函数指针Compare作为参数用整个参数来指示排序的规则。这样我们就利用回调函数实现了这一想法。我们可以写不同的排序规则作为回调函数比如笔者这里又写了一个按照绝对值比较大小的回调函数abs_greater
输出
-1 2 3 -4 -5 7 9另外回调函数还有更多有趣的应用比如事件回调函数等。
Ref
https://blog.csdn.net/helloyurenjie/article/details/79795059