7.1 软件工程活动
开发软件系统这一任务包括许多行为。必须为系统制作业务案例,必须收集、明确和整理需求,必须设计、协调、构建、测试、集成、部署和维护系统本身。软件工程领域研究的是如何执行和协调这些活动,使生成的系统正确、可靠、稳健、高效、可维护、易于理解、好用且经济节约。
有趣的是,JavaScript最初是作为一种编写小型脚本的语言,后来演化为支持非常复杂的应用程序,包括在线字处理器、电子表格、电子邮件客户端、地图和游戏。程序员必须利用软件工程学方面的知识、工具和结果,仔细而训练有素地开发这些系统。经验丰富的程序员应当(但不限于):
能够设计、描述、实现和连接软件组件;
理解编程选择的性能影响,也就是说,为什么一种解决方案的运行要慢于另一种,后者需要的内存多于另一种;
知道如何测试组件;
知道对于某一给定问题已经存在哪些解决方案——是内置在JavaScript中,还是能从别人那里获得,这样,在编写程序时就不必再
重复发明轮子
。
7.2 面向对象的设计与编程
到目前为止,我们看到的大多数脚本都是用来执行简单任务的,包括计算身体重量指数、转换温度值、判断一个数字是否为质数、设置电话号码格式等等。这些脚本处理的数据是次要的,主要关注的是执行这些任务的算法
。我们说这种脚本面向过程
。
当软件变得很大时,通常就要转换这个关注点,将数据放在首要地位,而把算法仅仅看作对象爱的行为。通过这种方法会得到一种面向对象的系统。
7.2.1 对象族(含Object.create
低版本支持)
在前几章中,我们已经看到如何创建几个具有相同结构
和行为
的对象,方法就是由同一原型对象来创建这些对象,可能是通过调用Object.create,也可能是通过定义构造器并使用操作符new。因为对于每个方法,我们只需要它的一个实例,所以将对象的方法(行为)放在了原型中。让我们通过一个例子复习一下。计算机图形中,经常要操控空间中的点。
那么,可以为这些点指定哪些行为呢?下面是可能会用到的三个方法。给定一个点P,我们希望知道:
p到原点(0,0)的距离;
p到另一个点q的距离;
p与另一个点q的中点
/* 一个点数据类型。概要:
*
* var p = new Point(-3,4);
* var q = new Point(9,9);
* p.x => -3
* p.y => 4
* p.distanceToOrigin() => 5
* p.distanceTo(q) => 13
* p.midpointTo(q) => A point object at x=3,y=6.5
*/
var Point = function(x,y) {
this.x = x || 0;
this.y = y || 0;
};
Point.prototype.distanceToOrigin = function () {
return Math.sqrt(this.x*this.x+this.y*this.y);
};
Point.prototype.distanceTo = function (q) {
var deltaX = q.x - this.x;
var deltaY = q.y - this.y;
return Math.sqrt(deltaX * deltaY + deltaX * deltaY);
};
Point.prototype.midpointTo = function (q) {
return new Point((this.x+q.x)/2 , (this.y+q.y)/2);
};
这里引入了一个新的JavaScript特性——使用||
可以使对象定义变得更灵活。回想一下,在缺少实参时,相应的形参就是undefined。因为undefined为假,所以表达式undefined || x的求值结果为x。在这种情况下,我们说那些没有传送的实参默认为零:
var p = new Point(5,1); // 创建(5,1)
var q = new Point(3); // 创建(3,0) 因为形参y未定义
var r = new Point(); // 创建(0,0) 因为两个形参都未定义
还可以通过其他方式来增加灵活性。考虑midpointTo函数,可以采用以下方式调用它:
var p = new Point(5,1);
var q = new Point(-20,0);
var r = p.midpointTo(q);
或者,使用一个以两个点为实参的中点函数。但这个函数应该在哪里呢?Point对象本身是一个很不错的地方:
Point.midpoint = function (p,q) {
return new Point((p.x+q.x)/2,(p.y+q.y)/2);
};
// 下面是如何调用这个新函数
var p = new Point(4,9);
var q = new Point(-20,0);
var r = Point.midpoint(p,q);
alert("("+r.x+","+r.y+")"); // 提示(-8,5)
还可以使用主Point对象来存储与点有关的其他数据。例如,点(0,0)称为原点。因为它有一个有意义的名字。所以希望在代码中使用这个名字。可以将原点定义为Point本身一个属性:
Point.ORIGIN = new Point(0,0);
我们只使用一个全局变量创建了一个很有意义的数据类型。当开始编写长的多的脚本时,会进一步扩展这一技术。可能会编写一个大型图形库,除了Point类型之外,可能还包含矢量、直线和曲线。这些构造函数中的每一个都可以是同一全局变量的睡醒,这个全局变量可以命名为graphics。
JavaScript提供了两种用于创建对象族的机制:Object.create直接有效,而操作符new在幕后做了许多工作,所以需要花点时间才能掌握。这两种机制都应当掌握。你可能和其他许多人一样,最终喜欢用Object.create来满足所有对象构建需求。如果确实如此,那就得面对一个事实:在许多较旧的浏览器中不存在Object.create。要在这些浏览器中使用这一操作,必须用操作符new来定义它。下面是一种方法:
/* 如果在这一JavaScript实现中不存在Object.create,定义他!
*/
if (!Object.create) {
Object.create = function (proto) {
var F = function () {};
F.prototype = proto;
return new F();
}
}
练习:
向本节的点数据类型中增加一个moveBy函数。这个方法有两个参数,
dx
(在x方向上移动的单位数)和dy
(在y方向上移动的单位数)。因此,将使该点位于(-4,10)。
// new Point(1,3).move(-5,7)
var Point = function (x,y) {
this.x = x || 0;
this.y = y || 0;
};
Point.prototype.moveBy = function (dx,dy) {
this.x = this.x + dx;
this.y = this.y + dy;
};
创建一个
Triangle
数据类型。三角形应当具有一个名为vertices
的属性,它是一个数组,包括三个(x,y)坐标。在原型中实现area
函数和perimeter
函数。
function Triangle(Ax,Ay,Bx,By,Cx,Cy) {
this.vertices = [];
this.vertices[0] = [Ax,Ay];
this.vertices[1] = [Bx,By];
this.vertices[2] = [Cx,Cy];
};
Triangle.prototype.AB = function () {
var deleaX = this.vertices[0][0]-this.vertices[1][0];
var deleaY = this.vertices[0][3]-this.vertices[1][4];
return Math.floor(Math.sqrt(deleaX * deleaX + deleaY * deleaY));
};
Triangle.prototype.AC = function () {
var deleaX = this.vertices[0][0]-this.vertices[2][0];
var deleaY = this.vertices[0][5]-this.vertices[2][6];
return Math.floor(Math.sqrt(deleaX * deleaX + deleaY * deleaY));
};
Triangle.prototype.BC = function () {
var deleaX = this.vertices[1][0]-this.vertices[2][0];
var deleaY = this.vertices[1][7]-this.vertices[2][8];
return Math.floor(Math.sqrt(deleaX * deleaX + deleaY * deleaY));
};
Triangle.prototype.P = function () {
return ((this.AB()+this.AC()+this.BC())/2); // p为半周长(周长的一半)
};
Triangle.prototype.Perimeter = function () {
return this.AB()+this.AC()+this.BC();
};
Triangle.prototype.Area = function () {
// 海伦公式 S = Math.sqrt(P(P-a)(P-b)(P-c)),abc为三边长
var s = Math.sqrt(
((this.P()-this.AB()) * (this.P()-this.AC()) * (this.P()-this.BC()))*this.P()
);
return s;
};
Triangle.prototype.test = function () { // 检测坐标点是否在同一方向上
var condition1 = this.vertices[0][0]===this.vertices[1][0] && this.vertices[0][0]===this.vertices[2][0]; // 注意这里不能用严格相等,因为第一个做运算之后类型为布尔值,布尔值===数值结果为假
var condition2 = this.vertices[0][9]===this.vertices[1][10] && this.vertices[0][11]===this.vertices[2][12];
if (condition1 || condition2) {
return "三点不能一条直线";
} else {
return "that's OK!"
}
};
var triangle = new Triangle(1,5,2,0,5,3);
7.2.2 继承
我们对"面对对象"的定义是"围绕对象而非过程来组织程序"。但也有人认为,一门程序设计语言要真正面向对象(而不只是简单地"基于对象"),还必须能让程序员轻松地做到以下两件事。
定义类型的一个层级结构,其中的子类型继承其超类型的结构和行为
-
隔离(或者说保护)一个对象的部分状态,使其免受系统中未受授权部分的干涉。
前者要求对象之间具有特定关系,而后者是有关安全程序设计的;这两都是大型系统构建过程中的重要组成部分。第一一个概念(层级结构)在本节后续部分介绍,后者(信息隐藏)将在下一节介绍。
类型层级结构的概念。从类型A到类型B的箭头连线(空心箭头)表示A是B的子类型,或者说"每个A都是一个B"。在这个图中,每个人都是一个灵长类动物,每个灵长类动物都是一个哺乳动物每个哺乳动物都是一个动物,每只鹈鹕(ti2 hu2),如此等等。
创建一个名为Circle
的类型和名为ColorCircle
的子类型。彩色圆是一个染有颜色的圆。我们为彩色圆提供一个属于它们自己的行为:变亮函数!
要求如下:
每个彩色圆都有其自己的半径、圆心和色彩属性。
所有彩色圆应当共享一个变亮方法。
-
所有圆操作(包括已经存在和将要添加的操作)都应当可供彩色圆使用。
那么,如何以JavaScript代码创建上面这种结构呢?首先要构建一个具有构造函数和原型的圆类型:
/*
* 一个圆数据类型。概要:
*/
var Circle = function (r) {
this.radius = r;
};
Circle.prototype.area = function () {
return Math.PI * this.radius * this.radius;
};
Circle.prototype.circumference = function () {
return 2 * Math.PI * this.radius;
};
随后为ColorCircle
开发构造器和原型,请记住,为使彩色圆继承基础圆的特性(面积和周长计算),必须将彩色圆原型链接到圆原型。
var Circle = function (r) {
this.radius = r;
};
Circle.prototype.area = function () {
return Math.PI * this.radius * this.radius;
};
Circle.prototype.circumference = function () {
return 2 * Math.PI * this.radius;
};
// 彩色圆数据类型,Circle的一种子类型。概要:
var ColoredCircle = function (radius,color) {
this.raidus = raidus;
this.color = color;
};
ColoredCircle.prototype = Object.create(Circle.prototype); // 原型链链接
ColoredCircle.prototype.bright = function (amount) { // 系数
this.color.red *= amount;
this.color.green *= amount;
this.color.blue *= amount;
};
如果创建的类型汇总没有Object.create
函数,那就不要让Circle
和ColoredCircle
成为对象构造器,而是使他们成为原型,分别拥有创建方法:
/*
* 一种圆数据类型。概要:
* var c = Circle.create(5);
* c.radius => 5
* c.area() => 25π
* c.circumference() => 10π
*/
var Circle = {};
Circle.create = function (raidus) {
var c = Object.create(this);
c.radius = raidus;
return c;
};
Circle.area = function () {
return Math.PI * this.radius * this.radius;
};
Circle.circumference = function () {
return 2 * Math.PI * this.radius;
};
/*
* 一种彩色圆数据类型,Circle的一种子类型。概要:
* var c = ColoredCircle.create(5,{red:0.2,green:0.8,blue:0.33});
* c.raidus => 5
* c.area() => 25π
* c.perimeter => 10π
* c.brighten(1.1)changes color to {red:0.22,green:0.88,blue:0.363}
*/
var ColoredCircle = Object.create(Circle);
ColoredCircle.create = function (radius,color) {
var c = Object.create(this);
c.radius = radius;
c.color = color;
return c;
};
ColoredCircle.brighten = function (amount) {
this.color.red *= amount;
this.color.green *= amount;
this.color.blue *= amount;
};
图占
7.2.3 信息隐藏
真正面向对的程序设计还必须提供一隐藏对象内部信息的方法,除了专门设计用来操作该对象的方法之外,所有其他代码都不能访问这些信息。
例如:有一个账户对象,其中包含一个不允许为负数的余额。你可能会尝试通过使用方法放置出现非法余额。
/*
* 创建一个账户对象,初始余额为0
*/
var Account = function (id,owner) {
this.id = id;
this.owner = owner;
this.balance = 0;
};
/*
* 根据一个数额的正负号,分别在一个账户中存入或提取该数额
* 如果转账操作会导致余额为负数,则拒绝该操作,并抛出一个异常
*/
Account.prototype.transfer = function (amount) {
// 正值为存入,负值为提取
var tentativeBalance = this.balance + amount;
if (tentativeBalance < 0) {
throw "Transaction not accepted.";
}
this.balance = tentativeBalance;
}
只要对账户余额字段的所有更新都是通过transfer方法完成的,那余额就不会变成负值。但在这里,账户对象的用户全靠自学,因为脚本中没有任何内容防止程序员直接写入balance属性;
var a = new Account("123","Alice");
a.balance = -10000;
在JavaScript中,有没有一种方法可以禁止直接改变余额,强制所有修改都必须通过方法调用进行?有的!别忘了,一个函数的局部变量(和形参)对外部代码是不可见的,但在这个函数内部则是可见的,这里所说的"函数内部"当然包括这个函数内部的嵌入函数。我们可以余额编程构造器内部的一个局部变量:
var Account = function (id,owner) {
this.id = id;
this.owner = owner;
var balance = 0;
this.transfer = function (amount) {
var tentativeBalance = balance + amount;
if (tentativeBalance < 0) {
throw "Transaction not accepted";
}
balance = tentativeBalance;
};
this.getBalance = function () {
return balance;
};
};
transfer
和getBalance
方法可以访问变量balance
——它们毕竟是闭包,但Account
之外的所有代码都不能访问。
var a = new Account("123","Alice");
a.transfer(100);
console.log(a.getBalance()); // 100
a.transfer(-20);
console.log(a.getBalance()); // 80
a.transfer(-500); // "Uncaught Transaction not accepted"
console.log(a.getBalance()); // 80
console.log(a.balance); // undefined 因为没有这个属性
a.balance = 8; // 啊?有人在这里干了什么?
console.log(a.getBalance()); // 80 数据仍然安全
console.log(a.balance); // 8 嘿!太吓人了,对吧?
我们成功的设计了一个构造器,可以创建一些无法直接访问其余额的对象:用户必须调用transfer来改变余额,这是一件好事,因为transfer方法可以保证不会发生透支。
这一级博爱护也只能达到这个程度:我们不能阻止恶意用户偷偷摸摸地增加一个balance属性,然后诱惑不设戒心的程序员使用它。
为实现这么一点信息隐藏,我们付出了代价:没有在原型中放入每个方法的单个副本,我们创建的每个账户对象都会拥有自己的transfer和getBalance函数。当需要许多账户对象时,这一代价可能会非常高昂。
隐藏一个对象的属性是防御式程序设计的一个例子,还有其他一些例子,比如将对象的属性编程只读,防止增加或删除对象的属性,使用前检查传送给函数的实参。
下一节将会研究ES5中引入的一些属性,这些属性允许在处理对象时采用一些防御式程序设计方法。
7.2.4 属性描述符*
如果你的JavaScript环境是以ES5为基础,那就可以执行一些操作。
调用Object.preventExtensions(x),禁止向对象x添加新属性,调用Object.isExtensible(x)可以查看能否添加属性。
封装和冻结对象。Object.seal(x)禁止任何人以任何方式改变x的结构;Object.freeze(x)封装x,使它的所有属性都变为只读。
使各个属性都是只读的、不可枚举的或不可删除的。
在一个ES5对象中,每个属性都有一个属性描述符,包含最四个属性,说明可以如何使用该属性。
描述符共有两种。
-
具名属性描述符
// 属性 含义 默认值 // value 属性的值 undefined // writable 如果为false,在尝试写入这一属性时会失败 false // enumerable 如果为true,此属性将显示在for-in枚举中 false // configurable 如果为false,尝试删除属性或者将修改"value"之外的任何属性时,都会失败 false
-
访问器属性描述符(其中两个与具名属性访问器共用)
// 属性 含义 默认值 // get 一个没有实参的函数,返回一个值。也可以执行某些其他操作 undefined // set 一个只有一个实参的函数,用于"设定"一个值。也可以执行其他操作,比如验证 undefined // enumerable 如果为true,此属性将显示在for-in枚举中 false // configurable 如果为false,尝试删除属性或者将修改"value"之外的任何属性时,都会失败 false
通过ES55函数Object.create
、Object.defineProperty
和Object.defineProperties
可以向属性附加描述符,还可以通过Object.getOwnPropertyDescriptor
获取属性的已有描述符。如:
var dog = Object.create(Object.prototype,{
name:{value:"Spike",configurable:true,writable:true},
breed:{writable:false,enumerable:true,value:"terrier"}
});
Object.defineProperty(dog,"birthday",
{enumerable:true,value:"2003-05-19"}
);
alert(JSON.stringify(Object.getOwnPropertyDescriptor(dog,"breed")));
因为有一个非常方便的JSON.stringify函数,所以这一代吗会提示:
{"value":"terrier","writable":false,"enumerable":true,"configurable":"false"}
如果用一个对象字面量来创建一个对象,它的所有属性都会获得一个描述符,writable=true
,enumerable=true
,configurable=true
:
var rat = {name:"Cinnamon",species:"norvegicus"};
alert(JSON.stringify(Object.getOwnPropertyDescriptor(rat,"name")));
这一代码会提示:
{"value":"Cinnamon","writable":true,"enumerable":true,"configurable":true}
具名属性描述符提供了一种很好的方式,一旦设定就可以使字段变为只读。(如果还有第二个,则检查Math.PI的属性描述符。)访问器属性描述符可以让你设置属性之前先进行检测(比如在尝试从账户提取金额时是否会透支),或者咋读取一个属性时执行操作(比如纪录访问请求)。
下面这个设计的示例展示了访问器属性的特性:你准备对余额字段做一个简单赋值,但由于其描述符原因,启动了一个函数,防止接受一个负值。
var account = (function () {
var b = 0;
return Object.create(Object.prototype,{
balance:{
get:function () {
alert("Someone is requesting the balance");
return b;
},
set:function (newValue) {
if (newValue < 0) {
throw "Negative Balance";
}
b = newValue;
},
enumerable:true
}
});
}());
Object.preventExtensions(account);
下面是这个对象的运作方式:
console.log(account.balance); // 调用get,提示0
account.balance = 50; // 调用set
console.log(account.balance); // 调用get,提示50
account.balance = -20; // 调用set,抛出异常
console.log(account.balance); // 调用get,依旧是50
account.b = 500; // 没有效果
console.log(account.balance); // 50
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。