2011年12月18日 15:30 [Edit]

javascript - Function.prototype.bindを無理矢理捕縛してみた

+1。

Function.prototype.bindは何がいいのか - 枕を欹てて聴く
というわけでFunction.prototype.bindは単なる簡単な追加機能とか補足みたいなのじゃなくて, 凄まじい新機能(call, applyに匹敵)で, かつ非常に奥が深いのでした.

なのにSafariとiOSとAndroidでサポートしてないなんて。あんまりだよ、こんなのってないよ。


Prototype.jsにあった、ような…

Prototype.jsのころはこんな感じでした。

var oBind = Function.prototype.bind; /* preserve the original */
Function.prototype.bind = function bind(thisArg) {
    var that = this;
    var args = Array.prototype.slice.call(arguments, 1);
    return function bound() {
      var a = args.concat(Array.prototype.slice.call(arguments));
      return that.apply(thisArg, a);
    }
};
try{
    /* works fine */
    function log(b, x){ return Math.log(x)/Math.log(b) };
    var log10 = log.bind(null, 10);
    p(log10(1e6));
    /* but this does not */
    function Test(a, b) {
      this.a = a;
      this.b = b;
    };
    Test.prototype.test = function Test_test() {
      p([this.a, this.b]);
    };
    var BoundTest = Test.bind(null, 100);
    new BoundTest(200).test();
}catch(e){
    p(e);
}
oBind ? Function.prototype.bind = oBind : delete Function.prototype.bind;

ECMAScript 5のFunction.prototype.bindとPrototype.jsのそれの最大の違いは、bindされた関数をコンストラクターとして使えるかどうかです。

もうnewも恐くない

PolyfillといえばMDN。実に面白い方法で解決しています。なるほどね。そうやってprototypeをつないでいるのか。

しかしこの方法も、Dateのような変態コンストラクターの前には無力です。

var oBind = Function.prototype.bind; /* preserve the original */
Function.prototype.bind = function(oThis){  
    if (typeof this !== "function") throw new TypeError(
        'Function.prototype.bind cannot bind type ' + typeof(this)
    );
    var aArgs   = Array.prototype.slice.call(arguments, 1),
        fToBind = this,   
        fNOP    = function () {},
        fBound  = function () {  
            return fToBind.apply(
                this instanceof fNOP ? this : oThis || window,
                aArgs.concat(Array.prototype.slice.call(arguments))
            );
        };
    fNOP.prototype = this.prototype;
    fBound.prototype = new fNOP();
    return fBound;  
};
try{
    /* works fine */
    function log(b, x){ return Math.log(x)/Math.log(b) };
    var log10 = log.bind(null, 10);
    p(log10(1e6));
    /* now works */
    function Test(a, b) {
      this.a = a;
      this.b = b;
    };
    Test.prototype.test = function Test_test() {
      p([this.a, this.b]);
    };
    var BoundTest = Test.bind(null, 100);
    new BoundTest(200).test();
    /* works, too! */
    function Point(x, y) {  
      this.x = x;  
      this.y = y;  
    }  
    Point.prototype.toString = function() {   
      return this.x + "," + this.y;   
    };  
    var pt = new Point(1, 2);  
    p(pt.toString()); /* "1,2" */ 
    var emptyObj = {};  
    var YAxisPoint = Point.bind(emptyObj, 0 /* x */);  
    var axisPoint = new YAxisPoint(5);  
    p(axisPoint.toString()); /* "0,5" */  
    p(axisPoint instanceof Point); /* true */
    p(axisPoint instanceof YAxisPoint); /* true */
    p(new Point(17, 42) instanceof YAxisPoint); /* false */
    /* but the following does not */
    Function.prototype.construct = function(aArgs) {  
        if (aArgs.constructor !== Array)  
            throw new TypeError("second argument to Function.prototype.construct must be an array");  
        var aBoundArgs = Array.prototype.concat.apply([null], aArgs),   
            fBound = this.bind.apply(this, aBoundArgs);  
        return new fBound();
    };  
    var aDateArgs = "2011-7-16 19:35:46".split(/[- :]/),  
    oMyDate1 = new Date(
        aDateArgs[0], aDateArgs[1], aDateArgs[2], aDateArgs[3], aDateArgs[4], aDateArgs[5]
    );  
    p(oMyDate1.toLocaleString());  
    var oMyDate2 = Date.construct("2011-7-16 19:35:46".split(/[- :]/));  
    p(oMyDate2.toLocaleString());  
    p(Point.construct([2, 4]).toString()); /* "2,4" */  
}catch(e){
    p(e);
}
oBind ? Function.prototype.bind = oBind : delete Function.prototype.bind;

evalも、魔法も、あるんだよ

諦めたらそれまでだ。でも、JavaScripterなら実装を変え(ry

というわけで行き着いたのがこの方法。元の関数でnewした後、__proto__を使って無理やりプロトタイプを繋ぎ変えています。よってIEでは動きません。でも元々の動機はSafariで動かすことにあったので、これでもいいかなって。

eval()の使い方に注目。引数に応じたnewを一回だけ生成しそれを再利用することで、

Function.prototype.bindは何がいいのか - 枕を欹てて聴く
        // なぜなら現行仕様はnew呼び出しの場合に引数として配列を与えるということができない
        // 苦し紛れー
        switch (a.length) {
          case 0:
            return new that();
          case 1:
            return new that(a[0]);
          case 2:
        /* … */
          case 10:
            return new that(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9]);
          default:
            throw new Error("myBind not support more than 10 length arguments as Constructor");
        }

という羽目に陥ることを防いでいます。

var oBind = Function.prototype.bind; /* preserve the original */
Function.prototype.bind = function bind(thisArg) {
    "use strict";
    var that = this,
        args = Array.prototype.slice.call(arguments, 1),
        slice = Array.prototype.slice,
        fnop  = function(){},
        news = [];
    var bound = function bound() {
        var a = args.concat(slice.call(arguments)),
            i = 0, l = a.length, as = [], evs, ret;
        if (this instanceof bound) { /* I am a constructor! */
            if (!news[l]){
                for (; i < l; i++) { as.push('a['+i+']'); };
                evs = 'news[l]=function(){return new that('
                    + as.join(',') 
                    + ');};';
                /* console.log(evs); */
                eval(evs);
            }
            ret = news[l]();
            if (typeof ret.__proto__ === 'object') {
                ret.__proto__ = bound.prototype;
            }
            return ret;
        } else {
            return that.apply(thisArg, a);
        }
    };
    fnop.prototype = that.prototype;
    bound.prototype = new fnop();
    return bound;
};
try{
    /* works  */
    function Point(x, y) {  
      this.x = x;  
      this.y = y;  
    }
    /* does not work IE because of __proto__ */
    Point.prototype.toString = function() {   
      return this.x + "," + this.y;   
    };  
    var pt = new Point(1, 2);  
    p(pt.toString()); /* "1,2" */ 
    var emptyObj = {};  
    var YAxisPoint = Point.bind(emptyObj, 0 /* x */);  
    var axisPoint = new YAxisPoint(5);  
    p(axisPoint.toString()); /* "0,5" */  
    p(axisPoint instanceof Point); /* true */
    p(axisPoint instanceof YAxisPoint); /* true */
    p(new Point(17, 42) instanceof YAxisPoint); /* false */
    /* works at last! */
    Function.prototype.construct = function(aArgs) {  
        if (aArgs.constructor !== Array)  
            throw new TypeError("second argument to Function.prototype.construct must be an array");  
        var aBoundArgs = Array.prototype.concat.apply([null], aArgs),   
            fBound = this.bind.apply(this, aBoundArgs);  
        return new fBound();
    };  
    var aDateArgs = "2011-7-16 19:35:46".split(/[- :]/),  
    oMyDate1 = new Date(
        aDateArgs[0], aDateArgs[1], aDateArgs[2], aDateArgs[3], aDateArgs[4], aDateArgs[5]
    );  
    p(oMyDate1.toLocaleString());  
    var oMyDate2 = Date.construct("2011-7-16 19:35:46".split(/[- :]/));  
    p(oMyDate2.toLocaleString());  
    p(Point.construct([2, 4]).toString()); /* "2,4" */
    /* but boundFun.length is wrong */
    var theDay = Date.bind(null, 2011, 12-1, 18);
    p( (new theDay(12,34,56)).toLocaleString() );
    p( theDay.length ); /* supposed to be 4 but 0 */
}catch(e){
    p(e);
}
oBind ? Function.prototype.bind = oBind : delete Function.prototype.bind;

最後に残った引数長

ここで、MDNのPolyfillの問題点をおさらいしておきましょう。

  • The partial implementation relies Array.prototype.slice, Array.prototype.concat, Function.prototype.call and Function.prototype.apply, built-in methods to have their original values.
  • The partial implementation creates functions that do not have immutable "poison pill" caller and arguments properties that throw a TypeError upon get, set, or deletion. (This could be added if the implementation supports Object.defineProperty, or partially implemented [without throw-on-delete behavior] if the implementation supports the __defineGetter__ and __defineSetter__ extensions.)
  • The partial implementation creates functions that have a prototype property. (Proper bound functions have none.)
  • The partial implementation creates bound functions whose length property does not agree with that mandated by ECMA-262: it creates functions with length 0, while a full implementation, depending on the length of the target function and the number of pre-specified arguments, may return a non-zero length.

boundされた関数の引数の長さなんて、実際の利用において一体誰が使うんだとは思うのですが、ここまで来たら少しでもECMAScript 5ネイティブのbindに近づけたい。というわけで出来上がったのがこちら。

var oBind = Function.prototype.bind; /* preserve the original */
Function.prototype.bind = function bind(thisArg) {
    "use strict";
    var that = this,
        args = Array.prototype.slice.call(arguments, 1),
        slice = Array.prototype.slice,
        fnop  = function(){},
        news = [],
        alen = that.length - arguments.length + 1,
        farg = [], i = 0,
        evs, bound;
    for(;i < alen; i++) farg.push('_'+i);
    evs = 'bound=function bound('+farg.join(',')+'){' +
    [
        "var a = args.concat(slice.call(arguments))," ,
            "i = 0, l = a.length, as = [], evs, ret;" ,
        "if (this instanceof bound) {" ,
            "if (!news[l]){" ,
                "for (; i < l; i++){ as.push('a['+i+']'); };" ,
                "evs = 'news[l]=function(){return new that('" ,
                    "+ as.join(',') " ,
                    "+ ');};';" ,
                "eval(evs);" ,
            "}" ,
            "ret = news[l]();" ,
            "if (typeof ret.__proto__ === 'object') {" ,
                "ret.__proto__ = bound.prototype;" ,
            "}" ,
            "return ret;" ,
        "} else {" ,
            "return that.apply(thisArg, a);" ,
        "}"
    ].join('\n') +
    "};";
    eval(evs);
    fnop.prototype = that.prototype;
    bound.prototype = new fnop();
    return bound;
}
try{
    /* works  */
    function Point(x, y) {  
      this.x = x;  
      this.y = y;  
    }
    /* does not work IE because of __proto__ */
    Point.prototype.toString = function() {   
      return this.x + "," + this.y;   
    };  
    var pt = new Point(1, 2);  
    p(pt.toString()); /* "1,2" */ 
    var emptyObj = {};  
    var YAxisPoint = Point.bind(emptyObj, 0 /* x */);  
    var axisPoint = new YAxisPoint(5);  
    p(axisPoint.toString()); /* "0,5" */  
    p(axisPoint instanceof Point); /* true */
    p(axisPoint instanceof YAxisPoint); /* true */
    p(new Point(17, 42) instanceof YAxisPoint); /* false */
    /* works at last! */
    Function.prototype.construct = function(aArgs) {  
        if (aArgs.constructor !== Array)  
            throw new TypeError("second argument to Function.prototype.construct must be an array");  
        var aBoundArgs = Array.prototype.concat.apply([null], aArgs),   
            fBound = this.bind.apply(this, aBoundArgs);  
        return new fBound();
    };  
    var aDateArgs = "2011-7-16 19:35:46".split(/[- :]/),  
    oMyDate1 = new Date(
        aDateArgs[0], aDateArgs[1], aDateArgs[2], aDateArgs[3], aDateArgs[4], aDateArgs[5]
    );  
    p(oMyDate1.toLocaleString());  
    var oMyDate2 = Date.construct("2011-7-16 19:35:46".split(/[- :]/));  
    p(oMyDate2.toLocaleString());  
    p(Point.construct([2, 4]).toString()); /* "2,4" */  
    /* finally... */
    var theDay = Date.bind(null, 2011, 12-1, 18);
    p( (new theDay(12,34,56)).toLocaleString() );
    p( theDay.length ); /* 4 */
}catch(e){
    p(e);
}
oBind ? Function.prototype.bind = oBind : delete Function.prototype.bind;

関数の、最高の友達

Jobsが生前言っていたように。HTML5のサポートは、App Storeの厳格なコントロールを正答化する最高の理由にもなっています。

それだけに、主要ブラウザーの中でSafari(とWebKit)だけがFunction.prototype.bindをネイティブ実装していないことが余計気になります。

404 Blog Not Found:javascript - そろそろECMAScript 5を使いたい少なくとも3つの理由
  • IEは9以上以降かつStandard Modeなら使える
  • Safari 5はFunction.prototype.bindのみ使えない
  • iOS5も同様
  • Android 2.3ではさらに加えてObject.sealなどObjectをロックする機能が使えない

数多の世界の運命を束ね、ポストPCの特異点となった君なら、どんな途方もない仕様だろうと、叶えられるだろう、Apple?

Dan the JavaScripter


この記事へのトラックバックURL

この記事へのトラックバック
JavaScriptで、関数の引数として関数を渡す(コールバックと呼ばれる)ことはよくあります。 例えばsetTimeoutがそうです。 setTimeout(function(){alert(1)}, 1000); // 1秒後にalert 下記のように書くことも当然できます。 var say = function(){ alert('hello');} setTimeou
JavaScriptで、メソッドをコールバックとして渡す方法(コールバック関数でthisをbindさせる方法)【DQNEO起業日記】at 2012年05月13日 04:17