🧑‍🍳

意図によるプログラミング~適切な抽象化で保守しやすいコードを作る~

2021/10/11に公開

レガシーコードからの脱却という本を読みました。そこに「意図によるプログラミング」というプララクティスがあったので、自分なりに噛み砕いてまとめてみました。

意図によるプログラミングとは

API、メソッド、サービスなどを外の世界に公開しようとするとき、メソッドの中に実装を入れることはしない。実装は別のメソッドに移譲する。

レガシーコードからの脱却 p211

外部とやり取りをする部分(APIやメソッドなど)の処理には、実装レベルのコードは書いてはいけない。他のメソッドやクラスなどに移譲して抽象度を高く保つべしということですね。

例えばトランプから1枚を引く公開APIがあったとして、ダメな例と良い例を並べてみました。

ダメな例

function drawTramp () {
   // 初期化
   const trumps = [1,2,3,4,5,6,7,8,9,10,11,12,13];
   
   // シャッフル
   for (let i = trumps.length - 1; i >= 0; i--) {
     const j = Math.floor(Math.random() * (i + 1));
     [trumps[i], trumps[j]] = [trumps[j], trumps[i]];
   }
   
   // 最初の一枚を取り出す
   return trumps[0]
}

良い例

// パターン1 クラス移譲
function drawTramp () {

   const trump = new Trump();
   
   trump.init();
   
   trump.shuffle();
   
   return trump.drawFirstOne();
}

// パターン2 関数移譲
function drawTramp () {

   const trumps = init();
   
   const shuffled = shuffle(trumps);
   
   return drawFirstOne(shuffled);
}

シャッフルのコードはここから借りました。

良い例のサンプルは詳細は省いています。

また書籍では抽象と実装が入り混じっているのもよくないといっています。

一部のコードは移譲し、一部のコードは実装するようなことをやってしまうと、頭の中で中小レベルと実装レベルの観点を切り替えなければいけなくなる。こうなると、頭の中でタスクスイッチが頻発する。1回では大したことがなくても、タスクスイッチがずっと続くと、非常に重い作業となってしまう。

これは例えば上記の例を参考にするとこんな感じでしょうか。

ダメな例 抽象と実装のいりまじったコード

function drawTramp () {
   // 初期化
   const trumps = [1,2,3,4,5,6,7,8,9,10,11,12,13];
   
   // シャッフル
   const shuffled = shuffle(trumps)
   
   // 最初の一枚を取り出す
   return shuffled[0]
}

このように公開APIの中身は「どのようにやるか」を並べるのではなく「何をやるのか」を並べるほうが理解しやすいです。TODOリストを作るイメージ。

ハンバーグ作るお

  ∧,,∧
 (;`・ω・)  。・゚・⌒) チャーハン作るよ!!
 /   o━ヽニニフ))
 しー-J

前のセクションで「意図によるプログラミング」のだいたいの意味を伝えられたので、次は抽象とはなにかを一緒に考えていきたいと思います。

そもそも目的と手段は表裏一体である

目的と手段は表裏一体です。「何をするのか」と「どうするのか」も表裏一体です。単に抽象のレベルが違うだけです。ここを説明したいと思います。

UXデザインの教科書 のp81に目標の階層性という項目が説明されています。

目標の階層性

例えば「車で海までスムーズに移動したい」という目標があったとき、その手段として「目的地まで道案内できるようにしたい」があります。次は「目的地まで道案内できるようにしたい」という目標に対して、その手段として「カーナビに目的地を設定したい」があります。手段は目標になり、逆もまた然りなのです。

よく手段が目的化しているみたいな批判がありますが、手段が目的化するのは自然なことなのです。ただその結果より上位の目的が達成できないのがよくないのです(本末転倒とはこのこと)。

目的と手段が表裏一体なことがわかったので、次はハンバーグのレシピを考える過程で適切な抽象レベルの考察を行います。

ハンバーグのレシピを考えよう

ハンバーグのレシピ適当に調べてみました。だいたいこんな流れではないでしょうか?分量は本質とは関係ないので省略してます。

1. 玉ねぎをみじん切りにする
2. 玉ねぎを飴色になるまで炒める
3. 合びき肉、炒めた玉ねぎ、パン粉、牛乳、塩を加えてよく混ぜ、手のひらサイズの塊を作り、空気をぬきながら成形し、ハンバーグのタネを作る
4. フライパンに油をひき、中火にする。ハンバーグのタネの片面を2~3分かけこんがり焼く
5. 片面が焼けたら、ひっくり返して弱火にし蓋をして約10分蒸し焼きにする
6. ケチャップ、ウスターソース、醤油をまぜソースを作る
7. 盛り付ける

この中で実装レベルと抽象レベルをあげてみてください。レシピですからね。かなり実装レベルになっていると思います。

僕は以下のように考えました。皆さんはどうでしたか?

実装レベル
  • ケチャップ、ウスターソース、醤油をまぜソースを作る
  • 合びき肉、炒めた玉ねぎ、パン粉、牛乳、塩を加えてよく混ぜ、手のひらサイズの塊を作り、空気をぬきながら成形
抽象レベル
  • みじん切り
  • 飴色になるまで炒める
  • こんがり焼く
  • 蒸し焼き
  • 盛り付ける

実装レベルと言いながらもそれは抽象レベルが低いだけなので、さらに抽象度を下げることもできると思います。

抽象レベルをそろえる

このレシピは抽象レベルと実装レベルが入り混じっている気がしますね。いっそのこともう少し抽象度を上げてみましょうか。

1. ハンバーグのタネを作る
2. ハンバーグを焼く
3. ソースを作る
4. 盛り付ける

シンプルになりましたね。誰も見てくれなさそうなレシピですが笑。実際のプログラミングであればこれくらい抽象度が高いほうがパッと見て何やってるかわかるので良いと思います。また抽象レベルの粒度も同じくらいです(感覚的にしか表現できないのがもどかしい)。

適切な抽象レベル

先程のセクションで抽象レベルを揃えたので、玉ねぎをみじん切りにして飴色になるまで炒めるプロセスは、ハンバーグのタネを作るプロセスの下に位置することになりました。

さて、みじん切りとはなにかと聞かれたら皆さんはどう答えますか?「みじん」という概念を参考に説明しますか?もしみじん切りを知らない人がいて、具体的にプロセスを説明するとしたら結構たいへんだと思います。以下にやってみました。

みじん切りプロセス
  1. 玉ねぎをまず縦方向に対して半分にきる
  2. 根っこの部分を残してその半分の皮をむき、もう半分は使わなければ保存する
  3. 切断面をまな板におく。このとき根っこの部分は上にする
  4. 上の部分をつなげたまま5ミリ幅の等間隔で縦方向に切っていく
  5. 上の部分をつなげたまま5~8ミリ幅の等間隔で切断面に対して水平方向に切っていく。大体の間隔でok。
  6. 上の部分まで横方向に5ミリ幅の等間隔で切っていく。切ったところからサイコロ状になっていく。
  7. 大きなところや不均一にのこったところなど適宜切っていく
    完成

チームのメンバーがみじん切りという概念を知っていれば、この言葉を使わない手はありません。細かいプロセスを書くよりもよい抽象レベルだと思います。

どの抽象レベルが適切かは読み手の経験と知識レベルに左右します。

「飴色になるまで焼く」という言葉は、同じ抽象レベルで「メイラード反応が目視で確認できるまで焼く」と言い換えることもできます。しかし、この言葉をレシピに書いても一部の詳しい人しか理解できません。その点飴色(きつね色とも言いますが)とはとてもよい言葉のチョイスです。

適切なメタファーをチョイスすることで詳細を見なくても何が起きるのかイメージできるようになります。

setterに関する考察

ここで学んだことをプログラミングにおけるsetterに適応してみます。

以下のようにユーザークラスがあります。プロパティはprivateにカプセル化されており、statusを変更するにはsetStatusを呼び出す必要があります。よく見ますね。

type UserStatus = 'TEMPORARY' | 'ACTIVE' | 'PENDING';

class User {
  private name: string;
  
  private status: UserStatus;
  
  constructor(args: {name: string; status: UserStatus}){
    this.name = args.name;
    this.status = args.status;
  }
  
  setStatus(status: UserStatus): void {
    this.status = status;
  }
}

つぎの例です。同じユーザークラスなのですがsetStatusの代わりにgetActivatedgetPendingというメソッドが定義されています。

type UserStatus = 'TEMPORARY' | 'ACTIVE' | 'PENDING';

class User {
  private name: string;
  
  private status: UserStatus;
  
  constructor(args: {name: string; status: UserStatus}){
    this.name = args.name;
    this.status = args.status;
  }
  
  getActivated(): void {
    this.status = 'ACTIVE';
  }
  
  getPending(): void {
    this.status = 'PENDING';
  }
}

これは抽象レベルが違うのです。
抽象レベルの違い

setStatusはより実装レベルに近く、getActivatedはより抽象レベルが高くなります。ステータスを変更する理由は多くあるので、その都度メソッドを定義するのです。コードを変更する理由はより高い抽象レベルで決まる(例えばユーザーアクティベート処理の前に何かしらのチェックを入れる変更が入ったなど)ので、このように抽象レベルが高いコードを書くことで保守がしやすくなります。

Discussion