变量类型

  在说函数之前,先来说说变量类型。

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,使用命名函数表达式实现了函数对象和函数名的解耦,可以正常执行