天天看點

JS資料類型,類型轉換,顯式和隐式強制類型轉換

1類型

定義:對開發者來說,類型是值的内部特征,它定義了值的行為,以使其差別于其他值。

了解類型,掌握類型,有助于了解掌握類型轉換

1.1内置類型

七種類型:

  1. 空值(null)
  2. 未定義(undefined)
  3. 布爾值(boolean)
  4. 數字(number)
  5. 字元串(string)
  6. 對象(object)
  7. 符合(symbol)

除對象外,其他統稱為基本類型

除了null,其他類型用typeof檢查都傳回類型名字元串,null傳回的是'object'。js是動态語言,變量可以随時持有任意類型的值。

2值

2.1數組

數組可以容納任何類型的值,類數組可以轉為數組,Array.from(...);

2.2字元串

字元串可以使用一些數組的方法,如length,下标取值等。字元串是不可變的,指的是字元串的成員函數不會改變其原始值,而數組的成員函數是在其原始值上操作

a = 'qq'
a.toUpperCase() // QQ
a // 'qq'
b = [1,1]
b.push(3)
b // [1,1,3]
           

許多數組函數處理字元串很友善,雖然字元串沒有,但是可以借用。

a = 'qwe'
let c = Array.prototype.join.call(a, '-')
let d = Array.prototype.map.call(a, function(v) {
    return v.toUpperCase() + '.'
}).join('')

c // q-w-e
d // Q.W.E
           

但是數組的reverse()函數是可變更成員的,字元串無法借用,因為字元串不可變。可以先把字元串split轉數組再使用reverse再join轉字元串

2.3數字

js隻有一種數值類型,number,包括整數和帶小數的十進制數。js的整數就是沒有小數的十進制數,是以42.0等同于42.

js的數字類型是基于IEEE754标準實作,也被稱為“浮點數”,js使用的是“雙精度”格式,即64位二進制。

二進制浮點數最大問題是會出現如下問題:

0.1 + 0.2 === 0.3 // false
           

從數學角度應該是true的,但是二進制浮點數中的0.1+0.2并不是十分精确,而是一個接近0.30000000000000004的數字

2.3.3 整數安全範圍

能夠安全呈現的最大整數是9007199254740991,最小是-9007199254740991。可以用Number.isInteger()檢測是否位為整數。注意,由于 JavaScript 采用 IEEE 754 标準,數值存儲為64位雙精度格式,數值精度最多可以達到 53 個二進制位(1 個隐藏位與 52 個有效位)。如果數值的精度超過這個限度,第54位及後面的位就會被丢棄,這種情況下,Number.isI nteger可能會誤判。 Number.isInteger(3.0000000000000002) // true 上面代碼中,Number.isInteger的參數明明不是整數,但是會傳回true。原因就是這個小數的精度達到了小數點後16個十進制位,轉成二進制位超過了53個二進制位,導緻最後的那個2被丢棄了。

可以用Number.isSafeInteger()檢測是否為安全整數。

2.4特殊數值

js資料類型中有幾個特殊的值需要注意

2.4.1不是值的值

undefined類型隻有一個值,即undefined,null類型隻有一個值,即null。

undefined一般用來表示沒有值,未指派

null一般用來表示空值

其中,null是特殊關鍵字,不是辨別符,不能用來做變量使用,undefined是一個辨別符,可以被當作變量和指派。

2.4.2特殊數字

1.不是數字的數字

如果數學運算符的操作數不是數字類型(或者無法解析為正常的十進制或十六進制數字)就無法傳回一個有效數字,這種情況傳回NaN,意指不是一個數字,或者了解為無效數值,壞數值更好。

如:
var a = 2 / 'qq' // NaN
typeof a === 'number' // true
           

Nan仍是個數字類型,更特殊的是NaN和自身不相等。可以用isNaN來判斷一個值是否為NaN。但是實際上

isNaN(NaN) // true
isNaN('qq') // true
           

對于非數字也傳回true,不過ES6有Number.isNaN()可以成功檢測

Number.isNaN(NaN) // true
Number.isNaN('qq') // false
           

2.無窮數

a=1/0 // Infinity
2/a // 0
-2/a //-0
           

3.零 js有一個正常0,也叫+0,和一個-0

0/-3 // -0
0*-3 // -0
-0 == 0 // true
-0 === 0 // true
           

加減法不會得到-0。為什麼需要-0呢,有些應用程式的資料,數字的符号位用來表示其他資訊(如方向),此時如果一個值位0的變量失去了它的符合,方向資訊就會丢失。

2.4.3特殊等式

通常判斷兩個值是否相等用== 和===。ES6新加入了一個工具方法Object.is(...),可以用來判斷兩個值是否絕對相等,可以用來處理上述特殊情況,但是一般判斷不用,因為

==
===
           

效率更高

2.5值和引用

指派和參數傳遞可以通過值複制和引用複制來完成,例如

var a = 2
var b = a // b是a的值的一個複本
b++
a // 2
b // 3

var c = [1, 2, 3]
var d = c // d是[,1, 2, 3]的一個引用
d.push(4)
c // [1, 2, 3, 4]
d // [1, 2, 3, 4]
           

簡單值總是通過值複制的方法指派/傳遞,包括(null,undefined,字元串,數字,布爾值,symbol)。

複合值,對象(包括數組和封裝對象)和函數,總是通過引用複制的方式來指派/傳遞

由于引用指向的是值本身而非變量,是以一個引用無法更改另一個引用的指向。如:

var a = [1]
var b = a
a // [1]
b // [1]

b = [2]
a // [1]
b // [2]
           

3.原生函數

js有内建函數,也叫原生函數,常用的有

String()
Number()
Boolean()
Array()
Object()
Function()
Date()
Error()
Symbol()
           

原生函數可以被當作構造函數使用,但是構造出對象三這樣的:

var a = new String('qq')
typeof a // 是object,不是String
a instanceof String // true
Object.prototype.toString.call(a) // "[object String]"
           

通過構造函數建立出的是封裝來基本類型值的封裝對象,typeof傳回的是對象類型的子類型

3.1内部屬性

所有typeof傳回是“object”的對象(如數組)都包含一個内部屬性[[Class]],這個屬性無法直接通路,一般通過Object.prototype.toString.call(...)通路。

3.2封裝對象包裝

由于基本類型值沒有.length和.toString()這樣的屬性和方法,需要通過封裝對象 才能通路,此時js會自動為基本類型值包裝一個封裝對象:

var a = 'abc'
a.length // 3
           

一般情況下不需要使用封裝對象,讓js自己決定,優先考慮用'abc',42這種基本類型值,而非new String()等。可以用.valueOf()擷取封裝對象中的基本類型值。

3.3原生函數作為構造函數

對于數組,對象,函數和正則,我們通常喜歡用字面量的形式來建立。實際上,使用字面量和構造函數效果是一樣的。應該盡量避免使用構造函數,因為有時候會有意外效果。

對于數組

var a = new Array(1,2,3) // 不是必須要帶new關鍵字,不帶會自動不全
a // [1,2,3]
b = [1,2,3]
b // [1,2,3]
           

但是當Array構造函數隻有一個參數時new Array(3) // [empty × 3],該參數會被當作數組預設長度,建立出一個空數組,有三個空單元,我們把包含至少一個空單元的數組叫稀疏數組。這種結構會導緻一些怪異行為,永遠不要建立和使用空單元數組。

除非萬不得已,否則盡量不要使用Object(...),Function(...),RegExp(...)

3.4原生原型

原生構造函數有自己的.prototype對象,如Array.prototype,這些對象包含其對應子類型所特有的行為特征。

indexOf()
charAt()
substr()
trim()
           

借助原型代理,所有字元串都可以通路這些方法,

4強制類型轉換

4.1值類型轉換

将一個值從一種類型轉換為另一種類型通常稱為類型轉換,這是顯式的情況,隐式的被稱為強制類型轉換 如

var a = 22
var b = a + '' // '22',隐式強制類型轉換
var c = String(a) // '22' ,顯式強制類型轉換
           

toString(),非字元串到字元串的強制類型轉換,

null 轉 'null'
undefined 轉 'undefined'
var a = [1,2]
a.toString() // '1,2'
           

toNumber(),轉換為數字

Number(true) // 1
Number(false) // 0
Number(undefined) // NaN
Number(null) // 0
Number('qq') // NaN
Number('22') // 22
           

對象(包括數組)會首先被轉換為相應的基本類型值,如果傳回的是非數字類型值,則再遵循以上規則将其強制轉換為數字。為了将值轉換為相應基本類型值,抽象操作ToPrimitive(見ES5規範9.1)會檢查該值是否有valueOf()方法,有且傳回基本類型值,使用該值進行強制類型轉換,沒有則使用toString()的傳回值(如果有)進行強制類型轉換。如果均不傳回基本類型值,會TypeError

toBoolean

布爾值,true和false,雖然1和0可以通過強制類型轉換為true和false,但它們不是一回事。

js中的值可以分為兩大類: 1 可以被強制類型轉換為false的值 2 其他(強制類型轉換為true)

以下是假值false:

undefined
null
false
+0   -0  NaN
""
           

真值

假值之外的都是真值,ru

[]
{}
function(){}
'false'
'0'
"''"
           

顯示強制類型轉換是那些顯而易見的類型轉換,我們應該盡可能的将類型轉換表達清楚,以免坑别人,可讀性越高,越容易了解。

4.2字元串和數字之間的顯示類型轉換

var a = 42
var b = String(a) || a.toString()
var c = '3.14'
var d = Number(c) || +c

日期轉數字
var d = new Date() || new Date().getTime() || Date.now()
+d // 1586784116068
           

4.2.1顯示轉換為布爾值

var a = "0"
var b = []
var c = {}

var d = ''
var e = 0
var f = null

Boolean(a) // true
Boolean(b) // true
Boolean(c) // true

Boolean(d) // false
Boolean(e) // false
Boolean(f) // false
           

雖然Boolean是顯示的,但是并不常用,一進制運算符!顯示的将值強制類型轉換為布爾值,但是它同時還将真值轉換為假值,是以顯示強制類型轉換為布爾值最常用方法是!!,更加清晰易讀

4.4隐式強制類型轉換

隐蔽的強制類型轉換,許多人對此诟病,認為它讓代碼變得晦澀難懂,會帶來負面影響,但是如果能靈活運用,它是非常不錯的,不能因噎廢食

4.4.1字元串和數字之間的隐式強制類型轉換

+運算符可以用于數字運算加法,也能用于字元串拼接。如

var a = '22'
var b = '0'

var c = 33
var d = 0

a + b // '220'
c + d // 33

a + c // '2233'

var x = [1,2]
var y = [3,4]
x + y // '1,23,4'
           

簡單來說,如果+其中一個操作數是字元串,(或者通過之前的規則得到字元串)則執行字元串拼接,否則執行數字加法。

我們可以使用空字元串""和數字相+得到字元串

var a = 22
a + '' // '42'
           

a + ''和Srring(a)有一個細微差别要注意:根據ToPrimitive()抽象操作規則,a+''會對a調用valueOf()方法,然後通過ToString抽象操作将傳回值轉換為字元串,而String(a)是直接調用ToString(),一般不會遇到這種問題,除非是對象,如:

var a = {
    valueOf: function() { return 22 },
    toString: function() { return 2 }
}
a + '' // '22'
String(a) // '2'
           

再看看從字元串強制類型轉換為數字

var a = '3.14'
var b = a - 0
b // 3.14
           

-是數字減法運算符,a - 0會将a強制類型轉換為數字,也可以使用* 和 /,因為這兩個運算符也隻适用于數字,不過不常見。

對象的-和+類似,如

var a = [11]
var b = [2]
a - b // 9
           

a和b先被轉為字元串(通過toString)再轉換為數字

4.4.2隐式強制類型轉換為布爾值

以下情況會發生布爾值隐式強制類型轉換

  1. if(...)語句中的條件判斷表達式
  2. for(...;...;...)語句中的條件判斷表達式的第二個
  3. while(...), doWhile(...)循環中的條件判斷表達式
  4. 三元運算符中的條件判斷表達式
  5. 邏輯運算符||和&&左邊的操作數(作為條件判斷表達式)

非布爾值會被隐式強制類型轉換為布爾值,如

var a = 42
var b = 'qq'
var c
var d = null

if (a) { console.log('YES') } // 'YES'
while(c) { console.log('no') }
c = d ? a : b // 'qq'
if((a && d) || c) { console.log('yes') } // 'yes'
           

4.4.3|| 和&&

|| 和 && 傳回的不一定是布爾值,而是兩個操作數中的其中一個的值 如

var a = 22
var b = 'abc'
var c = null

a || b // 22
a && b // 'abc'
c || b // 'abc'
c && b // null
           

||和&&會對第一個操作數判斷,,如果不是布爾值則先進行ToBoolean強制類型轉換,然後再判斷。

對于|| 如果第一個判斷為true,則傳回第一個值否則第二個值。

&&則相反,第一個判斷為true傳回第二個值,false傳回第一個值。

4.5相等比較

== 和 ===

==允許在相等比較中進行強制類型轉換,而‘===不允許。

如果比較的兩個值類型相同,==和===使用相同算法,
如果類型不同,就要考慮有沒有強制類型轉換的必要,
有就用==,沒有就用===.
           

另外要注意NaN不等于NaN,+0不等于-0。!= 和 !== 的判斷方式和==,===一樣。

字元串和數字之間相等比較

1.如果type(x)是數字,type(y)是字元串,則傳回x == toNumber(y)結果

1.如果type(x)是字元串,type(y)是數字,則傳回toNumber(x) == y結果

對象和非對象的相等比較

對象和基本類型之間相等比較:

1,如果type(x)是字元串或數字,type(y)是對象,傳回x == ToPrimitive(y)的結果

2.如果type(x)是對象,type(y)是數字或字元串,傳回ToPrimitive(x) == y的結果

4.5.1其他類型值和布爾類型值的相等比較

var a = '42'
var b = true
a == b // false
           

a是個真值,為什麼==結果不是true,規範說:

1.如果type(x)是布爾類型,則傳回toNumber(x) == y結果

2.如果Type(y)是布爾類型,則傳回x == toNumber(y)結果==

type(b)是布爾值,是以b被轉換為數字1,'42' 1再變42==1,結果false

4.5.2null和undefined

對于null和undefined,==比較也有隐式強制類型轉換,null == undefined // true,它們也與自身相等。

4.5.3<

a < b也涉及隐式類型轉換,分為2種情況,1,比較雙方都是字元串,和其他情況。

比較雙方先進行toPrimitive,如果結果出現非字元串,就根據toNumber規則将雙方強制類型轉換為數字來比較,如

var a = [42]
var b = ['44']
a < b // true
b < a // false
           

5文法

相比詞法,文法有點陌生,很多時候二者是一個意思,都是語言規則的定義,雖然有時有細微差别,js文法定義了詞法規則是如何構成可運作的程式代碼的。

5.1語句和表達式

我們常會把語句和表達式混為一談,但是二者是有重要差別的。對于英語來說,句子是完整表達某個意思的一組詞,有一個或多個短語組成,由标點連接配接,短語可以由更小短語組成。這是英語文法。

對于js,語句相當于句子,表達式相當于短語,運算符相當于标點和連接配接詞。

js中表達式可以傳回一個結果值,如:

var a = 3 * 6
var b = a
b
           

這裡3 * 6是一個表達式(結果為18),第二行a也是,第三行b也是,結果都是18.

這三行代碼都是包含表達式的語句,var a = 3 * 6和var b = a稱為“聲明語句”,因為它們聲明了變量并指派,a = 3 * 6,b = a(不帶var)叫“指派表達式” 第三行隻有一個表達式b,同時它也是個語句,這樣的通常叫:表達式語句“

5.1.1語句的結果值

語句都有一個結果值,可以在浏覽器控制台輸入語句,如輸入var a = 18,回車後會顯示undefined。ES5規範規定變量聲明算法實際有個傳回值,但是被變量語句算法屏蔽了(for in除外),最後傳回undefined

有個常見的坑:

1.[] + {} // "[object object]"
2.{} + [] // 0
           

在1中:根據規範,如果某個操作數是字元串或者能通過以下步驟轉換為字元串,+将進行拼接操作。如果一個操作數是對象(包括數組),則首先對其調用ToPrimitive抽象操作(先對操作數進行調用valueOf方法傳回基本類型值,然後通過調用ToString抽象操作轉換為字元串,valueOf無法傳回基本類型值則直接調用ToString轉字元串),該操作再調用[[DefaultValue]],以數字作上下文。是以[]調用valueOf傳回是[],調用toString傳回'',{}調用valueOf傳回{},調用toString傳回"[object object]",最終傳回"[object object]"

在2中,{}被當作獨立空代碼塊,代碼塊結尾不需要分号,是以不存在文法錯誤,最後 + []将[]顯式強制轉換為數字,0

5.2運算優先級

|| 和 && 傳回其中一個操作數的值,當隻有兩個操作數時邏輯很好了解,那麼如果多個呢? 就要運用“運算符優先級”規則

用,來連接配接一系列語句時,它的優先級最低,&&的優先級高于=,&&優先級高于||。

我們常使用

if (obj && obj.name) {...}
           

這種“短路”操作,防止obj未指派導緻obj.name出錯。根據運算優先級,對于複雜的多個運算符判斷,适當使用()将其包裹,使邏輯清晰,維護友善

5.3try ...finally

finally代碼總會在try之後執行,若有catch在catch後執行,無論什麼情況,finally最後一定會被調用。

== 要注意的==是,如果try中有return傳回一個值,如

function fn() {
    try {
        return 22
    } finally {
        console.log('hello')
    }
    console.log('end')
}

console.log(fn())
// hello
//22
           

throw也是一樣。如果finally中有抛出異常,則函數會在此終止,如果try中有return 傳回值,則該值會被丢棄。如果finally中有return,則會覆寫try和catch中的傳回值。

6異步和性能

一、為什麼JavaScript是單線程? JavaScript語言的一大特點就是單線程,也就是說,同一個時間隻能做一件事。那麼,為什麼JavaScript不能有多個線程呢?這樣能提高效率啊。

JavaScript的單線程,與它的用途有關。作為浏覽器腳本語言,JavaScript的主要用途是與使用者互動,以及操作DOM。這決定了它隻能是單線程,否則會帶來很複雜的同步問題。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加内容,另一個線程删除了這個節點,這時浏覽器應該以哪個線程為準?

是以,為了避免複雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特征,将來也不會改變。

為了利用多核CPU的計算能力,HTML5提出Web Worker标準,允許JavaScript腳本建立多個線程,但是子線程完全受主線程控制,且不得操作DOM。是以,這個新标準并沒有改變JavaScript單線程的本質。

二、任務隊列 單線程就意味着,所有任務需要排隊,前一個任務結束,才會執行後一個任務。如果前一個任務耗時很長,後一個任務就不得不一直等着。

如果排隊是因為計算量大,CPU忙不過來,倒也算了,但是很多時候CPU是閑着的,因為IO裝置(輸入輸出裝置)很慢(比如Ajax操作從網絡讀取資料),不得不等着結果出來,再往下執行。

JavaScript語言的設計者意識到,這時主線程完全可以不管IO裝置,挂起處于等待中的任務,先運作排在後面的任務。等到IO裝置傳回了結果,再回過頭,把挂起的任務繼續執行下去。

于是,所有任務可以分成兩種,一種是同步任務(synchronous),另一種是異步任務(asynchronous)。同步任務指的是,在主線程上排隊執行的任務,隻有前一個任務執行完畢,才能執行後一個任務;異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,隻有"任務隊列"通知主線程,某個異步任務可以執行了,該任務才會進入主線程執行。

具體來說,異步執行的運作機制如下。(同步執行也是如此,因為它可以被視為沒有異步任務的異步執行。)

(1)所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。

(2)主線程之外,還存在一個"任務隊列"(task queue)。隻要異步任務有了運作結果,就在"任務隊列"之中放置一個事件。

(3)一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看裡面有哪些事件。那些對應的異步任務,于是結束等待狀态,進入執行棧,開始執行。

(4)主線程不斷重複上面的第三步

三、事件和回調函數 "任務隊列"是一個事件的隊列(也可以了解成消息的隊列),IO裝置完成一項任務,就在"任務隊列"中添加一個事件,表示相關的異步任務可以進入"執行棧"了。主線程讀取"任務隊列",就是讀取裡面有哪些事件。

"任務隊列"中的事件,除了IO裝置的事件以外,還包括一些使用者産生的事件(比如滑鼠點選、頁面滾動等等)。隻要指定過回調函數,這些事件發生時就會進入"任務隊列",等待主線程讀取。

所謂"回調函數"(callback),就是那些會被主線程挂起來的代碼。異步任務必須指定回調函數,當主線程開始執行異步任務,就是執行對應的回調函數。

"任務隊列"是一個先進先出的資料結構,排在前面的事件,優先被主線程讀取。主線程的讀取過程基本上是自動的,隻要執行棧一清空,"任務隊列"上第一位的事件就自動進入主線程。但是,由于存在後文提到的"定時器"功能,主線程首先要檢查一下執行時間,某些事件隻有到了規定的時間,才能傳回主線程。

四、Event Loop 主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,是以整個的這種運作機制又稱為Event Loop(事件循環)。

setTimeout(_ => console.log(4))

new Promise(resolve => { resolve() console.log(1) }).then(_ => { console.log(3) })

console.log(2)

setTimeout就是作為宏任務來存在的,而Promise.then則是具有代表性的微任務,上述代碼的執行順序就是按照序号來輸出的。 所有會進入的異步都是指的事件回調中的那部分代碼 也就是說new Promise在執行個體化的過程中所執行的代碼都是同步進行的,而then中注冊的回調才是異步執行的。 在同步代碼執行完成後才回去檢查是否有異步任務完成,并執行對應的回調,而微任務又會在宏任務之前執行。 是以就得到了上述的輸出結論1、2、3、4。

7。尾調用優化

一、什麼是尾調用?

尾調用的概念非常簡單,一句話就能說清楚,就是指某個函數的最後一步是調用另一個函數。

function f(x){
  return g(x);
}
           

上面代碼中,函數f的最後一步是調用函數g,這就叫尾調用。

以下兩種情況,都不屬于尾調用。

// 情況一
function f(x){
  let y = g(x);
  return y;
}

// 情況二
function f(x){
  return g(x) + 1;
}
           

上面代碼中,情況一是調用函數g之後,還有别的操作,是以不屬于尾調用,即使語義完全一樣。情況二也屬于調用後還有操作,即使寫在一行内。

尾調用不一定出現在函數尾部,隻要是最後一步操作即可。

function f(x) {
  if (x > 0) {
    return m(x)
  }
  return n(x);
}
           

上面代碼中,函數m和n都屬于尾調用,因為它們都是函數f的最後一步操作。

二、尾調用優化

尾調用之是以與其他調用不同,就在于它的特殊的調用位置。

我們知道,函數調用會在記憶體形成一個"調用記錄",又稱"調用幀"(call frame),儲存調用位置和内部變量等資訊。如果在函數A的内部調用函數B,那麼在A的調用記錄上方,還會形成一個B的調用記錄。等到B運作結束,将結果傳回到A,B的調用記錄才會消失。如果函數B内部還調用函數C,那就還有一個C的調用記錄棧,以此類推。所有的調用記錄,就形成一個"調用棧"(call stack)。

尾調用由于是函數的最後一步操作,是以不需要保留外層函數的調用記錄,因為調用位置、内部變量等資訊都不會再用到了,隻要直接用内層函數的調用記錄,取代外層函數的調用記錄就可以了。

function f() {
  let m = 1;
  let n = 2;
  return g(m + n);
}
f();

// 等同于
function f() {
  return g(3);
}
f();

// 等同于
g(3);
           

上面代碼中,如果函數g不是尾調用,函數f就需要儲存内部變量m和n的值、g的調用位置等資訊。但由于調用g之後,函數f就結束了,是以執行到最後一步,完全可以删除 f() 的調用記錄,隻保留 g(3) 的調用記錄。

這就叫做"尾調用優化"(Tail call optimization),即隻保留内層函數的調用記錄。如果所有函數都是尾調用,那麼完全可以做到每次執行時,調用記錄隻有一項,這将大大節省記憶體。這就是"尾調用優化"的意義。

三、尾遞歸

函數調用自身,稱為遞歸。如果尾調用自身,就稱為尾遞歸。

遞歸非常耗費記憶體,因為需要同時儲存成千上百個調用記錄,很容易發生"棧溢出"錯誤(stack overflow)。但對于尾遞歸來說,由于隻存在一個調用記錄,是以永遠不會發生"棧溢出"錯誤。

function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120
上面代碼是一個階乘函數,計算n的階乘,最多需要儲存n個調用記錄,複雜度 O(n) 。

如果改寫成尾遞歸,隻保留一個調用記錄,複雜度 O(1) 。


function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

           

函數式程式設計有一個概念,叫做柯裡化(currying),意思是将多參數的函數轉換成單參數的形式。這裡也可以使用柯裡化。

function currying(fn, n) {
  return function (m) {
    return fn.call(this, m, n);
  };
}

function tailFactorial(n, total) {
  if (n === 1) return total;
  return tailFactorial(n - 1, n * total);
}

const factorial = currying(tailFactorial, 1);

factorial(5) // 120
上面代碼通過柯裡化,将尾遞歸函數 tailFactorial 變為隻接受1個參數的 factorial 。

第二種方法就簡單多了,就是采用ES6的函數預設值。


function factorial(n, total = 1) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5) // 120
           

上面代碼中,參數 total 有預設值1,是以調用時不用提供這個值。

總結一下,遞歸本質上是一種循環操作。純粹的函數式程式設計語言沒有循環操作指令,所有的循環都用遞歸實作,這就是為什麼尾遞歸對這些語言極其重要。對于其他支援"尾調用優化"的語言(比如Lua,ES6),隻需要知道循環可以用遞歸代替,而一旦使用遞歸,就最好使用尾遞歸。

繼續閱讀