本文使用 JavaScript 编写的简单示例来介绍函数式编程的一些知识和概念。
高阶函数 Higher order functions(HOF)
高阶函数是指至少具有下列功能之一的函数:
- 允许一个函数作为参数。
- 返回一个新的函数。
const withCount = fn => {
let count = 0;
return (...args) => {
console.log(`Call count: ${++count}`);
return fn(...args);
};
};
const add = (x, y) => x + y;
const countedAdd = withCount(add);
console.log(countedAdd(1, 2));
// Call count : 1
// 3
console.log(countedAdd(2, 2));
// Call count : 2
// 4
console.log(countedAdd(3, 2));
// Call count : 3
// 5
// 因为闭包的原因,count会每次+1,不会被初始化为0
不可变数据 Immutable Data
不可变数据在函数式编程中是必需的,因为突变/转变(mutation)是一种副作用。 数据转换不应该影响原始数据源,而是应该返回一个应用了更新的新数据源。
class MutableGlass {
constructor(content, amount) {
this.content = content;
this.amount = amount;
}
takeDrink(value) {
this.amount = Math.max(this.amount - value, 0);
return this;
}
}
const mg1 = new MutableGlass('water', 100);
const mg2 = mg1.takeDrink(20);
console.log(mg1 === mg2); // true
console.log(mg1.amount === mg2.amount); // true
// 指向同一个对象
class ImmutableGlass {
constructor(content, amount) {
this.content = content;
this.amount = amount;
}
takeDrink(value) {
return new ImmutableGlass(this.content, Math.max(this.amount - value, 0));
}
}
const mg1 = new ImmutableGlass('water', 100);
const mg2 = mg1.takeDrink(20);
console.log(mg1 === mg2); // false
console.log(mg1.amount === mg2.amount); // false
// 非同一个对象
常见用例:redux 里的 reducer 总是通过返回新的 store 来更新数据。
function todoApp(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return {
...state,
todos: [
...state.todos,
{
text: action.text,
completed: false,
},
],
};
default:
return state;
}
}
柯里化 Curring
把多参数函数重构为每次接收一个参数,将余下的参数作为返回的函数的参数,直到它收到所有的参数并最终求值。
// const add = (x, y) => x + y;
const add = x => y => x + y;
const addFive = add(5); // 返回一个等待第二个值的函数
console.log(addFive(4)); // 9
console.log(addFive(15)); // 20
console.log(add(5)(10)); // 15
用普通函数的形式,更易看出最内部的函数可以取到外面每一层函数的参数(通过闭包)。
function add(x) {
return function (y) {
return function (z) {
return x + y + z;
};
};
}
纯函数 Pure Functions
纯函数是输出完全来自其输入且在应用程序或外部世界中没有任何副作用的函数。
最常见的纯函数是数学里的函数
// f(x) = x + 1
const f = x => x + 1; //改写成 JavaScript
一些 非纯函数(impure)的例子
Ex. 1 - 输出不完全来自输入
// 受到外部变量影响
let COST_OF_ITEM = 19;
const cartTotal = quantity => COST_OF_ITEM * quantity;
cartTotal(2); // 38
COST_OF_ITEM += 2;
cartTotal(2); // 42
Ex. 2 - 相同的输入,不同的输出
// 受到非纯函数影响
const generateID = () => Math.floor(Math.random() \* 10000);
const createUser = (name, age) => ({
id: generateID(),
name,
age
})
createUser('Kyle', 100); // { id: 6324, name: "Kyle", age: 100 }
createUser('Kyle', 100); // { id: 3884, name: "Kyle", age: 100 }
createUser('Kyle', 100); // { id: 9347, name: "Kyle", age: 100 }
EX. 3 - 副作用
//修改了外部变量,带来副作用。
let id = 0;
const createFoodItem = name => ({
id: ++id,
name,
});
console.log(createFoodItem('煎饼果子')); // { id: 1, name: '煎饼果子' }
console.log(createFoodItem('烤冷面')); // { id: 2, name: '烤冷面' }
console.log(createFoodItem('手抓饼')); // { id: 3, name: '手抓饼' }
EX. 4 - 副作用 2
// 输出log属于影响外部世界,也是副作用。
const logger = msg => {
console.log(msg);
};
logger('Hello World!');
偏函数/局部应用 Partial Application
固定部分参数作为预设,接受剩余的参数。 当一个柯里化过的函数有一些但不是全部的函数被调用(还没有触发最后一个)时,就会发生局部应用。
简单示例:原生的 bind 函数
const sum = (a, b) => a + b;
const partial = sum.bind(null, 40);
partial(2); // 42
请求 api 的示例,把公共的参数作为预设,创建了可重用的函数
const fetch = require('node-fetch-npm');
const getFromAPI = baseURL => endpoint => cb =>
fetch(`${baseURL}${endpoint}`)
.then(res => res.json())
.then(data => cb(data))
.catch(err => {
console.log(err.message);
});
const getGithub = getFromAPI('https://api.github.com');
const getGithubUsers = getGithub('/users');
const getGithubRepos = getGithub('/repositories');
const getGithubOrgs = getGithub('/organizations');
getGithubUsers(data => {
console.log(data.map(user => user.login));
});
getGithubUsers(data => {
console.log(data.map(user => user.avatar_url));
});
getGithubRepos(data =>
data.forEach(repo => {
console.log(`Repo: ${repo.name}`);
})
);
getGithubOrgs(data =>
data.forEach(org => {
console.log(`Org: ${org.login}`);
})
);
无参数风格 Pointfree
将指定的函数作为参数传递,以避免编写带有临时变量(形参)的匿名函数。
好处:
- 增加 Legibility (可识别性/可读性)
- 减少 bug 的覆盖面
- 具名函数用于单元测试
const arr = [1, 2, 3];
//map内的匿名函数没有传达出有用的信息
arr.map(x => x * 2); // [2, 4, 6]
const double = x => x * 2; //具名函数更有意义
arr.map(double); // [2, 4, 6]
React 中的例子
const Item = ({ id, text }) => <li key={id}>{text}</li>;
const List = ({ items }) => <ul>{items.map(Item)}</ul>;
组合 compose
组合是函数式编程的核心和灵魂,是我们在应用程序中构建复杂性的方式。在某种意义上,组合就是函数的嵌套,将一个函数的结果作为输入传递给下一个函数。
const scream = str => str.toUpperCase();
const exclaim = str => `${str}!`;
const repeat = str => `${str} ${str}`;
console.log(repeat(exclaim(scream('hello world!'))));
上面的组合是简单的函数嵌套,当有很多个函数的时候会比较长,可读性差。
值得期待的管道操作符 |> 尚在 stage 1 阶段,可以更简洁的写出函数链式调用,目前还没有浏览器支持。
'hello world!' |> console.log |> repeat |> exclaim |> scream;
更好的方式是创建一个高阶函数,将任意数量的函数作为参数传进去。
// 接收任意数量的函数作为参数,x为初始值,使用reduceRight依次执行
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
// 将三个函数组合在一起
const withExuberance = compose(repeat, exclaim, scream);
console.log(withExuberance('hello world!')); // HELLO WORLD!! HELLO WORLD!!
在某些库中,比如 Ramda 和 Lodash/FP,会有 pipe 函数,它与 compose 相同,只是函数是从左到右简化的。
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
const withExuberance2 = pipe(scream, exclaim, repeat);
console.log(withExuberance2('hello world!')); // HELLO WORLD!! HELLO WORLD!!
通过正确的参数顺序提高函数的可用性
柯里化过的函数中参数顺序不同会带来很大的可用性的差距
const map = array => cb => array.map(cb);
const arr = [1, 2, 3, 4, 5];
const double = n => n * 2;
const withArr = map(arr);
console.log(withArr(double)); // [2, 4, 6, 8, 10]
console.log(withArr(n => n * 3));
先传入数组,除了直接调用数组的 map 方法,并没有带来其他用处
const map2 = cb => array => array.map(cb);
const withDouble = map2(double);
console.log(withDouble([1, 2, 3])); //[2, 4, 6]
先传入回调函数,创建 withDouble 函数,可以传入任何要执行 double 操作的数组,更有可用性。
参数的排序可以按照: 最具体 => 最不具体(Most specific => least specific)
const prop = key => obj => obj[key];
const propName = prop('name');
const people = [
{ name: 'Shirley' },
{ name: 'Kent' },
{ name: 'Sarah' },
{ name: 'Ken' }
]
const names = map2(propName)(people);
console.log(names); // ["孙笑川", "蔡徐坤", "药水哥", "抽象带篮子"]
在组合中使用结合律
函数组合遵循数学的结合律。1 + 2 + 3 = (1 + 2) + 3 = 1 + (2 + 3)
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const scream = str => str.toUpperCase();
const exclaim = str => `${str}!`;
const repeat = str => `${str} ${str}`;
const str = 'Hello World!';
const repeatExcitedly = compose(repeat, exclaim);
const screamLoudly = compose(exclaim, scream);
console.log(compose(repeatExcitedly, scream)(str)); // HELLO WORLD!! HELLO WORLD!!
console.log(compose(repeat, screamLoudly)(str)); // HELLO WORLD!! HELLO WORLD!!
两种操作是等价的 结合律使我们可以通过组合创建更加复杂的代码
在组合中调试
point free 的函数组合是不透明的,而且每一个都是没有副作用的纯函数,难以进行调试。
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
const map = fn => xs => xs.map(fn);
const split = pattern => str => str.split(pattern);
const join = separator => xs => xs.join(separator);
const lowerCase = str => str.toLowerCase();
const bookTitles = [
'The Culture Code',
'Designing Your Life',
'Algorithms to Live By',
];
let slugify = compose(map(join('-')), map(lowerCase), map(split(' ')));
try {
console.log(slugify(bookTitles));
} catch (error) {
console.log(error); // TypeError: str.toLowerCase is not a function
}
组合里出现了错误,需要定位到具体是哪里。 我们需要创建一个“逃生舱”。
const trace = msg => x => (console.log(msg, x), x);
trace 函数使用了逗号操作符,打印后返回 x 的值,实现副作用。
在组合中添加 trace 函数来调试,
//由于compose是从右到左的所以before split在前
slugify = compose(
map(join('-')),
trace('after lowercase'),
map(lowerCase),
trace('after split'),
map(split(' ')),
trace('before split')
);
根据打印的信息,我们可以发现 split 后的每一项是数组, 而 tolowerCase 接收的是字符串,所以会出错。
try {
console.log(slugify(bookTitles));
} catch (error) {
console.log(error);
}
// before split [ 'The Culture Code', 'Designing Your Life', 'Algorithms to Live By' ]
// after split [
// [ 'The', 'Culture', 'Code' ],
// [ 'Designing', 'Your', 'Life' ],
// [ 'Algorithms', 'to', 'Live', 'By' ]
// ]
调整顺序,把 lowerCase 放在第一个。
slugify = compose(map(join('-')), map(split(' ')), map(lowerCase));
console.log(slugify(bookTitles));
// [ 'the-culture-code', 'designing-your-life', 'algorithms-to-live-by' ]
得到预期结果,debug 完成。
上面函数 map 执行了三次,不够精简。我们可以对函数进行组合,并将其一次传递到 map 中。
const slugify = compose(
map(
compose(
join('-'),
split(' '),
lowerCase
)
)
)
组合的威力在此体现出来了。