函数式编程简介

各位朋友,大家好,我叫陈天,曾经在 Juniper 做了近十年的网络安全,也是社交旅游产品途客圈的创始人,如今在旧金山的一家创业公司 adRise 做 VP of Engineering。我略懂 c,python,javascript,写过点 golang,erlang,elixir,clojure,purescript,什么都知道一点。今天我跟大家卖个萌,分享一下『函数式编程』。

这个话题在过去的两三年逐渐火热起来,你可以看到一大批的新兴语言:scala,clojure,elixir,swift 等,还有很多第三方的库都是函数式编程的忠实拥趸,而混迹江湖多年的 C++ / Java,在各自较新的语言版本,如 C++ 11,Java 8,也都放下 OOP 的面子,强势引入了 lambda,以更加谦卑的姿态拥抱函数式编程。

除了编程语言,大数据处理(比如说ETL),分布式计算,也处处都能发现函数式编程的影子。

什么是函数式编程?它有什么优点?

在谈到函数式编程及其有什么优点之前,我们先看我们常见的编程方式,imperative programming(命令式编程)有什么缺点。

function getData(col) {
    var results = [];
    for (var i=0; i < col.length; i++) {
        if (col[i] && col[i].data) {
            results.push(col[i].data);
        }
    }
    return results;
}

这段代码很简单,它过滤一个传入的数组,取出里面每个元素的 data 域,然后插入新的数组返回。相信很多人都会撰写类似的代码。它有很多问题:复制代码
  • 我们在告诉计算机怎么样一步步完成一件事情。我们引入了循环,使用一个无关紧要的局部变量 i 控制循环(或者迭代器)。事实上我根本不需要关心这个变量怎么开始,怎么结束,怎么增长,这和我要解决的问题无关。

  • 我们引入了一个状态 results,并不断变更这个状态。在每次循环的时候,它的值都会发生改变。

  • 当我们的问题稍微改变的时候,比如我要添加一个函数,返回有关 data 长度的一个数组,那么我们需要仔细研读已有的代码,搞清楚整个逻辑,然后新写一个函数(多数情况下,工程师会启用「复制-粘贴-修改」大法。

  • 这样的代码撰写正确并不容易。

  • 这样的代码可读性很差,一旦内部状态超过 10 个,且互相依赖,要读懂它的逻辑并不容易。

  • 这样的代码无法轻易复用。

如果是函数式编程,你大概会这么写:

function getData(col) {
    return col
        .filter(item => item && item.data)
        .map(item => item.data);
}复制代码

我先对要处理的数组进行 filter,然后 map,得到结果。这段代码简洁,明了,如果你了解 filter / map,几乎很难写错。

而且你很容易重构,使其变得更加通用:

function extract(filterFn, mapFn, col) {
    return col => col.filter(filterFn).map(mapFn);
}

const validData = item => item && item.data;
const getData = extract.bind(this, validData, item => item.data);
const getDataLength = extract.bind(this, validData, item => item.data.length);复制代码

相比之前的代码,结构更清晰,更容易扩充,更符合 open-close 原则。

讲到这里我们大致已经能看出函数式编程的一些特点:

  • 提倡组合(composition)

  • 每个函数尽可能完成单一的功能

  • 屏蔽细节,告诉计算机我要做什么,而不是怎么做。我们看 filter / map,它们并未暴露自身的细节。一个 filter 函数的实现,在单核 CPU 上可能是一个循环,在多核 CPU 上可能是一个 dispatcher 和 aggregator,但我们可以暂时忽略它的实现细节,只需了解它的功能即可。

  • 尽可能不引入或者少引入状态。

这些特点运用得当的话,能够为软件带来:

  • 更好的设计和实现

  • 更加清晰可读的代码。由于状态被大大减少,代码更容易维护,也带来更强的稳定性。

  • 在分布式系统下有更好的性能。函数式编程一般都在一个较高的层次进行抽象,map / filter / reduce 就是其基础指令,如果这些指令为分布式而优化,那么系统无需做任何改动,就可以提高性能。

  • 使得惰性运算成为可能。在命令式编程中,由于你明确告诉了 CPU 一步步该怎么操作,CPU 只能俯首听命,优化的空间已经被挤压;而在函数式编程里,每个函数只是封装了运算,一组数据从输入经历一系列运算到输出,如果没有人处理这些输出,则运算不会被真正执行。

我们看一个例子:

lazy(bigCollection)
    .filter(validItem)
    .map(processItem)
    .skip(2)
    .take(3)复制代码

对于上述的代码,无论 bigCollection 有多大,循环都只会执行有限的次数。

我们再看一个例子:

const Stream = Rx.Observable;
Stream.from(urls)
    .flatMap(url => 
        new Stream.create(stream => {
            request(url, (error, response, body) => {
                if (error) return stream.onError(error);
                stream.onNext({ url, body });
            });
        })
        .retry(3)
        .catch(error => Stream.Just({ url, body: null }))
    )复制代码

这段代码用到了 Observable,Observable 的概念我们先放在一边,感兴趣的同学可以去看 FRP(functional reactive programming),这里我们认为 Observable 就是一个 stream。我们有一个 url 列表,需要获取每个 url 对应的 response body。每个 request 都至多 retry 3 次,如果 3 次还失败,就返回空。

同样的代码如果你用传统的方式去撰写,逻辑和脉络不会如此清晰。

有了以上的这些例子,相比大家对函数式编程有了一个初步的认识。现在我们回归本源,讲讲什么是函数(function)。

在我们初高中学函数的时候,我们知道函数有作用域和值域。对于一个函数 f(x) = x * x,如果其作用域是一切整数,那么函数的值域就是一切正整数。这里,整数和正整数就是这个函数输入和输出的类型。

我们这里讲的函数和数学里的函数几乎等价,都是将一个域的值(定义域)经过变换(transformation)映射到另一个域的值(值域)。数学函数最大的特点是如果一个函数 f(x) 的值域是另一个函数 g(x) 的定义域,那么这两个函数可以组合:

g(f(x)) = (g f)(x)

h(j(k(x, y, ...)))
   = h((j k)(x, y, ...))
   = (h j k)(x, y, ...)
   = (h j) (k(x, y, ...))复制代码

函数式编程也吸收了组合(composition)的特点。组合这个词大家听起来耳熟是不是,对滴,在面向对象编程中,最佳实践之一就是:多组合,少继承。这是多么奇怪的最佳实践啊,继承是面向对象的核心功能,但我们却要费尽心思尽可能少用这个核心功能?这是应为继承在不断降低代码的复用程度,如果要 DRY(Don't Repeat Yourself),要么使用 Mixin 做功能上的集成,要么把重复的代码移到基类,但这又会有问题,你也许并不能改写基类,或者即使你能改写基类,为了一个上层的功能改写基类又违反 Open-close principle,真是左右为难。所以我们提倡组合。

组合是一个很有威力的工具。上述的 h,j,k 是三个基本的函数,通过组合,我们能够衍生出一系列新的函数:(j k),(h j),(h j k),就像搭建乐高积木一样,大大扩展了功能。

很多函数式编程语言都提供专门的语法,如 compose(clojure),<<<(haskell 等。

上面的例子我们已经用到了组合,我们再看一个例子:

const getComponentPath = (name, basePath) => path.join(basePath, name);
const getModelPath = getComponentPath.bind(null, 'models');
const getConfigPath = getComponentPath.bind(null, 'config');

const getConfigFile = (p, name) => path.join(getConfigPath(p), name);

const readTemplate = filename => fs.readFileAsync(filename, ENCODING);

const processTemplate = params =>
    promise => promise.then(content => mustache.render(content, params));

const writeFile = filename => 
    promise => promise.then(
        content => fs.writeFileAsync(filename, content, ENCODING)
    );

const processConfigTemplate = R.pipe(
      getConfigFile,
      readTemplate,
      processTemplate(PARAMS),
      writeFile(getConfigFile(topDir, 'config.yml'))
);

processConfigTemplate(topDir, 'config.mustache')
    .then(() => console.log('done!'))
    .catch(err => console.log(err));复制代码

这段代码的功能非常简单,读取项目里的 config 目录下的 config.mustache 文件,生成 config.yml。很多人上手写这个功能时肯定是将其写成一个函数,而在这里我却写下了九个函数,与其直接相关的有六个函数。主体功能 processConfigTemplate 是若干的函数的直接组合。这里没有使用 compose,而是使用了另一个概念 pipe。它也用于组合函数,只不过它组合的方向和 compose 正好相反,比较方便书写。你可以这里理解 pipe,一组输入先后经过 pipe 中的每个函数,上一个函数的输入作为下一个函数的输出,这样不断执行下去,最后得到一个输出。

这样的代码可读性非常强,几乎不用注释,一个刚接手代码的javascript程序员就能看懂;而且它的复用性很强,里面的任何一个部分都可以用在其他地方,而且如果需求发生改变,比如我们不用 mustache 模板,改用 handlebar,整个逻辑里面,我们只需要替换,或者新加一个 processTemplate 函数。由于功能单一,每个函数的可测性非常强,很容易写 test case。如果让你维护这样的代码,那真是世界上最幸福的事情。

有同学可能会说,这样的代码怎么调试?我们回过头来看这个代码,自己想一想,这样的代码需要调试么?只要编译(抱歉,javascript 没有编译阶段)通过,你就几乎可以保证,这个代码是可以工作的。我是边写这篇手稿边写的代码,为简洁起见,没有提供一些库加载的语句,但我相信这个代码写下来,没有什么大问题。很多初入行的工程师非常依赖 IDE 的单步跟踪功能,我可以负责任地告诉你,随着你的成长,一定要减少直到消除对单步跟踪的依赖,未来的程序是单步跟踪无法调试的。如果你的系统运行在一个分布式的环境,上百台机器,上千个 core,你怎么 step in / step out?不现实。我觉得:

优秀的工程师脑袋里(或者在纸上)构思出代码的脉络,一气呵成,一行行将代码写出来;而蹩脚的工程师则需要依赖 IDE 提供的单步跟踪功能一行行把代码调出来。一个是写,一个是调,高下立现。

你之所以需要单步跟踪来调程序,是因为程序中出现来太多的中间状态,你无法追踪这些中间状态的值,所以需要借助 IDE 的力量。但函数式编程能够有效地帮助你控制甚至消灭中间状态,像上面的例子,你都没有中间状态了,还跟踪什么?

我们再看这段程序里面为了方便函数间组合出现的一些概念:

  1. curry

  2. closure

库里的照片

先看 curry。话说现在 curry 的手感火热得要死,各种无理由要人命的三分…抱歉,我跑题了,此处要将的 curry 不是勇士的 curry,而是函数式编程里的柯里化,在 wikipeida 里,是这么介绍 currying 的:

In mathematics and computer science, currying is the technique of translating the evaluation of a function that takes multiple arguments (or a tuple of arguments) into evaluating a sequence of functions, each with a single argument.

通俗地说就是把有多个参数的函数转换成一系列只有一个参数的函数。在 javascript 里面,可以使用 bind 进行柯里化。

比如这句:

const getConfigPath = getComponentPath.bind(null, 'config');复制代码

在进行函数式编程的时候,函数参数的位置很有讲究,需要精心安排,把辅助性的,可以柯里化的参数放在前面,以方便绑定。

另一种柯里化的方法,或者说,更正统的方法是通过高阶函数来完成。高阶函数是指一个函数可以接受另一个函数作为参数,或者返回一个函数作为结果。我们看这句:

const processTemplate = params =>
    promise => promise.then(content => mustache.render(content, params));复制代码

writeFile 这个函数里,我们接受 params 作为参数,返回一个接受 Promise 作为参数,并返回 Promise 的函数。高阶函数在函数式编程里面非常重要,事实上,如果你平日里使用 javascript 开发,尤其是 nodejs,几乎每天都会跟高阶函数打交道。

当一个函数返回另一个函数时,我们发现,返回的那个函数的函数体里面使用了 processTemplate 传进来的参数。这是函数式编程中又一个很重要的概念:闭包(closure)。闭包是指变量的作用域在整个 lexical scope 里始终有效,比如说函数A返回函数B,函数B可以在任何时候访问A的局部变量,包括参数,这就是闭包。闭包是函数式编程经常使用的模式,他能帮助你延迟计算,把计算推迟到需要的时候再进行。

什么是延迟计算呢?我们看一个例子:

const authMiddleware = config =>
    (req, res, next) => {
        if (config.auth.strategy === 'jwt') {
            const token = getToken(req.headers);
            jwt.verify(token, secret, ...);
        }
    }复制代码

用过 expressjs 的人大概能看出来,这个函数用来生成一个 expressjs 的 middleware。正常你写 middleware 时,有全部的上下文,你会直接这么写:

// config is define in this module somewhere
app.use((req, res, next) => {
    if (config.auth.strategy === 'jwt') {
        ...
    }
});复制代码

但如果你在写一个框架,在撰写这个 middleware 时,使用者还没有创建 app,也没有生成 config 对象,你唯一能做的就是,假定调用者会传给你一个合法的 config 对象,你为她返回一个 middleware 供其使用。这就是把计算延迟到需要的时刻。

清楚了柯里化和闭包后,我们再看它们对组合的贡献。在 processConfigTemplate 里面:

const processConfigTemplate = R.pipe(
      getConfigFile,
      readTemplate,
      processTemplate(PARAMS),
      writeFile(getConfigFile(topDir, 'config.yml'))
);复制代码

我们要计算 config 的文件名,获取其内容,使用参数处理模板,写成新的文件,所有的中间过程,如果想要能够把它们组合起来,那就必须让彼此之间的输入输出适配(adapt),而要完美地适配,少不了柯里化 / 闭包这样的概念。这样,processTemplatewriteFile 这样的函数不失其通用性,在其它场合也能使用。有了这些基本函数,我可以轻易地提供 processJadeTemplateprocessMustacheTemplatewriteDb 等等,组合出来各种各样的功能逻辑。

我们停下来想一想,如果是 OOP,你会怎么做?你怎么设计 interface,如何定义各种类,和类的行为,怎么抽象,用什么设计模式,adapter,chain of responsibility,等等。然后你会提醒自己,不要过度设计;之后需要扩展的时候,你又不得不破坏 open close principle,或抽象或重构。总之,为了达到同样的目的,OOP 就像宗教改革前的天主教,处处是繁文缛节,干点什么都像在举行仪式;而 FP 则像马丁路德的新教,简单,明了。

函数式编程有一些非常有趣的特性,比如说,如果你的函数的输入参数和输出参数有相同的类型,这是一种很特殊的函数,叫 monoid。比如说,javascript 里面的 Promise,它接受一个 Promise 并返回另一个 Promise,所以它是一个 monoid,而它又满足另外一些定律,它还是一个 Monad,或者更进一步,Either Monad。Promise 封装了一个值,在将来的某个时刻可以有成功(data)和失败(throw error)两种状态(有点薛定谔的猫的感觉)。这也是为什么正常你这样写代码:

fs.openFile(filename, encoding, (err1, data1) => {
    if (err1) return console.log(err1);
    processTemplate(data1, (err2, data2) => {
        if (err2) return console.log(err2);
        writeFile(filename, data2, encoding, (err3, data3) => {
            if (err3) return console.log(err3);
            console.log('Done!');
        });
    });
});复制代码

而使用 Promise,代码的结构可以简化为:

fs.openFileAsync(filename, encoding)
    .then(content => processTemplate(content))
    .then(content => writeFile(filename, content, encoding))
    .then(() => console.log('done!'))
    .catch(err => console.log(err));复制代码

我这里想说的不仅仅 Promise 可以用来解决 callback hell 这样的问题,而是:Promise 把我们一直以来头疼的错误处理给统一了!你可以一气呵成地组合你的代码,然后在一个统一的位置进行错误处理。这就是是 Monad 的威力!如果不是函数式编程的强大能力,你无法享受如此简洁的代码(仔细对比一下两种代码)。平日工作中,类似的结构有很多,不光是 callback hell,还有 if-else hell 等等。

时间关系,这个话题就不扯开了讲。

最后提一点,函数式编程有很多很多概念,如 Monad,monoid,functor,applicative,curry 等等,会把你绕得云里雾里的。但所有这些概念被引入的本质都是为了组合(composition),在函数式编程的世界了,组合是王道,是无处不在的。


复制代码

摘自:https://mp.weixin.qq.com/s?__biz=MjM5ODQ2MDIyMA==&mid=402307374&idx=1&sn=2ff35dc5bcadab0bbeae626f48f4e18e#rd

抽象的能力 https://zhuanlan.zhihu.com/p/20617201


你可能感兴趣的:(javascript,java,c/c++)