Subterranean Flower

【連載記事】JavaScriptでプログラミングを学ぶ その4:オブジェクト指向

Author
古都こと
ふよんとえんよぷよぐやま!!!

前回の記事では、配列・オブジェクトというデータ構造について学びました。今回はオブジェクト指向という設計思想について学びましょう。

連載目次

  1. 基礎と文法
  2. 関数
  3. 配列とオブジェクト
  4. オブジェクト指向(この記事)
  5. データ構造とアルゴリズム
  6. HTMLとDOM(予定)
  7. 未定

今回学ぶ内容

今までは、基礎的な文法や、JavaScriptを構成する要素について学びました。今回は少し毛色が違ってきて、「プログラムをどのように設計するか」の話になってきます。

プログラミングというのは実に自由なものです。同じプログラムを10人に組ませれば、10人とも全く違う作りをしていた、なんてこともありえます。しかしいくら自由だからといって、無計画に組んでいると、自分でもどういう作りをしているのかわからなくなったり、最悪の場合は動作するところまでたどり着けない可能性があります。

そこで、一般的には、ある設計思想のもとに決まりきった形をあらかじめ用意しておいて、そこにプログラムを当てはめていく、パズルのような作り方をします。これによりプログラミングの自由度自体は下がりますが、安定した、わかりやすいプログラムを作りやすくなります。この考え方は、特に中規模から大規模なプログラムを作るときに有効です。

今回は数ある設計思想の中から「オブジェクト指向」というものを学びます。オブジェクト指向は現代におけるメジャーな設計方法の一つで、ある程度のわかりやすさと使いやすさを兼ね備えています。オブジェクト指向はJavaScript以外でも用いられることが多いので、学んでおいて損はないでしょう。

オブジェクト指向

オブジェクト指向は、現代のプログラミングにおいて、おそらく最もメジャーな設計思想です。データを「オブジェクト」という塊として捉え、オブジェクト同士の相互作用によって処理が進んで行く、というのが基本的な考え方になります。この考えをできるだけ守りつつプログラムを組んでいくのが、オブジェクト指向プログラミングということになります。

言葉で説明するよりも、実例を見た方がわかりやすいでしょう。例えば2つの二次元ベクトルを加算するプログラムを考えます。このとき、いくつかの実現手段が考えられますが、ひとつは以下のようになるでしょう:

'use strict';

// 二次元ベクトルを表すクラス
class Vector2 {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

// 二次元ベクトル同士を加算する関数
function addVector2(v1, v2) {
  return new Vector2(v1.x + v2.x, v1.y + v2.y);
}

// 実際にこれらを利用する
const v1 = new Vector2(1, 2);
const v2 = new Vector2(3, 4);
const sum = addVector2(v1, v2);

console.log(`[${sum.x}, ${sum.y}]`);

二次元ベクトルを表すクラスVector2と、Vector2同士を加算する関数addVector2があります。これはあくまでオブジェクトを単なるデータとして扱い、その処理は外部の関数に任せるというスタイルです。

一方、以下のようなコードも考えられます:

'use strict';

// 二次元ベクトルを表すクラス
class Vector2 {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  // 二次元ベクトル同士を加算するメソッド
  add(other) {
    return new Vector2(this.x + other.x, this.y + other.y);
  }
}

// 実際にこれらを利用する
const v1 = new Vector2(1, 2);
const v2 = new Vector2(3, 4);
const sum = v1.add(v2);

console.log(`[${sum.x}, ${sum.y}]`);

先ほどの例とは違い、Vector2自身にaddというメソッドを持たせています。この場合はオブジェクトを単なるデータの塊ではなく、自身に関する操作を持ったデータという扱いをしています。

ここで前者の例は手続き型プログラミングと呼ばれ、後者の例はオブジェクト指向プログラミングと呼ばれます。手続き型プログラミングでは自分で全て操作するのが大まかな特徴で、オブジェクト指向プログラミングではデータに操作を持たせてデータ自身に処理を行ってもらうのが特徴になります。

しかし2つのコードを見比べてもわかる通り、全くの別物というわけではなく、ある程度の共通点はあります。これは、JavaScriptにおいては完全にはオブジェクト指向的(オブジェクトの相互作用)なコードにするのは難しいからです。よってオブジェクト指向と言えども、手続き的(オブジェクトの外部から操作する)なコードはある程度残ります。

単なる思想の違いなので、どちらが優れているとか、どちらが劣っているとかではないのですが、基本的には現代のJavaScriptプログラミングではオブジェクト指向で書くのがデファクトスタンダードとなっています。JavaScript自体もオブジェクト指向向きの機能がいろいろ揃っているので、オブジェクト指向で書いた方が恩恵を受けやすいです。

特に何の目標もないままにコードを書いていると、コードが乱雑になりがちです。どういう風にプログラムを組むかに迷ったら、とりあえずオブジェクト指向を選んでおけばよいでしょう。オブジェクト指向は劇的な変化はもたらしませんが、その分わかりやすさがあります。

また、ネット上の情報も、オブジェクト指向に関しての方が充実しています。オブジェクト指向でプログラムを組んでいると、何か困ったことがあったときに調べやすく、問題が解決しやすくなります。手詰まりを避けるためにも、オブジェクト指向を選ぶことをお勧めします。

カプセル化

「データの操作はデータ自身に持たせる」、これがオブジェクト指向でした。この思想を徹底すると、以下のようなことも言えそうです:

  • オブジェクトのデータ(プロパティ)は外から操作しない
  • データ(プロパティ)の操作はオブジェクト自身のメソッドを通して行う

ここで、これらを徹底することを、カプセル化と言います。カプセル化を施すことで、外からの変更がなくなるため、結果を予想しやすくなります。また、全ての変更がオブジェクト内部で行われるため、オブジェクト内部の処理のみを追いかけていれば、データにどのような変更が加えられるかを完全に把握することができます。

カプセル化はオブジェクト指向を最大限に活かすための基本的なテクニックです。カプセル化を行わなくてもプログラムは動作しますが、やっておくと綺麗で読みやすいコードになります。

まずカプセル化されていない例を見てみましょう。以下は、本と本棚(Bookshelf)を扱うプログラムです:

'use strict';

// 本を表すクラス
class Book {
  constructor(title, author) {
    this.title = title;
    this.author = author;
  }

  toString() {
    return `${this.title}(${this.author})`;
  }
}

// 本棚を表すクラス
class Bookshelf {
  constructor() {
    // 初期値は空の配列
    this.books = [];
  }
}

const myBookshelf = new Bookshelf();
const book = new Book('天の光は全て星', 'フレドリック・ブラウン');

// プロパティを外部から直接操作する
myBookshelf.books.push(book);

// 表示
for(const b of myBookshelf.books) {
  console.log(b.toString());
}

この例では、Bookshelfオブジェクトの外部からbooksプロパティに直接アクセスして、Bookオブジェクトをpushすることで操作しています。これはカプセル化されていません。booksプロパティを不適切にいじってしまえば、データに不都合が生じる可能性があります。

また、操作がオブジェクトの外部に関数として存在するので、全体としてまとまりがありません。これはこれで正常に動作するので問題ないのですが、コードが長くなってくると、「どこでプロパティを変更しているのか」がわかりにくくなります。

これをカプセル化してみましょう。booksプロパティを外から編集しないようにするのと、本を追加するメソッドaddBookを追加します:

'use strict';

// 本を表すクラス
class Book {
  constructor(title, author) {
    this.title = title;
    this.author = author;
  }

  toString() {
    return `${this.title}(${this.author})`;
  }
}

// 本棚を表すクラス
class Bookshelf {
  constructor() {
    // 初期値は空の配列
    // 変数名の先頭にアンダースコアをつけることで
    // 外からは変更してはいけないということを示す
    this._books = [];
  }

  // メソッドを通じて本を追加するようにする
  addBook(book) {
    this._books.push(book);
  }

  // プロパティの取得はgetterを通してする
  get books() {
    // Array.fromは配列のコピーを作るメソッド
    // 直接返すと外から変更できるようになってしまうので
    // コピーを返す
    return Array.from(this._books);
  }
}

const myBookshelf = new Bookshelf();
const book = new Book('天の光は全て星', 'フレドリック・ブラウン');

// プロパティをメソッドを通じて変更する
myBookshelf.addBook(book);

// 表示
for(const b of myBookshelf.books) {
  console.log(b.toString());
}

これでカプセル化できました。まず、「_books」のようにプロパティ名の先頭にアンダースコアをつけて、外からアクセスしないように示します。これはJavaScriptの機能ではなく、そういう慣習です。JavaScriptには、プロパティへのアクセス制限を施す機能が現在のところ無い(将来的には追加される予定です)ので、このような名付け方をよくします。

次にaddBookメソッドを追加しました。このメソッドを通じて_booksプロパティを変更します。これにより_booksプロパティの変更がBookshelfオブジェクト内のみで完結するようになり、コードの見通しが良くなります。

最後に_booksプロパティのコピーを返すbooksをgetterとして追加します。これで外部からはbookshelf.booksのような形でアクセスできるようになります。ただし、ここで_booksを直接返すとこれまでの変更の意味が全くなくなってしまうので、コピーを返します。配列のコピーにはArray.fromメソッドを使います。

カプセル化はどちらかというと中規模・大規模の開発で効果を発揮するものなので、この規模の例ではメリットが少しわかりづらいかもしれません。ですがいずれ必要になってくるテクニックではありますので、可能であれば習得しておいてください。

もう少し複雑な例

先ほどの例は少しシンプルすぎてわかりにくかったかもしれません。もう少しだけ複雑な例を用いたカプセル化の説明をしましょう。

今度は商品とショッピングカートを扱うプログラムです。ショッピングカートには商品を入れることができ、入れた商品の合計金額がわかります。カプセル化しないで書くと以下のようになります:

'use strict';

// 商品を表すクラス
class Product {
  constructor(name, priceYen) {
    this.name = name;
    this.priceYen = priceYen;
  }
}

// ショッピングカートを表すクラス
class ShoppingCart {
  constructor() {
    this.items = [];
    this.totalPriceYen = 0;
  }
}

// これらを使用する
const cart = new ShoppingCart();
const juice = new Product('ジュース', 120);
const game = new Product('ゲーム', 6000);

cart.items.push(juice);
cart.totalPriceYen += juice.priceYen;
cart.items.push(game);
cart.totalPriceYen += game.priceYen;

for(const item of cart.items) {
  console.log(`${item.name} ¥${item.priceYen}`);
}
console.log(`Total: ¥${cart.totalPriceYen}`);

本棚での例の時と同じように、ShoppingCartオブジェクトの中のプロパティを外部から直接編集しています。今回はそれに加え、totalPriceYenというプロパティも編集しています。

編集するプロパティが増えていくと、その分だけコードも複雑化します。関数にまとめることである程度は緩和できますが、やはりオブジェクトの外部に関数があると、「どこでプロパティを変更しているのかわかりづらい」という問題は解決しません。

これをカプセル化してみます。ShoppingCartクラスにaddItemメソッドを追加します。以下のようになります:

'use strict';

// 商品を表すクラス
class Product {
  constructor(name, priceYen) {
    this.name = name;
    this.priceYen = priceYen;
  }
}

// ショッピングカートを表すクラス
class ShoppingCart {
  constructor() {
    this._items = [];
    this._totalPriceYen = 0;
  }

  // 商品を追加するメソッド
  addItem(item) {
    this._items.push(item);
    this._totalPriceYen += item.priceYen;
  }

  get items() {
    return Array.from(this._items);
  }

  get totalPriceYen() {
    return this._totalPriceYen;
  }
}

// これらを使用する
const cart = new ShoppingCart();
const juice = new Product('ジュース', 120);
const game = new Product('ゲーム', 6000);

cart.addItem(juice);
cart.addItem(game);

for(const item of cart.items) {
  console.log(`${item.name} ¥${item.priceYen}`);
}
console.log(`Total: ¥${cart.totalPriceYen}`);

addItemメソッドを追加することで、オブジェクト内に処理がまとまり、見通しが良くなりました。また、オブジェクトの外部からプロパティを変更しないようにしたので、外部からはtotalPriceYenプロパティに関する処理について知る必要は無くなりました。

このように、データを外部から隠蔽するのと、「どのようにデータを扱うか」も隠蔽してしまうのがカプセル化となります。オブジェクトをブラックボックス化して、オブジェクト外部からは「どういう操作方法(メソッド)を持つか」だけを知れば、あとは内部処理に関しては知らなくてもいい作りにする事で責任の分離ができます。責任が綺麗に分離していると、コード全体の見通しが良くなり、大規模なコードのメンテナンスがやりやすくなります。

多相性(ポリモーフィズム)

先ほど説明したカプセル化を行う事で、オブジェクトに少し面白い特性が生まれます。メソッドを呼び出したとき、オブジェクトの外側ではなく、オブジェクト自身が動作を決めます。これを活用すれば、全く異なる処理を、全く同じ方法で扱うことができるようになります。これを多相性(ポリモーフィズム)と言います。

例えばJavaScriptにあらかじめ組み込まれているメソッドを例に見てみましょう。数値オブジェクトや真偽値オブジェクトはtoStringというメソッドを持ちます。これは引数0個のメソッドで、値を文字列型に変換します。どの値においてもtoStringを呼び出す事で文字列に変換できます。しかし実際には内部処理は異なっているはずです。例えば以下のようにです:

  • 数値オブジェクトのtoStringメソッド:123といった数値を’123’という文字列に変換して返す
  • 真偽値オブジェクトのtoStringメソッド:trueならば’true’、falseならば’false’を返す
  • 文字列オブジェクトのtoStringメソッド:文字列をそのまま返す

これらの内部処理は全く異なっているにも関わらず、toStringという統一的なメソッド呼び出しで文字列に変換することができます。これが多相性です。要はメソッド名・戻り値の型・引数を統一することで、統一的に扱えるようにする、ということです。

もう少し他の例も見てみましょう。例えば四角形、円、三角形の面積を計算するプログラムを考えます。このとき、それぞれのクラスにcalcAreaというメソッドを追加します:

'use strict';

// 四角形クラス
// 面積を計算するcalcAreaメソッドを持つ
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  calcArea() {
    return this.width * this.height;
  }
}

// 円を表すクラス
class Circle {
  constructor(radius) {
    this.radius = radius;
  }

  calcArea() {
    return this.radius * this.radius * Math.PI;
  }
}

// 三角形を表すクラス
class Triangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  calcArea() {
    return this.width * this.height / 2;
  }
}

// 異なるオブジェクトの混在した配列
const geometries = [
  new Rectangle(2, 4),
  new Circle(5),
  new Triangle(3, 2)
];

// どのオブジェクトもcalcAreaメソッドを持っているので
// 同じメソッド呼び出しで統一的に扱うことができる
for(const g of geometries) {
  console.log(g.calcArea());
}

Rectangle、Circle、Triangleは全く異なるクラスです。持っているプロパティも少し違います。しかしcalcAreaという共通のメソッドを持っています。内部処理はそれぞれ異なりますが、同じ名前のメソッドです。

それぞれのオブジェクトが入った配列が与えられたとき、面積を計算しようとすると、普通なら図形の種類による場合分けが必要になりますが、ここでは多相性があるので、それぞれcalcAreaメソッドを呼び出すだけで統一的に処理が行えます。

これはメソッドが呼び出されたときに何をするかを、オブジェクト側が決定しているためです。同じ名前のメソッドでも同じ処理をする必要はありません。自分の都合の良いように処理すれば良いのです。

多相性もカプセル化と同じく、小規模なプログラムではあまり用いられませんが、大規模なプログラムになると効果を発揮します。

継承

オブジェクト指向の特徴としてもうひとつ、「継承」というものがあります。ですがこれは現代ではあまり使われないのと、JavaScirptにおいてはほとんど使われることがないので、軽い紹介だけにしておきます。

継承は他のクラスのプロパティやメソッドを引き継ぐ機能です。他のクラスを継承するには、クラス名のあとに「extends 継承元」と書きます。

例えば四角形クラスを継承する正方形クラスを考えます。コードは以下のようになります:

'use strict';

// 四角形クラス
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  calcArea() {
    return this.width * this.height;
  }
}

// 正方形クラス
class Squre extends Rectangle {
  constructor(size) {
    super(size, size);
  }
}

ここでSquareクラスはRectangleクラスを継承し、プロパティとメソッドを引き継いでいます。継承をした時は、コンストラクタ内で「super()」という関数を呼び出して、継承元のコンストラクタを呼び出す必要があります。

あとはSquareクラスをnewすれば、まるでRectangleオブジェクトのように扱うことができます。このようにして継承を使えば他のクラスの特徴をそのままコピーすることができます。

しかし継承を使うとクラスとクラスの結びつきが強くなりすぎてしまいます。元のクラスを変更すれば継承したクラスにまで影響が及ぶからです。一般的にプログラミングにおいては、できるだけ責任は分離していた方が良いとされています。しかし継承はそれに反する行為なので、現代ではあまり推奨されていません。

もちろん継承が必要になる場面もあるのですが、カプセル化や多相性ほど多くはありません。継承については扱いが難しいので、また必要になったときに勉強すればいいでしょう。

今回のまとめ

今回はすこし趣向を変えて、「オブジェクト指向」という設計思想について学びました。これによって何か新しいことができるようになるわけではありませんが、ある一定の思想に則ったプログラムの設計はコードに秩序をもたらし、変更や追加に強い、高いメンテナンス性を手に入れることができます。

特にオブジェクト指向は今までのコードを少し変更するだけで実現でき、かなり習得しやすいものになります。劇的な変化はもたらさないので少しメリットがわかりにくいという問題点もありますが、何の設計指針もないままにプログラムを組むよりかは、ずっと綺麗なコードが出来上がるはずです。

オブジェクト指向についてより詳しく知りたい方は、「JavaScriptで気楽に始めるオブジェクト指向プログラミング」もあわせてご覧ください。

例題

例題1

例題:以下のような本を表すクラスBookと本棚を表すクラスBookshelfがある:

'use strict';

// 本を表すクラス
class Book {
  constructor(title, author) {
    this.title = title;
    this.author = author;
  }
}

// 本棚を表すクラス
class Bookshelf {
  constructor() {
    this._books = [];
  }

  // 本棚に本を追加するメソッド
  addBook(book) {
    this._books.push(book);
  }
}

このときBookshelfクラスに、本を検索して初めの結果を返すメソッドsearch(query)を追加せよ。なお、検索は文字列queryがタイトルか著者に含まれているものを一致したとみなす。また、実際に本棚にいくつか本を追加し、検索が機能することを確かめよ。

  • ヒント:ある文字列に他の文字列が含まれているか調べるには、’文字列’.includes(‘他の文字列’)を使用する。

手続き型プログラミングではsearch関数を用意しますが、オブジェクト指向プログラミングではオブジェクトにsearchメソッドを追加します。

これは素直に作ればできます。まずBookshelfクラスにsearch(query)メソッドを追加します。そしてfor文でthis._books配列を調べ、条件に一致した本をreturnするだけです。そしていくつか本を追加し、検索の動作チェックをします。これを実際にやると以下のようになります:

'use strict';

// 本を表すクラス
class Book {
  constructor(title, author) {
    this.title = title;
    this.author = author;
  }
}

// 本棚を表すクラス
class Bookshelf {
  constructor() {
    this._books = [];
  }

  // 本棚に本を追加するメソッド
  addBook(book) {
    this._books.push(book);
  }

  // 本を検索して返すメソッド
  search(query) {
    // 全ての本に対して
    for(const book of this._books) {
      // もしタイトルか著者にqueryが含まれていたら
      if(book.title.includes(query) || book.author.includes(query)) {
        return book; // その本を返す
      }
    }
  }
}

// 本棚と本を作る
const shelf = new Bookshelf();
const book1 = new Book('天の光は全て星', 'フレドリック・ブラウン');
const book2 = new Book('星を継ぐもの', 'ジェイムズ・P・ホーガン');

// 本を追加する
shelf.addBook(book1);
shelf.addBook(book2);

// 適当なキーワードで検索してみる
const found = shelf.search('星');
console.log(found.title);

searchメソッドの中ではfor-ofで本1つ1つを調べて、条件に合ったものを見つけたらreturnしています。条件は「文字列queryが含まれているか」なので、文字列のincludesメソッドを使います。タイトルと著者の両方を調べるので、||(OR演算子)で繋ぎます。

あとは実際に本を追加して検索が機能するかを確かめるだけです。適当な本を追加して、適当なキーワードで検索してみてください。

例題2

例題:二次元ベクトルを表すクラスVector2と、複素数を表すクラスComplexを作成し、文字列表現を返すメソッドtoStringを2つのクラスに追加せよ。また、実際に各クラスのインスタンスを作成し、toStringが正しく動作することを確かめよ。

これは典型的な多相性(ポリモーフィズム)を用いたプログラムです。Vector2とComplexで文字列に変換するための処理は異なりますが、toStringという共通の名前で呼び出せるようになります。

それぞれのクラスを作成し、toStringメソッドを追加しましょう。以下のようになるはずです:

'use strict';

// 二次元ベクトル表すクラス
class Vector2 {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return `[${this.x}, ${this.y}]`;
  }
}

// 複素数を表すクラス
class Complex {
  constructor(re, im) {
    this.re = re;
    this.im = im;
  }

  toString() {
    if(this.im < 0) {
      return `${this.re}${this.im}i`;
    } else {
      return `${this.re}+${this.im}i`;
    }
  }
}

// 実際にインスタンスを作成してみる
const vec = new Vector2(3, 5);
const comp = new Complex(2, -3);

// 同じメソッド名で呼び出せる
console.log(vec.toString());
console.log(comp.toString());

これでどちらのクラスのインスタンスでもtoStringというメソッドを呼び出せば文字列化できるようになりました。

課題

課題1(homework4-1)

課題:通貨を変換するクラスCurrencyConverterを作成せよ。CurrencyConverterはメソッドdollarToYen(dollar)とeuroToYen(euro)を持つものとする。ただし1ドル=110円、1ユーロ=135円とする。また、CurrencyConverterのインスタンスを作成し、正しく動作することを確かめよ。

応用課題

応用課題1(homework4-1)

課題:ブログへ記事(Article)を投稿(post)するプログラムが以下のように組まれている:

'use strict';

// 記事クラス
// タイトルと内容を持つ
class Article {
  constructor(title, text) {
    this.title = title;
    this.text = text;
  }
}

// ブログクラス
// 投稿を受け付ける
class Blog {
  post(article) {
    console.log(article.title);
    console.log(article.text);
    console.log('ブログに投稿しました!');
  }
}

// 記事を作成する
const article = new Article('テスト', 'これはテストです');

// 記事をブログに投稿する
const blog = new Blog();
blog.post(article);

このプログラムに新たにEmailクラスとSNSクラスを追加し、それぞれのクラスに記事を投稿するpost(article)メソッドを追加せよ。ただし、EmailはBlogと同様にtitleとtextの両方を処理できるが、SNSは記事のtitleを処理できない(titleを無視する)ものとする。なお、ここで「投稿」とはコンソールに内容を表示することを指す。

また、同じ記事をBlog、Email、SNSのそれぞれへ投稿できることを確かめよ。

  • ヒント1:EmailクラスはBlogクラスを少し変更するだけでで完成する
  • ヒント2:SNSクラスはtitleを無視することに気をつけよう

その5:データ構造とアルゴリズムへ続く

次の記事 → その5:データ構造とアルゴリズム

わからないときは

この連載記事を読んで、わからないところ、不思議なところ、納得のいかないところ等出てくると思います。そのときはTwitterで@kfurumiyaまでご連絡ください。できる範囲で回答いたします。

また、Twitterを使えない、Twitterでは短すぎるという場合はメールアドレス:kotofurumiya@gmail.comまでご連絡ください。

私を信用できないという場合は、以下のプログラミング質問サイトを活用してください: