くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログ。積極採用中です。

読みやすいコードを書く必殺技

こんにちは、バックエンドエンジニアのカーキです。 今日は自分が思う読みやすいコードの秘密を紹介したいと思います。 いきなり答えを言っちゃいますが、読みやすいコードを書く必殺技はコードの抽象度を揃えることです。

抽象度とは

抽象度というのはものを見る時の視野の広さや高さのことです。

例えば、「富士山はどこにあるの?」という質問の答えは 地球 --> アジア --> 日本 --> 静岡県 のどちらでも答えられると思います。 この例では 地球 が一番高い抽象度で、静岡県 が一番低い抽象度です。私達は普段質問の文脈によってどの抽象度で答えるかを無意識で選んでると思います。

以下のシナリオを想像してみましょう。 僕個人的にマックが大好きなのでマックで注文する時のシチュエーションにします。起きることはだいたいこんな感じでまとめられると思います。

f:id:curama-tech:20200722115146p:plain
抽象度

👆の画像のように注文するアクションを高い抽象度(左側)から低い抽象度(右側)にわけられます (もちろん記載されてる以外の分け方も全然あると思います)。

一番左に寄せられてるマックで注文するが抽象度マックスのアクションで、財布を出すレベルのものは一番低い抽象度にいることがわかると思います。抽象度が違うものを比較してみると、抽象度が高いものは遠くからの目線で見られて、低いものは比較的に近い目線から見られてるのがイメージできると思います。

抽象度の違いをイメージできたところで、コードを読みやすくすることと抽象度はどう関係あるのかについて深堀したいと思います。

コード例

早速ですが、また例から始めます。今回は上記のマックで注文した時のキッチンの店員さんの動きをシミュレーションしてみましょう。 注文は ソーセージエッグマフィンセット、飲み物は ホットコーヒー 前提で書いています。 本来ならOOPでちゃんと書ける部分いっぱいあると思いますが、抽象度の説明をシンプルにするためほぼ疑似コード程度で書いていますのでご了承ください

抽象度がバラバラのコードが混ざってる場合

// メインの関数 (一番高い抽象度)
function prepareSausageEggMuffinSet(): SausageEggMuffinSet { 
    let continueToastingMuffin = true;
    let muffin = new EnglishMuffin();
    while (continueToastingMuffin) {
        muffin = toastMuffin();
        if (muffin.color) === "GoldenBrown" {
            continueToastingMuffin = false;
        }
    }
    
    let sausage = new Sausage();
    addSaltAndPepper(sausage);
    sausage = makeRoundAndFlat(sausage);
    while (!sausage.isCooked) {
        cook(sausage);
    }
    let egg = new Egg();
    while (!egg.isCooked) {
        cook(egg);
    }
    const cheeseSlice = new CheeseSlice();
    const sausageEggMuffin = new SausageEggMuffin(
        sausage, 
        egg, 
        muffin, 
        cheese
    );
    
    const potato = new SlicedPotato();
    const eggForHashedPotato = new Egg();
    const seasonings = [new Salt(), new Pepper()];
    let hashedPotato = new HashedPotato(
        potato, 
        eggforHashedPotato, 
        seasonings
    );
    let continueCookingHashedPotato = true;
    while (continueCookingHashedPotato) {
        hashedPotato = cook(hashedPotato);
        if (hashedPotato.color) === "Golden" {
            continueCookingHashedPotato = false;
        }
    }
    const coffee = CoffeeMaker.brew();
    return new SausageEggMuffinSet(muffin, hashedPotato, coffee);
}

上記のコードを一度読んで見てください。抽象度バラバラのコードが同じところに混ざってると思います。一番高い抽象度の prepareSausageEggMuffinSet の実装に中レベルの toastMuffin もいれば、すごく抽象度低レベルのマフィンの色の比較も入っています。コレによって prepareSausageEggMuffinSetのコードが長くなり、読みにくくなってると思います。

抽象度を揃えた場合

まずは prepareSausageEggMuffinSet だけ最初に揃えて見ましょう。単純にソーセージエッグマフィンセット を作るためには ソーセージエッグマフィンハッシュポテトコーヒー が必要なので、 それぞれを作る処理を prepareSausageEggMuffin, prepareHashedPotato, prepareCoffee 関数で表します 。この一階層の抽象化を挟むことで ソーセージエッグマフィンを作るハッシュポテトを作るコーヒーを作る アクションそれぞれが独立化され、疎結合になっていると思います。

実際のコードはこんな感じになります。明らかにこっちのコードの方が読みやすいかと思いますがいかがでしょうか?

// メインの関数 (一番高い抽象度)
function prepareSausageEggMuffinSet(): SausageEggMuffinSet { 
    // 中で呼ばれてる全関数がprepareSausageEggMuffinSetより一段階低いレベルの抽象度
    const sausageEggMuffin = prepareSausageEggMuffin();          
    const hashedPotato = prepareHashedPotato();           
    const coffee = prepareCoffee();                 
    return new SausageEggMuffinSet(
        muffin, 
        hashedPotato, 
        coffee
    )
}

これだけだと ソーセージエッグマフィンセットは作られないので次は prepareSausageEggMuffin, prepareHashedPotato, prepareCoffee それぞれの実装をしてみましょう。 残りのコードはこんな感じになります。

function prepareSausageEggMuffin (): SausageEggMuffin {
    const muffin = toastMuffin();             
    const egg = cookEgg();
    const sausage = cookSausage();
    const cheeseSlice = new CheeseSlice();
    return new SausageEggMuffin(sausage, egg, muffin, cheese);
}


function prepareHashedPotato(): HashedPotato {
    const potato = new SlicedPotato();
    const eggForHashedPotato = new Egg();
    const seasonings = [new Salt(), new Pepper()];
    let hashedPotato = new HashedPotato(
        potato, 
        eggforHashedPotato, 
        seasonings
    );
    while (!hashedPotato.isCooked) {
        cook(hashedPotato);
    }
    return hashedPotato;
}

function prepareCoffee(): Coffee {
    const coffee = CoffeeMaker.brew();
    return coffee;
} 


function toastMuffin(): EnglishMuffin {
    let muffin = new EnglishMuffin();
    while (!muffin.isPerfeectlyToasted) {
        toast(muffin)
    }
    return muffin;
}

function cookEgg(): Egg {
    let egg = new Egg();
    while (!egg.isCooked) {
        cook(egg);
    }
    return egg;
}

function cookSausage(): Sausage {
    let sausage = new Sausage();
    addSaltAndPepper(sausage);
    sausage = makeRoundAndFlat(sausage);
    while (!sausage.isCooked) {
        cook(sausage);
    }
    return sausage;
}

結論

抽象度を揃えた場合は明らかに抽象度バラバラで書いた場合よりコードが読みやすくなっていて、各関数の役割もしっかりしてることがわかるかと思います。 上記の例から言いますと、抽象度バラバラの場合一目でオーダー入った時にどんなことをやってるかはわかりづらいと思います。高抽象度の関数 (prepareSausageEggMuffinSet)にいきなり低抽象度のコード (ソーセージや卵の具体的な作り方など) が書かれてるので prepareSausageEggMuffinSet が複数の理由で変わる可能性が高まっています (コーヒーの作り方が変わっても変わりますし、ソーセージの味付けが変わっても変わります)。一方で、抽象度揃えてる例の場合 「ソーセージエッグマフィン作って、ハッシュポテト作って、コーヒー作ってまとめて返す」とすぐにわかるようになってるかと思います。具体的「こうやってコーヒー作る」とか「こういう味付けする」など prepareSausageEggMuffinSet に書かれていないのでコーヒーの作り方やソーセージの味付けが変わったとしても prepareSausageEggMuffinSet へ影響がないようになっています。

当記事は「コードを読みやすくする」視点で書きましたが、上記のように抽象化するメリットは読みやすさだけではなく様々あると思います:

  1. 責任の制限: 抽象度を揃えることで責任の線がハッキリするので、Godクラスが生まれにくくなります。
  2. テスタビリティ: 抽象度を揃えることでコードがモジュール化され、単体テストしやすくなります。
  3. 変更への耐えやすさ・拡張性: 抽象度揃った場合自然とコードがある程度疎結合になっているので既存機能の変更や拡張はしやすくなります。 例えば、記載の例でソーセージの味付けを変えたい時に、抽象度揃えてない例の場合関係ないところ (例えばコーヒーの作り方) にも影響してしまう可能性があります。抽象度を揃えた例だと、メインの関数 prepareSausageEggMuffinSet に変更無し、prepareSausageEggMuffin 自体にも変更無しで、cookSausage の変更だけで済みます。

最後に

よくオブジェクト指向プログラミングでSOLIDという単語が出てくると思いますが、個人的にはSOLIDを意識してコードを書くより、抽象度を意識してコードを書いた方が自然とSOLIDに従うコードになるんじゃないかと思っています。なので、今度設計する時や同僚のコードレビューする時に抽象度にフォーカスしてみてください、新たな発見があるかも知れません!