分类

Pointfree 编程风格指南

作者: 阮一峰

日期: 2017年3月13日

本文要回答一个很重要的问题:函数式编程有什么用?

目前,主流的编程语言都不是函数式的,已经能够满足需求。为何还要学函数式编程呢,只为了多理解一些新奇的概念?

一个网友说:

"函数式编程有什么优势呢?"

"我感觉,这种写法可能会令人头痛吧。"

很长一段时间,我根本不知道从何入手,如何将它用于实际项目?直到有一天,我学到了 Pointfree 这个概念,顿时豁然开朗,原来应该这样用!

我现在觉得,Pointfree 就是如何使用函数式编程的答案。

一、程序的本质

为了理解 Pointfree,请大家先想一想,什么是程序?

上图是一个编程任务,左侧是数据输入(input),中间是一系列的运算步骤,对数据进行加工,右侧是最后的数据输出(output)。一个或多个这样的任务,就组成了程序。

输入和输出(统称为 I/O)与键盘、屏幕、文件、数据库等相关,这些跟本文无关。这里的关键是,中间的运算部分不能有 I/O 操作,应该是纯运算,即通过纯粹的数学运算来求值。否则,就应该拆分出另一个任务。

I/O 操作往往有现成命令,大多数时候,编程主要就是写中间的那部分运算逻辑。现在,主流写法是过程式编程和面向对象编程,但是我觉得,最合适纯运算的是函数式编程。

二、函数的拆分与合成

上面那张图中,运算过程可以用一个函数fn表示。

fn的类型如下。


fn :: a -> b

上面的式子表示,函数fn的输入是数据a,输出是数据b

如果运算比较复杂,通常需要将fn拆分成多个函数。

f1f2f3的类型如下。


f1 :: a -> m
f2 :: m -> n
f3 :: n -> b

上面的式子中,输入的数据还是a,输出的数据还是b,但是多了两个中间值mn

我们可以把整个运算过程,想象成一根水管(pipe),数据从这头进去,那头出来。

函数的拆分,无非就是将一根水管拆成了三根。

进去的数据还是a,出来的数据还是bfnf1f2f3的关系如下。


fn = R.pipe(f1, f2, f3);

上面代码中,我用到了 Ramda 函数库的pipe方法,将三个函数合成为一个。Ramda 是一个非常有用的库,后面的例子都会使用它,如果你还不了解,可以先读一下教程

三、Pointfree 的概念


fn = R.pipe(f1, f2, f3);

这个公式说明,如果先定义f1f2f3,就可以算出fn。整个过程,根本不需要知道ab

也就是说,我们完全可以把数据处理的过程,定义成一种与参数无关的合成运算。不需要用到代表数据的那个参数,只要把一些简单的运算步骤合成在一起即可。

这就叫做 Pointfree:不使用所要处理的值,只合成运算过程。中文可以译作"无值"风格。

请看下面的例子。


var addOne = x => x + 1;
var square = x => x * x;

上面是两个简单函数addOnesquare

把它们合成一个运算。


var addOneThenSquare = R.pipe(addOne, square);

addOneThenSquare(2) //  9

上面代码中,addOneThenSquare是一个合成函数。定义它的时候,根本不需要提到要处理的值,这就是 Pointfree。

四、Pointfree 的本质

Pointfree 的本质就是使用一些通用的函数,组合出各种复杂运算。上层运算不要直接操作数据,而是通过底层函数去处理。这就要求,将一些常用的操作封装成函数。

比如,读取对象的role属性,不要直接写成obj.role,而是要把这个操作封装成函数。


var prop = (p, obj) => obj[p];
var propRole = R.curry(prop)('role');

上面代码中,prop函数封装了读取操作。它需要两个参数p(属性名)和obj(对象)。这时,要把数据obj要放在最后一个参数,这是为了方便柯里化。函数propRole则是指定读取role属性,下面是它的用法(查看完整代码)。


var isWorker = s => s === 'worker';
var getWorkers = R.filter(R.pipe(propRole, isWorker));

var data = [
  {name: '张三', role: 'worker'},
  {name: '李四', role: 'worker'},
  {name: '王五', role: 'manager'},
];
getWorkers(data)
// [
//   {"name": "张三", "role": "worker"},
//   {"name": "李四", "role": "worker"}
// ]

上面代码中,data是传入的值,getWorkers是处理这个值的函数。定义getWorkers的时候,完全没有提到data,这就是 Pointfree。

简单说,Pointfree 就是运算过程抽象化,处理一个值,但是不提到这个值。这样做有很多好处,它能够让代码更清晰和简练,更符合语义,更容易复用,测试也变得轻而易举。

五、Pointfree 的示例一

下面,我们来看一个示例。


var str = 'Lorem ipsum dolor sit amet consectetur adipiscing elit';

上面是一个字符串,请问其中最长的单词有多少个字符?

我们先定义一些基本运算。


// 以空格分割单词
var splitBySpace = s => s.split(' ');

// 每个单词的长度
var getLength = w => w.length;

// 词的数组转换成长度的数组
var getLengthArr = arr => R.map(getLength, arr); 

// 返回较大的数字
var getBiggerNumber = (a, b) => a > b ? a : b;

// 返回最大的一个数字
var findBiggestNumber = 
  arr => R.reduce(getBiggerNumber, 0, arr);

然后,把基本运算合成为一个函数(查看完整代码)。


var getLongestWordLength = R.pipe(
  splitBySpace,
  getLengthArr,
  findBiggestNumber
);

getLongestWordLength(str) // 11

可以看到,整个运算由三个步骤构成,每个步骤都有语义化的名称,非常的清晰。这就是 Pointfree 风格的优势。

Ramda 提供了很多现成的方法,可以直接使用这些方法,省得自己定义一些常用函数(查看完整代码)。


// 上面代码的另一种写法
var getLongestWordLength = R.pipe(
  R.split(' '),
  R.map(R.length),
  R.reduce(R.max, 0)
);

六、Pointfree 示例二

最后,看一个实战的例子,拷贝自 Scott Sauyet 的文章《Favoring Curry》。那篇文章能帮助你深入理解柯里化,强烈推荐阅读。

下面是一段服务器返回的 JSON 数据。

现在要求是,找到用户 Scott 的所有未完成任务,并按到期日期升序排列。

过程式编程的代码如下(查看完整代码)。

上面代码不易读,出错的可能性很大。

现在使用 Pointfree 风格改写(查看完整代码)。


var getIncompleteTaskSummaries = function(membername) {
  return fetchData()
    .then(R.prop('tasks'))
    .then(R.filter(R.propEq('username', membername)))
    .then(R.reject(R.propEq('complete', true)))
    .then(R.map(R.pick(['id', 'dueDate', 'title', 'priority'])))
    .then(R.sortBy(R.prop('dueDate')));
};

上面代码已经清晰很多了。

另一种写法是,把各个then里面的函数合成起来(查看完整代码)。


// 提取 tasks 属性
var SelectTasks = R.prop('tasks');

// 过滤出指定的用户
var filterMember = member => R.filter(
  R.propEq('username', member)
);

// 排除已经完成的任务
var excludeCompletedTasks = R.reject(R.propEq('complete', true));

// 选取指定属性
var selectFields = R.map(
  R.pick(['id', 'dueDate', 'title', 'priority'])
);

// 按照到期日期排序
var sortByDueDate = R.sortBy(R.prop('dueDate'));

// 合成函数
var getIncompleteTaskSummaries = function(membername) {
  return fetchData().then(
    R.pipe(
      SelectTasks,
      filterMember(membername),
      excludeCompletedTasks,
      selectFields,
      sortByDueDate,
    )
  );
};

上面的代码跟过程式的写法一比较,孰优孰劣一目了然。

七、参考链接

(完)

珠峰培训

stuQ

留言(37条)

解决了我的疑问,谢谢老师

每过一段时间回来翻翻阮老师的文章,收获巨大!

谢谢阮老师,使得我更加理解函数式编程。

柯里化确实使pointfree更加自然,感谢!

有一种豁然开朗、拨云见日的感觉

这个风格似乎不方便调试

每每阅读阮老师的文章,感觉都收获巨大

并没有觉得这个解释了“函数式编程有什么用”,只是解答了“函数式编程怎么用”……

函数式编程是一种编程的模式,在这种编程模式中最常用的函数和表达式。它强调在编程的时候用函数的方式思考问题,函数也与其他数据类型一样,处于平等地位。可以将函数作为参数传入另一个函数,也可以作为别的函数的返回值。函数式编程倾向于用一系列嵌套的函数来描述运算过程。

最后的例子是强行加长了过程式编程的代码的吧?
一个 then 就完了非要写成五个。

Pointfree风格的getIncompleteTaskSummaries看起来很美,可是如果数据格式有可能错误就很麻烦;而且调试也很麻烦。

Algebra of Programming对point-free programming有深入的阐发

引用斑谷的发言:

这个风格似乎不方便调试

恰恰相反, 函数式编程最大的好处之一就是方便调试.

奔腾的编码思想根本停不下来,预感人工智能最终要占领地球

回头再把柯里化的内容再看下

阮老师
你好。
您的这篇文章对我启发很大。
我个人有些疑问。
我对于pointfree的理解是:
1. 借助于柯里化把多参函数改写成单参函数
2. 在pipeline里,由于隐式的参数传递, 可以对子函数进行无参调用
这样一来就和参数无关了,切合了pointfree这个观点。


// 返回较大的数字
var getBiggerNumber = (a, b) => a > b ? a : b;
这里是双参。可以删除这个函数而将
var findBiggestNumber =
arr => R.reduce(getBiggerNumber, 0, arr);
改为
var findBiggestNumber =
arr => Math.max(...arr)
这样会不会好一些呢?

filterMember(member name),
这个调用可不可以改成无参调用呢?

我参考的是下面这个链接:
http://xahlee.info/comp/point-free_programing.html

再次谢谢你的博文。

谢谢您的博文,谢谢您的知识分享。

讲的很详细,很容易理解,谢谢!

世界上没有什么问题是一个函数不能解决的,如果有就两个,如果还有就多个^_^

这篇还是在说「怎么用」,而不是在说「为什么要用」。
最后用于比对的代码也带有强烈的目的性,过程式的代码是把函数定义放到过程中,函数式代码确是引用已经定义的函数,当然后者会易读一些。

所以我们到底为什么需要纯函数的编程?

我一直以来都只用OOP,是函数式编程的小白。此文及《函数式编程入门教程》对我帮助很大,因为我开始了解到函数式编程的巨大优势!

最后的例子非常直观,直观得就像SQL

越写越像 SQL ,这些查找数据的操作
越写越像 SQL ,这些处理数据的操作

搞数据库 用SQL语言,那是不需要for循环的 ---- 真的和函数式编程好像。我喜欢

引用业余草的发言:

函数式编程是一种编程的模式,在这种编程模式中最常用的函数和表达式。它强调在编程的时候用函数的方式思考问题,函数也与其他数据类型一样,处于平等地位。可以将函数作为参数传入另一个函数,也可以作为别的函数的返回值。函数式编程倾向于用一系列嵌套的函数来描述运算过程。

这句话说得中肯。

我感觉例子跟面向对象或者过程差不多,只是把函数拆分得更细,方便测试,不容易出错.

R.pipe(
SelectTasks,//第一数据入口
filterMember(membername),
excludeTasks(
R.pipe(
SelectTasks,//第二数据入口
filterMember(membername),
excludeTasks('Completed'),
selectFields,
sortByDueDate,
)
),
selectFields,
sortByDueDate,
switchType(
R.pipe(//第一分支出口
SelectTasks,
……
),
R.pipe(//第二分支出口
SelectTasks,
……
),
R.pipe(//第三分支出口
SelectTasks,
……
)
),
)

- -!

并没有觉得这个解释了“函数式编程有什么用”,只是解答了“函数式编程怎么用”……

应该理解到本文说函数式编程提供了一种思考问题的方式,这种方式就是pointfree,通过函数的嵌套避免了循环等东西

data.tasks.filter(i=>i.username=='Scott').sort((p,q)=>p.dueDate>q.dueDate)
还是喜欢酱紫

十分精闢的博文!看完十分想要將函數式編程套用在日常開發中。
但我想請教老師,假設我現在是以開發網站後台的角度,我勢必會有 非Point-free的操作(DB/ IO等)與可以套用函數式編程的純資料運算的地方,請教老師能否以開發網站後台角度分享 如何將原本的過程式編程融合函數式編程開發呢?
目前對於這部分感到有些許混淆,再次感謝老師的分享

这个用lisp搞起来是不是更简单?

引用cc的发言:

这个用lisp搞起来是不是更简单?

当然了:p

Linq?我只能想到这个了

@阮一峰
谢谢阮老师的解释,感觉明白了很多,但是我还有一个问题。
您的用pipe连接的都是同步任务,如果是异步任务呢?是不是还是要这样
firstPromise.then(() => secondPromise).then(() => final).catch(err => handler(err));

老师,最后那个运行程序,多啦一个逗号

所以像最后一个示例中的R其实是一个static this?

引用cologler的发言:

最后的例子是强行加长了过程式编程的代码的吧?
一个 then 就完了非要写成五个。

同意,过程式其实这么写,代码量也不明显多:

let getIncompleteTaskSummaries = membername => {
return fetchData().then(data => {
let tasks = data.tasks;
let results = [];
for (let i = 0, len = tasks.length; i < len; i++) {
let item = tasks[i];
if (item.username === membername && !item.complete) {
let newItem = {
id: item.id,
dueDate: item.dueDate,
title: item.title,
priority: item.priority
};
results.push(newItem);
}
}
results.sort((first, second) => {
let a = first.dueDate,
b = second.dueDate;
return a < b ? -1 : a > b ? 1 : 0;
});
return results;
});
};
getIncompleteTaskSummaries('Scott').then(r => console.log(r))

https://jsperf.com/test-functional-programming-performance/

两种编程方式的性能比较,在较新的浏览器上,差距还是不小的,我在 Chrome 61.0.3141 / Windows 10 0.0.0下跑的,Ramda慢70%左右,如果数据量不大倒还好。

請問函式的運算能用在物件上嗎?
例:
var add2 = function () {
this.value += 2;
};
var add3 = ...

var add5 = pipe(add2, add3);
assert({value:1}.add(5).value===6);

我要发表看法

«-必填

«-必填,不公开

«-我信任你,不会填写广告链接