外部に依存しない。どうも、かわしんです。
複雑なプログラムをわかりやすく書くために、カプセル化 (Encapsulation) というテクニックが使われます。これは内部の詳細な実装や状態をモジュールのインターフェースの内側に隠蔽することで、外部からモジュールの内部を知ることなく使うことができるようにするというものです。多分、これは抽象化によるメリットで、カプセル化と抽象化は厳密には違うのかもしれませんが、ここでは特に区別することなく一緒にして扱います。
さて、プログラムを書くときにどの部分でクラスやモジュールの境界を分けるのかということは自明ではなく、良い境界を引くことは難しいです。そこで、良い境界の分け方と悪い境界の分け方の言語化を思いついたので紹介したいと思います。
悪い境界とは、自身の処理を正しく行うために外部の処理に依存するものだと僕は思います。
具体的には、外部からのメソッドの呼び出しの順番に強く依存したりパラメータの数が多くなってきたりするときには、それは間違った境界によってクラス・モジュール分けしてしまった可能性が高いです。
悪い境界が引かれてしまったプログラムは結果として、読みにくく、複雑で、将来の変更に弱い、メンテナンス性の低いプログラムになってしまいます。
良い境界が引かれたモジュールは、stable なインターフェースで stable な責任を果たします。そして、我々プログラマは、最終的にそれぞれのモジュールを組み合わせることでプログラムを組み上げます。将来の変更に対しては、モジュールの中身をいちいち変更するのではなく、その呼び出し方、組み合わせ方を変えることで要求の変更に対応します。
正しい境界を引くプログラムの書き方
以前書いた 55 日かけて OS を作った - kawasin73のブログ の "プログラミングの方法" の章で紹介したように、まず main() 関数に愚直に全ての処理を書き下して、その後に全体を眺めて共通する処理を関数やモジュールに切り出していくことで、正しく動くメンテナブルなソフトウェア を 0 から作り上げることができます。
プログラムを書く際には、設計と称して書く前にコンポーネントの境界を機能によって分けてしまいがちですが、これは往々にして間違った境界になりがちです。というか、そもそもプログラムを書く前にコンポーネントを分けることは難しいと僕は思っています。
それよりも、自分が愚直に書き下したプログラムを眺めてプリミティブな処理をモジュールとして切り出して、そのモジュール呼び出しの組み合わせによって最終的に読みやすいプログラムに変えていく方が結果的に将来の変更に強いロバストなプログラムになります。
これは UNIX 哲学 から学んだことでもあります。
境界の引き方の具体例
例としてログのローテーションをする処理を考えてみましょう。定期的にメトリックを収集しバイナリにコンパイルされたログをファイルに追記して、ファイルサイズが大きくなったら新しいファイルを作って書き込むということをします。Java っぽい疑似コードで書くとこんな感じで定期的に writeLog() が呼ばれます。
class Service {
private FileWriter writer;
private boolean rotateFile() {
writer.close();
renameFile(currentFilePath, previousFilePath);
writer = FileWriter.create();
if (writer == null) return false;
writer.write(initialSchemaData);
return true;
}
void writeLog() {
Data metricsData = collectMetrics();
metricsData.timestamp = now();
if (writer.getSize() + metricsData.getSerializedSize() > FILE_SIZE_LIMIT) {
if (!rotateFile()) return;
}
writer.write(metricsData);
}
}
class FileWriter {
private FileOutputStream outputStream;
private int fileSize = 0;
static FileWriter create() {
FileOutputStream outputStream = currentFilePath.open(O_TRUNC);
if (outputStream == null) return null;
return FileWriter(outputStream);
}
int getSize() {
return fileSize;
}
void write(Data data) {
outputStream.write(data.serialize());
fileSize += data.getSerializedSize();
}
}
FileWriter はファイルサイズを in memory でトラッキングすることで、ファイルサイズのチェックを stat(2) システムコールに頼ることなく行うことを目的としています。
このコードを見て、FileWriter は fileSize を管理しているがその値は FileWriter の中では使われていないため FileWriter は役割が少なすぎると思われるかもしれません。それよりも、ログローテーションのロジックを FileWriter.write() に入れ込んで Service.writeLog() からローテーションの実装を隠蔽した方がいいと思われるかもしれません。
しかし、FileWriter が fileSize の管理しかしないことは、悪い境界の引き方ではありません。なぜならば FileWriter は呼び出し元の呼び方に関わらずファイルのサイズをトラッキングし続けるからです。外部の処理に依存しません。
一方で、FileWriter にファイルのローテーションのロジックを追加したとき、こんな感じになります。
class Service {
private FileWriter writer;
void writeLog() {
Data metricsData = collectMetrics();
metricsData.timestamp = now();
writer.write(metricsData);
}
}
class FileWriter {
private boolean rotateFile() {
outputStream.close();
renameFile(currentFilePath, previousFilePath);
outputStream = currentFilePath.open(O_TRUNC);
if (outputStream == null) return false;
write(initialSchemaData);
return true;
}
boolean write(Data data) {
if (fileSize + data.getSerializedSize() > FILE_SIZE_LIMIT) {
if (!rotateFile()) return false;
}
outputStream.write(data.serialize());
fileSize += data.getSerializedSize();
}
}
確かに、ローテーションのロジックが中に入り、Service.writeLog() はファイルのローテーションを気にせずに FileWriter.write() を呼べば良くなりました。
しかし新しい仕様として、定期的に収集されるログであるためタイムスタンプは最初のメトリックファイルにだけ挿入してそれ以外はタイムスタンプの設定をスキップする変更をしたいとします。以前の実装であれば、以下のようにメインの Service.writeLog() を変更するだけで、FileWriter のロジックは変わりません。
class Service {
void writeLog() {
Data metricsData = collectMetrics();
Instant now = now();
if (!isCorrectInterval(now)) {
metricsData.timestamp = now;
}
if (writer.getSize() + metricsData.getSerializedSize() > FILE_SIZE_LIMIT) {
if (!rotateFile()) return;
metricsData.timestamp = now;
}
writer.write(metricsData);
}
}
しかし、FileWriter がログローテーションの責務を負ってしまった場合は、途端に変更が難しくなります。方法としては2つあって、
FileWriter.write()の引数にローテーションされたときに設定するタイムスタンプを追加するFileWriter.write()でローテーションが発生したときにはデータの書き込みを行わずに false を返し、Service.writeLog()にもう一度タイムスタンプを付与したDataで書き込み直してもらう
となると思います。前者は特定の状況でしか必要にならない引数が増えてしまい無駄です。後者は呼び出し側がローテーションの有無を知る必要が出てきてしまい、カプセル化で隠蔽したはずの情報が外に出てきてしまいました。また、いずれの場合も FileWriter と Service の両方に修正が必要になっています。
このように、ひとつのことをうまくやるコンポーネントを定義しその責務が将来に渡って変わらないように境界を選ぶことで将来の変更に強いプログラムになりますし、逆に不用意に責務を拡大させることで将来の仕様変更に脆いプログラムになってしまいます。
まとめ
僕が普段無意識にやっているロバストな境界の引き方について言語化してみました。僕も昔 DMM でインターンしてた初期は、まずクラスの分割を考えてからプログラムを書いて難解なプログラムを書いてしまっていたので、誰でも最初は通る道だと思います。
これから AI がプログラムを書くようになると、一般的なプログラミングのレベルに引きづられて下がってしまい、こういう将来への変更のロバストさがより重要になるのかもしれません。または、AI の腕力によって必要のないものになってしまうのかもしれません。知らんけど。