JavaScript 中的面向对象技术


到目前为止我们讨论的都是 JavaScript 类的基础知识,本节我们将利用 JavaScript 中的类进行编程。

集合类

集合是一种数据结构,用于表示非重复值的无序集合。JavaScript 的对象是属性名和与之对应值的基本集合,因此将对象只用做字符串的集合是大材小用,下面我们将用 JavaScript 实现更加通用的集合类 Set,它实现了从 JavaScript 值到唯一字符串的映射,然后将字符串作为属性名:

// 这是一个集合类的构造函数
function Set() {
    this.values = {};   // 集合数据保存在对象的属性里
    this.n = 0;   // 集合中值的个数
    this.add.apply(this, arguments);  // 把初始参数(构造函数传入参数)添加到集合
}
    
// 添加元素到集合
Set.prototype.add = function () {
    for (var i = 0; i < arguments.length; i++) {   // 遍历每个参数
        var val = arguments[i];    // 待添加到集合中的值
        var str = Set._v2s(val);   // 将值转化为字符串
        if (!this.values.hasOwnProperty(str)) {  // 如果不存在,将其添加到集合
            this.values[str] = val;  // 将字符串和值对应起来
            this.n++;    // 集合中值的计数加一
        }
    }
};
    
// 从集合中删除元素
Set.prototype.remove = function () {
    for (var i = 0; i < arguments.length; i++) {  // 遍历每个参数
        var str = Set._v2s(arguments[i]);   // 将参数值转化为字符串
        if (this.values.hasOwnProperty(str)) {  // 判断字符串属性是否存在
            delete this.values[str];    // 删除对应元素
            this.n--;   // 集合中值的计数减一
        }
    }
};
    
// 判断集合是否包含指定元素
Set.prototype.contains = function (value) {
    return this.values.hasOwnProperty(Set._v2s(value));
};
    
// 获取集合大小
Set.prototype.size = function () {
    return this.n;
};
    
// 遍历集合所有元素,在指定的上下文中调用元素
Set.prototype.foreach = function (f, context) {
    for (var s in this.values) {
        if (this.values.hasOwnProperty(s)) {    // 忽略继承的属性
            f.call(context, this.values[s]);
        }
    }
};
    
// 这是一个内部私有函数,用于将任意 JavaScript 值和唯一的字符串对应起来
Set._v2s = function (val) {
    switch (val) {
        case undefined:
            return 'u';
        case null:
            return 'n';
        case true:
            return 't';
        case false:
            return 'f';
        default:
            switch (typeof val) {
                case 'number':
                    return '#' + val;  // 数字带有 # 前缀
                case 'string':
                    return '"' + val;  // 字符串带有 " 前缀
                default:
                    return '@' + objectId(val);  // 对象和函数带有 @ 前缀
            }
    }
    
    // 对于任意对象而言都会返回一个字符串
    // 针对不同的对象,返回不同的字符串
    // 对于同一个对象的多次调用,总是返回相同的字符串
    function objectId(o) {
        var prop = "|**objectid**|";   // 私有属性,用于存放id
        if (!o.hasOwnProperty(prop))   // 如果对象没有id
            o[prop] = Set._v2s.next++;    // 将下一个值赋给它
        return o[prop];  // 返回这个id
    }
};
    
Set._v2s.next = 100;  // 设置初始id的值

我们可以测试上下面编写的集合类:

枚举类型

枚举类型是一种类型,它是值的有限集合,如果值定义为这个类型,那么该值就是「可枚举」的。目前 JavaScript 并未内置支持枚举类型,下面我们就来定义这个数据类型:

/**
 * 这个函数不是构造函数,而是一个工厂方法,用于创建一个新的枚举类型
 * 返回值是一个构造函数,包含名/值对映射表,但是不能使用它来创建该类的新实例
 * @param namesToValues
 * @returns {enumeration}
 */
function enumeration(namesToValues) {
    // 这个虚拟的构造函数是返回值,但是不能直接通过它创建新实例,否则抛出异常
    var enumeration = function () {
        throw "Can't Instantiate Enumerations.";
    };
    
    // 枚举值继承自这个原型对象
    var proto = enumeration.prototype = {
        constructor: enumeration,
        toString: function () {
            return this.name;
        },
        valueOf: function () {
            return this.value;
        },
        toJSON: function () {
            return this.name;
        }
    };
    
    // 用以存放枚举对象的数组
    enumeration.values = [];
    
    // 创建新类型的实例
    for (var name in namesToValues) {   // 遍历传入的每个参数
        var e = inherit(proto);         // 创建一个代表它的对象
        e.name = name;                  // 给它一个名字
        e.value = namesToValues[name];  // 给它一个值
        enumeration[name] = e;          // 将它设置为构造函数的属性
        enumeration.values.push(e);     // 将它存储到值数组中
    }
    
    // 用来对类实例进行迭代
    enumeration.foreach = function (f, c) {
        for (var i = 0; i < this.values.length; i++) {
            f.call(c, this.values[i]);
        }
    };
    
    // 返回标识这个新类型的构造函数
    return enumeration;
}
    
// 通过该方法定义继承自原型p的新对象
function inherit(p) {
    if (p == null) throw TypeError();
    if (Object.create)
        return Object.create(p);
    var t = typeof p;
    if (t !== 'function' && t !== 'object') throw TypeError();
    function f() {};
    f.prototype = p;
    return new f();
}

下面我们就用这个新定义的枚举类型来实现一个表示「扑克牌」的类:

// 定义一个表示玩牌的类
function Card(suit, rank) {
    this.suit = suit;   // 花色(如方块、梅花)
    this.rank = rank;   // 点数(如J、Q、K)
}
    
// 使用枚举类型定义花色和点数
Card.Suit = enumeration({Clubs: 1, Diamonds: 2, Hearts: 3, Spades: 4});
Card.Rank = enumeration({Two: 2, Three: 3, Four: 4, Five: 5, Six: 6, Seven: 7, Eight: 8,
                         Nine: 9, Ten: 10, Jack: 11, Queue: 12, King: 13, Ace: 14});
    
// 定义用于描述牌面的文本
Card.prototype.toString = function () {
    return this.rank.toString() + " of " + this.suit.toString();
};
    
// 比较扑克牌中两张牌的大小
Card.prototype.compareTo = function (that) {
    if (this.rank < that.rank)
        return -1;
    if (this.rank > that.rank)
        return 1;
    return 0;
};
    
// 以扑克牌的玩法规则对牌进行排序的函数
Card.orderByRank = function (a, b) {
    return a.compareTo(b);
};
    
// 以桥牌的玩法规则对牌进行排序的函数
Card.orderBySuit = function (a, b) {
    if (a.suit < b.suit) return -1;
    if (a.suit > b.suit) return 1;
    if (a.rank < b.rank) return -1;
    if (a.rank > b.rank) return 1;
    return 0;
};
    
// 定义用以表示一副标准扑克牌的类
function Deck() {
    var cards = this.cards = [];
    Card.Suit.foreach(function (s) {
        Card.Rank.foreach(function (r) {
            cards.push(new Card(s, r));
        });
    });
}
    
// 洗牌的方法
Deck.prototype.shuffle = function () {
    // 遍历数组中的每个元素,随机找出牌面最小的元素,并与之(当前遍历的元素)交换
    var deck = this.cards, len = deck.length;
    for (var i = len - 1; i > 0; i--) {
        var r = Math.floor(Math.random() * (i + 1)), temp;  // 随机数
        temp = deck[i], deck[i] = deck[r], deck[r] = temp;  // 交换
    }
    return this;
};
    
// 发牌的方法
Deck.prototype.deal = function (n) {
    if (this.cards.length < n)
        throw "Out of cards";
    return this.cards.splice(this.cards.length - n, n);
};

我们来测试下上面的代码,创建一副新扑克牌,洗牌并发牌:

标准转换方法

最重要的方法首当 toString(),这个方法的作用是返回一个可以表示这个对象的字符串。在需要用到字符串的地方使用对象时,JavaScript 会自动调用这个方法。如果没有实现这个方法,默认会从 Object.prototype 中继承 toString() 方法,但是默认实现可读性差([object Object]),一般我们还是会在类中自定义这个方法。

toLocaleString()toString() 极为类似,只不过前者是以本地敏感的方式将对象转换为字符串,默认情况下,对象所继承的 toLocaleString() 方法只是简单调用 toString() 方法,如果需要为对象到字符串的转换定义 toString() 方法,那么同样需要定义 toLocaleString() 方法用以处理本地化的对象到字符串转换。

第三个方法是 valueOf(),用来讲对象转换为原始值。比如当数字运算符和关系运算符作用于数字文本表示的对象时,会自动调用 valueOf() 方法,大多数的对象没有合适的原始值来表示它们,因而也没有定义这个方法。

第四个方法是 toJSON(),这个方法是由 JSON.stringify() 自动调用的。JSON 格式用于序列化良好的数据结构,可以处理 JavaScript 原始值、数组和纯对象。当对一个对象执行序列化操作时,会忽略对象的原型和构造函数,序列化后的字符串可以通过 String.parse() 函数反序列化为纯对象,但是所有对象方法都会丢失。如果一个对象有 toJSON() 方法,JSON.stringify() 并不会对传入的对象做序列化操作,而是调用 toJSON() 来执行序列化操作。

我们在 Set 类中并没有定义上述方法中的任何一个,JavaScript 中没有哪个原始值可以表示集合,因此也没有必要定义 valueOf() 方法,但是该类应当包含 toString()toLocaleString()toJSON() 方法。这里用到之前定义的 extend() 函数向 Set.prototype 添加方法:

// 将这些方法添加到 Set 类的原型对象中
extend(Set.prototype, {
    // 将集合转换为字符串
    toString: function () {
        var s = "{", i = 0;
        this.foreach(function (v) {
            s += ((i++ > 0) ? ", " : "") + v;
        });
        return s + "}";
    },
    // 类似toString,但是所有值都将调用toLocaleString
    toLocaleString: function () {
        var s = "{", i = 0;
        this.foreach(function (v) {
            if (i++ > 0) s += ", ";
            if (v == null)  // null 和 undefined
                s += v;
            else
                s += v.toLocaleString();
        });
        return s + "}";
    },
    // 将集合转换为数组
    toArray: function () {
        var a = [];
        this.foreach(function (v) {
            a.push(v);
        });
        return a;
    }
});
    
// 对于要从JSON转换为字符串的集合都要被当做数组来对待
Set.prototype.toJSON = Set.prototype.toArray;

调用集合类上的上述三个方法,返回结果如下:

比较方法

JavaScript 中的相等运算符比较对象时,比较的是引用而不是值。也就是说,只有当两个对象指向同一个引用时才返回 true。如果想要根据属性名和属性值来判断,需要自定义比较方法,通常的思路是先看两个对象的 constructor 属性是否相同以确保两个对象类型相同,然后比较两个对象的实例属性以保证它们的值相等。我们在 Complex 类中就实现了这样的比较方法 equals()。我们在 Range 类中也可以实现类似的方法:

// 判断两个Range对象是否相等
Range.prototype.equals = function (that) {
    if (that == null) return false;
    if (that.constructor !== Range) return false;
    return this.from == that.from && this.to == that.to;
};

比较 Set 类是否相等稍微有些复杂,不能简单比较两个集合的 values 属性,还要进行更深层次的比较:

Set.prototype.equals = function (that) {
    // 针对 null 和 undefined
    if (that == null)
        return false;
    
    // 两个集合指向同一个引用肯定相等
    if (this === that)
        return true;
    
    // that不是集合类肯定不相等 这里也可以使用 this.constructor == that.constructor
    if (!that instanceof Set)
        return false;
    
    // 如果两个集合类的大小不一样,也不相等
    if (this.size() != that.size())
        return false;
    
    // 现在检查两个集合中的元素是否完全一样
    // 如果两个集合不相等,则通过抛出异常的方式来终止循环
    try {
        this.foreach(function (v) {
            if (!that.contains(v))
                throw false;
        });
    } catch (x) {
        if (x === false)
            return false;
        throw x;
    }
    return true;
};

下面我们来测试下上面的代码:

如果将对象用于 JavaScript 的关系比较运算符,比如「<」和「<=」,JavaScript 会首先调用对象的 valueOf() 方法,如果这个方法返回一个原始值,则比较原始值,比如 enumeration() 方法所返回的枚举类型就包含 valueOf() 方法,因此可以使用关系运算符进行比较,但是大多数类并没有这个方法,为了能够比较这些类型的对象,可以定义一个 compareTo() 方法。

compareTo() 方法只能接收一个参数,该方法将传入参数和调用它的对象进行比较,如果this对象小于参数对象,返回小于0的值,如果 this 对象大于参数对象,返回大于0的值,如果相等,则返回0。这些关于返回值的约定非常重要,我们可以用下面的表达式替换掉关系运算符和相等运算符:

待替换 替换为
a<b a.compareTo(b)<0
a<=b a.compareTo(b)<=0
a>b a.compareTo(b)>0
a>=b a.compareTo(b)>=0
a==b a.compareTo(b)==0
a!=b a.compareTo(b)!=0

我们在 Card 类中定义了 compareTo() 方法,也可以在 Range 类中实现类似的方法:

// 根据下边界对 Range 对象进行排序,如果下边界相等则比较上边界
// 上下边界都相等,才返回0
Range.prototype.compareTo = function (that) {
    // 传入非 Range 值抛出异常
    if (!that instanceof Range)
        throw new Error("Can't compare a Range with " + that);
    var diff = this.from - that.from;
    if (diff == 0)
        diff = this.to - that.to;
    return diff;
};

给类定义了 compareTo() 方法后,就可以对类的实例组成的数组进行排序了,我们可以通过 Array.sort 来进行排序:

ranges.sort(function(a, b) { return a.compareTo(b); });

还可以像 Card 类一样,在 Range 类中定义一个排序算法:

Range.byLowerRound = function(a, b) { return a.compareTo(b); };

然后通过这个排序算法简化数组排序:

ranges.sort(Range.byLowerRound);

方法借用

JavaScript 中的方法就是一些简单的函数,赋值给对象属性,就可以通过对象来调用它。

一个函数可以赋值给多个属性作为不同方法来调用,多个类中的方法也可以共用一个单独的函数,如果定义了一个类,它的实例是类数组的对象,则可以将 Array.prototype 中的函数复制到所定义的类的原型对象中。从经典面向对象语言的视角来看,把一个类方法用到其他类中的做法叫做「多重继承」,然而,JavaScript 并非经典面向对象语言,这种方法重用可以称之为「方法借用」。

不仅 Array 方法可以借用,还可以自定义泛型方法,被其它类使用,下面我们来定义泛型方法 toString()equals()

var generic = {
    // 返回一个字符串,这个字符串包含构造函数的名字,以及所有非继承来的、非函数属性的名字和值
    toString: function () {
        var s = '[';
        // 如果对象有构造函数且构造函数有名字
        if (this.constructor && this.constructor.name)
            s += this.constructor.name + ": ";
        // 枚举所有非继承非函数的属性
        var n = 0;
        for (var name in this) {
            if (!this.hasOwnProperty(name))  // 跳过继承属性
                continue;
            var value = this[name];
            if (typeof value === "function")  // 跳过方法
                continue;
            if (n++)
                s += ", ";
            s += name + "=" + value;
        }
        return s + ']';
    },
    
    // 通过比较 this 和 that 的构造函数和实例属性来判断它们是否相等
    // 这种方式仅适用于实例属性是原始值的情况
    equals: function (that) {
        if (that == null)
            return false;
        if (this.constructor !== that.constructor)
            return false;
        for (var name in this) {
            if (name === "|**objectid**|")   // 跳过 Set 中的特殊属性
                continue;
            if (!this.hasOwnProperty(name))  // 跳过继承属性
                continue;
            if (this[name] !== that[name])
                return false;
        }
        return true;
    }
};

这样,如果哪些类没有定义 toString()equals() 方法的话,可以借用泛型方法:

Range.prototype.equals = generic.equals;

私有状态

在经典面向对象编程中,经常需要将对象的某个状态封装或隐藏在对象内,只有通过对象提供的公共方法才能访问这些状态,比如 Java 语言中的 private 字段,这种字段只能在类的实例方法中访问,在类的外部不可见。

在 JavaScript 中不支持类似语法,但我们可以通过将变量闭包到一个构造函数内来模拟实现 private 字段。为了实现这个功能,需要在构造函数内定义一个函数,并将这个函数赋值给新创建对象的实例属性。按照这个思路,我们来重构 Range 类,这一次,我们将不能直接通过 fromto 属性来访问范围端点,只能使用 from()to() 方法来访问它们,这里的 from()to() 方法是定义在每个 Range 对象上的,而不是从原型对象继承,其它方法和原来一样,只是获取范围端点的方式做了调整:

// 重构 Range 类的构造函数
function Range(from, to) {
    // 定义存取器函数来返回端点值,这些值都保存在闭包中
    this.from = function () {
        return from;
    };
    
    this.to = function () {
        return to;
    };
}
    
// 原型上的方法无法直接操作端点,必须调用存取器方法
Range.prototype = {
    constructor: Range,
    includes: function (x) {
        return this.from() <= x && this.to() >= x;
    },
    foreach: function (f) {
        for (var x = Math.ceil(this.from()); x <= this.to(); x++)
            f(x);
    },
    toString: function () {
        return "(" + this.from() + "," + this.to() + ")";
    }
};

这个新的 Range 类定义了用以读取范围端点的方法,但没有定义设置端点的方法或属性,这让类的实例看起来是不可修改的,但其实还是可以修改的,只不过要通过方法替换赋值的方式来实现:

但是这种封装是有成本的,意味着需要更多的系统开销,回导致系统运行更慢,占用更多内存。

构造函数的重载和工厂方法

有的时候我们希望对象的初始化有多种方式,有两种方法可以用来实现,一种是通过重载,一种是工厂方法。

通过重载构造函数可以让它根据不同的传入参数来执行不同的初始化方法,下面我们以 Set 类为例对其构造函数进行重载:

function Set() {
    this.values = {};
    this.n = 0;
    
    if (arguments.length === 1 && isArrayLike(arguments[0])) {
        this.add.apply(this, arguments[0]);
    } else if (arguments.length > 0) {
        this.add.apply(this, arguments);
    }
}

下面我们对上面重载后的构造函数进行调用:

可见这两种调用方式创建的集合对象元素是完全一样的。

此外我们还可以通过工厂方法的方式对类进行初始化:

Set.fromArray = function (a) {
    s = new Set();
    s.add.apply(s, a);
    return s;
};

可以给工厂方法定义任意的名字,不同名字的工厂方法用以执行不同的初始化。但是构造函数是类的公共标识,因此每个类只能有一个构造函数,不过我们可以定义多个构造函数继承自一个原型对象,如果这么做的话,这些构造函数的任意一个创建的对象都属于同一个类型,不过并不推荐这么做。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 类和类型

>> 下一篇: 子类