Skip to content

第八章:容器宝典

强大的容器

http://blog.dwinegar.com/2011/06/another-jar.html

我们已经学会了如何通过管道式组合纯函数处理数据。这些函数是行为的声明式规范。但控制流、错误处理、异步操作、状态管理以及作用该如何处理?本章将揭示这些重要抽象机制的构建基础。

首先构建基础容器。该容器应该能容纳任何类型的值——若只能存放西米布丁将失去实用价值。我们将以对象形式实现,但不赋予其面向对象意义上的属性和方法。这个容器应当被视为珍宝匣,是承载数据的特殊盒子。

js
class Container {
  constructor(x) {
    this.$value = x;
  }
  
  static of(x) {
    return new Container(x);
  }
}

这是我们的首个容器,特地命名为Container。通过Container.of构造函数可以避免到处使用new关键字。of函数的奥妙远不止于此,但目前可将其视为向容器注入值的规范方式。

让我们审视这个崭新的容器...

js
Container.of(3);
// Container(3)

Container.of('hotdogs');
// Container("hotdogs")

Container.of(Container.of({ name: 'yoda' }));
// Container(Container({ name: 'yoda' }))

在Node环境中容器显示为{$value: x}而非预期的Container(x)。Chrome能正确输出类型,不过这无关紧要。只要理解容器结构即可。虽然能改写inspect方法展示美观类型信息,但为简明起见,本书将以概念化形式示意容器输出效果。

在深入之前明确几个要点:

  • Container是单一属性的对象。尽管多数容器只保存单值,但容量并不受此限。我们约定属性名为$value

  • $value不能固定为特定类型,否则将违背容器通用性原则。

  • 数据一经存入即受容器保护。虽然可通过.$value取出,但这种方式违背设计初衷。

这种设计的优势将逐渐显现,此刻请暂时保持耐心。

第一个函子(Functor)

当数值被容器包裹后,需要一个执行函数操作的机制。

js
// (a -> b) -> Container a -> Container b
Container.prototype.map = function (f) {
  return Container.of(f(this.$value));
};

这类似于数组的map方法,但我们操作的是Container a而非[a]。工作原理本质相同:

js
Container.of(2).map(two => two + 2); 
// Container(4)

Container.of('flamethrowers').map(s => s.toUpperCase()); 
// Container('FLAMETHROWERS')

Container.of('bombs').map(append(' away')).map(prop('length')); 
// Container(10)

我们始终保持值在Container中操作。这种特性极其重要:容器内的值通过map传递处理,处理完毕立即放回容器保存。由于值从未脱离容器保护,我们可以链式调用多个map操作。如第三个示例所示,在此过程中还能改变值类型。

注意多次调用map实际构成了函数组合!背后的数学原理是什么?朋友们,这就是著名的*函子(Functor)*概念。

函子是实现map方法并遵循特定法则的类型

具体而言,函子(Functor)是包含契约的接口。虽然可命名为Mappable,但这样就失去了函子的趣味来源。函子源于范畴论,本章末将探讨其数学基础,现在让我们专注于直觉理解和实践应用。

为什么要包裹值并通过map操作?换种问法可能启发思路:委托容器执行函数能带来什么?答案是实现了函数应用的抽象。当我们map函数时,其实请求容器代为执行——这是极具威力的设计理念。

薛定谔的Maybe

cool cat, need reference

基础Container功能单一,通常称作Identity容器(其数学内涵后文详述)。真正实用的函子是像Maybe这样的变体,它在映射前会进行空值检查(此为教学简化版):

完整实现参见附录 B

js
class Maybe {
  static of(x) {
    return new Maybe(x);
  }

  get isNothing() {
    return this.$value === null || this.$value === undefined;
  }

  constructor(x) {
    this.$value = x;
  }

  map(fn) {
    return this.isNothing ? this : Maybe.of(fn(this.$value));
  }

  inspect() {
    return this.isNothing ? 'Nothing' : `Just(${inspect(this.$value)})`;
  }
}

Maybe继承自Container,新增空值校验机制。在map操作前检查是否存在有效值,从而规避空指针异常。

js
Maybe.of('Malkovich Malkovich').map(match(/a/ig));
// Just(True)

Maybe.of(null).map(match(/a/ig));
// Nothing

Maybe.of({ name: 'Boris' }).map(prop('age')).map(add(10));
// Nothing

Maybe.of({ name: 'Dinah', age: 14 }).map(prop('age')).map(add(10));
// Just(24)

即使映射空值也不会引发程序崩溃。因为Maybe在每次函数应用时都会检查有效性。

虽然点语法可用,但根据前文原则我们倾向无点风格(pointfree)。实际上无论采用哪种函子,map都能正确代理:

js
// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((f, anyFunctor) => anyFunctor.map(f));

这使函数组合更加优雅。Ramda的map同样适用该规则。教学时适当使用点语法,实践中可自由选择。注意此处类型签名中引入Functor f =>,表明f必须是函子——这个概念不难理解,但需要特别指出。

应用场景

实践中常见Maybe应用于可能返回空值的函数:

js
// safeHead :: [a] -> Maybe(a)
const safeHead = xs => Maybe.of(xs[0]);

// streetName :: Object -> Maybe String
const streetName = compose(map(prop('street')), safeHead, prop('addresses'));

streetName({ addresses: [] });
// Nothing

streetName({ addresses: [{ street: 'Shady Ln.', number: 4201 }] });
// Just('Shady Ln.')

safeHead在标准头部元素获取函数基础上添加类型安全。引入Maybe后,我们必须显式处理空值。该函数通过返回Maybe明示可能失败的情况,并通过map链强制保证取值安全,自动完成空值校验。此类API显著提升代码健壮性,将脆弱的纸片代码加固为木质结构。

有时函数会通过返回Nothing显式标识失败:

js
// withdraw :: Number -> Account -> Maybe(Account)
const withdraw = curry((amount, { balance }) =>
  Maybe.of(balance >= amount ? { balance: balance - amount } : null));

// This function is hypothetical, not implemented here... nor anywhere else.
// updateLedger :: Account -> Account 
const updateLedger = account => account;

// remainingBalance :: Account -> String
const remainingBalance = ({ balance }) => `Your balance is $${balance}`;

// finishTransaction :: Account -> String
const finishTransaction = compose(remainingBalance, updateLedger);


// getTwenty :: Account -> Maybe(String)
const getTwenty = compose(map(finishTransaction), withdraw(20));

getTwenty({ balance: 200.00 }); 
// Just('Your balance is $180')

getTwenty({ balance: 10.00 });
// Nothing

当余额不足时withdraw返回Nothing。该设计强制后续流程必须map处理结果,当出现Nothing时后续finishTransaction逻辑将跳过,确保失败后不更新账务数据。这正是预期行为——只有在取款成功时才更新账户状态。

值的释放

经常被忽视的是程序终需产生作用:发送JSON、界面渲染、文件操作等。我们无法直接return输出结果,必须通过特定函数释放容器内的值。这类似于禅宗公案:若程序无任何可观测效果,是否算真正运行?它可能只是空耗CPU周期后归于沉寂...

应用程序应该妥善保管数据直至最终操作。该过程可通过map完成而无需释放值。若试图强行取出Maybe中的值,可能面临值缺失的风险。正如薛定谔的猫处于叠加态,代码逻辑应保持这种状态直至最终操作,以此实现线性控制流。

当然我们也可以选择返回默认值继续流程,这需要maybe工具函数:

js
// maybe :: b -> (a -> b) -> Maybe a -> b
const maybe = curry((v, f, m) => {
  if (m.isNothing) {
    return v;
  }

  return f(m.$value);
});

// getTwenty :: Account -> String
const 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类似于条件判断:map对应if(x !== null)分支,而maybe处理完整的if/else逻辑。

初用Maybe可能稍显繁琐(Swift/Scala开发者会联想到Option(al))。频繁的空值检查看似冗余(尤其在值必定存在时),但习惯后将显著提升安全性——毕竟它能有效避免偷工减料带来的隐患。

编写不安全代码犹如在车流中拋掷彩绘鸡蛋,或使用劣质建材建造养老院。而Maybe为我们的函数安全保驾护航。

容我指出,实际实现通常将Maybe拆分为Just(x)/Nothing两种类型,以保持参数化特性。这也是为何常见到Some(x)/None的命名方式。

纯错误处理

pick a hand... need a reference

throw/catch并不纯粹——抛错会中断流程而非返回值。使用Either类型可实现更优雅的错误处理:

完整实现参见附录 B

js
class Either {
  static of(x) {
    return new Right(x);
  }

  constructor(x) {
    this.$value = x;
  }
}

class Left extends Either {
  map(f) {
    return this;
  }

  inspect() {
    return `Left(${inspect(this.$value)})`;
  }
}

class Right extends Either {
  map(f) {
    return Either.of(f(this.$value));
  }

  inspect() {
    return `Right(${inspect(this.$value)})`;
  }
}

const left = x => new Left(x);

LeftRightEither的两种子类型(省略了父类声明)。它们的行为差异显著:

js
Either.of('rain').map(str => `b${str}`); 
// Right('brain')

left('rain').map(str => `It's gonna ${str}, better bring your umbrella!`); 
// Left('rain')

Either.of({ host: 'localhost', port: 80 }).map(prop('host'));
// Right('localhost')

left('rolls eyes...').map(prop('host'));
// Left('rolls eyes...')

Left会忽略map操作,Right则与Container行为一致。Left的关键在于携带错误信息。

以日期转年龄为例,Either相比Nothing能提供错误详情:

js
const moment = require('moment');

// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
  const birthDate = moment(user.birthDate, 'YYYY-MM-DD');

  return birthDate.isValid()
    ? Either.of(now.diff(birthDate, 'years'))
    : left('Birth date could not be parsed');
});

getAge(moment(), { birthDate: '2005-12-12' });
// Right(9)

getAge(moment(), { birthDate: 'July 4, 2001' });
// Left('Birth date could not be parsed')

如同Nothing,返回Left将中断流程。但此时我们获得了错误诊断信息。类型签名中Either(String, Number)表明可能返回错误消息或有效年龄,这种非正式签名已足够传达类型信息。

js
// fortune :: Number -> String
const fortune = compose(concat('If you survive, you will be '), toString, add(1));

// zoltar :: User -> Either(String, _)
const zoltar = compose(map(console.log), map(fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// Right(undefined)

zoltar({ birthDate: 'balloons!' });
// Left('Birth date could not be parsed')

当日期有效时输出预测结果,否则返回包含错误信息的Left。这种处理类似于具备错误提示的异常机制,但更冷静优雅。

流程分支由日期有效性决定,但代码保持线性结构。通常我们会将console.log外置并通过map调用,该示例演示了Right分支的特殊处理。下划线表示忽略右侧值(某些环境需用console.log.bind(console)实现一等函数)。

值得注意:fortune函数完全独立于函子系统。这种设计使核心函数可脱离容器使用,需要时通过map*提升(lift)*到适当容器中。这种方式提高了函数复用性,可灵活适配不同函子。

Either既适合验证错误,也能处理文件丢失等严重错误。建议将其替代Maybe以增强错误反馈。

虽然我们仅用Either处理错误,其实它能表达逻辑析取(||),编码范畴论中的*余积(Coproduct)*概念。作为典型和类型,其可能值数目是组成类型可能性之和。尽管主要作为错误处理函子使用,但其他应用也值得探索。

map外,either工具函数接收两个处理函数:

js
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = curry((f, g, e) => {
  let result;

  switch (e.constructor) {
    case Left:
      result = f(e.$value);
      break;

    case Right:
      result = g(e.$value);
      break;

    // No Default
  }

  return result;
});

// zoltar :: User -> _
const zoltar = compose(console.log, either(id, fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// undefined

zoltar({ birthDate: 'balloons!' });
// 'Birth date could not be parsed'
// undefined

此处id函数原样返回Left值用于错误输出。通过maybeeither的对比可见条件分支处理的提升。

Old McDonald司效应...

dominoes.. need a reference

在讨论纯函数时,我们见过通过包裹副作用实现纯函数的案例:

js
// getFromStorage :: String -> (_ -> String)
const getFromStorage = key => () => localStorage[key];

若不封装,getFromStorage将因外部状态变化成为非纯函数。包裹后始终返回固定功能,虽然未产生实际作用,但宣称其为纯函数便问心无愧。

但此时该函数如同未拆封的手办被束之高阁。要操作其内容,就需要IO容器的介入。

js
class IO {
  static of(x) {
    return new IO(() => x);
  }

  constructor(fn) {
    this.$value = fn;
  }

  map(fn) {
    return new IO(compose(fn, this.$value));
  }

  inspect() {
    return `IO(${inspect(this.$value)})`;
  }
}

IO特殊之处在于其存储的是函数——但应视为延迟执行的副作用包裹器。其of方法实现延迟执行:虽然写入IO(x),实际存储的是() => x以避免立即求值。注意,为便于理解我们示意性展示IO内部值,但实际必须执行才能获取(这将破坏纯度)。

使用示例:

js
// ioWindow :: IO Window
const ioWindow = new IO(() => window);

ioWindow.map(win => win.innerWidth);
// IO(1430)

ioWindow
  .map(prop('location'))
  .map(prop('href'))
  .map(split('/'));
// IO(['http:', '', 'localhost:8000', 'blog', 'posts'])


// $ :: String -> IO [DOM]
const $ = selector => new IO(() => document.querySelectorAll(selector));

$('#myDiv').map(head).map(div => div.innerHTML);
// IO('I am some inner html')

ioWindow可直接映射,而`# 第八章:容器宝典

强大的容器

http://blog.dwinegar.com/2011/06/another-jar.html

我们已经学会了如何通过管道式组合纯函数处理数据。这些函数是行为的声明式规范。但控制流、错误处理、异步操作、状态管理以及作用该如何处理?本章将揭示这些重要抽象机制的构建基础。

首先构建基础容器。该容器应该能容纳任何类型的值——若只能存放西米布丁将失去实用价值。我们将以对象形式实现,但不赋予其面向对象意义上的属性和方法。这个容器应当被视为珍宝匣,是承载数据的特殊盒子。

js
class Container {
  constructor(x) {
    this.$value = x;
  }
  
  static of(x) {
    return new Container(x);
  }
}

这是我们的首个容器,特地命名为Container。通过Container.of构造函数可以避免到处使用new关键字。of函数的奥妙远不止于此,但目前可将其视为向容器注入值的规范方式。

让我们审视这个崭新的容器...

js
Container.of(3);
// Container(3)

Container.of('hotdogs');
// Container("hotdogs")

Container.of(Container.of({ name: 'yoda' }));
// Container(Container({ name: 'yoda' }))

在Node环境中容器显示为{$value: x}而非预期的Container(x)。Chrome能正确输出类型,不过这无关紧要。只要理解容器结构即可。虽然能改写inspect方法展示美观类型信息,但为简明起见,本书将以概念化形式示意容器输出效果。

在深入之前明确几个要点:

  • Container是单一属性的对象。尽管多数容器只保存单值,但容量并不受此限。我们约定属性名为$value

  • $value不能固定为特定类型,否则将违背容器通用性原则。

  • 数据一经存入即受容器保护。虽然可通过.$value取出,但这种方式违背设计初衷。

这种设计的优势将逐渐显现,此刻请暂时保持耐心。

第一个函子(Functor)

当数值被容器包裹后,需要一个执行函数操作的机制。

js
// (a -> b) -> Container a -> Container b
Container.prototype.map = function (f) {
  return Container.of(f(this.$value));
};

这类似于数组的map方法,但我们操作的是Container a而非[a]。工作原理本质相同:

js
Container.of(2).map(two => two + 2); 
// Container(4)

Container.of('flamethrowers').map(s => s.toUpperCase()); 
// Container('FLAMETHROWERS')

Container.of('bombs').map(append(' away')).map(prop('length')); 
// Container(10)

我们始终保持值在Container中操作。这种特性极其重要:容器内的值通过map传递处理,处理完毕立即放回容器保存。由于值从未脱离容器保护,我们可以链式调用多个map操作。如第三个示例所示,在此过程中还能改变值类型。

注意多次调用map实际构成了函数组合!背后的数学原理是什么?朋友们,这就是著名的*函子(Functor)*概念。

函子是实现map方法并遵循特定法则的类型

具体而言,函子(Functor)是包含契约的接口。虽然可命名为Mappable,但这样就失去了函子的趣味来源。函子源于范畴论,本章末将探讨其数学基础,现在让我们专注于直觉理解和实践应用。

为什么要包裹值并通过map操作?换种问法可能启发思路:委托容器执行函数能带来什么?答案是实现了函数应用的抽象。当我们map函数时,其实请求容器代为执行——这是极具威力的设计理念。

薛定谔的Maybe

cool cat, need reference

基础Container功能单一,通常称作Identity容器(其数学内涵后文详述)。真正实用的函子是像Maybe这样的变体,它在映射前会进行空值检查(此为教学简化版):

完整实现参见附录 B

js
class Maybe {
  static of(x) {
    return new Maybe(x);
  }

  get isNothing() {
    return this.$value === null || this.$value === undefined;
  }

  constructor(x) {
    this.$value = x;
  }

  map(fn) {
    return this.isNothing ? this : Maybe.of(fn(this.$value));
  }

  inspect() {
    return this.isNothing ? 'Nothing' : `Just(${inspect(this.$value)})`;
  }
}

Maybe继承自Container,新增空值校验机制。在map操作前检查是否存在有效值,从而规避空指针异常。

js
Maybe.of('Malkovich Malkovich').map(match(/a/ig));
// Just(True)

Maybe.of(null).map(match(/a/ig));
// Nothing

Maybe.of({ name: 'Boris' }).map(prop('age')).map(add(10));
// Nothing

Maybe.of({ name: 'Dinah', age: 14 }).map(prop('age')).map(add(10));
// Just(24)

即使映射空值也不会引发程序崩溃。因为Maybe在每次函数应用时都会检查有效性。

虽然点语法可用,但根据前文原则我们倾向无点风格(pointfree)。实际上无论采用哪种函子,map都能正确代理:

js
// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((f, anyFunctor) => anyFunctor.map(f));

这使函数组合更加优雅。Ramda的map同样适用该规则。教学时适当使用点语法,实践中可自由选择。注意此处类型签名中引入Functor f =>,表明f必须是函子——这个概念不难理解,但需要特别指出。

应用场景

实践中常见Maybe应用于可能返回空值的函数:

js
// safeHead :: [a] -> Maybe(a)
const safeHead = xs => Maybe.of(xs[0]);

// streetName :: Object -> Maybe String
const streetName = compose(map(prop('street')), safeHead, prop('addresses'));

streetName({ addresses: [] });
// Nothing

streetName({ addresses: [{ street: 'Shady Ln.', number: 4201 }] });
// Just('Shady Ln.')

safeHead在标准头部元素获取函数基础上添加类型安全。引入Maybe后,我们必须显式处理空值。该函数通过返回Maybe明示可能失败的情况,并通过map链强制保证取值安全,自动完成空值校验。此类API显著提升代码健壮性,将脆弱的纸片代码加固为木质结构。

有时函数会通过返回Nothing显式标识失败:

js
// withdraw :: Number -> Account -> Maybe(Account)
const withdraw = curry((amount, { balance }) =>
  Maybe.of(balance >= amount ? { balance: balance - amount } : null));

// This function is hypothetical, not implemented here... nor anywhere else.
// updateLedger :: Account -> Account 
const updateLedger = account => account;

// remainingBalance :: Account -> String
const remainingBalance = ({ balance }) => `Your balance is $${balance}`;

// finishTransaction :: Account -> String
const finishTransaction = compose(remainingBalance, updateLedger);


// getTwenty :: Account -> Maybe(String)
const getTwenty = compose(map(finishTransaction), withdraw(20));

getTwenty({ balance: 200.00 }); 
// Just('Your balance is $180')

getTwenty({ balance: 10.00 });
// Nothing

当余额不足时withdraw返回Nothing。该设计强制后续流程必须map处理结果,当出现Nothing时后续finishTransaction逻辑将跳过,确保失败后不更新账务数据。这正是预期行为——只有在取款成功时才更新账户状态。

值的释放

经常被忽视的是程序终需产生作用:发送JSON、界面渲染、文件操作等。我们无法直接return输出结果,必须通过特定函数释放容器内的值。这类似于禅宗公案:若程序无任何可观测效果,是否算真正运行?它可能只是空耗CPU周期后归于沉寂...

应用程序应该妥善保管数据直至最终操作。该过程可通过map完成而无需释放值。若试图强行取出Maybe中的值,可能面临值缺失的风险。正如薛定谔的猫处于叠加态,代码逻辑应保持这种状态直至最终操作,以此实现线性控制流。

当然我们也可以选择返回默认值继续流程,这需要maybe工具函数:

js
// maybe :: b -> (a -> b) -> Maybe a -> b
const maybe = curry((v, f, m) => {
  if (m.isNothing) {
    return v;
  }

  return f(m.$value);
});

// getTwenty :: Account -> String
const 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类似于条件判断:map对应if(x !== null)分支,而maybe处理完整的if/else逻辑。

初用Maybe可能稍显繁琐(Swift/Scala开发者会联想到Option(al))。频繁的空值检查看似冗余(尤其在值必定存在时),但习惯后将显著提升安全性——毕竟它能有效避免偷工减料带来的隐患。

编写不安全代码犹如在车流中拋掷彩绘鸡蛋,或使用劣质建材建造养老院。而Maybe为我们的函数安全保驾护航。

容我指出,实际实现通常将Maybe拆分为Just(x)/Nothing两种类型,以保持参数化特性。这也是为何常见到Some(x)/None的命名方式。

纯错误处理

pick a hand... need a reference

throw/catch并不纯粹——抛错会中断流程而非返回值。使用Either类型可实现更优雅的错误处理:

完整实现参见附录 B

js
class Either {
  static of(x) {
    return new Right(x);
  }

  constructor(x) {
    this.$value = x;
  }
}

class Left extends Either {
  map(f) {
    return this;
  }

  inspect() {
    return `Left(${inspect(this.$value)})`;
  }
}

class Right extends Either {
  map(f) {
    return Either.of(f(this.$value));
  }

  inspect() {
    return `Right(${inspect(this.$value)})`;
  }
}

const left = x => new Left(x);

LeftRightEither的两种子类型(省略了父类声明)。它们的行为差异显著:

js
Either.of('rain').map(str => `b${str}`); 
// Right('brain')

left('rain').map(str => `It's gonna ${str}, better bring your umbrella!`); 
// Left('rain')

Either.of({ host: 'localhost', port: 80 }).map(prop('host'));
// Right('localhost')

left('rolls eyes...').map(prop('host'));
// Left('rolls eyes...')

Left会忽略map操作,Right则与Container行为一致。Left的关键在于携带错误信息。

以日期转年龄为例,Either相比Nothing能提供错误详情:

js
const moment = require('moment');

// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
  const birthDate = moment(user.birthDate, 'YYYY-MM-DD');

  return birthDate.isValid()
    ? Either.of(now.diff(birthDate, 'years'))
    : left('Birth date could not be parsed');
});

getAge(moment(), { birthDate: '2005-12-12' });
// Right(9)

getAge(moment(), { birthDate: 'July 4, 2001' });
// Left('Birth date could not be parsed')

如同Nothing,返回Left将中断流程。但此时我们获得了错误诊断信息。类型签名中Either(String, Number)表明可能返回错误消息或有效年龄,这种非正式签名已足够传达类型信息。

js
// fortune :: Number -> String
const fortune = compose(concat('If you survive, you will be '), toString, add(1));

// zoltar :: User -> Either(String, _)
const zoltar = compose(map(console.log), map(fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// Right(undefined)

zoltar({ birthDate: 'balloons!' });
// Left('Birth date could not be parsed')

当日期有效时输出预测结果,否则返回包含错误信息的Left。这种处理类似于具备错误提示的异常机制,但更冷静优雅。

流程分支由日期有效性决定,但代码保持线性结构。通常我们会将console.log外置并通过map调用,该示例演示了Right分支的特殊处理。下划线表示忽略右侧值(某些环境需用console.log.bind(console)实现一等函数)。

值得注意:fortune函数完全独立于函子系统。这种设计使核心函数可脱离容器使用,需要时通过map*提升(lift)*到适当容器中。这种方式提高了函数复用性,可灵活适配不同函子。

Either既适合验证错误,也能处理文件丢失等严重错误。建议将其替代Maybe以增强错误反馈。

虽然我们仅用Either处理错误,其实它能表达逻辑析取(||),编码范畴论中的*余积(Coproduct)*概念。作为典型和类型,其可能值数目是组成类型可能性之和。尽管主要作为错误处理函子使用,但其他应用也值得探索。

map外,either工具函数接收两个处理函数:

js
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = curry((f, g, e) => {
  let result;

  switch (e.constructor) {
    case Left:
      result = f(e.$value);
      break;

    case Right:
      result = g(e.$value);
      break;

    // No Default
  }

  return result;
});

// zoltar :: User -> _
const zoltar = compose(console.log, either(id, fortune), getAge(moment()));

zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// undefined

zoltar({ birthDate: 'balloons!' });
// 'Birth date could not be parsed'
// undefined

此处id函数原样返回Left值用于错误输出。通过maybeeither的对比可见条件分支处理的提升。

Old McDonald司效应...

dominoes.. need a reference

在讨论纯函数时,我们见过通过包裹副作用实现纯函数的案例:

js
// getFromStorage :: String -> (_ -> String)
const getFromStorage = key => () => localStorage[key];

若不封装,getFromStorage将因外部状态变化成为非纯函数。包裹后始终返回固定功能,虽然未产生实际作用,但宣称其为纯函数便问心无愧。

但此时该函数如同未拆封的手办被束之高阁。要操作其内容,就需要IO容器的介入。

js
class IO {
  static of(x) {
    return new IO(() => x);
  }

  constructor(fn) {
    this.$value = fn;
  }

  map(fn) {
    return new IO(compose(fn, this.$value));
  }

  inspect() {
    return `IO(${inspect(this.$value)})`;
  }
}

IO特殊之处在于其存储的是函数——但应视为延迟执行的副作用包裹器。其of方法实现延迟执行:虽然写入IO(x),实际存储的是() => x以避免立即求值。注意,为便于理解我们示意性展示IO内部值,但实际必须执行才能获取(这将破坏纯度)。

使用示例:

js
// ioWindow :: IO Window
const ioWindow = new IO(() => window);

ioWindow.map(win => win.innerWidth);
// IO(1430)

ioWindow
  .map(prop('location'))
  .map(prop('href'))
  .map(split('/'));
// IO(['http:', '', 'localhost:8000', 'blog', 'posts'])


// $ :: String -> IO [DOM]
const $ = selector => new IO(() => document.querySelectorAll(selector));

$('#myDiv').map(head).map(div => div.innerHTML);
// IO('I am some inner html')

是返回IO的函数。虽然渲染为概念值,但实际始终是{$value: [Function]}。映射操作实则为函数组合,如同摆放多米诺骨牌——构建计算链但不立即执行。这种模式类似于命令模式或任务队列。

一旦领悟函子本质,就能跨越具体实现差异进行映射。函子定律(本章结尾详述)为此提供了理论保障,使我们能在保持纯度的前提下操作副作用。

最终必须执行IO内的副作用——此时纯度将被破坏。但是调用方承担执行责任,核心代码始终保持纯洁。示例演示如何将执行权移交调用方:

js
// url :: IO String
const url = new IO(() => window.location.href);

// toPairs :: String -> [[String]]
const toPairs = compose(map(split('=')), split('&'));

// params :: String -> [[String]]
const params = compose(toPairs, last, split('?'));

// findParam :: String -> IO Maybe [String]
const findParam = key => map(compose(Maybe.of, find(compose(eq(key), head)), params), url);

// -- Impure calling code ----------------------------------------------

// run it by calling $value()!
findParam('searchTerm').$value();
// Just(['searchTerm', 'wafflehouse'])

我们的库仅负责包装请求参数,调用方执行副作用且可能形成多层容器嵌套(如IO(Maybe([x]))),这种结构具有强大的表现力。

必须纠正IO属性名的误导性:$value实为副作用触发器,我们将其更名为unsafePerformIO以警示风险。

js
class IO {
  constructor(io) {
    this.unsafePerformIO = io;
  }

  map(fn) {
    return new IO(compose(fn, this.unsafePerformIO));
  }
}

调用形式改为findParam('searchTerm').unsafePerformIO(),使意图清晰可见。

至此完成IO的介绍。接下来探讨使用场景迥异的Task容器。

异步任务

回调函数如同埃舍尔绘制的螺旋迷宫,层层嵌套令人窒息。好在有更好的异步处理方案,其名字以"F"开头。

由于实现复杂,此处使用Folktale库的Data.Task(曾用名Data.Future)演示:

js
// -- Node readFile example ------------------------------------------

const fs = require('fs');

// readFile :: String -> Task Error String
const readFile = filename => new Task((reject, result) => {
  fs.readFile(filename, (err, data) => (err ? reject(err) : result(data)));
});

readFile('metamorphosis').map(split('\n')).map(head);
// Task('One morning, as Gregor Samsa was waking up from anxious dreams, he discovered that
// in bed he had been changed into a monstrous verminous bug.')


// -- jQuery getJSON example -----------------------------------------

// getJSON :: String -> {} -> Task Error JSON
const getJSON = curry((url, params) => new Task((reject, result) => {
  $.getJSON(url, params, result).fail(reject);
}));

getJSON('/video', { id: 10 }).map(prop('title'));
// Task('Family Matters ep 15')


// -- Default Minimal Context ----------------------------------------

// We can put normal, non futuristic values inside as well
Task.of(3).map(three => three + 1);
// Task(4)

rejectresult对应错误与成功回调。通过map操作未来值,如同直接操作现值——这便是map的复用魅力。

熟悉Promise的读者会发现map类似then,但Task是纯函数式实现。该比喻仅辅助理解,本书不会使用非纯的Promise。

IO类似,Task延迟执行异步操作。实际上,所有异步副作用都可用Task处理而无需额外IOmap操作如同编写未来指令集——这种科技拖延术精妙绝伦。

运行Task需调用fork方法(类比unsafePerformIO)。该方法以非阻塞方式启动异步流程:

js
// -- Pure application -------------------------------------------------
// blogPage :: Posts -> HTML
const blogPage = Handlebars.compile(blogTemplate);

// renderPage :: Posts -> HTML
const renderPage = compose(blogPage, sortBy(prop('date')));

// blog :: Params -> Task Error HTML
const blog = compose(map(renderPage), getJSON('/posts'));


// -- Impure calling code ----------------------------------------------
blog({}).fork(
  error => $('#error').html(error.message),
  page => $('#main').html(page),
);

$('#spinner').show();

fork启动后立即返回,页面显示加载图标。最终根据结果渲染内容或报错——全部流程保持线性可读。

注意控制流仍保持从上到下阅读顺序,而非跳转于回调之间,极大提升代码可读性。

Task还整合了Either的错误处理能力——因异步世界的错误无法用常规控制流处理。这种内置的错误处理机制干净强大。

当然EitherIO的地位不可取代。看这个复杂的假设案例:

js
// Postgres.connect :: Url -> IO DbConnection
// runQuery :: DbConnection -> ResultSet
// readFile :: String -> Task Error String

// -- Pure application -------------------------------------------------

// dbUrl :: Config -> Either Error Url
const dbUrl = ({ uname, pass, host, db }) => {
  if (uname && pass && host && db) {
    return Either.of(`db:pg://${uname}:${pass}@${host}5432/${db}`);
  }

  return left(Error('Invalid config!'));
};

// connectDb :: Config -> Either Error (IO DbConnection)
const connectDb = compose(map(Postgres.connect), dbUrl);

// getConfig :: Filename -> Task Error (Either Error (IO DbConnection))
const getConfig = compose(map(compose(connectDb, JSON.parse)), readFile);


// -- Impure calling code ----------------------------------------------

getConfig('db.json').fork(
  logErr('couldn\'t read file'),
  either(console.log, map(runQuery)),
);

虽然Task处理异步读取,但同步验证依然需要Either,数据库连接需要IO——说明各司其职的重要性。

异步流程的复杂性将由后续章节的monad等概念解决。但首先需要奠定数学模型基础。

铺垫至此,立即切入正题。

理论点滴

函子源于范畴论,需满足两大定律:

js
// identity
map(id) === id;

// composition
compose(map(f), map(g)) === map(compose(f, g));

同一律简单但关键。这些法则可用代码验证:

js
const idLaw1 = map(id);
const idLaw2 = id;

idLaw1(Container.of(2)); // Container(2)
idLaw2(Container.of(2)); // Container(2)

运行可见结果相等。再看组合律

js
const compLaw1 = compose(map(append(' world')), map(append(' cruel')));
const compLaw2 = map(compose(append(' world'), append(' cruel')));

compLaw1(Container.of('Goodbye')); // Container('Goodbye cruel world')
compLaw2(Container.of('Goodbye')); // Container('Goodbye cruel world')

范畴论中,函子将对象与态射映射到另一范畴。新范畴需满足同一律与组合律——而函子定律已确保这点。

可将范畴视为对象与态射构成的网络。函子F将源范畴C中的对象a映射为目标范畴中的F a。参考下图:

Categories mapped

例如Maybe将常规类型映射为空值安全范畴。代码中通过map与容器实现,确保原始组合关系不变。技术上说,函子属于自函子(endofunctor),但现阶段可简单理解为跨范畴映射。

通过图表理解态射映射:

functor diagram

无论选择哪条路径,最终结果一致。这种等价性为代码重构提供了数学依据。

js
// topRoute :: String -> Maybe String
const topRoute = compose(Maybe.of, reverse);

// bottomRoute :: String -> Maybe String
const bottomRoute = compose(map(reverse), Maybe.of);

topRoute('hi'); // Just('ih')
bottomRoute('hi'); // Just('ih')

假设有函数链式调用:

functor diagram 2

根据函子定律我们可以立即重构并确保正确性。

函子支持多层嵌套:

js
const nested = Task.of([Either.of('pillows'), left('no sleep for you')]);

map(map(map(toUpperCase)), nested);
// Task([Right('PILLOWS'), Left('no sleep for you')])

当处理嵌套容器Future<Array<Either<String, Number>>>时,必须执行三次map方能应用函数。此时可通过函子组合简化操作:

js
class Compose {
  constructor(fgx) {
    this.getCompose = fgx;
  }

  static of(fgx) {
    return new Compose(fgx);
  }

  map(fn) {
    return new Compose(map(map(fn), this.getCompose));
  }
}

const tmd = Task.of(Maybe.of('Rock over London'));

const ctmd = Compose.of(tmd);

const ctmd2 = map(append(', rock on, Chicago'), ctmd);
// Compose(Task(Just('Rock over London, rock on, Chicago')))

ctmd2.getCompose;
// Task(Just('Rock over London, rock on, Chicago'))

调用组合函数即可简化操作。函子组合满足结合律,配合恒等函子形成范畴——这种模式展现了数学之美的同时,也带来架构启示。

总结

已介绍的函子仅是冰山一角。树形结构、列表、键值对等可迭代类型都是函子,事件流与响应式编程中的Observable亦然。函子普遍存在于编程世界,后续章节将频繁使用。

如何处理多函子参数调用?如何管理带副作用的异步操作序列?要解决这些问题需要学习monad等新工具,这正是下章重点。

第九章:Monad的洋葱

练习题

开始练习!

使用addmap实现容器内值的自增函数 // incrF :: Functor f => f Int -> f Int const incrF = undefined;


已知用户对象:

js
const user = { id: 2, name: 'Albert', active: true };

开始练习!

使用safeProphead查找用户首字母 // initial :: User -> Maybe String const initial = undefined;


已知工具函数:

js
// showWelcome :: User -> String
const showWelcome = compose(concat('Welcome '), prop('name'));

// checkActive :: User -> Either String User
const checkActive = function checkActive(user) {
  return user.active
    ? Either.of(user)
    : left('Your account is not active');
};

开始练习!

编写函数结合checkActiveshowWelcome实现权限控制 // eitherWelcome :: User -> Either String String const eitherWelcome = undefined;


现有以下函数:

js
// validateUser :: (User -> Either String ()) -> User -> Either String User
const validateUser = curry((validate, user) => validate(user).map(_ => user));

// save :: User -> IO User
const save = user => new IO(() => ({ ...user, saved: true }));

开始练习!

编写validateName验证用户名长度,并结合eithershowWelcomesave实现用户注册 注意either的两个参数需返回同类型值 // validateName :: User -> Either String () const validateName = undefined; // register :: User -> IO String const register = compose(undefined, validateUser(validateName));