乐趣区

红宝书笔记第6章面向对象的程序设计

本章内容

  • 理解对象属性
  • 理解并创建对象
  • 理解继承

ECMA-262 把对象定义为:“无序属性的集合,其属性可以包含基本值、对象或者函数。”严格来讲,这就相当于说对象是一组没有特定顺序的值。

每个对象都是基于一个引用类型创建的,既可以是原生类型,也可以是开发人员定义的类型。

6.1 理解对象

创建对象最简单的方式就是创建一个 Object 的实例,然后为它添加属性和方法。

var person = {
    name: "Jack",
    age: 29,
    sayName: function() {alert(this.name);
    }
}

这些属性在创建时都带有一些特征值(characteristic),JS 通过这些特征值来定义它们的行为。

6.1.1 属性类型

ECMAScript 中有两种属性:数据属性和访问器属性。

1. 数据属性

数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有 4 个描述其行为的特性。

  • [[Configurable]]
  • [[Enumerable]]
  • [[Writable]]
  • [[Value]]

对于直接在对象上定义的属性,它们的 [[Configurable]]、[[Enumerable]]、[[Writable]] 特性都被设置为 true,而 [[Value]] 特性被设置为指定的值。

要修改属性默认的特性,必须使用 ECMAScript 5 的 Object.defineProperty() 方法。接收 3 个参数:属性所在的对象、属性名字、一个描述符对象。其中描述符(descriptor)对象的属性必须是:configurable/enumerable/writable/value。设置其中的一个或多个值,可以修改对应的特性值。

var person = {}
Object.defineProperty(person, "name", {
    writable: false,
    configurable: false,
    value: "Nick"
})
alert(person.name); // Nick
person.name = Jack;
alert(person.name); // Nick
delete person.name;
alert(person.name); // Nick

注意:一旦把属性定义为不可配置的,就不能再把它变回可配置了。也就是说,可以多次调用 Object.defineProperty()方法修改同一个属性,但在把 configurable 设置为 false 后,就不能了。

在调用 Object.defineProperty() 时,如果不指定,则 configurable/writable/enumerable 都为 false。

2. 访问器属性

访问器属性不包含数据值;它们包含一对 getter/setter 函数(不过,这两个函数都不是必须的)。在 读取 访问器属性时,会调用 getter 函数,这个函数负责返回有效的值;在 写入 访问器属性时,会调用 setter 函数并传入新值,这个函数负责决定如何处理数据。特性如下:

  • [[Configurable]]
  • [[[Enumerable]]
  • [[Get]]:在读取属性时调用的函数,默认值 undefined。
  • [[Set]]:在写入属性时调用的函数,默认值 undefined。

访问器属性不能直接定义,必须使用 Object.defineProperty()。

var book = {
    _year: 2004,
    edition: 1
};

Object.defineProperty(book, "year", {// IE9+
    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

下划线是一种常用的记号,用于表示只能通过对象方法来访问的属性。
以上是使用访问器属性的常见方式,即设置一个属性的值会导致其他属性的变化。
不一定要同时指定 getter 和 setter。只指定 getter 表示属性是不能写,反之则表示属性不能读。

6.1.2 定义多个属性

Object.defineProperties() 可以通过描述符一次性定义多个属性。接收 2 个参数:1、第一个对象是要添加和修改其属性的对象;2、第二个对象的属性与第一个对象中要添加或修改的属性一一对应。

var book = {};
Object.defineProperties(book, { // IE9+
 _year: {
     writable: true,
     value: 2004
 },
 edition: {
     writable: true,
     value: 1
 },
 year: {get: function() {return this._year;},
     set: function(newValue) {
         this._year = newValue;
         this.edition++;
     }
 }
})

6.1.3 读取属性的特性

使用 ECMAScript 5 中的 Object.getOwnPropertyDescriptor() IE9+ 方法,可以取得给定属性的描述符。这个方法接收两个参数:1、属性所在的对象;2、要读取器描述符的属性名称。返回值是一个对象,如果是数据属性,这个对象的属性有 configurable/enumerable/writable/value,如果是访问器属性,则这个对象的属性有 configurable/enumerable/get/set

// 使用前面的例子
var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
alert(descriptor.value); // 2004
alert(descriptor.configurable); //false
alert(typeof descriptor.get); //"undefined"

var descriptor = Object.getOwnPropertyDescriptor(book, "year");
alert(descriptor.value); //undefined
alert(descriptor.enumerable); //false
alert(typeof descriptor.get); //"function"

6.2 创建对象

问题:使用同一个接口创建很多对象,会产生大量的重复代码。

6.2.1 工厂模式

工厂模式抽象了创建具体对象的过程。用函数来封装以特定接口创建对象的细节。

function createPerson(name, age) {var o = new Object();
    o.name = name;
    o.age = age;
    o.sayName = function() {alert(this.name);
    };
    return o;
}

var person1 = createPerson("Jack", 29);
var person2 = createPerson("Nick", 22);

工厂模式虽然解决了创建多个相似对象的问题,但是没有解决对象识别的问题(即怎样知道一个对象的类型)。

6.2.2 构造函数模式

可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayName = function() {alert(this.name);
    }
}
var person1 = new Person("Jack", 23);
var person2 = new Person("Nick", 22);

构造函数模式有以下几个特点:

  • 没有显示地创建对象;
  • 直接将属性和方法赋给了 this 对象;
  • 没有 return 语句。
  • 函数名开头必须大写。
  • 构造函数本身也是函数,只不过可以用来创建对象而已。

要创建 Person 的新实例,必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下 4 个过程:

  • 创建一个新对象;
  • 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象);
  • 执行构造函数中的代码(为这个新对象添加属性);
  • 返回新对象。

使用 instanceof 检测对象类型:

alert(person1 instanceof Object); // true
alert(person1 instanceof Person); // true
alert(person2 instanceof Object); // true
alert(person2 instanceof Person); // true

创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型;而这正是构造函数模式胜过工厂模式的地方。

1. 将构造函数当做函数

// 当做构造函数来使用
var person = new Person("Nick", 29);
person.sayName(); // "Nick"

// 当做普通函数调用
Person("Nick", 29); // 添加到 window 对象
window.sayName(); // "Nick"

// 在另一个对象作用域中调用
var o = new Object();
Person.call(o, "Nick", 29);
o.sayName(); // "Nick"

2. 构造函数的问题

每个方法都要在每个实例上重新创建一遍。在前面的例子中,person1 和 person2 的 sayName() 方法并不是同一个 Function 的实例。因为函数是对象,所以每定义一个函数,也就实例化了一个对象。(new Function())。

解决的办法,可以把函数定义移到构造函数外部。

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayName = sayName;
}

function sayName() {alert(this.name);
}

var person1 = new Person("Jack", 23);
var person2 = new Person("Nick", 22);

但新问题是:在全局作用域定义的函数实际上只能被某个对象调用,这让全局作用域名不副实。而且,如果对象需要定义很多方法,那么就要定义多个全局函数,于是这个自定义的引用类型就没有丝毫封装性可言。

6.2.3 原型模式

每个函数都有一个 prototype(原型)属性,这个属性是一个指针,指向一个对象,这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。

也可以说 prototype 就是通过调用构造函数而创建的对象实例的原型对象。使用原型对象的好处是可以让所有“对象实例”共享“原型对象”所包含的属性和方法。

6. 原型对象的问题

  1. 它省略了为构造函数传递初始化参数这一环节,结果所有实例在默认情况下都将取得相同的属性值。
  2. 原型模式的最大问题是它的共享的本性所导致的。这个问题在包含引用类型值的属性上显而易见。
function Person() {}

Person.prototype = {
    constructor: Person,
    friends: ["Jack"]
};

var person1 = new Person();
var person2 = new Person();

person1.friends.push("Nick");

alert(person1.friends); // "Jack, Nick"
alert(person2.friends); // "Jack, Nick"
alert(person1.friends === person2.friends); // true

实例一般都是要有自己的全部属性的,然而由于 person1.friends 和 person2.friends 都指向同一个数组,导致修改其中一个,就会在另一个上同步共享。

6.2.4 组合使用构造函数模式和原型模式

构造函数模式用于定义实例属性,原型模式用于定义方法和共享的属性。

  • 每个实例都会拥有自己的一份实例属性的副本,但同时又共享着对“方法”的引用,最大限度地节约了内存。
  • 这种混成模式还支持向构造函数传递参数。
function Person(name, age) {
    this.name = name;
    this.age = age;
    this.friends = ["Jack"];
}

Person.prototype = {
    constructor: Person,
    sayName: function() {alert(this.name);
    }
}

var person1 = new Person("Nick", 22);
var person2 = new Person("Mike", 21);

person1.friends.push("Jane");

alert(person1.friends); // "Jack, Jane"
alert(person2.friends); // "Jack"
alert(person1.friends === person2.friends); // false
alert(person1.sayName === person2.sayName); // true

混成模式中,不同实例引用了不同的数组,因此原型对象的问题解决了。

6.2.5 动态原型模式

function Person(name, age) {
    // 属性
    this.name = name;
    this.age = age;
    
    // 方法
    if (typeof this.sayName != "function") {Person.prototype.sayName: function() {alert(this.name);
        }
    }
}

var person1 = new Person("Nick", 22);
person1.sayName();

if 语句检查的可以是初始化之后应该存在的任何属性或方法——不必用一大堆 if 语句检查每个属性和每个方法,只要检查其中一个即可。

对于采用这种模式创建的对象,可以使用 instanceof 操作符确定它的类型。

使用动态原型模式时,不能使用对象字面量重写原型,如果重写,则会切断现有实例与新原型之间的联系。

6.2.6 寄生构造函数模式

6.2.7 稳妥构造函数模式

6.3 继承

由于函数没有签名,在 ECMAScript 中无法实现【接口继承】。ECMAScript 只支持【实现继承】,而且其实现继承主要依靠【原型链】来实现。

6.3.1 原型链

基本思想

利用原型让一个引用类型继承另一个引用类型的属性和方法。

构造函数、原型、实例 之间的关系:

  • 每个构造函数都有一个原型对象;
  • 原型对象都有一个指向构造函数的指针;
  • 实例都包含一个指向原型对象的内部指针[[Prototype]]

实现原型链的基本模式:

function A() {this.aproperty = true;}

A.prototype.getAValue = function() {return this.property;};

function B() {this.bproperty = false;}

// 继承了 A,创建了 B 的实例,并将实例赋给 B.prototype
B.prototype = new A();

B.prototype.getBValue = function() {return this.bproperty;}

var instance = new B();
alert(instance.getAValue); // true

实现的本质是 重写原型对象,代之以一个新实例的类型。原来存在于 A 的实例中的所有属性和方法,现在也存在于 B.prototype 中。

1. 默认原型

所有应用类型默认都继承了 Object,而这个继承也是通过原型链实现的。所有函数的默认原型都是 Object 的实例,因此默认原型都会包含一个内部指针,指向 Object.prototype。这也是所有自定义类型都会继承 toString()valueOf() 等默认方法的根本原因。

2. 确定原型和实例的关系

可以通过两种方式来确定原型和实例之间的关系。
方法一:instanceof,只要用这个操作符来测试实例和原型链中出现过的构造函数,结果就会返回 true。

alert(instance instanceof Object); // true
alert(instance instanceof A); // true
alert(isntance instanceof B); // true

由于原型链的关系,instance 是 Object、A、B 中任何一个类型的实例。

方法二:isPropertyOf,只要是原型链中出现过的原型,都可以说是该原型链所派生的实例的原型,因此该方法也会返回 true。

alert(Object.prototype.isPropertyOf(instance)); // true

3. 谨慎地定义方法

子类型有时候需要覆盖超类型中的某个方法,或者需要添加超类型中不存在的某个方法。给原型添加方法的代码一定要放在替换原型的语句之后。

function A() {this.property = true;}

A.prototype.getAValue = function() {return this.property;};

function B() {this.bproperty = false;}

// 继承了 A
B.prototype = new A();

// 添加新方法
B.prototype.getBValue = function() {return this.bproperty;}

// 重写超类型方法
B.prototype.getAValue = function() {return false;}

注意,通过 A 的实例调用 getAValue() 方法时,仍然继续调用原来的方法。

在通过原型链实现继承时,不能使用对象字面量创建原型方法。因为这样做会重写原型链。

function A() {this.property = true;}

A.prototype.getAValue = function() {return this.property;};

function B() {this.bproperty = false;}

// 继承了 A
B.prototype = new A();

// 添加新方法
B.prototype = {getBValue: function() {return this.bproperty;}
};

var instance = new B();
alert(instance.getAValue); // error!

4. 原型链的问题

  1. 最主要的问题来自包含引用类型值的原型。包含引用类型值的原型属性会被所有实例共享;而这也正是为什么要在构造函数中,而不是在原型对象中定义属性的原因。在通过原型来实现继承时,原型实际上会变成另一个类型的实例。于是,原先的实例属性也就顺理成章地变成了现在的原型属性了。
  2. 在创建子类型的实例时,没有办法在不影响所有对象实例的情况下,不能向超类型的构造函数中传递参数。因此,实践中很少会单独使用原型链

6.3.2 借用构造函数(constructor stealing)

又叫“伪造对象”或“经典继承”。
基本思想
在子类型构造函数的内部调用超类型构造函数。函数只不过是在特定环境中执行代码的对象,因此通过使用 apply() 和 call() 也可以在(将来)新创建的对象上执行构造函数。

function A() {this.colors = ["red"];
}

function B() {
    // 继承了 A
    A.call(this);
}

var instance1 = new B();
instance1.colors.push("blue");
alert(instance1.colors); // "red, blue"

var instance2 = new B();
alert(instance2.colors); // "red"

1. 传递参数

相对于原型链而言,借用构造函数有一个很大的有时,可以在子类型构造函数中向超类型构造函数传递参数。

function A(name) {this.name = name;}

function B() {
    // 继承了 A
    A.call(this, "Jack");
}

var instance1 = new B();
alert(instance1.name); // "Jack"

为了确保 A 构造函数不会重写子类型的属性,可以在调用超类型构造函数后,再添加应该在子类型中定义的属性。

2. 借用构造函数的问题

  1. 方法都在构造函数中定义,因此函数复用就无从谈起;
  2. 在超类型的原型中定义的方法,对子类型而言也是不可见的,结果所有类型都只能使用构造函数模式。因此借用构造函数也很少单独使用

6.3.3 组合继承(combination inheritance)

又叫“伪经典继承”,组合了原型链继承和借用构造函数继承。既通过在原型上定义方法实现了函数服用,又能保证每个实例都拥有自己的属性。

function A(name) {
    this.name = name;
    this.colors = ["red"];
}

A.prototype.sayName = function() {alert(this.name);
};

function B(name, age) {
    // 继承属性
    A.call(this, name); // 第二次调用 A
    this.age = age;
}

// 继承方法
B.prototype = new A(); // 第一次调用 A
B.prototype.constructor = B;
B.prototype.sayAge = function() {alert(this.age);
}

var instance1 = new B("Jack", 22);
instance1.colors.push("blue");
alert(instance1.colors); // "red, blue"
instance1.sayName(); // "Jack"
instance1.sayAge(); // 22

var instance2 = new B("Nick", 21);
alert(instance2.colors); // "red"
instance2.sayName(); // "Nick"
instance2.sayAge(); // 21

组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,成为 JS 中最常用的继承模式。而且,instanceof 和 isPropertyOf() 也能够用于识别基于组合继承创建的对象。

组合模式的问题

无论什么情况下,都会调用两次超类型构造函数:一次是在创建子类型原型的时候,一次是在子类型构造函数内部。

6.3.4 原型式继承

function object(o) {function F(){};
    F.prototype = o;
    return new F();}

原型式继承要求必须有一个对象作为另一个对象的基础。

ECMAScript 5 中新增了 Object.create() 来规范原型式继承。接收 2 个参数:1、一个用做新对象原型的对象;2、(可选)一个为新对象定义额外属性的对象。在传入一个参数的情况下,Object.create() 和 object() 的行为相同。

var person = {};
var anotherPerson = Object.create(person);

如果只想让一个对象与另一个对象保持类似的情况下,原型式继承完全可以胜任。但是包含引用类型值的属性始终都会共享相应的值,这点与原型模式一样。

6.3.5 寄生式继承(parasitic)

它的思路与寄生构造函数和工厂模式相似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真地是它做了所有工作一样返回对象。

function createAnother(original) {var clone = object(original); // 通过调用函数创建一个新对象
    clone.sayHi = function() { // 以某种方式增强对象
        alert("Hi");
    };
    return clone; // 返回对象
}

var person = {
    name: "Jack",
    friends: ["Nick", "Tony"]
};

var anotherPerson = createAnother(person);
anotherPerson.sayHi(); // "Hi"

新对象不仅具有 person 的所有属性和方法,还有自己的方法。

在主要考虑“对象”而不是“自定义类型”和“构造函数”的情况下,寄生式继承也是一种有用的模式。object() 并不是必需的;任何能够返回新对象的函数都适用于该模式。

注意:使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率;这一点与构造函数模式类似。

6.3.6 寄生组合式继承

本质上,就是使用“寄生式继承”来继承超类型的原型,再将结果指定给子类型的原型。

function inheritPrototype(sub, super) {var prototype = Object(super); // 创建对象
    prototype.constructor = sub; // 增强对象
    sub.prototype = prototype; // 指定对象
}
  1. 创建超类型原型的一个副本;
  2. 为创建的副本添加 constructor 属性,从而弥补因【重写原型】而失去的默认的 constructor 属性;
  3. 将新创建的对象(即副本)赋值给子类型的原型。

修改之前的例子:

function A(name) {this.name = name;}

A.prototype.sayName = function() {alert(this.name);
};

function B(age) {A.call(this, "Jack");
    this.age = age;
}

inheritPrototype(B,A);

B.prototype.sayAge = function() {alert(this.age);
}

该模式的高效率体现在它只调用了一次 A 构造函数,并且因此避免了在 B 的 prototype 上面创建不必要的、多余的属性。与此同时,原型链还能保持不变;因此,还能够正常使用 instanceof 和 isPrototypeOf() 方法。开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

6.4 小结

ECMAScript 支持面向对象(OO)变成,但不使用类或者接口。对象可以在代码执行过程中创建和增强,因此具有动态性而非严格定义的实体。在没有类的情况下,可以采用下列模式创建对象:

  • 工厂模式,使用简单的函数创建对象,为对象添加属性和方法,然后返回对象。这个模式后来被构造函数所取代。
  • 构造函数模式,可以创建自定义引用类型,可以像创建内置对象实例一样使用 new 操作符。不过,构造函数模式的缺点是:它的每个成员都无法得到复用,包括函数。由于函数可以不局限于任何对象(即与对象具有松散耦合的特点),因此没有理由不在多个对象间共享函数。
  • 原型模式,使用构造函数的 prototype 属性来指定那些应该共享的属性和方法。组合使用构造函数模式和原型模式时,使用构造函数定义实例属性,使用原型模式定义共享的属性和方法。

JS 主要通过原型链实现继承。原型链的构建是通过将一个类型的实例赋值给另外一个构造函数的原型实现的。这样,子类型就可以继承超类型的属性和方法,这一点与基于类的继承很相似。

原型链的问题是:对象实例共享所有继承的属性和方法,因此不适宜单独使用。解决这个问题的技术是借用构造函数,即在子类型构造函数的内部调用超类型构造函数。这样就可以做到每个实例都具有自己的属性,同时还能保证只使用构造函数模式来定义类型。

使用最多的继承模式是组合继承,这种模式使用原型链继承共享的属性和方法,通过借用构造函数继承实例属性。
此外,还存在下列可供选择的继承模式:

  • 原型式继承,可以在不必预先定义构造函数的情况下实现继承,其本质是执行对给定对象的浅复制。而复制得到的副本还可以得到进一步改造。
  • 寄生式继承,与原型式继承非常相似,也是基于某个对象或某些信息创建一个对象,然后增强该对象,最后返回对象。为了解决组合继承模式由于多次调用超类型构造函数而导致的低效率问题,可以将这个模式与组合继承模式一起使用。
  • 寄生组合式继承,集寄生式继承与组合继承的优点于一身,是实现基于类型继承的最有效方式。
退出移动版