データ指向プログラミング入門:Java・Rust・Pythonで学ぶ4原則と実装パターン
この記事でわかること
- データ指向プログラミング(DOP)の4原則と、OOPが抱える複雑性をどのように解消するか
- Java(records + sealed interfaces + pattern matching)でDOPを実現する具体的な実装パターン
- Rust のStruct of Arrays(SoA)パターンによるキャッシュ効率の最適化手法
- Python の dataclass / TypedDict を使ったデータ中心設計の実践例
- 「DOPを使うべき場面」と「OOPを使うべき場面」の判断基準
対象読者
- 想定読者: OOP(オブジェクト指向プログラミング)の経験があるMLエンジニア・ソフトウェアエンジニア
-
必要な前提知識:
- Python の基礎文法(dataclass の基本は知らなくてもOK)
- Java または Rust いずれかの基本的な読解力(コード例はコメント付きで解説)
- クラス設計・継承・ポリモーフィズムの基本理解
結論・成果
データ指向プログラミングは、Yehonathan Sharvit が体系化した4原則 ——コードとデータの分離、汎用データ構造の使用、データの不変性、スキーマとデータの分離—— に基づくプログラミングパラダイムです。OOPのようにデータと振る舞いを1つのオブジェクトに閉じ込めるのではなく、データを透明で不変な値として扱い、それを操作する関数を分離して設計します。
Rustでの実測ベンチマークでは、Array of Structs(AoS)からStruct of Arrays(SoA)へのデータレイアウト変更だけで約50%の処理速度向上が報告されています(An Introduction to Data Oriented Design with Rust)。Javaでは、records + sealed interfaces + pattern matching の組み合わせにより、switch式で網羅性チェック付きの型安全なデータ処理が可能になりました。Nubankの事例では、Clojureの不変データ設計によりシステムの大部分を純粋関数・ステートレスなコードで構成でき、並行処理のバグを大幅に削減できたと報告されています(Building Nubank)。
DOPの4原則を理解する
データ指向プログラミングの概念は、Yehonathan Sharvit の著書『Data-Oriented Programming』(Manning, 2022)で体系化されました。また、Brian Goetz(Java言語アーキテクト)は Inside.java の記事シリーズ でJava向けにDOP原則を再定義しています。ここでは両者の定義を統合して、4つの原則を解説します。
原則1: コードとデータを分離する
OOPではクラスの中にデータ(フィールド)と振る舞い(メソッド)を同居させます。DOPではこの2つを明確に分けます。
Pythonで比較してみましょう。
OOP的なアプローチ:
# OOP: データと振る舞いが一体化
class Order:
def __init__(self, items: list[str], quantities: list[int], prices: list[float]):
self.items = items
self.quantities = quantities
self.prices = prices
def total(self) -> float:
return sum(q * p for q, p in zip(self.quantities, self.prices))
def add_item(self, item: str, quantity: int, price: float) -> None:
self.items.append(item)
self.quantities.append(quantity)
self.prices.append(price)
DOP的なアプローチ:
# DOP: データは単なる値、関数は独立して存在
from dataclasses import dataclass
@dataclass(frozen=True) # frozen=True で不変にする
class Order:
items: tuple[str, ...]
quantities: tuple[int, ...]
prices: tuple[float, ...]
# 関数はデータ構造の「外」に置く
def calculate_total(order: Order) -> float:
return sum(q * p for q, p in zip(order.quantities, order.prices))
def add_item(order: Order, item: str, quantity: int, price: float) -> Order:
# 既存のorderを変更せず、新しいOrderを返す
return Order(
items=order.items + (item,),
quantities=order.quantities + (quantity,),
prices=order.prices + (price,),
)
DOPでは Order はデータを保持するだけの透明な構造体です。calculate_total や add_item は Order に依存しない独立した関数であり、テストもしやすくなります。MLエンジニアにとっては、PyTorchの nn.Module(状態 + 振る舞い)と JAX の「パラメータは単なる辞書、関数は純粋関数」の違いに似ています。
なぜこの分離が重要か:
- テストが容易になる: 関数は入力と出力だけで検証できる(モックが不要)
- 再利用性が向上: 同じデータ構造に対して複数の関数を自由に追加できる
- 並行処理の安全性: データが不変なのでロック不要
原則2: 汎用データ構造でデータを表現する
DOPでは、ドメインごとに専用のクラスを作るのではなく、マップ・リスト・タプルなどの汎用データ構造を活用します。これはML分野で、特徴量を専用クラスではなく dict や DataFrame で扱うのと同じ発想です。
# 汎用データ構造(辞書)でデータを表現
from typing import TypedDict
class OrderItem(TypedDict):
name: str
quantity: int
price: float
# 汎用関数:任意のOrderItemリストに適用可能
def total_price(items: list[OrderItem]) -> float:
return sum(item["quantity"] * item["price"] for item in items)
# データは単なる辞書
order: list[OrderItem] = [
{"name": "GPU A100", "quantity": 2, "price": 10000.0},
{"name": "SSD 2TB", "quantity": 4, "price": 200.0},
]
print(total_price(order)) # 20800.0
ただし、この原則の適用には注意が必要です。すべてを辞書にすると型安全性が失われます。Pythonでは TypedDict(外部入力向け)と dataclass(内部ロジック向け)を使い分けるのが現実的なアプローチです。
注意: 「汎用データ構造を使う」は「すべてを辞書にする」という意味ではありません。各言語の型システムが提供する透明なデータ型(Pythonの
dataclass、Javaのrecord、Rustのstruct)を活用しつつ、不必要な振る舞いの付与を避けることがポイントです。
原則3: データは不変にする
DOPの3つ目の原則は、データを作成後に変更しないことです。変更が必要な場合は、変更を適用した新しいデータを作ります。
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class UserProfile:
name: str
email: str
role: str
# 元のデータ
user = UserProfile(name="田中", email="tanaka@example.com", role="viewer")
# ロール変更: 元のuserは変わらない
admin_user = replace(user, role="admin")
print(user.role) # "viewer"(変更されていない)
print(admin_user.role) # "admin"(新しいインスタンス)
不変データの利点は並行処理で顕著になります。MLパイプラインでデータの前処理を並列実行する場合、不変データなら競合状態(race condition)を心配する必要がありません。
Nubankの Alex Miller 氏は「システムに必要な状態の量は驚くほど少ない」と述べています。不変データを前提に設計すると、コードの大部分が純粋関数・ステートレスになり、状態管理が必要な箇所が自然と最小化されます(Building Nubank)。
よくある誤解: 「不変データは遅い」
「毎回コピーするのは非効率では?」という疑問は自然ですが、実際には構造共有(Structural Sharing)という技術により、変更されていない部分は元のデータと共有されます。Clojureの永続データ構造では、高分岐木(branching factor 32)のルートから変更されたリーフまでのパスのみをコピーするため、ほぼ定数時間で操作が完了します(Clojure's Persistent Data Structures)。
原則4: スキーマとデータ表現を分離する
DOPの4つ目の原則は、「データの形がどうあるべきか」(スキーマ)と「実際のデータ」を別々に管理することです。
from pydantic import BaseModel, Field
# スキーマの定義(データとは独立)
class TrainingConfig(BaseModel):
model_name: str = Field(..., pattern=r"^[a-zA-Z0-9_-]+$")
learning_rate: float = Field(..., gt=0, lt=1)
batch_size: int = Field(..., ge=1, le=4096)
epochs: int = Field(..., ge=1, le=1000)
# データ(例: JSONファイルから読み込み)
raw_config = {
"model_name": "bert-base",
"learning_rate": 0.001,
"batch_size": 32,
"epochs": 10,
}
# スキーマによるバリデーション(境界で実施)
config = TrainingConfig(**raw_config)
MLエンジニアにとって、これは馴染み深いパターンです。Pydanticによるリクエストバリデーションや、TensorFlowのtf.data パイプラインにおけるスキーマ定義と同じ考え方です。外部入力の境界でバリデーションを行い、内部では型安全なデータとして信頼するというアプローチです。
JavaでDOPを実装する:records + sealed interfaces + pattern matching
Javaは近年、Project Amberの一環としてDOPを言語レベルでサポートする機能を追加しています。Brian Goetz は InfoQ の記事 で「records, sealed classes, and destructuring with record patterns constitute the first feature arc of data-oriented programming for Java」と述べています。
sealed interface + record で代数的データ型を表現する
OOPでは「状態によって振る舞いが変わるオブジェクト」をクラス階層と多態性で表現しますが、DOPではsealed interface + recordで代数的データ型(Algebraic Data Type)を構築します。
// sealed interface: このインターフェースを実装できるクラスを制限
// Pythonで言えば Union[Success, Failure, Timeout, Interrupted] に近い
public sealed interface AsyncResult<V> {
// 各ケースはrecord(不変データクラス)で定義
record Success<V>(V value) implements AsyncResult<V> {}
record Failure<V>(Throwable cause) implements AsyncResult<V> {}
record Timeout<V>() implements AsyncResult<V> {}
record Interrupted<V>() implements AsyncResult<V> {}
}
Pythonの Union 型や、MLフレームワークでよく見る Result 型に近い考え方です。record は Pythonの @dataclass(frozen=True) と同等で、不変・透明なデータ保持クラスを簡潔に定義できます。
pattern matching で安全にデータを処理する
Java 21以降の switch式とパターンマッチングを使うと、sealed interfaceの全ケースを網羅的に処理できます。
// pattern matching によるswitch式: 全ケースの処理漏れをコンパイル時に検出
public String handleResult(AsyncResult<String> result) {
return switch (result) {
case AsyncResult.Success<String>(var value)
-> "成功: " + value;
case AsyncResult.Failure<String>(var cause)
-> "失敗: " + cause.getMessage();
case AsyncResult.Timeout<String> t
-> "タイムアウト: リトライしてください";
case AsyncResult.Interrupted<String> i
-> "中断されました";
// ↑ 全ケースを網羅しないとコンパイルエラー
};
}
なぜOOPのVisitorパターンより優れているか:
- Visitorパターンでは新しいケースの追加時にインターフェースとすべての実装クラスを修正する必要がある
- sealed interface + pattern matching なら、新しいrecordを追加するだけで、処理漏れはコンパイラが検出してくれる
注意: DOPはOOPの完全な置き換えではありません。Brian Goetz 自身が「data-oriented programming excels with simple services that process plain, ad-hoc data, while OOP remains superior for modeling complex entities and defending boundaries」と述べている通り、両者は補完関係にあります(InfoQ)。
実際のユースケース: APIレスポンスのモデリング
// ユーザー検索APIのレスポンスを代数的データ型でモデリング
public sealed interface SearchResult {
record NoMatch() implements SearchResult {}
record ExactMatch(User user) implements SearchResult {}
record FuzzyMatches(List<User> candidates) implements SearchResult {}
}
// レスポンスの処理: 網羅性が保証される
public Page renderSearchResult(SearchResult result) {
return switch (result) {
case NoMatch()
-> new NotFoundPage("ユーザーが見つかりません");
case ExactMatch(var user)
-> new UserProfilePage(user);
case FuzzyMatches(var candidates)
-> new DisambiguationPage(
candidates.stream().limit(10).toList()
);
};
}
このパターンは「不正な状態を表現不可能にする」(Make illegal states unrepresentable)という原則を体現しています。SearchResult は3つの状態しか取りえず、各状態が保持するデータも型で保証されています。
Java DOP の今後: Carrier Classes
2026年2月、Brian Goetz はcarrier classes/interfaces の設計ノートを公開しました。record は便利ですが、「すべてのフィールドがコンストラクタ引数」「equals/hashCode の自動生成が必須」といった制約があります。carrier class はこれらの制約を緩和しつつ、パターンマッチングとの互換性を維持する仕組みです。
| 特性 | record | carrier class(提案中) |
|---|---|---|
| 不変性 | 暗黙的にfinal | 選択可能 |
| コンストラクタ | 全フィールド必須 | 柔軟に定義可能 |
| equals/hashCode | 自動生成 | 選択可能 |
| パターンマッチング | 対応 | 対応(予定) |
| 継承 | 不可 | 可能 |
RustでDOPを実装する:SoA パターンとキャッシュ効率
ここまでは「データの論理的な設計」の話でしたが、Rustの世界ではデータの物理的なメモリレイアウトもDOPの重要な要素です。これは「Data-Oriented Design」(DOD)と呼ばれ、CPUキャッシュの効率を最大化するためにデータの配置を最適化するアプローチです。
Array of Structs(AoS)vs Struct of Arrays(SoA)
ゲームエンジンやシミュレーションで多数のエンティティを扱う場合、データのメモリレイアウトがパフォーマンスに大きく影響します。
// AoS: オブジェクト指向的なレイアウト
// 各Playerのデータがメモリ上で連続して配置される
struct Player {
name: String, // ← 位置更新時に不要
health: f64, // ← 位置更新時に不要
location: (f64, f64),
velocity: (f64, f64),
acceleration: (f64, f64),
}
// players: [Player0全データ][Player1全データ][Player2全データ]...
// 位置更新時、nameやhealthもキャッシュラインに載ってしまう
fn update_positions_aos(players: &mut Vec<Player>) {
for player in players.iter_mut() {
player.location.0 += player.velocity.0;
player.location.1 += player.velocity.1;
player.velocity.0 += player.acceleration.0;
player.velocity.1 += player.acceleration.1;
}
}
// SoA: データ指向的なレイアウト
// 同じ種類のデータがメモリ上で連続して配置される
struct Players {
names: Vec<String>,
health: Vec<f64>,
locations: Vec<(f64, f64)>,
velocities: Vec<(f64, f64)>,
accelerations: Vec<(f64, f64)>,
}
// locations: [loc0][loc1][loc2]... ← 連続メモリ
// velocities: [vel0][vel1][vel2]... ← 連続メモリ
// accelerations: [acc0][acc1][acc2]... ← 連続メモリ
fn update_positions_soa(world: &mut Players) {
for (pos, (vel, acc)) in world
.locations
.iter_mut()
.zip(world.velocities.iter_mut().zip(world.accelerations.iter()))
{
pos.0 += vel.0;
pos.1 += vel.1;
vel.0 += acc.0;
vel.1 += acc.1;
}
}
An Introduction to Data Oriented Design with Rust のベンチマークによると、SoAレイアウトではコンパイラがループを2要素同時に展開し、約50%の速度向上を達成しています。これはアルゴリズムの変更なしに、メモリレイアウトの変更だけで実現された改善です。
同ベンチマークでは他にも以下の結果が報告されています:
| パターン | 速度改善 | 要因 |
|---|---|---|
| AoS → SoA | 約50%向上 | キャッシュライン効率化、ループ展開 |
| LinkedList → Vec | 約10倍高速 | 連続メモリによるベクトル化 |
| 動的ディスパッチ → 単相化 | 約4倍高速 | vtableインダイレクション除去 |
| ホットループ内分岐除去 | 約4倍高速 | 分岐予測ミス低減 |
soa-derive: RustでSoAを自動導出する
手動でSoAレイアウトを管理するのは煩雑です。soa-derive クレートを使うと、#[derive(StructOfArray)] マクロでSoAバージョンを自動生成できます。
use soa_derive::StructOfArray;
// #[derive(StructOfArray)] で自動的にSoAバージョンが生成される
#[derive(StructOfArray)]
struct Particle {
position: (f64, f64, f64),
velocity: (f64, f64, f64),
mass: f64,
charge: f64,
}
// 自動生成される: ParticleVec(SoAレイアウトのVec)
// positions: Vec<(f64, f64, f64)>
// velocities: Vec<(f64, f64, f64)>
// masses: Vec<f64>
// charges: Vec<f64>
注意: SoAが常にAoSより高速とは限りません。個々のエンティティの全フィールドにアクセスするパターン(例: シリアライズ、ログ出力)ではAoSの方が効率的です。アクセスパターンに応じてレイアウトを選択することが重要です。
ECSパターン: ゲームを超えたバックエンド適用
Entity-Component-System(ECS)はゲームエンジンで生まれたアーキテクチャパターンですが、近年はバックエンドシステムにも適用されています。TheOpinionatedDev の記事では、リアルタイム物流システム(数千台のデバイスが300msごとにテレメトリを送信)で従来のOOPアプローチでは性能が不足し、ECSパターンへの移行で解決した事例が紹介されています。
ECSの3要素:
-
Entity: IDだけを持つ(データなし)。Pythonで言えば単なる
int -
Component: データだけを持つ(振る舞いなし)。
dataclassやstructに相当 - System: ロジックだけを持つ(データなし)。特定のComponent群を処理する純粋関数
Unity DOTS、Unreal Engine Mass、Rust の Bevy Engine など主要なゲームエンジンがECSを採用しています。
Pythonでの実践: dataclass と TypedDict の使い分け
MLエンジニアが最も使うPythonでのDOP実践パターンを整理します。
dataclass(内部ロジック向け)vs TypedDict(外部入力向け)
| 特性 | dataclass | TypedDict |
|---|---|---|
| 実体 | クラスインスタンス | 辞書(dict) |
| 不変性 |
frozen=True で不変化可 |
不変化不可(通常のdict) |
| 用途 | 内部ドメインモデル | JSON/API入力の型付け |
| バリデーション | Pydanticと組合せ | 実行時バリデーションなし |
| パフォーマンス |
slots=True で高速化 |
dict相当 |
| MLでの類似物 |
nn.Module のconfig |
JSONの学習設定ファイル |
from dataclasses import dataclass
from typing import TypedDict
# TypedDict: 外部から来るJSON入力の型定義
# 実態はただのdictなので、JSONデシリアライズと相性が良い
class RawExperimentConfig(TypedDict):
model_name: str
learning_rate: float
batch_size: int
# dataclass: 内部で使うドメインモデル
# frozen=True + slots=True で不変・高速
@dataclass(frozen=True, slots=True)
class ExperimentConfig:
model_name: str
learning_rate: float
batch_size: int
effective_batch_size: int # 計算で導出される値
# 境界でバリデーション → 内部モデルに変換
def parse_config(raw: RawExperimentConfig, num_gpus: int) -> ExperimentConfig:
return ExperimentConfig(
model_name=raw["model_name"],
learning_rate=raw["learning_rate"],
batch_size=raw["batch_size"],
effective_batch_size=raw["batch_size"] * num_gpus,
)
# 純粋関数: ExperimentConfigを受け取り、新しい値を返す
def adjust_for_gradient_accumulation(
config: ExperimentConfig, accumulation_steps: int
) -> ExperimentConfig:
from dataclasses import replace
return replace(
config,
effective_batch_size=config.effective_batch_size * accumulation_steps,
)
ハマりポイント: frozen=True の dataclass を使う場合、__hash__ が自動生成されるため set や dict のキーとして使えます。ただし、list や dict をフィールドに含めると frozen=True でもその中身は変更可能です。完全な不変性が必要な場合は tuple や frozenset を使いましょう。
実践パターン: ML実験管理をDOPで設計する
from dataclasses import dataclass, replace, field
from typing import TypedDict
# --- データ定義(不変) ---
@dataclass(frozen=True, slots=True)
class Metric:
name: str
value: float
step: int
@dataclass(frozen=True, slots=True)
class ExperimentResult:
experiment_id: str
config: ExperimentConfig
metrics: tuple[Metric, ...] # tupleで不変性を保証
status: str # "running" | "completed" | "failed"
# --- 関数(データから分離) ---
def add_metric(result: ExperimentResult, metric: Metric) -> ExperimentResult:
"""メトリクスを追加した新しいExperimentResultを返す"""
return replace(result, metrics=result.metrics + (metric,))
def mark_completed(result: ExperimentResult) -> ExperimentResult:
"""完了状態に更新した新しいExperimentResultを返す"""
return replace(result, status="completed")
def best_metric(result: ExperimentResult, metric_name: str) -> Metric | None:
"""指定メトリクスの最良値を返す"""
matching = [m for m in result.metrics if m.name == metric_name]
return max(matching, key=lambda m: m.value) if matching else None
# --- 使用例 ---
initial = ExperimentResult(
experiment_id="exp-001",
config=ExperimentConfig("bert-base", 0.001, 32, 128),
metrics=(),
status="running",
)
# 各操作は新しいインスタンスを返す(元のデータは不変)
with_loss = add_metric(initial, Metric("accuracy", 0.85, step=100))
with_more = add_metric(with_loss, Metric("accuracy", 0.92, step=200))
completed = mark_completed(with_more)
# 履歴が自然に残る
print(initial.status) # "running"(変更されていない)
print(completed.status) # "completed"
print(best_metric(completed, "accuracy")) # Metric(name='accuracy', value=0.92, step=200)
DOP vs OOP: どちらを使うべきか
DOPとOOPは二者択一ではなく、問題の性質に応じて使い分けるものです。
| 判断基準 | DOPが適している | OOPが適している |
|---|---|---|
| データの性質 | 不変の値(イベント、設定、レコード) | 変化するエンティティ(ライフサイクルがある) |
| 操作パターン | データの変換・集約が中心 | 状態遷移・ライフサイクル管理が中心 |
| 拡張の方向 | 新しい操作の追加が多い | 新しいデータ型の追加が多い |
| 並行性要件 | 高い(マルチスレッド、分散処理) | 低い(シングルスレッド中心) |
| 代表的な適用先 | データパイプライン、API処理、バッチ処理 | GUIフレームワーク、ゲームのアクター |
よくある間違い: 「DOPが新しいからOOPより良い」と考えてしまうことです。DOPは不変データの処理に強く、OOPは複雑なエンティティのモデリングに強い。実際のシステムでは両方のアプローチを組み合わせるのが現実的です。例えば、ドメインモデルの外側(APIレスポンス処理、データ変換パイプライン)にはDOPを、内側(複雑なビジネスルール)にはOOPを適用する「外殻DOP・内核OOP」のアプローチが有効です。
よくある問題と解決方法
| 問題 | 原因 | 解決方法 |
|---|---|---|
frozen=True のdataclassが遅い |
フィールドアクセスのオーバーヘッド |
slots=True を追加する(Python 3.10+) |
| 不変データの深いネスト更新が煩雑 |
replace() の連鎖が長くなる |
lens パターンやイミュータブル更新ライブラリの活用 |
| Java record の制約(継承不可等) | record の設計上の制限 | carrier classes(Java将来バージョン)を待つか、sealed interface で代用 |
| SoAのコードが冗長 | 手動でのフィールド分離管理 | Rust: soa-derive、C++: テンプレート、ライブラリの活用 |
| 既存OOPコードベースへの段階的導入 | 全面書き換えのコスト | 新規モジュールからDOPを適用。APIレスポンス処理やデータ変換層から導入 |
まとめと次のステップ
まとめ:
- データ指向プログラミングの4原則は、(1) コードとデータの分離、(2) 汎用データ構造、(3) 不変データ、(4) スキーマ分離
- Javaでは records + sealed interfaces + pattern matching により言語レベルでDOPをサポートし、代数的データ型と網羅的パターンマッチングで型安全なデータ処理が可能
- RustのSoAパターンではメモリレイアウトの最適化だけで約50%の速度向上が報告されており、アルゴリズム変更なしにキャッシュ効率を改善できる
- Pythonでは
dataclass(frozen=True)+TypedDictの使い分けにより、境界バリデーションと内部不変モデルの両立が実現できる - DOPはOOPの置き換えではなく補完であり、データの性質と操作パターンに応じて使い分けるのが現実的
次にやるべきこと:
- 既存プロジェクトのAPIレスポンス処理やデータ変換層で、DOPパターンを試してみる
- Java開発者は
sealed interface+record+ switch式のパターンを小さなモジュールで実践する - Rust開発者はホットパス(頻繁に実行される処理)のデータレイアウトをAoSからSoAに変更し、ベンチマークで効果を測定する
- Yehonathan Sharvit 著『Data-Oriented Programming』(Manning)で4原則を体系的に学ぶ
参考
- Data-Oriented Programming - Yehonathan Sharvit (Manning)
- Data-Oriented Programming in Java v1.1 - Brian Goetz (Inside.java)
- Data Oriented Programming in Java - Brian Goetz (InfoQ)
- Data-Oriented Programming for Java: Beyond Records - Brian Goetz (OpenJDK)
- Java Explores Carrier Classes to Extend Data-Oriented Programming beyond Records (InfoQ)
- An Introduction to Data Oriented Design with Rust - James McMurray
- soa-derive - Struct of Array helpers in Rust (GitHub)
- The Data-Oriented Rust Pattern: ECS Beyond Games (Medium)
- Designing Real Systems with Immutable Data in Clojure - Nubank
- Clojure's Persistent Data Structures (Java Code Geeks)
- CppCon 2025: More Speed & Simplicity: Practical Data-Oriented Design in C++
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。