Day 12 - JavaScript - code review、數值判斷、readline 模組化

Code Review 的幾種形式

Code review 的核心目標是提升程式碼品質、分享知識、找出潛在問題。實務上常見的形式可分為:

1. 自我 review(隔一段時間回頭看)

  • 時機:完成一段功能後,提交給別人看之前。

  • 做法:先放下手邊工作幾分鐘,再回來用「陌生人」的視角重新審視程式碼 diff,檢查是否有明顯的邏輯錯誤、錯字或不一致的命名。

  • 優點:能過濾掉最基本的錯誤,提升自己產出的品質,也讓後續的 review 更有效率。

2. 和 AI 討論 review

  • 時機:需要快速得到「語法、風格、明顯 bug」的檢查;或想要「其他寫法」的靈感。

  • 做法:將一小段程式碼或一個檔案交給 AI,並清楚說明程式的目的與你的問題,例如:「幫我找出這段程式碼中可以重構的地方」或「幫我想想還有哪些測試情境我沒考慮到」。

  • 優點:即時性高,能快速找出「機器眼」擅長發現的模式問題,也是很好的 brainstorm 夥伴。

3. 同儕或 mentor review(Peer Review)

  • 時機:團隊開發的標準流程(例如 GitLab MR / GitHub PR)。

  • 做法:將改動整理成清楚的 commit 或 MR,附上修改目的、核心變更與對應測試。Reviewer 會從架構、可維護性、是否符合團隊規範等角度給出建議。

  • 優點:能藉由不同人的觀點找出自己的盲點,特別是在業務邏輯、架構設計與長期維護性上。

4. 結對 / Over-the-shoulder review

  • 時機:遇到較複雜的問題,或在設計一個新功能時,需要即時討論。

  • 做法:兩個人(或多人)共用一個螢幕或線上共編工具,一人負責寫,其他人邊看邊給建議。本質上是「即時的 code review」,隨時調整寫法。

  • 優點:回饋最即時,適合用在釐清需求、設計 API 或解決棘手問題的階段。


Review 內容 checklist

不論用哪種形式,都可以從這三大塊內容檢查:

  1. 語法與風格

    • 命名是否語意清楚?(例如 data、temp、item 這類命名是否可以更具體?)

    • 是否使用 === 而非 == 來避免非預期的型別強制轉換?

    • 是否可以用可選串連(?.)或空值合併運算子(??)來簡化 null 或 undefined 的檢查?

  2. 演算法與設計

    • 是否有過於複雜的巢狀迴圈?時間複雜度是否合理?

    • 在需要高效能查詢的場景,是否可以用 Map 或 Set 的 has() 取代陣列的 find() 或 includes()?

    • public API 的定義是否合理?模組與模組之間的界線是否清楚?

  3. 測試與邊界

    • 測試是否只測了 happy path(正常情境),而忽略了邊界值(例如 0、-1、空陣列)或錯誤輸入?

    • 測試的 mock 是否過多,導致在測「mock 本身」而不是「真實邏輯」?

    • 測試名稱是否清楚描述「規格」(it('should return sum when given a positive integer')),而不是只寫「case 1」?


適合使用 AI 協助 code review 的情境

AI 很適合處理「機器擅長、但人類覺得瑣碎」的情境,可以當作 pre-review 或輔助教練。

  • 開發中「自己先過一輪」的 pre-review

    • 情境:剛寫完一小段功能,丟給同事 review 前。

    • AI 用途:快速掃描語法錯誤、未使用的變數、重複的邏輯、過長的函式,並提醒缺少哪些測試情境(例如邊界、錯誤輸入)。

  • 練習題或 side project 尋求靈感

    • 情境:像現在練習 TDD、演算法題目。

    • AI 用途:問「同一題有沒有其他寫法?」、「這段程式碼可以怎麼重構?」、「這份測試規格還可以怎麼拆解?」,當作 brainstorm 夥伴。

  • 處理大量重複、模板化的改動

    • 情境:在多個檔案中加入同樣的 log、錯誤處理,或修改 API 名稱。

    • AI 用途:幫忙檢查是否有檔案漏改、拼錯字,或協助產生重構後的樣板程式。

  • 理解複雜或老舊的程式碼

    • 情境:接手別人的專案,或看到一段很長的函式。

    • AI 用途:請它用自然語言摘要「這支函式在做什麼」,或畫出 pseudo-code,幫助你快速建立 mental model。

  • 產生安全性 / 效能的提示清單

    • 情境:處理使用者輸入、檔案 I/O、API 呼叫等潛在風險點。

    • AI 用途:請它列出「這種情境常見的安全性或效能問題」,當作一份動態 checklist 來逐項檢查。

不那麼適合只靠 AI review 的情境

  • 涉及複雜商業規則的決策。

  • 需要團隊共識的架構設計或風格取捨。

在這類情境,AI 可以提供選項,但最終還是要由人類做出決策。


今天進行了第一次 code review

下面列出這次發現前兩週的程式碼可以再改善的部分:


用 try...catch...finally 顯示友善訊息

在 CLI 裡的典型流程:

import { divideBy3Times } from './q9.js';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

const rl = readline.createInterface({ input, output });

async function main () {
const num = await rl.question('請輸入一個正整數:');
const n = parseInt(num, 10);

try {
const times = divideBy3Times(n);
console.log(`${n} 至少要除以 3 連續 ${times} 次,小數點後第二位四捨五入後是零`);
} catch (error) {
console.log('輸入錯誤,請輸入一個大於 0 的整數');
} finally {
rl.close();
}
}

main();
  • domain 函式(例如 divideBy3Times)在輸入不合法時 throw new Error(...)。

  • try...catch 負責把錯誤轉成「使用者看得懂的訊息」,避免未捕捉例外直接中斷程式。

  • finally 確保 rl.close() 一定會被呼叫,釋放 readline 的資源。

參考:流程控制與例外處理
參考:Errors | Node.js
參考:Readline | Node.js


變數宣告與命名方法

  • 避免 magic number:重要數字用常數或已宣告變數表達(例如 const MAX_LINES = 2)。

  • 語意化命名:變數用名詞、函式用動詞,名稱可以長,但要一眼看得出用途。

  • let / const 原則:

    • 預設用 const,只有真的需要重新指定(reassign)才改用 let。

    • 這樣可以在語法層幫忙擋掉部分 bug。

參考:語法與型別


數值方法

Number(n) 與 parseInt(n, 10)

  • Number(n):將整個值轉成 number,若字串不是完整合法數字,結果會是 NaN。

  • parseInt(n, 10):會從左到右解析字串中的整數部分,例如 "123abc" → 123,"abc" → NaN。

  • 在 CLI 題目中,常見做法是:

    • 使用 parseInt(num, 10) 把輸入字串轉成整數或 NaN。

    • 再搭配 Number.isInteger(n)、n > 0 檢查是否是合法正整數。

參考:Number
參考:parseInt()

isNaN 與 Number.isNaN

  • isNaN(x)(全域)會先嘗試把 x 轉成 number 再判斷是不是 NaN,例如 isNaN('hello') 會是 true(因為 'hello' 轉數字變成 NaN)。

  • Number.isNaN(x) 不會做強制轉型,只有在「型別是 number 且值是 NaN」時才回傳 true。

  • 用 parseInt 或 Number() 把值轉成 number,接著用 Number.isNaN(n) 檢查會比較直覺、也比較安全。

參考:NaN
參考:isNaN()
參考:Number.isNaN()


陣列方法與 for loop

  • 陣列方法(map、filter、reduce 等)在處理「整個陣列的轉換」時,語意通常比裸 for 迴圈更清楚,可讀性較好。

  • 但在需要 break / continue、或對性能非常敏感的情況,傳統 for / for...of 仍然是很好的選擇。

  • 可以把原則調整為:

    • 優先考慮用陣列方法讓 intent 更清楚。

    • 如果遇到需要細緻控制流程或性能要求,再回到 for。

參考:陣列與陣列方法


readline

  • readline.createInterface({ input, output }) 裡:

    • input 通常綁定 process.stdin,代表從終端機讀入的資料。

    • output 通常綁定 process.stdout,代表在終端機顯示的提示文字。

  • console.log 單純寫到 stdout,與 readline 是否存在無直接關係。

  • rl.close() 用來關閉 Interface,釋放對 input / output 的控制,觸發 close 事件:

    • 建議在一個互動流程結束後就呼叫 rl.close(),避免忘記關閉導致後續程式無法正常使用 readline 或卡在等待輸入。

參考:Readline | Node.js


模組化程式碼

模組 1:共用流程抽到 /util

  • 將多支題目會重複用到的流程抽成 util 函式,例如:

    • 數字輸入驗證

    • 共同的格式化邏輯

  • 要給其他檔案使用的函式,用 export 暴露出來,這些對外暴露的函式可以視為這個模組的 public API。

  • 「最小 TDD」原則下,可以這樣安排測試重點:

    • 優先完整測 public API(例如 rotate90、calString 或 /util 中 export 出來的函式),涵蓋正常情境、邊界情境與錯誤輸入。

    • 檔案內部只給自己用的 helper 函式,優先透過 public API 間接被測到,不一定都要另外 export 出來寫獨立測試。

    • 如果某個 helper 邏輯很複雜、難以透過 public API 測試,就考慮把它抽到 /util 作為獨立模組的 public API,並為它寫測試。

  • 這樣可以在「練習 TDD」與「維持模組邊界乾淨」之間取得平衡:測試專注於對外承諾的行為,內部實作則依照需要逐步重構。

參考:JavaScript modules

模組 2:輸入檢查(validation)

  • 建立專門負責輸入驗證的函式或模組,例如:

    • validatePositiveInteger(n):檢查是否為大於 0 的整數,不合法時丟錯。

  • 好處:

    • domain 函式邏輯更純粹、只關注「題目核心運算」。

    • 驗證邏輯可以共用,測試也集中。

模組 3:readline I/O 啟動 / 關閉

  • 可以把「建立 readline、詢問問題、關閉」封裝成單獨模組,例如:

    • createCli() 回傳一個 ask(question) 的 async 函式,內部自行處理 rl 的建立與關閉。

  • 這樣每一題 CLI 程式都可以只專注在:

    • 題目運算

    • 如何把結果印出

而 I/O 細節則集中管理在這個模組中。

參考:readline 模組

從「窮舉案例」到「規格層級」的測試

目前對多項式 function calString(n) 的測試是類似「窮舉案例」的寫法,例如:

// calString(n) 範例:
// calString(3) => "1+2-3=0"
// calString(6) => "1+2-3+4-5+6=5"

test.each([
[6, "1+2-3+4-5+6=5"],
[10, "1+2-3+4-5+6-7+8-9+10=7"],
[1, "1=1"],
[3, "1+2-3=0"],
])('輸入 %i 應印出正確算式與結果', (n, expected) => {
expect(calString(n)).toBe(expected);
});

這種做法的特徵是:

  • 一次檢查「整串輸出」是否完全等於預期字串。

  • 想增加信心時,自然的做法是「多塞幾個 n,多窮舉幾組 input/output」。

這已經能有效驗證「小範圍內的行為」,但還可以再往下拆成「規格層級」的測試,分別檢查不同規則。


規格層級測試:拆成三種規則來驗證

以一個簡化版函式 sumString(n) 為例:

// 回傳 1 + 2 + ... + n 的字串與總和,例如:
// sumString(3) => "1+2+3=6"
function sumString (n) { /* ... */ }

1. 規則 A:數值結果要正確

不在乎左邊長什麼樣,單純檢查「= 右邊的數值」是否正確。

test.each([
  [1, 1],   // 1
  [3, 6],   // 1 + 2 + 3
  [5, 15],  // 1 + 2 + 3 + 4 + 5
])('輸入 %i 時,總和要正確', (n, expectedSum) => {
  const result = sumString(n); // "1+2+3=6"
  const [, sumStr] = result.split('=');
  expect(Number(sumStr)).toBe(expectedSum);
});

對應回 calString(n),就是只抽出 = 右邊的 sum 來比對,而不是整串字串。


2. 規則 B:字串格式要符合規定

專門檢查算式格式是否符合規則,例如:

  • 第一個數不帶 + 或 -。

  • 後面的正數都要帶 +。

  • 整體只包含數字、+、-。

test('格式:第一個數不帶 +,其餘正數帶 +,結尾有 =sum', () => {
const result = sumString(5); // "1+2+3+4+5=15"
const [expr] = result.split('='); // "1+2+3+4+5"

// 利用正則把每一段數字(含前面的符號)切出來
const tokens = expr.match(/[+-]?\d+/g); // ["1", "+2", "+3", "+4", "+5"]

// 規則 B1:第一個 token 不可以以 '+' 開頭
expect(tokens.startsWith('+')).toBe(false);

// 規則 B2:後面每個 token 如果不是負數,就必須以 '+' 開頭
for (const token of tokens.slice(1)) {
if (!token.startsWith('-')) {
expect(token.startsWith('+')).toBe(true);
}
}
});


對應回 calString(n),就可以檢查:

  • 第一項沒有符號。

  • 後面為正的都是 +數字,為負的是 -數字。


3. 規則 C:輸入驗證(非法輸入要丟錯)

專門檢查輸入是否合法,而不是算式正不正確:

test.each([[0], [-1], [1.5], ['3']])('輸入非正整數 %p 應丟錯', (value) => {
  expect(() => sumString(value)).toThrow();
});

對應回 calString(n),就是補上「n 不是正整數時要丟錯」的測試,而不是再多列幾個正常的 n。


兩種測試思路的差異

  • 類窮舉測試(現在已經做的)

    • 多組 n → 比對「整串輸出」是否完全等於預期。

    • 好處:直覺、一次看「輸入→輸出」是否正確。

  • 規格層級測試(可以再補的)

    • 先把需求拆成多條規則(數值結果、字串格式、輸入合法性),各自寫測試。

    • 好處:哪條規則壞了,測試訊息更清楚;未來改格式或內部實作時,較不容易全掛。

放在現在的多項式練習上,就是:

除了「針對多個 n 比對整串算式字串」,還可以:

  • 拆出「結果數值規則」來測 sum。

  • 拆出「格式規則」來測字串長相。

  • 拆出「輸入規則」來測錯誤處理。

讓測試從「多案例比對」進一步升級成「行為規格的集合」。