跳至主要內容

函数

xmut-lby大约 25 分钟

函数

什么是函数

我们先看最简单的函数定义:

function add( a, b ){
    return a+b;
}

console.log(add(1,1));
console.log(add(2,3));

显然这段代码会在控制台打出2和5

2
5

这个简单的例子可以说明函数的用法。

function add( a, b ){
    return a+b;
}

这段代码称为函数的定义。这段定义里,我们用function关键字来申明一个函数,这点与C语言是不同的。C语言并没有一个专门的关键字来表明这里在申明函数。

和C语言一样,JS的函数的组成元素是比较相似的:

  1. 函数名称:function后直接给定的符号称为函数名,我们通过函数名来调用函数。
  2. 函数参数:函数括号里的部分称为函数的参数,参数可以调节函数的行为,使函数使用更为灵活。本例中,add函数有两个参数,分别记为a和b。
  3. 函数的返回值:我们用return a+b返回函数调用的结果。

调用函数也和C语言类似:

let s = add(1,2);

注意参数的次序,这里这样调用,就告诉函数,令参数a等于1,同时令参数b等于2。参数的次序显然是不能颠倒的

这里s就等于函数的调用结果3。

请自己尝试一下。

练习一下

编写一个函数,给定参数n,返回1+2+3+...+n的值

深入一些

函数名称和定义

函数名遵循变量命名的所有规则(意味着,可以有$符号)。当然也有不能命名为关键字的限制。

实际上定义一个函数的方式有很多种,比较常见的还可以这样定义:

let add2 = function( a, b ){
    return a+b;
}

这种方式你可以这样理解,我们定义了一个函数:

function(a,b){
    return a+b;
}

这个函数定义时function关键字后直接接括号,没有给出名字。我们称这个函数为匿名函数。之后我们将这个函数赋值给变量add2

let add2 = function //....把匿名函数赋值给add2这个变量。

没错,函数可以被赋值给变量(或者说变量可以是函数)。,在javascript里这非常常见。

为什么

写到这里有可能C语言学的好的和学的不好的都沉默了,函数为什么可以被赋值给变量,这两个不应该是风马牛不相及的吗?

这个问题应该反过来问,为什么不?

函数可以被当作变量一样赋值(或者说,参数化)能给编码设计带来非常大的便利。

考虑以下需求,编写一个函数来进行两个数字的计算,你可能会这么写:

function calc( a, op, b ){
    switch(op){
        case "+": return a+b; break;
        case "-": return a-b; break;
        case "*": return a*b; break;
        case "/": return a/b; break;
    }
}

这函数当然没错,但会带来问题:每增加一种算符,就需要修改一次这个函数,每次修改都可能引入错误,频繁修改会导致代码可靠性下降。 同时函数会越来越长,可维护性直线下降。

因此这种方法很直观,但从软件工程的角度,是一种非常不好的设计

实际上我相信有过2000行以上编码经验的同学应该已经有了朦胧的想法:如果可以通过字符串(或者下表)来查找函数,很多代码会简练的多!

那么能不能呢?看下面的解决方案:

let op_table = {
    "+":function(a,b){return a+b},
    "-":function(a,b){return a-b},
    "*":function(a,b){return a*b},
    "/":function(a,b){return a/b}
}
function calc( a, op, b ){
    return op_table[op](a,b);
}

我们用对象op_table来存储运算符到处理函数的映射,每个运算符都映射到一个函数上。

这样在calc函数里,我们就可以直接通过op来查表,获得相关的函数然后调用返回。

你可能觉得这两种代码没有太大区别,但实际上区别巨大:calc的编写者不再需要知道有多少种算符,算符编写者和调用者实现了分离

另一方面,无论算符如何增加和减少。calc这个函数都不需要进行修改

这么厉害,那C语言不是辣鸡语言吗?

很遗憾,辣鸡的是你,C语言可以通过函数指针达到这个目的。

此外面向对象语言,C++,Java等语言,通过多态来达到这个目的。

所以你看,实际上把函数参数化才是大家的共识。

甚至可以说如果说软件工程专业有什么我们一定要你掌握的,那就是这种解决问题的思维方式

函数的参数

参数是函数灵活性的体现。比如add函数,正因为有了参数,函数才可以执行任意参数的加法操作。这大大拓展了函数的使用灵活性。

当然函数可以没有参数:

var kou6machine = function(){
    for(i=0;i<10;++i){
        console.log("666");
    }
}

这个函数就没有参数,那么他的行为就是固定的,你可以认为这样的函数只是用来打包一定的操作,避免你多次重写。

目前为止跟C语言都没什么不同。但接下来就不一样了:

函数也可以有不固定长度的参数:

var fuduParams =function(...params){
    for(i=0;i<params.length;++i){
        console.log(params[i]);
    }
}
fuduParams(1,2,3,4);

这里的定义中,...告诉解释器,参数长度不固定,后面的params定义了变量来存放所有参数,因为参数数目是不固定的,解释器会将所有的参数以数组的形式放入params中。我们遍历数组,就可以取出所有参数了。

你知道吗?

console.log就是一个可变参数的函数。

试试

此外,javascript里还可以通过arguments来获得所有参数:

var youfuduParams = function(){
    for( i=0; i<arguments.length; ++i){
        console.log(arguments[i])
    }
}
youfuduParams(1,2,3,4);

我们并没有定义参数,但通过关键字arguments,我们依然可以将所传递的参数读取出来。

要注意的是,当函数既有有名参数,又要不定长时,你需要把不定长的部分写在最后:

var rightParams = function(a,b, ...params){
    console.log(a);
    console.log(b);
    for(i=0;i<params.length;++i){
        console.log("unnamed params:"+params[i].toString());
    }
}
rightParams(1,2,3,4);
1
2
unnamed params:3
unnamed params:4

1和2会被放入a和b中,剩下的放入params中。

而这样写是错误的:

var wrongParams = function(...params,a,b){
}

因为这会让解释器困惑,到底哪些变量要被放入a和b。

注意,arugments的行为和明确指明的params不同,不管参数申明没申明,最后都会出现在arguments里

var argumentsParams = function(a,b){
    console.log(a);
    console.log(b);
    for(i=0;i<arguments.length;++i){
        console.log("arguments params:"+arguments[i].toString());
    }
}
argumentsParams(1,2,3,4);
1
2
arguments params:1
arguments params:2
arguments params:3
arguments params:4

注意:正如js变量定义时不需要给出变量的类型,js的参数也不需要给出类型,同时这也意味着你可以输入任意类型的变量,以add函数为例,add(1,2)固然可以,add("helllo", "world")也没问题。

解释型语言的一大特点就是,不跑到出问题的点,他就装作没问题。

练习一下

请编写一个不定长参数的函数,返回输入的所有数字之和。

返回值

前面你可能到了,add函数通过return返回a和b的和,后面你又可以看到,很多函数都没有return。正如你看到的,函数可以有返回值,也可以没有。当没有显示给出函数的返回值时,相当于函数返回undefined。

没有返回值的函数相当于打包一组操作,有些语言会特别申明这种类型(例如VB),但C族语言通常不区分这两类。

函数可以返回任意类型,只要是能赋值的东西,就能被返回。甚至返回函数也是可以的:

var getFunc = function (){
    return function(name){
        console.log("Hello "+name)
    }
}

f = getFunc();
f("World");

getFunc是一个函数,这个函数返回一个函数(匿名的),返回的这个函数带一个参数,你可以调用被返回的这个函数。

和参数一样,你不需要显示申明函数的返回值。因为没到函数返回时,你根本不知道是什么类型。

另外如果你在一个函数里想直接返回(不带返回值),那么可以用return;在当前行直接返回,这也相当于return null;

要注意的是,在函数的任意位置都可以return,不管带值返回还是不带值返回,都可以写在函数的任意位置。在一些嵌套环境下,比如循环中,直接退出通常会大量降低代码的复杂度。

练习一下

编写一个函数,给定不大于81的数字,问是否可以拆成两个个位数之积,可以请打出,不可以请输出找不到。

系统函数

JavaScript的解释器提供了很多写好的功能,这些功能都以函数的形式提供。

比如你们用过的parseInt就是一个函数,之前讲过的ceilfloor也都是函数。

在Math模块下,还有很多函数:

函数说明
Math.min求最小值(变长)
Math.max求最大值(变长)
Math.sqrt求开平方
Math.pow求n次方

等等等等。

当你有任何想法的时候,先不要写,先找找,是不是已经有相关的工具提供给你了。

为什么需要函数

到现在为止,你应该能理解函数是什么了。简而言之,就是打包的一组操作,他可以接受输入(参数),根据输入产生输出(返回值)。当然也可以没有输入或输出,存粹就是固定的流程,不接受输入。或者干脆就是一组操作,没有输出。

那么为什么我们需要函数?代码不是直接写就完了吗? 至少有三个理由我们应该使用函数:

JavaScript必须用

JavaScript是以回调函数为核心的语言,因为JS跟页面逻辑深度绑定,GUI的逻辑就是触发式的,在实现逻辑上必然要求以回调函数为主。

你在JS里响应页面的所有操作,点击,拖拽,滚动等等,最后都要触发回调函数调用才能完成相关功能。

这跟C是很不同的,C语言你不用函数都能活一个学期,JavaScript里你活不了两节课。

降低重复度

有一些功能我们会很经常用。例如,你页面上有3、4个块在移动,他们遵循相同的逻辑。如果你为每个块都单独写一段移动的代码,显然是非常低效的。如果写成函数,将这些块作为参数传递给函数,那么你只需要写一份代码。

这其实引入编程领域的一大原则:不要复制代码。复制代码是非常低效的行为,不仅让代码变长,变得难以阅读,而且维护很难。Ctrl+C之前先想想,是不是可以提炼成函数来处理(不用怀疑,一定可以)。

模块化代码逻辑

经常有人一个函数一写就是几千行,这样的函数根本无法阅读。

正确的做法是保持函数的逻辑尽量简单。一眼就能看完。

如果某个函数的功能过分复杂,那么你应该把其中一部分功能写成函数(即便这些功能就在这里用),然后在母函数里调用写的子功能函数。

这对绝大多数代码都可以做到。

参考下业界的规范:

  • 多数公司对函数行数的要求是100~200以内。
  • 业界普遍认为的,好的函数复杂度在15以下,一般最好维持在7~10左右。
  • linux内核编码规范要求函数尽量在1屏或2屏的范围内(字符终端屏幕,一屏是80x24)

复杂度

度量函数复杂程度的参数,每增加一个ifforwhile等产生分支的语句,复杂度+1

合理提炼函数,提高设计水平,提升代码美感,是一个码农从菜鸟到入门的门槛之一。

作用域和变量定义

首先再次强调,JavaScript里你不需要给出变量类型,变量什么时候要用,什么时候直接申明就行。

全局变量

所谓全局变量,是指定义在全局空间的变量,这个变量无论在哪个空间都能访问。最简单的就是,所有函数,都能访问所有全局变量,而不需要额外定义。

最常见的是直接定义在所有函数的外面的变量:

let a = "global";
let printIt = function(){
    print(a); //可以输出,因为a是全局变量。
}

在printIt函数内部我们可以直接访问在外面定义的变量a,因为其是全局的。

迷惑的变量定义

在之前的代码里,我们一直都用let a这样的方式来申明变量。然而实际上JS支持非常简单的变量定义方式,就是直接定义:

a = "Hello World!";

这种方式其实也是很多脚本语言定义变量的方式。毕竟多一个let实际上并不必要。然而这种定义方式会带来非常迷惑的情况:

var defineIt = function(){
    a = "global"; //我们以为a定义在函数内部,只能给自己访问,其实并不是。
}
var printIt = function(){
    print(a); //依然可以访问,因为a是全局的。
}

在JS里,不管变量定义在哪里,哪个犄角旮旯的地方,只要直接写定义a=xxx,他就是全局可以访问的。这和其他语言完全不同。这是个历史遗留问题。

这也是为什么虽然我们可以用a=xxx来申明变量,但我们一直坚持使用let a = xxx的原因!

这一特点看起来非常便利,其实会带来极大的问题。

一直以来,JavaScript都有一个非常纠结的问题——我的定这个名字的变量名会不会把别人的覆盖了?

看下面的代码:

a = 1
var printIt = function(){
    console.log(a);
}

var changeIt = function(){
    a = 10; // 会覆盖全局变量a的定义
}

我们在所有函数外定义了一个变量a,这种变量称为全局变量。他可以被所有的函数所访问。因此printIt函数可以奏效。

如果又来一个人,写了changeIt,我们假定他不知道a已经被定义了,他写下了函数changeIt,里面修改了a的值。

对于changeIt的编写者,他可能完全不知道外面定义哪些变量,他申明a是完全可以理解的。但这个申明,就会修改全局变量a的值。

printIt(); //输出1
changeIt();
printIt(); //输出10

前后两次的输出是不同的,因为全局变量a的值,在changeIt中被修改了。这对于使用变量a的人来说是无妄之灾,但责备写changeIt的人也很不公平,因为这意味着他必须知道所有其他函数里定义或者使用的全局变量,并小心避开他。

这种作法在史前一个页面也就需要一个几百行的js程序的年代是可以的,但在目前这个动辄上万行上十万行代码的年代显然是不行的。

var变量定义和局部变量

针对这个问题,早年间JavaScript就引入了var关键字来声明变量:

var b = 1; //用var定义,全局变量b
var printB = function(){
    console.log(b);
}

var changeB = function(){
    var b = 10; //局部变量b,修改b不会修改全局变量b
    console.log(b);
}

这个var的用法就比较接近C语言的变量使用方式了。用var申明的变量,作用域遵循就近原则。 当函数内申明了变量,那么全局变量的定义就被覆盖,使全局变量在函数作用域内部不会生效。

以上面的代码为例,执行changeB时,因为我们申明var b=10;,因此解释器知道changeB函数的内部存在一个局部变量b。 这样当在函数内访问变量b时,就知道访问的是函数内部的b,而不会影响到全局范围的b

这种处理使函数的编写者无需再担心自己定义的变量是否会影响外部的变量,进而影响到其他函数的行为。大大降低了编码的耦合度。

::: tip延申阅读 事实上不加var的变量定义方式,并不是定义成全局变量这么简单,上面这么说只是方便理解。 具体可以看这里:https://www.cnblogs.com/saolv/p/11001223.html :::

var变量的特点

作用域提升

var变量虽然解决了全局变量覆盖的问题,但依然留下很多其他问题,来看这道题目,这题目以前很经常出现在各种js前端的面试题中:

var a = 100;
var func = function(){
    console.log(a);
    var a = 10;
    console.log(a);
}
f();

这道题会输出

undefined
10

这就比较反直觉。第一个console.log(a)执行时,局部变量a还没有被定义,按直觉他应该输出全局变量100才对,但并没有,他输出了undefined。这是因为,不管var变量申明在哪个地方,都相当于申明在函数的头部,var的作用域也贯穿整个函数。

所以函数func其实相当于:

var func=function(){
    var a;
    console.log(a);
    a = 10;
    console.log(a);
}

这被称为作用域提升。

函数级作用域

再看这段代码:

var func1 = function(){
    var x=1;
    if(true){
        var x =10;
    }
    console.log(x);
}

你可能认为,这是两个不同的变量x,因此这里应该输出1,事实上这里会输出10。 var变量的作用域是整个函数,不管处在函数的哪个块中,他都属于整个函数 因此这段代码相当于:

var func1 = function(){
    var x;
    x=1;
    if(true){
        x =10;
    }
    console.log(x);
}

这就容易理解了。

let变量

块级作用域

var变量全函数有效的特点,使其依然存在一些令人费解的行为,这种状态下,我们说var没有块级作用域,所谓块,可以理解成花括号包裹起来的范围。 var只受函数影响,不受函数内部花括号的影响。因此说他没有块级作用域。

针对这种情况,ES6引入了一种新的变量申明方式:let,实现了块级作用域。

看代码:

var func2 = function(){
    let x = 1; // 函数域局部变量x
    if(true){
        let x =10; // if块域局部变量x
    }
    console.log(x);
}

换用let申明,两个x因为所处块不同,所以是两个变量,不会相互干扰。 let变量进一步降低了耦合程度。使编码变得更方便。

注意事项

不能在定义前使用 当你在var定义之前使用变量,无非出现变量未定义。但如果你在let之前就试图使用变量,你会获得一个异常:

var func_var = function(){
    console.log(a); // 合法,因为作用域提升
    var a = 10;
    console.log(a);
}
var func_let = function(){
    console.log(a); // 不合法,在let之前使用
    let a = 10;
    console.log(a);
}

一个块只能申明一个同名let变量,不能重复申明

var func_2let = function(){
    let a = 10;
    console.log(a);
    let a = 20; // 不合法,重复申明同名变量
    console.log(a);
}

注意定义前使用只有在运行到该行时才会出错,但重复申明let在js加载之后就会报错,从而导致整个js都不执行。

暂时性死区 let较为严格的特点使其可能出现一种叫暂时性死区的问题。ES6规定,在块内一旦申明let,他的效力覆盖整个块:

let d = 100;
var func_deadzone = function(){
    console.log(d);
    let d = 200;
    console.log(d);
}

我们定义了一个全局变量d,同时在函数func_deadzone里又定义了一个d,第一个console.log(d);时,函数内的d还没有被let,你可能认为此时d是全局的那个。但事实上,由于let的效力覆盖整个块,因此解释器会认为第一个d也应该是函数内的局部d。又由于使用先于定义,所以这里会触发一个错误。从块开始到let变量定义的部分称为暂时性死区,意指这个区域内变量不可用,即便他有全局变量或者更高作用域的同名变量也不行。

const变量

ES6引入的另一种变量类型是const变量,弥补了javascript一直以来的缺陷。const变量是指该变量的值一经定义就不能修改,这在一些常量定义上非常有用,可以避免该变量被随意修改:

var func_const = function(){
    const a = 10;
    a = 20; //非法,const变量不能修改
}

如果你对C语言的宏有所了解,那么const变量就是javascript里宏的替代品。

总结一下

几类变量的特点

变量种类变量类型特点
a=1只能是全局可访问命名冲突问题,各种相互干扰。
var a=1可以是全局和局部变量,作用域为函数没有块级作用域,有作用域提升问题。
let a=1可以是全局和局部变量,以及块内变量块级作用域,更严格,不能重复定义,
不能在定义前使用,有暂时性死区。
const a=1常量,作用域和let相同常量,值不能修改

我们应该如何选择

通常来说,a=1这种变量副作用太大,遇到就要焚烧、挖坑、填埋并做上标记方圆10km里不要有活人。 var a=1,可以用,且由于其奇葩的特性经常出现在考题里。一般来说,用于兼容旧版本浏览器,或者既有代码不修改继续使用。 较新的版本下,应该尽量使用letconst

进阶

参数的默认值

参数可以设置默认值:

let sayHello = function( name, word="Hello World!"){
    console.log( name+" say:"+word );
}

sayHello("tom"); // tom say: Hello World!

这样用户可以不给出word的值时,解释器就会将默认的参数填入。

这里要注意可变参数和默认值的不同,可变参数的参数数目可以有任意个,默认参数则是固定个。

不过他们有一点是相同的——定义部分都必须放在所有正常参数之后:

let sayHello = function( name, word="Hello World!"){ // 合法
}

let sayHello = function( word="Hello World!", name ){ // 不合法
}

这也是很容易理解的,如果这样写,当你写一个参数时,解释器就不知道你是要给word还是name

参数赋值

另一个比C语言更灵活的语法是你可以直接指定对应参数的值:

let func = function( a, b ){
    console.log(a,"",b);
}

正常调用是这么调用:

func(1,2); // a = 1  b = 2

但你可以直接指定参数的值:

func( b=2, a=1); // 可以,此时b=2,a=1

这可以为复杂参数函数的编写带来很大的便利,你可以这样写:

function complex_params( a=1, b=2, c=3, d=4, e=5 ){ // 有很多默认参数
}

complex_params(c=6);  // 为c指定参数,其他参数默认。

列表的展开

JS支持一种称为列表展开的作法:

let a = [1,2];
let b = [ 3, ...a , 4 ];  //注意中间的...a
console.log(b); // [3,1,2,4],有四个元素,包括a里的两个

 

这里第二行的...a的意思是:a的内容(也就是1和2)拿出来,挨个放到这里

注意跟列表元素成员的情况做对比:

let a = [1,2];
let b = [3,a,4];
console.log(b); // [3, [1,2], 4] 有三个元素,其中第二个元素是一个列表

展开不仅仅可以用于列表,也可以应用于对象,这里不再赘述了。

那为什么要在函数里讲这点呢?因为函数参数也支持列表展开:

let params = [1,2];
let func = function( a, b ){
    console.log(a,"",b);
}
func(...params);  // 把params展开后作为func的参数。

这种情况下a=1b=2

更详细的内容看这里open in new window

闭包

先看下面的代码:

function gen_closure(){
    let a = 0;
    return function(){
        a++;
        console.log(a);
    }
}

初学者对这样的函数可能会比较懵。注意这里有两个函数:gen_closure和它内部定义的一个匿名函数,其中匿名函数被作为返回值返回。

其次我们在子函数(匿名函数)内部访问了父函数(gen_closure)的局部变量。这是可以的!

返回的这个匿名函数我们可以使用:

let f1 = gen_closure(); // 此时`a`的值被确定并被“封闭”到f1里了。
f1();  // 1
f2();  // 2

你会发现每次调用这个函数,输出的值都会增加。因为当函数返回,也就是生成了一个函数并赋值给f1时,你可以认为a也同步产生了一份

此时a这个变量相当于被封闭进了f1里,随着f1的调用,它会被不断更新。

思考一下

两个闭包函数对象之间的a是什么关系?

let f1 = gen_closure();
let f2 = gen_closure();
f1();
f1();
f1();
f2(); // 此时输出什么?

先做出你的判断,然后在浏览器上测试一下你的想法,你的想法有道理吗?

闭包是JS这类脚本语言里比较常见的功能。局部变量的封闭性有助于在保存状态的同时减少全局变量的使用。

值与引用

我们都知道C语言里传参有所谓的值传递和引用传递的区别。

void inc_value( int n ){
    n++;
}

void inc_ref( int* p){
    *p++;
}

int i = 0;
inc_value(i); // i不会被修改
inc_ref(&i); //  i会被修改

我个人不太愿意采用这种区分方式。因为所谓引用传递跟值没什么不同,只不过传递的值是一个指针罢了。强行区分反而容易造成混淆。

从上面的代码我们可以看出所谓引用传递只不过是使用指针来进行参数传递,这带来两个优点:

  1. 修改指针指向的空间,会导致空间内容发生变化,这样就打破了函数的“次元壁”。
  2. 指针所占空间只有4个字节(64位是8个字节),如果参数是非常复杂的结构体,指针传参的开销远小于值传参。

因此实际上除了基本类型,比如intfloat之类的参数,稍微复杂的参数都应当尽量采用引用传递。

如果你看成熟的的API,也是采用这一原则的,即便函数内部并不准备修改某个结构体变量,依然会以指针的形式传递给函数。

虽然值传递和引用传递在C语言上可能造成混淆,但对于JS则是一个不得不讨论的问题:众所周知JS没有指针,那么是不是JS函数调用都采用值传递呢?

并不是,看下面的代码:

let a = [1,2,3,4]
let b = a
b[0] = 100
console.log(a) // [100,2,3,4]

按我们在C语言里的理解,ba是两个不同的变量,修改b不应该导致a的值发生变化,但实际上并非如此。

因此这里要这样理解:内存里有一个列表[1,2,3,4]。第一行将变量a指向这个列表,第二行又将b指向这个列表。

所以这里变量(引用)ab指向内存里的同一个对象。也就是说实际上ab都是引用(指针),而不是值。

指针类似物如何工作
指针类似物如何工作

引用是什么?

你可以把引用看成一个隐含的指针。

也就是说,虽然我不是以指针形式定义的,但我就是指针。

这就带来一个问题,就是既然变量定义都一个样,我怎么知道某个变量是引用还是值呢?

let s = "Hello World!";
let i = 10;
let o = { a:1, b:2 };

以上三者哪些是引用,哪些是值呢?

回答是:靠约定(规定)。

JS和大多数脚本语言都使用一种非常自然的约定:

  1. 变量类型分为基础(简单)类型和复合(复杂)类型
  2. 基础类型使用值传递,而复合类型则一律使用引用传递。

对于JS来说这个划分比较简单:列表和对象是复合类型,用引用传递;数字、字符串、布尔等类型都是基础类型,使用值传递。 这个划分也是非常符合直觉的。只有一点需要稍加注意,字符串是基础类型,这点其实大多数脚本语言也采用相同的处理方式。

思考一下

a = [1,2,3,4]
b = a
b = [5,6,7,8]
console.log(a)

你认为结果应该是什么?试一下,跟你想象的是不是符合?为什么?