LoginSignup
75
78

オブジェクト指向って何だよ!

Last updated at Posted at 2021-12-13

ことの始まり

「オブジェクト指向」でググる。読み応えのある検索結果だ。

オブジェクト指向とは、「ある役割を持ったモノ」ごとにクラス(プログラム全体の設計図)を分割し、モノとモノとの関係性を定義していくことでシステムを作り上げようとするシステム構成の考え方。

そして、動物の例が挙げてある。

動物の「鳴く」という機能を一般化してクラスとして実装します。それを「動物」の具象である「犬」や「猫」に対して実装します。これによって、現実世界のモノの見方をプログラムに落とし込むことができます。

犬や猫をプログラムで表現するって、どんなアプリ作ってるんですか。

背景

オブジェクト指向は1970年、つまり今から半世紀近く前に提唱された概念だ。今や全てのプログラム言語が「オブジェクト指向の影響を受けている」といっても過言ではない。

オブジェクト指向型言語の代表とされるC++やC#、Pythonのユーザは、すでに少なからずオブジェクト指向的な要素には触れているだろう。今日のシステムはほとんどオブジェクト指向型言語を採用している。

だが、オブジェクト指向はひどく難解で、誤認されがちだ。ひどい記事だと「オブジェクト指向に明確な説明はない」とか「オブジェクト指向は古い」とか書いていたり、ドメイン駆動とプロパティをごちゃ混ぜにしてオブジェクト指向としているケースすらある。

記事の目的

そこで、本記事の目的は単純明快

  • オブジェクト指向を理解すること

ただ1つである。

動機

オブジェクト指向でデザインされたクラスは、極めて再利用性が高く、他のプロジェクトに持って行っても、すぐそのまま使うことができる。保守も非常に簡単である。仕様変更にも強い。アップデートも容易だ。

オブジェクト指向をマスターした後は、まるでコンテナをクレーンで移動させて巨大な建造物を作るようにソフトウェアが設計できるようになる。

プログラミング歴10年目の筆者が保証しよう。オブジェクト指向をマスターすれば、システム設計がそれはもう楽になる。

しかしオブジェクト指向も万能ではない。業務の性質上テストを書くことができない科学者や、とりあえず動くコードが欲しいプログラミング初心者の役には立たないだろう。コードが再利用できない、テストが書けない環境なら、オブジェクト指向はあまり意味がない。むしろ手間が増えるだけで逆効果かもしれない。だから使いどころを選ぶ武器だといえる。

おことわり

専門外の人に向けた資料をまとめ直したため、冗長な箇所がある。不明瞭な点や意見・感想はぜひコメントへ。

あと、この文章の読み方だが、分かっているところはさっさと読み飛ばして先へ進んでほしいことも併せて伝えておく。

過去の主要な言語パラダイムの流れ

現在を知るためには過去の一連の流れを知っておくことが重要である。ここで歴史的に知っておくべきものは

  • 手続き型プログラミング
  • 構造化プログラミング
  • 関数プログラミング
  • オブジェクト指向

だ。

1. 手続き型プログラミング

手続き型プログラミングの定義は意見が割れるところであり、

  • 命令型プログラミング
  • プロシージャ型プログラミング

のどちらかをそのように呼んでいる。現代のプログラミング言語は、ほぼ全部手続き型 だ。

命令型プログラミング

命令型プログラミングというのは、昔々、ENIACというコンピュータがあった頃の話だ。

なんと恐ろしいことに、このコンピュータは計算の度に回路を繋ぎなおす必要がある。足し算をやって、引き算をやる時にはまた回路を変更するのだ。

これだとあんまりにも不便なので、汎用計算が出来るノイマン型コンピュータというものが設計された。ノイマン型コンピュータでは、MOV, ADD, PUSHなどの命令を駆使してプログラミングする。この 汎用計算が出来る方式命令型プログラミング方式 と呼ぶ。
image.png

現代のコンピュータはすべてノイマン型のため、この定義に沿っていえば現役のプログラム言語は全て手続き型だといえる。

プロシージャ型プログラミング

さて、ノイマン型コンピュータの誕生から、少し時代が進んでからの話をしよう。最も原始的なプログラミング言語であるアセンブラでは、 あるひと塊の処理にラベルを付けて再利用できる仕組み が導入された。

例えば、以下のように一連の処理(=プロシージャ)に対して、「表示処理(PRINT:)」などと命名する。ここでは画面に文字を表示する機能を実装している。

; 表示処理
PRINT:
	mov ax, 0200H
	mov dl, [ds:bx]
	cmp dl, 00H
	jz PRINTEND
	int 21H ;システムコール
	inc bx
	jmp PRINT
PRINTEND:

ここで作った一連の処理は PRINT と5文字書くだけで実行できる。大変便利である。これが プロシージャ型プログラミング である。この定義によっても現代のプログラミング言語のほとんどが手続き型といえる.

2. 構造化プログラミング

手続き型プログラミングの波が過ぎ去ったあと、高級言語というものが出てくる。

高級とは何なのか。食パンが1枚1万円というセレブな話をしているのではない。ここでいう「級」とはレベルのことだ。
 
情報の世界はレイヤ構造になっていて、ある一連の処理に名前を付けて処理をまとめるということを繰り返して出来上がっている。その抽象のレベルを1段上る(=ある一連の処理に名前を付けてそれらをまとめて呼び出せるようにする)というのが「高級になる」ということだ。

初期の高級言語「C・Java・FORTRAN・COBOL…」が開発される頃、「プログラムが要するに全部ifとforで書ける」という特性がコンピュータにあることが知られていた。(むろんこの本質は今日も変わっていない。)

そこで、今日のプログラミング言語は全て、 if文とfor文を持つ設計 になった。これが 構造化プログラミング である1

3. 関数型プログラミング

前節では構造化プログラミングでif文というものが出来たという話をした。ここで、条件分岐について考えてみよう。

条件に応じて与えるダメージ量を変えたいことはないだろうか。下のような例を考えよう。 TAKE_DAMAGE プロシージャにはダメージ計算の式が書かれている。

int hp;
int damage;
int defence;

TAKE_DAMAGE:
    if ( damage - defence > 5 ) hp -= damage * 2;
    if ( damage - defence < 5 ) hp -= damage;
    ・・・

この TAKE_DAMAGE ラベルの中身が更に500行膨らんだときを考えてみよう。このとき戦慄的なプログラムが誕生する。恐ろしいことは、ダメージ量がHP, damage, defence以外の変数によっても決定される可能性があることだ。このときプログラムはスパゲッティコードへの第一歩を歩み、暴走し始める。

ここから想像できる事故を未然に防ぐため、 決まった入力から明確に出力が決まる「関数」という表記方法 が使われるようになった(下はその例)。

int hp;
int attack;
int defence;

int Damage (int attack, int defence) {
    if ( damage - defence > 5 ) return damage * 2;
    else if ( damage - defence < 5 ) return damage;
    ・・・
}

実は、関数には単に入力と出力が決まる以外のメリットもある。たとえば、自分自身を指定することで漸化式を記述できる。次はその例だ。

// 5+4+3+2+1
// Result: 13
print( "Result: " + func(5) );

int func(int n){
    if (n==1) return 1;
    return n + func(n-1);
}

ここでは func(n)func(n-1) を呼び出している。 自分自身を指定することで漸化式を記述する手法再帰 と呼び、競技プログラミングで必須の手法である2。初項 f(0) を設定しないと無限ループに入ることも有名である。

入力から出力が一意に決まる、関数という仕組みを使うプログラミング手法関数型プログラミング と呼ぶ。対義語はプロシージャ型の、ベタ打ちプログラミングである。

4. オブジェクト指向

そしてオブジェクト指向の説明に入るわけだが、オブジェクト指向は、ここまで見てきたパラダイムとは全く違う。既に起きている問題を解決するための物ではないのだ。ベースの着想は「こんな考え方出来たら面白い」などと一見してあほらしい3考え方だ。

よって、先に言っておくと、残念なことにオブジェクト指向をマスターしても、難しい問題を解く上での困難は何も解決しない。関数型プログラミングのように「再帰ができるから高度なアルゴリズムが簡単に記述出来る」なんてこともない。 ただし変更に耐性を持ち、再利用も容易なスーパーコードを書けるようにはなる。システム屋には非常に喜ばしいが、数学者の役には1ミリも立たない4

それでは、オブジェクト指向の説明に入ろう。

オブジェクト指向とは何か

オブジェクト指向が何かを理解するためには、最初に「オブジェクト」を理解するのが近道だ。

Qiitaやネットの情報でよく、オブジェクトは「現実のモノだ」とか「コンピュータで処理できるデータの塊だ」という説明を見かける。これは嘘である。

オブジェクトとはオブジェクトである

オブジェクト指向における「オブジェクト」の指すところだが、これは日本語で言う モノ だ。情報の世界に存在していて万人が認識できる対象ということが必要だ。ほかに制約はない。

情報の世界に存在して万人が認識できる とはどういうことか。説明しよう。
下にゲーム画面がある5
20111206unityTraining-thumbnail2.jpg

GUIのボタンアイコン3Dのキャラクター が確認できる。そして、目で見えないがデータを保存する ストレージ や、サーバへ行くためにネットワークを流れる データ というものも存在する。

上で挙げた例は全てオブジェクト だ。確かに情報の世界に存在して万人が認識できる。だから、 オブジェクト といえる。

新しい概念「オブジェクト」

詳しく説明しよう。Undertaleというゲームを例にする。白い四角の中で自キャラを操作して弾を避けるシューティングゲームである。
undertale1.jpg
終盤のボス戦では、この白い四角の枠をプレイヤーを使って動かせる6

undertale-40-10.jpg
この1シーンから、Undertaleというゲームには、従来のファミコンゲームの設計思想と異なる考え方が底流にあることが読み取れる。

実はオブジェクト指向以前のゲームでは、GUIでキャラクターを動かすことは許されていたが、逆にキャラクターがGUIに触ったり、GUIに対して変更を与えたりすることはできなかった。なぜなら、オブジェクト指向よりも前の世界線では、GUIとは「タダの線」で、単に画面上に描画される図形 だからだ。

オブジェクト指向が出来てから、GUIとは画面上にぶちまけられた「図形(もしくはタダの線)」ではなくなった。上の図で白い四角の枠は、枠という「物体」 と判定される。だからキャラクターが触れても良いことになった。
 
オブジェクトは日本人の感覚だと「八百万の(神様が宿っている)もの」に近い 。データもオブジェクトに含まれるというのは一見して直感に反している。しかし、八百万の神だと思えば何となく納得できる気がする7

例えば「八百万の神」というからには当然万人が認識できるものに名前を付ける必要がある。山の神様はいるが、右半身+左足の薬指みたいな神様はいない 。同様にTopPage というクラスはあっても、GUI操作 + DB操作 みたいなクラスがあると困惑してしまう。

 
脱線してしまったが、オブジェクト指向とは要するに上で説明した「オブジェクト」でプログラムを組立てよう、という考え方だ。

オブジェクト指向の実装

では、「オブジェクト」指向を完全に理解したところで、具体的な実装方法について考えよう。

オブジェクト指向が劇的に上達するルールがある。それは

  1. -erで終わるクラスを作るな
  2. ググって出てくる名前を付ける

の2個だ。

1. -erで終わるクラスを作るな

まずは前者を解説しよう。最初聞いた時、私も「何言ってんだこいつ」と思った。

プログラマとして中級レベルに差し掛かってからというもの、筆者はController, Manager, Helper, Drawer...etc を量産した。そして、これらの区別がオブジェクト指向マスターだと、そう信じていた。

しかしこの考え方は全くの間違いであった。「-erで終わるクラスを作るな」という教えの真の意図を理解したとき、目から鱗が落ちた のである。

クソオブジェクト

ここに -er で終わるクラス名の代表例を挙げよう:

Controller, Manager, Helper, Renderer...

これらのクラスは 実際に存在しない 。「Controller」とは誰(あるいは何)なのか?実際に存在しない架空のオブジェクトを作ってはいけない。

実在しないクラスはすなわち、クソオブジェクト である。クソオブジェクトの例として、Scriptオブジェクトに付いた

1.PNG

「GUI Controller」コンポーネントについて考えよう。

2.PNG

長いのはまあいいとして、最大な問題はコンポーネントの名前だ8。そもそもGUI Controllerとは何だろう?

試しに GUI Controller で思い付く具体的な関数の中身を書き出してみてほしい。これは恐らく無理だろう。あまりにも抽象的だ。

では User Data はどうだろう?読者の頭の中では大体こんな感じのデータ構造が浮かんだのではないだろうか。

データ構造
・ユーザ名
ID
・アイコン画像
     ...

なんと分かりやすいデータ構造だろうか。

2. ググって出てくる名前を付ける

先の GUI ControllerUser Data の差は何だろうか。それは 万人に分かる名前か という点である。User Data の中身は誰でも想像しやすかったが、GUI Controller の中身を想像することは至難の業であった。

では、この MVCモデル的な GUIController はどうやって処分すれば良いのだろう。一緒に考えてみよう。

このオブジェクトが管理すべき目的は GUI だ。だからそもそも GUIController という名前はやめて、GUIという名前 にしよう。ところが GUI では ザックリ度が高すぎる 。だからもっと分割してHome Post Menu... に分ける。

例えば Home はホーム画面に存在するオブジェクトの処理だけをする。ホーム画面を一番上に戻す処理とか、初期化処理が書かれている。ここに間違っても「ホーム画面上にあるボタンが押された時の処理」を書いてはいけない。ボタンの処理はボタンに書いてほしい。

究極的には、そもそもHome Post Menu...に分ける必要は無い。これらのコードは中身の処理も大体同じになると思われる。 だから全部まとめて Page みたいなプログラムにしてしまえば良い。

こんな具合で 誰にでも分かる名前を付けるということがオブジェクト指向では大切 だ。

補足1:送られるデータに気を配る

アプリ内データに対しても気を配るとベターだ。引き続きSNSの例を考えよう。

SNSを作ると、大体こういう「自分のアイコンと名前」「投稿日」「ひとこと」「反応」を付けてあるモノによく遭遇する。

カードみたいに見えるので、「カード」と呼ぼう。このとき CardData という名前で、データをコンポーネントの形で持っておく実装が楽だ。たとえ投稿日時は今表示しないとしても、データベース上のIDや投稿日時、ユーザ名...をとりあえずすべての「カード」に保持しておくのがベストだ。

そうすればカードの形が変わったとしても

このようにカードの表示部分を変えるだけで済み、わざわざクラスの設計をやり直す必要がない。これで-erクラスとはお別れだ(※)。

Manager が「マネージャー」という役職の人物を表す場合、Controller が「ゲームのコントローラ」など具体的な物体を指す場合は当然ながら例外である。

補足2:○○Helper、○○Player

○○Helper、○○Playerについても名称を改善可能だ。

例えば、

  • DataGetHelper なら UserInfo / UserDatabase
  • StreamFilePlayer なら Movie

などと直すといった具合である。

-erは「手続き」に意識が行くが、オブジェクトの名前なら「モノ」に焦点が合う。要するに オブジェクト指向では、モノの名前を正しく設定することが最も重要といえる 。モノが中心で、これに対して操作を加える考え方が大事だ。

補足3:例外

上級者向けのTipsだがバックエンドやフロントエンドのAPI呼出箇所など、ネットワークやDB、バックエンドが絡む設計は必ずしもこの限りでない。データの扱いはまた別の特殊な技法(DOA、データモデル)の理解が必要である。

オブジェクト指向の3大要素

オブジェクトを理解すれば、自ずとオブジェクト指向の真の姿が見えてきただろう。その上で、次はオブジェクト指向の3大要素だ。

この「3大要素」という名前に惑わされるやつが多い。とても多い。「オブジェクト指向ってなんだよ?」と聞かれて最初から3要素の話を始める奴はオブジェクト指向エアプレイヤーである。だが、オブジェクトの意味を完全に理解した読者は、果敢に次の段階に進むべきだ。

下に挙げたのはその要素である。

  1. 継承
  2. カプセル化
  3. ポリモーフィズム

カプセル化 」はオブジェクト指向の本質にかろうじて近いが、他の2つはほとんど付属品だ。

1. 継承

まずはインデックス通りに最初の要素「継承」だが、この機能は単なるプログラムの コピぺ機能 ではない。自分もオブジェクト指向を語り出したころ「要するにコピペ機能でしょ」と豪語していたが、この認識は誤りである。

継承はコードのコピペ機能ではない

「継承をあえて一言で説明すると、コピペ機能だ」という主張は、一見すると正しい。なぜか。

ここに3つのスクリプトがあるとする:
A. 円の面積を求める Menseki.cs

Menseki.cs
public class Menseki(){
    public float pi = 3.14f;

    // 半径x半径x3.14 した値を返す
    public float Area_Circle(int r) {
        return (r * r * pi);
    }
}

B. 円柱の体積を求める Volume_Cylinder.cs

Volume_Cylinder.cs
class Volume_Cylinder : Menseki {
    // 底面積x高さ の値を表示する
    void Volume_Cylinder(int r, int height){
        print( Area_Circle(r) * height );
    }
}

C. 円錐の体積を求める Volume_Cone.cs

Volume_Cone.cs
class Volume_Cone : Menseki {
    // 底面積x高さx(1/3) の値を表示する
    void Volume_Cone(int r, int height){
        print( Area_Circle(r) * height * (1.0f/3.0f) );
    }
}

これらのコードは事実上、次のコードと等しい。

A. 円の面積を求めてから円柱の体積を求めるCylinder.cs

Cylinder.cs
float pi = 3.14f;

// 半径x半径x3.14 した値を返す
float Area_Circle(int r) {
    return (r * r * pi);
}

// 底面積x高さ の値を表示する
void Volume_Cylinder(int r, int height){
    print( Area_Circle(r) * height );
}

B. 円の面積を求めてから円錐の体積を求めるCone.cs

Cone.cs
float pi = 3.14f;

// 半径x半径x3.14 した値を返す
float Area_Circle(int r) {
    return (r * r * pi);
}

// 底面積x高さx(1/3) の値を表示する
void Volume_Cone(int r, int height){
    print( Area_Circle(r) * height * (1.0f/3.0f) );
}

実行結果だけ見ると両者から得られる結果は同じだ。継承という機能によって、基底クラスの中身が派生クラスの一番上にそのままコピペされているように見える。

実は動作の理解として「コピペ」という認識は正しい。
・・・のだが、継承は 使用にあたって重要なルール が設定されており、このような認識ではいけない。

継承とは「派生」である

では、「継承」とは何なのか。歴史的に、そもそも「継承」という呼び方は変化形である。

JavaやC#では「継承」と呼ばれてきたこの機能だが、元祖オブジェクト指向言語「Smalltalk」のことを思い出してほしい。もともとこの機能は inherit(継承) ではなく、 derive(派生) と呼ばれていた。

派生 とはこうだ。

例えば「ヒト」や「ゴリラ」は、「哺乳類(サル目)」である。ゴリラは哺乳類からの派生である。難しい言い方をすると、 サブクラス(=ゴリラ)はスーパークラス(=哺乳類)から派生したもの である。

サブクラス(=ゴリラ)にはスーパークラス(=哺乳類)の性質(=おっぱいを飲む)が適用されるという特徴も知られている。実はゴリラもおっぱいを飲むのだ。

「ゴリラ」はおっぱいを飲むので「哺乳類」を継承して良い。しかし「哺乳類」は必ずしも素手で丸太を折ることはできないので「ゴリラ」クラスを継承してはいけない。

同様に、さっきの「面積」クラスの話に戻ると「体積」クラスが「面積」クラスを引き継いではいけない。哺乳類がゴリラでないのと同様に、体積は面積ではないのだ。

ex.) 継承の文脈では、is-a ⇔ has-a についても知っておくと良いだろう。 ゴリラ is 哺乳類 で、ゴリラ has おっぱい である。ゴリラと哺乳類はis-a関係となり、継承の適用対象となる。has-aの場合はコンポジションの対象となる。

正しい継承

では先の「面積」クラスを継承(派生)を使って正しく書き直してみよう9

円や三角といった図形は「形(面積を持つもの)」の派生で、球や直方体は「立体(体積を持つもの)」の派生だ。

using System;

interface Shape {
    double Area();
}

interface Solid : Shape {
    double Volume();
}

class Circle : Shape {
    public double R { get; private set; }
    public Circle(double r) {
        R = r;
    }
    public virtual double Area() {
        return R * R * Math.PI;
    }
}

class Triangle : Shape {
    public int Bottom { get; private set; }
    public int Height { get; private set; }
    public Triangle(int bottom, int height) {
        Bottom = bottom;
        Height = height;
    }
    public double Area() {
        return Bottom * Height / 2;
    }
}

class Square : Shape {
    public int Size { get; private set; }
    public Square(int size) {
        Size = size;
    }
    public double Area() {
        return Size * Size;
    }
}

class Rectangle : Shape {
    public int Width { get; private set; }
    public int Height { get; private set; }
    public Rectangle(int width, int height) {
        Width = width;
        Height = height;
    }
    public double Area() {
        return Width * Height;
    }
}

class Ball : Circle, Solid {
    public Ball(double r) : base(r) {
    }
    public override double Area() {
        return base.Area() * 4;
    }
    public double Volume() {
        return Area() * R / 3;
    }
}

class Corn : Circle, Solid {
    public double Height { get; private set; }
    public Corn(double r, double height) : base(r) {
        Height = height;
    }
    public override double Area() {
        return base.Area() + Math.PI * R * Math.Sqrt(R * R + Height * Height);
    }
    public double Volume() {
        return base.Area() * Height / 3;
    }
}

class Cylinder : Circle, Solid {
    public double Height { get; private set; }
    public Cylinder(double r, double height) : base(r) {
        Height = height;
    }
    public override double Area() {
        return 2 * base.Area() + Height * 2 * R * Math.PI;
    }
    public double Volume() {
        return Height * base.Area();
    }
}

public class Program {
    public static void Main() {
        Shape[] shapes = {
             new Circle(10), new Circle(20), new Circle(30),
             new Triangle(10, 10), new Triangle(20, 20),
             new Square(10),
             new Rectangle(10, 20),
             new Ball(10),
             new Corn(10, 10),
             new Cylinder(10, 10),
        };
        foreach (var shape in shapes) {
            Console.WriteLine(shape.Area());
        }
        Solid[] solids = {
             new Ball(20),
             new Corn(20, 20),
             new Cylinder(20, 20), new Cylinder(30, 30),
        };
        foreach (var slid in solids) {
            Console.WriteLine("{0}, {1}", slid.Area(), slid.Volume());
        }
    }
}

実行結果は下のようになる。

314.159265358979
1256.63706143592
2827.43338823081
50
200
100
200
1256.63706143592
758.447559174816
1256.63706143592
5026.54824574367, 33510.3216382911
3033.79023669926, 8377.58040957278
5026.54824574367, 25132.7412287183
11309.7335529233, 84823.0016469244

上のコードで、ShapeSolidといったクラス10は、突然出現したわけではない。

継承は親から子への派生だが、 子から親への汎化 に着目することは非常に重要だ。作者は円・三角・四角等の図形があることに気付いたから、これらをまとめて「形」と定義した。人類は、サルやチンパンジーを見て「哺乳類」と定義した。決して「哺乳類」が先にいて、サルやチンパンジーが生まれてきたわけではないのだ。

つまり継承の出番とは、設計または実装を進める途中で「AとBとCに共通の性質がある」事に気付いたときだ。

継承は忘れよう

ある機能を知ってしまうと使いたくなるのが人間の性だ。 よくある事例として「コードやオブジェクトが重複しているのでこれらを1つにまとめるため」継承を使う人がいるのだがこれは最悪な使い方 だ。詳しくは次の項で述べる。

「駆け出しエンジニア」がこのようにして一見「ベテランっぽいコード」を量産するのを数多く見た。継承を使うのは、プログラムの抽象化レベルを上げるべきだと分かってからでも遅くない。

現代のプログラミング言語はあまりにも複雑なのである。

例えば VB.Net の Button クラスひとつを見ても、用法によっては到底使わないようなメソッドまでも存在しており、この Button クラスを継承して新たなクラスを作るのは現実的でない。

継承を使うのは自分の小さな世界だけ にとどめておき、クラスの性質をオーバーライドするような事態はなるべく避けるのが世界を守るためのオブジェクト指向の使い方である。

最適化の第2法則にも「まだ最適化をするな」と書いてあるように、継承を使うのも同様にまだ待つのがベターかもしれない。

継承はプログラマの目先の手間を減らすための道具ではない

非常に残念な知らせだが、オブジェクト指向は使い倒せば使い倒すほど目先の労力は増える。だから、使い捨てのコードに全く向いていない。

ベテランとなり、クラス設計をするあなたには、2つの選択肢で迷う日が来るだろう。

  1. コードやオブジェクトの重複を減らして1つにまとめる
  2. オブジェクトに共通する性質を1つにまとめて 適切に名前を付ける

この2つは似たようで全く違う。

前者は先に述べた最悪の使い方であり、プログラマーはただの怠け者といえる。これは端的に言うと、「エアコン&テレビ&ドア」を合体した物体を作り出す行為である。 継承を使えば(使わなくても)「コードが楽しく書ける」なんてことは現実的にはあり得ない

後者は勤勉だ。例えて言うなら「エアコン」「テレビ」「ドア」に共通する「ネジ」を作り出す行為である。大変合理的で良い。プログラマは勤勉であるべきだ。発明は非常に難しい行為であり、 継承も同様に相当な論理的思考力を必要とする

どうしても "継承を使ってみたい" 場合、先に

  • フィールドやインスタンス化・コンポジションで解決できないか
  • 重複はそのままにしてはダメか?

をちょっと考えてみてほしい。そのうえで上位の抽象概念が必要なら継承を使うのが良い。

2. カプセル化

次に第二要素「カプセル化」だ。

カプセル化を データ保護機能 と勘違いしている人が多い。たしかに大学の講義や技術書でもこう説明されているので、筆者も長年そういうものだと思い込んでいた。

しかし、この考え方だとどんなコードを読んでも意図が理解できないだろう。この考えは誤りだったのかもしれない。今日から新しい認識をしてみよう。

カプセル化とは「カプセルにすること」である

表題にもあるように、カプセル化の意味とは「カプセルにすること」だ。この業界において「カプセル」という言葉には絶対の意味合いがある。図で表すと、下の図のようになる11

Capsulation.png

複雑な処理は全部カプセルとして閉じ込め、あるボタンを押すだけで全部の処理をやってくれるようにする。中の実装は知らなくて良い。これがカプセル化だ。

カプセルに名前を付ける

オブジェクト指向で作られた「オブジェクト」は明確な名前を持つ必要がある。では名前はどのように付ければ良いのか。

悪い例がある。次のコードはトランプを表している。

Card.cs
public class Card {
    public Card(int code) {
        num = code;
    }
    public int num = 0;
}
Test.cs
class Test {
    void Main() {

        Card card = new Card(0);

        string suit = card.num / 13 switch
        {
            0 => "スペード",
            1 => "ハート",
            2 => "クラブ",
            3 => "ダイヤ",
            _ => throw new InvalidOperationException()
        };

        print(suit + "の" + (code % 13 + 1)); // スペードの1

    }
}

これは、トランプのコードである。ロジック自体はよくあるやり方で、全部のカードに 0 ~ 51 の通し番号を振るのである。

  • 0 ~ 12までは「スペード」のカード
  • 13~25までが「ハート」のカード
  • 26~38は「クラブ」のカード
  • 39~51が「ダイヤ」のカード

である。例えば 14 番は "ハートの2" に対応する。

さて、ここで Test クラスに注目すれば、上のコードはオブジェクト指向として適格なコードではなく、違法建築と一目でバレてしまう。工場での試作時はOKだが、人の家でこれをやったらマズイ。これが違法たる最大の要因は 命名が適切でない ためだ。

Card クラスに注目し、よく考えてほしい。普通のトランプはこうだ。

こうではないだろう。

先の例で上がった Card クラスは後者の実装をしている。Card card = new Card(0); の部分だ。そんなトランプは存在しない 。よって違法であり、こんなコードを見かけた日には控えめにキレてもいい。

では、次のような実装はどうだろうか12

PlayingCard.cs
// トランプ
interface PlayingCard
{
    int Suit();    // 絵柄
    int Number();    //数字
}
Card.cs
// int型のコードで管理されるトランプ
class IntCard : PlayingCard
{
    readonly int code;
    
    public IntCard(int code)
    {
        this.code = code;
    }

    public int Suit()
    {
        return code / 13;
    }  

    public int Number()
    {
        return code % 13 + 1;
    }

    public override string ToString()
    {
        string[] suit = {"スペード", "ハート", "クラブ", "ダイヤ"};
        return suit[this.Suit()] + "の" + this.Number();
    }
}
// 使い方
PlayingCard card = new IntCard(0);
print(card);  // スペードの1

Card という54枚の物体は、PlayingCard の一種であり、全て「絵柄」「数字」という共通の性質を持つ。さらに、それぞれ個別の通し番号が付いているが、それは内緒の性質であり公表されていない。

これがオブジェクト指向的な "トランプ" であり、先の図でいえば、前者:

である。こちらの方が何となく正しいコードのような気がする。

このように疑似的にモノを表現する仕組みがオブジェクト指向(カプセル化)だ。モノの使いやすさ、便利さを考える、デザイン的要素がオブジェクト指向の難しさでもあるのだが、別に良い記事があるのでそちらを読んでほしい11

補足:Tell, Don't Ask.13

このような実装をするためによく言われる法則にTell, Don't Ask. (求めるな、命じよ)の原則というのがある。

下のダメな例では、敵のHPを「求めて」ダメージを与えるのに対し、良い例では敵に「命じて」ダメージを与えている。

ダメな例
// 敵のHPを「求めて」ダメージを与える
void Attack(){
    enemy.hp = enemy.hp - attackPower;
}
良い例
// 敵に「命じて」ダメージを与える
void Attack(){
    enemy.takeDamage(attackPower);
}

他人のHPを勝手にいじるのは基本的にご法度で、「ここから先はそちらの都合」ということにして、残りは相手のオブジェクトで処理してもらうのが一般的な実装だ14

3. ポリモーフィズム

さて、最後の要素であるポリモーフィズムについて解説しておこう。ポリモーフィズム自体は多態性を意味する言葉だ。

ポリモーフィズムという言葉も多態的である。その意味も一つでなく、状況によって異なる意味を指す。例えば、以下の3つを指すケースがある15

  • オーバーライド(≒サブタイピング)
  • コンポーネント化(=パラメータ多相)
  • オーバーロード(=アドホック多相)

オブジェクト指向の文脈においては、オーバーライドを指すのが一般的な感覚だが、読者がググる手間を減らすため、すべて説明する。

A. オーバーライド

こいつは 継承の親戚 だ。継承に慣れてくると、もっと一般のクラスに対して処理をしたくなる瞬間が訪れる。

実は下のように Duck(アヒル), Cuckoo(カッコウ), Bird(鳥) クラスを定義したとき、

public class Duck : Bird {}
public class Cuckoo : Bird {}

public class Bird {}

次に例として挙げた

  • bird1 のように、 Bird クラスを参照する書き方
  • bird2bird3 のように、 DuckCuckoo クラスを参照して Bird クラスに代入する行為

どちらの方式も全く問題ない。

public class Main() {
    public void Start() {
        Bird bird1 = new Bird();
        Bird bird2 = new Duck();
        Bird bird3 = new Cuckoo();
    }
}

派生型(Duck, Cuckoo)は、基底型(Bird)のインスタンスにできるという機能がある。これがサブタイピングである。

さらに、オーバーライドというのは継承元のコードを書き換えることが出来る仕組みである。この仕組みは本記事で解説しない。

B. コンポーネント化

コンポーネント化についても触れておこう。これは オブジェクト間でコードを使いまわす 仕組みである。

3人エージェントがいて、それぞれPaul, Smith, Johnと呼ばれていることにする。

「これらは違うオブジェクトだから3個スクリプト作らなきゃ。Paul クラス、Smith クラス、John クラス…?」

少し待ってほしい。

Agentクラス を作って、コピーした方が労力が掛からなくて良いのではないだろうか。名前と走る速さ・パワーをフィールドとして持てば良いのではないか?

こうして共通の性質を持つもの(Paul, Smith, John)に対して、共通のクラス(Agent)を割り当てるのがコンポーネント化だ。

わざわざ個別のオブジェクトすべてに対してクラスを割り振るのは、あまりに大変だ。だからこうしてモノの性質の一般化を図っているのである。

C. オーバーロード

オーバーロードについても触れよう。オーバーロードは 同一コード内で関数名を使いまわす 仕組みだ。

ある関数に対して string 引数を使いたい時もあれば、int を引数に取りたい時もある。
次のコードを見てほしい。

Speak.cs
class Speak(){
    void Say(string text){}
    void SaySpeed(string text, int speed){}
    void SayBreak(string text, int speed, int breakTime){}
}

引数に応じて、関数名も3通り作ったのである。かつてはこんなコードが平気で書かれていた。しかし、最近のC#では引数を変えても関数名は1つで良い。

Speak.cs
class Speak(){
    void Say(string text){}
    void Say(string text, int speed){}
    void Say(string text, int speed, int breakTime){}
}

このような書き方が認められているというのがポリモーフィズムだ。

オブジェクト指向の利点と課題

ここまでオブジェクト指向の理解のために紙面を割いてきた。もしも紙なら一冊の本になっているだろう(電子媒体の時代に生まれて良かった)。最後に、オブジェクトには具体的にどのような利点があるのか、どのような課題があるのか考えてみよう。

利点

オブジェクト指向設計に3つの利点がある。

コードを使いまわせる
変更に強いコードを書ける
・プロジェクトの全容を把握しやすい

ことだ。

1. コードを使い回せる

三者のうち最も重要なのは、コードを使いまわせる点である。オブジェクト指向で書かれたプログラム資産の蓄積によって、一晩でアプリが完成する可能性 が示唆される。

コードを使いまわすための代表的な仕組みはモジュール化であろう。

モジュール構想

モジュール化の構想自体は以前からあり、C# (.NET) でも NuGet などとして実装されている。Unityでは、より進んだモジュールの実装として、UnityPackageというフォーマットが整備されている。

UnityPackageでは、複数のC#のコードをパッケージにして別のプロジェクトに持っていくことが出来る。一度パッケージを作ってしまえば、何回でも同じ組み合わせでコードを使いまわせる のである。もちろん、中身はC#コードであるから、各ファイルを書き換えたり、差し替えたりすることも容易 である。

従来のモジュール構想(C#.NET)の問題は、モジュールの肥大化である。各クラスやモジュールのサイズが大きくなり、いわゆる "神クラス" とまでは行かないまでも数十万行単位の非常に長大なコードやdllファイルが組み込まれる。要するに、モジュール内の小さな変更と応用がすぐに出来ない。

しかし、オブジェクト指向によってこの問題は偶然解決されつつある。カプセル化されたオブジェクトクラスは、せいぜい数千行と1単位毎のコードサイズが小さい。ひとつのパッケージにいくつものスクリプトが入り、スクリプト単位での交換が可能となる。この方式(UnityPackage方式)は、巨大なdllを一つだけ組み込む方式よりも高速かつ細やかにアプリを組立てることができる。

個別コードの使いまわしが出来る点もメリットである。例えば、 User というオブジェクトがIDとパスワードを持っているとしよう。同じ構造で良ければ、 User クラスは別プロジェクトに持って行ってそのまま活用 出来る。

ただし、このコードの使い回しが通用するのは扱う領域が同一ドメインで、言葉の意味が同じ場合である。同じ Card でも、SNS領域とゲーム分野では全く意味が違うので、この場合同じオブジェクトは使い回せない。

ドメインって何?

ドメインについて補足する。ここに1本のペンがあると仮定する16

image.png
ユーザにとってこれは「字を書く道具」だが、文房具屋さんにとってペンは「売るとお金が手に入る商品」だ。

この「ユーザにとって」とか「文房具屋さんにとって」という部分を ドメイン と呼んでいる。「字を書く道具」とか「売るとお金が手に入る商品」というレベルで、ドメインにおいて捨象されたペンが ドメインモデル である。

再建設

何回でも使いまわす前提のコードを作るということにはデメリットもあって、設計の時間が延びる。この辺の事情は建設業界と似ている。

モジュールを束ねて、巨大なアプリという建設物を作るためには、各パーツの接合部がちゃんと使いやすいようになっているのがベターだ。ちょっとしたズレが建物を崩壊させる。

ソフトウェアの良さは、建設と違って割と容易に土台から作り直せるということだ。1回作ってみて、ダメだったら修正すれば良い。3回作り直すことを想定してかは分からないが、工期見積を最短納期の3倍で見積もる「3倍見積法」という手法が現場では流行っているらしい。

2. 変更に強いコードを書ける

オブジェクト指向のもう一つの利点は、変更に強いコードを書けることだ。システム開発だと実装を進める中で、仕様が変わることがある。「DBのキーを1つ増やしたい」「GUIのレイアウト変えたい」← こういうことは日常茶飯事だ。

このとき、GUIControllerLoginControllerを使っていると何が起きるかというと、膨大なこれらのクラスの中を探しまわってコードを修正していくことになる。小手先の修正を繰り返すとプロジェクトは瓦解する。

オブジェクト指向であれば人間の直感に近い考え方を取っているので、修正すべき場所がすぐに分かる。修正箇所も割と明確だ。非オブジェクト指向の実装と比べて、オブジェクト指向の実装では コード量も増加するが大型プロジェクトではそれ以上のリターンがある

3. プロジェクトの全容を把握しやすい

オブジェクト指向の3つ目の利点は、システム開発で重要な 「何がどうなっているか」という全容の把握を容易にする ことだ。

オブジェクトという考え方は、人間の考え方にかなり近い。直感的にオブジェクトの役割と動作を把握できる。また、プログラムの説明も容易だ。

大規模開発だと人が替わってコード内容を説明することがある。

その時、「こういうモノとこういうモノがあって」伝えるだけで良く、ある程度経験を積んだエンジニア相手なら説明を省略できる。「こういう手続きをやってデータがこうなってて」と全部説明しなくて良い。深く考える必要もない。

課題

「オブジェクト指向は後発ゆえ、デメリットは特にない」という説もある。これは少し違うのではないかと思う。

オブジェクト指向には3つの課題があると考えている。

  • 複数オブジェクトの扱いに弱い
  • メッセージが見えない
  • レイヤ構造から逃げられない

2つは理論上の欠陥、1つは情報という世界の本質的な構造に由来する問題だ。前者は未解決だが、後者には暫定的解決策がある。

1. 複数オブジェクトの扱いに弱い

まず第一のオブジェクト指向の限界として、2つ以上のオブジェクトを操作できないというのがある。

あるオブジェクトに「鳴け」と命令して「ニャー」とログを出させるのは簡単だ。では、オブジェクトが2つ以上になった場合はどうだろうか。

現実世界で「並べ」と言ったら生徒が並ぶ。しかし、プログラムの世界で「並べと言うだけで生徒を並ばせる」ことはできない。全員に「座標(x, y)に移動しろ」と耳打ちして回るしかないのだ。

まるで幼稚園児と先生のようだ。

このように、「先生」に当たるオブジェクトに「生徒」全員を登録してやらなければならない。現実と大きく違う点の1つはここだ。オブジェクト指向という道具の難しさはこういうところではないか。

2. メッセージが見えない

第二の問題は、渡されるデータが見えないことだ。オブジェクト指向は本来、オブジェクト同士がメッセージをやり取りして「あたかもモノ同士が会話しているかのように振舞わせる」という設計だった。

下に図を載せた。左が理想で、右が現実だ。

現実には右のようにメソッドを呼び出す。オブジェクト間でやり取りされるメッセージは、自然界であれば第三者が観測できる。情報の世界ではそうではない。盗聴は出来ないし、またそもそも普通のC#であればメッセージ自体が実装されていない。

情報の世界は捨象されているので、完全に自然を模倣することは出来ない。よくある「オブジェクト指向を動物で例えた説明」の違和感はこれだ。そもそもプログラムで犬や猫を作れるわけがない。

機械と人間は根本的に異なる。機械の本質は捨象だ。この根本的な違いのせいで、捨象されたメッセージという概念の理解が難しくなっている。これはプログラミングの難しさの本質でもある。メッセージという仕組みが今後使われるようになるかは分からないが、出来れば改善した方が良い課題の一つだろう。

3. レイヤ構造からは逃げられない

第三の問題として、オブジェクト指向はレイヤ構造を併用する必要がある。冒頭の「ノイマン型コンピュータ」のところで、情報における「レベル」の話をしたが、同一アプリケーションの中でもレベルが異なるパターンがある。

それは、ネットワークやデータ(ローカル)が設計に入る場合だ。目に見える部分と見えない部分のレイヤーは異なるといえる。

目に見える部分と見えない部分の処理を同じ処に書くのはアンチパターンの一つだ。サーバにデータを送る処理と、描画を行うスクリプトは分けなくてはならない。

よくLogin ControllerというコードにGUIの処理と、データ部分の処理をまとめて書いている実装を見かける。これは良くない。目に見える部分とデータ部分(=見えない部分)はレイヤが違うので、階層的な配置を上手く考える必要がある。

下図も完璧な例ではないのだが、Unity(C#)において、データ+通信のロジックと、目に見える部分の処理を分離するという意味では良いやり方だ。
image.png
目に見える「View」の部分と、ローカルのメモリやストレージ上のデータを扱う「Database」の部分、サーバと通信を行う「System」レベルで明確に実装が分かれている。

最初に、ネットワーク上をやり取りされるデータもモノだという話をした。実はデータも構造体(クラス)で定義しておくと後々楽なことが多い。私が過去に関わったスマホアプリのプロジェクトでも、もれなくデータはクラス化されていた。

ネットワーク周りも、出来ればこのように各オブジェクトに勝手に通信させるのではなく

このように、ネットワークオブジェクトを通じて情報を送るのが良いだろう。

NOはネットワークオブジェクトの略だ。通信に使われるソケットと回線は通常1組だからだ。

若干脱線してしまったが、大事なのはレイヤを分けることだ。

複雑で大規模なアプリケーションはレイヤを積み上げることで作られる。奇跡的にたまたま上手く積み上げられても、そのようなアプリは保守段階で瓦解するだろう。とくにデータとネットワークが絡む場合は、レイヤ構造を意識しないとならないケースが多い。

MVCとかMVPという言葉を聞いたことはないだろうか。それはこの「レイヤアーキテクチャ」の話だ。その果てにはMVVMとかクリーンアーキテクチャまで様々な概念が存在する。オブジェクト指向はこれらのレイヤアーキテクチャと組み合わせる必要がある17

オブジェクト指向を学んで終わり、ではない。ソフトウェア開発は考えることが多い。これが第三の課題だ。

まとめ

「オブジェクト」とはあらゆるモノのことだ。3Dの物体だけでなく、GUI1つ1つ・キャラクタやドアなどの物体、果てにはオブジェクト同士で受け渡しされるデータやファイルまでもがオブジェクトといえる。オブジェクト同士の相互作用は重要だ。

オブジェクト指向で作られたプログラムは、再利用性が極めて高く、変更にとても強い代わりに、捨象のレベルがあまりに高く理解するときにつまづきやすいポイントがある。複雑なアプリケーションにはオブジェクト指向以外の考え方も組み込んで設計する必要がある。

オブジェクト指向には色々な側面がある。「コードを使い回すための考え方」「カプセル化したものに名前を付ける考え方」「究極のモジュール化」これらはどれも正しく、見る角度が違うだけといえる。


ご指摘・ご質問はコメント欄に頂けますと幸いです。

  1. 実際の動機は goto を無くそうというところや、ロジックの抽象化というところにあったようだが。

  2. 人間、誰しも心に内なるひろゆきを秘めているものだが、「それってfor文でも書けますよね」などと言うのは野暮である。

  3. 高名な学者さんが作られたので「あほらしい」などと言ってはならない。

  4. 役に立つ度合いは定量的でないので注意

  5. 『Unityで始めるゲーム作り』のサンプルプログラム

  6. ネタバレとはけしからん

  7. UnityだとGameObjectを作るだけでモノが出来てしまうために混乱しやすいが、GameObjectは名前通りObjectの一種だ。「一種だ」というのは、オブジェクトにはヒエラルキー上に実体がなくてもいいものがあるため。

  8. 他の問題も色々。そもそもこれはMVCモデルならModelにあたり、Controllerではない。
    staticで良いものをわざわざdynamicに記述している点にも課題は残る(こちらはMonoBehaviourの仕様のせいなので仕方ないといえば仕方ない)。別レイヤの処理もまとめて書いている点も中々にクレイジーかもしれない。

  9. コメントでご指摘いただきました @shiracamusさんありがとうございます。

  10. 正しくはインターフェイス

  11. 『オブジェクト指向と10年戦ってわかったこと(Qiita)』から引用 2

  12. 『カプセル化=データの変更制限ではない(オブジェクト指向プログラミングを極める)』から引用

  13. プログラミングの法則の1つ。ほかには
    「DRYの原則」
    「YAGNIの原則」
    「PIEの原則」
    「コマンドとクエリの分離原則」
    「求めるな、命じよ」
    「最小知識の原則」
    等がある。

  14. ここで言う「処理してもらう」とはオブジェクト指向における「委譲(Delegation)」とかいう難しい話ではなく、"単に処理を投げつけておけば楽" というレベルの話である。C#における delegateLambda 式に取って代られており、その議論も旧時代のものとなりつつある。

  15. 原義((PDF)On Understanding Types, Data Abstraction, and Polymorphism)にはポリモーフィズムは4つの要素だと書かれている。

  16. ドメイン駆動設計とは何なのか? ユーザーの業務知識をコードで表現する開発手法について|CodeZine」より

  17. 【プログラミング】MVC,MVPを理解するためのレイヤーアーキテクチャ」を参照

75
78
25

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
75
78