Month: June 2012

正则学习问答

最近有幸在开源中国51CTO两家网站作为嘉宾参与了于正则表达式的专题问答。在问答过程中,我收集到学习正则表达式过程中的某些普遍问题,在这里专门花一点篇幅来回答

正则表达式是难学的,这不存在疑义。但是我认为,难点也只在语法方面。正则表达式已经有年头了,它(的语法)诞生于上世纪七十年代。那是个怎样的情景?举个简单的例子吧,Unix下的usrdev等名字,就是那时留传下来的,现在已经有很多人诟病了,usr不是user,dev不是device,难学,也难记。经过这些年的飞速发展,当年的很多问题已经被包装得美轮美奂,如今的用户可能更习惯直接点击“用户目录”、“驱动器”之类的图标,再也不用为那些不规则的简短名字发愁。但是不幸的是,一直以来正则表达式的语法却没有太多的变化,甚至后续增加的功能,也沿袭了之前的语法风格,在编程语言日渐人性化的今天,它自然显得非常难懂了。今天的开发人员可能更习惯Regex.CharRange(‘a’, ‘z’)这样的写法,而不习惯[a-z];遇到(?![a-z])这样的结构就更是抓瞎,除非转为Regex.CheckRight(Regex.CharRange(‘a’, ‘z’))的写法。

不过,换一个角度来看,两者其实是一回事,只是表现形式不同,一个类似要诀,一个类似大白话。如果我们能在头脑里构建出从要诀到大白话的转换,正则表达式就简单了许多,甚至可以说就是模块的拼接。比如支付宝的流水号为18或26位数字,用正则表达式匹配,就是^([0-9]{18}|[0-9]{26})$,或者^[0-9]{18}([0-9]{8})?$。其中的逻辑很简单:^用来锁定开头,$用来锁定结尾,[0-9]匹配数字字符,([0-9]{18}|[0-9]{26})表示两个并列的选项,即数字字符串长度为18位或26位,而[0-9]{18}([0-9]{8})?表示至少需要出现18位的数字字符串,在这之后可能还有一个8位的数字字符串(所以总长度是26位)。一般的正则表达式应用,就是这么简单。

如果你觉得上面说的没错,那么学习正则表达式的难题就只剩下了选择得当的方法。我们学习编程语言时,都强调不能只看书,要动手写程序,甚至最好的办法是把书上的例子亲自输入运行一遍,这样才算真正学会了。但在许多人眼里,正则表达式或许算不上编程语言,所以学习是点到即止,甚至是满足于从网络上抄一些现成的表达式。所以,常见的问题之一是“有没有什么学习的捷径”,很不幸,答案是没有——既然拷贝他人的代码不能学会编程,抄阅现成的表达式、随便翻几篇文档,当然也学不会正则。不过也有幸运的消息,真正学会正则表达式并不需要花太长的时间。

以我的经验,学习正则表达式,真正要做的是深入理解常用功能:字符组、多选分支、匹配模式、环视。可以说,弄明白了这几点,80%的正则问题都可以解决。但是要弄明白这几点,就需要专门的学习:字符组是解决什么问题的,它是怎么使用的?多选分支是解决什么问题的,它是怎么使用的?应当抽一些时间专门学习、思考;这些都弄明白了,再研究解决复杂问题的表达式是怎么构成的。如果你可以每天抽1-2小时专门学习,两周内就会有明显收效,一个月几乎就可以修炼到相当水平。而且,以我的经验,在学习新的编程语言时,不但要把书上的例子都亲自输入运行一遍,更要自己动手去改一改示例代码,看看会出现什么现象,再想想为什么会这样。如果你在学习正则表达式时也做到这一点,必然能够事半功倍。

如果你真正理解了这些常用功能,对它们的价值和使用有清晰的概念,那么另一个麻烦也就迎刃而解了——不同语言的正则表达式不同,如何解决?虽然不同语言中的正则表达式规定各有不同,但背后的思想是统一的,不同的只是表现形式,或者说概念的落地方式。好处在于,编程语言的文档不会详细讲解什么是字符组,什么是多选分支,但会详细告诉你字符组在本语言中是如何表示的,多选分支又是如何表示的(不信你可以在这些文档中搜索character class或者alternation)。所以如果你的脑子足够清楚,即便不确定最终的表达式如何写,也只需要查文档就可以解决。举个例子,匹配空白字符的字符组\s,在Java字符串中要写作\\s,因为\s并不是Java字符串中的一个合法转义序列,所以之前还必须有\来转义\;在PHP中可以直接写作\s,因为PHP处理字符串时会把无法识别的转义序列原封不动地保存下去;在Unix下的某些工具中,必须写作[[:space:]],这是Perl风格的\s在POSIX规范中的表示法。看起来比较麻烦,也仅此而已,因为我们知道,这里需要用到的,就是“匹配空白字符的字符组”。

以上写了这么多,可能有人会说:正则表达式这东西,不登大雅之堂,没必要花那么多精力。或许正是这种观点,形成了“不认真学习正则表达式”思想根源。幸运的是,这个问题其实很好想明白,因为很多事情都是这个道理。比如写文章,我们不要求人人都是作家,但是人人都有可能在需要的时候写出几篇拿得出手的正经文章,“不是作家”并不是“需要时写不出正经文章”的理由。为了能在需要的时候写出正经文章,就必须专门抽出时间来学习和练习写作。正则表达式的学习,其实也是这个道理。

这种说法可以说服一些人,但还有一些人是说服不了的。同时据我观察,那些不能被说服的人,似乎也没有花太多精力在其它“正事”上,反而会不时为正则表达式所困扰。与此相反的是,真正有职业素质的程序员,就像the Productive Programmer中说的那样,会愿意花2小时写出一个正则表达式,为以后节省无穷无尽的时间。当然,以上说的这一切的前提,都是能端正学习正则表达式,或者说学习有价值技能的的态度。做软件的人大都读过布鲁克斯的名文《没有银弹》,所以这里不妨借用他的话说,正则表达式的学习,也不存在银弹。

浅谈编码

《学到不会忘》中我提到,为了写《正则指引》,专门抽了些时间学习Unicode,也因此明白了很多与编码有关的问题,只是最后没有全部写进《正则指引》中,以免离题。不过,这并不妨碍专门用一篇文章来讲解编码问题。

其实所谓编码问题,不外乎若干概念,弄明白了这些概念,编码问题就可以迎刃而解了,所以这里按照概念来展开讲解。

字符和字符集

字符,就是我们日常使用的各种文字,比如中文的,英文的ABC,日文的,都是字符。手写可以用到的字符几乎是无限的,但在计算机中,必须事先约定好字符的范围,也就是穷举出所有“可以使用”的字符。这个范围,就是通常说的“字符集”(Character Set)。

ISO8859-1是开发中常见的字符集(MySQL默认就采用这种字符集),它支持的语言有英语、德语、法语等,也即包含了英语、德语、法语中的字符。GBK是另一种常见的字符集,它源自GB2312字符集,GB表示“国标”,GB2312即是国家标准,它的另一个名字是CP936(Code Page 936),以前在Linux下播放MP3,如果发现ID3标签乱码,设定为CP936就可以解决。因为制定较早,GB2312只包含6763个汉字,并不足够覆盖日常的使用,所以诞生了GBK,其中的K表示“扩展”。有意思的是,GBK是微软制定的字符集,而不是“国标”,只是曾由国家技术监督局标准化司、电子工业部科技与质量监督司公布为“技术规范指导性文件”。除此以外,港台地区以前使用Big5字符集,曾经在Dos下玩过港台游戏的朋友应该还记得“大五码”这个名字。

与字符相关的另一个概念是字形(glyph),它是字符显示出来的样子,同一个字符可以有好几种写法,每种写法其实对应一种字形。下面举例列出了“高”字的几种字形(资料来自Wiki)。

编码与码值

在计算机内部,所有的数据都是编码保存的,字符也不例外。因此,每一种字符集不但约定了可以使用的文字的范围,而且为每一个字符确定了唯一的代码,称为码值(Code Point,也叫“代码点”)。

在ISO8859-1字符集中,A的码值是41(十六进制),=的码值是3d(十六进制);在GBK字符集中,的码值是b7 a1(十六进制),的码制是b7 a2(十六进制)。因为单个字节只能表示最多256个字符,而中文字符超过256个,所以GBK编码选用2个字节表示单个字符,相应的,其码值也是4位十六进制数值。

我们说的“GBK编码”、“ISO8859-1编码”,其实既指其对应的字符集,又指其对应的码值规定。通常,两者是一体的,但是在Unicode编码中的情况,却不是这样。

Unicode

随着计算机和互联网的发展,各自为战的字符集很快就遇到了问题:如果我需要在一篇文章中同时使用中文和日文字符,该怎么办呢?设定为日文编码(常用的为Shift-JIS、EUC-JP)则不能涵盖中文字符,设定为中文编码则必须放弃日文字符,所以需要一种统一的、可以覆盖各种语言的字符集,于是Unicode字符集应运而生了。

Unicode的最初想法是用2个字节(16位,65536个码值)来表示世界上所有的语言,所以它的字符集称为UCS-2(2 byte Universal Character Set)。用2个字节表示一个字符,就会带来字节序问题:在传输和存储时,到底是先传输高位字节(big endian),还是低位字节(little endian)呢?这个问题Unicode也没有确切的答案,所以设定了BOM(byte order mark,字节序标识)来解决。BOM对应的码值是fe ff,无论fe ff,还是ff fe,在Unicode中都没有实际的意义,所以不会造成干扰。在读取使用了BOM的文件时,先读取头两个字节,如果是ff fe,就是little endian,如果是fe ff,就是big endian;如果文件的开头两个字节既不是fe ff,也不是ff fe,默认采用big endian。如果你用Windows的记事本创建Unicode编码的文件,文件头就会包含little endian的BOM,其它一些文本编辑工具则不会。如果用程序解析包含BOM的XML文件,可能遇到非法字符的错误,必须先截去开头的BOM信息。

最早制定Unicode规范时,大家乐观认为觉得65536个字符就可以覆盖地球上所有语言中的字符了,这种今天看来草率的乐观,导致了不少后果。

第一,因为东亚文字(主要是中、日、韩三种语言的文字)字符非常多,为了节省码值,就将三种语言中字形类似的字符映射到同一码值,这种做法称为UniHan(统一汉字,在Unicode规范中,也称为“东亚文字(East Asian)”),比如中文(包括大陆、香港、台湾)和韩文及日文的“骨”、“直”等字,虽然写出来的字形(glyph)有微小差别,但码值是相同的。这样的好处是节省了码值,而且某些跨语言搜索可以直接进行,比如搜索日文关于“直角”的资料,直接输入“直角”即可。这样的坏处是,不能依靠码值判断到底属于中日韩语言中的哪一种(三种语言中的常用字符大都属于CJK_Unified_Ideography这个书写系统),而且,对于码值相同但字形不同的字符,到底选择哪个字形来显示,还应当参考locale设定(使用过Linux的人大都会记得zh-CN.UTF-8这样的locale设定,它可以影响到”直“、”骨“之类的字形选择)。

网络上有不少资料说,匹配中文字符的正则表达式是[\x4e00-\x9fa5],也有资料说是[\x4e00-\x9fff]。从原理上看,它们都是用字符组表示某个范围,起始码值都是4e 00,结束码值却有不同,这是为什么呢?仔细阅读UniHan规范可知,其实它们的原理都是使用CJK_Unified_Ideography书写系统(Script,这是一种Unicode属性,下面会详细讲到)中的文字,在1992年提交给IRG(International Rapporteur Group)的字符只排到9f a5。在这之后,制定更新版本的Unicode规范时都进行了扩展,新增了字符。不过从根本上说,4e00-9fff是预留给东亚文字的码值范围,所以使用[\x4e00-\x9fff]是更好的选择。具体信息可以参考 UniHan规范

如果要“完整地”匹配所有的中文(东亚文字),还必须考虑Unicode各版本中增补的CJK统一表意字符,从CJK Unified Ideographs Extension ACJK Unified Ideographs Extension B一直到最新的CJK Unified Ideographs Extension E,具体细节可以参考Wiki上的说明

第二,用2个字节表示单个字符并不合适。对ASCII字符来说,用单个字节就可以表示,2个字节造成了大量的浪费;另一方面,65536个字符并不够表示世界上的所有字符,所以Unicode规范进行了扩编,截止本文写作时止,最新的Unicode 6.1.0规范包含110116个字符,所需的字节当然超过2个(16位)。针对这种问题,Unicode字符提供了不同的字符编码方式(Character Encoding Scheme),可以这么理解:字符的码值是一回事,在存储和传输时,具体落实为几个字节,如何表示,又是另一回事,码值的具体表示形式,就由字符编码方式来规定。常见的Unicode字符编码方式有:UTF-8,UTF-16等,其中的UTF是UCS Transformation Format的缩写,明确表示它是一种传输格式,所以我们可以说“Unicode字符集”,也可以说“Unicode编码”,还可以说“UTF-8编码”、“UTF-16编码”,但不能说“UTF-8字符集”、“UTF-16字符集”。

UTF-8是一种变长编码,第一个字节的最高位如果是0,则表示这个字符用单个字节表示,否则,从这一位开始向后数,有多少个连续的1,这个字符就用多少个字节表示。于是,英文字符只需要1个字节就可以表示,而中文字符一般需要3个字节来表示。比如字,其码值为53 d1,但UTF-8文字编码方式下表示为e5 8f 91

如果我们拿到一段文本,不知道它到底是GBK编码还是UTF-8编码,就可以依据UTF-8编码的这个特征进行判断。不过我之前试验过一个取巧(但不那么保险)的办法:因为中文里的“的”字出现非常频繁,而当时要判断的文本一般都不短,所以直接查找文本中是否出现了GBK的“的”字或UTF-8的“的”字,也可以判断出来。

UTF-16则是一种定长编码,每个字符都采用2个字节,16位来表示。的UTF-16编码方式下表示为53 d1。相比UTF-8,它的字符长度固定,本来是一种好处。但是因为Unicode字符集已经超过了65536个字符,所以UTF-16已经没有什么优势了,对超过16位的Unicode字符,UTF-16必须补充另外两个字节来表示,多出来的这两个字节称为代理对(Surrogate Pair)。

Java在诞生时就有”先见之明“地选择了UTF-16作为内部文字编码方式,每个字符在JVM内部都使用16位来表示,所以Java中的char是long类型,也就是16位整数。但是随着Unicode字符集中的字符超过65536个,Java原来的字符串处理API就无能为力了。为弥补这个问题,Java 5.0另外提供了CodePoint相应的方法,比如计算CodePoint个数的codePointCount(),取代之前的length(),以及获取某个CodePoint的codePointAt()方法,取代之前的charAt()方法。另外,在进行跨语言通讯(比如调用Web Service)时,往往必须显式指定输入输出的文字编码方式为UTF-16,否则有可能遭遇乱码。

既然Unicode包含了几乎所有的字符,这些字符的分类管理当然也更复杂。比如,针对某个字符,必须能知道它属于哪种语言;再比如,还需要知道某个字符到底是空白字符,还是标点字符,还是文字字符——ASCII编码中的字符可以分为控制字符、字母字符、标点字符等等,各个分类所包含字符的码值是位于连续区间的,所以直接指定码值范围即可(参加下面的ASCII码表),但是在Unicode中,不同语言的标点字符,其码值必然不是连续的,必须要有办法表示这些分类。要满足这些需求,就必须依靠Unicode属性。

Unicode属性

Unicode不但包含了更多的字符,多种编码方式,还提供了非常有用的功能,即Unicode字符集中的每个字符,都具有好几种属性,它们从不同的方面描述这个字符的某个特征。最常见的属性有:Unicode Property、Unicode Block、Unicode Script,以下分别简要介绍。

Unicode Property的记法类似\p{L}\p{P},按照字符的功能分类Unicode字符,而每个Unicode字符只能属于一个Unicode Property。 不妨这么理解Unicode Property:它并不按照字符所属的语言来划分Unicode字符,而是按照字符的功能来划分,比如\p{Z}表示任意的空白字符或不可见的分隔符;\p{P}表示任何标点字符,等等。遇到中英文混排、全角半角同时出现的情况,我们就可以用\p{Z}匹配所有的空白字符(不关心到底是全角空格还是半角空格),用\p{P}匹配所有的标点字符(而不用关心逗号到底是中文逗号还是英文逗号),不用费心细节。

Unicode Block则不同于Unicode Property,它按照编码区间划分Unicode字符,每个Unicode Block中的字符编码都是落在同一个连续区间的。因为Unicode编码表中,某种语言的字符通常是落在同一区间的,所以它也可以粗略表示某类语言的字符,比如\p{InHebrew}表示希伯莱语字符,\p{InCJK_Unified_Ideographs}表示兼容CJK(中文、日文、韩文)统一表意字符。如果你细心观察,会发现Unicod Block的名字虽然类似某种语言的名字,但都有“In”(Java风格)或者“Is”(.NET风格)前缀,这表明它其实对应的还是“落在某个区间的Unicode字符”。

Unicode Script按照字符所属的书写系统来划分Unicode字符,比如\p{Greek}表示希腊语字符,\p{Han}表示汉语(中文字符)。它的写法类似Unicode Block,只是名字的开头没有“Is”或者“In”。

以上三种属性互相独立,之间没有层叠关系,可以用下面这幅图简要说明。

在处理字符串时,如果可以用到这几种属性,就会非常方便。如今流行的语言中,大都可以通过内建的正则表达式来获得这几种属性,并进行相应的处理。但是,语言对Unicode属性的支持并没有硬性的标准,所以造成不同语言的支持程度各有不同。一般地说,支持Unicode Property的语言有.NET、Java、PHP、Ruby(限1.9以上版本);支持Unicode Block的语言有.NET、Java;支持Unicode Script的语言有PHP和Ruby(限1.9以上版本)。具体的使用方法,可以参考Regex Tutorial的专题页面,也可以阅读《正则指引》第7章。

说说我理解的职业开发人员

应人民邮电出版社图灵公司的邀请,我有幸参与了Bob大叔所著Clean Coder(不是Clean Code)的翻译。

与前作Clean Code不同,这本书着重讲述的是开发人员的“职业素养”,也即职业开发人员应当如何做事。在阅读中,我时常会忍俊不禁,也会拍案叫绝,感叹Bob大叔把深刻的道理讲得这样通透。我虽然没有Bob大叔那样好的文笔,不过对“开发人员的职业素养”这个话题,也有很多话想说,索性分几个方面写下来。

学习

开发人员在工作之前,一般都已经经过大学阶段的专业学习。众所周知,大学的很多课程已经相当落后,教材也非常保守,所以我见过的好开发人员,不少都是自学成才。但是,这些问题并不能否认通过专业课程学习知识的意义,职业开发人员理解的“学习”,应当明确地区分知识、课程、教材:知识是重要的、稳定的,课程和教材是不那么重要的、变化的。

可以非常肯定地说,数据结构、编译原理、操作系统这类知识,是整个计算机世界的基石,是任何时候也不会过时的。即便毕业后不从事专门的科研,这类知识也会从你接触到的各种现象中体现出来。我在大学时基本抛弃了学校指定的课程和教材,但自己反复啃过影印版的《现代操作系统》,反复做过北大屈婉玲老师的三本《离散数学习题集》,后来在工作中受益匪浅——调优程序的性能,很可能需要理解调度、死锁、用户空间与系统空间等知识;重构复杂的布尔逻辑,很可能要依赖数理逻辑中的定律。如果当时没有反复的研习,没有深入理解这背后的原理,并且没有领悟到这些原理和各种现象之间的联系,遇到很多问题我很可能就会两眼抓瞎,充其量凭经验试错,无论如何,其效率远不及知识体系指导下的实践。

据我观察,大学生之所以对课程不感冒,除去学校和教师的原因,另一个因素是,几乎很少有教材能把看起来乏味的原理,和生活中遇到的问题讲清楚。学习图算法时,你是否想过“人、狼、羊、草过河”的问题可以直接由它来解决?学习内存管理时,你是否想过为什么Windows 95、Windows 98都那么容易蓝屏,到了Windows XP才有了长足的进步?我相信,如果能把原理与这类例子对应起来,你的理解就会深刻许多,印象也会深刻许多。不幸的是,这类“打通/联系”的工作,在国内教材基本是一片空白,国外教材也只有部分涉及。其结果就是,不少“有经验”的开发人员面对“32位机为什么只能支持4G内存”、“进程间通讯有哪几种方式,各有什么优劣”、“浮点数是怎么表示的,为什么是不准的”之类基本问题一脸茫然,不要小看了这些问题,不懂它们,你开发出来的程序只能凑合用,因为根子上就欠考虑,所以后期遇到问题要重构和调优,就会难比登天,最终搞到自己疲惫不堪。

对此,我的建议是:如果你现在还在学校,不妨仔细想清楚知识、课程、教材之间的关系,确定重要的知识,选择好的教材,自己安排自己的课程。如果你已经离开学校,而且感觉自己的基础并不牢靠,不妨从手头的工作开始,想想它用到了哪些原理,对应哪些知识,逐步、有针对性地补习。这其实并不难——我的朋友张东亮(@zhasm),之前几乎没有任何计算机基础知识,只是因为对正则表达式的爱好,找到了一份开发人员的工作,一年之后,他已经开始啃编译原理的书籍,而且确实学进去了。

以上说的主要是“专门”的学习,如果是工作之后的学习,会有很大的不同。

首先,工作之后的学习更多依靠自觉,没有几家公司会付出代价让员工像学生那样“学习”,所以更多时候,你只能花自己的时间、自己的金钱来学习。很多人一想到要花自己的时间,自己的金钱,心里就打了退堂鼓。要明确的是,公司没有老师那样强烈的责任培养员工“成长”,如果你找不到好的、薪水高的工作,很难责怪上一家公司没提供好的培训。所以,担心金钱和时间而放弃学习,最终的结果是自己的停滞,逐渐丧失竞争优势。相反,投入时间和金钱来学习,不但可以保持甚至扩大你的竞争优势,如果这种行为可以坚持、内化到生活中,也有助于保持健康、饱满的精神状态。

其次,工作以后的学习,需要努力摆脱工作环境的限制。我见过不少开发人员,因为工作限定在某个平台,某种语言,业余时间的学习便全部投入到这种平台、这种语言上,而没有思考自己是否合适做这些平台和语言,这些平台和语言是否处于没落期。在学校里,考分或许往往是唯一的度量,但在工作中,行将没落的语言和平台,你运用得再熟练,也于事无补。况且,过于专精于一门语言、一个平台,反而会限制你的思维和视野,影响迅速学习陌生知识的能力——要在短时间内熟悉陌生平台和语言的例子,在我们工作中并不少见,在整个IT业界中更是家常便饭。为了让它真的成为“便饭”,平时还是应当有意识地摆脱工作环境的限制,挑战自己的思维惯性。

责任

我曾经见过很多的简历,在“工作经历”里,项目描述写得天花乱坠,如何先进,如何复杂,采用了多少新技术,但是具体到个人责任,或者语焉不详,或者极其潦草。这样简历,体现的是责任感的缺失——对于自身责任没有明确的认知,也就没有足够的担当;这样的人,通常不用面试,就可以知道并不是合格的“职业开发人员”。

另一方面,我在面试时,经常会问两个问题,其中很重要的一个是:在你的工作经历中,收获最大或者印象最深的事件是什么。一般来说,如果能回答得有条理、有依据,大多可以判定为合格的职业开发人员。因为,有责任感的开发人员,大多不会把程序看成身外之物,更多地会把程序与自己的道德、声誉等等联系起来,甚至把程序看成自己的孩子;所以,必然会投入时间精力去总结、反思、完善、改进,就像照顾自己的孩子那样。其实,就我的经验看,真正的职业开发人员,不但能很好地回答这个问题,而且说起自己做过的事情,多有种充沛的自信感:XX项目是我做的,其特点是什么,我是如何如何做的,遇到什么问题,是如何解决的……涉及的技术不必很先进,开发的系统也不必很复杂,只要能够这么自信满满地一条条历数下来,你的职业素养就是无可厚非的。

业务

软件开发中,需求变化是无可避免的。虽然敏捷开发、极限编程宣称要“拥抱变化”,但真正做到拥抱变化,却是难上加难。原因在于:一方面,不少开发人员对变化本身就持怀疑甚至抵触态度;另一方面,许多需求完全是无规则、无理由地变化,不但造成极大的浪费,也严重影响开发人员的情绪。

这个问题非常普遍,也很严重。我思考了很久,发现比较合适的解决办法是进行角色的互换,尤其是开发方(包括开发人员),不能局限于“按照规程实现功能”的角色,而应当深入思考和理解业务。

不少开发人员最“理想”的工作环境就是:根本不关心自己的工作成果给谁用,怎么用,会产生什么结果,他们更喜欢这样的描述:什么类型的数据从哪里来,怎样处理之后,最后交给哪里。在架构清晰、流程完备的大公司里,或许你只需要安心填格子即可,但是拥有这样工作环境的开发人员,占总数的多少呢?更多的人面对的还是变化不定的需求,甚至连业务部门自己都不清楚自己要的是什么,这种情况下,只关心“数据从哪里来,怎样处理,交给哪里”之类的问题,无异于盲人骑瞎马,无异于挖坑埋葬自己。

相反,如果你清楚某个实现方案的缘由,知道它是基于何种应用场景,如何设计出来的,就可以在相当程度上把握它的价值和所需的工作量。如果更主动一些,可以和业务部门谈,这么做,将来会遇到什么问题,如果将来要改,哪些环节是可以改的,哪些环节是不能改的——如果你设身处地地为对方考虑,给出的建议一定比技术味道浓厚的“做不出来”更有说服力。如果做不到这么主动,你也可以预估,哪些业务是稳定不变的,哪些业务是一定会遇到问题需要改变的,然后可以合理分配工作量:对那些明显没什么前途的项目,可以适当保留资源,以免将来竹篮打水;对那些目前业务部门认为不重要,其实又相当有价值的项目,可以适当多投入精力,以免将来措手不及——要知道,业务部门提的“紧急”需求,多半不会考虑开发的工作量。

需要补充的是,做到上面这点,其实有相当的难度:一方面,你的技术功底必须足够扎实,在满足需求时,不仅仅是“模仿”现实,而应当知道这种现实,在数字世界里应当如何表达,如何重构,受到哪些条件和规则的限制(比如同一个抽象操作的不同实现,到底是选择Switch语句还是多态,其实是有章可循的,必须根据实际情况选择);另一方面,又要能跳开技术的局限,从更全面的视角理解、把握业务。不过,这是非常值得花功夫的——从某种意义上可以说,当前热门的“领域驱动开发(Domain Driven Development)”,说的大抵就是这回事。

时间

在软件开发中,时间绝对是一个非常重要的因素。在这方面,已经有无数的巨著,无数的案例,无数的先烈,但是时间,仍然是一个值得讨论的话题。

总的来说,人月是一个神话,我们不可能绝对精确地把握开发时间,但是这并不意味着,我们不能从某种程度上把握时间。我个人的经验是,计划是在现实参照下的不断调整和修正中逐渐准确的。最重要的,并不是确定远大的目标,然后限定多长时间必须完成;而是可以把大的项目拆分为不同的模块,把整个开发流程划分为不同的阶段。如果你的模块划分得足够细致,就可以以每个模块的工作量,相对准确地得知整个项目的耗时;如果你的流程划分得足够合理,就可以在各个阶段拿出看得见、用得着的结果,供业务方使用。这样,一方面避免了“到最后一起推出,却发现与业务方想象大相径庭”的尴尬;另一方面,在开发过程中,每个阶段结束,就可以提供一个阶段的生产力,作为开发方,在面对质疑时,有足够的资本和底气。

从个人方面,我注意到,职业开发人员还有另一个特点:就是可以相当精确地估计某个“小活”的工作量。以我自己和我的一些朋友为例,面对一些细致而且明确的需求,我们经常可以精确估计到工作量,时间精确到以半小时计。在紧密协作的“背靠背”编程中,我会说:现在是几点,所以我会在几点之前,给你提供怎样的功能,其行为是怎样的,接口是怎样的(行为和接口可以事先约定)。这样的自信,既要求对所需技术、会遇到难题的把握,也要求在头脑里对任务有完整清晰的模型。虽然难度不小,但能做到这一点,确实是职业素养的典型体现。

学到不会忘

博文视点的张春雨编辑告诉我,八次印刷的《精通正则表达式》已经全部售罄了, O’Reilly 与电子工业出版社续签了版权合同,准备重新上市,让我写一点东西。

该写什么好呢?

2007 年 《精通》上市时,我还在中关村,天气好的时候可以望见颐和园的佛香阁;而现在,窗外景色已经换成了珠江边的小蛮腰;对正则表达式的使用,也从随手拈来变得生疏——许多问题需要翻查《精通》,翻查自己写的《正则指引》。究其原因,与正则表达式直接相关的开发做得少了,古话说“勤则立,嬉则荒”,就是这个道理。

荒是荒了,毕竟还没荒废,虽然有很多细节需要查阅,但是我很清楚,某个问题能不能用正则表达式解决,该怎样解决。或者说,虽然手上生疏了,心里其实没有忘记,而这一切,归源都是之前死啃过《精通》的缘故。

在阅读《精通》之前,我已经查阅了网上的不少资料,对正则表达式有了基本了解,能像模像样地解决一些实际问题,可算“够用”了。这时候遇见《精通》这样“现实价值不那么大”的书,能静下心去阅读,其实带着点毕业不久的傻气,只是单纯想把它弄懂搞透。所以,遇到匹配原理这类看来没多少实用价值的知识,还会愿意花时间去揣摩、研习。回头想想,也正是因为当时有这种傻气,可算是意外的收获:工作中经常需要学习一些工具和原理,虽然当时也“学会”了,但不久就忘个精光;相比之下,正则表达式却是学到了“不会忘”的程度。更典型的例子是游泳,几乎人人都可以做到“一朝学会,终身不忘”。同样是“学会”,为什么差距这么大呢?

这个问题我想了很久,最后的答案是,“学会”的定义是不同的。

通常我们说“学会”了某项技术、某门语言,意思是“凑合能用”,或者说“可以对照文档( Google )解决问题”的程度——你用 Python 解决了一个问题,就说明你“学会”了Python ,哪管是步步 Google ,还是照抄现成的代码。而我们说“学会”了游泳,意思是可以在水里行动而不沉下去,更重要的是在游泳时不需要时刻背诵各种口诀:吸气—伸手—划水—蹬腿—抬头—呼气……,如果你在泳池里还要时时谨记这些口诀,是绝对谈不上“学会”的。

两者虽然都叫“学会”,其实相差迥异:第一种“学会”是“照猫画虎”,第二种“学会”是“融会贯通”,虽然都可以解决问题,但从第一种“学会”到达第二种“学会”,其实需要经历漫长的过程。而且,两种“学会”都能解决问题,所以在达到第二种“学会”的漫长过程中,你很可能感觉不到自己的进步,反而会困惑继续学习的意义乃至放弃——既然能对着文档操作,既然有现成的资料,为什么要去理解背后的原理呢。

对我来说,第二种“学会”的好处是显而易见的,最重要的一点就是不会忘记——学习的时间增长一倍,遗忘的难度将会增加十倍、二十倍甚至一百倍。这些年来,我见到了太多这样的例子:有人每次用到正则表达式都会抓狂,都要四处极力搜索、反复盲目尝试,花很长时间才能凑出、蒙对解决方案;另一方面,他们又不愿意花时间潜心学习《精通》这样的经典。因为反复遗忘,需要反复学习,最终浪费了大量的时间。

许多人不愿意专门花时间来学习正则表达式,是认为它属于奇技淫巧,并非工作必须。但这理由是不成立的:我们大部分人不是作家,但为了在需要的时候写得出文章,还是必须专门花时间来练习写作。而且,专门花时间来学习“非必要”的技能,以后往往能有意想不到的收获。我真切体会到并且懂得这个道理,恰好也是与《精通》的翻译有缘。

在翻译《精通》时,为了省却重新编排索引的麻烦,需要做到中英文版页页对应,于是我专门学习了侯捷老师写的《Word排版艺术》,并且亲手尝试了每个例子,记熟了有关的概念和术语,从此学会了格式和样式的角度定义文档,再不用为格式之类的问题烦恼。这些年来虽然用得并不 多,却没有忘记。去年写作《正则指引》时,我事先完整定义了各种格式、样式、引用等等,交稿时节省了自己和出版社大量的时间。

另一个例子仍然与正则表达式有关。去年,为了写作《正则指引》中Unicode的章节,我专门花了时间研读Unicode规范,虽然最终《指引》中没有列出学到的全部知识,但我对Unicode的理解已经不再限于“在程序中设定Unicode编码即可”。前几天,有位同事遇到Unicode字符Ä (U+00C4)无法打印的问题,于是我建议他使用A和¨ (U+0041和U+0308)的两个Unicode字符来表示(按照Unicode规范,两个字符可以“组合”成一个字符),果然解决了问题。这段经历再次证明,真的学会了,就真的不会忘。

亚里士多德曾说:所谓幸 福,就是尽情地施展我们掌握的技能,等待期望的结果。然而很多时候,我们以为自己可以解决,但是之前学过的技能已经遗忘,于是施展起来步履沉重、举步维艰,最后只能精疲力竭地等待结果,自然与幸福绝缘。相反,如果我们能把重要的技能都真正学会,学到不会忘的程度,自然可以接近幸福。如果你想收获自如驾驭 正则表达式的幸福,不妨从这本书开始吧。