i-school - 肥大化したクラス内の処理を、役割に応じたクラスを複数作成して分割する実装例
 プログラムの学習において、最初のうちは1つのクラス内に色々な処理を書いていくケースがほとんどです。
教材やネットのサイトでも多くそういった書式が紹介されています。

 ソースコードを書くことに慣れてきたら、クラス内の処理を役割単位で分割して、複数のクラスに分けて役割ごとに制御する方法を学習し、実践しましょう。



<新しい学習内容>
 ・abstract(アブストラクト) 修飾子による抽象クラスと抽象メソッドの宣言
 ・クラスの継承とメソッドのオーバーライド処理



ケーススタディ


 よくあるケースとして、PlayerController クラスや GameManager クラスの肥大化があります。
特に初心者の場合や、プログラムに慣れていない場合、複数のクラスに処理を書くことによって処理を読み解くことが難しくなるため、
最初のうちは1つのクラスに集約していくことが多いです。

 実際に1つのクラスに色々な処理がまとまっているソースコードを利用し、クラスの分割を行っていきます。


分割前のクラス


 今回は PlayerController を例に、役割ごとに処理を分割して考えてみます。


PlayerController.cs

<= クリックしたら開きます。




処理を役割に沿って考えてみる


 PlayerController クラス内には、多くの処理があります。
それらの処理を、どういった役割を果たしているのか、という観点で分割してみてください。
その分割したものが役割の1つとなり、同時に、1つのクラスとして作成していく、という方向性になります。

 また処理の内部には、複数の処理内で共通で利用している処理も見受けられます。
例えば十字キー入力値は移動の処理と、アニメの向きの処理の両方で同じ値が利用されています。
こういった部分は共通している部分として、別の役割(共通処理をまとめたクラス)として考えます。

 つまり、移動とアニメの処理からは「十字キー入力値」の処理は含まないようにします。
では、必要な値はどうするのか、ということですが、「共通で利用する処理」をまとめた役割(クラス)を作り、
そこから「十字キー入力値を受け取って」利用するようにします。
それぞれのクラス内で同じ処理を書くのでなくて、共通している情報を管理しているクラスから受け取って利用する、という考え方です。
このようにすることで、十字キー入力の処理は、個々のクラスに書くのでははなく、共通して利用するクラス内のみ(1つのクラス)に集約出来ます。 


参考サイト
MicroSoft
継承
https://docs.microsoft.com/ja-jp/dotnet/csharp/fun...


リファクタリングの方向性


 クラスを分割するにあたっては、クラスの継承機能を利用して、役割(責務)の分割を行っていきます。

 最も最上段にあるクラスは PlayerBase クラスです。一番のスタート地点になるクラスを親クラス、あるいは基幹クラス(スーパークラス)と呼びます。
このクラスは MonoBehaviour クラスを継承しています。

 PlayerBase クラスは abstract 修飾子を用いた抽象クラスとして作成をしています。
抽象クラスは、それ単体ではインスタンスが作成できないクラスです。つまり、いずれかのクラスに継承させる前提で設計を行うクラスになります。



 PlayerBase クラスでは、各クラスに共通して利用する変数やメソッドを作成します。
そのため、個々のクラスに別途備えたい機能(変数やメソッド)については、この PlayerBase クラスには定義しません。
例えば、移動の処理、アニメの処理、という風に、現在の PlayerController クラスに書かれている処理を役割ごとに分けます。

 共通する処理が記述された PlayerBase クラスを親クラスとして継承することで、個々のプレイヤーの役割用のクラスを子クラスとして作成します。

 設計と実装にあたり、全体の設計が完了したら、まずは継承元(親)となる PlayerBase クラスから順番に作成していくことになります。

 PlayerBase クラスを作成したら、それを継承させた Player 〜 クラスを子クラスとして作成します。
実際にはこの Player 〜 クラスが、障害物のゲームオブジェクトにアタッチするためのクラスになります。


設計 ー役割の考え方ー


 現在の PlayerController クラスの内容から、機能別に役割を分類してみましょう。

・移動させる機能
・アニメーションを移動方向に同期させる機能
・オブジェクトを判定する機能

 このうち、移動させる機能とアニメーションを移動方向に同期させる機能については、キー入力の値を同じ情報として利用しています。
そのため共通する処理をまとめたクラスを1つ作成し、また、このクラスで、それ以外のクラスを管理します。
つまり、管理者(マネージャー)となるクラスを新しく用意し、他の機能をまとめ上げるようにします。

・マネージャークラス


親クラスを作成する



<= クリックしたら開きます。



<abstract(アブストラクト) 修飾子による抽象クラスと抽象メソッドの宣言>


 abstract 修飾子をクラスの宣言に追加することで、抽象クラスを作成することができます。

<抽象クラス>
 /// <summary>
 /// プレイヤー用の抽象クラス
 /// </summary>
 public abstract class PlayerBase : MonoBehaviour

 抽象クラスとはインスタンスの作成できない(new できない)クラスです。継承を行う前提で作成されているクラスのことを言います。
クラス内には抽象メソッドを1つ以上宣言しておく必要があります。通常のクラスと同じように、private 修飾子や public 修飾子を使って変数やメソッドを実装することも出来ます。



 メソッドの宣言に abstract 修飾子を追加することで抽象メソッドを作成することができます。
これは中身のない(処理の実体のない)メソッドであるため、継承した派生(子)クラス内においてオーバーライドして利用することを前提として作成されているメソッドです。

<抽象メソッド>
    /// <summary>
    /// 初期設定用の共通処理
    /// </summary>
    public abstract void SetUpPlayer();

 抽象メソッドは抽象クラス内でしか作成できません。
また抽象クラスを継承しているクラスでは必ず、すべての抽象メソッドをオーバーライドして実装する必要があります

 この機能を用いることによって、実装の仕方(メソッドの強制)を統一することができます。
つまり、PlayerBase クラスを継承したクラスでは、必ず抽象メソッドである SetUpPlayer メソッドを実装する必要がありますので、
この処理を通じて各クラスにおけるプレイヤーの初期設定を制御するという、同じ処理の流れで設計ができます。

 実装した抽象メソッドでは、子クラスがそれぞれの処理を実装することになりますので、プログラムの多態性(ポリモーフィズム)を実現出来るようになっています。


<参考サイト>
MicroSoft
abstract
https://docs.microsoft.com/ja-jp/dotnet/csharp/lan...
++C++; // 未確認飛行 C 様
抽象メソッド、抽象クラス
https://ufcpp.net/study/csharp/oo_abstract.html
.NET Column 様
C#のabstract classとは?インターフェイスについても併せて解説!
https://www.fenet.jp/dotnet/column/language/c-shar...
XR-HU3 様
[第14回] 抽象クラス(abstract)の使い方を学ぶ|Unityで学ぶC#入門
https://xr-hub.com/archives/19842


<抽象クラスと抽象メソッド>


 作成した PlayerBase クラスを継承させて、複数の Player 〜 で始まる子クラスを作成していきます。
今回は PlayerBase クラスを抽象クラスとして作成していますが、継承するクラスは抽象クラスである必要はありません
現に、PlayerBase クラス自体は、抽象クラスではない MonoBehaivour クラスを継承しています。

 クラスを継承した場合、継承している親クラス(今回であれば PlayerBase クラス)の継承しているクラスも引き続きます
そのため、PlayerBase クラスを継承した子クラスは MonoBehaivour クラスと PlayerBase クラスの2つのクラスを継承しているクラスになります。

 MonoBehaivour クラスが継承されていますので、Start メソッドや OnTrigger 〜 メソッドも利用できますし、
ゲームオブジェクトにアタッチして利用することも出来ます。

 PlayerBase クラスは抽象クラスであるため、abstract 修飾子で宣言されている抽象メソッドがある場合、その抽象メソッドの実装が強要されます。
実装する際には override キーワードを用います。抽象メソッドをすべて実装しないとエラーがでるようになっています。

 詳しくは別の記事を参考にしてください。

 クラスの継承


子クラスを作成する


 各クラスごとに明確な役割を設けるとともに、責務のない処理は書き込まないように配慮します。

 また PlayerManager がすべての子クラスの管理者となります。
そのため、各子クラス同士はお互いを知らない状態(メンバ変数で管理していない。疎結合化)を目的にもしています。

 キー入力に関しては、十字キーが移動の方向とアニメーションの方向の両方で利用されているため、
各子クラス内でキー入力の判定は行わず、PlayerManager 内でキー入力の感知を行い、
その情報を必要なクラス(PlayerMove と PlayerAnimation)に、メソッドの引数を通じて送り届ける形で実装しています。


1.PlayerMove.cs


 移動に関する処理のみを記述します。
そのため、Rigidbody コンポーネントはこちらのクラスでのみ管理します。

 親クラスである PlayerBase クラスに定義されている抽象メソッドである SetUpPlayer メソッドをオーバーライドして実装し、
このクラス独自の振る舞いを行っています。


<= クリックしたら開きます。




2.PlayerAnimation.cs


 このクラスでは移動方向に合わせたアニメーションの同期処理を実装しています。
先ほどの PlayerMove クラスと同様に、継承している親クラス(PlayerBase)の抽象メソッドを実装し、このクラスでは Animator クラスの取得を行っています。

 先ほどの PlayerMove クラスの SetUpPlayer メソッドでは Rigidbody クラスの取得を行っていました。
同じメソッドであっても、実装されている内容が異なっていることが分かります。


<= クリックしたら開きます。



3.PlayerAction.cs


 アクションボタンの判定に成功し、対象となるオブジェクトにイベントが設定されていた場合、
そのイベントを実行する処理を記述しています。

 また NPC に限り、アニメの向きをキャラ側に向けさせるようにしています。

 この処理内では、GameEventHandler クラスの ExecuteGameEvents メソッドを実行することにより、
命令を受けた GameEventHandler が、アサインされている GameEvent を順番に実行していく処理につなげています。


<= クリックしたら開きます。




4.EventChecker.cs


 アクション用のボタンを押下した際の処理です。
プレイヤーの向いている方向に Ray を投射し、指定されたレイヤーを持ち、かつ、コライダーを持つオブジェクトがあるかを判定しています。

 該当するゲームオブジェクトがある場合のみ、true と Ray の接触したゲームオブジェクトの情報を戻すメソッドを実装しています。
Ray が接触しない場合には false と null(ゲームオブジェクトなし)を戻しています。

 この時点ではまだ GameEvent があるかどうかまでは判定していません。


<= クリックしたら開きます。




5.PlayerManager.cs


 上記の4つのクラスをまとめて管理するクラスです。
このクラスには RequireComponent 属性を付与し、該当する4つのクラスを指定していますので、
このクラスをアタッチすると、4つのクラスも自動的に追加でアタッチされます。

 このクラスのみが4つのクラスをメンバ変数として管理し、それぞれの処理に対して命令を出しています。
トップダウン型のような命令系統の仕組みをイメージしてもらえれば分かりやすいでしょう。

 またイベントの開始と終了も管理していますので、イベント中は移動できないようにしたり、画面の不要なタップを阻害するようにしています。

 共通の処理としてキー入力を判定し、その情報を移動のクラスとアニメーションのクラスにメソッドを通じて渡しています。
こうすることにより、各クラス内で判定をさせる必要なく、共通の値をそれぞれのクラスに利用してもらう設計です。


<= クリックしたら開きます。



クラス図


 ここまでに制作したクラス間の関係性を可視化することで、分割されたクラスがどのような状況か、イメージしやすくなります。

 クラス図を用意しましたので、各クラスの関連性を見てみましょう。



<親クラスと子クラスの関係性>



<管理クラスと子クラスの関係性>




 クラス図を書くことにより、自分のイメージを整理する際にも役立ちますし、第三者にクラスの状態を説明する場合にも情報を正確に伝えることが出来ます。


プレイヤー役のゲームオブジェクトに PlayerManager クラスをアタッチし、PlayerManager にアサインする


 まずプレイヤー役のゲームオブジェクトを複製します。複製後、1つは非表示にします。
カメラなどで追従対象になっている場合には、カメラの設定先も見直してアサインし直してください。

 以前の状態のゲームオブジェクトを残しておくことで、インスペクターでの設定値の確認なども出来るためです。

 複製された方の PlayerController のスクリプトを Remove し、代わりに PlayerManager クラスをアタッチしてください。
一緒に4つのクラスもアタッチされます。

 それらのクラスを PlayerBases 変数にアサインして登録してください。
これでこのクラスが4つのクラスのマネージャーとして機能するようになります。

 移動速度のみ設定がありますので、PlayerMove クラスで設定を行ってください。


インスペクター画像



 以上で設定は完了です。



ゲームを実行して動作を確認する


 クラスを分割する前と同じように動作するかを検証してください。


 インスペクタータブの上でメニュー表示し、Debug を選択すると、private 修飾子の変数がインスペクターから確認出来るようになります。


メニュー表示



ゲーム実行前の Debug のインスペクター画像





 ゲームを実行した際には、これらの変数に必要な情報がアサインされているかも確認しておいてください。
Debug.Log メソッドを活用していただいても大丈夫です。


ゲーム実行後のインスペクター画像



設計の重要性とクラスの分割


 1つのクラスが肥大化してしまうことは多々あります。
学習中のうちは問題ありませんが、自分でプログラムを考えながら書いてみると、こういった設計部分での問題にぶつかります。
そのため基本的には、クラスを作成する前から肥大化する可能性が見えることがほとんどです。

 かといって最初から分割して作成していければよいのですが、それも中々難しいです。

 ソースコードを書く前に、設計を考えることから始めてみてください。
以前よりもたくさんの時間をクラスの設計に費やしてみることをお勧めします。
どのような処理を、どのクラスに書くのかを明確にしておくことで、肥大化しやすいクラスを前もって認知しておくこともできます。

 ソースコードを書いている間も、常に、設計を頭に考えて進めていくようにしてみてください。
設計は書き出しておくことで考えていることを言語化・可視化できるので、よりイメージしやすくなります。