ソフトウェア設計についてtwada技術顧問と話してみた 〜 A Philosophy of Software Design をベースに 〜

はじめに

スタンフォード大学の John Ousterhout 教授が執筆された “A Philosophy of Software Design”(以下 APoSD と略す) という書籍をご存じでしょうか? 書籍のタイトルを直訳すると、「ソフトウェア設計の哲学」となります。書籍の内容はまさに、ソフトウェア設計について扱っています。

本書籍をベースに、「A Philosophy of Software Design を30分でざっと理解する」というお題で社内ランチ勉強会が開催されました。本記事執筆者である岩瀬(@iwashi86)が発表者であり、勉強会資料は以下のとおりです。

スライド P.4 に記載したとおり、本書籍は John Ousterhout 教授の意見が強く反映されており、ソフトウェアエンジニアであれば、議論を呼ぶ箇所があります。実際、勉強会の実況Slackでは、「これはどうなんだろう?」といった疑問があがっていました。

本記事では勉強会であがっていた疑問について、twada 顧問と対話した内容を紹介1していきます。読者の皆様のソフトウェア設計に関する知見の拡大が記事のゴールです。

なお、事前の免責が1点あります。対話の起点は APoSD ですが途中からソフトウェア開発全般のトピックに派生していきます。「お、APoSD から外れて、ソフトウェア設計全般のトピックに派生していったな」という気持ちで読んでいただけるとありがたいです。

さて以降は、大きく2部構成となります。前半では、 APoSD での主張概要を紹介します。後半を読むためのベースラインを合わせるのが狙いです。SpeakerDeckの資料をお読みの場合は飛ばしていただいて構いません。後半は、本題となるtwada 顧問との議論パートです。早速いってみましょう!

APoSD の概要

APoSD の主題は「複雑性」です。この複雑性はソフトウェアの構造に関するものであり、システムの理解や修正を難しくするものです。システムの理解や修正が容易であれば、そのシステムは「複雑ではない」ということになります。仮にどんなに大規模なシステムだったとしても、理解や修正が容易であれば、APoSDの定義では、複雑ではないシステムになります。

複雑性が増大すると、以下の3つが起こります。

  1. Change Amplification (変更の増大)
  2. Cognitive Load (認知的負荷)
  3. Unknown Unknowns (未知の未知)

たとえば、一見単純に見えても変更箇所が多い場合が 「変更の増大」 に該当します。また、覚えるべきAPIや、気にしないといけない変数が多い、といった場合は「認知的負荷」の増大につながります。さらに、あるタスクを完了させたいとして、何を変更すればいいかわからない状況が未知の未知となります。APoSDでは、未知の未知が3つの中で最悪と述べられています。なぜならば、何らかの変更を加えても、バグが出るまで発見できないためです。

このような複雑性はどこから生まれてきてしまうのでしょうか?APoSDではその要因として、

  1. Dependency (依存性)
  2. Obscurity (不明瞭性)

の2点が挙げられています。たとえば、何らかのモジュールを操作する場合に、1つのモジュールで完結できない場合は、依存性がある状態になります。また、コードを読み解かないとわからない場合は不明瞭性がある状態となります。一例として、 “time” という変数があった場合、単位などの補足がなければどのように理解していいかわかりません。

この複雑性に、ソフトウェアエンジニアはどのように立ち向かえばいいのでしょうか? APoSDでは、その方法として以下2点が挙げられています。

  1. 複雑性の排除(たとえば、特別なケースを排除する)
  2. 複雑性の隠蔽(たとえば、難解な部分が見えなくても使えるようにカプセル化する)

書籍では、この1と2を中心に、全22章でより具体的に説明されていきます。詳細は原著に譲るとして、ここから一部のトピックについて当社技術顧問のtwadaさんと議論していきます。

技術顧問とAPoSDについて話してみた

小クラス主義 と 大クラス主義

APoSD では4章の中で、以下のソースコードが記載されています。

FileInputStream fileStream  = new FileInputStream(fileName);
BufferedInputStream bufferedStream = new BufferedInputStream(fileStream);
ObjectInputStream objectStream = new ObjectInputStream(bufferedStream);

やりたいのは、ファイルを開いてシリアライズ化されたオブジェクトを読み込むことです。クラス設計としては、Gang of Four デザインパターンでいう、Decorator パターンが活用されています。結果として、小さいクラスを組み合わせて実装を進めるコードになっています。

本件を勉強会で話していた際に、Slackでtwada顧問からコメントが上がっています。

これについて、以下で深堀りしていきます。

iwashi: 小クラス主義ってことは大クラス主義があります?

twada: はい、小クラス主義と大クラス主義があります。私のこれまでの言語キャリアから考えると、小クラス主義の代表はJavaで、大クラス主義の代表はRubyです。

小クラス主義の例としては、Javaの先ほどの File I/O がわかりやすいですね。大クラス主義におけるRubyの例としては、 Array とか String のクラスに多くの機能や責務がありますね。少ないクラスが、それぞれ多くの機能を持つ。必然的にクラスが大きくなります。

iwashi: この辺りは、APoSDの主張とどう関連がありますか?

twada: APoSDの著者が述べているのは、Deep Moduleが良いということですよね。Deep Module(深いモジュール)とは、インターフェースが狭くて実装が深いことです。反対に、Shallow Module(浅いモジュール)はインタフェースが広いわりに、実装が浅いことを意味します。

これ自体は小クラス主義とも、大クラス主義ともちょっと違います。ただ、小クラス主義は必然的にShallowなModuleに近づきがちです。

iwashi: Shallow ModuleのようにIFが広いのを避けたいのは、認知負荷の増大でしょうか?

twada: そうです。よく似た機能がいくつもあって、その中から選ばないといけない、利用者としては認知的負荷が高い。

よく似ていて、ちょっとずつ機能が違うクラスがたくさんあり、その中から選んで使ってください、みたいなパターンはありますよね。あるいは、よく似ていて、ちょっとずつ違うメソッドがあるというパターン。これも認知負荷が高いですよね。

Javaの設計思想

iwashi: 小クラス主義にも一定のメリットがあるのかと思います。たとえば、Javaのもともとの設計思想だと何を意識されていたのでしょうか?

twada: これから述べる課題は最近のJavaでは解決されていますが、歴史からお話します。

もともとのJavaの設計の背景にあったのは、すべての状況を破綻なく扱える設計を提供しようという価値観です。

たとえば巨大なファイルを開こうとすると、out of memoryになり得ますよね。だから、たとえば、ArrayとかListにファイルの全行を簡単に読み込むのは、意図的にできないようにしていました。それよりは、どのような場合でも適切に動く汎用的な組み合わせを提供しています。たとえば、InputStreamを開いて、InputStreamReaderでCharレベルに変換して、BufferedReaderでバッファリングするという例のコードになります。

「これは理屈は分かるんだが、めんどくさいよね」「正しいけど、面倒だよね」という話になるんです。ちなみにこの問題はJavaの進化と共にだんだん解決されていき、現在では単純な問題は短く解決できるようになりました。この正しさと面倒くささの問題に対してRubyも大クラス主義の観点からアプローチしています。

Rubyでの設計例 - utc_offset

Rubyの標準ライブラリの設計者はMatzさんを中心に何人かいます。その中の1人の田中 哲(akr)さんは、正しさと使いやすさの関係をとても敏感に考えています。正しいやり方が簡単でないと、みんな正しいやり方を使ってくれない。だから、正しいやり方がもっとも簡単であるべきというAPI設計をしたんです。

簡単だけど若干間違っているコードと、正しいけどすごく面倒なコードがあるときに、人は簡単だけど若干まちがっているコードに引き寄せられがちです。その典型例は、UTCからの時差の求め方。時刻処理は正確にやろうとするとかなり面倒な部分があります。具体的には、うるう秒があると一秒ずれるとか。この部分を正しく実装しようとすると、2行だったものが8行ぐらいになるわけです。すると、誤差があっても簡単な方に開発者は寄っていきます。

そんなとき、田中さんはRubyのTimeクラスにutc_offsetを定義して、正しいけど面倒なやり方を1回のメソッド呼び出しで済むように設計しました。Timeクラスのutc_offsetメソッドを呼び出すほうが明らかに短くてかつ正しいやり方というわけです。「一番正しいやり方が、一番短くて簡単であるべき」という考え方によって正しい方向に利用者を誘導する設計をしたわけです。この辺りの詳細は『APIデザインケーススタディ ――Rubyの実例から学ぶ。問題に即したデザインと普遍の考え方』に載っています。

良いインタフェースとは?

実は同じ主旨の内容が、プログラマが知るべき97のことの1つにも載っています。

良いインタフェースとは次の2つの条件を満たすインタフェースのことです。

  1. 正しく使用する方が操作ミスをするより簡単
  2. 誤った使い方をすることが困難

このような考え方に照らし合わせると、小クラス主義で、利用者に適切な組み合わせ方や責務の選び方を委ねるのもなかなか難しいな、という話になります。もちろん、言うは易く行うは難し、という話でもあります。

iwashi: なるほど、たしかに設計のもっとも難しいポイントですね。

1つのことを実現するために1つの正しいやり方がある、というのはシンプルでとても素晴らしいと思うのですが、現実として、1つのことを実現するために複数のやり方がある例って、これまでどのようなものがあったのでしょうか?

twada: 古典である『プログラミング作法』から1つ紹介します。この中では、Cの標準ライブラリが紹介されていますね。たとえば、アウトプットストリームに1文字書きこむ場合に、putc、fputc、fprintf、fwriteがありますね。

iwashi: たしかに利用者側は混乱しますね。ライブラリを作るなり、ビジネスロジックを作る場合には、そういう設計を避けるのが大事ということですね。

twada: そうです。たとえば、『プログラミング作法』から引用すると

少なくとも、どうしても関数を増やさなければならない明確な根拠が 生まれるまでは、広いインターフェイスよりも狭いインターフェイスのほうが 望ましい。1つのことだけ実行し、それをうまく実行すること。 可能だからというだけでインターフェイスに追加してはならないし、問題があるのは実装のほうなのにインターフェイスに手直ししたりしないこと。 たとえば、速度面で有利なmemcpyと安全面で有利なmemmoveがあるよりも、常に安全に利用でき、できれば高速に動作する関数が1種類存在する方がいい。

とあります。

iwashi: なるほど。

似た機能でちょっと違うものの実装ってどうする?

iwashi: 少し話を戻して、「似た機能でちょっと動作が違う場合」というのは現実の開発現場でよく出会う例かと思います。これって、どうすればいいんでしょうか?

twada: たとえば、動作をオプションで変えるかどうか等で悩むことになりますよね。APoSDでも言及されています。 この点は、私だったらリファクタリングをしていきます。

iwashi: リファクタリングの方向性は?

twada: 基本戦略としては、よく似ていてちょっとずつ違う箇所があるときに、ちょっとずつ違う部分をくくりだしていきます。そうすると、完全に一致する部分と、それぞれ違う部分に分かれていきます。完全に一致する部分は外部に抽出して共通化できます。昔は親クラスに抽出して継承による差分プログラミングをしていた時期がありましたが、現代では推奨されません。オブジェクトを組み合わせ、共通部分を委譲していきます。

その際にインタフェースを広げないように、バリエーションを追加するのにポリモーフィズムを使います。ここは、APoSDの著者とは流派が違うかもしれませんね。

iwashi: ポリモーフィズムの箇所の実装イメージをもう少し具体的にお話しいただくとどうなりますか?

twada: まず、よく似ていてちょっとずつ違う部分で共通を抽出すると、共通部分の大部分は利用者が直接触らない部分に移動します。共通部分の多くは利用者に露出していないので、設計はある程度自由になります。

次に、新しいバリエーションが増えるときに、それをどう扱うか、という話になります。1つ目のユースケース、2つ目のユースケース、3つ目のユースケースで似たような抽象が見て取れるのであれば、共通部分との界面にインタフェースを導入して、汎用的な実装として組み合わせられるようにします。リファクタリングが結果的にドメインモデリングに近づいていきます。

iwashi: これは共通部分の抽出とは違うってことですか?

twada: そうです。具体例を出します。

たとえば、私が受託して開発していたある学内システムがあります。最初はあるレポートを出すという要件が1つあり、機能は一枚岩で作られていました。しばらくして新たな連携先がでてきて、ちょっとずつ要件が違うレポートを出す必要が増えました。

  • 学生向けはその学生だけの情報が出る
  • 教員向けレポートならクラス全員の情報が出る
  • 担当クラスの教員向けには全情報を出すが、担当外の教員が見る場合は、いくつかの情報がマスクされる
  • 今後は連携している他大学の教員もレポートが見えるようになる予定。その際にはさらに情報がマスクされる

このようによく似ているけど、ちょっとずつ違う要件が出てくるんですよね。単純にやるならば、if文で条件分岐します。閲覧するユーザも引数で渡すとか。

iwashi: 一般ユーザーと管理者ロールで分けるべきケースとかですよね。

twada: そうです。

新しいロジックが入るたびに、if文が増えることになります。ただ、リファクタリングしていくと、次のような気づきがあります。

たとえば「何を見せる」「見せない」というロジックは、データレベルの認可、データ可視性のロジックになっているわけですよね。リファクタリングしていく中で、完全に同じ部分と違う部分を寄せる・集めていくと、違う部分というのはだいたいにおいてこのようなロジックであることに気づきました。

そうなると、何をやればいいかというと、ユーザーが誰かではなく、レポートの元データの抽出機能とコンテクスト毎に異なるデータのフィルターがあって、2つの組み合わせで動けば要件を満たせる、という話になります。

たとえば、ログインユーザーがその授業の担当教員の場合は全部見えるフィルターを渡します。フィルターの中身は実質的にはNull Objectパターンで良いわけです。学生だったら、担任以外だったら、学外だったら、という形で各々のフィルターインスタンスを作れるようにします。GoFのStrategyパターンですね。それを状況に応じて共通のレポーターに渡せばいいわけです。 (学生の場合は全員検索してからフィルターするよりもそもそも自分のデータだけを抽出する方が効率が良いので、まずは動くもののパフォーマンス改善の余地が大きいバージョンでリリースし、後にデータ抽出部分のフィルタリングも含んだ複合的なインターフェイスになりました)

iwashi: Javaで実装するならフィルターの interface を定義することになりますか?

twada: Javaでいえばそんな感じですね。

このレポートの場合はいくつかのユースケースがありますが、総じて何を見せるか・見せないかのロジックの差異が多かったので、ロジックの差異を表現したクラスを作って渡していく。そうすることで、レポート機能のコードはほぼ変更無く、新たなフィルタリングが提供できます。たとえば今後は連携している他の大学の教員もレポートが見えるようになり、そのときはマスクする項目がさらに増える予定です。この場合も、他大学の教員用のStrategyクラスを用意すれば良いことになります。

これは、継承に頼ると出てこないレベルの抽象なんです。継承でやるなら、AbstractReporterが出てきて、if文が抽象メソッドになってて穴埋めする実装になるでしょう。具象クラスに穴埋め部分を実装してフィルタリングするわけですね。つまり、親クラスが抽出を行い、サブクラスがフィルタリングをする。この方向性だと、データ抽出とデータレベルの認可が異なることに気づきにくい。

iwashi: これは差分クラスに近い考え方ですね。GoFパターンでいえば、Template Methodパターンに近いですね。

iwashi: Template Methodパターンって、現代だと筋が悪いのでしょうか?

twada: 筋がいいのは、現代ではあまりありませんが、処理の順序を示すパターンですね。たとえば、ユニットテストにありますよね。

iwashi: テスト前のsetUp、テスト後のtearDownですね。

twada: そうです。ただそれも、Template Methodパターンが必須というわけではありません。 たとえばObserverパターンでも実装できますよね。現代においては、そもそも継承をあまり使わなくなってきています。

APoSD の内容で継承を考えると?

iwashi: なるほど。この辺で、もう一度APoSDに戻りたいのですが、APoSDで述べられていた次の3つの複雑性から招かれる事象と、継承の関連は何なのでしょうか?

  1. Change Amplification (変更の増大)
  2. Cognitive Load (認知的負荷)
  3. Unknown Unknowns (未知の未知)

twada: 継承は親クラスとの間に強い依存関係が生まれますよね。継承の使い方を誤ってしまうと、継承階層が深くなります。また処理を読み解くときにサブクラスを読んで、次に親クラスを読んで、親の中身を見たと思ったら、またサブクラスでオーバーライドされていた、みたいな不可解さを持つことがあります。

iwashi: APoSDでは、複雑性の要因は APoSD で次の2つがあがっていましたね。

  1. Dependency (依存性)
  2. Obscurity (不明瞭性)

まさに、この2つにもつながるわけですね。不明瞭性でいえば、コードの距離が遠くなりますからね。

twada: そういうわけです。というわけで、最近出てきたプログラミング言語では継承という概念がないことも増えてきて、継承ベースのテクニックが推奨されなくなってきたんですね。

おわりに

本記事では、前半でAPoSDの概要を紹介しました。後半では、小クラス主義・大クラス主義、インターフェース設計、リファクタリングによるドメイン抽出といった内容の対話を紹介してきました。

実は、後半の対話内容は全体の内容のうち、半分程度しか記事に起こせていません(半分だけでも非常に多いかも?)。本記事が好評でしたら、後半パートや、続編を検討していきますので、 Twitter などでフィードバックいただけますとありがたいです!


  1. 「fukabori.fm の文字起こしじゃないか」と思った方。だいたいあってます。

© NTT Communications Corporation All Rights Reserved.