跳到主要内容

函子与单子

函子(Functor)和单子(Monad)是函数式编程中最具代表性的抽象概念。它们源于范畴论,但在编程实践中,我们可以从实用的角度来理解和使用它们,而不必深入数学理论。

为什么需要这些抽象?

在开始之前,让我们先理解为什么需要函子和单子这样的抽象。

在编程中,我们经常处理"包装在容器中的值":

  • 数组:多个值的容器
  • Promise:未来值的容器
  • Maybe/Either:可能不存在或可能出错的值的容器

这些容器有一些共同的操作模式:我们想对容器里的值进行转换,但不想手动拆开容器。函子和单子就是为这种场景设计的统一接口。

函子(Functor)

什么是函子?

函子是一个实现了 map 方法的容器类型。map 方法让你可以在不离开容器的情况下,对容器内的值进行转换。

用更数学的语言来说,函子是一种类型构造器,它实现了将一个函数应用到包装值上的能力。

函子的定义

一个类型要成为函子,必须满足:

  1. 有一个类型构造器,用于包装值
  2. 有一个 map 方法,接受一个函数,返回新的函子
// 函子接口(概念性的,JavaScript 没有接口)
interface Functor<T> {
map<U>(fn: (value: T) => U): Functor<U>;
}

最简单的函子:Identity

// Identity 函子:最简单的容器
class Identity {
constructor(value) {
this.value = value;
}

// 静态工厂方法
static of(value) {
return new Identity(value);
}

// map 方法:对容器内的值应用函数
map(fn) {
return Identity.of(fn(this.value));
}

// 提取值
get() {
return this.value;
}
}

// 使用
const result = Identity.of(5)
.map(x => x + 1)
.map(x => x * 2)
.get();

console.log(result); // 12

函子定律

函子必须遵守两条定律,确保行为可预测:

定律一:同一律(Identity)

如果传入的函数是恒等函数(x => x),则返回的函子应该与原函子相同:

// 同一律:map(id) = id
const id = x => x;

const functor = Identity.of(5);
const mapped = functor.map(id);

console.log(mapped.get() === functor.get()); // true

定律二:组合律(Composition)

对函子连续应用两个函数,应该等于先组合这两个函数再应用:

// 组合律:map(f).map(g) = map(x => g(f(x)))
const f = x => x + 1;
const g = x => x * 2;

const functor = Identity.of(5);

// 方式一:连续 map
const way1 = functor.map(f).map(g);

// 方式二:组合后 map
const way2 = functor.map(x => g(f(x)));

console.log(way1.get() === way2.get()); // true(都是 12)

这些定律保证函子的行为是可预测、可组合的。

JavaScript 中的函子

数组是函子

数组是最常见的函子实现:

const numbers = [1, 2, 3, 4, 5];

// 数组的 map 方法符合函子定义
const doubled = numbers.map(x => x * 2);
console.log(doubled); // [2, 4, 6, 8, 10]

// 验证同一律
const id = x => x;
console.log([1, 2, 3].map(id)); // [1, 2, 3](与原数组相同)

// 验证组合律
const f = x => x + 1;
const g = x => x * 2;
const arr = [1, 2, 3];

const way1 = arr.map(f).map(g); // [4, 6, 8]
const way2 = arr.map(x => g(f(x))); // [4, 6, 8]

Promise 也是函子

Promise 的 then 方法类似于 map

Promise.resolve(5)
.then(x => x + 1) // 类似于 map
.then(x => x * 2)
.then(console.log); // 12

函子的价值

函子的真正价值在于它提供了一种统一的操作容器的方式。无论容器是什么类型(数组、Promise、Maybe),都可以用 map 来处理内部的值。

// 对不同容器用相同方式操作
const processArray = arr => arr.map(x => x * 2);
const processPromise = p => p.then(x => x * 2);
const processIdentity = i => i.map(x => x * 2);

// 如果有统一接口,可以用同一个函数处理
const process = functor => functor.map(x => x * 2);

单子(Monad)

什么是单子?

单子是函子的扩展。它解决了一个函子无法优雅处理的问题:嵌套的容器

考虑这个场景:

// 假设有一个可能失败的异步操作
const fetchUser = id =>
id > 0 ? Promise.resolve({ id, name: '张三' }) : Promise.reject('无效ID');

// 假设获取用户后还要获取他的帖子
const fetchPosts = user =>
user.id ? Promise.resolve([{ title: '帖子1' }, { title: '帖子2' }]) : Promise.reject('无帖子');

// 如果直接用 map,会得到 Promise<Promise<Post[]>>
const nested = fetchUser(1).map(user => fetchPosts(user));
// 这会得到嵌套的 Promise,很麻烦

// 使用 then(相当于 flatMap),会自动展平
const flat = fetchUser(1).then(user => fetchPosts(user));
// 直接得到 Promise<Post[]>

单子提供了 flatMap(也叫 chainbind>>=)方法,可以将嵌套的容器展平。

单子的定义

一个类型要成为单子,必须满足:

  1. 是一个函子(有 map 方法)
  2. 有一个 of(也叫 unitreturn)方法,用于将值包装进容器
  3. 有一个 flatMap(也叫 chainbind)方法,用于处理返回容器的操作
// 单子接口(概念性的)
interface Monad<T> extends Functor<T> {
static of<T>(value: T): Monad<T>;
flatMap<U>(fn: (value: T) => Monad<U>): Monad<U>;
}

简单单子实现

class Box {
constructor(value) {
this.value = value;
}

// 包装值
static of(value) {
return new Box(value);
}

// 函子的 map
map(fn) {
return Box.of(fn(this.value));
}

// 单子的 flatMap
flatMap(fn) {
return fn(this.value);
}

// 提取值
fold(onValue) {
return onValue(this.value);
}
}

// 使用 map vs flatMap
const box = Box.of(5);

// map 返回包装后的值
const mapped = box.map(x => x + 1);
console.log(mapped); // Box { value: 6 }

// flatMap 用于函数返回 Box 的情况
const flatMapped = box.flatMap(x => Box.of(x + 1));
console.log(flatMapped); // Box { value: 6 }

// 为什么需要 flatMap?
// 当操作可能失败时
const safeDivide = (a, b) =>
b === 0 ? Box.of(null) : Box.of(a / b);

// 使用 flatMap 链式调用
const result = Box.of(10)
.flatMap(x => safeDivide(x, 2)) // Box(5)
.flatMap(x => safeDivide(x, 0)); // Box(null)

console.log(result); // Box { value: null }

单子定律

单子必须遵守三条定律:

定律一:左同一律(Left Identity)

// of(a).flatMap(f) 等于 f(a)
const f = x => Box.of(x * 2);

const a = 5;
const left = Box.of(a).flatMap(f);
const right = f(a);

console.log(left.value === right.value); // true

定律二:右同一律(Right Identity)

// m.flatMap(of) 等于 m
const m = Box.of(5);
const result = m.flatMap(Box.of);

console.log(result.value === m.value); // true

定律三:结合律(Associativity)

// m.flatMap(f).flatMap(g) 等于 m.flatMap(x => f(x).flatMap(g))
const f = x => Box.of(x + 1);
const g = x => Box.of(x * 2);
const m = Box.of(5);

const left = m.flatMap(f).flatMap(g);
const right = m.flatMap(x => f(x).flatMap(g));

console.log(left.value === right.value); // true(都是 12)

实用单子示例

Maybe 单子

Maybe 单子用于安全处理可能为 nullundefined 的值:

class Maybe {
static of(value) {
return value === null || value === undefined
? new Nothing()
: new Just(value);
}

static just(value) {
return new Just(value);
}

static nothing() {
return new Nothing();
}
}

class Just extends Maybe {
constructor(value) {
super();
this.value = value;
}

map(fn) {
return Maybe.of(fn(this.value));
}

flatMap(fn) {
return fn(this.value);
}

getOrElse(defaultValue) {
return this.value;
}

isJust() { return true; }
isNothing() { return false; }
}

class Nothing extends Maybe {
map(fn) {
return this; // 无值时,直接返回自身
}

flatMap(fn) {
return this; // 无值时,直接返回自身
}

getOrElse(defaultValue) {
return defaultValue;
}

isJust() { return false; }
isNothing() { return true; }
}

// 实际使用
const user = {
profile: {
address: {
city: '北京'
}
}
};

const getUserCity = user =>
Maybe.of(user)
.flatMap(u => Maybe.of(u.profile))
.flatMap(p => Maybe.of(p.address))
.map(a => a.city)
.getOrElse('未知');

console.log(getUserCity(user)); // '北京'
console.log(getUserCity({})); // '未知'
console.log(getUserCity(null)); // '未知'

// 不使用 Maybe 的写法(冗长且容易出错)
const getUserCityOld = user => {
if (!user) return '未知';
if (!user.profile) return '未知';
if (!user.profile.address) return '未知';
if (!user.profile.address.city) return '未知';
return user.profile.address.city;
};

Either 单子

Either 单子用于处理可能出错的操作,并能携带错误信息:

class Either {
static left(value) {
return new Left(value);
}

static right(value) {
return new Right(value);
}

static fromNullable(value) {
return value === null || value === undefined
? Either.left(value)
: Either.right(value);
}

static tryCatch(fn) {
try {
return Either.right(fn());
} catch (e) {
return Either.left(e);
}
}
}

class Left extends Either {
constructor(value) {
super();
this.value = value;
}

map(fn) {
return this; // Left 不执行转换
}

flatMap(fn) {
return this; // Left 不执行转换
}

fold(onLeft, onRight) {
return onLeft(this.value);
}

getOrElse(defaultValue) {
return defaultValue;
}

isLeft() { return true; }
isRight() { return false; }
}

class Right extends Either {
constructor(value) {
super();
this.value = value;
}

map(fn) {
return Either.right(fn(this.value));
}

flatMap(fn) {
return fn(this.value);
}

fold(onLeft, onRight) {
return onRight(this.value);
}

getOrElse(defaultValue) {
return this.value;
}

isLeft() { return false; }
isRight() { return true; }
}

// 实际使用
const parseJSON = str =>
Either.tryCatch(() => JSON.parse(str));

const result1 = parseJSON('{"name": "张三"}');
console.log(result1.fold(
error => `解析失败: ${error.message}`,
data => `解析成功: ${data.name}`
)); // 解析成功: 张三

const result2 = parseJSON('invalid json');
console.log(result2.fold(
error => `解析失败: ${error.message}`,
data => `解析成功: ${data.name}`
)); // 解析失败: ...

// 链式验证
const validateAge = age =>
age < 0 ? Either.left('年龄不能为负数') :
age > 150 ? Either.left('年龄不合理') :
Either.right(age);

const validateName = name =>
!name || name.trim() === '' ? Either.left('姓名不能为空') :
Either.right(name.trim());

const validateUser = data =>
validateName(data.name)
.flatMap(name =>
validateAge(data.age)
.map(age => ({ name, age }))
);

console.log(validateUser({ name: '张三', age: 25 }));
// Right { value: { name: '张三', age: 25 } }

console.log(validateUser({ name: '', age: 25 }));
// Left { value: '姓名不能为空' }

console.log(validateUser({ name: '张三', age: -5 }));
// Left { value: '年龄不能为负数' }

Task 单子(异步操作)

Task 单子用于处理异步操作,类似于 Promise 但更可控:

class Task {
constructor(fn) {
this.fn = fn;
}

static of(value) {
return new Task((reject, resolve) => resolve(value));
}

static rejected(error) {
return new Task((reject, resolve) => reject(error));
}

map(fn) {
return new Task((reject, resolve) =>
this.fn(
reject,
value => resolve(fn(value))
)
);
}

flatMap(fn) {
return new Task((reject, resolve) =>
this.fn(
reject,
value => fn(value).fn(reject, resolve)
)
);
}

run(onRejected, onResolved) {
return this.fn(onRejected, onResolved);
}
}

// 使用
const fetchUser = id =>
new Task((reject, resolve) => {
fetch(`/api/users/${id}`)
.then(res => res.json())
.then(resolve)
.catch(reject);
});

const fetchPosts = userId =>
new Task((reject, resolve) => {
fetch(`/api/posts?userId=${userId}`)
.then(res => res.json())
.then(resolve)
.catch(reject);
});

// 组合异步操作
fetchUser(1)
.flatMap(user => fetchPosts(user.id))
.map(posts => posts.map(p => p.title))
.run(
error => console.error('错误:', error),
result => console.log('结果:', result)
);

常见单子模式

链式操作

单子最强大的能力是链式操作,每一步都可能失败或返回容器:

// 读取配置、连接数据库、查询数据的链式操作
const loadConfig = Either.tryCatch(() =>
JSON.parse(fs.readFileSync('config.json'))
);

const connectDB = config =>
Either.tryCatch(() => dbDriver.connect(config.connectionString));

const queryData = connection =>
Either.tryCatch(() => connection.query('SELECT * FROM users'));

const result = loadConfig()
.flatMap(connectDB)
.flatMap(queryData)
.fold(
error => console.error('操作失败:', error),
data => console.log('查询结果:', data)
);

防御式编程

使用单子进行防御式编程,避免空指针错误:

// 安全获取嵌套属性
const safeGet = (...keys) => obj =>
keys.reduce(
(maybe, key) => maybe.flatMap(o => Maybe.of(o[key])),
Maybe.of(obj)
);

const data = {
user: {
profile: {
settings: {
theme: 'dark'
}
}
}
};

const getTheme = safeGet('user', 'profile', 'settings', 'theme');

console.log(getTheme(data).getOrElse('light')); // 'dark'
console.log(getTheme({}).getOrElse('light')); // 'light'

小结

函子和单子是函数式编程中处理"容器中的值"的抽象:

函子

  • 实现了 map 方法的容器
  • 允许对容器内的值进行转换,不离开容器
  • 遵守同一律和组合律

单子

  • 扩展了函子,增加了 flatMap 方法
  • 解决嵌套容器的问题
  • 提供链式操作的能力

实用单子

  • Maybe:安全处理可能为空的值
  • Either:携带错误信息的失败处理
  • Task:可控的异步操作

理解函子和单子的价值不在于实现它们,而在于理解这种抽象模式。它们提供了一种统一的方式来处理各种"上下文"中的值,让代码更加健壮、可组合、易于推理。