在修改编码之初,我先在一个数据结构里添加了一个变量,几天后,我添加了一个极为简单的函数,这个函数会访问那个数据结构新添加的变量。编译和连接都没有问题,make install也成功了。但在运行时竟然出现段错误。由于函数是一个非常简单的函数,可以确信不会产生任何越界或非法访问之类的操作。但问题是什么呢?
这让我伤透了脑筋,调试的时候也是出现此错误,而且还找不到原因。
后来发现是因为系统时间的问题而导致make的错误。由于在修改数据结构时的时间竟然是比原始的修改前的文件的时间还要早,因此其实没有重新编译。而后来操作此变量的函数所在的文件可能又重新编译了。这样的结果当然是,此函数的语句在引用一个内存中根本没有出现的变量,不会出错才怪呢。
解决的方法极为简单,修改系统时间,重新修改修改过的文件并保存,最后重新编译系统。一切OK!
结论:
² make只是管理代码关连的工具,它也会犯错,但这种错误是源于程序员本身。
² 请关注开发时对版本的控制。
我遇到的宏错误问题主要是swap引起的,由于PostgreSQL在qsort_arg中没有用memcpy,而是用了几个宏来实现字节的拷贝。相关代码和测试如下:
//qsort.c static void swapfunc(char *, char *, size_t, int);
//真正的拷贝函数 #define swapcode(TYPE, parmi, parmj, n) / do { / size_t i = (n) / sizeof (TYPE); / TYPE *pi = (TYPE *)(void *)(parmi); / TYPE *pj = (TYPE *)(void *)(parmj); / do { / TYPE t = *pi; / *pi++ = *pj; / *pj++ = t; / } while (--i > 0); / } while (0)
// 调用swap前一定要使用此宏来初始化!! #define SWAPINIT(a, es) swaptype = ((char *)(a) - (char *)0) % sizeof(long) || / (es) % sizeof(long) ? 2 : (es) == sizeof(long)? 0 : 1;
static void swapfunc(a, b, n, swaptype) char *a, *b; size_t n; int swaptype; { if (swaptype <= 1) swapcode(long, a, b, n); else swapcode(char, a, b, n); }
//实现任意数据结构的交换 #define swap(a, b) / if (swaptype == 0) { / long t = *(long *)(void *)(a); / *(long *)(void *)(a) = *(long *)(void *)(b); / *(long *)(void *)(b) = t; / } else / swapfunc(a, b, es, swaptype) // 测试swap typedef struct student{ char * name; char * id; long year; long day; }student;
int main() { int swaptype; student a ; student b; int es = sizeof(a); a.day=1; b.day=2; SWAPINIT(&a, es) swap(&a,&b); return 0; } |
图表 28:用宏实现的swap注释及测试代码
在main函数中,如果要调用swap,一定要插入宏SWAPINIT(&a, es)。而这个宏又用到swaptype,所以要定义一全swaptype变量,这就是我一开始觉得奇怪的地方,swaptype明明没有用到,却要定义。后来发现是在SWAPINIT和swap中都用到了此变量。
对于宏错误,同组的同学还遇到了#define a 3*2+1却忘记了在3*2+1外加一括号的问题。这个错误虽然在几年前就已经知道要防范,但由于宏指令可读性不强,错误有时是防不胜防。
解决的办法是:用宏时一定要用括号。或者,最好不用宏。
从中我们可以看到宏的巨大缺点,同时也是它的优点——都是简单的字符串替换策略导致。它的优点是可以不用检查类型,而这正是现在高级语言所避免的,现在无论是Java,还是C#都是强语言类型,就连哪怕int a=1;if(a==true)之类的语句都不能通过。这有效的防止了编译时通过,而运行时出现的“莫明其妙”的问题。加之我们的痛苦经验,我们可以得出一个结论:
结论:让尽量多的错误在编译时解决,让尽量少的错误在运行时出现。
在修改Tuplesortstate结构体后,在ExecSort中添加代码以访问此结构体的成员时,遇到这个错误,编译器提示说无法访问结构体成员。我一时无法理解,明明我在Tuplesortstate结构体内添加了三个成员变量,而且也已经编译通过了,在ExecSort函数所在的nodeSort.c的开始,也已经把Tuplesortstate的头文件包含进来了。声明一个Tuplesortstate *指针也可以,为什么却不能访问其变量?
问题的原因在于Tuplesortstate结构体定义在tuplestore.c文件中,tuplesort.h中只有一个其类型的typedef: typedef struct Tuplesortstate Tuplesortstate;
解决的方法是把Tuplesortstate结构的定义从tuplestore.c中一份到nodeSort.c即可。
问题的实质在于:要定义一个类型的变量或引用此类型变量(或指针)的成员时,必须知道这个类型的内存布局,而不只是一个名字。关于类型、定义与内存布局在下一节中有更为详细的阐述。
但现在只需要记住这条经验结论:类型定义在头文件中,可以让你万无一失。
在算法编写完成并编译通过后,我用了几乎相当于编写算法的时间来调试程序,而程序中最难调试也是错误最大的错误便是数组下标错位!这些代码虽然占了大概不到20%的代码,但这种错误却几乎占掉了调试的80%的时间精力,或许这也是一种另类的20-80原则?
经常出现的下标错误有:
“第n”与a[n-1]的对应,由于我们自然语言中,第几是从1开始计算,而C中的数组下标却是从0开始计算。这种错位经常让人“自然而然”的在程序中留下隐患,简直防不胜防。
边界下标的问题:这也是最常见的下标出错的原因之一,0,1,-1和n,n-1,这几个特殊的边界,比如数组从0开始,但插入排序从下标1开始,如果下标为0时还让其递减,就变成-1了,n是数组的长度,但a[n-1]又是数组的最后一个元素。凡此种种,如果稍不留神,但留下了一个极难抓到的臭虫。
这种问题的解决办法,除了写程序时的脑袋保持清醒外,几乎没有什么全能的办法可以杜绝。倒是有一些习惯或叫技巧可以借鉴。
如在进行for循环时,for(int i=0;i
再有一个仅是习惯,而非原则的“技巧”是,对于for和while的选择,你可以选择一种成为你循环习惯,像我几乎不会用到while,因为for可以做while同样的事情,而且自己也比较熟悉,不会老犯错误。
还有一个技巧:当你在判断是+1还是-1时,可以用特殊值比如1,0之类,代入看看你是需要怎么控制。
除了在编写代码时头脑清醒和一些小的习惯之外,这种错误的解决只能依靠调试时的“敏感”和耐心了。——或许,这才是程序员的真本事,就像庖丁解牛一样,把对错误和关键的敏感融入本性,以至于“恢恢乎于其间而游刃有余”。
我们也可以有一个这样的结论:正确的下标操作=清晰的思路+良好的习惯+纯熟的经验+耐心。
或者,我们也可以写这样的等式:好的程序员 >=(一定有拥有) 清晰的思路+良好的习惯+纯熟的经验+耐心。
在编写Select(S,K)算法之后,编译、调试并运行都已经通过,且结果很正确。当我在表里再添加一些元组,运行同样的语句,竟然就出现“server closed the connection unexpectedly”。而且在修改select的count或offset参数时,这种错误有时出现,有时又不出现,很像内存访问的问题,但一时也让我百思不得其解。
没有办法,只能在出现错误的时候,跟踪执行了。在经常了很多的次的Select(S,K)递归调用之后,突然发现了这个巨大的BUG!
在Select(S,K)算法的最后一步递归调用时,出现了所选的m*是数组中最小的元素,也就是说smallerIndex为0,而后面的递归的子问题规模没有变化,也就开成死循环,导致出现上述错误。
跟踪数据观察图如下图所示:
图表 29:“不可思议”的死循环BUG的跟踪观察结果
从图中可以看出,此时要在大小为11的元组中找出一个第7小的数来,但选择的项地址pTemp却是最小的数,导致smallerIndex为0,而largerIndex为11。
注意,由于要处理元组排序码相等的情况,所以我们把和pTemp相同的元素也一并放入到pLarger中,也包括pTemp本身。也正是因为这个原因,只需要修改最后一步,让其循环找到pTemp后,将其和pLarger[0]交换,递归规模也减少1,问题也就解决了。
相关的代码如下:
//qsort_arg.c /*ouyang 12.11*/ // 如果恰好有smallerInder比pTemp小,则pTemp即为解. if(k==(smallerInder+1)) {
} // 否则缩小子问题,递归调用求解. else if(k<(smallerInder+1)) { pTemp = select_s_k(pSmaller,k, a, smallerInder, es, cmp, arg, select_count, select_offset); } else { if(smallerInder>0) { pTemp = select_s_k(pLarger,k-smallerInder, a, largerInder, es, cmp, arg, select_count, select_offset); } else//!!ouyang 12-15找到一个巨大的BUG,当smallerInder为时,即所选的pTemp刚好就是最小无元素之时,如果不加这个分支,会导致死循环!!!! //由于largerInder中包含和pTemp相等的元素,故不可能为,不用考虑。 {
for(i=0;i { if(pLarger[i]==pTemp) { //swap to position 0 pTemp= pLarger[0]; pLarger[0]=pLarger[i]; pLarger[i]=pTemp;
break; } } pTemp = select_s_k(&pLarger[1],k-smallerInder-1, a, largerInder-1, es, cmp, arg, select_count, select_offset); } } // 释放内存 free(pMid); free(pSmaller); free(pLarger); return pTemp; } |
图表 30:“不可思议”的死循环问题的解决代码
经验结论:边界的处理,最少的代码却可能隐藏着最大的错误。
前一章提到的make因为系统时间的问题而导致一些错误的行为,其实都是因为版本的控制而导致的。由于PostgreSQL的文件达一千多个,文件夹也有200多个。如果修改错了某个文件,过了一段时间,都有可能不知修改了什么文件,在什么文件夹下,可见版本控制 事关重大。
在企业,这个问题也会被极大的放大,因此一般都会采用CVS或SVN之类的方式来解决版本控制问题。但由于我们这个实习虽然面对的文件却很多,但修改的地方比较少,同组的人也只有一两个,当然就不会兴师动众的何用SVN之类的解决方案了。
在本实习中,我采用了以下的方式来控制版本,以达到提纲挈领的作用。
在每处修改之处加统一的注释,方便搜索。比如,本项目所有修改的地方都有ouyang和修改的日期做注释。
新建一个文件夹,采用和PostgreSQL相同的文件夹结构,但只把修改过的文件拷贝进来,只针对此文件夹查找和跳转。如果要转入到PostgreSQL文件夹下,只须删除路径中的那个新文件段即可。如home/postgreSQLChanged12-16/postgresql- 8.2.5 /src/backend/executor,如果要到postgresql-8.2.5的同文件件下,只需要删除/postgreSQLChanged12-16,便可进入home/postgresql-8.2.5/src/backend/executor。
在PostgreSQL中,我们应用到了大量的指针排序,也遇到了关于内存的种种问题,这促使我们撰写此节,来探究关于内存的更多话题。
对于程序员而言,什么是内存和内存地址?下面的讨论是我的一些感想,在不涉及内存泄漏、野指针之类的编程错误的情况下,讨论我们如何正确看待内存。
内存是一系列编好地址的单元。
在32位系统中,这个地址一般为32位。程序员面对的就是这样的一系列的虚拟内存,怎么把这些虚拟内存映射到真正的物理内存,是操作系统的事情。
在明白了内存只是一系列有地址的单元之后,下面从几个角度去看内存布局与程序是如何解释内存的。
类型是定义好的一种内存布局,变量是按照这个定义好的类型和布局去开辟定量的内存空间。而指针也是一个变量,此变量存储在一个四个字节的内存区域中,存储的内容是某个变量的地址或者是NULL。
当声明一个指针(变量)时,此指针还没有保存(或叫指向)任何有效地址。当把某个类型的变量地址赋值给此指针时,此指针变量便存储了一个地址,这个地址其实是之前变量的起始地址。
当对一个指针或变量做取值操作时,计算机根据其类型去解释内存布局。这就是变量类型和指针类型的意义。
指针的类型最好和其存储有变量类型一致,因为这样肯定会正确的解释此变量的内存布局。
但是,这个是“最好”,不是必须,当派生类变量的地址赋值给基类型的指针变量时,基类型的指针照样可以正确的解释内存布局。甚至,可以把变量地址传给一个不同的类型的指针。
下面的代码演示了一件看似神奇,但很符合内存之道的做法:
// ouyang 12-16 // 内存与布局的测试 #include using namespace std;
typedef struct student{ int day; int year; }student;
typedef struct teacher { int teacher_day; //int year; }teacher;
int main() { student a; a.day =1; teacher *b; b = (teacher*)(void*)(&a);
cout<<"b->teacher_day: "<
return 0; } |
图表 31:类的内存布局测试代码
类与函数,从不同的角度实现了代码的复用。你或许可以很轻易的指出两者的不同,比如类是对相同概念的一种包装,而函数是对算法的共同步骤进行的包装。但是,反应到内存角度,他们有什么区别?
先简述一下另外两个简单,但极为重要的概念:定义与声明。其中又分变量定义、类型定义与函数定义,变量定义是已经根据其类型为其开辟了一块内存空间。类型定义指明确定义了类型的内存布局,即成员变量和函数变量。函数定义是函数体的实现。
在C/C++中,其实没有变量声明这一说,如果把指针变量也看成一种四个字节大小的变量的话,teacher * b;也是在定义一个指针变量,只是没有初始化而已。类型声明只是声明一个类型名,而没有更多的关于此类型的内存布局的说明。比如typedef,或C++中两个类相互引用时,可以采用类的声明。
因此,在一个类型A中,如果某个变量是另一个类型B的指针,那只需在此时知道B的名称。但不允许定义B的变量,也无法取B类型指针的成员,因为此时无法知道B的内存布局。这也是产生“结构体成员访问错误”的真正原因。如果要用此思路解决问题,此时需要把B类的定义拷贝到该处。
那为什么函数定义只需要加一个extern,然后在其他文件声明一下即可以调用呢?而类型却不行呢?
这是因为类型需要内存布局来定义变量,而函数只需要一个函数引用地址却可以使用这个函数,它没有成员内存布局,只有一个函数起始地址和一段连续的代码。
这就是类型与函数在内存中真正的不同之处。
在阅读PostgreSQL源码的时候,一个最大的收获就是使用一个面向过程的C语言实现了面向对象的种种特性。两种编程思想的特点在N多的文章或书籍中都有很好的注解。比较经典的表述是:(我不知道是不是可以称为经典,因为这两句话深存于脑海中已多年):
面向过程编程,是以问题为核心,从整体到部分、模块化的、一步步的从抽象到具体。
面向对象编程,是以问题中所有对象及其关系为核心,从问题出发,模块化的,从抽象到具体设计完类之后,又从具体的类出发,从底向上,通过组合与继承,一步步的从具体回归抽象。
无疑,面对对象编程比面向过程更复杂,也更强大。当然,从抽象层次和封闭性考虑,面向对象可以使问题变得简单。下面就讨论两个和PostgreSQL实习相关的两个用C来实现的面向对象技术。
PostgreSQL中所有的结点类都采用了此技巧。比如所有的类都继承自Node,所有的计划状态类都继承自PlanState类。下面总结一下此技术的关键几点:
² Struct类型和变量的连续内存布局使伪继承成为可能。比如所有的结点的第一个域都是NodeTag,这个正是Node的内存布局。关于其继承类对象如何可以转化为其基类对象,请参阅上一节。
² 使用枚举来区分不同的继承类类型,以正确的解释内存布局。这也是为什么PostgreSQL中到处都有switch—case结构。
² 指针都是4个字节,并用C语言允许“自由”转换。
基于以上的原因,C可以很简单的实现伪继承。此技术相对下面要讨论的多态还比较简单,下面就剖析一下用C来实现多态的技术。
PostgreSQL中没有真正实现多态的技术,而只是用了switch—case+伪继承来实现“不同类型的不同动作”。但是switch—case结构无论出现在什么地方,都意味着很有可能这段代码的可扩展性就大大的降低。因为用这种技术来分支,都是在编译时就已经定下来了,如果未来要增加一种类型,就必须得修改所有地方的代码(包括类、switch—case还有类型枚举标记)并重新编译。这显然有违软件工程中的简单性和可扩展性原则。下面就简要讨论多态实现的几个关键技术。
² virtual table:每一个类都会有一个Virtual table与之相联,并且每个类都至少有一个虚函数,那就是它的虚析构函数。Virtual table其实只是一个函数指针数组。一个类的Virtual table由在这个类中所声明的以及由这个类继承来的虚函数的地址所构成。对于继承来的虚函数,仅仅那些没有被覆写的才会被加进去。
² 内存布局:在上一小节中已经描述。不再赘述。
我之前实现过一个简单的模拟,但没有找到代码,等找到之后再补充此点。
C++的很多哲学是:能在编译时确定的事情就一定在编译时确定,而Java的设计中,却经常出现如果在运行才真正确定,那就等运行时刻再定吧。比如C/C++中不允许数组在运行时候指定长度,而Java则在运行时可以动态的改变数组的长度。
两者各有千秋,前者重效率,但缺乏灵活性和可扩展性。后者可能效率不高,但可扩展性很好。
两者设计理念的不同可能与语言的定位有关,此主题不是本文讨论范围。回到我们的主题:“编译与运行 静态与动态”。
什么东西是在编译时确定的事?什么东西是在运行时确定的事?
编译或编译前确定的事情有:
² 预编译,如宏替换,include之类。
² 类的结构与接口
² 继承
² 数组
² 变量换名
² 变量定义
运行时确定的事情有:
² 动态申请内存
² 多态以决定类的行为
² 类中的引用(如指针)所指的真正类型及行为
可能还有更多。这里只是罗列了几类。
无论如何,区分静态与动态,编译与运行,并把可能的错误尽量的在编译时找到,而不是运行时,是正确的编程,并编写出正确的程序所必须了解的事情。
继承是类的静态结构,而组合是获得类的功能的动态方法。比如在PlanState中,既继承自Node,又包含Plan和Estate引用,他因此获得三者的功能。
在这里,我们建议尽量多的使用引用,因为
² 继承是类的静态行为,在编译时就确定了不会变;而组合是对象的动态行为,在运行时才确定。
² 使用对象的组合来设计可以更灵活,更有弹性。这是因为你只需要调用组合类的接口即可完成接口的“包装”,更有弹性。虽然与继承相比,它多了一个对象的引用,但只是一个对象的引用而已,这可以使得在运行时使这个引用指向不同的子类,而获得在运行时确定行为时改变行为的弹性。
² 使用类的继承可能会增加大量的类,导致管理不太方便。
² 继承与组合的共同点是都可以代码复用的产生新的行为,只是前者可能局限于“覆盖重写”,而后者则是接口调用重新包装。
当然,这是有关设计模式的东西,我们不做深谈。重要的是:我们应该时刻牢记,我们的设计应该尽量的统一和灵活,当两者不能同时具备时,只能根据我们的需要做一种折衷权衡。
终于写到本文的最后了,这篇长达50页的报告中,汇聚了我们的心血,也充满了我们的收获。也因此,我们更加相信:一份耕耘 一分收获!