プログラミング
組み合わせの複雑さから線形な優雅さへ:変換エンジンの設計
From Combinatorial Mess to Linear Elegance: Architecting a Conversion Engine (blog.minimal.app)
要約
Minimalアプリは、Markdown、Rich Text、HTML、PDFなど複数のファイル形式に対応したインポート/エクスポート機能を提供します。この記事では、ファイル形式間の変換における複雑さを管理するため、中間表現(IR)と呼ばれる統一されたシステムを導入した経緯を説明しています。IRを使うことで、N^2の複雑さを持つ直接変換ではなく、N*2の線形な関係で新しい形式への対応が可能になり、システムの拡張性とメンテナンス性が向上しました。
全文翻訳
Minimalは現在、Markdown、リッチテキスト、HTML、PDF、プレーンテキスト、そして独自のMNML形式間でのインポートおよびエクスポートをサポートしています。以下のエッセイでは、Swiftプログラミング言語でこのシステムをどのように構築したかを説明します。人間中心のデザインと、この新しい技術をiOSおよびmacOSアプリにどのように組み込んだかについては、デザイン中心のエッセイをこちらでお読みください。
一つのファイル形式を別の形式に変換することは容易ではなく、サポートするファイル形式が増えるにつれて、その複雑さは増すばかりです。この複雑さを管理するため、私たちは中間表現(Intermediate Representation)に依存する統合されたシステムを構築しました。中間表現はファイル形式間の仲介役として機能します。中間表現(「IR」)を使用することで、あらゆるファイル形式のペア間で直接変換する代わりに、特定のファイル形式からIRへ、そしてIRから別のファイル形式へと簡単に変換できるようになります。
中間表現
もしメモをある形式から別の形式へ直接変換していたら、ファイル形式を追加するたびに複雑さが増す変換の網に囚われていたでしょう。この混乱を避けるため、私たちはまず中間表現と呼ばれるシステムを構築しました。混乱と優雅さの比較。IRがなければ、6つのファイル形式は30の関係(N^2 - N)を生み出すでしょう。IRがあれば、6つのファイル形式は12の異なる関係(N * 2)を生み出します。
IRはすべてのファイル形式の中間に位置します。このアーキテクチャでは、新しい形式のサポートを追加するには、他のファイル形式を考慮することなく、その形式専用のコンバータを構築するだけで済みます。データ型のサポートを拡張するにつれて、複雑さは線形に増加します。自然界もこれを行います。生物学者はこれを「蝶ネクタイ」または「砂時計」アーキテクチャと呼びます。要するに、単純化された中間段階が、相互作用の両側が独立して複雑になることを可能にします。例えば、細胞は信じられないほど多様な分子を取り込み、それがより少ない共有中間体(異化作用)に消化されます。反対側では、細胞はこれらの中間体を細胞が利用する複雑な分子の配列に再構築します(同化作用)。もし細胞がすべての入力分子を必要な出力分子にマッピングしなければならないとしたら、完全に代謝するために何倍もの内部プロセスが必要になるでしょう。中間表現はこれを簡素化し、操作の両側をより進化しやすくします。しかし、自然界はプログラマーとデザイナーに強力な教訓を提供します:共有された仲介者に依存するシステムはしばしば固定化されます。IRは方程式のいずれかの側が独自に進化しやすくしますが、より広範なシステムが進化するのを難しくします。IRの定義を変更するには、それと相互作用するすべてが新しい形に更新される必要があります。アーキテクチャのロックインの優れた例は、しばしば「凍結した事故」と表現される遺伝コードです。核酸情報(DNAに保存されRNAとしてコピーされる)はコドンと呼ばれる3単位の配列で読み取られ、各コドンはアミノ酸鎖の組み立ての指示です。これらの記号の意味は生命の機構に深く埋め込まれているため、単純に変更したり再解釈したりすることはできません。もしコドンの意味が変更されたら、細胞によってタンパク質が誤って構築されるでしょう。遺伝コード – コドンとアミノ酸の共有マッピング – は生物学的な中間表現のようなものです。それは核酸配列とタンパク質の構築の間に位置し、標準化されたコードを介して複雑な生命がどのように発現されるかを決定します。この標準が生命全体でほぼ普遍的であるのには理由があります。一度それが細胞機構の発現の中心となると、共有されたロジックとルールが変わる可能性はますます低くなりました。
コード
以下は、Swiftで書かれたIRのドキュメント構造です。名前の衝突を防ぐために独自の「IR」名前空間に配置しました(このコードは他のコードと一緒に存在します)。
```swift
/// Namespace for the Intermediate-Representation.
enum IR {
// Intentionally has no cases. Exists purely to scope the types below.
}
// MARK: - Document
extension IR {
/// A parsed note in dialect-neutral form.
struct Document: Equatable {
/// A sequence of `Block` (stacks vertically), each containing a sequence of `Inline` (stacks horizontally).
var blocks: [Block]
var resources: [String: Resource]
init(blocks: [Block] = [], resources: [String: Resource] = [:]) {
...
}
}
}
// MARK: - Blocks
extension IR {
/// A unit of textual content that stacks vertically.
indirect enum Block: Equatable {
case blankLine
case paragraph([Inline])
case codeBlock(language: String?, content: String)
case heading(level: Int, inlines: [Inline])
case bulletList([ListItem])
case orderedList(items: [ListItem], start: Int)
case todoList([TodoItem])
case blockquote([Block])
case pullquote([Inline])
case horizontalRule
case embed(resourceId: String)
}
struct ListItem: Equatable {
var blocks: [Block]
init(blocks: [Block]) {
self.blocks = blocks
}
}
struct TodoItem: Equatable {
var checked: Bool
var blocks: [Block]
init(checked: Bool, blocks: [Block]) {
...
}
}
}
// MARK: - Inlines
extension IR {
/// Content that flows horizontally inside a block.
indirect enum Inline: Equatable {
case text(String)
case strong([Inline])
case emphasis([Inline])
case underline([Inline])
case link(url: String, inlines: [Inline])
case inlineCode(String)
case folder(name: String)
case embed(resourceId: String)
case lineBreak
}
}
// MARK: - Resources
extension IR {
/// An embed payload. Held off the tree and referenced by id.
struct Resource: Equatable {
var kind: String
var mimeType: String?
var data: Data?
var url: String?
var attributes: [String: String]
init(kind: String, mimeType: String? = nil, data: Data? = nil, url: String? = nil, attributes: [String: String] = [:]) {
...
}
}
}
```
IR(中間表現)の構造を記述する実際のコード。
すべてのファイル形式が同じ規約をサポートしているわけではないため(例:Markdownは色付きテキストを表現せず、MNMLはテーブルをサポートしない)、変換中に「譲歩(concessions)」を発行することがよくあります。
```swift
/// A record of something the engine simplified, downgraded, or set aside during conversion.
struct Concession: Equatable {
var category: Category
var description: String
var count: Int?
init(category: Category, description: String, count: Int? = nil) {
...
}
enum Category: Equatable {
case unsupportedFormatting
case downgraded
case dropped
case truncated
}
}
extension Array where Element == Concession {
mutating func appendOrIncrement(_ concession: Concession) {
...
}
}
```
譲歩の集約とレポートの構造を記述する実際のコード。
解析とレンダリング
変換は、ソースファイルをIRに解析することから始まり、IRをターゲットファイル形式にレンダリングすることで終了します。以下にDocumentParserとDocumentRendererプロトコルを示します。各ファイル形式は、これらのプロトコルをカスタム実装することで、その解析およびレンダリングロジックを実装する必要があります。
```swift
/// Protocol for producing an `IR.Document` from source text of a specific `Format`.
/// Each parser handles exactly one input format — `DocumentParserMinimal` reads MNML, `DocumentParserHTML` reads HTML, and so on. The orchestrator picks the right one based on the caller's `Format`.
/// Parsers never render. Their only output is the Intermediate-Representation and any concessions.
protocol DocumentParser {
/// The input format this parser handles.
var format: Format { get }
/// Parse `source` into an IR document.
/// Any content the IR can't represent is recorded in the result's concessions.
func parse(_ source: String) throws -> ParseResult
}
/// The outcome of a parse: the IR document, plus a record of what was simplified.
struct ParseResult: Equatable {
var document: IR.Document
var concessions: [Concession]
init(document: IR.Document, concessions: [Concession] = []) {
self.document = document
self.concessions = concessions
}
}
```
DocumentParserプロトコル。実際のコード。
```swift
/// Protocol for producing output of a specific `Format` from an `IR.Document`.
/// Each renderer handles exactly one output format: `HTMLRenderer` emits HTML, `MNMLRenderer` emits MNML, and so on. The orchestrator picks the right
```