命名函数表达式探秘

 

JScript的bug

令人讨厌的是,JScript(也就是IE的ECMAScript实现)严重混淆了命名函数表达式。JScript搞得现如今很多人都站出来反对命名函数表达式。而且,直到JScript的最近一版——IE8中使用的5.8版——仍然存在下列的所有怪异问题。

下面我们就来看看IE在它的这个“破”实现中到底都搞出了哪些花样。唉,只有知已知彼,才能百战不殆嘛。请注意,为了清晰起见,我会通过一个个相对独立的小例子来说明这些问题,虽然这些问题很可能是一个主bug引起的一连串的后果。

例1:函数表达式的标识符渗透到外部(enclosing)作用域中
cript >    var f = function g(){};
    typeof g; // "function"
还有人记得吗,我们说过:命名函数表达式的标识符在其外部作用域中是无效的? 好啦,JScript明目张胆地违反了这一规定——上面例子中的标识符DE>gDE>被解析为函数对象。这是最让人头疼的一个问题了。这样,任何标识符都可能会在不经意间“污染”某个外部作用域——甚至是全局作用域。而且,这种污染常常就是那些难以捕获的bug的来源。

例2:将命名函数表达式同时当作函数声明和函数表达式
cript >    typeof g; // "function"
    var f = function g(){};
如前所述,在特定的执行环境中,函数声明会先于任何表达式被解析。上面这个例子展示了JScript实际上是把命名函数表达式当作函数声明了;因为它在“实际的”声明之前就解析了DE>gDE>。

这个例子进而引出了下一个例子:

例3:命名函数表达式会创建两个截然不同的函数对象!
cript >    var f = function g(){};
    f === g; // false

    f.expando = 'foo';
    g.expando; // undefined
问题至此就比较严重了。或者可以说修改其中一个对象对另一个丝毫没有影响——这简直就是胡闹!通过例子可以看出,出现两个不同的对象会存在什么风险。假如你想利用缓存机制,在DE>fDE>的属性中保存某个信息,然后又想当然地认为可以通过引用相同对象的DE>gDE>的同名属性取得该信息,那么你的麻烦可就大了。

再来看一个稍微复杂点的情况。

例4:只管顺序地解析函数声明而忽略条件语句块
cript >    var f = function g() {
        return 1;
    };
    if (false) {
        f = function g(){
            return 2;
        };
    }
    g(); // 2
要查找这个例子中的bug就要困难一些了。但导致bug的原因却非常简单。首先,DE>gDE>被当作函数声明解析,而由于JScript中的函数声明不受条件代码块约束(与条件代码块无关),所以在“该死的”DE>ifDE>分支中,DE>gDE>被当作另一个函数——DE>function g(){ return 2 }DE>——又被声明了一次。然后,所有“常规的”表达式被求值,而此时DE>fDE>被赋予了另一个新创建的对象的引用。由于在对表达式求值的时候,永远不会进入“该死的”DE>ifDE>分支,因此DE>fDE>就会继续引用第一个函数——DE>function g(){ return 1 }DE>。分析到这里,问题就很清楚了:假如你不够细心,在DE>fDE>中调用了DE>gDE>(在执行递归操作的时候会这样做。——译者注),那么实际上将会调用一个毫不相干的DE>gDE>函数对象(即返回2的那个函数对象。——译者注)。

聪明的读者可能会联想到:在将不同的函数对象与DE>arguments.calleeDE>进行比较时,这个问题会有所表现吗?DE>calleeDE>到底是引用DE>fDE>还是引用DE>gDE>呢?下面我们就来看一看:

cript >  var f = function g(){
    return [
      arguments.callee == f,
      arguments.callee == g
    ];
  };
  f(); // [true, false]
  g(); // [false, true]
看到了吧,DE>arguments.calleeDE>引用的始终是被调用的函数。实际上,这应该是件好事儿,原因你一会儿就知道了。

另一个“意外行为”的好玩的例子,当我们在不包含声明的赋值语句中使用命名函数表达式时可以看到。不过,此时函数的名字必须与引用它的标识符相同才行:

cript >  (function(){
    f = function f(){};
  })();
众所周知(但愿如此。——译者注),不包含声明的赋值语句(注意,我们不建议使用,这里只是出于示范需要才用的)在这里会创建一个全局属性DE>fDE>。而这也是标准实现的行为。可是,JScript的bug在这里又会出点乱子。由于JScript把命名函数表达式当作函数声明来解析(参见前面的“例2”),因此在变量声明阶段,DE>fDE>会被声明为局部变量。然后,在函数执行时,赋值语句已经不是未声明的了(因为f已经被声明为局部变量了。——译者注),右手边的DE>function f(){}DE>就会被直接赋给刚刚创建的局部变量DE>fDE>。而全局作用域中的DE>fDE>根本不会存在。

看完这个例子后,相信大家就会明白,如果你对JScript的“怪异”行为缺乏了解,你的代码中出现“严重不符合预期”的行为就不难理解了。

明白了JScript的缺陷以后,要采取哪些预防措施就非常清楚了。首先,要注意防范标识符泄漏(渗透)(不让标识符污染外部作用域)。其次,应该永远不引用被用作函数名称的标识符;还记得前面例子中那个讨人厌的标识符DE>gDE>吗?——如果我们能够当DE>gDE>不存在,可以避免多少不必要的麻烦哪。因此,关键就在于始终要通过DE>fDE>或者DE>arguments.calleeDE>来引用函数。如果你使用了命名函数表达式,那么应该只在调试的时候利用那个名字。最后,还要记住一点,一定要把NFE(Named Funciont Expresssions,命名函数表达式)声明期间错误创建的函数清理干净。

嗯,对于上面最后一点,我觉得还要再啰嗦两句:

JScript的内存管理

熟悉上述JScript缺陷之后,再使用这些有毛病的结构,就会发现内存占用方面的潜在问题。下面看一个简单的例子:

cript >  var f = (function(){
    if (true) {
      return function g(){};
    }
    return function g(){};
  })();
我们知道,这里匿名(函数)调用返回的函数——带有标识符DE>gDE>的函数——被赋值给了外部的DE>fDE>。我们也知道,命名函数表达式会导致产生多余的函数对象,而该对象与返回的函数对象不是一回事。由于有一个多余的DE>gDE>函数被“截留”在了返回函数的闭包中,因此内存问题就出现了。这是因为(if语句)内部(的)函数与讨厌的DE>gDE>是在同一个作用域中被声明的。在这种情况下 ,除非我们显式地断开对(匿名调用返回的)DE>gDE>函数的引用,否则那个讨厌的家伙会一直占着内存不放。

cript >  var f = (function(){
    var f, g;
    if (true) {
      f = function g(){};
    }
    else {
      f = function g(){};
    }
    // 废掉g,这样它就不会再引用多余的函数了
    g = null;
    return f;
  })();
请注意,这里也明确声明了变量DE>gDE>,因此赋值语句DE>g = nullDE>就不会在符合标准的客户端(如非JScript实现)中创建全局变量DE>gDE>了。通过DE>废掉DE>对DE>gDE>的引用,垃圾收集器就可以把DE>gDE>引用的那个隐式创建的函数对象清除了。

在解决JScript NFE内存泄漏问题的过程中,我运行了一系列简单的测试,以便确定DE>废掉DE>DE>gDE>能够释放内存。

测试

这里的测试很简单。就是通过命名函数表达式创建10000个函数,把它们保存在一个数组中。过一会儿,看看这些函数到底占用了多少内存。然后,再废掉这些引用并重复这一过程。下面是我使用的一个测试用例:

cript >  function createFn(){
    return (function(){
      var f;
      if (true) {
        f = function F(){
          return 'standard';
        }
      }
      else if (false) {
        f = function F(){
          return 'alternative';
        }
      }
      else {
        f = function F(){
          return 'fallback';
        }
      }
      // var F = null;
      return f;
    })();
  }

  var arr = [ ];
  for (var i=0; i<10000; i++) {
    arr[i] = createFn();
  }
通过运行在Windows XP SP2中的Process Explorer可以看到如下结果:

cript >  IE6:

    without `null`:   7.6K -> 20.3K
    with `null`:      7.6K -> 18K

  IE7:

    without `null`:   14K -> 29.7K
    with `null`:      14K -> 27K
这个结果大致验证了我的想法——显式地清除多余的引用确实可以释放内存,但释放的内存空间相对不多。在创建10000个函数对象的情况下,大约有3MB左右。对于大型应用程序,以及需要长时间运行或者在低内存设备(如手持设备)上运行的程序而言,这是绝对需要考虑的。但对小型脚本而言,这点差别可能也算不了什么。

有读者可能认为本文到此差不多就该结尾了——实际上还差得远呢 :)。我还想再多谈一点,这些内容涉及的是Safari 2.x。

Safari中存在的bug

在Safari较早的版本——Safari 2.x系列中,也存在一些鲜为人知的与NFE有关的bug。我在Web上看到有人说Safari 2.x不支持NFE 。实际上不是那么回事。Safari确实支持NFE,只不过它的实现中存在bug而已(很快你就会看到)。

在某些情况下,Safari 2.x遇到函数表达式时会出现不能完全解析程序的问题。而且,此时的Safari不会抛出任何错误(例如DE>SyntaxErrorDE>),只会“默默地知难而退”:

cript >  (function f(){})(); // <== NFE
  alert(1); // 由于前面的表达式破坏了整个程序,因此这一行永远不会执行
经过多次测试,我得出一个结论:Safari 2.x 不能解析非赋值表达式中的命名函数表达式。下面是一些赋值表达式的例子:

cript >    // 变量声明
    var f = 1;

    // 简单赋值
    f = 2, g = 3;

    // 返回语句
    (function(){
      return (f = 2);
    })();
换句话说,把命名函数表达式放到一个赋值表达式中会让Safari“很高兴”:

cript >  (function f(){}); // 失败

  var f = function f(){}; // 没问题

  (function(){
    return function f(){}; // 失败
  })();

  (function(){
    return (f = function f(){}); // 没问题
  })();

  setTimeout(function f(){ }, 100); // 失败
 
  Person.prototype = {
    say: function say() { ... } // 失败
  }
 
  Person.prototype.say = function say(){ ... }; // 没问题
同时这也就意味着,在不使用赋值表达式的情况下,我们不能使用习以为常的模式返回命名函数表达式:

cript >
  // 以下返回命名函数表达式的常见模式,对Safari 2.x来说是不兼容的:
  (function(){
    if (featureTest) {
      return function f(){};
    }
    return function f(){};
  })();

  // 在Safari 2.x中,应该使用以下稍麻烦一点的方式:
  (function(){
    var f;
    if (featureTest) {
      f = function f(){};
    }
    else {
      f = function f(){};
    }
    return f;
  })();

  // 或者,像下面这样也行:
  (function(){
    var f;
    if (featureTest) {
      return (f = function f(){});
    }
    return (f = function f(){});
  })();

  /*
    可是,这样一来,就额外使用了一个对函数的引用,而该引用还被封闭在了返回函数的闭包中。
        为了最大限度地降低额外的内存占用,可以考虑把所有命名函数表达式都赋值给一个变量。
  */

  var __temp;

  (function(){
    if (featureTest) {
      return (__temp = function f(){});
    }
    return (__temp = function f(){});
  })();

  ...

  (function(){
    if (featureTest2) {
      return (__temp = function g(){});
    }
    return (__temp = function g(){});
  })();

  /*
    这样,后续的赋值语句通过“重用”前面的引用,达到了不过多占用内存的目的。
  */
如果兼容Safari 2.x非常重要,就应该保证源代码中不能出现任何“不兼容”的结构。虽然这样做不免会让人着急上火,可只要抓住了问题的根源,还是绝对能够做到的。

对了,还有个小问题必须说明一下:在Safari 2.x中声明命名函数时,函数的字符串表示不会包含函数的标识符:

cript >  var f = function g(){};

  // 看到了吗,函数的字符串表示中没有标识符g
  String(f); // function () { }
这不算什么大问题。但正如我以前说过的,函数的反编译结果是无论如何也不能相信的

 

转自:http://lizenglang1.blog.163.com/blog/static/13626970120100802825211/

 

 

练习:

f = function() { return true; };
g = function() { return false; };
( function() {
if (g() && [] == ![]) {
f = function f() { return false; };
function g() { return true; };
}
})();

f(); // What's the result?

答题要求:

  1. 至少考虑 Chrome, Firefox, Safari, Opera, IE 五大浏览器。
  2. IE 考虑 IE6 到 IE9 beta, 其它浏览器考虑最新稳定版。
  3. 不能仅给一个结果,要说明为什么。

答案

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值