6. 面向对象的程序设计

ECMA-262 对象的定义

对象是无序属性的集合,属性值可以是基本值、对象和函数

  1. 对象是一组没有特定顺序的名值对儿。
  2. 属性就是名值对儿。
  3. 属性具有特性。

6.1 理解对象

  • 属性类型
    • ECMAScript中有两种属性:数据属性和访问器属性
    • ES5定义了只有内部才能用的attribute特性,描述property属性的各种characteristic特征
    • 数据属性
      • 数据属性有4个描述其行为的特性
        • [[Configurable]] 配置; 默认true;能否通过delete删除属性从而重新定义属性;能都修改属性的特性;能否把属性修改为访问器属性
        • [[Enumerable]] 枚举; 默认true;能否for-in循环
        • [[Writable]] 可写; 默认true;能否修改
        • [[Value]] 读值; 默认undefined;包含属性的数据值;读时从此读;写入时,新值存在此
      • 要修改属性的默认特性,必须使用Object.defineProperty(obj, prop, descriptor)
        • 属性所在的对象
        • 属性的名字
        • 描述符对象,拥有上述四个值
      • 使用Object.defineProperty()创建属性时,如果不指定,configurable,writable,enumerable,默认值都会是false,所以不要不指定
    • 访问器属性
      • 访问器属性不包含数据值,包含一对儿getter和setter函数
        • [[Configurable]]
        • [[Enumerable]]
        • [[Get]]
        • [[Set]]
      • 访问器属性不能直接定义,必须通过Object.defineProperty()来定义
      • 在属性前加下划线_value,标记该属性为私有属性,只能通过对象方法访问
      • 遗留实现
        • __defineGetter__()
        • __defineSetter__()
// 数据属性descriptor
var person = {}
Object.defineProperty(person, "name", {
	configurable: true,
	writable: true,
	enumerable: false,
	value: 'Nicholas'
})

for(let key in person){
	console.log(key) // none
}
// 访问器属性
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++;
		}
	}
})

// 旧法
book.__defineGetter__("year", function(){
	return this._year;
})
book.__defineSetter__("year", function(newValue){
	if(newValue > 2004){
		this._year = newValue;
		this.edition++;
	}
})
  • 一次定义多个属性
    • Object.defineProperties(obj, props)
// 与上文创建的对象一致
var book = {};
Object.defineProperties(book, {
	_year: {
		configurable: true,
		writable: true,
		value: 2004
	},
	edition: {
		writable: true,
		value: 1
	},
	year: {
		get: function(){
			return this._year;
		},
		set: function(newValue){
			if(newValue > 2004){
				this._year = newValue;
				this.edition++;
			}
		}
	}
})
  • 读取属性的(attribute)特性
    • Object.getOwnPropertyDescriptor(obj, prop)

6.2 创建对象

1. 工厂模式

function createPerson(name, age, job){
	var obj = new Object();
	obj.name = name;
	obj.age = age;
	obj.job = job;
	obj.sayName = function(){
		return this.name;
	};
	return obj;
}
  • 【缺点】没有解决对象识别问题,无法知道一个对象的类型

2. 构造函数模式

function Person(name, age, job){
	this.name = name;
	this.age = age;
	this.job = job;
	this.sayName = function(){
		return this.name;
	}
}
  • 与工厂模式不同之处:
    • 没有显示创建对象
    • 直接将属性和方法赋给了this对象
    • 没有return语句
  • new操作符经历4个步骤
    1. 创建一个对象
    2. 将构造函数作用域赋给新对象(this指向新对象)
    3. 执行构造函数中代码(为新对象添加属性和方法)
    4. 返回新对象
  • new新建的对象实例拥有constructor属性,指向构造函数
    • constructor属性最初是用来表示对象类型的
    • 识别对象类型instanceof更可靠
    • **【优点】**创建自定义构造函数意味着将来可以将其实例标识为一种特定的类型,这是构造函数模式胜过工厂模式的地方
    • 这种方式定义的构造函数是定义在Global对象中的
  • 将构造函数当做函数
    • 普通函数调用:会将属性和方法都添加给window对象
    • 在另一个对象的作用域中调用
      • var obj = {}; Person.call(obj, 'Nicholas', '25', 'Engineer');
      • 这会为obj对象添加属性和方法
      • 通常借此实现“继承”
  • **【缺点】**方法在每个实例上创建一遍,只要有新实例就要创建一遍方法副本;不同实例的同名方法是不同函数
// 一种变通的解决方案
function Person(name, age, job){
	this.name = name;
	this.age = age;
	this.job = job;
	this.sayName = sayName; // 所有新建的对象实例共享全局方法
}
function sayName(){
	return this.name;
}

3. 原型模式

  • prototype
    • 构造函数属性
    • 每个函数都有一个prototype属性,这是一个指针,指向一个对象,我们称为原型对象,原型对象的用途是存储特定类型实例的共享属性和方法
    • 只要创建一个新函数,就会为该函数创建一个prototype属性
  • constructor
    • 原型对象属性
    • 所有原型对象,都会获得一个constructor属性,指向prototype属性所在的函数
    • 创建自定义构造函数后,其原型对象默认只会取得constructor属性,其他方法则都是从Object继承来的
    • isPrototypeOf()
  • [[prototype]]
    • 实例对象属性
    • 调用构造函数创建一个新的对象实例后,该对象内部包含[[prototype]]指针,指向构造函数的原型对象
    • __proto__
    • Object.getPrototypeOf()
  • 原型链
    • 可以通过对象实例访问保存在原型中的属性或方法,但不能通过实例重写原型中的属性或方法
    • 实例中的属性会屏蔽原型上的同名属性
    • 修改实例属性也不会影响原型上的属性
    • 可以使用delete删除实例上的属性,从而重新访问原型属性
    • obj.hasOwnProperty(prop)检测实例属性
    • obj.getOwnPropertyDescriptor(prop)检测实例属性描述符
  • in操作符
    • prop in obj 返回对象能否访问给定属性,包括实例和原型属性
    • 经典应用: hasPrototypeProperty()
    • Object.keys(obj) // 所有可枚举
    • Object.getOwnPropertyNames(obj) // 不论是否可枚举
  • 视觉封装 —> 字面量重写原型
    • 用包含所有属性和方法的对象字面量重写整个原型对象
    • 问题是constructor指向了Object
    • **【缺点】**在字面量中显示指定constructor会改变constructor的[[Enumerable]]
    • 使用Object.defineProperty(obj, prop, descriptor)
  • 原型的动态性
    • 实例与原型之间的松散连接关系 —> 为原型对象添加方法会立即在实例上反映,即便实例在添加方法之前创建
    • prototype是指针,不是副本
    • 字面量重写原型对象的**【弊端】**:失去了原型动态性,重写切断了新写原型和已存在实例间的联系
  • 原型模式的**【缺点】**
    • 省略了为构造函数传递初始化参数环节,所有实例默认情况都取相同的初始属性值
    • 方法适合共享,属性不适合

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

  • 混成模式
    • 用构造函数模式定义实例属性
    • 用原型模式定义共享方法和属性
    • 使用最广泛、认可度最高的创建自定义类型模式
function Person(name, age, job){
	// properties
	this.name = name;
	this.age = age;
	this.job = job;
}
// methods
Person.prototype.sayName = function(){
	return this.name;
}

5. 动态原型模式

  • 其他OO开发人员,不习惯独立地定义 构造函数 和 原型
  • 将原型方法挂载 放在构造函数内部执行
function Person(name, age, job){
	// properties
	this.name = name;
	this.age = age;
	this.job = job;
	// methods
	if(typeof this.sayName !== "function"){
		Person.prototype.sayName = function(){
			return this.name;
		}
	}
}

6. 寄生构造函数模式

  • 雷同于工厂模式,用处不多
function Person(name, age, job){
	var obj = new Object();
	// properties
	obj.name = name; 
	obj.age = age;
	obj.job = job;
	// methods
	obj.sayName = function(){
		return this.name;
	}
	// return
	return obj;
}

7. 稳妥构造函数模式

  • 稳妥对象 durable object
    • 没有公共属性
    • 方法中不引用this对象

6.3 继承

许多OO语言都支持两种继承方式:

  • 接口继承
    • 接口继承只继承方法签名
    • 接口继承,继承的是方法签名,也就是函数名和函数形参列表。至于修饰符,返回值,函数体,都不继承。子类自己去实现。这是重写,覆盖,屏蔽,隐藏,随便怎么说。重载是函数名相同,形参列表不同。也就是说重载和重写的区别就是:重写必须是名参都一致,重载则是名一致参不一致。
    • 重写只有在继承时才体现(称为多态)。重载则比较广义,方法名一致参不一致都可以叫重载。
  • 实现继承
    • 实现继承则继承实际的方法
  • JavaScript没有方法签名
  • 方法头指定 [修饰] [返回值类型] [方法名] [形式参数]
  • 形参列表指的是形参的类型、顺序和数目
  • 方法名和形参列表共同组成方法签名[method signature]

1. 原型链

  • 一个构造函数的原型对象是另一个构造函数的实例
    • subType.prototype = new superType() 实现继承
    • 实现的本质是重写原型对象
  • 确定原型和实例的关系
    • obj instanceof Function
    • Function.prototype.isPrototypeOf(obj)
  • 先继承超类,再添加或重写方法
    • 添加或重写方法时,一条一条的挂载
    • 使用超类的实例重写子类原型,实现继承
    • 不要使用字面量重写原型,会切断原型链
  • 问题
    • 原型(超类实例)属性被所有实例共享
    • 创建子类实例时,不能向超类的构造函数传参
// 超类
function SuperType(){
	this.prop = true;
}
SuperType.prototype.getSuperValue = function(){
	return this.prop;
}
// 子类
function SubType(){
	this.subProp = false;
}
// 继承
SubType.prototype = new SuperType();
// 添新
SubType.prototype.getSubValue = function(){
	return this.subProp;
}

var superInstance = new SuperType();
var subInstance = new SubType();

2. 借用构造函数

  • constructor stealing [构造函数借用] [伪造对象] [经典继承]
    • 在子类构造函数内部调用超类构造函数
    • 我们实际上是在未来将要新创建的子类实例的环境下调用了超类构造函数
    • 会在新子类对象上执行超类构造函数中定义的对象初始化代码
    • 结果每个子类实例都会具有自己的属性副本
  • 【优点】 在子类构造函数内向超类构造函数传参
  • 【缺点】 单纯的构造函数模式,方法都写在构造函数体内,函数复用无从谈起
function SuperType(name){
	this.name = name;
	this.sayName = function(){
		return this.name;
	}
}
function SubType(name, age){
	// 继承
	SuperType.call(this, name);
	// 自有
	this.age = age;
	this.sayAge = function(){
		return this.age;
	}
}

3. 组合继承

  • combination inheritance [伪经典继承]
  • 避免了原型链和借用构造函数的缺点,融合了优点,成为了最常用的继承模式
function SuperType(name){
	this.name = name;
	this.colors = ['red','green','blue'];
}
SuperType.prototype.sayName = function(){
	return 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(){
	return this.age;
}

4. 原型式继承

  • 2006年道格拉斯·克罗克福德提出, Prototypal Inheritance in Javascript
  • 基于已有对象创建新对象
  • Object.create(obj, {value: 'xxx'}) 返回以obj为原型的对象
  • 【缺点】包含引用类型的属性始终都会共享相应的值;例如数组

5. 寄生式继承

  • 增强对象:给对象加属性或方法

6. 寄生组合式继承

  • 组合继承的【缺点】 调用两次超类构造函数

    • 首先为了继承方法,用超类实例对象重写子类原型对象
    • 其次为了继承并拥有子类自己的属性,在子类构造函数内调用超类构造函数
    • 实际上,为了继承方法而实例化超类,有点画蛇添足,因为超类实例不仅有方法还有属性,而我们只需要方法;第一次调用超类构造函数后在子类的原型链上已经有了共享属性,第二次调用超类构造函数后,在子类新对象上添加了属性,屏蔽了子类原型上的共享属性。也就是说,子类莫名其妙拥有了两套属性,一套在新对象实例中,一套在原型对象上,而原型上的那套是多余的副本,因为被屏蔽了。
    • 如果我们始终在原型上挂载共享方法,那么我们其实只需要使用超类原型重写子类原型,而不是使用超类实例重写子类原型。
  • 【最后の结论】: 开发人员普遍认为寄生组合式继承是引用类型最理想的继承范式。

// 超类原型重写子类原型,更改指向
function inheritPrototype(subType, superType){
	var prototype = Object(superType.prototype);
	prototype.constructor = subType;
	subType.prototype = prototype;
}