1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

データ指向プログラミング入門:Java・Rust・Pythonで学ぶ4原則と実装パターン

1
Last updated at Posted at 2026-03-28

データ指向プログラミング入門: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_totaladd_itemOrder に依存しない独立した関数であり、テストもしやすくなります。MLエンジニアにとっては、PyTorchの nn.Module(状態 + 振る舞い)と JAX の「パラメータは単なる辞書、関数は純粋関数」の違いに似ています。

なぜこの分離が重要か:

  • テストが容易になる: 関数は入力と出力だけで検証できる(モックが不要)
  • 再利用性が向上: 同じデータ構造に対して複数の関数を自由に追加できる
  • 並行処理の安全性: データが不変なのでロック不要

原則2: 汎用データ構造でデータを表現する

DOPでは、ドメインごとに専用のクラスを作るのではなく、マップ・リスト・タプルなどの汎用データ構造を活用します。これはML分野で、特徴量を専用クラスではなく dictDataFrame で扱うのと同じ発想です。

# 汎用データ構造(辞書)でデータを表現
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: データだけを持つ(振る舞いなし)。dataclassstruct に相当
  • 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=Truedataclass を使う場合、__hash__ が自動生成されるため setdict のキーとして使えます。ただし、listdict をフィールドに含めると frozen=True でもその中身は変更可能です。完全な不変性が必要な場合は tuplefrozenset を使いましょう。

実践パターン: 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原則を体系的に学ぶ

参考


注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?