惰性函数定义模式

http://realazy.org/blog/2007/08/16/lazy-function-definition-pattern/


这篇文章阐述的是一种函数式编程(functional-programming)设计模式,我称之为惰性函数定义(Lazy Function Definition)。我不止一次发现这种模式在JavaScript中大有用处,尤其是编写跨浏览器的、高效运行的库之时。

热身问题

编写一个函数foo,它返回的是Date对象,这个对象保存的是foo首次调用的时间。

方法一:上古时代的技术

这个最简陋的解决方案使用了全局变量t来保存Date对象。foo首次调用时会把时间保存到t中。接下来的再次调用,foo只会返回保存在t中的值。

var t;
function foo() {
    if (t) {
        return t;
    }
    t = new Date();
    return t;
}

但是这样的代码有两个问题。第一,变量t是一个多余的全局变量,并且在 foo调用的间隔期间有可能被更改。第二,在调用时这些代码的效率并没有得到优化因为每次调用 foo都必须去求值条件。虽然在这个例子中,求值条件并不显得低效,但在现实世界的实践例子中常常会有极为昂贵的条件求值,比如在if-else-else-…的结构中。

方法二:模块模式

我们可以通过被认为归功于Cornford Crockford模块模式来弥补第一种方法的缺陷。使用闭包可以隐藏全局变量t,只有在 foo内的代码才可以访问它。

var foo = (function() {
    var t;
    return function() {
        if (t) {
            return t;
        }
        t = new Date();
        return t;
    }
})();

但这仍然没有优化调用时的效率,因为每次调用foo依然需要求值条件。

虽然模块模式是一个强大的工具,但我坚信在这种情形下它用错了地方。

方法三:函数作为对象

由于JavaScript的函数也是对象,所以它可以带有属性,我们可以据此实现一种跟模块模式质量差不多的解决方案。

function foo() {
    if (foo.t) {
        return foo.t;
    }
    foo.t = new Date();
    return foo.t;
}

在一些情形中,带有属性的函数对象可以产生比较清晰的解决方案。我认为,这个方法在理念上要比模式模块方法更为简单。

这个解决方案避免了第一种方法中的全局变量t,但仍然解决不了foo每次调用所带来的条件求值。

方法四:惰性函数定义

现在,这是你阅读这篇文章的理由:

var foo = function() {
    var t = new Date();
    foo = function() {
        return t;
    };
    return foo();
};

foo首次调用,我们实例化一个新的Date对象并重置 foo到一个新的函数上,它在其闭包内包含Date对象。在首次调用结束之前,foo的新函数值也已调用并提供返回值。

接下来的foo调用都只会简单地返回t保留在其闭包内的值。这是非常快的查找,尤其是,如果之前那些例子的条件非常多和复杂的话,就会显得很高效。

弄清这种模式的另一种途径是,外围(outer)函数对foo的首次调用是一个保证(promise)。它保证了首次调用会重定义foo为一个非常有用的函数。笼统地说,术语“保证” 来自于Scheme的惰性求值机制(lazy evaluation mechanism)。每一位JavaScript程序员真的都应该学习Scheme,因为它有很多函数式编程相关的东西,而这些东西会出现在JavaScript中。

确定页面滚动距离

编写跨浏览器的JavaScript, 经常会把不同的浏览器特定的算法包裹在一个独立的JavaScript函数中。这就可以通过隐藏浏览器差异来标准化浏览器API,并让构建和维护复杂的页面特性的JavaScript更容易。当包裹函数被调用,就会执行恰当的浏览器特定的算法。

在拖放库中,经常需要使用由鼠标事件提供的光标位置信息。鼠标事件给予的光标坐标相对于浏览器窗口而不是页面。加上页面滚动距离鼠标的窗口坐标的距离即可得到鼠标相对于页面的坐标。所以我们需要一个反馈页面滚动的函数。演示起见,这个例子定义了一个函数getScrollY。因为拖放库在拖拽期间会持续运行,我们的getScrollY必须尽可能高效。

不过却有四种不同的浏览器特定的页面滚动反馈算法。Richard Cornford在他的feature detection article文章中提到这些算法。最大的陷阱在于这四种页面滚动反馈算法其中之一使用了 document.body. JavaScript库通常会在HTML文档的<head>加载,与此同时docment.body并不存在。所以在库载入的时候,我们并不能使用特性检查(feature detection)来确定使用哪种算法。

考虑到这些问题,大部分JavaScript库会选择以下两种方法中的一种。第一个选择是使用浏览器嗅探navigator.userAgent,为该浏览器创建高效、简洁的getScrollY. 第二个更好些的选择是getScrollY在每一次调用时都使用特性检查来决定合适的算法。但是第二个选择并不高效。

好消息是拖放库中的getScrollY只会在用户与页面的元素交互时才会用到。如果元素业已出现在页面中,那么document.body也会同时存在。getScrollY的首次调用,我们可以使用惰性函数定义模式结合特性检查来创建高效的getScrollY.

var getScrollY = function() {

    if (typeof window.pageYOffset == 'number') {
        getScrollY = function() {
            return window.pageYOffset;
        };

    } else if ((typeof document.compatMode == 'string') &&
               (document.compatMode.indexOf('CSS') >= 0) &&
               (document.documentElement) &&
               (typeof document.documentElement.scrollTop == 'number')) {
        getScrollY = function() {
            return document.documentElement.scrollTop;
        };

    } else if ((document.body) &&
               (typeof document.body.scrollTop == 'number')) {
      getScrollY = function() {
          return document.body.scrollTop;
      }

    } else {
      getScrollY = function() {
          return NaN;
      };

    }

    return getScrollY();
}

总结

惰性函数定义模式让我可以编写一些紧凑、健壮、高效的代码。用到这个模式的每一次,我都会抽空赞叹JavaScript的函数式编程能力。

JavaScript同时支持函数式和面向对象便程。市面上有很多重点着墨于面向对象设计模式的书都可以应用到JavaScript编程中。不过却没有多少书涉及函数式设计模式的例子。对于JavaScript社区来说,还需要很长时间来积累良好的函数式模式。

原文:Lazy Function Definition Pattern. 转载没有我的信息没有关系,但你一定得写上原文信息,谢谢。

更新

这个模式虽然有趣,但由于大量使用闭包,可能会由于内存管理的不善而导致性能问题。来自FCKeditor的FredCK改进了getScrollY,既使用了这种模式,也避免了闭包:

var getScrollY = function() {

    if (typeof window.pageYOffset == 'number')
        return (getScrollY = getScrollY.case1)();

    var compatMode = document.compatMode;
    var documentElement = document.documentElement;

    if ((typeof compatMode == 'string') &&
               (compatMode.indexOf('CSS') >= 0) &&
               (documentElement) &&
               (typeof documentElement.scrollTop == 'number'))
        return (getScrollY = getScrollY.case2)();

    var body = document.body ;
    if ((body) &&
               (typeof body.scrollTop == 'number'))
        return (getScrollY = getScrollY.case3)();

    return (getScrollY = getScrollY.case4)();
};

getScrollY.case1 = function() {
    return window.pageYOffset;
};

getScrollY.case2 = function() {
    return documentElement.scrollTop;
};

getScrollY.case3 = function() {
    return body.scrollTop;
};

getScrollY.case4 = function() {
        return NaN;
};

请看具体的评论

20 Responses to “惰性函数定义模式”

  1. 沈蚊 Says:

    羡慕。。。偶都木有时间写Blog。。。

  2. TerranC Says:

    恩..非常不错的文章

  3. netfishx Says:

    好!peter的这篇文章又更新,帮忙也翻译一下。:)

  4. DF Says:

    有意思的入门文章,javascript在functional方面的特性可能甚少被开发者考虑。
    不过我觉得closure少被使用的原因是IE6的memory leak,被reference不能释放closure,除非开发者主动清空;调用的越多,留下的隐患越多……
    http://www.jibbering.com/faq/faq_notes/closures.html#clMem
    http://msdn.microsoft.com/library/default.asp?url=/library/en-us/ietechcol/dnwebgen/ie_leak_patterns.asp
    而Microsoft似乎建议不要使用闭包……
    http://support.microsoft.com/default.aspx?scid=KB;EN-US;830555

  5. iWorm Says:

    嗯, 写的不错.
    懒人用懒惰模式. 哈哈哈

  6. qinyf Says:

    看到第一眼以为是你自创的函数,哈哈。
    内容赞一下

  7. aw Says:

    脚本语言的函数实在是博大精深到了极致。

  8. shqlsl Says:

    非常的棒. 特别 第二次运行… 真的好办. 对于只有一次运行来说没啥效果…
    你能不能给一个效率对比的… 天天关注你的博客

  9. shqlsl Says:

    我做了一个测试… 非常棒

  10. g-carton Says:

    看不大懂,还没学到这个东西呢~
    不过感觉第二段代码的写法很独特~呵呵!

  11. hax Says:

    realazy同志,吹捧的话我就不说了。俺说点不中听的。

    首先我觉得你这篇文章所讨论的方法并不宜称之为惰性函数定义。实际上我第一次看到这个标题,我以为你打算在js中使用一种模拟lazy evaluation的技术,但实际上并非如此。在我的印象中,lazy译作惰性,常见于惰性求值,而”惰性函数”我们会理解为使用惰性求值的函数。所以你的”惰性函数定义”的名字容易造成歧义。

    其次我觉得你所说的方法与lazy initialization(http://en.wikipedia.org/wiki/Lazy_initialization)并无本质区别,只是js对象可以改写,所以你改写后避免了一次判断。

    下面,我要泼一点冷水。我认为这一技巧在绝大多数场合是没有什么特别优点的,相反存在着陷阱。

    在目前所有的js引擎中,一次判断的性能开销与其他开销(例如一次函数调用的开销)相比,根本不在一个数量级上。而你所说的”如果之前那些条件非常多和复杂的话”,这个假设是错误的,因为并不需要每次都进行非常多和复杂的条件判断——方法三本质上是一个cache模式。如果这些条件逻辑上每次都需要重新计算,那也不可能去使用你的“函数重新定义自身”的方法。

    注意,对于方法三的运用来说,你前面的示例和后面的getScrollY其实有一定的不同。前者是直接缓存函数的运算结果,后者则需要缓存函数分支条件。相对来说后者看上去更适合用你的”函数重新定义自身”的方法,而前者则相反。

    在”缓存运算结果”的case里,方法四的实际效率比方法三更低。我们来分析一下:

    第一次调用时,方法三需要一次boolean判断,然后运算,然后缓存结果(一次对local的赋值),然后返回结果。方法四需要运算,然后产生一个闭包,重新定义自己(一次对外层scope的赋值),然后再调用一次自身,返回调用结果。

    可见,第一次调用的时候,肯定是方法三快。

    再看以后的调用,方法三需要一次boolean判断,然后返回缓存结果。方法四直接返回结果。

    看上去方法四会快,但是注意,方法三的缓存结果是在local上,而方法四,是在外面一层的scope上,因此变量搜索上,理论上方法三会比方法四快一点。当然变量搜索的开销其实是很小的,但反过来,boolean判断的开销也是非常小的。因此很难说方法四就比方法三快,即使快,也快得非常有限,可以忽略不计。

    但是方法四,其实存在另一方面的问题。

    在方法三中,如果有很多的中间运算结果,在第一次运行后,这些结果都会被自动回收回去。而方法四中,由于替换自身的闭包对于被替换的方法内的所有变量都存在引用,因此这些变量所指向的对象全部不会被回收回去!!除非你手动把变量设置为null——手动清空显然不是什么好模式。

    当然理论上,在闭包中没有明确用到的外部变量其实可以不影响垃圾回收(除非在闭包中存在eval之类的东西),但是目前我已知的主流js引擎似乎都没有做这方面的优化。因此,不恰当的使用方法四,会造成内存泄漏。即使在getScrollY的例子中,根本不存在任何局部变量,其所产生的闭包中也不存在任何原本方法的可能的引用,但实际上原方法仍是不会被回收的。如果你要测试的话,可以在第一次调用前执行getScrollY.wasteMemory = new Array(100000).join(‘foobar’);然后观察浏览器的内存在第一次调用后是否就减小了。我虽然没有实测过,但是根据经验,结果应该是不会释放这些内存的。而且在第一次调用之后,你甚至再也没有机会去释放这个内存,呵呵。

    值得注意的是,这并非浏览器bug造成的内存泄漏,而只是由于缺乏优化而造成的泄漏。

    所以你的这个模式恐怕既不健壮,也不高效,至于说是不是紧凑呢,我自己是没看出来方法四比方法三紧凑在哪里。而且就这个模式本身来说,并没有用到很functional的技巧。

    BTW,我看到最后发现是翻译的。。。不知道你有没有兴趣把我的回复翻译回去贴到原出处去,呵呵。

  12. Lich_Ray Says:

    确实,就优化难度上来说,优化同一布尔表达式的反复求值比优化闭包要常见地多。
    不过文章给出的思想确实是可行的,风格也没什么问题,而且最重要的是,很安全。

  13. hax Says:

    To lich ray:

    为什么很难做闭包优化,这和js的一些特点有关,请看我javaeye上的最新一篇blog的最后一段。简单来说:eval可能会动态访问任何变量,with可以动态的影响scope。

  14. 张沈鹏 Says:

    function foo() {
    return this.date||(this.date=Date());
    };

  15. 张沈鹏 Says:

    惰性函数定义?不是最优化方案
    http://zsp.javaeye.com/blog/113579

  16. JSFans Says:

    我是初学,看了第四种的写法,有几个地方不明白,我很想弄明白,希望能解释一下。问题是这样的:函数内是对foo方法的重新定义吧?如果是的话,我们直接在调用函数的时候foo()不就完成对新方法的调用了吗?为什么还要加上reutrn foo()呢?我也知道,没有return foo()会出错,可还是想不通这句话的作用。再有楼主说这么写“只会简单地返回t保留在其闭包内的值”,可每次返回的时间都是不同的,这样不还是等同于重新计算一次时间了吗?还是我的理解错误。别见笑我的问题,盼望你的解答,谢谢。

  17. hax Says:

    刚刚洗了个澡,想到似乎有点地方不够严谨,上来补充一下。

    一个是,是否有js引擎做了优化可以把closure没有用到的变量对象回收掉,也许某些编译型js引擎会作这种优化,但是我没有测试过。
    另一个,改写函数本身是存在一定风险的(例如在第一次调用之前,有人持有了对该方法的引用,而第一次调用又恰好存在某种影响条件的副作用)。
    而且也违背functional的精神(纯fp中不会存在重新binding的事情)。

  18. x16manx Says:

    To JSFans: 关于return foo();

    return是为了完成函数的重定义. return foo()是为了在函数内部就完成了对函数重定义的第一次调用.

    你可以这么理解.

    如果我们在函数内部.只是return foo;这样也是可以的.

    但是,在外部调用的时候,我们就要写两次foo()的调用,才能达到我们想要的重定义以后的函数的执行.
    foo();//第一次,仅仅完成函数的重定义.
    foo();//第二次,真真的执行重定义以后的新函数.

    很显然,这样不够好,所以就将return foo();放在了函数内部.这样在外面的第一调用就已经是
    在执行重定义以后的新函数了.

    还有,我对于方法三,理解上也比较吃力. 为什么在函数执行后,原来的对象属性还存在着呢.
    这个怎么和闭环对应上呢.Cache就更不理解了.

    谁来解惑啊.

  19. s79 Says:

    @JSFans
    return foo()是为了让第一次调用的时候有返回值。第一次改变了foo的引用,也要返回值。当然写成return foo() 还不如直接return t 简明。
    @x16manx
    方法三把t作为对象foo(这个对象是个函数)的属性,所以得以保存。就像全局变量(其实相当于window的属性)能被保存下来一样。也就是说只要能找到foo,就能找到foo.t,不会被回收。
    hax说的Cache就是把t保存下来的意思。

    其实这样保存和使用全局变量保存t,本质上是一样的。都使用了不必要的public变量或属性。个人认为闭包保存私有变量是最好方法。

  20. YONG Says:

    赞赞赞!牛牛牛。脚本语言原来如此的优美!


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值