[TIR-00]为何要用Ramda ?

标签:nodejs, functional programming


本文是Thinking in Ramda系列文章的引导篇,此系列讨论的是nodejs语言下的函数式编程(以下简称FP,即Functional Programming),名为【Ramda编程思想】。

buzzdecafe刚把Ramda介绍给这个世界时,收到两组截然相反的回响。那些习惯了(用Javascript或其它语言)使用函数式技术的人,大部分的反应是:Cool。他们可能是因为用过而为之而兴奋,也可能只是知道这是一个潜在的工具,但可以肯定的一点是,他们知道Ramda是为何而生的。

别一组回响则是深深的问号:这是什么???

(英文原文链接)

对于不习惯函数式编程的人来说,Ramda似乎并无多大新意。它的大部分主要特性都早就在其它库里出现过,例如UnderscoreLoDash

你不能说他们的想法有错。如果你想继续使用熟习已久的命令式及面向对象(object-oriented)的风格来写程序的话,Ramda对你而言的确没有太多特别的地方。

但其实,Ramda的核心价值在于它提供了一种与别不同的编程风格,这种编程风格出自纯粹的函数式编程语言:Ramda通过函数组合的概念使你能用更简洁高效的方式来表达复杂逻辑。需要指出的是,任何一个有compose函数的库都能让你把函数组合起来,重点在于:使一切更简单。

下面我们来看看Ramda是如何做到这点的。

“待办事项”这个典型应用似乎已经成为要对比web框架时的必选例子,所以我们也不落俗套地用它来举例。首先,假设我们要过滤一个TODO列表,把所有已完成的事项从列表中剔除。

使用Javascript语言自带的数组原型方法,我们可以这样写:

// 普通的JS
var incompleteTasks = tasks.filter(function(task) {
    return !task.complete;
});

如果你喜欢LoDash,会简单一点:

// Lo-Dash的版本
var incompleteTasks = _.filter(tasks, {complete: false});

上述两种写法,我们都能得到一个过滤后的数组。

在有Ramda的世界里,其实我们更偏向于这样写:

var incomplete = R.filter(R.where({complete: R.equals(false)}));

发现不同之处了吗?上述代码并没有提及任务列表。这个Ramda式的写法只给了我们一个函数。

我们最终还是要用任务列表数组调用上面的函数来得到过滤后的数据集。

这个就是重点

因为得到的是函数,我们可以很容易地把它与其它函数组合来操作我们所需的任何数据。假设我们有一个函数groupByUser,能把任务列表的元素根据用户分组。这样我们可以很简单地组合出一个新的函数:

var activeByUser = R.compose(groupByUser, incomplete);

上述代码“选取”未完成的任务并且按用户将之分组。

又或者,只要我们喜欢,我们可以把数据也提供给这个函数来实现相同的效果。例如我们简单地手写的函数,它可能会是这样:

// 徒手实现
var activeByUser = function(tasks) {
    return groupByUser(incomplete(tasks));
};

当然,基实我们并不需要如此手工地实现函数组合这个概念。并且函数组合是函数式编程领域里极其重要的一个基本概念。下面我们来看看如果再深入一步,会是怎样。假如现在需要把每个用户的待办事项按到期日期来排序呢?

var sortUserTasks = R.compose(R.map(R.sortBy(R.prop("dueDate"))), activeByUser);

写成一行又如何?

观察敏锐的读者可能已经注意到其实我们可以把上面的代码再简化组合成一行。Ramda的compose函数允许传入多个参数,为何不一步到位实现上述逻辑呢?

var sortUserTasks = R.compose(
    R.mapObj(R.sortBy(R.prop('dueDate'))),
    groupByUser,
    R.filter(R.where({complete: false})
);

我的回答是只有当你没有其它地方要用到activeByUserincomplete这两个中间函数时,上面这样写才合理。因为这样写会令调试变得困难,而且其实并没带来多少代码可读性上的提高。

其实我更偏向于另一种写法。上面我们用了相当复杂的一段内部代码,这段代码有可能需要被重复使用。也许这样写会更好一点:

var sortByDate = R.sortBy(R.prop('dueDate'));
var sortUserTasks = R.compose(R.mapObj(sortByDate), activeByUser);

这样,我们就可以用sortByDate去把任何集合的的任务列表按到期日期排序。(事实上,灵活性远不只于此;它能把任何一个具有可排序的”dueDate”属性的对象集合进行排序)

举一反三,我们也可以把集合元素按到期日期进行降序排序。

var sortByDateDescend = R.compose(R.reverse, sortByDate);
var sortUserTasks = R.compose(R.mapObj(sortByDateDescend), activeByUser);

如果我们确切肯定需求方只需要按最近日期最先排序的话,我们可以把这些逻辑组合成一个简单的定义:sortByDateDescend。我个人建议是两个都保留,以便日后随时切换升或降序。当然这个取决于你个人喜好。

数据去哪里了?

直到目前为止,我们仍未提及到要操作的数据。这有点诡异,讲的是数据处理并未提及到要处理的数据…只能说是处理。讲真,做人要有耐性。当你进行函数式编程时,你得到的都是由各种函数组成的管道。一个函数处理完后把结果流入下一个函数,以此类推,直到最后一个函数处理完从而得到你需要的结果。

我们上面写的代码其实是一个函数的集合:

incomplete: [Task] -> [Task]
sortByDate: [Task] -> [Task]
sortByDateDescend: [Task] -> [Task]
activeByUser: [Task] -> {String: [Task]}
sortUserTasks: {String: [Task]} -> {String: [Task]}

而且虽然我们使用了较早前定义的函数来构造出sortUserTasks,其实它们都有被独立使用的潜力。我们确实取得了一些成就。我只是假设我们有一个byUser的函数来构建activeByUser,但其实直到目前我们还未见到这个函数的定义。现在让我们来看看如何实现之。

以下是其中一种实现方法:

var groupByUser = R.partition(R.prop('username'));

partition函数使用了Ramda版本的reduce,Ramda的reduce非常类似Javascript语言原生的Array.prototype.reduce函数。这个就是所谓的foldl,一个其它函数式编程语言常用的术语。这里就不展开讨论reduce了,你可以在网上继续深入研究它。我们的partition函数只是使用了reduce来把列表分组成具有相同key的子列表,key由一个函数作用在所有元素上而定,在这里是prop('username'),这个函数只是析构出每个元素对象里的”username”属性。

(说到这里,我应该已通过神奇的新函数把你的注意力从数据中抽离出来了吧?哈哈,上面我仍然未提及到要处理的数据。而且,另外还有一大波闪亮的新函数即将登场了)

等等,还有更多闪光点?

我们可以把这个问题再深入一层。要从一个列表里最取先的5个元素,可以使用Ramda提供的take函数。所以,要取出每个用户的待办任务里取出最先的5个事项,我们可以这样写:

var topFiveUserTasks = R.compose(R.mapObj(R.take(5)), sortUserTasks);

然后,我们可以把得到的对象再精简成部分属性的集合,例如只保留标题及到期日期。用户名是明显地冗余的,另外也许其它属性我们也不想传递给其它系统。

这里我们可以使用Ramda的一个类似SQL的select函数,叫project:

var importantFields = R.project(['title', 'dueDate']);
var topDataAllUsers = R.compose(R.mapObj(importantFields), topFiveUserTasks);

上面我们创建的一些函数在这个TODO应用里似乎天生就能在其它地方重用。其它的函数似乎只是能被组合成一个主函数的占位符而已。所以让我们回顾一下,也许之前的代码可以组合成这样:

var incomplete = R.filter(R.where({complete: false}));
var sortByDate = R.sortBy(R.prop('dueDate'));
var sortByDateDescend = R.compose(R.reverse, sortByDate);
var importantFields = R.project(['title', 'dueDate']);
var groupByUser = R.partition(R.prop('username'));
var activeByUser = R.compose(groupByUser, incomplete);
var topDataAllUsers = R.compose(R.mapObj(R.compose(importantFields, 
    R.take(5), sortByDateDescend)), activeByUser);

能看到要处理的数据未?

对,到这里,应该能看到要处理的数据了。

是时候把数据传入各个处理函数了。重点是这个函数都接受同样类型的数据,一个TODO的元素数组。我们尚未描述过这些元素的数据结构,但可以肯定的是至少有以下属性:

  • complete: Boolean
  • dueDate: String, 格式为YYYY-MM-DD
  • title: String
  • userName: String

所以,如果我们有一个任务数组,如何使用?很简单:

var results = topDataAllUsers(tasks);

就如此简单?

以上各种构建的函数定义,结果就是这一行代码?

对,就是这么简单。上面代码的执行结果会是一个类似这样的对象:

{
    Michael: [
        {dueDate: '2014-06-22', title: 'Integrate types with main code'},
        {dueDate: '2014-06-15', title: 'Finish algebraic types'},
        {dueDate: '2014-06-06', title: 'Types infrastucture'},
        {dueDate: '2014-05-24', title: 'Separating generators'},
        {dueDate: '2014-05-17', title: 'Add modulo function'}
    ],
    Richard: [
        {dueDate: '2014-06-22', title: 'API documentation'},
        {dueDate: '2014-06-15', title: 'Overview documentation'}
    ],
    Scott: [
        {dueDate: '2014-06-22', title: 'Complete build system'},
        {dueDate: '2014-06-15', title: 'Determine versioning scheme'},
        {dueDate: '2014-06-09', title: 'Add `mapObj`'},
        {dueDate: '2014-06-05', title: 'Fix `and`/`or`/`not`'},
        {dueDate: '2014-06-01', title: 'Fold algebra branch back in'}
    ]
}

但这里有一点很有趣。你还可以把同样的初始任务列表传入incomplete函数中去得到一个过滤过的数组:

var incompleteTasks = incomplete(tasks);

这时,会返回类似如下的内容:

[
    {
        username: 'Scott',
        title: 'Add `mapObj`',
        dueDate: '2014-06-09',
        complete: false,
        effort: 'low',
        priority: 'medium'
    }, {
        username: 'Michael',
        title: 'Finish algebraic types',
        dueDate: '2014-06-15',
        complete: true,
        effort: 'high',
        priority: 'high'
    } /*, ... */
]

同理,你可以把任务列表传入sortByDatesortByDateDescend, importantFields, byUser, 或者activeByUser这些函数中。因为它们都操作类似的数据类型:一个任务数组。这样,我们可以通过组合来构建一个庞大的工具链。

新需求

最后,你收到通知,我们的程序需要支持另一个特性。你需要针对某个用户过滤出他的任务列表,然后执行同样的过滤、排序、以及之前你做过的获取元素部分属性返回的数据封装处理。

这些逻辑现在嵌入在topDataAllUsers函数里…也许你会发现其实我们的组合函数太庞大了。但其实要重构它也很容易。通常,最难的地方在于起个好名。而”gloss”也许并不是一个好名,以下是我尽力而为的一个版本了:

var gloss = R.compose(importantFields, R.take(5), sortByDateDescend);
var topData = R.compose(gloss, incomplete);
var topDataAllUsers = R.compose(R.mapObj(gloss), activeByUser);
var byUser = R.use(R.filter).over(R.propEq("username"));

然后当你要使用它时,这样调用:

var results = topData(byUser('Scott', tasks));

其实,我真的只想获取数据

现在回到最初的函数:

var incomplete = R.filter(R.where({complete: false}));

如何把它变成能获取数据的函数?很简单:

var incompleteTasks = R.filter(R.where({complete: false}), tasks);

同理,上述各主要函数也能如法炮制:只需要添加一个tasks参数到调用函数的函数后面,这样就能返回数据。

发生什么事了?

这是Ramda的另一个重点。所有Ramda的关键函数都自动curry化了。这意味着你并不需要提供函数需要的所有参数,这样不会真的调用你的函数,而是返回一个新的函数,当你把最后一个数据参数再补上时,才会真正发生函数调用。所以filter函数的定义与predicate函数一样涉及到数组的值。在最初的版本里,我们并不提供数据值,所以filter返回一个新的函数,接受最后一个数据参数。第二个版本中,我们把数据数组传了过去,结果它与之前的参数一起发生了求值运算,返回了最终的数值。

自动Curry化及直接就函数第一,数据放在最后的API设计使Ramda很容易使用函数式编程风格来写程序。

详细的Ramda Curry化可以另外再写一篇文章来介绍。这里,强烈建议读一读Hugh Jackson’s 的好文:Curry如何有用

给一个可执行的代码DEMO

(function(R) {
    var incomplete = R.filter(R.where({complete: false}));
    var sortByDate = R.sortBy(R.prop('dueDate'));
    var sortByDateDescend = R.compose(R.reverse, sortByDate);
    var importantFields = R.project(['title', 'dueDate']);
    var groupByUser = R.partition(R.prop('username'));
    var activeByUser = R.compose(groupByUser, incomplete);
    var gloss = R.compose(importantFields, R.take(5), sortByDateDescend);
    var topData = R.compose(gloss, incomplete);
    var topDataAllUsers = R.compose(R.mapObj(gloss), activeByUser);
    var byUser = R.use(R.filter).over(R.propEq("username"));
    
    log("Gloss for Scott:");
    log(topData(byUser("Scott", tasks)));
    log("====================");
    log("Gloss for everyone:");
    log(topDataAllUsers(tasks));
}(ramda));
<pre><code id="output"></code></pre>
<script>
var log = (function() {
    var o = document.getElementById("output");
    return function(obj) {o.innerHTML += "\n" + JSON.stringify(obj, null, 4);}
}()); 
var tasks = [
    {username: 'Michael', title: 'Curry stray functions', dueDate: '2014-05-06', 
               complete: true, effort: 'low', priority: 'low'},    
    {username: 'Scott', title: 'Add `fork` function', dueDate: '2014-05-14', 
               complete: true, effort: 'low', priority: 'low'},    
    {username: 'Michael', title: 'Write intro doc', dueDate: '2014-05-16', 
               complete: true, effort: 'low', priority: 'low'},    
    {username: 'Michael', title: 'Add modulo function', dueDate: '2014-05-17', 
               complete: false, effort: 'low', priority: 'low'},    
    {username: 'Michael', title: 'Separating generators', dueDate: '2014-05-24', 
               complete: false, effort: 'medium', priority: 'medium'},
    {username: 'Scott', title: 'Fold algebra branch back in', dueDate: '2014-06-01', 
               complete: false, effort: 'low', priority: 'low'},
    {username: 'Scott', title: 'Fix `and`/`or`/`not`', dueDate: '2014-06-05', 
               complete: false, effort: 'low', priority: 'low'},
    {username: 'Michael', title: 'Types infrastucture', dueDate: '2014-06-06', 
               complete: false, effort: 'medium', priority: 'high'},
    {username: 'Scott', title: 'Add `mapObj`', dueDate: '2014-06-09', 
               complete: false, effort: 'low', priority: 'medium'}, 
    {username: 'Scott', title: 'Write using doc', dueDate: '2014-06-11', 
               complete: false, effort: 'medium', priority: 'high'},
    {username: 'Michael', title: 'Finish algebraic types', dueDate: '2014-06-15', 
               complete: false, effort: 'high', priority: 'high'},
    {username: 'Scott', title: 'Determine versioning scheme', dueDate: '2014-06-15', 
                complete: false, effort: 'low', priority: 'medium'},
    {username: 'Michael', title: 'Integrate types with main code', dueDate: '2014-06-22', 
               complete: false, effort: 'medium', priority: 'high'},
    {username: 'Richard', title: 'API documentation', dueDate: '2014-06-22', 
               complete: false, effort: 'high', priority: 'medium'},
    {username: 'Scott', title: 'Complete build system', dueDate: '2014-06-22', 
               complete: false, effort: 'medium', priority: 'high'},
    {username: 'Richard', title: 'Overview documentation', dueDate: '2014-06-25', 
               complete: false, effort: 'medium', priority: 'high'}
];
</script>
"Gloss for Scott:"
[
    {
        "title": "Complete build system",
        "dueDate": "2014-06-22"
    },
    {
        "title": "Determine versioning scheme",
        "dueDate": "2014-06-15"
    },
    {
        "title": "Write using doc",
        "dueDate": "2014-06-11"
    },
    {
        "title": "Add `mapObj`",
        "dueDate": "2014-06-09"
    },
    {
        "title": "Fix `and`/`or`/`not`",
        "dueDate": "2014-06-05"
    }
]
"===================="
"Gloss for everyone:"
{
    "Michael": [
        {
            "title": "Integrate types with main code",
            "dueDate": "2014-06-22"
        },
        {
            "title": "Finish algebraic types",
            "dueDate": "2014-06-15"
        },
        {
            "title": "Types infrastucture",
            "dueDate": "2014-06-06"
        },
        {
            "title": "Separating generators",
            "dueDate": "2014-05-24"
        },
        {
            "title": "Add modulo function",
            "dueDate": "2014-05-17"
        }
    ],
    "Scott": [
        {
            "title": "Complete build system",
            "dueDate": "2014-06-22"
        },
        {
            "title": "Determine versioning scheme",
            "dueDate": "2014-06-15"
        },
        {
            "title": "Write using doc",
            "dueDate": "2014-06-11"
        },
        {
            "title": "Add `mapObj`",
            "dueDate": "2014-06-09"
        },
        {
            "title": "Fix `and`/`or`/`not`",
            "dueDate": "2014-06-05"
        }
    ],
    "Richard": [
        {
            "title": "Overview documentation",
            "dueDate": "2014-06-25"
        },
        {
            "title": "API documentation",
            "dueDate": "2014-06-22"
        }
    ]
}

上述优雅的代码应该能清楚的演示出使用Ramda的充分理由了。

使用Ramda

上述代码都能使用,而且用到的技术应该能让你体验Ramda的好处。你可以直接从GitHub Repository上及npm 安装的方法获取代码。

在Node中使用:

npm install ramda
var R = require('ramda')

在浏览器中使用:

<script src="path/to/yourCopyOf/ramda.js"></script>

<script src="path/to/yourCopyOf/ramda.min.js"></script>


上篇: [TIR-02]Ramda思维:组合函数的魔法
下篇: [TIR-03]Ramda思维:部分应用函数