游擊補強或猴子補丁?


iThome 網站首載:游擊補強或猴子補丁?

根據維基百科,游擊補強(Guerrilla patch)是指執行時期動態改變程式碼,因為英文上同音於猩猩修補(Guerrilla patch),後來改稱猴子補丁(Monkey patch)聽來比較不嚇人一些。實際上不同語言社群中,猴子補丁會有不同的定義與作法,大致都是指在不修改既有原始碼情況下,動態修改程式運作行為的能力,而且通常指那些非常開放的修改方式,猴子補丁適切地形容了這類修補行為,連猴子也會做,甚至帶有粗魯輕率的意涵!

猴子補丁的優點

在程式語言中,JavaScript是執行猴子補丁相對容易的一門語言,舉例而言,並非瀏覽器都支援JavaScript陣列的forEach方法,在偵測瀏覽器不支援陣列的forEach時,可使用程式碼Array.prototype.forEach = function(callback) {...},使得陣列實例都可執行forEach方法,如果想刪除陣列某個方法,也可以輕易地將Array.prototype上該名稱設為undefined來達到目的。

JavaScript是1995年5月的短短十天內建立,為了讓使用者可對這倉促建立的語言進行修補,Brendan Eich賦于JavaScript極大可塑性,對於一個物件,開發者可進行幾乎完全的改造。在《Effective JavaScript》前言中,Brendan Eich談到「開發人員能夠藉此編輯標準物件,以克服一些臭蟲」、「在舊瀏覽器中模擬出未來的新功能性」,甚至「以其他語言為模式,建立了工具組(Toolkit)或框架程式庫」。

Brendan Eich言論中說明了猴子補丁的優點,在不修改標準或第三方程式庫原始碼下,開發者能在發現臭蟲時進行即時修正,可以對原生(Native)物件或第三方程式庫進行新功能擴充,像是在某瀏覽器中偵測到物件沒有某方法時,對物件本身或原型進行方法增添,讓應用程式可以相容於新舊或不同品牌瀏覽器;既然可在不修改既有原始碼下修正臭蟲、擴充功能,可將這些修正臭蟲、擴充功能的程式碼設計為工具組或框架程式庫,當引入這類工具組或框架程式庫時,就可以全面修正、擴充語言的功能,甚至改變或賦予語言新樣貌,像是瀏覽器上的Prototype、jQuery程式庫,或甚至是後來的Node.js都是具體實例。

猴子補丁的危險性

在不修改標準或第三方程式庫原始碼下,就可隨意地改變物件行為,聽來確實是很誘惑人的功能,然而這也是猴子補丁最大的危險性,因為修補太過容易,而修改後的行為又會影響整個應用程式,因而輕率的猴子補丁,很容易就造成程式其他地方異常。舉例而言,JavaScript的String有個match方法,可傳回符合正規表示式(Regular expression)的字串,否則傳回null,基於某個理由,開發者可能想撰寫str.match(re).length,然後當match傳回null時這段程式碼會出錯,因而他猴子式地修補了match,使之找不到符合正規表示式時傳回空字串,如果應用程式中另一名開發者撰寫了str.match(re) === null ? value1 : value2,那麼他的運算就會傳回錯誤結果。

猴子補丁的另一個問題在於,兩個以上的程式庫嘗試修改相同方法時就會出問題。舉例而言,若有程式庫嘗試修改Array.prototype.filter為留下符合條件的元素,另一程式庫修改為去除符合條件的元素,那麼同時引入兩個程式庫時,使用filter方法就有一半機率會出錯,這取決於哪個程式庫覆蓋了另一程式庫的定義,當程式庫引入時大量修改了物件行為,這種錯誤就容易發生。

可以修改原生物件的行為也是令人爭議的。舉例而言,如果網頁中引入Prototype程式庫,因為它會對原生物件進行修改,使得判斷物件行為是來自原生或是程式庫修改時,界線變得糢糊,令程式中某些地方依賴了Prototype修補的行為而不自知,在其他沒有引入Prototype的環境中,也容易因為試圖使用Prototype修補的行為而錯誤頻繁。

避免輕率粗暴的猴子補丁

要避免輕率粗暴的猴子補丁,方式之一就是僅新增物件原本沒有的方法,因為是新增方法,程式中沒有其他部份依賴在這個方法上,因而不會影響程式其他地方。為了避免多個程式庫嘗試修改相同方法,可以藉由測試來防護,像是在偵測到陣列本身沒有forEach方法時才予以新增,如果陣列本身具備forEach方法,然而開發者希望有個迭代方法,能於迭代時同時提供索引與元素,那麼不要直接修改forEach,而可以是新增一個forEachWithIndex方法來補強。

如果要以猴子補丁重新定義方法,那麼不應改變該方法在文件上載明的行為。如果文件該方法不會拋出錯誤,那就不應該這麼做,如果文件聲明String的match找不到符合正規表示式的字串時應傳回null,那就不應重新定義為傳回空字串。以猴子補丁重新定義一個方法,可以是為了修補臭蟲,或是在原本行為上加以額外動作,此時可在重新定義方法時,令最後傳回原方法的結果。例如orgIndexOf = String.prototype.indexOf的話,可如下重新定義String.prototype.indexOf
String.prototype.indexOf = function(ch) {
    // 前置處理
    rtv = orgIndexOf(ch);
    // 後置處理
    return rtv;
};

類似地,先前提到Stringmatch方法需求,可以新增noNullMatch方法,並於其中委託給原match方法的方式來修補。例如若如下修補程式的話,那麼就可以用str.noNullMatch(re).length來解決先前需求:
String.prototype.noNullmatch = function(re) {
    var found = this.match(re);
    return found === null ? '' : found;
};

在原生物件上新增方法是有爭議的,極端的建議是,絕對不要對原生物件進行修改,像是jQuery程式庫就不對原生物件進行擴充,而使用jQuery物件來對選取之物件操作,如此判斷物件行為是來自原生或程式庫補強,界線就變得清晰。在標準物件上新增方法的理由之一是,該方法已經是事實上的(de facto)標準,例如陣列的forEach方法,在〈JavaScript Garden〉文件中亦建議擴充原生物件的另一理由是提供相容性,像在舊JavaScript引擎中提供Object.extend方法。《Effective JavaScript》書中條款42建議「任何會修改共用原型的程式庫,最起碼都應該在說明文件中清楚地指明」。

從開放性的控制中學習最佳實踐

以上對於避免粗暴的猴子補丁之建議,部份來自《Effective JavaScript》書籍與〈JavaScript Garden〉文件,部份來自《Well-Grounded Rubyist》書中13.2,有關修改Ruby核心類別與模組的內容;Python也可進行猴子補丁,然而不允許修改標準類別,除此之外,上述建議仍是適用的;《Well-Grounded Rubyist》中談到「學習控制Ruby的開放性,部份是與程式設計技巧有關,然而大多是有關於最佳實踐」,這些實踐有很大程度是可以共通於不同語言。

不改變方法在文件上載明的行為,套用在Java於子類重新定義方法時也是適用的,如果子類重新定義方法時,行為上與父類方法有異,那在原本允許替換子類實例的場合,程式行為就有可能出現異常;需要在原有行為上加以額外動作時,裝飾器(Decorator)模式將實際動作委託給原物件,並於前後加上額外行為,裝飾器物件上也可新增方法,以符合擴充功能的需求,如果無法吸取這些最佳實踐來控制程式行為,即使程式不提供猴子補丁的可能性,開發者寫程式也只會像個猴子,而不是靈活的游擊隊員了。