单体模式讲解(代码实例)
定义
单体是一个用来划分命名空间并将一批相关方法和属性组织在一起的对象,如果它可以被实例化,那么它只能被实例化一次。
注:单体是一个只能被实例化一次并且可以通过一个众所周知的访问点访问的类。——传统的定义
拥有私有成员的单体
1、下划线表示
在单体对象中使用下划线表示法是一种告诫其他程序员不要直接访问特定成员的简明办法:
GiantCorp.DataParser = { //私有方法 _stripWhitespace: function(str) { return str.replace(/\s+/,''); } _stringSplit: function(str, delimiter) { return str.split(delimiter); }, //公有方法 stringToArray: function(str, delimiter, stripWS) { if(stripWS) { str = this._stripWhitespace(str); } var outputArray = this._stringSplit(str, delimiter); return outputArray; } }
在以后,你发现_stringSplit没有必要作为一个单独的函数存在,你可以删除它。因为这是用下划线标记的私有方法,没人会调用它(如果有人的话,他活该)。 stringToArray用this访问单体中的其它方法,这简便,但有风险。this不一定指向GiantCorp.DataParser。使用全名GiantCorp.DataParser访问单体内其它成员更加保险。
2、 使用闭包
简单的单体是这样的:
MyNamespace.Singleton = {};
现在,用一个立即执行函数创建单体:
MyNamespace.Singleton = function() { return {}; }(); //或者这样: MyNamespace.Singleton = (function() { return {}; })();
上面两个MyNamespace.Singleton相同。 但是,加上函数包装后,就创建了一个可以添加真正的私用成员的闭包。任何声明在这个匿名函数(不是对象字面量中)的变量或函数都只能被在同一个闭包中声明的其它函数访问。 这个闭包在匿名函数执行结束后依然存在,在其中声明的函数和变量总能从匿名函数所返回的对象内部(并且只能从内部)访问。
下面代码示范在匿名函数中添加私有成员:
MyNamespace.Singleton = (function() { //私有成员 var privateAttribute1 = false; var privateAttribute2 = [1, 2, 3]; function privateMethod1() { //... } function privateMethod2(args) { //... } //公有成员 return { publicAttribute1: true, publicAttribute2: 10, publicMethod1: function() { //... }, publicMethod2: function(args) { //... } } })();
这种单体模式又称模块模式。它可以把一批相关方法和属性组织为模块并起到划分命名空间的作用。
3、两种技术比较
对于1中的例子,用闭包实现一下:
GiantCorp.DataParser = (function() { //私有属性 var whitespaceRegex = /\s+/; //私有方法 function stripWhitespace(str) { return str.replace(whitespaceRegex,''); } function stringSplit(str, delimiter) { return str.split(delimiter); }, return { //公有方法 stringToArray: function(str, delimiter, stripWS) { if(stripWS) { str = stripWhitespace(str); //不需要this.来访问 } var outputArray = stringSplit(str, delimiter); //同上 return outputArray; } } })();
使用闭包,私有方法和属性可以直接使用名称访问,而不必在前面加上this.或GiantCorp.DataParser.前缀,前缀只用于访问单体对象的公用成员。 使用闭包,可以确保私有成员不会在单体对象之外被使用。可以改变对象实现细节且不影响别人代码。 使用闭包,可以对数据进行保护和封装(单体很少被这样用,除非数据只能被保存在一个地方)。
惰性实例化
上面单体模式的各种实现方式有一个共同点:单体对象都是在脚本加载时被创建出来。 对于资源密集型或配置开销大的单体,更合理的做法是将其实例化推迟到需要使用它的时候。该技术叫做惰性加载,它最常用于必须加载大量数据的单体。 那些被用作命名空间、特定网页专用代码包装器或组织相关实用方法的工具的单体最好还是立即实例化。
对惰性加载的单体的访问必须借助于一个静态方法。应该这样调用其方法:Singleton.getInstance().methodName(),而不是这样调用:Singleton.methodName()。getInstance方法会检查该单体是否已经被实例化。如果没有,它将创建并返回其实例。如果单体已经实例化过,那么它将返回现有实例。
下面示范一下如何把普通单体转化为惰性加载单体:
MyNamespace.Singleton = (function() { //私有成员 var privateAttribute1 = false; var privateAttribute2 = [1, 2, 3]; function privateMethod1() { //... } function privateMethod2(args) { //... } //公有成员 return { publicAttribute1: true, publicAttribute2: 10, publicMethod1: function() { //... }, publicMethod2: function(args) { //... } } })();
1、 转化工作的第一步是把单体的所有代码转移到一个名为constructor的方法中:
MyNamespace.Singleton = (function() { function constructor() { //私有成员 var privateAttribute1 = false; var privateAttribute2 = [1, 2, 3]; function privateMethod1() { //... } function privateMethod2(args) { //... } //公有成员 return { publicAttribute1: true, publicAttribute2: 10, publicMethod1: function() { //... }, publicMethod2: function(args) { //... } } } })();
2、constructor方法不能从闭包外部访问,我们可以全权控制其调用时机。公有方法getInstance就是用来实现这种控制的。为了让constructor方法成为公用方法,只需要将其放到一个对象字面量中并返回该对象即可:
MyNamespace.Singleton = (function() { function constructor() { ... } return { getInstance: function() { //这里是控制代码 } } })();
3、现在开始编写用于控制单体类实例化时机的代码。它需要做两件事:
第一,它必须知道该类是否已经被实例化过。 第二,如果该类已经实例化过,那么它需要掌握其实例的情况,以便能返回这个实例。
做这两件事需要用到一个私有属性和已有的私有方法constructor:
MyNamespace.Singleton = (function() { //私有属性 var uniqueInstance; function constructor() { ... } return { getInstance: function() { //若没有被实例化过 if(!uniqueInstance) { uniqueInstance = constructor(); } return uniqueInstance; } } })();
把一个单体转化为惰性加载单体后,调用方式需要修改。像这样的方法调用:
MyNamespace.Singleton.publicMethod1()
应该被改为这样的形式:
MyNamespace.Singleton.getInstance().publicMethod1()
惰性加载单体的缺点之一在于其复杂性。用于创建这种类型的单体的代码并不直观,而且不易理解(良好的文档倒是可以提供帮助)。 如果需要创建一个延迟实例化的单体,最好为其编写一条注释解释原因,以免别人把它简化为普通单体。 如果觉得命名空间名称太长,可以创建一个别名。此别名是一个保存了对特定对象的引用的变量。例如:var MNS = MyNamespace.Singleton。这样会创建一个全局变量,最好还是把它声明在一个特定网页专用代码包装器单体中。在存在单体嵌套的情况下会出现一些作用域方面的问题。在这种场合下访问其他成员最好使用完全限定名(如GiantCorp.SingletonName)而不是this。
分支
分支是一种用来把浏览器之间的差异封装到在运行期间进行设置的动态方法中的技术。
例如,我们需要创建一个返回XHR对象的方法。这种XHR对象在大多数浏览器中是XMLHttpRequest类的实例,而在IE早期版本中则是某种ActiveX类的实例。这样一个方法通常会进行某种浏览器嗅探或对象探测。如果不使用分支技术,那么每次调用该方法,所有的那些浏览器嗅探代码都要再次运行。要是这个方法调用频繁,就会严重缺乏效率。
更有效的做法是只在脚本加载时一次性地确定针对特定浏览器的代码。这样,在初始化完成后,每种浏览器都只会执行针对它的JavaScript实现而设计的代码。
我们可以创建两个不同的字面量对象,并根据某种条件将其中之一赋给那个变量:
MyNamespace.Singleton = (function() { var objectA = { method1: function() { ... }, method2: function() { ... } }; var objectB = { method1: function() { ... }, method2: function() { ... } }; return (someCondition) ? objectA : objectB; })();
上面的代码创建了两个对象字面量,它们拥有相同的方法。实现了同样的接口,可以执行相同的任务,不同之处仅仅在于对象的方法具体使用的代码。 分支技术并不总是更高效的选择。在上面的例子中,有两个对象被创建并保存在内存中,但派上用场的只有一个。在考虑使用该技术时,你必须在缩短计算时间(判断使用哪个对象的代码只会执行一次)和占用更多内存这一利一弊之间权衡一下。
下面这个例子就属于适合采用分支技术的情况,因为其中分支对象较小而判断使用哪个对象的开销较大。
用分支技术创建XHR对象
在本例中我们创建一个单体,它有一个生成XHR对象实例的方法。
首先判断需要多少分支,因为所能实例化的对象只有3种不同类型,所以需要3个分支。这些分支分别按其返回的XHR对象类型命名:
var SimpleXhrFactory = (function() { var standard = { createXhrObject: function() { return new XMLHttpRequest(); } }; var activeXNew = { createXhrObject: function() { return new ActiveXObject('Msxml2.XMLHTTP'); } }; var activeXOld = { createXhrObject: function() { return new ActiveXObject('Microsoft.XMLHTTP'); } }; })();
这3个分支各包含一个对象字面量,都有一个名为createXhrObject的方法,这个方法做的是返回一个XHR对象。
- 第二步是根据条件将3个分支中某一分支的对象赋给那个变量。具体做法是逐一尝试每种XHR对象,直到遇到一个当前JavaScript环境支持的对象:
var SimpleXhrFactory = (function() { var standard = { createXhrObject: function() { return new XMLHttpRequest(); } }; var activeXNew = { createXhrObject: function() { return new ActiveXObject('Msxml2.XMLHTTP'); } }; var activeXOld = { createXhrObject: function() { return new ActiveXObject('Microsoft.XMLHTTP'); } }; var testObject; try { testObject = standard.createXhrObject(); return standard; } catch(e) { try { testObject = activeXNew.createXhrObject(); return activeXNew; } catch(e) { try { testObject = activeXOld.createXhrObject(); return activeXOld; } catch(e) { throw new Error('No XHR object found in this environment.'); } } } })();
使用该API的程序员只要调用SimpleXhrFactory.createXhrObject()就能得到适合特定运行环境的XHR对象。用了分支技术后,所有的特性嗅探代码都只会执行一次,而不是每生成一个对象就要执行一次。
单体模式的使用场合
在简单项目中,你可以把单体用作命名空间。 在稍大、稍复杂的项目中,单体可以用来把相关代码组织在一起以便日后维护,或者用来把数据或代码安置在一个众所周知的单一位置。 在大型或复杂项目中,那些开销较大却很少使用的组件可以被包装到惰性加载单体中;针对特定环境的代码可以被包装到分支型单体中。
单体模式之利
主要是对代码的组织作用。
把相关方法和属性组织在一个不会被多次实例化的单体中,代码的调试和维护更轻松。 描述性的命名空间可以增强代码的自我说明,有利于新手阅读和理解。 把方法包裹在单体中,防止被其他程序员误改、防止全局变量污染。 单体可以把代码与第三方库代码和广告代码隔离开,在整体上提高网页的稳定性。 使用惰性实例化技术,减少不需要它的用户承受的不必要的内存消耗(还可能包括带宽消耗)。 分支技术可以用来创建高效的方法,根据运行时的条件确定对象字面量,不会在每次调用时都一再浪费时间检查运行环境。
单体模式之弊
单体模式提供的是一种单点访问,可能导致模块间的强耦合。这是这种模式受到的主要批评。有时创建一个可实例化的类更为可取,哪怕只会被实例化一次。 因为单体可能导致类间的强耦合,所以也不利于单元测试。无法单独测试一个调用了来自单体的方法的类,而只能把它与那个单体作为一个单元一起测试。单体最好还是留给定义命名空间和实现分支型方法这些用途,这些情况下耦合不是什么问题。 有时某种更高级的模式会更符合需要。与惰性加载单体相比,虚拟代理能给予对类实例化方式更多的控制权。也可以用一个真正的对象工厂取代分支型单体(虽然这个工厂可能也是一个单体)。不要仅仅因为单体可以了就使用它,请选择最合适的模式。
下一篇: 我的Spark SQL单元测试实践