Help us understand the problem. What is going on with this article?

Javascript: classやinterfaceを知らない人に伝えたいリスコフの置換原則(初級者向け)

最近、勉強会をする機会があったり、部下を育てるような話が出てきたのでSOLID原則についてまとめようと思いました。
これ、Javascriptばかり書いている人に伝えようと思っても、そもそもクラスつかってなかったり、インターフェースなんてないし、なんて人も多いので結構説明に困るんですよね。。。

なので、Javascriptでの簡単な事例を通してSOLID原則を語るシリーズを書くことにしました。

今回は SOLID原則の 「L」 リスコフの置換原則(Liskov substitution principle)です。


こんなUIをつくる機能を実装する案件がありました。よくある画像とテキストがセットになったUIですね。

スクリーンショット 2020-08-08 23.50.30.png

取得できるデータはこんな感じです。

const data = [
  { thumbnail: '/path/to/image', text: 'text' },
  { thumbnail: '/path/to/image', text: 'text' },
  { thumbnail: '/path/to/image', text: 'text' },
]

HTMLをレンダリングする処理はこんなふうにしました。

function renderDOM(data) {
  return `
    <ul class="media">
      ${data.reduce((lists, item) => lists += `
        <li class="media__item">
          <div class="media__thumbnail">
            <img src="${item.thumbnail}" />
          </div>
          <div class="media__text">
            <p>${item.text}</p>
          </div>
        </li>  
      `, '')}
    </ul>
  `
}

とくに変わったところはないですね。

しかし後日、テキストにリンクが設定できるように機能が追加されてしまったとします。

data = [
  { thumbnail: '/path/to/image', text: 'text' }, // url がない古いデータ
  { thumbnail: '/path/to/image', text: 'text' }, // url がない古いデータ
  { thumbnail: '/path/to/image', text: 'text' }, // url がない古いデータ
  { thumbnail: '/path/to/image', text: 'text', url: '/path/to/page' }, // url がある新しいデータ
]

当然、処理も書き直さなくてはいけません。HTMLをレンダリングする処理はこんなふうに直しました。

function renderDOM(data) {
  return `
    <ul class="media">
      ${data.reduce((lists, item) => lists += `
        <li class="media__item">
          <div class="media__thumbnail">
            <img src="${item.thumbnail}" />
          </div>
          <div class="media__text">
            <!--
              ↓url があったらpタグをaタグで囲む分岐を入れる
            -->
            ${ item.url ? `<a href="${item.url}">` : '' }
            <p>${item.text}</p>
            ${ item.url ? `</a>` : '' }
          </div>
        </li>  
      `, '')}
    </ul>
  `
}

この修正をした際にこんなことを思うかもしれません。

・機能追加のたびに、ここ直すのめんどくさい
・機能追加のたびに、どんどんコードが複雑になりそう

理想は機能追加があった場合でも、既存の処理を修正しないようにしたいところです。
ここでリスコフの置換原則という考え方が役に立ちます。

wikipediaより引用

T 型のオブジェクト x に関して真となる属性を q(x) とする。このとき S が T の派生型であれば、S 型のオブジェクト y について q(y) が真となる。

これを読んですぐに理解できる人は多くないと思うので整理します

q(x)

今回の例では renderDOMメソッド のことです。

T 型のオブジェクト x

Tは、{ thumbnail: 画像へのパス, text: 文字列 } というデータ構造 のことになります。xは実際のデータのことです。

S 型のオブジェクト y

Sは、{ thumbnail: 画像へのパス, text: 文字列, url: URL文字列 } というデータ構造 のことになります。yは実際のデータのことです。

x に関して真となる属性を q(x) とする

これは今回の例では、「renderDOM{ thumbnail: 画像へのパス, text: 文字列 } というデータを前提に実装されている」ということです。

S が T の派生型であれば:

「urlがあるデータ」は「urlがないデータ」の派生型だと考えるということです。

e.g.

T: { thumbnail: 画像へのパス, text: 文字列 }
 ↓ 派生( url 追加)
S: { thumbnail: 画像へのパス, text: 文字列, url: URL文字列 }

S 型のオブジェクト y について q(y) が真となる

renderDOM{ thumbnail: 画像へのパス, text: 文字列 } というデータの配列を前提に実装されているので、同じデータ構造を継承している { thumbnail: 画像へのパス, text: 文字列, url: URL文字列 } というデータの配列でも動くはずだよね、ということです。

さっきの例では、元々

S 型のオブジェクト y について q(y) が真となる

を満たしてはいましたが、それだけでは機能が足りないので分岐を書いて機能を追加したわけですね。しかし、これはどういったデータを想定していると言えるのでしょうか?

正解は次の2つのデータが来ることを前提にしている、です。

T: { thumbnail: 画像へのパス, text: 文字列 }

S: { thumbnail: 画像へのパス, text: 文字列, url: URL文字列 }

当たり前のことを言っているようですが、SOLID原則の一つに「単一責任の原則」というものがあります。これは一つの処理やオブジェクトなどは1つのことに責任を持つべきだ、ということを言っているのですが今回の例ではTとSの両方の処理について責任を持ってしまっているのですね。

つまり、このrenderDOMメソッドは url が「あるとき」と「ないとき」の2種類の描画について責任を持ってしまっています。機能が追加されるたびに責任も増えていき、分岐処理も複雑になっていく未来が見えますね。

責任が一つだけならばそもそも分岐処理は必要ないわけです。
では、このrenderDOMメソッドの責任を一つにするにはどうすればいいでしょうか?

function renderDOM(data) {
  return `
    <ul class="media">
      ${data.reduce((lists, item) => lists += `
        <li class="media__item">
          <div class="media__thumbnail">
            <img src="${item.thumbnail}" />
          </div>
          <div class="media__text">
            <!--
              !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
              ↓ここに分岐が生まれないようになんとかしたいが。。。
              !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
            -->
            ${ item.url ? `<a href="${item.url}">` : '' }
            <p>${item.text}</p>
            ${ item.url ? `</a>` : '' }
          </div>
        </li>  
      `, '')}
    </ul>
  `
}

一つの案として、安易に分岐処理を追加するのではなく、renderDOM メソッドの実装を少し抽象化してあげるといいでしょう。

つまり、

renderDOM がやらなければいけないことを次のように変更します。

before

  • アイテムの thumbnail をつかって imgタグを描画する
  • アイテムの text を使って pタグを描画する
  • アイテムに url があれば pタグをaタグで囲む

 ↓

after

  • サムネイル枠を描画する
  • テキスト枠を描画する

処理の細々とした内容が少しフワッとした感じになりましたね。これを実際に修正したバージョンがこちらです。

function renderDOM(data) {
  return `
    <ul class="media">
      ${data.reduce((lists, item) => lists += `
        <li class="media__item">
          <div class="media__thumbnail">
            <!--
              細かいことは知らんけど、サムネイルのDOM文字列を書き出す
            -->
            ${item.thumbnailDOM}
          </div>
          <div class="media__text">
            <!--
              細かいことは知らんけど、テキストのDOM文字列を書き出す
            -->
            ${item.textDOM}
          </div>
        </li>  
      `, '')}
    </ul>
  `
}

thumbnailDOM とか textDOM ってなんだよ、と思った方。すみません、こちらで勝手にルールをつくりました。

つまり、このメソッドは 「thumbnailDOM と textDOM を持ってさえいればそれを表示するよ。細かいことは知らんけど。」という作りにしました。データ構造の前提が次のようになったのです。

{
  thumbnailDOM: サムネイル部分のHTML文字列
  textDOM: テキスト部分のHTML文字列
}

データを加工しなくちゃいけないじゃないか、と思った方。その通りです。加工しましょう。どこで加工するかは場合によりますが、次のような優先度で考えればいいと思います。

  1. そもそものデータ構造出力部分を修正できるのであればそこを修正する
  2. データ取得処理を汎用的に切り出しているなら、そこに変換処理も入れてあげる
  3. データを使う側の処理に、データを変換する処理を追加してあげる

データを加工する処理はこんな感じにしました。

data = [
  { thumbnail: '/path/to/image', text: 'text' }, // url がない古いデータ
  { thumbnail: '/path/to/image', text: 'text' }, // url がない古いデータ
  { thumbnail: '/path/to/image', text: 'text' }, // url がない古いデータ
  { thumbnail: '/path/to/image', text: 'text', url: '/path/to/page' }, // url がある新しいデータ
]

// ベースのDOM文字列をデータに追加する
data.filter(item => item.text).forEach(itemA => {
  itemA.thumbnailDOM = `<img src="${itemA.thumbnail}" />`
  itemA.textDOM = `<p>itemA.text</p>`
})

// urlがあれば textDOMをリンク付きに上書きする
data.filter(item => item.url).forEach(itemB => {
  itemB.textDOM = `<a href="itemB.url"><p>itemB.text</p></a>`
})

話を簡単にするためにfilterの判定は簡易的なものにしています。パフォーマンスが気になるなら無駄に走査しないように条件を調整するといいでしょう。

元々のデータに renderDOM で必要になる thumbnailDOMtextDOM を追加してます。これによって、リンクをつけるかどうかはアイテムが知っていることになります。

renderDOM は 細かいことは知りませんが、アイテムが提供するDOM文字列を利用するだけでいいのです。

ちなみに、新しくサムネイルに alt を設定する機能が追加されたとしましょう。既存の処理を変更せずにこの機能を実装する場合は、次の処理を追加するだけで実現できそうです。

// altがあれば thumbnailDOMを alt付きのimgタグで上書きする
data.filter(item => item.alt).forEach(itemC => {
  itemC.thumbnailDOM = `<img src="${itemC.thumbnail}" alt="${itemC.alt}" />`
})

機能追加対応によって、既存の処理を修正する必要は無くなりました。既存の処理にバグが出ないかどうかなど、もう気にする必要はありませんね。デバッグも追加した変換処理が正しく動いているかチェックするだけです。

ここで リスコフの置換原則を振り返りましょう

S 型のオブジェクト y について q(y) が真となる

renderDOM は { thumbnail: 画像へのパス, text: 文字列 } というデータの配列を前提に実装されているので、同じデータ構造を継承している { thumbnail: 画像へのパス, text: 文字列, url: URL文字列 } というデータの配列でも動くはずだよね

元々の実装では、この条件を満たしていましたが、機能を追加するときになって安易に分岐を設定したために、想定するデータの前提が2つになってしまったことと、単一責任の原則に違反した状態になってしまったことが問題になりました。

今回はどうしても修正が発生してしまう状態だったので、せっかくなら今後修正が楽になるように次のように前提を変えました。これはリスコフの置換原則と単一責任の原則の両方に考慮しています。

q(x): 今回の例では renderDOM
T: { thumbnailDOM: サムネイル部分のDOM文字列, textDOM: テキスト部分のDOM文字列 }
S: { thumbnailDOM: サムネイル部分のDOM文字列, textDOM: テキスト部分のDOM文字列, ...etc }

S が T の派生型であれば:

T: { thumbnailDOM: サムネイル部分のDOM文字列, textDOM: テキスト部分のDOM文字列 }
 ↓ 派生
S: { thumbnailDOM: サムネイル部分のDOM文字列, textDOM: テキスト部分のDOM文字列, ...etc }

S 型のオブジェクト y について q(y) が真となる

renderDOM は { thumbnailDOM: サムネイル部分のDOM文字列, textDOM: テキスト部分のDOM文字列 } というデータの配列を前提に実装したので、thumbnailDOMtextDOM を持っていれば動くはずだよね

...

リスコフの置換原則が言っていることは、「前提としてないデータに対して分岐作ったりしたらダメだよね」ってことなんですが、「じゃあどおすればいいのさ」という問いに対して一つ例を書いてみました。いかがだったでしょうか?

classやインターフェースを知らない人向けに、SOLID原則を説明しようと思って書いているので、「継承つかえよ」とか「インターフェースは?」みたいな話はいったん脇に置いています。

解決方法としては他にもいろいろアイデアがあると思いますが、大事なことは

使う側で都合のいいルールを先に決めて、データ側をそこに合わせている

ということでしょう。
このルールは気安く変えるべきものではないので、どういうルールにするかを考えるのが
設計の腕の見せどころですね。

...

後日、新しく title を追加する機能を実装することになりました。UIはこんな感じに変更になります。

スクリーンショット 2020-08-09 1.20.29.png

表示の要件が変わってしまったので、今の状態では対応できなさそうですね。
修正は必須ですが、今後修正が発生しないようにしたいところです。この場合、あなたならどう対応しますか?

SeijiNishiwaki
元々webサイトの保守をしていましたが、成長に限界を感じてJavaの世界に飛び込んだものの、やっぱりフロントエンドがやりたくなって戻ってみたらいろんなことが変わっていて楽しい毎日です。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした