2014年5月10日 星期六

從不同程式語言的異同思考問題的本質

《學程式語言的樂趣》描述我早期學程式語言的事,後來幾年多了一些心得 ( Python, Haskell, C++, Objective-C ),之後有機會再補上。這裡想寫的是藉由學習不同程式語言後明白的事。

學特別的程式語言有助於掌握不同的概念。我最初慣用的語言是 C (高中三年),再來是 Java (大學四年)。藉由 Java 開始接觸 OOP 的思維,除了程式語法之外,學到更多的是寫程式的思考方式。其實回頭再去看 C,也可以使用一樣的思維,只是當語言直接綁入思維時,會被迫這麼做。就像用 Haskell 必須寫出 stateless 的程式,就會更明確地留意 state。不過學 Java 的時候還沒察覺到這點。

後來長時間地陸續用了 Ruby、Python、C++,還有加減寫 JavaScript。開始掌握到一些程式語言背後共同的特性:組合邏輯控制,以及管理資料的狀態。回歸到最原始的電路來看,程式的運作就是邏輯控制 (and/or) 和狀態 (on/off)。當要做的事變複雜時,必須結構性地管理它們。

舉例來說:

  • Java 定了 class 和 interface 表示「資料+實作邏輯」和「操作介面」,class 內又有 public/(default)/protected/private 限制資料的存取範圍。還有 package 封裝同一群 class。
  • Python 表現的比較直接,容易連結回 C 該如何表現這些思維。每個 method 第一個參數一定是物件本身,慣例命名為 self。'_' 開頭的 function 和 method 表示只限於內部使用。用 module 封裝同一群 class。
  • C++ 藉由不同的方式使用 virtual,class 可當作 "class" 或當作 "interface",或帶有實作的 "interface"。class 有 public/protected/private 限制資料的存取範圍,但又有 friend 可讓指定的 class 存取無視這些限制。有 namespace 封裝同一群 class,還有 anonymous namespace 可以嚴格的封裝實作細節。

不同的語言有各自擅長的地方,學習別的語言的時候,會想要借鏡別的語言的特色,運用到原本常用的語言裡。但是接觸過 Scheme 和 Haskell 再回頭嘗試用在 Python 和 Java 的時候,遇到很大的問題:

  • 無法直覺地使用天生不支援的功能。比方說 Java 的 function 不是 first-class function,得用物件包一層,再自成一套體系。例如用 functionaljava
  • 和原本語言本身的風格不合,增加其他人的維護成本。

第二點是比較大的問題。自己用了一套「functional Java」的寫法,其他人也得學習如何解讀。以 Python 舉例,下面是同一件事用三種不同寫法:

for-loop

numbers = []
for i in range(0, 10):
    numbers.append(i * 2)

list comprehension

numbers = [i * 2 for i in range(0, 10)]

map

numbers = map(lambda i: i * 2, range(0, 10))

對沒學過 Python 的人來說,for-loop 比較直覺,沒學過 Python 也可推敲出意思;初看 list comprehension 不明白這是什麼,不過花點時間看幾個例子就懂了。map 比較困難,得有 functional programming (FP) 的概念才能明白。

Python 本來就有這些語法,我們不會責怪這樣寫的人。但用在 Java 裡就有爭議了,為什麼要另外包一套框架,寫出要別人花額外工夫才能讀懂的程式?

後來我察覺到更深層的思維:OOP 也好、FP 也好,目的都是結構性地管理資料和邏輯,只是表達的方式不同。掌握到這點後,我將時間花在有助於真正目的事上,而不是寫得更 OOP 或 FP。原本遇到的困擾 (如何在 B 語言裡善用 A 語言的特性) 自然就不存在了。

舉例來說:

  • 為什麼 FP 要強調 immutable,還有習慣用 map? 因為 immutable 的資料沒有 state,自然沒有被誤改的風險。有沒有用 FP 的 map 到不是重點,map 只是方便套用一套控制邏輯產生另一批 immutable 資料。是實作層級的小幫手,不是設計準則。
  • 為什麼 OOP 強調 Tell, Don't Ask? 也是為了減少外漏 state。即使用沒有 OOP 的語言,也可這麼做。像 iOS 的 Quartz 2D,API 需要用的 CGContextRef 是 struct CGContext* 的 typedef,但是使用者不知道 struct CGContext 是什麼,只知道呼叫函式時要傳入 CGContextRefQuartz 2D 封裝需要的 state 在 struct CGContext 內,隨時可改變它的結構。
  • 為了方便模組之間獨立開發,不用擔心日後改寫模組內部的實作造成外部程式出錯,最重要的是定出乾淨的介面,讓別人不會誤用。再來才是善用語言的特性讓別人想誤用也不行。例如 C++ 的 anonymous namespace 是隱藏實作的好方法;Java 的 package + (default) class 也是好方法;Python ... 只能用底線命名然後祈禱不要有人亂搞。

從問題的本質 (管理資料和邏輯) 來看,自然會明白這些設計準則,還有區分出設計和實作 (準則和表現方式)。剩下的是衍生想法,像是管理 state 方法的優先順序:

  • 沒有 member field (沒 state 最好!)
  • 有 immutable member field (非得有 state 不可的話,至少讓它不可修改)
  • private mutable member field (非改不可的話,盡可能減少能改它的程式碼)
  • ...

重點還是掌握問題的本質,避免迷失在解決的方案之中。

結語

總結來說,一套程式語言有一套思維,試過多套不同特性的語言後,藉由它們之間的同異處,可以察覺這些語言想解決的問題是什麼。繼而明白真正的問題是什麼,再回饋到解決問題的思維中,避免落入特定解決模式裡 (如道地的 OOP 或 FP)。起初學不同語言只是覺得好玩,誤打誤撞得到這樣的體會,滿有意思的。

相關文章

2014年3月2日 星期日

事情的另一面: 測試與除錯技巧

回顧過去數年的經驗,最大的體悟除《從需求出發理解背後技術的思考脈胳》以及《專注於滿足需求而非工具或方法》之外,大概就是沒有所謂的鐵則,許多事情都有另一面,端看你處於什麼情境、從什麼角度看它。

上份工作以 machine learning 為主 (2008 ~ 2011),針對一個特定的應用問題,實驗不同的演算法,看看能否實際運用在產品上。結果不如預期時,會陷入不知該懷疑演算法、演算法參數、還是程式寫錯的窘境。因此,對於程式正確的要求,遠高於其它產品。這樣才能專注地藉由實驗修正理論模型。在這個需求之下,Test Driven Development 就成了相當有用的開發方式。經過數次用 TDD 開發的經驗後,總算能夠達到和不用 TDD 差不多的開發效率。這意味著用了 TDD 也不會拖慢完成第一版的時間,並且程式更易於理解和維護。儼然是 TDD 的完全勝利!

但這個經驗有個代價。代價不是學習 TDD 的時間,畢竟學什麼工具或方法 (如OOP) 都要時間。學習 TDD 對立的結果是: 學不到除錯的技巧。

起初我以為除錯是果,治本的方式自然是避免有因。那麼,TDD 貌似完美的解法,學 TDD 即可。但是開發專案免不了團隊合作,也免不了使用第三方程式。換句話說,錯誤遍地都是,有時甚至是作業系統或編譯器的錯。拙劣的除錯技巧無法適應這個時代。我在和別人一起除錯時才發覺這事。對方能力很好,不過開發習慣不太好,常犯一些「我無法想像的錯誤」。但也因為他的習慣,讓他可以比較快看懂別人寫的亂糟糟的程式,可以比較快想到問題可能出錯的地方。我費了不少力氣才補足這塊,實在是始料未及的事。只能說,該走過的路還是得走,無法省去。

目前的工作以 C++ 為主,為了在大量的原始碼裡除錯,偶而會視需求加強一下 gdb 的使用技巧,還有練習寫 python script 簡化 gdb 操作程序。相對於兩年前,gdb 的操作技巧進步不少。另外也寫了 gj 幫助閱讀程式碼。但是在觀察同事的開發方式後,發覺我有時過於依賴工具的便利性,反而減少全面性的思考。最後還是得有系統地一步步思考、推論,才能有效率地解決問題 (關於這點,之後有適當材料再另寫文章說明「系統化的解決問題」)。換句話說,熟練除錯工具反而無意識地減少我系統性思考的時間,也滅少我系統性思考的經驗。我的意思並非捨棄除錯工具,像 Sherlock 那樣全部都在自己的思維宮殿裡解決 (雖說那樣實在是相當地帥啊!)。凡人如我等,還是需要工具輔助搜集情報和記錄訊息。只是這兩件事是相斥的,愈是熟練除錯工具,愈少思考;愈長思考,自然也愈不依賴除錯工具。

再回頭看 TDD,目前的工作絕大多數情況不適用 TDD。一來不像開發 machine learning 工具那樣,要求近乎100%正確。二來大部份的程式和 GUI 相關,本來就不容易測試。若要達到像以前一樣的開發效率,我得先熟悉 C++,再熟悉 C++ 基本的 unit test 工具,再熟悉和 GUI 相關的測試工具和知識。對照達到後帶來的好處,相當地不划算。不過其中一個和 GUI 無關但和網路高度相關的子專案,我很自然地用 Python + TDD 的方式開發核心部份。日後上線時,也從中獲得明確的回報: (1) 極少的錯誤。以及 (2) 透過單元測試輕鬆地重制線上偶而才會發生的網路錯誤,只更新一次程式碼就修正了問題。可參考《寫出容易測試的程式》了解類似的處境和用到的技巧。當然,我也因此「失去」一些線上除錯的經驗。

除了測試與除錯的心得外,軟體設計模式和軟體開發準則,也讓我經歷了幾次「打破鐵則」的心路歷程。之後再另寫文章補充。

回顧這些事,讓我明白個人經驗的侷限,而減少過度歸納和推衍的習慣。看別人的論述時,會多想想自己和對方的情境,從中得出目前我能用到的部份。不會過於尋找或遵從「聖杯」。白話來講,就是比較務實吧。