第 6 章: 函数式编程

随着程序的规模越来越大,它们的复杂性也会越来越高,变得越来越难以理解。我们都认为自己很聪明,当然,但我们毕竟是凡人,即使是中等程度的混乱也会让我们感到困惑。然后一切都开始走下坡路。在你不真正理解的东西上工作,就像在电影中那些定时炸弹上随意剪断电线一样。如果你幸运的话,你可能会找到正确的电线——特别是如果你你是电影的主角,并摆出一个恰如其分的戏剧性姿势——但总是有可能把一切都炸毁的可能性。

诚然,在大多数情况下,破坏程序不会导致任何大爆炸。但当一个程序因某人无知的修补而退化为一堆错误的破败不堪的集合时,把它重塑成一个合理的东西是一项可怕的劳动——有时候你可能不如从头开始做得好。

因此,程序员一直在寻找方法来尽可能降低程序的复杂性。一个重要的途径是尝试使代码更加抽象。在编写程序时,很容易在每个点都陷入琐碎的细节。你遇到了一些小问题,你处理它,然后继续下一个小问题,等等。这使得代码读起来就像一个老祖母的故事。

是的,亲爱的,做豌豆汤你需要豌豆,那种干的。而且你必须至少浸泡一个晚上,否则你必须煮几个小时。我记得有一次,我的愚蠢儿子想做豌豆汤。你相信他没把豌豆浸泡吗?我们几乎都把牙给咬断了。总之,当你把豌豆浸泡好后,你大约需要每人一杯豌豆,注意了,它们在浸泡的时候会膨胀一点,所以如果你不小心,它们会从你用来盛它们的任何东西里溢出来,所以也要用足够的水来浸泡,但正如我所说,大约一杯豌豆,在它们干燥的时候,而且在它们浸泡后,你用每杯干豌豆四杯水煮它们。让它炖两个小时,这意味着你要盖上锅盖,让它保持微沸,然后加入一些切碎的洋葱,切片的芹菜茎,也许还有一个或两个胡萝卜和一些火腿。让它们再煮几分钟,就可以吃了。

另一种描述这个食谱的方法

每人:一杯干豌豆,半个切碎的洋葱,半个胡萝卜,一根芹菜茎,可选火腿。

把豌豆浸泡一夜,在四杯水(每人)中炖煮两个小时,加入蔬菜和火腿,再煮十分钟。

这更短,但如果你不知道怎么浸泡豌豆,你一定会把它放进太少的水里,把它搞砸。但怎么浸泡豌豆可以查阅,这就是诀窍。如果你假设你的听众有一定的基本知识,你就可以用一种处理更大概念的语言交谈,并以更短、更清晰的方式表达事物。这或多或少就是抽象的概念。

这个牵强的食谱故事与编程有什么关系?嗯,显然,食谱就是程序。此外,厨师应该具有的基本知识对应于程序员可以使用函数和其他结构。如果你还记得这本书的介绍,像 while 这样的东西可以让构建循环变得更容易,在 第 4 章 中,我们写了一些简单的函数,以便让其他函数更短、更直接。这些工具,其中一些由语言本身提供,另一些由程序员构建,被用来减少程序中其他部分中无趣细节的数量,从而使该程序更容易处理。


函数式编程,这是本章的主题,通过巧妙地组合函数来实现抽象。掌握了一系列基本函数的程序员,更重要的是,掌握了如何使用它们的知识,比从头开始的程序员要有效得多。不幸的是,标准的 JavaScript 环境提供的基本函数少得可怜,所以我们必须自己编写它们,或者,这通常更可取,使用别人的代码(更多内容将在 第 9 章 中介绍)。

还有其他流行的抽象方法,最值得注意的是面向对象编程,这是 第 8 章 的主题。


一个丑陋的细节是,如果你有一点审美,你一定会开始厌烦它,那就是无休止地重复的 for 循环遍历一个数组:for (var i = 0; i < something.length; i++) ...。这能抽象出来吗?

问题是,大多数函数只是接收一些值,组合它们,然后返回一些东西,而这样的循环包含一个它必须执行的代码片段。很容易写一个遍历数组并打印出每个元素的函数

function printArray(array) {
  for (var i = 0; i < array.length; i++)
    print(array[i]);
}

但是,如果我们想做除了打印以外的事情呢?由于“做某事”可以用一个函数来表示,而函数也是值,我们可以将我们的操作作为函数值传递

function forEach(array, action) {
  for (var i = 0; i < array.length; i++)
    action(array[i]);
}

forEach(["Wampeter", "Foma", "Granfalloon"], print);

通过使用匿名函数,就像 for 循环一样的东西可以用更少的无用细节来编写

function sum(numbers) {
  var total = 0;
  forEach(numbers, function (number) {
    total += number;
  });
  return total;
}
show(sum([1, 10, 100]));

注意,变量 total 在匿名函数内部是可见的,因为存在词法作用域规则。还要注意,此版本几乎不比 for 循环短,而且需要一个相当笨重的 }); 在它的末尾——大括号关闭了匿名函数的主体,圆括号关闭了对 forEach 的函数调用,分号是必需的,因为这个调用是一个语句。

你确实得到了一个绑定到数组中当前元素的变量 number,所以不再需要使用 numbers[i] 了,当这个数组是通过计算某个表达式创建的时候,也不需要把它存储在一个变量中,因为它可以直接传递给 forEach

第 4 章 中的猫代码包含这样的片段

var paragraphs = mailArchive[mail].split("\n");
for (var i = 0; i < paragraphs.length; i++)
  handleParagraph(paragraphs[i]);

现在可以写成...

forEach(mailArchive[mail].split("\n"), handleParagraph);

总的来说,使用更抽象(或“更高层”)的结构会带来更多信息,更少噪音:sum 中的代码读起来是“对于 numbers 中的每个数字,将该数字添加到 total 中”,而不是...“有一个变量从零开始,它向上计数到名为 numbers 的数组的长度,对于这个变量的每个值,我们查找数组中对应的元素并将其添加到 total 中”。


forEach 的作用是将一个算法,在本例中是“遍历一个数组”,抽象出来。算法中的“空缺”,在本例中是针对这些元素中的每一个要做什么,是用传递给算法函数的函数填充的。

对其他函数进行操作的函数被称为 高阶函数。通过对函数进行操作,它们可以在全新的层面上谈论操作。来自 第 3 章makeAddFunction 函数也是一个高阶函数。它不是接收一个函数值作为参数,而是产生一个新的函数。

高阶函数可以用来泛化许多常规函数难以描述的算法。当你能在你的处置范围内拥有一系列这些函数时,它可以帮助你以更清晰的方式思考你的代码:你可以将算法分解成几个基本算法的组合,这些算法通过名称调用,不需要一遍又一遍地键入。

能够编写我们想要做什么,而不是如何做,意味着我们在更抽象的层面上工作。在实践中,这意味着更短、更清晰、更令人愉快的代码。


另一种有用的高阶函数类型是修改它所给定的函数值

function negate(func) {
  return function(x) {
    return !func(x);
  };
}
var isNotNaN = negate(isNaN);
show(isNotNaN(NaN));

negate 返回的函数将它所接收的参数馈送到原始函数 func,然后对结果取反。但是,如果你想要取反的函数接受多个参数怎么办?你可以通过 arguments 数组访问传递给函数的任何参数,但是当你不确定你有多少参数时,你如何调用一个函数呢?

函数有一个名为 apply 的方法,用于处理这种情况。它接受两个参数。第一个参数的作用将在 第 8 章 中讨论,现在我们只在其中使用 null。第二个参数是一个数组,包含函数必须应用到的参数。

show(Math.min.apply(null, [5, 6]));

function negate(func) {
  return function() {
    return !func.apply(null, arguments);
  };
}

不幸的是,在 Internet Explorer 浏览器中,很多内置函数,比如 alert,并不是真正的函数... 或者其他什么。当它们被赋予 typeof 运算符时,它们报告自己的类型为 "object",并且它们没有 apply 方法。你自己的函数不会遇到这个问题,它们始终是真正的函数。


让我们看看与数组相关的几个更基本的算法。sum 函数实际上是一个算法的变体,这个算法通常被称为 reducefold

function reduce(combine, base, array) {
  forEach(array, function (element) {
    base = combine(base, element);
  });
  return base;
}

function add(a, b) {
  return a + b;
}

function sum(numbers) {
  return reduce(add, 0, numbers);
}

reduce 通过反复使用一个函数将数组组合成一个单一值,该函数将数组中的一个元素与一个基值组合起来。这正是 sum 所做的,所以它可以通过使用 reduce 来缩短... 除了加法运算符而不是 JavaScript 中的函数,所以我们首先必须把它放到一个函数中。

reduce 函数将函数作为第一个参数而不是最后一个参数的原因,与 forEach 不同,一部分是因为这是传统 - 其他语言也是这样做的 - 另一部分是因为这使我们能够使用一个特定的技巧,我们将在本章末尾讨论。这意味着,在调用 reduce 时,将归约函数写成匿名函数看起来有点奇怪,因为现在其他参数都跟在函数后面,与普通 for 块的相似性完全消失了。


例 6.1

编写一个函数 countZeroes,它接受一个数字数组作为参数并返回其中出现的零的个数。使用 reduce

然后,编写高阶函数 count,它接受一个数组和一个测试函数作为参数,并返回数组中测试函数返回 true 的元素个数。使用此函数重新实现 countZeroes

function countZeroes(array) {
  function counter(total, element) {
    return total + (element === 0 ? 1 : 0);
  }
  return reduce(counter, 0, array);
}

带问号和冒号的奇怪部分使用了新的运算符。在 第 2 章 中,我们看到了单目和二目运算符。这个是三目运算符 - 它作用于三个值。它的效果类似于 if/else,区别在于,if 有条件地执行语句,而这个则有条件地选择表达式。问号之前的第一个部分是条件。如果此条件为 true,则选择问号后的表达式,本例中为 1。如果为 false,则选择冒号后的部分,本例中为 0

使用此运算符可以使某些代码片段变得更短。当其中的表达式变得很大,或者您需要在条件部分中做出更多决策时,直接使用 ifelse 通常更易读。

以下是使用 count 函数的解决方案,其中包含生成相等性测试器的函数,以使最终的 countZeroes 函数更短

function count(test, array) {
  return reduce(function(total, element) {
    return total + (test(element) ? 1 : 0);
  }, 0, array);
}

function equals(x) {
  return function(element) {return x === element;};
}

function countZeroes(array) {
  return count(equals(0), array);
}

另一个通常有用的“基本算法”与数组相关,称为 map。它遍历数组,将函数应用于每个元素,就像 forEach 一样。但它不会丢弃函数返回的值,而是使用这些值构建一个新数组。

function map(func, array) {
  var result = [];
  forEach(array, function (element) {
    result.push(func(element));
  });
  return result;
}

show(map(Math.round, [0.01, 2, 9.89, Math.PI]));

请注意,第一个参数称为 func,而不是 function,这是因为 function 是一个关键字,因此不是有效的变量名。


曾经,在特兰西瓦尼亚的深山森林里,住着一位隐居者。大部分时间,他只是在山里游荡,和树木说话,和鸟儿一起欢笑。但有时,当倾盆大雨把他困在小木屋里,呼啸的风让他感到无比渺小时,这位隐居者就会有一种想要写作的冲动,想把一些想法倾注到纸上,也许在那里它们可以变得比他本人更伟大。

在诗歌、小说和哲学方面惨败后,这位隐居者最终决定写一本技术书籍。他年轻时做过一些计算机编程,他认为只要能写出一本关于编程的好书,名利和认可必然随之而来。

于是他开始写。起初他用树皮碎片,但事实证明那不太实用。他到附近的村庄买了一台笔记本电脑。写了几章后,他意识到他想把书做成 HTML 格式,以便把它放在他的网页上……


您熟悉 HTML 吗?它是用来在网页上添加标记的,我们在这本书中会使用它几次,所以如果您了解它的工作原理,至少是大体上的,那就太好了。如果你是一个好学生,你可以现在去网上搜索一个好的 HTML 入门教程,读完后回来这里。你们中的大多数人可能都是糟糕的学生,所以我只会简要说明一下,希望足够了。

HTML 代表“超文本标记语言”。HTML 文档全是文本。因为它必须能够表达此文本的结构,例如哪个文本是标题,哪个文本是紫色等等,所以一些字符具有特殊含义,有点类似于 JavaScript 字符串中的反斜杠。'小于' 和 '大于' 字符用于创建 “标签”。标签提供有关文档中文本的额外信息。它可以独立存在,例如标记图片在页面中出现的位置,也可以包含文本和其他标签,例如标记段落的开始和结束位置。

有些标签是必须的,整个 HTML 文档必须始终包含在 html 标签之间。以下是一个 HTML 文档的示例

<html>
  <head>
    <title>A quote</title>
  </head>
  <body>
    <h1>A quote</h1>
    <blockquote>
      <p>The connection between the language in which we
      think/program and the problems and solutions we can imagine
      is very close.  For this reason restricting language
      features with the intent of eliminating programmer errors is
      at best dangerous.</p>
      <p>-- Bjarne Stroustrup</p>
    </blockquote>
    <p>Mr. Stroustrup is the inventor of the C++ programming
    language, but quite an insightful person nevertheless.</p>
    <p>Also, here is a picture of an ostrich:</p>
    <img src="img/ostrich.png"/>
  </body>
</html>

包含文本或其他标签的元素首先使用 <tagname> 打开,然后使用 </tagname> 结束。html 元素始终包含两个子元素:headbody。第一个包含有关文档的信息,第二个包含实际的文档。

大多数标签名都是神秘的缩写。h1 代表“标题 1”,是最大的一种标题。还有 h2h6,代表越来越小的标题。p 代表“段落”,img 代表“图片”。img 元素不包含任何文本或其他标签,但它确实有一些额外的信息,src="img/ostrich.png",称为 “属性”。在本例中,它包含有关此处应显示的图像文件的信息。

因为 <> 在 HTML 文档中具有特殊含义,所以它们不能直接写在文档的文本中。如果你想在 HTML 文档中写 '5 < 10',你必须写 '5 &lt; 10',其中 'lt' 代表 '小于'。'&gt;' 用于 '>',并且由于这些代码也使“与”符号具有特殊含义,因此普通的 '&' 被写为 '&amp;'。

现在,这些仅仅是 HTML 的基本知识,但它们应该足以让你顺利完成本章,以及后面涉及 HTML 文档的章节,而不会完全迷糊。


JavaScript 控制台有一个函数 viewHTML,可以用来查看 HTML 文档。我把上面的示例文档存储在变量 stroustrupQuote 中,因此您可以通过执行以下代码来查看它

viewHTML(stroustrupQuote);

如果您安装了某种弹出式阻止程序或将其集成到您的浏览器中,它可能会干扰 viewHTML,后者会尝试在新窗口或选项卡中显示 HTML 文档。尝试配置阻止程序以允许此网站弹出。


所以,继续讲故事,这位隐居者想让他的书以 HTML 格式呈现。起初,他只是直接在他的手稿中写下了所有标签,但输入所有这些小于号和大于号让他手指酸痛,而且他经常忘记在需要“与”符号时写 &amp;。这让他头疼。接下来,他尝试在 Microsoft Word 中写书,然后将其保存为 HTML。但由此产生的 HTML 比它需要的大十五倍,而且更复杂。而且,Microsoft Word 让他头疼。

他最终想出的解决方案是:他将以纯文本的形式写作,遵循关于段落分隔方式和标题外观的一些简单规则。然后,他将编写一个程序将此文本转换为他想要的精确 HTML。

规则如下

  1. 段落用空行分隔。
  2. 以“%”符号开头的段落是标题。 “%”符号越多,标题越小。
  3. 在段落中,可以通过将文本放在星号之间来强调文本。
  4. 脚注写在花括号之间。

在他痛苦地为他的书奋斗了六个月后,这位隐居者只完成了几段。这时,他的小屋遭到雷击,把他击毙,永远地结束了他的写作梦想。从他笔记本电脑的焦炭残骸中,我找到了以下文件

% The Book of Programming

%% The Two Aspects

Below the surface of the machine, the program moves. Without effort,
it expands and contracts. In great harmony, electrons scatter and
regroup. The forms on the monitor are but ripples on the water. The
essence stays invisibly below.

When the creators built the machine, they put in the processor and the
memory. From these arise the two aspects of the program.

The aspect of the processor is the active substance. It is called
Control. The aspect of the memory is the passive substance. It is
called Data.

Data is made of merely bits, yet it takes complex forms. Control
consists only of simple instructions, yet it performs difficult
tasks. From the small and trivial, the large and complex arise.

The program source is Data. Control arises from it. The Control
proceeds to create new Data. The one is born from the other, the
other is useless without the one. This is the harmonious cycle of
Data and Control.

Of themselves, Data and Control are without structure. The programmers
of old moulded their programs out of this raw substance. Over time,
the amorphous Data has crystallised into data types, and the chaotic
Control was restricted into control structures and functions.

%% Short Sayings

When a student asked Fu-Tzu about the nature of the cycle of Data and
Control, Fu-Tzu replied 'Think of a compiler, compiling itself.'

A student asked 'The programmers of old used only simple machines and
no programming languages, yet they made beautiful programs. Why do we
use complicated machines and programming languages?'. Fu-Tzu replied
'The builders of old used only sticks and clay, yet they made
beautiful huts.'

A hermit spent ten years writing a program. 'My program can compute
the motion of the stars on a 286-computer running MS DOS', he proudly
announced. 'Nobody owns a 286-computer or uses MS DOS anymore.',
Fu-Tzu responded.

Fu-Tzu had written a small program that was full of global state and
dubious shortcuts. Reading it, a student asked 'You warned us against
these techniques, yet I find them in your program. How can this be?'
Fu-Tzu said 'There is no need to fetch a water hose when the house is
not on fire.'{This is not to be read as an encouragement of sloppy
programming, but rather as a warning against neurotic adherence to
rules of thumb.}

%% Wisdom

A student was complaining about digital numbers. 'When I take the root
of two and then square it again, the result is already inaccurate!'.
Overhearing him, Fu-Tzu laughed. 'Here is a sheet of paper. Write down
the precise value of the square root of two for me.'

Fu-Tzu said 'When you cut against the grain of the wood, much strength
is needed. When you program against the grain of a problem, much code
is needed.'

Tzu-li and Tzu-ssu were boasting about the size of their latest
programs. 'Two-hundred thousand lines', said Tzu-li, 'not counting
comments!'. 'Psah', said Tzu-ssu, 'mine is almost a *million* lines
already.' Fu-Tzu said 'My best program has five hundred lines.'
Hearing this, Tzu-li and Tzu-ssu were enlightened.

A student had been sitting motionless behind his computer for hours,
frowning darkly. He was trying to write a beautiful solution to a
difficult problem, but could not find the right approach. Fu-Tzu hit
him on the back of his head and shouted '*Type something!*' The student
started writing an ugly solution. After he had finished, he suddenly
understood the beautiful solution.

%% Progression

A beginning programmer writes his programs like an ant builds her
hill, one piece at a time, without thought for the bigger structure.
His programs will be like loose sand. They may stand for a while, but
growing too big they fall apart{Referring to the danger of internal
inconsistency and duplicated structure in unorganised code.}.

Realising this problem, the programmer will start to spend a lot of
time thinking about structure. His programs will be rigidly
structured, like rock sculptures. They are solid, but when they must
change, violence must be done to them{Referring to the fact that
structure tends to put restrictions on the evolution of a program.}.

The master programmer knows when to apply structure and when to leave
things in their simple form. His programs are like clay, solid yet
malleable.

%% Language

When a programming language is created, it is given syntax and
semantics. The syntax describes the form of the program, the semantics
describe the function. When the syntax is beautiful and the semantics
are clear, the program will be like a stately tree. When the syntax is
clumsy and the semantics confusing, the program will be like a bramble
bush.

Tzu-ssu was asked to write a program in the language called Java,
which takes a very primitive approach to functions. Every morning, as
he sat down in front of his computer, he started complaining. All day
he cursed, blaming the language for all that went wrong. Fu-Tzu
listened for a while, and then reproached him, saying 'Every language
has its own way. Follow its form, do not try to program as if you
were using another language.'

为了纪念我们这位善良的隐居者,我想为他完成他的 HTML 生成程序。解决此问题的一个好方法如下

  1. 在每条空行处切开文件,将其拆分为段落。
  2. 从标题段落中删除“%”字符,并将它们标记为标题。
  3. 处理段落本身的文本,将其拆分为普通部分、强调部分和脚注。
  4. 将所有脚注移动到文档底部,在其位置留下数字 1
  5. 将每个部分包装到正确的 HTML 标签中。
  6. 将所有内容组合成一个 HTML 文档。

这种方法不允许脚注出现在强调文本中,反之亦然。这有点武断,但有助于使示例代码保持简单。如果您在本章结束时想挑战自己,可以尝试修改程序以支持“嵌套”标记。

整个手稿,作为一个字符串值,可以通过调用 recluseFile 函数在本页获得。


算法的步骤 1 非常简单。空行是在一行中出现两个换行符时得到的,如果您还记得字符串拥有的 split 方法,我们在 第 4 章 中见过,您会意识到这样做可以解决问题

var paragraphs = recluseFile().split("\n\n");
print("Found ", paragraphs.length, " paragraphs.");

例 6.2

编写一个名为 processParagraph 的函数,该函数以段落字符串作为参数,检查该段落是否为标题。如果是,则剥离“%”字符并统计其数量。然后,它返回一个包含两个属性的对象:content,包含段落内的文本;type,包含该段落必须包裹的标签,普通段落为 "p",只有一个“%”的标题为 "h1",有 X 个“%”的标题为 "hX"

请记住,字符串具有 charAt 方法,可用于查看字符串中的特定字符。

function processParagraph(paragraph) {
  var header = 0;
  while (paragraph.charAt(0) == "%") {
    paragraph = paragraph.slice(1);
    header++;
  }

  return {type: (header == 0 ? "p" : "h" + header),
          content: paragraph};
}

show(processParagraph(paragraphs[0]));

这里我们可以尝试之前看到的 map 函数。

var paragraphs = map(processParagraph,
                     recluseFile().split("\n\n"));

然后,,我们就得到了一个包含良好分类的段落对象数组。不过我们有点超前了,我们忘记了算法的步骤 3。

处理段落本身的文本,将其拆分为普通部分、强调部分和脚注。

可以分解成

  1. 如果段落以星号开头,则删除强调部分并将其存储。
  2. 如果段落以左大括号开头,则删除脚注并将其存储。
  3. 否则,删除从第一个强调部分或脚注到字符串末尾的部分,并将其存储为普通文本。
  4. 如果段落中还有剩余内容,则从 1 开始重新执行步骤。

例 6.3

构建一个名为 splitParagraph 的函数,该函数以段落字符串作为参数,返回一个段落片段数组。想一个好方法来表示这些片段。

方法 indexOf(它在字符串中搜索字符或子字符串并返回其位置,如果未找到则返回 -1)可能在这里会有用。

这是一个棘手的算法,有很多不完全正确或过于冗长的描述方法。如果你遇到问题,只要思考一分钟。尝试编写内部函数来执行构成算法的较小操作。

这是一个可能的解决方案。

function splitParagraph(text) {
  function indexOrEnd(character) {
    var index = text.indexOf(character);
    return index == -1 ? text.length : index;
  }

  function takeNormal() {
    var end = reduce(Math.min, text.length,
                     map(indexOrEnd, ["*", "{"]));
    var part = text.slice(0, end);
    text = text.slice(end);
    return part;
  }

  function takeUpTo(character) {
    var end = text.indexOf(character, 1);
    if (end == -1)
      throw new Error("Missing closing '" + character + "'");
    var part = text.slice(1, end);
    text = text.slice(end + 1);
    return part;
  }

  var fragments = [];

  while (text != "") {
    if (text.charAt(0) == "*")
      fragments.push({type: "emphasised",
                      content: takeUpTo("*")});
    else if (text.charAt(0) == "{")
      fragments.push({type: "footnote",
                      content: takeUpTo("}")});
    else
      fragments.push({type: "normal",
                      content: takeNormal()});
  }
  return fragments;
}

请注意 takeNormal 函数中 mapreduce 的过度使用。本章是关于函数式编程的,所以我们将以函数式方式进行编程!你能理解它是如何工作的吗?map 生成一个包含给定字符位置的数组,如果未找到则为字符串末尾,而 reduce 获取其中的最小值,即我们必须查看的字符串中的下一个点。

如果你不使用 map 和 reduce 来编写它,你会得到类似以下内容的东西

var nextAsterisk = text.indexOf("*");
var nextBrace = text.indexOf("{");
var end = text.length;
if (nextAsterisk != -1)
  end = nextAsterisk;
if (nextBrace != -1 && nextBrace < end)
  end = nextBrace;

这更加丑陋。大多数情况下,当需要根据一系列内容(即使只有两个)做出决定时,将它编写为数组操作比在单独的 if 语句中处理每个值要好。 (幸运的是,第 10 章描述了一种更简单的方法来请求字符串中“此字符或那个字符”的第一个出现位置。)

如果你编写的 splitParagraph 以不同于上面解决方案的方式存储片段,你可能需要调整它,因为本章中其余函数假设片段是具有 typecontent 属性的对象。


我们现在可以将 processParagraph 连接起来,以拆分段落内的文本,我的版本可以修改如下

function processParagraph(paragraph) {
  var header = 0;
  while (paragraph.charAt(0) == "%") {
    paragraph = paragraph.slice(1);
    header++;
  }

  return {type: (header == 0 ? "p" : "h" + header),
          content: splitParagraph(paragraph)};
}

将其映射到段落数组上,我们得到了一个段落对象数组,该数组又包含片段对象数组。接下来要做的是取出脚注,并将对它们的引用放在它们的位置。类似于以下内容

function extractFootnotes(paragraphs) {
  var footnotes = [];
  var currentNote = 0;

  function replaceFootnote(fragment) {
    if (fragment.type == "footnote") {
      currentNote++;
      footnotes.push(fragment);
      fragment.number = currentNote;
      return {type: "reference", number: currentNote};
    }
    else {
      return fragment;
    }
  }

  forEach(paragraphs, function(paragraph) {
    paragraph.content = map(replaceFootnote,
                            paragraph.content);
  });

  return footnotes;
}     

replaceFootnote 函数对每个片段进行调用。当它得到一个应该保留在原位的片段时,它只返回它,但当它得到一个脚注时,它将该脚注存储在 footnotes 数组中,并返回一个对它的引用。在此过程中,每个脚注和引用都进行了编号。


这给了我们足够多的工具来从文件中提取我们需要的的信息。现在剩下的就是生成正确的 HTML。

很多人认为连接字符串是生成 HTML 的好方法。当他们需要一个链接到(例如)你可以玩围棋的游戏网站时,他们会这样做

var url = "http://www.gokgs.com/";
var text = "Play Go!";
var linkText = "<a href=\"" + url + "\">" + text + "</a>";
print(linkText);

(其中 a 是用于在 HTML 文档中创建链接的标签) ... 这不仅笨拙,而且当字符串 text 碰巧包含尖括号或和号时,它也是错误的。你的网站上会出现奇怪的事情,你看起来会让人尴尬地像个业余爱好者。我们不希望这种情况发生。一些简单的 HTML 生成函数很容易编写。所以让我们来编写它们。


成功生成 HTML 的秘诀是将 HTML 文档视为数据结构,而不是扁平的文本。JavaScript 的对象提供了一种非常容易的方式来模拟这一点

var linkObject = {name: "a",
                  attributes: {href: "http://www.gokgs.com/"},
                  content: ["Play Go!"]};

每个 HTML 元素都包含一个 name 属性,用于给出它所代表的标签的名称。当它具有属性时,它还包含一个 attributes 属性,该属性包含一个存储属性的对象。当它具有内容时,存在一个 content 属性,包含包含在此元素中的其他元素的数组。字符串在我们的 HTML 文档中充当文本片段的角色,因此数组 ["Play Go!"] 表示此链接内部只有一个元素,它是一个简单的文本片段。

直接在这些对象中键入很笨拙,但我们不必这样做。我们提供了一个快捷函数来为我们执行此操作

function tag(name, content, attributes) {
  return {name: name, attributes: attributes, content: content};
}

请注意,由于我们允许元素的 attributescontent 在不适用时为未定义,因此如果不需要,可以省略此函数的第二个和第三个参数。

tag 仍然相当原始,所以我们为常见的元素类型(如链接或简单文档的外部结构)编写快捷方式

function link(target, text) {
  return tag("a", [text], {href: target});
}

function htmlDoc(title, bodyContent) {
  return tag("html", [tag("head", [tag("title", [title])]),
                      tag("body", bodyContent)]);
}

例 6.4

如果需要,回顾示例 HTML 文档,编写一个 image 函数,该函数在给定图像文件的路径时,将创建一个 img HTML 元素。

function image(src) {
  return tag("img", [], {src: src});
}

当我们创建了文档后,它将必须被缩减为字符串。但是,从我们一直在生成的数据结构中构建此字符串非常简单。重要的是要记住要转换文档文本中的特殊字符...

function escapeHTML(text) {
  var replacements = [[/&/g, "&amp;"], [/"/g, "&quot;"],
                      [/</g, "&lt;"], [/>/g, "&gt;"]];
  forEach(replacements, function(replace) {
    text = text.replace(replace[0], replace[1]);
  });
  return text;
}

字符串的 replace 方法创建了一个新的字符串,其中第一个参数中的所有模式都被第二个参数替换,因此 "Borobudur".replace(/r/g, "k") 给出 "Bokobuduk"。不要担心这里的模式语法——我们将在 第 10 章中详细讨论。escapeHTML 函数将必须进行的不同替换放在一个数组中,以便它可以循环遍历它们并将它们逐个应用于参数。

双引号也被替换,因为我们也将使用此函数来处理 HTML 标签属性内的文本。它们将被双引号包围,因此它们内部不能有任何双引号。

四次调用 replace 意味着计算机必须遍历整个字符串四次才能检查和替换其内容。这效率不高。如果我们足够关心,我们可以编写一个更复杂的版本,类似于我们之前看到的 splitParagraph 函数,以便只遍历一次。现在,我们太懒了,不想这样做。同样,第 10 章展示了一种更好的方法来执行此操作。


要将 HTML 元素对象转换为字符串,我们可以使用类似于以下的递归函数

function renderHTML(element) {
  var pieces = [];

  function renderAttributes(attributes) {
    var result = [];
    if (attributes) {
      for (var name in attributes) 
        result.push(" " + name + "=\"" +
                    escapeHTML(attributes[name]) + "\"");
    }
    return result.join("");
  }

  function render(element) {
    // Text node
    if (typeof element == "string") {
      pieces.push(escapeHTML(element));
    }
    // Empty tag
    else if (!element.content || element.content.length == 0) {
      pieces.push("<" + element.name +
                  renderAttributes(element.attributes) + "/>");
    }
    // Tag with content
    else {
      pieces.push("<" + element.name +
                  renderAttributes(element.attributes) + ">");
      forEach(element.content, render);
      pieces.push("</" + element.name + ">");
    }
  }

  render(element);
  return pieces.join("");
}

请注意,in 循环从 JavaScript 对象中提取属性,以便从它们中创建 HTML 标签属性。还要注意,在两个地方,数组被用来累积字符串,然后这些字符串被连接成一个结果字符串。为什么我没有直接从一个空字符串开始,然后使用 += 运算符向它添加内容呢?

事实证明,创建新的字符串,尤其是大型字符串,是相当耗费工作量的。请记住,JavaScript 字符串值永远不会改变。如果你向它们连接内容,就会创建一个新的字符串,旧的字符串将保持不变。如果我们通过连接许多小字符串来构建一个大字符串,则在每一步都需要创建新的字符串,仅在连接下一个片段时才被丢弃。另一方面,如果我们将所有小字符串存储在一个数组中,然后连接它们,则只需要创建一个大字符串。


因此,让我们尝试一下这个 HTML 生成系统...

print(renderHTML(link("http://www.nedroid.com", "Drawings!")));

似乎工作正常。

var body = [tag("h1", ["The Test"]),
            tag("p", ["Here is a paragraph, and an image..."]),
            image("img/sheep.png")];
var doc = htmlDoc("The Test", body);
viewHTML(renderHTML(doc));

现在,我应该警告你,这种方法并不完美。它实际渲染的是 XML,它与 HTML 类似,但结构更严谨。在简单的情况下,例如上面这种情况,不会造成任何问题。但是,有一些东西,它们是正确的 XML,但不是正确的 HTML,它们可能会使试图显示我们创建的文档的浏览器感到困惑。例如,如果你在文档中有一个空 script 标签(用于将 JavaScript 放入页面),浏览器将不会意识到它是空的,并认为它之后的所有内容都是 JavaScript。 (在这种情况下,问题可以通过在标签内部放置一个空格来解决,这样它就不再为空了,并且获得了正确的结束标签。)


例 6.5

编写一个名为 renderFragment 的函数,并使用它实现另一个名为 renderParagraph 的函数,该函数接收一个段落对象(其中已经过滤掉了脚注),并生成正确的 HTML 元素(它可能是一个段落或一个标题,具体取决于段落对象的 type 属性)。

此函数可能对渲染脚注引用很有用

function footnote(number) {
  return tag("sup", [link("#footnote" + number,
                          String(number))]);
}

sup 标签将以“上标”的形式显示其内容,这意味着它将比其他文本更小,并且略高一点。链接的目标将类似于 "#footnote1"。包含“#”字符的链接引用页面内的“锚点”,在本例中,我们将使用它们来确保点击脚注链接会将读者带到页面底部,也就是脚注所在的位置。

用于渲染强调片段的标签是em,普通文本可以不使用任何额外的标签进行渲染。

function renderParagraph(paragraph) {
  return tag(paragraph.type, map(renderFragment,
                                 paragraph.content));
}

function renderFragment(fragment) {
  if (fragment.type == "reference")
    return footnote(fragment.number);
  else if (fragment.type == "emphasised")
    return tag("em", [fragment.content]);
  else if (fragment.type == "normal")
    return fragment.content;
}

我们快完成了。唯一还没有渲染函数的是脚注。为了使"#footnote1"链接正常工作,每个脚注都必须包含一个锚点。在 HTML 中,锚点用a元素指定,它也用于链接。在这种情况下,它需要一个name属性,而不是href

function renderFootnote(footnote) {
  var number = "[" + footnote.number + "] ";
  var anchor = tag("a", [number], {name: "footnote" + footnote.number});
  return tag("p", [tag("small", [anchor, footnote.content])]);
}

那么,下面是这个函数,它在给定格式正确的文件和文档标题后,返回一个 HTML 文档。

function renderFile(file, title) {
  var paragraphs = map(processParagraph, file.split("\n\n"));
  var footnotes = map(renderFootnote,
                      extractFootnotes(paragraphs));
  var body = map(renderParagraph, paragraphs).concat(footnotes);
  return renderHTML(htmlDoc(title, body));
}

viewHTML(renderFile(recluseFile(), "The Book of Programming"));

数组的concat方法可以用来将另一个数组连接到它,类似于+运算符对字符串的操作。


在本章后面的章节中,像mapreduce这样的基本高阶函数将始终可用,并将被代码示例使用。偶尔,会向其中添加一个新的有用工具。在第九章中,我们将开发一个更加结构化的方式来处理这组'基本'函数。


在使用高阶函数时,运算符在 JavaScript 中不是函数,这常常令人恼火。我们在很多地方需要addequals函数。你一定会同意,每次都重新编写这些函数很麻烦。从现在开始,我们将假设存在一个名为op的对象,其中包含这些函数。

var op = {
  "+": function(a, b){return a + b;},
  "==": function(a, b){return a == b;},
  "===": function(a, b){return a === b;},
  "!": function(a){return !a;}
  /* and so on */
};

所以我们可以写reduce(op["+"], 0, [1, 2, 3, 4, 5])来对数组求和。但如果我们需要类似equalsmakeAddFunction的东西,其中一个参数已经有了值呢?在这种情况下,我们又回到了重新编写一个新的函数。

对于这样的情况,被称为'部分应用'的东西非常有用。你想要创建一个新函数,它已经知道了一些参数,并将它传递的任何额外参数视为在这些固定参数之后出现。这可以通过巧妙地利用函数的apply方法来实现。

function asArray(quasiArray, start) {
  var result = [];
  for (var i = (start || 0); i < quasiArray.length; i++)
    result.push(quasiArray[i]);
  return result;
}

function partial(func) {
  var fixedArgs = asArray(arguments, 1);
  return function(){
    return func.apply(null, fixedArgs.concat(asArray(arguments)));
  };
}

我们希望允许同时绑定多个参数,所以asArray函数是必要的,它可以将普通的数组从arguments对象中分离出来。它将它们的内容复制到一个真正的数组中,以便可以在它上面使用concat方法。它还接收一个可选的第二个参数,它可以用来省略开头的一些参数。

还要注意,有必要将外部函数(partial)的arguments存储到一个具有另一个名称的变量中,因为否则内部函数将无法看到它们——它有自己的arguments变量,它会遮蔽外部函数的变量。

现在equals(10)可以写成partial(op["=="], 10),而无需专门的equals函数。你还可以做这样的事情。

show(map(partial(op["+"], 1), [0, 2, 4, 6, 8, 10]));

map将它的函数参数放在数组参数之前的原因是,通过给它一个函数来部分应用map通常很有用。这将函数从操作单个值提升为操作一组值。例如,如果你有一个包含数字数组的数组,并且你想对它们全部平方,你可以这样做。

function square(x) {return x * x;}

show(map(partial(map, square), [[10, 100], [12, 16], [0, 1]]));

当你想要组合函数时,最后一个有用的技巧是函数组合。在本章开头,我展示了一个函数negate,它将布尔运算符应用于调用函数的结果。

function negate(func) {
  return function() {
    return !func.apply(null, arguments);
  };
}

这是一个一般模式的特例:调用函数 A,然后将函数 B 应用于结果。组合是数学中的一个常见概念。它可以用类似这样的高阶函数捕获。

function compose(func1, func2) {
  return function() {
    return func1(func2.apply(null, arguments));
  };
}

var isUndefined = partial(op["==="], undefined);
var isDefined = compose(op["!"], isUndefined);
show(isDefined(Math.PI));
show(isDefined(Math.PIE));

这里我们定义了新的函数,而没有使用function关键字。当你需要创建一个简单的函数传递给例如mapreduce时,这很有用。但是,当一个函数变得比这些例子更复杂时,通常用function直接写出来会更短(更不用说更高效了)。

  1. 像这样...