積み上げと磨き上げ
原文は Matthias Endler により に公開されました。 このブログを購読する
長年にわたり、堅牢なソフトウェアシステムを構築する上で、互いに補完し合う2つのアプローチに行き着きました。それが「積み上げ」と「磨き上げ」です。
積み上げとは、小さなコアから始めて徐々に機能を追加していくことです。磨き上げとは、非常に大雑把なアイデアから始めて、時間をかけて洗練させていくことを意味します。
どちらが本質的に優れているということはありません。ほとんどスタイルの問題であり、チームの力学や問題領域への習熟度に左右されます。そもそもこのテーマに関する私の考えが特に目新しいわけでもありませんが、これまでの学びをまとめておきたいと思いました。
積み上げ

出典: Wikimedia パブリックドメイン
「積み上げ」は、まず堅固な土台を作ることに重点を置きます。よく知っているシステムに取り組むときや、参照できる明確な仕様があるときに、私はこのアプローチを好んで使います。たとえば、プロトコルの実装や、私のMOS 6502エミュレータのようなハードウェアのエミュレーションで活用しています。
「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を使って自分でウィンドウとレンダリングループを作ったときの方が、ずっと良い体験でした。ええ、コード全体が1つのファイルに収まっていましたし、テストもありませんでした。語るべきアーキテクチャすら存在しませんでした。
それでも……ゲームに取り組むのは最高に楽しい体験でした!いつでも後からリファクタリングできることはわかっていましたが、まずは今この瞬間に集中して、ゲームプレイをきちんと仕上げたかったのです。
次に示すのが、私のゲームループです。極めて手続き的で、始めるにあたって大きなフレームワークを学ぶ必要もありませんでした。
#[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のエキスパートである必要はありません。
ループの各イテレーションで、私がやっていることは単純に次のとおりです。
- 入力を取得する
- プレイヤーの状態を更新する
- プレイヤーを描画する
- 次のフレームを待つ
この種の作業では、ごく典型的な設計です。
やろうと思えば、今からコードを磨き上げ、よりモジュール化されたプロダクション対応の設計へとリファクタリングすることもできます。入力処理とプレイヤーのロジックを分離するための「リスナー/コールバック」システムや、複数のゲームオブジェクトを管理するためのシーングラフ、あるいはゲームエンティティとそのコンポーネントを管理するためのオントロジーシステムを導入することもできるでしょう。でも、なぜわざわざそうする必要があるでしょうか?今はアーキテクチャではなく、ゲームのメカニクスこそが重要なのです。
適切なバランスを見つける
どちらのやり方でも、正確で、保守性が高く、効率的なシステムにたどり着くことができます。優劣はありません。
多くの人はどちらか一方のアプローチに惹かれる傾向があると感じています。ただ、両方のアプローチに精通し、どちらのモードをいつ適用すべきかを知っておくことは役に立ちます。賢く選んでください。2つのアプローチは問題の異なる端から出発するため、途中で切り替えるのはかなり厄介なのです。
記事をランダムに読む
コメント
ログインしてコメントする