深入理解JavaScript函数式编程

作者: iamswf | 来源:发表于2018-12-02 21:33 被阅读117次

    JavaScript函数式编程

    大家都知道JavaScript可以作为面向对象或者函数式编程语言来使用,一般情况下大家理解的函数式编程无非包括副作用函数组合柯里化这些概念,其实并不然,如果往深了解学习会发现函数式编程还包括非常多的高级特性,比如functormonad等。国外课程网站egghead上有个教授(名字叫Frisby)基于JavaScript讲解的函数式编程非常棒,主要介绍了boxsemigroupmonoidfunctorapplicative functormonadisomorphism等函数式编程相关的高级主题内容。整个课程大概30节左右,本篇文章主要是对该课程的翻译与总结,有精力的强烈推荐大家观看原课程 Professor Frisby Introduces Composable Functional JavaScript

    1. 使用容器(Box)创建线性数据流

    普通函数是这样的:

    function nextCharForNumberString (str) {
      const trimmed = str.trim();
      const number = parseInt(trimmed);
      const nextNumber = number + 1;
      return String.fromCharCode(nextNumber);
    }
    
    const result = nextCharForNumberString(' 64');
    console.log(result); // "A"
    

    如果借助Array,可以这样实现:

    const nextCharForNumberString = str =>
        [str]
        .map(s => s.trim())
        .map(s => parseInt(s))
        .map(i => i + 1)
        .map(i => String.fromCharCode(i));
    
    const result = nextCharForNumberString(' 64');
    console.log(result); // ["A"]
    

    这里我们把数据str装进了一个箱子(数组),然后连续多次调用箱子的map方法来处理箱子内部的数据。这种实现已经可以感受到一些奇妙之处了。再看一种基本思想相同的实现方式,只不过这次我们不借助数组,而是自己实现箱子:

    const Box = x => ({
      map: f => Box(f(x)),
      fold: f => f(x),
      toString: () => `Box(${x})`
    });
    
    const nextCharForNumberString = str =>
        Box(str)
        .map(s => s.trim())
        .map(s => parseInt(s))
        .map(i => i + 1)
        .map(i => String.fromCharCode(i));
    
    const result = nextCharForNumberString(' 64');
    console.log(String(result)); // "Box(A)"
    

    至此我们自己动手实现了一个箱子。连续使用map可以组合一组操作,以创建线性的数据流。箱子中不仅可以放数据,还可以放函数,别忘了函数也是一等公民:

    const Box = x => ({
      map: f => Box(f(x)),
      fold: f => f(x),
      toString: () => `Box(${x})`
    });
    
    const f0 = x => x * 100; // think fo as a data
    const add1 = f => x => f(x) + 1; // think add1 as a function
    const add2 = f => x => f(x) + 2; // think add2 as a function
    const g = Box(f0)
    .map(f => add1(f))
    .map(f => add2(f))
    .fold(f => f);
    
    const res = g(1);
    console.log(res); // 103
    

    这里当你对一个函数容器调用map时,其实是在做函数组合。

    2. 使用Box重构命令式代码

    这里使用的Box跟上一节一样:

    const Box = x => ({
      map: f => Box(f(x)),
      fold: f => f(x),
      toString: () => `Box(${x})`
    });
    

    命令式moneyToFloat

    const moneyToFloat = str =>
        parseFloat(str.replace(/\$/g, ''));
    

    BoxmoneyToFloat

    const moneyToFloat = str =>
        Box(str)
        .map(s => s.replace(/\$/g, ''))
        .fold(r => parseFloat(r));
    

    我们这里使用Box重构了moneyToFloatBox擅长的地方就在于将嵌套表达式转成一个一个的map,这里虽然不是很复杂,但却是一种好的实践方式。

    命令式percentToFloat

    const percentToFloat = str => {
      const replaced = str.replace(/\%/g, '');
      const number = parseFloat(replaced);
      return number * 0.01;
    };
    

    BoxpercentToFloat

    const percentToFloat = str =>
        Box(str)
        .map(str => str.replace(/\%/g, ''))
        .map(replaced => parseFloat(replaced))
        .fold(number => number * 0.01);
    

    我们这里又使用Box重构了percentToFloat,显然这种实现方式的数据流更加清晰。

    命令式applyDiscount

    const applyDiscount = (price, discount) => {
      const cost = moneyToFloat(price);
      const savings = percentToFloat(discount);
      return cost - cost * savings;
    };
    

    重构applyDiscount稍微麻烦点,因为该函数有两条数据流,不过我们可以借助闭包:

    BoxapplyDiscount

    const applyDiscount = (price, discount) =>
        Box(price)
        .map(price => moneyToFloat(price))
        .fold(cost =>
            Box(discount)
            .map(discount => percentToFloat(discount))
            .fold(savings => cost - cost * savings));
    

    现在可以看一下这组代码的输出了:

    const result = applyDiscount('$5.00', '20%');
    
    console.log(String(result)); // "4"
    

    如果我们在moneyToFloatpercentToFloat中不进行拆箱(即fold),那么applyDiscount就没必要在数据转换之前先装箱(即Box)了:

    const moneyToFloat = str =>
        Box(str)
        .map(s => s.replace(/\$/g, ''))
        .map(r => parseFloat(r)); // here we don't fold the result out
    
    const percentToFloat = str =>
        Box(str)
        .map(str => str.replace(/\%/g, ''))
        .map(replaced => parseFloat(replaced))
        .map(number => number * 0.01); // here we don't fold the result out
    
    const applyDiscount = (price, discount) =>
        moneyToFloat(price)
        .fold(cost =>
            percentToFloat(discount)
            .fold(savings => cost - cost * savings));
    
    const result = applyDiscount('$5.00', '20%');
    
    console.log(String(result)); // "4"
    

    3. 使用Either进行分支控制

    Either的意思是两者之一,不是Right就是Left。我们先实现Right

    const Right = x => ({
      map: f => Right(f(x)),
      toString: () => `Right(${x})`
    });
    
    const result = Right(3).map(x => x + 1).map(x => x / 2);
    console.log(String(result)); // "Right(2)"
    

    这里我们暂且不实现Rightfold,而是先来实现Left

    const Left = x => ({
      map: f => Left(x),
      toString: () => `Left(${x})`
    });
    
    const result = Left(3).map(x => x + 1).map(x => x / 2);
    console.log(String(result)); // "Left(3)"
    

    Left容器跟Right是不同的,因为Left完全忽略了传入的数据转换函数,保持容器内部数据原样。有了RightLeft,我们可以对程序数据流进行分支控制。考虑到程序中经常会存在异常,因此容器通常都是未知类型RightOrLeft

    接下来我们实现RightLeft容器的fold方法,如果未知容器是Right,则使用第二个函数参数g进行拆箱:

    const Right = x => ({
      map: f => Right(f(x)),
      fold: (f, g) => g(x),
      toString: () => `Right(${x})`
    });
    

    如果未知容器是Left,则使用第一个函数参数f进行拆箱:

    const Left = x => ({
      map: f => Left(x),
      fold: (f, g) => f(x),
      toString: () => `Left(${x})`
    });
    

    测试一下RightLeftfold方法:

    const result = Right(2).map(x => x + 1).map(x => x / 2).fold(x => 'error', x => x);
    console.log(result); // 1.5
    
    const result = Left(2).map(x => x + 1).map(x => x / 2).fold(x => 'error', x => x);
    console.log(result); // 2
    

    借助Either我们可以进行程序流程分支控制,例如进行异常处理、null检查等。

    下面看一个例子:

    const findColor = name =>
        ({red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'})[name];
    
    const result = findColor('red').slice(1).toUpperCase();
    console.log(result); // "FF4444"
    

    这里如果我们给函数findColor传入green,则会报错。因此可以借助Either进行错误处理:

    const findColor = name => {
      const found = {red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'}[name];
      return found ? Right(found) : Left(null);
    };
    
    const result = findColor('green')
                .map(c => c.slice(1))
                .fold(e => 'no color',
                     c => c.toUpperCase());
    console.log(result); // "no color"
    

    更进一步,我们可以提炼出一个专门用于null检测的Either容器,同时简化findColor代码:

    const fromNullable = x =>
        x != null ? Right(x) : Left(null); // [!=] will test both null and undefined
    
    const findColor = name =>
        fromNullable({red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'}[name]);
    

    4. 利用chain解决Either的嵌套问题

    看一个读取配置文件config.json的例子,如果位置文件读取失败则提供一个默认端口3000,命令式代码实现如下:

    const fs = require('fs');
    
    const getPort = () => {
      try {
        const str = fs.readFileSync('config.json');
        const config = JSON.parse(str);
        return config.port;
      } catch (e) {
        return 3000;
      }
    };
    
    const result = getPort();
    console.log(result); // 8888 or 3000
    

    我们使用Either重构:

    const fs = require('fs');
    
    const tryCatch = f => {
      try {
        return Right(f());
      } catch (e) {
        return Left(e);
      }
    };
    
    const getPort = () =>
        tryCatch(() => fs.readFileSync('config.json'))
        .map(c => JSON.parse(c))
        .fold(
            e => 3000,
            obj => obj.port
        );
    
    const result = getPort();
    console.log(result); // 8888 or 3000
    

    重构后就完美了吗?我们用到了JSON.parse,如果config.json文件格式有问题,程序就会报错:

    SyntaxError: Unexpected end of JSON input

    因此需要针对JSON解析失败做异常处理,我们可以继续使用tryCatch来解决这个问题:

    const getPort = () =>
        tryCatch(() => fs.readFileSync('config.json'))
        .map(c => tryCatch(() => JSON.parse(c)))
        .fold(
            left => 3000, // 第一个tryCatch失败
            right => right.fold( // 第一个tryCatch成功
                e => 3000, // JSON.parse失败
                c => c.port
            )
        );
    

    这次重构我们使用了两次tryCatch,因此导致箱子套了两层,最后需要进行两次拆箱。为了解决这种箱子套箱子的问题,我们可以给RightLeft增加一个方法chain

    const Right = x => ({
      chain: f => f(x),
      map: f => Right(f(x)),
      fold: (f, g) => g(x),
      toString: () => `Right(${x})`
    });
    
    const Left = x => ({
      chain: f => Left(x),
      map: f => Left(x),
      fold: (f, g) => f(x),
      toString: () => `Left(${x})`
    });
    

    当我们使用map,又不想在数据转换之后又增加一层箱子时,我们应该使用chain

    const getPort = () =>
        tryCatch(() => fs.readFileSync('config.json'))
        .chain(c => tryCatch(() => JSON.parse(c)))
        .fold(
            e => 3000,
            c => c.port
        );
    

    5. 命令式代码使用Either实现举例

    const openSite = () => {
      if (current_user) {
          return renderPage(current_user);
        }
        else {
          return showLogin();
        }
    };
    
    const openSite = () =>
        fromNullable(current_user)
        .fold(showLogin, renderPage);
    
    const streetName = user => {
      const address = user.address;
      if (address) {
        const street = address.street;
        if (street) {
          return street.name;
        }
      }
      return 'no street';
    };
    
    const streetName = user =>
        fromNullable(user.address)
        .chain(a => fromNullable(a.street))
        .map(s => s.name)
        .fold(
            e => 'no street',
            n => n
        );
    
    const concatUniq = (x, ys) => {
      const found = ys.filter(y => y ===x)[0];
      return found ? ys : ys.concat(x);
    };
    
    const cancatUniq = (x, ys) =>
        fromNullable(ys.filter(y => y ===x)[0])
        .fold(null => ys.concat(x), y => ys);
    
    const wrapExamples = example => {
      if (example.previewPath) {
        try {
          example.preview = fs.readFileSync(example.previewPath);
        }
        catch (e) {}
      }
      return example;
    };
    
    const wrapExamples = example =>
        fromNullable(example.previewPath)
        .chain(path => tryCatch(() => fs.readFileSync(path)))
        .fold(
            () => example,
            preivew => Object.assign({preview}, example)
        );
    

    6. 半群

    半群是一种具有concat方法的类型,并且该concat方法满足结合律。比如ArrayString

    const res = "a".concat("b").concat("c");
    const res = [1, 2].concat([3, 4].concat([5, 6])); // law of association
    

    我们自定义Sum半群,Sum类型用来求和:

    const Sum = x => ({
      x,
      concat: o => Sum(x + o.x),
      toString: () => `Sum(${x})`
    });
    
    const res = Sum(1).concat(Sum(2));
    console.log(String(res)); // "Sum(3)"
    

    继续自定义All半群,All类型用来级联布尔类型:

    const All = x => ({
      x,
      concat: o => All(x && o.x),
      toString: () => `All(${x})`
    });
    
    const res = All(true).concat(All(false));
    console.log(String(res)); // "All(false)"
    

    继续定义First半群,First类型链式调用concat方法不改变其初始值:

    const First = x => ({
      x,
      concat: o => First(x),
      toString: () => `First(${x})`
    });
    
    const res = First('blah').concat(First('ice cream'));
    console.log(String(res)); // "First(blah)"
    

    7. 半群举例

    这里先占位,回头再补充。

    const acct1 = Map({
      name: First('Nico'),
      isPaid: All(true),
      points: Sum(10),
      friends: ['Franklin']
    });
    
    const acct2 = Map({
      name: First('Nico'),
      isPaid: All(false),
      points: Sum(2),
      friends: ['Gatsby']
    });
    
    const res = acct1.concat(acct2);
    console.log(res);
    

    8. monoid

    半群满足结合律,如果半群还具有幺元(单位元),那么就是monoid。幺元与其他元素结合时不会改变那些元素,可以用公式表示如下:

    e・a = a・e = a

    我们将半群Sum升级实现为monoid只需实现一个empty方法,调用改方法即可得到该monoid的幺元:

    const Sum = x => ({
      x,
      concat: o => Sum(x + o.x),
      toString: () => `Sum(${x})`
    });
    
    Sum.empty = () => Sum(0);
    
    const res = Sum.empty().concat(Sum(1).concat(Sum(2)));
    // const res = Sum(1).concat(Sum(2)).concat(Sum.empty());
    console.log(String(res)); // "Sum(3)"
    

    接着我们继续将All升级实现为monoid:

    const All = x => ({
      x,
      concat: o => All(x && o.x),
      toString: () => `All(${x})`
    });
    
    All.empty = () => All(true);
    
    const res = All(true).concat(All(true)).concat(All.empty());
    console.log(String(res)); // "All(true)"
    

    如果我们尝试着将半群First也升级为monoid就会发现不可行,比如First('hello').concat(…)的结果恒为hello,但是First.empty().concat(First('hello'))的结果就不一定是hello了,因此我们无法将半群First升级为monoid。这也说明monoid一定是半群,但是半群不一定是monoid。半群需要满足结合律,monoid不仅需要满足结合律,还必须存在幺元。

    9. monoid举例

    Sum(求和):

    const Sum = x => ({
      x,
      concat: o => Sum(x + o.x),
      toString: () => `Sum(${x})`
    });
    
    Sum.empty = () => Sum(0);
    

    Product(求积):

    const Product = x => ({
      x,
      concat: o => Product(x * o.x),
      toString: () => `Product(${x})`
    });
    
    Product.empty = () => Product(1);
    
    const res = Product.empty().concat(Product(2)).concat(Product(3));
    console.log(String(res)); // "Product(6)"
    

    Any(只要有一个为true即返回true,否则返回false):

    const Any = x => ({
      x,
      concat: o => Any(x || o.x),
      toString: () => `Any(${x})`
    });
    
    Any.empty = () => Any(false);
    
    const res = Any.empty().concat(Any(false)).concat(Any(false));
    console.log(String(res)); // "Any(false)"
    

    All(所有均为true才返回true,否则返回false):

    const All = x => ({
      x,
      concat: o => All(x && o.x),
      toString: () => `All(${x})`
    });
    
    All.empty = () => All(true);
    
    const res = All(true).concat(All(true)).concat(All.empty());
    console.log(String(res)); // "All(true)"
    

    Max(求最大值):

    const Max = x => ({
      x,
      concat: o => Max(x > o.x ? x : o.x),
      toString: () => `Max(${x})`
    });
    
    Max.empty = () => Max(-Infinity);
    
    const res = Max.empty().concat(Max(100)).concat(Max(200));
    console.log(String(res)); // "Max(200)"
    

    Min(求最小值):

    const Min = x => ({
      x,
      concat: o => Min(x < o.x ? x : o.x),
      toString: () => `Min(${x})`
    });
    
    Min.empty = () => Min(Infinity);
    
    const res = Min.empty().concat(Min(100)).concat(Min(200));
    console.log(String(res)); // "Min(100)"
    

    10. 使用foldMap对集合汇总

    假设我们需要对一个Sum集合进行汇总,可以这样实现:

    const res = [Sum(1), Sum(2), Sum(3)]
        .reduce((acc, x) => acc.concat(x), Sum.empty());
    
    console.log(res); // Sum(6)
    

    考虑到这个操作的一般性,可以抽成一个函数fold。用node安装immutableimmutable-extimmutable-ext提供了fold方法:

    const {Map, List} = require('immutable-ext');
    const {Sum} = require('./monoid');
    
    const res = List.of(Sum(1), Sum(2), Sum(3))
        .fold(Sum.empty());
    
    console.log(res); // Sum(6)
    

    也许你会觉得fold接受的参数应该是一个函数,因为前面几节介绍的fold就是这样的,比如BoxRight

    Box(3).fold(x => x); // 3
    Right(3).fold(e => e, x => x); // 3
    

    没错,不过fold的本质就是拆箱。前面对BoxRight类型拆箱是将其值取出来;而现在对集合拆箱则是为了将集合的汇总结果取出来。而将一个集合中的多个值汇总成一个值就需要传入初始值Sum.empty()。因此当你看到fold时,应该看成是为了从一个类型中取值出来,而这个类型可能是一个仅含一个值的类型(比如BoxRight),也可能是一个monoid集合。

    我们继续看另外一种集合Map

    const res = Map({brian: Sum(3), sara: Sum(5)})
        .fold(Sum.empty());
    
    console.log(res); // Sum(8)
    

    这里的Map是monoid集合,如果是普通数据集合可以先使用集合的map方法将该集合转换成monoid集合:

    const res = Map({brian: 3, sara: 5})
        .map(Sum)
        .fold(Sum.empty());
    
    console.log(res); // Sum(8)
    
    const res = List.of(1, 2, 3)
        .map(Sum)
        .fold(Sum.empty());
    
    console.log(res); // Sum(6)
    

    我们可以把这种对普通数据类型集合调用map转换成monoid类型集合,然后再调用fold进行数据汇总的操作抽出来,即为foldMap

    const res = List.of(1, 2, 3)
        .foldMap(Sum, Sum.empty());
    
    console.log(res); // Sum(6)
    

    11. 使用LazyBox延迟求值

    首先回顾一下前面Box的例子:

    const Box = x => ({
      map: f => Box(f(x)),
      fold: f => f(x),
      toString: () => `Box(${x})`
    });
    
    const res = Box(' 64')
                .map(s => s.trim())
                .map(s => parseInt(s))
                .map(i => i + 1)
                .map(i => String.fromCharCode(i))
                .fold(x => x.toLowerCase());
    
    console.log(String(res)); // a
    

    这里进行了一系列的数据转换,最后转换成了a。现在我们可以定义一个LazyBox,延迟执行这一系列数据转换函数,直到最后扣动扳机:

    const LazyBox = g => ({
      map: f => LazyBox(() => f(g())),
      fold: f => f(g())
    });
    
    const res = LazyBox(() => ' 64')
                .map(s => s.trim())
                .map(s => parseInt(s))
                .map(i => i + 1)
                .map(i => String.fromCharCode(i))
                .fold(x => x.toLowerCase());
    
    console.log(res); // a
    

    LazyBox的参数是一个参数为空的函数。在LazyBox上调用map并不会立即执行传入的数据转换函数,每调用一次map待执行函数队列中就会多一个函数,直到最后调用fold扣动扳机,前面所有的数据转换函数一触一发,一个接一个的执行。这种模式有助于实现纯函数。

    12. 在Task中捕获副作用

    本节依然是讨论Lazy特性,只不过基于data.task库,该库可以通过npm安装。假设我们要实现一个发射火箭的函数,如果我们这样实现,那么该函数显然不是纯函数:

    const launchMissiles = () =>
        console.log('launch missiles!'); // 使用console.log模仿发射火箭
    

    如果使用data.task可以借助其Lazy特性,延迟执行:

    const Task = require('data.task');
    
    const launchMissiles = () =>
        new Task((rej, res) => {
          console.log('launch missiles!');
          res('missile');
        });
    

    显然这样实现launchMissiles即为纯函数。我们可以继续在其基础上组合其他逻辑:

    const app = launchMissiles().map(x => x + '!');
    
    app
    .map(x => x + '!')
    .fork(
        e => console.log('err', e),
        x => console.log('success', x)
    );
    
    // launch missiles!
    // success missile!!
    

    调用fork方法才会扣动扳机,执行前面定义的Task以及一系列数据转换函数,如果不调用forkTask中的console.log操作就不会执行。

    13. 使用Task处理异步任务

    假设我们要实现读文件,替换文件内容,然后写文件的操作,命令式代码如下:

    const fs = require('fs');
    
    const app = () =>
        fs.readFile('config.json', 'utf-8', (err, contents) => {
          if (err) throw err;
          const  newContents = contents.replace(/8/g, '6');
          fs.writeFile('config1.json', newContents,
            (err, success) => {
            if (err) throw err;
            console.log('success');
          })
        });
    
    app();
    

    这里实现的app内部会抛出异常,不是纯函数。我们可以借助Task重构如下:

    const Task = require('data.task');
    const fs = require('fs');
    
    const readFile = (filename, enc) =>
        new Task((rej, res) =>
            fs.readFile(filename, enc, (err, contents) =>
                err ? rej(err) : res(contents)));
    
    const writeFile = (filename, contents) =>
        new Task((rej, res) =>
            fs.writeFile(filename, contents, (err, success) =>
                err ? rej(err) : res(success)));
    
    const app = () =>
        readFile('config.json', 'utf-8')
        .map(contents => contents.replace(/8/g, '6'))
        .chain(contents => writeFile('config1.json', contents));
    
    app().fork(
        e => console.log(e),
        x => console.log('success')
    );
    

    这里实现的app是纯函数,调用app().fork才会执行一系列动作。再看看data.task官网的顺序读两个文件的例子:

    const fs = require('fs');
    const Task = require('data.task');
    
    const readFile = path =>
        new Task((rej, res) =>
            fs.readFile(path, 'utf-8', (error, contents) =>
                error ? rej(error) : res(contents)));
    
    const concatenated = readFile('Task_test_file1.txt')
                        .chain(a =>
                            readFile('Task_test_file2.txt')
                            .map(b => a + b));
    
    concatenated.fork(console.error, console.log);
    

    14. Functor

    Functor是具有map方法的类型,并且需要满足下面两个条件:

    fx.map(f).map(g) == fx.map(x => g(f(x)))

    fx.map(id) == id(fx), where const id = x => x

    Box类型为例说明:

    const Box = x => ({
      map: f => Box(f(x)),
      fold: f => f(x),
      inspect: () => `Box(${x})`
    });
    
    const res1 = Box('squirrels')
                .map(s => s.substr(5))
                .map(s => s.toUpperCase());
    const res2 = Box('squirrels')
                .map(s => s.substr(5).toUpperCase());
    console.log(res1, res2); // Box(RELS) Box(RELS)
    

    显然Box满足第一个条件。注意这里的s = > s.substr(5).toUpperCase()其实本质上跟g(f(x))是一样的,我们完全重新定义成下面这种形式,不要被形式迷惑:

    const f = s => s.substr(5);
    const g = s => s.toUpperCase();
    const h = s => g(f(s));
    
    const res = Box('squirrels')
                .map(h);
    console.log(res); // Box(RELS)
    

    接下来我们看是否满足第二个条件:

    const id = x => x;
    const res1 = Box('crayons').map(id);
    const res2 = id(Box('crayons'));
    console.log(res1, res2); // Box(crayons) Box(crayons)
    

    显然也满足第二个条件。

    15. 使用of方法将值放入Pointed Functor

    pointed functor是具有of方法的functor,of可以理解成使用一个初始值来填充functor。以Box为例说明:

    const Box = x => ({
      map: f => Box(f(x)),
      fold: f => f(x),
      inspect: () => `Box(${x})`
    });
    Box.of = x => Box(x);
    
    const res = Box.of(100);
    console.log(res); // Box(100)
    

    这里再举个functor的例子,IO functor:

    const R = require('ramda');
    
    const IO = x => ({
      x, // here x is a function
      map: f => IO(R.compose(f, x)),
      fold: f => f(x) // get out x
    });
    
    IO.of = x => IO(x);
    

    IO是一个值为函数的容器,细心的话你会发现这就是前面的值为函数的Box容器。借助IO functor,我们可以纯函数式的处理一些IO操作了,因为读写操作就好像全部放入了队列一样,直到最后调用IO内部的函数时才会扣动扳机执行一系列操作,试一下:

    const R = require('ramda');
    const {IO} = require('./IO');
    
    const fake_window = {
        innerWidth: '1000px',
        location: {
            href: "http://www.baidu.com/cpd/fe"
        }
    };
    
    const io_window = IO(() => fake_window);
    
    const getWindowInnerWidth = io_window
    .map(window => window.innerWidth)
    .fold(x => x);
    
    const split = x => s => s.split(x);
    
    const getUrl = io_window
    .map(R.prop('location'))
    .map(R.prop('href'))
    .map(split('/'))
    .fold(x => x);
    
    console.log(getWindowInnerWidth()); // 1000px
    console.log(getUrl()); // [ 'http:', '', 'www.baidu.com', 'cpd', 'fe' ]
    

    16. Monad

    functor可以将一个函数作用到一个包着的(这里“包着”意思是值存在于箱子内,下同)值上面:

    Box(1).map(x => x + 1); // Box(2)
    

    applicative functor可以将一个包着的函数作用到一个包着的值上面:

    const add = x => x + 1;
    Box(add).ap(Box(1)); // Box(2)
    

    而monod可以将一个返回箱子类型的函数作用到一个包着的值上面,重点是作用之后包装层数不增加:

    先看个Boxfunctor的例子:

    const Box = x => ({
      map: f => Box(f(x)),
      fold: f => f(x),
      inspect: () => `Box(${x})`
    });
    
    const res = Box(1)
                .map(x => Box(x))
                .map(x => Box(x)); // Box(Box(Box(1)))
    console.log(res); // Box([object Object])
    

    这里我们连续调用map并且map时传入的函数的返回值是箱子类型,显然这样会导致箱子的包装层数不断累加,我们可以给Box增加join方法来拆包装:

    const Box = x => ({
      map: f => Box(f(x)),
      join: () => x,
      fold: f => f(x),
      inspect: () => `Box(${x})`
    });
    
    const res = Box(1)
                .map(x => Box(x))
                .join()
                .map(x => Box(x))
                .join();
    console.log(res); // Box(1)
    

    这里定义join仅仅是为了说明拆包装这个操作,我们当然可以使用fold完成相同的功能:

    const Box = x => ({
      map: f => Box(f(x)),
      join: () => x,
      fold: f => f(x),
      inspect: () => `Box(${x})`
    });
    
    const res = Box(1)
                .map(x => Box(x))
                .fold(x => x)
                .map(x => Box(x))
                .fold(x => x);
    console.log(res); // Box(1)
    

    考虑到.map(...).join()的一般性,我们可以为Box增加一个方法chain完成这两步操作:

    const Box = x => ({
      map: f => Box(f(x)),
      join: () => x,
      chain: f => Box(x).map(f).join(),
      fold: f => f(x),
      inspect: () => `Box(${x})`
    });
    
    const res = Box(1)
                .chain(x => Box(x))
                .chain(x => Box(x));
    console.log(res); // Box(1)
    

    17. 柯里化

    这个非常简单,直接举例,能看懂这些例子就明白柯里化了:

    const modulo = dvr => dvd => dvd % dvr;
    
    const isOdd = modulo(2); // 求奇数
    
    const filter = pred => xs => xs.filter(pred);
    
    const getAllOdds = filter(isOdd);
    
    const res1 = getAllOdds([1, 2, 3, 4]);
    console.log(res1); // [1, 3]
    
    const map = f => xs => xs.map(f);
    
    const add = x => y => x + y;
    
    const add1 = add(1);
    const allAdd1 = map(add1);
    
    const res2 = allAdd1([1, 2, 3]);
    console.log(res2); // [2, 3, 4]
    

    18. Applicative Functor

    前面介绍的Box是一个functor,我们为其添加ap方法,将其升级成applicative functor:

    const Box = x => ({
      ap: b2 => b2.map(x), // here x is a function
      map: f => Box(f(x)),
      fold: f => f(x),
      inspect: () => `Box(${x})`
    });
    
    const res = Box(x => x + 1).ap(Box(2));
    console.log(res); // Box(3)
    

    这里Box内部是一个一元函数,我们也可以使用柯里化后的多元函数:

    const add = x => y => x + y;
    
    const res = Box(add).ap(Box(2));
    console.log(res); // Box([Function])
    

    显然我们applicative functor上调用一次ap即可消掉一个参数,这里res内部存的是仍然是一个函数:y => 2 + y,只不过消掉了参数x。我们可以连续调用ap方法:

    const res = Box(add).ap(Box(2)).ap(Box(3));
    console.log(res); // Box(5)
    

    稍加思考我们会发现对于applicative functor,存在下面这个恒等式:

    F(x).map(f) = F(f).ap(F(x))

    即在一个保存值x的functor上调用map(f),恒等于在保存函数f的functor上调用ap(F(x))

    接着我们实现一个处理applicative functor的工具函数liftA2

    const liftA2 = (f, fx, fy) =>
        F(f).ap(fx).ap(fy);
    

    但是这里需要知道具体的functor类型F,因此借助于前面的恒等式,我们继续定义下面的一般形式liftA2

    const liftA2 = (f, fx, fy) =>
        fx.map(f).ap(fy);
    

    试一下:

    const res1 = Box(add).ap(Box(2)).ap(Box(4));
    const res2 = liftA2(add, Box(2), Box(4)); // utilize helper function liftA2
    
    console.log(res1); // Box(6)
    console.log(res2); // Box(6)
    

    当然我们也可以定义类似的liftA3liftA4等工具函数:

    const liftA3 = (f, fx, fy, fz) =>
        fx.map(f).ap(fy).ap(fz);
    

    19. Applicative Functor举例

    首先来定义either

    const Right = x => ({
      ap: e2 => e2.map(x), // declare as a applicative, here x is a function
      chain: f => f(x), // declare as a monad
      map: f => Right(f(x)),
      fold: (f, g) => g(x),
      inspect: () => `Right(${x})`
    });
    
    const Left = x => ({
      ap: e2 => e2.map(x), // declare as a applicative, here x is a function
      chain: f => Left(x), // declare as a monad
      map: f => Left(x),
      fold: (f, g) => f(x),
      inspect: () => `Left(${x})`
    });
    
    const fromNullable = x =>
        x != null ? Right(x) : Left(null); // [!=] will test both null and undefined
    
    const either = {
        Right,
        Left,
        of: x => Right(x),
        fromNullable
    };
    

    可以看出either既是monad又是applicative functor。

    假设我们要计算页面上除了headerfooter之外的高度:

    const $ = selector =>
        either.of({selector, height: 10}); // fake DOM selector
    
    const getScreenSize = (screen, header, footer) =>
        screen - (header.height + footer.height);
    

    如果使用monodchain方法,可以这样实现:

    const res = $('header')
        .chain(header =>
            $('footer').map(footer =>
                getScreenSize(800, header, footer)));
    console.log(res); // Right(780)
    

    也可以使用applicative实现,不过首先需要柯里化getScreenSize

    const getScreenSize = screen => header => footer =>
        screen - (header.height + footer.height);
    
    const res1 = either.of(getScreenSize(800))
        .ap($('header'))
        .ap($('footer'));
    const res2 = $('header')
        .map(getScreenSize(800))
        .ap($('footer'));
    const res3 = liftA2(getScreenSize(800), $('header'), $('footer'));
    console.log(res1, res2, res3); // Right(780) Right(780) Right(780)
    

    20. Applicative Functor之List

    本节介绍使用applicative functor实现下面这种模式:

    for (x in xs) {
      for (y in ys) {
        for (z in zs) {
          // your code here
        }
      }
    }
    

    使用applicative functor重构如下:

    const {List} = require('immutable-ext');
    
    const merch = () =>
        List.of(x => y => z => `${x}-${y}-${z}`)
        .ap(List(['teeshirt', 'sweater']))
        .ap(List(['large', 'medium', 'small']))
        .ap(List(['black', 'white']));
    const res = merch();
    console.log(res);
    

    21. 使用applicatives处理并发异步事件

    假设我们要发起两次读数据库的请求:

    const Task = require('data.task');
    
    const Db = ({
      find: id =>
        new Task((rej, res) =>
            setTimeOut(() => {
                console.log(res);
                res({id: id, title: `Project ${id}`}) 
            }, 5000))
    });
    
    const report = (p1, p2) =>
        `Report: ${p1.title} compared to ${p2.title}`;
    

    如果使用monadchain实现,那么两个异步事件只能顺序执行:

    Db.find(20).chain(p1 =>
        Db.find(8).map(p2 =>
            report(p1, p2)))
        .fork(console.error, console.log);
    

    使用applicatives重构:

    Task.of(p1 => p2 => report(p1, p2))
    .ap(Db.find(20))
    .ap(Db.find(8))
    .fork(console.error, console.log);
    

    22. [Task] => Task([])

    假设我们准备读取一组文件:

    const fs = require('fs');
    const Task = require('data.task');
    const futurize = require('futurize').futurize(Task);
    const {List} = require('immutable-ext');
    
    const readFile = futurize(fs.readFile);
    
    const files = ['box.js', 'config.json'];
    const res = files.map(fn => readFile(fn, 'utf-8'));
    console.log(res);
    // [ Task { fork: [Function], cleanup: [Function] },
    //   Task { fork: [Function], cleanup: [Function] } ]
    

    这里res是一个Task数组,而我们想要的是Task([])这种类型,类似promise.all()的功能。我们可以借助traverse方法使Task类型从数组里跳到外面:

    [Task] => Task([])

    实现如下:

    const files = List(['box.js', 'config.json']);
    files.traverse(Task.of, fn => readFile(fn, 'utf-8'))
      .fork(console.error, console.log);
    

    23. {Task} => Task({})

    假设我们准备发起一组http请求:

    const fs = require('fs');
    const Task = require('data.task');
    const {List, Map} = require('immutable-ext');
    
    const httpGet = (path, params) =>
        Task.of(`${path}: result`);
    
    const res = Map({home: '/', about: '/about', blog: '/blod'})
    .map(route => httpGet(route, {}));
    console.log(res);
    // Map { "home": Task, "about": Task, "blog": Task }
    

    这里res是一个值为TaskMap,而我们想要的是Task({})这种类型,类似promise.all()的功能。我们可以借助traverse方法使Task类型从Map里跳到外面:

    {Task} => Task({})

    实现如下:

    Map({home: '/', about: '/about', blog: '/blod'})
    .traverse(Task.of, route => httpGet(route, {}))
    .fork(console.error, console.log);
    // Map { "home": "/: result", "about": "/about: result", "blog": "/blod: result" }
    

    24. 类型转换

    本节介绍一种functor如何转换成另外一种functor。例如将either转换成Task

    const {Right, Left, fromNullable} = require('./either');
    const Task = require('data.task');
    
    const eitherToTask = e =>
        e.fold(Task.rejected, Task.of);
    
    eitherToTask(Right('nightingale'))
    .fork(
        e => console.error('err', e),
        r => console.log('res', r)
    ); // res nightingale
    
    eitherToTask(Left('nightingale'))
    .fork(
        e => console.error('err', e),
        r => console.log('res', r)
    ); // err nightingale
    

    Box转换成Either

    const {Right, Left, fromNullable} = require('./either');
    const Box = require('./box');
    
    const boxToEither = b =>
        b.fold(Right);
    
    const res = boxToEither(Box(100));
    console.log(res); // Right(100)
    

    你可能会疑惑为什么boxToEither要转换成Right,而不是Left,原因就是本节讨论的类型转换需要满足该条件:

    nt(fx).map(f) == nt(fx.map(f))

    其中nt是natural transform的缩写,即自然类型转换,所有满足该公式的函数均为自然类型转换。接着讨论boxToEither,如果前面转换成Left,我们看下是否还能满足该公式:

    const boxToEither = b =>
        b.fold(Left);
    
    const res1 = boxToEither(Box(100)).map(x => x * 2);
    const res2 = boxToEither(Box(100).map(x => x * 2));
    console.log(res1, res2); // Left(100) Left(200)
    

    显然不满足上面的条件。

    再看一个自然类型转换函数first

    const first = xs =>
        fromNullable(xs[0]);
    
    const res1 = first([1, 2, 3]).map(x => x + 1);
    const res2 = first([1, 2, 3].map(x => x + 1));
    console.log(res1, res2); // Right(2) Right(2)
    

    前面的公式表明,对于一个functor,先进行自然类型转换再map等价于先map再进行自然类型转换。

    25. 类型转换举例

    先看下first的一个用例:

    const {fromNullable} = require('./either');
    
    const first = xs =>
        fromNullable(xs[0]);
    
    const largeNumbers = xs =>
        xs.filter(x => x > 100);
    
    const res = first(largeNumbers([2, 400, 5, 1000]).map(x => x * 2));
    
    console.log(res); // Right(800)
    

    这种实现没什么问题,不过这里将large numbers的每个值都进行了乘2的map,而我么最后的结果仅仅需要第一个值,因此借用自然类型转换公式我们可以改成下面这种形式:

    const res = first(largeNumbers([2, 400, 5, 1000])).map(x => x * 2);
    
    console.log(res); // Right(800)
    

    再看一个稍微复杂点的例子:

    const {Right, Left} = require('./either');
    const Task = require('data.task');
    
    const fake = id => ({
      id,
      name: 'user1',
      best_friend_id: id + 1
    }); // fake user infomation
    
    const Db = ({
      find: id =>
        new Task((rej, res) =>
            res(id > 2 ? Right(fake(id)) : Left('not found')))
    }); // fake database
    
    const eitherToTask = e =>
        e.fold(Task.rejected, Task.of);
    

    这里我们模拟了一个数据库以及一些用户信息,并假设数据库中只能够查到id大于2的用户。

    现在我们要查找某个用户的好朋友的信息:

    Db.find(3) // Task(Right(user))
    .map(either =>
        either.map(user => Db.find(user.best_friend_id))) // Task(Either(Task(Either)))
    

    如果这里使用chain,看一下效果如何:

    Db.find(3) // Task(Right(user))
    .chain(either =>
        either.map(user => Db.find(user.best_friend_id))) // Either(Task(Either))
    

    这样调用完之后也有有问题:容器的类型从Task变成了Either,这也不是我们想看到的。下面我们借助自然类型转换重构一下:

    Db.find(3) // Task(Right(user))
    .map(eitherToTask) // Task(Task(user))
    

    为了去掉一层包装,我们改用chain

    Db.find(3) // Task(Right(user))
    .chain(eitherToTask) // Task(user)
    .chain(user =>
        Db.find(user.best_friend_id)) // Task(Right(user))
    .chain(eitherToTask)
    .fork(
        console.error,
        console.log
    ); // { id: 4, name: 'user1', best_friend_id: 5 }
    

    26. 同构(isomorphrism)

    这里讨论的同构不是“前后端同构”的同构,而是一对满足如下要求的函数:

    from(to(x)) == x

    to(from(y)) == y

    如果能够找到一对函数满足上述要求,则说明一个数据类型x具有与另一个数据类型y相同的信息或结构,此时我们说数据类型x和数据类型y是同构的。比如String[char]就是同构的:

    const Iso = (to, from) =>({
      to,
      from
    });
    
    // String ~ [char]
    const chars = Iso(s => s.split(''), arr => arr.join(''));
    
    const res1 = chars.from(chars.to('hello world'));
    const res2 = chars.to(chars.from(['a', 'b', 'c']));
    console.log(res1, res2); // hello world [ 'a', 'b', 'c' ]
    

    这有什么用呢?我们举个例子:

    const filterString = (str1, str2, pred) =>
      chars.from(chars.to(str1 + str2).filter(pred));
    
    const res1 = filterString('hello', 'HELLO', x => x.match(/[aeiou]/ig));
    
    console.log(res1); // eoEO
    
    const toUpperCase = (arr1, arr2) =>
      chars.to(chars.from(arr1.concat(arr2)).toUpperCase());
    
    const res2 = toUpperCase(['h', 'e', 'l', 'l', 'o'], ['w', 'o', 'r', 'l', 'd']);
    
    console.log(res2); // [ 'H', 'E', 'L', 'L', 'O', 'W', 'O', 'R', 'L', 'D' ]
    

    这里我们借助Arrayfilter方法来过滤String中的字符;借助StringtoUpperCase方法来处理字符数组的大小写转换。可见有了同构,我们可以在两种不同的数据类型之间互相转换并调用其方法。

    27. 实战

    课程最后三节的实战例子见:实战

    相关文章

      网友评论

      本文标题:深入理解JavaScript函数式编程

      本文链接:https://www.haomeiwen.com/subject/pouscqtx.html