読者です 読者をやめる 読者になる 読者になる

Kokudoriing

技術系与太話ブログ

配列っぽそうで配列っぽくない少し配列っぽいオブジェクト

JavaScript

かなり今さらなネタ。
食べたいと思いつつまだ食べたことないのでいつか食べたいです。

桃屋 辛そうで辛くない少し辛いラー油 110g

桃屋 辛そうで辛くない少し辛いラー油 110g

本題。
JavaScript の Array.prototype 以下のメソッド群はある特徴があります。
それらはできる限り配列っぽいものを配列のように振る舞います。

さて、配列っぽいものとは?
JavaScript における配列はちょっとだけ高機能なオブジェクト以上でもそれ以下でもありません。
数値、文字列、真偽値、null、undefined 以外の全てはオブジェクトであり、配列もオブジェクトです。

そして JavaScript のオブジェクトはプロパティを持てます。
非常に重要なことですが、オブジェクトのプロパティ名は文字列ならなんでもOKです。
逆を言うと、オブジェクトのプロパティ名は文字列以外は全てダメです。

ここでオブジェクトのプロパティ取得と配列の要素アクセスの比較。

(function(undefined) {
  var object = {
      name: 'kokudori',
      age: 20
    },
    array = [1, 2, 3];

  console.log(object['name']);// output 'kokudori'
  console.log(array[1]); // output 2
}).apply(this);

非常に似てますね。というか、同じです。
配列のインデックス指定はオブジェクトのプロパティ名アクセスでしかありません。

つまり array.1 みたいなことと同じことをやっているだけです。
しかし array.1 は失敗します。
JavaScript ではプロパティ名の先頭に数値を使えません。
これは正確ではなく、 のアクセスだと許容されます。

配列に関係なく全ての JavaScript オブジェクトに でアクセスすると、[] の中身を文字列化します。
つまり、array[1] と array['1'] は意味的に同値です。
そして array[1] は内部で array['1'] に変換され、他のオブジェクトと同様にプロパティアクセスを行います。
(重要なのはこれが言語レベルの話であることです。
実装レベルでは高速化のために別のアプローチが取られているかもしれません。
しかしそれは実装依存の話であり、知る必要のない情報です。)

つまりこれで配列っぽいオブジェクトを自作できることがわかります。

(function(undefined) {
  var arrayLike = {
      '0': 4,
      '1': 5,
      '2': 6,
      length: 3
    },
    array = [1, 2, 3];

  console.log(arrayLike[1]);// output 5
  console.log(array[1]); // output 2
}).apply(this);

しかし本来こんなことに意味はありません。
配列として機能させたければ配列を使うほうがよっぽど優れています。
また、JavaScript の配列はネイティブで疎な配列をサポートしているので、length 値が不思議な扱われ方をします。

(function(undefined) {
  var array = [1, 2, 3];

  console.log(array.length); // output 3
  array[100] = 123;
  console.log(array.length); // output 101
  array.length = 2;
  console.log(array[2]); // output undefined
}).apply(this);

つまり、length 値は index + 1 の値を常に取り、変更可能で、変更するとその長さの配列に自動で縮みます。
どう考えても length は不変であるべきだろと思うのですが、可変です。混乱のもとになるので代入しないほうが良いでしょう。
ちなみに array.slice(0, size) で似たような事がより良い形*1で可能です。

ここまで見てみるとどう考えても素直に配列作ったほうがいいように思えます。
しかし、例えば関数スコープ内の arguments オブジェクトは配列のように振る舞うオブジェクトです。
世の中には配列っぽいオブジェクトがあるという事実が大切。

さて、ようやく冒頭のお話。
この配列っぽそうで配列っぽくない少し配列っぽいオブジェクトには致命的なデメリットが。
いくら配列に似せようが所詮ただのオブジェクトなので slice やら sort やらといったメソッドが無いです。
あと ECMAScript5 で追加された素敵メソッド達(map, reduce, filter などなど)も使えず。
これは泣ける。

しかし JavaScript には別オブジェクトのメソッドを勝手に拝借できる強力な機能がいくつかあります。
array.slice やレシーバ(コンテキスト)を必要としてるので、apply / call でなんとかなりそうですね。

(function(undefined) {
  var arrayLike = {
      '0': 7,
      '1': 2,
      '2': 5,
      length: 3
    },
    joined = Array.prototype.join.apply(arrayLike),
    sorted = [].sort.call(arrayLike, function(n, m) {
      return n - m;
    });

  console.log(joined); // output '7, 2, 5'
  console.log(sorted[0]); // output 2
  console.log(sorted[1]); // output 5
  console.log(sorted[2]); // output 7
}).apply(this);

さて、配列っぽいオブジェクトで見事配列のメソッドを使うことに成功しました。
ここで Array.prototype.join と .sort と2通りの書き方をした事に大きな意味はありません。
どちらでも同じ意味であることを示したかっただけです。
もちろん
.sort の方はインスタンス化しているのでメモリ効率が悪いですが、空の配列を作ることは現在のJavaScriptにおいて無視できるほど安すぎるコストです。

冒頭で述べた通りこれは意図された設計です。
もっと言うとこれらのメソッドたちは index と length のみを要求します。
もちろんうまくいかないメソッドもあります。
例えば Chrome21 において reverse は配列であることを要求します。
結局の所、どのメソッドが配列っぽいオブジェクトに適用できるかはブラウザ依存になってしまいます。

というわけで最後に乱暴な解決策。
つまりは配列っぽいオブジェクトを配列に変換してしまえば良い訳です。

(function(undefined) {
  var arrayLike = {
      '0': 7,
      '1': 2,
      '2': 5,
      length: 3
    },
    array = Array.prototype.slice.apply(arrayLike);

  console.log(array); // output [7, 2, 5]
  console.log(array instanceof Array); // output true
}).apply(this);

Array.prototype.slice は非常に多くのブラウザで配列っぽいオブジェクトへの適用が可能なメソッドです。
この手法が不可能な場合は殆どありません。
しかし、もしあったとしても普通の配列のように length を終わり値として for文 でぶん回せばいいだけです。

そして、疎な配列っぽいオブジェクトの場合はどちらの手法も使えません。
どちらも配列が来るものと考え、length 回探索します。
これは疎な配列のメリットを完全に殺しています。
しかし疎な配列に関しては自分がオブジェクトの生成に携わるケースが殆んどでしょう。
なので、疎な配列は配列として扱うべきでしょう。


最後に、配列っぽいオブジェクトは混乱以外の何物でもありません。
次の ECMAScript6 だと arguments は廃止されて 実引数の配列になるだとかならないだとか。
配列っぽく見せたいのなら、配列を使いましょう。

*1:Array.prototype.slice は元の配列をそのままに、新たな配列を生成し、返します。殆どの場合、これは副作用による複雑さを排除した良い方法です。