クラス設計の考え方

作成:2004年4月20日

吉田誠一のホームページ   >   ソフトウェア工学   >   技術コラム   >   オブジェクト指向

ソフトウェアの開発において、クラスの設計は、大切なポイントの1つです。どのようなクラスや関数を作るのか。ソフトウェアのデザインは、それによって決まります。

現在のソフトウェア工学で主流となっているのは、オブジェクト指向の考え方です。開発言語も、C++やJavaといったオブジェクト指向言語が広く使われています。しかし、いくらオブジェクト指向言語を使って開発していても、クラス設計の考え方が誤っていれば、まったくオブジェクト指向的でないソフトウェアができてしまいます。

貴方が、あるちょっとした機能の追加を頼まれたとしましょう。さて、いくつのクラスや関数を作れば良いのでしょうか。また、そのクラスや関数の名前は、どのように付ければ良いのでしょうか。貴方なら、どのように考えを進めて、クラスや関数を設計していきますか?

ここでは、ワイルドカードを使った文字列の検索を例に、クラス設計をする際の考え方を紹介します。

目次

  1. サンプルケース
  2. アルゴリズム
  3. クラス設計
  4. 4人の解答
  5. 関数かクラスか
  6. 関数か、メンバー関数か
  7. 適切な名前の付け方
  8. インターフェースの決め方
  9. ユーティリティクラスの作成

サンプルケース

次のような画面を持つ、英和辞典アプリケーションを作るとしましょう。

英和辞典アプリケーションの画面

検索語を入力して、「検索」ボタンを押すと、発音と、日本語の訳が表示されます。ここでのポイントは、「検索語」欄には、ワイルドカードを使える、という点です。例えば、「検索語」欄に「*ight」と入力して、「検索」ボタンを押すと、画面には「eight」や「night」などが表示される、という訳です。

この画面は、貴方のチームメイトがすでに作ってくれました。SearchWordDialogという名前のダイアログクラスになっています。

貴方は、入力された(ワイルドカードを使った)検索語と、辞書にある英単語が、一致するかどうか、の判定をするルーチンを作ってくれるように、頼まれました。例えば、検索語が「l*v*」だったとすると、「love」ならOK、「like」ならNG、という判定をするルーチンを作れば良い訳です。

アルゴリズム

判定をするアルゴリズムは、図の通りとします。

判定アルゴリズム

まずはじめに、与えられた検索語を、ワイルドカード文字「*」で区切って、いくつかのブロック文字列に分割します。それから、与えられた英単語の前から順に、ブロック文字列が含まれているかどうか、1つずつ探していきます。

クラス設計

さて、ここから先が、クラス設計の本題です。貴方なら、この後、どのように考えを進めていきますか? そして、どのような名前のクラスや関数を作りますか?

4人の解答

A君、B君、C君、そして私の、4人の解答を紹介します。貴方なら、これらをどのように採点しますか?

A君の解答

A君は、検索語と、英単語と、2つの文字列を引数に与えるような、関数を作りました。クラスにはしませんでした。

bool CompareWildCard ( const char* search_word, const char* english_word );
          

B君の解答

B君は、次のようなクラスを作りました。

class WordSearch {
public:
    // コンストラクタ。検索語を与える。
    WordSearch ( const char* search_word );

    // 英単語を与える。検索語と一致すれば、trueを返す。
    bool CompareWithWord ( const char* english_word );
};
          

C君の解答

C君は、次のようなクラスを作りました。

class WildCardCondition {
public:
    // コンストラクタ。
    WildCardCondition ( );

    // 「*」で分割して、ブロック文字列に分けておく。
    InitializeConditionBlocks ( const char* condition );

    // ブロック文字列が含まれているか、1つずつ調べる。
    bool CheckConditionBlocksInString ( const char* string );
};
          

InitializeConditionBlocksという関数で、ワイルドカードを使った検索語を与えます。ここでは、検索語を「*」で切り分けて、いくつかのブロック文字列に分割して、それをメンバー変数として持っておきます。

CheckConditionBlocksInStringという関数で、英単語を与えます。ここでは、メンバー変数として持っておいたブロック文字列が、英単語に含まれているかどうか、前から順に、1つずつチェックしていきます。

これらの処理は、「アルゴリズム」の章で説明した通りです。

私の解答

私が考えたクラスは、次のようなものです。

class WildCardCondition {
public:
    WildCardCondition ( const char* condition );

    bool Accepts ( const char* string );
};
          

ここで、関数名が「Accept」ではなく「Accepts」になっている理由については、「ブール値を返すメンバー関数の命名規則」をご覧下さい。

関数かクラスか

A君の解答は、クラスではなく、関数になっていました。

bool CompareWildCard ( const char* search_word, const char* english_word );
        

オブジェクト指向に慣れていない人は、関数を作りがちだと思います。しかし、関数として作ってしまうと、拡張性に難が生じます。

このサンプルケースでは、検索語として「*ight」と入力しても、「*IGHT」と入力しても、どちらでも、eightやnightが表示されてほしいと思います。とはいえ、ワイルドカードを使った条件と、文字列が一致するかどうか、の判定をするルーチンは、汎用性がありますから、他の場所や、他のソフトウェアでも利用したいところです。となると、大文字/小文字の区別をするかどうか、ケースバイケースで、どちらでもできるようにしたくなります。

私の解答の例では、SetCaseSensitiveという関数を追加するだけで済みます。

class WildCardCondition {
public:
    WildCardCondition ( const char* condition );

    // 大文字/小文字の区別をするならtrueを与える。
    void SetCaseSensitive ( bool flag );

    bool Accepts ( const char* string );
};
        

A君のように関数として作ってしまうと、引数を1つ追加しなくてはなりません。

bool CompareWildCard ( const char* search_word, const char* english_word,
                       bool case_sensitive );
        

この場合の問題は2つあります。

1つは、機能が追加されるたびに、引数がどんどん増えていってしまい、引数が多くなりすぎる、という点です。例えば、ワイルドカード文字として「*」以外の文字も使えるようにしたい、となれば、また1つ、引数が増えてしまいます。

もう1つの、より重大な問題は、この関数を使っている箇所を見た時に、引数の意味が分からない、という点です。

クラスにした場合は、呼び出し側は次のようなコードになります。

WildCardCondition condition("*IGHT");
condition.SetCaseSensitive(false);
bool flag = condition.Accepts("eight");
        

このコードでは、「false」という値が何を表しているのかが、関数名(SetCaseSensitive)で表現されており、一目瞭然です。

ところが、関数として作ってしまうと、呼び出し側は次のようなコードになります。

bool flag = CompareWildCard("*IGHT", "eight", false);
        

これを見ただけでは、3番目の引数「false」が何を表しているのか、分かりません。

『ワイルドカードを使った条件と、文字列が一致するかどうか、の判定をするルーチン』と言われた時に、たいていの人は、引数は2つだと想像するでしょう。それなのに、上記のコードには、引数が3つもあります。そのズレが、可読性を下げる大きな要因となります。

思慮深い人であれば、上記のコードは、次のように書いてくれるでしょう。

bool case_sensitive = false;
bool flag = CompareWildCard("*IGHT", "eight", case_sensitive);
        

これなら、「false」という値の意味が分かります。しかし、このように書いてくれるかどうかは、関数を使う人に任されてしまっています。

一方、クラスにした場合は、必ずSetCaseSensitiveという関数名を書かなくてはなりません。この制約を設けられるのが、このケースで、関数ではなく、クラスとして作る利点です。

関数か、メンバー関数か

A君の解答は、CompareWildCardという関数を作る、というものでした。

A君よりもオブジェクト指向言語に慣れている人は、CompareWildCard関数を、独立した関数ではなく、冒頭で紹介した検索画面、つまり、SearchWordDialogという名前のダイアログクラスの、メンバー関数として作ることが多いかもしれません。

独立した関数と、メンバー関数とでは、どちらの方が良いのでしょうか?

このケースでは、独立した関数とする方が正解です。メンバー関数にするのは、publicなメンバー関数として追加するのはもちろん、privateなメンバー関数として追加するのも、適切ではありません。

何故、CompareWildCard関数を、SearchWordDialogクラスのメンバー関数にしてはいけないのでしょうか。その理由は、次の通りです。

publicなメンバー関数は、そのクラスが持つ機能、つまり、インターフェースを表しています。そのクラスが表すデータに対して行える操作が、publicなメンバー関数として表現されています。

SearchWordDialogクラスは、冒頭で紹介した検索画面を表すクラスです。この画面は、ユーザが「検索」ボタンを押したというイベントをキャッチしたり、検索された英単語を画面に表示する機能を持っているでしょう。SearchWordDialogクラスのインターフェースを想像すると、こんな感じのものが思い浮かぶと思います。

class SearchWordDialog {
public:
    // 「検索」ボタンが押された時のイベントハンドラ。
    void OnSearch ( );

    // 英単語を画面に表示。
    void AddWord ( Word word );
};
        

一方、CompareWildCard関数は、ワイルドカードを使った条件と、文字列が一致するかどうか、の判定をする機能です。検索画面に対して何かを行う、というものではありません。検索画面が持つデータを使って何かを行う、という訳でもありません。検索画面とは、関係が無いのです。

仕様書または概要設計書には、『検索画面では、ワイルドカードの判定処理を行い、一致する英単語のみ表示する』と書いてあるかもしれません。ですが、勘違いしないで下さい。この文章は、『検索画面を表すSearchWordDialogクラスに、ワイルドカードの判定処理をするCompareWildCard関数を作る』という意味ではないのです。

publicなメンバー関数を追加するのに抵抗を感じる人であっても、privateなメンバー関数は不用意に追加してしまうことがあります。

privateなメンバー関数は、外からは見えません。そのため、privateなメンバー変数は、クラスを実装する人が、自由に作ってよいと言われています。しかし、privateであっても、メンバー関数はそのクラスの機能を表します。publicなメンバー関数が、そのクラスを使う人に対しての、いわば外向きの顔だとすれば、privateなメンバー関数は、そのクラスを読んだり、改造したりする人に対しての、内向きの顔です。

publicなメンバー関数に比べると、privateなメンバー関数には、クラスの実装に強く依存したものができることも多いです。しかし、あるべきでない関数をメンバー関数として加えるのは、privateであっても、厳に慎むべきです。

ところで、前章では、クラスと関数とを比べた時の関数の欠点として、機能を追加すると引数が増える、という点を挙げました。この欠点は、メンバー関数としても同様です。

引数を増やさずに、メンバー関数をもう1つ追加する、という方法を考える人がいるかもしれません。そうすると、次のようになります。

class SearchWordDialog {
public:
    // 「検索」ボタンが押された時のイベントハンドラ。
    void OnSearch ( );

    // 英単語を画面に表示。
    void AddWord ( Word word );

private:
    // ワイルドカードを使った条件と、文字列が一致するか、判定。
    bool CompareWildCard ( const char* search_word, 
                           const char* english_word );

    // 大文字/小文字の区別をするならtrueを与える。
    void SetCaseSensitive ( bool flag );
};
        

こうすると、確かに関数の引数は増えません。しかし、この解決策は最悪です。何故なら、大文字/小文字の区別をするかどうかを、検索画面に対して設定することになるからです。無関係なメンバー関数やメンバー変数が増えて、SearchWordDialogクラスのインターフェースがどんどん乱れてしまいます。関数の引数が増える方が、はるかにましでしょう。

適切な名前の付け方

B君の解答はどうでしょうか。どこに問題があるでしょうか。

class WordSearch {
public:
    // コンストラクタ。検索語を与える。
    WordSearch ( const char* search_word );

    // 英単語を与える。検索語と一致すれば、trueを返す。
    bool CompareWithWord ( const char* english_word );
};
        

B君の解答を見ると、クラス設計の際に、切り分けが上手くできていないことが分かります。それは、クラスや関数、変数の名前に表れています。

B君の付けた名前を、日本語に訳してみましょう。

B君の付けた名前
クラスWordSearch単語の検索
関数CompareWithWord単語と比較
変数search_word検索語
english_word英単語

冒頭で、B君に与えられた課題を思い出してみると、次のようなものでした。

貴方は、入力された(ワイルドカードを使った)検索語と、辞書にある英単語が、一致するかどうか、の判定をするルーチンを作ってくれるように、頼まれました。

B君は、この文章で使われている言葉を、そのままクラスや関数、変数の名前に使いました。

一方、私の解答を見てみましょう。

class WildCardCondition {
public:
    WildCardCondition ( const char* condition );

    bool Accepts ( const char* string );
};
        

ここで使われている名前は、次の通りです。

私の付けた名前
クラスWildCardConditionワイルドカードの条件
関数Accept受け入れる
変数condition条件
string文字列

B君よりも、より汎用性のある名前となっています。

B君の付けた名前は、裏を返すと、次のような意味になってしまいます。

  • このクラスでは、単語の比較しかできない。例えば、ファイル名の検索などには、流用できない。
  • このクラスは、検索にしか使えない。ワイルドカードを使うケースでも、検索機能でなければ、このクラスは流用できない。
  • コンストラクタには、検索画面の「検索語」欄で入力した検索語しか、指定できない。

クラスや関数、変数の名前も、インターフェースを表す大切な要素の1つです。B君が関数の名前をCompareWithWordとした、ということは、とりもなおさず、『この関数の引数には(文字列であっても)単語以外は指定するな』と、B君が主張していることになります。

ですが、実際にはそうではありません。B君が使ったこのクラスは、引数にファイル名を渡して、ファイルの検索に使っても、まったく問題ありません。

B君がこのような名前を付けてしまったのは、冒頭で、B君に与えられた課題の文章が、頭にあったからです。言い換えると、B君が作ったクラスが、いま直面しているアプリケーションのどこでどう使われるのか、それを考えながら作ってしまったからです。

クラスや関数、変数の名前を付ける時には、いま直面しているアプリケーションのことは、いったん忘れる必要があります。そのクラスのことだけ考えて、そのクラスだけを見た時に適切と思えるような、そんな名前を付ける必要があります。

このケースでは、考えるべきことは、『ワイルドカードを使った条件と、文字列が一致するかどうか、の判定をする』ということだけです。このクラスを使う側の事情、つまり、

  • 検索画面で使う。
  • ユーザが検索語を入力する。
  • それと一致する英単語を検索する。

というようなことは、いったん忘れることです。そうすれば、おのずと適切な名前が思い浮かぶことでしょう。

インターフェースの決め方

C君の解答はどうでしょうか。どこに問題があるでしょうか。

class WildCardCondition {
public:
    // コンストラクタ。
    WildCardCondition ( );

    // 「*」で分割して、ブロック文字列に分けておく。
    InitializeConditionBlocks ( const char* condition );

    // ブロック文字列が含まれているか、1つずつ調べる。
    bool CheckConditionBlocksInString ( const char* string );
};
        

C君の解答を見ると、クラスを作る過程で、「インターフェースを決める」というステップが抜け落ちてしまって、いきなり実装のことから考え始めていることが分かります。それは、関数の名前に表れています。

このサンプルケースでは、冒頭で課題を与えられた後で、まずアルゴリズムを決めました。C君は、そこから、次のように考えたのでしょう。

  1. 最初に、ワイルドカードを使った条件を与える。
  2. そこでは、条件を「*」で切り分けて、ブロック文字列に分割して、メンバー変数に持っておく。
  3. 次に、文字列を与え、一致するかどうかを判定して返す。
  4. そこでは、与えられた文字列に、メンバー変数として持っておいたブロック文字列が含まれているか、1つずつチェックしていく。

これは、オブジェクト指向に慣れていない人によく見られる、手続き的な考えの進め方です。C君は、作ったものはクラスではありましたが、発想はオブジェクト指向的ではなく、手続き的のようです。

上記の考え方に従うと、作るべきメンバー関数とその名前は、次のように決まっていきます。

  1. 最初に、ワイルドカードを使った条件を与える。
    • まず、1個目の関数ができる。
  2. そこでは、条件を「*」で切り分けて、ブロック文字列に分割して、メンバー変数に持っておく。
    • 1個目の関数は、メンバー変数の、条件のブロック文字列を初期化するものだから、名前を「InitializeConditionBlocks」とする。
  3. 次に、文字列を与え、一致するかどうかを判定して返す。
    • 次に、2個目の関数ができる。
  4. そこでは、与えられた文字列に、メンバー変数として持っておいたブロック文字列が含まれているか、1つずつチェックしていく。
    • 2個目の関数は、条件のブロック文字列が、引数の文字列に含まれているかどうかチェックするものだから、名前を「CheckConditionBlocksInString」とする。

さて、こうして決められた関数を見て、貴方はどう感じるでしょうか。おそらく、関数名が長すぎる、分かりにくい、と感じるのではないでしょうか。

前章では、クラスのpublicなメンバー関数は、そのクラスを使う人に対して、クラスの機能を表す、外向きの顔であると言いました。しかし、上記の考え方で付けられた名前は、そのクラスを使う人に対して、機能を適切に表してはいません。むしろ、作る人の頭の中を表した、内向きの顔になってしまっています。

貴方がこの関数を見た時の視点は、このクラスを使う人としての立場に立っています。ですから、内向きの顔になっている関数は、、名前が分かりにくい、と感じられるのです。

一方、私の解答は、C君とはまったく逆の考え方で作られています。まず最初に、使う人の立場に立って、インターフェースを決めるのです。

具体的には、まず初めに、これから作るクラスの使い方を表す、サンプルプログラムを考えます。このケースでは、ワイルドカードを使った条件と、文字列が一致するかどうか、の判定をするサンプルコードは、次のようになります。

WildCardCondition condition("l*v*");
bool flag1 = condition.Accepts("love");  // -> true
bool flag2 = condition.Accepts("like");  // -> false
        

こうして、サンプルプログラムを書いてしまえば、クラス名や関数名、関数の引数、つまり、クラスのインターフェースがすべて決まってしまいます。

クラスや関数の名前は、サンプルプログラムのコードが、プログラム言語ではなく、自然言語として読めるかどうか、で判断しています。サンプルプログラムを日本語に訳してみると、判断がしやすいでしょう。

ワイルドカードの条件として、条件「l*v*」を定義。
その条件が、「love」を受け付けるか?
その条件が、「like」を受け付けるか?
        

日本語に訳した時に、そのままきちんとした文章になっていれば、適切な名前と思ってよいでしょう。

一方、C君の付けた名前で、サンプルプログラムを書いてみると、次のようになります。

WildCardCondition condition;
condition.InitializeConditionBlocks("l*v*");
bool flag1 = condition.CheckConditionBlocksInString("love");    // -> true
bool flag2 = condition.CheckConditionBlocksInString("like");    // -> false
        

日本語に訳すと、次のようになります。

ワイルドカードの条件として、空の条件を定義。
その条件に、「l*v*」を、条件ブロックを初期化する。
その条件に対し、条件ブロックが文字列「love」の中にあるかチェックする。
その条件に対し、条件ブロックが文字列「like」の中にあるかチェックする。
        

まわりくどい表現になり、自然な文章に訳せないことが分かります。日本語で考えれば、「条件ブロック」という単語を使うのは、無意味に思えるでしょう。

関数の名前が適切かどうかは、呼び出し側から見た時に、自然で、充分で、冗長でないかどうか、がポイントになります。クラスの使い方を日本語で説明して、それをそのまま英語に訳したものが関数名になっているのが良いでしょう。

ユーティリティクラスの作成

クラスのインターフェースが決まったら、次は中身について考えます。

このサンプルケースでは、次のようなインターフェースを持ったクラスを作ることに決めたとしましょう。

class WildCardCondition {
public:
    WildCardCondition ( const char* condition );

    bool Accepts ( const char* string );
};
        

中身については、冒頭で紹介したアルゴリズムを思い出してください。

まずはじめに、与えられた検索語を、ワイルドカード文字「*」で区切って、いくつかのブロック文字列に分割します。それから、与えられた英単語の前から順に、ブロック文字列が含まれているかどうか、1つずつ探していきます。

ここでは、与えられた検索語を、ワイルドカード文字「*」で区切って、いくつかのブロック文字列に分割する、という処理があります。この処理は、C言語で10〜20行くらいで書けるでしょう。さて、その10〜20行のコードは、貴方ならどこに書きますか?

オブジェクト指向に慣れていない人は、ここで次のように考えを進めがちです。

  • 与えられた文字列を「*」で区切ってブロック文字列に分割する、という関数を作る。
  • この関数は、コンストラクタから呼び出すので、private関数とする。
class WildCardCondition {
public:
    WildCardCondition ( const char* condition );

    bool Accepts ( const char* string );

private:
    // 文字列を「*」で区切ってブロック文字列に分割する。
    void SeparateCondition ( const char* condition );
};
        

しかし、この処理を、WildCardConditionクラスのprivate関数として作るのは、クラス設計としては不適切です。

『文字列を分割する』という機能は、『ワイルドカードを使った条件と、文字列が一致するかどうか、の判定をする』という機能とは別の、新しい機能です。ですから、このケースでは、『文字列を分割する』という機能を持った、新しいクラスを作るべきです。

Java言語を知っている人であれば、StringTokenizerというクラスがあることをご存知でしょう。ここでは、StringTokenizerと同等のものを作ることになります。

上の例で示したように、オブジェクト指向に慣れていない人は、ある1つのクラスを作っている時に、あらゆるコードをすべてその1つのクラスに詰め込もうとする傾向があるようです。

もし、StringTokenizerというクラスがすでに存在して、使い方を知っていれば、たいていの人は、自然に、StringTokenizerクラスを使ったコードを書いていたことでしょう。StringTokenizerクラスが無ければ、本来は、それを作るだけの話です。WildCardConditionクラスは、StringTokenizerクラスが存在していてもいなくても、同じ作りになるはずなのです。

ところが、StringTokenizerクラスが存在しなかった、もしくは知らなかった、というだけで、何故か、WildCardConditionクラスにメンバー関数を追加し、そのデザインを変えてしまうのです。考えてみれば、妙な話です。

クラス設計では、世の中にありとあらゆる便利なユーティリティクラスが存在する、と考えてみると良いかもしれません。実際にクラスの中身を作ってみて、本当に必要なユーティリティクラスが出てきたら、自分でそれを作れば良いのです。

Copyright(C) Seiichi Yoshida ( comet@aerith.net ). All rights reserved.