2.1 摘要卡片
在历史学者们的研究方法中,很重要的一个手段就是做摘要卡片,这是历史学者们的一项基本功。这是因为,历史现象庞杂纷纭,如果不针对某个特定的方向进行“去粗存精”的筛选和整理,不抓住重点,就常常会陷入种种琐碎的细节而不能自拔。不过摘要卡片的制作并非一劳永逸,对于同一历史记载,这一次按这个角度、这个粒度所做的卡片,经过一段时间的研究之后可能又要回过去从另一角度、另一粒度再做一个。这,就是历史学者们的调查研究,而结论只能产生于调查研究之后。
当然,时至今日,很少有人会再去抄写那种纸质的卡片了,计算机使摘要卡片的制作更高效、更方便,也更容易检索,但是作为一种研究方法,原理上仍一样,这是值得我们借鉴的。
不过比之历史学者们的研究,我们有个最大的优势,就是一般而言我们可以实验,而他们一般都无法实验。
所以,我们对于程序源代码的研究主要有两种比较有效的手段:一是实验;二是采用摘要的方法浓缩代码以利阅读理解。但是对于像Hadoop这样在大规模集群上运行的大型软件,其实想要实验也不容易。即便是在单机上运行的软件,有些实验也并非轻而易举,这也是为什么测试用例的设计并非小事的原因之一。但是,相比之下,采用摘要结合阅读源代码的研究方法,却是随时随地都可进行的。
其实广义的摘要方法人们早就在用,伪代码就是一种摘要。但是伪代码式的摘要有个缺点,就是常常跟源代码对不上号。因为伪代码和源代码是对同一算法的两种表述,这两种表述有可能相当吻合,也有可能相去甚远。所以还不如直接从源代码中抽取一些在某个角度上具有实质性意义的语句,这样既相当于一种比较真实的伪代码描述,又容易跟源代码对上号,便于互相参照引证。作者平时就是采用这样的方法的,本书的写作也采用了这样的方法。不过从源代码中抽取语句也不能完全原封不动。首先程序中为保持层次清晰而加的indentation,即逐层后缩,就不宜照搬,因为一般每层右缩一个Tab,那就是8个字符的位置,几个if或for嵌套下来,一个语句在本行中就排不下了。另外,结合下述的情景分析,这种摘要的一个很大的好处就是可以就地展开函数调用。比方说,函数A()里面调用了B(),但是B()里面又做了些什么呢?如果我们只能直接引用源代码,那么A()和B()就得分列,但是实际上CPU在执行的时候却是顺序的,先从A()里面进入B(),做了些什么以后又返回到A(),所以此前和此后的程序执行环境就可能有了变化。把A()和B()分列,一方面对我们理解会有些妨碍,一方面也给叙述带来困难。所以,就地展开函数调用常常是有好处的,但是当然也不能一概而论。然而既然要就地展开,一方面逐层后缩的问题就更突出了,另一方面还有个问题,就是如何区分因函数调用而形成的层次和因if、for等语句所形成的层次,还有因结构成分引起的层次关系,为此最好能用不同的符号表示。
下面用一个实例来说明作者所用的表示方法,这是为Hadoop源码中的一个示例程序WordCount所做的摘要,侧重于它的Mapper:
class WordCount{} ]class TokenizerMapper extendsMapper<Object, Text, Text, IntWritable>{} ]]Text word //WordCount包含TokenizerMapper,后者又包含word,所以是双重的包含 ]]map(Obj ect key, Text value, Context context) > StringTokenizer itr=new StringTokenizer(value.toString()) > while (itr.hasMoreTokens()){ >+ word.set(itr.nextToken()) >+ context.write(word, one) > } ]class IntSumReducer extends Reducer<Text, IntWritable, Text, IntWritable>{} ]]reduce(Text key, Iterable<IntWritable> values, Context context) ]main(String[]args) > Configuration conf=new Configuration() > … //既然是摘要,自然就会有省略,所以这个省略号可有可无 Job j ob=new Job(conf, "word count") > job.setMapperClass(TokenizerMapper.class) //想知道这是怎么回事,所以就地展开 ==Job.setMapperClass(Class<? extends Mapper> cls) >> ensureState(JobState.DEFINE)//job的状态必须还在DEFINE阶段,否则发起异常 >> conf.setClass(MAP_CLASS_ATTR, cls, Mapper.class)
==Configuration.setClass(String name, Class<? > theClass, Class<? > xface) //把cls即TokenizerMapper.class写入配置块中以MAP_CLASS_ATTR为键的配置项 > job.setReducerClass(IntSumReducer.class) > System.exit(job.waitForCompletion(true)?0 :1)
这是对于WordCount这个类的定义,我用一对花括号表示类,或界面(interface),或枚举(Enum),就像用圆括号表示函数(或方法,在本书中函数与方法是同义词,可以互换使用)、用方括号表示数组(Array)。下面会讲到,类就相当于数据结构加函数,再加对于内嵌类的定义。所以类的内部可以有数据成分、有函数定义、有对别的类的定义,我用右方括号来表示这种包含关系,因为这是键盘上最接近于“包含”这个语义的符号。包含的关系可以嵌套。同时,我用“>”表示调用或执行的关系,也以此表示因调用或执行而形成的层次关系。这样,通常一个Tab制表位或至少两个空格的右缩就减少成一个“>”。此外,我还用一个加号表示因if、for、while等程序结构所形成的层次关系,读者可以看到这里while循环中的语句前面都多了一个加号。其实使用加号并不理想,因为在遇上例如“++n”这样的语句时容易让人看花了眼,但是键盘上也没有明显比这更合适的符号。
WordCount这个类没有数据成分,或者这里被省略了,但是有对于TokenizerMapper和IntSumReducer两个内嵌类的定义,还有个main()函数。作为一个类,一般应该有一个构造方法,也叫WordCount(),但是在摘要中往往被省略,因为构造方法的代码通常都很简单。但是WordCount这个类的源码中倒确实没有构造方法,这应该是因为它没有数据成分,无须构造。
但是TokenizerMapper内部确实有个数据成分word,其类型为Text,还有个map()函数。TokenizerMapper也没有提供构造方法,应该是因为它的数据成分word无须初始化。这个类是对Mapper类的扩充,而Mapper类的定义是一种“模板(Template)”定义,又称“泛型定义”,对此缺乏了解的读者可以先看一点Java语言编程方面的参考书。
现在,假定我们想知道main()函数中对job.setMapperClass()的调用是怎么回事,首先我们得知道对象job的类型是Job,所以这是对Job.setMapperClass()的调用,于是我用“==”表示这样的等价关系。另外,比较好的做法是把定义于这个函数的形式参数表抄列在这里,让实参和形参形成对照。但是说实话我也常常偷懒,因为有时候这种对应其实很明显。然后,这里就把这个函数就地展开了,我们可以看到这个函数中的代码摘要。如果需要,我们还可进一步展开Configuration.setClass()。
至于摘要中的黑体加粗部分,则纯粹就是为了引起注意,没有别的含义。
摘要是很个人、随机性很大的工作,同一个人对同一段代码所做的两次摘要,哪怕是为了同一个目的、从同一角度,也可能会有所不同,就像历史学者所做的摘要卡片一样。因为摘要是给人看(而不是让机器处理)的,而且主要是给自己看的,只要自己认为合适就好。当然也要尽量接近程序的原意。
我们在做摘要时原则上要尽量保持语句的原貌,但是有时候也不得不对代码进行某种等价变换。这一般发生在几种情况下。一种是函数调用的嵌套,例如上面摘要中的语句:
System.exit(job.waitForCompletion(true)?0 :1)
这是根据job.waitForCompletion(true)的返回值确定以常数0或1作为System.exit()的参数。如果我们想展开job.waitForCompletion(),就地放在这个语句下面就不合适了,因为日后可能一下子搞不清这究竟是对job.waitForCompletion()还是对System.exit()的展开。在这样的情况下,我们就得将其变换成例如下面这样:
w=job.waitForCompletion(true) System.exit(w?0 :1)
这样,就可以就地展开了。这里的中间变量名w是任意的,当然如果能用例如success之类有点意义的就更好。
还有一种情况是函数调用出现在if、while等语句的条件部分,就如上面map()函数中的while (itr.hasMoreTokens())语句。碰到这样的情况,如果真的想要就地展开,就得把代码处理成例如这样:
> h=itr.hasMoreTokens() > while (h){ >+ … >+ h=itr.hasMoreTokens() > }
或者,就不要就地展开了,改成单独为此做一代码摘要加以展开,例如:
[WordCount.TokenizerMapper.map()> hasMoreTokens()] StringTokenizer.hasMoreTokens() > …
这里,前面方括号里面是调用路径,说明这是从map()函数中调用过来的。调用路径中的“>”表示直接调用。有时候我们也需要说明间接或辗转的调用关系,或者是“引起、导致”的关系而不是调用的关系,那就用“=>”。
下面则是对hasMoreTokens()这个函数的代码摘要,里面对于函数调用还可以再就地展开。
还有一种比较特殊的情况,那是对某些抽象类的动态扩充或对界面(interface)的动态实现,这其实并不是我们做摘要时的就地展开,但是形态上有点相似并对摘要的制作有点影响。我们举个实例。先看原始的代码(与当前话题无关的内容已经省略):
class StreamPumper { … StreamPumper(final Log log, final String logPrefix, final InputStream stream, final StreamType type){ … thread=new Thread(new Runnable(){ @Override public void run(){ try{
pump(); }catch(Throwable t){ ShellCommandFencer.LOG.warn( logPrefix+":Unable to pump output from"+type, t); } } }, logPrefix+":StreamPumper for"+type); thread.setDaemon(true); } … }
这里,在StreamPumper类的构造方法StreamPumper()中,要创建一个线程来执行一个Runnable对象,但是Java中的Runnable是个界面(interface),对于界面是无法创建对象的,必须要定义一个实现这个界面的类,才可以创建那个类的对象。所以一般的做法是老老实实定义一个类来实现这个界面,例如“class StreamPumperImpl implements Runnable{}”,这叫静态定义。但是有时候觉得这太麻烦了,就在程序中动态定义一个无名类,并立即加以创建。所谓动态定义,就是要补上这个界面要求提供的方法,或者用自己的方法替换继承来的方法。所以这里就提供了一个run()函数,里面调用pump()。
为这么一段代码做的摘要,可以是这样:
class StreamPumper {} ]StreamPumper(Log log, String logPrefix, InputStream stream, StreamType type) > hread=new Thread(new Runnable(){} ]run() > pump() > thread.setDaemon(true)
我们原则上把run()函数放在“new Runnable()”的下方,表示这是所动态定义的那个实现了Runnable界面的无名类的run()函数。不过在这样的情况下我们一般就不对pump()进行就地展开了。
顺便提一下,在构造函数StreamPumper()的参数表中,我们把final一类的关键字省略了,同样我们一般也省略public、private一类的关键字,因为那些属性无关程序的逻辑和流程。更重要的是,除非实有必要,我们也会省略try{}catch()的程序结构,因为对于异常的处理一般不属于程序的“主旋律”。另外,一些对于边界条件的检验(常常称为sanity check),例如“if(n==0)return”之类,也往往与程序的主旋律无关。还有一些善后操作,例如“close(file)”等,对于我们理解程序的流程和原理没有多大关系,所以也常被省略。
本书很少直接引用原始代码,而大多采用摘要。这样一来紧凑,二来可以就地展开。还有一个好处,就是可以降低对于版本升级的敏感度,因为版本升级反映在具体的函数上有时候只是某些细部的修改(特别是对于Bug的修理)而无关宏观的流程,不一定在摘要里面表现出来。