对象及属性
JS中的对象并不像Python一样通过类来操作,所以在JS内一般不采用类的概念。JS的对象指的是无序属性的集合,类似于一个散列表,就是一个键值对,其中值可以是数据或者函数。
每个对象都是基于一个引用类型创建的,这个引用类型可以是上一章的原生引用类型,也可以是自定义的类型。
在引用类型里已经提到过,创建对象最简单的方法就是使用Object构造函数建立一个对象,然后对其添加属性。也可以通过字面量花括号加键值对的方式创建对象。
先看一下对象的属性,然后深入剖析一下对象的建立与继承。
对象的属性
对象的属性分为两种:数据属性和访问器属性。
数据属性
数据属性包含一个数据值的位置,可以写入或者读取值。ECMA规范里对数据属性规定了四个特性:
- [[Configurable]],是否能通过delete删除该属性,是否能修改属性的特性,是否能将数据属性修改为访问器属性。直接在对象上定义的属性默认该项为true。
- [[Enumerable]],是否能通过for-in返回该属性,即对对象调用for-in时,该属性是否包含在返回的内容里。直接在对象上定义的属性默认为true。
- [[Writable]],是否能修改属性的值。直接在对象上定义的属性默认为true。
- [[Value]],保存了这个属性的数据值,读写的时候都从Value的位置进行读写,这个特性默认为undefined。
要修改一个属性的上述特性,需要用到 Object.defineProperty(Obj, attrName, descriptor)
静态方法。其中Obj是对象名,attrName是这个对象的属性名,descriptor是一个键值对,键名就是特性名称,值为要设置的值。
person = {name:"jenny",age:16};
Object.defineProperty(person,"name",{
writable:false,
value:"cony",
});
console.log(person.name);
person.name = "jenny";
console.log(person.name);
这个例子设置了name属性为不可写,然后设置了值,这样即使修改该属性也无效。这其中要注意的是,如果把configurable特性修改为false,则之后就不能再修改除了writable以外的特性了。
访问器属性
包含两个函数getter和setter,在访问的时候调用getter,负责返回有效的值;在写入的时候调用setter,由setter函数负责如何处理数据。
与数据属性类似,访问器属性也包含四个特性:
- [[Configurable]] 与数据属性相同
- [[Enumerable]] 与数据属性相同
- [[Get]] 在读取的时候调用的函数,默认为undefined。
- [[Set]] 在写入的时候调用的函数,默认为undefined。
一个属性想要成为访问器属性,通过创建对象的方法无法定义,必须通过上文提到的 Object.defineProperty(Obj, attrName, descriptor)
静态方法。其核心在于定义Get和Set特性,看一个例子:
let book = {
_year:2004,
edition:2,
};
Object.defineProperty(book,"year",{
get:function () {
return this._year;
},
set:function (newValue) {
if(newValue>2004){
this._year = newValue;
this.edition = 2 + (newValue-2004);
}
else {
this._year = 2004;
this.edition = 2;
}
}
})
这个例子里,_year和edition都是数据属性(_year表示不想被从对象外部直接访问的属性,实际上依然可以访问),然后通过Object静态方法创建了一个叫做year的访问器属性。然后给访问器属性设置了get和set函数。set对应的函数必须有一个参数用来接受新的值。
在这个例子里,通过逻辑判断,通过year这个访问器属性去自动按照想要的逻辑修改了_year和对应edition的值。这就是使用访问器属性的常见方式:通过一个属性控制其他属性的变化。
如果不使用Object的静态方法,则还有两个旧方法可以用来设置访问器:__defineGetter__ 和 __defineSetter__
。这两个方法是遗留下来的,可以被浏览器支持,但是推荐使用Object的静态方法。
上述的静态方法一次建立一个属性,还有 Object.defineProperties(Obj, attrName_property_dict)
用于一次性修改多个数据属性的特性和创建多个访问器属性。第二个参数是属性名与特性和值的嵌套字典。
读取属性的特性
通过 Object.getOwnPropertyDescriptor(obj,attrName)
可以获得一个对象,里边保存了这个属性的所有特性与值。
对象是什么:建立对象的过程
在开始写这章之前,不得不说读到这章简直醍醐灌顶。Python的教程中重点在于解释类与对象的关系,但没有从机制上说明。高程三这一章重在讲解对象和继承,把面向对象的机制讲解的非常透彻。
一开始要理解一个概念,就是对象的原型。这里的对象,虽然书里指的是JS语言里的Object类型,但很快就可以发现,就将其当做面向对象程序设计思想中的普遍的对象定义是毫无问题的。
原型,就是对象的.prototype
属性(是一个指针)指向的一个对象。一旦一个对象建立(函数,引用类型等等一切),解释器就建立一个这个对象的原型,然后将这个对象的.prototype
属性指向这个原型。
工厂模式--构造函数模式--原型模式--组合使用构造函数模式和原型模式的思路发展,主要是两个推动,一个是如何判断对象所属的类型,究竟是属于新的类型还是与构造函数无关,这个推动了原型的发展(构造函数也是普通的函数,没有返回值的话默认返回当前的一个实例);一个是避免共享方法的重复定义,这个推动了将共享方法和属性放置在原型中,将实例独有的方法和属性放置在实例中的设计思想。二者共同演化出了组合使用构造函数模式和原型模式。类比Python,在定义类的时候就定义了原型,而类的__init__
方法就是构造函数。
工厂模式
function createPerson(name,age,job){
var o = new Object();
o.name = name;
o.age= age;
o.job= job;
o.sayName = function () {
console.log(this.name);
};
return o;
}
工厂模式批量制造对象,工厂模式的最大问题是,制造出来的对象的类型是Object。不同的工厂函数制造出来的全部都是同一个类型的对象,无法区分对象类型
构造函数模式
function Person(name,age,job) {
this.name = name;
this.age=age;
this.job= job;
this.sayName= function () {
console.log(this.name);
};
}
let p1 = new Person("jenny",33,"artist");
let p2 = new Person("cony",4,"magician");
函数没有返回值的情况下,默认返回当前的这个对象,像例子里这样的函数就是构造函数。在Webstorm里甚至可以认出来这是构造函数并且提示使用new来生成对象。
构造函数不是一个特别的函数,如果不加new使用,就和一个普通的函数没有任何区别。像例子中如果直接使用,则返回undefined。
用构造函数生成的p1 和 p2 两个实例,都有一个属性叫做constructor, 指向构造函数。这个属性其实就是用来标识对象类型的。例子中的对象用p1 instanceof Person; p1 instanceof Object;
都可以发现是true,这样就可以区分对象的类型了。这是构造函数胜过工厂模式的地方,也是初步的面向对象设计思想。
原型模式
构造函数的主要问题在于方法的重复定义,如果每个对象都要具备某个方法,构造函数模式会把这个方法写到每一个对象里,每一个对象的方法都是不同的实例。而如果把相同的方法移出构造函数定义在别处,虽然代码不重复,但对象的封装性就没有了。所以引入了原型模式,即把需要共享的东西,定义到对象的原型上去。
function Person() {
Person.prototype.name = "jenny";
Person.prototype.age = 33;
Person.prototype.job = "artist";
Person.prototype.sayName = function () {
console.log(this.name)
}
}
let p1 = new Person();
有了原型以后,给原型定义好了属性和方法,根据原型生成的新对象,自动就有了这些属性和方法
原型模式是理解面向对象程序设计的核心。每个实例(例如p1)内部有一个[[prototype]]指针指向Person的原型(而不是Person构造函数)。用图来说明:
通过 Object.getPrototypeOf(p1) === Person.prototype
的结果为true,以及 Person.prototype.constructor === p1.constructor
为true,可以验证图中的结构。
还有一个需要注意的地方是,如果重写了原型对象,就会切断实例与原来原型的关系。
知道了原型,就可以修改任何对象的原型的方法和属性,比如可以给内置数据类型添加新的方法。但是在标准的开发中,不推荐对内置数据类型做任何修改。
组合使用构造函数和原型模式
原型模式显而易见的特点是,用原型模式构造出来的对象缺少自己特有的属性和方法,只能后期添加。面向对象的思路发展到这一步,就得到了一般建立对象的标准方法,即个性化的东西加载在对象上,共性的东西放置到原型内。只需要先写构造函数,再定义原型并指定constructor属性为构造函数。然后创建对象。
function Person(name,age,job) {
this.name = name;
this.age= age;
this.job = job;
this.friends = ['Minko','cony','arashi']
}
Person.prototype = {
constructor: Person,
sayName: function () {
console.log(this.name)
}
};
let p1 = new Person("jenny",33,'artist');
let p2 = new Person("cony",4,'magician');
其实这样就已经完成了标准的对象的建立。与其他OOP语言比如Python看上去令人疑惑的地方可能是构造函数和原型在代码层面的分离。但这只是表现形式不同而已。动态原型就是在组合模式的基础上实现的用一段代码块包起所有生成对象的代码。
高程三后边讲的寄生构造和稳妥构造也不用关注,因为构造出来的对象依然无法判断类型。
这里又想到了python,由于都是OOP设计思想的程序,所以其对象设计理念是一样的。Python的表现形式是定义一个class,然后在class内部书写初始化函数__init__。其实class就相当于JS的原型,而初始化函数__init__就是构造函数。为什么class里的__init__不需要写return,找到答案了吗?
继承
很多OOP语言实现继承的方式有两种:一种是继承接口,一种是继承实现。接口继承就是继承方法签名,实现继承就是继承实际的函数方法。JS里没有函数签名,故无法通过接口继承,只能继承实现。
在JS里继承是通过原型链的方式实现的,基本思想是利用原型让一个引用类型继承另外一个引用类型的属性和方法。
基础原型链模式
原型链,顾名思义,就是把原型链接起来。假设我们有一个对象O1,构造函数是O,构造函数的原型是OP,所以O1能够使用所有OP的属性和方法。为了达到继承效果,也就是O1还可以使用其他类型的属性和方法,如果OP里还有一个[[prototype]]指针,指向另外一个类型的原型OP2,根据对象调用属性的顺序(自己没有就通过[[prototype]]指针到原型里去找),那O1就可以同时使用OP和OP2的所有属性和方法了(OP里没有的方法,会通过指针 到OP2里去找)。这个就是原型链的原理。
这个时候想到,拥有指向OP2的原型的指针的东西是什么呢?根据前边学到的对象的知识,这个东西就是OP2原型对应类型的实例。也就是说,想要A类型继承B类型的话,只要把B类型的实例当成A类型的原型就可以了。
用一段最简单的代码和图片来说明
function SuperType() {
this.property= true;
SuperType.prototype.getSuperValue = function () {
return this.property;
}
}
function SubType() {
this.subproperty = false;
}
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function () {
return this.subproperty;
};
let instance = new SubType();
SubType的原型就是SuperType的实例,SuperType的实例指向SuperType的原型。其实JS内所有的对象都继承Object,所以其实SuperType的原型里还有一个[[prototype]]指针指向Ojbect的原型。
这样结构就非常清楚了,SubType的constructor指向SuperType的构造函数。自己的属性是自己的,如果找不到,就去作为原型的实例里去找,如果再找不到,就去实例的原型里找,如果实例的原型里找不到,就会去实例的[[prototype]]指针指向的地方去找,这就是原型链继承。
继承以后再测试原型和实例的关系,可以发现,instance 同时属于SubType,SuperType,Object三种类型,所有在原型链里出现过的原型,都是instance的原型。
基础原型链存在的问题主要是两个:一是原型链上父类型的引用类型属性到了子类型里会被当成原型属性,被所有实例共享;二是不能向超类型的构造函数里传递参数,也就无法传递子类型特有的属性和方法。所以一般不用基础原型链来继承。
借用构造函数模式
为了解决原型链的问题,想到了一个巧妙的解决方法。基础原型链继承的时候,可以发现真正的构造函数是父类型的构造函数。但函数就是一段依赖于执行环境的代码,可以借用过来,在子类型的环境里执行,然后加上特有的参数就可以了。
function SuperType() {
this.colors = ['red','blue','green'];
}
function SubType() {
SuperType.call(this);
}
SubType.prototype = new SuperType();
SubType.prototype.getcolors = function () {
console.log(this.colors);
};
let i1 = new SubType();
let i2 = new SubType();
i1.colors.push("white");
i1.getcolors();
i2.getcolors();
核心是在子类型的定义中,让父类型的构造函数在子类型的环境中运行,给子类型对象也添加了colors属性,由于执行环境不同,父类型与子类型、子类型的实例之间的colors属性之间都彼此独立。测试代码也证明了这一点。
可以调用父类型的构造方法之后,由于.call方法可以传参数,就可以想办法来给子类型添加个性化的数据了,看例子:
function SuperType(name) {
this.name = name;
}
function SubType() {
SuperType.call(this,name);
this.age= age;
}
但是仅仅采用构造函数,而不使用原型链,会发现需要重写每个对象的方法,无法共享任何数据。而且因为没有原型链,超类型的方法和属性无法被子类型共享。这个时候自然就想到了将二者结合,个性化的数据借用构造函数,共通属性和方法通过原型链。
注:个人觉得高程三这里写的不太好,高程三的本意是原型继承和借用构造函数继承是两种互斥的方式,但是没明说,导致看到这里会自动在原型链的基础上再加上借用构造函数继承。
组合继承模式
看到这里,其实也就想到组合继承的代码怎么写了。父类型的构造函数该干啥干啥,需要共享的方法和属性都写上。然后子类型借用过来,顺便在自己的构造函数里该新增的新增,该覆盖的覆盖。最后二者用原型链一连,就实现了标准的继承。
function SuperType(name) {
this.name = name;
this.colors = ['red','green','blue'];
// 在父类原型上定义方法,也可以被继承到子类
SuperType.prototype.sayName = function () {
console.log(this.name)
}
}
function SubType(name,age) {
SuperType.call(this,name);
this.age= age;
}
// 连接原型链
SubType.prototype = new SuperType();
// 由于调用了父类的构造函数,实际是自己的构造函数发挥作用,所以修改原型的constructor指针
SubType.prototype.constructor = SubType;
// 在子类原型=父类实例上定义方法,可以被所有的子类共享
SubType.prototype.sayAge = function () {
console.log(this.age);
};
let p1 = new SubType('jenny',33);
let p2 = new SubType("cony",4);
每次产生对象,调用父类构造函数建立父类指定的属性name,然后加上个性化的age,父类型原型的方法可以继承下来,子类型原型的方法也可以继承下来。 instanceOf 和 Obj.prototype.isPrototypeOf()
都可以正常判断出子类的类型。
后边的原型式继承和寄生式继承可以不看,因为不是高效率的,只是用于快速生成已有对象的继承或者增强方式。重要的是寄生式组合继承。
寄生组合式继承模式
仔细观察前边的组合继承,其中在调用父类型的构造函数时候执行了一次构造函数,在建立原型链的时候又调用了一次构造函数。问题还不仅仅是执行了两次构造函数。在子类型的构造函数执行的时候,子类型实例上有了name 和 colors 两个属性。在建立原型链的时候建立了一个SuperType的实例作为SubType的原型,这个原型上又定义了一次name和color。结果就是实例的属性覆盖了原型的同名属性。
寄生组合型就是为了避免两次调用父类型,其思想很简单,就是创建要继承的类的原型的实例,创建以后因为新的副本没有constructor,把实例的构造函数指针指向子类的构造函数,再把实例赋给子类型当成原型就行了。用一行函数来替换组合继承里的第二行出现SuperType()的语句。
function inheritPrototype(SubType, SuperType) {
let prototype = Object(SuperType.prototype);
console.log(prototype);
prototype.constructor = SubType;
SubType.prototype = prototype;
}
function SuperType(name) {
this.name = name;
this.colors = ['red','green','blue'];
}
SuperType.prototype.sayName = function(){
console.log(this.name);
};
function SubType(name,age){
SuperType.call(this,name);
this.age = age;
}
// 这一行用来替代再次执行父类型构造函数
inheritPrototype(SubType,SuperType);
// SubType.prototype = new SuperType();
SubType.prototype.sayAge = function(){
console.log(this.age)
};
let p1 = new SubType('jenny',33);
let p2 = new SubType("cony",4);
寄生组合型避免了在子类的原型上创建多余的属性,因此被认为是引用类型最理想的继承方法。