アプリケーションのコード規模に対する雑感

  • 主観です
  • 規模はコード行数 (LOC) で考えています
  • あくまでアプリケーションについての話です
    • ライブラリについてはまた事情が変わってくる
  • コードについての話しかしません

LOC 〜 102

  • アプリケーションというより, ちょっとしたスクリプト程度
  • すぐに全貌を把握できる
  • 困ったことがあったら作り直せば良いので, 正直何でも良いと思う

LOC 〜 103

  • おそらく単機能のアプリケーション
  • 全貌を完全に把握していられる
  • 一人で開発しているなら何でも良いと思う
    • 規模が小さいので, 型検査や lint のような静的解析は目視でもなんとかなる
    • 機能が少ないので, テストは手動でもなんとかなる
    • もちろん便利だと思うならコスト次第で使えば良い
  • 複数人 (あるいは一人でも時間が離れている) で開発するのであれば, CI にある程度コストを払ったほうが良いかもしれない
    • 機械的に型検査や lint がされていた方が安心だが, 全員が同程度の練度かつ規約を共有していれば必須とも限らない
    • 自動でテストが行われていると安心だが, 全員が十分にコミュニケーションをとれていれば必須とも限らない
    • コストの具合によっては一人で開発するよりも大変なこともありそう
  • まだ困ったことがあったらなんとか作り直せる規模なので, 設計は最低限でも絶対に悪いわけではないと思う
    • 複数人でわけがわからなくならない程度になんとかなっていればよいはず

LOC 〜 104

  • 複数の機能があるアプリケーション
  • 全貌を完全には把握できなくなってくる
  • ふつう複数人で開発することにはなっていそうで, そのために CI にはコストが払われていてほしい
    • 機械的に型検査や lint はされていてほしい
    • 自動でテストが行われていてほしい
  • 何か破綻したときにすぐに一から作り直せるような規模ではないので, 設計は重要になってくるはず
    • LOC 〜 103 から成長してきたときは, 大きく改修は必要だとしても, 規模的になんとかはなりそう

LOC 〜 105

  • おそらく多数の機能があるアプリケーション
  • 全く把握できていない部分も出てくる
  • CI はないと成り立たないと思う
  • モノリシックな設計には限界が来る規模だと思われる
    • 全部把握すべき設計で把握できていない部分があるのではなく, 把握すべきところを絞った設計になっていて, そうでないところは全く気にしなくても良くなっていてほしい
    • LOC 〜 104 から成長してきたときに大きく改修が必要だと, 規模が規模なのでつらいことになりそう
      • なので成長が見込める場合はある程度は予期して設計おくべきなのであろう
    • つらい

LOC 〜 106

  • この規模のアプリケーションの開発経験がないのでよくわからないし, 想像もほとんどできない
  • みなさんはどのくらいの規模まで行けますか

非対称な状態間の同期の実装メモ

状態間の同期は一般的な話題ではあるのですが, 例として React の制御コンポーネントの実装を題材にします. ここで非対称な状態とは, 各状態のとり得る値の集合の間に一対一の対応がないことを指しています. というか一対一に対応するのであればふつうは状態を分けなくてよろしい.

まずはおさらいとして, 基本的な制御コンポーネントを見てみましょう (sandbox).

const MyTextInput: React.VFC<{
  text: string;
  onChange: (text: string) => void;
}> = ({ text, onChange }) => (
  <input
    type="text"
    value={text}
    onChange={(e) => {
      onChange(e.target.value);
    }}
  />
);

この場合はユーザーが入力するデータと, このコンポーネント外部で状態として管理されるデータ (text) がともに string なので, 特に変わったことはありません.

ではユーザー入力と状態でデータの種類が異なるような場合はどうでしょうか. ここでは例として, ユーザー入力が string, 状態は Date であるような時刻入力コンポーネントを考えてみます (sandbox).

function stringify(date: Date): string {
  try {
    return date.toISOString();
  } catch {
    return "";
  }
}

function parse(text: string): Date {
  if (text === "") {
    return new Date(NaN);
  } else {
    return new Date(text);
  }
}

const MyDateInput: React.VFC<{
  date: Date;
  onChange: (date: Date) => void;
}> = ({ date, onChange }) => (
  <input
    type="text"
    value={stringify(date)}
    onChange={(e) => {
      onChange(parse(e.target.value));
    }}
  />
);

これは動作確認してみるとすぐにわかりますが, まともにテキスト入力することができず, ほぼ役に立ちません. よくある実装ミスです.

この例の実装では, 状態として管理されているのは date: Date のみです. 一方で,

  • 不正な時刻を表す Date は 1 種類だが, 時刻の表現として不正な string は無数に存在する
  • 同じ時刻を表す Date は 1 種類だが, 同じ時刻を表す string"2006-01-02T15:03:04.999Z""2006-01-03T00:03:04.999+0900" など, いくつも存在する

といったように, Datestring の値は一対一に対応せず, Date の値の種類よりも string の値の種類の方が多くなっています. このため, 例えばユーザーの入力途中の時刻として不正な string などは保持することができず, 入力に支障をきたしてしまいます.

ということで, 少なくとも date: Date とは別に, ユーザー入力の string も状態として管理する必要があります. 実際にやってみましょうs (sandbox).

const MyDateInput: React.VFC<{
  date: Date;
  onChange: (date: Date) => void;
}> = ({ date, onChange }) => {
  // string で入力データを保持するための状態
  const [text, setText] = useState(() => stringify(date));

  const textRef = useRef(text);
  useEffect(() => {
    if (text !== textRef.current) {
      // text が変更されたら通知 (date に反映)
      onChange(parse(text));
    }
    textRef.current = text;
  }, [text, onChange]);

  return (
    <input
      type="text"
      value={text}
      onChange={(e) => {
        setText(e.target.value);
      }}
    />
  );
};

一見うまく動くように見えるのですが, これでは非制御コンポーネントとなってしまっているので, 当初の制御コンポーネントを作るという要件を満たしていません. 例えば date が変更された場合, その変更はこのコンポーネントには反映されません. よくある実装ミスその 2 です.

では date が変更されたときに, コンポーネント内部の状態に対して反映が行われるようにしてみましょう (sandbox).

const MyDateInput: React.VFC<{
  date: Date;
  onChange: (date: Date) => void;
}> = ({ date, onChange }) => {
  // string で入力データを保持するための状態
  const [text, setText] = useState(() => stringify(date));

  const textRef = useRef(text);
  const dateRef = useRef(date);
  useEffect(() => {
    if (text !== textRef.current) {
      // text が変更されたら通知 (date に反映)
      onChange(parse(text));
    } else if (date !== dateRef.current) {
      // date が変更されたら text に反映
      setText(stringify(date));
    }
    textRef.current = text;
    dateRef.current = date;
  }, [text, date, onChange]);

  return (
    <input
      type="text"
      value={text}
      onChange={(e) => {
        setText(e.target.value);
      }}
    />
  );
};

ところがこれでは最初と同じように, まともにテキスト入力できないようになってしまいました. 複雑になった分より悪くなったといえます.

ここで残っている問題は date が変更されたときに, 無条件で text を更新していることです. 先程見たように, Datestring に比べて値の種類の数が少ないので,

  1. ユーザー入力によって text が更新され, date に反映
  2. これによって date が更新されたので text に反映

と相互に反映を行う過程で, Date で表現できない text の違いが潰されて, 結果としてユーザー入力が破壊されるような結果となってしまうのです.

これを解消するためには, 2 での text への反映に条件をつけて, すでに datetext が同じ時刻 (不正な場合を含む) を表しているような場合はスキップするようにしてやればよいです (sandbox).

function equal(a: Date, b: Date): boolean {
  return Object.is(a.getTime(), b.getTime());
}

const MyDateInput: React.VFC<{
  date: Date;
  onChange: (date: Date) => void;
}> = ({ date, onChange }) => {
  // string で入力データを保持するための状態
  const [text, setText] = useState(() => stringify(date));

  const textRef = useRef(text);
  const dateRef = useRef(date);
  useEffect(() => {
    if (text !== textRef.current) {
      // text が変更されたら通知 (date に反映)
      onChange(parse(text));
    } else if (date !== dateRef.current) {
      // date が変更されたら text に反映
      // ただし text にすでに date が反映されているとみなせる場合は無視
      if (!equal(date, parse(text))) {
        setText(stringify(date));
      }
    }
    textRef.current = text;
    dateRef.current = date;
  }, [text, date, onChange]);

  return (
    <input
      type="text"
      value={text}
      onChange={(e) => {
        setText(e.target.value);
      }}
    />
  );
};

こうすることでユーザー入力は破壊されず, かつ制御コンポーネントとして適切な振る舞いをするようになりました. めでたしめでたし.

2021-11-06 追記 このパターンを簡単に実装するためのライブラリを作ったのでよろしければどうぞ. github.com


最初に書いたとおり状態間の同期は一般的な話題なので, もっと抽象的に議論することもできるはず. 私は自分が考えている問題にそのまま適用できるように一般化していたら話題が散らかってきたのと, 数式や図が書きづらい環境で厳密に書くのが面倒になってきたので今回はここまで. 先行研究はいくらでもありそうなので, もし参考になりそうな文献をご存知であれば教えて下さい.

あと注意したいのは, 状態を複数持って同期するのは大抵アンチパターンです (React に限らず, GUI アプリケーション全般においても). 必要かどうか十分考察した上で, 本当に必要な場面でのみ使いましょう.