なぜ、オブジェクト指向が生まれた理由の理解が重要なのか
この記事では、当社の新人エンジニア研修の参考に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;
// ゲーム中断の文字列
private static readonly string STOP_GAME = "end";
public static void Run() {
// 勝負の結果
int result = DRAW;
// 乱数発生インスタンス生成
Random rnd = new Random();
do {
Console.WriteLine("\n選んでください 0:グー 1:チョキ 2:パー end:終了");
// 入力値初期化
int yourHand = END;
// キーバードからの入力値を得る
string inp = Console.ReadLine();
// キー入力が無ければループ続行
if (null == inp || inp.Length == 0) {
continue;
}
// キー入力がゲーム中断ならばループを抜ける
if (inp.Equals(STOP_GAME, StringComparison.OrdinalIgnoreCase)) {
break;
}
// 入力を整数値に変換(例外が発生する可能性がある)
// 入力値が範囲外の場合はもう一度入力を促す
if (!int.TryParse(inp, out yourHand) || (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 STOP_GAME = "end";
// 手の名前
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:パー end:終了");
// 入力値初期化
int yourHand = END;
string inp = Console.ReadLine();
// キー入力が無ければループ続行
if (null == inp || inp.Length == 0) {
continue;
}
// キー入力がゲーム中断ならばループを抜ける
if (inp.Equals(STOP_GAME, StringComparison.OrdinalIgnoreCase)) {
break;
}
// 入力を整数値に変換(例外が発生する可能性がある)
// 入力値が範囲外の場合はもう一度入力を促す
if (!int.TryParse(inp, out yourHand) || (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.は、処理の重複と分散の問題です。主に設計ミスや、お客様のわがまま(?)によって開発途中でアプリケーションの仕様が変更になったりすることが原因で引き起こされる、こちらも大変やっかいな悩みです。
オブジェクト指向以前のパラダイムでは、似たような処理を少しだけ内容を変えて実行する方法が、条件分岐を使うか、データ構造に工夫するかの二つしかありません。既に書かれたメソッドの呼び出し順序を変更もできませんし、呼び出し元が違えば、ほとんど同じ処理なのに新しいプログラム部品を作らなければならなくなることもざらにあります。
=========あるプログラムファイル=========
public static void RegisterProduct(Product pr) {
// 商品の登録処理
}
=========別のプログラムファイル=========
public static void RegisterCampaignProduct(CampaignProduct cpr) {
// 期間限定のキャンペーン商品のため、ちょっとだけ違う商品のちょっとだけ違う登録処理
}
=========さらに別のプログラムファイル=========
public static void RegisterAnotherProduct(AnotherProduct apr) {
// 流通経路が違うため、ちょっとだけ違う商品のちょっとだけ違う登録処理
}
・
・
(その他多数)
・
そうすると、ある処理にバグが見つかった、又は機能追加が必要になった時、必ず忘れずに全ての「似たような処理」を直さなければならなくなります。又は、複数ある「似たような処理」のうち、どれを改修すべきでどれは触ってはいけないのかを、いちいち判断しなければならなくなります。
あるバグを直したら、それが原因で別のバグが出る、という現象を「デグレード」(略してデグレ)と呼ぶのですが、この重複と分散の問題は、デグレの温床になるであろうことが、容易に想像がつきますね。
このような2つの問題を内包したアプリケーションは今でもたくさん存在していて、しかも実際に稼働もしています。古い言語で数十年前に開発されたアプリの障害対応や、機能追加のプロジェクトが現在も世界中で進行しているのです。
この手のプロジェクトにアサインされると、まずは設計書や仕様書を頼りにどこでどんな処理をしているのかを探るところから始めなければなりません。この作業を解析と言います。ドキュメントがあればまだ良い方で、設計書や仕様書自体が存在しない場合も多々あるのです。そうなれば完全に手探りでアプリケーションの構造を一から解析する羽目になります。
何時間もかかってある処理を追いかけていたら、結局使われていない消し忘れの処理だった、などという、どこに鬱憤をぶつけてよいかわからなくなるような悲惨な例を、筆者も何度も体験しています。
オブジェクト指向というパラダイムは、手続き指向型とデータ指向型のデメリットを解消するだけでなく、このような長年のプログラマーの悩みをなんとか解消できないか、という目的で作られたのです。
2. オブジェクト指向型プログラミング
それでは解説は後回しにして、じゃんけんゲームをオブジェクト指向で作るとどうなるかをご覧ください。次の3つのファイルを作成ましょう。
using System;
namespace Chap09;
// じゃんけんの手のインスタンスを生成するクラス
internal class Hand {
/**************************
* インスタンスメンバー
**************************/
// 手の名前
public string Name { get; set; }
// 勝敗表(プレーヤーの手から見たPCの手に対応する勝敗のディクショナリ)
private readonly Dictionary<int, int> winLossMap = new Dictionary<int, int>();
// コンストラクタ
public Hand(int n) {
this.Name = HAND_NAMES[n];
// 配列の添字と値で Dictionary(連想配列)を作成
for (int i = 0; i < WINLOSS[n].Length; i++) {
winLossMap[i] = WINLOSS[n][i];
}
}
// 勝敗を得る
// 引数: int pcHand プレーヤーの手のコード
// 戻り値: int 勝敗
public int judge(int pcHand) {
return this.winLossMap[pcHand];
}
// ToStringのオーバーライド
public override string ToString() {
return this.Name;
}
/**************************
* スタティックメンバー
**************************/
// 手
public const int GUU = 0;
public const int CHOKI = 1;
public const int PAA = 2;
// 有効な手の番号
private const int MIN_HAND = GUU;
private const int MAX_HAND = PAA;
// 勝ち負けを表す定数
public const int WIN = 0;
public const int LOSE = 1;
public const int DRAW = 2;
// 手の名前
private static readonly string[] HAND_NAMES = 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}
};
// 手のインスタンスの配列
public static Hand[] hands = {
new Hand(Hand.GUU),
new Hand(Hand.CHOKI),
new Hand(Hand.PAA)
};
// 手を決定する有効な番号かの真偽値を返す
public static bool isValidHand(int n) {
return (MIN_HAND <= n && n <= MAX_HAND);
}
}
using System;
using Chap09;
namespace Chap09;
// じゃんけんゲームの本体を保持するクラス
// 全メソッドはstaticで実装する
// インスタンスを生成する必要がないため、クラスをstatic宣言する
internal static class JankenGame {
/*************************************************
* ※このクラスにインスタンスメンバーはありません
*************************************************/
/**************************
* スタティックメンバー
**************************/
// ゲーム中断の文字列
private static readonly string STOP_GAME = "end";
// ゲーム中断又は不正な入力の場合の戻り値
public const int END = 3;
public const int OUT_OF_RANGE = -1;
// 勝敗を知らせるメッセージ
private static readonly string[] RESULT_MSG = new string[] {
"あなたの勝ちです!",
"わたしの勝ちです",
"あいこです",
"ゲームが中断されました"
};
// じゃんけんゲーム本体
// 一回のじゃんけんを実行する
public static int Do() {
// 乱数発生インスタンス生成
Random rnd = new Random();
Console.WriteLine("\n選んでください 0:グー 1:チョキ 2:パー 3:終了");
// 入力値初期化
int yourHand = END;
// キーバードからの入力値を得る
string inp = Console.ReadLine();
// キー入力が無ければループ続行
if (null == inp || inp.Length == 0) {
return OUT_OF_RANGE;
}
// キー入力がゲーム中断ならばENDを返す
if (inp.Equals(STOP_GAME, StringComparison.OrdinalIgnoreCase)) {
return END;
}
// 入力を整数値に変換(例外が発生する可能性がある)
// 入力値が範囲外の場合はもう一度入力を促す
if (!int.TryParse(inp, out yourHand) || !Hand.isValidHand(yourHand)) {
return OUT_OF_RANGE;
}
// PCの手を乱数で決める(下の式で0 - 2の値になる)
int pcHand = rnd.Next(3);
// お互いの手を表示する
Console.WriteLine($"あなたは「{Hand.hands[yourHand]}」を出しました");
Console.WriteLine($"わたしは「{Hand.hands[pcHand]}」を出しました");
// 勝負の結果 0:勝ち 1:負け 2:あいこ
return Hand.hands[yourHand].judge(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 (Hand.DRAW == result) {
Console.WriteLine("あいこです");
}
// あいこ又は無効な入力の場合は勝負続行
} while (Hand.DRAW == result || JankenGame.OUT_OF_RANGE == result);
// 勝敗を出力
Console.WriteLine(JankenGame.getResultMsg(result));
// 終了メッセージ出力
Console.WriteLine("終わります");
}
}
なぜファイルが三つに分かれたのか、それぞれのファイルにどう機能を役割分担したのかを、よく考えながら解説を読んでください。皆さんが、この三つのファイルを見て気づくポイントは、以下のような感じでしょうか。
- 情報と処理が合体した
- クラスに機能を分割した
- クラスのメンバーが二種類ある
以下、それぞれのポイントについて、なぜそうなっているのか、どういう効果があるのかを解説していきます。
1.情報と処理が合体した ~情報と処理を一体化させて可読性の低下を防ぐ~
それぞれのクラスが必要とする情報を内部に持ち、やるべき処理をも実装していることに注目です。オブジェクト指向では、情報と処理をクラスという単位にまとめて、ある処理が必要とする情報は同じクラス内に書かれている、という実装をします。こうすることで、コードとデータの分離を最小限に抑え、手続き指向やデータ指向ではできなかった、データと処理の乖離による可読性の低下というデメリットを解消しようとしているのです。
2.クラスに機能を分割した ~機能をクラスにブレイクダウンして再利用性を上げる~
アプリケーションの機能を各クラスにブレイクダウンすることで、一つのクラスが持つコード量を抑え、可読性を上げられます。例えば、Janken01とJanken02では一回のじゃんけんが二重のループ処理になっていましたね。ネストしたループは可読性が低下する主な原因の一つになります。それを各ループがExample03とJankenGameに分散したことで、それぞれのコードが読みやすくなりました。
また、各クラスに明確な役割を持たせることで、アプリケーション全体の機能をわかりやすく表現できるようになっています。これは、プログラマーの長年の悩みであるコードの再利用性が上がった、ということでもあります。オブジェクト指向においてもクラス間の依存関係はもちろん存在します。が、明確な役割を持ち、自身が使う情報も内包できるクラスがプログラムの部品になったことで、以前よりも格段に再利用しやすくなっているのは間違いありません。
3.クラスメンバーが二種類ある ~クラスの二重構造:インスタンスとスタティック~
Handクラスを構成する変数やメソッドに、static
と書いてあるものと、書いていないものがあることに気づいたでしょうか。実は、クラスは二種類の要素を持てるようになっているのです。つまり、二重構造になっているのですね。
static
宣言された変数やメソッドを、スタティックメンバーと呼びます。それに対して、static
宣言されていないメンバーを、インスタンスメンバーと呼びます。Hand
クラスのサンプルには、コメントで二種類のメンバーの定義場所が明示してありますので、確認してください。両者の違いは以下の通りです。
- インスタンスメンバー:必要に応じて複数生成されるオブジェクトのメンバーで、個別の情報と処理を持つ
- スタティックメンバー:クラスの使用時に一つだけメモリに読み込まれ、共通の情報と処理を一つだけ持てる
つまり、一つのクラスから二種類のオブジェクトを作ることができるようになっているのですね。この二重構造がどういう仕組みなのかを、Hand
クラスを題材にして見ていきましょう。Hand
はじゃんけんの手のクラスです。なので、グー担当のHand
、チョキ担当のHand
、パー担当のHand
と、三つのHand
のオブジェクトを69行目からnew
という演算子を使って生成し、hans
という配列に入れています。
この、new
演算子を使って生成するタイプのオブジェクトをインスタンスと呼びます。インスタンスを作成するときに呼び出されるのが、コンストラクタというメソッドです。
class クラス名 {
// コンストラクタ
public クラス名(引数リスト) {
// インスタンスの初期化処理
}
}
Handクラスのコンストラクタは18行目から記述されています。コンストラクタでは、インスタンスが持つフィールドやプロパティを初期化する処理を行います。
グーのインスタンスは、「私はグーだ、私はチョキに勝ってパーに負けるのだ」、という情報を自分で持っています。ですから、プレーヤやPCが選んだ手の名前を表示するとき、あなたは誰?と手のインスタンスに聞けば答えてくれます。また、PCが選んだ手を渡して「あなたは勝った?負けた?それともあいこ?」と聞けば、それも答えてくれるわけです。
このように、インスタンスとは、種類は同じだが他とは違う独自の値を持つ情報(Hand
では手の名前や勝敗表)と、その情報を使った処理を持たせて使うオブジェクトだということがわかります。
それに対して、Hand
クラスのスタティックメンバーはどんな情報を持っているのでしょうか。見ると、それぞれの手の名前の配列や、各手が持つ勝敗表の元になる配列、手の番号の定数値など、それぞれの手が持たなくてよい、共通の値が並んでいますね。スタティックなメソッドも、プレーヤが入力した番号が有効範囲内かを判定するという、これもそれぞれの手に持たせる必要のない、共通の処理を行っています。
クラスのスタティックメンバーとは、インスタンスに個別に持たせなくてよい共通の情報と処理を記述するためのものなのです。
ECサイトの会員クラスを例にとれば、名前や住所などの個人情報はインスタンスに持たせ、会員規約の確認を行うメソッドはスタティックで実装する、というようにクラスの機能をインスタンスとスタティックメンバーに割り振ることができるようになっているわけです。
このクラスの二重構造のおかげで、オブジェクト指向型のプログラミングでは、一つのクラスが持つ情報と処理を用途によって更に二種類にブレイクダウンできるのです。これは、データとコードを合体させたことと相まって、さらに可読性とメンテナンス性を上げられる、ということに他なりません。
4.処理の重複と分散の問題
ここまでの解説で、可読性とプログラム部品の再利用性の問題については、光が見えましたね。では、「ちょっとだけ違う同じような処理の重複と分散」についてはどうなのでしょうか。ご安心ください、オブジェクト指向パラダイムは、「継承」と「多態性」という二つの特性によって、処理の重複と分散問題をも解消できる術を持っています。
クラスの親子関係を作り、共通処理は親クラスに、違う部分だけを子クラスに実装する、という方法で、「少しだけ違う処理」を重複なく書けるように工夫されているのです。この件については、後の章で詳しく解説いたします。乞うご期待。
3. その他のテクニック
ここで、オブジェクト指向で書いたじゃんけんゲームに使ったその他のテクニックについて解説します。皆さんのご参考になれば幸いです。
1.クラスの責務を考慮したクラス設計
このじゃんけんゲームは三つのクラスで構成されていますが、各クラスの役割がはっきりしています。
- Hand - 手の情報を所持し、手が行うべき処理も実装するクラス
- JankenGame - あいこも含め、一回のじゃんけんを行うクラス
- JankenGameのDoを呼び出し、結果を表示するエントリポイント
手続き指向やデータ指向バージョンでは一つのメソッドでじゃんけんゲームの全てを実装していました。それを、三つのクラスにブレイクダウンしたことで、各情報と機能が整理され、それぞれの処理が明確に且つ簡潔になっています。
どのクラスが何を知っているべきで、どんな処理を実装すべきかを考えることを、クラスの責務を考慮する、と言います。クラス構成を設計する上で、大変重要な作業です。
また、一つのクラスは一つの処理に対してだけ責任を持つように設計すべきだ、という考え方を、単一責任の原則を守ると言います。クラスの責務を単一責任の原則を守って考慮することで、適切な処理のブレイクダウンが可能になり、可読性が高いスッキリした構成のクラス設計をすることが出来ます。
2.static クラスの利用
前述のようにクラスは二重構造になっており、個別のインスタンスに持たせるべきメンバーと一つだけあれば良いスタティックメンバーが両方定義できます。しかし、JankenGameのように、ゲームの処理本体を実装するだけのクラスなら、インスタンスを生成する必要がありません。Handクラスを利用して一回のじゃんけんを行うためのスタティックなメソッドだけがあれば良いのです。
このようなインスタンスメンバーを持たないクラスは、36行目のようにクラス自体をstatic
宣言することで、間違ってインスタンスが生成されないようにできます。これを、静的クラスと言います。静的クラスを作るメリットは、誤操作でインスタンスが作れないようになることの他に、記述が簡潔で可読性が良いこと、インスタンス化しないのでメモリ効率が良いこと、などがあります。
3.ルールやメッセージの集中管理
Handクラスは手に関する全ての情報を持ち、JankenGameクラスはゲーム進行に必要な全ての情報を持っています。これを、関連するデータの集中管理、と言います。集中管理のメリットは、変更や機能追加を行う場合に、どこのクラスにあるデータなのかがすぐわかるので、可読性とメンテナンス性を上げて、作業の時間効率のアップにもつながることです。
4.マジックナンバーを定数に
このプロジェクトでは、数値も文字列も殆どリテラル(=即値)を使っていません。3とも関連しますが、記述する数値の意味が分かるように、できる限り定数で宣言した値を使っています。いちいち定数を宣言するのも面倒だと思われるかもしれませんが、これは案外大切な作業なのです。
コードのそこかしこに即値で数値が書いてあるプロジェクトは、値の意味が伝わりづらく、それだけで可読性とメンテナンス性が悪くなります。また、同じ即値がどこに書いてあるのかも判別できないため、改修によるデグレも起きやすくなってしまいます。
定数やreadonly(読み出し専用)の意味が分かる名前がついた値を使えば、同じ値を使っている箇所をVisual Studioで追うのも大変楽ですし、もしその値を変更するような場合でも、影響が及ぶ範囲をすぐに特定できます。
5.Dictionary(連想配列)の利用
Handクラスでは、データ指向型で二次元配列だった勝敗表のデータを、Dictionaryというコレクションに変換しています。Dictionaryは、配列が持つ要素に添字の連番ではなく、一意に値を特定できるキーを付けられるようにしたもので、他の言語では「マップ」や「連想配列」と呼ばれるデータ形式です。
じゃんけんの勝敗表程度のデータではあまりありがたみは感じられないかもしれませんが、少なくともDictionaryを使うことで、配列を添字で参照するよりは、手の番号と勝敗の紐付けが強くなります。また、番号ではなく文字列や列挙型などをキーにして値を特定したい要素がたくさんある場合には、大きな威力を発揮しますので、ここでも採用してみました。
6.大文字小文字を無視する文字列比較
JankenGameの55行目は、大文字小文字を無視する文字列比較の構文です。以前の書き方よりもスッキリしているので、ご紹介しました。
7.例外が発生しない文字列の数値変換
C#は、例外処理を必要な場合だけ行えばいいように、随所に開発陣の配慮が見られる言語です。
JankenGameの61行目、int.TryParse(inp, out yourHand)
という記述は、もし数値に変換できない文字列が入力された場合は戻り値がfalseになります。
4. オブジェクト指向を使いさえすれば全問題が解消できるのか
ここまで読まれた大半の皆さんがこう思っておられますよね。残念ながら、答えはNOです。「えぇ?ここまで来てナニソレ(怒)」と思った方もいらっしゃるでしょう。
すみませんでした。もう少しちゃんとお答えします。
「事前調査やお客様のヒアリングをしっかりやって、アプリケーションの全体像を漏れなく明確にし、クラスの責務と全体のバランスを考慮した良いクラス構成の設計ができれば、様々な問題をかなり軽減できるかもしれません」
というのが、筆者の長年の経験から申し上げられる真摯な回答です。
オブジェクト指向型パラダイムは、確かに以前のものとは比べものにならない程進化した考え方です。しかし手続き指向型やデータ指向型を完全に超えた万能のパラダイムなのかと言うと、そうとは言い切れないのです。なぜでしょうか。それは以下三つの理由によります。
- オブジェクト指向は、手続き指向とデータ指向の問題を依然として内包している
- 動作の仕組みが複雑化し過ぎたおかげで学習コストが高く、適正な設計法を習得しづらい
- 新たに加わった技術が強力な反面、使い方を間違えると危険が大きい
1.については、じゃんけんゲームがオブジェクト指向言語であるC#を使っているにも関わらず、手続き指向寄りにもデータ指向寄りにも書けた、ということを思い出してください。つまり、書き方によってはせっかくのオブジェクト指向パラダイムを生かすことが出来ずに、以前のパラダイムが持つ問題を再現してしまう可能性もある、ということです。
2.については、クラスの二重構造やクラスの責務と言った、新しい概念を正しく理解した上で設計を行わないと、ヘタをすれば以前のパラダイムより、もっと複雑に絡まり合ったスパゲティコードができあがる危険があります。
データとコードが一体化してブレイクダウンしやすくなったと言うことは、部品が細分化しすぎてかえって構造がわかりにくくなってしまう危険もある、ということです。また、後の章で解説する継承のやり方を誤れば、以前のパラダイムには無かったタイプの可読性の低下が問題になる可能性もあるのです。
3.については、近年までオブジェクト指向のメリットだと言われていた要素が、実はデメリットなのではないかという議論が出てくるほどの問題を引き起こしている、という事実があります。
決してオブジェクト指向が万能なのではありませんし、これまでの問題を完全に解消できると断言もできません。ただ、正しい理解の上で適切に使えば、これまでよりは良い結果が生まれるだろう、ということです。
オブジェクト指向パラダイムは、正しく使ってこそ真価が発揮できるのです。
次回は、「オブジェクト指向基礎③ オブジェクトとメモリ領域」を学びます。