π,序曲,第一个reducer

花了好久的时间学习π calculus;天资愚钝至今还没有学明白,好在不影响写代码。javascript

任何一种和计算或者编程相关的数学理论,均可以有两种不一样的出发点:一种是能够做为基础理论(或计算模型)解释程序员们天天用各类语言写下的代码,它背后的本质是怎样的;这就象用物理学解释生活里看到的各类天然现象;另外一种是经过对理论的学习,了解到它在概念层面上具体解决了什么问题,以及针对哪类问题特别有效,在编程开发实践中尝试应用其思想。java

后一种相对玄学,可是反过来讲这个思考和实践的过程对理解理论颇有帮助。node

π和λ同样很抽象,离编程实践很远,并且,彻底存在可能性,一个完整的实践须要语言和运行环境一级支持。可是学习一件事物呢,不要太功利,找到乐趣开动思惟是最重要的,在能真正在工程上大面积应用以前,不妨就把它看做是一个益智游戏。这样的心态就会让学习变得富有乐趣,不容易焦虑或者有挫折感。程序员


我不打算从符号入手讲解π,可是它的基础概念要交代一下。编程

π是关于进程的算术(或者叫演算);算术(Calculus)一词不如想象的那么吓人,不要由于曾经噩梦般的考试生活对它天生恐惧。算术的意思只是说,咱们但愿咱们的代码里的构件,类也好,方法也好,他们是能够如此灵活的组合使用的,就像咱们在数学上的运算符,能够算整数、天然数、复数、向量、矩阵、张量、等等;数学上有不少的运算符可用,大多数运算符都能应用在至关普遍的数学对象上;因此咱们说数学系统是丰富的,是强大的思惟工具和解决问题的方法。segmentfault

说π是进程算术的意思很天然,就是构建一个系统时把它看做是不少进程的组合;在这里进程的含义和咱们在代码中写下的函数差很少,可是它不是指操做系统意义上的进程,也不像λ那样能够描述函数。数组

除了过程,π里只有一个概念:通信。构成系统的多个进程,包括大量实际系统中的动态过程,他们用通信的方式交互;这二者就构成了系统的所有。promise

π里的通信和Golang或者CSP里的channel,或者,Alan Kay定义的那种OO或者Actor Model里的message,又或者,咱们实际在编程中使用的socket或者ipc,有没有关系?关系确定是有的,可是π里定义的通信比全部这些都更加纯粹;并且,在π里只有通信这一件事;这预示着,在这个系统里的全部行为,都由通信来完成。并发


咱们来看一下π里最基础也是最重要的一个表达式:框架

clipboard.png | clipboard.png

(这个表达式在segmentfault的显示有误,应该是一行,中间用vertical pipe,在π里表示并发组合)

|左侧的表达式的意思是,有一个叫作c的通信通道,能够收到一个值,收到这个值以后P才能够开始演算(估值),P里面的x,都替换成收到的值;固然这个值是个常数是咱们最喜闻乐见的,但实际上也可能收到一个完整的π表达式(就成了High Order了)。

在右侧的表达式和左侧相反,它指的是P过程若是要开始演算,前提条件是向通信通道c发送一个y出去;这个从程序员的角度看感受可能无法理解,console.log()以后才能继续执行是什么意思?好像历来没有遇到过输出阻塞程序运行并且让程序员伤脑筋的事儿。

可是这个表达式在π里很重要;在编程里一样很重要。

输出前缀在π里表述的意思是一个过程被blocking到有请求时才开始。好比实现一个readable stream,在buffer里的数据枯竭或者低于警惕线的时候才会启动代码读取更多数据填充buffer。

而前面这个表达式,能够看做是没有buffer的两个过程,一个读,一个写;而后两侧的过程均可以开始执行,并且,是以并发的方式。在π里,或者其余相似的符号系统里,这种表达式变换叫作reduction,和数学表达式销项简化是同样的。


因此咱们写下的第一个玩具级代码片断里,这个类的名字就叫作Reducer

Reducer能够接受一个callback形式的函数做为生产者(producer),producer等待到reducer对象的on方法被调用时开始执行,当它产生结果时更新reducer对象的error或者data成员,同时,等待这个值的函数(在调用on时被保存在consumers成员数组中,被所有调用。

这个producer只能运行一次,若是完成以后还有on请求,会同步调用请求函数。只工做一次这个限制让这个类没法作到可变动数据的观察,不过那不是咱们如今须要考虑的问题。

class Reducer {
  constructor (producer) {
    if (typeof producer !== 'function') throw new Error('producer must be a function')
    this.producer = producer
  }

  on (f) {
    if (Object.prototype.hasOwnProperty.call(this, 'data') ||
      Object.prototype.hasOwnProperty.call(this, 'error')) {
      f() 
    } else {
      if (this.consumers) {
        this.consumers.push(f)
      } else {
        this.consumers = [f] 
        this.producer((err, data) => {
          if (err) {
            this.error = err 
          } else {
            this.data = data
          }   
          const consumers = this.consumers
          delete this.producer
          delete this.consumers
          consumers.forEach(f => f())
        })  
      }   
    }   
  }
}

那么你可能会问,node.js里有emitter了,还有各类stream,为何要单独写这样一个Reducer

在成品的开发框架中提供的类,通常都是完善的工具,它包含的不仅有一个概念,并且要应对不少实际的使用需求。

而咱们这里更强调概念,这是第一个缘由;第二个缘由,是reducer更原始(primitive),它不是用于继承的,也没有定义任何事件名称,即,它没有行为语义。

node.js里的emitter能够在π的意义上看做一个表达式,每个相似write之类的方法都是一个通信channel,每个on的事件名称也是一个通信channel,换句话说,它不是一个基础表达式。

把一个非基础表达式做为一个基础构件是设计问题,当咱们须要表达它没有提供的更基础或者更灵活的语义要求时就有麻烦,好比咱们有两个event source其中一个出错时:

const src1onData = data => { ... }
  const src1onError = err => {
    src1.removeListener('data', src1onData)
    src1.removeListener('error', src1onError)
    src1.on('error', () => {})  // mute further error
    src2.removeListener('data', src2onData)
    src2.removeListener('error', src2onError)
    src2.on('error', () => {})  // mute further error
    src1.destroy()
    src2.destroy()
    callback(err)
  }

  const src2onData = data => { ... }
  const src2onError = err => {
    ....
  }

  source1.on('data', src1onData)
  source1.on('error', src1onError)
  source2.on('data', src2onData)
  source2.on('error', src1onError)

在node.js里相似这样的代码不在少数;形成这个困难的缘由,就是“互斥”这个在π里只要一个加号(+)表示的操做,在emitter里受到了限制;并且emitter的代码已经有点重了,本身重载不是很容易。

在看实际使用代码以前来看一点小小的算术逻辑。

// one finished
const some = (...rs) => {
  let next = rs.pop() 
  let fired = false
  let f = x => !fired && (fired = true, next(x))
  rs.forEach(r => r.on(f))
}

// all finished
const every = (...rs) => {
  let next = rs.pop()
  let arr = rs.map(r => undefined)
  let count = rs.length 
  rs.forEach((r, i) => r.on(x => (arr[i] = x, (!--count) && next(...arr))))
}

module.exports = {
  reducer: f => new Reducer(f),
  some,
  every,
}

就像javascript的数组方法同样,咱们但愿可以灵活表达针对一组reducer的操做。好比第一个some方法;它用了javascript的rest parameters特性,参数中最后一个是函数,其余的都是reducer,这样使用代码的形式最好读。

some的意思是同时on多个reducer,但只要有一个有值了,最后一个参数函数就被调用。

every的意思也是同时on多个reducer,但须要所有有值,才会继续。

这里的代码很原始,并且对资源不友好,但用于说明概念能够了。


最后来看一点实际使用的代码:

// bluetooth addr (from ssh)
const baddr = reducer(callback => getBlueAddr(ip, callback)) 
// bluetooth device info
const binfo = reducer(callback => 
  pi.every(baddr, () => ble.deviceInfo(baddr.data, callback)))

第一个reducer是baddr是取设备蓝牙地址的;getBlueAddr是很简单仍是很复杂不要紧。这句话说明读取baddr在当前上下文下没有其余依赖性,能够直接执行;可是这个语句并无马上开始读取蓝牙地址的过程。它至关于咱们前面写的π表达式:

clipboard.png

即过程P(getBlueAddr)能产生(输出)一个蓝牙地址,可是它会一直等到有人来读的时候才会开始运行。

出发这个过程开始执行的代码在在最后一句,在binfo的producer里。这个pi.every(...)的调用,就至关于:

clipboard.png

由于这个代码在binfo的producer里,因此它还没开始执行,也不会和baddr的producer发生reduction

binfo的producer代码里出现了对另外一个reducer的on, pi.every, pi.some之类的操做,就直接表述了binfobaddr的依赖关系。这是这种看起来有点小题大做的写法的一个好处,就是你阅读代码时依赖性是一目了然。

这两行代码在运行后,两个producer过程都没开始,由于没有一个reducer被on了。若是你须要触发这个过程,能够写:

pi.every(binfo, () => {
  console.log('test every', baddr.data, binfo.data)
})

固然这个写法在并发编程里不推荐,由于你是读了binfo的代码知道依赖性的,不然console.log可能会发生错误。推荐的作法是一股脑把你要的reducer都写到everysome里去,他们之间的依赖性对every或者some的回调函数来讲是黑盒的:

pi.every(baddr, binfo, () => {
  console.log('test every', baddr.data, binfo.data)
})

不管是some仍是every,都是让全部被请求的reducer的producers同时开始工做,即并发组合。在everysome的参数列表里,顺序不重要,这是并发本质;对于只请求一个reducer的状况,everysome没有区别。

若是你须要顺序组合,大概能够这样写:

pi.every(baddr, () => pi.every(binfo, () => {
  ...
}))

不过为何会须要顺序呢?咱们在写流程代码的时候须要的,不是顺序,是依赖性;偶尔发生的彻底没有数据传递的顺序,好比另外一个读取文件的过程必须等到一个写入文件的过程结束,也能够理解为前面一个过程产生了一个null结果是后面们一个过程须要的。

上面这句话是Robin Milner在他的图灵奖获奖发言里说的。在并发编程里之须要并发组合这一种操做符,不须要再发明一个顺序组合操做符号,由于它只是并发组合的一个特例。

在node.js里,由于异步特性,分号( ;)是语言意义上的顺序组合,可是模型意义上的并发组合。callback, emitter, promise,async/await,以及上面的这个形同柯里化的 pi.every语句,都是顺序组合的表达。可是我相信你看完这篇文章后会理解,在并发编程里,只有局部是为了便于书写须要这种顺序组合。

并发编程和顺序编程的本质不一样,是前者在表达依赖性,而不是顺序。


我鼓励你用Reducer写点实际的代码,虽然它不能应对连续变化的值,只是单发(one-shot)操做,但不少时候也是能够的,好比写流程,或者写http请求。

而说道写流程,我不得不说π的一大神奇特性,就是它的通信语义已经足够表达全部流程。就像你在这里看到的代码同样,事实上用π能够构件整个程序表达顺序。

事实上我在最近几周就在写测试代码。有大量的set up/tear down和各类通信。不一样的测试配置。用π写出来的代码我最终不关心每一个测试下如何作不一样的初始化,由于代码所有是Lazy的,我只要在最后用every一次性Pull全部我要的reducer便可。

至于执行顺序,老实说我也不晓得。这就是并发编程!


这里有一点rx的味道对吗?

不过我不熟悉rx,我须要的也不是数据流模型;我关注的是过程的组合,如何清晰的看出依赖性,如何优雅的处理错误。

这里写的Reducer很是有潜力,它体如今:

  1. 你看到了everysome,实际上咱们能够作不少复杂的逻辑在里面,好比第一个错误,好比错误类型的过滤器,好比收集够指定数量的结果就返回;
  2. 分开错误处理和成功的代码路径是可能的,Reducer里能够只on错误结果,或者正确结果;
  3. 而最重要的rx的不一样,是reducer里能够装入比简单的callback更rich的函数或者对象,例若有cancel方法的,能emit progress事件的,等等;
  4. 前面说过,π里有一个+号表示互斥过程;象some或者every同样写一个互斥的on多个reducer,很容易;
  5. 互斥的一个较为复杂的状况是conditional的,这个其实也很容易写,至关于reducer级联了,写在前面的用于条件估值;更复杂的状况的是pattern matching,即用pattern选择继续执行的过程,那就更帅了,用库克的话说,I am thrilled;

All in all,仍是那句老话,less is more。Emitter的设计错误在于它的目的是提供继承,而不是用于实现灵活的代数方法。

固然,reducer也只是刚刚开始。几个月后,我会再回来的。


补:文中所述的最基础的π的reduction的严格表述以下,左侧的name z从channel x出去后被vertical pipe右侧接收到,Q表达式里的y所以所有替换成z,[z/y]用于表述这个替换,称为alpha-conversion,而这个表达式从左侧到右侧的变换,就是beta-reduction。

clipboard.png

相关文章
相关标签/搜索