2010年11月21日 星期日

Problem Solving 的技巧 (1):系統設計是一連串的取捨

費曼和杜書伍可說是影響我思考方式最深的人,即使重覆閱讀、重新思考他們話,仍能獲得不少新體悟。今天偶然發現《真正的問題是什麼?你想通了嗎?》的推薦序是杜書伍寫的,看了以後收獲良多,看了這麼多書,這還是頭一回看了推薦序而覺得有用。

文中提到:

釐清問題的真實性後,面對真正的問題時,須有一個認知:甚少問題能以單一方案解決,而須由不同面向,分頭淡化問題。...這些不同面向的解法,單獨用都只能解決局部問題,但配套提出後,卻能大幅降低問題的嚴重性,到一可接受的範圍內。

企業經營無時不刻面臨問題的發生與解決,在問題決的過程中會發現,現實生活中並沒有可「百分之百」被解決的問題。誠如書中所言:「每一個解決方案都是下一個問題的根源」,一個有利於某面向的方案,代價往往是犠牲另一面向的利益。因此,如何透過溝通、妥協的過程,尋求最適的解法而非完美的解法,將問題的衝擊降到多數人可接受的範圍內,即為好的解決方式,否則反而可能適得其反,滋生新的問題。

看了這段話,更明白杜書伍花了多少歲月與心力實踐他在《打造將才基因》裡說的方法,完全是實實在在的真工夫。用精簡的文字描述出極為深刻的道理,實在是太厲害啦!

舉例來說,使用資料庫時,需同時擔心寫入速度和確保不會損失資料。若每次寫入時都寫入硬碟,可以確保資料正確,但卻會很慢;若先寫入記憶體,集合成一批再一起寫入硬碟,可以大幅提昇速度,但若資料庫伺服器當掉,卻會喪失大批資料。

一個配套解法是每次都寫入硬碟確保資料正確,接著使用 RAID 提昇寫入速度。但是用上 RAID 仍是操作硬碟,提昇有限,於是再加上帶有 write cache 的 RAID,這樣就能大幅提昇速度。但這又造成新的問題:雖然資料庫伺服器掛了不會影響資料,但若機房停電,仍會喪失 RAID cache 內的資料。於是在 RAID 裡加上電池,就能克服這問題 (附帶一提,我很喜歡這句話:「系統設計是一連串的取捨」)。

整個串起來就是「資料庫伺服器每次都寫入硬碟,並使用帶有 write cache + 電池的 RAID 機器」,這樣能同時兼顧寫入速度並確保不會損失資料,不過就變成大幅提昇開銷了。若將支出也列入考量點的話,配套解法可能又不同,也許是降低寫入速度的要求,也許是容許損失多少時間內的資料。

以這麼技術的問題做為例子,其實有損杜書伍提的解題思維、以及《真正的問題是什麼?你想通了嗎?》的價值。真正在解決問題時,需要思考多個面向,而不能只局限在定死的框架裡以技術本位思考。以資工的術語類比的話,用 BFS 的方式思考會比 DFS 容易找出好方案。比方說可以考慮修改規格或換方式滿足需求,或是重新定義問題,找出真正的需求和剛好到位的解法。問題往往來自於期望和感受之間出現落差,有時換個角度看問題,就能剛好繞開原本的技術難題,並且沒有犠牲任何事情。

以我最近在做的事為例,我在找網站壓力測試的工具,雖然能找到一些好用的工具,但它們卻沒提供良好的登入機制,讓我無法開始測試。若硬往技術方向思考,解決問題的選項不外乎再找下一個工具,或是改工具的程式碼,加入一個前置登入的機制。但其實退一步思考,我真正的需求是產生大量連線,以「如同使用者登入」的方式進行壓力測試,我並不需要用正規的方式登入自家網站,大可另寫一個網址,開後門避開繁瑣的登入流程,直接登入測試帳號。於是,我只需要評估壓力測試工具,不用在意它是否有完備的登入機制。

但思考面向變廣後,比起定好其它因素、單純考量技術來說,問題也變得更複雜。要有效地在不同面向間穿梭,需要更多的經驗和練習,就像以往專精學一件事一樣,只是變得更困難。杜書伍在《打造將才基因》裡有不少這方面的說明,強烈推薦閱讀。概念是先專精一個技能,接著專精第二個技能,並找出和先前學習經驗的異同點,融會兩者成為自己的思考系統。接著專精第三個、第四個技能,找異同點,漸漸拼出多元且深入的思考方式。

最近這一年有點開竅,開始會朝多元化思考,先記錄目前的體悟。待有更多經驗後,再來寫心得吧。

2010年11月4日 星期四

讓 if、else 帶有更明確的語意

最近在維護程式時對於 if、else 有更深的體會,一但邏輯分支變多,很難釐清各種控制流程,一些簡單的習慣可以大幅簡化除錯和改程式的負擔。

變數的初始值

一個常見的情境是有個變數會依條件而有不同的值,典型的寫法如下:

1
2
3
4
5
# 假設後面有一長串算式會乘上 weight,這裡先決定它的值
if double:
   weight = 2
else:
   weight = 1

( 備註,在 Python 裡 if / else 裡設的變數和它的外層是同一個 scope。 )

或是善用程式語言提供的三元運算子設值 (即 ? : ),在 Python 裡則是這麼寫:

1
weight = 2 if double else 1

若有多種情況,在其它語言裡可能會用 switch,我個人不喜歡 switch,覺得用起來不直覺,Python 裡也沒有 switch,但可以用 dict 代替:

1
2
3
4
weight = {
    'double': 2,
    'triple': 3,
}.get(condition, 1)

操作複雜時可在 dict 的 value 裡改用輔助的小函式,明確的用簡短的程式表明「這區塊在決定 weight 的值」。

別小看這一點小改變,當程式碼很多時,看到 “value = a if condition else b” 可以立即明白這裡的判斷式是用來設值,可以省下為 if、else 這區塊煩心的時間,也可以減少消耗精神和腦內暫存記憶。

提前處理簡單的分支

以用遞迴的方式實作費氏數列為例:

1
2
3
4
5
6
def fib(n)if n < 0:  # Error input.
        raise ValueError('n must be positive.')
    if n == 0 or n == 1return 1
    return fib(n - 1) + fib(n - 2)

上面的寫法先處理例外,接著就能放心處理正常的情況,再來處理特例 (初始值),最後就能專心和主邏輯奮戰,而覺得主邏輯變得單純許多,很好處理。

較大的程式,就是先寫幾個簡單輔助小函式 (例如 is_invalid()),先呼叫小函式避開特殊情況,一樣可以化繁為簡。

避免巢狀區塊和 continue、break

常見到在多層迴圈裡呼叫 if、else,並和 continue、break 混用,我個人覺得這種寫法很亂,而傾向用小函式 + return 避開使用 continue 或 break,比方像下面的程式要從一個兩層 list 裡找出每個 list 第一個負數,並算出負數的總和:

1
2
3
4
5
6
7
sum = 0
for numbers in a_list_of_numbers:
    for n in numbers:
        if n < 0break
    if n < 0sum += n

可以改用小函式配合 return 避免使用 break 並「隱藏」分支:

1
2
3
4
5
6
7
8
9
10
def find_first_negative(numbers)'''Return 0 if there is no negative number.'''
    for n in numbers:
        if n < 0return n
    return 0
 
sum = 0
for numbers in a_list_of_numbers:
    sum += find_first_negative(numbers)

改寫後,兩個 if 都不見了,主邏輯很清楚地表現出「找出各 list 第一個負數並加總」。

同樣的,程式愈複雜時,這些寫法省下的思考時間愈可觀。

明確的指明 else 的處理方式

這和前面提的東西有一點相衝突,視情況而定。在任何有 if、elif 的情況,即使 else 的情況不需做任何處理,仍要明確的寫出 else 並加上註解。如下所示:

1
2
3
4
5
6
if some_condition:
    ...
elif another_condition:
    ....
else:  # Do nothing.
    pass

這個作法的目的是,讓其他讀這份程式的人,明白原作者沒有漏考慮 else 的情況,不處理是符合預期的作法。函式愈長時,這樣寫的好處愈明顯。別小看這個小動作,程式碼一多,回頭讀程式碼時,這點小動作可以省下不少分心的機會。

結語

上面提的例子背後的目的都一樣,就是避免讀程式碼的人分心在分支裡,而能專注在主邏輯上。類似的例子還有「使用 iterator 少用 for + index」,平時留意一些小細節,不但能愈寫愈快 (省去煩心細節的時間),也能降低維護成本,讓其他人易於理解。舉手之勞做環保,大家一起來維護程式碼的品質吧!