十八年开发经验分享(三)问题解决篇(中)

关于本系列文字的来源,初衷和内容定位可以参考第一篇的开头部分,链接地址如下:
http://blog.csdn.net/binarytreeex/article/details/8174445
http://www.cnblogs.com/WideUnion/archive/2012/11/12/2766397.html
本文前一篇地址如下,感兴趣的可以访问下面的连接:
http://blog.csdn.net/binarytreeex/article/details/8456766
http://www.cnblogs.com/WideUnion/archive/2013/01/01/2841335.html
 
本文是问题解决篇的第二部分,继续讨论与解决实际工作中的问题相关的内容。本文假设读者是具备一定开发经验的。这个所谓的经验是指需要满足这样一个底线:代码中的语法错误和逻辑错误可以自己独立解决,或者说基本上可以独立解决。如果是开发新手,尤其是没有经验的毕业生,我不推荐来看本文,对这部分程序员来说本文的难度有点大了。
 
对于有一定开发经验的程序员来说,工作中遇到的问题基本上是可以自己独立解决的。所以本文讨论的内容旨在帮助这部分程序员更好的解决问题,或者说在进一步提高解决问题的能力上,提供一些参考和有用的方法与思路。和上一篇一样你不能期望本文可以解决你所遇到的所有问题,或者看了本文之后就可以立马武功大增,这是不现实的。但是如果记住我个人的这些经验,然后在工作中去实践,在日积月累中可以帮助你(至少有一部分程序员是这样)更快,更好的提高解决问题的能力,这个是可以做到的。因为我就是这么走过来的,所以如果你也想这么走过来,那么我分享的经验对你一定是有帮助的。
 
再次强调本文介绍的内容来自个人的实践,对于解决问题这样一个宏大的话题和全体开发者这样一个宽泛的群体来说,局限性和片面性是在所难免的。所以请同行们自行取舍,同时也要根据自己的经验,实际应用场合做出适当的变化,这样才能更好的应用本文介绍的内容。如果分享的内容可以为同行们解决实际工作中的问题起到积极作用的话,那么我的目的就达到了。当然如果能够达到庖丁解牛那样游刃有余的境界那是最好的。
 
一.解决问题的一般步骤
当程序员在实际工作中遇到问题时该如何去解决呢?以我个人的经验来说大致是以下几个步骤。首先需要去认识问题,确认这是一个什么样的问题或者问题是什么。比如对于调试程序中出现的bug,往往是以能够重现bug为前提的,否则无从下手。这种可重现性对我们认清并判断问题是一个前提。在实现某一个功能时,程序员也需要对实现的功能有一个清楚的认识,然后才能选择合适的方法来解决。所以解决问题的第一步是认清问题。第二步是评估这个问题是否有解决的办法,或者说需要做一定尝试工作以确定这个问题是可以解决的。这一步也很重要,它的作用在于保证程序员在解决问题的一开始就朝一个正确地方向前进。这一步的另外一个作用在于评估解决一个问题是否值得去做。有时候解决一个问题,不总是以解决作为结果的,有时候放弃也是一个合理的选择。第三步是选择合适的方法解决问题。不同的人对于相同的问题可能会给出不同解,这个由解决问题的程序员自己决定。能力,环境等等因素都会导致不同的解决问题的方法。
 
二.程序员通常会遇到什么样的问题
在大部分的情况下程序员遇到的问题属于工程问题,而不是科研问题。与我们在学校中做习题相比实际工作中的问题具有一些不同的特征。
 
1.是否存在解
在学校中我们做的习题一定是有解的,但是实际工作中的问题则未必,或者说自己面对的问题不一定是可以最终被解决的,而是以失败告终的。
 
2.是否有最优解
习题往往存在一个所谓的标准答案,但是实际工作中的问题往往会存在多个或者若干个可行办法来解决问题,工程师需要从中选择一个。所谓的最优解有可能是不存在的。
 
3.选择解的标准不同
用于判断最优(或者说最合适)的解的标准不同。做习题往往是单纯的比较方法本身的差异。而在实际工作中需要考虑其它相关的或者周边的因素,比如:难易程度,成本等等。对我而言我把最终选择的方法的可理解性,可维护性和功能变化后可重构性放在非常重要的位置,作为选择解的标准。
 
4.可选解的选择范围不同
做习题只能是用知识正面解决问题,当然抄袭是不算的。但是实际工作中的问题可以使用非技术手段来解决。比如对于性能问题的改善,可以通过重新设计算法来解决,也可以通过改善硬件配置来解决;对于用户提出的bug,可以直接修改解决,也可以通过富有技巧性的沟通来解决,等等。
 
实际工作中的问题还有一个比较乐观的特点,那就是问题往往总是可以被解决的。其原因是有由于大环境和小环境决定的。大环境是公司或者团队在选择开发任务或者立项时会有一个评估,这和上面提到的解决问题步骤中的第一第二步类似。小环境是,在具体安排开发任务时,往往会根据各人不同的技术特长而安排有针对性的开发任务,或者有选择性的安排不同难度的任务。就我而言,当初是以VC入行的,结果公司开始就安排VC的开发任务,今后就一直以此为依据来安排开发任务的。从而导致我很少有机会接触其他的开发任务,比如Java之类的。
 
在实际工作中,程序员遇到的大部分问题都是如此的。所以从战略层面讲,没有必要畏惧问题。当程序员遇到问题时,请保持信心,要相信方法总是会比问题多的。这样的心理状态对解决问题也是很有帮助的。
 
三.解决问题的基础条件
就我个人的体会而言,解决问题的基础条件应该有三个,但是本文需要关注的只有两个,那就是知识和经验。实际上对我而言,在解决问题时我最担心的是需要用到我不知道的知识,这才是最可怕的。这次在给Entity Model Studio开发时序图时就出现过这样的担心,后文会讨论这个例子的。现在举另外一个例子来简单说明一下知识的作用。
 
有一个非常著名游戏叫魔法门,我在2000年之前玩过其中的几个版本。其中一个版本有这么一个任务,要求主角到一个修道院去拿一件物品。该物品在一条走廊的尽头,如果直接走过去拿是不行的,因为那里有一个牧师在做祷告,挡住了去路。按照攻略上的说法,需要去大堂的阁楼上按照一定的次序拉动五根绳索,然后回来,这时牧师就离开了,从而可以成功拿到物品。这五根绳子的拉动方式是这样,用鼠标选择然后点击,如果点击的顺序匹配上了,游戏就给出一个响声作为提示。否则你就可以一直没完没了的点下去。好,现在简单说明一下这个问题的核心焦点是什么。
 
首先说明这不是一个难问题,但是有一个不算问题的小障碍,那就是在处理输入数据时和我们通常的方式是不同的。我们回想一下自己实现过的功能,一般来说用户输入的数据(这个游戏场景里就相当于用鼠标点绳索)会有一个明显的结束操作。比如:用户注册,填完注册信息之后按提交按钮;查询时输入表示条件的数据,然后按查询按钮,等等。但是这个游戏中的场景却不是这样的,这个第一人称的角色扮演游戏允许用户可以不停的点击鼠标,而不是要求玩家每点五次鼠标然后再按提交按钮,程序做判断,匹配就通过,否则重新再点五次。与我们通常处理的情况相比这是最大的不同,这个问题的核心焦点就在这里。有兴趣的同行可以考虑一下自己会如何做。这里限于篇幅,我就不说明解决的过程了,而是直接给出我的作法。当然这个不是什么标准答案,但是从用到的知识和方法来说,我相信魔法门的真正开发者也应该是这么做的。
 
这里假设正确的点击次序是54321,也就是第一次点第五根绳子,第二次点击第四根绳子,以下类推,这个数据可以保存在一个数组里,比如整型数组。当玩家第一次点击时,获得点击的绳子的序号,然后和数组的第一个元素比较。如果相同,那么玩家第二次点击时就和数组中的第二个元素比较,如果一直正确那么每次点击后都依次比较后一个元素,一直比较到最后一个元素正确时,就可以让那个牧师走开了。如果比较的结果不对,那么当玩家再次点击时就要和数组中的第一个元素比较,重新开始了。比如,玩家连续三次点对,但是第四次点击错误的话,那么第五次点击时比较的数据是数组中的第一个元素。好了,解决问题的思路有了,下面的问题是代码怎么写?一般来说直接用嵌套的if语句或者嵌套的循环就算做出来也是不可取的。打分的话是零分,肯定是零分。我推荐一个做法是用状态转换图,到这里,书上学到的知识开始用上了。
 
下图是我画的状态转换图,当然只是一个示意,将就着看吧,不规范或者不正确的地方可以指出来。

 

 

由于问题比较简单,表示状态的变量和表示数组下标的变量可以用同一个来表示,并且初始化为零。玩家每点击一次就和下标指示的当前位置的元素比较,如果一致,下标就加一。然后判断下标是否超出数组长度,如果是那就表示通过了,否则将下标设置为零。所以,可能的代码大概是这样:
 

if (data[state] == inputData)
    state = state + 1;
else
    state = 0;

if (state >= 5)
{
   触发通过剧情
}

或者简洁一点可以写成这样:

state = (data[state] == inputData) ? state + 1 : 0;

if (state >= 5)
{
   触发通过剧情
}

其中data保存的是正确次序的数组,state就是那个状态也就是下标,inputData表示玩家点击的绳子的编号。

 
这里需要强调一下,如果按照书本上的知识,严格规范的按照状态转换图的作法来解决这个问题的话,代码会比较臃肿。比如,我们在这个例子中还是遵守一个状态用一个独立方法来处理的话,那就很没有必要了。所以提醒工程师们在应用知识时做适当的有针对性的变化。另一个需要强调的是,知识在这个例子中对解决问题是非常有效的。想想开发魔法门的程序员和我们相比当然强的很多很多,但是只要有足够的知识,我们就可以和他们做的一样好,至少这个局部的小功能是这样的。请体会一下知识的重要性。
 
解决问题的另外一个基础条件是经验。这是在长期职业工作中逐步积累起来的一种敏锐的判断力和识别能力。这种职业的嗅觉或者直觉往往可以在第一时间告诉工程师自己,什么是正确的方法。但是遗憾的是,经验这个东西除了自己逐步积累,或者用功的去积累,好像没有什么别的办法可以替代。
 
基于上面的讨论:解决问题的步骤,问题的类型,解决问题的条件,在实际工作中解决问题时,我归纳出需要注意的事项有三点,它们是:行不行,好不好和对不对。很遗憾的是,在实际工作中这三点往往被忽略,或者没有主动的使用,即便是用上了,也是下意识的。下面依次讨论这三点。
 
1.行不行
所谓行不行就是,需要考虑面临的问题是可以解决的么?这个解决有两个层面的意思,第一个层面是能不能解决这个问题,第二个层面是我能不能解决这个问题。基于上面的介绍,一般来说或者大部分情况下,程序员是不会明显的体会到这个步骤的存在的。因为接受的开发任务都是被过滤过的,大部分情况下程序员自己都是知道应该怎么去完成工作的。所以这个步骤往往被忽略了。还有一种认识是,行不行这个问题往往是存在于一些比较难的或者大的问题中。比如考察项目的可行性,等等。这当然是事实,但是在实际工作中,在处理一些小问题时(或者相对较小的问题),也会需要程序员带着这种意识,去解决问题,从而避免自己在第一时间犯错误。这时行不行这个步骤的作用可以理解为,这个问题是否值得或者是否应该去解决。下面举几个例子说明一下。
 
第一次例子是在一个对日外包工作做得一个通信程序的项目。预计的工作量是三个程序员两个月完成,应该说是一个不起眼的小项目。但是日方在工作环境上提出了苛刻的要求,这些要求导致通信程序的调试将无法从服务器得到任何可用信息。另一个问题是中方项目经理对通信程序的开发经验不足,没有要求日方提供与服务器端通信的协议文档。在这样的前提下,两次会议先后经过三个项目经理,一个部门经理,一个技术总监的参与下,还是认为项目是可以完成的。最后实际耗费的开发时间是七个月,日方限制的条件全部作废,并且提供了通信协议文档,这才算勉强完成。这应该是一个教训,我觉得其最大原因可能是对日外包公司有一种奴性的文化,对员工提出的正确意见完全不予采纳,盲目强调下级对上级的服从,同时还唯日方要求为正确。
 
第二个例子也是一个外包的活。客户提出的要求是改进一下现有的程序,通过进一步了解需求后发现两个情况:
1.  修改的原因是性能问题
2.  客户很快就会实施新的系统
由于改善性能的前提是需要明确性能的瓶颈在哪里,根据对实际的工作环境的了解,发现这个工作做起来会比较困难。其次性能是否有足够的提高没有把握。所以这项目最后评估下来是不做了。
 
第三个例子是当初确定Entity Model Studio是否支持图形化的UML建模功能。这个判断相对上面两个例子就复杂一些了。正方的理由是:
1.  Entity Model Studio从最初开始的定位就需要支持
2.  通过市场调研为了确立优势,也需要支持
3.  我们有自己的一些理解和特色的功能需要图形化的设计界面作为支持
而反方的理由基本上只有一个,那就是WideUnion的开发团队是否可以胜任这个开发任务。相对上面两个例子,是否支持这个决定事实上不是通过一次,两次沟通就完成的。而是在此后的一个相当长的时间段中才逐步完成的。最终还是认为需要支持,当然事实上我们也做到了。最终确定支持的理由来自同类产品的比较,Entity Model Studio产品的远景和定位,以及开发技能的积累和研发难点的突破。
 
上面的三个例子所涉及的问题都不是很大的问题,但是复杂程度有差异,做出判断的理由不同,最终的结果也各有不同。所以提醒开发者在类似场合下还是需要认真对待这个步骤的。
 
2.好不好
所谓好不好是指最终选择的用来解决问题的方法是否为最优的或者是否为足够好。作为一个独立的用于帮助程序员判断自己选择的方法是否足够好的提问,这是一个很有挑战的步骤,对工程师的能力是一个综合的考验,当然也是一个富有乐趣的工作。在实际工作中,工程师在第一判断下所选择的方法往往就是应该满足工作要求的,从这个角度来说就是足够好的。之所以出现这个情况是由于上面介绍的大小环境决定的。那么在这样的前提下,还有必要再独立的用好不好这样的提问步骤来帮助我们确认所选择的方法是否足够好呢?
 
回答当然是肯定的,有必要。我们从上文提到的问题解决步骤开始解答这个提问。作为解决问题的第一步,需要考察面临的问题是否可以解决。作为最直接也是最简单的回答就是给出一个可以解决该问题的方法。这样我们在解决这个问题的过程中就得到了第一个解。但是这个解往往是不能用于实际工作中的,因为这个解只是为了确认问题可解决,而在解的质量上可能会存在缺陷或者在可行性上存在难度,从而使得这个解在用于实际工作中时,往往不能满足工程上的要求。于是我们在解决问题的下一个步骤中需要在第一个解的基础上做优化或者再找出一个更好的解。这样会得到第二个解,基于第二个解,我们才真正开始着手解决问题。但是在这个解决过程中,我们会发现当前对问题的认识有可能是不全面或者错误的,也有可能若干细节我们还没有考虑到。这个时候需要对第二个解做修改,改进或者重新寻找第三个解。所以从这个步骤来说,我们在解决同一个问题的过程中很有可能会得到两个,三个解或者若干个解。因此面临好不好这个问题是经常性的。
 
这里再用上面提到的魔法门点绳索的例子来说明一下如何查找不同的解。在寻找新的解时,首先需要明确一点,我为什么需要去寻找新的解,或者说寻找新的解的理由是什么。这个步骤对开发者能力是有要求的,需要对具体的问题,环境和要求综合考虑。就本例来说寻找不同解的原因主要是两个。第一个是原来的解用到了状态图,这部分内容来自编译原理的自动机。显然没有学到这部分知识的程序员要用这个解是有困难的,也就是说这个解对使用的人是有要求的。第二个原因是来自另一个游戏中的场景。在暗黑2中为了解救凯恩首先需要在石头矿野中按照指定的次序点击五根石柱,从而激活一个传送门来解救凯恩。这里点击五根石柱和点击五根绳索两个场景几乎一模一样,唯一的区别是魔法门中点击的次序玩家是不知道的,而暗黑中点击的次序是知道的。这个差异是让我尝试找出不同解的第二个原因。
 
下面就直接给出新的解法。将绳索看作是按钮,开始时只允许第一个按钮可以点击,其他按钮为disable,当然这个disable不是不让点,而是说点了没有效果。玩家点击第一个绳索时,就enable下一个绳索,这个enable是指点击了有效果。然后玩家点击下一个绳索时,再enable下下一个绳索。直至所有绳索被点击后触发剧情。这个解对书本知识的要求可能几乎没有。
 
还可以给出第三个解法。这个解法需要我们再定义一个新的数组(或者链表),这个数组用于保存玩家过去五次点击鼠标的数据。但是这个数组需要用一个与众不同的数来初始化,这里可以选用-1,而绳子的编号是0,1,2,3,4,注意这点。当玩家每次点击鼠标时,将玩家点击的绳子的序号插入到最后,然后数组中的原来数据依次往前移一个位置,这样原来第一个元素就从数组中移出去了,不在数组中了。实际上这就是一个队列的基本操作,注意这里使用到了数据结构的知识。然后再将这个数组中的数据和data数组中的数据比较,如果一致就通过,否则继续等待玩家的输入。关于寻找不同解的其他例子可以参考我的其它博文,比如:
http://blog.csdn.net/binarytreeex/article/details/1595936
http://blog.csdn.net/binarytreeex/article/details/1585550
 
再简单说明一下,上面的例子在推广到一般情况下的应用。寻找不同的解首先需要一个理由,开发者需要根据自己的实际情况去发现这样的理由。一般来说这个理由往往是某一个存在的缺陷或者瑕疵。在开始寻找不同的解时,需要明确主要矛盾是什么,或者说新的解的目的是什么。如果把,团队成员的技能水平,成员人数,时间,问题难度,开发工具,可利用的资料,完成的质量数量等等统称为外部条件,那么当外部条件发生变化时,寻找不同的解可以帮助我们任然可以完成原来的目标和任务。需要强调一下,在我的观念中,根据不同条件,要求和场合找出不同的解来更好的达到目的,是一个工程师应该具备的能力,而且是一个很重要的能力。
 
下面说一下如何来判断一个解是否更好。如果面临的是一个算法问题,我的建议是时间复杂度放在优先考虑的地位,可以为了性能牺牲空间,只要不是巨大的牺牲空间都是可以接受的。如果是非算法问题,那么考虑的因素就会多一些,情况会有点复杂。我的建议是首先考虑选的方法是否可以满足功能的要求,其次是考虑稳定性和可靠性。选择的方法可以在各种条件或者情况下相对更可靠稳定的工作。然后是选择的方法是否够简单,够易于理解。应该说前面两个是硬指标,是必须满足的,而排在第三个标准就是简单和易于理解,这说明我对这点非常重视。其原因是简单和易于理解的方法有以下几个优点:
1.  可以得到的更好的质量和可靠性。
2.  便于实现和修改
3.  更少的调试和测试的工作量
4.  对编码人员的要求可以相对低一些
 
当然还有其他的一些可供参考的标准,同行们可以根据自己面对的实际问题和场景自己来确定。
 
3.对不对
对不对的意思是指,我们做的对么?这个步骤可能最有可能或者最高频被忽略掉的。对于一个有经验的程序员来说,一般不会去选择一个对错不确定的方法来解决问题。或者更恰当的表述是,有经验的程序员都是在认为自己正确的前提下才会去做出选择。而问题就恰好出在这里,自己认为正确并不表示事实上就是对的。这里需要明确一下,如果一个程序员需要一个独立的步骤来确认自己的选择是否正确,那么这个程序员面临的问题是比较难的或者比较复杂的,否则是没有必要这么做的。
 
最理想的确认自己的选择是否正确,是象数学证明那样得到证明,当然这并不容易做的。因为我们面临的问题往往不是一个可以用数学来描述的命题。其次是寻找足够的充分的理由来说明正确性,这时经验的作用至关重要。我曾在实际工作中发现这么一个情况。一个开发者按照我的要求去实现一个功能。等我去查看进度的时候发现解决的过程基本顺利,编码也完成,但是问题出在解的正确性的保证上。这个开发者对正确性的保证是用调试和测试来完成的。由于使用到了一个自己设计的算法,所以只靠调试和测试来确保正确是不对的,象这类问题的正确性的保证首先是算法正确性需要得到证明。调试和测试只是例证,不是推演证明,只能作为辅助手段。这个步骤有可能被很多开发者忽略,或者没有意识到。就这个例子来说,经过沟通后发现所采用的算法的正确性的证明难度太大,所以重新设计了一个易于理解的算法来完成功能。如果不这么做的话,完成的软件就有可能会表现为不稳定,时不时出错,导致用户觉得质量太差。
 
作为一个解决问题的思维和沟通上的方法,对自己提问对不对也是很有用处的。在一次和其他部门的同事配合解决问题时就是如此。那次的背景情况如下:我的同事是做PHP的,有经验但是不太强;我是做C的,做过通信,但是我刚去什么情况都不了解。C写的通信程序拼接字符串,然后通过socket直接向PHP服务器的端口发送字符串,以模拟http通信。现在问题来,测试程序发现数据没有保存到数据库中。PHP一侧有日志,从日志看数据没有发送过去,而C这边也有日志,日志表明数据发送了。于是双方各持一词,都怀疑对方的程序问题。那么谁出问题了?这次遇到的问题的关键是我刚去那个公司上班,接手时也没有交接,直接通过读程序了解相关的事情。所以无论从哪一个角度讲,我都不是很清楚,再说我对PHP一窍不通,从而给沟通和解决问题带了难度。这个时候就需要冷静的问一下自己,我怀疑对方的程序有问题,那么我有证据么?或者说我的判断对么?于是和相关的PHP程序员沟通了一下,要求在服务器端的PHP代码中独立加入写文件的代码,并且这部分代码是不受如何业务逻辑干扰的。测试后发现日志文件可以生成,从而证明数据发送到了服务器端。然后,还是使用这个方法在PHP代码的不同位置,加入写日志文件的代码,最终找出了问题代码的位置。
 
这次就写到这里,下一篇还是继续讨论如何解决问题这个话题。下一次讨论的是在遇到一个自己不知道如何解决的问题时应该采取什么样的方法和思路最终解决该问题。下一篇的题目是问题解决篇(下)。如何解决问题这个话题原本只打算写一篇的,现在最终会写成三篇,应该说这在实际工作中是一个很有份量也很有实际价值的话题。我相信靠博文分享是不够的,如果有兴趣的可以加我的QQ群,群号是:244054966,这个群创业的人多一点;231233168,这个群新手多一点。希望能在群中和同行们互相分享共同提高。加入时请说明:CSDN博文

posted @ 2013-03-01 11:16  WideUnion  阅读(3485)  评论(4编辑  收藏  举报