1年目から身につけたい! チーム開発 6つの心得

第3章細かな粒度で実装しよう―単純なパーツを組み合わせた見通しの良い設計

コードの見た目がそろったら、次は設計だね


せ、設計……! 僕、そういうのってちゃんと勉強したことないんですよね……デザインパターンとか

難しく考える必要はないさ。たしかにデザインパターンみたいな有名な設計技法はいろいろあるけど、もっと基本的な方針から身につけることが大事だよ。たとえば、実装の粒度とか

粒度?


「クラスが大きすぎるから分けなさい」とか「パラメータが多すぎるから減らしなさい」とか指摘されたことはないかい? それのことだよ

大きな実装は適切な粒度に分割しよう

放っておくと実装は肥大化する

プログラムを開発していると、関数やクラスなどの実装がどんどん肥大化していくことがあります。

リスト1は、⁠メールクライアントのMozilla Thunderbirdの起動時に、UIUser Interface注1を非表示にしたあと、すべての受信サーバへの認証を試行して、認証に成功した場合だけUIを再表示する(1つでも認証に失敗したサーバがあればThunderbirdを終了する⁠⁠、というアドオンtb-force-auth-at-startupの実装例です。ここでは実装の詳細は気にせずに、それぞれの個所が何をしているのかを示したコメントだけを追ってください。

リスト1 肥大化した実装
var ForceAuthAtStartup = {
  // Thunderbird が起動したあとにこのメソッドが実行されると仮定する
  onMailStartupDone: function() {
    // ① UI を非表示にする
    document.documentElement.style.visibility = "hidden";
    // ②認証が必要な受信サーバを収集する
    var allServers = MailServices.accounts.allServers;
    var servers = [];
    for (let i = 0, maxi = allServers.length, server; i < maxi; ++i) {
      let server = allServers.queryElementAt(i, Ci.nsIMsgIncomingServer);
      if (server.type != "none")
        servers.push(server);
    }
    // ③各受信サーバに認証を試行する
    var successCount = 0;
    var failureCount = 0;
    servers.forEach(function(server) {
      server.verifyLogon({
        OnStartRunningUrl: function() {},
        OnStopRunningUrl: function(url, exitCode) {
          // ④個々の受信サーバで認証に成功したかどうかを判別する
          if (Components.isSuccessCode(exitCode))
            successCount++;
          else
            failureCount++;

          // ⑤すべてのサーバの処理が終わったら、全体の成否を判定する
          if (successCount + failureCount == servers.length) {
            // ⑥全体の成否の判定結果に応じて処理を行う
            // ⑦すべて成功ならUI を再表示する
            if (successCount == servers.length)
              document.documentElement.style.visibility = "";
            else // ⑧そうでないならThunderbird を終了する
              Cc["@mozilla.org/toolkit/app-startup;1"]
                   .getService(Ci.nsIAppStartup)
                   .quit(Ci.nsIAppStartup.eAttemptQuit);
          }
        }
      }, MailServices.mailSession.topmostMsgWindow);
    }
  }
};

リスト1の実装は、1モジュール・1メソッドだけで完結しています。単純に行数や文字数だけを見て、これを「コンパクトな実装」と思う人もいるかもしれません。

しかし、⁠UIの操作」⁠受信サーバの認証処理」といった複数の領域に渡る処理がほぼそのまま1ヵ所に詰め込まれていて、機能追加や修正のための変更が非常にしにくい状態になっており、実際は、肥大化した複雑な実装と言えます。複雑な実装ではプログラム全体が密に絡み合っていて、要件が少し変わっただけで全体を丸ごと作りなおさないといけないこともあります。また、バグの温床になりやすく、障害が発生したときの原因究明も難しいです。複雑な実装は、中・長期的に見るとメリットよりもデメリットのほうが大きいのです。

思いつくまま作業を進めた場合や、手探りで実装していった場合には、このようなことになってしまいがちです。細部にわたって事前に完璧な設計を行っておくのはまず不可能ですから、このような実装が突発的に生まれてしまうことは珍しくありません。

このような肥大化した実装をより良い設計に改める方法としては、問題の切り分けと、クラスやモジュール、メソッドといった各レベルでの実装の切り分けが有効です。段階を踏んで見ていきましょう。

解決しようとしている問題を切り分けよう

プログラムの中での実装の単位の大きさは、粒度という言葉で表されます。リスト1のような複雑な実装は「粒度が大きい」と言われ、1メソッドが数行程度であるような単純な実装は「粒度が小さい」と言われます[2]⁠。プログラム全体としては大規模でも、個々のモジュールなどの粒度が適切な実装は、機能追加や変更がしやすく、障害があったときにも修正しやすいです。

しかし、単純にコードを一定の行数でバラバラに分けたりまとめたりすればよいというものでもありません。実装の大きさは、解決しようとしている問題の大きさと強く結びついています。実装を適切な粒度にするためには、解決したい問題をよく分析して、小さな問題の集合としてとらえなおす必要があります。このような作業を問題の切り分けと言います。

初級者と中・上級者の最大の違いは、ここにあります。初級者は目の前にある問題をその形のまま解決しようとしがちですが、1人の人間が解決できる問題の大きさには限りがあります。そのため個人の許容量を超える大きさの問題が降ってくるとお手上げになってしまいます。しかし問題の切り分けができる中・上級者は、そのような大きく複雑な問題を咀嚼(そしゃく)して、自分の理解できる単位にまで小さく切り分けます。そうすることで、複雑な問題でも解決の道筋を見つけることができるのです図1⁠。

図1 一見すると複雑な問題も、切り分けると単純な問題になる
図1 一見すると複雑な問題も、切り分けると単純な問題になる

問題を切り分ける基準としてわかりやすいのは、特に目立つキーワードです。たとえば先の例で挙げたThunderbirdアドオンが解決しようとしている問題であれば、⁠起動時」⁠UI」⁠認証」などがキーワードでしょう。これらを軸にすると次のように問題を切り分けられます。

  1. Thunderbirdの起動時に全体の処理を開始する
  2. UIの状態を変える(表示・非表示の切り替え、終了など)
  3. 受信サーバで認証する(認証を試行して、成功したか失敗したか判断する)

クラスやモジュールを分けよう

問題を適切に切り分けられれば、あとは実装を問題に合わせて切り分けるだけです。リスト2は、前項で切り分けた問題の単位でリスト1のモジュールを分割した例です。

リスト2 モジュールを分割した状態
// 起動時の処理
var StartupHandler = {
  onMailStartupDone: function() {
    UIController.deactivate();
    Authenticator.tryAuth(function(succeeded) {
      // ⑥全体の成否の判定結果に応じて処理を行う
      if (succeeded)
        UIController.activate();
      else
        UIController.exit();
    });
  }
};

// UI の状態を変更する処理
var UIController = {
  deactivate: function() {
    // ① UI を非表示にする
  },
  activate: function() {
    // ⑦ UI を再表示する
  },
  exit: function() {
    // ⑧ Thunderbird を終了する
  }
};

// 認証に関係する処理
var Authenticator = {
  tryAuth: function(aCallback) {
    // ②認証が必要な受信サーバを収集する
    // ③各受信サーバに認証を試行する
    ...
    // ④個々の受信サーバで認証に成功したかどうかを判別する
    ...
    // ⑤すべてのサーバの処理が終わったら、全体の成否を判定する
    if (successCount + failureCount == servers.length)
      aCallback(successCount == servers.length);
    ...
  }
};

モジュール数は増えましたが、実装が性質ごとに分かれているため、調査や変更のために見るべきコードの範囲はむしろ狭くなります。たとえば、⁠認証が完了するまでの間、UIを非表示にするのではなくウィンドウを最小化するようにしたい」というときはUIControllerモジュールの実装だけを見ればよいですし、⁠認証を求める処理を一時的に無効化できるようにしたい」というときはAuthenticatorモジュールの実装だけを見れば済みます。このように、全体的な見通しは良くなっています。

実装の切り分け基準を問題の切り分け基準と合わせると、実装を細かく分けすぎたり、本来1ヵ所にまとまっているべき実装が離れたり、といったことの発生を防ぎやすくなります。細かく分けることだけにとらわれずに、「この実装はどんな問題を解決するためのものか?」を常に意識するようにしましょう。

なお、この例では認証の処理だけをAuthenticatorモジュールに切り分けるにあたって、コールバック関数[3]を使うようにしています。⁠認証する処理」そのものと「認証の結果を受けて行う処理」のように、依存関係にあるもののそれぞれは本質的に別のことである処理どうしを切り分けるためには、コールバック関数がよく使われます。

メソッドを分けよう

クラスやモジュールだけでなく、メソッドも分けることができます。リスト2の実装はよく見ると、AuthenticatorモジュールのtryAuthメソッドに突出して多くの処理が集中していますので、これもより細かい単位に分けましょう。

このメソッドは「認証対象のサーバを収集して、それぞれのサーバで認証し、すべてのサーバの結果が集まったらコールバック関数を実行する」という問題を解決しています。これはさらに次のように小さな問題へと切り分けられます。

  • 認証対象のサーバを収集する
  • 各サーバで認証する
  • すべてのサーバの認証結果が集まった段階でコールバック関数を実行する

もとのtryAuthメソッドを、これらの各問題を解決する小さなメソッドへとさらに分割した例がリスト3です。serververifyLogonメソッドは第1引数として受け取ったリスナオブジェクト[4]OnStopRunningUrlメソッドに認証の結果を渡していますが、リスト1の③ではリスナをループのたびに新しく生成していました。リスト3の③ではAuthenticatorモジュール自身をリスナとして使うようにしており、OnStopRunningUrlメソッドが④の役割を果たす「サーバの認証が終わった段階で処理を行う」メソッドとなっています。また、tryAuthメソッドに渡されたコールバック関数や成功/失敗のカウンタをこれらのメソッド間で共有する必要があるため、コールバック関数やカウンタはAuthenticatorモジュールのプロパティ(メンバ変数)として保持するようにしています。

リスト3 メソッドを分割した状態
var Authenticator = {
  collectAuthServers: function() {
    // ②認証が必要な受信サーバを収集する
    var allServers = MailServices.accounts.allServers;
    var servers = [];
    ...
    return servers;
  },
  tryAuth: function(aCallback) {
    this.callback = aCallback;
    // ③各受信サーバに認証を試行する
    this.successCount = 0;
    this.failureCount = 0;
    this.servers = this.collectAuthServers();
    this.servers.forEach(function(server) {
      server.verifyLogon(this, MailServices.mailSession.topmostMsgWindow);
    });
  },

  OnStartRunningUrl: function() {},
  OnStopRunningUrl: function(url, exitCode) {
    // ④個々の受信サーバで認証に成功したかどうかを判別する
    if (Components.isSuccessCode(exitCode))
      this.successCount++;
    else
      this.failureCount++;

    // ⑤すべてのサーバの処理が終わったら、全体の成否を判定する
    if (this.successCount + this.failureCount == this.servers.length)
      this.callback(this.successCount == this.servers.length);
  }
};

パラメータの数を減らそう

関数やメソッドの引数、コマンドラインツールのオプションなど、実装の外部からパラメータを与える場面は多いです。パラメータが必要な実装を作るときには、それをどのような形で受け取るのかに注意しましょう。パラメータの数が多すぎる場合、それは実装の粒度が適切でないのかもしれません。

引数の順番は間違えやすい

たとえば、⁠文字列の文字エンコーディングを変換する関数」をJavaScriptで実装する場面を考えてみます。必要なパラメータは「変換対象の文字列」⁠変換元の文字エンコーディング」⁠変換先の文字エンコーディング」の3つです[5]⁠。最も単純な実装方法は、リスト4のようにこれらを引数として受け取るというものでしょう。

リスト4 3つの引数を受け取る実装
//「文字列」「変換元」「変換先」という順になっている
function convertEncoding(sourceString, fromEncoding, toEncoding) {
  ...
}

では、この関数を使う場面を想像してみます。何かほかの作業を終えて、今度は文字列のエンコーディングをUTF-8からShift_JISへ変換する必要がある処理を実装することになりました。そういえばそんな処理を前に実装したんだった、ということでvar name = convertEncoding(...と書き始めたところで、はたと手が止まります。あれ、この関数、どんな順番で引数を取るんだったっけ……?

関数やメソッドを定義したときには覚えていても、あとでその機能を使いたくなったときには順番を忘れてしまっている、というのは非常によくあります。そんなときは引数の順番を類推することになりますが、ここで解釈の余地が生まれてしまいます。

  • 「文字列の」変換なんだから、文字列が最初に来るはず
  • 文字列の「エンコーディング」の変換なんだから、エンコーディングが最初に来るはず
  • 変換元、変換先、という順番で並ぶのが自然だ
  • 得られる結果の文字エンコーディングが重要なんだから、変換先エンコーディングが最初に来るはず

どれもそれなりに妥当そうです。実際に、Node.js用のnpmライブラリの1つencodingがリスト4のconvertEncodingに似たAPIを採用していますが、こちらはリスト5のように、変換元と変換先のエンコーディング名が逆順になっています。

リスト5 encodingの利用例
var encoding = require("encoding");
//「文字列」「変換先」「変換元」という順になっている
var sjisBuffer = encoding.convert(utf8Buffer, "Shift_JIS", "UTF-8");

また、iconvという別のライブラリは、リスト6のように変換器のインスタンスを生成してから文字列のエンコーディングを変換するという形式です。変換対象の文字列をどの順番で渡すか悩まなくて済むのが利点ですが、変換元と変換先のエンコーディング名の順番を間違えるとやはり動きません。

リスト6 iconvの利用例
var Iconv = require("iconv").Iconv;
var converter = new Iconv("UTF-8", "Shift_JIS");
var sjisBuffer = converter.convert(utf8Buffer);

このように、解釈のぶれが大きくなるので、引数の順番は間違えてしまいがちだと言えます。こういったAPIはバグの温床となり得ます。

引数の数を減らそう

実装の粒度が小さくなると、引数の数も少なくなる傾向にあります。先に挙げたiconvの例も、変換器の生成に引数を2つ、文字列の変換時にはメソッドに引数を1つだけ渡すという風に、それぞれの場面で指定する引数の数が少なくなっていました。このように、一度に1つの関数ですべてのことを片付けるのではなく、関数やメソッドの役割を細かく分割していくことで、それぞれの関数が必要とする引数の数は減っていきます。

JavaScriptの文字列は内部的にはUTF-16でエンコードされているものとして扱われます。このことを前提として、npmライブラリのiconv-liteでは、変換操作を「UTF-16から指定した文字エンコーディングへの変換(エンコード⁠⁠」と「指定した文字エンコーディングからUTF-16への変換(デコード⁠⁠」という2つの処理に分けることで、リスト7のように各段階の引数を1つずつ減らしています。

リスト7 iconv-liteの利用例
var iconvLite = requrie("iconv-lite");
var unicodeString = iconvLite.decode(utf8Buffer, "UTF-8");
var sjisBuffer = iconvLite.encode(unicodeString, "Shift_JIS");

実際のところ、国際化を考慮した開発では、⁠文字列のエンコーディングをAからBへ直接変換する」という汎用性の高い機能は必要でないことが多いです[6]⁠。このように、その機能単体で見たときのことだけを考えるのでなく、それがどういう場面のための機能であるかまでを考慮に入れると、実際の利用局面に即した使いやすい機能にできます。

また、WHATWGWeb Hypertext Application Technology Working GroupのWeb API仕様の一部として検討されているTextEncoderTextDecoderでは、リスト8のように、エンコード用変換器とデコード用変換器もそれぞれインスタンスを作るようになっています。ここまでくると、引数の順番を間違えることはもうあり得ませんね。

リスト8 TextEncoder/TextDecoderの利用例
var decoder = new TextDecoder("UTF-8"),
var unicodeString = decoder.decode(utf8BytesArray);
var encoder = new TextEncoder("Shift_JIS");
var sjisBytesArray = encoder.encode(unicodeString);

クラスの機能にしよう

ここまで、⁠文字列」「エンコーディング情報⁠⁠、そして「エンコーディング変換という処理」をすべて切り離して考えていましたが、このように関連性が極めて高い要素は1つのクラスにまとめてしまうと、さらにすっきりします。あるユーティリティ関数が特定のデータを処理する専用なのであれば、その関数の機能は「特定のデータ」を表現するクラスがメソッドとして持つべきだと言えるでしょう。

実際に、Rubyの文字列クラスはエンコーディング変換のためのメソッドを持っており、リスト9のような書きかたができます。データと変換機能が切り離されている場合と比べて、変換器をわざわざ用意せず済みますし、引数の順番も覚えなくてよいので、書き間違いのようなうっかりミスが生じる余地はさらに減っています。

リスト9 RubyのStringクラスの機能
utf8_string = " 日本語の文字列"
sjis_string = utf8_string.encode("Shift_JIS")
eucjp_string = sjis_string.encode("EUC-JP")

このように、データを保持したオブジェクトに対して、データを適切に操作するためのメソッドを紐付けて容易に使えるようにするというのも、クラスの重要な役割の一つです。


1つのプログラムを1人だけで専任で開発していると、プログラムの内容すべてが頭に入ったままでいられるから、実装が肥大化したり複雑化したりしてても気がつきにくいんだ。でもそういうコードは、しばらく開発から離れていてプログラムの内容を忘れてしまうと、次に開発を再開できる状態までプログラムの内容を理解しなおすのに、ものすごく時間がかかるんだよ

チームのみんなで開発しようと思っても、そういうコードは重荷になるんですね


ああ。そうならないように、実装の粒度には常に気をつけるようにしないとね


おすすめ記事

記事・ニュース一覧