Hatena::ブログ(Diary)

しふーのブログ このページをアンテナに追加 RSSフィード Twitter

2012年02月18日

JavaScriptをやり始めた人が理解したほうが良いJSONパーサのコード

そろそろ4月も近いということもあって、新たにWeb業界SIer業界に入るぞという方がプログラミング勉強をし始めているころでしょうか。最近は、エンタープライズでもWebクライアントが主流になりつつあるのでJavaScriptの習得は避けては通れない道だと思います。また、Node.jsサーバーサイドのJavaScriptも出てきたこともあって、非常に有用言語になりつつあります

そんなJavaScriptを学び始めている人の中でも、ある程度プログラミングをやったことがある人がJavaScriptの綺麗な書き方を学ぶのに絶対理解しておいた方が良い300行程度のソースコードがあります

それは、JavaScript: The Good Partsに載っているJSONパーサのコードです。

この有名な本のAppendixに、このJSONパーサのコードは載っています

ちなみにソース自体は、オライリー本家

http://oreilly.com/javascript/excerpts/javascript-good-parts/json.html

からJSON の生みの親、Douglas Crockford 氏のコメントのままのコードコピーしてくることもできます

なおこの本は、他の言語をかじった方、自分場合JavaRubyからJavaScriptを本格的に始めるのに向いている本だと言われていますが、本当に素晴らしい内容でした。JavaScript関数型言語プロトタイプオブジェクト指向言語としての大きな可能性を示してくれます

JavaScriptは、Web上にひどいコードが散乱しており、どう書くのが美しく良い方法なのかというのが一見してわかりにくい言語です。この本はそんなカオスワールドへの一筋の光でした。無論、綺麗なコードを書くためにはコーディング規約などもありますが、このJSONパーサのコードは、そういったものを超えたある種のコード定義の仕方の哲学のようなものを伝えてくれます

ただ、残念なのがそのJSON パーサのコードコメントの邦訳が思ったよりいけてないのです...。というわけで、ないものは作ってしまえというわけで、そのソースコード自分なりの概要と詳細なコメントを付けてみました。ぜひコードリーディングにお役立てください。

みなさんのJavaScriptの実装の良いアイディアになればと思います

(ちなみにこのコード、読んでいろんな手法が学べる勉強になるコードではありますが、実際の用途にはIE6とか古いブラウザでない限りJSON.parse()関数を使って下さい。ネイティブなので圧倒的に速いです。なおjQueryprototype.jsにはブラウザ判定して使い分ける機能がついています。)


処理概要

以上のような流れになっています

以下は実際のソースコードです、ChromeJavaScriptコンソールを立ち上げてすぐに実行定義して、実行例から、実際にこのコードJSON文字列パースしたり、reviver関数適用する動きを見てみても良いかもしれません。

ではソースに入ります

var json_parse = function () {
// JSONをパースして、JavaScriptのデータ構造を生成する関数
// シンプルな再帰下降パーサで実装されています
// 
// 実行例: 
// var obj = json_parse("{\"first\": \"Jerome\"}");
// 
// 実行例(全ての値に操作を行うreviver関数ありの場合):
// var obj =  json_parse("{\"first\": \"Jerome\"}", 
// function(key, value){if(key==='first') return 'hoge'; return value;});
//
// Chromeの方は、ツール > JavaScriptコンソールに、このソースを全て貼り付けて、
// 実行例を実行して、すぐに動作確認できます。
 

// グローバル変数を生成することを避けるため、以下の変数及び関数は、
// 関数の中で定義されます

     var at,     // 現在の文字のインデックス値
         ch,     // 現在の文字
         escapee = {
             '"':  '"',
             '\\': '\\',
             '/':  '/',
             b:    'b',
             f:    '\f',
             n:    '\n',
             r:    '\r',
             t:    '\t'
         },
         text,

         error = function (m) {

// 何か問題が発生した場合に、errorが呼び出す関数

             throw {
                 name:    'SyntaxError',
                 message: m,
                 at:      at,
                 text:    text
             };
         },

         next = function (c) {

// 一文字次に進める関数
// もしパラメーターcが指定されていたら、
// それが現在の文字にマッチするのかを調べ、矛盾がありそうならエラーで終わらせます

             if (c && c !== ch) {
                 error("Expected '" + c + "' instead of '" + ch + "'");
             }

// 次の文字を取得します。もしそれ以上がなかったら、
// 空文字を返します

             ch = text.charAt(at);
             at += 1;
             return ch;
         },

         number = function () {

// 数値を解析する関数

             var number,
                 string = '';

// もしマイナスが来たら、マイナスをstringに取りおいて次へ
             if (ch === '-') {
                 string = '-';
                 next('-');
             }

// 0〜9までの間の文字コードならば、stringに連結させては次へ
             while (ch >= '0' && ch <= '9') {
                 string += ch;
                 next();
             }

// .が来たら.をstringに結合して、次があればstringに数値を結合します
             if (ch === '.') {
                 string += '.';
                 while (next() && ch >= '0' && ch <= '9') {
                     string += ch;
                 }
             }

// eかEが来たら、+か-を処理後、0〜9までを処理します
             if (ch === 'e' || ch === 'E') {
                 string += ch;
                 next();
                 if (ch === '-' || ch === '+') {
                     string += ch;
                     next();
                 }
                 while (ch >= '0' && ch <= '9') {
                     string += ch;
                     next();
                 }
             }

// 最後にstringを結合、数値変換したものをnumberに代入し、チェックして返します
// ----- ちょっと難しい書き方の解説 ------
// n= +string; で文字列から数値変換できる。 n = string - 0; でも可
// 文字列を加算減算することで暗黙的キャストが走るがparseInt使ったほうが分かりよいです
// 余談ですが、文字コードは、'A'.charCodeAt()で10進数で確認できます
// ---------------------------------------
             number = +string;
             if (isNaN(number)) {
                 error("Bad number");
             } else {
                 return number;
             }
         },

         string = function () {

// 文字列を解析する関数

             var hex,
                 i,
                 string = '',
                 uffff;

// 文字列を解析する場合には、"と\という文字を探す必要があります
// Unicode文字は、\とuを処理した後、16進数として数値で読み、最大で4回、読み込みながらhexに足していきます
// 最後にfromCharCode関数を使って数値から文字に変換します
// isFinite関数は引数が有限値か調べる関数で、ここではチェックに使っています
// エスケープする文字は、あらかじめ定義していた
// escapeeオブジェクトをセットとして使い、値が存在するかで判定しています

             if (ch === '"') {
                 while (next()) {
                     if (ch === '"') {
                         next();
                         return string;
                     } else if (ch === '\\') {
                         next();
                         if (ch === 'u') {
                             uffff = 0;
                             for (i = 0; i < 4; i += 1) {
                                 hex = parseInt(next(), 16);
                                 if (!isFinite(hex)) {
                                     break;
                                 }
                                 uffff = uffff * 16 + hex;
                             }
                             string += String.fromCharCode(uffff);
                         } else if (typeof escapee[ch] === 'string') {
                             string += escapee[ch];
                         } else {
                             break;
                         }
                     } else {
                         string += ch;
                     }
                 }
             }
             error("Bad string");
         },

         white = function () {

// ホワイトスペースを無視する関数

             while (ch && ch <= ' ') {
                 next();
             }
         },

         word = function () {

// true, false, nullを処理する関数

             switch (ch) {
             case 't':
                 next('t');
                 next('r');
                 next('u');
                 next('e');
                 return true;
             case 'f':
                 next('f');
                 next('a');
                 next('l');
                 next('s');
                 next('e');
                 return false;
             case 'n':
                 next('n');
                 next('u');
                 next('l');
                 next('l');
                 return null;
             }
             error("Unexpected '" + ch + "'");
         },

         value,  // 後で出てくるvalue関数の格納場所

         array = function () {

// 配列を解析する関数

             var array = [];

             if (ch === '[') {
                 next('[');
                 white();
                 if (ch === ']') {
                     next(']');
                     return array;   // 空配列
                 }
                 while (ch) { //値があるなら、value関数の実行結果を配列に入れる
                     array.push(value());
                     white();
                     if (ch === ']') {
                         next(']');
                         return array;
                     }
                     next(',');
                     white();
                 }
             }
             error("Bad array");
         },

         object = function () {

// オブジェクトを解析する関数

             var key,
                 object = {};

             if (ch === '{') {
                 next('{');
                 white();
                 if (ch === '}') {
                     next('}');
                     return object;   // 空のオブジェクト 
                 }
                 while (ch) {
                     key = string();   // keyはstring関数の値を入れる
                     white();
                     next(':');
                     object[key] = value(); // objectはvalue関数の結果を入れる
                     white();
                     if (ch === '}') {
                         next('}');
                         return object;
                     }
                     next(',');
                     white();
                 }
             }
             error("Bad object");
         };

     value = function () {

// JSONの値を解析する関数
// その値は、オブジェクト、配列、文字列、数値、もしくは単語で構成されます。
// なお今まで作ってきた、一文字一文字を評価しては値を返すという
// 全ての関数をここで合成してvalue関数を組み上げています。
// 無論、object関数とarray関数からはこのvalue関数が呼ばれており、再帰しています。

         white();
         switch (ch) {
         case '{':
             return object();
         case '[':
             return array();
         case '"':
             return string();
         case '-':
             return number();
         default:
             return ch >= '0' && ch <= '9' ? number() : word();
         }
     };

///////////// ここまで関数と変数の準備 ///////////////

// 最後にこの無名関数でjson_parse関数を返します。
// 引数はsource(JSON文字列), reviver(全valueに処理する関数)の関数です。
// なお、この関数はここまでに定義したすべての関数と変数にアクセスできます。
// これが便利なレキシカルスコープをつかったクロージャの仕組みです。
// このように、無名関数の定義内の変数を参照する
// 無名関数内無名関数を返すテクニックをモジュールと言います。
// 無名関数内変数は、外部から変更されない安全な変数になります。

     return function (source, reviver) {
         var result;

         text = source; // sourceをtextに移動
         at = 0;
         ch = ' ';
         result = value(); // value関数を使いtextが再帰的に評価されていく
         white();
         if (ch) { // ここで文字列の最後に到達しているはず
             error("Syntax error");
         }

// もし引数にreviver関数がいたら(revive:復活させる)、 
// 新しい構造を再帰的に処理して、
// 全ての名前と値のペアをreviver関数に渡して変換処理を行います。
// 処理は結果を空のキーとして保存しているテンポラリオブジェクトから開始されます。
// もしreviver関数がない場合は、単にresultを返します。

         return typeof reviver === 'function' ?
             function walk(holder, key) {
                 var k, v, value = holder[key];
                 if (value && typeof value === 'object') {
                     for (k in value) {
                         if (Object.hasOwnProperty.call(value, k)) {
                             v = walk(value, k);
                             if (v !== undefined) {
                                 value[k] = v;
                             } else {
                                 delete value[k];
                             }
                         }
                     }
                 }
                 return reviver.call(holder, key, value);
             }({'': result}, '') : result;

// ------------------ 難しいので処理を解説 ----------------------
// まず、walkが再帰巡回関数となっています。k, v はkeyとvalueの一時変数です。
//
// walk関数では、引数のholderオブジェクトからkeyのvalueを取り出し、
// そのvalueのタイプがobjectだったら中身をチェックします。
// for-in句はvalueの中身のキーをkに取りループします。そしてkをキーに
// valueオブジェクトの中身のバリュー取りその値をvに入れて
// walk関数を再帰呼出しします。
// 
// なお、call関数は、関数の呼び方の一つです。
// 引数にthisになるものと、その関数の引数になるものを第二引数以降に入れて使います。
// apply関数と同様の動きをし、第二引数が配列を渡すかどうかだけが違います。
// ちなみにここでのhasOwnPropertyチェックは
// プロトタイプオブジェクトのものを無視するために行われていると思われます。
//
// walk関数でオブジェクトのツリーを深さ優先探索しながら、
// walk関数の戻り値を元あったバリューに格納していきます。
//
// walk関数は、渡されたreviver関数を
// thisをholder、第一引数をkey、第二引数をvalueとして
// call関数で実行し、変更したいバリューを返すということをします。
//
// まとめると、すべてのオブジェクトに対して
// 何かしらの変更処理を実施することが可能なようになっています。
// その際に、その所有者とキーとバリューが分かるようにもなっています。
//
// なお何もしないreviver関数は、
// function(key, value){return value;}
// のようになります。keyがfirstの時、値を"hoge"にする場合、
// function(key, value){if(key==='first') return 'hoge'; return value;}
// となります。
// -----------------------------------------------------------------
     };

// 関数を返す無名関数が即時実行され、json_parse関数が返され変数に格納されます。
}();

以上です。

無名関数定義関数を返す関数、即時実行、クロージャモジュール関数合成、再帰深さ優先探索エラー処理、文字コード解析、関数のcallを使った実行、for-inとhasOwnPropertyを使ったプロパティループなど、いろいろなエッセンスが詰め込まれたソースコードですが、何かJavaScriptの実装で得るものがあればと思いますお疲れ様でした。

// 2012/02/19 クロージャ変数を守って関数を返す手法モジュールと呼ぶことを追記

// 2012/02/19 修正:0〜9までの間の文字コードで無いならば→0〜9までの間の文字コードならば

// 2012/02/19 ユニコード文字を処理のコメントを追記

// 2012/02/19 実際の用途にはネイティブ実装のJSON.parse()関数を勧めることを追記

pennywiselikepennywiselike 2013/04/30 14:07 はてなブックマークからきました

とても参考になりました

ありがとうございます

acomaguacomagu 2015/01/27 17:59 突然すみません。
この実装、本からの転載ということですが、例えば"\u30"とかいうJSONを渡した時、エラーになると思うんですが、、どう思われますか?
私の見当違いですかね?
Stringのユニコードの処理がおかしいような...
(しふーさんが書かれたわけではないですから知ったこっちゃないとは思いますが)

sifuesifue 2015/01/27 23:36 acomaguさん
"\u30"はJSONのフォーマットではないですね。ユニコードの30もないですし(参考: http://ash.jp/code/unitbl21.htm )。そもそも、JSONはオブジェクトか配列リテラルで囲んでないといけません。例えば、こういうコードだったら大丈夫です。
json_parse("[\"\\u3042\"]");
> ["あ"]
ちなみに、ブラウザデフォルトのJSON.parseメソッドも同様の値を返します。
JSON.parse("[\"\\u3042\"]");
> ["あ"]

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証