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

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

この記事を読むのに必要な時間:およそ 7 分

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

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

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

たとえば,⁠文字列の文字エンコーディングを変換する関数」を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はバグの温床となり得ます。

注5)
JavaScriptの文字列はそれ自体がエンコーディングの情報を持っていないためです。

引数の数を減らそう

実装の粒度が小さくなると,引数の数も少なくなる傾向にあります。先に挙げた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);
注6)
「データ形式を変換するのは入出力の境界だけにとどめて,内部処理では可能な限り汎用的な形式で取り扱う」というセオリーがあるためです。

クラスの機能にしよう

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

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

リスト9 RubyのStringクラスの機能

utf8_string = " 日本語の文字列"
sjis_string = utf8_string.encode("Shift_JIS")
eucjp_string = sjis_string.encode("EUC-JP")

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


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

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


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


COLUMN 実装の粒度を細かくして得られるそのほかのメリット

ユニットテストしやすくなる

アプリケーション全体の挙動ではなく,個々のモジュールやメソッド,関数などの細かい単位で期待したとおりに動作するかどうか検証することをユニットテストと言います。適切な粒度の実装には,ユニットテストがしやすいというメリットがあります。たとえば先の実装例について「認証を試みる対象のサーバをきちんと検出できているかどうか」を検証する場合を考えてみましょう。

リスト1のように実装の粒度が大きいと,⁠Thunderbirdを起動して,手作業で設定を整えて準備する」⁠Thunderbirdを起動して,実際にパスワードを手入力して,UIが表示されたかどうかを目視確認する」のように検証手順が煩雑になります。また,確認も間接的な方法に限られるため,検証としては大雑把です。

リスト3のように実装が細かく分かれていると,検証手順はAuthenticator.collectAuthServers()を実行して戻り値を見る」という1ステップだけで済みます。ピンポイントで検証対象の処理を実行して結果を確認するので,精度の高いユニットテストになりますし,ここまで単純ならxUnit系のテスティングフレームワークなどを用いた自動化もしやすいです。

最適化しやすくなる

適切な粒度の実装は,アルゴリズムに関わる最適化しやすいというメリットもあります。たとえば,全体で10秒かかってしまう処理があり,それを最適化する場面を考えてみましょう。

リスト1のように実装の粒度が大きいと,どこの部分に時間がかかっているのかを見極めにくいため,効率良く最適化を進められません。⁠なんとなく時間がかかっていそうに見える部分」をやみくもに削っても,かかる時間は結局それほど変わらなかった,ということも珍しくありません。

しかしリスト3のように実装が細かく分かれていれば,デバッガやプロファイラといったツールの支援を受けやすく,極端に時間がかかっている個所(これをボトルネックと言います)を容易に分析できます。仮に「あるメソッドが,本当は1回でいいはずなのに,無駄に1万回も実行されてしまっている」ということがわかれば,そのメソッドの呼び出し回数を減らすことで処理時間は1万分の1に短縮できます図a)⁠

図a 目に付きやすい細かい個所の修正より,見えにくいボトルネックの解消のほうが効果は大きい

図a 目に付きやすい細かい個所の修正より,見えにくいボトルネックの解消のほうが効果は大きい

著者プロフィール

結城洋志(ゆうきひろし)

株式会社クリアコード所属。Firefox黎明期からアドオン開発を手がけ,業務上もMozilla製品の技術サポートを担当。代表作は「ツリー型タブ」「テキストリンクなど。また,日経Linux誌にて「シス管系女子」を連載中。

コメント

コメントの記入