函数式编程思维

本文探讨函数式编程的范畴论基础、核心理论及特性,详细阐述了纯函数、柯里化、point free、高阶函数、尾调用等概念,并介绍了函子、of方法、Maybe和Either函子、ap函子以及Monad在IO操作中的应用,旨在帮助开发者深化对函数式编程的理解。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

一.范畴论 

二.函数式编程基础理论

三.特性

四.高阶函数

1.纯函数

2.函数的柯里化

3.point free

4.声明式与命令式代码

 5.优缺点

6.惰性函数 

7.高阶函数

8.尾调用

五.函子

六.of方法

七.Maybe函子

八.Either 函子

九.ap 函子

十.Monad函子

十一.IO 操作


一.范畴论 

  1. 函数式编程是范畴论的数学分支是一门很复杂的数学,认为世界上所有概念体系都可以抽象出一个个范畴
  2. 彼此之间存在某种关系概念、事物、对象等等,都构成范畴。任何事物只要找出他们之间的关系,就能定义
  3. 箭头表示范畴成员之间的关系,正式的名称叫做“态射”(morphism)。范畴论认为,同一个范畴的所有成员,就是不同状态的“变形”(transformation)。通过“态射”,一个成员可以变形成另一个成员

二.函数式编程基础理论

  1. 函数式编程其实相对于计算机的历史而言是一个非常古老的概念,甚至早于第一台计算机的诞生。函数式编程的基础模型来源于λ(Lambda x=>x*2)演算,而λ演算并非设计于在计算机上执行,它是在20世纪三十年代引入的一套用于研究函数定义、函数应用和递归的形式系统。
  2. 函数式编程不是用函数来编程,也不是传统的面向过程编程。主旨在于将复杂的函数符合成简单的函数(计算理论,或者递归论,或者拉姆达演算)。运算过程尽量写成一系列嵌套的函数调用。
  3. Javascript是披着c外衣的Lisp
    // 用函数编程,非函数式编程
    function test(){
    
    }
    test()
  4. 真正的火热是随着React的高阶函数而逐渐升温

三.特性

  1. 函数是一等公民。所谓“第一等公民”,指的是函数与其他数据类型一样,传入另一个函数,或者作为别的函数的返回值。
  2. 不可改变变量。在函数式编程中,我们通常理解的变量在函数式编程中也被函数代替了:在函数式编程中变量仅仅代表某个表达式。这里所说的"变量"是不能被修改的。所有的变量只能被赋一次初值
  3. 没有“副作用”
  4. 不修改状态
  5. map和reduce是最常用的函数式编程方法

四.高阶函数

1.纯函数

  • 对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态。
  • var xs = [1,2,3,4];
    //Array.slice是纯函数,因为它没有副作用,对于固定的输入,输出总是固定的
    xs.slice(0,2);
    xs.splice(0,2);    //会改变源数据,非纯函数

    优缺点:

  • import _ from 'loadsh';
    var sin = _.memorize(x => Math.sin(x));
    
    //第一次计算的时候会稍微慢一点
    var a = sin(1);
    
    //第二次有了缓存,速度极快
    var b = sin(1);
    
    
    //纯函数不仅可以有效降低系统的复杂度,还有很多很棒的特性,比如可缓存性
  • //不纯的
    var min = 18;
    var checkage = age => age > min;
    
    //纯的,这很函数式
    var checkage = age => age > 18;
    
    
    //在不纯的版本中,checkage不仅取决于age 还有外部依赖的变量min
    
    //纯的checkage把关键数字18硬编码在函数内部,扩展性比较差,柯里化优雅的函数式可以解决问题
    
    //柯里化之前
    function add(x, y) {
        return x + y
    }
    add(1, 2)
    
    //柯里化之后
    function addX(y) {
        return function (x) {
            return x + y
        }
    }
    addX(2)(1)

2.函数的柯里化

  • 传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
  • var checkage = min => (age => age > min);
    var checkage2 = checkage(18);
    checkage2(20);
    function foo(p1, p2) {
        this.val = p1 + p2;
    }
    var bar = foo.bind(null, 'p1');
    var baz = new bar('p2');
    console.log(baz.val);

    事实上柯里化是一种 “预加载” 函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。

3.point free

  • 把一些对象自带的方法转化成纯函数,不要命名转瞬即逝的中间变量
  • //转变前,我们不关心str这个中间变量
    const f = str => str.toUpperCase().split(' ');
    
    
    //转换后
    var toUpperCase = word => word.toUpperCase();
    var split = x => (str => str.split(x));
    
    var f = compose(split(' '), toUpperCase);
    
    f("abcd efgh")
    
    //这种风格能够帮助我们减少不必要的命名,让代码保持简洁和通用
    
    
    //实现一个compose
    
    function compose(...funcs) {
    
      // 没有传入函数运行直接返回参数
      if (funcs.length === 0) {
        return arg => arg
      }
    
    
      // 只传入一个函数,就返回其本身
      if (funcs.length === 1) {
        return funcs[0]
      }
    
    
      // 核心代码其实就是一句reduce, reduce特性就是按顺序执行,并且将结果传递给下一次执行
      // reduce顺序执行多个相依赖的promise也很好用
    
      return funcs.reduce((a, b) => (...args) => a(b(...args)))
    }
    

4.声明式与命令式代码

  • 命令式代码的意思就是,通过编写一条又一条指令去让计算机执行一些动作,这其中一般会涉及到很多繁杂的细节。而声明式就要优雅很多,通过写表达式方法来声明我们想干什么,而不是通过一步一步的指示
    //命令式
    let arr = []
    let list = [{ id: 1, name: '李四' }]
    for (let i = 0; i < list.length; i++) {
        arr.push(list[i].id)
    }
    
    //声明式
    let arr = list.map(c => c.id)

 5.优缺点

  • 函数式编程的一个明显的好处就是这种声明式的代码,对于无副作用的纯函数,我们完全可以不考虑函数内部是如何实现的,专注于编写业务代码。优化代码时,目光只需要集中在这些稳定坚固的函数内部即可
  • 不纯的函数式代码会产生副作用或者依赖外部系统环境,使用它们时总是要考虑这些不干净的副作用。在复杂的系统中,维护成本加大。

6.惰性函数 

  • 函数执行的分支只会在函数第一次调用的时候执行,在第一次调用过程中,该函数会被覆盖为另一个按照合适方式执行的函数,这样任何对原函数的调用就不用再经过执行的分支了。
    // 因为各浏览器之间的行为差异,经常会在函数中包含了大量的 if 语句,
    // 以检查浏览器特性,解决不同浏览器的兼容问题。
    
    function addEvent(type, element, fun) {
         if (element.addEventListener) {
              addEvent = function (type, element, fun) {
                   element.addEventListener(type, fun, false);
              }
         }
         else if (element.attachEvent) {
              addEvent = function (type, element, fun) {
                   element.attachEvent('on' + type, fun);
              }
         }
         else {
              addEvent = function (type, element, fun) {
                   element['on' + type] = fun;
              }
         }
         return addEvent(type, element, fun);
    }
    
    
    // 声明函数时就指定适当的函数
    
    var addEvent = (function () {
        if (document.addEventListener) {
            return function (type, element, fun) {
                element.addEventListener(type, fun, false);
            }
        }
        else if (document.attachEvent) {
            return function (type, element, fun) {
                element.attachEvent('on' + type, fun);
            }
        }
        else {
            return function (type, element, fun) {
                element['on' + type] = fun;
            }
        }
    })();

7.高阶函数

  • 函数当参数,把传入的函数做一个封装,然后返回这个封装函数,达到更高程度的抽象。
    //命令式
    let add = function(a, b) {
        return a + b
    }
    function math(func, array) {
        return func(array[0], array[1])
    }
    math(add, [1, 2])

8.尾调用

某个函数的最后一步是调用另一个函数,中间没有其他操作。

  • 传统递归:普通递归时,内存需要记录调用的堆栈所在的深度和位置信息。在最底层计算返回值,再根据记录的信息,跳回上一层级计算,然后再跳回更高一层,依次运行,直到最外层的调用函数。在cpu计算和内存会消耗很多,而且深度过大时,会出现堆栈溢出。
    function sum(n) {
        if (n === 1) return 1;
        return n + sum(n - 1)
    }
    
    sum(5)
    // (5 + sum(4))
    // (5 + (4 + sum(3)))
    // ...
    // (5 + (4 + (3 + (2 + sum(1)))))
    // (5 + (4 + (3 + (2 + 1))))
    // ...
    // (5 + (4 + 6))
    // (5 + 10)
    // 15
    
  • 细数尾递归:整个计算过程是线性的,调用一次sum(x, total)后,会进入下一个栈,相关的数据信息跟随进入,不再放在堆栈上保存。当计算完成最后的值之后,直接返回到最上层的sum(5,0)。这能有效防止堆栈溢出。
  • 尾调用优化:即只保留内层函数的调用记录。  函数调用会在内存形成一个"调用记录",又称"调用帧"(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用记录上方,还会形成一个B的调用记录。等到B运行结束,将结果返回到A,B的调用记录才会消失。如果函数B内部还调用函数C,那就还有一个C的调用记录栈,以此类推。所有的调用记录,就形成一个"调用栈"(call stack)。  如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。   
  • 在ES6,通过尾调用优化,js代码在解释成机器码的时候,将会向while看齐,也就是说,同时拥有数学表达能力和while的效能。  ES6的尾调用优化只在严格模式下开启,正常模式是无效的。这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
    arguments:返回调用时函数的参数。
    func.caller:返回调用当前函数的那个函数。
    
    尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。
    严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。
  • function sum(x, total) {
        if (x === 1) {
            return x + total    
        }
        return sum(x - 1, x + total)
    }
    // sum(5, 0)
    // sum(4, 5)
    // sum(3, 9)
    // sum(2, 12)
    // sum(1, 14)
    // 15
  • // 尾递归优化
    function foo(n) {
        return bar(n*2)
    }
    
    function bar() {
        // 查看调用帧
        console.trace()
    }
    
    // 只有一个执行栈
    foo@ VM65:2
    (anonymous) @ VM65:10
    // 强制指定 只留下bar
    return continue
    !return
    #function()
    // 浏览器并未支持
  •  闭包:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
    // 虽然外层的makePowerFn函数执行完毕,栈上的调用帧被释放,
    // 但是堆上的作用域并不能被释放,因此power依旧可以被powerFn函数访问,这样就形成了闭包
    
    function makePowerFn(power) {
        function powerFn(base) {
            return Math.pow(base, power);
        }
        return powerFn;
    }
    var square = makePowerFn(2);
    square(3);  // 9

五.函子

  • 函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。
  • 函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。

    class Functor {
      constructor(val) { 
        this.val = val; 
      }
    
      map(f) {
        return new Functor(f(this.val));
      }
    }
    // Functor是一个函子,它的map方法接受函数f作为参数,然后返回一个新的函子,
    // 里面包含的值是被f处理过的(f(this.val))
    // 一般约定,函子的标志就是容器具有map方法。该方法将容器里面的每一个值,映射到另一个容器。
    
    // 例子
    (new Functor(2)).map(function (two) {
      return two + 2;
    });
    // Functor(4)
    
    (new Functor('flamethrowers')).map(function(s) {
      return s.toUpperCase();
    });
    // Functor('FLAMETHROWERS')
    
    (new Functor('bombs')).map(_.concat(' away')).map(_.prop('length'));
    // Functor(10)

上面的例子说明,函数式编程里面的运算,都是通过函子完成,即运算不直接针对值,而是针对这个值的容器----函子。函子本身具有对外接口(map方法),各种函数就是运算符,通过接口接入容器,引发容器里面的值的变形。

因此,学习函数式编程,实际上就是学习函子的各种运算。由于可以把运算方法封装在函子里面,所以又衍生出各种不同类型的函子,有多少种运算,就有多少种函子。函数式编程就变成了运用不同的函子,解决实际问题。

六.of方法

  • 你可能注意到了,上面生成新的函子的时候,用了new命令。这实在太不像函数式编程了,因为new命令是面向对象编程的标志。
  • 函数式编程一般约定,函子有一个of方法,用来生成新的容器。
  • 下面就用of方法替换掉new
Functor.of = function(val) {
  return new Functor(val);
};

Functor.of(2).map(function (two) {
  return two + 2;
});
// Functor(4)

七.Maybe函子

  • 函子接受各种函数,处理容器内部的值。这里就有一个问题,容器内部的值可能是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。
    Functor.of(null).map(function (s) {
      return s.toUpperCase();
    });
    // TypeError
    
    
    class Maybe extends Functor {
      map(f) {
        return this.val ? Maybe.of(f(this.val)) : Maybe.of(null);
      }
    }
    
    
    Maybe.of(null).map(function (s) {
      return s.toUpperCase();
    });
    // Maybe(null)
    

八.Either 函子

  • 条件运算if...else是最常见的运算之一,函数式编程里面,使用 Either 函子表达。
  • Either 函子内部有两个值:左值(Left)和右值(Right)。右值是正常情况下使用的值,左值是右值不存在时使用的默认值。
    class Either extends Functor {
      constructor(left, right) {
        this.left = left;
        this.right = right;
      }
    
      map(f) {
        return this.right ? 
          Either.of(this.left, f(this.right)) :
          Either.of(f(this.left), this.right);
      }
    }
    
    Either.of = function (left, right) {
      return new Either(left, right);
    };
    
    // 用法
    
    var addOne = function (x) {
      return x + 1;
    };
    
    Either.of(5, 6).map(addOne);
    // Either(5, 7);
    
    Either.of(1, null).map(addOne);
    // Either(2, null);
    
  • Either 函子的另一个用途是代替try...catch,使用左值表示错误。

九.ap 函子

  • 函子里面包含的值,完全可能是函数。我们可以想象这样一种情况,一个函子的值是数值,另一个函子的值是函数。
    function addTwo(x) {
      return x + 2;
    }
    
    const A = Functor.of(2);
    const B = Functor.of(addTwo)
    
    

    上面代码中,函子A内部的值是2,函子B内部的值是函数addTwo

    有时,我们想让函子B内部的函数,可以使用函子A内部的值进行运算。这时就需要用到 ap 函子。

    ap 是 applicative(应用)的缩写。凡是部署了ap方法的函子,就是 ap 函子。

    class Ap extends Functor {
      ap(F) {
        return Ap.of(this.val(F.val));
      }
    }
    
    // 注意,ap方法的参数不是函数,而是另一个函子。
    
    // 因此,前面例子可以写成下面的形式。
    Ap.of(addTwo).ap(Functor.of(2))
    // Ap(4)

    ap 函子的意义在于,对于那些多参数的函数,就可以从多个容器之中取值,实现函子的链式操作。

    function add(x) {
      return function (y) {
        return x + y;
      };
    }
    
    Ap.of(add).ap(Maybe.of(2)).ap(Maybe.of(3));
    // Ap(5)
    
    // 上面代码中,函数add是柯里化以后的形式,一共需要两个参数。通过 ap 函子,
    // 我们就可以实现从两个容器之中取值。它还有另外一种写法。
    
    Ap.of(add(2)).ap(Maybe.of(3));

十.Monad函子

  • 函子是一个容器,可以包含任何值。函子之中再包含一个函子,也是完全合法的。但是,这样就会出现多层嵌套的函子。
    Maybe.of(
      Maybe.of(
        Maybe.of({name: 'Mulburry', number: 8402})
      )
    )
  • 上面这个函子,一共有三个Maybe嵌套。如果要取出内部的值,就要连续取三次this.val。这当然很不方便,因此就出现了 Monad 函子。

    Monad 函子的作用是,总是返回一个单层的函子。它有一个flatMap方法,与map方法作用相同,唯一的区别是如果生成了一个嵌套函子,它会取出后者内部的值,保证返回的永远是一个单层的容器,不会出现嵌套的情况。

    class Monad extends Functor {
      join() {
        return this.val;
      }
      flatMap(f) {
        return this.map(f).join();
      }
    }
     上面代码中,如果函数f返回的是一个函子,那么this.map(f)就会生成一个嵌套的函子。所以,join方法保证了flatMap方法总是返回一个单层的函子。这意味着嵌套的函子会被铺平(flatten)。

十一.IO 操作

  • Monad 函子的重要应用,就是实现 I/O (输入输出)操作。

    I/O 是不纯的操作,普通的函数式编程没法做,这时就需要把 IO 操作写成Monad函子,通过它来完成。

    var fs = require('fs');
    
    var readFile = function(filename) {
      return new IO(function() {
        return fs.readFileSync(filename, 'utf-8');
      });
    };
    
    var print = function(x) {
      return new IO(function() {
        console.log(x);
        return x;
      });
    }

    上面代码中,读取文件和打印本身都是不纯的操作,但是readFileprint却是纯函数,因为它们总是返回 IO 函子。

    如果 IO 函子是一个Monad,具有flatMap方法,那么我们就可以像下面这样调用这两个函数。

    readFile('./user.txt')
    .flatMap(print)

    这就是神奇的地方,上面的代码完成了不纯的操作,但是因为flatMap返回的还是一个 IO 函子,所以这个表达式是纯的。我们通过一个纯的表达式,完成带有副作用的操作,这就是 Monad 的作用。

    由于返回还是 IO 函子,所以可以实现链式操作。因此,在大多数库里面,flatMap方法被改名成chain

    var tail = function(x) {
      return new IO(function() {
        return x[x.length - 1];
      });
    }
    
    readFile('./user.txt')
    .flatMap(tail)
    .flatMap(print)
    
    // 等同于
    readFile('./user.txt')
    .chain(tail)
    .chain(print)
    
    // 上面代码读取了文件user.txt,然后选取最后一行输出。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值