開源專案讀起來 | 你看過計算機的裡面嗎?
Hi!大家好,我是神 Q 超人!終於又寫回開源專案讀起來系列啦,上次在寫 圈圈叉叉 那篇時候的開心感,就讓我很想繼續找些有趣或常見的小程式來讀,然後比較大家寫法間的差異,結果轉眼間一年半過去的現在才有時間繼續寫下去,果然變成大人後的時間總是特別少(其實是自己一直在偷懶 😂)
好的!那把話說回來,繼上次的圈圈叉叉後,這次選的主題也是做為練習對象很常見的計算機!就我剛剛在 GitHub 搜尋,光是用 JavaScript 寫了計算機的 repositories 就有 15 萬左右!
這篇文章一樣會舉出三個不同的計算機寫法,然後來看看這些寫法的優點,學習一波吧!
我會怎麼做?
好的!在看其他開發者的邏輯之前,先來想一下如果是我的話,會怎麼處理呢?
如果是我大概會先確認要計算的算式會以什麼方式傳進來吧,如果是用像是 ['1', '+', '2', '*', '3']
這種方式的話,那我就會先去找找輸入的陣列中有沒有包含要先處理的乘法或除法,如果沒找到的話再找加法和減法。
在找到當前要處理的運算符號後,就會把這個運算符號位置的前一個和後一個數字取出來,並根據找到的運算符號得到結果。
取得這次的運算結果後,再把原本陣列放著「被運算的兩個數字」和「運算符號」的位置,替換成剛剛算出來的結果。
只要這樣子反覆運算,一直到陣列中只剩下一個值時,它就是答案了!那來看看把上面的想法轉換成程式後會是什麼樣子吧:
const calculator = (input) => {
const workingInput = [...input];
// 4. 如果陣列中剩下一個值,就是答案了,所以跳出迴圈回傳
while (workingInput.length > 1) {
// 1. 這裡開始找現在要運算的運算符號在哪裡
const prioritizedOperators = ['*', '/'];
const othersOperators = ['+', '-'];
let targetOperatorIndex = workingInput.findIndex(
(letter) => prioritizedOperators.includes(letter)
);
if (targetOperatorIndex === -1) {
targetOperatorIndex = workingInput.findIndex(
(letter) => othersOperators.includes(letter)
);
}
// 2. 這裡把剛剛找到的運算符號位置前一個和後一個取出來,並根據運算符號算當前結果
const num1 = Number(workingInput[targetOperatorIndex - 1]);
const num2 = Number(workingInput[targetOperatorIndex + 1]);
let result = null;
switch (workingInput[targetOperatorIndex]) {
case '+':
result = num1 + num2;
break;
case '-':
result = num1 - num2;
break;
case '*':
result = num1 * num2;
break;
case '/':
result = num1 / num2;
break;
}
// 3. 這裡把原本放「兩個被運算的數字」和「此次運算符號」的位置替換成剛剛算出來的結果
workingInput.splice(targetOperatorIndex - 1, 3, result);
}
return workingInput[0];
}
雖然上方的程式碼裡面沒有去處理到括號的邏輯,但就只有加減乘除的四則運算來說,執行結果是不會有問題的!
在完成上方的第一版程式之前,我一直覺得計算機的邏輯有很多細節一定很麻煩,但是寫完第一版後,就發現好像也沒那麼困難,反正一開始別想太多就是了,大家可以從簡單的只有兩個數字、或是只有加和減,完成後再加上多一些數字和乘或除,透過建立小目標慢慢達成,就會不知不覺完成啦!
好的!好像又有點離題,那再拉回到文章中,繼續看看其他人怎麼做的吧!
其他人怎麼寫?
案例一
首先登場的是 fossasia/Ember_Simple_Calculator,會選這個 repo 是因為作者使用的方法充分利用的 JavaScript 提供的原生語法,而且也有滿多其他的 repo 也都是用這種方式,到底是什麼呢?大家可以先想一下再繼續看下去!
實現的方法非常簡單,全都在 niteshkumarniranjan/embercalculator/app/components/calc-main.js 裡面的 equal
方法裡:
答案就是 eval
!其實只要能夠在 UI 上面限制好「某些特定情境下的操作」,例如「點擊運算符號後,點擊其他運算符號就不會有反應,或是取代上一次點擊的運算符號」。
如此一來,不論是括號或是其他的什麼,只要把一些會造成出錯的輸入限制住,就能夠確保每次輸入都能取得正確的運算式字串,並將該字串丟給 eval
取得結果就好,這個很 JavaScript。
案例二
接著是用 React Native 寫的 benoitvallon/react-native-nw-react-calculator,會選它當案例二就單純因為是星星數最高的 repo,但意外的其實不是星星高這件事情,而是星星數最高的計算機 repo 居然是 RN 寫的才讓我感到驚訝。
在這個專案裡面,作者把計算的邏輯放在 src/common/stores/Calculator.js 的 processCalculation
方法裡。但是我覺得在看 processCalculation
之前,可以先看看同檔案的 processKey
方法,確認作者是如何處理「當前要計算的資料格式」:
在 processKey
裡面會先判斷點擊的按鍵是不是數字,如果是的話會先把該數字放到 _numberKeyPressBuffer
陣列裡面。舉例來說,如果先按下 1,再按下 2 的話,那 _numberKeyPressBuffer
的內容就會是 [1, 2]
,當然再放進去的時候作者也做了許多防呆,有興趣的話可以看 processNumberKeyPressed
方法。
另外如果 keyType
不是數字,而是運算式的話,就會做幾件事情:
- 把
_signKeyTyped
變數更新為當下的運算符號。 - 把
_numberKeyPressBuffer
的內容放到_numbersFromBuffer
裡面。 - 清空
_numberKeyPressBuffer
。 - 執行擁有計算核心邏輯的
processCalculation
方法。
為什麼最後要執行 processCalculation
呢?其實在進入到 processCalculation
方法的時候,會先判斷 _numbersFromBuffer
裡面有幾個數字了,如果 _numbersFromBuffer
裡面已經有兩個數字,就會用上一次記錄下的 _signKeyTyped
去替兩個數字作運算,然後把 _numbersFromBuffer
更新為只有運算結果:
舉個例子,如果我依序在畫面上點擊 1、+、2、*、5,那執行起來變數會這樣變化:
點擊 1:
_numberKeyPressBuffer = [1];
_numbersFromBuffer = [];
_signKeyTyped = null;
點擊 +:
_numberKeyPressBuffer = [];
_numbersFromBuffer = [1];
_signKeyTyped = 'add';
// 執行 processCalculation,但是 _numbersFromBuffer 只有一個數字
// 所以不做任何事
點擊 2:
_numberKeyPressBuffer = [2];
_numbersFromBuffer = [1];
_signKeyTyped = 'add';
點擊 *:
_numberKeyPressBuffer = [];
_numbersFromBuffer = [1, 2];
_signKeyTyped = 'multiply';
// 執行 processCalculation,但是 _numbersFromBuffer 內有兩個數字
// 所以把他們加起來,並更新 _numbersFromBuffer
// 執行 processCalculation 之後:
_numberKeyPressBuffer = [];
_numbersFromBuffer = [2];
_signKeyTyped = 'multiply';
點擊 5:
_numberKeyPressBuffer = [5];
_numbersFromBuffer = [2];
_signKeyTyped = 'multiply';
如果你在看到上方的舉例後,或是在更早之前,在解釋點擊運算符號後會做的幾件事情時,就開始越想越不對勁,那你是對的!
以上方的結果來說當我點擊乘法的時候,理想中的行為應該是用上一次的加法替 _numbersFromBuffer
的兩個數字作運算,運算完後再更新 _signKeyTyped
為當前點擊的乘法,或是用另一個變數去紀錄上一次點擊的運算符號,否則先更新的後果就會讓後面點擊的乘法取代 1 + 2 變成 1 * 2。大家也可以到 repo 提供的 Demo 網頁 玩玩看。
不過前提是這真的是 Bug,而不是作者有意為之。 😂
案例三
第三個案例是 WebDevSimplified/Vanilla-JavaScript-Calculator,也是我自己最喜歡的計算機寫法,這個 repo 回到香草 JS,用 class 的方式完成顯示、操作和計算的邏輯。文件結構很單純,主要的程式碼在 script.js:
如果我們先不管 UI 如何顯示,而是集中在觀察如何處理計算機邏輯的話,可以看三個方法,分別是處理點擊數字的 appendNumber
、點擊運算符號的 chooseOperation
和計算結果的 compute
:
在 appendNumber
裡就是單純的將點擊的數字轉成字串組合進 this.currentOperand
。
另外在點擊運算符號觸發的 chooseOperation
裡面,會有幾個判斷:
- 如果
this.currentOperand
沒有值的話,那點運算符號就沒反應。 - 如果
this.previousOperand
有值的話,就執行this.compute
計算結果。 - 如果
this.currentOperand
有值,但this.previousOperand
沒有值的話,那就把當下的運算符號記到this.operation
,然後把this.previousOperand
的值設定成this.currentOperand
,設定完後清空this.currentOperand
。
這個 chooseOperation
的邏輯有沒有很熟悉?其實它和案例二的邏輯差不多!案例二是在「運算之前」就記當前點擊的運算符號,而案例三是在「運算之後」。
最後來看一下 compute
吧:
在 compute
根據 this.currentOperand
、this.previousOperand
和 this.previousOperand
得到目前計算結果後,會將結果放到 this.currentOperand
裡面,並清空 this.operation
和 this.previousOperand
,接著回到 chooseOperation
繼續執行。
因此如果同樣在畫面上依序點擊 1、+、2、*、5 的話,變數的變動會如下:
點擊 1:
this.currentOperand = '1';
this.previousOperand = '';
this.operation = undefined;
點擊 +:
this.currentOperand = '1';
this.previousOperand = '';
this.operation = undefined;
// 因為 this.previousOperand 是空的,所以不執行 this.compute
this.previousOperand = '1';
this.currentOperand = ''; // 把值給 this.previousOperand 後清空
this.operation = '+';
點擊 2:
this.currentOperand = '2';
this.previousOperand = '1';
this.operation = '+';
點擊 *:
this.currentOperand = '2';
this.previousOperand = '1';
this.operation = '+';
// 因為 this.previousOperand 有值,執行 this.compute
// 將當前的計算結果放到 this.currentOperand 裡
// 清空 this.previousOperand 和 this.operation
this.currentOperand = '3';
this.previousOperand = '';
this.operation = '';
// 在 compute 清空後,又在 chooseOperation 更新當前點擊的運算符號
this.operation = '*';
點擊 5:
this.previousOperand = '3';
this.currentOperand = '5';
this.operation = '*';
就是正確的結果啦!沒想到光是將 this.operation 更新的時機與計算換個位置,就有那麼大的差別對吧!雖然邏輯和案例二差不多,但感覺在文章裡有這種反差感感覺滿不錯的,就把兩個都收錄進來了!
反思時間
在看完上方三種不同的計算機後,就能進入到反思時間啦!以下是兩點我自己覺得可以嘗試和沒想過的:
- 用
eval
是真的可以有奇效!不需要自己處理計算邏輯,而是將思考的重心全部集中到輸入限制上。 - 在我的第一版本,只思考了如何處理計算邏輯而已,沒有想像到程式碼與 UI 互動的過程,因此算很偷懶的都比上方列舉案例還要少了一個輸入的步驟。
另外是關於案例二和案例三的,它們與案例一或是我所寫的不同,會根據使用者的輸入,階段性的計算結果,但是這麼做其實會有先乘除後加減的問題。
舉例來說,如果我在計算機上依序點擊 1、+、2、*、5,然後點擊等於計算,最後的答案應該會是 11,但他們的結果會是 15,因為 1 + 2 會先被算出來,再輸入乘 5 的時候就會是 3 * 5,大家也可以點擊自己 iphone 的計算機看看結果哦!
因此我覺得在階段性算出結果後,不能那麼果斷地將先前的運算式移除,如果留著的話能夠在最後計算時,依照「完整的運算式」判斷先乘除後加減。
以上就是開源專案讀起來的計算機篇啦!推薦大家如果也有注意到某些主題的小作品很多人在寫,然後好奇那是怎麼做到的,也可以像文章中這樣慢慢分析其他開發者的程式碼,偶爾會看到有趣的寫法或是 Bug,那如果真的看不懂的話,就留言給我,讓我來幫你看! 😂
最後如果文章裡有任何錯誤,或是對內容有任何問題和想法,再麻煩大家留言告訴我,我會盡快回覆和修正的,非常感謝!🙌