JavaScript 面向对象编程之封装

Author: Guang

说JavaScript是虚假的OO编程是非正式口头化的评论,也是对面向对象编程的一个误解。面向对象编程思想不绑定任何语言,每种语言都有自己实现面向对象编程范式的机制,Java、C#使用类来实现面向对象编程范式,而JavaScript使用原型机制来实现面向对象编程范式。所以,JavaScript对面向对象编程范式的实现的确是有很大不同。有关面向对象编程的更多信息请参考英文版的维基百科条目:Object-oriented programming(注:中文版面向对象编程的维基百科条目仍然将类的支持作为面向对象编程的必要元素)。

面向对象编程最首要的是系统中的事物都由对象来表达。Java和C#开发语言本身是符合面向对象范式的,这类语言将对象约束在类(class)上,类作为一批具备相同特点或行为的对象的抽象,它用来描述一个种类的对象,创建一个对象就是对一个类的实例化过程,它们通过类的定义完成对象的封装。例如,在系统中,一个person1是对象,第二个person2也是对象,Java和C#会通过类Person来表达person1person2的属性和行为。换句话说,Java和C#通过对类的管理来达到管理对象的目的。JavaScript中没有Java和C#中那样的类(class)定义(虽然ECMAScript 6引入了class关键字,目前为止它的本质是构造函数的语法糖,JavaScript引擎把它当作函数处理),属于无类编程(classless programming)。在JavaScript中万物皆对象,并且JavaScript具备很强的松散性,在JavaScript中创建、修改对象是容易的,也很容易能够完成复杂对象的封装。根据JavaScript语言本身的特点,开发人员亦可以实现对象成员的静态化和私有化,完成对象属性的管理。JavaScript也具备完善的对象管理能力。这篇文章中我们讨论JavaScript如何完成面向对象编程的封装范式。

创建对象

JavaScript创建对象很简单,它支持使用字面量创建对象,完成创建对象逻辑的复用,演化出多种模式,由简单到复杂,每次演进都解决了当前面对的问题,最终走向成熟的方案。

1. 字面量创建对象

ECMA-262把对象定义为:“无序属性的集合,其属性可以包含基本值,对象或者函数(方法)”,JavaScript 支持简单的对象字面量创建对象(对象字面量创建方法是调用new Object()然后为对象逐一添加属性的简写模式)。在面向对象编程中,作为逻辑封装的基本单元,对象的属性和方法之间该是有内在联系的。譬如我们创建一个person对象,它的JavaScript代码应该如下:

let person = {
    name: 'Guang',
    age: 32,
    job: 'Software Engineer',
    introduce: function() {
        return 'My name is ' + this.name;
    }
}

将相互关联的属性和方法组合在一个具体的对象中,就是一个简单的封装了。不过如果我要创建多个person对象,这个简单的封装不能够进行代码复用。

2. 工厂模式

可以使用一个函数去创建person对象,来达到代码复用的目的。代码如下:

function Person(name, age, job) {
    const person = {
        name: name,
        age: age,
        job: job,
        introduce: function() {
            return 'My name is ' + this.name;
        }
    }
    return person;
}

const person1 = Person('Guang', 32, 'Software Engineer');
const person2 = Person('Greg', 28, 'Hardware Engineer');

这就是创建对象的工厂模式的应用,只需要传递不同的参数,便可以创建具备类似逻辑的不同对象。但是工厂模式也有一个问题,它没有一个机制或者标识表达多个person对象之间的联系。为了建立具备类似逻辑的对象之间的联系,JavaScript引入了构造函数模式来创建同一类对象。

3. 构造函数模式

构造函数模式利用了函数的内部对象this,构造函数本身与普通函数没有差别,当函数使用new关键字进行调用时它便是构造函数调用,直接调用函数就是普通调用。在使用构造函数模式创建对象时,JavaScript引入了对象属性constructor来标识它是由哪个构造函数创建的。

构造函数模式创建person对象的代码如下:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.introduce = function() {
        return 'My name is ' + this.name;
    };
}

const person1 = new Person('Guang', 32, 'Software Engineer');
const person2 = new Person('Greg', 28, 'Hardware Engineer');

此时对象person1person2constructor均指向它们同一个的构造函数Person(),验证如下:

console.log(person1.constructor === Person); // 输出 true
console.log(person2.constructor === Person); // 输出 true

构造函数也是普通的函数,但是构造函数的调用要求使用new关键字调用,这是因为使用new关键字调用,函数内的this绑定才能正确的发生,如果按照普通函数调用方式调用函数,非严格模式下函数的this对象会绑定到全局对象上。使用关键字new调用函数时做了下面四件事:

  1. 创建一个新对象
  2. 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象)
  3. 执行构造函数中的代码
  4. 返回新对象

构造函数模式也有问题。在面向对象范式中,同类对象的行为是一致的,可以复用的不仅是代码逻辑,而是方法本身。我们知道,在JavaScript中,函数(方法)也是对象,构造函数模式的对象方法却不是同一个,而是为每一个对象创建了同名方法。在上述代码中就是person1.introduce不等于person2.introduce,但它们可以是同一个方法。这造成了内存的浪费,我们可以基于JavaScript的原型机制来解决这个问题。JavaScript的原型机制是设计模式原型模式的一种实现,为什么选用原型模式来创建JavaScript中的对象我们先按下不表,我们先看看原型模式是如何创建JavaScript对象的。

4. 原型模式

Javascript规定,所有函数都有一个prototype属性,这个属性指向一个对象,这个prototype属性指向的对象可以包含函数作为构造函数创建的所有对象共享的属性和方法。任何JavaScript引擎实现的对象属性查找机制都要满足这个规定。这是JavaScript扩展对象的机制,我们可以利用它消除构造函数创建对象时造成的内存浪费问题(后面要介绍的继承实现也是基于原型机制来实现的)。 使用原型模式创建对象的代码如下:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
}

Person.prototype.introduce = function() {
    return 'My name is ' + this.name;
};

const person1 = new Person('Guang', 32, 'Software Engineer');
const person2 = new Person('Greg', 28, 'Hardware Engineer');

验证以下效果:

console.log(person1.introduce === person2.introduce); // 输出 true

JavaScript使用原型机制来扩展对象,这不是一个简单的实现,后面我们专门聊聊JavaScript中的原型。

延申:使用new调用构造函数创建对象时,会自动为每个构造的对象设置原型([[Prototype]]),如果构造函数返回非原始值,则该值将成为new调用构造函数的结果。在这种情况下,可能无法正确绑定[[Prototype]]。

5. 使用class声明

ECMAScript 6提出了class声明,值得注意的是class声明支持块级作用域绑定,声明提升和let相似。目前class声明得到了大部分浏览器的支持,如果我们的开发环境支持使用class声明,我们可以使用它来创建对象,它更简洁易用。示例代码如下:

class Person {
    constructor(name, age, job) {
        this.name = name;
        this.age = age;
        this.job = job;
    }

    introduce() {
        return 'My name is ' + this.name;
    }
}

const person1 = new Person('Guang', 32, 'Software Engineer');
const person2 = new Person('Greg', 28, 'Hardware Engineer');

console.log(person1.introduce === person2.introduce); // 输出 true

目前,在JavaScript引擎中class声明的本质仍然是构造函数,在作用域绑定和声明提升上有些许不同。如果我们的程序运行环境不支持class声明,我们可以借助Babel来将ES6代码转换成ES5代码,可以看到转换结果也是函数。

属性管理

JavaScript对对象的属性管理,有些天然支持面向对象范式,例如静态化,有些是缺少面向对象范式支持,如私有化,我们需要对属性的私有化进行模拟。

静态属性

函数也是对象,直接添加到函数上的属性是静态属性,对于class声明,可以使用关键字static在类上声明静态属性。代码示例如下:

// 1. 在函数上声明静态属性
function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
}

Person.constructorName = 'Person'; // 声明静态字符串属性
Person.getConstructorName = function() { // 声明静态方法属性
    return this.constructorName;
}

console.log(Person.getConstructorName()); // 输出 Person
// 2. 在 `class` 声明中声明静态属性
class Person {
    static constructorName = 'Person'; // 声明静态字符串属性
    static getConstructorName() {  // 声明静态方法属性
        return this.constructorName;
    }
    constructor(name, age, job) {
        this.name = name;
        this.age = age;
        this.job = job;
    }
}

console.log(Person.getConstructorName()); // 输出 Person

注意,静态方法中的this默认绑定是作为对象的函数本身,而不是函数创建的对象。

对象属性的私有化

好的封装性离不开对象属性私有化的支持,限制外界对对象的访问能力,对象本身才能更好的管理自身封装的逻辑。目前JavaScript是不支持对象属性私有化的。我们需要借助JavaScript的闭包机制来模拟对象属性的私有化。以下代码分别展示了构造函数和类中是如何模拟私有化属性。

在构造函数中模拟私有属性:

function Person(name, age, job) {
    let privateValue = 'private value';
    this.name = name;
    this.age = age;
    this.job = job;
    this.getPrivateValue = function() {
        return privateValue;
    }
}

const person = new Person('Guang', 32, 'Software Engineer');
console.log(person.privateValue); // 输出 undefined
console.log(person.getPrivateValue()); // 输出 "private value"

注意,因为借用闭包来模拟私有属性,这个“私有属性”只是在构造函数中声明的一个变量,没有被绑定到this上,也不能够被添加到原型链上,它只能在构造函数的作用域中被访问,所以访问私有属性的方法不能够放到函数原型中去复用方法本身。

class声明中模拟私有属性:

class Person {
    constructor(name, age, job) {
        let privateValue = 'private value';
        this.name = name;
        this.age = age;
        this.job = job;
        this.getPrivateValue = function() {
            return privateValue;
        }
    }
}

const person = new Person('Guang', 32, 'Software Engineer');
console.log(person.privateValue); // 输出 undefined
console.log(person.getPrivateValue()); // 输出 "private value"

结合立即执行函数模拟私有静态属性结合上面两者。

在构造函数中模拟静态私有属性:

const Person = (function () {
    let constructorName = 'Person';

    function Person(name, age, job) {
        this.name = name;
        this.age = age;
        this.job = job;
    }

    Person.getConstructorName = function() {
        return constructorName;
    };

    return Person;
})();

console.log(Person.constructorName); // 输出 undefined
console.log(Person.getConstructorName()); // 输出 "Person"

class声明中模拟静态私有属性:

const Person = (function () {
    let constructorName = 'Person';

    class Person {
        static getConstructorName() {
            return constructorName;
        }
        constructor(name, age, job) {
            this.name = name;
            this.age = age;
            this.job = job;
        }
    }
    return Person;
})();

console.log(Person.constructorName); // 输出 undefined
console.log(Person.getConstructorName()); // 输出 "Person"

方法的私有化和属性的私有化方法相同。

对象属性私有化的方式不止于此,另外比较常见的有属性名下划线+Proxy访问控制法、使用Symbol值作为属性标识法,以及使用WeakMap实现的方法,它们都需要借助特殊的机制,在此不再展开讨论。

ESNext 中的对象属性私有化

proposal-class-fieldsproposal-private-methods定义了calss声明的私有属性以及私有方法,这2个提案已经处于Stage 3。最新的Chrome已经支持了它们。在class声明中,在属性声明的属性名前添加字符#即可完成属性或方法的私有化。示例代码如下:

class Person {
    #privateValue = 'private value';
    constructor(name, age, job) {
        this.name = name;
        this.age = age;
        this.job = job;
    }

    getPrivateValue() {
        return this.#privateValue;
    }
}

const person = new Person('Guang', 32, 'Software Engineer');
console.log(person.privateValue); // 输出 undefined
console.log(person.getPrivateValue()); // 输出 "private value"

发表留言

历史留言

--空--