【设计模式】封装与信息隐藏

“信息隐藏”是目的,而“封装”则是实现这一目的手段。

为对象创建私有属性是面向对象语言中最基本的特性之一,它能让对象隐藏实现细节、降低对象间的耦合,同时也能保证数据完整性,防止外部对内部的修改。

虽然 JavaScript 也是面向对象语言,但是它没有任何内置机制去声明公有或私有属性。不过得益于 JavaScript 的作用域链,我们可以像前面实现接口一样去实现对象的私有、公有属性和特权方法。

作用域链

在开始学习封装之前,我们先要了解一下什么是“作用域”和“作用域链”。

在 JavaScript 中,只有函数才有“作用域”这个概念,它指在函数内部定义的成员外部无法直接访问,例如:

function testFn() {
    var test = 'A'
    console.log(test)
}

testFn() // A
testFn.test // undefined

在 testFn 内部定义的 test 变量,我们无法访问,其内部已经形成一个作用域,外部无法访问这个作用域内的成员。

但是在这个作用域中的 console.log 函数却能访问这个变量,因为它处于这个作用域中,它是有权访问这个变量的。

上面我们是直接在函数内执行一个函数,如果我们不立即执行函数,而是把这个函数返回,在外部执行,那么它还能访问 test 变量么?

function testFn() {
    var test = 'A'
    
    function log() {
        console.log(test)
    }
    return log
}

var logFn = testFn()
logFn() // A

答案是肯定的,函数返回的函数仍然能访问到函数内部的变量!而实现这一功能的特性就是“作用域链”。

关于作用域链,简单地说就是函数执行时,会把函数的作用域加到当前作用域链的顶端;查找成员时会从作用域链顶端开始查找,一层一层上下查找,如果都找不到,则返回 undefined。

门户大开型对象

创建一个对象最简单的方式就是创建一个类,用一个函数做构造函数。用这种方式创建的对象,我们称之为“门户大开型对象”,因为这种对象的所有属性、方法都是公开的,我们可以直接访问到。

function Book(name, author) {
    if (!name) {
        throw new Error('书名未定义')
    }
    if (!author) {
        throw new Error('作者未定义')
    }
    this.name = name
    this.author = author
    this.getName = function() {
        return this.name
    }
    this.getAuthor = function() {
        return this.author
    }
}

var book = new Book('《钢铁是怎样炼成的》', '奥斯特洛夫斯基')
book.name // 《钢铁是怎样炼成的》
book.author // 奥斯特洛夫斯基
book.getName() // 《钢铁是怎样炼成的》
book.getAuthor() // 奥斯特洛夫斯基

这种创建对象的方式很简单,也很方便,而且构造函数中也有对属性的校验,咋一看没什么问题。

但是,其内部属性和方法完全是对外公开的,也就是说我们可以随意修改这些属性方法。虽然在创建时验证了 name 和 author,但如果我们直接修改实例属性呢?

function Book(name, author) {
    if (!name) {
        throw new Error('书名未定义')
    }
    if (!author) {
        throw new Error('作者未定义')
    }
    this.name = name
    this.author = author
    this.getName = function() {
        return this.name
    }
    this.getAuthor = function() {
        return this.author
    }
}

var book = new Book('《钢铁是怎样炼成的》', '奥斯特洛夫斯基')
book.getName() // 《钢铁是怎样炼成的》
book.getAuthor() // 奥斯特洛夫斯基
book.author = '断崖上的风'
book.getAuthor() // 断崖上的风
book.author = ''
book.getAuthor() // ''

这样的修改方式是不被允许的,但以这种方式创建的对象,完全可能被其他人有意或者无意地修改。

为了防止这种情况,我们可以添加特定方法去修改这些属性。

function Book(name, author) {
    if (!name || name === '') {
        throw new Error('书名未定义')
    }
    if (!author || author === '') {
        throw new Error('作者未定义')
    }
    this.name = name
    this.author = author
    this.setName = function(name) {
        if (!name) {
            throw new Error('书名未定义')
        }
        this.name = name
    }
    this.getName = function() {
        return this.name
    }
    this.setAuthor = function(author) {
        if (!author) {
            throw new Error('作者未定义')
        }
        this.author = author
    }
    this.getAuthor = function() {
        return this.author
    }
}

var book = new Book('《钢铁是怎样炼成的》', '奥斯特洛夫斯基')
book.setAuthor('') // Uncaught Error: 作者未定义

通过特定方法,我们可以实现修改属性时的校验。

但这样并不完全保险,因为当另外一个程序员看到这个对象时,他不一定去调用特定方法去修改属性,可能仍会直接修改对象属性。

用命名规范区分私有属性

针对上面的情况,我们可以命名规范约定 的方式来告诉别人“这个属性是私有属性,不能直接修改”,但这并不能阻止他人修改。

这种方式,我们一般用“_”来标识私有属性或者方法。

function Book(name, author) {
    this._name
    this._author
    this.setName = function(name) {
        if (!name) {
            throw new Error('书名未定义')
        }
        this._name = name
    }
    this.getName = function() {
        return this._name
    }
    this.setAuthor = function(author) {
        if (!author) {
            throw new Error('作者未定义')
        }
        this._author = author
    }
    this.getAuthor = function() {
        return this._author
    }
    this.setName(name)
    this.setAuthor(author)
}

通过这种方式,我们可以把私有成员同其它成员区分开来,一般人看到带下划线的属性、方法也不会去随意更改。但是,这种方式同之前定义接口的方式一样,属于文档形式上的,你仍然阻止不了某些心怀不轨的人。

利用作用域链实现私有成员

前面已经讲过了,在函数内部定义的变量,外部无法访问,其内部方法仍然可以访问。利用这一特性,我们可以在构造函数里定义私有变量,然后通过特权方法去访问这些变量。

function Book(bookName, authorName) {
    var name
    var author
    
    // 特权方法
    this.setName = function(bookName) {
        if (!bookName) {
            throw new Error('书名未定义')
        }
        name = bookName
    }
    this.getName = function() {
        return name
    }
    this.setAuthor = function(authorName) {
        if (!authorName) {
            throw new Error('作者未定义')
        }
        author = authorName
    }
    this.getAuthor = function() {
        return author
    }
    
    this.setName(bookName)
    this.setAuthor(authorName)
}

var book = new Book('《钢铁是怎样炼成的》', '奥斯特洛夫斯基')
book.name // undefined
book.getName() // 《钢铁是怎样炼成的》
book.name = 'HAHAHA'
book.getName() // 《钢铁是怎样炼成的》
booke.setName('《金瓶梅》')
book.getName() // 《金瓶梅》

通过作用域链的方式,我们并没有直接暴露对象属性,而是通过公开特权方法来间接操作私有属性。

像上列中,book 的 name 和 author 属性已经完全隐藏了,你除了通过特权方法来操作,无任何其他方式来操作这两个属性。

这就达到了信息隐藏的目的。

其他方式

很多时候,我们只想防止属性被外部随意改写,像上面创建特权方法的形式其实就是将对属性的读写操作用特定函数去实现。其实这两个特权方法就是访问器属性的 getter 和 setter。

function Book(bookName, authorName) {
    var name
    var author
    
    // 访问器属性
    Object.defineProperty(this, 'name', {
        get: function() {
            return name
        },
        set: function(value) {
            if (!value) {
            throw new Error('书名未定义')
        }
        name = value
        }
    })
    Object.defineProperty(this, 'author', {
        get: function() {
            return author
        },
        set: function(value) {
            if (!value) {
                throw new Error('作者未定义')
            }
            author = value
        }
    })
    
    this.name = bookName
    this.author = authorName
}

var book = new Book('《钢铁是怎样炼成的》', '奥斯特洛夫斯基')
book.name // 《钢铁是怎样炼成的》
book.name = 'HAHAHA'
book.name = '' // Uncaught Error: 书名未定义