《C程序设计新思维》一6.3 不使用malloc的指针

    xiaoxiao2024-04-20  7

    本节书摘来自异步社区《C程序设计新思维》一书中的第6章,第6.3节,作者【美】Ben Klemens,更多章节内容可以访问云栖社区“异步社区”公众号查看

    ### 6.3 不使用malloc的指针当我们告诉计算机把A设置为B时,意思可能是下面两者之一:

    把B的值复制给A。此时用A++来增加A的值,B的值不会改变。使A成为B的别名。于是A++也会增加B的值。在代码中,每次表示把A设置为B时,需要明确是为了创建一份拷贝还是创建一个别名。这绝不是C特有的问题。

    对于C而言,我们总是创建一份拷贝。但是,如果我们复制了数据的地址,这个指针的一份拷贝就成为了这个数据的别名。这是一种精巧的实现别名的方式。

    其他语言具有不同的习惯:LISP系的语言非常依赖于别名,并有专门的set命令用于复制。Python对于标量一般进行复制,对于列表则执行别名操作(除非使用了copy或deepcopy)。另外,事先知道期望的结果可以避免产生大量的错误。

    GNU科学库包含了vector和matrix对象,它们都具有data成员,后者本身是一个double值的数组。我们假设有一些用typedef定义的vector/matrix对,并有一个包含了这些数据对的数组:

    这样,第1个矩阵的第1个成员的表示即如下:

    如果你熟悉这样的语法,就很容易接受它。但是,输入起来还是比较麻烦的。我们可以为它设置一个别名:

    在上面所显示的两种类型的赋值中,这里的等号所表示的是别名类型的赋值:只有一个指针被复制,如果我们修改了*elmt1,your_data内部被指向的数据也会被修改。

    别名操作是一种类似于malloc-free的体验,它可以使我们获得指针操作的灵活性同时又不必关心内存管理。

    下面是没必要使用malloc函数的另一个例子。假设有一个接受一个指针变量作为输入的函数:

    如果函数的用户过于紧密地把指针与malloc关联在一起,可能觉得自己必须分配内存才能把指针传递给这个函数:

    事实上,最方便的用法是通过自动内存分配来完成任务:

    自己动手:正如前面我提供的建议,每次编写一行表示把A设置到B这样的代码时,需要明确自己想要的是创建一个别名还是一份拷贝。研究下你手头上的一些代码(不管是用什么语言编写的),然后逐行检查,问问自己在哪些情况下把复制替换为别名是合理的。

    6.3.1 结构被复制,数组创建别名

    如例6-2所示,复制一个结构的内容只需要一行操作代码就可以完成。

    例6-2 不需要逐个复制结构的每个成员(copystructs.c)

    修改d1,观察d2有没有发生变化。

    这些断言都可以通过。

    和以前一样,我们需要知道自己的赋值操作是创建了数据的一份拷贝还是在创建一个新的别名。这里是什么情况呢?我们修改了d1.b和d1.c,但d2中的b、c值并没有发生变化,因此它是创建了一份拷贝。但是,一个指针的拷贝仍然指向原先的数据,因此当我们修改d1.efg[0]时,这个修改还会影响指针d2.efg的拷贝。我的建议是如果需要对指针内容也进行复制的深拷贝,就需要一个结构复制函数。如果我们并不需要追踪任何指针,那么使用复制函数就有点小题大作了,只需要使用等号就可以。

    既然d2 = d1就可以实现复制,如果像assert(d1==d2)这样的单行比较函数也能够完成任务就非常理想了,但这并不是标准的做法。然而,我们可以把两个结构看成是比特流来进行这种比较,通过memcmp来完成(前面要加上#include ):

    如果d1的位列表与d2的位列表匹配,这个函数就返回0,这与strcmp(str1, str2)在两个字符串匹配时返回0的方式非常相似。对于数组,等号将会复制一个别名,而不是数据本身。在例6-3中,我们可以进行相同的创建拷贝测试,即修改原先的数据,并检查拷贝值。

    例6-3 结构被复制,但是把一个数组设置为另一个数组只是创建了一个别名(copystructs2.c)

    通过:当拷贝被修改时,原数据也被修改。

    例6-4逐步显示了灾难发生的过程。两个函数自动分配两块内存:第一个函数分配了一个结构,第二个函数分配了一个较短的数组。作为自动分配的内存,我们知道在每个函数结束时,它们各自的内存块将被释放。

    指定return x为返回语句的函数会把x的值返回给调用函数(C99 & C11§6.8.6.4(3))。这看上去相当简单,但是这个值必须被复制到调用函数中,而后者的函数帧即将要被销毁。如前所述,对于结构、数值甚至是指针类型,调用函数将得到返回值的一份拷贝。对于数组,调用函数将得到指向这个数组的指针,而不是数组中数据的拷贝。

    最后一种情况是个很讨厌的陷阱,因为被返回的指针可能指向一块自动分配的数组数据,而后者在函数退出时将被销毁。返回一个指向一块可能已经被自动销毁的内存的指针是再糟糕不过的事情了。

    例6-4 可以从一个函数中结构,但不能直接返回数组(automem.c)

    这个初始化是通过指定的初始化值列表进行的。如果读者没有遇到过这种方法,在这几章中要抓紧熟悉。

    这里是合法的。在函数退出时,会创建自动分配的out的一份拷贝,然后这个局部拷贝被销毁。

    这条语句则是非法的。在这里,数组事实上是被当作指针看待的,因此在退出时,会创建指向out的指针的一份拷贝。但是一旦这块自动分配的内存被销毁,这个指针就指向一块坏数据。如果编译器够聪明的话,它会对这种情况提出警告。

    回到调用get_even的那个函数,evens是个合法的指向int类型的指针,但它所指向的数据已经被释放。这可能会产生段错误、打印出垃圾值,在很幸运的情况下或许会打印出正确的值(只这一次)。

    如果需要对数组进行拷贝,我们仍然可以通过一行代码来完成,但这样就回到了内存操纵的语法,如例6-5所示。

    例6-5 复制一个数组需要使用memcpy,它已经过时,但是能够完成任务(memcpy.c)

    6.3.2 malloc和内存操纵

    现在回到内存部分,也就是在内存中直接处理地址。这些操作常常需要通过malloc手工分配内存。

    避免与malloc有关的缺陷的最简单方法,就是避免使用malloc。过去(上世纪80年代和90年代),我们需要使用malloc处理各种类型的字符串操作,但在第9章将详细介绍怎样在完全避免使用malloc的情况下处理字符串。我们也需要malloc处理那些必须在运行时设置长度的数组,这是一种相当常见的情况,正如第7章“让声明流动起来”一节所述,这也是过时的方法。

    下面是我粗略整理的使用malloc的原因列表。

    1.为了改变一个现存的数组的长度,需要使用realloc,而重新分配仅对于那些一开始就是通过malloc分配的内存块才起作用。

    2.如前面所解释的那样,我们无法从函数返回一个数组。

    3.有些对象在它们的初始化函数之后很久仍然应该存在。不过,在第11章“11.2.3基于指向对象的指针编码”一节中,我们将把这些内存管理操作包装到new/copy/free函数中,使它们不至于产生不良影响。

    4.自动内存是在函数帧的堆栈中分配的,它的长度限制可能只有几MB(甚至更少)。因此,大块的数据(即任何以MB为单位的数据)应该是堆中分配,而不应该在堆栈中分配。另外,我们很可能在某个函数中存储某种类型的对象数据,因此在实践中应该调用一个object_new函数而不是使用malloc本身对它进行操作。

    5.有时候,我们发现函数会被要求返回一个指针。例如,在第12章“12.2.2用Pthreads轻松实现线程”一节中,模板要求我们编写一个返回void *的函数。为了避开这个麻烦,我们简单地返回了NULL,但有时候会遇到无法像这样简单处理的情况。另外,注意第10章“10.9从函数返回多个数据项”一节讨论了从一个函数返回结构,因此我们可以发送回相对复杂的返回值,而不需要进行内存分配,这就避免在函数内部进行内存分配的常见情况。

    可见情况实际上并没有那么多,第5种情况是极为罕见的,第4条常常是第3条的一种特殊情况,因为巨量数据集一般会放在类似对象的数据结构中。在产品代码中,要尽可能少地使用malloc,使主代码不需要进一步处理内存管理。

    6.3.3 错误来源于星号

    好了,我们现在已经清楚,指针和内存分配是独立的概念,但是处理指针本身仍然可能存在问题,因为那些星号还是令人困惑。

    指针声明语法的设计书面原因是为了让指针的声明形式和它的使用形式看上去相似。它们的具体含义取决于声明方式:

    由于i是个整数,因此我们只有通过int i把*i声明为整数才是自然的。

    就是这样,如果它可以帮到你,那就太好了。我并不确信能够发明一种歧义更少的方法来完成这个任务。

    在唐·诺曼的The Design of Every day Things一书中,始终倡导一条常见的设计规则,即“功能截然不同的事物看上去应该明显不一样”[Norman 2002]。书中提供了飞机控制的例子,两个看上去相同的控制杆常常完成截然不同的任务。在危急情况下,这可能会诱使飞行员犯错。

    在这里,C的语法也存在类似的尴尬,因为在声明中的i和声明之外的i所表示的含义截然不同。例如:

    在我的脑海中,已经抛弃了让声明和使用看上去相似的规则。下面是我所采用的规则,它很好地满足了我的需要:当用于声明时,星号表示指针。不用于声明时,星号表示指针的值。

    这里有一个合法的代码片断:

    根据上面这个规则,在第2行代码中可以看到,这种初始化方式是正确的,因为j是个声明,因此表示指针。在第3行代码中,k也是个指针声明,因此把它赋值给j是合理的。在最后一行,*j不是出现在声明中,因此它表示一个普通的整数,并且我们可以把12赋值给它(i也会随之被修改)。

    因此下面是第一个提示:记住在声明行中看到i时,它是个指向某对象的指针。在非声明行看到i时,它是指针所指向的值。

    在稍后讨论一些指针运算之后,我将提供另一个提示,在处理奇怪的指针声明语法时会有用。

    6.3.4 你需要知道的各种指针运算

    数组的某个成员可以用数组的基地址加上一个偏移量来表示。我们可以声明一个指针double *p;,把它作为基地址,然后就可以像数组一样在这个基地址上使用偏移量。在基地址上,我们可以找到第1个成员p[0]的内容,在基地址上前进一步可以找到第2个成员p[1]的内容,接下来以此类推。因此,只要提供一个指针以及两个相邻成员之间的距离,就可以把它作为数组使用了。

    我们可以直接采用基地址加偏移量的书面形式,类似(p+1)。正如教科书所描述的那样,p[1]等同于(p+1),这就解释了为什么数组的第1个成员是p[0] == (p+0)。K & R(在他们的著作《C编程语言》中花了6页的篇幅解释这个问题(第2版,第5.4和5.5节)。

    这个理论提示了一些规则,用于在实际应用中表述数组和它们的成员。

    可以通过显式的指针形式double *p,或静态/自动形式double p[100]来声明数组。不管是哪种情况,第n + 1个数组成员都是p[n]。不要忘了第一项是0而不是1,这样就可以采用特殊形式p[0] == *p。如果需要第n个成员的地址(而不是实际值),使用&符号:&p[n]。当然,第1个成员的地址就是&p[0] == p。例6-6展示了这些规则的一些实际应用。

    例6-6 一些简单的指针运算(arithmetic.c)

    使用特殊形式*evens写入到evens[0]。

    成员1的地址,赋值给一个新指针。

    引用数组第1个成员的通常方式。

    下面我再送你一个很好的技巧,这个技巧建立在指针运算规则“p+1表示数组中下一个成员的地址(&p[1])”的基础上。根据这个规则,我们不需要在遍历数组的循环中使用下标。在例6-7中我们就使用了一个备用指针来指向list的头部,然后用p++在数组中向前遍历,直到数组尾部的NULL标记,从而获得了整个数组值。如果你查看了接下来的指针声明的提示,会更容易理解这种用法。

    例6-7 我们可以利用p++表示“前进到下一个指针”实现循环的流水化 (pointer_arithmetic1.c)

    自己动手:如果不了解p++,你打算怎样实现这个目标?如果目标是为了实现简洁的语法表示形式,基地址加偏移量这个技巧并不能提供太多的帮助,但它确实解释了C的许多工作原理。事实上,我们可以考虑一下使用结构,例如:

    作为一种智力模型来分析,我们可以把list看成是基地址,list[0].b与基地址的距离正好用来表示b。也就是说,假设list的位置是整数(size_t)&list,b位于(size_t)&list + sizeof(int);,这样list[2].d的位置将是(size_t)&list + 6sizeof(int) + 5sizeof(double)。根据这种思路,结构就与数组非常相似了,区别是结构的成员是用名称而不是序号表示的,并且它们具有不同的类型和长度。

    这个思路并不是非常正确,因为存在对齐这个因素,系统可能会决定数据需要位于某个特定长度的内存块中,因此字段尾部可能会填充一些额外的空间,使下一个字符从正确的位置开始,并且结构的尾部可能也会进行填充,使结构列表中的每个结构能够大致对齐[C99和C11,§6.7.2.1(15)和(17)]。stddef.h头文件定义了offsetof宏,它精确地描述了基地址加领偏移量的思路:list[2].d的实际地址是(size_t)&list + 2*sizeof(abcd_s) + offsetof(abcd_s, d)。

    顺便说一下,在结构的起始处不可能出现填充,因此list[2].a肯定等于(size_t)&list+ 2*sizeof(abcd_s)。

    下面是个笨拙的函数,它以递归的方式对列表中的成员进行计数,直到遇到值为0的成员。假设我们想把这个函数用于零值为合理数据的任何类型的列表,因此我们让它接受一个void指针(当然这不是一种好的思路)。

    基地址加偏移量的规则解释了为什么这种做法是不行的。为了表示a_list[1],编译器需要知道a_list[0]的准确长度,这样才能知道应该从基地址偏移多少。但是,由于没有与之相关联的类型,它无法计算这个长度。

    typedef作为一种教学工具任何时候当我们遇到一种复杂的类型时,类似于指向某种类型的指针的指针的指针等情况,可以考虑用typedef进行简化。

    例如,下面这个常见的定义:

    有效地减少了字符串数组的视觉混乱,使它们的意图变得清晰。

    在前面的指针运算p++例子中,char list[]这样的声明是否很清楚地告诉你它表示一个字符串列表而p是一个字符串?

    例6-8对例6-7的for循环进行了重写,用string替换了char *。

    例6-8 添加一个typedef声明使笨拙的代码稍稍变得清晰(pointer_arithmetic2.c)

    list的声明行现在变得简单,很清晰地表示它是个字符串列表,并且string p也很清晰地表示p是个指向字符串的指针。因此,p表示一个字符串。

    最后,我们仍然需要记住字符串是个指向字符的指针。例如,NULL是个合法的字符串值。

    我们甚至可以更进一步,例如使用上面的typedef加上typedef stringlist string*,声明一个字符串的二维数组。这种方法有时候非常实用,但有时候只会增加记忆的负担。

    从概念上讲,函数类型的语法实际上是指向一个特定类型的函数的指针。如果我们有一个头部类似下面这样的函数:

    然后只要添加一个星号(并加上括号以保证优先级),就可以描述一个指向这种类型的函数的指针:

    然后在前面加上typedef来定义一种类型:

    现在我们可以把它当作一种类型使用,例如声明一个接受另一个函数作为其输入参数的函数,可以这样:

    通过对函数指针类型的重新定义,那些接受其他函数作为输入的函数的表达—其中连环星号的书写曾是令人生畏的考验变得不再可怕。

    最后需要说明的是,指针实际上要比教科书所描述的简单得多,因为它实际上只是一个位置或别名,根本不需要涉及不同类型的内存管理。像指向字符串的指针的指针这样的复杂构造总是会让人感到迷惑,但这只不过是因为我们以狩猎为生的祖先从来没有见到过这玩意而已。至少,C提供了typedef这个工具来处理它们。

    相关资源:敏捷开发V1.0.pptx
    最新回复(0)