C#
オブジェクト指向
基礎
プログラミング教育

オブジェクト指向をより理解するために実際に書いて解説する

はじめに

こんにちは、yoship1639です。
普段はゲームエンジンを用いない個人ゲーム開発を行っております。

本記事は、オブジェクト指向がいまいち理解できない人なんとなく理解はしたけどプログラムに落とし込めない人を対象に、
簡単なプログラムをオブジェクト指向満載で実際に書いて、理解を深めてもらおうと思い記述しています。

プログラミング言語は普段私がよく使うC#で記述しますが、オブジェクト指向は言語の壁を越えて利用できる概念ですので、
言語の記述ではなく、その記述の持つ意味を昇華させ俯瞰的に見ていただけると、より理解が進むのではないかと思います。

本記事の背景・目的

オブジェクト指向に関する記述はとても増えてきました。しかし、一部を除いては理解を妨げる解説をしている記事が多数見受けられます。
具体的な用語の説明をならべても、抽象化し過ぎた解説しても、プログラミングに落とし込むことはできません。

「継承」「カプセル化」「ポリモーフィズム」。オブジェクト指向の3大要素と言われていますが、そんなことより大事な事がオブジェクト指向にはあります。
「現実世界を正しく捉える」のがオブジェクト指向を言われていますが、抽象化し過ぎているし現実世界を正しく捉えなければオブジェクト指向を使う意味がないかと言われるとそんなこと全くありません、ですが現実正解を正しく捉える考え方をするとオブジェクト指向プログラミングが容易になるのは正しいと思います。なので7割不正解です。

大事なのは、オブジェクト指向のそもそもの利点をどれだけ理解したうえで、どれだけその利点を具体的にプログラミングに正しく落とし込めるかです。

オブジェクト指向の利点。いくつ具体的に挙げられた上でそれを落とし込めるか。それが出来なければハリボテと変わりません。
その利点に気が付くため、そして理解を深めるために、実際に書きながら解説しオブジェクト指向の素晴らしさに気が付いてもらいたいと思います。

オブジェクト指向のメリット

解説する前に、何のためにオブジェクト指向を用いるのかを簡単に説明します。
私は、オブジェクト指向を使うメリットは大きく4つに大別できると考えています。
それは、保守性、汎用性、拡張性、独立性の維持・確立です。
もっと簡潔に言うと、人が理解しやすく、あらゆる変更や追加に対して柔軟に対応できる様にするためです。
1つずつ簡単にメリットを説明します。

保守性

  • 既に記述されたコードのリファクタリングが容易になる
  • 必要なデータ・振る舞いのみを提供出来る

汎用性

  • 既に記述されたコードを修正することなく再利用が容易になる

拡張性

  • 既に記述されたコードを修正することなく上書き,書き足すことが容易になる

独立性

  • オブジェクト同士が互いに疎遠な関係となることで、保守、汎用化が容易になる
  • 修正や追加を加えた際に他オブジェクトへの影響を最小限に抑えることができる

これらのメリットを享受し、柔軟性を最大限高めることがオブジェクト指向を用いる決定的な理由です。
デザインパターンも、上記のメリットをより享受するためのシステムデザインを詳細に区分けしたものとなっています。
これはとても大事な概念ですので、しっかりと胸に刻み込んでください。

もう一度言います、オブジェクト指向を使うメリット、目的を間違えないでください。
カプセル化、継承、ポリモーフィズムは手段です。目的じゃありません。
保守性、汎用性、拡張性、独立性を確立させることが目的です。

メリットや機能についてはもっと詳細に解説するべきですが、既にとても良い解説をされている記事がありますので、詳細はそれを参考にしてください。

オブジェクト指向で書く

それでは、実際にオブジェクト指向プログラミングをしながら解説していきたいと思います。

書く前に絶対気を付けること

まず、一番初めに気を付けなければならない事があります。
それは、何を作るのか予めハッキリさせる事です。
なぜかというと、作るものを決めなければオブジェクト指向プログラミングが破綻する(柔軟性が喪失される)からです。

オブジェクト指向の基本は、オブジェクト同士がメッセージを送り合うことです。
何を作るのか不明であるという事は、オブジェクトがメッセージを送れない、もしくはオブジェクトが意味不明のメッセージを送る事と同じです。

もの凄くダメな例を紹介します。考えなしに後から継ぎ足していく・変更を加えていくパターンです。
例えば、何も考えずAさんとBさんの二人のクラスを作成したとします。

class A { }
class B { }

何を作るのかわからないけど、AさんとBさんは人間だから身長と体重を実装しておきます。

class A
{
    public float Height { get; set; }
    public float Weight { get; set; }
}
class B
{ 
    public float Height { get; set; }
    public float Weight { get; set; }    
}

後で、AさんがBさんに挨拶しなければならない事が判明しました。すると、AさんはBさんに「挨拶する」というメッセージを送らなければならないので、次のように実装しました。

class A
{
    public float Height { get; set; }
    public float Weight { get; set; }
}
class B
{ 
    public float Height { get; set; }
    public float Weight { get; set; } 

    public void Greeting(A person, string message)
    {
        // TODO: 挨拶の処理
    }   
}

さらに、Bさんは挨拶をし返さないといけない事が判明。すると、

class A
{
    public float Height { get; set; }
    public float Weight { get; set; }

    public void ReceiveGreeting(B person, string mes)
    {
        // TODO: 挨拶返し処理 
    }
}
class B
{ 
    public float Height { get; set; }
    public float Weight { get; set; } 
    public float Message { get; set; } 

    public void Greeting(A person, string mes)
    {
        // TODO: 挨拶処理
        person.ReceiveGreeting(this, Message);
    }   
}

BさんはAさんの挨拶に対して返す言葉を実装しなければならなくなり、新しくMessageを実装しました。
見た感じ、身長と体重がかぶっているので、それを抽出し抽象クラスにしようと思います。

abstract class Human
{
    public float Height { get; set; }
    public float Weight { get; set; }
}
class A : Human
{
    public void ReceiveGreeting(B person, string mes)
    {
        // TODO: 挨拶返し処理 
    }
}
class B : Human
{ 
    public float Message { get; set; } 

    public void Greeting(A person, string mes)
    {
        // TODO: 挨拶処理
        person.ReceiveGreeting(this, Message);
    }   
}

カオスになってきましたね。
もし、次に新たな新鋭Cさんが登場し挨拶しなければならなくなったらどうしますか?
もし、Aさんが実は軟体浮動生物で身長の概念が無かったらどうしますか?
もし、Bさんはのっぺら棒でしゃべれなかったとしたらどうしますか?

この作り方を続けると、見た目はオブジェクト指向っぽいですが、その恩恵はゼロであるといっても過言ではありません。
恩恵がない具体的な理由は以下です。

  • 使いもしない身長や体重を持つという保守性の欠如
  • AがBを知らないと挨拶ができないという汎用性・独立性の欠如
  • BがAを知らないと挨拶返しができないという汎用性・独立性の欠如
  • 新しい概念の導入に対する汎用性・拡張性の欠如

何を作るのかハッキリさせないで後から継ぎ足すというのは、上記を見てわかる通り、破滅をもたらすだけで、オブジェクト指向の恩恵どころか、ただの難解なプログラムに化けるだけとなってしまいます。

何度も言います、何を作るのかハッキリさせてからプログラミングしなければ、オブジェクト指向の恩恵を得られません
言い換えると、設計がとても重要であると言えます。

何を作るか決める

何を作るのかが大事である事を理解した所で、さっそく何を作るかを決めます。
簡単な例で解説しますので、今回は「タケシが持っている冷凍ギョウザを電子レンジに入れたら食べられるギョウザとなって出てくる」という例で解説したいと思います。

さて、何をしたいのかをはっきりさせると、どうすればいいのかが見えてきます。
タケシは冷凍餃子を持っていますね。
タケシは電子レンジを操作しますね。
電子レンジは冷凍ギョウザを受け取り、ギョウザにして返しますね。
ギョウザは冷凍されているかという状態を持ちますね。
上記のように、何を作るのか決めて初めて、オブジェクトがどんなデータを持ちどんな振る舞いをするのかが見えてきます。

それでは、コードに落とし込んでいきます。
これからコーディングするのは、オブジェクト指向の恩恵がほとんど得られないバージョンです。
これは、順に恩恵が得られるバージョンに変化させるために敢えてこの状態からスタートします。
くれぐれも、恩恵がほとんどないバージョンを正しいコーディングだと思わないでください。

恩恵がほとんどないバージョン

まず、登場人物を確認します。「タケシ」「電子レンジ」「冷凍ギョウザ」「ギョウザ」ですね。
まずは、純粋に登場人物をクラス化してみましょう。

class Takeshi { }
class Microwave { }
class Gyoza { }
class FrozenGyoza { }

しかし、冷凍ギョウザとギョウザの違いは、温められているかどうかだけです。なので

class Takeshi { }
class Microwave { }
class Gyoza
{
    public bool Frozen { get; set; }
}

この様にFrozenプロパティを実装し、冷凍されているかどうかを定義します。

次に、電子レンジは温める機能を持っているので、Heatメソッドを定義します。
Heatメソッドは、冷凍ギョウザをギョウザに変化させるので、

class Takeshi { }
class Microwave
{
    public Gyoza Heat(Gyoza gyoza)
    {
        gyoza.Frozen = false;
        return gyoza;
    }
}
class Gyoza
{
    public bool Frozen { get; set; }
}

Frozenをfalseにし、温められた状態にして返します。

タケシは冷凍ギョウザを持っているので、プロパティに追加します。
タケシは電子レンジを操作するので、メソッドとして実装します。

class Takeshi
{
    public Gyoza Gyoza { get; set; }
    public void UseMicrowave(Microwave microwave)
    {
        microwave.Heat(Gyoza);
    }
}
class Microwave
{
    public Gyoza Heat(Gyoza gyoza)
    {
        gyoza.Frozen = false;
        return gyoza;
    }
}
class Gyoza
{
    public bool Frozen { get; set; }
}

そして、これらを操作するメインプログラムを書きます。

class TakeshiAndGyoza
{
    static void Main(string[] args)
    {
        var takeshi = new Takeshi(); // タケシ
        var gyoza = new Gyoza() { Frozen = true }; // 冷凍餃子
        var microwave = new Microwave(); // 電子レンジ

        takeshi.Gyoza = gyoza; // ギョウザを持っている

        takeshi.UseMicrowave(microwave); // タケシが電子レンジを使う
    }
}

出来ました。「タケシが持っている冷凍ギョウザを電子レンジに入れたら食べられるギョウザとなって出てくる」プログラムです。
見た感じオブジェクト指向になっているので、問題なさそうに見えます。

さて、あなたは、このプログラムに100点満点で何点つけますか?

恩恵がほとんどないバージョンの採点

私は5点をつけます。このプログラムで5点は良い方です。
言われた通りの機能を実装するという意味では100点です。ちゃんと実装されています。しかし、今後の展開を加味すると全くダメです。
オブジェクト指向のメリットを思い出してください。
保守性、汎用性、拡張性、独立性をこのプログラムが持っているのか、見ていきます。

まず、保守性です。
オブジェクト毎に分けられていて、リファクタリングする際にはそこまで問題なさそうです。ギョウザに温められた温度を付け加える場合でも、電子レンジに新機能を追加する場合でも、対応はできそうです。しかし、タケシのギョウザの状態を電子レンジ以外でも自由に変更できてしまいますね。なので5点付けます。

汎用性です。
全くありません。タケシではなく、カスミが電子レンジを使う場合は新たにカスミをタケシと同じ様に定義しなければなりません。ギョウザではなく肉まんだった場合電子レンジとタケシの記述を変更しなければなりません。電子レンジで温められるのはギョウザだけというのは如何なものでしょうか。0点です。

拡張性です。
一見ありそうに見えますが、ありません。肉まんに対応する場合タケシに肉まんプロパティを追加すればいいだけですが、ピザまんを追加する場合もシュウマイを追加する場合も同じく記述する羽目になります。タケシの手が持ちません。0点です。

独立性です。
全くありません。タケシはギョウザと電子レンジの存在を知らないと確立できません。電子レンジはギョウザを知らないと存在できません。互いが互いを知らないといけない状態になっています。唯一まともなのはギョウザだけです。しかし0点です。

柔軟性を持たないと更なる問題に直面します。

  • タケシでもカスミでもなくサトシだったら?オーキドだったら?
  • 電子レンジじゃなくオーブンレンジだったら?ガスコンロだったら?
  • 肉まん、ピザまん、シュウマイ、コロッケ、お魚、hogehoge、etc・・・?

何を作るのか決めただけでは、対象が変更される可能性に対応できません。

先ほどのプログラムは柔軟性がほとんどありません。なので、追加や修正に一苦労です。
なので、先ほどのプログラムの柔軟性を上げ、対象が変更される場合でも修正に柔軟に耐えうる形に改良します。

恩恵がそこそこあるバージョン

改良と言いつつ、0から記述します。手を加えるのも億劫なので。。。

まず、登場人物のおさらいです。「タケシ」「電子レンジ」「冷凍ギョウザ」「ギョウザ」です。
お題はこれです。「タケシが持っている冷凍ギョウザを電子レンジに入れたら食べられるギョウザとなって出てくる」

さて、前回の採点を踏まえたら、以下の予想ができました。
カスミだったら?サトシだったら?オーキドだったら?
オーブンレンジだったら?ガスコンロだったら?
肉まん、ピザまん、シュウマイ、コロッケ、お魚、hogehoge、etc・・・?

まず、タケシじゃなかった場合を考えます。カスミ、サトシが電子レンジを使う場合、タケシ、カスミ、サトシが共通して行うのは「電子レンジを使う」です。なので、「電子レンジを使う人」として抽象化させ、HumanUsingMicrowaveという抽象クラスを作成します。
名前付けはしっかりして下さい。抽象クラスの名前はHumanでいいでしょと思った人は要注意です。

// 電子レンジを使う人
abstract class HumanUsingMicrowave
{
    public Gyoza Gyoza { get; set; }
    public Gyoza UseMicrowave(Microwave microwave)
    {
        return microwave.Heat(Gyoza);
    }
}

しかし、これではオーブンレンジだったら?肉まんだったら?という問題に対応できていません。
なので、タケシの場合と同じ様に「電子レンジ」を「食べ物を温めるもの」に昇華させます。
ギョウザも同様に「ギョウザ」ではなく「食べ物」に昇華させます。

// 食べ物
abstract class Food
{
    public bool Frozen { get; set; }
}

// 食べ物を温めるもの
abstract class FoodHeater
{
    public Food Heat(Food food)
    {
        food.Frozen = false;
        return food;
    }
}

これらを加味すると、「電子レンジを使う人」は「食べ物を温めるものを使う人」となります。

// 食べ物を温めるものを使う人
abstract class HumanUsingFoodHeater
{
    public Food Food { get; set; }
    public Food UseFoodHeater(FoodHeater foodHeater)
    {
        return foodHeater.Heat(Food);
    }
}

これで、「タケシが持っている冷凍ギョウザを電子レンジに入れたら食べられるギョウザとなって出てくる」は
食べ物を温めるものを使う人が持っている食べ物を食べ物を温めるものに入れたら食べられる食べ物となって出てくる」に昇華しました。言っていることは無茶苦茶ですが、オブジェクト指向的にはウェルカムなお題です。

抽象クラスを定義したところで、実際にタケシ、電子レンジ、ギョウザを定義します。

class Takeshi : HumanUsingFoodHeater { }
class Microwave : FoodHeater { }
class Gyoza : FreezableFood { }

これで、タケシがカスミになっても、ギョウザが肉まんになっても、サブクラスで定義すれば良いだけになりました。

メインプログラムを記述します。

class TakeshiAndGyoza
{
    static void Main(string[] args)
    {
        var takeshi = new Takeshi(); // タケシ
        var gyoza = new Gyoza() { Frozen = true }; // 冷凍餃子
        var microwave = new Microwave(); // 電子レンジ

        takeshi.Food = gyoza; // ギョウザを持っている

        takeshi.UseFoodHeater(microwave); // タケシが電子レンジを使う
    }
}

メインプログラムは前回のプログラムとほとんど変わっていないように見えますが、抽象クラスをばっちり定義し人や食べ物の変化に柔軟に対応できる様になったので大丈夫でしょう。

さて、あなたは、このプログラムに100点満点で何点つけますか?

恩恵がそこそこあるバージョンの採点

私は30点をつけます。
例によって、オブジェクト指向のメリットである保守性、汎用性、拡張性、独立性をこのプログラムが持っているのか、見ていきます。

まず、保守性です。
前回同様、オブジェクト毎に分けられていて、リファクタリングする際にはそこまで問題なさそうです。食べ物に温められた温度を付け加える場合でも、電子レンジに新機能を追加する場合でも、対応はできそうです。しかし、食べ物の状態を電子レンジ以外でも自由に変更できる点は今だ改善されていません。更に「食べ物」という抽象クラスの名前も微妙です。ここは本来名は体を表すように「冷凍可能な食べ物」となっていなくては意味が通じません。なので5点付けます。

汎用性です。
実はあまりありません。カスミになっても肉まんになっても対応できそうなのに?と思うかもしれませんが、それはこのプログラム上だけの話。人や食べ物の変化に対応できることだけが汎用化ではありません。食べ物の数が変わったらどうするのか、「食べ物を温めるものを使う人」であるタケシを他のプログラムで一体どう扱えばいいのか、、、5点です。

拡張性です。
拡張する場合は容易そうです。既に抽象クラスを定義しているので、タケシだけに筋トレという機能を付けても他のオブジェクトへの影響はあまりなさそうです。15点です。

独立性です。
こちらも実はほとんどありません。抽象クラスというのは思った以上に独立性を確立するものであるとは言えません。「食べ物を温めるものを使う人」「食べ物を温めるもの」を知らないと扱えないのですから。さらにメインプログラムを見てみると「タケシ」「電子レンジ」「ギョウザ」という具体的に具象化されたオブジェクトが散見されています。しかし最初のプログラムよりかはましになってはいます。5点です。

この30点を低いと思う方もいれば高いと思う方もいると思います。自分の置かれている境遇や知識量によって評価は変わってくるので。
私は、まだ改良の余地があると知っているのでこの位の点数になっています。

今回のプログラムは柔軟であるとはまだ言えません。例えば次の追加項目があったらどうしますか?

  • タケシとサトシは筋トレができ、カスミは電子レンジを使えて、サトシは電子レンジを使えない

ここで、「食べ物を温めるものを使う人」である抽象クラスをスーパークラスにしたことを後悔します。
HumanUsingFoodHeaterをベースにサブクラスとしてサトシを定義したいところなのに、それが絶対にできないというジレンマを抱えなければならないのですから。

しょうがないからHumanUsingFoodHeaterのスーパークラスとしてHumanを定義し、サトシはそっちのサブクラスにする事で解決しよう。もしさらに追加されることがあったら、更に分岐させて解決しよう。。。そして、これを繰り返すうちに何がベースなのか、正しい機能分けが出来ているのか、分からなくなっていきます。そしていずれ現れるひし形継承、多重継承。これが抽象クラスしか扱えない場合の泣きの終着点です。

では、いったいどうすればいいのか。
先ほどのプログラムを更にオブジェクト指向の恩恵が受けられる様に改良していきます。

恩恵がけっこうあるバージョン?

0から書き直します。さて、抽象クラスでは改良、機能追加に限界があることがわかってきました。
度重なる変更や予想もできない変更点が現れた時に、苦肉の対応しかできないのです。

そこで登場するのが「インターフェース」です。オブジェクト指向の終着点と言っても過言ではないと思います。
インターフェースはオブジェクトが持つ機能を表します。
これが正しく扱えて、ようやくオブジェクト指向ユーザの仲間入りです。

登場人物のおさらいです。「タケシ」「電子レンジ」「冷凍ギョウザ」「ギョウザ」です。
お題はこれです。「タケシが持っている冷凍ギョウザを電子レンジに入れたら食べられるギョウザとなって出てくる」

先ほどのプログラムで、「タケシ」を「食べ物を温めるものを使う人」。「電子レンジ」を「食べ物を温めるもの」。「ギョウザ」を「食べ物」と抽象化させ抽象クラスとして考えました。

しかし、度重なる機能追加、修正に耐えられません。
ここで、抽象化された「食べ物を温めるものを使う人」から機能のみを抽出することにしました。
つまり「人間」と「食べ物を温めるものを使う」という機能に分けるという事です。

この「食べ物を温めるものを使う」がインターフェースです。コードにすると

interface IFoodHeaterUser
{
    Food UseFoodHeater(FoodHeater foodHeater);
}
abstract class Human
{
    public Food Food { get; set; }
}

ここで大事なこと、HumanにIFoodHeaterUserを実装させません。実装させるとHumanUsingFoodHeaterと同値となってしまいます。
ではどこで実装するのか。タケシです。タケシは「人間」であり「食べ物を温めるものを使う」機能を有します。

class Takeshi : Human, IFoodHeaterUser
{
    public Food UseFoodHeater(FoodHeater foodHeater) => foodHeater.Heat(Food);
}

ここで先ほどの追加項目「タケシとサトシは筋トレができ、カスミは電子レンジを使えて、サトシは電子レンジを使えない」を実装してみます。
筋トレができるというのは「筋トレする」という機能になりますね。これはIMuscleTrainerというインターフェースになります。

それぞれの特徴を並べるとこうなります。

Human IFoodHeaterUser IMuscleTrainer
Takeshi
Kasumi ×
Satoshi ×

この様な場合でもインタフェースを使えば即座に対応可能です。

interface IMuscleTrainer
{
    void MuscleTraining();
}
// 温め、筋トレ可能のタケシ
class Takeshi : Human, IFoodHeaterUser, IMuscleTrainer
{
    public Food UseFoodHeater(FoodHeater foodHeater) => foodHeater.Heat(Food);
    public void MuscleTraining() { // TODO: 筋トレ }
}
// 温め可能のカスミ
class Kasumi : Human, IFoodHeaterUser
{
    public Food UseFoodHeater(FoodHeater foodHeater) => foodHeater.Heat(Food);
}
// 筋トレ可能のサトシ
class Satoshi : Human, IMuscleTrainer
{
    public void MuscleTraining() { // TODO: 筋トレ }
}

もし抽象クラスで解決しようと考えた場合、継承だらけでカオスなことになるのは目に見えています。
インターフェースは柔軟性に非常に優れているのです。
もし、別の機能追加が来ても、インターフェースを実装すればいいだけなのですから。
もしタケシが筋トレ不可能になったら、IMuscleTrainerを除けばいいだけなのですから。

次に「電子レンジ」を考えます。「食べ物を温めるもの」として先ほどは捉えました。
これは「もの」と「食べ物を温める」機能に分けられます。しかし、「もの」はあまりにも漠然としていますしオブジェクトそのものを指します。なので、「もの」という抽象クラスは当然作らず、「食べ物を温める」というインターフェースのみを定義します。

interface IFoodHeater
{
    Food Heat(Food food);
}

class Microwave : IFoodHeater
{
    public Food Heat(Food food)
    {
        food.Frozen = false;
        return food;
    }
}

これで、不慮の追加修正が来ても即座に対応できそうです。

「ギョウザ」です。「食べ物」と定義しましたが「冷凍可能な食べ物」が意味的に正しいと先ほど指摘しました。
この「冷凍可能な食べ物」は「食べ物」と「冷凍可能」という機能に分けられます。

interface IFreezable
{
    bool Frozen { get; }
}
abstract class Food { }
class Gyoza : Food, IFreezable
{
    public bool Frozen { get; set; }
}

よくある間違いが、「冷凍可能な食べ物」もしくは「食べ物」をインターフェースで定義することです。このような間違いは次のように見るとどっちが正しいのかわかります。
「冷凍可能な食べ物」は「もの」なのか「機能」なのかです。「機能」だったらインターフェースです。
言うまでもなく、「もの」ですのでこれはインターフェースではありません。
「冷凍可能」は「機能」なのでインターフェースです。
ここがよく言われる「Is-a」「Has-a」の部分ですね。

ここで間違えるとどうなるかというと、オブジェクト1つは2つのオブジェクトであるという状態になります。説明するまでもなく理解不能ですね。

「ギョウザ」を修正したので、「食べ物を温める」も「冷凍可能を解凍」に修正します。

// 冷凍可能なものを解凍する機能
interface IThawFrozen
{
    IFreezable Heat(IFreezable freezable);
}
// 冷凍可能なものを解凍する機能をもつ電子レンジ
class Microwave : IThawFrozen
{
    public IFreezable Heat(IFreezable freezable)
    {
        freezable.Frozen = false;
        return freezable;
    }
}

まだ問題があります。冷凍可能なものの状態を電子レンジ以外で自由に変更可能である点です。これは、IFreezableはIThawFrozenでないと修正できない様にすれば良いような気がします。修正すると

interface IFreezable
{
    bool Frozen { get; }
    void Thaw(IThawFrozen thawFrozen);
}
abstract class Food { }
class Gyoza : Food, IFreezable
{
    public bool Frozen { get; internal set; }
    public void Thaw(IThawFrozen thawFrozen)
    {
        thawFrozen.Heat(this);
    }
}

FrozenのセッターをprivateにしてしまうとMicrowaveのHeatで変更不可になってしまうので同一アセンブリ内を修正可能にするためにinternalとして定義します。

これでOKと思いきや、MicrowaveのHeatでFrozenの状態を変更できません。それもそのはず、IFreezableにセッターは存在しないのです。だけど冷凍可能なものの状態を電子レンジ以外で自由に変更可能なのはどうすれば。。。

必殺技を使います。protected internal interfaceです。Microwaveの中でIFreezableを定義することでそれを実装するGyozaは自身のFrozenプロパティの値を自身とMicrowave以外のオブジェクトからアクセス不可となり明確なアクセス権をMicrowaveだけが持てる様になります。IFreezableがThawを持つ必要もなくなりすっきりしました。保守性も確保できています。

// 冷凍可能なものを解凍する機能
interface IThawFrozen
{
    IFreezable Heat(IFreezable freezable);
}
// 冷凍可能なものを解凍する機能をもつ電子レンジ
class Microwave : IThawFrozen
{
    public IFreezable Heat(IFreezable freezable)
    {
        freezable.Frozen = false;
        return freezable;
    }
    // 冷凍可能である機能
    protected internal interface IFreezable
    {
        bool Frozen { get; set; }
    }
}
abstract class Food { }
class Gyoza : Food, Microwave.IFreezable
{
    public bool Frozen { get; set; }
}

IFreezableに対してHeatするようになったのでタケシの実装も変わります。例外処理は割愛します。

class Takeshi : Human, IFoodHeaterUser
{
    public Food UseFoodHeater(FoodHeater foodHeater) => foodHeater.Heat((IFreezable)Food);
}

最後にメインプログラムですが、メインプログラムが「タケシ」「電子レンジ」「ギョウザ」の実態を知らなくてはならないのは如何なものかと思い立ち、Factoryパターンにて生成するという手法を取ることにしました。

abstract class AHumanFactory
{
    public abstract Human Create(string name);
} 
class HumanFactory : AHumanFactory
{
    private Dictionary<string, Type> humans = new Dictionary<string, Type>()
    {
        {"Takeshi", typeof(Takeshi)},
        {"Kasumi", typeof(Kasumi)},
        {"Satoshi", typeof(Satoshi)},
    };
    public override Human Create(string name)
    {
        Type type = null;
        if (humans.TryGetValue(name, out type)) 
            return (Human)Activator.CreateInstance(type);
        return null;
    }
}

abstract class AFoodHeaterFactory { } // 割愛
abstract class AFoodFactory { } // 割愛

Factoryを作ったので、メインプログラムを書きます。ファクトリからオブジェクトを生成することでポリモーフィズムを実現しよりカプセル化がなされました。実態が隠されまくっているので一安心です。

class TakeshiAndGyoza
{
    static void Main(string[] args)
    {
        AHumanFactory humanFactory = new HumanFactory();
        AFoodHeaterFactory foodHeaterFactory = new FoodHeaterFactory();
        AFoodFactory foodFactory = new FoodFactory();

        var human = humanFactory.Create("Takeshi");
        var food = foodFactory.Create("Gyoza");
        var foodHeater = foodHeaterFactory.Create("Microwave");

        human.Food = food;

        if (human is IFoodHeaterUser)
        {
            (IFoodHeaterUser)human.UseFoodHeater(foodHeater);
        }
    }
}

さあ、オブジェクト指向満載で書き上げました。あらゆるパターンにも対応させるために色んな技を駆使したプログラムです。

さて、あなたは、このプログラムに100点満点で何点つけますか?

恩恵がけっこうあるバージョン?の採点

採点はしません。
どこから理解するのをやめましたか?
何かがおかしいと思いませんか?

オブジェクト指向のメリットは柔軟性を最大限持たせられる事です。それに則したプログラムになっているはずです。
なんでこんなに面倒くさいのかと思いませんでしたか?
高々「タケシが持っている冷凍ギョウザを電子レンジに入れたら食べられるギョウザとなって出てくる」プログラムを書きたかっただけなのに。こんな労力を使わないといけないのかと思いませんでしたか?

これがオブジェクト指向のデメリットです。
ありとあらゆる方面から変更に耐えうる予測を立て考え、試行錯誤し導いた結果の対価に釣り合わないものが出来上がっているのです。
小規模プログラムにおいて柔軟性の最大化を求めると、その労力に見合わない対価しか得られません。
このプログラムは、その規模に対しオブジェクト指向が行き過ぎてしまったようです。

ちなみに、敢えて途中から理解しにくい説明にしました。
後、プログラム色々間違えている箇所がありますが気にしないでください。

結局オブジェクト指向にメリットはあるのか

あります。ただし、
幾度となく修正や変更が重なる事が予め予想される中~大規模プログラムを書く時に最も享受できます。
それらは一晩で出来上がるような代物ではないため、地道に基盤を築いていくためにもオブジェクト指向プログラミングは大変有効です。

比較的小規模である。追加や変更の予定がない。使い捨ての予定である。この場合はメリットはあまりありません。
勉強にはなりますが、労力に見合った成果物は得られません。

そして、行き過ぎたオブジェクト指向プログラミングは禁物です。時間がかかるだけで逆にリファクタリングし辛く柔軟性を失う可能性もあります。
常に注意しなくてはいけないのは、柔軟性に富んでかつ無駄に時間が奪われないプログラミングをするためにどこまでオブジェクト指向を突き詰めるかを意識し続けることです。

今回の例の場合は、私だったらタケシが筋トレ機能を~あたりを実装できたところで突き詰めるのをやめると思います。それ以上は労力に見合った対価を得られるとは思わなかったので。

慣れてくると、どういうインターフェースを予め定義すれば良いのか直ぐにわかるようになり、そのインターフェースを定義しコーディングしていくと面倒な場面に出くわすのかどうかも直感でわかるようになります。抽象クラスについてもデザインパターンについても同様です。

これが出来て初めて、オブジェクト指向の恩恵がより得られる様になるのではないかと思います。

オブジェクト指向のメリットを享受できるアプローチ

じゃあ結局どうすればいいのかという話になります。
これは環境によって変わりますが、個人開発がメインで設計書を具体的に作成しない私の場合はこうします。

  1. 何を作成したいのか決める
  2. 作成するものから明確にオブジェクト分けする
  3. オブジェクトを抽象化してみる
  4. 抽象化したものから機能を抽出する
  5. 機能をインターフェースにする
  6. 変更が多い個所と分かっている場合はインターフェースのままにする
  7. 変更されない、極端に少ないと分かっている場合は抽象クラス、クラスにする
  8. オブジェクトの依存度(独立性)を考える
  9. 依存度が多そうな場合はデザインパターンから回避法を適用する

上記は一例ですが、普遍的なので私はよくこのやり方をコーディング前にしています。
頭の中で考えるのがとても難しい場合は書きながら修正を加えていきます。

このアプローチを適当にお題を決めて具体的に見てみます。

  1. タケシがトースターで焼くパンをサトシに与える
  2. タケシ、トースター、パン、サトシに分ける
  3. タケシとサトシは人間、トースターは食べ物を焼くもの、パンは食べ物に抽象化
  4. トースターは食べ物を焼くという機能がある
  5. 食べ物を焼く→IFoodBakerにインターフェース化
  6. トースターに機能を付け加えると判明している場合はIFoodBakerのまま
  7. 機能を付け加えないと分かっている場合はFoodBakerという抽象クラス、もしくはToasterというクラスのまま
  8. タケシはパンとトースターに依存、トースターはパンに依存、サトシはパンに依存、パンは依存なし
  9. タケシの依存が多いため、人間が食べ物を焼くものを使うという振る舞いをVisiterパターンに任せる

みたいな感じです。結構使えるのではないかと思います。
ちなみにこのアプローチが絶対に正しいとは言い切れません。刻々と状況は変わるので。

まとめ

デメリットもいろいろ述べましたが、それでもメリットの方が大きいのは確かです。
なので、ぜひともオブジェクト指向にチャレンジして欲しいです。

後、これだけは言えると思います。
オブジェクト指向プログラミングに正解はありません。
長年オブジェクト指向プログラミングをしていますが、こうするのが一番の正解というのはないです。
なぜなら、どこまで修正や追加がされるのかは予め明確に知ることができないからです。明確にわからないから、どこまで突き詰めればいいのかも明確にはわかりません。
時と場合にもよりますし、環境にもよります。

ですので、最後に書いたプログラムも、分からないという人もいれば、分かりやすいという人もいれば、まだ足りないという人もれば、全然だめという人もいるはずです。正解は明確にはわからないので。

しかし、先ほど述べたようにアプローチ、近道はあると思います。どれだけ知識を持っていて予測がどこまでできるかが勝負です。これは長年の経験でようやく得られます。練習するしかないと思います。

長文にお付き合いいただきありがとうございました。