複雑さを相手に抽象化を盾にしましょう

こんにちは、サーバーサイドエンジニアの Steven です。

この記事は Enigmo Advent Calendar 2020 の 11 日目の記事です。

抽象化という単語とその議論をそれほど目にすることがありませんが、設計においては極めて重要な概念だと思いますので、ここで抽象化は何を指すのか、何のためのものなのか、どうやるのかを説明してみます。

ソフトウェア・エンジニアリングとは

それが明確となっていないと、どうして抽象化が必要なのかは曖昧となってしまうこともあるかと思いますので、まずは方針にしていることについて語ります。

解釈は複数あると思いますが、一つの文章で表すと、ソフトウェア・エンジニアリングとは人間のアイデアアルゴリズムに変換することだと思います。 人間の観点で、不確定で無限とも取れるアイデアを、限り有る計算関数の組み合わせで有限なものに変えるとも取れます。 決まった特定な目的を果たすために、有限なものだけを使って何かを作ることから、問題解決とも取れると思います。レゴ作りやパズル解決という比喩は気に入ってます。 以上の説明のキーワードは「変換」と「問題解決」です。

一つ一つ個別なものと捉えると、ウェブ業界の通常の仕事では実に難しい問題はそうそうないと思います。業界として今まで見たことのない新しいアルゴリズムの発明はまず必要ないです。 ただし、仕事で求められるのは人間の言葉で表現される高レベルな問題の解決がほとんどで、その一つの大きな問題を解けるにはお互いに影響し合うたくさんな小さな問題を同時に解決しないといけないです。 なので、そういう問題は本質的に難しいというより、複雑という方が相応しいと思います。 したがって、エンジニアの主な評価基準はどれだけ難しい問題を解決できるかというより、如何に複雑さを抑えて、大きい問題を簡単に解決する・保つかの方です。

高校時代に歴史と地理学の先生から聞いた言葉ですが、「難しくするのは簡単。簡単にするのは難しい。」というのが印象的で今にも覚えています。パラダイムシフトとも言えたかもしれません。 「やっぱりわからないから、すごい」のではなく、「思ってたより、ぜんぜん簡単でわかりやすい」の方を目指すべきです。 解決策が簡単だったのはもともとの問題が簡単だったからというのはまずなく、エンジニアが頑張ったから、最終的には簡単な解決になったという方が正しい解釈だと思います。

その中で抽象化はものを簡単に保つための手段となります。

抽象化とは

抽象化という概念自体は抽象的なので、一つの文章で具体的に説明しきるのは難しいですが、以下のように解釈しています。 抽象化とは、特定の問題を概念として分析と分割し、単一の要素として扱えるようにした上で、その要素を組み合わせることでより大きい問題の解決に汎用的に使えるようにすることです。 抽象化をトピックごとにより細かく説明します。

抽象化の目的

大きい問題はいつもより細かい問題で構成されてます。 最上層にある、ユーザーに提供したい高レベルな結果(ボタンで操作できるカート画面)と、最下層にある、実装上の低レベルな詳細(カートの SQL テーブルに適用ポイントを保存する)は両方意識しやすくて、それだけをベースにそのまま開発に入ることがあったかもしれません。 ただし、その両端の間にはとんでもない距離があり、最下層からそのまま最上層を実装しようとすると、結果が凝ったものであればあるほど、開発の効率が下がり、目標は達成しにくくなります。 なぜなら、最下層から最上層まで一気に何かを実装しようとすると、さまざまな、関係のない詳細を一気にかつ同時に気にしないといけなくなるからです。開発の負担が単純に大きすぎるものになってしまいます。

なので、その残念な、非効率な開発環境を避けるために、大きい問題をより小さい問題に分割し、問題を部分的に解決できるようにします。 解決の負担が減った小さい問題を解けられたら、結果の要素を繋ぐことで大きい問題は簡単に解決可能となります。

馴染みのある例に例えるなら、車を作るのが目的な場合、構成が曖昧なまま車を一気につくるより、動力機関、燃料容器、収納スペースなどと概念として分析してから、それぞれの要素を設計・作成し、最終的に全部を組み合わせる方が効率的です。 以上で問題をものに例えたのですが、ことにも例えられます。 生き物としてエネルギーを得るということを分析するのでしたら、食べ物の探し、入手、調理、飲食、分解、摂取などと、ステップにも分けられます。

それが抽象化です。大きい問題をそれぞれより小さい問題に分けて、構造を見出すことです。 どうしても解決できない問題が相手の場合、その問題をそれぞれの概念として分析し、より細かい問題として分割した上で、再度挑戦するのがいいかと思います。 そうすれば、実に難しい問題は意外と少ないかもしれません。

抽象化の特徴

役に立ついい抽象化にはいくつかの特徴があります。大きく分けて、以下の2つにまとめてみました。

問題の構造化

問題を分割するとしても、正しいやり方と正しくないやり方があります。

純化が目的なので、そもそも簡単というのはどういうものなのかを明確にする必要があります。 簡単なものは本質までさかのぼった場合、一つだけなものとして考えるものです。一つの概念、一つのパターン、一つの責任など。複数な概念を合わせることで初めて成立するものであれば、もはや簡単ではないです。 ただし、その一つなものがより小さい複数なもので構成されていても、簡単じゃなくなるわけではないです。一つとして考えられれば、その時点で簡単です。

なので、問題を分割するに当たって、同じように、分割されたものをそれぞれ独立した、一つ一つとして考えられるものにするべきです。 複数な概念がオーバーラップするような、曖昧なものが分割の結果でしたら、それほど問題の単純化には貢献しないものとなるからです。

大きい問題を分割する際は、木構造の要領でものを分けて、それぞれの部分の大きさを抑えながら、大きいものからどんどん小さいものに構造化するのが望ましいです。 そうすることで、一つ一つの問題の解決は同程度の難易度になって、全体の単純化に繋がります。高レベルな問題は低レベルな問題と同じぐらいの努力で解決可能となります。 同時に、特定の細かい問題がどの問題の一部となってるのかも明白になって、把握がしやすくなります。

木の一つのノードを分けるとして、枝の間に共通点が少ない場合は、枝の数も抑えるべきです。 なぜなら、実装で5つの枝を一つのノードに集約するのがそれほど難しくなくても、共通点のない枝が 20本もあれば、集約がそれなりに難しくなります。 どうしても枝の数が多い場合は、共通点となる概念をベースに、一部の枝を一つにまとめて、新しい子ノードで問題をまた分割すれば大丈夫です。

最初は少なくても、改修で一つのノードの枝の数が少しずつ増えないようにするには、最初から分割の結果を、元の問題の 100% をカバーする、同じ抽象レベルのものにするのがいいと思います。 商品の購入過程はかならず選択、購入、受け取りの3つのステップに分けられますので、はじめからそうと分割すれば、後から枝の数が増える可能性が低いです。

まとめると、問題の構造化において、いい抽象化なら、問題は - 独立した概念として分割される - 木構造として構造化される - 同程度の大きさとして分けられる - 木として各ノードの枝の数が抑えられる - 木として同じノードの枝は同じ抽象レベルにある

そんな風に問題を分割すれば、それぞれの問題の解決は実装しやすくなります。

インターフェイスの単純化

問題がうまく分割されれば、その時点で簡単になります。 ただし、それだけではそれぞれの問題の解決策のつなぎ方が簡単になるとは限らないので、インターフェイスの面でも複雑さを抑える必要があります。 この項目ではより具体的な説明になるので、問題の解決として「機能」という単語を使います。

特に考慮せず、機能一つ一つをそのまま実装するだけだと、その機能を使うためのインターフェイスは機能よりになってしまいます。 ただし、そのそれぞれの機能は皆違いますので、インターフェイスの間の互換性がいいものにならず、機能を繋ぐだけでかなりな努力が必要となります。 なので、それを避けるため、インターフェイス自体を簡単なものに保ち、共通言語でそれぞれの機能をつなげるようにする必要があります。

いいインターフェイスには以下の特徴があります。

  • インターフェイスは包まれてる機能と同じ抽象レベルで表現されてます
    • 名称(クラス名、メソッド名、引数名など)がその抽象レベルに合わされてます
    • その抽象レベルに合わない実装詳細は表に出ません
  • インターフェイスのエンドポイントは最小限に抑えられてます
    • 機能を活用するために必要なオペレーションのみが公開されてて、利用方法が明白です
  • インターフェイスが必要とする引数の数が抑えられて、少ない加工でもその引数を簡単に提供できます
    • 必要のないデータまでを求めませんが、呼び出し元で準備が必要となってしまう細かすぎるデータも求めません
    • 機能と同じ抽象レベルのデータを引数にします
      • たとえば、商品の価格を計算する機能では、商品モデルを受け取るだけでも問題ありません
  • インターフェイスの返り値も引数と同じルールに従って、他の機能でそのまま活用できます
  • インターフェイスは基本的にステートレスです
    • メソッドをどんな形でどれだけ呼んでも、内部ステートが変わらず、機能の結果に影響しません
    • 最終的のステートを格納するモデルクラスは例外です
  • インターフェイスの実装詳細が隠蔽されてます
    • 呼び出し元が実装の詳細を気にする必要がありません
    • 後から実装が変わっても、変更なく機能をそのまま利用できます(実装の詳細が漏洩しません)
  • インターフェイスはコンテキストには必要以上に依存せず、他のコンテキストでも再利用できます
    • 活用する場合に、必ず他の機能と併用しないと使えない状況に陥ることがありません
    • コンテキストがなくても機能をそのまま理解できます

その特徴を持つインターフェイスを実装するのが難しい時がありますが、どれだけインターフェイスを高レベルなものに保てたかによって複雑さが決まることが多いです。 インターフェイス設計の過程で機能の実装自体が難しくなることがありますが、難しい実装と比べて難しいインターフェイスの方は影響が大きいので、選ぶ必要がある時は実装よりインターフェイスの方を簡単に保つべきです。

抽象化のメリット

うまく抽象化できれば、様々なメリットが現れます。

単純性

  • 全体的にわかりやすくなるので、調査にかかる時間が短縮されます
    • もともと実装した人にとっても、触ったことがない新人にとっても
  • 抽象化を考慮する時間が必要となりますが、実装自体にかかる時間は減ります
    • 問題をそれぞれ個別として扱えるようになるので、一つの問題のみに集中できるようになります

柔軟性

  • 各機能は明確に隔離されるので、一つの機能の修正が他の機能に与える影響が減ります
    • ものをより自由に変更できるようになります
  • 各機能は高レベルなインターフェイスで包まれるので、機能の間に新しい機能を追加するのが簡単になります
  • 各機能のコンテキストへの依存も抑えられるので、リファクタリングがよりやりやすくなります
    • 機能の再編など
    • 機能の実行順番の変更など

保守性

  • 単純性と柔軟性の改善から、保守性もそのまま向上されます
    • バグ発生時にどこを修正すればいいのかがより早くわかります
    • 該当箇所を修正したら、漏れが発生しにくくなります
      • 一つの問題が一つのところで対応されるので
    • 密結合状態が避けられるので、リファクタリングの必要性も減ります

安定性

  • 同じ理由で安定性も改善されます
    • 問題の分割で漏れにはより早く気づくので、仕様漏れやバグの発生率は減ります

テスト性

  • 機能一つ一つは独立するので、ユニットテストも実装しやすくなります
    • コンテキストとテストデータの準備で必要となる努力は減ります
    • 単一責任に重点が置かれるので、複数の関係のないものを同時にテストする頻度も減ります

抽象化をするには

うまく抽象化をするには何を気にするべきか、どのステップを取るべきかを紹介してみます。

概念の分析

抽象化と関係がないことですが、まずするべきなのは対象の案件を具体的なものにすることだと思います。 道標となるメインな仕様があるとして、エッジケースがあるのか、コンテキストが何なのか、未定なところがあるのか、というところを洗い出します。

それができたら、その案件を概念として分析します。 問題を解決するために必要となるデータ(モデルなど)には何があるのかを、「問題の構造化」で紹介した問題の分け方を活かして、分析します。 データを細かく分析できたら、その次に処理(関数など)の分析をします。 問題解決のためにどの処理が必要なのかを洗い出します。

ドメイン層(ビジネスロジック)とアプリケーション層(フレームワーク)を明確に分けることも望ましいです。 うまく分けて、問題をそれぞれの層の独立したものとして分析できれば、全体が単純化されることが多いです。

簡単に実装できそうな大きさの、曖昧なところのない、一つ一つな要素になるまで、データと処理の分析を繰り返します。 抽象化の一番難しい作業は以上の分析になるので、クリアできたら、残りのステップは簡単です。

Tips

分析結果で不可分と見える一つのデータか処理がやはり大きいという印象を抱くことがあるかもしれません。 一見では不可分ですが、大きいと見えたなら、おそらく複数な違う要素でさらに構成されてます。 その要素を暴き出すために、質問を問いて、そのデータか処理の本質を探し出すのがいいかと思います。 そのものは何なのか、何が目的か、実装するには何が必要かなど。

同じく、分析でうまく表現できないデータか処理が現れるかもしれません。 そのものをどう実装できるのかがよくわからない時は新しい概念の導入を検討します。 商品というのはそのまま概念として成り立ちますが、一部の商品のみを扱える処理があるとわかったなら、商品には種類という概念を導入する必要があるかもしれません。 扱い方が全然変わってしまうなら、商品モデルにステートを表すメソッドかカラムを追加するだけのではなく、ラッパークラスを通して、モデルを抽象化するのが妥当な可能性があります(たとえば、購入できない商品対購入できる商品など)。 当然、処理のほうにも新しい概念の導入が必要となる場合があります。

概念としては、ステート、ポリシー、イベント、エラー、アクション、プレゼンター、ストラテジー、エクストラクター、ノーマライザー、セレクター、ヒストリーなど、ものとことのどちらにも無限とあります。 プログラムに自由に新しい概念を導入しましょう。

インターフェイスの用意

その次に、分析されたものに一つ一つインターフェイスを与えます。

インターフェイスの単純化」で紹介した特徴を意識して、簡単なインターフェイスの設計を目標とします。 簡潔にまとめると、以下の特徴を目指します。

  • 用途が伝わる抽象的な名称
  • 数の抑えられたエンドポイント
    • パブリックな関数やメソッドなど
  • 単純な引数と返り値
  • ステートレスなインターフェイス
  • 実装の詳細を隠蔽したカプセル化
  • 抑えられたコンテキストへの依存

最初から完璧なインターフェイスを設計することが難しい時があります。 そういう時はまず用途を果たすものを作ってから、そのインターフェイスを少しずつ改善していく方が効率的です。

基本的にインターフェイスの設計が終わってから、実装に入るべきです。 そうすれば、実装に左右されず、簡単なものが作りやすくなります。 ただし、実装で曖昧なところが多い時は実装をある程度進めてから、インターフェイスを設計するのもありです。

Tips

名称としては、要素の実装を必要以上に具体的に表さないながら、用途や目的を明確にした、周りと同じ抽象レベルなものが望ましいです。 クラス名、メソッド名、変数名など、どのものにも以上のルールを適用します。 高レベルなコンテキストで、WriteProductIdToRedisAddProductToCartの間で後者の方が望ましいでしょう。 なぜかというと、WriteToRedisProductIdは実装を直接表すものでありながら、その用途を表してないです。 クラスの実装と呼び出し元を調べないと、用途が何なのかがわからないという問題もあれば、実装が変わった場合、クラス名がその実装と合わなくなります。

簡単なインターフェイスを作るには、実装の詳細とコンテキストを一旦全部忘れて、設計したいデータや処理をブラックボックスとして考えるのがいいと思います。 そのインターフェイスでしたら、触ったことのない、コンテキストに疎い新人にとって、そのまま意味をなすものなのかを確認します。 本当に簡単なものであれば、インターフェイスを見るだけで、大体なことは理解できるはずです。

QA

実装が一つ完成したら、結果を振り返って、抽象化としての質を確かめるのがいいでしょう。

  • インターフェイスも実装もわかりやすいか
  • 用途と使い方に関してどこかに違和感がないか
  • 単一責任が保たれてるか
  • 実装が顕になってないか
  • その抽象レベルで不可分であるか
  • コンテキストへの依存が少ないか

何かよくないところを発見したら、概念の分析を確認するか、インターフェイスを調整します。

適用例

初期状況

現在進めている React プロジェクトでは、アナウンスという、特定の条件下で画面に表示される注意事項というものがあります。 同時に、エラーという、サーバーから受け取る動的に変わる説明事項もあります。 画面のデザイン上では、色を除いて、アナウンスとエラーは大体一緒です。

エラーの仕組みはすでに実装されていて、React コンポーネント内でエラー配列から該当エラーをタグでフィルターして、そのままレンダーするようになっていました。

const renderErrors = () =>
  errors.filter(Error.match({ tag: 'totals' }))
        .map((error, index) => <Error key={index} error={error} />)

return (
  <div>
    {renderErrors()}
    ...
  </div>
)

一方で、アナウンスはコンポーネント内で直接表示すべきかを計算して、そのままレンダーするようになっていました。

const renderCashOnDeliveryMethodAnnounce = () => {
  if (!(hasDeliveryMethodWithPrepaidFees && isPayOnReceipt)) {
    return
  }
  return <Announce title="着払いを選択しました。" details="..." />
}

ただし、そのやり方だと、コンポーネント一つ一つに表示条件とメッセージの定義を行わないといけなくて、DRY ではないところから保守性が下がります。 エラーの仕組みと似てるところも複数あったので、共通化ができるのではないかと思いました。

改善策

メッセージという新しい概念を導入

まず気づいたのは<Error /><Announce />というコンポーネントが大体一緒だったということです。 もとを辿れば、エラーとアナウンスはユーザーに何かを伝えるためのものなので、抽象化して<Message />としてエラーとアナウンスを再定義しました。 <Message />はただのメッセージであって、エラーやアナウンスの用途を考慮しないものなので、インターフェイスは汎用的です。

<Message importance={} title={} details={} />

importanceはメッセージの重要度を表しています。値としてはinfodangerがあります。 そのimportanceを使って、メッセージの色が決まりますので、<Message />が特に考慮していなくても、呼び出し元でアナウンスとエラーの両方をそのまま表せます。 概念にオーバーラップがないので、疎結合となります。

アナウンスという概念を明確に

このままでは、アナウンスという概念はコードには明確に現れず、表示条件と組み合わされたメッセージ以上のものにはならないです。 それだと、保守性は上がらず、すべてのアナウンスの改修が必要となれば、箇所の一つ一つを修正しないといけなくなるのと、アナウンスに関するルールも明確になりません。 アナウンスはどこからどこまでのものなのかが曖昧になってしまいます。

なので、その状況でアナウンスを明確なものにするため、アナウンスの定義、略してアナウンスの概念を導入しました。 アナウンスには重要度、文章、表示箇所と表示条件がありますので、定義でそれを明示的に表現します。

// announces.js

const ANNNOUNCES = {
  cash_on_delivery_method_selected: {
    importance: 'warning',
    title: '着払いを選択しました。',
    details:
      '商品価格に含まれていた送料分が引かれますが、別途、着払い料金が必要です。',
    tags: ['totals'],
    when: ({
      product: { hasDeliveryMethodWithPrepaidFees },
      deliveryMethod: { isPayOnReceipt }
    }) => hasDeliveryMethodWithPrepaidFees && isPayOnReceipt
  },
  // ...
}

ANNOUNCESオブジェクトのバリューはアナウンスの定義となります。

  • 重要度はimportance
  • 文章はtitledetails
  • 表示箇所はtagsで対象オブジェクトを間接的に指定します
  • 表示条件はwhenでカート商品というモデルを引数に定義します

アナウンスはすべて一つのファイル内で定義されてるので、アナウンス横断の修正は簡単になります。 タグでアナウンスが対象にするエリアを定義していますが、どのタグがどのエリアに当たるのかを決めるのはコンポーネントなので、疎結合です。 表示条件も、特定のコンポーネントでのみアクセスできるデータを使わず、どのアナウンスにも渡される汎用モデルを引数としているので、コンテキストには依存しません。 あとからアナウンスのタグや表示条件を変えても、コンポネントの方で何も変更なく、アナウンスがそのまま更新されます。

この修正でアナウンス機能は他のものから独立して、一つのものとして扱えるようになりました。

アナウンスとエラーのレンダリングを抽象化

アナウンスとエラーは両方メッセージとなりました。 または、アナウンスの表示箇所はエラーと同じくタグを使って指定できるようになりました。 なので、レンダー処理はアナウンスとエラーの間で抽象化可能となります。

return (
  <div>
    {renderMessages(cartItem, { tag: 'totals' })}
    ...
  </div>
)

以上のコードでは結局エラーかアナウンスかを意識せず、ただカート商品の、特定のタグのメッセージをレンダーするように単純化されました。 エラーはcartItem内にあるerrors配列が使われて、メッセージがレンダーされます。 アナウンスはannounces.js内の定義を対象に、cartItemtagを使うことでマッチするものを抽出して、メッセージがレンダーされます。 ただし、呼び出し元ではその内部処理を意識せず、より高い抽象レベルでメッセージをレンダーしてるだけです。 あとから、また違う種類のメッセージを自由に追加できます。

低レベルの詳細が抽象化されて、プログラムの単純性と柔軟性が改善されました。

終わりに

誰もが、ある程度の抽象化は意識せずにできてしまいます。ただし最大までにその概念を活かすには努力と経験が必要となります。 抽象化の目的でライブラリーを活かすのも重要であれば、ビジネスロジックの抽象化も必要不可欠です。

抽象化をうまく活かせたプロジェクトはリリース後でも修正が容易で、時間が経っても追加開発で特に難しくならないです。 ただし、抽象化されたものが少しずつ具体化して、どんどん変更しにくくならないように、気をつけて常に努力する必要があります。

バランスにも気をつけないといけないです。最大まで抽象化したものが逆に理解しにくくなることもあります。 抽象化と具体化の間のいい中間点を見つけるのが目的となります。ただし、高い抽象レベルでも名称がしっかりしていれば、大体問題にならないと思います。

抽象化は科学的な手順に沿って行うのも可能でしょうが、感に頼って抽象化するのが基本だと思います。 その感を育てるには経験を重ねないといけないですが、メリットが実に大きいので、コストパーフォーマンスがいいです。 新人と経験者の違いの一つは、どれだけ抽象化をうまくできるかというところにあると思います。

最後に、一見では難しいと見えた問題は、抽象化をうまく活かせれば、意外と簡単になります。 概ね、対応中の実装で一つや2つの概念が見えていないからこそ、複雑と感じてしまいます。 プログラム内でその概念を明確に表せれば、複雑さは大体解消されます。

明日の記事の担当はディレクターの神吉さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co