为什么要全面思考问题
□ 在软件开发中,对一个问题思考得越全面,编写出的代码就会越严谨,出现bug的几率就越低;反之,如果没有对一个问题进行全面而深入的思考,编写出的代码就会漏洞百出,出现各种莫名其妙、无法复现的bug的几率也就急剧增加。
□ 软件就是数据加逻辑,数据是“肉身”,逻辑是“灵魂”。如果不全面思考问题,在某些情况下, “灵魂”就会“精
神错乱”,甚至损坏“肉身”,进而导致无法正常工作。
□ 只有经过全面思考编写出的代码,才是严谨的,才能保证可靠性。一份代码即使严格遵守了代码规范,重构
了设计模式,但思考不全面,逻辑不严谨,也不能称之为优雅。
□ 没有经过全面思考开发出的软件,虽然短期内可能能正常工作,但长远来看,各种问题和漏洞一定会爆发出来,从而导致系统的可靠性、可维护性和稳定性大打折扣。记住墨菲定律:凡是你认为可能会出错的,它一定会出错。
下面,我们通过几个实例来理解如何进行全面思考。
实例1
输入若干个整数作为数组,将数组中每一个元素除以第一个元素的结果,作为新的数组元素值。
这道编程题并不难,稍加一思索,很容易给出下面的答案。
using namespace std; static void DivArray(int *pnArray, int nSize) { for (int i = 0; i < nSize; i++) { pnArray[i] /= pnArray[0]; } } int main() { int nSize = 0; cin >> nSize; int *pnNumber = new int[nSize]; for (int i = 0; i < nSize; i++) { cin >> (*pnNumber++); } DivArray(pnNumber, nSize); delete pnNumber; pnNumber = NULL; return 0; }
看着是不是也没什么大问题?实际上,这段代码至少有以下6个问题:
1、没有对输入的nSize进行检查,nSize可能为负数、0或者很大的一个正数(可能会导致内存溢出)。
2、从cin输入数组元素后,pnNumber已经不指向第一个数组元素了,后面使用和释放会出错。
3、函数DivArray中,没有对传入的两个参数进行检查。
4、函数DivArray中,数组中第一个元素可能为0,被0除会导致程序崩溃。
5、函数DivArray中,从0开始遍历数组,导致数组中第一个元素已经变成了1,后面元素的逻辑均不正确。
6、释放pnNumber时,应当使用delete [],而不是delete。
从这个例子可以看出来,问题简单,并不代表不需要全面思考。不全面思考,后果很严重,你的代码中到处都充斥着漏洞和bug。
实例2
自行封装一个函数,用于实现内存拷贝,函数原型如下:
void *memcpy(void *dest, const void *src, size_t count);
有的新手看到这个题目,觉得so easy,立马写出了下面的代码。
void *memcpy(void *dest, const void *src, size_t count) { if (src == NULL || dest == NULL) { return NULL; } while (count--) { *dest++ = *src++; } return dest; }
很可惜,上面的代码除了一些低级的语法错误外,思考得也不够全面。
1、src和dest都是void *类型,不能对其进行++操作。
2、返回的dest指针,已经不是原始传入的dest指针了。
3、当dest指向的内存区域,与src指向的内存区域有重叠时,可能导致拷贝错误的数据。
正确的实现可以参考下面的代码。
void *memcpy(void *dest, const void *src, size_t count) { if (src == NULL || dest == NULL) { return NULL; } if (dest > src && (char *)dest < (char *)src + count) { char *pSrc = (char *)src + count - 1; char *pDest = (char *)dest + count - 1; while (count--) { *pDest-- = *pSrc--; } } else { char *pSrc = (char *)src; char *pDest = (char *)dest; while (count--) { *pDest++ = *pSrc++; } } return dest; }
实际上,如果更进一步思考,上面的代码还有优化的空间。
1、当count为0时,其实可以直接返回dest,后面的逻辑就不用考虑count了,更简洁。
2、当src和dest相等时,说明它们指向的是同一块区域,可以直接返回dest,不需要再去拷贝。
3、当count较大时,一个字节一个字节拷贝的方式,效率非常低。如果追求效率的话,需要考虑字节对齐和多字节拷贝等,可参考glibc中memcpy的实现(不支持内存重叠)。
实例3
有一根长为L的平行于x轴的细木杆,其左端点的x坐标为0(故右端点的x坐标为L)。刚开始时,上面有N只蚂蚁,第i(1≤i≤N)只蚂蚁的横坐标为xi(假设xi已经按照递增顺序排列),方向为di(0表示向左,1表示向右)。每只蚂蚁都以速度v向前走,当任意两只蚂蚁碰头时,它们会同时调头朝相反方向走,速度不变。编写程序求解以下问题: 1、所有蚂蚁都离开木杆需要多长时间? 2、所有蚂蚁都离开木杆共碰撞了多少次? 3、第i只蚂蚁离开木杆需要多长时间?
以下为这道题的思考过程。
1、因为每只蚂蚁的初始方向是任意的,两只蚂蚁碰头后会调头,因此,所有蚂蚁的实时坐标是动态的,且是相互影响的,直接编程很难处理。考虑下面两只蚂蚁的情况,从碰头到离开木板的时间,是x和10-x的较大值。
如果两只蚂蚁碰头后,不调头,而是仍按原方向前进,从碰头到离开木板的时间,也是x和10-x的较大值,只不过后离开木板的那只蚂蚁变了。也就是说,蚂蚁碰头后不调头,不影响所有蚂蚁离开木板的时间。
2、同上面的分析,蚂蚁碰头后不调头,不影响蚂蚁碰撞的次数,只影响哪两只蚂蚁碰撞了。在此假设下,每只蚂蚁碰撞的次数,就是初始时,与其前进方向相反的蚂蚁的个数。
3、与问题1和2不同,这里求解的是某只蚂蚁离开木杆的时间,需要我们更深入地分析和思考问题。不管某只蚂蚁中间和其他蚂蚁碰撞了多少次,到最后一次碰撞时,这两只蚂蚁爬行的时间和路程是相同的,假设路程用A表示。碰撞后,各自调头,离开目标,则向左离开木板的那只蚂蚁爬行的总路程为:A+x。而这个A+x也可以理解为另一只蚂蚁之前爬行的路程加上继续向左离开木板爬行的路程。
怎么更形象地理解呢?可以假设每只蚂蚁背着一粒米,碰头时,交换各自的米,然后调头。这样,蚂蚁虽然调头了,但米的前进方向始终不变。蚂蚁爬行的路程,就是它离开木板时背着的米走过的路程。那么,两者如何关联起来呢?
假设初始时,有P只蚂蚁向左,则有N-P只蚂蚁向右。因为每次碰撞后,向左和向右的蚂蚁数量不变,因此,最终肯定有P只蚂蚁从左边离开木板,有N-P只蚂蚁从右边离开木板。是哪P只蚂蚁呢?
假如蚂蚁a初始在蚂蚁b左边,则根据规则,任何时候,蚂蚁a的相对位置都在蚂蚁b左边。故初始时靠左的前P只蚂蚁,必定会从左边离开木板,其他N-P只蚂蚁,必定会从右边离开木板。
假如i不大于P,则第i只蚂蚁会从左边离开木板,它爬行的路程,就是它离开木板时背着的米走过的路程。它背着的米哪来的?来自初始时第i只向左的蚂蚁。故初始时第i只向左的蚂蚁的坐标,就是第i只蚂蚁爬行的路程。同理,假如i大于P,则L减去第i-P只向右的蚂蚁的坐标,就是第i只蚂蚁爬行的路程。
实例4
嵌入式设备进行升级时,支持升级uboot、内核、根文件系统和程序。以升级程序为例,我们在升级时的大致流程是什么样的?有哪些需要注意的地方?
我们可以用下面的流程图来理解嵌入式设备升级程序的处理逻辑。
1、返回进度时,应包括接收数据的进度和向分区写入数据的进度两部分,比如:前者占60%,后者占40%,然后根据这个比例计算总的进度。
2、程序包应当将一些相关信息也打包进去,以便于校验,比如:包的类型、版本号、对应的硬件型号。包的类型用于区分是uboot、内核还是程序;版本号用于和当前系统进行版本的比较;不同硬件的程序包是不能混着升级的,故需要知道硬件型号。
3、某个客户端正在升级程序时,又有其他客户端升级程序,怎么办? 最简单的办法:直接返回错误。
4、程序分区的大小是有限制的,程序包的大小超过了程序分区的大小,怎么办? 认为程序包非法,终止升级流程,并返回错误码给客户端,由客户端进行提示。
5、正在向程序分区写入数据时,如果此时数据传输通道异常断开了,怎么办? 不要终止写入,而应当继续完成整个升级流程(只是不需要返回进度了)。如果此时终止数据写入,已经写入的数据是不完整的,重启后,程序将无法正常运行。
6、向程序分区写完所有数据,并发送了100%的进度信息后,如果此时立即断开数据传输通道,客户端可能收不到进度为100%的信息,导致客户端认为升级出错了。怎么办? 发送了100%的进度信息后,应当等待一段时间(比如:3秒钟)后,再跳出工作循环。在这段时间内,正常情况下,客户端应当能收到进度为100%的信息,然后由客户端断开数据传输通道,模组检测到连接断开后,跳出工作循环。
7、正在向程序分区写入数据时,传过来了重启设备或关机的命令,怎么办? 处理重启设备或关机的命令时,如果发现正在升级程序,直接返回错误。
8、正在向程序分区写入数据时,断电了怎么办?
有以下几种处理方式:
□ 不处理
上电后,模组会变成“砖”,除非通过串口重新写入程序。
□ 使用备份分区
当空间足够时,存在一个程序分区和一个备份程序分区。
(1)升级程序时,先写入备份程序分区,写完并同步后,开始写程序分区,同时置正在升级标志为true。写完程序分区并同步后,置正在升级标志为false,说明升级成功。
(2)在根文件系统分区或其他分区中,存在一个子程序。子程序运行后,检查正在升级标志。若发现为true,说明升级不成功,需要进行恢复。若发现为false,则检查程序进程是否存在,若不存在,则也需要进行恢复。若不需要进行恢复,则子程序直接退出。恢复的逻辑为:首先读取备份程序分区,并同步写到程序分区;然后,检查正在升级标志是否为true,若为true,则修改为false;最后重启。
(3)升级程序时,检查子程序进程是否存在,若存在,则直接返回失败,避免同时读写备份程序分区。
□ 使用链接
(1)当通过链接下载升级程序包时,可以将链接保存下来。
(2)子程序发现需要恢复时,从保存的链接下载程序包,再写入程序分区。
总结
1、全面思考问题的前提条件是经验和积累,只有具备丰富的经验和充足的积累后,才能做到全面思考。因此,需要我们多拓展知识面的宽度,多挖掘知识面的深度。
2、全面思考问题时,需要考虑一个问题的所有影响因素,以及这些因素之间的关联关系和相互作用。
3、多从各个角度、各个层面考虑问题,比如:从代码规范的角度看有没有遵守,从封装的角度看合不合理,从逻辑的角度看严不严密,从效率的角度看还能否优化,等等。
4、当一种思维方式行不通或遇阻时,不要“钻牛角尖”。多尝试跳出这种思维方式,换一个角度,换一种思维方式思考和分析问题。
5、多反问自己:类里面的成员变量,都有正确赋初始值吗?分配的内存和创建的句柄,有正确释放吗?在当前这个位置必须要释放吗,释放完了其他地方还有没有在使用?… …
6、多从全局和整体思考问题,这样才能够看到事物的来龙去脉,看到事物发展变化的趋势及其背后的驱动因素。