如何选择正确的编程语言
几个月前,一个同事问我,应该如何选择编程语言,或者有没有什么固定的选择模式,当时我便打算写点什么。上周在硅谷开会,这我是第一次跟“hack3rs”的创业狂以及技术狂们打交道。我学会了很多前所未闻的脏话,也有所得–即便是追求精简的初创企业也倾向于把问题过份复杂化。
将真正领悟精简精神的人甄别出来并不困难。谷歌,Facebook以及Akamai的程师们的讲座魅力十足。他们从一个更宏观的角度思考和解决问题。这跟公司的财力,规模没有关系,他们特意剪除细枝末节,以便将注意力集中在问题的根本。
我自己也曾一味要求手下考虑使用高级编程语言甚至全面向对象语言,我发现许多的新时代初创企业也还没领悟其精髓。他们用Javascript、Python和Ruby编程,却不明白为什么要用这些语言。
不可否认,把循环写得紧凑或者避免使用模板固有其道理。但如果这是你选择一门编程语言的唯一理由,那么你就大错特错了。日常工作中,与其用基于深度优化的向量化C++语言构建的多核并行异步map-reduce架构去做一个卷积离散傅立叶变换(correlation-DFT),我宁愿用BASIC来做一个快速傅立叶变换(FFT)。
那么到底应该根据什么来选择编程语言呢?唯一检验标准:是否言而达意。
抛开语言的执行效率和功能等等不谈,一门语言必须能够让你描述自己的意图,不光是对编译器而言,更是对未来的读者而言。我相信软件维护中99%的问题都是由于最初写代码的人没能准备表述他们的意图造成的。如果言不达意,文档就不叫文档。如果言不达意,UML图就不是UML图。如果无法描述某种数据型适用于哪些操作符的话,面向对象编程就不是面向对象编程。言而达意不是指C风格的ModifyWindowEx(HWND wnd)不易读而Window.modify()告诉了你和编译器这个window可以和不可以做什么。关键是要表明你的意图。
Fortran如今已大大落后,因为它用下面这种方式描述一个算式:
MOV AX, $5D ADD AX, $6F MOV $7F, AX
其实完全可以写成这样:
c = a + b
如此你就知道是a加上b,结果存到c,即便你不懂计算机也能看懂。
一个常见的误解是:函数式编程语言表达你要什么(what you want)而命令式编程语言表达你想怎样(how you want)。
这是一种糟糕的理解。因为有时候“你想怎样”恰恰是你想表达的意思。
按照我一贯的博文风格,请你问自己一个基本问题,当面临语言的选择时:
“我是否把意思说清楚了?”
如果你无法回答这个问题,那么你没有用最佳语言。如果你不得不写文档或者做注释,这说明你的代码没能描述你的意图。看看这个函数原型:
char* reverseString(const char *foo);
在缺少关于空指针,空字符串以及其他异常处理文档的帮助下,根本没法理解作者到底想干什么。这不太好。当然,函数内部可能对输入做了无数的验证,但你必须写一堆针对各种特定输入的单元测试以确保你的假设是正确的。
我所指的“把意思说清楚”是什么意思呢?假设C++在原型中支持以下虚拟语法:
char* @Nullable reverseString(@NonNullable const char *foo);
函数原型中加上这些注解有两个好处:
1. 你不需要事先测试foo是不是null。编译器保证会给你一个非null。
2. 明确地告诉调用者你不容忍null。这种表述方式编译器能够明白,优秀的静态分析工具可以检测到这类bug,这是C语言做不到的。
虽然这看起来只不过是增强了一下语法,实际不仅如此,它还增强了语义。如此不论是人或是机器就明白foo这个变量不可为null,否则函数很生气,后果很严重。而且,你给这个函数划定了界限,再不用担心foo可否为null了。
函数式编程并不是万金油:
大家对我的另外一个常见误解是我推崇纯函数式语言。我的确有理由喜欢它们。看到上面那个式子了吗?
c = a + b
如果我想把expr1和expr2的值相加该如何表达呢?
c = (expr1) + (expr2)
如果expr1有附加操作而且会影响expr2的值又该如何表达呢?这并不罕见:
c = (a++) + (a + b);
这里的问题不是你想的那样。我知道你在想什么:“天知道这门语言会如何解释这个式子。万一计算的顺序反了怎么办?”
你想错了。正是由于人们会产生那样的想法,编程语言才会有这样的特点。要解答你的疑问很简单,看看编译手册就知道了。
上面式子的根本问题是我无法知道那样的计算顺序是偶然的还是有意的。我确切地知道上面式子的会做什么,但我无法确定的是,它的计算顺序是不是有意的?我能不能优化那个式子,放到一个循环里去?我能不能在多核多线程的情况下调用它?假设有人问我,如果给z赋值10而不是20,会不会影响c的值,我无法回答。
理论上是无法回答上面那个问题的。当然了我们可以根据经验做加一些断言(assertion)。在断言出了一堆或者一个警告后,理性地说,我们仍然不知道z会不会影响a或者b,最终影响到c。
为什么这很重要
代码的可维护性是建立在代码的可阅读性的基础上的。你知道为什么CSS不好吗?如果仅仅是程序员写错了或者设计者把字体和布局规则混淆了,地球人都知道那还不算太坏。CSS坏就坏在如果不加上大量的注释,人们就无法通过字面上的意思来理解代码的意图。
别忘了基于规则的声明式语言并不是新概念,更不是革命。50年前Prolog就提供了类似CSS的声明方式。今天的Erlang也提供了这类方式,并在业界得到广泛应用。
请看下面这行代码:
div .title #subtitle {color: blue}
如果不加载试一下的话,我敢打赌你完全想不到这会对页面产生怎样的效果。字面上完全看不出跟其它规则的关系,也看不出它如何处理匹配冲突。
因此对于汝等Ruby/Python/Node.js程序员而言,我的建议是,如果你真想超凡脱俗的话,学学谷歌和Facebook。他们使用一些实验性技术,并不是为了取代for-loops,而是用来表明for-loops的意图。快速原型的话选择简单的语言就可以了,当需要准确描述意图的时候才考虑更换编程语言。
命令式语言的必要性:
最后,我想解释一下为什么命令式语言是必要的。看看下面这个驱动程序例子:
setlpt1(00000000b); setlpt1(00010000b); setlpt1(00000000b);
这是我假想的串口命令协议。这几行代码是按照先后顺序排列的。哪怕200年以后,它们的意图也不会发生什么变化。必要的时候使用命令型语言,明确地告诉读者不要打乱这些代码。你不应该改变它们的顺序。你也不会把他们用在某些抽象的端口上,它们只适用于串口或者所谓打印机口。
用函数式语言来实现上面的功能,并且加上同步原语来保证它们按照顺序运行,是愚蠢的。
结论:
如果说这篇文章有一点点值得总结的东西的话,那便是:下次你写任何代码/规范/程序的时候,问问自己,意图是否清楚表达?未来的维护者看到你写的东西,是否能明白它