共通化すれば良いとは限らない

ここのところ偶然なのか「共通化」という言葉を多く聞いているのですが, その言葉を聞くたびに身構えていることに気がついたので, この気持ちの出どころを共有しておきます.

なぜ身構えているかというと, 共通化が必ずしもコードを良い状態にするとは限らないにも関わらず, それ自体が目的になってしまっている (ように見える) ことが多いからです. この手のリファクタリングの目的はあくまでコードの改善のはずで, そのことを忘れて共通化するだけで満足してしまうと, 良くてリファクタリングの効果が半減, 悪ければ逆効果になってしまいます.

個人的にコードを共通化する上で注意してほしいと思っているのは以下の二つです.

  • コードを共通化すべきでない場合もある
  • 通化されたコードは一般的な原則にしたがって設計されなければならない

似たようなことは歴史の中で何度も繰り返し言われていることだろうと思いますが, 改めて.

コードを共通化すべきでない場合もある

通化に関する原則として有名なものに DRY (Don't Repeat Yourself) がありますが, これはどのような場合にもコードを共通化すべきだというものではありません.

DRY 原則が述べていることは, 同じ知識を繰り返し書いてはいけないということです. ここでいう知識とは, プログラミングの文脈では状況・問題・解決手段の組 (ある状況の下である問題を解決するためにはどういった手段をとればよいか) と考えておけば良いと思います. これらが複数の場所に書かれていると, どれが (あるいはどれも) 正しいのかわからないですし, 変更は必ず同時に行う必要があるので大変です.

逆に異なる知識 (状況・問題・解決手段のいずれかが異なる) であれば DRY 原則の対象からは外れるため, 繰り返し書かないようにしてやる義理はありません. むしろ異なる知識同士を無理やり一つにまとめてしまうと, 状況や問題など知識の内容が曖昧になったり, 変更を加えようとすると互いに干渉してしまったりと, 扱いが非常に難しくなってしまうため避けるべきです.

ここで落とし穴になるのが, コードで表現されるのはふつう知識の全てではなく, 最も多いのは解決手段のみであるということです. この事実を無視して コード = 知識 のように捉えてしまうと, DRY 原則を適用したつもりで誤った共通化をしてしまう可能性があります.

とはいえ状況・問題・解決手段は互いに無関係ではないので, 多くのコードの重複は知識の重複であるのも事実です. すべてがそうでないからといって共通化を避けすぎてもいけません.

例. 問題や解決手段は同じでも状況が異なる場合

React による UI コンポーネントを例にします.

一つ目のコンポーネントは, 画面上部にありがちなユーザー設定へのリンクを表示するためのものです.

function UserSettingsLink({ user }) {
  return (
    <a href="/settings" class="user-settings-link">
      <img src={user.iconUrl} />
      <span>{user.screenName}</span>
    </a>
  );
}

もう一つは, ユーザーのリストの項目を表示するためのコンポーネントです.

function UserListItem({ user }) {
  return (
    <li class="user-list-item">
      <img src={user.iconUrl} />
      <span>{user.screenName}</span>
    </li>
  );
}

これらのコンポーネントのうち, ユーザーのアイコンと名前を表示する部分のコードは全く同じです. この部分は共通化すべきでしょうか?

私はこれらの場合は状況が異なるため共通化すべきではないと考えます. 状況が異なれば問題も変わる可能性があって, 例えばそれぞれ表示したい項目やスタイリングが変わるかもしれませんし, それに応じて解決手段であるマークアップも変わることがあるかもしれません. 大まかな目的は近いものの, 詳細まで同じになっているのは単なる偶然の一致で, なんらかの同一の知識を表しているわけではないと考えるのが良さそうです.

あるいはコードをそのまま共通化するのではなく, より一般的な状況や問題を想定した形にコードを抽象化するという方法も採れるかもしれません. が, この規模であれば得られるメリットよりも良い抽象を考えたり理解したりするコストの方が高くついてしまいそうですね.

通化されたコードは一般的な原則にしたがって設計されなければならない

上に挙げた DRY 原則を含むプログラミングの一般的な原則に従うことは, コードの可読性・保守性・柔軟性・再利用性などを確保する上で非常に重要です.

ところが特に既存のコードをリファクタリングする過程で共通化が行われた場合に起こりがちなことですが, 共通化されたコードは重複した部分を文字通りに共通化しただけのようになっており, 一般的な原則に従えていないことがしばしばあります. これでは折角のリファクタリングの価値が半減してしまいます.

例えば以下のようなコードのリファクタリングを考えてみましょう.

  • 関数 A:
    • A 専用の処理
    • 重複した処理
  • 関数 B:
    • B 専用の処理
    • 重複した処理

これらの関数の処理の重複をなくしたいという目的で, 以下のような共通化が行われることがあります.

  • 関数 AB:
    • A のときは A 専用の処理
    • B のときは B 専用の処理
    • 共通の処理

直ちにこの形の共通化が良くないとは言えません. 例えば以下のような条件を満たしているのであれば, 妥当な共通化であると言えるでしょう.

  • 処理のパターンは今後も A, B しか存在しないことが期待できる
  • A, B それぞれの専用の処理は共通の処理と比べてごく小さい

一方でこれらの条件が満たされない場合には, 一般的な原則に基づいて問題点や改善案を考えることができます.

例 1. 開放・閉鎖原則に反する場合

まずは上に挙げた条件のうち, 特に前者の条件が満たされない場合を考えてみましょう.

  • 処理のパターンは A, B 以外にも存在するかもしれない

例えば A, B とは別のパターン C に対応しなければいけなくなったとすると, 関数 AB は以下のように変更される必要があります.

  • 関数 ABC:
    • A のときは A 専用の処理
    • B のときは B 専用の処理
    • C のときは C 専用の処理
    • 共通の処理

このように新たなパターンに対応できるように拡張をするときにそのコード自体を変更しなければならないというのは, 解放・閉鎖原則に反しています. 拡張のたびに共通の処理に手を入れなければいけないというのはコードの柔軟性や再利用性が低いと言えますし, 共通の処理にパターンごとの条件分岐が増えることで可読性や保守性にも影響を与えてしまいます.

拡張に対して開いた状態にリファクタリングすためには, 次のように共通の処理だけを関数に切り出し, 各パターンはそれを利用するような形にすると良いでしょう.

  • 関数 X:
    • 共通の処理
  • 関数 A:
    • A 専用の処理
    • 関数 X を呼び出す
  • 関数 B:
    • B 専用の処理
    • 関数 X を呼び出す
  • 関数 C:
    • C 専用の処理
    • 関数 X を呼び出す

例 2. 単一責任の原則に反する場合

続いて後者の条件が満たされない場合を考えてみます.

  • A, B それぞれの専用の処理は共通の処理と比べて小さくない

もし A, B 専用の処理の規模が小さければ, 関数 AB の責任は共通の処理を実行することで, そこに小さなバリエーションが存在するだけであると考えられます. しかし専用の処理の規模が大きくなってくると, もはやバリエーションとしては捉えられず, 関数 AB が A の処理の実行と B の処理の実行という二つの責任を持っているように見えてきます.

このような状態は単一責任の原則に反していると言えます. 関数が複数の責任を持っている状態では, その関数が表している知識が理解しづらくなったり, ある一面に対する変更が他の面にも影響を与えてしまったりと, 可読性や保守性が低下してしまいます.

こちらの場合も, やはり共通の処理だけを関数に切り出すことで, それぞれの関数の責務を一つに限定するのが良いでしょう.

  • 関数 X:
    • 共通の処理
  • 関数 A:
    • A 専用の処理
    • 関数 X を呼び出す
  • 関数 B:
    • B 専用の処理
    • 関数 X を呼び出す

まとめ

ということで, 共通化は単にしておけば良いというものではありません.

  • コードを共通化すべきでない場合もある
    • 知識 (状況・問題・解決方法) が重複している場合のみ共通化すべき
  • 通化されたコードは一般的な原則にしたがって設計されなければならない
    • 通化されたコードについても可読性・保守性・柔軟性・再利用性などを確保すべき

コードを共通化するときにはこういったことを考えつつ, なぜ共通化するのか, なぜその方法で共通化するのかについて説明できるようにしましょう.

Optional Variance Annotations の挙動メモ

TypeScript 4.7 の optional variance annotations の挙動を若干勘違いしていたのでメモ.

例 1 (Playground):

type Extends<A, B> = A extends B ? true : false;

type Bivariant<T> = {};

type Bivariant_A0 = Extends<Bivariant<"foo">, Bivariant<"foo" | "bar">>; // true
type Bivariant_A1 = Extends<Bivariant<"foo" | "bar">, Bivariant<"foo">>; // true
type Bivariant_A2 = Extends<Bivariant<"foo">, Bivariant<"bar">>;         // true

type Covariant<out T> = {};

type Covariant_A0 = Extends<Covariant<"foo">, Covariant<"foo" | "bar">>; // true
type Covariant_A1 = Extends<Covariant<"foo" | "bar">, Covariant<"foo">>; // false
type Covariant_A2 = Extends<Covariant<"foo">, Covariant<"bar">>;         // false

type Contravariant<in T> = {};

type Contravariant_A0 = Extends<Contravariant<"foo">, Contravariant<"foo" | "bar">>; // false
type Contravariant_A1 = Extends<Contravariant<"foo" | "bar">, Contravariant<"foo">>; // true
type Contravariant_A2 = Extends<Contravariant<"foo">, Contravariant<"bar">>;         // false

type Invariant<in out T> = {};

type Invariant_A0 = Extends<Invariant<"foo">, Invariant<"foo" | "bar">>; // false
type Invariant_A1 = Extends<Invariant<"foo" | "bar">, Invariant<"foo">>; // false
type Invariant_A2 = Extends<Invariant<"foo">, Invariant<"bar">>;         // false

例 2 (Playground):

type Extends<A, B> = A extends B ? true : false;

type Invariant<in out T> = {};

// A0 = false
type A0 =
  [Invariant<"foo">, Invariant<"bar">] extends [infer Foo, infer Bar]
    ? Extends<Foo, Bar>
    : never;

type Foo = Invariant<"foo">;
type Bar = Invariant<"bar">;
// A1 = true
type A1 = Extends<Foo, Bar>;

例 3 (Playground)

type Invariant<in out T> = {};

declare const foo1: Invariant<"foo">;
const bar1: Invariant<"bar"> = foo1;
//    ~~~~
// Type 'Invariant<"foo">' is not assignable to type 'Invariant<"bar">'.
// Type '"foo"' is not assignable to type '"bar"'.

type Foo = Invariant<"foo">;
type Bar = Invariant<"bar">;
declare const foo2: Foo;
// ok
const bar2: Bar = foo2;

どういうこと

TypeScript の type で定義されるものは基本的にはエイリアスである. つまり type F<T> = U のような定義があったとき, 任意の型はそこに含まれる F<T> の出現を U に置き換えた型と同じ意味を持つということになっている.

厳密には distributive conditional types であったり tuple や array に対する mapped types があるので文字通りに置き換えただけでは意味が変わってくるものの, これらは通常の conditional types や mapped types とは異なる別の (直接記述することが許可されていない) 型のエイリアスを定義していると考えればおそらく変なことはないはず.

例 1 を見るとこの解釈が若干難しくなってくる. 例えば Invariant_A2 については先に Invariant を展開してしまうと Extends<{}, {}> となり明らかに true になるはずであるが, そうはなっていない. 実際には Extends が先に展開され, さらに conditional type の評価も Invariant を展開しないまま行われている見える.

つまり variance annotation が存在する場合はこれまでのエイリアスとは異なり, TypeScript がどういった順番でエイリアスを展開するかが重要になってくる. 部分型の判定の前にエイリアスが展開されてしまっては variance annotation 通りの判定がされないかもしれない. (私が勘違いしていたのはここで, まさかエイリアスの展開順序で挙動が変わらないだろうと思っていた.)

ではどういった場合にエイリアスが先に展開されてしまうのかというのは, 型レベルプログラミングに慣れていると多少の勘がはたらくものの, 完全な理解はコンパイラのコードを読まなければ難しそう.

仮に外側から展開されているとしたらどうかと思って例 2 の A0 のように Extends の前に infer してみたが, これではまだ Invariant は先に展開されないらしい.

一方で例 2 の A1 のように, 一度別の type として定義してやるとそこでエイリアスが展開されるらしく, Extends<Foo, Bar>Extends<{}, {}> と同じ結果になる. これはエイリアスの右辺が variance の情報を持った特殊なオブジェクト型になっているわけではなさそうだということでもある (もしそうなら展開のタイミングは自由になる).

また例 3 のように変数の型に直接使った場合についても, エイリアスは直ちには展開されないらしい.

結論

という感じで実際にエイリアスの展開されるタイミングによって部分型の判定が変わります. 一瞬幽霊型 () を in out で宣言しておけばよいかと思ったんですが, これではちょっと怖いですね.

リリースノートの通り, annotation を書くとしても実際の variance 通りにしておくのが無難そうです. そのようにしている限りは展開タイミングによって部分型判定が変わることはありません.

派手な再帰的型定義を書いたりしない場合の一般的な使い方としては, 以下のように variance が変化するのを未然に防ぐくらいでしょうか.

type List<T> = {
  head: () => T;
  // ...
  // XXX: 新規にメソッド追加したら covariant でなくなった
  append: (value: T) => List<T>;
};

declare const xs: List<number>;
// これまでコンパイルできていたコードがコンパイルできない
const ys: List<number | string> = xs;
//    ~~
// Type 'List<number>' is not assignable to type 'List<string | number>'.

// out があれば covariant でなくなったことに未然に気がつける
type List2<out T> = {
  //       ~~~~~
  // Type 'List2<sub-T>' is not assignable to type 'List2<super-T>' as implied by variance annotation.
  // Types of property 'append' are incompatible.
  head: () => T;
  // ...
  append: (value: T) => List2<T>;
};

// あるべき姿はこう
type List3<out T> = {
  head: () => T;
  // ...
  append: <U>(value: U) => List3<T | U>;
};