π,序曲,第一個reducer

uglee發表於2019-08-24

花了很久的時間學習π calculus;天資愚鈍至今尚未學明白,好在不影響寫程式碼。

任何一種和計算或者程式設計相關的數學理論,都可以有兩種不同的出發點:一種是可以作為基礎理論(或計算模型)解釋程式設計師們每天用各種語言寫下的程式碼,它背後的本質是怎樣的;這就象用物理學解釋生活裡看到的各種自然現象;另一種是通過對理論的學習,瞭解到它在概念層面上具體解決了什麼問題,以及針對哪類問題特別有效,在程式設計開發實踐中嘗試應用其思想。

後一種相對玄學,但是反過來說這個思考和實踐的過程對理解理論很有幫助。

π和λ一樣很抽象,離程式設計實踐很遠,而且,完全存在可能性,一個完整的實踐需要語言和執行環境一級支援。但是學習一件事物呢,不要太功利,找到樂趣開動思維是最重要的,在能真正在工程上大面積應用之前,不妨就把它看作是一個益智遊戲。這樣的心態就會讓學習變得富有樂趣,不容易焦慮或者有挫折感。


我不打算從符號入手講解π,但是它的基礎概念要交代一下。

π是關於程式的算術(或者叫演算);算術(Calculus)一詞不如想象的那麼嚇人,不要因為曾經噩夢般的考試生活對它天生恐懼。算術的意思只是說,我們希望我們的程式碼裡的構件,類也好,方法也好,他們是可以如此靈活的組合使用的,就像我們在數學上的運算子,可以算整數、自然數、複數、向量、矩陣、張量、等等;數學上有很多的運算子可用,大多數運算子都能應用在相當廣泛的數學物件上;所以我們說數學系統是豐富的,是強大的思維工具和解決問題的方法。

說π是程式算術的意思很自然,就是構建一個系統時把它看作是很多程式的組合;在這裡程式的含義和我們在程式碼中寫下的函式差不多,但是它不是指作業系統意義上的程式,也不像λ那樣可以描述函式。

除了過程,π裡只有一個概念:通訊。構成系統的多個程式,包括大量實際系統中的動態過程,他們用通訊的方式互動;這兩者就構成了系統的全部。

π裡的通訊和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

相關文章