インターフェイスに関する記述は基本的なこと、あるいは専門的すぎる内容が多かったため、インターフェイスについてはまとめず、デザインパターンのみについてまとめた。
#はじめに
私は普段C#を使ったUnity開発に取り組んでいます。これまではどちらかと言えば、「良いコードを書きたい」というより、「バグがなくてきちっと動けばそれで良い」という考えでコードを書いてきたように思います。ただ、エンジニアとしてはそれではやはり不十分でしょう。そこで、『Adaptive Code ~ C#実践開発手法』を読んで勉強することにしました。
「アダプティブ」とは、コードを大幅に変更することなく、新しい要求や予想外のシナリオに対処する適応力のことです。
ただ、本書は基本的に.NET Frameworkでの開発を前提として書かれていますし1、文字数も多いので、自分にとって重要な箇所を抜き出してまとめながら学習を進めることは意味のあることだと思います。
今回のまとめは第4章の内容に当たります。
#アダプティブデザインパターン
デザインパターンはうっかりすると使いすぎてしまうことがある。また、常に適用可能であるとは限らず、単純なソリューションのはずが、クラス、インターフェイス、間接化、抽象化だらけの意味もなく複雑なソリューションになってしまうこともある。
正しいパターンを適用する正しい場所を見つけることがポイント。
本章、あるいは本書に含まれるデザインパターンは、アダプティブコードの作成に役立つかどうかという観点から選択されている。
Null Object パターン
Null Objectパターンの目的は、nullオブジェクトをチェックするコードだらけになるのを防ぐこと。
戻り値がnull出ないことをすべてのクライアントがチェックして、NullReferenceExceptionのスローにつながるnullの逆参照を阻止する。
→サービスコードでNull Objectパターンを実装する。2
// サービスコード
public class UserRepository : IUserRepository {
private ICollection<User> users;
public UserRepository {
users = new List<User> {
new User(Guid.NewGuid()),
new User(Guid.NewGuid()),
new User(Guid.NewGuid()),
new User(Guid.NewGuid())
};
}
public IUser GetByID(Guid userID) {
IUser userFound = users.SingleOrDefault(user => user.ID == userID);
if (userFound == null) { // ①
userFound = new NullUser();
}
return userFound;
}
}
// クライアントコード
class Program {
static IUserRepository userRepository = new UserRepository();
static void Main(string[] args) {
var user = userRepository.GetByID(Guid.NewGuid());
user.IncrementSessionTicket(); // ②
}
}
①:返されたUserオブジェクトが実際にnull参照かどうかをチェックして、null参照である場合は、NullUserというIUserのサブクラスのインスタンスを返す。可能な限り、すべてのメソッドを「何もしないメソッド」に近いものとしてオーバーライドするのがNullUserクラスの正しい実装である。
②:nullチェックの実行責任をサービスコードとしているので、ここではnullチェックは必要ない。
NullUserのコード↓
public class NullUser : User {
public void IncrementSessionTicket() {
// 何もしない
}
}
NullUserオブジェクトのメソッドまたはプロパティから別のオブジェクトへの参照を返すことが期待される場合も、常に、それらの型のNull Objectを実装を返すようにする。つまり、すべてのNUll Object実装で再帰的なNull Object実装を返す必要がある。これにより、クライアント側でnull参照をチェックする必要がなくなる。
これには、記述しなければならないユニットテストの数が少なくなるという利点もある。クライアントごとにチェックを実装する必要があるとき、そのチェックが存在することを確認するユニットテストも必要なので。この場合はリポジトリ実装が、NullUser実装が返されることを確認するためのユニットテストになる。
###IsNull プロパティアンチパターン
Null Objectパターンでは、IsNullというBooleanプロパティがインターフェイスに必要になることがある。このインターフェイスの本来の実装はすべて、このプロパティに対してfalseの値を返し、このインターフェイスのNull Object実装はtrueの値を返す。以下は、先の例に基づいて、これがどのような仕組みになるのかを示す。
public interface IUser {
void IncrementSessionTicket();
string Name {
get;
}
bool IsNull {
get;
}
}
public class User : IUser {
・・・
public string Name {
get;
private set;
}
public void IncrementSessionTicket() {
sessionExpiry.AddMinutes(30);
}
public bool IsNull {
get { return false; }
}
private DateTime sessionExpiry;
}
public class NullUser : IUser {
public void IncrementSessionTicket() {
// 何もしない
}
public bool IsNull {
get { return false; }
}
public string Name {
get {
throw new NotImplementedException();
}
}
}
このプロパティには、そもそもカプセル化を目的としているオブジェクトからロジックがこぼれ出てしまうという問題がある。例えば、本来の実装とNull Object実装とを区別するために、クライアントコードにif文が入り込むようになる。これでは、さまざまなクライアントに同じロジックが実装されるのを阻止するという、このデザインパターンのそもそもの目的が台無しになってしまう。以下のコードは、この問題がどのようなものであるかを示す。
static void Main(string[] args) {
var user = userRepository.GetByID(Guid.NewGuid());
// Null Objectパターンを適用しないと、ここで例外がスローされる
user.IncrementSessionTicket();
string userName;
if (!user.IsNull) {
userName = user.Name;
} else {
userName = "unknown";
}
Console.WriteLine("The user's name is {0}", userName);
Console.ReadKey();
}
この問題を修正するには、NullUserクラスでnullユーザーの名前をカプセル化する。正しくカプセル化すれば、IsNullプロパティは不要になる。
public class NullUser : IUser {
public void IncrementSessionTicket() {
// 何もしない
}
public string Name {
get { return "unknown"; }
}
}
static void Main(string[] args) {
var user = userRepository.GetByID(Guid.NewGuid());
// Null Objectパターンを適用しないと、ここで例外がスローされる
user.IncrementSessionTicket();
Console.WriteLine("The user's name is {0}", userName);
Console.ReadKey();
}
##Adapter パターン
Adapterパターンは、オブジェクトインスタンスが実装していないインターフェイスにクライアントが依存していたとしても、そのオブジェクトインスタンスをクライアントに提供できるようにするデザインパターンである。このデザインパターンが使用されるのは、一般に、目的のインターフェイスに合わせてターゲットクラスを変更することが不可能な場合である(クラスがsealedで宣言されているとか、手出しできないアセンブリに含まれているとか)。Adapterパターンの実装方法には、Class Adapterパターンを使用する方法と、Object Adapterパターンを使用する方法の2種類がある。
###Class Adapterパターン
Class Adapterパターンは、アダプター(実装)としての継承を利用する。つまり、クライアントが期待しているインターフェイスに適用させる必要があるのは、ターゲットクラスのサブクラスである。実際の仕組みは以下のようになる。
public class Adaptee {
public void MethodB() {
}
}
public class Adapter : Adaptee {
public void MethodA() {
MethodB();
}
}
// クライアントコード
class Program {
static Adapter dependency = new Adapter();
static void Main(string[] args) {
dependency.MethodA();
}
}
Class Adapterパターンは、それほど利用されない。これは主に、開発者が継承よりも合成を優先するように教えられているため。継承はホワイトボックスの再利用であり、サブクラスをそのインターフェイスだけでなくクラスの実装にも依存させる。合成はブラックボックスの再利用であり、依存関係はインターフェイスに限定されるため、クライアントに悪影響をおよぼさずに実装を変更することが可能である。
※「ホワイトボックスの再利用」と「ブラックボックスの再利用」の2つの擁護派それぞれ、実装の内部を確認できることと、実装の内部が調べられないように隠されていることを意味する。
###Object Adapterパターン
Adapterパターンとしてはこちらの方が一般的。
Object Adapterパターンは、合成を利用することで、インターフェイスのメソッドを外側のカプセル化されているオブジェクトへ委譲(デリゲート)する。
以下は、Object Adapterパターンの実際の仕組みを示す。アダプターはターゲットクラスをコンストラクターのパラメーターとして受け取り、それに対して委譲する。
public interface IExpectedInterface {
void MethodA();
}
public class Adapter : IExpectedInterface {
private TargetClass target;
public Adapter(TargetClass target) {
this.target = target;
}
public void MethodA {
target.MethodB();
}
}
public class TargetClass {
public void MethodB() {
}
}
// クライアントコード
class Program {
static IExpectedInterface dependency = new Adapter(new TargetClass());
static void Main(stirng[] args) {
dependency.MethodA();
}
}
##Strategy パターン
Strategyパターンでは、指定された1つ以上の「戦略」に基づいてクラスの振る舞いを変更できる。使用するのは、オブジェクトの状態に応じてクラスの振る舞いを切り替える必要がある場合。この振る舞いをクラスの現在の状態に応じて実行時に変更できる場合、Strategyパターンはその振る舞いをカプセル化するのに最適。以下は、Strategyパターンをインターフェイスとして定義し、クラスで使用する方法を示す。
public interface IStrategy {
void Execute();
}
public class ConcreteStrategyA : IStrategy {
public void Execute() {
Console.WriteLine("ConcreteStrategyA.Execute()");
}
}
public class ConcreteStrategyB : IStrategy {
public void Execute() {
Console.WriteLine("ConcreteStrategyB.Execute()");
}
}
public class Context {
private readonly IStrategy strategyA = new ConcreteStrategyA();
private readonly IStrategy strategyB = new ConcreteStrategyB();
private IStrategy currentStrategy;
public Context() {
currentStrategy = strategyA;
}
public void DoSomething() {
currentStrategy.Execute();
// 呼び出しごとに戦略(ストラテジー)を切り替える
currentStrategy = (currentStrategy == strategyA) ? strategyB : strategyA;
}
}
#さらなる汎用性を求めて
インターフェイスには、ダックタイピングやミックスインなど、より専門的な機能があり、本書はそれについてもまとめられているが、そうした機能が利用されることはまれなのでここでは省略する。
#まとめ
インターフェイスは、クラスの多様性をカプセル化することを可能にするポリモーフィズムを促進し、デザインパターンを成長させる原動力となる。それでいて、インターフェイスは実際には何もしない。
インターフェイスは実装がなければ使いものにならないことを思い出してほしい。しかし、インターフェイスがなければ、実装とそれらに関連する依存関係がコードに染みついてとれなくなり、保守や拡張が難しくなってしまう。うまく配置されたインターフェイスは、サービスコードのダーティな実装上の詳細と、うまく構造化されたクライアントとを隔てる防壁となる。