第6章オブジェクトの秘密の生活
オブジェクト指向言語の問題点は、暗黙的な環境を常に抱えていることです。バナナが欲しかったのに、バナナを持ったゴリラとジャングル全体が出てきました。
プログラマが「オブジェクト」と言うとき、それは重みのある言葉です。私の職業では、オブジェクトは生き方であり、聖戦の主題であり、いまだに力を失っていない愛される流行語です。
外の人にとっては、おそらく少し分かりにくいでしょう。プログラミング構成要素としてのオブジェクトの簡単な歴史から始めましょう。
歴史
この物語は、ほとんどのプログラミングの物語と同様に、複雑さの問題から始まります。複雑さを管理可能なものにするという一つの哲学は、互いに隔離された小さなコンパートメントに分割することです。これらのコンパートメントは、オブジェクトという名前になりました。
オブジェクトとは、内部の複雑さを隠す硬い殻であり、代わりにいくつかのノブとコネクタ(メソッドなど)を提供して、オブジェクトを使用するためのインターフェースを提示します。この考え方は、インターフェースが比較的シンプルで、オブジェクト内部で起こっている複雑なことは、オブジェクトを操作する際には無視できるということです。

例として、画面上の領域へのインターフェースを提供するオブジェクトを想像できます。この領域に図形やテキストを描画する方法を提供しますが、これらの図形が画面を構成する実際のピクセルに変換される方法の詳細は隠されています。`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`の背後にあります。
プロトタイプに存在するプロパティを上書きすることは、多くの場合、役立つことです。ウサギの歯の例が示すように、これはより一般的なオブジェクトクラスのインスタンスで例外的なプロパティを表すために使用でき、非例外的なオブジェクトはプロトタイプから標準値を単純に取得できます。
標準の関数と配列のプロトタイプに、基本的なオブジェクトプロトタイプとは異なる`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」というイベントは絶対にありません。
奇妙なことに、toString
はfor
/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
関数は、外部配列が列ではなく行の配列であるため、少し難しくなります。map
(forEach
、filter
、および同様の配列メソッド)は、与えられた関数に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
列の数値を右揃えしていません。これについては、すぐに説明します。
ゲッターとセッター
インターフェースを指定する際には、メソッドではないプロパティを含めることができます。minHeight
とminWidth
を数値を保持するだけのプロパティとして定義することもできました。しかし、そうするとコンストラクタでそれらを計算する必要があり、オブジェクトの構築とは直接関係のないコードが追加されます。たとえば、下線付きセルの内部セルが変更された場合、下線付きセルのサイズも変更する必要があるため、問題が発生します。
このため、インターフェースに非メソッドプロパティを含めないという原則を採用する人もいます。単純な値プロパティに直接アクセスする代わりに、getSomething
とsetSomething
メソッドを使用してプロパティの読み書きを行います。このアプローチの欠点は、多くの追加メソッドを記述および読み取る必要があることです。
幸いなことに、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.prototype
がTextCell.prototype
から派生しているため、RTextCell
はTextCell
のインスタンスです。この演算子は、Array
のような標準的なコンストラクタにも適用できます。ほとんどすべてのオブジェクトはObject
のインスタンスです。
概要
したがって、オブジェクトは私が最初に描いたよりも複雑です。それらにはプロトタイプ(他のオブジェクト)があり、プロトタイプにそのプロパティがある限り、それらが持たないプロパティを持っているかのように動作します。単純なオブジェクトのプロトタイプはObject.prototype
です。
名前が通常大文字で始まる関数であるコンストラクタは、new
演算子を使用して新しいオブジェクトを作成するために使用できます。新しいオブジェクトのプロトタイプは、コンストラクタ関数のprototype
プロパティにあるオブジェクトになります。これを使用して、特定の型のすべての値が共有するプロパティをそのプロトタイプに配置することができます。instanceof
演算子は、オブジェクトとコンストラクタが与えられると、そのオブジェクトがそのコンストラクタのインスタンスかどうかを判断できます。
オブジェクトでできる便利なことの1つは、それらのインターフェースを指定し、そのインターフェースを介してのみオブジェクトと通信するように全員に指示することです。オブジェクトを構成する残りの詳細は、インターフェースの背後に隠されたカプセル化されます。
インターフェースについて話すようになったら、誰が1種類のオブジェクトだけがこのインターフェースを実装できると言っているでしょうか?異なるオブジェクトが同じインターフェースを公開し、インターフェースを持つ任意のオブジェクトで動作するコードを記述することをポリモーフィズムといいます。これは非常に便利です。
いくつかの詳細のみが異なる複数の型を実装する場合、新しい型のプロトタイプを古い型のプロトタイプから派生させ、新しいコンストラクタが古いコンストラクタを呼び出すようにするだけで役立ちます。これにより、古い型に似たオブジェクト型が得られますが、必要に応じてプロパティを追加および上書きできます。
練習問題
ベクトル型
2次元空間のベクトルを表すコンストラクタVector
を作成します。x
とy
パラメータ(数値)を受け取り、同じ名前のプロパティに保存する必要があります。
Vector
プロトタイプにplus
とminus
の2つのメソッドを与えます。これらは別のベクトルをパラメータとして受け取り、2つのベクトルの(this
内のものとパラメータ)のx値とy値の合計または差を持つ新しいベクトルを返します。
ベクトルの長さを計算するゲッタープロパティlength
をプロトタイプに追加します。つまり、点(x、y)と原点(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
別のセル
この章で前に説明したテーブルセルのインターフェースに準拠するStretchCell(inner, width, height)
という名前のセル型を実装します。別のセルをラップする必要があります(UnderlinedCell
が行うように)結果のセルの幅と高さが少なくとも指定されたwidth
とheight
になるようにします。内部セルが自然に小さくなる場合でもです。
// 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", " "]
シーケンスインターフェース
値のコレクションの反復処理を抽象化するインターフェースを設計します。このインターフェースを提供するオブジェクトはシーケンスを表し、インターフェースは、そのようなオブジェクトを使用するコードがシーケンスを反復処理し、構成されている要素値を見て、シーケンスの終わりがいつになったかを調べる方法を提供する必要があります。
インターフェースを指定したら、シーケンスオブジェクトを受け取り、先頭の5つの要素(シーケンスの要素数が5未満の場合はそれより少ない要素)に対してconsole.log
を呼び出す関数logFive
を作成してみてください。
次に、配列をラップし、設計したインターフェースを使用して配列の反復処理を可能にするオブジェクト型ArraySeq
を実装します。代わりに、整数範囲を反復処理するオブジェクト型RangeSeq
を実装します(コンストラクタにfrom
とto
引数を取ります)。
// 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のような言語ではやや非効率的です。