【最終回】C#でクラスを作ろう(14)/デザインパターン

7月 29, 2022

このシリーズについて

C言語やC++言語などを学んではいるけどクラスをあまり作ったことが無い、という方を対象にしています。
このシリーズでは、C#でクラスを作るための基本的な構文を解説しています。C++やJavaなどと共通している概念も多いですが、サンプルコードは基本的にC#で解説します。ところどころ、C++特有の概念を解説することもあります。

過去13回の記事で、C#でのクラスの作り方とその文法を解説してきました。最終回の今回は、デザインパターンという考え方について解説します。

…が、デザインパターンの詳細については解説しません。これは僕の私見なのですが、現代においてデザインパターンを覚えることはそれほど重要ではないと思うからです。

スポンサーリンク

GoFのデザインパターン

広い意味での「デザインパターン」とはその名の通り、クラスやメソッドなどをデザインするときの典型的なパターンのことです。しかし、この手の話の文脈で「デザインパターン」と言ったときには、GoFのデザインパターンのことを指します。

書籍『オブジェクト指向における再利用のためのデザインパターン』において、GoF (Gang of Four; 四人組) と呼ばれる4人の共著者は、デザインパターンという用語を初めてソフトウェア開発に導入した。GoFは、エーリヒ・ガンマ、リチャード・ヘルム、ラルフ・ジョンソン、ジョン・ブリシディースの4人である。彼らは、その書籍の中で23種類のパターンを取り上げた。
彼らはこう述べている。

コンピュータのプログラミングで、素人と達人の間では驚くほどの生産性の差があり、その差はかなりの部分が経験の違いからきている。達人は、さまざまな難局を、何度も何度も耐え忍んで乗り切ってきている。そのような達人たちが同じ問題に取り組んだ場合、典型的にはみな同じパターンの解決策に辿り着く。これがデザインパターンである (GoF)。

Wikipedia:デザインパターン

GoFのデザインパターンは1995年頃に登場し、C++やJava言語をより高度に使いこなすための典型的かつ有用なパターンとして、当時は脚光を浴びました。

23種類のパターンには全て名前が付いています。

パターン名 概要
Abstract Factory 関連する一連のインスタンスを状況に応じて、適切に生成する方法を提供する
Builder 複合化されたインスタンスの生成過程を隠蔽する
Factory Method 実際に生成されるインスタンスに依存しない、インスタンスの生成方法を提供する
Prototype 同様のインスタンスを生成するために、原型のインスタンスを複製する
Singleton あるクラスについて、インスタンスが単一であることを保証する
Adapter 元々関連性の無い2つのクラスを接続するクラスを作る
Bridge クラスなどの実装と、呼出し側の間の橋渡しをするクラスを用意し、実装を隠蔽する
Composite 再帰的な構造を表現する
Decorator あるインスタンスに対し、動的に付加機能を追加する
Facade 複数のサブシステムの窓口となる共通のインターフェイスを提供する
Flyweight 多数のインスタンスを共有し、インスタンスの構築のための負荷を減らす
Proxy 共通のインターフェイスを持つインスタンスを内包し、利用者からのアクセスを代理する
Chain of Responsibility イベントの送受信を行う複数のオブジェクトを鎖状につなぎ、それらの間をイベントが渡されてゆくようにする
Command 複数の異なる操作について、それぞれに対応するオブジェクトを用意し、オブジェクトを切り替えることで、操作の切り替えを実現する
Interpreter 構文解析のために、文法規則を反映するクラス構造を作る
Iterator 複数の要素を内包するオブジェクトの要素に対して、順番にアクセスする方法を提供する
Mediator オブジェクト間の相互作用を仲介するオブジェクトを定義し、オブジェクト間の結合度を低くする
Memento データ構造に対する一連の操作のそれぞれを記録しておき、以前の状態の復帰または操作の再現が行えるようにする
Observer インスタンスの変化を他のインスタンスから監視できるようにする
State オブジェクトの状態を変化させることで、処理内容を変えられるようにする
Strategy データ構造に対して適用する一連のアルゴリズムをカプセル化し、アルゴリズムの切り替えを容易にする
Template Method あるアルゴリズムの途中経過で必要な処理を抽象メソッドに委ね、その実装を変えることで処理が変えられるようにする
Visitor データ構造を保持するクラスと、それに対して処理を行うクラスを分離する
Wikipedia:デザインパターン

ざっとこんな感じです。GoFのデザインパターンの趣旨の1つに、このように23種類のパターン1つ1つにしっかりと名前を付けることによって、プログラマー間で認識の共有を可能にするという点があります。「ここはCommandパターンで作ってね」「あいよ、了解」というコミュニケーションを可能にしようというわけです。

そしてさらに、GoFのデザインパターンを学ぶ課程そのものが、プログラミング教育の中級カリキュラムとなり得ます。個々の指導者がそれぞれのカリキュラムを独自に考える必要が無くなり、「2学期はGoFをやろう」みたいな感じで、一種の世界共通の指導要領となるわけです。

現代ではGoFは重要ではない

しかし2020年現在、GoFのデザインパターンがプログラマーのバイブルになっているとは言い難い状況です。それにはいくつかの理由があります(一部、僕の私見も含みます)。

まず、そもそもGoFのデザインパターンは、C++やJava言語のためのデザインパターンであるという点が挙げられます。全てC++やJavaで記述することを前提としているため、それらの言語より古い言語では実現が難しいパターンもあります。

要するにGoFのデザインパターンは、C++やJavaであっても記述困難な定型パターンを言語テクニックでカバーしたものであるとも言えます。

しかし逆に、C++,Javaより新しいプログラミング言語の観点から言えば、GoFのデザインパターンはC++,Javaの言語仕様の不足を補ったにすぎないと考えることもできます。実際、ある識者は、

一部のデザインパターンは、プログラミング言語(例: Java, C++)の機能の欠損の印であると主張されることがある。計算機科学者のピーター・ノーヴィグは、GoFによるデザインパターン本の23パターンのうち16パターンは、言語によるサポートによって単純化または除去できることをLispやDylanを用いて実演した。

Wikipedia:デザインパターン

このような見解を述べています。わかりやすい例で言えば、C#においてはIteratorパターンやSingletonパターンは言語仕様の一部に組み込まれており、もはやテクニカルにデザインパターンを適用する類のものではなくなっています。(この2つのパターンについては後述します)

1995年当時、オブジェクト指向だオブジェクト指向だと騒がれ始め、それを体現する最新最強のプログラミング言語としてJavaが大流行しました。言語仕様以外の部分でも、非マイクロソフト陣営がスクラムを組んでJavaを推したのも流行の原因の1つでしょう。

特に過激な者は、「Javaでなければプログラミングにあらず。他の言語でGoFもどきを記述できたとしても、それはGoFではない。なぜならJavaでないからだ。」とさえ言い放っていたという噂もあります(真偽は不明)。

そこまで狂信的な人はほんのごく一部だったと思いますが、いずれにしても、日進月歩、いや秒進分歩で進化し続けるプログラミングという世界において、ある種のJava信奉的思想がGoFの普及を阻害していたのではないかと感じています。

普及しなかった別の要因としては、単純にこの23種のパターンを習得するのが難しいというのもあったかもしれません。特に日本人の場合、ある程度英単語に精通していない限り、FlyweightとかMementoとか言われても一体どういうパターンなのかイメージが沸きません。

さらに、サンプルコードが難しかったという点もあります。そもそもGoFのデザインパターンは「デザインの考え方」を示すものなので、「これぞ」という決定版のサンプルコードはありません。そうなると各指導者がそれぞれサンプルコードを書くわけですが、これがまた難しい。

実際、今ググって出てくるGoFのデザインパターンのサンプルコードは、難しく書かれているものが多いです。どう難しいかと言うと、各パターンがそれぞれ主張する「何をしたいか」という構造を抽象クラスによって記述しているものが多く、一段階抽象化して解説している記事が多いというところです。

各デザインパターン自体が一種の抽象化なのに、その抽象化したものをさらに抽象化しているので、何が言いたいのかを理解するのがとても難しいんです。根気よくググれば、各パターンが主張する本質だけを浮き彫りにし、関係ないところは(再利用はできないけど)具体的な例で解説しているページもたまに見つかりますが。

というわけで。

このGoFのデザインパターン23種を全部習得する必要は無いと、僕は思います。いくつかのパターンは現代のプログラミング言語には言語仕様として組み込まれていて、自然と習得できるからです。

また、どのパターンも基本的には、メソッドの内側と外側、クラスの内側と外側を分離し、それぞれの部分を記述する際に余計な関心事を分離することを目的としています。この考え方は、本ブログのメソッドの中の人と外の人データを中心に考える、そして本連載のC#でクラスを作ろうなどで幾度と無く解説してきました。

余計な関心事を分離してプログラミング中の脳の消費リソースを抑えることによって、ミスを少なくし、そ記述の追加や変更にも柔軟に対応できるようにしておくという発想。それを常日頃から心がけていれば、実はGoFの23種類のパターンのうちいくつかは、知らない間に既に身に付いているはずです。

デザインパターン例(1)/Iterator

Iteratorというのはこういうやつです。

//Famiryクラスは家族を表わし、何人かの人物(Personクラス)を保持する
Famiry famiry = new Famiry();
famiry.Add(new Person("父"));
famiry.Add(new Person("母"));
famiry.Add(new Person("兄"));
famiry.Add(new Person("弟"));
 
//Famiryクラスは、反復子iteratorを生成する
Iterator iterator = famiry.GetIterator();
 
//家族内の全ての人物に対し、反復して処理を行う
while (iterator.HasNext())
{
    Person person = iterator.Next();
    Console.WriteLine($"私は{person.Name}です");
}

Iteratorを完全なものにするためには、Iteratorクラスを定義し、HasNextメソッドやNextメソッドをしっかり定義しなくてはいけません。ここではその実装は省略しますが、とりあえずその実装ができているとしましょう。

iterator.Next()は、「次の」家族内の人物を取得するメソッドです。最初は父、次は母、のようにです。そして、Next()を繰り返して4人全員取得した後、iterator.HasNext()はfalseを返します。「次はもう無いよ」ということです。

この仕組みにより、リストのようなクラスに対して、全てのメンバーを反復して列挙することができます。反復の順序やインデックス(何番目であるか)はどうでもよくてとにかく反復したいとき、このIteratorパターンは有効です。

しかしこのIteratorパターン、よく知ってるアレと同じですよね。

foreach (Person person in famiry)
{
    Console.WriteLine($"私は{person.Name}です");
}

そう、C#におけるforeachと同じです。しかも、IteratorだとかHasNextだとか、そういうものは一切出てきません。つまり、これがIteratorパターンであることを知らずとも、自然に我々はIteratorパターンを習得しているのです。

Javaは当初、foreachがありませんでした。でもこのような反復処理は行いたい。そこで、Iteratorパターンというテクニックが編み出されたわけです。

でも、よく考えてみれば、これはテクニックでカバーすべき問題でしょうか? Javaが不完全だったためにこんな面倒くさいことをしなければならなくなったのであって、むしろ手を入れるべきは言語仕様のほうじゃないだろうか?

これがまさに、先ほどの識者の見解

一部のデザインパターンは、プログラミング言語(例: Java, C++)の機能の欠損の印であると(以下略)

Wikipedia:デザインパターン

に該当する顕著な例です。結局Iteratorパターンに相当するforeach構文はC#では最初から搭載され、Javaでも新しいバージョンでは拡張for文として利用可能となっています。

デザインパターン例(2)/Singleton

あるクラスに関して、そのインスタンスは必ず1つしか生成されないようしなければならない、という状況はよくあります。例えば、アプリケーション全体でアプリケーション自体の情報を保持するAppクラス、グローバル変数を一ヶ所にまとめるGlobalクラスのようなものです。

そのような場合にはSingletonパターンを適用せよ、とGoFは述べています。

class App
{
    private static App _app = new App(); //唯一のインスタンス
    private App() { } //空のprivateコンストラクタを定義
    public static App GetInstance()
    {
        return _app;
    }
 
    public void SomeMethod()
    {
        //何らかの処理
    }
}
 
//使う側
App.GetInstance().SomeMethod();

Appクラスは、内部に唯一の自分自身のインスタンス_appを静的フィールドとして持ちます。この唯一のインスタンスを取り出すには、静的メソッドGetInstance()を使います。

そして、敢えて空のprivateコンストラクタを定義しておくことによって、デフォルトコンストラクタを無効にし、なおかつ外側から(publicなところから)はインスタンスが生成できないようにします。

このようにいくつかのテクニックを組み合わせることにより、常に唯一のインスタンスしか持たないAppクラスを作ることができました。

でもこれ、どこかで見たことありますよね? そう、静的クラスです。

static class App
{
    public static void SomeMethod()
    {
        //何らかの処理
    }
}
 
//使う側
App.SomeMethod();

あんな面倒くさいことをしなくても、静的クラスで済むじゃん、って話です。

これもやはり、Javaには静的クラスというものが定義できなかったのが原因です(静的メソッドなどはOK)。C#のように静的クラスが定義できるなら、あんな謎のprivateコンストラクタなんてテクニックを使わなくても、Singletonは実現できるんです。

実は、GoFのやり方のままでSingletonを定義したほうがいい場合もあります。それはどんなときかと言うと、Singletonなクラスを継承したり抽象クラス化したりする場合です。静的クラスの場合は継承ができないので、そのような場合にはGoFのやり方に従うことになります。

デザインパターン例(3)/Factory Method

GoFのオリジナルのFactory Methodは、「Factoryであること」というデザインパターンをさらに一段階抽象化した形で構成されています。また、似た名前のAbstract Factoryパターンは、そのFactory Methodをさらにさらにもう一段階抽象化しています。

これだと何がなんだかわからないので、「Factoryであること」だけに注目したSimple Factoryパターンというものを紹介します。Simple FactoryパターンはGoFの23種類のデザインパターンには含まれていませんが、GoFに準ずるものとしてよく登場します。

これは、「GoFこそ全て」と盲目的に信奉するのではなく、現実の有用性に即して抽象度を下げたほうがよいのではないか、と後に考えられた結果だと思います。

では、この簡単なほうのSimple Factoryの例を見ていきましょう。

//ファイルコピーの共通部分(抽象基本クラス)
abstract class FileCopyItem
{
    public abstract void DoFileCopy();
}
 
//ローカルコピーを担うクラス
class LocalFileCopyItem : FileCopyItem
{
    public override void DoFileCopy()
    {
        //ファイルコピーの実行
        File.Copy(this.SourceFilePath, this.DestinationFilePath);
    }
}
 
//FTP送信を担うクラス
class FtpFileCopyItem : FileCopyItem
{
    public override void DoFileCopy()
    {
        //FTP送信の実行
        WebClient wc = new WebClient();
        wc.Credentials = new NetworkCredential(this.FtpUserName, this.FtpPassword);
        wc.UploadFile(this.DestinationURL, this.SourceFilePath);
    }
}
 
//Simple Factory
class FileCopyItemFactory
{
    public FileCopyItem Create(string destPath)
    {
        if (destPath[i].Contains("ftp://"))
            return new FtpFileCopyItem(destPath);
        else
            return new LocalFileCopyItem(destPath);
    }
}
 
//使い方
FileCopyItemFactory factory = new FileCopyItemFactory();
//ファイルコピー先の配列
string[] destPath = new string[]
{
    @"C:\",
    @"C:\SomeFolder\",
    @"ftp://example.com/htdocs/",
    @"ftp://example.com/htdocs/SomeDirectory/"
};
//4つのFileCopyItemを生成する
FileCopyItem[] itemList = new FileCopyItem[4];
for (int i = 0; i < 4; i++)
{
    itemList[i] = factory.Create(destPath[i]);
}
//ループを回して4つのファイルコピーを実行
for (int i = 0; i < 4; i++)
{
    itemList[i].DoFileCopy();
}

この例は、本シリーズ第11回のポリモーフィズム(多態性)の中で複数の子クラスを親クラスのリストでまとめるときの例として紹介したコードの改良版になります。

クラス図

ローカルパス(例えば"C:\SomeFolder\")にファイルをコピーする処理を担うLocalFileCopyItemクラスと、FTP転送によって(例えば"ftp://example.com/htdocs/")ファイルを転送する処理を担うFtpFileCopyItemクラスがあったとします。それぞれ「ファイルをコピーする」という処理は同じなので、共通部分を抽象基本クラスFileCopyItemにまとめています。

LocalFileCopyItemクラスを使うかFtpFileCopyItemクラスを使うかは、コピー先のパス(またはURL)によって決まります。"ftp://"が付いていればFTP、それ以外はLocalにしたいと思います。

このとき、"ftp://"が付いているかどうかをメインロジック側に書いてしまうと、メインロジックに余計な判定処理が増えてしまいます。メインロジックの関心事は、「このコピー先にコピーしたい」ということだけです。「どのような方法でコピーするか」という内部処理は、できればメインロジック側は考えたくありません。

そこで、Simple Factoryパターンの登場です。

Simple Factory

Factoryとは工場のことです。このFileCopyItemFactoryは、コピー先のパスまたはURL文字列を受け取って、適切なインスタンスを生成する工場です。

実際の振り分け処理は、FileCopyItemFactoryの中のCreateメソッドに書かれています。ここで"ftp://"の文字があるかどうかを判定し、LocalFileCopyItemのインスタンスか、FtpFileCopyItemのインスタンスか、いずれか適切なほうを生成して返しています。

Createメソッドの返り値の型は抽象基本クラスFileCopyItemです。つまり、returnのときに暗黙的にFileCopyItemにアップキャストされます。

さて、メインロジック側を見てください。

itemList[i] = factory.Create(destPath[i]);

Factory(工場)がCreateメソッドによって生成したインスタンスを取得しているだけです。メインロジック側は、LocalかFTPかを分ける判定をしていません。それどころか、Createメソッドによって返されたインスタンスがLocalなのかFTPなのかを知ることさえありません。そう、そんなことは知らなくていいのです。

メインロジック側は、とにかくFactoryによって生成されたインスタンスに対し、抽象基本クラスFileCopyItemの抽象メソッドDoFileCopyを呼び出しさえすれば、実際のコピー処理は完了します。それがLocalFileCopyItemならローカルコピーが行われるし、FtpFileCopyItemならFTP転送が行われるわけですが、メインロジック側はそんなことはどっちでもいいのです。コピーさえしてくれればいいのですから。

このように、実際に生成するインスタンスの種類を適切に振り分けて生成してくれる工場、それがFactoryです。

GoFのデザインパターンに含まれる「Factory Method」パターンは、このFactory(工場)さえも抽象クラスにして、工場の振る舞い(つまり、生成するインスタンスの種類の判定方法)をも柔軟に差し替えることができるようになっています。

しかし実際のところは、生成の判定方法さえも柔軟に変化させる必要があるような複雑な事例はあまり多くありません。Factoryは具体的な1つのものであることが多いので、このように抽象度を下げた簡易版のSimple Factoryパターンがよく使われるわけです。

実は僕は、このSimple Factoryパターンですら無駄に抽象度が高いと感じているので、もっと簡単にした方法を使っています。特に呼び名は無いので、うーん、Base Class Factoryパターンとでもしましょうか。

//ファイルコピーの共通部分(抽象基本クラス)
abstract class FileCopyItem
{
    public abstract void DoFileCopy();
 
    public static FileCopyItem Create(string destPath)
    {
        if (destPath[i].Contains("ftp://"))
            return new FtpFileCopyItem(destPath);
        else
            return new LocalFileCopyItem(destPath);
    }
}
 
//使う側
FileCopyItem[] itemList = new FileCopyItem[4];
for (int i = 0; i < 4; i++)
{
    itemList[i] = FileCopyItem.Create(destPath[i]);
}

FactoryのCreateメソッドを抽象基本クラス内の静的メソッドに変えただけなんですが、こうするともはやFactoryというクラスすら必要無くなります。そもそもCreateメソッドは抽象基本クラスFileCopyItem型のオブジェクトを生成するためのものなので、じゃぁわざわざFactoryなんていう別クラスを作らずとも、FileCopyItemクラスの静的メソッドにすればいいじゃん、ってことです。

GoFのデザインパターンではこのFactoryクラスのように、「何らかの概念」をクラスにするという考え方が多く登場します。インスタンスを生成するという概念、処理の順序を規定するという概念、オブジェクト間の動作を橋渡しするという概念、などなど。なおかつ、その「概念たち」の共通部分をさらに抽象化して、概念の概念、抽象化の抽象化とも言うべきクラスが多数登場します。

使いこなせれば有用なデザインパターンなのですが、複雑すぎるパターンは逆にプログラムの構造をわかりにくくします。いかにシンプルに処理を記述するかというのがデザインパターンの目的の1つであったはずなのに、これでは本末転倒です。

また、複雑度や習熟の難易度という観点以外に、クラスが乱発されすぎるという難点もあります。1995年当時は「概念さえもクラス化しよう」という一種の流行があったのですが、近年は逆に、あまりクラスを多く作るべきではないという考え方も増えてきています。

そのあたりを考慮して僕はこのBase Class Factoryパターン(ぼくが命名)を使っているんですが、いかがでしょうか。

デザインパターンは時とともに変化する

IteratorパターンやSingletonパターンのように現在は言語仕様に搭載されているため全く不必要なもの、Factory Methodパターンのように独自に簡易版にしてもよいもの。

これらの事例が示す通り、デザインパターンの在り方は時とともに変化します。

しかしその一方でやはり、GoFの23種類のパターンが素晴らしい考え方であることは間違いありません。これらを丸暗記したり、使いにくくてもオリジナルの形のまま使おうとする必要はありませんが、一度は全種類に目を通してみてください。

「へー、こんなのがあるんだ」くらいの感覚でいいと思います。そうやって身につけた感覚は、今後のクラス設計にきっと役に立つはずです。

C#でクラスを作ろう

全14回にわたって、C#でクラスを作る連載をしてきました。

クラスの設計というのには、これぞという決まった答えがありません。

実際にいろいろ手を動かしてみて、時には抽象過ぎてわけがわからない失敗クラスができあがってしまうということも多数経験して、感覚を磨いていきましょう。

良きプログラミングライフを。おつかれさまでした。

スポンサーリンク