第3版が公開されました。こちらで読んでください

第6章
オブジェクトの秘密の生活

オブジェクト指向言語の問題点は、暗黙的な環境を常に抱えていることです。バナナが欲しかったのに、バナナを持ったゴリラとジャングル全体が出てきました。

ジョー・アームストロング、『Coders at Work』より

プログラマが「オブジェクト」と言うとき、それは重みのある言葉です。私の職業では、オブジェクトは生き方であり、聖戦の主題であり、いまだに力を失っていない愛される流行語です。

外の人にとっては、おそらく少し分かりにくいでしょう。プログラミング構成要素としてのオブジェクトの簡単な歴史から始めましょう。

歴史

この物語は、ほとんどのプログラミングの物語と同様に、複雑さの問題から始まります。複雑さを管理可能なものにするという一つの哲学は、互いに隔離された小さなコンパートメントに分割することです。これらのコンパートメントは、オブジェクトという名前になりました。

オブジェクトとは、内部の複雑さを隠す硬い殻であり、代わりにいくつかのノブとコネクタ(メソッドなど)を提供して、オブジェクトを使用するためのインターフェースを提示します。この考え方は、インターフェースが比較的シンプルで、オブジェクト内部で起こっている複雑なことは、オブジェクトを操作する際には無視できるということです。

A simple interface can hide a lot of complexity.

例として、画面上の領域へのインターフェースを提供するオブジェクトを想像できます。この領域に図形やテキストを描画する方法を提供しますが、これらの図形が画面を構成する実際のピクセルに変換される方法の詳細は隠されています。`drawCircle`などのメソッドのセットがあり、そのようなオブジェクトを使用するために知る必要があるのはそれだけです。

これらのアイデアは、1970年代と1980年代に最初に考案され、1990年代には、オブジェクト指向プログラミング革命という巨大な誇大宣伝の波によって持ち上げられました。突然、オブジェクトがプログラミングの正しい方法であり、オブジェクトを含まないものは時代遅れのナンセンスだと宣言する多くの人々が現れました。

そのような熱狂は常に多くの非現実的な愚行を生み出し、それ以来、一種の反革命が起こっています。一部の分野では、オブジェクトは現在、かなり悪い評判を持っています。

私は、イデオロギー的な観点ではなく、実際的な観点からこの問題を見ることを好みます。オブジェクト指向文化が普及させた、特にカプセル化(内部の複雑さと外部のインターフェースの区別)という、いくつかの有用な概念があります。これらは研究する価値があります。

この章では、JavaScriptのやや風変わりなオブジェクトの考え方と、それが古典的なオブジェクト指向技術とどのように関連しているかを説明します。

メソッド

メソッドは、関数値を保持するプロパティにすぎません。これは単純なメソッドです。

var rabbit = {};
rabbit.speak = function(line) {
  console.log("The rabbit says '" + line + "'");
};

rabbit.speak("I'm alive.");
// → The rabbit says 'I'm alive.'

通常、メソッドは、呼び出されたオブジェクトに対して何かを行う必要があります。関数がメソッドとして呼び出されると(プロパティとして検索され、`object.method()`のようにすぐに呼び出されると)、その本体内の特別な変数`this`は、それが呼び出されたオブジェクトを指します。

function speak(line) {
  console.log("The " + this.type + " rabbit says '" +
              line + "'");
}
var whiteRabbit = {type: "white", speak: speak};
var fatRabbit = {type: "fat", speak: speak};

whiteRabbit.speak("Oh my ears and whiskers, " +
                  "how late it's getting!");
// → The white rabbit says 'Oh my ears and whiskers, how
//   late it's getting!'
fatRabbit.speak("I could sure use a carrot right now.");
// → The fat rabbit says 'I could sure use a carrot
//   right now.'

このコードは、`this`キーワードを使用して、話しているウサギの種類を出力します。`apply`メソッドと`bind`メソッドはどちらも、メソッド呼び出しをシミュレートするために使用できる最初の引数をとることを思い出してください。この最初の引数は、実際には`this`に値を与えるために使用されます。

`apply`と同様のメソッドに`call`があります。これもメソッドである関数を呼び出しますが、配列としてではなく、通常どおり引数を取ります。`apply`と`bind`と同様に、`call`には特定の`this`値を渡すことができます。

speak.apply(fatRabbit, ["Burp!"]);
// → The fat rabbit says 'Burp!'
speak.call({type: "old"}, "Oh my.");
// → The old rabbit says 'Oh my.'

プロトタイプ

よく見てください。

var empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]

空のオブジェクトからプロパティを取り出しました。魔法です!

まあ、実際にはそうではありません。私は単にJavaScriptオブジェクトの動作に関する情報を隠していました。プロパティのセットに加えて、ほとんどすべてのオブジェクトにはプロトタイプもあります。プロトタイプとは、プロパティのフォールバックソースとして使用される別のオブジェクトです。オブジェクトが持たないプロパティのリクエストを受け取ると、そのプロパティについてプロトタイプ、次にプロトタイプのプロトタイプなどが検索されます。

では、その空のオブジェクトのプロトタイプは誰ですか?それは偉大な祖先プロトタイプ、ほとんどすべてのオブジェクトの背後にあるエンティティ、`Object.prototype`です。

console.log(Object.getPrototypeOf({}) ==
            Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null

ご想像のとおり、`Object.getPrototypeOf`関数はオブジェクトのプロトタイプを返します。

JavaScriptオブジェクトのプロトタイプ関係はツリー状の構造を形成し、この構造のルートには`Object.prototype`があります。これは、すべてのオブジェクトに表示されるいくつかのメソッド(オブジェクトを文字列表現に変換する`toString`など)を提供します。

多くのオブジェクトは、プロトタイプとして`Object.prototype`を直接持たず、代わりに独自のデフォルトプロパティを提供する別のオブジェクトを持っています。関数は`Function.prototype`から派生し、配列は`Array.prototype`から派生します。

console.log(Object.getPrototypeOf(isNaN) ==
            Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) ==
            Array.prototype);
// → true

このようなプロトタイプオブジェクトは、それ自体がプロトタイプ(多くの場合`Object.prototype`)を持ち、`toString`などのメソッドを間接的に提供します。

`Object.getPrototypeOf`関数は、明らかにオブジェクトのプロトタイプを返します。`Object.create`を使用して、特定のプロトタイプを持つオブジェクトを作成できます。

var protoRabbit = {
  speak: function(line) {
    console.log("The " + this.type + " rabbit says '" +
                line + "'");
  }
};
var killerRabbit = Object.create(protoRabbit);
killerRabbit.type = "killer";
killerRabbit.speak("SKREEEE!");
// → The killer rabbit says 'SKREEEE!'

「proto」ウサギは、すべてのウサギで共有されるプロパティのコンテナとして機能します。キラーウサギのような個々のウサギオブジェクトには、自分自身にのみ適用されるプロパティ(この場合はその種類)が含まれており、プロトタイプから共有プロパティを派生します。

コンストラクタ

共有プロトタイプから派生するオブジェクトを作成するより便利な方法は、コンストラクタを使用することです。JavaScriptでは、関数の前に`new`キーワードを付けて呼び出すと、コンストラクタとして扱われます。コンストラクタは、`this`変数が新しいオブジェクトにバインドされ、別のオブジェクト値を明示的に返さない限り、この新しいオブジェクトが呼び出しから返されます。

`new`で作成されたオブジェクトは、そのコンストラクタのインスタンスと呼ばれます。

これがウサギの簡単なコンストラクタです。コンストラクタの名前を大文字にすることは、他の関数と簡単に区別するための慣例です。

function Rabbit(type) {
  this.type = type;
}

var killerRabbit = new Rabbit("killer");
var blackRabbit = new Rabbit("black");
console.log(blackRabbit.type);
// → black

コンストラクタ(実際にはすべての関数)は、自動的に`prototype`という名前のプロパティを取得します。これはデフォルトで、`Object.prototype`から派生するプレーンな空のオブジェクトを保持します。このコンストラクタで作成されたすべてのインスタンスは、このオブジェクトをプロトタイプとして持ちます。したがって、`Rabbit`コンストラクタで作成されたウサギに`speak`メソッドを追加するには、次のようにするだけです。

Rabbit.prototype.speak = function(line) {
  console.log("The " + this.type + " rabbit says '" +
              line + "'");
};
blackRabbit.speak("Doom...");
// → The black rabbit says 'Doom...'

プロトタイプがコンストラクタに関連付けられる方法(その`prototype`プロパティを通して)と、オブジェクトがプロトタイプを持つ方法(`Object.getPrototypeOf`で取得できる)の違いに注意することが重要です。コンストラクタの実際のプロトタイプは`Function.prototype`です。なぜなら、コンストラクタは関数だからです。その`prototype`プロパティは、それを通して作成されたインスタンスのプロトタイプになりますが、その自身のプロトタイプではありません。

派生プロパティの上書き

プロパティをオブジェクトに追加すると、それがプロトタイプに存在するかどうかは関係なく、プロパティはオブジェクト自体に追加され、それ以降は独自のプロパティとして持つようになります。プロトタイプに同じ名前のプロパティがある場合、このプロパティはオブジェクトに影響しなくなります。プロトタイプ自体は変更されません。

Rabbit.prototype.teeth = "small";
console.log(killerRabbit.teeth);
// → small
killerRabbit.teeth = "long, sharp, and bloody";
console.log(killerRabbit.teeth);
// → long, sharp, and bloody
console.log(blackRabbit.teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small

次の図は、このコードの実行後の状況を概略的に示しています。`Rabbit`と`Object`のプロトタイプは、オブジェクト自体に見つからないプロパティを検索できる背景として`killerRabbit`の背後にあります。

Rabbit object prototype schema

プロトタイプに存在するプロパティを上書きすることは、多くの場合、役立つことです。ウサギの歯の例が示すように、これはより一般的なオブジェクトクラスのインスタンスで例外的なプロパティを表すために使用でき、非例外的なオブジェクトはプロトタイプから標準値を単純に取得できます。

標準の関数と配列のプロトタイプに、基本的なオブジェクトプロトタイプとは異なる`toString`メソッドを与えるためにも使用されます。

console.log(Array.prototype.toString ==
            Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2

配列で`toString`を呼び出すと、`join(",")`を呼び出した場合と同様の結果が得られます。配列の値の間にカンマを置きます。`Object.prototype.toString`を配列で直接呼び出すと、異なる文字列が生成されます。その関数は配列について知らないため、単に「object」という単語と型の名前を角括弧で囲みます。

console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]

プロトタイプ干渉

プロトタイプはいつでも使用して、それに基づいてすべてのオブジェクトに新しいプロパティとメソッドを追加できます。たとえば、ウサギにダンスさせる必要があるかもしれません。

Rabbit.prototype.dance = function() {
  console.log("The " + this.type + " rabbit dances a jig.");
};
killerRabbit.dance();
// → The killer rabbit dances a jig.

それは便利です。しかし、問題を引き起こす状況もあります。前の章では、名前のプロパティを作成し、対応する値を値として与えることで、値を名前と関連付ける方法としてオブジェクトを使用しました。第4章からの例を以下に示します。

var map = {};
function storePhi(event, phi) {
  map[event] = phi;
}

storePhi("pizza", 0.069);
storePhi("touched tree", -0.081);

`for`/`in`ループを使用してオブジェクト内のすべてのphi値を反復処理し、通常の`in`演算子を使用して名前が存在するかどうかをテストできます。しかし残念ながら、オブジェクトのプロトタイプが邪魔になります。

Object.prototype.nonsense = "hi";
for (var name in map)
  console.log(name);
// → pizza
// → touched tree
// → nonsense
console.log("nonsense" in map);
// → true
console.log("toString" in map);
// → true

// Delete the problematic property again
delete Object.prototype.nonsense;

それは完全に間違っています。「nonsense」というイベントはデータセットにはありません。「toString」というイベントは絶対にありません。

奇妙なことに、toStringfor/inループには表示されませんでしたが、in演算子はそれに対してtrueを返しました。これは、JavaScriptが列挙可能なプロパティと非列挙可能なプロパティを区別するためです。

単に代入して作成するすべてのプロパティは列挙可能です。Object.prototypeの標準プロパティはすべて非列挙可能であるため、このようなfor/inループには表示されません。

Object.defineProperty関数を使用して、独自の非列挙可能プロパティを定義できます。これにより、作成するプロパティの種類を制御できます。

Object.defineProperty(Object.prototype, "hiddenNonsense",
                      {enumerable: false, value: "hi"});
for (var name in map)
  console.log(name);
// → pizza
// → touched tree
console.log(map.hiddenNonsense);
// → hi

これでプロパティは存在しますが、ループには表示されません。それは良いことです。しかし、通常のin演算子が、Object.prototypeのプロパティがオブジェクトに存在すると主張するという問題が残っています。そのためには、オブジェクトのhasOwnPropertyメソッドを使用できます。

console.log(map.hasOwnProperty("toString"));
// → false

このメソッドは、プロトタイプを見ずに、オブジェクト自体にプロパティがあるかどうかを知らせます。これは、in演算子が提供する情報よりも、多くの場合、より有用な情報です。

誰かが(プログラムにロードした他のコードが)基本オブジェクトのプロトタイプをいじったのではないかと心配な場合は、for/inループを次のように記述することをお勧めします。

for (var name in map) {
  if (map.hasOwnProperty(name)) {
    // ... this is an own property
  }
}

プロトタイプのないオブジェクト

しかし、ウサギの穴はそこで終わりません。誰かがmapオブジェクトにhasOwnPropertyという名前を登録し、値を42に設定したとしたらどうでしょうか? map.hasOwnPropertyへの呼び出しは、関数ではなく数値を保持するローカルプロパティの呼び出しを試みます。

このような場合、プロトタイプは邪魔になるだけで、実際にはプロトタイプのないオブジェクトの方が好ましいでしょう。プロトタイプを指定してオブジェクトを作成できるObject.create関数を確認しました。プロトタイプとしてnullを渡して、プロトタイプのない新しいオブジェクトを作成できます。プロパティが何でもあり得るmapのようなオブジェクトの場合、まさにこれが必要なものです。

var map = Object.create(null);
map["pizza"] = 0.069;
console.log("toString" in map);
// → false
console.log("pizza" in map);
// → true

はるかに良くなりました!オブジェクトが持つすべてのプロパティは、そのオブジェクト自身のプロパティであるため、hasOwnPropertyの回避策は不要になりました。これで、Object.prototypeに対してどのような操作が行われていても、for/inループを安全に使用できます。

ポリモーフィズム

値を文字列に変換するString関数をオブジェクトに対して呼び出すと、そのオブジェクトのtoStringメソッドを呼び出して、意味のある文字列を作成し、返すように試みます。標準プロトタイプの一部は、"[object Object]"よりも有用な情報を含む文字列を作成できるように、独自のtoStringバージョンを定義していると述べました。

これは強力なアイデアの簡単な例です。コードが特定のインターフェース(この場合はtoStringメソッド)を持つオブジェクトで動作するように記述されている場合、このインターフェースをサポートするあらゆる種類のオブジェクトをコードに接続でき、正常に動作します。

この手法はポリモーフィズムと呼ばれます(実際の変身は関係ありません)。ポリモーフィックなコードは、期待するインターフェースをサポートしている限り、さまざまな形状の値で動作できます。

テーブルのレイアウト

ポリモーフィズムと、一般的なオブジェクト指向プログラミングがどのようなものかをよりよく理解するために、少し複雑な例を説明します。プロジェクトは次のとおりです。テーブルセルの配列の配列が与えられた場合、列がまっすぐで、行が整列した、適切にレイアウトされたテーブルを含む文字列を作成するプログラムを作成します。このようなもの

name         height country
------------ ------ -------------
Kilimanjaro    5895 Tanzania
Everest        8848 Nepal
Mount Fuji     3776 Japan
Mont Blanc     4808 Italy/France
Vaalserberg     323 Netherlands
Denali         6168 United States
Popocatepetl   5465 Mexico

私たちのテーブル構築システムの動作方法は、ビルダー関数が各セルに幅と高さを要求し、この情報を使用して列の幅と行の高さを決定することです。次に、ビルダー関数はセルに正しいサイズで自分自身を描画するように要求し、結果を単一の文字列に組み立てます。

レイアウトプログラムは、明確に定義されたインターフェースを介してセルオブジェクトと通信します。このようにして、プログラムがサポートするセルのタイプは事前に固定されません。後で新しいセルのスタイル(たとえば、テーブルヘッダーの下線付きセル)を追加できますが、インターフェースをサポートしている場合、レイアウトプログラムを変更する必要なく、正常に動作します。

これがインターフェースです。

この例では、高階配列メソッドを多用します。これは、このアプローチに適しているためです。

プログラムの最初の部分は、セルのグリッドの最小列幅と行高さの配列を計算します。rows変数は、各内部配列がセルの行を表す配列の配列を保持します。

function rowHeights(rows) {
  return rows.map(function(row) {
    return row.reduce(function(max, cell) {
      return Math.max(max, cell.minHeight());
    }, 0);
  });
}

function colWidths(rows) {
  return rows[0].map(function(_, i) {
    return rows.reduce(function(max, row) {
      return Math.max(max, row[i].minWidth());
    }, 0);
  });
}

アンダースコア(_)で始まる、またはアンダースコアのみからなる変数名は、(人間の読者にとって)この引数は使用されないことを示す方法です。

rowHeights関数は、セルの配列の最大高さを計算するためにreduceを使用し、それをmapでラップして、rows配列のすべての行に対して実行します。それほど難しくないはずです。

colWidths関数は、外部配列が列ではなく行の配列であるため、少し難しくなります。mapforEachfilter、および同様の配列メソッド)は、与えられた関数に2番目の引数(現在の要素のインデックス)を渡すことをこれまで言及していませんでした。最初の行の要素をマッピングし、マッピング関数の2番目の引数のみを使用することで、colWidthsは、列インデックスごとに1つの要素を持つ配列を作成します。reduceへの呼び出しは、各インデックスに対して外部rows配列で実行され、そのインデックスで最も幅の広いセルの幅を選択します。

テーブルを描画するコードを次に示します。

function drawTable(rows) {
  var heights = rowHeights(rows);
  var widths = colWidths(rows);

  function drawLine(blocks, lineNo) {
    return blocks.map(function(block) {
      return block[lineNo];
    }).join(" ");
  }

  function drawRow(row, rowNum) {
    var blocks = row.map(function(cell, colNum) {
      return cell.draw(widths[colNum], heights[rowNum]);
    });
    return blocks[0].map(function(_, lineNo) {
      return drawLine(blocks, lineNo);
    }).join("\n");
  }

  return rows.map(drawRow).join("\n");
}

drawTable関数は、内部ヘルパー関数drawRowを使用してすべての行を描画し、改行文字でそれらを結合します。

drawRow関数自体は、まず行のセルオブジェクトをブロックに変換します。ブロックとは、セルの内容を表す文字列の配列で、行で分割されたものです。単に数値3776を含むセルは["3776"]のような単一要素の配列で表される可能性がありますが、下線付きのセルは2行を占め、["name", "----"]のような配列で表される可能性があります。

同じ高さの行のブロックは、最終出力で互いに隣接して表示する必要があります。drawRowの2回目のmap呼び出しは、左端のブロックの行をマッピングし、それぞれについてテーブルの全幅にわたる行を収集することで、この出力を行ごとに構築します。これらの行は改行文字で結合され、drawRowの戻り値として行全体を提供します。

drawLine関数は、ブロックの配列から隣接して表示する必要がある行を抽出し、スペース文字で結合して、テーブルの列間に1文字のギャップを作成します。

では、テーブルセルのインターフェースを実装するテキストを含むセルのコンストラクターを作成しましょう。コンストラクターは、文字列メソッドsplitを使用して文字列を行の配列に分割します。これは、引数の出現ごとに文字列を分割し、部分の配列を返します。minWidthメソッドはこの配列で最大行幅を見つけます。

function repeat(string, times) {
  var result = "";
  for (var i = 0; i < times; i++)
    result += string;
  return result;
}

function TextCell(text) {
  this.text = text.split("\n");
}
TextCell.prototype.minWidth = function() {
  return this.text.reduce(function(width, line) {
    return Math.max(width, line.length);
  }, 0);
};
TextCell.prototype.minHeight = function() {
  return this.text.length;
};
TextCell.prototype.draw = function(width, height) {
  var result = [];
  for (var i = 0; i < height; i++) {
    var line = this.text[i] || "";
    result.push(line + repeat(" ", width - line.length));
  }
  return result;
};

このコードは、値がstring引数をtimes回数繰り返した文字列である文字列を作成するヘルパー関数repeatを使用します。drawメソッドはこれを使用して、すべての行に必要な長さになるように「パディング」を追加します。

これまで書いたすべてを試して、5×5のチェッカーボードを作成してみましょう。

var rows = [];
for (var i = 0; i < 5; i++) {
   var row = [];
   for (var j = 0; j < 5; j++) {
     if ((j + i) % 2 == 0)
       row.push(new TextCell("##"));
     else
       row.push(new TextCell("  "));
   }
   rows.push(row);
}
console.log(drawTable(rows));
// → ##    ##    ##
//      ##    ##
//   ##    ##    ##
//      ##    ##
//   ##    ##    ##

うまくいきました!しかし、すべてのセルが同じサイズであるため、テーブルレイアウトコードは実際には何も興味深いことを行いません。

構築しようとしている山のテーブルのソースデータは、サンドボックスのMOUNTAINS変数と、ウェブサイトからダウンロード可能です。

列名を含むトップ行を強調表示するために、ダッシュ文字のシーケンスでセルの下線を引く必要があります。問題ありません。下線を処理するセルタイプを作成するだけです。

function UnderlinedCell(inner) {
  this.inner = inner;
}
UnderlinedCell.prototype.minWidth = function() {
  return this.inner.minWidth();
};
UnderlinedCell.prototype.minHeight = function() {
  return this.inner.minHeight() + 1;
};
UnderlinedCell.prototype.draw = function(width, height) {
  return this.inner.draw(width, height - 1)
    .concat([repeat("-", width)]);
};

下線付きセルは別のセルを含みます。下線によって占められるスペースを考慮するために高さを1追加しますが、そのセルのminWidthメソッドとminHeightメソッドを呼び出すことで、最小サイズを内部セルの最小サイズと同じであると報告します。

このようなセルの描画は非常に簡単です。内部セルの内容を取得し、ダッシュでいっぱいの1行を連結するだけです。

下線機能があれば、データセットからセルのグリッドを構築する関数を記述できるようになります。

function dataTable(data) {
  var keys = Object.keys(data[0]);
  var headers = keys.map(function(name) {
    return new UnderlinedCell(new TextCell(name));
  });
  var body = data.map(function(row) {
    return keys.map(function(name) {
      return new TextCell(String(row[name]));
    });
  });
  return [headers].concat(body);
}

console.log(drawTable(dataTable(MOUNTAINS)));
// → name         height country
//   ------------ ------ -------------
//   Kilimanjaro  5895   Tanzania
//   … etcetera

標準のObject.keys関数は、オブジェクトのプロパティ名の配列を返します。テーブルの上段には、列の名前を示す下線付きセルを含める必要があります。その下には、データセット内のすべてのオブジェクトの値が通常のセルとして表示されます。keys配列をマップすることで、すべての行でセルの順序が同じになるようにします。

結果のテーブルは、前の例に似ていますが、height列の数値を右揃えしていません。これについては、すぐに説明します。

ゲッターとセッター

インターフェースを指定する際には、メソッドではないプロパティを含めることができます。minHeightminWidthを数値を保持するだけのプロパティとして定義することもできました。しかし、そうするとコンストラクタでそれらを計算する必要があり、オブジェクトの構築とは直接関係のないコードが追加されます。たとえば、下線付きセルの内部セルが変更された場合、下線付きセルのサイズも変更する必要があるため、問題が発生します。

このため、インターフェースに非メソッドプロパティを含めないという原則を採用する人もいます。単純な値プロパティに直接アクセスする代わりに、getSomethingsetSomethingメソッドを使用してプロパティの読み書きを行います。このアプローチの欠点は、多くの追加メソッドを記述および読み取る必要があることです。

幸いなことに、JavaScriptは両方の利点を兼ね備えたテクニックを提供しています。外部からは通常のプロパティのように見えるプロパティを指定できますが、秘密裏にメソッドが関連付けられています。

var pile = {
  elements: ["eggshell", "orange peel", "worm"],
  get height() {
    return this.elements.length;
  },
  set height(value) {
    console.log("Ignoring attempt to set height to", value);
  }
};

console.log(pile.height);
// → 3
pile.height = 100;
// → Ignoring attempt to set height to 100

オブジェクトリテラルでは、プロパティのgetまたはset表記を使用して、プロパティの読み取りまたは書き込み時に実行される関数を指定できます。また、Object.defineProperty関数(これまでは非列挙プロパティの作成に使用していました)を使用して、既存のオブジェクト(プロトタイプなど)にそのようなプロパティを追加することもできます。

Object.defineProperty(TextCell.prototype, "heightProp", {
  get: function() { return this.text.length; }
});

var cell = new TextCell("no\nway");
console.log(cell.heightProp);
// → 2
cell.heightProp = 100;
console.log(cell.heightProp);
// → 2

definePropertyに渡されるオブジェクトには、同様のsetプロパティを使用してセッターメソッドを指定できます。ゲッターが定義されていてセッターが定義されていない場合、プロパティへの書き込みは無視されます。

継承

テーブルレイアウトの練習はまだ終わっていません。数値の列を右揃えすると、可読性が向上します。TextCellのような別のセルタイプを作成する必要がありますが、右側にパディングするのではなく、左側にパディングして右揃えにします。

プロトタイプに3つのメソッドすべてを含む新しいコンストラクタを記述することもできます。しかし、プロトタイプ自体にもプロトタイプがあり、これにより巧妙なことができます。

function RTextCell(text) {
  TextCell.call(this, text);
}
RTextCell.prototype = Object.create(TextCell.prototype);
RTextCell.prototype.draw = function(width, height) {
  var result = [];
  for (var i = 0; i < height; i++) {
    var line = this.text[i] || "";
    result.push(repeat(" ", width - line.length) + line);
  }
  return result;
};

通常のTextCellからコンストラクタとminHeightおよびminWidthメソッドを再利用します。RTextCellは基本的にTextCellと等価ですが、drawメソッドに異なる関数が含まれています。

このパターンは継承と呼ばれます。これにより、比較的少ない作業で既存のデータ型からわずかに異なるデータ型を構築できます。通常、新しいコンストラクタは古いコンストラクタを呼び出します(this値として新しいオブジェクトを渡せるようにするためにcallメソッドを使用します)。このコンストラクタが呼び出された後、古いオブジェクト型に含まれるはずのすべてのフィールドが追加されたと仮定できます。コンストラクタのプロトタイプが古いプロトタイプから派生するように配置することで、この型のインスタンスもそのプロトタイプ内のプロパティにアクセスできます。最後に、これらのプロパティの一部を新しいプロトタイプに追加することで上書きできます。

これで、値が数値であるセルにRTextCellを使用するようにdataTable関数を少し調整すると、目的のテーブルが得られます。

function dataTable(data) {
  var keys = Object.keys(data[0]);
  var headers = keys.map(function(name) {
    return new UnderlinedCell(new TextCell(name));
  });
  var body = data.map(function(row) {
    return keys.map(function(name) {
      var value = row[name];
      // This was changed:
      if (typeof value == "number")
        return new RTextCell(String(value));
      else
        return new TextCell(String(value));
    });
  });
  return [headers].concat(body);
}

console.log(drawTable(dataTable(MOUNTAINS)));
// → … beautifully aligned table

継承は、カプセル化とポリモーフィズムとともに、オブジェクト指向の伝統の基礎を成す部分です。しかし、後者は一般的に素晴らしいアイデアと考えられていますが、継承はやや物議を醸しています。

その主な理由は、それがしばしばポリモーフィズムと混同され、実際よりも強力なツールとして販売され、その後あらゆる種類の醜い方法で過剰に使用されていることです。カプセル化とポリモーフィズムは、コードの断片を互いに分離して、プログラム全体の複雑さを軽減するために使用できますが、継承は基本的に型を結び付け、より多くの複雑さを生み出します。

見たように、継承なしでポリモーフィズムを実現できます。継承を完全に避けるように言うつもりはありません。私は自分のプログラムで定期的に使用しています。しかし、あなたはそれを、新しい型を少ないコードで定義するのに役立つ少し怪しいトリックとして、コード編成の壮大な原則としてではなく、見るべきです。型を拡張するより良い方法は、UnderlinedCellが別のセルオブジェクトをプロパティに格納し、独自のメソッドでメソッド呼び出しを転送することで構築する方法のように、コンポジションです。

instanceof演算子

オブジェクトが特定のコンストラクタから派生したかどうかを知ることは、時々役立ちます。そのため、JavaScriptはinstanceofと呼ばれる二項演算子を提供しています。

console.log(new RTextCell("A") instanceof RTextCell);
// → true
console.log(new RTextCell("A") instanceof TextCell);
// → true
console.log(new TextCell("A") instanceof RTextCell);
// → false
console.log([1] instanceof Array);
// → true

この演算子は、継承された型を見抜きます。RTextCell.prototypeTextCell.prototypeから派生しているため、RTextCellTextCellのインスタンスです。この演算子は、Arrayのような標準的なコンストラクタにも適用できます。ほとんどすべてのオブジェクトはObjectのインスタンスです。

概要

したがって、オブジェクトは私が最初に描いたよりも複雑です。それらにはプロトタイプ(他のオブジェクト)があり、プロトタイプにそのプロパティがある限り、それらが持たないプロパティを持っているかのように動作します。単純なオブジェクトのプロトタイプはObject.prototypeです。

名前が通常大文字で始まる関数であるコンストラクタは、new演算子を使用して新しいオブジェクトを作成するために使用できます。新しいオブジェクトのプロトタイプは、コンストラクタ関数のprototypeプロパティにあるオブジェクトになります。これを使用して、特定の型のすべての値が共有するプロパティをそのプロトタイプに配置することができます。instanceof演算子は、オブジェクトとコンストラクタが与えられると、そのオブジェクトがそのコンストラクタのインスタンスかどうかを判断できます。

オブジェクトでできる便利なことの1つは、それらのインターフェースを指定し、そのインターフェースを介してのみオブジェクトと通信するように全員に指示することです。オブジェクトを構成する残りの詳細は、インターフェースの背後に隠されたカプセル化されます。

インターフェースについて話すようになったら、誰が1種類のオブジェクトだけがこのインターフェースを実装できると言っているでしょうか?異なるオブジェクトが同じインターフェースを公開し、インターフェースを持つ任意のオブジェクトで動作するコードを記述することをポリモーフィズムといいます。これは非常に便利です。

いくつかの詳細のみが異なる複数の型を実装する場合、新しい型のプロトタイプを古い型のプロトタイプから派生させ、新しいコンストラクタが古いコンストラクタを呼び出すようにするだけで役立ちます。これにより、古い型に似たオブジェクト型が得られますが、必要に応じてプロパティを追加および上書きできます。

練習問題

ベクトル型

2次元空間のベクトルを表すコンストラクタVectorを作成します。xyパラメータ(数値)を受け取り、同じ名前のプロパティに保存する必要があります。

Vectorプロトタイプにplusminusの2つのメソッドを与えます。これらは別のベクトルをパラメータとして受け取り、2つのベクトルの(this内のものとパラメータ)のx値とy値の合計または差を持つ新しいベクトルを返します。

ベクトルの長さを計算するゲッタープロパティlengthをプロトタイプに追加します。つまり、点(xy)と原点(0、0)の距離です。

// Your code here.

console.log(new Vector(1, 2).plus(new Vector(2, 3)));
// → Vector{x: 3, y: 5}
console.log(new Vector(1, 2).minus(new Vector(2, 3)));
// → Vector{x: -1, y: -1}
console.log(new Vector(3, 4).length);
// → 5

ソリューションは、この章のRabbitコンストラクタのパターンに非常に近いものにすることができます。

コンストラクタにゲッタープロパティを追加するには、Object.defineProperty関数を使用できます。(0, 0)から(x, y)までの距離を計算するには、ピタゴラスの定理を使用できます。この定理によると、求める距離の2乗は、x座標の2乗とy座標の2乗の合計に等しくなります。したがって、√(x2 + y2)が求める数値であり、Math.sqrtはJavaScriptで平方根を計算する方法です。

別のセル

この章で前に説明したテーブルセルのインターフェースに準拠するStretchCell(inner, width, height)という名前のセル型を実装します。別のセルをラップする必要があります(UnderlinedCellが行うように)結果のセルの幅と高さが少なくとも指定されたwidthheightになるようにします。内部セルが自然に小さくなる場合でもです。

// Your code here.

var sc = new StretchCell(new TextCell("abc"), 1, 2);
console.log(sc.minWidth());
// → 3
console.log(sc.minHeight());
// → 2
console.log(sc.draw(3, 2));
// → ["abc", "   "]

インスタンスオブジェクトに3つのコンストラクタ引数をすべて格納する必要があります。minWidthminHeightメソッドは、innerセルの対応するメソッドを呼び出す必要がありますが、指定されたサイズより小さい数値が返されないようにする必要があります(おそらくMath.maxを使用します)。

内部セルへの呼び出しを単純に転送するdrawメソッドを追加することを忘れないでください。

シーケンスインターフェース

値のコレクションの反復処理を抽象化するインターフェースを設計します。このインターフェースを提供するオブジェクトはシーケンスを表し、インターフェースは、そのようなオブジェクトを使用するコードがシーケンスを反復処理し、構成されている要素値を見て、シーケンスの終わりがいつになったかを調べる方法を提供する必要があります。

インターフェースを指定したら、シーケンスオブジェクトを受け取り、先頭の5つの要素(シーケンスの要素数が5未満の場合はそれより少ない要素)に対してconsole.logを呼び出す関数logFiveを作成してみてください。

次に、配列をラップし、設計したインターフェースを使用して配列の反復処理を可能にするオブジェクト型ArraySeqを実装します。代わりに、整数範囲を反復処理するオブジェクト型RangeSeqを実装します(コンストラクタにfromto引数を取ります)。

// Your code here.

logFive(new ArraySeq([1, 2]));
// → 1
// → 2
logFive(new RangeSeq(100, 1000));
// → 100
// → 101
// → 102
// → 103
// → 104

これを解決する1つの方法は、シーケンスオブジェクトに状態を与えることです。つまり、それらを使用する過程でプロパティが変更されます。シーケンスオブジェクトがどれだけ進んだかを示すカウンターを格納できます。

インターフェースは、少なくとも次の要素を取得する方法と、反復処理がシーケンスの末尾に達したかどうかを判別する方法を提供する必要があります。これらの機能を1つのメソッドnextにまとめ、シーケンスの末尾ではnullまたはundefinedを返すようにすることは魅力的です。しかし、シーケンスが実際にnullを含む場合、問題が発生します。そのため、末尾に達したかどうかを判別するための別のメソッド(またはゲッタープロパティ)の方がおそらく好ましいでしょう。

別の解決策として、オブジェクトの状態を変更しない方法があります。現在の要素を取得するメソッド(カウンターを進めない)と、現在の要素以降の残りの要素を表す新しいシーケンスを取得するメソッド(シーケンスの末尾に達した場合は特別な値)を提供できます。これは非常にエレガントです。シーケンスの値は使用後も「自分自身」のままであり、何が起こるのかを心配することなく他のコードと共有できます。残念ながら、反復処理中に多くのオブジェクトを作成するため、JavaScriptのような言語ではやや非効率的です。