🖊️

私のソフトウェアの設計指針: マルチプラットフォーム対応と静的検査の活用

に公開

GUIやGPUなどのプラットフォームに依存する必要がないただの変換ツールや管理ツールなどを開発するときの私の設計指針
GPU computing, OS kernel, device driver などはこの記事の主対象ではない
desktop GUI application, mobile application, game engine などのプラットフォームに依存するものでも、その機能を設計方針に従って適切に整理することで、プラットフォームに依存しない部分にこの方針を適用するべき
AIなどに設計させるときにこのURLを渡して参考にさせるために用いる

以下、Rustでの開発を基調とし、TypeScriptやNEPLは必要に応じて暗黙あるいは明示で説明する
或いは私の自作プログラミング言語であるNEPLでもRustと同様、あるいはさらに強力な方針が使用できる
Rust以外の言語を使用する場合でも識別子などを適切に読み替えてこの方針に則ること

設計方針: 依存関係の抽象化・整理とマルチプラットフォーム対応

計画初期段階で1つのtargetしか目標にしていない場合でも、必ずこの作業を行っておく
今後targetが増えないことの保証は不可能であり、今後targetが増えた時に簡単に対応できるようにする
今後永久にtargetが1であっても、機能の本体とインターフェイスを分離して設計・実装することには意義がある

まず依存関係を整理し、抽象化する
機能の本体、核となる部分はtoolname-core/として#![no_std]で作成する allocは必要に応じて利用する
ここでCommandLine ArgやStdIOやFile SystemやNetworkなどのプラットフォームに依存する内容は適切に抽象化する
toolname-cli/toolname-lib/などを設けて、そこでFileSystemなどプラットフォーム依存の具体的な実装をtoolname-core/に提供し、CommandLineやStdInの内容を基にtoolname-core/に用意された関数などを呼び出す
これを行うことで、例えばVirtualFSなどに差し替えてテストしたりWebで同じtoolname-core/でツールを使えるようにしたりすることが簡単にできる

まず対応するのはRustのCLI(x86_64やarm),WASM(wasm32-wasip2)でのCLIツール、次いでWasm Bindgenを用いたWeb向けのライブラリとそれを呼び出すTypeScriptによるWeb Playground
Webフロントエンドや小規模な開発用スクリプトとしてTypeScriptの使用を認めるが、nullundefinedなどは認めず、APIやライブラリでは最小限の境界でOption,Resultに変換して扱うこと

プラットフォームの抽象化では Feature flag などを活用すること

開発方針: 静的検査の活用や不整合の防止

  • 特段の事情がない限りプログラムやドキュメントなどのテキストファイルの文字コードはすべてUTF-8に統一すること
  • 静的検査の正確性は必須
    • 型安全,メモリ安全は必達
    • nullやundefinedは禁止、OptionやResultなどで扱うこと
    • 目的に応じて十分に高機能で正確な静的検査が用意されていること
    • 用意されている検査が活用されるような実装にすること
      • 成功や失敗はOptionやResultを用いて明示的に扱うこと
        • エラーメッセージはenumを用いて統一的に管理し、また静的検査が効くようにすること
          • エラー自体とエラーの表示は分離すること
            • 表示する言語や色やフォントが変わってもエラーが変わるわけではない
            • エラーはenumで管理しておきそれをdisplayする部分は分離するべきだ
      • 数値や文字列ではなくenumを用いて静的検査が効く形で管理すること
      • matchによる網羅性検査が効く形で分岐すること
      • まとめるべきデータはstructを使用すること
  • 純粋性すなわち副作用の除去と参照透過性の尊重
    • 無駄に副作用などを増やさない
    • 関数で参照する値は引数で明示的に渡し、結果は返り値で返す
    • StdIOやFileSystemやNetworkなどはcli/lib/にあたる表層で集中的に管理し、core/では扱わずできる限り純粋関数とする
      • GUIやTUIの場合でもできるだけgui/tui/、或いはそれらを抽象化したui/などの表層で管理し、ツール機能の本体であるcore/は純粋性を重視する
    • 決定性の尊重とも関連する
  • 不変性の明示・尊重
    • 使用する言語などの方針に合わせ、mutableやimmutableのいずれかを必ず明示する
    • パフォーマンスなどのなんらかの理由がない限り、基本的に不変になるようにする
      • パフォーマンスは尊重する(後述)
  • 式指向の活用
    • if式やmatch式など式指向の仕組みを用いて不必要な中間変数などを削減する
  • ネストはできるだけ浅く保つ
    • match式が使える、match式を使うべき個所でif式を用いてはならない
      • match式が使えるのに使わないのは網羅性検査の恩恵もないし徒にネストが深くなって可読性が落ちる
  • 丁寧なドキュメントコメントの整備
    • rustdocの、使用例をドキュメントコメントの中に記し、一括で使用例を検査できる仕組みを参考にする
      • NEPLではrustdocのようなmarkdownに加えて、[漢字/よみ]{用語/注釈}による拡張記法をサポートする
        • 例えば{NEPL/プログラミング言語の名称}のdoccommentでは[拡張/かくちょう]されたMarkdown[記法/きほう]が[使用/しよう]できる
    • 何を実装しており、どのような目的に使用できるのか、時間計算量・空間計算量はどうか、典型的な使用例はどのような形かを詳細に記述する
      • 実装者が守るべき contract を記載する
      • 戻り値がenum(OptionやResultもenumである)の場合など、場合分けがある場合にはその条件を明記する
      • 型情報だけではわからないことを記載する
      • ドキュメントテストの中で最もシンプルな使用例と典型的な使用例を示す、つまりその関数やモジュールの使い方を例示し、かつその例が確かに動作することをテストとして確認する
    • 契約と現状の説明を分離し、両方を明示的に記載すること
      • 変わらないことが保証されるのは何か
        • 例えば機能や性能、計算量の最悪の場合など
      • 今後変わり得るのは何か
        • 現在の実装のアルゴリズムの詳細や現在の実装での計算量など
        • 変わり得るものであったとしてもドキュメントから確認できるようにしておく
          • ただし今後も変わらない契約と今後変わり得る詳細は明示的に書き分けること
  • テストの整備
    • 使い方を説明するためのドキュメントコメントのテストとは別に、実装の正しさを確認しミスを検出するための詳細で大規模なテストを別に整備すること
    • テストは丁寧に整備し、またそのテストの目的や期待される結果の解説をテストケースに対するドキュメントコメントで行う
      • 特にエラーケースであるとき、エラーの種類は何か、なぜそのエラーであるのか、その期待を説明するドキュメントはどこにあるのか
      • 境界条件などテストで網羅されるべきとされる一般的な指針に則ること
      • マルチプラットフォーム対応の要件として、環境によって結果が変化しやすい内容について丁寧にテストを整備すること
  • ゼロコスト抽象化
    • 本質的に同じ処理は抽象化して切り出してまとめて扱う
      • 複数の対象への同種の操作について、静的(コンパイル時)に解決されるオーバーロードやジェネリクスを用いて実行時にオーバーヘッドがない形で抽象化を行う
    • 関数名などに対象となる型の名前は入れるべきでない
      • 型についての選択は型注釈で行うべきであって関数名で行うべきでない
      • i32_addのようにプレフィックスをつけたりせずに、すべての型でaddを使えるようにする
        • ライブラリのprivateな識別子なら許容するが、少なくともexportされるpublicな識別子では用いないこと
          • privateな識別子でもできるだけ使用しないこと
  • 責務の分割
    • 依存関係はできるだけDAGになるように
      • モジュールに分割し、モジュールのimport,export関係がこの依存関係と親和するように
    • ディレクトリの適度な階層化
      • module boundary と dependency direction が表れる範囲で階層化する
      • 過剰な階層化は避ける 分割のし過ぎは不可である
      • _での接続などのファイルの命名規則を用いた階層構造は禁止 ファイルの階層構造は必ずディレクトリを用いること
  • 多くの場合で問題はシンプルに解決できることを忘れないこと
    • 場当たり的な修正、複雑な条件分岐を用いた対処は多くの場合間違いである
      • 場当たり的な対処を見つけたら完全に削除し然るべき実装に完全に修正すること
    • 対象を抽象化して全てに広く適用できる規則や処理を考えること
  • パフォーマンス追求
    • 計算量
    • 強力な所有権や型の静的検査と関数の純粋性の尊重や依存関係のDAG化、ゼロコスト抽象化などの設計を活かして、安全が裏付けされたハイパフォーマンスを追求すること
      • メモリ使用量や実行時間の最適化など
        • 探索範囲の削減など
      • オーバーヘッドの削減など
    • 上記で、純粋性や責務の分割など様々な制約を課した これを活かしてキャッシュなど様々な方法で探索空間や計算量を削減できる
      • 特にNEPLは関数の型に純粋性のフラグがあり(fnimpure fn)、コンパイラで純粋性が静的に検査される

試作段階における開発方針

  • 試作で免除されるのは後方互換の保証であってソースコードの品質ではない
    • 技術的負債は残すな
      • 試作なのに、なぜ製品のように後方互換などを期待する必要があるのか
        • 後方互換は不要
        • 不適切な既存コードはすべて修正する
        • 仕様は破壊的変更を行っていいが、変更するたびに仕様に不整合が生まれていないか丁寧に確認し修正すること
    • 暫定の雑設計は不可
      • 暫定実装はいいが暫定設計は禁止
        • 暫定実装のデータ構造やモジュールや関数には識別子にprefixで暫定実装であることを明示し、それに付随するドキュメントコメントに本来どのように実装すべきものをどのように妥協して暫定実装したか記すこと
          • 「暫定」という語を人間の記憶に頼らず、検索可能・検査可能にする
        • その仕様設計で要件は満たせるかまた不整合がないかよく確認すること
        • 実装に時間がかかるときには最終的な目標だけでなく実装計画としてどのような段階を踏んで完成まで開発するか順番や確認項目や完了条件について計画を立てること
          • ticketやissueの形式で集中的に管理すること、スクリプトを用いてIDの一意性などを確認し、タスクのインデックスは機械的に更新されるようにすること
      • 設計ミスが発覚したらその設計は破棄して再設計再実装する
      • 公開 API、データモデル、エラー型、モジュール境界などについてはなるべく暫定実装を行わず、実装初期から最終設計に近づける
        • ただしそれでも設計ミスが発覚した場合には再設計再実装を厭わない

Discussion