7.1 软件工程活动

开发软件系统这一任务包括许多行为。必须为系统制作业务案例,必须收集、明确和整理需求,必须设计、协调、构建、测试、集成、部署和维护系统本身。软件工程领域研究的是如何执行和协调这些活动,使生成的系统正确、可靠、稳健、高效、可维护、易于理解、好用且经济节约。
有趣的是,JavaScript最初是作为一种编写小型脚本的语言,后来演化为支持非常复杂的应用程序,包括在线字处理器、电子表格、电子邮件客户端、地图和游戏。程序员必须利用软件工程学方面的知识、工具和结果,仔细而训练有素地开发这些系统。经验丰富的程序员应当(但不限于)

  • 能够设计、描述、实现和连接软件组件;

  • 理解编程选择的性能影响,也就是说,为什么一种解决方案的运行要慢于另一种,后者需要的内存多于另一种;

  • 知道如何测试组件;

  • 知道对于某一给定问题已经存在哪些解决方案——是内置在JavaScript中,还是能从别人那里获得,这样,在编写程序时就不必再重复发明轮子


7.2 面向对象的设计与编程

到目前为止,我们看到的大多数脚本都是用来执行简单任务的,包括计算身体重量指数、转换温度值、判断一个数字是否为质数、设置电话号码格式等等。这些脚本处理的数据是次要的,主要关注的是执行这些任务的算法。我们说这种脚本面向过程

当软件变得很大时,通常就要转换这个关注点,将数据放在首要地位,而把算法仅仅看作对象爱的行为。通过这种方法会得到一种面向对象的系统。


7.2.1 对象族(含Object.create低版本支持)

在前几章中,我们已经看到如何创建几个具有相同结构行为的对象,方法就是由同一原型对象来创建这些对象,可能是通过调用Object.create,也可能是通过定义构造器并使用操作符new。因为对于每个方法,我们只需要它的一个实例,所以将对象的方法(行为)放在了原型中。让我们通过一个例子复习一下。计算机图形中,经常要操控空间中的点。
图片描述
那么,可以为这些点指定哪些行为呢?下面是可能会用到的三个方法。给定一个点P,我们希望知道:

  1. p到原点(0,0)的距离;

  2. p到另一个点q的距离;

  3. 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函数,那就不要让CircleColoredCircle成为对象构造器,而是使他们成为原型,分别拥有创建方法:

    /*
     *    一种圆数据类型。概要:
     * 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;
        };
    };

transfergetBalance方法可以访问变量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.createObject.definePropertyObject.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


Queen
139 声望20 粉丝