javascript 中用方法實現管道與繞過繼承建構子的限制
記錄幾個以前想到的有趣 js 用法。 包括因為管道運算符一直沒出,用原型鏈達成的管道方法, 也順便探討一下多層呼叫函數的方式,與其它語言中的作法。 另一個和 js 的物件導向有關, 也就是把建構函數寫在方法裡,讓繼承時更有彈性。
管道運算符與方法
js 很早就有說要出管道運算符,
可以解決多層呼叫難以閱讀的問題,
把 a(b(c(d)))
寫成 d |> c |> b |> a
。
一些函數式的 js 函式庫也多會實作類似的功能,
但可能會用 compose 而不是 pipe:
(a . b . c)(d)
。
其它語言的解決方式
其它 js 的衍生語言,
像 coffee script 就有對應的解決方法。
coffee script 中函數呼叫可以省略括號,
所以可以寫成 console.log 'hello world'
,
前面的例子就變成 a b c d
,
雖然看起來有點奇怪,但至少乾淨很多。
如果要多個參數如 a(b, c(d(e), f))
,
則是寫成 a b, (c (d e), f)
,
不過 coffee script 也是接受 js 的用括號呼叫的寫法的,
直接寫成 a(b, c d(e), f )
也是可以。
而 live script 則是定義了 pipe 運算子。
對比之下 haskell 一樣呼叫函數不用括號,
但執行順序是 curry, a b c d
會變成 a(b,c,d)
。
要巢狀呼叫要嘛用括號改變優先順序: a (b (c d))
,
不然就是用 compose (a . b . c) d
。
haskell 似乎沒有 pipe。
用方法模擬管道運算符
其實 js 中就有一個很類似的東西: 原型鏈 。 例如在使用 array 處理資料時, 常看到的 map filter reduce 寫法, 或者是 promise 的 then 串接。 那能不能把這類操作擴增到所有 js 變數上呢?
list.map(x => x * 6 + 3)
.filter(x => x % 2 == 0)
.forEach(x => console.log(x))
fetch(url).then(response => response.text())
.then(text => console.log(text))
這類操作會在 promise 或 array 上存在, 是因為他們都是某種容器,含有某個值的容器。 那如果用類似的作法來為 js 的數字實作一個 pipe 方法, 是不是可以達成類似上述的鏈的功能?
Number.prototype.pipe = function (f) {
return f(this)
}
let x = 7
x.pipe(x => x + 3).pipe(y => y * 6)
接下來,再利用 js 的一項特性,所有東西都是物件, 例如 Number 是一種 Object, 也就是說可以在數字存取物件上定義的方法。 同理來說 String Boolean 也是, 更不用說 Array Map Set 等,甚至瀏覽器中的 dom 也是, 只要把 pipe 方法定義在 Object 上, 那幾乎所有 js 的變數都可以存取了。
console.assert(typeof Number.prototype == 'object')
console.assert(
typeof Number.prototype.hasOwnProperty == 'function'
)
Object.prototype.pipe = function (f) {
return f(this)
}
'hello world'.pipe(console.log)
// [String: 'hello world']
原始類型與物件
疑?是不是看到奇怪的東西了?
那個奇怪的 String 是怎麼回事?
其實我也不是很清楚,
但似乎關係到 js 對原始類型的處理方式。
js 中建立物件的方式是用 new 如 new Object()
,
但原始類型不能用 new,否則會造出奇怪的東西,
就像這樣子。
let n = 0
let new_n = new Number()
console.assert(typeof n == 'number')
console.assert(typeof new_n == 'object')
前面的 Number.pipe 可以正確執行, 是因為 js 內的自動轉型, 對物件使用數學計算時,會嘗試把物件轉為數字, 於是就成功了。 只有在 console.log 時會不小心露出馬腳。
如果有人也嘗試自己 patch 過 js 基本類型, 可能會發現類似的問題;也可能不會,因為有自動轉型。 可能是 js 在以原始類型為 this 呼叫函數時,會先裝箱成物件。
function showType() {
return [typeof this, this]
}
let [type, x] = showType.call('abc')
console.assert(type == 'object')
console.assert(x !== 'abc')
String.prototype.showType = showType
'abc'.showType()
詳細原理我也不清楚,但原始類型還有另一項特徵,
不能擁有自己的屬性。
js 的物件只要沒有被 Object.freeze
,
都可以在任何候新增屬性。
原始類型則不能新增自己的屬性,
但 new 出來的原始類型物件就可以。
而在呼叫方法的時候,可能被當作屬性處理了。
let x = 3
x.foo = true
console.assert(x.hasOwnProperty('foo') == false)
let xo = new Number(3)
xo.foo = true
console.assert(xo.hasOwnProperty('foo'))
原始類型拆箱
其實解法也很簡單,只要在 pipe 方法中,
檢查一下物件是不是 Number String Boolean 這三類物件就好了,
我們可以用物件的 constructor 屬性來得知他是由哪個建構函數建構的。
最後,要取得物件對應的原始值,則可以用 .valueOf()
達成。
Object.prototype.pipe = function (fn, ...arg) {
let originValue
switch (this.constructor) {
case String:
case Number:
case Boolean:
originValue = this.valueOf()
break
default:
originValue = this
}
if (typeof fn == 'function') {
return fn(originValue, ...arg)
}
else {
return fn.reduce((value, f) => f(value), originValue)
}
}
這個函數是幾年前寫的,當初還預想了像 bind 或 call 那樣, 可以把給函數的參數放在參數列表裡傳入。 另外如果要 pipe 多次,與其呼叫多次 pipe, 可以直接把函數寫成陣列傳入。
let x = 3
x.pipe([
a => a+3,
b => b*2,
c => c-1,
console.log
])
不能用 pipe 方法的場合
js 中雖然原始類型也是物件,連 true 和 false 都是,
但有二種東西不是物件,null 和 undefined。
雖然 typeof null === 'object'
,
但這二種值是不能存取屬性的,會噴錯。
let xn = null
xn.pipe(console.log)
let xu = undefined
xu.pipe(console.log)
所要呼叫 x.pipe(f)
前要先確保 x 不是 null 也不是 undefined。
這點其實可以透過 optional chain 達成:
x?.pipe(f)
,如果 x 是 null 或 undefined,
那就會直接回傳 undefined。
js 管道運算符細節
但其實 pipe 方法還有一些明顯的缺點,像是傳入物件的方法時 this 會遺失,
這是 js 傳入方法時的常見問題了,只能用箭頭函數或是 bind 來避免。
上文中的 x.pipe(console.log)
之所以可以成立,
是因為無數人已經碰過 console.log 這個問題,
所以 console.log 早就可以不用綁在 console 上呼叫了。
但如果是新定義的 管道運算符 |>
,
就可以直接規定管道中的 this 不會丟失,
規定 x |> other.method
等價於 other.method(x)
。
甚至還可以把最潮的 async/await 放進去,
規定 x |> await other.method |> console.log
等價於 console.log(await other.method(x))
,
不然原本依運算的優先等級來看,
會變成 console.log((await other.method)(x))
。
傳遞 this 的管道運算符
其它同時也有一些有趣的運算符在討論,像是 雙冒號運算符 ::
,
效果像是反過來的 Function.prototype.call
,
例如 object::func(a,b)
等價於 func.call(object, a, b)
。
應用除了方便匯入一些用到 this 的函式庫,
也有類似管道的用法:
function fibo() {
[this.x, this.y] = [this.y, this.x+this.y]
}
let fibo2 = ({x: 0, y: 1})::fibo()::fibo()
console.assert(fibo2.x == 1)
console.assert(fibo2.y == 2)
這個運算符就比較適合用 method 實作, 一樣實作在 Object.prototype 上就可以了。
Object.prototype.call = function (f, ...args) {
return f.call(this, ...args)
}
let fibo2 = ({x: 0, y: 1}).call(fibo).call(fibo)
當然,運算符還有一些特殊規則,
像不呼叫的話就會變成 bind,
let barBind = foo::bar
同義於 let barBind = bar.bind(foo)
。
不過 bind 也可以用方法實作就是了:
Object.prototype.bind = function (f, ...args) {
return f.bind(this, ...args)
}
更彈性繼承建構子函數的方式
js 的物件在 es6 中有了正式的繼承方式 class extends。 es6 的 class 在繼承中可以用 super 存取父元素的方法, 在重寫子元素方法時,可以據此修改呼叫的參數, 或加工原本方法的回傳值。 但如果要重寫建構子 ,則重寫的 constructor 一定要呼叫 super, 且不能在呼叫 super 前存取 this;這就變得有點麻煩。 像是想在物件初始化前先做一些前置步驟。
class Bar extends Foo {
constructor(a) {
// 一定要呼叫 super,且不能在呼叫父建構子前存取 this
super(a+1, a+3)
this.c = a
}
method(x) {
// 可以使用 super.method 呼叫父物件版本的方法
const parent = super.method(x+1, x+2)
return parent * x
}
}
js 的物件導向中, 所謂的類別其實只是一個帶有 prototype 的函數, 而物件則是在使用 new 關鍵字呼叫函數時產生的。 所以無論如何都是一個獨立的一個新函數, 呼叫父類別的建構函數是完全可選的。
在以前的時代如果要繼承 ,
就直接把 prototype 設成某個父類實例,
或是設成 Object.create(Parent.prototype)
。
如果要改寫建構子,
大部份是在建構子中用 Parent.call(this, ...arguments)
這種奇怪的方式呼叫。
因此如果要規避必須呼叫 super 的麻煩,
有個比較 hacky 的技巧,
就是不要把程式放到 constructor 裡,
而是塞在另一個方法裡,像 init 或乾脆叫 _constructor
。
然後在 constructor 中就只做一件事,呼叫該方法。
class Foo {
constructor(...args) {
this.init(...args)
}
init() {
this.foo = true
}
}
在子類如果要魔改的話,直接改在 init 方法上即可。 因為在改寫方法時,不需要規守建構子中必須先呼叫 super 的規範。 再加上把收到的所有參數都原封不動傳入 init, 就能在 init 裡做完所有事。 事實上,唯一能在 constructor 裡能做而 method 不行的, 就是 return 一個新物件取代 this,但如果都用了 extends, 應該不會用到這功能。
class Bar extends Foo {
init(a, b) {
this.bar = true
super.init(a, b+1)
this.foo = false
}
}
因為重新定義 constructor,直接延用父類的就好, 所以子類不需要定義自己的 constructor。 也可以很自由的在任何時候,在原方法呼叫前、中、後, 對 this 做其它操作, 而不被 constructor 中須要先呼叫 super 的規則限制。