2

本文为本人参与的前端早读课公众号《成为函数式码农》系列翻译的第五篇,第六篇仍在翻译中,以下为其它五篇的地址。

成为一名函数式码农系列之一

成为一名函数式码农系列之二

成为一名函数式码农系列之三

成为一名函数式码农系列之四

成为一名函数式码农系列之六


原文地址 译者:墨白 校对:野草

刚开始学习函数式编程时,理解函数式编程的核心概念是最重要的,有些时候也是最难的一步。但其实没必要一定如此。这个没有正确的答案。

引用透明(referential transparency)

引用透明是一个富有想象力的优秀术语,它是用来描述纯函数可以被它的表达式安全的替换。通过一个例子来帮助理解这个术语。

在代数中,你有一个下面的公式:
y = x + 10

然后被告知:
x = 3

你可以把x的值带入到之前的方程中,得到:
y = 3 + 10

注意这个方程仍然是有效的。我们可以利用纯函数做一些相同类型的替换。

下面是一个Elm的方法,在传入的字符串两边加上单引号:

quote str =
  "'" ++ str ++ "'"

下面是使用它的代码:

findError key =
  "Unable to find " ++ (quote key)

代码中,当查询key值失败时,findError构建了一个报错信息。

因为quote方法是纯函数,我们可以简单地将quote函数体(仅仅只是个表达式)替换掉在findError中的方法调用:

findError key =
  "Unable to find " ++ ("'" ++ str ++ "'")

这就是我所说的“反向重构”(它对我而言有更多的意义),一个可以被程序员或者程序(例如编译器和测试程序)用来推理代码的过程。

这在推导递归函数时尤其有用。

执行顺序

大部分程序是单线程的,即有且只有一段代码在当前执行。即使你有多线程的程序,大部分程序仍然阻塞等待I/O去完成,例如,file,network等等。

这也是当我们编写代码的时候,我们很自然考虑按次序来编写代码:

1. 拿到面包
2. 把2片面包放入烤面包机
3. 选择加热时间
4. 按下开始按钮
5. 等待面包片弹出
6. 取出烤面包
7. 拿黄油
8. 拿黄油刀
9. 制作黄油面包

在这个例子中,有两个独立的操作:拿黄油以及加热面包。它们在step9时开始变得相互依赖。

我们可以在step1到6的时候做step7和8因为它们之间相互独立。

但当我们开始做的时候,事情开始复杂了:

线程1:
--------
1. 拿到面包
2. 把2片面包放入烤面包机
3. 选择加热时间
4. 向下推杆
5. 等待面包片弹出
6. 取出烤面包

线程2:
1. 拿黄油
2. 拿黄油刀
3. 等待线程1完成
4. 制作黄油面包

如果线程1失败,线程2怎么办?怎么协调这两个线程?烤面包这一步骤在哪个线程运行:线程1,线程2或者两者?

我们完全可以不去思考这些复杂的,只让我们的程序单线程运行,这更简单。

但是,只要能够提升我们程序的效率,那就是值得的,我们要付出努力来写好多线程程序。

然而,关于多线程,存在两个主要的问题。首先,多线程程序非常难写、读、理解、测试以及debug。

第二,一些语言,例如JavaScript,并不支持多线程,就算有些语言支持多线程,对它的支持也很弱。

但是,如果运行顺序并不重要并且一切都是并行执行的呢?

尽管这听起来有些疯狂,但其实并不像听起来那么混乱。让我们来看一下Elm的代码来形象的理解它:

buildMessage message value =
    let
        upperMessage =
            String.toUpper message
        quotedValue =
            "'" ++ value "'"
    in
        upperMessage ++ ": " ++ value

这里的buildMessage接受messagevalue,然后,生成大写的message,冒号和在单引号中value

注意到upperMessagequotedValue是独立的。我们怎么知道的呢?

对于独立,有两点必须必须满足。首先,它们必须是纯函数。这很重要,因为它们必须不会被其它方法的运行影响到。

如果它们不是纯函数,那么我们永远不可能知道它们是否独立。那种情况下,我们不得不依赖于它们在程序中调用的顺序来确定它们的执行顺序。这是所有命令式语言的工作原理。

第二点必须满足的就是一个函数的输出值不能作为其它函数的输入值。如果存在这种情况,那么我们不得不等待其中一个完成才能执行下一个。

在上面的代码示例中,upperMessagequotedValue两者都是纯的并且没有一个需要依赖其它的输出。

因此,这两个方法可以在任何顺序下执行。

编译器可以自行决定执行的顺序,而不需要程序员的人为参与。这只有在纯函数式编程语言中才适用,因为在一般编程语言中是很难去(不是不可能)预估不同顺序带来的副作用。

纯函数式语言里面,执行的顺序是可以由编译器决定的

鉴于无法一再加快CPU的运行速度,这一做法是非常有利的。生产商也不断增加CPU内核芯片的数量,这就意味着可以在硬件这一层面实现代码的并行处理。

但遗憾的是,我们无法通过命令式的语言充分利用这些芯片,而只是发挥了它们很小一部分的功能。如果要充分利用就要彻底改变程序的体系结构。

使用纯函数语言,我们就有希望在不改变任何代码的情况下充分地发挥CPU芯片的功能并取得良好成效。

类型注释

在静态类型语言中,类型是内联定义的。 这里通过一些Java代码来说明:
<pre>
public static String quote(String str) {

return "'" + str + "'";

}
</pre>
注意类型是如何同函数定义内联在一起的。当你有泛型时,它变的更糟:
<pre>
private final Map<Integer, String> getPerson(Map<String, String> people, Integer personId) {
// ...
}
</pre>

我已经给定义类型的字段加粗了以使其更加显眼,但它们看来去仍然和函数定义纠缠在一起。你不得不很小心地去找到这些变量的名字。

如果是用神奇的动态类型语言,这不是一个问题。在JavaScript中,我们这样写代码:

var getPerson = function(people, personId) {
    // ...
};

这样的代码没有任何的繁琐的类型信息更易阅读。唯一的问题就是我们放弃了类型检测的安全特性。我们能够很简单的传入这些参数,例如,一个Number类型的people以及一个Object类型的personId

除非程序运行,否则我们发现不了这样的问题,而这样的问题也可能在代码上线之后几个月才能出现。而这样的问题在Java中不会出现,因为它无法通过编译。

但是,假如我们能同时拥有这两者的优异点呢?JavaScript的语法简单性以及Java的安全性。

其实我们是可以的。下面是一个带类型注释的用Elm写的方法:

add : Int -> Int -> Int
add x y =
    x + y

请注意类型信息是在单独的代码行上面的。而正是这样的分割使得其有所不同。

现在你可能认为类型注释有错字。 我知道我第一次见到它的时候。 我认为第一个 - >应该是一个逗号。 但并没有错别字。

当你看到它加上隐含的括号,代码就清晰多了:

add : Int -> (Int -> Int)

这表示,add是一个方法,它接受单个Int类型的参数,返回一个方法,这个方法接受一个Int类型的参数,并且返回一个Int类型的值。

这里还有一个带括号类型注释的代码:

doSomething : String -> (Int -> (String -> String))
doSomething prefix value suffix =
    prefix ++ (toString value) ++ suffix

上面的代码表示doSomething是一个方法,它接受单个类型为String的参数并且返回一个函数,返回的函数接受单个类型为Int的参数,并且再次返回一个函数,这次返回的函数接受一个类型为String的参数,并且返回一个String

注意为什么每个方法都只接受一个参数呢?这是因为每个方法在Elm里面都是柯里化。

由于括号总是隐含在右边,它们并不是必须。所以我们可以简写成:

doSomething : String -> Int -> String -> String

当我们传递函数作为参数时,括号是必要的。 没有它们,类型注释将是不明确的。 例如:

takes2Params : Int -> Int -> String
takes2Params num1 num2 =
    -- do something

与下面的并不同:

takes1Param : (Int -> Int) -> String
takes1Param f =
    -- do something

takes2Param是一个接受两个参数的函数,两个参数都是Int类型。然而,takes1Param需要接受一个参数,这个参数为函数,而函数需要接受两个Int类型的参数。

下面是map的类型注释:

map : (a -> b) -> List a -> List b
map f list =
    // ...

上面需要括号是因为f(a -> b)类型,即接受类型为a的单个参数并返回类型为b的某个函数.

这里类型a是代指任何类型。 当类型是大写时,它是一个显式类型,例如,String。 当类型为小写时,它可以是任何类型。 这里的a可以是String,但也可以是Int。

如果你看到(a -> a),那么,就是指input类型以及output类型是相同的。它们到底是什么类型并不重要,重要的是它们必须匹配。

但在map这一示例中,有这样一段(a -> b)。这意味着它既能返回一个不同的类型,也能返回一个相同的类型。

但是一旦a的类型确定了,(TODO the whole signature)a在整段代码中就必须为这个类型。例如,如果a是一个Intb是一个String,那么这段代码就相当于:

(Int -> String) -> List Int -> List String

上面就是所有的a都被替换成Int,所有的b都被替换成String

List Int类型意味着一个值都为Int类型的列表,List String意味着一个值都为String类型的列表。如果你已经在Java或者其他的语言中使用过泛型,那么这个概念你应该是熟悉的。

在这个系列文章的最后,我将会探讨如何使用你在日常生活中学到的东西,例如,函数式编程以及Elem。


望舒
2.3k 声望133 粉丝

an unexamined life is a life not worth living