网络埋伏纪事

实例讲解 JS 函数式编程 (第一部分)

网络埋伏纪事 · 2016-12-14翻译 · 747阅读 原文链接

函数式编程(FP)可以改善编程的方式。但是它很难学,很多文章和教程并没有深入讲解 Monad、Applicative 等,也没有用实例来帮助我们在日常使用强大的函数式编程技术。这就是为什么我考虑写一篇文章让使用函数式编程技术变得更容易的原因。

请注意:本博客的重点放在为什么需要 xyz 功能,而不是 XYZ 功能是什么上。

在第一部分,将通过两个示例来学习函数式编程的基础知识、柯里化、纯函数、Fantasy-land 规范、Functor、Monad、Maybe Monad 和 Either Monad。

函数式编程

函数式编程是一种通过组合一组函数来编写程序的风格。

函数式编程基本上是要求我们将所有东西都封装到函数中,编写很多小的可重用的函数,然后一个接着一个调用(比如,func1.func2.func3),或者以组合的方式调用(比如, func1(func2(func3()))),从而得到结果。

但是为了实际上以这种风格编写程序,函数需要遵循一些规则,并克服一些挑战,比如下面提到的一些:

函数式编程的挑战:

如果一切都可以通过组合一系列函数来完成,那么:

  1. 如何处理 if-else 条件?(提示:Either Monad)
  2. 如何处理空异常?(提示:Maybe Monad)
  3. 如何确保函数是真正可重用的,并且可以到处重用?(提示:纯函数、引用透明
  4. 如何确保传递给函数的数据是不可修改的,这样就能到处重用该数据? (提示:纯函数、不可变性)
  5. 如果多个链在一起的函数只能传递一个值,我们如何能依然让它作为链的一部分?(提示:柯里化和高阶函数
  6. 等等。。。

函数式解决方案:

为了处理所有这些挑战,Haskell 这类纯函数式编程语言提供了各种很方便的数学工具和概念,比如 Monad、Functor 等等。

JavaScript 并没有提供很多很方便的工具,但是然后它的函数式编程特性足够强大,可以让人们编写库来实现这些工具。

Fantasy-Land 规范和函数式编程库

库如果要提供 Functor、Monad 等特性,需要实现遵循某些规范的函数/类,才能像 Haskell 这类语言一样提供函数性。

Fantasyland 规范是最有名的规范之一,它解释了每个 JS 函数/类应该是什么样的。

上图展示了所有的规范及其依赖。规范实际上就是一些法则,与 Java 中的接口相似。从 JS 的角度来看,可以把规范当作实现了 map、of、chain 等方法的类或者构造器函数。

例如:

如果一个 JS 类实现了 map 方法,那么它就是一个 Functor。并且该 map 方法必须按规范工作(ps:这是简化版,实际上有很多规则)。

同样,如果一个 JS 类按照规范实现了 map 和 ap 函数,那么它就是一个 Apply Functor。

同样,如果一个 JS 类实现了 Functor、Apply、Applicative、Chain 以及 Monoad 本身(因为依赖链),那么它就是一个 Monad(亦称 Monad Functor)。

注意:依赖可能看起来像继承,但是不一定是!例如,Monad 既实现了 Applicative 又实现了 Chain 规范。

符合 Fantasy-Land 规范的库

实现 FL 规范的库有几个。例如:monet.jsbarely-functionalfolktalejsramda-fantasy(基于 Ramda)、immutable-ext(基于 ImmutableJS)、Fluture 等等。

我应该用什么库呢?

lodash-fpramdajs 这种库,只能让你可以用函数式风格开始编写程序。但是它们不能提供函数以使用核心的数学概念(比如 Monad、Functor、Foldable)来实际解决现实问题。

所以,除了它们以外,还得使用一个遵循 fantasy-land 规范的库。这样的库包含: monet.jsbarely-functionalfolktalejsramda-fantasy(基于 Ramda)、immutable-ext(基于 ImmutableJS)、Fluture 等等。

注意:本文将使用 ramdajsramda-fantasy

OK,现在我们了解了基础知识,下面我们来看看一些实例,并通过这些示例来学习各种函数式编程特性和技术。

示例 1 — 处理空检查

涉及的主题:Functor、Monad、Maybe Monad 和 柯里化。

用例: 我们想根据用户的首选语言显示不同的主页(在用户的首选项内,见下文)。需要编写一个 getUrlForUser 函数,为用户(joeUser)的首选语言(spanish)从 URL 列表(indexURLs)中返回合适的 URL。

问题是: 首选语言可能是空,用户本身也可能是空(没有登录进来),首选语言可能不在 indexURLs 列表中。所以我们将不得不处理很多 null 或者 undefined。

//TODO 分别用命令式、函数式风格写
const getUrlForUser = (user) => {
//todo
}

//User 对象
let joeUser = {
    name: 'joe',
    email: 'joe@example.com',
    prefs: {
        languages: {
            primary: 'sp',
            secondary: 'en'
        }
    }
};

//全局的 indexURLs,映射不同的语言
let indexURLs = {
    'en': 'http://mysite.com/en',  //English
     'sp': 'http://mysite.com/sp', //Spanish
    'jp': 'http://mysite.com/jp'   //Japanese
}

//应用 url 到 window.location
const showIndexPage = (url) => { window.location = url };

解决方案(命令式 Vs 函数式):

PS: 如果函数式版本看上去很难理解,不要担心,本文后面我们会一步一步讲解。

// 命令式版本:
// 太多 if-else 和 null 检查;依赖于全局 indexURLs;
// "en" URL 是所有国家的默认值
const getUrlForUser = (user) => {
  if (user == null) {        // 没有登录进来
    return indexURLs['en'];  // 返回默认页
  }
  if (user.prefs.languages.primary && user.prefs.languages.primary != 'undefined') {
    if (indexURLs[user.prefs.languages.primary]) { //如果翻译存在
      return indexURLs[user.prefs.languages.primary];
    } else {
      return indexURLs['en'];
    }
  }
}

//调用
showIndexPage(getUrlForUser(joeUser));


// 函数式编程版本:
// (起初有点难理解,但是更健壮、无缺陷)
// 用到的函数式编程技术:Functor、Maybe Monad 和柯里化
const R = require('ramda');
const prop = R.prop;
const path = R.path;
const curry = R.curry;
const Maybe = require('ramda-fantasy').Maybe;

const getURLForUser = (user) => {
    return Maybe(user) //将 user 封装到一个 Maybe 对象
        .map(path(['prefs', 'languages', 'primary'])) //使用 Ramda 来获取首选语言
        .chain(maybeGetUrl); // 传递语言给 maybeGetUrl,得到 URL 或者null Monad
}

const maybeGetUrl = R.curry(function(allUrls, language) { // 柯里化来将它转换为一个函数参数
    return Maybe(allUrls[language]); // 返回 Monad(url | null)
})(indexURLs); // 传递 indexURLs 而不是全局访问


function boot(user, defaultURL) {
   showIndexPage(getURLForUser(user).getOrElse(defaultURL));
}

boot(joeUser, 'http://site.com/en'); //'http://site.com/sp'

OK,下面我们先理解几个用于本解决方案的函数式编程概念和技术。

Functor

任何存储一个值,并且实现了 map 方法的类(或者构造函数)或者数据类型都被称为 Functor

例如:数组即是 Functor。因为数组既可以存储值,又有 map 方法可以将函数映射到它存储的值。

const add1 = (a) => a+1;

let myArray = new Array(1, 2, 3, 4); //存储值

myArray.map(add1) // -> [2,3,4,5] //应用函数

下面我们自己写一个 Functor "MyFunctor"。它只是一个存储某些值,并且实现了 map 方法的 JS 类。这个 map 方法对存储的值应用 map 函数,然后从结果创建一个新的 Myfunctor,并返回新的 MyFunctor。

const add1 = (a) => a + 1;

class MyFunctor { // 自定义 "Functor"
  constructor(value) {
    this.val = value;
  }
  map(fn) {   // 应用 map 函数到 this.val,并返回新 Myfunctor
   return new Myfunctor(fn(this.val));
  }
}

//temp 是一个存储值 1 的 Functor 实例
let temp = new MyFunctor(1); 
temp.map(add1) //-> temp 允许我们 map "add1"

PS: 除了要实现 map 外,Functor 还需要实现其它规范(参见 Fantasyland 规范),但是这里我不打算涉及它们。

Monad

Monad 也是 Functor,即它们有 map 方法,但是它除了实现 map 方法外,还实现了更多方法。如果再看看依赖图,会发现还需要实现不同规范中的各种其它特性,比如:Apply(ap 方法)、 Applicative(apof 方法),以及 Chain(chain 方法)。

简化了的解释:在 JS 中,Monad 是存储一些数据,并按照规范实现了处理这些数据的 map、ap、of 和 chain 方法的类或者构造器函数。

下面是一个示例实现,这样就可以了解 Monad 的内部机制。

//Monad - 一个示例实现
class Monad {
    constructor(val) {
        this.__value = val;
    }
    static of(val) {// Monad.of 比 new Monad(val) 更简单
        return new Monad(val);
    };
    map(f) {// 应用 map 函数,但是返回另一个 Monad!
        return Monad.of(f(this.__value));
    };
    join() { // 用于在 Monad 外获取值
        return this.__value;
    };
    chain(f) {// 辅助函数,先映射,然后获取值
        return this.map(f).join();
    };
    ap(someOtherMonad) {// 用于处理多个 Monad
        return someOtherMonad.map(this.__value);
    }
}

现在,在函数式编程中,通常不会用普通的 Monad,而是经常用更特殊、更有用的 Monad,比如 Maybe Monad 或者 Either Monad。所以,下面我们来看看 Maybe Monad。

Maybe Monad

Maybe Monad 是实现 Monad 规范的类。不过其特殊之处在于它是用来处理 null 或 undefined 值的。

特别是,如果存储的数据是 null 或者 undefined,那么它的 map 函数就完全不会执行,因而避免了 null 或者 undefined 问题。它被用于正在处理 null 值的情况下。

如下代码展示 Maybe Monad 的 ramda-fantasy 实现。它根据值是有用的值,还是 null/undefined,创建两个不同子类(Just 或者 Nothing)之一的实例。

虽然 Just 和 Nothing 有相同的方法(map、orElse 等),不过 Just 的实例要做一些事情,而 Nothing 的实例是什么事都不做。

请特别关注下面的 “map” 和 “orElse” 方法

// 展示 ramda-fantasy 库的 Maybe 实现的相关部分
// 完整源代码请看 https://github.com/ramda/ramda-fantasy/blob/master/src/Maybe.js 

function Maybe(x) { //<-- 返回 Just 或者 Nothing 的 Maybe 的主构造器
  return x == null ? _nothing : Maybe.Just(x);
}

function Just(x) {
  this.value = x;
}
util.extend(Just, Maybe);

Just.prototype.isJust = true;
Just.prototype.isNothing = false;

function Nothing() {}
util.extend(Nothing, Maybe);

Nothing.prototype.isNothing = true;
Nothing.prototype.isJust = false;

var _nothing = new Nothing();

Maybe.Nothing = function() {
  return _nothing;
};

Maybe.Just = function(x) {
  return new Just(x);
};

Maybe.of = Maybe.Just;

Maybe.prototype.of = Maybe.Just;


// functor
// 在 Just 上执行 map,执行函数,并从结果返回 Just
Just.prototype.map = function(f) { 
  return this.of(f(this.value));
};

// 在 Nothing 上执行 map,不会做任何事情
Nothing.prototype.map = util.returnThis;

Just.prototype.getOrElse = function() {
  return this.value;
};

Nothing.prototype.getOrElse = function(a) {
  return a;
};

module.exports = Maybe;

下面我们看看如何使用 Maybe monad 来处理 “null" 检查。

遵循如下步骤:

  1. 如果对象可能为 null,或者有 null 属性,就在它外面创建一个 Monad 对象。
  2. 使用像 ramdajs 这种 Maybe 感知的库,来访问来自 Monad 以及 Monad 中的值,并对其做处理。
  3. 如果实际值恰好是 null,就提供一个默认值(即处理前面的 Null 错误)。
// 第 1 步。不要用这种方式:
if (user == null) { //未登录进来
    return indexURLs['en']; //返回默认页
}

// 而要用:
 Maybe(user) //返回 Maybe({userObj}) 或者 Maybe(null)。即,数据包含在 Maybe 内。


//  第 2 步。不要用。。
 if (user.prefs.languages.primary && user.prefs.languages.primary != 'undefined') {
    if (indexURLs[user.prefs.languages.primary]) {//如果翻译存在
      return indexURLs[user.prefs.languages.primary];

// 要用:
// 一个知道如何处理 Maybe 中的数据的库,比如 Ramda 的 map.path:
 <userMaybe>.map(path(['prefs', 'languages', 'primary']))

// 第 3 步。不要用。。
 return indexURLs['en']; // 硬编码默认值

// 要用:
// Maybe 库提供了 'orElse' 或者 'getOrElse' 方法,要么返回实际数据,要么返回默认值
<userMayBe>.getOrElse('http://site.com/en')

柯里化 — (帮助处理全局数据和多参数函数)

涉及的主题:纯函数和组合

如果想像 func1.func2.func3 或者 (func1(func2(func3())) 一样,将一组函数链在一起,那么所有这些函数每个就只能接受一个输入参数。例如,如果 func2 带有两个参数 func2(param1, param2),那就不能把它放在链中!

但是实际上,很多函数带有多个参数。那么,如何在组合中使用它们呢?解决方案是:柯里化(Currying)。

柯里化就是将一个带有多个参数的函数,转换为一个一次带有一个参数的函数。在所有参数都被传递进来之前,它是不会执行该函数的。

此外,柯里化还能被用在访问全局值的情况下。即,让它变成纯函数。

下面我们再来看看我们的解决方案:

// 下面的要点在于展示如何处理全局值,还让函数变成可链的

// 全局的 indexURLs 映射不同的语言
let indexURLs = {
    'en': 'http://mysite.com/en',  //English
    'sp': 'http://mysite.com/sp', //Spanish
    'jp': 'http://mysite.com/jp'   //Japanese
}

// 命令式的
// 简单,但是有出错的可能,并且是不纯(访问全局变量)
const getUrl = (language) => allUrls[language]; 

// 函数式编程
// 柯里化之前:
const getUrl = (allUrls, language) => {
    return Maybe(allUrls[language]);
}

// 柯里化之后:
// 将函数转换为单参数函数
const getUrl = R.curry(function(allUrls, language) {
    return Maybe(allUrls[language]);
});

const maybeGetUrl = getUrl(indexURLs) // 在柯里化的函数中存储全局值

// 从这里起,maybeGetUrl 只需要一个参数(语言),所以现在可以像这样链:
maybe(user).chain(maybeGetUrl).bla.bla

示例 2 - 处理错误抛出函数,并且在错误发生之后立即中止

涉及的主题:Either Monad

如果有默认值来替换 Null 错误,那么 Maybe Monad 还是很不错。但是如果函数需要抛出错误,该怎么办呢?而且,当将多个抛出错误的函数链在一起时,如何知道是哪个函数抛出错误呢(即,要快速检测失败)?

例如,假如有 func1.func2.func3,如果 func2 抛出一个错误,就应该略过 func3 及其它后来的函数,并且正确显示错误是来自 func2,这样才好处理它。

Either Monad

Either Monad 适合于处理会潜在抛出错误的多个函数,并且想在错误发生后立即停止,这样就可以精确定位错误发生的地方。

用例: 例如,在下面的命令式风格代码片段中,计算商品(items)的税金(tax)和折扣(discount),最后显示总价(showTotalPrice)。

注意,如果价格(price)不是数值,那么 tax 函数会抛出错误。同样,如果价格不是数值, discount 函数也会抛出错误,并且如果商品的价格小于 10,它也会抛出错误。

所以 showTotalPrice 含有多个错误检查。

//命令式
//返回错误或者含税价
const tax = (tax, price) => {
  if (!_.isNumber(price)) return new Error("价格必须是数值");

  return price + (tax * price);
};

//返回错误,或者含折扣价
const discount = (dis, price) => {
  if (!_.isNumber(price)) return (new Error("价格必须是数值"));

  if (price < 10) return new Error("价格低于 10 美元 的商品不打折");

  return price - (price * dis);
};

const isError = (e) => e && e.name == 'Error';

const getItemPrice = (item) => item.price;

//显示税后和折扣后的总价。需要处理多个错误。
const showTotalPrice = (item, taxPerc, disount) => {
  let price = getItemPrice(item);
  let result = tax(taxPerc, price);
  if (isError(result)) {
    return console.log('错误: ' + result.message);
  }
  result = discount(discount, result);
  if (isError(result)) {
    return console.log('错误: ' + result.message);
  }
  // 显示结果
  console.log('总价为: ' + result);
}

let tShirt = { name: 't-shirt', price: 11 };
let pant = { name: 't-shirt', price: '10 美元' };
let chips = { name: 't-shirt', price: 5 }; //小于 10 美元错误

showTotalPrice(tShirt) // 总价为: 9.075
showTotalPrice(pant)   // 错误: 价格必须是数值
showTotalPrice(chips)  // 错误: 价格低于 10 美元 的商品不打折

这个命令式风格的示例可以用 Either Monad 来改进。

下面我们来看看 showTotalPrice 如何能通过使用 Either Monad 改进,并且用函数式编程风格重写所有代码。

Either Monad 提供两种构造器:Either.Left 和 Either.Right。可以把它们当作是 Either 的子类。Left 和 Right 都是 Monad!理念是在 Left 中存储错误和异常,而在 Right 中存储有用的值

即根据值来创建 Either.Left 或者 Either.Right 的实例。之后就可以在这些值上执行 map、chain 等等来组合它们

虽然 Left 和 Right 都提供 map、chain 等方法,但是 Left 构造因为存储的是错误,所以不做任何事情。而 Right 构造器会实现所有的函数,因为它包含了实际的结果。

OK,下面我们来看看如何将命令式示例改为函数式。

第 1 步: 将返回值用 Left 和 Right 包装

注意:包装是指创建某些类的实例。这些函数内部会调用 new,所以我们不用调用 new。

var Either = require('ramda-fantasy').Either;
var Left = Either.Left;
var Right = Either.Right;

const tax = R.curry((tax, price) => {
  // 将错误包装在 Either.Left 中
  if (!_.isNumber(price)) return Left(new Error("价格必须是数值")); 

  // 将结果包装在 Either.Right 中
  return  Right(price + (tax * price)); 
});

const discount = R.curry((dis, price) => {
  // 将错误包装在 Either.Left 中
  if (!_.isNumber(price)) return Left(new Error("价格必须是数值"));

  // 将错误包装在 Either.Left 中
  if (price < 10) return Left(new Error("低于 10 美元的商品不打折")); 

  // 将结果包装在 Either.Right 中
  return Right(price - (price * dis)); 
});

第 2 步: 将初始值包装在 Right 中,因为它是一个有效值,这样我们就可以组合它。

const getItemPrice = (item) => Right(item.price);

第 3 步: 创建两个函数,一个用来处理最后的错误,另一个处理结果。然后将它们包装在 Either.either 中(来自于 ramda-fantasy.js api)。

Either.either 带有三个参数,一个是成功处理器,一个是错误处理器,一个是 Either Monad。Either 被柯里化了。所以我们现在可以只传递两个处理器,后面再传递 Either(第三个参数)。

一旦 Either.either 接收到所有三个参数,它就会把第三个参数 Either,根据 Either 是 Right 还是 Left,传递给成功处理器或者错误处理器。

const displayTotal = (total) => { console.log(‘Total Price: ‘ + total) };

const logError = (error) => { console.log(‘Error: ‘ + error.message); };

const eitherLogOrShow = Either.either(logError, displayTotal);

第 4 步: 使用 chain 方法将多个错误抛出函数组合在一起。将它们的结果传递给 Either.either,即 eitherLogOrShoweitherLogOrShow 会负责将结果传递给成功处理器或者失败处理器。

const showTotalPrice = (item) => eitherLogOrShow(getItemPrice(item).chain(apply25PercDisc).chain(addCaliTax));

将所有代码放在一起:

const tax = R.curry((tax, price) => {
  if (!_.isNumber(price)) return Left(new Error("价格必须是数值"));

  return  Right(price + (tax * price));
});

const discount = R.curry((dis, price) => {
  if (!_.isNumber(price)) return Left(new Error("价格必须是数值"));

  if (price < 10) return Left(new Error("低于 10 美元的商品不打折"));

  return Right(price - (price * dis));
});

const addCaliTax = (tax(0.1));//10%

const apply25PercDisc = (discount(0.25));// 25% 折扣

const getItemPrice = (item) => Right(item.price);


const displayTotal = (total) => { console.log('总价为: ' + total) };

const logError = (error) => { console.log('错误: ' + error.message); };

const eitherLogOrShow = Either.either(logError, displayTotal);

//api
const showTotalPrice = (item) => eitherLogOrShow(getItemPrice(item).chain(apply25PercDisc).chain(addCaliTax));

let tShirt = { name: 't-shirt', price: 11 };
let pant = { name: 't-shirt', price: '10 美元' }; // 错误
let chips = { name: 't-shirt', price: 5 }; // 小于10美元错误

showTotalPrice(tShirt) // 总价为: 9.075
showTotalPrice(pant)   // 错误: 价格必须是数值
showTotalPrice(chips)  // 错误: 低于 10 美元的商品不打折

谢谢您的阅读!如果喜欢本文,请点 💚,并在推特上分享!🙏🏼

相关文章