Typescript函数式编程: Maybe monad

翻译自 Advanced functional programming in TypeScript: Maybe monad

从这篇博客开始, 我想做一个关于monad的简短系列。如果你对Javascript函数式编程有所了解[比如不可变(immutability)和纯函数(pure function)],这里就带你进一步深入理解这种很酷的范式(paradigm)。无论你是否听说过monad, 或者听说过但不知道monad是什么,这个系列将尽可能用简单实用的词汇来解释monad。

我已经在博客上写过这个话题(monads in C#, monads in Scala), 但这次我想看看monad在前端的应用。多说一句 -- 我用Typescript而不是Javascript, 因为用强类型的语言更容易说明monad。但你不需要精通Typescript就可以理解这篇文章。

这个博客系列所有的代码都放在github上了。你可以查看和这个系列相关的提交历史。

让我们开始monod的旅程吧。

背景

我们要开发一个简单的应用, 实现以下场景: 一个公司有一个员工的层级结构 (每个员工可以有一个上级)。用户输入员工ID(数字)就可以看到他们上级的名字。我们先从不用monad的一般方法来实现。这里是UI的HTML代码:

<body>
    <h1>Find employee's supervisor</h1>
    <p>
        <label for="employeeId">Enter employee ID</label>
        <input type="text" name="employeeId" id="employeeIdInput" />
    </p>
    <p>
        <button type="button" id="findEmployeeButton">Find supervisor's name</button>
    </p>
    <p id="searchResults"></p>
</body>

这段HTML代码由员工 ID的输入框和搜索员工上级名字的按钮组成。组织界面行为的逻辑如下:

import { EmployeeRepository } from "./employee.repository";

const employeeIdInputEl = document.getElementById("employeeIdInput") as HTMLInputElement;
const findEmployeeButtonEl = document.getElementById("findEmployeeButton");
const searchResultsEl = document.getElementById("searchResults");

const repository = new EmployeeRepository();

findEmployeeButtonEl.addEventListener("click", () => {
    const supervisorName = getSupervisorName(employeeIdInputEl.value);
    if (supervisorName) {
        searchResultsEl.innerText = `Supervisor name: ${supervisorName}`;
    } else {
        searchResultsEl.innerText = "Could not find supervisor for given id";
    }
});

function getSupervisorName(enteredId: string) {
    if (enteredId) {
        const employee = repository.findById(parseInt(enteredId));
        if (employee && employee.supervisorId) {
            const supervisor = repository.findById(employee.supervisorId);
            if (supervisor) {
                return supervisor.name;
            }
        }
    }
}

首先,我们取到某些HTML元素。接下来在button上加上点击处理函数。在点击处理函数里,我们调用getSupervisorName 函数。所有的逻辑都在getSupervisorName里面(具体逻辑稍后在看)。最后我们用查到的结果更新p这个标签。最后我们快速看一下class EmployeeRepository

import { Employee } from "./employee.model";

export class EmployeeRepository {
    private employees: Employee[] = [
        { id: 1, name: "John" },
        { id: 2, name: "Jane", supervisorId: 1 },
        { id: 3, name: "Joe", supervisorId: 2 },
    ];

    findById(id: number) {
        const results = this.employees.filter(employee => employee.id === id);
        return results.length ? results[0] : null;
    }
}

这个类只是写死了一些内存中的员工层级结构。interface Employee长这样:

export interface Employee {
   id: number;
   name: string;
   supervisorId?: number;
}

嵌套,嵌套,嵌套

现在来关注getSupervisorName这个函数:

function getSupervisorName(enteredId: string) {
    if (enteredId) {
        const employee = repository.findById(parseInt(enteredId));
        if (employee && employee.supervisorId) {
            const supervisor = repository.findById(employee.supervisorId);
            if (supervisor) {
                return supervisor.name;
            }
        }
    }
}

正如我们看到的一样,函数体有这几层嵌套。这是因为查找上级(supervisor)的过程中要处理一些异常情况。

  • 用户可能没有输入员工ID就点了按钮
  • 输入的ID可能没有对应员工
  • 查找的员工可能没有上级(比如公司CEO或独立的顾问)
  • 可能员工上级的ID没有对应到某个员工(数据有误)

换句话说,这里调用了很多方法(operation)而且每个方法都可能返回空值(空的用户输入,空的查找结果等等)。这个函数需要处理所有的异常case,因此需要嵌套的if语句。这样做有问题吗?我想是的:

  • 写这样的代码很容易漏掉某些情况的判断,编译器也检查不出来
  • 代码可读性差

我们来看一下如何能同时解决这两个问题。

引入Maybe

简化代码的方式是识别出一个模式,并且通过抽象来影藏模式的实现细节。这里反复出现的模式就是getSupervisorName里面的嵌套if语句。

if (result) {
  const nextResult = operation(result);
  if (nextResult) {
     // and so on...
  }
} // else stop

但是如何抽象这种模式呢?出现这个if语句的原因是result变量保存的值可能是空值。我们来构造一个包装类(wrapper)来持有这个值并且能够表达出这个值是否为空值(即nullundefined或空string)的情况。将这个包装类命名为Maybe:

export class Maybe<T> {
    private constructor(private value: T | null) {}

    static some<T>(value: T) {
        if (!value) {
            throw Error("Provided value must not be empty");
        }
        return new Maybe(value);
    }

    static none<T>() {
        return new Maybe<T>(null);
    }

    static fromValue<T>(value: T) {
        return value ? Maybe.some(value) : Maybe.none<T>();
    }

    getOrElse(defaultValue: T) {
        return this.value === null ? defaultValue : this.value;
    }
}

Maybe的实例持有一个value, value可能是一个真实的值也可能是null。这里null是一个表达空值的内部表示。构造函数声明为private, 这样只能通过调用Maybe的静态方法somenone来构建Maybe实例。最后,getOrElse可以安全地提取Maybe持有的值。调用的时候必须传入缺省值,Maybe是空的时候将返回这个缺省值。到这里应该一切都OK。我们现在可以显示地表达一个方法返回一个可能为空的值。修改EmployeeRepositoryfindById方法:

findById(id: number): Maybe<Employee> {
    const results = this.employees.filter(employee => employee.id === id);
    return results.length ? Maybe.some(results[0]) : Maybe.none();
}

注意findById的返回值现在更有意义了,更好的捕获了开发者的意图。findById的确可能返回一个空值,比如员工ID在repository里不存在。更进一步我们可以修改Employee接口,来表明supervisorId可能是一个为空的值。

export interface Employee {
    id: number;
    name: string;
    supervisorId: Maybe<number>;
}

要让Maybe类变得有用,我们要添加一些方法。你已经知道数组上可以调用map方法,对吧?就是把一个方法应用到数组的每个元素上。如果我们把Maybe看做一个特殊的数组,即只有一个值或0个值的数组,那么我们也定义一个类似的map方法供我们使用。

map<R>(f: (wrapped: T) => R): Maybe<R> {
    if (this.value === null) {
        return Maybe.none<R>();
    } else {
        return Maybe.fromValue(f(this.value));
    }
}

我们的map方法传入一个对持有的值进行某种变换f函数,并且把变换的结果包装成一个新的Maybe实例返回出去。如果Maybenone,返回出去的也是空的Maybe(就像在空的数组上调用map方法会返回一个空的数组)。R是类型参数,表示f转换函数的返回类型。那如何使用map呢?原始版本的getSupervisorName方法包含下面if语句:

const supervisor = repository.findById(employee.supervisorId);
if (supervisor) {
    return supervisor.name;
}

但现在findById会返回一个Maybe! 碰巧我们定义了map方法,里面的逻辑和这里if的语义是一样的!因此,我们可以把上面的代码改成:

const supervisor: Maybe<Employee> = repository.findById(employee.supervisorId);
return supervisor.map(s => s.name);

我们是不是把if语句通过抽象隐藏起来了?是的,我们做到了!然而目前我们还不能把整个方法改成这种风格。

map, 还是flatMap ?

上面的转换用map方法是可行的,但如果是这种情况:

const employee = repository.findById(parseInt(enteredId));
if (employee && employee.supervisorId) {
    const supervisor = repository.findById(employee.supervisorId);
    // ...
}

我们可以尝试改成用map:

const employee: Maybe<Employee> = repository.findById(parseInt(enteredId));
const supervisor: Maybe<Maybe<Employee>> = employee.map(e => repository.findById(e.supervisorId));

看到问题了吗?supervisor的类型是Maybe<Maybe<Employee>>。这是因为我们的变换函数现在把一个基本的值映射(map)到了一个Maybe类型的值(之前我们只是从一个基本值映射到另一个基本值)。是否有办法把<Maybe<Maybe<Employee>>变换成一个简单的<Mapbe<Employee>>? 或者说,我们想把我们的Maybe扁平化(flatten)。再次借用数组的类比。你可以扁平化([...].flatten())嵌套数组[[1,2,3], [4,5,6]] => [1,2,3,4,5,6]。我们在Maybe里面添加一个新的方法flatMap。作用和map很像,但是这个方法会把结果扁平化,消除嵌套的Maybe

flatMap<R>(f: (wrapped: T) => Maybe<R>) : Maybe<R> {
    if (this.value === null) {
        return Maybe.none<R>();
    } else {
        return f(this.value);
    }
}

实现很简单。如果Maybe实例持有的不是空值我们就提取这个值,传给f,然后把f的返回值返回出去(返回一个空或非空的Maybe)。如果Maybe实例是空的,我们就返回一个空的Maybe。注意f的函数签名,之前是T=>R,现在是T=>Maybe<R>。有了flatMap之后,我们就能够把上面代码重写成:

const employee: Maybe<Employee> = repository.findById(parseInt(enteredId));
const supervisor: Maybe<Employee> = employee.flatMap(e => repository.findById(e.supervisorId));

Maybe Monad 实战

现在,我们所有准备工作做好了,可以重写getSupervisorName

function getSupervisorName(maybeEnteredId: Maybe<string>): Maybe<string> {
    return maybeEnteredId
        .flatMap(employeeIdString => Maybe.fromValue(parseInt(employeeIdString))) // parseInt can fail
        .flatMap(employeeId => repository.findById(employeeId))
        .flatMap(employee => employee.supervisorId)
        .flatMap(supervisorId => repository.findById(supervisorId))
        .map(supervisor => supervisor.name);
}

我们消除了所有的嵌套if语句!getSupervisorName的实现变成了对输入值的一个优雅的转换管道。处理空值的细节代码都是一些模板代码而且会使代码的真正意图不明显,因此需要把这些细节隐藏起来。现在使用Maybe做到了。注意如果传给flatMap的任何方法返回none, 整个flatMap连就会立即返回none。这和之前的if语句是一致的逻辑。下面是在点击监听函数中的使用:

findEmployeeButtonEl.addEventListener("click", () => {
    const supervisorName = getSupervisorName(Maybe.fromValue(employeeIdInputEl.value));
    searchResultsEl.innerText = `Supervisor name: ${supervisorName.getOrElse("could not find")}`;
});

你应该猜到了,Maybe就是一个 monad! monad的正式定义是支持下面两种操作的容器类型:

  • return - 从一个普通的值构造一个容器类型的值(这里的nonesome)
  • bind - 可以组合monad值 (flatMap)

monad也必须尊崇一些monad法则,我们暂时先不看。目前,你可以相信我我们的Maybe实现已经准守了monad法则!

总结

这篇是这个系列的第一篇,我们构造了第一个monad。Maybemonad的目的是抽象出处理空值的逻辑。多亏了这个引入这个类型,我们现在写代码就不用担心如何处理空值的情况了。在下一篇,我们会看一下使用Typescript把使用monad的代码变得更加可读。你有没有发现monad很有趣?

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,997评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,603评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,359评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,309评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,346评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,258评论 1 300
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,122评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,970评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,403评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,596评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,769评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,464评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,075评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,705评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,848评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,831评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,678评论 2 354

推荐阅读更多精彩内容