引言
本文是函数式编程指北的快速记录
一等公民函数
WHY:
-
消除冗余代码/减少修改成本 e.g.
// before httpGet('/post/2', json => renderPost(json)); // 如果要修改,胶水函数也需修改 httpGet('/post/2', (json, err) => renderPost(json, err)); // after httpGet('/post/2', renderPost); -
避免特定命名,增加复用性
// 只针对当前的博客 const validArticles = articles => articles.filter(article => article !== null && article !== undefined), // 对未来的项目更友好 const compact = xs => xs.filter(x => x !== null && x !== undefined);
警惕
this陷阱var fs = require('fs'); // 太可怕了 fs.readFile('freaky_friday.txt', Db.save); // 好一点点 fs.readFile('freaky_friday.txt', Db.save.bind(Db));函数式编程本身会尽量避免使用this(因其依赖上下文,破坏函数纯性),仅在对接面向对象类库时需兼容处理。
纯函数
纯函数定义:
纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。
副作用定义:
副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。
好处:减少外部依赖,减少认知负荷。 不是要禁止使用一切副作用,而是说,要让它们在可控的范围内发生。
纯函数好处
-
可缓存性(Cacheable)
简单来说,就是memoize,类似python的
@cache。e.g.
var squareNumber = memoize(function(x){ return x*x; }); squareNumber(4); //=> 16 squareNumber(4); // 从缓存中读取输入值为 4 的结果 //=> 16小技巧:通过延迟执行将不纯函数转换为纯函数:
var pureHttpCall = memoize(function(url, params){ return function() { return $.getJSON(url, params); } });之所以纯是因为它总是会根据相同的输入返回相同的输出:给定了 url 和 params 之后,它就只会返回同一个发送 http 请求的函数。
-
可移植性/自文档化(Portable / Self-Documenting)
自给自足,所以依赖明确,易于观察&理解。从函数签名即可理解足够多信息。
e.g.
var signUp = function(Db, Email, attrs) { return function() { var user = saveUser(Db, attrs); welcomeUser(Email, user); }; };其次,强迫依赖注入使得应用更灵活。函数甚至可以序列化通过socket发送。
我最喜欢的名言之一是 Erlang 语言的作者 Joe Armstrong 说的这句话:“面向对象语言的问题是,它们永远都要随身携带那些隐式的环境。你只需要一个香蕉,但却得到一个拿着香蕉的大猩猩...以及整个丛林”。
-
可测试性(Testable) 很容易明白。
-
合理性(Reasonable)
引用透明性(referential transparency):如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。
纯函数保证了引用透明性。
技术:等式推导(Equational Reasoning):
var punch = function(player, target) { if(player.team === target.team) { return target; } else { return decrementHP(target); } };由于数据不可变,将team替换为实际值:
var punch = function(player, target) { if("red" === "green") { return target; } else { return decrementHP(target); } };if语句结果为false,可以删除:
var punch = function(player, target) { return decrementHP(target); };简化:
var punch = function(player, target) { return target.set("hp", target.hp-1); }; -
并行(最重要) 可以并行运行任意纯函数,因为不需要访问共享内存,无副作用,自然没有竞态情况。
柯里化(Curry)
柯里化:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
简单的例子:
var add = function(x) {
return function(y) {
return x + y;
};
};
var increment = add(1);
var addTen = add(10);
increment(2);
// 3
addTen(2);
// 12
更好些的例子:
var curry = require('lodash').curry;
var match = curry(function(what, str) {
return str.match(what);
});
var replace = curry(function(what, replacement, str) {
return str.replace(what, replacement);
});
var filter = curry(function(f, ary) {
return ary.filter(f);
});
var map = curry(function(f, ary) {
return ary.map(f);
});
只需传给函数一些参数,就能得到一个新函数。
e.g.
var getChildren = function(x) {
return x.childNodes;
};
var allTheChildren = map(getChildren);
// v.s.
var allTheChildren = function(elements) {
return _.map(elements, getChildren);
};
哪怕输出是另一个函数,它也是纯函数。
代码组合
函数饲养
组合:
var compose = function(f,g) {
return function(x) {
return f(g(x));
};
};
将两个函数结合产生新函数。
e.g.
var toUpperCase = function(x) { return x.toUpperCase(); };
var exclaim = function(x) { return x + '!'; };
var shout = compose(exclaim, toUpperCase);
shout("send in the clowns");
这样很容易看出是从右向左的数据流,可读性大于嵌套函数。 组合满足结合律:
var associative = compose(f, compose(g, h)) == compose(compose(f, g), h);
因为满足结合律,所以调用分组不重要,所以可以让参数可变:
var lastUpper = compose(toUpperCase, head, reverse);
Pointfree
Pointfree style means never having to say your data 函数无须提及将要操作的数据是什么样的。一等公民的函数、柯里化(curry)以及组合协作起来非常有助于实现这种模式。
e.g.
// example 1
var snakeCase = function (word) {
return word.toLowerCase().replace(/\s+/ig, '_');
};
// pointfree
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);
// example 2
var initials = function (name) {
return name.split(' ').map(compose(toUpperCase, head)).join('. ');
};
// pointfree
var initials = compose(join('. '), map(compose(toUpperCase, head)), split(' '));
initials("hunter stockton thompson");
// H.S.T
好处:减少不必要命名,保持简洁&通用,但需要注意不是所有的函数式代码都是Pointfree的。
常见错误
// 错误做法:我们传给了 `angry` 一个数组,根本不知道最后传给 `map` 的是什么东西。
var latin = compose(map, angry, reverse);
latin(["frog", "eyes"]);
// error
// 正确做法:每个函数都接受一个实际参数。
var latin = compose(map(angry), reverse);
latin(["frog", "eyes"]);
// ["EYES!", "FROG!"])
Debug
可以用以下不纯函数来debug:
var trace = curry(function(tag, x){
console.log(tag, x);
return x;
});
var dasherize = compose(join('-'), toLower, split(' '), replace(/\s{2,}/ig, ' '));
dasherize('The world is a vampire');
// TypeError: Cannot read property 'apply' of undefined
// tracing:
var dasherize = compose(join('-'), toLower, trace("after split"), split(' '), replace(/\s{2,}/ig, ' '));
// after split [ 'The', 'world', 'is', 'a', 'vampire' ]
// fix:
var dasherize = compose(join('-'), map(toLower), split(' '), replace(/\s{2,}/ig, ' '));
dasherize('The world is a vampire');
// 'the-world-is-a-vampire'
范畴学
对象的搜集 对象就是数据类型,例如 String、Boolean、Number 和 Object 等等。通常我们把数据类型视作所有可能的值的一个集合(set)。像 Boolean 就可以看作是 [true, false] 的集合,Number 可以是所有实数的一个集合。把类型当作集合对待是有好处的,因为我们可以利用集合论(set theory)处理类型。
态射的搜集 态射是标准的、普通的纯函数。
态射的组合 就是本章介绍的组合。Compose 函数是符合结合律的,这并非巧合,结合律是在范畴学中对任何组合都适用的一个特性。
上图展示了什么是组合
组合像一系列管道那样把不同的函数联系在一起,数据就可以也必须在其中流动——毕竟纯函数就是输入对输出,所以打破这个链条就是不尊重输出,就会让我们的应用一无是处。 我们认为组合是高于其他所有原则的设计原则,这是因为组合让我们的代码简单而富有可读性。另外范畴学将在应用架构、模拟副作用和保证正确性方面扮演重要角色。
示例程序
// 注释为llm添加
// 配置RequireJS,设置模块别名和路径
// requirejs.config():RequireJS的配置方法,用于定义模块加载的路径和其他选项
requirejs.config({
// paths属性:定义模块名称到模块文件路径的映射
paths: {
// 将"ramda"模块映射到指定的CDN路径,加载Ramda函数式库
ramda: 'https://cdnjs.cloudflare.com/ajax/libs/ramda/0.13.0/ramda.min',
// 将"jquery"模块映射到指定的CDN路径,加载jQuery库
jquery: 'https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min'
}
});
// 加载所需模块,使用RequireJS的异步加载机制
// require():RequireJS的模块加载方法,第一个参数是依赖数组,第二个是回调函数
require([
'ramda', // 加载Ramda函数式编程库
'jquery' // 加载jQuery库
],
// 模块加载完成后执行的回调函数,参数对应依赖数组中的模块
// _:Ramda库的实例,$:jQuery库的实例
function (_, $) {
////////////////////////////////////////////
// 工具函数和不纯函数定义(会产生副作用的函数)
// 定义Impure对象,包含所有会产生副作用的函数
var Impure = {
// 封装$.getJSON为柯里化函数,用于获取JSON数据
// _.curry():Ramda的柯里化函数,将多参数函数转换为可分步调用的函数
// callback:数据获取成功后的回调函数
// url:请求的URL地址
getJSON: _.curry(function(callback, url) {
// $.getJSON():jQuery的AJAX方法,用于从服务器获取JSON数据
$.getJSON(url, callback);
}),
// 封装设置HTML内容的操作,柯里化函数
// sel:DOM选择器
// html:要设置的HTML内容
setHtml: _.curry(function(sel, html) {
// $(sel).html(html):jQuery方法,设置匹配元素的HTML内容
$(sel).html(html);
})
};
// 创建图片元素的函数
// url:图片的src属性值
var img = function (url) {
// $('<img />', { src: url }):jQuery创建图片元素的方法,设置src属性
return $('<img />', { src: url });
};
// 调试用的跟踪函数,柯里化函数,用于在函数组合中输出中间结果
// tag:标识信息,x:要跟踪的值
var trace = _.curry(function(tag, x) {
// console.log():浏览器控制台输出
console.log(tag, x);
// 返回输入值,保证函数组合的连续性
return x;
});
////////////////////////////////////////////
// 业务逻辑函数(纯函数,无副作用)
// 生成Flickr API请求URL的函数
// t:搜索标签
var url = function (t) {
// 拼接URL字符串,返回完整的API请求地址
return 'https://api.flickr.com/services/feeds/photos_public.gne?tags=' + t + '&format=json&jsoncallback=?';
};
// 从Flickr数据项中提取图片URL的函数
// 使用Ramda的函数组合:先获取'media'属性,再获取其'm'属性
// _.compose(f, g):Ramda的函数组合,返回一个新函数,等价于f(g(x))
// _.prop('media'):获取对象的'media'属性值
// _.prop('m'):获取对象的'm'属性值
var mediaUrl = _.compose(_.prop('m'), _.prop('media'));
// 从API响应中提取所有图片URL的函数
// 先获取'items'属性,再对每个项应用mediaUrl函数
// _.map(mediaUrl):对数组中的每个元素应用mediaUrl函数
// _.prop('items'):获取响应对象的'items'属性(图片数组)
var srcs = _.compose(_.map(mediaUrl), _.prop('items'));
// 将图片URL数组转换为图片元素数组的函数
// 先获取图片URL数组,再将每个URL转换为img元素
// _.map(img):对每个URL应用img函数,生成图片元素
var images = _.compose(_.map(img), srcs);
// 渲染图片到页面的函数
// 先将图片URL转换为图片元素,再将这些元素设置为body的HTML内容
// Impure.setHtml("body"):设置body元素的HTML内容
var renderImages = _.compose(Impure.setHtml("body"), images);
// 应用的主函数,组合URL生成和数据获取逻辑
// 先根据标签生成URL,再使用该URL获取数据并渲染图片
// Impure.getJSON(renderImages):获取JSON数据后调用renderImages渲染
var app = _.compose(Impure.getJSON(renderImages), url);
// 启动应用,搜索标签为"cats"的图片
app("cats");
});
Hindley-Milner类型签名
e.g.
// match :: Regex -> String -> [String]
var match = curry(function(reg, s){
return s.match(reg);
});
简单来说,每传一个参数,就会弹出签名最前面的那个类型,也可以进行分组:
// match :: Regex -> (String -> [String])
更多示例:
// id :: a -> a
var id = function(x){ return x; }
// map :: (a -> b) -> [a] -> [b]
// map 接受两个参数,第一个是从任意类型 a 到任意类型 b 的函数;第二个是一个数组,元素是任意类型的 a;map 最后返回的是一个类型 b 的数组
var map = curry(function(f, xs){
return xs.map(f);
});
// 更复杂一些的:
// reduce :: (b -> a -> b) -> b -> [a] -> b
var reduce = curry(function(f, x, xs){
return xs.reduce(f, x);
});
缩小可能性范围
引入类型变量会出现的特性(Parametricity)。 比如说:
// head :: [a] -> a
通过这个类型签名,我们可以猜测这个方法与类型无关,a可以是任意类型,且判断这个函数不能对a做任何特定的事情。
自由定理
也是引入类型变量带来的。
// head :: [a] -> a
compose(f, head) == compose(head, map(f));
// filter :: (a -> Bool) -> [a] -> [a]
compose(map(f), filter(compose(p, f))) == compose(filter(p), map(f));
第一个例子中,等式左边说的是,先获取数组的头部,然后对它调用函数 f; 等式右边说的是,先对数组中的每一个元素调用 f,然后再取其返回结果的头部。 这两个表达式的作用是相等的,但是前者要快得多。
第二个例子 filter 也是一样。等式左边是说,先组合 f 和 p 检查哪些元素要过滤掉,然后再通过 map 实际调用 f; 等式右边是说,先用 map 调用 f,然后再根据 p 过滤元素。这两者也是相等的。
类型约束
签名可以把类型约束为一个特定的接口。
// sort :: Ord a => [a] -> [a]
该签名表示a一定是个Ord对象,i.e. a必须实现Ord接口。
这样我们可以获取a,函数的更多信息,并限定函数的作用范围。
我们把这种接口声明称为类型约束(Type Constraints)。
容器
目前已知信息:
- 函数式程序使用管道将数据在纯函数间传递。
- 程序是声明式的。
接下来应当考虑:
- Control Flow
- Error Handling
- Async Actions
- State
- Effects
创建容器
容器可以装任何类型的值,是对象,但是不存在OOP下的属性/方法:
var Container = function(x) {
this.__value = x;
}
// 使用of作为ctor,暂时认定为是将值放入容器的方式
Container.of = function(x) { return new Container(x); };
关于Container:
- Container 是个只有一个属性的对象。尽管容器可以有不止一个的属性,但大多数容器还是只有一个。我们很随意地把 Container 的这个属性命名为 __value。
__value不能是某个特定的类型,不然 Container 就对不起它这个名字了。- 数据一旦存放到 Container,就会一直待在那儿。我们可以用
.__value获取到数据,但这样做有悖初衷。
Functor
有了值,自然也需要操控的方式。
// (a -> b) -> Container a -> Container b
Container.prototype.map = function(f){
return Container.of(f(this.__value))
}
// 使用:
Container.of(2).map(function(two){ return two + 2 })
//=> Container(4)
Functor: 是实现了 map 函数并遵守一些特定规则的容器类型。
map使我们能够不离开Container的情况下操作里面的值,也可以连续调用。 当 map 一个函数的时候,我们请求容器来运行这个函数。
e.g. Maybe
// Maybe 最常用在那些可能会无法成功返回结果的函数中
// 这样使用map时就可以避免空值了
var Maybe = function(x) {
this.__value = x;
}
Maybe.of = function(x) {
return new Maybe(x);
}
Maybe.prototype.isNothing = function() {
return (this.__value === null || this.__value === undefined);
}
Maybe.prototype.map = function(f) {
return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
}
// pointfree风格
// map :: Functor f => (a -> b) -> f a -> f b
var map = curry(function(f, any_functor_at_all) {
return any_functor_at_all.map(f);
});
// usecase
// safeHead :: [a] -> Maybe(a)
var safeHead = function(xs) {
return Maybe.of(xs[0]);
};
var streetName = compose(map(_.prop('street')), safeHead, _.prop('addresses'));
streetName({addresses: []});
// Maybe(null)
streetName({addresses: [{street: "Shady Ln.", number: 4201}]});
// Maybe("Shady Ln.")
// 有时可以明确返回一个null来表示失败:
var withdraw = curry(function(amount, account) {
return account.balance >= amount ?
Maybe.of({balance: account.balance - amount}) :
Maybe.of(null);
});
释放容器中的值
我们的代码,就像薛定谔的猫一样,在某个特定的时间点有两种状态,而且应该保持这种状况不变直到最后一个函数为止。
// 可以使用帮助函数
// maybe :: b -> (a -> b) -> Maybe a -> b
var maybe = curry(function(x, f, m) {
return m.isNothing() ? x : f(m.__value);
});
// getTwenty :: Account -> String
var getTwenty = compose(
maybe("You're broke!", finishTransaction), withdraw(20)
);
getTwenty({ balance: 200.00});
// "Your balance is $180.00"
getTwenty({ balance: 10.00});
// "You're broke!"
在一些语言中,Maybe被伪装成Optional
“纯”错误处理
使用Either类型。
传统try/catch不纯,会中断流程。
Either通过返回值明确成功或失败,且携带相关信息。
Either包含两个子类:Left和Right,都是Functor:
var Left = function(x) {
this.__value = x;
}
Left.of = function(x) {
return new Left(x);
}
Left.prototype.map = function(f) {
return this;
}
var Right = function(x) {
this.__value = x;
}
Right.of = function(x) {
return new Right(x);
}
Right.prototype.map = function(f) {
return Right.of(f(this.__value));
}
Left:表示错误 / 失败情况,内部存储错误信息。
map方法:忽略传入的函数,直接返回自身(“短路” 特性)。
构造:Left.of(x)(x 为错误信息)。`
Right:表示成功情况,内部存储正确结果。
map方法:应用传入的函数处理内部值,返回新的 Right(与 Identity 函子类似)。
构造:Right.of(x)(x 为正确结果)。
// 成功时:Right会处理值
Right.of("rain").map(str => "b" + str); // Right("brain")
// 失败时:Left忽略处理,保留错误信息
Left.of("rain").map(str => "b" + str); // Left("rain")
实际应用:
// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
const birthdate = moment(user.birthdate, 'YYYY-MM-DD');
if (!birthdate.isValid()) return Left.of("生日解析失败");
return Right.of(now.diff(birthdate, 'years'));
});
// 成功情况
getAge(moment(), {birthdate: '2005-12-12'}); // Right(18)
// 失败情况
getAge(moment(), {birthdate: 'invalid'}); // Left("生日解析失败")
统一处理:either 方法
either函数接受两个处理函数(分别处理 Left 和 Right),强制覆盖两种情况,返回统一类型结果:
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = curry((leftHandler, rightHandler, e) => {
switch(e.constructor) {
case Left: return leftHandler(e.__value);
case Right: return rightHandler(e.__value);
}
});
// 示例:统一打印结果或错误
const zoltar = compose(console.log, either(id, fortune), getAge(moment()));
zoltar({birthdate: '2005-12-12'}); // 打印"若存活,你将年满 19"
zoltar({birthdate: 'invalid'}); // 打印"生日解析失败"
Effects
IO:捕获&延迟非纯操作,同时支持链式处理。
var IO = function(f) {
this.__value = f;
}
IO.of = function(x) {
return new IO(function() {
return x;
});
}
IO.prototype.map = function(f) {
return new IO(_.compose(f, this.__value));
}
// example
// 1. 捕获“获取window”的非纯操作,生成IO
var io_window = new IO(() => window);
// 2. 链式map:仅组合函数,不执行
var io_width = io_window.map(win => win.innerWidth); // IO(函数:获取innerWidth)
var io_href = io_window.map(_.prop('location')).map(_.prop('href')); // IO(函数:获取href)
// 3. 最终触发:调用unsafePerformIO()执行所有延迟操作
console.log(io_width.unsafePerformIO()); // 实际输出宽度(如1430)
代码虽积累不纯操作,但只有最终调用是不纯的:
////// 纯代码库: lib/params.js ///////
// url :: IO String
var url = new IO(function() { return window.location.href; });
// toPairs = String -> [[String]]
var toPairs = compose(map(split('=')), split('&'));
// params :: String -> [[String]]
var params = compose(toPairs, last, split('?'));
// findParam :: String -> IO Maybe [String]
var findParam = function(key) {
return map(compose(Maybe.of, filter(compose(eq(key), head)), params), url);
};
////// 非纯调用代码: main.js ///////
// 调用 __value() 来运行它!
findParam("searchTerm").__value();
// Maybe(['searchTerm', 'wafflehouse'])
Async
Task:以纯函数形式封装异步操作。支持链式处理(类似同步代码),避免回调嵌套,同时内置错误处理。
| 核心部分 | 实现逻辑 |
|---|---|
| 构造函数 | new Task((reject, resolve) => { ... }):- reject:异步失败时调用(传递错误);- resolve:异步成功时调用(传递结果)。 |
Task.of(x) |
生成包含“同步固定值x”的Task,支持统一的链式处理(如Task.of(3).map(n => n+1))。 |
map(f) |
不立即执行f,而是将其注册为“异步结果的处理函数”,返回新Task(类似Promise的then)。 |
| 执行触发 | 需调用fork(rejectHandler, resolveHandler):- 触发异步操作; - 非阻塞(不阻塞主线程/Event Loop); - 分别处理失败( rejectHandler)和成功(resolveHandler)。 |
实际应用示例
Task可封装各类异步操作(文件读取、HTTP请求等),并通过map链式处理结果。
示例1:Node.js读取文件
var fs = require('fs');
// 1. 封装异步读文件:readFile :: String -> Task(Error, String)
var readFile = function(filename) {
return new Task((reject, resolve) => {
fs.readFile(filename, 'utf-8', (err, data) => {
err ? reject(err) : resolve(data); // 失败reject,成功resolve
});
});
};
// 2. 链式处理:读文件 → 按行分割 → 取第一行(仅注册操作,不执行)
var firstLineTask = readFile("metamorphosis")
.map(split('\n'))
.map(head);
// 3. 触发执行:fork处理结果/错误
firstLineTask.fork(
err => console.error("读文件失败:", err),
line => console.log("第一行:", line) // 输出小说首句
);
示例2:jQuery异步请求JSON
// 1. 封装GET请求:getJSON :: String -> {} -> Task(Error, JSON)
var getJSON = curry((url, params) => {
return new Task((reject, resolve) => {
$.getJSON(url, params, resolve).fail(reject); // 成功resolve,失败reject
});
});
// 2. 链式处理:请求视频数据 → 取标题
var videoTitleTask = getJSON('/video', {id: 10})
.map(_.prop('title'));
// 3. 触发执行
videoTitleTask.fork(
err => $("#error").html(err.message),
title => $("#video-title").html(title) // 输出“Family Matters ep 15”
);
Task与其他函子的关联
-
与IO的相似性:均为“延迟执行”的容器,不主动触发操作(IO需
unsafePerformIO,Task需fork);IO可视为Task的特殊情况(同步非纯操作)。 -
与Either的融合:Task内置“失败/成功”双分支,天然包含Either的错误处理能力(
reject对应Left,resolve对应Right),无需额外封装。 -
组合使用示例:异步读配置文件(Task)→ 验证配置合法性(Either)→ 连接数据库(IO):
// 1. 读配置文件(Task)→ 解析JSON → 验证配置(Either)→ 连接数据库(IO) var getConfig = compose( map(compose(connectDb, JSON.parse)), // connectDb返回Either(IO(连接)) readFile // 读文件返回Task(Error, String) ); // 2. 触发执行:处理Task失败 + Either分支 getConfig("db.json").fork( err => logErr("读文件失败:", err), // Task失败 either( // Task成功后,处理Either的Left/Right err => console.log("配置错误:", err), // Either Left ioConn => ioConn.unsafePerformIO() // Either Right:执行IO连接数据库 ) );
核心优势与注意事项
- 优势:
- 控制流线性化:异步代码可按“同步链式”编写,避免回调嵌套,可读性高。
- 纯不纯分离:核心逻辑(
map中的处理函数)保持纯,仅fork触发非纯操作。 - 非阻塞执行:
fork不阻塞主线程,符合异步编程本质(如fork后可立即显示加载中动画)。
- 注意事项:
- 必须调用
fork:未调用fork的Task仅注册操作,不会实际执行。 - 错误处理明确:需在
fork中显式处理reject分支,避免遗漏错误。
- 必须调用