JavaScript 从看懂到看开

 

  文章篇幅较长,知识点涵盖比较广泛,作为学习 JS 的一个总结。文章中仅涵盖 ES5 及之前的传统的知识点,未涵盖 ES6 及之后的新特性。

  JavaScript(简称“JS”) 是一种具有函数优先的轻量级,解释型或即时编译型的编程语言。虽然它是作为开发Web页面的脚本语言而出名的,但是它也被用到了很多非浏览器环境中,JavaScript 基于原型编程、多范式的动态脚本语言,并且支持面向对象、命令式和声明式(如函数式编程)风格。

  主要内容有:值传递与引用传递、深拷贝与浅拷贝、弱类型定义语言的特性、JS的“类”、JS中的this、call/apply/bind方法、原型链中的__proto__、原型链中的prototype、原型链中constructor、原型链总结、JS面向对象、JS实现继承的几种方式、 JS 中的函数式编程、函数式编程思维、函数式编程的理论基础、函数的副作用、引用透明、函数的柯里化、函数式编程样例-JS实现邱奇数

值传递与引用传递

  JS 是弱类型语言,相比于强类型语言,其对函数式编程的支持更加丰富。对于 JS 来说,数据类型也分为基本数据类型和引用数据类型,基本数据类型包括:Number、String、Boolean、Null、 Undefined、Symbol(ES6),除上述六种类型外,其余类型均为引用类型,也就是我们所说的“类”。基本类型的传值为值传递,而引用类型的传值为引用传递,比如值传递的例子:

   引用传递的例子:

 

  但是引用传递时,传递的是引用的值而不是变量的引用。比如:

   传递给 setA 的是变量 a 指向的new Object() 的地址,而不是 a 的地址。所以,函数内部对 形参b(传入的是a) 的指向的改变并不会影响变量 a 的指向的改变。在这个例子中,执行 setA 后 a 的指向依然是new Object() 而不是 new Array() !

深拷贝与浅拷贝

  说到值传递与引用传递,就不得不再说一下深拷贝与浅拷贝。对于一个对象来说,拷贝便是复制该对象来创建一个与该对象一模一样的副本。对象的成员也有基本数据类型成员及引用数据类型成员,在拷贝时针对引用数据类型成员,如果是仅将引用拷贝了一份则为浅拷贝,否则为深拷贝,比如一个浅拷贝的例子:

   可以看到,对于 r 中的 sons 只是拷贝了一份 p 中 sons 的引用, r 与 p 共用同一个 sons 对象,r 对其的修改也会影响到 p。很多情况下我们希望 r 与 p 是完全独立的两个对象,那么对于 p 中的引用类型我们便需要也创建一份副本传递给 r 而不是仅仅给 r 传递一个引用。深拷贝我们可以使用递归实现:

   深拷贝、浅拷贝代码如下:

<!--浅拷贝-->
function
copy(p){ var result={}; for(i in p){ result[i]=p[i]; } return result; }
<!--深拷贝-->
function deepCopy(p,c){
    if(p==null||p==undefined){return p;}
    for(i in p){
        if(typeof p[i]==='object'){
            c[i]=(p[i].constructor===Array)?[]:{};
            deepCopy(p[i],c[i]);
        }else{
            c[i]=p[i];
        }
    }
    return c;
}

var p={
    name:p,
    sons:{
        son1:'1',
        son2:'2'
    }
}

var r=deepCopy(p,{});
r.name='r';
r.sons.son1='rSon1';
console.log('p.name  :'+p.name);
console.log('r.anme  :'+r.name);
console.log('p.son.son1  :'+p.sons.son1);
console.log('r.son.son1  :'+r.sons.son1);
弱类型定义语言的特性
  JS 是一个弱类型定义语言,弱类型定义语言即为数据类型可以被忽略的语言。它与强类型定义语言相反, 一个变量可以赋不同数据类型的值。比如我们可以将字符串 '12' 和整数 3 进行连接得到字符串 '123',
然后可以把它看成整数 123,而不需要显示转换:

   刚接触弱类型这个名词可能会与强类型、动态类型、静态类型的关系搞不清楚。弱类型/强类型,动态类型/静态类型是两个角度的分类方式。弱类型/强类型强调的是类型是否安全、而静态/动态强调的是在编译期还是运行期进行类型检查。定义如下图所示: 

  JS中我们操作的是“变量”,但我们不关心变量属于什么类型,只要变量有我们操作变量是需要的行为即可。这便是 Has-a 与 Is-a 的问题,理解该问题对于理解JS的类型至关重要。  

  实际上JS对变量类型的宽容给编码带来了很大的灵活性,由于无需进行类型检测,开发者可以尝试调用任意对象的任意方法,而无须去考虑它原本是否被设计为拥有该方法。这一切都建立在鸭子类型(duck typing)的概念上。鸭子类型的通俗说法是:“如果它走起路来像鸭子,叫起来也是鸭子,那么它就是鸭子。”

  比如如果我们要的只是鸭子的叫声,这个声音的主人到底是一个鸡还是要鸭子并不重要。鸭子类型指导我们只关注对象的行为,而不关注对象本身,也就是关注HAS-A(拥有什么),而不是IS-A(是什么)。

  比如我们定义一个Duck类型和一个Dog类型:

function Duck(){
    this.name='duck';
}

function Dog(){
    this.name='dog';
}

Duck.prototype={
    say:function(){
        console.log('i am a '+this.name);
    }
}

Dog.prototype=Duck.prototype;

  我们创建一个 ducks 数组让 duck 们依次 say:

var ducks=[];
ducks[0]=new Duck();
ducks[1]=new Dog();
for(i in ducks){
    ducks[i].say();
}

  因为 Dog 类型也拥有 say() 方法,所以也可以无障碍的加入鸭子数组中。我们的处理代码并不在乎数组里是 duck 还是 dog,只在乎数组中的对象能不能 say()。

  在动态类型语言的面向对象设计中。鸭子类型的概念至关重要。利用鸭子类型的思想,我们不必借助超类型的帮助,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程”。无法面向接口,很难想象我们如何使用依赖倒置原则来设计健壮可重用的代码。

JS的“类”

   除了上面说的面向接口编程,面向对象编程也是 JS 的一项重要特性。

  JS 中是没有真正意义上 “类” 的概念的。我们定义一个类往往是通过定义一个构造函数来声明类的结构。而且在将对象 new 出来后,我们可以为对象新增任意属性,对象的结构并不受限于构造函数中声明的结构。例如我们可以在new一个对象后为该对象合法的添加构造函数中不存在的属性(这里是 run 属性):

   new字是一个语法糖,一个来自 JS 之父的关怀,我们可以想象,不使用new,我们依然可以创建一个对象:

  多写了几行代码。new关键字的内在运作机制也是这样的,私下里帮我们写了这些多出来的代码。 (其中的 call 方法会在下面 this 中说)

  new私下帮我们做了三件事:

  创建一个新对象:var car={};  

  将 this 指向新对象:this=car;(伪代码,这样写是不对的)

  执行 this.xxx=xxx,也就是在 car 中新增构造函数中的属性。

  这样便完成了一个对象的初始化,对比看看,JAVA 初始化对象是如何做的:

  在堆中开辟一片新的空间 -->  设置对象头(指向对象所属的类的指针、GC分代信息等等)--> 在该内存中设置成员变量,并设置初始化值(比如 int 是0,引用类型是null等)--> 执行构造函数,为成员变量赋值。

  对象创建的过程大同小异,核心都是 开辟空间-->设置成员 -->创建完成返回指向该对象的指针。

  只是 JS 中没有 “类” 的概念,我们用构造方法代劳实现模板模式。

JS中的this

  看了上面 new 关键字的运行机制,this指向的改变在创建新对象时至关重要。对 this 关键字的理解对于 JS 的学习也至关重要,下面我们看一下 this 到底指向哪里。

  当一个函数没有明确的调用对象的时候, 也就是单纯作为独立函数调用的时候, 将对函数的this使用默认绑定: 绑定到全局的window对象。比如:

   或者:

   所以在全局函数内定义的临时变量并不能通过 this 获取:

   而将一个方法声明在一个对象内(非函数对象)时,this 被显式绑定到该对象上:

  但要注意一种特殊情况,对一个在对象内的函数中声明的函数,其 this 指向 window 。比如:

  因为按理说,其应该指向包含它的对象 fun ,但 fun 是一个函数对象,this 不能指向函数对象,所以指向了window。

  如果一个对象被作为另一个对象的属性,不影响 this 的指向:

   this 的指向简单的说便是指向了调用该方法的对象(非函数对象,若函数包含函数则不再向上寻找调用对象,直接绑定window)。
  那么我们考虑一种情况,如果用外部引用调用对象中的方法,this 指向对象还是外部引用,如下:

  可以看到,this 指向了外部引用所属的对象 outter。这说明this的指向不是由定义this决定的, 而是随脚本解析自动赋值的。所以分以下几种情况:

  1. 在全局作用域中,this指向window。

  2. 在函数作用域中,this指向调用该函数的对象(非函数对象)。其实全局作用域中便是在window中被调用,指向window合情合理。

  3. 对象中的方法中的 this 指向该方法所属的对象。

  4. 构造函数中指向新对象。如上一节所说,new 时改变了构造函数中this的指向。

  5. 在异步操作中指向window,比如定时器函数中:

   6.在事件绑定时,指向绑定事件的元素。

   7. lamda表达式中this直接指向window而不是包含它的对象:

    a. 箭头函数的this是在定义函数时绑定的, 不是在执行过程中绑定的

    b. 箭头函数中的this始终指向父级对象

    c. 所有 call() / apply() / bind() 方法对于箭头函数来说只是传入参数, 对它的 this 毫无影响。

  向要改变 this 的指向,JS提供了三个方法。

call、apply、 bind方法

  每个函数对象都拥有上述三个方法,可以用来改变 this 的指向。

  call 函数可以将函数中 this 指向改为传入的对象,比如:

  原本 this 指向 window ,call(aaa)时执行 fun 函数并将函数中的 this 指向了aaa。

  对于有入参的函数,在对象后依次放入参数即可:

  apply() 与call() 非常相似, 不同之处在于提供参数的方式, apply() 使用参数数组, 而不是参数列表:

   bind() 创建的是一个新的函数( 称为绑定函数), 与被调用函数有相同的函数体, 当目标函数被调用时this的值绑定到 bind() 的第一个参数上:

   上述三个函数每个方法对象都可以使用,它们属于方法对象原型链顶端的 Function 的原型对象:

 原型链中的__proto__

  上面我们说了 JS 中的类的定义及创建方式,下面我们来看一下 JS 中实现类的关联的原型链。

  在 JS 中,我们创建的对象都会包含一个默认属性:--proto--。该属性并非继承而来,而是对象天生自带的,比如我们创建一个空的对象a:

  可以看到,即使 a 是一个空的对象,依然会被自动赋予一个属性 __proto__,指向了Object(函数对象指向 f())。--proto--属性便是对象的“原型”。

  每个对象维护着一个--proto--指针,指向了对象的原型。依靠该指针,我们可以实现对象间的继承关系。

  与 Java 中的继承类似,在 Java 中每个对象的对象头中维护着一个类型指针,当我们调用的方法在对象所属的类中不存在时,就会去对象的父类中寻找。

  而在 JS 中,继承是对象与对象之间的关系,而不是类与类。

  也就是说,在 JS 中,不仅方法会沿着继承链向上寻找,属性也会沿着继承链向上寻找,这与 Java 是不同的。因为 Java 的类型支持更加完备,对于属性,无论是父类的属性还是子类的属性,在创建对象时都会写入子类的对象中(private等私有属性除外),并且在创建子类对象时会同时执行父类的构造函数与子类的构造函数将父类的属性及子类的属性进行初始化。

  在 Java 中,属性是对象创建时便写在对象的结构中的,只有函数需要沿着继承链去寻找确认。在 Java 中,继承链是类与类之间连接起来的,属性的继承是在对象创建时完成的。

  而在 JS 中,原型链是由对象指向对象的,无论对象的属性还是方法,都需要我们沿着对象的原型链去寻找。当一个属性或方法子类没有时,便会沿着__proto__指针向上寻找,找到便直接使用父对象中对应的属性或方法,若找不到便继续沿着父类的__proto__指针向上寻找,知道在基类中还找不到,便会报undefined错误。

  所以,子类使用父类的属性时,其实是和父类共用了同一个属性,而不是在子类中真正继承了一个属于子类的属性。

  在改变子类的属性时,如果属性时一个基本数据类型,则为子类新增该属性,不影响父类中的属性,比如:

  我们改变 son 中的 name,并没有影响 father中的 name,而是为 son 新增了一个 name 属性。

  引用类型也一样:

  需要注意的是,既然当子类使用父类的属性时是子类与父类共用同一个属性,那么父类的属性修改时将会影响所有的子类:

   可以看到,修改 father 的 name 时,son1 与 son2 的 name 都将修改。因为 son1 与 son2 都没有 name 属性,它们都在与 father 共用一个 name 属性。

原型链中的prototype

  在上一节我们看到,一个对象(非函数对象)在创建时会被自动添加一个__proto__属性,但并没有 prototype 属性。

  prototype 属性是属于函数对象的,而非普通对象,我们在创建一个函数对象时,函数对象会自带一个 prototype 属性,这个属性指向了该函数的“原型对象”。需要注意的是“原型对象”与上一节中说的“原型”是两个概念。

   prototype 属性仅在函数在作为构造使用时才会发生作用,用于为构造出的对象添加“原型”,也就是添加父对象。当一个函数被作为构造函数使用时,new 出来的对象的__proto__指针将会指向该函数的 prototype 指针指向的对象。比如:

   可以看到,对构造函数 Car 添加 prototype 可以达到为 car 对象添加 __proto__的效果,但这样做显然比每次 new 出 car 来都为其添加__proto__简洁的多。

原型链中constructor

  每个原型都会有一个 constructor 属性,指向了它的构造函数。例:

   当我们在使用别人实例化好的对象时,如果我们想要为类添加属性,可以通过为对象的构造函数的 prototype 增加属性来实现。此时我们可以借助 instance.constructor.prototype

来拿到构造函数,进而拿到 prototype 对象。

  有一点值得注意的是,当我们修改一个对象的原型时,需要手动的修改原型的 constructor 属性,否则其指向还是原型的构造函数。比如:

function Father(){}
function Son(){}
var father=new Father();
console.log(father.constructor===Son);
Son.prototype=father;
console.log(father.constructor===Son);
console.log(Son.prototype.constructor===Son);

   我们如果不手动修改构造函数的 prototype ,prototype 是一个自动生成的对象,其 constructorconstructor是指向构造函数的:

  所以当我们手动修改构造函数的原型对象时,需要一并修改对象函数的 constructor 指向,使其指向该函数。防止使用 constructor 的场景出现错误。

  比如对于:

function Person(name,age){
    this.name=name||'i am a person with no name';
    this.age=age||'i do not want to tell you my age';
}
var person=new Person();
function Student(leaningName){
    this.learn='i am learning'+leaningName||'Math';
}
Student.prototype=person;
var student=new Student();

  我们应该重写原型对象 person 的 constructor ,我们可以这样:

function Person(name,age){
    this.name=name||'i am a person with no name';
    this.age=age||'i do not want to tell you my age';
}
var person=new Person();
function Student(leaningName){
    this.learn='i am learning'+leaningName||'Math';
}
Student.prototype=person;
Student.prototype.constructor=Student;
var student=new Student();

  也可以这样:

function Person(name,age){
    this.name=name||'i am a person with no name';
    this.age=age||'i do not want to tell you my age';
}
var person=new Person();
function Student(leaningName){
    this.__proto__.constructor=Student;
    this.learn='i am learning'+leaningName||'Math';
}
Student.prototype=person;
var student=new Student();
console.log(student.__proto__.constructor===Student);

  不过第二种写法明显没有第一种合理,因为每次创建对象,都需要为原型对象设置一次 constructor 。

原型链总结

  每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那么假如我们让原型对象等于另一个类型的实例,结果会怎样?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立。如此层层递进,就构成了实例与原型的链条。这就是所谓的原型链的基本概念。

  狭义的说,沿着对象的__proto__指针构成的链表便是原型链。最终整个原型链的关系如下:

JS面向对象

  JS 与 JAVA 在继承上本质的不同是,JAVA 的继承是类之间的继承;而 JS 之间的继承是对象之间的继承。

  因为 JAVA 有完备的类型支持,我们声名类和类与类之间的关系,并在此结构下创建对象。属性在创建对象时绑定到对象中,而函数顺着继承链向上查找。

  而 JS 的继承是在对象之间通过__proto__指针拉起原型链,对象共用原型链中祖先对象的属性。属性与函数均顺着继承链向上查找。

  因为一直在做Java,在很长一段时间内对 JS 的继承模式都觉着别扭。没有类和实例的概念,实例间通过指针连接起来共用祖先属性怎么想都不如 Java 中以类为模板并定义类之间的关系,对象只是特定场景下类的实例来的合理。知道看过阮一峰老师的解读后才恍然大悟,了解一个特性的由来还是需要了解其产生的目的和特定的历史背景,毕竟软件不一定一直都是在追求最合理,厚此薄彼、让步妥协的设计也极为广泛的存在着,甚至说为了什么设计而牺牲什么设计在每个软件工程中都是必然发生的。

  而对于 JS ,这种让步就体现在,面向对象对语言简洁性的让步。

  因为最初的 JS 被设计为实现用户与浏览器交互的简单语言,借此将服务器端的一部分压力分担到前端来,所以 JS 仅需要支持一些简单的功能即可。

  但是在 JS 诞生的时期,面向对象的概念正如日中天,开发者们希望 JS 也拥有面向对象的特性。但是如果像其它语言一样实现类和实例的概念的话将大大提升 JS 的复杂性和学习成本。

  JS 之父参照了其它语言(比如C++,Java)中创建对象的方式,关键字 new + 构造函数。将类的结构声明在构造函数中,借此模拟类的概念,实现面向对象。

  比如:

   这样一来便实现了面向对象的概念,但是还存在一个问题,就向如上例子中,color 是每个实例特有的属性,各个实例间是不一样的。但是 name 在各个实例间是一样的,都是 car 。那么用如上方式创建的 car1 和 car2 中都存储着一份相同的 name ,这样及其浪费空间,而且也不符合面向对象中继承的特性,向上抽取。

  于是便有了我们上面说的原型链。将每个实例公共的属性抽取到原型中实现,并以此实现继承的概念:

   这样一来,内存中只有原型中有一个name,以此为原型的对象共用它。换句话说,借此我们可以将对象间公共的属性抽取到原型中来,实现继承的概念。

JS实现继承的几种方式

  上面介绍了类、原型链以及 JS 的面向对象,下面我们看一下在 JS 中如何实现继承。

  原型继承  

  简单的说将便是为一个对象指定一个原型,通过该固定的原型构建原型链。

function Father(name,age){
    this.name=name;
    this.age=age;
}
function Son(){}
Son.prototype=new Father('myfather','150');
var son=new Son();

  这种方式有一个坏处便是无法给父类构造函数传参,因为原型链上是一个固定的实例化对象。就比如上面这个例子,所有 Son 的父对象都是 new Father('myfather','150'),而不能实现每个Son对象的父类名字和年龄不一样,在实际生产中这种方式我们基本不用。

  调用父类构造函数

  为了解决上面的问题,在子类中调用父类的构造函数,即通过apply或者call改变父类构造函数中的 this 指向子类对象,为子类对象增加父类构造函数中定义的属性。

  这样每个子类中父类的属性都是与该子类绑定的,是每个子类特有的。该方法与原型链没有关系,子类的原型并不是父类,只是通过构造函数有了父类的属性。

function Father(name,age){
    this.name=name;
    this.age=age;
}
Father.prototype.company='inspur';
function Son(name,age){
    this.flag='from son';
    Father.apply(this,[name,age]);
}
var son=new Son('son1','18');
console.dir(son);
console.log(son.company);

  可以看到,子类中自带父类的 age 和 name,不需要去原型链向上寻找。但是子类的 __proto__是 Object 而不是 Father ,那么弊端也显而易见,子类单纯的继承了父类,但没有与父类在一条继承链上。也就是说父类的祖先对象中的属性子类无法使用。

  组合继承

  为了解决子类与父类不在一条原型链的问题,出现了组合继承的方式。即我们结合 原型继承 和 调用父类构造函数继承 两种方式,既在子类构造函数中调用父类构造函数,又为子类添加一个父类原型使其构成一条完整的原型链。

function Father(name,age){
    this.name=name||'default name';
    this.age=age;
}
Father.prototype.company='inspur';
function Son(name,age){
    this.flag='from son';
    Father.apply(this,[name,age]);
}
Son .prototype=new Father();
var son=new Son('son1','18');
console.dir(son);
console.log(son.company);

   寄生组合继承

  ES5中最常用的方式,名字高大上,但跟 组合继承 差不多。只是组合继承中子类的__proto__指向父类对象,但寄生组合继承中子类的__proto__指向父类对象的原型。也就是说,寄生组合继承中子类对象拥有独立的父类构造方法中的属性,在原型链上与父类对象平级公用一个父类对象作为原型。

  为了下面理解顺畅,我们先看一个方法 Object.create(oto, propertiesObject)。create方法用于创建一个新对象,并将新对象的__proto__指向传入参数 oto,propertiesObject是可选参数,如果传入则将这些属性添加到新生成的对象中,比如:

function Father(){
    this.name='myFather';
}
var father=new Father();
var son=Object.create(father,{
    age: {
        value:"18",
        enumerable: true
      },
    say:{
        value:function(){
        console.log('i am son');
        },
        enumerable: true
      }
});
console.log(son.__proto__===father);
console.log(son.name);
console.dir(son);
son.say();

  

  可以看到,age 与 say 是son 对象自带的属性,而 son 对象的原型为 father,可以使用 father 的属性。而传进去的 propertiesObject 写法略显怪异,以为其牵扯到了数据属性与访问器属性,这个我们后面再说。

   这样创建对象可以更加简洁的为对象指定__proto__,其与 new 的不同在于 new 出的对象的__proto__指向了构造函数的prototype,而该方法的__proto__指向传入的第一个参数,该参数是可以为 null 的:

   看完 Object.create 方法,我们再来看寄生组合继承,寄生组合继承与组合继承的不同是,寄生组合继承通过 Object.create 方法创建子类对象并将子类对象的__proto__指向父类的__proto__:

function Father(){
    this.name='father';
}
function Son(){
    Father.call(this);
}
Son.prototype=Object.create(Father.prototype);
var son=new Son();
console.dir(son);

   注意上述代码中的:

function Father(){
    this.name='father';
}
function Son(){
    Father.call(this);
}
Son.prototype=Object.create(Father.prototype);
var son=new Son();
console.dir(son);

  可以看到虽然子类是有构造函数的,但其只是调用了父类的构造函数,产生的对象的 constructor 属性指向了父类的构造函数:

   所以我们需要修改子类对象的 constructor 属性和构造函数原型对象的 constructor 属性。

 JS 中的函数式编程

  前面提到,JS 最初被设计为一个用来与浏览器交互的简单语言,为了更加的简单与易用,JS 在对类型的支持上做了很多妥协。所以在对面向对象的支持上,JS 不如传统的面向对象语言(如C++、JAVA)完善。

  但也正因为 JS 的简单,其对函数式编程的支持强于大部分传统的面向对象语言。JS 的弱定义类型及函数优先的特性使其尤其适合编写声明式(如函数式)编程的代码。下面我们来了解一下函数式编程的相关知识。

函数式编程思维

  函数式编程是范畴论的数学分支是一门很复杂的数学,认为世界上所有概念体系都可以抽象出一个个范畴。
  彼此之间存在某种关系概念、事物、对象等等,都构成范畴。任何事物只要找出他们之间的关系,就能定义。
  箭头表示范畴成员之间的关系,正式的名称叫做"态射"(morphism)。范畴论认为,同一个范畴的所有成员,就是不同状态的"变形"(transformation)。通过"态射",一个成员可以变形成另一个成员。

函数式编程的理论基础

  函数式编程来源于 λ (Lambda x=>x*2)演算,而 λ 演算并非设计于在计算机上执行,它是在 20 世纪三十年代引入的一套用于研究函数定义、函数应用和递归的形式系统。

  不能将函数式编程与面向过程编程的概念混为一谈,函数式编程的主旨在于将复杂的函数符合成简单的函数(计算理论,或者递归论,或者拉姆达演算)。运算过程尽量写成一系列嵌套的函数调用。

  在函数式编程中,函数是“一等公民”。所谓的“一等公民”是指函数与其它数据类型一样,有同等的地位。函数既可以传入参数进行运算,也可以作为参数传入其它函数,或者作为其它函数的返回值。

  不可改变量。在函数式编程中,我们通常理解的变量在函数式编程中也被函数代替了:在函数式编程中变量仅仅代表某个表达式。这里所说的’变量’是不能被修改的。所有的变量只能被赋一次初值。

  简单的说便是:函数是”第一等公民”;只用”表达式",不用"语句";没有”副作用";不修改状态;引用透明(函数运行只靠参数)。

函数的副作用

  所谓函数副作用是指,当调用函数时,被调用函数除了返回函数值之外,还对主调用函数产生附加的影响。例如,调用函数时在被调用函数内部:
  修改全局量的值;(比如 I/O 操作)
  修改主调用函数中声明的变量的值。
  简单来说,一个函数除了完成了其本身的运算,还对外部环境产生了影响。对于一个函数是否符合函数式编程的要求,我们判断的边界便在于函数是否存在副作用。
  但是一个完整的程序必然需要各个模块之间的协作,编写一个全部函数都是无副作用的纯函数的工程是不可想象的。通常,我们可以将造成副作用的操作放到外层函数(外壳),保证大部分函数(内核)是无副作用的,通过这层外壳统一的对内核函数可能产生的副作用进行处理。
  比如我们模拟一下买车:
function buyCar(money){
    this.money+=money;
    this.carNum-=1;
}
var carFactory={
    money:0,
    carNum:100,
    buy:buyCar
};
carFactory.buy(20000);
console.dir(carFactory);

   其中 buyCar 方法改变了 carFactory 对象中的 carNum 与 money 的值,因此是有副作用的。我们对其进行一下改造:

function buyCar(money){
    return {
        money:money,
        num:1
    }
}
var carFactory={
    money:0,
    carNum:100,
    buy:buyCar,
    handler:function(f){
        this.carNum-=f.num;
        this.money+=f.money;
    }
};
carFactory.handler(buyCar(20000));
console.dir(carFactory);

  经过改造后 buyCar 函数不再对周围环境产生副作用,而副作用转嫁到了 handler 函数。我们使用 handler 函数统一处理其它函数可能产生的副作用,作为其它函数统一的“壳”。这样可以使其它函数符合函数式编程的要求,进行相关的演算及处理。

  通过一层薄薄的外壳将内核函数的副作用转移出来是目前处理函数副作用的主流方式。

引用透明
  假如存在一个函数f,若表达式f(x)对所有引用透明的表达式x也是引用透明,那么这个f是一个纯函数(也就是说,传入引用透明的x表达式给f,函数f(x)的返回值可以代替这个函数在其他程序起的作用)。那么我们可以知道,一个有着副作用的函数不是引用透明的,因为其代表的不仅仅是返回值,还有其对周围环境产生的影响。

函数的柯里化

  柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。

  我们在 λ 演算中经常会用到柯里化,比如:

  λx.λy. x+y

  在实际生产中,柯里化往往表现为传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

  比如:

function(x,y){
    return x+y;
}

  我们可以柯里化为:

function addX(y){
    return function(x){
        return x+y;
    }
}

  两者效果是一样的:

  事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。同时这也是闭包的一种体现,内部函数的定义依赖于外部变量,外部函数的不同入参下会得到不同的内部函数。闭包可以看上一篇博客:从 λ 演算看 JS 与 JAVA8 闭包

函数式编程样例-JS实现邱奇数 

  前面讲述了函数式编程部分最基础的知识点,下面我们来看一下 JS 如何实现邱奇数,从而更加直观的理解 JS 如何实现函数式编程:

//邱奇数0
var zero=function(f){
    return function(x){
        return x;
    }
}
//邱奇数加一, SUCC=λn.λf.λx.f(nfx)
var succ=function(n){
    return function(f){
        return function(x){
            return f(n(f)(x));
        }
    }
}
//虚拟目标函数 f
function f(x){
    return x+1;
}
//邱奇数1
var one=succ(zero);
//邱奇数2
var two=succ(one);
//邱奇数3
var three=succ(two);
//邱奇数加法  PLUS = λm.λn.λf.λx.mf(nfx)
var plus=function(m,n){
    return function(f){
        return function(x){
            return m(f)(n(f)(x));
        }
    }
}
//邱奇数6
var six=plus(three,three);
//邱奇数乘法  MULT = λm.λn.m(PLUS n)
var mult=function(m,n){
    return function(f){
        return function(x){
            return m(n(f))(x);
        }
    }
}
//邱奇数8
var eight=mult(two,plus(one,three));
console.log(six(f)(0));
console.log(eight(f)(0));

  可以看到,JS 对函数式编程的支持十分友好,我们几乎可以将原生的 λ 表达式转化为 JS 函数。上述样例中 邱奇数6 用加法实现,邱奇数8 用乘法与加法混合实现,看下效果:

  再多提一句,邱奇数的含义是入参 f 对入参 x 作用的次数。

  加法  PLUS = λm.λn.λf.λx.mf(nfx)  返回的是一个 f 对 x 作用 m+n 次的函数。

  乘法  MULT = λm.λn.m(PLUS n)  返回的是一个 f 对 x 作用 m*n 次的函数。

  关键是返回的是函数,入参也是函数,x 既可以是函数也可以是实参,因为函数有着与其它数据类型一样的地位。想要顺畅的理解 λ 表达式到 JS 函数的转换,需要熟练理解和使用柯里化在 JS 及 λ 表达式中的实现。

posted @ 2020-02-04 17:54  牛有肉  阅读(494)  评论(0编辑  收藏  举报