4.3 从运算式语言到函数式语言
现在让我们回到最开始的话题:为什么“连续求值”会成为函数式语言的基本特性呢?这是因为函数式语言是基于对Lambda 演算的实现而产生的,其基本运算模型就是:
n (表达式)运算产生结果;
n 结果(值)用于更进一步的运算。
至于从LISP开始引用的“函数”这个概念,其实在演算过程中只有“结果(值)”的价值:它是一组运算的封装,产生的效果是返回一个可供后续运算的值。因此我们应该认识到,函数式语言中所谓的“函数”并不是真正的精髓,真正的精髓在于“运算”,而函数只是封装“运算”的一种手段。
到了这里,我们如果“假设系统的结果只是一个值”,那么“我们必然可以通过一系列连续的运算来得到这个值”。不过,这句话要分成两半来看,我们下面先讲“连续运算”,然后再来讨论“结果只是一个值”的问题。
4.3.1 JavaScript中的几种连续运算
4.3.1.1 连续赋值
在JavaScript中,一种常见的情况就是连续赋值:
var a = b = c = d = 100;
我们把它写成下面这种格式,可能会让人更能理解(我并不是想说明这种语法风格更好。当然,如果你想要为每个变量做注释,可能这是个不错的主意):
1 var a =
2 b =
3 c =
4 d = 100;
第4行的表达式“d = 100”被首先运算。因为表达式有返回值,所以得到了运算结果“100”。接下来,该值参与下一个赋值表达式运算,变成了“c = 100”。如此类推,我们得到了连续赋值的效果。
所以,在别的某些语言中(例如Pascal),连续赋值可能是一种“新奇的语法特性”,但在JavaScript语言中,它不过是一种连续表达式运算的效果。
4.3.1.2 三元表达式的连用
我们前面提到过三元表达式(?:),这个表达式在C语言里是一种并不非常推荐的语句。因此对于刚才那个例子:
child = (!LC && !RC) ? 0 : (!LC ? RC : LC);
总有人会在书籍中故意弱化或丑化这个表达式的表现。其实开始的那段代码,一般人并不会那样写,即使要写成三元表达式,也应该如下:
child = (LC || RC) ? ( LC ? LC : RC ) : 0;
在函数式语言中,三元表达式其实不但应该使用,甚至被推荐连用。因为这样能够充分发挥连续运算的特性:
1 var objType = _get_from_Input();
2 var cls = ((objType == 'String') ? String :
3 (objType == 'Array') ? Array :
4 (objType == 'Number') ? Number :
5 (objType == 'Boolean') ? Boolean :
6 (objType == 'RegExp') ? RegExp :
7 Object
8 );
9 var obj = new cls();
在这个例子里,我们也可以看到一种良好的代码书写风格(至于第8行的括号是放在第7行的最末或新起一行,可以看成一种习惯)。但我们充分利用了表达式求值的特性:第2~6行的每个三元表达式的第三个运算元,其实都是下一行运算的返回结果。
显然,“运算产生值并参与运算”这一特性,使得上述的代码成为可能。否则,你可能需要写下面这样的代码(当然,你可能现在仍旧认为下面这样的代码风格更漂亮):
var objType = _get_from_Input();
switch (objType) {
case 'String': {
obj = new String();
break;
}
case 'Number': {
// ...
}
// ...
default: {
obj = new Object();
}
}
一部分理解了面向对象编程的“多态性”的开发人员可能会主张下面的代码:
var cls;
var objType = _get_from_Input();
switch (objType) {
case 'String': {
cls = String;
break;
}
case 'Number': {
// ...
}
// ...
default: {
cls = Object;
}
}
var obj = new cls();
熟悉模式的开发人员则不慌不忙地提出他们的观点:
// ...
// (对于不同的语言,以上省略10~50行类工厂的实现代码)
var objType = _get_from_Input();
var fac = new Factiory();
var cls = fac.getClass(objType);
var obj = new cls();
我们不必去评述这几种代码实现的优劣。就如同代码风格一样,在不同的体系之下,存在不同的评判标准。但现在的问题是:你在使用一门函数式语言,然而你的代码利用函数式语言的特性了吗?
4.3.1.3 一些运算连用
在前面这个三元表达式中的例子中,我们说明了行代码:
child = (!LC && !RC) ? 0 : (!LC ? RC : LC);
至少可以被改成如下的形式:
child = (LC || RC) ? ( LC ? LC : RC ) : 0;
然后我们又说,可以通过更好的代码格式化,来使得三元表达式连用的代码可读性得到提升。例如:
child = !( LC || RC ) ? 0
:( LC ? LC
: RC );
但事实上在JavaScript中,一些语法约定基本不需要用户开发人员写上面这样的代码。正如这个例子,我们无非是想要得到LC、RC和0值之一。这可以借助连续的逻辑“或(||)”运算来得到。上面的代码等效于:
child = LC || RC || 0;
这行代码中,等号右边的表达式的意思是说:
n 如果LC能被转换成逻辑“true”(值为真),则运算返回LC的实际值;否则,
n 如果RC值为真,则返回RC的实际值;否则,
n ……
n 直到表达式结束,返回表达式最后一个运算元的实际值。
而这样的运算结果,正是我们需要得到的child值。
这段代码其实也正好说明了JavaScript中逻辑运算的实质——逻辑“或”运算并非为布尔值专设。按照上面规则,对布尔值进行“或(||)”运算的效果,只是一个特例而已。
4.3.1.4 函数与方法的调用
在前面我们使用了一个不非常恰当的例子。因为孤立来看,我们要得到一个对象的类类型,并不需要用那样复杂的三元表达式,用类厂可能的确是不错的主意。但我说那是一个孤立的问题。因为我们忽视了另外一项JavaScript特性:对象的构造、函数与方法的调用等,本质上都是表达式运算,而非语句。
举例来说,我们可以用下面的代码完成对象的构造:
var obj = new ( (obj=='String') ? String : Object );
这行代码是用一个运算来作为new运算的入口参数——注意new不是语句的语法关键字,而是运算符。
所以在JavaScript中,我们事实上可以用下面的连续运算来完成上一小节的示例:
var obj = new (
(objType == 'String') ? String :
(objType == 'Array') ? Array :
(objType == 'Number') ? Number :
(objType == 'Boolean') ? Boolean :
(objType == 'RegExp') ? RegExp :
Object
);
这样,连续的一组三元表达式的运算结果,成为了new运算符的输入,而new运算符后面的一对括号“( )”,在这里起到的是强制运算符的作用。最后,new运算的结果(构造一个对象实例),被作为赋值运算的运算元,然后赋给了变量obj。
接下来的代码将会更加有趣。让我们对上面的代码做一点小修改:
10 alert(
11 (new (
12 (objType == 'String') ? String :
13 (objType == 'Array') ? Array :
14 (objType == 'Number') ? Number :
15 (objType == 'Boolean') ? Boolean :
16 (objType == 'RegExp') ? RegExp :
17 Object
18 )
19 ).toString()
20 )
是的,我故意将代码分隔成这个样子,以使你更清楚地看到运算的层次。我们看到new运算在第9行得到了运算结果:一个对象实例。然后它被一对强制运算符给包括了起来(第2~10行)。强制运算的结果还是返回该实例,——我们在这里只是需要取得一个语法上清晰的效果——而该实例接下来就调用了一下方法toString()。
如果方法调用不是表达式而是语句,那么上面这样的代码就不可能被写出来。所以,第10行代码的实质是,刚被创建的对象实例:
n 先通过点运算符“.”进行了一次对象属性toString存取,
n 然后通过运算符“( )”进行了一次方法调用。
这两次运算的结果,返回了对象的序列化值。接下来这个值被送入alert()函数的参数表(第1~11行代码),最终显示输出。
4.3.2 运算式语言
在本小节中,我们将讨论一种新的编程范型:运算式语言。它满足说明式语言的两个特性:一是陈述运算,二是求值。
不同的运算式语言的编程能力是不同的,在本节中我们将列举两个这种类型的语言。需要注意的是,它们一开始时并不是以一个语言范型出现的——而更像是某个体系中的小功能而已。
4.3.2.1 运算的实质,是值运算
将“值运算”换个说法,就是“求值”。如果说“运算的实质,就是求值”,那么大家会觉得顺理成章。但是,这里的“值”如果是指“值类型”的数据呢?
在该设问中,我们其实已经向程序设计语言的本质走得更近了一步。为了说明这一点,我们先来考察一下JavaScript的各种运算的结果类型。表4-1对此作出了完整的分析。
表4-1 JavaScript的各种运算的结果类型(目标)
|
分类 |
名称 |
符号 |
说明 |
运算元 |
目标 |
|
计算运算 |
加法 |
+ |
将两个数相加 |
number |
number |
|
减法 |
- |
对两个表达式执行减法操作 |
|||
|
乘法 |
* |
将两个数相乘 |
|||
|
除法 |
/ |
将两个数相除并返回一个数值结果 |
|||
|
取余 |
% |
将两个数相除,并返回余数 |
|||
|
递增 |
++ |
给变量加一 |
|||
|
递减 |
-- |
将变量减一 |
|||
|
一元正值 |
+ |
||||
|
一元取反 |
- |
表示一个数值表达式的相反数 |
|||
|
按位运算 |
按位与 |
& |
对两个表达式执行按位与操作 |
||
|
按位左移 |
<< |
将一个表达式的各位向左移 |
|||
|
按位非 |
~ |
对一个表达式执行按位取非操作 |
|||
|
按位或 |
| |
对两个表达式指定按位或操作 |
|||
|
按位右移 |
>> |
将一个表达式的各位向右移,保持符号不变 |
|||
|
按位异或 |
^ |
对两个表达式执行按位异或操作 |
|||
|
无符号 |
>>> |
在表达式中对各位进行无符号右移操作 |
续表
|
分类 |
名称 |
符号 |
说明 |
运算元 |
目标 |
|
逻辑运算 |
逻辑与 |
&& |
对两个表达式执行逻辑与操作 |
boolean |
boolean |
|
逻辑非 |
! |
对表达式执行逻辑非操作 |
|||
|
逻辑或 |
|| |
对两个表达式执行逻辑或操作 |
|||
|
字串 |
连接 |
+ |
连接字符或字符串 |
string |
string |
|
函数 |
函数调用 |
( ) |
调用函数并返回结果值 |
function |
(*注1) |
|
比较 |
比较 |
(章节2.3.4) |
返回比较结果 |
(任意) |
boolean |
|
赋值运算 |
赋值 |
= |
将一个值赋给变量 |
(任意) |
(*注1) |
|
复合赋值 |
(章节2.3.5) |
运算并将结果值赋给变量 |
(值类型) |
(值类型) |
|
|
对象 |
对象构造 |
new |
创建一个新对象 |
function |
object |
|
对象检查 |
instanceof |
返回一个 Boolean 值,表明对象是否为特定类的一个实例 |
object |
boolean |
|
|
成员存取 |
[ ]或. |
存取对象的成员 |
object (string) (标识符) |
(*注1) |
|
|
成员删除 |
delete |
删除对象的属性,或删除数组中的一个元素 |
boolean |
||
|
成员检查 |
in |
检查一个对象成员是否存在 |
object |
boolean |
|
|
其他 |
typeof |
typeof |
返回运算元数据类型的字符串 |
(任意) |
string |
|
表达式逻辑 |
三元条件(*) |
?: |
根据条件执行两个表达式之一 |
boolean (表达式) |
(*注2) |
|
优先级 |
( ) |
包含运算符的执行优先级信息的列表 |
(表达式) |
||
|
逗号 |
, |
使两个表达式连续执行 |
|||
|
void |
void |
避免一个表达式返回值 |
undefined |
*注1:取决于具体的值、变量或对象成员的数据类型。
*注2:是表达式之间的运算关系,结果只对表达式产生影响。
在表4-1中,最令人惊讶的结论是:所有的运算都产生“值类型”的结果值。正因为“运算都产生值类型的结果”,且“所有的逻辑语句结构都可以被消灭”,所以结论是:“系统的结果必然是值,并且可以通过一系列的运算来得到这一结果”。
我们知道,计算机其实只能表达值数据。任何复杂的现象(例如界面、动画或模拟现实),在运算系统看来其实只是某种输出设备对数值的理解而已,运算系统只需要得到这些数值,至于如何展示,则是另一个物理系统(或其他运算系统)来负责的事情。
所以运算的实质其实是值的运算。至于像“指针”、“对象”这样抽象结构,在运算系统来看,其实只是定位到“值”以进行后续运算的工具而已——换言之,它们是不参与“求值”运算的。到这里,读者应该明白为什么表4-1中的结果类型“必然是值”了。
4.3.2.2 有趣的运算:在Internet Explorer和J2EE中
在Internet Explorer中,层叠样式表(CSS,Cascading Style Sheet)中会有一些运算过程,用于设定一些特殊的样式属性,例如颜色和URL地址:
<style>
/* 设置字体颜色 */
DIV {
COLOR: rgb(127, 127, 0);
}
/* 设置背景图片的url地址 */
TABLE {
BACKGROUND: url(http://127.0.0.1/bg.png);
}
</style>
这些过程是符合CSS规范的。但是Internet Explorer 5.0及更高版本的浏览器对此做出了一些扩展,它们使用一个名为expression的过程,来表明CSS属性需要通过一个计算过程来得到值。具体来说,如下例:
<style>
SPAN {
BORDER: 1 solid red;
POSITION: absolute;
}
#span_left {
LEFT: 0px;
WIDTH: 300px;
}
#span_right {
LEFT: 300px;
WIDTH: expression(document.body.clientWidth - 300);
}
</style>
我们可以用接下来的HTML代码来展示这个样式表的效果:
<body>
<span id="span_left">300px</span>
<span id="span_right" onresize="this.innerText = this.clientWidth + 'px'">
</span>
</body>
其效果如图4-1所示。

图4-1 上述样式表在Internet Explorer浏览器中的效果
在上面的样式表中,我们用一个表达式运算来使span_right的宽度总是随当前网页的宽度而变化。我们的目的是要使left/right两个<SPAN>标签动态地填充网页上的左右两个部分。因此事实上我们也可以用如下方式写span_right的样式表:
/* 上例的一个更好的版本 */
#span_right {
LEFT: expression(document.getElementById('span_left').currentStyle.width);
WIDTH: expression(document.body.clientWidth -
parseInt(this.currentStyle.left));
}
这些是Internet Explorer上的非常强大的功能。事实上,在IE 6.0中,有一个名为IE 7的开源项目就利用这种特性,为IE 6.0上的CSS样式表实现了与IE 7等同的功能。
那么,Internet Explorer中是如何扩展这样的一个功能的呢?
如果我们更加完整地考察这个expresstion()过程,就会发现它的一些独特之处,包括:
n 它是JavaScript语法的脚本代码;
n 它可以访问整个的文档对象模型(DOM,Document Object Model);
n 它只能是一个表达式,或一组用逗号分隔的表达式;
n 它不能使用任何语句和语句分隔符(分号);
n 它可以声明并使用函数(函数中也可以出现语句),但函数只用于值运算;
n 整个表达式的运算结果是值(用于赋给样式表属性)。
我们综合这些特性就可以发现,这个expresstion()中所包括的,其实是一种:
n 消灭了语句的、
n 用表达式来运算求值的
JavaScript语言的简化版本。
这是真正有趣的地方。事实上我们可以通过样式表中的expresstion()过程来完成所有的工作,而无需单独写其他的脚本代码。换而言之,此处的expresstion()已经具备了整个JavaScript语言的编程能力——不过在这里我们还要强调expresstion()允许声明和使用函数这一特性。关于这一点,我们下一小节还会重新提及。
这一类的特性也出现在J2EE这种大型的语言系统中。在JSTL 1.0中,为了方便存取数据而自定义了这样一种语言(只能用在JSTL标签中),要求以#开始,将变量或表达式放在一对大括号之间,例如:
#{...}
由于它可以直接访问faces-config.xml中定义的名称、客户端Request中的参数,或者是JavaBeans中的成员等数据,因此可以写出这样的代码来:
<f:view>
名称:<h:outputText
value="#{userBean.name}, #{param.name}"/>
</f:view>
这个语言名为“JavaServer Faces(JSF) Expression Language(EL)”,JSEL中还可以使用数值运算、逻辑运算、关系运算,以及数组和对象成员存取等表达式。和刚才我们提到的CSS中的expresstion()过程一样,JSEL提供的也是一个运算求值的结果;并且在表达式运算过程中,不会出现语句这种语法元素。
这一类的语言,被称为表达式语言(Expression Language,EL)。前面提到过,我们可以消灭语言中的陈述运算逻辑的三种语句(顺序、分支和循环),并使代码具有完全等同的编程能力。所以我们也看到表达式语言具有充备的程序设计能力,是一种极端精华的编程范型。
为了将这个范型与直译的“表达式(Expression)”区分开来,在随后的文字中我们将称之为“运算式语言(范型)”——事实上也存在这样的翻译,但这种翻译一般强调的是名词性质的表达式。而我们在这里使用这个名词,主要强调“通过运算求值来实现程序设计”的这样一个编程范型。
4.3.3 如何消灭掉语句
读者应当注意到:由连续运算来组织代码与用顺序语句来组织代码,是两种不同的风格。对于“运算式语言”这种新的语言风格,如果要让它成为一种纯粹的且完备的语言(范型),那么我们需要让它仅通过“表达式运算”就能完成全部的程序逻辑,这包括其他语言中的三种基本逻辑结构:顺序、分支与循环——是的,我们在讲述命令式语言时提到过这三种基本逻辑结构,它既用于组织代码,亦用于陈述逻辑。同样,运算式语言也需要这两种能力(如果不考虑代码写得多么凌乱、难懂的话,我们可以忽略前者)。
因此,为了让“(纯粹地)连续运算”能实现足够复杂的系统,我们要在消减掉“语句”这个语法元素的同时,通过表达式来陈述三种基本逻辑。我们将看到:在运算式语言中,语句是可以被消灭掉的。
4.3.3.1 通过表达式消灭分支语句
单个分支的IF条件语句,可以被转换成布尔表达式。例如:
/**
* 示例1: 消灭条件分支语句(无else分支)
*/
if (tag > 1) {
alert('true');
}
// 转换成
(tag > 1) && alert('true');
IF条件分支语句(一个或两个分支),总是可以被转换(三元)条件表达式。例如下面的代码:
/**
* 示例2: 消灭条件分支语句
*/
// 1. 无else分支
if (tag > 1) {
alert('true');
}
// 转换成
(tag > 1) ? alert('true') : null;
// 2. 有else分支
if (tag > 1) {
alert('true');
}
else {
alert('false');
}
// 转换成
(tag > 1) ? alert('true') : alert('false');
由于一个多重分支语句可以被转换成IF条件分支语句的连用,例如:
/**
* 示例3: 多重分支语句与IF语句连用的等效性
*/
switch (value) {
100:
200: alert('value is 200 or 100'); break;
300: alert('value is 300'); break;
default: alert('I don\'t know.');
}
// 等效于
if (value == 100 || value == 200) {
alert('value is 200 or 100');
}
else if (value == 300) {
alert('value is 300');
}
else {
alert('I don\'t know.')
}
因此SWITCH语句与IF语句连用等效。而IF语句连用则可以用三元条件表达式连续运算来替代:
/**
* 示例4: 消灭IF语句连用
* (参考示例3)
*/
// ... (if语句连用示例代码略)
// 转换为
(value = 100 || value == 200) ? alert('value is 200 or 100')
: (value = 300) ? alert('value is 300')
: alert('I don\'t know.');
4.3.3.2 通过函数递归消灭循环语句
放开易用性不论,常见的三种循环语句while、do..while和for是可以互换的,对这一点我想无需论述。因此,下面只以do..while语句为例,来讨论循环语句的问题。
循环语句可以通过函数递归来模拟,这一点其实也是经过证实的,如下例:
/**
* 示例1: 通过函数递归来模拟循环语句
*/
var loop = 100;
var i = loop;
do {
// do something...
i--;
}
while (i > 0);
// 用函数递归模拟上述循环语句
function foo(i) {
// do something...
if (--i > 0) foo(i);
}
foo(loop);
// 用函数递归模拟上述循环语句(更能展现函数式语言特性的)
void function(i) {
// do something...
(--i > 0) && arguments.callee(i);
}(loop);
但是,如果用函数来模拟循环,那么必然存在一个问题,就是栈溢出。循环语句的一个良好特性就是开销很小,而在函数的递归调用过程中,由于需要为每次函数调用保留私有数据和上下文环境,因此将消耗大量的栈空间。
但是递归中也可以存在不占用栈的情况,这就是尾递归。简单地讲,尾递归是指在一个函数的执行序列的最后一个表达式中出现的递归调用。由于这个递归是最后一个表达式,那么当前函数不需要为下一次调用保持栈和运算的上下文环境。换而言之,这种情况下,递归函数的多次调用中要么使用同一个栈(和上下文环境),要么根本就不使用栈(和上下文环境)。
一个简单的实现方法就是:尾递归相当于在函数尾部发生的一个(无需返回的)跳转指令。由于这种特性,所以满足尾递归的函数,就可以在不消耗栈和上下文环境的情况下,用来替代循环语句。关于该理论,在SICP中有过详细的解释,而在现实中,Scheme、Erlang等语言都将尾递归作为一种重要的特性内置于编译器中。这些编译器内置尾递归(或强调必须使用严格的尾递归)的原因在于:在函数式等编程范型中,通过编译器的优化,可以无需“(循环)语句”这种编程元素来实现高性能的迭代运算。
然而不幸的是,目前已知的JavaScript的解释环境中并不支持这种特性。因此,我们在这里讨论函数式时,可以说“能够通过函数递归来消灭循环语句”,但在不支持尾递归(及其优化技术)的JavaScript中,这种实现将以大量栈和内存消耗为代价。
4.3.3.3 其他可以被消灭的语句
由于可以不使用循环和switch,所以标签语句也就没有存在的价值——事实上函数式语言中另外有与“标签语句”类似的、函数式的实现。与此相同的,流程控制中的一般子句(break和continue)也没有存在的价值。
前面说过函数式语言可以不使用寄存器,因此这事实上只需要值声明,而不需要变量声明——值参与运算,变量其实是值的寄存。所以在函数式语言中,变量声明语句也是不需要的。
所以你会看到,在函数式语言中,除了值声明和函数中的返回子句之外,其他的语句都是可以被消灭的。但是,为什么这两种语句不能被消灭呢?我想,我不需要再给出答案了吧——如果你认真地读过本章节的话。





