FizzBuzz

來解 FizzBuzz 吧!附詳細解題思路

前言

FizzBuzz 大概是最廣為人知的程式設計題目之一,它的題目是這樣的:

  • 打印出 1100 的數字
    • 假如數字是 3 的倍數,則打印 Fizz
    • 假如數字是 5 的倍數,則打印 Buzz
    • 假如數字是 35 的公倍數,則打印 FizzBuzz

解題

基本解

最直接的解法即是製作一個函式會打印 1 ~ 100 的數字,並依照規則判斷是否要打印 FizzBuzzFizzBuzz。但明顯這樣的做法會將所有數值都寫死在程式當中,未來要擴充條件時就需要修改程式碼,尚不夠靈活。

function fizzBuzz(max) {
for (let i = 1; i <= max; i++) {
if (i % 3 === 0 && i % 5 === 0) {
console.log('FizzBuzz');
} else if (i % 3 === 0) {
console.log('Fizz');
} else if (i % 5 === 0) {
console.log('Buzz');
} else {
console.log(i);
}
}
}
fizzBuzz(100);

分離資料與邏輯

既然可以預期規則都是 某數字 要打印 某結果 那麼或許可以使用物件來紀錄這些 Key-Value 結構的資料:

function fizzBuzz(max) {
const map = {
3: 'Fizz',
5: 'Buzz',
15: 'FizzBuzz',
};
for (let i = 1; i <= max; i++) {
if (i % Object.keys(map)[0] === 0 && i % Object.keys(map)[1] === 0) {
console.log(Object.values(map)[2]);
} else if (i % Object.keys(map)[0] === 0) {
console.log(Object.values(map)[0]);
} else if (i % Object.keys(map)[1] === 0) {
console.log(Object.values(map)[1]);
} else {
console.log(i);
}
}
}

資料是抽離出來了,但邏輯還是依賴指定 map 物件當中的特定內容,讓我們再寫個迴圈自動的將 map 物件的內容取出來,讓資料來驅動邏輯:

function fizzBuzz(max) {
const map = {
3: 'Fizz',
5: 'Buzz',
};
for (let i = 1; i <= max; i++) {
let output = '';
for (const key in map) {
if (i % key === 0) {
output += map[key];
}
}
console.log(output || i);
}
}

保持數值不變(Immutable)

以上解方可以觀察到目前定義了 i 以及 output 兩個變數並且於程式中持續的變動其內容,在某些程式開發風格當中會被視為應當避免的習慣,我們可以嘗試看看將複寫變數的部分改為純粹函式🔗

numberReplacer(100, {
3: 'Fizz',
5: 'Buzz',
}).forEach((value) => console.log(value));
function numberReplacer(max, replacementMap) {
return Array.from({ length: max }, (_, index) => {
const currentNumber = index + 1;
const replacement = Object.entries(replacementMap).reduce(
(acc, [divisor, replaceWord]) => (currentNumber % divisor === 0 ? acc + replaceWord : acc),
'',
);
return replacement || currentNumber;
});
}

補齊文件與檢查邊界案例

JavaScript 是動態型別語言,因此其他人在使用這個函式時可能不清楚具體參數的需求,這裡透過 JSDoc 來補齊文件,也可以考慮使用 TypeScript 來檢測型別或於 Runtime 進行型別檢測:

/**
* 生成一陣列,將 1 到 max 之間的數字取代為指定字串
*
* @param {number} max - 最大數值
* @param {Object<number, string>} replacementMap - key 為除數,value 為取代字串
* @returns {Array<string | number>} - 取代後的數值
* @throws {TypeError} 如 max 不是正整數
* @throws {TypeError} 如 replacementMap 不是非空物件
* @throws {TypeError} 如 replacementMap 內的 key 不是是正整數
* @example
* numberReplacer(15, { 3: 'Fizz', 5: 'Buzz' });
* // Returns [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz', 11, 'Fizz', 13, 14, 'FizzBuzz']
*/
function numberReplacer(max, replacementMap) {
if (!Number.isInteger(max) || max <= 0) {
throw new TypeError('max 必須是正整數');
}
if (typeof replacementMap !== 'object' || replacementMap === null) {
throw new TypeError('replacementMap 必須是非空物件');
}
return Array.from({ length: max }, (_, index) => {
const currentNumber = index + 1;
const replacement = Object.entries(replacementMap).reduce((acc, [divisor, replaceWord]) => {
const numDivisor = Number(divisor);
if (!Number.isInteger(numDivisor) || numDivisor <= 0) {
throw new TypeError('replacementMap 內的 key 必須是正整數');
}
return currentNumber % numDivisor === 0 ? acc + replaceWord : acc;
}, '');
return replacement || currentNumber;
});
}
try {
numberReplacer(100, {
3: 'Fizz',
5: 'Buzz',
}).forEach((value) => console.log(value));
} catch (error) {
console.error(error.message);
}

藉由局部應用來簡化 numberReplacer

你可能會想說:老天!只是要印個 FizzBuzz 為什麼每次都要透過 numberReplacer 來計算?有沒有辦法單純創個函式只傳入最大值就好?就像一開始一樣?讓我們用局部應用函式來透過創造抽象達成簡化:

const createFizzBuzz = (max) => {
return numberReplacer(max, {
3: 'Fizz',
5: 'Buzz',
});
};
const fizzBuzz100 = createFizzBuzz(100);
fizzBuzz100.forEach((value) => console.log(value));
const fizzBuzz15 = createFizzBuzz(15);
fizzBuzz15.forEach((value) => console.log(value));

在這次解題過程中我貫徹 DRY🔗 原則,並且透過不斷的重構來提升程式碼的可讀性與可維護性,但也可以思考真的有必要製造更多抽象嗎?過早的最佳化是萬惡之源,或許 YAGNI🔗

延伸閱讀