如何成为一位函数式编程爱好者(Part 5)

发布于 Heng温

踏出理解函数式编程概念的第一步是最重要的一步,有时也是最难的一步。不过也不一定,取决于你们的思考方式。

引用透明性

引用透明是个有趣的概念,它是指一个纯函数能安全地使用其表达式替换。下面用一个例子来说明。

在代数中如果有下面的公式:

y = x + 10

如果给出:

x = 3

你可以将x带回公式得到:

y = 3 + 10

等式依然成立。我们也可以在纯函数中做同样的变换。

这有一个Elm的纯函数,用来在给定字符串两侧添加单引号:

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

然后下面是一段使用了它的代码:

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

这里的findError在无法找到key时构建了一个错误信息。

因为quote函数是纯函数,我们可以将findError中的函数调用换成quote函数本身(只是一个表达式):

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

我将这叫做反向重构(我认为这么说更好理解),一个让程序员或程序(比如编辑器和测试程序)使用的过程。

这对分析递归函数尤为有用。

执行顺序

大多数程序是单线程的,即一次有且仅有一段代码被执行。即时你有,大多数线程也必须为等待I/O完成阻塞,比如文件操作,网络操作等。

当我们写代码时,习惯用自然顺序来考虑执行步骤:

1. Get out the bread
2. Put 2 slices into the toaster
3. Select darkness
4. Push down the lever
5. Wait for toast to pop up
6. Remove toast
7. Get out the butter
8. Get a butter knife
9. Butter toast

在这个例子中,有两个独立的操作:拿黄油和烤面包。只有在步骤9时才相互依赖。

我们可以同时做7,8和1~6,因为他们是相互独立的。

但我们这么做时,事情就复杂了:

Thread 1
--------
1. Get out the bread
2. Put 2 slices into the toaster
3. Select darkness
4. Push down the lever
5. Wait for toast to pop up
6. Remove toast
Thread 2
--------
1. Get out the butter
2. Get a butter knife
3. Wait for Thread 1 to complete
4. Butter toast

如果线程1失败了,线程2会怎么样?有什么方法可以协调这两个线程吗?哪个线程拥有吐司呢?线程1,线程2还是两者都有?

不考虑这些复杂的定西,让程序变成单线程会更简单。

但到了需要不遗余力地提搞程序效率的地步时,我们必须付出努力来写多线程软件。

然而,多线程有两个主要问题。多线程程序难写,难读,难推测,测试和调试。

第二,一些语言,比如JavaScript,不支持多线程,其他语言可能支持,但支持得很糟糕。

但是假如顺序不重要,所有代码都并行执行呢?

虽然听上去很疯狂,但实际并没有那么混乱。让我们看一段Elm代码来解释:

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

这个buildMessage接收messagevalue两个参数,然后将message大写,在value两侧加单引号,然后在两者间加个冒号。

注意upperMessagequotedValue是相互独立的。我们怎么来证实?

对于这种独立性,有两件事是必须的。首先,他们必须是纯函数。这很重要,因为他们的执行不会相互影响。

如果他们不是纯函数,我们永远无法知道他们相互独立。如果是那种情况,就必须依赖他们在程序中的调用顺序来决定他们的执行顺序。指令式语言就是这么工作的。

对独立性而言第二个重点就是一个函数的输出不能用作另一个函数的输入。如果那样的话,第二个函数开始之前必须要等第一个完成。

这个例子中,upperMessagequotedValue都是纯函数且两者的输出互不影响。

所以,这两个函数可以按任意顺序执行。

编译器在不需要任何程序的帮助下进行分析。这只有在纯函数语言中才有可能,因为去判断副作用的影响是很困难甚至不可能的。

编辑器能分析出纯函数语言中代码的执行顺序。

考虑到CPU不会越来越快,这种特性有很有优势。而且,制造商也在增加越来越多的核。这意味着代码可以在硬件层面上并行执行。

不幸的是,在指令式语言中,除了一些粗糙的方式,我们无法发挥这些核的全部优势。但如果这样做需要彻底改变程序架构。

在纯函数语言中,我们具有在更好粒度上自动挖掘CPU优势的能力,不需要改变任何一行代码。

类型标注

在静态类型语言中,类型在行内定义。比如下面的Java代码:

public static String quote(String str) {
    return "'" + str + "'";
}

类型和函数定义在同一行。如果有泛型的话会更糟糕:

private final Map<Integer, String> getPerson(Map<String, String> people, Integer personId) {
   // ...
}

我已经加粗了类型加以区分,但是仍然会干扰到函数定义。你必须小心地寻找变量名。

在动态类型语言中就没有这个问题。在JavaScript里,我们这样写代码:

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

没有了烦人的类型信息读起来更容易。唯一的问题就是我们放弃了类型的安全性。我们可能传入相反的参数,给people传入Number类型,给personId传入Object类型。

在程序执行前我们都不会发现,可能数月之后将其投出生产环境才能发现。这中情况不会发生在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和另一个Int,然而,takes1Param接收一个参数,一个接收Int和另一个Int的函数。

这是一个map的类型标注:

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

这里的括号是必须的,因为f(a -> b)类型,即一个接收a类型参数,返回b类型的函数。

类型a可以是任意类型。如果一个类型是大写的,说明是一个明确类型,比如String。问一个类型是小写的,表示它可以是任何类型。这里的a可以是String,也可以是Int

如果你看到(a -> a),这表示输入类型和输出类型必须相同。他们是什么类型不重要,但是必须相同。

但是在map这个例子中,我们使用的(a -> b)。这意味着它可以返回与入参不同的类型,也可以返回和入参相同的类型。

但一旦a的类型确定了,a的所有标注都会变成那个类型。比如,如果aIntbString,那这个类型标注等价于:

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

所有的a都被Int替换,所有的b都被String替换。

List Int类型表示一个包含多个Int类型值的列表,List String表示包含多个String类型值的列表。如果你在Java或者其它语言中使用过泛型,它们的概念类似。

记在脑子里

这次就到这。

在这系列文章的最后一部分,我将告诉你如何将之前学的东西用到日常工作中,即函数式JavaScript和Elm

本文根据@Charles Scalfani的《So You Want to be a Functional Programmer (Part 5)》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://medium.com/@cscalfani/so-you-want-to-be-a-functional-programmer-part-5-c70adc9cf56a#.vqnc36qww

Heng温

前端开发,音乐,动漫,技术控,喜欢折腾新鲜事物,以不断学习的态度,在前端的路上走下去。

如需转载,烦请注明出处:https://www.fedev.cn/javascript/so-you-want-to-be-a-functional-programmer-part-5.htmlair max 90 essential metallic silver