js的面向对象的程序设计之理解继承
来自《javascript高级程序设计 第三版:作者Nicholas C. Zakas》的学习笔记(六)
先来解析下标题——对象和继承~
一、对象篇
ECMA-262把对象的定义为:"无序属性的集合,其属性可以包含基本值、对象或者函数。"=。=用自己的话理解就是:对象就是散列表,无非就是一组名值对,其中的值可以是数据或是函数。ECMAScript中有两种属性:数据属性和访问器属性。而对于描述属性的基本特征是为了实现javascript引擎用的,在javascript中不能直接访问它们,规范把它们放在两对儿方括号内。
数据属性:
- [[Configurable]]:表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。直接在对象上定义的属性,它们的这个特性的默认值为true;
- [[Enumerable]]:表示能否通过for-in循环返回属性。直接在对象上定义的属性,它们的这个特性的默认值为true;
- [[Writable]]:表示能否修改属性的值。直接在对象上定义的属性,它们的这个特性的默认值为true;
- [[Value]]:包含这个属性的值。读取属性值的时候,从这个位置读;写入属性值的时候,把新值保存在这个位置。这个特性的默认值为undefined。
Object.defineProperty(属性所在的对象,属性的名字,一个描述符对象),该方法可以修改对应属性的值。
var person = { name:"Nicholas" }; var person = {}; Object.defineProperty(person,"name", { writable:false; value:"Nicholas" }); alert(person.name); //"Nicholas" person.name = "Grey"; alert(person.name); //"Nicholas"
相应的特征都是可以配置的,但是需要注意的一点是:一旦把属性定义为不可配置的,就不能再把它变回可配置了。此时,再调用Object.defineProperty()方法修改除writable之外的特性都会导致错误。
访问器属性:
访问器属性不包含数据值;它们包含一对儿getter和setter函数(但是这两个函数都不是必需的)。访问器属性有如下4个特性:
- [[Configure]]:表示能否通过delete删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为数据属性。对于直接在对象上定义的属性,这个特性的默认值为true;
- [[Enumable]]:表示能否通过for-in循环返回属性。对于直接在对象上定义的属性,这个特性的默认值为true;
- [[Get]]:在读取属性时调用的函数。默认值为undefined;
- [[Set]]:在写入属性时调用的函数。默认值为undefined;
访问器属性不能直接定义,必须使用Object.defineProperty()定义。
var book = { _year:2004, edition:1 }; Object.defineProperty(book,"year", { get: function() { return this._year; }, set: function(newValue) { if(newValue > 2004) { this._year = newValue; this.edition += newValue -2004; } } }); book.year = 2005; alert(book.edition); //2
在把year属性修改为2005会导致_year变成2005,而edition变为2.这是使用访问器属性的常见方法,即设置一个属性的值会导致其他属性发生变化。在这两个方法之前还有两个非标准的方法:_defineGetter_()和_defineSetter_()。这里不再展开介绍。
定义多个属性:
Object.defineProperties(要添加和修改其属性的对象,对象的属性与第一个对象中要添加或修改的属性一一对应),如:
var book = {}; Object.defineProperties(book, { _year: { value: 2004 }, edition: { value: 1 }, year: { get:function() { return this._year; }, set:function() { if (newValue > 2004) { this._year = newValue; this._year = newValue; this.edition += newValue - 2004; } } } }
读取属性的特征:
Object.getOwnPropertyDescriptor(属性所在的对象,要读取其描述符的属性名称),返回值是一个对象。
创建对象:
方法主要有:工厂模式、构造函数模式、原型模式、组合模式(主要是构造函数模式和原型模式的组合)、一些改进模式(如构造函数的改进)
- 工厂模式:使用同一个接口创建多个对象,会产生大量的重复代码。为了解决这个问题,人们开始使用工厂模式的一种变体。
function createPerson(name,age,job) { var o = new Object(); o.name = age; o.job = job; o.sayName = functio () { alert(this.name); }; return o; } var person1 = createPerson("carol",23,"front"); var person2 = createPerson("grub",25,"doctor");
缺点:虽然解决了创建多个相似对象的问题,但是却没有解决对象识别问题(即怎么知道一个对象的类型)。
- 构造函数模式:
function Person(name,age,job) { this.name = name; this.age = age; this.job = job; this.sayName = function() { alert(this.name); }; } var person1 = new Person("carol",23,"front"); var person2 = new Person("grub",25,"doctor");
仔细对比工厂函数和构造函数的区别你会发现:其实工厂函数就是一个有返回对象的函数,使用的时候就是普通函数调用的形式;而构造函数,没有显示地创建对象,直接将属性和方法赋给了this对象,没有return语句,必须使用new操作符。说白了,其实构造函数也是函数,所以如果你要把构造函数当做普通函数那样处理也是可以的。不同的是构造得到的对象的作用域不同:
//当作构造函数 var person = new Person("carol",23,"front"); person.sayName(); //当作普通函数 Person("grub",25,"sf"); window.sayName(); //在另一个对象的作用域中调用 var o = new Object(); Person.call(o,"carol",28,"doctor"); o.sayName();
需要注意的是:在前面的例子中,person1和person2分别保存着Person的一个不同的实例。这两个对象都有一个constructor(构造函数)属性,该属性指向Person,但是一般来说使用instanceof操作符更加靠谱。
构造函数的问题:每个方法都要在每个实例上重新创建一遍。你知道为什么吗?嘿嘿~~可以这么理解,ECMAScript中的函数都是对象(这不知道的话,建议先看看我写的前几篇的学习笔记),因此每定义一个函数,也就是实例化了一个对象。从逻辑上讲,可以这么理解:
function Person(name,age,job) { this.name = name; this.age = age; this.job = job; this.sayName = new Function("alert(this.name)"); //等价 }
导致了不同实例的同名函数是不相等。当然你可以采用如下方法解决,定义一个全局函数,然后共享之,如下:
function Person(name,age,job) { this.name = name; this.age = age; this.job = job; this.sayName = sayName; } function sayName() { alert(this.name); } var person1 = new(..........); var person1 = new(..........); //共享了全局作用域中定义的同一个sayName()函数
可是,这真得太不完美了=。=如果你学过C++,如果你曾经还留心过C++,你肯定能想来,C++的全局变量毫不留情地破坏了封装性和全局变量会占用更多的内存问题。同样如果我们在js中定义很多全局函数,我们自定义的引用类型就丝毫没有封装性可言。于是乎,出现了原型模式。。。。
- 原型模式:
创建的每一个函数都有一个prototype(原型)属性,这个属性是一个指针,指向一个对象,而这个对象的用途包含可以由特定类型的所有实例共享的属性和方法。可以这么理解,prototype就是通过构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换句话说,不必在构造函数中定义对象实例的信息,而是可以将这些对象信息直接添加到原型对象中,如:
function Person() { } Person.prototype.name = "carol"; Person.prototype.age = 23; Person.prototype.job = "front"; Person.prototype.sayName = function() { alert(this.name); }; var person1 = new Person(); person1.sayName(); //"carol" var person2 = new Person(); person2.sayName(); //"carol" alert(person1.sayName == person2.sayName); //true
进一步学习知识,isPrototypeOf()方法来确定对象之间是否存在某种关系。
alert(Person.prototype.isPrototypeOf(person1)); //true alert(Person.prototype.isPrototypeOf(person2)); //true
ECMAScript5还新增加了Object.getPrototypeOf(),在所有支持的实现中,这个方法返回[[prototype]]的值:
alert(Object.getPrototypeOf(person1) == person.prototype); //true alert(Object.getPropertyOf(person1).name); //"carol"
每个代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例本身开始。如果在实例中找到了具有给定名字的属性,则返回该属性的值;如果就没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性。
原型最初只包含constructor属性,而该属性也是共享的,因此可以通过对象实例访问。
虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型的值。如果我们在实例中添加了一个属性,而该属性与实例属性中的一个属性同名,那我们就在实例中创建该属性,该属性将会屏蔽原型中的那个属性。~其实。啰哩叭嗦,总结成一句话,就是——覆盖问题,看下例子:
function Person() { } Person.prototype.name = "carol"; Person.prototype.age = 29; Person.prototype.job = "manage"; Person.prototype.sayName = function() { alert(this.name); }; var person1 = new Person(); var person2 = new Person(); person1.name = "grey"; alert(Person1.name); // "grey"---来自实例 alert(Person2.name); // "carol" --来自原型,说明person1中的修改只是在我们访问person1时,阻止我们访问原型中的属性,还原型中的属性并没有被修改,这也是搜索原型的 结果
hasOwnPrototype()方法,可以检测一个属性是存在于实例中还是原型中。这个方法只在给定属性存在于对象实例中才会返回true。而对应地,in操作符会通过对象能够访问给定属性时返回true,无论该属性存在于实例中还是原型中。所以同时使用hasOwnProperty()和in操作符就可以确定该属性到底是存在于对象中,还是存在于原型中。
注意:IE早起版本的实现中存在一个Bug,即屏蔽不可枚举属性的实例属性不会出现在for-in循环中:
var o = { toString:function() { return "My Object"; } }; for (var prop in o) { if (prop == "string") { alert("Found toString"); //在IE中不会显示 } }
定义的toString()方法,屏蔽了原型中(不可枚举)的toString()方法。要取得对象上所有可枚举的实例属性,可以使用ECMAScript5的Object.keys()方法。这个方法接收一个对象作为参数,返回一个包含所有可枚举属性的字符串数组。例如:
function Person() { } Person.protoype.name = "carol"; Person.prototype.age = 23; Person.prototype.job = "front"; var keys = Object.keys(Person.prototype); alert(keys); // "name,age,job"
下面来点更加简单的原型语法,话不多讲,看代码:
function Person() { } Person.prototype = { name: "carol", age: 29, job: "front", sayName:function() { alert(this.name); } };
对于Person.prototype来说,最终结果相同,但是有一个例外:constructor属性不再指向Person。如果使用前面那种逐个定义的语法,每创建一个函数,就会同时创建它的prototype对象,这个对象也会自动获得constructor属性。而我们在这里使用的语法,本质上是重写了默认的prototype对象,不再指向Person函数。尽管如此,instanceOf还是能够返回正确的结果,但通过constructor已经无法确定对象的类型了。
var friend = new Person(); alert(friend instanceOf Object); //true alert(friend instanceOf Person); //true alert(friend.constructor == Person); //false alert(friend.constructor == Object); //true //如果constructor很重要,你可以特意将它设置回适当的值 function Person() { } Person.prototype = { constructor:Person, //you see name:"carol", age:23, job:"front", sayName:function() { alert(this.name); } };
原型的动态性:由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来,即使是先创建了实例后修改原型也是照样如此。
var friend = new Person(); Person.Prototype.sayHi = function() { alert("hi"); }; friend.sayHi(); //"hi"
灰常灰常需要注意:尽管可以随时为原型添加属性和方法,并且修改能够立即在所以对象实例中反映出来,但如果是重写整个原型对象,那么情况是不一样的。调用构造函数会为实例添加一个指向最初原型的[[prototype]]指针,而把原型修改为另外一个对象就等于切断了构造函数与最初原型之间的联系。记住:实例中的指针仅仅指向原型,而不指向构造函数。看下文:
function Person() { } var friend = new Person(); //创建实例 Person.prototype = { //重写原型对象 constructor:Person, name:"carol", age:9, job:"front", sayName:function(){ alert(this.name); } }; friend.sayName(); //error
****不推荐在产品化的程序中修改原生对象的原型。如果因为某个实现中缺少某个方法,就在原生对象的原型中添加这个方法,那么当在另一个支持该方法的实现中运行代码时,就可能导致命名冲突。
同样灰常灰常重要——原型对象的问题:我们希望实例能够有自己的属性,但是很清楚地看到,对于原型模式,因为共享的问题,是不能有自己的属性。分析构造和原型的特点,你会发现,两者优点相结合才是王道。
- 组合使用构造函数模式和原型模式:(还是例子来得实在)
function Person(name,age,job) { this.name = name; this.age = age; this.job = job; this.friends = ["Shelby","Count"]; } Person.prototype = { constructor:Person, sayName:function() { alert(this.name); } } var person1 = new Person("carol",29,"front"); var person2 = new Person("grey",26,"Doctor"); person1.friends.push("van"); alert(person1.friends); alert(person2.friends); alert(person1.friends === person2.friends); //false alert(person1.sayName === person2.sayName); //true Please tell me what you can see^_^
- 动态原型模式(不多说,看代码足够表达意思了)
function Person(name,age,job) { this.name = name; this.age = age; this.job =job; if (typeof this.sayName != "function") { //本质上,就是多个判断 Person.prototype.sayName = function(){ alert(this.name); }; } } var friend = new Person("carol",23,"front"); friend.sayName();
- 寄生构造函数模式
function Person(name,age,job) { var o = new Object(); o.name = name; o.age = age; o.job =job; o.sayName= function () { alert(this.name); }; return o; } var friend = new Person("carol",23,"front"); friend.sayName(); //carol
各种像工厂模式、像构造函数模式有木有=。=这个模式可以在特殊的情况下用来为对象创建构造函数。假设我们想创建一个具有额外方法的特殊数组。由于不能直接修改Array构造函数,因此可以使用这个模式:
function SpecailArray(){ var values = new Array(); values.push.apply(values,arguments); values.toPipedString = function() { return this.join("|"); }; return values; } var colors = new SpecialArray("red","blue","green"); alert(colors.toPipedString()); // "red|blue|green"
- 稳妥构造函数模式
function Person(name,age,job) { var o = new Object(); //在这里可以定义私有变量和成员 o.sayName = function() { alert("hello"); }; return o; }
在以这种模式创建的对象中,除了使用sayName()方法之外,没有其他办法访问name的值。安全性瞬间得到保证。
与寄生构造函数模式类似,使用稳妥构造函数模式创建的对象与构造函数之间并没有什么关系,所以instanceof操作数对这种对象没有什么意义。
二、继承篇
既然谈到了js的继承,那么首先要明确一点——ECMAScript中没有类的概念。每个对象都是基于一个引用类型存在的,这个引用类型可以是原生类型也可以是自定义类型。因为函数没有签名(可能你需要google下签名的概念,不过如果你看过我之前的连载博文,应该知道签名的意思呢),无法实现接口继承,只支持实现继承,而且实现继承主要是依靠原型链来实现。其基本思想就是:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。那原型链的基本概念是什么呢?~可以这么考虑:假如我们让原型对象等于另一个类型的实例,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然是成立的,如此层层递进,就构成了实例与原型的链条。~文字有点枯燥
function SuperType() { this.property = true; } SuperType.property.getSuperValue = function() { return this.property; }; function SubType() { this.subproperty =false; } //继承SuperType SubType.property = new SuperType(); SubType.property.getSubValue = function() { return this.subproperty; }; var instance = new SubType(); alert(instance.getSuperValue()); //true
- 所有引用类型默认继承了Object,而这个继承也是通过原型链实现
- 确定原型和实例的关系:instanceOf操作符(只要用这个操作符来测试实例和原型链中出现过的构造函数,结果就会返回true)和isPrototypeOf()方法(只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型)
- 子类型有时候要重写超类型中的方法,或者需要添加超类型中不存在的方法,无论如何,给原型添加方法的代码一定要放在替换原型的语句之后
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; }; //重写超类的方法 SubType.prototype.getSuperValue = function() { return false; }; var instance = new SubType(); alert(instance.getSuperValue()); //false
- 注意在通过与原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做就会重写原型链,如:
function SuperType() { this.property = true; } SuperType.prototype.getSuperValue = function() { return this.property; }; function SubType() { this.subproperty = false; } //继承 SubType.prototype = new SuperType(); //使用字面量添加新方法,会导致上一行代码无效 SubType.prototype = { getValue:function() { return this.subproperty; }, someOtherMethod:function(){ return false; } }; var instance = new SubType(); alert(instance.getSuperValue()); //error
- 原型链的问题:包含引用类型值的原型属性会被所有实例共享;而这正是为什么要在构造函数中,而不是在原型对象中定义属性的原因。
function SuperType() { this.colors = ["red","blue","green"]; } function SubType() { } //继承 SubType.prototype = new SuperType(); //涉及到原型对象 var instance1 = new SubType(); instance1.colors.push("black"); alert(instance1.colors); //"red,blue,green,black" var instance2 = new SubType(); alert(instance2.colors); //"red,blue,green,black" 发现了什么^_^
因为SubType的原型链共享原型,简直就是牵一发而动全身~
- 原型链的第二个问题,就是在创建子类型的实例时,不能向超类型的构造函数中传递参数。实际上就是说,没有办法在不影响所有对象实例的情况下,给超类的构造函数传递参数。
为了解决原型链的问题,引入了借用构造函数
借用构造函数:
又叫“伪造对象”或“经典继承”,思想很简单,就是在子类型构造函数的内部调用超类型构造函数。因为函数只是在特定环境中执行代码的对象,因此通过使用apply和call方法可以在新创建的对象上执行构造函数,如下:
function SuperType() { this.colors = ["red","blue","green"]; } function SubType() { SuperType.call(this); //继承SuperType } var instance1 = new SubType(); instance1.colors.push("black"); alert(instance1.colors); //"red,blue,green,black" var instance2 = new SubType(); alert(instance2).colors); //"red,blue,green"
你会发现,SubType的每个实例都会有具有自己的colors属性的副本了。当然还有更多优势,比如传递参数
function SuperType(name) { this.name =name; } function SubType() { SuperType.call(this,"Nicholas"); this.age =29; } var instance = new SubType(); alert(instance.name); //"Nicholas alert(instance.age); //29
借用构造函数的问题很明显,无法避免构造函数模式的问题——函数复用无从谈起(参见本文前部分的构造模式和原型模式分析)。
于是,伟大的人类,总是喜欢优点的结合,于是乎,组合继承诞生了===
组合继承:
又叫“伪经典继承”,废话不多说,看代码一目了然
function SuperType(name) { this.name = name; this.color = ["red", "blue","green"]; } SuperType.prototype.sayName = function() { alert(this.name); }; function SubType(name,age) { //继承属性 SuperType.call(this,name); this.age = age; } //继承方法 SubType.prototype = new SuperType(); SubType.prototype.sayAge = function() { aler(this.age); }; var instance1 = new SubType("carol",23); instance1.colors.push("black"); alert(instance1.colors); //"red,blue,green,black" instance1.sayName(); //"carol" instance1.sayAge(); //23 var instance2 = new SubType("grey",25); alert(instance2.colors); //"red,blue,green" instance2.sayName(); //"grey instance2.sayAge(); //25
想必你已经发现什么了。没错,组合继承实现了属性的独立,方法的共享。这是怎么办到的呢?~请看SuperType的定义:属性部分用的是构造函数模式,方法是定义在原型对象上,然后在SubType继承的时候,在构造函数中,用call方法实现SuperType属性的继承(是构造模式的),然后在prototype上继承SuperType的原型对象上的方法(原型模式),此时就有了构造模式的属性和原型模式的方法。
组合继承是最常用的继承模式。
原型式继承:
借助原型可以基于已有的对象创建新的对象。
function object(o) { function F(){} F.prototype = o; return new F(); }
本质上,object就是对传入的对象进行了一次浅复制。要求必须要有一个对象作为另一个对象的基础。
var person = { name:"carol", friends: ["sheel","court","van"] }: var anotherPerson = object(person); anotherPerson.name = "craol"; anotherPerson.friends.push("Rob"); var yetAnotherPerson = object(person); yetAnotherPerson.name = "Linda"; yetAnotherPerson.friends.push("Barbie"); alert(person.friends); //"sheel,court,van,Rob,Barbie"
这个新对象对person作为原型,所以它的原型中就包含一个基本类型值属性和一个引用类型值属性、这意味着person.friends不仅属于person所有,而且也会被anotherPerson和yetAnotherPerson共享,就相当于创建了person对象的两个副本。
寄生式继承:
function createAnother(orginal) { var clone = object(orginal); clone.sayHi = function() { alert("hi"); }; return clone; } var person = { name: "carol", friends:["sheeli","Linda","Van"] }; var anotherPerson = createAnother(person); anotherPerson.sayHi(); //"hi"
新对象不仅拥有person对象的全部属性和方法,还有自己的sayHi()方法。
注:使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率,这与构造模式类似。
寄生组合式继承:
组合继承一般是js最常用的继承模式,不过它也有不足。比如无论什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,另一次是在子类型构造函数内部。子类型最终会包含超类型对象的全部实例属性,但我们不得不在调用子类型构造函数时重写这些属性。
function SuperType(name) { this.name = name; this.colors = ["red","blue","green"]; } SuperType.prototype.sayName = function() { alert(this.name); }; function SubType(name,age) { SuperType.call(this,name); //第二次调用SuperType() this.age =age; } SubType.prototype = new SuperType(); //第一次调用SuperType() SubType.prototype.constructor = SubType; SubType.prototype.sayAge = function() { alert(this.age); };
所谓的寄生继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。思想:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。本质上就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。模式:
function inheritPrototype(subType,superType) { var prototype = object(superType.prototype); //创建对象 prototype.constructor = subType; //增强对象,弥补因重写原型而失去默认的constructor属性 subType.prototype = prototype; //指定对象 }
实例:
function SuperType(name) { this.name = name; this.colors = ["red","blue","green"]; } SuperType.prototype.sayName = function() { alert(this.name); }; function SubType(name,age) { SuperType.call(this,name); this.age = age; } inheritPrototype(SubType,SuperType); SubType.prototype.sayAge = function() { alert(this.age); };
这个例子的高效体现在只调用了一次SuperType构造函数,并且因此避免了在SubType.prototype上面创建不必要的、多余的属性。以此同时,原型链还能保持不变,所以能够正常使用instanceof和isPropertyOf()。=。=个人的理解是这样子的:SuperType.call(this,name)调用了构造函数,实现构造模式继承,将属性成功继承过来;如果要继承原型中的方法sayName,按照组合继承=也是要用到构造函数的SubType.prototype = new SuperType(),这无疑也会继承构造函数中的属性,所以我们在寄生组合继承中就是避免了这次构造函数的调用,用上了inheritPrototype。在inheritance中我们supertype的原型对象,将subtype指向这个原型对象,就可以继承supertype原型对象中的方法,从而避免了在SubType.prototype上创建多余的属性。
普遍认为,寄生组合继承时应引用类型最理想的继承方式。
PS:看到这是不是有被虐到的感觉,哈哈!~检测检测你的理解+记忆能力如何~~
用几个关键词想想你到学习到了什么:
创建对象的模式:工厂模式、构造函数模式、原型模式
继承:原型继承、寄生式继承、寄生组合式继承、组合继承
我永远都相信,人的大脑跟不上磁盘的扩容,于是乎,好记性不如烂笔头(现在是不如键盘上敲敲)。~生活也好,工作也好,用你的大脑记住高效有用的东西,值得铭记的东西,剩下的,交给伟大的科技把=。=不然怎么会有科技改变什么生活之说=。=