なぜ、オブジェクト指向が生まれた理由の理解が重要なのか

この記事では、当社の新人エンジニア研修の参考にC#を解説します。

前回は「文字列を使いこなそう」について学びました。Stringクラスの機能を利用して様々な文字列操作ができましたね。

文字列操作でオブジェクトを使うことに慣れ親しんでいただいたところで、今回は、改めてオブジェクト指向が生まれた背景をお伝えし、どういう思想で作られたパラダイムなのかを理解していただこうと思います。他のプログラミングパラダイムと比べることで、オブジェクト指向のメリットデメリットをご一緒に考えていきましょう。

オブジェクト指向は初心者には理解が難しいと言われています。しかし、生まれた理由を知れば、オブジェクト指向プログラミングが実際どういう考え方に基づいているのかの理解も進むはず、と筆者は考えます。そしてその知識は、オブジェクト指向のメリットを生かし、デメリットを抑えるスキルを身に付けることにつながっていくのではないでしょうか。

1. オブジェクト指向以前のプログラミングパラダイム

プログラミングパラダイムとは

まず最初に、プログラミングパラダイムってなに?というところから話を始めましょう。アプリケーションの二大要素が情報処理であることは、以前にもお伝えしましたね。ECサイトであれば、会員や商品の情報と、それらを使った会員登録やカート管理、決済などの処理が必要で、アプリケーションとは、情報と処理の塊のことですよ、というあの話です。

プログラミングパラダイムとは、その情報と処理の扱い方の違いを表現したもの、と言えるでしょう。ここからは、代表的なプログラミングパラダイムである、手続き指向型データ指向型オブジェクト指向型の順に、それぞれの情報処理の扱い方の違いについて解説していきましょう。

手続き指向型プログラミング

手続き指向型は、処理を主体にしたプログラミングです。この講義でもご紹介した条件分岐や繰り返し構文を駆使して、処理で必要な機能を実装することに主眼を置いたパラダイムです。「百聞は一見に如かず」じゃんけんゲームを題材に、実例を見ていただきましょう。

using System;
namespace Chap09;

// 手続き志向型のじゃんけんゲーム
internal class Janken01 {

    // 入力値定数
    private const int GUU = 0;
    private const int CHOKI = 1;
    private const int PAA = 2;
    private const int END = 3;

    // 勝ち負けを表す定数
    private const int WIN = 0;
    private const int LOSE = 1;
    private const int DRAW = 2;

    public static void Run() {

        // 勝負の結果
        int result = DRAW;

        // 乱数発生インスタンス生成
        Random rnd = new Random();

        do {
            Console.WriteLine("\n選んでください 0:グー 1:チョキ 2:パー 3:終了");

            // 入力値初期化
            int yourHand = END;

            // キーバードからの入力値を得る
            try {
                string inp = Console.ReadLine();

                // キー入力が無ければループ続行
                if (null == inp || inp.Length == 0) {
                    continue;
                }

                // 入力を整数値に変換(例外が発生する可能性がある)
                yourHand = int.Parse(inp);

            } catch (Exception ex) {
                // 不正な入力など例外が発生すればループ続行
                // 数値以外の入力の場合もループ続行
                continue;
            }

            // 入力値がENDの場合はゲーム終了
            if (END == yourHand) {
                break;

            // 入力値が範囲外の場合はもう一度入力を促す
            } else if (yourHand < GUU || PAA < yourHand) {
                continue;
            }

            // PCの手を乱数で決める(下の式で0 - 2の値になる)
            int pcHand = rnd.Next(3);

            // お互いの手を表示する
            Console.WriteLine($"あなたは「{getHandName(yourHand)}」を出しました");
            Console.WriteLine($"わたしは「{getHandName(pcHand)}」を出しました");

            // 勝負の結果 0:勝ち 1:負け 2:あいこ
            if (GUU == yourHand) {
                // プレイヤーがグーの場合
                if (GUU == pcHand) {
                    // pcがグーであいこ
                    result = DRAW;
                } else if (CHOKI == pcHand) {
                    // pcがチョキでプレーヤーの勝ち
                    result = WIN;
                } else {
                    // pcがパーでプレーヤーの負け
                    result = LOSE;
                }
            } else if (CHOKI == yourHand) {
                // プレイヤーがチョキの場合
                if (GUU == pcHand) {
                    // pcがグーでプレーヤーの負け
                    result = LOSE;
                } else if (CHOKI == pcHand) {
                    // pcがチョキであいこ
                    result = DRAW;
                } else {
                    // pcがパーでプレーヤーの勝ち
                    result = WIN;
                }
            } else {
                // プレイヤーがパーの場合
                if (GUU == pcHand) {
                    // pcがグーでプレーヤーの勝ち
                    result = WIN;
                } else if (CHOKI == pcHand) {
                    // pcがチョキでプレーヤーの負け
                    result = LOSE;
                } else {
                    // pcがパーであいこ
                    result = DRAW;
                }
            }

            // 勝負判定
            switch (result) {
                case WIN:
                    Console.WriteLine("あなたの勝ちです!");
                    break;
                case LOSE:
                    Console.WriteLine("わたしの勝ちです");
                    break;
                case DRAW:
                default:
                    Console.WriteLine("あいこです");
                    break;
            }
        } while (DRAW == result);

        Console.WriteLine("終わります");
    }

    // 手の名前を返す
    private static string getHandName(int hand) {
        switch (hand) {
        case GUU:
            return "グー";
        case CHOKI:
            return "チョキ";
        case PAA:
            return "パー";
        default:
            return "不明";
        }
    }
}

これが手続き指向型で書いたじゃんけんゲームです。たかがじゃんけんなのですが、こうしてプログラムにしようとすると、結構な行数になりますね。この講義では初出の構文を解説しておきます。

24行目では、乱数を作るためのインスタンスを生成しています。乱数とは、毎回違うランダムな数のことです。このインスタンスは乱数を疑似的に生成してくれます。処理の関係で完全にランダムな数にはなりませんが、この程度のゲームであれば問題はありません。

34行目では、コンソールに入力された一行分の文字列をstring inpに代入しています。続く処理では、何も入力がなかった場合や数値以外が入力された場合に、再度入力を促すようにループ処理を行っています。例外については後の章でまた、詳しく解説します。

プレーヤーの手が決まると、PCの手を乱数で決めて、勝負判定の処理を行っています。じゃんけんは手が三つあって、それぞれの勝ち負けも三種類ありますから、合計9つの条件判定が必要です。

この条件判定処理が冗長に見えるかもしれませんが、これが手続き指向型の長所でもあり、短所でもあります。条件判定を全てコーディングで行うので、プログラム自体は長くなりますが、どういう場合にどういう処理をするのかが目で追いやすく、処理の構造を理解しやすい造りになるのです。これが手続き指向型のメリットです。

デメリットは、もう皆さんおわかりと思いますが、ともかくコードが長くなることです。確かに読んでみれば処理を追いやすくはありますが、ここまで長いと読むのが大変ですよね。読むのが大変だということは、改修や機能追加が大変だということです。これを、メンテナンス性が悪い、と言います。それでは次に、データ指向型のプログラムを見てみましょう。

データ指向型プログラミング

手続き指向型のデメリットを一部解消するために生まれたのがデータ指向型の考え方です。データ構造にロジックを持たせて処理を軽くできないか、というパラダイムです。データ指向型で書いたじゃんけんゲームをご覧ください。

using System;
using System.Runtime.CompilerServices;
namespace Chap09;

// データ志向型のじゃんけんゲーム
internal class Janken02 {

    // 入力値定数
    private const int GUU = 0;
    private const int CHOKI = 1;
    private const int PAA = 2;
    private const int END = 3;

    // 勝ち負けを表す定数
    private const int WIN = 0;
    private const int LOSE = 1;
    private const int DRAW = 2;

    // 手の名前
    private static readonly string[] HANDS = new string[] { "グー", "チョキ", "パー" };

    // 勝敗表、プレーヤーの手とPCの手を要素とした二次元配列
    private static readonly int[][] WINLOSS = new int[][] {
                                      /* PCグー チョキ パー */
        /* プレーヤー グー */    new int[] {DRAW, WIN, LOSE },
        /* プレーヤー チョキ */  new int[] {LOSE, DRAW, WIN},
        /* プレーヤー パー */    new int[] { WIN, LOSE, DRAW}
    };

    // 勝敗を知らせるメッセージ
    private static readonly string[] RESULT_MSG =
        new string[] { "あなたの勝ちです!", "わたしの勝ちです", "あいこです", "ゲームが中断されました" };

    public static void Run() {

        // 勝負の結果
        int result = DRAW;

        // 乱数発生インスタンス生成
        Random rnd = new Random();

        do {
            Console.WriteLine("\n選んでください 0:グー 1:チョキ 2:パー 3:終了");

            // 入力値初期化
            int yourHand = END;

            // キーバードからの入力値を得る
            try {
                string inp = Console.ReadLine();

                // キー入力が無ければループ続行
                if (null == inp || inp.Length == 0) {
                    continue;
                }

                // 入力を整数値に変換(例外が発生する可能性がある)
                yourHand = int.Parse(inp);

            } catch (Exception ex) {
                // 不正な入力など例外が発生すればループ続行
                // 数値以外の入力の場合もループ続行
                continue;
            }

            // 入力値がENDの場合はゲーム終了
            if (END == yourHand) {
                break;

                // 入力値が範囲外の場合はもう一度入力を促す
            } else if (yourHand < GUU || PAA < yourHand) {
                continue;
            }

            // PCの手を乱数で決める(下の式で0 - 2の値になる)
            int pcHand = rnd.Next(3);

            // お互いの手を表示する
            Console.WriteLine($"あなたは「{HANDS[yourHand]}」を出しました");
            Console.WriteLine($"わたしは「{HANDS[pcHand]}」を出しました");

            // 勝負の結果 0:勝ち 1:負け 2:あいこ
            result = WINLOSS[yourHand][pcHand];

            // 勝負判定
            Console.WriteLine(RESULT_MSG[result]);

        } while (DRAW == result);

        Console.WriteLine("終わります");
    }
}

いくつかの配列を作っただけで、ずいぶんプログラムが短くなってスッキリしましたね。これがデータ指向プログラミングのメリットです。配列などに意味のある並べ方でデータを格納し、それを利用することで、冗長になりがちな条件分岐の記述を、配列の値を読み出すインデックス(添え字)の表記に替えることができた、という例です。

上記のサンプルでは、プレーヤの手とPCの手をインデックスにしてWINLOSS配列を参照するだけで、全く条件分岐構文を使わずに、勝敗(又はあいこ)が取得できています。良さそうなパラダイムですよね。でも、データ指向型にもデメリットはあるのです。

データ指向型プログラミングのデメリットは、プログラムとは別の場所に定義するデータに意味を持たせるために、処理を追いにくくなることです。上の例でも、WINLOSSの二次元配列の意味を、パッと見で瞬時に理解できるでしょうか。コードが楽になる代わりに、プログラムに記述していたロジックが別の場所に定義してあるデータに移動することが原因で、さらに理解しづらくなってしまう、というのがデータ指向型のデメリットです。

長年のプログラマーの悩み ~手続き指向型とデータ指向型共通の問題点~

次のサンプルをご紹介する前に、オブジェクト指向以前のパラダイムで仕事をしていた技術者たちの共通の悩みについても触れておきます。それは、以下の二点です。両方とも手続き指向型データ指向型、どちらにもある共通の問題点です。

  1. スパゲティコード ~ プログラムの部品が複雑に別の部品に絡まっており、新しいプロジェクトで再利用がしづらい
  2. 処理の重複と分散 ~ 同じような処理なのに内容が少し違うだけで、複数個所に何度も書かなければならない

1.は、いわゆるスパゲティコードと呼ばれる問題です。規模が大きく、処理が複雑なアプリケーションになればなるほど、この問題は深刻になっていきます。一つのプログラムファイルで実現できる機能では、当然全てのやりたいことを実装することなどできません。処理をブレイクダウンしてたくさんのプログラムを書くうちに、機能同士が密接に依存しあい、さらにはデータ構造の複雑さもあいまって、簡単に他のプロジェクトでは使えないプログラムばかりになってしまうのです。

「前のプロジェクトで作ったあのプログラムがこっちでも使えればなぁ…だいぶ時間が稼げるはずなのに」という問題は、キャリアが長いプログラマーの誰もが持っている悩みの一つです。

2.は、処理の重複と分散の問題です。主に設計ミスや、お客様のわがまま(?)によって開発途中でアプリケーションの仕様が変更になったりすることが原因で引き起こされる、こちらも大変やっかいな悩みです。

オブジェクト指向以前のパラダイムでは、似たような処理を少しだけ内容を変えて実行する方法が、条件分岐を使うか、データ構造に工夫するかの二つしかありません。既に書かれたメソッドの呼び出し順序を変更もできませんし、呼び出し元が違えば、ほとんど同じ処理なのに新しいプログラム部品を作らなければならなくなることもざらにあります。

=========あるプログラムファイル=========
public static void RegisterProduct(Product pr) {
    // 商品の登録処理
}

=========別のプログラムファイル=========
public static void RegisterAnotherProduct(AnotherProduct apr) {
    // 期間限定のキャンペーン商品のため、ちょっとだけ違う商品のちょっとだけ違う登録処理
}

そうすると、ある処理にバグが見つかった、又は機能追加が必要になった時、必ず忘れずに全ての「似たような処理」を直さなければならなくなります。又は、複数ある「似たような処理」のうち、どれを改修すべきでどれは触ってはいけないのかを、いちいち判断しなければならなくなります。

あるバグを直したら、それが原因で別のバグが出る、という現象を「デグレード」(略してデグレ)と呼ぶのですが、この重複と分散の問題は、デグレの温床になるであろうことが、容易に想像がつきますね。

このような2つの問題を内包したアプリケーションは今でもたくさん存在していて、しかも実際に稼働もしています。古いバージョンの言語で数十年前に開発されたアプリの障害対応や、機能追加のプロジェクトが現在も世界中で進行中なのです。

この手のプロジェクトにアサインされると、まずは設計書や仕様書を頼りにどこでどんな処理をしているのかを探るところから始めなければなりません。この作業を解析と言います。ドキュメントがあればまだ良い方で、設計書や仕様書自体が存在しない場合も多々あるのです。そうなれば完全に手探りでアプリケーションの構造を一から解析する羽目になります。

何時間もかかってある処理を追いかけていたら、結局使われていない消し忘れの処理だった、などという、どこに鬱憤をぶつけてよいかわからなくなるような悲惨な例を、筆者も何度も体験しています。

オブジェクト指向というパラダイムは、手続き指向型データ指向型のデメリットを解消するだけでなく、このような長年のプログラマーの悩みをなんとか解消できないか、という目的で作られたのです。

オブジェクト指向型プログラミング

それでは解説は後回しにして、じゃんけんゲームをオブジェクト指向で作るとどうなるかをご覧ください。次の3つのファイルを作成ましょう。

using System;
namespace Chap09;

// じゃんけんの手のインスタンスを生成するクラス
internal class Hand {

    // 手の名前
    public string Name { get; set; }

    // 勝敗表(プレーヤーの手から見たPCの手に対応する勝敗の配列)
    private int[] winLoss;

    // コンストラクタ
    // 引数: String name 手の名前
    // 引数: int[] winLoss 勝敗表
    public Hand(String name, int[] winLoss) {
        // 引数を自身のフィールドに代入する
        this.Name = name;
        this.winLoss = winLoss;
    }


    // 勝敗を得る
    // 引数: int pcHand プレーヤーの手のコード
    // 戻り値: int 勝敗

    public int getWinLoss(int pcHand) {
        return winLoss[pcHand];
    }
}

using System;
using Chap09;

namespace Chap09;

// じゃんけんゲームの本体を保持するクラス
// 全メソッドはstaticで実装する
// インスタンスを生成する必要がないため、コンストラクタはprivate宣言する
internal class JankenGame {

    // 入力値定数
    public const int GUU = 0;
    public const int CHOKI = 1;
    public const int PAA = 2;
    public const int END = 3;
    public const int OUT_OF_RANGE = 4;

    // 勝ち負けを表す定数
    public const int WIN = 0;
    public const int LOSE = 1;
    public const int DRAW = 2;

    // 手の名前
    private static readonly string[] HANDS = new string[] { "グー", "チョキ", "パー" };

    // 勝敗表、プレーヤーの手とPCの手を要素とした二次元配列
    private static readonly int[][] WINLOSS = new int[][] {
                                      /* PCグー チョキ パー */
        /* プレーヤー グー */    new int[] {DRAW, WIN, LOSE },
        /* プレーヤー チョキ */  new int[] {LOSE, DRAW, WIN},
        /* プレーヤー パー */    new int[] { WIN, LOSE, DRAW}
    };

    // 勝敗を知らせるメッセージ
    private static readonly string[] RESULT_MSG =
        new string[] { "あなたの勝ちです!", "わたしの勝ちです", "あいこです", "ゲームが中断されました" };

    // 手のインスタンスの配列
    private static Hand[] hands = {
        new Hand(HANDS[GUU], WINLOSS[GUU]),
        new Hand(HANDS[CHOKI], WINLOSS[CHOKI]),
        new Hand(HANDS[PAA], WINLOSS[PAA])
    };

    // インスタンス生成を禁止するコンストラクタ
    private JankenGame() {}

    // じゃんけんゲーム本体
    // 一回のじゃんけんを実行する
    public static int Do() {

        // 乱数発生インスタンス生成
        Random rnd = new Random();

        Console.WriteLine("\n選んでください 0:グー 1:チョキ 2:パー 3:終了");

        // 入力値初期化
        int yourHand = END;

        // キーバードからの入力値を得る
        try {
            string inp = Console.ReadLine();

            // キー入力が無ければループ続行
            if (null == inp || inp.Length == 0) {
            return OUT_OF_RANGE;
            }

            // 入力を整数値に変換(例外が発生する可能性がある)
            yourHand = int.Parse(inp);

            } catch (Exception ex) {
            // 不正な入力など例外が発生すればループ続行
            // 数値以外の入力の場合もループ続行
            return OUT_OF_RANGE;
        }

        // 入力値がENDの場合はゲーム終了
        if (END == yourHand) {
            return END;

            // 入力値が範囲外の場合はもう一度入力を促す
        } else if (yourHand < GUU || PAA < yourHand) {
            return OUT_OF_RANGE;
        }

        // PCの手を乱数で決める(下の式で0 - 2の値になる)
        int pcHand = rnd.Next(3);

        // お互いの手を表示する
        Console.WriteLine($"あなたは「{hands[yourHand].Name}」を出しました");
        Console.WriteLine($"わたしは「{hands[pcHand].Name}」を出しました");

        // 勝負の結果 0:勝ち 1:負け 2:あいこ
        return hands[yourHand].getWinLoss(pcHand);
    }

    // 勝敗のメッセージを返す
    public static String getResultMsg(int result) {
        return RESULT_MSG[result];
    }
}

using System;
using Chap09;

namespace Chap09;
internal class Janken03 {
    public static void Run() {
        // ゲーム開始メッセージ出力
        Console.WriteLine("じゃんけんゲームです!\n");

        // 結果を初期化(勝ち、負け、あいこ、無効な入力の4種類の値が入る)
        int result = 0;

            // 勝敗が決まるか、中断までループ
            do {

                // ゲーム本体を呼び出す
                result = JankenGame.Do();

                // あいこの場合、メッセージを出力
                if (JankenGame.DRAW == result) {
                    Console.WriteLine("あいこです");
                }

                // あいこ又は無効な入力の場合は勝負続行
            } while (JankenGame.DRAW == result || JankenGame.OUT_OF_RANGE == result);

        // 勝敗を出力
        Console.WriteLine(JankenGame.getResultMsg(result));

        // 終了メッセージ出力
        Console.WriteLine("終わります");
    }
}

なぜファイルが三つに分かれたのか、それぞれのファイルにどう機能を役割分担したのかを、よく考えながら解説を読んでください。以下が、皆さんに注目していただきたいポイントです。

  1. 情報と処理の合体
  2. クラスの役割分担 ~ 手のクラス、ゲーム本体、実行のエントリポイントの3つクラスに役割を分担した
  3. クラスの責務 ~ じゃんけんの手は自分の名前とどの手に勝ってどの手に負けるのかを知っているべき
  4. クラスの責務を考える上での目線の統一 視点が一貫している
  5. ルールやメッセージを集中管理
  6. エントリーポイントがシンプル
  7. インスタンスとスタティックの使い分け

実験

フランスのじゃんけんには、グー、チョキ、パーの他に「ピュイ」(井戸)という手があります。手続き指向型、データ指向型、オブジェクト指向型のじゃんけんゲームに、ピュイの手を追加してみてください。

各パラダイムの良い点悪い点が、改修や機能追加のやり方の違いからよく理解できると思います。

Objectとは「モノ」のことでした。

例えば、スマートフォンを考えてみましょう。スマートフォンには、メーカー、OS、バッテリー容量などの属性(データ)があり、通話する、アプリを開くといった動作(メソッド)を持っています。このように、データと動作をセットにしたものがオブジェクトです。

この記事の1回目でお話ししたように、オブジェクト指向とは他の工業製品と同じように部品を組み合わせることでプログラム全体を作り上げるという考え方でした。

例えば、新人エンジニアの皆さんがお使いのパソコンも多くの部品から構成されています。私たちは、それぞれの部品の内部構造を知らなくても、それらを組み合わせて使うことができます。大多数の人は“CPU”と“メモリ”の区別も曖昧なままスマホやパソコンを使っています。それでもスマホとパソコンをつないでデータを交換したりインターネットを見たりすることはできます。また、それぞれの部品単位でアップデートすることもできます。例えば、古くなったハードディスクを新しいものに交換する、などといったことができます。

この考え方をプログラムに応用して、再利用性やメンテナンス性の高いプログラム部品でシステムを組むのがオブジェクト指向です。

つまり、オブジェクトはプログラムの部品です。そして、オブジェクトの設計図がクラスです。クラスには、オブジェクトに共通する属性(情報や機能)を定義します。この設計図をもとに作成した実際の部品がインスタンスです。

人によって言葉の使い方に多少のずれがありますが、この研修では、

オブジェクト(抽象概念) = クラス(設計図) + インスタンス(具体的なもの)

という言葉で使い分けています。

単にオブジェクトといった場合には、それがクラスのことを指すこともあれば、インスタンスのことを指すこともあります。ですから本書ではオブジェクトという言葉の使用は極力避けて、クラスとインスタンスという言葉を使うようにしています。

繰り返しになりますが、オブジェクト指向とは、プログラムを「部品の組み合わせ」として設計し、再利用性やメンテナンス性を高める考え方です。

  1. オブジェクト: データとその振る舞いをまとめたもの(抽象概念)。
  2. クラス: オブジェクトの設計図。
  3. インスタンス: クラスを元に実際に作成されたオブジェクト。

例えば、今現在あなたが見ているこの画面も、ウインドウというインスタンスの上にメニューというインスタンスが乗っていて、クリックの情報を受け取るインスタンスがある、という風になっているというと少しはイメージの助けになりますでしょうか?

論より証拠、以下のプログラムを実行していただくとお分かりただけると思います。

namespace Chap09;
using System;
using System.Windows.Forms;

class Example00 {
    [STAThread]
    static void Main() {
        Form form = new Form();
        form.Width = 1000;
        form.Height = 500;
        Application.Run(form);
    }
}

例えばウインドウサイズを変更したいとき、どこをいじれば良いか分かりますか?

これは比喩的な表現ですが、パソコンクラスというものを作ったとして、あなたや隣の人の机に乗っている具体的なパソコンがインスタンスです。新入社員クラスがあったとして、あなたやあなたの隣の人がそのインスタンスです。

2. フィールド(インスタンス変数)を持ったクラスの定義

例えば新入社員の皆さんで下図の名簿を作ったとします。名簿の項目はクラスに当たります。そして一人一人の行はインスタンスです。

例:新人エンジニアクラス

話を簡単にするため、新人エンジニアクラスが「社員番号(id)」と「名前(name)」2つの情報だけを持つとします。C#のクラスであれば、以下のように定義できます。

新人エンジニア
クラスとインスタンスを名簿に例えると
namespace Chap09;

class NewEngineer1 {
    // フィールド(または「インスタンス変数」)
    public int Id;
    public String Name;
}
  1. int Id; は「社員番号」のインスタンス変数
  2. String Name; は「名前」のインスタンス変数

この時点ではメインメソッドがないので、実行はできません。「NewEngineer1クラス」を使ってテストしたいなら、別にエントリーポイント(Mainメソッド)を用意する必要があります。

インスタンスの生成とフィールドの使用

次は、クラスを使うクラスを作って試しましょう。

namespace Chap09;

public class Example01 {
    public static void run() {
        NewEngineer1 se1 = new NewEngineer1();
        Console.WriteLine(se1.Id);
        Console.WriteLine(se1.Name);
    }
}

  1. NewEngineer1 se1;
    • これは「NewEngineer1型の変数se1を宣言」している。まだ何も入っていない。
  2. se1 = new NewEngineer1();
    • new 演算子で「NewEngineer1クラスのインスタンス」をヒープ領域に生成し、その参照をse1に代入。
    • コンストラクタ NewEngineer1() が暗黙的に呼ばれ、インスタンスを初期化。
  3. Console.WriteLine(se1.Id);
    • フィールドIdの値を出力。まだ値を設定していないので、C#では既定値0が出力されます。(intの既定値が0)
  4. Console.WriteLine(se1.Name);
    • こちらは文字列型の既定値が null なので、何もない状態が出力されます。

<実行結果>

0
(空行またはnull)

C#でのフィールドの既定値は以下のように決まっています。

  1. boolfalse
  2. 数値型 (int, long, double etc.) → 0 (各型に応じた0)
  3. char'\0' (Unicode NUL)
  4. 参照型 (string, カスタムクラスなど) → null

フィールドに値を代入

namespace Chap09;

public class Example02 {
    public static void run() {
        NewEngineer1 se1 = new NewEngineer1();
        se1.Id = 1;
        se1.Name = "yamada";
        Console.WriteLine(se1.Id);
        Console.WriteLine(se1.Name);
    }
}

<実行結果>

1
yamada

ここで使っている IdName は、各インスタンスが固有に持つデータなので「インスタンス変数」とも呼びます。

3. メソッドを持ったクラスの定義

例:show()メソッドを追加

同じ新人エンジニアクラスにメソッドを持たせることもできます。次の NewEngineer2 は、Show()メソッドを定義して、一度にIdNameを表示します。

namespace Chap09;

class NewEngineer2 {
    public int Id;
    public string Name;

    // インスタンスメソッド
    public void Show() {
        Console.WriteLine($"私のIDは{Id}、名前は{Name}です。");
    }
}
namespace Chap09

public class Example03 {
    public static void run() {
        NewEngineer2 se2 = new NewEngineer2();
        se2.Id = 2;
        se2.Name = "tabuchi";
        se2.Show();
    }
}

<実行結果>

私のIDは2、名前はtabuchiです。
  1. Show() はいわゆるインスタンスメソッド。呼び出し方は 変数名.メソッド名()
  2. Show() 内では IdName を直接参照できる(同じインスタンス内なので)。

4. インスタンスを複数生成

namespace Chap09;

public class Example04 {
    public static void run() {
        NewEngineer2 yamazaki = new NewEngineer2();
        yamazaki.Id = 1;
        yamazaki.Name = "yamazaki";
        yamazaki.Show(); // 私のIDは1、名前はyamazakiです。

        NewEngineer2 imai = new NewEngineer2();
        imai.Id = 2;
        imai.Name = "imai";
        imai.Show(); // 私のIDは2、名前はimaiです。
    }
}

同じクラスNewEngineer2から複数インスタンスをnewで作って、各々のフィールドを設定できます。

ToString() メソッド呼び出し時の表示

C#でインスタンス変数を Console.WriteLine(se2) のように直接書くと、ToString() が呼ばれて文字列が返されます。未オーバーライドのままだと Namespace.ClassName やハッシュコードが出力されます。

Console.WriteLine(yamazaki);
// 例: Chap09.NewEngineer2

ToString()をオーバーライドすれば、任意の文字列を返すことができます。が、ここではまだ説明しません(継承ポリモーフィズムで詳説)。

5. 参照型とプリミティブ型

C#でも変数には値型参照型があります。

  • 値型: bool, int, doubleなど
    • 変数そのものがデータの実体を持つ
  • 参照型: string, 配列 (T[]), ユーザー定義クラス (class)、objectなど
    • 変数には実体(オブジェクト)へのポインタ/アドレス(参照)が入る

NewEngineer2 se2 = new NewEngineer2(); の場合、se2 には インスタンスそのものではなく「ヒープ上のインスタンスを指す参照」が格納されます。

新人エンジニア
C#の型の体系

1つのインスタンスを複数の変数で参照

NewEngineer2 se1 = new NewEngineer2();
se1.Id = 1;
se1.Name = "yamazaki";

NewEngineer2 se2 = se1; // 同じ参照をコピー
se2.Show();

se2 も同じオブジェクトを指すため、se2.Show() の結果は se1.Show() と全く同じ出力になります。

新人エンジニア
一つのインスタンスを2つの参照が指している状態

6. 参照型の配列

インスタンスを配列にまとめることもできます。

namespace Chap09;

public class Example05 {
    public static void run() {
        NewEngineer2[] seArray = new NewEngineer2[3];

        // インスタンスを生成して配列の各要素に代入
        seArray[0] = new NewEngineer2();
        seArray[1] = new NewEngineer2();
        seArray[2] = new NewEngineer2();

        seArray[0].Id = 1; seArray[0].Name = "tabuchi";
        seArray[1].Id = 2; seArray[1].Name = "shinohara";
        seArray[2].Id = 3; seArray[2].Name = "kokubun";

        foreach (NewEngineer2 e in seArray) {
            e.Show();
        }
    }
}

<実行結果>

私のIDは1、名前はtabuchiです。
私のIDは2、名前はshinoharaです。
私のIDは3、名前はkokubunです。

配列の要素は最初null

値型の配列と異なり、「new NewEngineer2[3]」 だけではインスタンスが生成されません。配列の各要素に参照を格納するため、seArray[i] = new NewEngineer2(); が必要です。

もしこれを忘れると、seArray[0]null のままなので、後で seArray[0].Name = "tabuchi"; のようにアクセスすると NullReferenceException が発生します。

7. NullReferenceException

C#で「存在しないオブジェクトへの参照」を使おうとすると、NullReferenceException が発生します。

NewEngineer2[] seArray = new NewEngineer2[3];
// まだ要素はnull
seArray[0].Id = 1;  // ← ここで NullReferenceException

エラー例:

Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.

seArray[0]null なのに seArray[0].Id とアクセスしようとしたためです。

null とは

  1. null は参照型変数が「どのオブジェクトも指していない」特別な状態
  2. null""(空文字) は別物
  3. null0"null" とも違う
string str = null;
Console.WriteLine(str == null);      // True
Console.WriteLine(str == "null");    // False
Console.WriteLine(str == "");        // False

C#のガーベージコレクションにより、参照されなくなったオブジェクトは自動的にメモリ解放されます(いつ解放されるかはランタイム依存)。

8. 参照による値渡しと値渡し

C#では、引数の受け渡しはすべて“値渡し” ですが、参照型の場合「参照(アドレス)の値」がコピーされて渡るため、結果的に同じオブジェクトを共有します。

例: 参照(オブジェクト)を引数に渡す

static void Show(NewEngineer1 se) {
    Console.WriteLine($"{se.Id}:{se.Name}");
}

public static void Main() {
    NewEngineer1 yamazaki = new NewEngineer1 { Id = 1, Name = "yamazaki" };
    Show(yamazaki);
}

Show(yamazaki)yamazaki 参照のコピーが渡り、Show内のseyamazakiが同じオブジェクトを指します。

メソッド内でオブジェクトを変更すると呼び出し元にも反映

static void Modify(NewEngineer1 se) {
    se.Name += "_san";
}

public static void Main() {
    NewEngineer1 yamazaki = new NewEngineer1 { Id = 1, Name = "yamazaki" };
    Modify(yamazaki);
    Console.WriteLine(yamazaki.Name); // yamazaki_san
}

呼び出し先メソッドで Nameを書き換えると、呼び出し元でも変更が見える。

プリミティブ(値型)の場合

static void PlusOne(int i) {
    i++;
    Console.WriteLine($"呼び出し先のi: {i}");
}

public static void Main() {
    int i = 10;
    Console.WriteLine($"呼び出し元のi: {i}");
    PlusOne(i);
    Console.WriteLine($"呼び出し元のi: {i}");
}

<実行結果>

呼び出し元のi: 10
呼び出し先のi: 11
呼び出し元のi: 10

値型ではメソッド呼び出し時に「値のコピー」が渡されるため、呼び出し先で変更しても呼び出し元に影響しません。

新人エンジニア
参照による値渡しと単なる値渡し




9. メソッドの戻り値に参照を使う

メソッドの戻り値にクラスのインスタンスを返すと、実質的に複数の値(フィールド)をまとめて返せます。

static NewEngineer2 Compare(NewEngineer2 se1, NewEngineer2 se2) {
    return (se1.Id > se2.Id) ? se1 : se2;
}

public static void Main() {
    NewEngineer2 e1 = new NewEngineer2 { Id = 8, Name = "shinohara" };
    NewEngineer2 e2 = new NewEngineer2 { Id = 2, Name = "imai" };
    NewEngineer2 bigger = Compare(e1, e2);
    bigger.Show();
}

ここで Compare メソッドは「idが大きいほうのインスタンス」を戻り値として返します。戻ってきたインスタンスを呼び出し元で再利用可能です。

10. コンストラクタでインスタンスの初期化

クラス名と同じ名前のメソッドが「コンストラクタ」です。new クラス名(...) で呼び出され、インスタンス生成時に一度だけ自動実行されます。

基本構文

class クラス名 {
// コンストラクタ
public クラス名(引数リスト) {
// インスタンスの初期化処理
}
}


コンストラクタには戻り値の型を付けません。(void も書きません)

例: NewEngineer3 クラス

namespace Chap09;

class NewEngineer3 {
    public int Id;
    public string Name;

    // コンストラクタ
    public NewEngineer3(int id, string name) {
        this.Id = id;
        this.Name = name;
    }
}

namespace Chap09;

public class Example06 {
    public static void run() {
        NewEngineer3 se = new NewEngineer3(3, "tabuchi");
        Console.WriteLine($"{se.Id} : {se.Name}");
    }
}
}

<実行結果>

3 : tabuchi

  1. this.Id = id;this は「このインスタンス自身」を指すキーワード。フィールドと引数の区別のためによく使われます。
  2. コンストラクタを定義しなかった場合、C#では「デフォルトコンストラクタ」が自動生成されます(引数なしでインスタンス生成可能になる)。

インスタンスを変数に代入する意味

もしも、インスタンスをヒープ領域に作って使い捨てにするのであれば、以下のように書けば実現できます。

new Sample("imai");

namespace P09;
using System;

class ConstructorTest1 {
    static void Main(string[] args) {
        new Sample("imai");
    }
}

namespace P09;
using System;

class Sample {
    private string name;

    public Sample(string name) {
        Console.WriteLine("コンストラクタが呼ばれてインスタンスが作られました。");
        this.name = name;
        Greet();
    }

    public void Greet() {
        Console.WriteLine("私の名前は:" + name);
    }
}

しかし、これではインスタンスを再利用できません。

再利用するために以下のように変数に入れるのです。

Sample sample = new Sample("imai");

namespace P09;
using System;

class ConstructorTest2 {
    static void run() {
        Sample sample = new Sample("imai");
        Console.WriteLine(); // 空行を出力
        sample.Greet();
    }
}

コンストラクタのオーバーロード

コンストラクタもメソッド同様にオーバーロードできます。引数の数や型が違うコンストラクタを複数定義可能です。

namespace P09;
using System;

class NewEngineer4 {
    public int Id;
    public string Name;

    // (1) 引数2つ
    public NewEngineer4(int id, string name) {
        this.Id = id;
        this.Name = name;
    }

    // (2) 引数なし → (1)のコンストラクタを呼び出す
    public NewEngineer4() {
		this(0, "名無し");
    }
}

namespace P09;
using System;

public class Example05 {
    public static void run() {
        NewEngineer4 se = new NewEngineer4(); // 引数なし
        Console.WriteLine($"{se.Id} : {se.Name}"); 
        // => 0 : 名無し
    }
}

: this(...) 構文を使ってコンストラクタの中で別のコンストラクタを呼び出せます。

11. インスタンス変数と static変数 の使い分け

  1. インスタンス変数 … 各インスタンスが固有に持つ情報(例: Id, Name
  2. static変数 (クラス変数) … クラス全体で共有したい情報(例: 全新人エンジニアの総数)
namespace P09;
using System;

class NewEngineer5 {
    public static int Count;
    public int Id;
    public string Name;

    public NewEngineer5(int id, string name) {
        this.Id = id;
        this.Name = name;
        Count++;
        Show();
    }

    public void Show() {
        Console.WriteLine($"{Id} : {Name} : {Count}人目です。");
    }
}


namespace P09;
using System;

public class Example06 {

    public static void run() {
        new NewEngineer5(4, "imai");
        new NewEngineer5(3, "shinohara");
        new NewEngineer5(2, "tabuchi");
        Console.WriteLine($"クラスの総人数: {NewEngineer6.Count}");
    }
}

<実行結果>

4 : imai : 1人目です。
3 : shinohara : 2人目です。
2 : tabuchi : 3人目です。
クラスの総人数: 3

Countstatic int なので、インスタンスごとに別々の値を持たない。「全体でただ1つ」

IdName は各インスタンス固有の情報

以下の「メモリの3つの領域のイメージ」も参考にして下さい。

新人エンジニア
メモリの3つの領域のイメージ

staticメソッドとインスタンスメソッドの使い分け

  1. インスタンスメソッド … 個々のインスタンスが持つ情報this)を操作したい場合。
  2. staticメソッド … インスタンスに依存しない処理(汎用的ユーティリティなど)。

C#の System.Math クラスはstaticメソッド (Math.Sin(...), Math.Cos(...) 等) を多く持ち、「インスタンスを生成するまでもない共通処理」を表現します。

また、Mathクラスのようにインスタンスを作ってほしくない場合は、private constructor でブロックします。

public static class MyUtility {
    // 静的クラスとするとインスタンス生成不可
    public static double MyStaticMethod() {
        ...
    }
}

C#では static class と書くと、そのクラスはすべて静的メンバーだけを持つようになります(インスタンス化禁止)。


<まとめ:隣の人に正しく説明できたらチェックを付けましょう>

□ オブジェクト指向は、再利用性やメンテナンス性の高い部品(クラス)でシステムを組む考え方である

□ オブジェクト(抽象概念) = クラス(設計図) + インスタンス(具体的なもの)

□ 個々のインスタンス固有の変数を「インスタンス変数」(またはフィールド)と呼ぶ

□ C#で使用できる変数の型には値型と参照型があり、参照型変数にはインスタンスへの参照が入る

□ インスタンスの参照をメソッドの引数や戻り値に使うことで、多くの情報をまとめて受け渡しできる

□ コンストラクタはインスタンスを生成するときに自動的に呼ばれるメソッドで、クラス名と同名・戻り値なし

□ インスタンス変数を扱うのがインスタンスメソッド、クラス全体で共有したい情報や処理はstaticを使う

次回は、「オブジェクト指向基礎③ オブジェクトとメモリ領域」を学びます。


最後までお読みいただきありがとうございます。