JavaScript高級程序設計(第3版)學習筆記7——函數(上)


變量類型

  在說函數之前,先來說說變量類型。

1、變量:變量在本質上就是命名的內存空間。

2、變量的數據類型:就是指變量可以存儲的值的數據類型,比如Number類型、Boolean類型、Object類型等,在ECMAScript中,變量的數據類型是動態的,可以在運行時改變變量的數據類型。

3、變量類型:是指變量本身的類型,在ECMAScript中,變量類型就只有兩種:值類型和引用類型。當變量的數據類型是簡單數據類型時,變量類型就是值類型,當變量的數據類型是對象類型時,變量類型就是引用類型。在不引起歧義的情況下,也可以稱變量的數據類型為變量類型。

  那么,值類型和引用類型有什么區別呢?最主要的一個,就是當變量類型為值類型時,變量存儲的就是變量值本身,而當變量類型為引用類型時,變量存儲的並不是變量值,而只是一個指向變量值的指針,訪問引用類型的變量值時,首先是取到這個指針,然后是根據這個指針去獲取變量值。如果將一個引用類型的變量值賦給另一個變量,最終結果是這兩個變量同時指向了一個變量值,修改其中一個會同時修改到另一個:

var a = {
    name:'linjisong',
    age:29
};
var b = a;//將引用類型的變量a賦給變量b,a、b同時指向了a開始指向的那個對象
b.name = 'oulinhai';//修改b指向的對象,也就是修改了a指向的對象
console.info(a.name);//oulinhai
b = {//將變量重新賦值,但是b原來指向的對象沒有變更,也就是a指向的對象沒有變化
    name:'hujinxing',
    age:23
};
console.info(a.name);//oulinhai

  好了,關於變量類型先說到這,如果再繼續到內存存儲數據結構的話,就怕沉得下去浮不上來。

函數

  如果說對象是房間,那么函數就是有魔幻效應的房間了。函數首先是對象,然后這個函數對象還具有很多魔幻功能……

1、函數

(1)函數是對象

  函數也是一種對象,而用於創建函數對象實例的函數就是內置的Function()函數(創建對象實例需要函數,而函數又是一種對象實例,是不是讓你有了先有雞還是先有蛋的困惑?別鑽牛角尖了,只要雞能生蛋,蛋能孵雞就行了,誰先誰后還是留給哲學家吧),但是函數這種對象,又和一般的對象有着極大的不同,以至於對函數對象實例使用typeof時返回的不是object而是function了。

(2)函數名是指向函數對象的引用類型變量

function fn(p){
    console.info(p);
}

console.info(fn);//fn(p),可以將fn作為一般變量來訪問
var b = fn;
b('function');//function,可以對b使用函數調用,說明b指向的對象(也就是原來fn指向的對象)是一個函數

注:關於函數名,在ES5的嚴格模式下,已經不允許使用eval和arguments了,當然,參數名也不能用這兩個了(我想除非你是專業黑客,否則也不會使用這些作為標識符來使用吧)。

2、函數創建

(1)作為一種對象,函數也有和普通對象類似的創建方式,使用new調用構造函數Function(),它可以接受任意數量的參數,最后一個參數作為函數體,而前面的所有參數都作為函數的形式參數,前面的形式參數還可以使用逗號隔開作為一個參數傳入,一般形式為:

var fn = new Function(p1, p2, ..., pn, body);
//或者
var fn = Function(p1, p2, ..., pn, body); 
//或者
var fn = new Function("p1, p2, ..., pn", q1, q2, ..., qn, body);
//或者
var fn = Function("p1, p2, ..., pn", q1, q2, ..., qn, body);

例如:

var add = new Function('a','b','return a + b;');
console.info(add(2,1));//3
var subtract = Function('a','b','return a - b;');
console.info(subtract(2,1));//1
var sum = new Function('a,b','c','return a + b + c;');
console.info(sum(1,2,3));//6

這種方式創建函數,會解析兩次代碼,一次正常解析,一次解析函數體,效率會影響,但是比較適合函數體需要動態編譯的情況。

(2)由於函數對象本身的特殊性,我們還可以使用關鍵字function來創建函數:

function add(a, b){
    return a + b;
}
console.info(add(2,1));//3
var subtract = function(a, b){
    return a - b;    
};
console.info(subtract(2,1));//1

從上可以看到,使用function關鍵字創建函數也有兩種方式:函數聲明和函數表達式。這兩種方式都能實現我們想要的效果,那他們之間有什么區別呢?這就是我們下面要講的。

3、函數聲明和函數表達式

(1)從形式上區分,在ECMA-262的規范中,可以看到:

函數聲明:  function Identifier       (參數列表(可選)){函數體}
函數表達式:function Identifier(可選)(參數列表(可選)){函數體}

除了函數表達式的標識符(函數名)是可選的之外沒有任何區別,但我們也可以從中得知:沒有函數名的一定是函數表達式。當然,有函數名的,我們就只能從上下文來判斷了。

(2)從上下文區分,這個說起來簡單,就是:只允許表達式出現的上下文中的一定是函數表達式,只允許聲明出現的上下文的一定是函數聲明。舉一些例子:

function fn(){};//函數聲明
//function fn(){}(); // 異常,函數聲明不能直接調用
var fn = function fn(){};//函數表達式
(function fn(){});//函數表達式,在分組操作符內
+function fn(){console.info(1);}();//1,函數表達式,出現在操作符+之后,因此可以直接調用,這里,也可以使用其它的操作符,比如new 
new function fn(){console.info(2);}();//2,函數表達式,new操作符之后
(function(){
    function fn(){};//函數聲明    
});

(3)區別:我們為什么要花這么大力氣來區分函數聲明和函數表達式呢?自然就是因為它們的不同點了,他們之間最大的不同,就是聲明會提升,關於聲明提升,在前面基礎語法的那一篇文章中,曾經對全局作用域中的聲明提升做過討論,我們把那里的結論復習一下:

A、引擎在解析時,首先會解析函數聲明,然后解析變量聲明(解析時不會覆蓋類型),最后再執行代碼;

B、解析函數聲明時,會同時解析類型(函數),但不會執行,解析變量聲明時,只解析變量,不會初始化。

在那里也舉了一些例子來演示(回憶一下),不過沒有同名稱的聲明例子,這里補充一下:

 1 console.info(typeof fn);//function,聲明提升,以函數為准
 2 var fn = '';
 3 function fn(){    
 4 }
 5 console.info(typeof fn);//string,由於已經執行了代碼,這里fn的類型變為string
 6 try{
 7     fn();//已經是string類型,不能調用了,拋出類型異常
 8 }catch(e){
 9     console.info(e);//TypeError
10 }
11 fn = function(){console.info('fn');};//如果想調用fn,只能再使用函數表達式賦值給fn
12 fn();//fn,可以調用
13 
14 console.info(typeof gn);//function
15 function gn(){    
16 }
17 var gn = '';
18 console.info(typeof gn);//string

可以看出:不管變量聲明是在前還是在后,在聲明提升時都是以函數聲明優先,但是在聲明提升之后,由於要執行變量初始化,而函數聲明不再有初始化(函數類型在提升時已經解析),因此后面輸出時就成為String類型了。

上面第3行定義了一個函數,然后第7行馬上調用,結果竟然不行!你該明白保持全局命名空間清潔的重要性了吧,要不然,你可能會遇到“我在代碼中明明定義了一個函數卻不能調用”這種鬼事情,反過來,如果你想確保你定義的函數可用,最好就是使用函數表達式來定義,當然,這樣做你需要冒着破壞別人代碼的風險。

還有一個問題,這里我們怎么確定變量類型是在初始化時候而不是在變量聲明提升時候改變的呢?看下面的代碼:

console.info(typeof fn);//function
function fn(){
}
var fn;
console.info(typeof fn);//function

可以看到,聲明提升后類型為function,並且由於沒有初始化代碼,最后的類型沒有改變。

  關於函數聲明和函數表達式,還有一點需要注意的,看下面的代碼:

if(true){
    function fn(){
        return 1;    
    }    
}else{
    function fn(){
        return 2;
    }    
}
console.info(fn());// 在Firefox輸出1,在Opera輸出2,在Opera中聲明提升,后面的聲明會覆蓋前面的同級別聲明

if(true){
    gn = function(){
        return 1;    
    };
}else{
    gn = function(){
        return 2;
    };    
}
console.info(gn());// 1,所有瀏覽器輸出都是1

  在ECMAScript規范中,命名函數表達式的標識符屬於內部作用域,而函數聲明的標識符屬於定義作用域。

var sum = function fn(){
    var total = 0,
        l = arguments.length;
    for(; l; l--)
    {
        total += arguments[l-1];
    }
    console.info(typeof fn);
    return total;
}
console.info(sum(1,2,3,4));//function,10
console.info(fn(1,2,3,4));//ReferenceError

  上面是一個命名函數表達式在FireFox中的運行結果,在函數作用域內可以訪問這個名稱,但是在全局作用域中訪問出現引用異常。不過命名函數表達式在IE9之前的IE瀏覽器中會被同時作為函數聲明和函數表達式來解析,並且會創建兩個對象,好在IE9已經修正。

  除了全局作用域,還有一種函數作用域,在函數作用域中,參與到聲明提升競爭的還有函數的參數。首先要明確的是,函數作用域在函數定義時不存在的,只有在函數實際調用才有函數作用域。

// 參數與內部變量,參數優先
function fn(inner){
    console.info(inner);// param
    console.info(other);// undefined
    var inner = 'inner';
    var other = 'other';
    console.info(inner);// inner
    console.info(other);// other
}
fn('param');

// 參數與內部函數,內部函數優先
function gn(inner){
    console.info(inner);// inner()函數
    console.info(inner());// undefined
    function inner(){
        return other;
    }
    var other = 'other';
    console.info(inner);// inner()函數
    console.info(inner());// other
}
gn('param');

通過上面的輸出結果,我們得出優先級:內部函數聲明 > 函數參數 > 內部變量聲明

  這里面的一個過程是:首先內部函數聲明提升,並將函數名的類型設置為函數類型,然后解析函數參數,將傳入的實際參數值賦給形式參數,最后再內部變量聲明提升,只提升聲明,不初始化,如果有重名,同優先級的后面覆蓋前面的,不同優先級的不覆蓋(已經解析了優先級高的,就不再解析優先級低的)。
  說明一下,這只是我根據輸出結果的推斷,至於后台實現,也有可能步驟完全相反,並且每一步都覆蓋前一步的結果,甚至是從中間開始,然后做一個優先級標志確定是否需要覆蓋,當然,從效率上來看,應該是我推斷的過程會更好。另外,全局作用域其實就是函數作用域的一個簡化版,沒有函數參數。

  這里就不再舉綜合的例子了,建議將這篇文章和前面的基礎語法那一篇一起閱讀,可能效果會更好。關於優先級與覆蓋,也引出下面要說的一個問題。

4、函數重載

  函數是對象,函數名是指向函數對象的引用類型變量,這使得我們不可能像一般面向對象語言中那樣實現重載:

1 function fn(a){
2     return a;
3 }
4 function fn(a,b){
5     return a + b;
6 }
7 
8 console.info(fn(1));  // NaN
9 console.info(fn(1,2));// 3

不要奇怪第8行為什么輸出NaN,因為函數名只是一個變量而已,兩次函數聲明會依次解析,這個變量最終指向的函數就是第二個函數,而第8行只傳入1個參數,在函數內部b就自動賦值為undefined,然后與1相加,結果就是NaN。換成函數表達式,也許就好理解多了,只是賦值了兩次而已,自然后面的賦值會覆蓋前面的:

var fn = function (a){ return a; }
fn = function (a,b){ return a + b;}

那么,在ECMAScript中,怎么實現重載呢?回想一下簡單數據類型包裝對象(Boolean、Number、String),既可以作為構造函數創建對象,也可以作為轉換函數轉換數據類型,這是一個典型的重載。這個重載其實在前一篇文章中我們曾經討論過:

(1)根據函數的作用來重載,這種方式的一般格式為:

function fn(){
    if(this instanceof fn)
    {
        // 功能1
    }else
    {
        // 功能2
    }
}

這種方式雖然可行,但是很明顯作用也是有限的,比如就只能重載兩次,並且只能重載包含構造函數的這種情形。當然,你可以結合apply()或者call()甚至ES5中新增的bind()來動態綁定函數內部的this值來擴展重載,但這已經有了根據函數內部屬性重載的意思了。
(2)根據函數內部屬性來重載

function fn(){
    var length = arguments.length;
    if(0 == length)//將字面量放到左邊是從Java中帶過來的習慣,因為如果將比較操作符寫成了賦值操作符(0=length)的話,編譯器會提示我錯誤。如果你不習慣這種方式,請原諒我    
   {
return 0; }else if(1 == length) { return +arguments[0]; }else{ return (+arguments[0])+(+arguments[1]); } } console.info(fn());//0 console.info(fn(1));//1 console.info(fn(true));//1 console.info(fn(1,2));//3 console.info(fn('1','2'));//3

這里就是利用函數內部屬性arguments來實現重載的。當然,在內部重載的方式可以多種多樣,你還可以結合typeof、instanceof等操作符來實現你想要的功能。至於內部屬性arguments具體是什么?這就是下面要講的。
5、函數內部屬性arguments

  簡單一點說,函數內部屬性,就是只能在函數體內訪問的屬性,由於函數體只有在函數被調用的時候才會去執行,因此函數內部屬性也只有在函數調用時才會去解析,每次調用都會有相應的解析,因此具有動態特性。這種屬性有:this和arguments,這里先看arguments,在下一篇文章中再說this。

(1)在函數定義中的參數列表稱為形式參數,而在函數調用時候實際傳入的參數稱為實際參數。一般的類C語言,要求在函數調用時實際參數要和形式參數一致,但是在ECMAScript中,這兩者之間沒有任何限制,你可以在定義的時候有2個形式參數,在調用的時候傳入2個實際參數,但你也可以傳入3個實際參數,還可以只傳入1個實際參數,甚至你什么參數都不傳也可以。這種特性,正是利用函數內部屬性來實現重載的基礎。

(2)形式參數甚至可以取相同的名稱,只是在實際傳入時會取后面的值作為形式參數的值(這種情況下可以使用arguments來訪問前面的實際參數):

function gn(a,a){
    console.info(a);
    console.info(arguments[0]);
    console.info(arguments[1]);
}
gn(1,2);//2,1,2
gn(1);//undefined,1,undefined

這其實也可以用本文前面關於聲明提升的結論來解釋:同優先級的后面的覆蓋前面的,並且函數參數解析時同時解析值。當然,這樣一來,安全性就很成問題了,因此在ES5的嚴格模式下,重名的形式參數被禁止了。

(3)實際參數的值由形式參數來接受,但如果實際參數和形式參數不一致怎么辦呢?答案就是使用arguments來存儲,事實上,即便實際參數和形式參數一致,也存在arguments對象,並且保持着和已經接受了實際參數的形式參數之間的同步。將這句話細化一下來理解:

  • arguments是一個類數組對象,可以像訪問數組元素那樣通過方括號和索引來訪問arguments元素,如arguments[0]、arugments[1]。
  • arguments是一個類數組對象,除了繼承自Object的屬性和方法(有些方法被重寫了)外,還有自己本身的一些屬性,如length、callee、caller,這里length表示實際參數的個數(形式參數的個數?那就是函數屬性length了),callee表示當前函數對象,而caller只是為了和函數屬性caller區分而定義的,其值為undefined。
  • arguments是一個類數組對象,但並不是真正的數組對象,不能直接對arguments調用數組對象的方法,如果要調用,可以先使用Array.prototype.slice.call(arguments)先轉換為數組對象。
  • arguments保存着函數被調用時傳入的實際參數,第0個元素保存第一個實際參數,第1個元素保存第二個實際參數,依次類推。
  • arguments保存實際參數值,而形式參數也保存實際參數值,這兩者之間有一個同步關系,修改一個,另一個也會隨之修改。
  • arguments和形式參數之間的同步,只有當形式參數實際接收了實際參數時才存在,對於沒有接收實際參數的形式參數,不存在這種同步關系。
  • arguments對象雖然很強大,但是從性能上來說也存有一定的損耗,所以如果不是必要,就不要使用,建議還是優先使用形式參數。
    fn(0,-1);
    function fn(para1,para2,para3,para4){
        console.info(fn.length);//4,形式參數個數
        console.info(arguments.length);//2,實際參數個數
        console.info(arguments.callee === fn);//true,callee對象指向fn本身
        console.info(arguments.caller);//undefined
        console.info(arguments.constructor);//Object(),而不是Array()
        try{
            arguments.sort();//類數組畢竟不是數組,不能直接調用數組方法,拋出異常
        }catch(e){
            console.info(e);//TypeError
        }
        var arr = Array.prototype.slice.call(arguments);//先轉換為數組
        console.info(arr.sort());//[-1,0],已經排好序了
        
        console.info(para1);//0
        arguments[0] = 1;
        console.info(para1);//1,修改arguments[0],會同步修改形式參數para1
        
        console.info(arguments[1]);//-1
        para2 = 2;
        console.info(arguments[1]);//2,修改形式參數para2,會同步修改arguments[1]
        
        console.info(para3);//undefined,未傳入實際參數的形式參數為undefined
        arguments[2] = 3;
        console.info(arguments[2]);//3
        console.info(para3);//undefined,未接受實際參數的形式參數沒有同步關系
        
        console.info(arguments[3]);//undefined,未傳入實際參數,值為undefined
        para4 = 4;
        console.info(para4);//4
        console.info(arguments[3]);//undefined,為傳入實際參數,不會同步
    }

    經過測試,arguments和形式參數之間的同步是雙向的,但是《JavaScript高級程序設計(第3版)》中第66頁說是單向的:修改形式參數不會改變arguments。這可能是原書另一個Bug,也可能是FireFox對規范做了擴展。不過,這也讓我們知道,即便經典如此,也還是存有Bug的可能,一切當以實際運行為准。

  • 結合arguments及其屬性callee,可以實現在函數內部調用自身時與函數名解耦,這樣即便函數賦給了另一個變量,而函數名(別忘了,也是一個變量)另外被賦值,也能夠保證運行正確。典型的例子有求階乘函數、斐波那契數列等。
    //求階乘
    function factorial(num){
        if(num <= 1)
        {
            return 1;
        }else{
            return num * factorial(num - 1);    
        }
    }
    var fn = factorial;
    factorial = null;
    try{
        fn(2);//由於函數內部遞歸調用了factorial,而factorial已經賦值為null了,所以拋出異常
    }catch(e){
        console.info(e);//TypeError
    }
    
    //斐波那契數列
    function fibonacci(num){
        if(1 == num || 2 == num){
            return 1;    
        }else{
            return arguments.callee(num - 1) + arguments.callee(num - 2);    
        }
    }
    var gn = fibonacci;
    fibonacci = null;
    console.info(gn(9));//34,使用arguments.callee,實現了函數對象和函數名的解耦,可以正常執行

    遞歸的算法非常簡潔,但因為要維護運行棧,效率不是很好。關於遞歸的優化,也有很多非常酣暢漓淋的算法,這里就不深入了。

  需要注意的是,arguments.callee在ES5的嚴格模式下已經被禁止使用了,這時候可以使用命名的函數表達式來實現同樣的效果:

//斐波那契數列
var fibonacci = (function f(num){
    return num <= 2 ? 1 : (f(num - 1) + f(num - 2));
});
var gn = fibonacci;
fibonacci = null;
console.info(gn(9));//34,使用命名函數表達式實現了函數對象和函數名的解耦,可以正常執行


 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM