開源專案讀起來 | 你看過計算機的裡面嗎?

神Q超人
Starbugs Weekly 星巴哥技術專欄
16 min readMay 28, 2023
Photo by Ashraf Ali on Unsplash

Hi!大家好,我是神 Q 超人!終於又寫回開源專案讀起來系列啦,上次在寫 圈圈叉叉 那篇時候的開心感,就讓我很想繼續找些有趣或常見的小程式來讀,然後比較大家寫法間的差異,結果轉眼間一年半過去的現在才有時間繼續寫下去,果然變成大人後的時間總是特別少(其實是自己一直在偷懶 😂)

好的!那把話說回來,繼上次的圈圈叉叉後,這次選的主題也是做為練習對象很常見的計算機!就我剛剛在 GitHub 搜尋,光是用 JavaScript 寫了計算機的 repositories 就有 15 萬左右!

https://github.com/search?q=calculator+language%3AJavaScript&type=repositories&l=JavaScript

這篇文章一樣會舉出三個不同的計算機寫法,然後來看看這些寫法的優點,學習一波吧!

我會怎麼做?

好的!在看其他開發者的邏輯之前,先來想一下如果是我的話,會怎麼處理呢?

如果是我大概會先確認要計算的算式會以什麼方式傳進來吧,如果是用像是 ['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 方法裡:

https://github.com/fossasia/Ember_Simple_Calculator/blob/master/niteshkumarniranjan/embercalculator/app/components/calc-main.js#L15

答案就是 eval!其實只要能夠在 UI 上面限制好「某些特定情境下的操作」,例如「點擊運算符號後,點擊其他運算符號就不會有反應,或是取代上一次點擊的運算符號」。

如此一來,不論是括號或是其他的什麼,只要把一些會造成出錯的輸入限制住,就能夠確保每次輸入都能取得正確的運算式字串,並將該字串丟給 eval 取得結果就好,這個很 JavaScript。

案例二

接著是用 React Native 寫的 benoitvallon/react-native-nw-react-calculator,會選它當案例二就單純因為是星星數最高的 repo,但意外的其實不是星星高這件事情,而是星星數最高的計算機 repo 居然是 RN 寫的才讓我感到驚訝。

在這個專案裡面,作者把計算的邏輯放在 src/common/stores/Calculator.js 的 processCalculation 方法裡。但是我覺得在看 processCalculation 之前,可以先看看同檔案的 processKey 方法,確認作者是如何處理「當前要計算的資料格式」:

https://github.com/benoitvallon/react-native-nw-react-calculator/blob/master/src/common/stores/CalculatorStore.js#L127

processKey 裡面會先判斷點擊的按鍵是不是數字,如果是的話會先把該數字放到 _numberKeyPressBuffer 陣列裡面。舉例來說,如果先按下 1,再按下 2 的話,那 _numberKeyPressBuffer 的內容就會是 [1, 2],當然再放進去的時候作者也做了許多防呆,有興趣的話可以看 processNumberKeyPressed 方法。

另外如果 keyType 不是數字,而是運算式的話,就會做幾件事情:

  1. _signKeyTyped 變數更新為當下的運算符號。
  2. _numberKeyPressBuffer 的內容放到 _numbersFromBuffer 裡面。
  3. 清空 _numberKeyPressBuffer
  4. 執行擁有計算核心邏輯的 processCalculation 方法。

為什麼最後要執行 processCalculation 呢?其實在進入到 processCalculation 方法的時候,會先判斷 _numbersFromBuffer 裡面有幾個數字了,如果 _numbersFromBuffer 裡面已經有兩個數字,就會用上一次記錄下的 _signKeyTyped 去替兩個數字作運算,然後把 _numbersFromBuffer 更新為只有運算結果:

https://github.com/benoitvallon/react-native-nw-react-calculator/blob/master/src/common/stores/CalculatorStore.js#L207

舉個例子,如果我依序在畫面上點擊 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:

https://github.com/WebDevSimplified/Vanilla-JavaScript-Calculator/blob/master/script.js#L1

如果我們先不管 UI 如何顯示,而是集中在觀察如何處理計算機邏輯的話,可以看三個方法,分別是處理點擊數字的 appendNumber、點擊運算符號的 chooseOperation 和計算結果的 compute

https://github.com/WebDevSimplified/Vanilla-JavaScript-Calculator/blob/master/script.js#L18

appendNumber 裡就是單純的將點擊的數字轉成字串組合進 this.currentOperand

另外在點擊運算符號觸發的 chooseOperation 裡面,會有幾個判斷:

  1. 如果 this.currentOperand 沒有值的話,那點運算符號就沒反應。
  2. 如果 this.previousOperand 有值的話,就執行 this.compute 計算結果。
  3. 如果 this.currentOperand 有值,但 this.previousOperand 沒有值的話,那就把當下的運算符號記到 this.operation,然後把 this.previousOperand 的值設定成 this.currentOperand,設定完後清空 this.currentOperand

這個 chooseOperation 的邏輯有沒有很熟悉?其實它和案例二的邏輯差不多!案例二是在「運算之前」就記當前點擊的運算符號,而案例三是在「運算之後」。

最後來看一下 compute 吧:

https://github.com/benoitvallon/react-native-nw-react-calculator/blob/master/src/common/stores/CalculatorStore.js#L127

compute 根據 this.currentOperandthis.previousOperandthis.previousOperand 得到目前計算結果後,會將結果放到 this.currentOperand 裡面,並清空 this.operationthis.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 更新的時機與計算換個位置,就有那麼大的差別對吧!雖然邏輯和案例二差不多,但感覺在文章裡有這種反差感感覺滿不錯的,就把兩個都收錄進來了!

反思時間

在看完上方三種不同的計算機後,就能進入到反思時間啦!以下是兩點我自己覺得可以嘗試和沒想過的:

  1. eval 是真的可以有奇效!不需要自己處理計算邏輯,而是將思考的重心全部集中到輸入限制上。
  2. 在我的第一版本,只思考了如何處理計算邏輯而已,沒有想像到程式碼與 UI 互動的過程,因此算很偷懶的都比上方列舉案例還要少了一個輸入的步驟。

另外是關於案例二和案例三的,它們與案例一或是我所寫的不同,會根據使用者的輸入,階段性的計算結果,但是這麼做其實會有先乘除後加減的問題。

舉例來說,如果我在計算機上依序點擊 1、+、2、*、5,然後點擊等於計算,最後的答案應該會是 11,但他們的結果會是 15,因為 1 + 2 會先被算出來,再輸入乘 5 的時候就會是 3 * 5,大家也可以點擊自己 iphone 的計算機看看結果哦!

因此我覺得在階段性算出結果後,不能那麼果斷地將先前的運算式移除,如果留著的話能夠在最後計算時,依照「完整的運算式」判斷先乘除後加減。

以上就是開源專案讀起來的計算機篇啦!推薦大家如果也有注意到某些主題的小作品很多人在寫,然後好奇那是怎麼做到的,也可以像文章中這樣慢慢分析其他開發者的程式碼,偶爾會看到有趣的寫法或是 Bug,那如果真的看不懂的話,就留言給我,讓我來幫你看! 😂

最後如果文章裡有任何錯誤,或是對內容有任何問題和想法,再麻煩大家留言告訴我,我會盡快回覆和修正的,非常感謝!🙌

--

--