JavaScriptはオブジェクト指向言語の一種です。オブジェクト指向を意識してコードを書くことで、より効率的なプログラムを書くことができるようになります。
しかし一方で、オブジェクト指向というものは複雑で、簡単には会得できません。そこで、この記事では要点だけをかいつまんで、オブジェクト指向について説明したいと思います。JavaScriptを使って、オブジェクト指向を学んでみましょう。
オブジェクト指向って難しい?
オブジェクト指向の深い世界
「オブジェクト指向」。名前だけはよく聞きますし、プログラミングの技術であることだけはわかっています。ですが、その謎を解くためにGoogleで検索してみたり、Wikipediaのページを見てみたりしても、なかなか小難しいことが書いてあり、哲学書を読んでいるときと同じような眠さに襲われます。
ウェブだけでなく、書籍についても同様です。例えば、今、この記事を書いている私の手元には、参考書として、バートランド・メイヤー著の「オブジェクト指向入門 第2版」があります。これは大変な名著で、オブジェクト指向のなんたるかをあますことなく書き記しています。しかし900ページあります。そして、それが2冊あります。2冊重ねれば人を殴り殺せるでしょう。
もう少し気楽にオブジェクト指向したい
「やってられるか!」
それが正直な感想でしょう。私もそう思います。私も学び始めの頃は何度も挫折しました。オブジェクト指向を理解することを諦めた人も何人も見かけました。
でも、その複雑怪奇で長ったらしい情報は、全部必要なんです。必要だから、Wikipediaには小難しく書いているのです。必要だから、バートランド・メイヤーの本は900ページが2冊になるんです。必要なんです。
それでもなお、やはりこういう気持ちがあります。「もう少し気楽にオブジェクト指向したい」と。
気楽にやろう
深く潜ろうとすれば、どこまでも潜れてしまうのがオブジェクト指向というものです。なので、この記事ではあまり深くは突っ込まないようにしたいと思います。
気楽にやっていきましょう。
オブジェクト指向ってなんだろう
まずは手続き型プログラミングについて知ろう
オブジェクト指向の話に入る前に、まずは「手続き型プログラミング」について知らなければなりません。手続き型プログラミングというのは、データがあって、関数があって、それらの命令を上から順番に実行していくようなプログラミングの手法です。
……ええと、つまり手続き型プログラミングというのは普通のプログラミングでは?と思った方、その通りです。全くその通りです。全く普通のプログラミング手法のことを、手続き型プログラミングと呼ぶのです。
そしてこれはオブジェクト指向と対立するものではありません。両立することが可能です。実際、JavaScriptはオブジェクト指向言語ですが、手続き的に書くこともできます。
例えば以下のようなJavaScriptコードは非常に手続き的です:
function calcCircleArea(radius) {
return radius * radius * Math.PI;
}
const r = 5;
const area = calcCircleArea(r);
数値などのデータがあります。計算のための関数があります。代入などの命令があります。これは実に手続き的です。普通のコードです。
つまり、普通にコードを書けば、それが手続き型プログラミングとなるのです。
じゃあオブジェクト指向ってなんなんだ
じゃあオブジェクト指向とはいったい……?普通ではないプログラミング……?ますますわからなくなってきました。
オブジェクト指向に関しては、実例を見る方がわかりやすいでしょう。例えば、配列の中身をソート(並び替え)するプログラムを書くとします。手続き的に書くと以下のようになるでしょう:
// 配列をソートする関数。
// 難しいので中身は理解しなくても良い。
function sort(array, length) {
for(let i = 0; i < length; i++) {
for(let j = length - 1; j > i; j--) {
if(array[j] < array[j-1]) {
[array[j], array[j-1]] = [array[j-1], array[j]];
}
}
}
}
const a = [3, 6, 10, 4, 8, 1];
const len = 6;
sort(a, len);
このコードには配列データがあって、関数で中身を入れ替え、ソートしています。sort関数の中身が理解できないこと以外は、いたって普通のコードです。
次にこれと全く同じことをオブジェクト指向的に書いてみます。以下のようになります:
const a = [3, 6, 10, 4, 8, 1];
const b = a.sort((a, b) => a - b);
全く違うコードになりましたね!
まず目につくのはコードの短さ……ですが実はあまり本質的ではないのでそれは少し置いときます。一番の違いは、データの操作を全部自分でやっているか、操作を配列自身にやらせているかです。
手続き型の例では、sort関数を書いて、外部から手を加え、自分自身で中身を操作して、配列のソートを完了させています。一方オブジェクト指向的な書き方では、配列自身が持つsort関数を実行して、配列自身にソートさせています。
もうひとつ例を見てみましょう。
function boolToString(bool) {
// trueなら'true'を、falseなら'false'を返す
return bool ? 'true' : 'false';
}
const value = true;
const str = boolToString(value);
これは真偽値(true/false)を文字列に変換するコードを手続き型で書いた例です。
これをオブジェクト指向で書き直してみます。
const value = true;
const str = value.toString();
手続き型の例では、自分で真偽値をチェックして、文字列に変換していました。しかしオブジェクト指向では、真偽値が持つtoString関数を実行して、真偽値自身に文字列に変換させています。
データを外から操作するのではなく、外部からは命令だけして、あとはデータ自身に処理を任せる。これがオブジェクト指向です。
たったこれだけ
オブジェクト指向の一番大切なことは、たったこれだけです。最初に覚えるべきことはこれで全部です。残りは後から覚えればいいので、今日はとりあえずこれだけは覚えてから帰ってください。自分でデータを操作するのではなく、自分は命令だけをして、後の処理はデータに任せる、これがオブジェクト指向です。意外と簡単!
設計思想の違い
じゃあ、オブジェクト指向は新しい優れた作り方で、手続き型は時代遅れの劣った作り方?
いえいえ、そんなことはありません。要は単なる思想の違いです。「自分で全部やる」のが手続き型プログラミングで、「命令だけを送ってあとは任せる」のがオブジェクト指向プログラミングです。どっちが凄いとかどっちが凄くないとかではなく、思想が違うのです。自分が気に入った方を選べばいいのです。好きな方でプログラムを組みましょう。
ただしこの記事はオブジェクト指向についての記事です。オブジェクト指向を学びたいという人のための記事です。手続き型に関しては大したことは書いていません。「私は手続き型で生きるんだ!」という人はここで記事を読むのをやめていいでしょう……。
オブジェクト指向のメリット
しかし思想の違いと言えど、何かしら優れた点があるからこそオブジェクト指向は選ばれているはずです。オブジェクト指向でプログラムを書くメリットはなんでしょう。実は、これはなかなかに難しい問題です。
検索してみると、「オブジェクト指向は再利用性が高い」だとか「責任の分離ができる」だとか、様々なことが書かれています。確かにこれらはオブジェクト指向が得意とする分野です。ですがこれらは手続き型プログラミングでも十分に気をつけて書けば実現できることであり、オブジェクト指向特有のメリットとは言いづらい状況です。
オブジェクト指向言語は、当然ながらオブジェクト指向による記述を強力にサポートしているので、手続き的に書くよりも効率よく書ける場合が多いです。特に大規模なプログラムになってくると、顕著な差が出てきます。しかしこれもオブジェクト指向自体の利点というよりかは、言語の特性に近く、「オブジェクト指向だからこそ」と言うのは少し難しいでしょう。
あえてひとつ挙げるならば、「現代において大多数のメジャーなプログラミング言語がオブジェクト指向である」というところでしょうか。少し打算的というか現金な考え方ですが、多数派の考え方に乗るというのは、大きなメリットがあります。オブジェクト指向を覚えておけば大多数のプログラムを読み書きするのに困りませんし、困ったときも、多数派なので周りに相談しやすいです。
とにかく、このあたりはあまり深く考えすぎるとドツボにはまります。「世の中にはオブジェクト指向という宗派が存在する。多数派なのでそれに乗っておけばいろいろな利点がある」ぐらいの認識でいる方が楽でしょう。
使うにはもっと知識が必要
オブジェクト指向について、大雑把に理解することができました。しかし、これだけでは終わりません。このままでは「どうやって使うのか」がわかりません。
道具の存在を知るのと、道具を活用するのとでは、全く別の概念です。ナイフの存在を知っていても刃を逆に向けて持っていれば全く意味はありませんし、車輪の存在を知っていても横倒しにして運ぼうとすれば苦痛が伴います。オブジェクト指向という道具を扱うには、「オブジェクト指向を知っている」とはまた別の知識が必要になってくるのです。
オブジェクト指向に関する知識を身につけよう
「たったこれだけ」とは言うものの、その単純な要素の周りには、複雑な概念が数多く渦巻いています。今回はその中から重要だと思われる事項について、いくつか厳選して伝えたいと思います。
オブジェクトとメッセージパッシング
データに命令を送って、データが命令を処理するのがオブジェクト指向だと言いました。これを実現するには、最低限ふたつの概念が必要になります。
ひとつはオブジェクトです。オブジェクトは、命令を受け取ることができるものです。命令を受け取って、命令を処理するのがオブジェクトの仕事になります。
もうひとつはメッセージパッシング(メッセージング)です。オブジェクトに送る命令のことをメッセージと言い、これをオブジェクトに渡すことをメッセージパッシングあるいはメッセージングと言います。オブジェクトは受け取ったメッセージを適切に処理します。
オブジェクト指向とオブジェクト指向
オブジェクトとメッセージパッシングがオブジェクト指向の中心概念です。そしてこれらの概念から、ふたつの流儀が生まれました。オブジェクトに重きを置き、オブジェクトを中心とする、いわゆる一般的なオブジェクト指向と、メッセージパッシングに重きを置き、メッセージのやりとりを中心とするメッセージ中心のオブジェクト指向です。
この中で、JavaScriptはオブジェクト中心のオブジェクト指向を採用しています。そして、どちらかというとメッセージパッシングについてはあまり重要視していない言語になります。
JavaScriptのオブジェクト
JavaScriptはオブジェクト中心のオブジェクト指向であると言いました。それはどうやって実現しているのでしょうか。
先述の通り、JavaScriptのオブジェクト指向において、データは単なるデータではないのです。配列が自分自身をソートする関数を持つように、何かしらの操作を持つ、特殊なデータということになります。こういった操作を持つデータを、JavaScriptではオブジェクトを用いて実現してます。この「JavaScriptのオブジェクト」は、オブジェクト指向の意味での「オブジェクト」とは意味が違いますが、同一視しても特に問題はないでしょう。
JavaScriptのオブジェクトは、実体はキーと値の組からなる連想配列です。通常の値の他に、関数も含めることができるため、「操作(関数)を含むデータ」を作ることができます。
const object = {
data: '文字列などの値を保持することができる。',
operation: () => '関数も値として含めることが可能。'
}
例えばさきほどの配列の例ですと、配列(Array)は単なる配列ではなく、配列に関する操作も内包したオブジェクトであるArrayオブジェクトとなります。
ここで、オブジェクトの持つ関数のことをメソッドと言います。例えばArrayオブジェクトが持つsort関数は、Arrayオブジェクトのメソッドです。また、オブジェクトの持つ変数のことをプロパティと言います。つまり、オブジェクトは、メソッドとプロパティから構成されています。
// Arrayオブジェクトを生成
const a = [1, 6, 2, 4, 9, 3];
// Arrayオブジェクトのsortメソッドで配列をソートする
const b = a.sort((a, b) => a - b);
// Arrayオブジェクトのlengthプロパティで長さを取得する
const len = a.length;
JavaScriptでは、一部例外を除き、ほぼ全てがオブジェクトです。数値は数値に関する操作を持つNumberオブジェクトですし、文字列は文字列に関する操作を持つStringオブジェクトですし、配列は配列に関する操作を持つArrayオブジェクトです。
とても厳密な話をすると、数値は実はNumberオブジェクトではありません(同様に文字列はStringオブジェクトではありません)が、実際に使用するときはNumberオブジェクトに変換されるので特に意識する必要はありません。
// NumberオブジェクトのtoPrecisionメソッドは
// 指定した有効数字の文字列を返す。
const num = (100.123456).toPrecision(2)
// Stringオブジェクトのtrimメソッドは
// 左右の空白文字を削除した文字列を返す。
const str = ' 文字列 '.trim();
メッセージパッシング
オブジェクト指向を実現するにはオブジェクトの他にもメッセージパッシングが必要になります。JavaScriptではどのようにメッセージパッシングを扱っているのでしょうか。
オブジェクトに対して「これを実行して!」と命令を送るのがJavaScriptにおけるメッセージパッシングです。配列の例で言えば、プログラマが「配列さん、sortメソッドを実行して!」と書いたのがメッセージパッシングです。これをArrayオブジェクトが受け取り「あいよ!」と実行に移ります。
しかし、本格的にメッセージパッシングを取り入れたプログラミング言語と違い、JavaScriptにおいては、メッセージパッシングは実際のところはただのメソッド呼び出しになります。メソッド呼び出しが、まるでオブジェクトにメッセージを送っているかのように見えると捉えればいいでしょう。
手続き型プログラミングとの両立
オブジェクト指向言語においても、たいていの場合は手続き的な処理を書くことは可能です。というよりも、手続き型なコードを完全に排除することは、JavaScriptの場合においてほぼ不可能となります。基本的には、オブジェクト指向と手続き型プログラミングは、両立していくことになります。
そうなってくると、どの程度手続き的に書くか、どの程度オブジェクト指向的に書くか、ということが問題となってきます。これはなかなか難しい問題で、一概に「こうすればいい」とは言えません。全部が全部オブジェクト指向的に書けばいいかというと、必ずしもそうとは言えず、その逆もそうです。このあたりは読みやすさやプロジェクトの規模などと相談して、なんとか上手いこと決めていくしかないと思います。何度かやっているうちに力加減を覚えていくでしょう。
オブジェクト指向な機能の使い方
さて、言葉の定義だけしていてもあまり面白みはないでしょう。次は実際にオブジェクト指向なコードの書き方を学んでみましょう。まずはJavaScriptに存在するオブジェクト指向に関する機能をいくつか紹介していきます。
自分でやるのではなく、オブジェクトにメッセージを送り処理をしてもらう。オブジェクト指向は「たったこれだけ」……とは言いましたが、それを実現するには実に多数の機能が必要になってきます。少し多いですが、めげずに覚えていきましょう。
オブジェクトを定義して生成する
Arrayオブジェクトなどは、あらかじめ用意されたオブジェクトでした。しかし事前に用意されたオブジェクトだけでは物足りないことがあります。そういったときには、自分でオブジェクトを定義し、作成することができます。やってみましょう。
JavaScriptでは、プロトタイプと呼ばれる、テンプレートとなるオブジェクトを用意し、そのプロトタイプへの参照を持つオブジェクトを生成することで新しいオブジェクトを作り出す仕組みがあります。ええと、簡単に言えば、あるプロトタイプを元に生成されたオブジェクトは、そのプロトタイプの機能を使える、ということです。
プロトタイプとなるオブジェクトを作成する最も簡単な方法は、classキーワードを使用することです。例えば二次元上の点を表すPointオブジェクトのプロトタイプを作成してみます:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
いろいろと新しいものが出てきました!まずはconstructorメソッドです。これはコンストラクタと呼ばれる特殊なメソッドで、オブジェクトが新しく生成されたときに実行されます。つまり、オブジェクトの初期化処理をここに書いておくわけです。コンストラクタが不要の場合は、ここは省略することができます。
次に出てくるのがthisです。基本的に変なことをしなければ、class内ではthisは新たに生成されたオブジェクトを表す識別子です。例えばthis.xと書けば、そのオブジェクトのプロパティxにアクセスできます。ここでは、コンストラクタに渡された値(xとy)を、オブジェクト(this)のプロパティとして書き込んでいます。
さて、これでプロトタイプとなるオブジェクトの作成ができました。あとはプロトタイプを元に新しいオブジェクトを生成するだけです。生成にはnewキーワードを使用します。newキーワードを使うと、コンストラクタが実行され、オブジェクトが生成されます。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
// newキーワードでオブジェクトを複製する。
// 括弧内にはコンストラクタに渡す引数を書く。
const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
console.log(`x: ${p1.x}, y: ${p1.y}`); // x: 1, y: 2
console.log(`x: ${p2.x}, y: ${p2.y}`); // x: 3, y: 4
新しいオブジェクトを作ることができました!x座標とy座標を持つ、Pointオブジェクトです。しかし、これだけでは単にプロパティを持っているだけのオブジェクトとなり、オブジェクト指向的な「メソッドを持ったオブジェクト」にはなっていません。
このままでも構わないのですが、できればオブジェクト指向っぽくしたいですよね。なので、メソッドとして、Pointオブジェクトに二点間の距離を計算するdistanceToメソッドを実装してみましょう。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
// メソッドを実装する。functionキーワードは不要。
// オブジェクトotherを受け取り、thisとの距離を計算して返す。
distanceTo(other) {
// 二点間の距離(わからなかったらググる!)
const diffX = this.x - other.x;
const diffY = this.y - other.y;
return Math.sqrt(diffX * diffX + diffY * diffY);
}
}
const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
// p1とp2の距離を計算する。
const distance = p1.distanceTo(p2);
console.log(distance);
これで少しはオブジェクト指向っぽくなりました。Pointオブジェクトのx, yを外部から操作して距離を求めるのではなく、Pointオブジェクトにメソッドを持たせて、Pointオブジェクト自身に計算をやらせるというところがオブジェクト指向的には重要です。
staticメソッド
生成されるオブジェクトにメソッドを持たせる方法の他に、プロトタイプにメソッドを持たせる(※厳密にいうとだいぶ違いますが、ここでは詳しくは触れません)方法もあります。これはstaticメソッドと呼ばれるものです。
staticメソッドは、staticキーワードを用いて宣言します。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
static distance(p1, p2) {
const diffX = p1.x - p2.x;
const diffY = p1.y - p2.y;
return Math.sqrt(diffX * diffX + diffY * diffY);
}
}
const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
const distance = Point.distance(p1, p2);
console.log(distance);
呼び出すときには、「プロトタイプ名.staticメソッド名」で呼び出します。この場合は、Point.distanceになります。
staticメソッドは便利ですが、あまり多用していると手続き型プログラミングと変わらなくなってしまうので、使用は必要最低限にしておきましょう。
オブジェクトを継承する
オブジェクトは、他のオブジェクトの特性を「継承」することができます。特性というのは、主にメソッドやプロパティのことです。
例えば「矩形(四角形)オブジェクト」と「正方形オブジェクト」を作る場合を考えます。このとき、何も考えずにコードを書くと以下のようになります:
// 矩形を表すオブジェクト。
class Rectangle {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
calcArea() {
return this.width * this.height;
}
collidesWith(other) {
const horizontal = (other.x < this.x + this.width) && (this.x < other.x + other.width);
const vertical = (other.y < this.y + this.height) && (this.y < other.y + other.height);
return (horizontal && vertical);
}
}
// 正方形を表すオブジェクト。
class Square {
constructor(x, y, size) {
this.x = x;
this.y = y;
this.width = size;
this.height = size;
}
calcArea() {
return this.width * this.height;
}
collidesWith(other) {
const horizontal = (other.x < this.x + this.width) && (this.x < other.x + other.width);
const vertical = (other.y < this.y + this.height) && (this.y < other.y + other.height);
return (horizontal && vertical);
}
}
Rectangle(矩形)オブジェクトとSquare(正方形)オブジェクトをそのまま書いてみました。それぞれ、座標x,yプロパティと、幅widthプロパティ、高さheightプロパティ、面積を計算するcalcAreaメソッドと、他の四角形との当たり判定を計算するcollidesWithメソッドで成り立っています。まだ小さなコードですが、プログラムの規模が大きくなると、もっと増えていくでしょう!
どちらのオブジェクトにも似たようなコードが並んでいます。これはあまりよろしくない兆候です。私たちはプログラミングの基礎で、「共通部分はまとめる」と習いました。それができていません。しかしどうやってまとめましょう。
オブジェクトの定義において、extendsキーワードを使うと、他のオブジェクトを「継承」することができます。他のオブジェクトを継承することで、そのオブジェクトのメソッドなどを丸ごと引っ張ってくることができます。
SquareオブジェクトはRectangleオブジェクトを継承すれば単純化できそうですね。やってみましょう。
// 矩形を表すオブジェクト。
class Rectangle {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
calcArea() {
return this.width * this.height;
}
collidesWith(other) {
const horizontal = (other.x < this.x + this.width) && (this.x < other.x + other.width);
const vertical = (other.y < this.y + this.height) && (this.y < other.y + other.height);
return (horizontal && vertical);
}
}
// 正方形を表すオブジェクト。
class Square extends Rectangle {
constructor(x, y, size) {
super(x, y, size, size);
}
}
Squareオブジェクトが随分とすっきりしました。exntedsキーワードを使って、Rectangleオブジェクトを継承したからです。このとき、SquareオブジェクトにもcalcAreaメソッドやcollidesWithメソッドが継承され、自由に使えます。
そしてSquareオブジェクトのコンストラクタに、見慣れないsuperという関数が見えます。これは継承元のオブジェクト(この場合Rectangle)のコンストラクタを呼び出すための関数です。super関数を使うことで、コンストラクタの処理を継承元に丸投げすることができます。
継承は便利な機能です。しかし危険も伴います。オブジェクトとオブジェクトが、あまりにも密接に結びつきすぎるのです。継承をすると、継承元のオブジェクトの変化に大きく影響されるようになります。例えば上の例でのRectangleオブジェクトがメソッドを追加・削除・変更した場合、それがSquareオブジェクトにまで及びます。
「あのオブジェクトのあのメソッドがほしいから継承しよう」などということをやっていると、継承元の変更に振り回される、危険なオブジェクトが誕生することになるのです。継承を使うのは、明確に上下関係が存在する場合のみにしましょう。
オーバーライド
他のオブジェクトを継承したとき、子となる側のオブジェクトで、親オブジェクトのメソッドを上書きして再定義することができます。これをオーバーライドと言います。
オーバーライドに特別な手法は必要ありません。子オブジェクト側で親オブジェクトと同じ名前のメソッドを定義するだけです。
class Person {
shout() {
console.log('私は人間だ!!');
}
}
class Hero extends Person {
shout() {
console.log('私はヒーローだ!!');
}
}
const hero = new Hero();
hero.shout(); // 私はヒーローだ!!
また、super.メソッド名を使うことで、オーバーライド前のメソッドを呼び出すこともできます。
class Person {
shout() {
console.log('私は人間だ!!');
}
}
class Hero extends Person {
shout() {
super.shout();
console.log('私はヒーローだ!!');
}
}
const hero = new Hero();
hero.shout(); // 私は人間だ!!私はヒーローだ!!
getterとsetter
例えば、先ほどのPointオブジェクトを不変オブジェクトにしたいと考えたとします。不変オブジェクトというのは、プロパティの値が途中で書き変わらないオブジェクトのことです。しかし素直に書くと、xもyも外部から普通にアクセスでき、外部からの書き換えが可能になってしまいます。これでは不変オブジェクトとは言えません。どう解決すればいいでしょうか。
そこでgetterという仕組みを使います。getterはメソッドをプロパティに見せかける機能です。この機能を使えば、外部から値を書き換えるのが難しいオブジェクトを作ることができます。以下の例をみてください:
class Point {
constructor(x, y) {
this._x = x;
this._y = y;
}
get x() {
return this._x;
}
get y() {
return this._y;
}
}
const p = new Point(1, 2);
console.log(p.x);
このときgetキーワードを使ってメソッドを実装すると、プロパティとしてアクセスできるメソッドが作れます。つまり、x()ではなく、xとしてアクセスできるということです。これをgetterと言います。getterは何もそのメソッド名通りの値を返す必要はなく、好きな値を返して構いません。ただしgetterは引数ゼロ個のメソッドです。
加えて、コンストラクタで、プロパティの名前を_xと_yにしています。これは変な名前をつけて、オブジェクトの外部からアクセスしにくくするためです。もちろんp._x = 5、などとすれば普通に代入できてしまうので、気休め程度にしかなりませんが……。外部からの完全なアクセスの遮断はJavaScriptの機能不足のためできません(他のプログラミング言語だとだいたいできます)。
そしてこれらを組み合わせることで、「_xや_yという値には直接はアクセスしにくくする」というのと「xやyという単純なプロパティ名でアクセスできる」ということの両立ができています。
また、getterには他の使い道もあります。例えばRectangleオブジェクトの面積計算を、calcArea()ではなくareaという名前にしたい、ということがあると思います。これもgetterを使うことで実現できます。
class Rectangle {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
get area() {
return this.width * this.height;
}
}
// areaプロパティとして使用可能。
const rect = new Rectangle(0, 0, 3, 4);
console.log(rect.area);
ただし、プロパティというものは、大多数のプログラマにとって、「軽い処理」だと思われています。なので、getterとして「重い処理」を実装すると、いらぬ混乱を招く可能性があります。getterとして外に晒すのは、軽い処理だけにしておきましょう。
また、getterの逆として、setterというものがあります。setterはプロパティのように代入できるメソッドを作り出す機能です。たとえばSquareオブジェクトのsizeプロパティに代入すると、widthプロパティとheightプロパティが自動的に変更されるという仕組みを作りたいとします。このときsetterを使えば簡単に実現できます。
class Rectangle {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
}
class Square extends Rectangle {
constructor(x, y, size) {
super(x, y, size, size);
}
get size() {
return this.width;
}
set size(value) {
this.width = value;
this.height = value;
}
}
const s = new Square(0, 0, 2); // size = 2で初期化する
s.size = 5; // sizeに5を代入する
console.log(s.size); // 5と表示される
setキーワードを使うことでsetterが実装できます。setterは引数1個のメソッドです。setterに値が代入されたとき、setter内部の処理が動きます。この場合は、size(setter)に値が代入されたとき、widthプロパティとheightプロパティをその値で書き換えるようにしています。
オブジェクト指向で書いてみよう
長々とした機能紹介がやっと終わりました!やったー!次は実際にそれらの機能を使ってオブジェクト指向なプログラムを書いてみましょう。
BMIの計算
お題:身長(m)と体重(kg)が与えられた時、BMIを計算し、表示しなさい。
入力データ例:身長1.7m、体重65kg
これは単純な計算問題です。まずは手続き型で書くとどうなるか見てみましょう。
function calcBmi(heightMeter, weightKg) {
return weightKg / (heightMeter * heightMeter);
}
const height = 1.7;
const weight = 65;
const bmi = calcBmi(height, weight);
console.log(bmi);
データがあり、BMIを計算する関数があり、それらを利用して計算しています。これは実に手続き的なコードです。
では、これをオブジェクト指向で書き直してみましょう。身長と体重はBodyMeasurementオブジェクトにまとめ、そのオブジェクトにBMI計算をやらせるのがよさそうです。
class BodyMeasurement {
constructor(heightMeter, weightKg) {
this.heightMeter = heightMeter;
this.weightKg = weightKg;
}
// BMIを計算して返すメソッド
calcBmi() {
return this.weightKg / (this.heightMeter * this.heightMeter);
}
}
const measurement = new BodyMeasurement(1.7, 65);
const bmi = measurement.calcBmi();
console.log(bmi);
オブジェクト指向的な書き方では、データを「身体測定」オブジェクトに持たせ、そのオブジェクトにBMIを計算させています。自分自身で計算するのではなく、オブジェクトに計算を任せるのが重要です。
複素数の計算
お題:複素数c1とc2があります。このときc1 + c2を求め、表示しなさい。
入力データ例:c1 = 1 + 2i, c2 = 3 + 4i
これも簡単な問題ですね。まずは手続き型で書くとどうなるか試してみましょう。
// 複素数の和を求めて返す関数
function add(c1, c2) {
return {
re: c1.re + c2.re,
im: c1.im + c2.im
}
}
// 入力データ
const c1 = { re: 1, im: 2 };
const c2 = { re: 3, im: 4 };
// 計算
const sum = add(c1, c2);
// 表示
const sumImStr = sum.im > 0 ? '+ ' + sum.im : sum.im;
console.log(`sum = ${sum.re} ${sumImStr}i`); // 4 + 6i
実にシンプルです。入力データがあり、それを関数で計算し、表示する。とても単純な流れです。
これをオブジェクト指向で書くとどうなるでしょう。複素数を保持するComplexオブジェクトを作り、そのComplexオブジェクトに計算を任せるのがいいでしょう。
// 複素数を表すComplexオブジェクト
class Complex {
constructor(re, im) {
this.re = re;
this.im = im;
}
// 他の複素数otherを受け取り、
// 加算した結果を新しい複素数として返す
add(other) {
return new Complex(
this.re + other.re,
this.im + other.im
);
}
// オブジェクトを文字列に変換するtoStringメソッド。
// 課題とは直接関係ないが、作っておくと便利。
toString() {
const imStr = this.im > 0 ? '+ ' + this.im : this.im;
return `${this.re} ${imStr}i`;
}
}
// 入力データ
const c1 = new Complex(1, 2);
const c2 = new Complex(3, 4);
// 計算
const sum = c1.add(c2);
// 表示
console.log(sum.toString());
オブジェクト指向で書くことで、加算や乗算処理をComplexオブジェクトに一任し、実際の計算はそれを呼びだすだけで済ませています。
JSONの保存と読み出し
お題:JSONを保存・読み出しできるプログラムを作りなさい。ただし保存先は変数でよいものとする。
入力データ例:{ ‘data’: ‘json’ }
この問題はどうでしょう。まずは手続き型で書いてみます。一応ある程度の抽象化は施しておきます。
let jsonStore;
function storeJson(json) {
jsonStore = json;
}
function loadJson() {
return jsonStore;
}
const json = { 'data': 'json' };
storeJson(json);
const json2 = loadJson();
console.log(json2);
頑張ればもう少し綺麗にできそうですが、今回はこの辺にしておきましょう。
さてこれをオブジェクト指向で書いてみます。まず、JSONデータを保持するJsonStoreオブジェクトを用意します。JsonStoreにはJSONを保存するメソッドと読み出すメソッドを用意します。
class JsonStore {
constructor() {
this._jsonData = {};
}
storeJson(json) {
this._jsonData = json;
}
loadJson() {
return this._jsonData;
}
}
const json = {'data': 'json'};
const store = new JsonStore();
store.storeJson(json);
const json2 = store.loadJson();
console.log(json2);
こんな感じになるでしょうか。データとその操作がひとつのオブジェクトに集約されたので、ずいぶん見通しが良くなったと思います。
オブジェクト指向の技法
オブジェクト指向を最大限に活かすには、様々な技法が必要になってきます。今回はその中からいくつかを簡単に紹介したいと思います。なお本格的に説明しているとずいぶん長くなってしまうので、大雑把な概要だけとなります。
カプセル化
オブジェクトに対してメッセージを送り、オブジェクトになんとかうまいことやってもらうのがオブジェクト指向でした。この教えを忠実に守るなら、次のことが言えそうです:
- オブジェクトのプロパティは、外部から書き込みすべきではない(読み出しは可)
- オブジェクトの操作は、メソッドを通じてのみ行うべき
つまり私たちはメソッドを通じてオブジェクトに仕事を任せるべきであって、手続き型のようにオブジェクトのデータを直接いじりまわすべきではない、ということです。
これを実現することを、カプセル化と言います。カプセル化されたオブジェクトは、外部からの直接的な操作を受け付けず、メソッドを通じてのみ操作ができます。カプセル化を施すことで、よりオブジェクト指向的なプログラムを組むことができます。
まずカプセル化されていない例から見ていきましょう。商品リストを表すProductListオブジェクトを作ることを考えます。このとき、カプセル化されていないと以下のようになります:
class ProductList {
constructor() {
this.products = [];
}
}
const prodList = new ProductList();
// オブジェクトのプロパティを直接操作している。
// これはカプセル化されていない!
prodList.products.push({name: 'スマートフォン', priceYen: 80000});
prodList.products.push({name: 'タブレット', priceYen: 100000});
const sp = prodList.products.find((p) => p.name === 'スマートフォン');
console.log(sp);
オブジェクトの持つ配列を、直接操作しています!これはあまりよろしくありません。オブジェクト指向なのだから、手続き的に操作するのではなく、オブジェクトに全てを任せるべきです。
このProductListにカプセル化を施したのが以下の例になります:
class ProductList {
constructor() {
// プロパティ名の先頭にアンダースコアをつけておく
this._products = [];
}
add(product) {
this._products.push(product);
}
findByName(name) {
return this._products.find((p) => p.name === name);
}
}
const prodList = new ProductList();
// オブジェクトのメソッドを通して操作している。
// カプセル化されている証拠。
prodList.add({name: 'スマートフォン', priceYen: 80000});
prodList.add({name: 'タブレット', priceYen: 100000});
const sp = prodList.findByName('スマートフォン');
console.log(sp);
オブジェクトのプロパティを直接操作するのではなく、メソッドを通して命令しています。これがカプセル化です。プログラムの挙動自体は変わりませんが、よりオブジェクト指向らしいプログラムになりました。カプセル化を意識することで、オブジェクト指向らしさを保つことができます。
ここでプロパティ名の先頭にアンダースコア(_)をつけているのは、JavaScriptの慣習で「これは外からいじってはいけないプロパティですよ」という意味です。JavaScriptには現在のところアクセス禁止を強制する機能が欠けているので強制力はないのですが、何もしないよりかはマシになります。
また、どうしても外側からプロパティにアクセスしなければならないことがあります。そういうときは、getterを使って、読み取りだけ許可するように作ればいいでしょう。
class Member {
constructor(name, id) {
// プロパティ自体にはアンダースコアをつけて隠しておく
this._name = name;
this._id = id;
}
// getterを使って外から読み取れるようにする
get name() {
return this._name;
}
get id() {
return this._id;
}
}
// getterがあるので読み取り可能
const member = new Member('John Appleseed', 1);
console.log(member.name);
多相性(ポリモーフィズム)
オブジェクト指向では、命令を受けたオブジェクト側が動作を決めます。命令を与えた側ではなく、命令を受けた側であるオブジェクトが、です。よって異なるオブジェクトに同じ命令を与えても、異なる動作を引き起こすことがあります。この特性のことを多相性(ポリモーフィズム)と言います。多相性は時として面白い特徴を生み出します。
多相性を活用するには、Javaなどの他の言語では少し手間がかかりますが、JavaScriptではシグネチャ(メソッド名、引数、戻り値)を同じにするだけで利用することができます。
多相性の最も単純な例は、JavaScriptの標準オブジェクトが実装しているtoStringメソッドです。様々なオブジェクトがtoStringメソッド実行命令を受け取りますが、その内部処理は様々です。しかしtoStringという共通のメソッドで動作します。
const numStr = (100).toString();
const boolStr = true.toString();
const strStr = 'string'.toString();
toStringメソッドは「オブジェクトを文字列に変換する」という約束事は同じです。しかし内部での処理は異なってくるでしょう。Numberオブジェクトを文字列に変換するには複雑な処理が必要になりますし、Booleanオブジェクトを文字列に変換するには「trueなら’true’、そうでなければ’false’」とする処理が必要になりますし、Stringオブジェクトを文字列に変換するには文字列をそのまま返す処理が必要になります。つまり、同じメソッド呼び出しに対して異なる処理を行っています。これが多相性です。
より実用的な例を考えてみましょう。TwitterとFacebookにテキストを送信できるプログラムを組むことを考えます。しかしTwitterにはTwitterの投稿制限があり、FacebookにはFacebookの投稿制限があります。さらにテキストの送信方法も全く異なります。つまり、全く異なる処理をしないといけません。そこで多相性を活用して、同じメソッド名、同じ引数、同じ戻り値のメソッドを実装します。
class Twitter {
post(text) {
// Twitter特有の処理をここに書く。
console.log('Twitterに投稿しました!');
}
}
class Facebook {
post(text) {
// Facebook特有の処理をここに書く。
console.log('Facebookに投稿しました!');
}
}
const twitter = new Twitter();
const facebook = new Facebook();
const text = 'テストテスト';
// 異なるオブジェクトで、同じ名前、同じ引数のメソッドが使える。
// 内部の処理は大きく異なる。
twitter.post(text);
facebook.post(text);
これでTwitterとFacebookに統一的な方法で投稿することができます!メソッドの呼び出しさえすれば、あとはそれぞれのオブジェクトがうまくやってくれるでしょう。
多相性のメリットは、異なるものを統一的に扱えることでしょう。このメリットは、特に複数のオブジェクトが混在している時に活きてきます。
class Twitter {
post(text) {
console.log('Twitterに投稿しました!');
}
}
class Facebook {
post(text) {
console.log('Facebookに投稿しました!');
}
}
class Tumblr {
post(text) {
console.log('Tumblrに投稿しました!');
}
}
// この配列には複数のサービスが混在している。
const accounts = [
new Twitter(),
new Facebook(),
new Tumblr()
]
const text = 'テストテスト';
// 異なるオブジェクトの集合を、
// 同じ方法で処理している。
for(const s of accounts) {
s.post(text);
}
これはTwitterとFacebook、Tumblrに同時投稿するプログラムです。それぞれのサービスは、テキストを投稿するために異なる処理をしなければいけません。しかし配列の中にはこれらが混在しています。
ですが、for文の中では、たった一行で複数のサービスに対する処理を済ませることができています。これは多相性を活用し、postというメソッドで操作を統一しているからです。もし多相性がなければ、if文などでサービスの場合分けをする必要があったでしょう。
継承
継承については「オブジェクト指向な機能の使い方」の項目で説明しました。継承も、オブジェクト指向言語の機能というよりかは、技法に近いものです。
継承を使うことで、親オブジェクトのメソッド・プロパティを受け継ぎ、追加分だけを書くことでオブジェクトを作成することができます。これを差分プログラミングと言います。
しかし継承は、親オブジェクトの特性を受け継ぐことから、オブジェクトとオブジェクトの関係が密接になりすぎる危険性があります。プログラミングでは一般的にモジュール同士の関係は「疎」であることが美徳とされていますが、オブジェクトの関係を「密」にする継承はこれに反していると言えます。
よって、紹介しておきながらなんですが、継承は現代ではあまり積極的には使われません。危険だからです。もちろん継承を使わないと実現できないこともあるので、選択肢のひとつとして頭の隅に置いておくのは重要ですが、「とりあえず困ったら継承」という行為だけはやめるようにしましょう。
ここまでのまとめ
いわゆる普通のプログラミングである手続き型プログラミングに対し、データ自体に命令をし、処理を任せるのがオブジェクト指向プログラミングです。これらふたつは異なる設計思想の元に存在し、直接的に比較できるものではありません。しかし現代ではオブジェクト指向言語が圧倒的多数を占め、オブジェクト指向を学ぶことは大きなメリットとなります。
オブジェクト指向はオブジェクトとメッセージパッシングという概念からなりなっています。オブジェクトは命令を受け取ることができるもののことで、メッセージパッシングはそのオブジェクトに命令を渡すことを意味します。このうちどちらを重視するかによって、異なるオブジェクト指向が生まれました。オブジェクト重視のオブジェクト指向と、メッセージ重視のオブジェクト指向です。JavaScriptはオブジェクト重視のオブジェクト指向を採用しています。
JavaScriptでは、オブジェクトの定義に、プロトタイプと呼ばれるオブジェクトを使います。プロトタイプを作成することで、任意のオブジェクトを作成することができます。このとき、オブジェクトの持つ関数をメソッド、変数をプロパティと呼びます。JavaScriptにはオブジェクト指向プログラミングをサポートする様々な機能がついており、継承やオーバーライド、getter/setterなどが利用可能になっています。
オブジェクト指向を最大限に活かすための技法として、カプセル化や多相性、継承というものがあります。カプセル化はオブジェクト指向の理念をより強く実現するために役に立ち、多相性はオブジェクト指向の「メッセージを受け取った側が動作を決める」という特性で、これらを活用すればより効率的なプログラミングが可能になります。継承は親オブジェクトの特性を受け継ぐ機能ですが、これは現代ではあまり使われません。
最後に
オブジェクト指向は、一般的に難しい概念だと言われています。オブジェクト指向は歴史が長く、様々な流行り廃りに取り巻かれ、構成する要素も多数存在し、「オブジェクト指向はこういうもの」と一言で言い表すのは難しいものです。この記事では簡単のため、「JavaScriptでのオブジェクト指向」に的を絞り、なおかつそれを構成する最低限の要素のみを紹介しました。
最低限とは言うものの、これだけあればオブジェクト指向でプログラミングするのには困らないであろうという要素については、全て詰め込みました。記事タイトルで騙すような形になって申し訳ないのですが、ずいぶんと長い記事になりました。ですが結果として、この記事ひとつあれば、基本的な要素の把握には困らなくなったと思います。
一方で各要素の掘り下げについてはあまり深追いしておらず、オブジェクト指向の本質的な理解には至らないのではないかとは思います。しかし初めはそれでいいのではないか、と私は考えています。もちろん、より高度なオブジェクト指向の知識が必要になることもあるでしょう。将来的に、ここで学んだ付け焼き刃の知識が役に立たなくなることが、必ず来ます。でも、そうなってから改めて学びなおしても、遅くないのではないでしょうか。
「JavaScriptのオブジェクト指向について」「総合的に」説明している記事や書籍というのは、なかなか見つかりません。Javaのオブジェクト指向の例は非常に多く、書籍も充実していますが、JavaScriptに関してはあまり充実しているとは言えません。また、個別の要素について説明している記事はウェブ上に大量にありますが、自力でそれぞれの知識を繋ぐことを要求するのは、初心者には酷です。それでも、オブジェクト指向言語を使う以上は、オブジェクト指向でプログラムを書くことは、避けられません。私たちはオブジェクト指向に立ち向かっていく必要があります。なので、その一助になればと思い、この記事を書きました。
この記事が、JavaScriptにおけるオブジェクト指向を理解するための足がかりになることを願っています。