π,序曲,第一個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

相關文章
相關標籤/搜索