積み上げることと磨き上げること
長年かけて、堅牢なソフトウェアシステムを構築するうえで、相補的な二つのアプローチに行き着きました。積み上げる方法と、磨き上げる方法です。
積み上げるとは、小さなコアから始めて少しずつ機能を付け加えていくやり方です。磨き上げるとは、非常に粗いアイデアから出発し、時間をかけて洗練させていくやり方です。
どちらが本質的に優れているということはありません。ほとんどスタイルの問題であり、チームの力学や問題領域への習熟度に左右されます。そもそも、このテーマに関する私の考え自体、特に目新しいものではありません。ただ、長年学んできたことを一度整理しておきたいと思いました。
積み上げる

出典: Wikimedia パブリックドメイン
積み上げる手法では、まず堅牢な土台を作ることに注力します。私は、よく知っているシステムを扱うときや、参照できる明確な仕様があるときに、この手法を好んで使います。たとえば、プロトコルの実装や、私のMOS 6502 emulatorのようなハードウェアのエミュレーションで活用しています。
私は「bottom-up」よりも「building up」という表現を好んで使います。前者の方が、構築して上へ伸びていくイメージを喚起するからです。「bottom-up」はより抽象的で方向性を示すだけの言葉に感じられます。また、「bottom-up」はいかにも専門用語という響きがあるのに対し、「building up」はより直感的で視覚的にも分かりやすく、技術に詳しくない関係者に考えを伝える際にも役立ちます。
積み上げる手法をとるとき、私が意識しているルールがいくつかあります。
- 合成しやすくテストしやすい、原子的な構成要素に集中します。
- シンプルで検証可能な性質から、強力な保証を積み上げていきます。
- パフォーマンスではなく、正確性を重視します。
- 自分の考えを検証するために、コードと同時にドキュメントも書きます。
- 次のレイヤーに進む前に、抽象化をしっかり固めます。
分析的な思考を得意とする人たちと協働するとき、このアプローチはうまく機能します。形式手法や数学を背景に持つ人は、「構成要素」や証明といった観点で考える傾向があります。また、関数型プログラミングを好む人も、この手法を好む印象があります。
Rustのような言語では、型システムが不変条件の強制に役立ち、シンプルな部品から複雑なシステムを積み上げるのを容易にしてくれます。また、Rustのトレイトシステムは合成を促すため、この考え方ともよく合致します。
一方で、「積み上げる」アプローチの欠点は、目に見える成果が得られるまでに基礎部分に多くの時間を費やすことになる点です。このやり方ではMVPにたどり着くまで時間がかかります。また、一度特定のアーキテクチャにコミットすると方向転換が難しくなるため、硬直的で柔軟性に欠けると感じる人もいます。
たとえば、Webフレームワークを構築する場合を考えてみます。プロジェクトの初期には、山のように決めるべきことがあります。
- 同期的にするか、非同期的にするか。
- リクエストのルーティングをどうするか。
- ミドルウェアは設けるか、どう実装するか。
- レスポンス生成はどう行うか。
- エラーハンドリングをどうするか。
積み上げるアプローチでは、まずこれらの問いに答え、核となる抽象化を設計することから始めます。リクエストやレスポンスの型、ルーター、ミドルウェアシステムといった基盤部分はフレームワークの背骨であり、極めて堅牢でなければなりません。
核となるデータ構造とその相互作用を固めてから、初めて公開APIの構築に移ります。これにより非常に堅牢でよく設計されたシステムが生まれますが、そこに到達するまでには長い時間がかかることもあります。
例として、人気の高いhttpクレートにおけるRequest構造体をこちらに示します。
#[derive(Clone)]
pub struct Request<T> {
head: Parts,
body: T,
}
/// Component parts of an HTTP `Request`
///
/// The HTTP request head consists of a method, uri, version, and a set of
/// header fields.
#[derive(Clone)]
pub struct Parts {
/// The request's method
pub method: Method,
/// The request's URI
pub uri: Uri,
/// The request's version
pub version: Version,
/// The request's headers
pub headers: HeaderMap<HeaderValue>,
/// The request's extensions
pub extensions: Extensions,
_priv: (),
}この短いコードの中にも、巧妙な設計上の判断がいくつも詰まっています。
Request構造体はボディの型Tに対してジェネリックになっており、ボディの表現方法(バイトストリームや文字列など)に柔軟性を持たせています。Parts構造体がRequest構造体から分離されているため、ボディを意識することなくリクエストのメタデータに簡単にアクセスできます。Extensionsは、基盤となるプロトコルから派生した追加データを格納するために利用できます。_priv: ()フィールドはゼロサイズ型で、外部のコードがPartsを直接生成するのを防ぐために使われています。これにより、提供されたコンストラクタの使用が強制され、Parts構造体の不変条件が保たれます。
Extensionsを除けば、この設計は時の試練に耐えてきました。2017年の最初のバージョンからほとんど変わっていません。
磨き上げる

出典: Wikimedia パブリックドメイン
もう一つのアプローチとして、私が同じくらい有効だと感じているのが「磨き上げる」手法です。この手法では、粗いプロトタイプ(あるいは垂直スライス)から始めて、時間をかけて磨いていきます。納得のいく結果が得られるまで、粗い部分を何度も削り落としていくのです。例えるなら木工のようなもので、粗い木材から始めて徐々に洗練させ、芸術作品へと仕上げていく感覚です。(木工が実際にどんなものかはまったく知りませんが、きっとそんな感じなのだろうと想像しています。)
重要なのは、これはプロトタイピングに似てはいますが、同一ではないという点です。違いは、書いたコードを捨てることを前提としていないことです。むしろ、問題が持つ反復的な性質を活かし、最終版にたどり着くまで意図的に「下書き」に取り組むのです。必要であれば、どの段階で手を止めて現状のバージョンを出荷することもできます。
このアプローチは、試行錯誤や素早い反復が求められるクリエイティブなプロジェクトでうまく機能すると感じています。ゲーム開発やスクリプト言語を背景に持つ人は、探索的に作業することに慣れているため、この手法を好む傾向があります。
この手法をとるとき、私が意識しているルールは次のとおりです。
- 内なる完璧主義者をオフにします。
- 初稿を書いている最中は編集しません。
- コードの重複は全面的に許容します。
- リファクタリング、リファクタリング、ひたすらリファクタリングします。
- テストは初稿が完成するまで後回しにします。
- まずは最も外側のAPIに集中して固め、その後に内部を磨き上げます。
このアプローチでは、コードを捨てて新しいことを試すのが容易になります。一方で、事前に計画を立て、整理整頓された体系的な進め方を好む人にとっては、もどかしく感じられることもあります。その「混沌」とした感じが、敬遠される要因のようです。
例として、Rustでゲームを書く場合を考えてみます。ゲームのあらゆる側面を調整し、ゲームプレイのメカニクスが「しっくりくる」と感じられるまで素早く反復したいと思うでしょう。
そのためには、ゲームループの骨組みだけから始めるとよいでしょう。次に、画面内を動き回れるプレイヤーキャラクターを追加します。ジャンプの高さや移動速度を、感触が良くなるまで調整します。この段階では、あなたとゲームロジックの間に抽象化はほとんどありません。重複したコードやハードコードされた値が多くなるかもしれませんが、今はそれで構いません。核となるゲームプレイのメカニクスが固まってから、コードのリファクタリングを始めればよいのです。
ゲームデザインの初期段階でBevyなどのフレームワークを使うと、Rustがかえって邪魔になることもあると感じています。エンティティ・コンポーネント・システムはかなり重厚に感じられ、素早い反復を妨げることがあります。(少なくとも、前回Bevyを試したときはそう感じました。)
macroquadを使って自分でウィンドウと描画ループを作ったときの方が、ずっと良い体験でした。ええ、コード全体が一つのファイルに収まっていましたし、テストもありませんでした。語るべきアーキテクチャも存在しませんでした。
それでも……ゲーム制作は素晴らしい体験でした!コードはいつでも後からリファクタリングできると分かっていましたし、何よりも目の前のゲームプレイを正しく作り上げることに集中したかったのです。
私のゲームループは次のとおりです。極めて命令的で、始めるのに大規模なフレームワークを学ぶ必要はありませんでした。
#[macroquad::main("Game")]
async fn main() {
let mut player = Player::new();
let input_handler = InputHandler::new();
clear_background(BLACK);
loop {
// Get inputs - only once per frame
let movement = input_handler.get_movement();
let action = input_handler.get_action();
// Update player with both movement and action inputs
player.update(&movement, &action, get_frame_time());
// Draw
player.draw();
next_frame().await
}
}このコードを理解するのに、Rustのエキスパートである必要はありません。
ループの各イテレーションでは、シンプルに次のことを行っているだけです。
- 入力を取得します。
- プレイヤーの状態を更新します。
- プレイヤーを描画します。
- 次のフレームを待ちます。
これは、この種の開発ではごく典型的な設計です。
やろうと思えば、今からコードを磨き上げ、よりモジュール化された本番対応の設計へとリファクタリングすることもできます。入力処理とプレイヤーロジックを分離する「リスナー/コールバック」システムを導入したり、複数のゲームオブジェクトを管理するシーングラフを導入したり、ゲームエンティティとそのコンポーネントを管理するオントロジーシステムを導入したりすることも可能です。でも、なぜわざわざそうする必要があるでしょうか。今はアーキテクチャではなく、ゲームメカニクスが重要なのです。
適切なバランスを見つける
どちらの手法でも、正確で保守性が高く効率的なシステムを構築できます。優劣はありません。
多くの人は、どちらか一方のアプローチに惹かれる傾向があると感じています。ただ、両方のアプローチに精通し、どちらをいつ適用すべきかを知っておくと役立ちます。賢く選んでください。二つのアプローチは問題の異なる端から出発するため、途中で切り替えるのはかなり難しいからです。
記事をランダムに読む