撰寫良好程式碼的原則
探討撰寫良好程式碼的必要性,以及一般撰寫良好程式碼的主要原則。
撰寫良好程式碼的必要性
如果只急於快速編寫程式碼以實現當前的需求,可能會導致技術債務累積到無法控制的程度,進而在後續維護時出現問題。因此,在進行開發專案時,從一開始就盡可能撰寫易讀性高且易於維護的良好程式碼,其重要性不言而喻。
對於演算法問題解決(PS, Problem Solving)或程式競賽(CP, Competitive Programming)來說,通常在完成問題解決或比賽結束後,就不會再使用到所寫的程式碼。特別是在CP中,由於有時間限制,有人可能會認為快速實現比撰寫良好程式碼更重要。要回答這個問題,需要思考自己為什麼要進行PS/CP,以及追求的方向是什麼。
個人認為,通過PS/CP可以學到以下幾點:
- 在給定的執行時間限制和記憶體限制等條件下解決問題的過程中,可以使用並熟悉各種演算法和資料結構。這有助於在實際專案中,對於特定情況應該使用哪種演算法和資料結構有更好的直覺。
- 編寫並提交程式碼後,可以立即獲得關於正確/錯誤、執行時間和記憶體使用量的客觀回饋,這有助於練習快速且熟練地編寫準確的程式碼,不會遺漏任何細節。
- 可以通過查看其他高手編寫的程式碼,與自己的程式碼進行比較,找出需要改進的地方。
- 相較於實際開發專案,PS/CP涉及的是規模較小且功能相似的程式碼,需要反覆編寫。(特別是獨自練習PS時)這提供了一個不受截止日期等限制的環境,可以專注於細節,練習編寫簡潔且良好的程式碼。
雖然有些人可能純粹將PS/CP作為興趣,但如果是為了間接提升程式設計能力而進行PS/CP,那麼最後提到的「練習撰寫良好程式碼」這一點,其重要性不亞於前面三點。因為撰寫良好程式碼並非與生俱來的能力,而是需要通過反覆練習才能逐漸熟練。此外,複雜且難以閱讀的程式碼不僅難以除錯,連自己也難以一次就準確編寫。如果花費大量時間在低效率的除錯上,反而可能無法快速實現。雖然PS/CP與實際工作環境有很大差異,但完全不關注撰寫良好程式碼,只急於當前實現,基於上述原因,這可能本末倒置。因此,個人認為即使在PS/CP中,也應該盡量編寫簡潔有效的程式碼。
2024.12 補充評論:
根據目前的趨勢來看,除非是主修電腦科學並以開發為職業,否則如果只是將程式設計作為數值分析或實驗數據分析等工具,可能更好的選擇是積極利用GitHub Copilot、Cursor、Windsurf等AI工具來節省時間,並將節省下來的時間用於學習其他內容。如果純粹將PS/CP作為興趣,那當然沒人會阻止,但為了練習編寫程式碼而投入時間和精力在PS/CP上,現在看來可能成本效益比較低。甚至對於開發職位來說,至少作為入職考試的程式設計測驗,其重要性可能會比以前大幅降低。
撰寫良好程式碼的原則
無論是在比賽中編寫的程式碼還是在實務中編寫的程式碼,被認為是良好程式碼的條件大致相同。本文將介紹一般撰寫良好程式碼的主要原則。然而,在PS/CP中,為了快速實現,相較於實務可能會有一些妥協的部分,這些情況會在文中特別提及。
編寫簡潔的程式碼
“KISS(Keep It Simple, Stupid)”
- 程式碼越短越簡潔,自然就越不容易出現拼寫錯誤或簡單的錯誤,除錯也更容易
- 盡可能編寫無需額外註解就能輕鬆理解的程式碼,只在真正需要時才添加註解進行詳細說明。保持程式碼結構本身的簡潔性比依賴註解更為理想。
- 如果要寫註解,應該清晰簡潔地撰寫
- 傳遞給一個函式的參數應該不超過3個,如果需要傳遞更多參數,最好將它們打包成一個物件來傳遞
條件陳述式的深度(depth)如果變得太深(二重、三重),會降低可讀性,因此應盡量避免增加條件陳述式的深度。 例如:相較於上面的程式碼,使用保護子句(Guard Clause)的下面的程式碼在可讀性方面更有優勢
1 2 3 4 5 6 7 8 9 10
async def verify_token(email: str, token: str, purpose: str): user = await user_service.get_user_by_email(email) if user: token = await user_service.get_token(user) if token : if token.purpose == 'reset': return True return False
1 2 3 4 5 6 7 8 9 10 11 12
async def verify_token(email: str, token: str, purpose: str): user = await user_service.get_user_by_email(email) if not user: return False token = await user_service.get_token(user) if not token or token.purpose != 'reset': return False return True
然而,在PS/CP中,為了進一步縮短程式碼長度以快速編寫,有時會使用C/C++的巨集(macro)這種技巧。這在時間緊迫的比賽中偶爾使用會很有用,但這只適用於PS/CP,一般來說應該避免在C++中使用巨集。 例如:
1
#define FOR(i,n) for(int i=0; i<n; i++)
程式碼模組化
“DRY(Don’t Repeat Yourself)”
- 如果重複使用相同的程式碼,應將該部分分離為函式或類別以便重複使用
- 通過模組化積極重複使用程式碼可以提高可讀性,而且在日後需要修改程式碼時,只需修改該函式或類別一次,因此更容易維護
- 原則上,一個函式理想情況下應該只執行一個功能,而不是兩個或更多功能。然而,在PS/CP中編寫的程式碼通常是執行簡單功能的小規模程式,因此重複使用有限,而且由於時間限制,可能難以像實務中那樣嚴格遵守原則。
利用標準函式庫
“Don’t reinvent the wheel”
- 在學習演算法或資料結構的階段,直接實現佇列或堆疊等資料結構、排序演算法等來理解原理是有用的,但除此之外,積極利用標準函式庫是更好的選擇
- 標準函式庫已經被無數次使用和驗證,而且經過良好的最佳化,比自己重新實現更有效率
- 使用現有的函式庫可以避免不必要地浪費時間直接實現相同功能的程式碼,而且在協作時,其他團隊成員也更容易理解你寫的程式碼
使用一致且明確的命名法
“Follow standard conventions”
- 使用不含糊的變數名和函式名
- 通常每種程式設計語言都有相應的命名慣例(naming convention),應該熟悉所使用語言的標準函式庫中使用的命名慣例,並在宣告類別、函式、變數等時一致地應用
- 命名時應清楚表明每個變數、函式和類別的功能,如果是布林(boolean)型別,則應明確在什麼條件下會返回真(True)
將所有資料正規化後儲存
- 所有資料都應該以一致的格式正規化後處理
- 如果相同的資料有兩種或以上的格式,可能會導致字串表示略有不同,或雜湊值不同等難以發現的微妙錯誤
- 在儲存和處理時區、字串等資料時,應該在輸入或計算後立即轉換為UTC、UTF-8編碼等單一標準格式。最好在表示該資料的類別的建構函式中從一開始就執行正規化,或在接收資料的函式中立即執行正規化。
分離程式碼的邏輯和資料
與程式碼邏輯無關的資料不應直接放在條件陳述式中,而應分離為獨立的表格
例如:比起上面的程式碼,像下面這樣編寫更為理想。1 2 3 4 5 6
string getMonthName(int month){ if(month == 1) return "January"; if(month == 2) return "February"; ... if(month == 12) return "December"; }
1 2 3 4 5
const string monthName[] = {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}; string getMonthName(int month){ return monthName[month-1]; }