JavaScript 修練 (3) | 運算子執行先後

運算子

前篇文章有提到表達式 ( Expression ) 會回傳值,而除了純值、呼叫函式之外,運算子 ( Operator ) 執行時也會回傳值,所以 MDN 文件把表達式和運算子放在同一章節內是有道理的

等號運算子也會回傳值

我們知道等號運算子 = ( Assignment Operator ) 是將右側的值賦予到左側變數上,但看開發者工具的 Console 執行結果,會發現它也會回傳值

等號運算子也會回傳值

🔮如果是 var a = 1; 則不會回傳值,雖然後方 a = 1 是表達式,但前篇有提過用 var 宣告會讓這行程式碼被判斷為陳述式,所以最終不會回傳值

補充:Assignment Operator 中文翻譯有指派運算子、指定運算子

運算子與運算元

運算子是由一個符號或單字組成,且至少要搭配一個運算元才能執行,所以我們常常會聽到的「一元」、「二元」、「三元」運算子,就是在說這個運算子的運算元數量

以下方的 + 運算子為例,而左右兩個數字是運算元,所以它是「二元運算子」

運算子與運算元

大部分運算子都是「二元運算子」,且都是以符號形式為主,如算數運算子 ( Arithmetic Operator ): +-*/,此外也有「一元運算子」和「三元運算子」

一元運算子

前面提到的 delete 就是一元運算子,它是以 delete 這個單字為運算子,搭配後方的運算元組成,執行過程中會刪除物件下的一個屬性,同時也會回傳刪除的結果 ( 下圖紅框處 )

delete 是一元運算子

另外 typeof 也是一元運算子,它可以用來取得運算元的型別

typeof 是一元運算子

三元運算子

三元運算子只有一個,也稱為「條件運算子」,它由兩個運算子 ?: 和三個運算元組成,會由 ? 左邊判斷式的真假值決定要執行 : 左邊或右邊的表達式,然後回傳一個結果,寫法示範如下

1
2
// 判斷式 ? 真值時執行的表達式 : 假值時執行的表達式
1 === 1 ? '相等' : '不相等';

因為 1 === 1true,所以執行 : 左邊的表達式,回傳值為 '相等'

三元運算子執行後的回傳值

和前篇介紹的 if 判斷式很類似,但寫法與執行還是有差異,它不僅可以作為判斷用,也可以用來回傳最終執行結果

1
2
3
const isSleeping = true;
const result = isSleeping ? '睡覺中,請勿打擾' : '醒了,請吵我';
console.log(result); // '睡覺中,請勿打擾'

無論是哪一種類型的運算子,都要記得「運算子屬於表達式的一種」( 會回傳值 )

運算子位置影響回傳和賦值先後

學習運算子 ++ 的過程中,一定有看過類似 ++aa++ 這兩種寫法,放前面跟放後面有差嗎?答案是有的,但是不一定每次都能看出它們的差異,所以可能會被忽略,導致當這樣的差異影響到程式碼的結果時,找不出原因在哪

部分運算子在執行過程中不會影響原始值,如:前面提過的 + 運算子,雖然會進行計算,但除非將結果另外用等號賦值,否則不會變更到變數的原始值

1
2
3
4
5
var a = 1;
console.log(a + 1); // 2

// + 運算子沒有變更 a 的原始值
console.log(a); // 1

但也有運算子在執行過程中會影響原始值,如:開頭說的 ++ 運算子,它屬於一元運算子,在它前方或後方加入一個值為數值的變數作為運算元,就可以讓該運算元變數的值增加 1

以下兩組程式碼,差異只有第二行 ++ 運算子放在 a 變數前或後,但無論前後都會影響變數原始值讓它增加 1,所以變數 a 的值最後都被賦予為 2

1
2
3
var a = 1;
console.log(++a); // 2
console.log(a); // 2
1
2
3
var a = 1;
console.log(a++); // 1
console.log(a); // 2

仔細看會發現雖然最後 a 的值都是 2,但是 ++aa++ 兩者用 console.log 印出的結果卻不同,這是因為 ++ 運算子放在運算元前方或後方,會影響它回傳值的時機點

  • ++ 在前方:會先計算並賦值,然後才會回傳運算元的變數值
  • ++ 在後方:會先回傳運算元的變數值,然後才會計算並賦值

因此,隨然最後都會改變變數 a 的值,但是執行順序的不同,就會導致程式碼運作的不同,接下來看看幾個範例,實際比較兩者運作上的差異吧 ~

以下兩組程式碼就是因為回傳時機點不同,而導致 *10 的計算結果不同,第一組程式碼是將 a 的值 +1 後,才執行計算 *10,第二組則是先用 +1 前的 a 值和 *10 計算 ( 1 * 10 ),再將 a 的值 +1

1
2
3
4
5
var a = 1;

// a++ 會先回傳值 1,跟後面的 * 10 計算,然後再把 a + 1 的計算結果賦值給 a,所以 b 的值是 10
const b = a++ * 10;
console.log(b); // 10
1
2
3
4
5
var a = 1;

// ++a 會先 a + 1 的計算結果賦值給 a,再回傳值 2,然後跟後面的 * 10 計算,所以 b 的值是 20
const b = ++a * 10;
console.log(b); // 20

小試身手

那麼經過上面的說明,試試看你能不能回答以下三個 console.log 印出的結果吧 ~

1
2
3
4
5
var a = 1;

console.log(a++);
console.log(a);
console.log(++a);

以上程式碼中 a++ 會先回傳變數 a 的值,所以 console.log(a++); 印出的結果不是計算賦值後的結果,是 a 計算前的值 1,而 console.log(a); 印出的結果,才是前面計算賦值後的結果 2,最後 ++a 會先計算並賦值,再回傳變數 a 的值,所以 console.log(++a); 印出的結果是計算後的結果 3

回傳值不一定是賦值

前面提過等號運算子,可以讓 = 右側的值賦予到左側,而且會回傳一個新值,而很多時候我們會誤以為賦值一定會成功,「回傳的值」在我們心裡跟「賦予的值」默默畫上了等號,但是這兩個值並不一定會相等 ( 直接心碎 QAQ,就像以下範例

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定義 obj 物件,在裡面加入屬性 value
const obj = {
value: 1
}

// 用 Object.freeze() 凍結物件
Object.freeze(obj);

// 重新將 obj.value 賦值為 2,會回傳的值為 2
obj.value = 2; // 2

// 賦予給 obj.value 的值還是 1
console.log(obj.value); // 1

下圖是執行以上程式碼的結果,可以看到當執行 obj.value = 2; 回傳的值為 2,因為 obj 物件內的屬性 value 無法被更動,所以就算重新賦值,obj.value 的值還是 1,但是紅框處的回傳值卻不是 1 而是 2,因此等號回傳的結果不一定與賦予值相等

等號回傳值不一定與賦予值相等

連續賦值產生的問題

既然前面提到了賦值,那就順便說說連續賦值會產生的問題吧 ~ 先看看以下範例,newValue 的最終值是 1 還是 2 呢?

1
2
3
4
5
6
const obj = {
value: 1
}
Object.freeze(obj);
const newValue = obj.value = 2;
console.log(newValue); // ?

依照前面講的等號執行順序,應該是先執行 obj.value = 2,把右邊的值 2 賦予給 obj.value,再執行 newValue = obj.value,把 obj.value 的值賦予給 newValue,然後因為 value 無法被更動,所以答案是 1 對吧?

很遺憾答錯了… 答案是 2,這時候一定覺得滿頭問號,為什麼?

現在就來重新拆解上方程式碼的執行流程:

  1. 執行 obj.value = 2,這時候會回傳一個新值 2
  2. 執行 newValue = 2,而這個 2 就來自於流程 1 回傳的值,所以 newValue 的值跟 obj.value 的值根本無關

連續賦值程式碼執行流程

中間的部分就算無法被賦值,賦予值的行為也還是會繼續進行,這就是要避免在實戰中連續賦值的原因,它可能會給你帶來意想不到的結果 (除非你喜歡驚嚇不然請避免這樣寫

其中在 ESLint 中 no-multi-assign 的內容就有提到不要連續賦值的範例如下,當連續賦值時,其實只有左側的變數 a 有被宣告,中間的變數 b 則會因為沒宣告變成全域屬性,所以在外層作用域也可以取得 b 的值

1
2
3
4
5
6
(function() {
const a = b = 0;
b = 1;
})();
console.log(b); // 1
console.log(window); // 印出結果如下圖,b 變成全域屬性

b 變成全域屬性

優先序與相依性

我們在學數學四則運算時,老師總會說要「先乘除後加減」,所以當看到 4 + 2 * 3,我們會習慣先處理後面的 2 * 3,再將結果 6 與 4 相加,得出結果為 10

這個算式在 JavasScript 中得出的結果也是 10,這邊我們會發現其實 JavasScript 在執行時也有優先順序的概念,這樣的概念不僅限於數學符號,而是所有運算子都會依據此優先順序執行

JS 乘法優先於加法

而 JavasScript 中的「運算子優先序」( Operator precedence ) 就是用來決定執行先後順序的,優先序較高的運算子就會優先執行,上述的的例子中 * 運算子優先序就高於 + 運算子

另外還有一個「相依性」( Associativity ) 則用於決定執行的方向性,通常會認為程式碼是由左向右執行,但是其實也有許多運算子是由右向左執行的,如上面提到的等號運算子就是由右向左執行

而「優先序」與「相依性」會影響整段程式碼的執行結果,如果沒有它們決定執行順序,那麼 JavasScript 執行的概念會大亂,另外在 MDN 文件上也有表格,整理各個運算子的優先序與相依性可以參考

接下來來看幾個範例,觀察優先序與相依性如何影響程式碼執行順序

以下範例中,= 運算子優先序低於右側的 *+ 運算子,所以會先執行右側結果,而 * 運算子優先序高於 + 運算子,所以計算結果為 7,接下來再執行 = 把右側結果 7 賦值給左側的 a,所以 a 的值為 7

1
const a = 3 + 2 * 5;

接下來看一個比較的範例,兩行程式碼如果以我們固有的思考方式,會覺得兩個都應該回傳 true,但是對 JS 來說這兩行程式碼執行之後的結果卻不同

1
2
1 < 2 < 3;  // 回傳 true
3 > 2 > 1; // 回傳 false

兩行程式碼看似相同結果卻不同,所以跟連續賦值一樣,連續比較也是不建議在實戰中出現的寫法,那麼就來看看這兩行程式碼實際時怎麼執行的吧 ~

第一行 1 < 2 < 3;

  1. < 運算子的相依性是由左向右執行,1 < 2 會得到 true 的結果
  2. 再來執行 true < 3,true 會轉型為數字 1,而 3 大於 1,所以結果為 true

第二行 3 > 2 > 1;

  1. > 運算子的相依性是由左向右執行,3 > 2 會得到 true 的結果
  2. 再來執行 true > 1,1 不大於 1,所以結果為 false

看完上面兩行程式碼的實際執行,就會發現 1 < 2 < 3; 會回傳 true,只是個美麗的誤會,實際上並不會照我們期望的那樣比較,而是一步一步按照相依性規則執行比較的結果

結語

前面有提到運算子屬於表達式的一種,結合前篇文章表達式會回傳值的概念和以上程式碼示範,可以觀察到表達式的回傳值會影響前後文的運作,鏈式寫法雖然比較精簡,但還是要了解其中執行先後的概念,才能避開不如預期的錯誤或結果

那我們就下篇文章見囉 ~