【深入理解jvm虚拟机 第三版 pdf】深入理解Javascript中的面向对象

更新时间:2019-10-19    来源:面向对象编程    手机版     字体:

【www.bbyears.com--面向对象编程】


在学习Javascript的过程中,面向对象是必须要学会的课题。然而,网络上大部分文章只是简单地讲述如何通过代码实现面向对象,忽略了理论和原理方面的知识。本系列文章将从头开始逐步讲解面向对象编程思想及其在Javascript中的实现与应用。

从一个简单的需求开始

假设我们需要在程序中计算各种形状的周长,代码可能是这样的:

var rectangle = {
    name: "长方形1",
    type: "rectangle",
    length: 5,  // 长
    width: 10   // 宽
};
var square = {
    name: "正方形1",
    type: "square",
    length: 5  // 边长
};
var circle = {
    name: "圆形1",
    type: "circle",
    radius: 5  // 半径
};

function computePerimeter(shape) {
    var result;
    switch (shape.type) {
        case "rectangle":
         // 矩形周长:(长+宽)*2
         result = (shape.length + shape.width) * 2;
         break;
        case "square":
         // 正方形周长:边长*4
         result = shape.length * 4;
         break;
        case "circle":
         // 圆形周长:2*PI*半径
         result = 2 * Math.PI * shape.radius;
         break;
    }

    return shape.name + "的周长是" + result;
}
但这种实现方式有着不少缺陷:

要增加更多形状的周长计算时,必须改动computePerimeter函数,可扩展性差;
算法集中在computePerimeter函数,随着形状越来越多,这个函数会越来越庞大,最终变得难以维护;
computePerimeter通过shape.type判定形状类型,但如果type属性值错误(如拼错或大小写错误),就无法计算出正确的周长值。
如何解决以上缺陷呢?带着这个问题,我们开始学习面向对象。

类和对象

类是对具有相似特征事物的统称,例如“电脑”是对世界上所有电脑的统称;而对象则是某个类的具体事物,例如世界上所有的电脑都是电脑对象,又叫做电脑类的实例。

类与类之间最常见的关系是继承,其中被继承的类称为父类,继承而来的类称为子类;子类具有父类的所有特征。例如平板电脑都是电脑的子类,iPad是平板电脑的子类。在继承的层次关系中,位于最上层的又被成为基类,例如电脑是iPad的基类。

具体到编程语言中,类实质上是一种数据类型。例如在Javascript中,所有字符串都是String类的实例,所有数组都是Array类的实例,所有类型的基类都是Object。

C#中的面向对象

Javascript并不是一门面向对象的语言,为了便于描述和理解各种概念,下面先通过C#来学习。

类的声明

C#中通过关键字class来声明类,例如Square类(正方形):

class Square
{
    public string Name;
    public double Length;
    public Square()
    {
        this.Length = 1;
    }
    public double Perimeter()
    {
        return this.Length * 4;
    }
}
类可以有属性和方法。上面代码中的Name和Length为属性,分别是字符串和浮点数类型;而Perimeter则为方法,它没有参数,返回值是浮点数类型。声明属性和方法时还可以加上访问修饰符:public、private或protected(用处后面再详细介绍)。类中还有一个名字跟类名一样的特殊函数,即构造函数,这个函数在创建实例时自动调用。

声明类之后就可以通过new操作符创建对象:

Square square = new Square();
square.Perimeter(); // 构造函数中给Length属性赋值为1,因此结果为4
square.Length = 10;
square.Perimeter(); // 给Length属性赋值为10,因此结果为40
接下来声明矩形类和圆形类:

class Rectangle
{
    public string Name;
    public double Length;
    public double Width;
    public double Perimeter()
    {
        return (this.Length + this.Width) * 2;
    }
}

class Circle
{
    public string Name;
    public double Radius;
    public double Perimeter()
    {
        return 2 * Math.PI * Radius;
    }
}
继承

仔细观察可以发现:Square、Rectangle和Circle存在一些共同点:

它们都有一个字符串类型的属性Name;
它们都有一个返回值类型为double、没有任何参数的方法Perimeter。
为了减少代码重复,可以进一步抽象出一个Shape类(形状),然后让Square、Rectangle和Circle都继承于它。然而,声明Shape类的时候,问题来了。形状类不能表示任何一种具体的形状,也就无法知道如何计算周长,那Perimeter方法应该如何实现呢?

class Shape
{
    public string Name;
    public double Perimeter()
    {
        // do what?
    }
}
答案是不实现。在面向对象的概念中,所有对象都是通过类来描绘的,但是反过来,并不是所有的类都是用来描绘对象的。某些类中没有包含足够的信息来描绘具体的对象,也就无法实例化,只能作为基类存在,这样的类就是抽象类。而Shape正是这样一个类:

abstract class Shape
{
    public string Name;
    public abstract double Perimeter();
}
抽象类通过abstract关键字声明,可以包含抽象方法。抽象方法只有方法声明,没有具体实现,它存在的意义是,强制子类必须存在这样一个方法并且实现它。

有了Shape这个父类,Square、Rectangle、Circle可以重写为:

class Square : Shape
{
    public double Length;
    public override double Perimeter()
    {
        return this.Length * 4;
    }
}

class Rectangle : Shape
{
    public double Length;
    public double Width;
    public override double Perimeter()
    {
        return (this.Length + this.Width) * 2;
    }
}

class Circle : Shape
{
    public double Radius;
    public override double Perimeter()
    {
        return 2 * Math.PI * Radius;
    }
}
C#中通过冒号来表示继承,冒号后面的为父类。由于Shape类的三个子类都继承了Name这个属性,所以不需要再声明一次了。它们又都实现了Perimeter方法,但实现各不相同,这就是面向对象中的多态——一种定义,多种实现。

多态还有一种重要的作用——把不同的子类对象当作父类对象来处理,屏蔽不同子类对象之间的差异。也就是说可以把变量声明为父类类型,却实例化为子类对象,例如:

Shape shape = new Circle();
不过要注意,此时shape变量仍然是Shape类型,而非Circle类型,所以不能访问它的Radius属性:

Shape shape = new Circle();
shape.Radius = 10; // 编译失败
但这样写是没有问题的:

Circle circle = new Circle();
circle.Radius = 10;
Shape shape = circle;
封装

先来看看以下代码有什么问题:

Square square = new Square();
square.Length = -10;
很明显,正方形的边长不可能为负数。然而,给Length赋值为负数却是合法的。为了阻止这种非法值,面向对象提供了封装这一特性。

封装是通过访问修饰符来实现的:

public:在类的内部及外部均可访问;
private:在类的内部可以访问;
protected:在类及其子类的内部可以访问。
假如把Square类Length属性的修饰符改成private,它就不能被外部访问了(无论是读还是写):

class Square
{
    private double Length;
    // 其他照抄
}
Square square = new Square();
double length = square.Length; // 读操作,编译失败
square.Length = 10;  // 写操作,编译失败
那么问题来了,如果要访问Length的值,该怎么办?答案就是通过public的方法:

class Square
{
    private double Length;
    public double GetLength()
    {
        return this.Length;
    }
    public void SetLength(double length)
    {
        if (length < 0) { throw new Exception("…"); }
        this.Length = length;
    }
    public double Perimeter()
    {
        return this.Length * 4;
    }
}

Square square = new Square();
square.SetLength(10);
虽然Length属性是private的,但GetLength和SetLength方法是public的,通过调用它们就间接获取或修改Length的值。并且,SetLength对传入的参数进行了判断,如果是非法值就抛出异常,这样就保证了值是合法的。

解决问题

掌握了以上知识,开头提到的缺陷就可以解决了。结合继承、多态、封装的特性,先编写几种形状类:

// 形状www.111cn.net
abstract class Shape
{
    protected string Name;
    public string GetName() { return this.Name; }
    public void SetName(string name) { this.Name = name; }

    public abstract double Perimeter();
}

// 正方形
class Square
{
    private double Length;
    public double GetLength() { return this.Length; }
    public void SetLength(double length)
    {
        if (length < 0) { throw new Exception("…"); }
        this.Length = length;
    }

    public double Perimeter()
    {
        return this.Length * 4;
    }
}

// 长方形
class Rectangle : Shape
{
    private double Length;
    public double GetLength() { return this.Length; }
    public void SetLength(double length)
    {
        if (length < 0) { throw new Exception("…"); }
        this.Length = length;
    }

    private double Width;
    public double GetWidth() { return this.Width; }
    public void SetWidth(double width)
    {
        if (width < 0) { throw new Exception("…"); }
        this.Width = width;
    }

    public double Perimeter()
    {
        return (this.Length + this.Width) * 2;
    }
}

// 圆形
class Circle : Shape
{
    private double Radius;
    public double GetRadius() { return this.Radius; }
    public void SetRadius(double radius)
    {
        if (radius < 0) { throw new Exception("…"); }
        this.Radius = radius;
    }

    public double Perimeter()
    {
        return 2 * Math.PI * Radius;
    }
}
接着编写ComputePerimeter函数并调用:

string ComputePerimeter(Shape shape)
{
    return shape.GetName() + "的周长是" + shape.Perimeter();
}

Square square = new Square();
square.SetLength(5);
ComputePerimeter(square);

Circle circle = new Circle();
circle.setRadius(5);
ComputePerimeter(circle);
多态的作用在此刻显现:

由于可以把子类对象当做父类对象来处理,所以square和circle都可以作为Shape对象传入为ComputePerimeter的参数;
GetName和Perimeter都是Shape类的方法,所以调用它们也不会有任何问题;
不同形状的Perimeter方法实现各不相同,这样就可以执行与形状相对应的周长算法。
如果此时要增加三角形,要怎么改呢?答案是不用改,只需新增代码即可:

// 三角形类
class Triangle : Shape
{
    private double sideA;
    private double sideB;
    private double sideC;

    public double GetSideA() { return this.sideA; }
    public double GetSideB() { return this.sideB; }
    public double GetSideC() { return this.sideC; }

    public void SetSides(double a, double b, double c)
    {
        if (a < 0 || b < 0 || c < 0)
        {
            throw new Exception("...");
        }

        // 两边之和大于必须第三边
        if (a + b <= c || a + c <= b || b + c <= a)
        {
            throw new Exception("...");
        }

        // 两边之差的绝对值必须小于第三边
        if (Math.Abs(a - b) >= c || Math.Abs(a - c) >= b || Math.Abs(b - c) >= a)
        {
            throw new Exception("...");
        }

        this.sideA = a;
        this.sideB = b;
        this.sideC = c;
    }

    public override double Perimeter()
    {
        return this.sideA + this.sideB + this.sideC;
    }
}

var triangle = new Triangle();
triangle.setSides(3, 4, 5);
ComputePerimeter(triangle);
小结

开放-封闭原则,即对扩展开放、对修改封闭,是面向对象的重要原则。其核心思想是对抽象编程,而不对具体编程。通过继承和多态机制,可以实现对抽象体(如Shape类)的继承,再通过覆写其方法(如不同形状的Perimeter方法)来改变行为的实现细节。


严格来说,Javascript并不是一门面向对象的语言,因为它没有原生提供完整的面向对象机制。但它的语言特性又允许我们去模拟大部分这些机制。

new操作符

Javascript中也用new操作符创建类的实例,例如:

var arr = new Array(10); // 创建长度为10的数组
var date = new Date; // 不需要传参数给构造函数时,括号可以省略
与C#不同的是,Javascript中new的是函数而不是class,这个函数即为类的构造函数。在构造函数中,可以声明属性和方法。例如:

function Square() {
    this.length = 1;
    this.perimeter = function() {
        return this.length * 4;
    };
}

var square = new Square();
square.perimeter(); // 4
square.length = 10;
square.perimeter(); // 40
虽然上面的代码看上去很正常,但实际上存在一个缺陷:

var square1 = new Square();
var square2 = new Square();
square1.perimeter === square2.perimeter; // false
由于每次创建Square对象执行构造函数时都创建了新的函数perimeter,所以多个Square对象的perimeter并非指向同一个函数。这样会造成内存的浪费。要解决这个问题,可以使用原型链。

原型链

Javascript中的对象都有一个内部属性__proto__(在新浏览器中已经变成可读可写的外部属性了)指向其构造函数的prototype;构造函数的prototype也是一个对象,所以它也有一个__proto__属性指向它的构造函数的prototype……如此逐层查找,直到__proto__属性的值是null为止。这就是原型链。下面以代码证实这一规则:

var arr = new Array();
arr.__proto__ === Array.prototype; // true
那么Array.prototype.__proto__又是什么呢?在控制台打印出来可以发现,是一个Object对象,所以:

Array.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__; // null
Object类是所有类型的基类,所以它的prototype的__proto__属性为null。最后得出arr的原型链为:

arr.__proto__
Array.prototype
Array.prototype.__proto__
an Object object
object.__proto__
Object.prototype
Object.prototype.__proto__
null
原型链的用处在于:当访问对象的某个属性或方法时,如果对象本身没有这个属性或方法,就会在原型链中由浅到深进行查找(浅处优先)。同样地有代码可以证实这一规则:

var arr = new Array();
// arr本身没有push
arr.hasOwnProperty("push"); // false
// 访问arr.push时实际上访问的是Array.prototype.push。
arr.push === arr.__proto__.push; // true
arr.push === Array.prototype.push; // true
不难发现,即使有多个Array对象,它们也是共享Array.prototype上的方法。所以,借助原型链就可以解决前面提到的多个函数副本浪费内存的问题:

function Square() {
    this.length = 1;
}
Square.prototype.perimeter = function() {
    return this.length * 4;
};

var square1 = new Square();
var square2 = new Square();
square1.perimeter === square2.perimeter; // true
继承

原型链的层次结构跟面向对象的继承非常相似,所以原型链也可以用于模拟继承。先来看一个简单的例子:

function Shape() {
    this.name = "形状";
}
Shape.prototype.perimeter = function() { };

function Square() {
    this.length = 1;
}
// Javascript中没有抽象类的概念,所以Shape是可以实例化的
Square.prototype = new Shape(); // important
Square.prototype.perimeter = function() {
    return this.length * 4;
};www.111cn.net

var square = new Square();
square.name; // "形状"
square.perimeter(); // 4
其中最为关键的就是把一个Shape对象加入到Square的原型链中去。根据原型链的规则:

访问square.name时,实际上访问的是Square.prototype.name,也就是那个Shape对象的name属性。
调用square.perimeter()时,虽然Square.prototype和Shape.prototype中都存在perimeter方法,但根据浅处优先原则,调用的是Square.prototype.perimeter。
很明显,这跟继承的效果是一样的。

封装

目前Javascript没有任何机制可以控制属性和方法的可访问性,也就是说private和protected都无法实现。但是利用闭包的特性可以实现类似private的效果:

function Square() {
    var length = 1;
    this.setLength = function(len) {
        length = len;
    };
    this.getLength = function() {
        return length;
    };
    this.perimeter = function() {
        return length * 4;
    };
}

var square = new Square();
square.perimeter(); // 4
square.setLength(10);
square.perimeter(); // 40
由于length变量的作用域是在Square内,所以外部无法访问,也就达到了私有的效果。但这样一来,三个方法就不能写到Square.prototype上,因为一旦它们脱离了Square的作用域,就无法访问length了。

鉴于封装特性难以实现,实际工作中更多是通过编码规范来进行约束。例如可以规定:名字以双下划线开头的属性或方法是private的,名字以单下划线开头的属性或方法是protected的。代码示例:

function Square() {
    this.__length = 1;
}
Square.prototype.setLength = function(len) {
    this.__length = len;
};
Square.prototype.getLength = function() {
    return this.__length;
};
Square.prototype.perimeter = function() {
    return this.__length * 4;
};

var square = new Square();
square.perimeter();
square.setLength(10);
square.perimeter();
小结

最后,我们再来了解一下new操作符的大概执行过程:

创建一个新的空对象;
设置新对象的__proto__属性为构造函数的prototype;
以新对象为this执行构造函数。
如果以代码表示,则为:

// 以new Foo()为例
var obj = { };
obj.__proto__ = Foo.prototype;
Foo.call(obj);
大家不妨以此过程回顾前面的代码段。

在Javascript中,虽然借助原型链就可以实现继承,但这里面还是有很多细节问题的要处理的。分析并解决这些问题后,就可以把创建类的过程写成一个通用函数了。

constructor属性

Javascript中的对象都有一个constructor的属性指向其构造函数。例如:

function A() { }
var a = new A();
a.constructor; // A
确切地说,constructor属性是位于构造函数的prototype上。下面的代码可以证实这一规则:

function A() { }

var a = new A();
console.log(a.constructor); // A

delete A.prototype.constructor; // 删除原型上的constructor属性
console.log(a.constructor); // Object
由于删除了A.prototype下的constructor属性,所以访问a.constructor的时候,在原型链中的查找就得查到Object.prototype上去,而Object.prototype.constructor自然就是Object。

现在看一下简单的原型链继承方案带来的问题:

function A() { }
function B() { }
B.prototype = new A();

var a = new A();
a.constructor; // A
var b = new B();
b.constructor; // A
可见,b的constructor应为B,但却成了A。原因是:b.constructor即B.prototype.constructor,而此时B.prototype是一个A对象,A对象的constructor即A.prototype.constructor,而A.prototype.constructor正是A。

幸好constructor是一个可写的属性,所以只要重新设定这个值,问题就解决了:

function A() { }
function B() { }
B.prototype = new A();
B.prototype.constructor = B; // important

var a = new A();
a.constructor; // A
var b = new B();
b.constructor; // B
instanceof操作符

从字面意思来看,instanceof用于判断某个对象是否某个类的实例,但准确地说,它是用于检测某个对象的原型链中是否包含某个构造函数的prototype。举个例子:

var arr = new Array();
arr instanceof Array; // true
arr instanceof Object; // true
由于Array.prototype和Object.prototype都在arr的原型链中,所以上面的测试结果均为true。另外还要注意,instanceof的检测只跟原型链有关,跟constructor属性没有任何关系。所以,基于原型链的继承不会影响到instanceof的检测。

带参数的构造函数

前面通过原型链实现继承的例子中,构造函数都是不带参数的,一旦有参数,这个问题就复杂很多了。先看看下面的例子:

function A(data) {
    this.name = data.name;
}
function B() { }
B.prototype = new A();
B.prototype.constructor = B;

var b = new B();
这段代码运行的时候会产生异常:

Cannot read property "name" of undefined

出现异常的代码就是A构造函数内的那一行。出现异常的原因是:要访问data.name,就得保证data不为null或undefined,但是执行B.prototype=new A()时,却没有传参数进去,此时data为undefined,访问data.name就会出现异常。

仅解决这个问题并不难,只要在new A()时传入一个不为null且不为undefined的参数就行:

function A(data) {
    this.name = data.name;
}
function B() { }
B.prototype = new A({ });
B.prototype.constructor = B;

var b = new B();
b.name; // undefined
然而,实际情况远没有这么简单。

其一,A的参数可能不止一个,其内部逻辑也可能更为复杂,随便传参数进去很有可能导致异常。要彻底解决这个问题,得借助一个空函数:

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

function Temp() { }
Temp.prototype = A.prototype; // important
B.prototype = new Temp();
B.prototype.constructor = B;

var b = new B();
b.name; // undefined
Temp即为该空函数,它的prototype被更改为A.prototype,即Temp与A共享同一个prototype。因此,在忽略构造函数内部逻辑的前提下,把B.prototype设成Temp的实例跟设成A的实例效果是一样的。但因为Temp内部没有逻辑,所以new Temp()肯定不会产生异常。

其二,很多时候我们需要把子类构造函数的参数传给父类构造函数。比如说达到这样的效果:

var b = new B({ name: "b1" });
b.name; // "b1"
这就需要在子类构造函数中调用父类构造函数:

function A(data) {
    this.name = data.name;
}
function B() {
    A.apply(this, arguments); //important
}

function Temp() { }
Temp.prototype = A.prototype;
B.prototype = new Temp();
B.prototype.constructor = B;

var b = new B({ name: "b1" });
console.log(b.name);
通过A.apply(this, arguments)就可以确保操作的对象为当前对象(this),且把所有参数(arguments)传到A。

createClass函数

总算是完全解决了这些细节问题,为了不在每次创建类的时候都要写这么一大堆代码,我们把这个过程写成一个函数:

function createClass(constructor, methods, Parent) {
    var $Class = function() {
        // 有父类的时候,需要调用父类构造函数
        if (Parent) {
            Parent.apply(this, arguments);
        }
        constructor.apply(this, arguments);
    };

    if (Parent) {
        // 处理原型链
        var $Parent = function() { };
        $Parent.prototype = Parent.prototype;
        $Class.prototype = new $Parent();
        // 重设constructor
        $Class.prototype.constructor = $Class;
    }

    if (methods) {
        // 复制方法到原型
        for (var m in methods) {
            if ( methods.hasOwnProperty(m) ) {
                $Class.prototype[m] = methods[m]; 
            }
        }
    }

    return $Class;
}
在这个函数的基础上,把计算周长的问题解决掉:

// 形状类
var Shape = createClass(function() {
    this.setName("形状");
}, {
    getName: function() { return this._name; },
    setName: function(name) { this._name = name; },
    perimeter: function() { }
});

// 矩形类
var Rectangle = createClass(function() {
    this.setLength(0);
    this.setWidth(0);
    this.setName("矩形");
}, {
    setLength: function(length) {
        if (length < 0) {
            throw new Error("...");
        }
        this.__length = length;
    },
    getLength: function() { return this.__length; },
    setWidth: function(width) {
        if (width < 0) {
            throw new Error("...");
        }
        this.__width = width;
    },
    getWidth: function() { return this.__width; },
    perimeter: function() {
        return (this.__length + this.__width) * 2;
    }
}, Shape);

// 正方形类
var Square = createClass(function() {
    this.setLength(0);
    this.setName("正方形");
}, {
    setLength: function(length) {
        if (length < 0) {
            throw new Error("...");
        }
        this.__length = length;
    },
    getLength: function() { return this.__length; },
    perimeter: function() {
        return this.__length * 4;
    }
}, Shape);

// 圆形
var Circle = createClass(function() {
    this.setRadius(0);
    this.setName("圆形");
}, {
    setRadius: function(radius) {
        if (radius < 0) {
            throw new Error("...");
        }
        this.__radius = radius;
    },
    getRadius: function() { return this.__radius; },
    perimeter: function() {
        return 2 * Math.PI * this.__radius;
    }
}, Shape);


function computePerimeter(shape) {
    console.log( shape.getName() + "的周长是" + shape.perimeter() );
}

var rectangle = new Rectangle();
rectangle.setWidth(10);
rectangle.setLength(20);
computePerimeter(rectangle);

var square = new Square();
square.setLength(10);
computePerimeter(square);

var circle = new Circle();
circle.setRadius(10);
computePerimeter(circle);
最后

最后总结一下在Javascript中模拟面向对象的要点:

new的是构造函数,而不是类;
属性写在构造函数内,方法写到原型链上;
继承可以通过原型链实现;
封装难以实现,可通过代码规范来约束。
此外,鉴于构造函数是函数,普通函数也是函数,建议通过不同的命名规则区分它们:给构造函数命名时使用Pascal命名法,给普通函数命名时使用驼峰命名法。

本文来源:http://www.bbyears.com/jsp/74228.html