Rustが難しいって言われるのはデザインパターンのせいかも説

ポエムです。

現在、Rust言語で音楽系ソフトを制作中です。

一般に「Rustは強力だが習得が難しい言語」って言われてます。よくある説明だと所有権システムが難しいとか言われてますが、言語仕様そのものよりも設計段階で、オブジェクト指向での鉄板パターンがRustでは使えない/使いにくいのが原因なんじゃないかと思ったんです。

所有権システムは難しいか?

よくRust学習のハードルとして引き合いに出される「所有権」システム。

簡単に言うと「スコープ内でも変数にアクセス制限つけるで」という言語仕様ですね、これのおかげでコンパイル時に”変更されてはいけない値・もう存在しない値”へのアクセスが検知されてるわけです。

C/C++出身だとそれほど難しくもないというか、Cの生ポインタmallocやC++のstd::shared_ptrに比べればスマートだしゼロコストだしでありがたみしか無いです。

Rustはビルドエラーのメッセージが親切

あまり難しくないと感じたのは、ビルドエラーメッセージのわかりやすさです。

前出のstd::shared_ptrを無理やりfreeするコードをビルドしてみると、確かにコンパイルエラーで止まってくれます。でもエラーメッセージは100行弱、それをパッと見で「これはstd::shared_ptrのエラーだ」とわかるまでにはある程度の経験が要ります。トレースログも出ているのでどの変数にトラブルが発生しているか探せますが、初学者にはイミフなことうけあいです。

Rustの場合、「この値は移動済みやで」とか5行程度のエラーメッセージで教えてくれますので、メモリやポインタの基本概念がわかっていればRust自体は初心者でも修正が容易です。


というわけでポインタやメモリ管理の概念がない言語からRustに移った人には難しいのかな、という印象です。

そんな私も、最初はRust難しいって思ったんですけどね、それは「オブジェクト指向言語に思考が染まりすぎていた」のが原因だったように思います。

世の中の多くの人気言語はオブジェクト指向

オブジェクト指向言語(OOP)とは、というのはこの記事を探して読んでるあなたには釈迦に説法だと思いますので割愛。

オブジェクト指向とひとことに言ってもJavaのようなガチガチのものから、Javascriptのような「ゆるい」OOPまで様々ありますが、

  • クラスがある
  • 継承がある

厳密にはもっと条件がありますけど、この2点を満たせばオブジェクト指向って言っていいんじゃないでしょうか。

クラスのカプセル化でメモリ保護ができる、継承で一回のコーディングで同じ機能を安全に使い回せる、ということで2000年くらいからOOPが少しずつ広まっていって、2010年頃は
「OOPに非ずはプログラミング言語に非ず」
みたいな雰囲気ありましたね。

PythonもRubyもPHPもC#もVisualBasicもオブジェクト指向です。TIOBEで調べたら2025年8月時点でトップ10のうち8個がOOP、OOP言語で57.1%。(Perlも一応オブジェクト指向でカウントしてます)

OOPの弱点

OOPでは基本的に、クラスの中に機能も値も持ちます。

大規模な開発になると、どのクラスがどの役割を担っているのかがわかりにくくなって、複数のクラスで同じような機能を実装していたり、クラスの継承関係が複雑になってしまったりということが起こります。

ゲームプログラミングとかだとこれが顕著です。例えば

  • Monsterクラスを継承したMageクラス
    • castFirebolt()を実装
  • Monsterクラスを継承したGoblinクラス
    • Goblinクラスを継承したGoblinMageクラス
      • castFirebolt()を実装 <-機能がダブる

または魔法を使う機能を別クラスにして

  • Magicianクラス
    • castFirebolt()を実装
    • castThunderbolt()を実装
  • Monsterクラス
    • MonsterクラスとMagicianクラスを継承したMageクラス
    • Monsterクラスを実装したGoblinクラス
      • GoblinクラスとMagicianクラスを継承したGoblinMageクラス
        • castThunderbolt()は使えないようにする <-冗長
        • 代わりにcastSummonSkelton()を実装 <-将来のバグの予感
    • EliteMonsterクラス
      • エリートモンスターは魔法も使えるようにしたい
      • 魔法を使わないパワー系エリートモンスターも必要
        • じゃぁElitePowerMonsterクラスとEliteMagicianMonsterクラスで分けるか
          • 死亡フラグ
  • 回復魔法を使うモンスター用のクラスも必要
    • Healerクラス作るか
      • ひょっとしてEliteHealerMonsterも必要?
  • バフ・デバフモンスターのクラスも必要 ….(無限に続く)

この辺を継承で表現しようとすると、ダイヤモンド継承問題という「継承元で実装した同名の関数・変数がどっちが使われるのか」が発生しやすく、結局モンスターの種類数だけ専用クラスを作るハメになったりします。(DiabloIIがそうだったらしいです。ソース失念)

こういったデメリットがあるため、Unity、UnrealEngine、Godotなどの主要ゲームエンジンでは、エンティティ・コンポーネント・システムというOOPの言語仕様に依らない仕組みでこの問題を回避しています。オブジェクトに機能(コンポーネント)を追加していくスタイルですね。

Rustは非オブジェクト指向

RustやGo言語は比較的新しい言語でオブジェクト指向が真っ盛りの頃に作られた言語ですが、これらのデメリットを避けるために敢えてオブジェクト指向を排除した言語設計になっています。先に上げたUnityのコンポーネントのように、trait(Goはinterface)を使って”機能をオブジェクトに追加する”ような設計です。

さっきのゲームのモンスターの例だと

  • Monsterトレイト
  • Spellcasterトレイト
  • Eliteトレイト ….etc

これらの組み合わせで、オブジェクトに機能を追加してゴブリン、ゴブリンメイジ、エリートゴブリンなどバリエーションを作っていけます。

// 基本的なモンスターの行動を定義するtrait
trait Monster {
    fn name(&self) -> &str;
    fn health(&self) -> u32;
    fn attack_power(&self) -> u32;
    
    // デフォルト実装を持つメソッド
    fn attack(&self) -> String {
        format!("{}が{}のダメージで攻撃した!", self.name(), self.attack_power())
    }
    
    fn is_alive(&self) -> bool {
        self.health() > 0
    }
}

// 魔法使いの特殊能力を定義するtrait
trait Spellcaster {
    fn mana(&self) -> u32;
    fn cast_spell(&self, spell_name: &str) -> String;
}

// 特殊能力を持つエリートモンスターのtrait
trait Elite {
    fn special_ability(&self) -> String;
    fn leadership_bonus(&self) -> u32;
}

// 通常のゴブリン
#[derive(Debug)]
struct Goblin {
    name: String,
    health: u32,
    attack_power: u32,
}

impl Goblin {
    fn new(name: String) -> Self {
        Self {
            name,
            health: 25,
            attack_power: 8,
        }
    }
}

// Goblin用のMonster trait実装
impl Monster for Goblin {
    fn name(&self) -> &str {
        &self.name
    }
    
    fn health(&self) -> u32 {
        self.health
    }
    
    fn attack_power(&self) -> u32 {
        self.attack_power
    }
    
    // デフォルト実装をそのまま使用(オーバーライドしない)
}

// ゴブリンメイジ
#[derive(Debug)]
struct GoblinMage {
    name: String,
    health: u32,
    attack_power: u32,
    mana: u32,
}

impl GoblinMage {
    fn new(name: String) -> Self {
        Self {
            name,
            health: 20,
            attack_power: 5,
            mana: 30,
        }
    }
}

// GoblinMage用のMonster trait実装
impl Monster for GoblinMage {
    fn name(&self) -> &str {
        &self.name
    }
    
    fn health(&self) -> u32 {
        self.health
    }
    
    fn attack_power(&self) -> u32 {
        self.attack_power
    }
    
    // デフォルト実装をオーバーライド
    fn attack(&self) -> String {
        if self.mana > 0 {
            format!("{}が魔法で{}のダメージを与えた!(残りマナ: {})", 
                   self.name(), self.attack_power() + 3, self.mana - 5)
        } else {
            format!("{}がスタッフで{}のダメージで攻撃した!", 
                   self.name(), self.attack_power())
        }
    }
}

// GoblinMage用のSpellcaster trait実装
impl Spellcaster for GoblinMage {
    fn mana(&self) -> u32 {
        self.mana
    }
    
    fn cast_spell(&self, spell_name: &str) -> String {
        match spell_name {
            "fireball" => format!("{}がファイアボールを詠唱した!15ダメージ!", self.name()),
            "heal" => format!("{}がヒールを唱えて10HP回復した!", self.name()),
            _ => format!("{}は{}という呪文を知らない...", self.name(), spell_name),
        }
    }
}

// エリートゴブリン
#[derive(Debug)]
struct EliteGoblin {
    name: String,
    health: u32,
    attack_power: u32,
    experience: u32,
}

impl EliteGoblin {
    fn new(name: String) -> Self {
        Self {
            name,
            health: 45,
            attack_power: 15,
            experience: 100,
        }
    }
}

// EliteGoblin用のMonster trait実装
impl Monster for EliteGoblin {
    fn name(&self) -> &str {
        &self.name
    }
    
    fn health(&self) -> u32 {
        self.health
    }
    
    fn attack_power(&self) -> u32 {
        self.attack_power
    }
    
    fn attack(&self) -> String {
        format!("{}が渾身の力で{}のダメージで攻撃した!クリティカルヒット!", 
               self.name(), self.attack_power() + 5)
    }
}

// EliteGoblin用のElite trait実装
impl Elite for EliteGoblin {
    fn special_ability(&self) -> String {
        format!("{}が「戦闘の雄叫び」を発動!周囲の味方の攻撃力が上がった!", self.name())
    }
    
    fn leadership_bonus(&self) -> u32 {
        5
    }
}
.....

traitはOOPの継承のように使うことも可能です。implするときに関数をオーバーライドして異なる動作をさせるも可能です。(上のコードだとGoblinMageのattack関数は、マナがある時は魔法攻撃するように変えています)

非オブジェクト指向だから難しい

90年代、Pascal言語やC言語がプログラミング言語の世界を席巻してた時代は「オブジェクト指向は難しい」って言われてたものですが、今では「Rustは非オブジェクト指向だから難しい」という逆の問題が出てきているのは興味深いです。

人間の価値基準って、

「これまで積み上げてきたパターンが通用しない」⇒難しい

なんでしょうね。

Rustではデザインパターンが通用しない

さて主題に入りますが、厳密にデザインパターンというとGoF(Gang of Four)の23種類を指しますが、実際にはRAIIとかDIとかGoFに入っていないものも含めて呼ばれることが多いですね。メジャーなのはトータル40種類くらいでしょうか?

全部が全部使えないわけではないんですが、Rustの仕様ではかなり無理をしないと使えないものがある、というのを認識しておくと学習効率が良くなると思います。

Rustで使えるパターンと使えないパターンを一覧表にしてみました。

パターン名適合度理由
Strategyトレイトでの実装が自然、コンパイル時型安全性
Template Methodデフォルト実装付きトレイト、コンポジション重視に適合
Builder型安全ビルダー、コンパイル時設定漏れ検出
Factory Method列挙型・トレイトとの組み合わせが自然
Decoratornewtypeパターン、トレイト実装で安全
Commandクロージャ・トレイトオブジェクトで効率的
RAII (Resource Acquisition Is Initialization)Rustの所有権システムの核となる概念、Drop traitで自動実装
Move SemanticsRustの所有権システムそのもの、コピー不要でゼロコスト
Repository Patternトレイトでの抽象化が自然、テスタビリティ向上
Dependency Injectionトレイト・ジェネリクスで型安全なDI実現
Option/Maybe PatternOption<T>として言語に組み込み済み
Result/Either PatternResult<T, E>として言語に組み込み済み
Pipeline PatternIterator chainやメソッドチェーンで自然に表現
Functional Programming Patternsmap, filter, fold等が標準、関数型プログラミング支援
Adapternewtypeパターンで型安全だが用途限定
Facadeモジュールシステムと相性良いが必要性低い
Observerチャネル・イベント駆動で可能だが所有権管理要注意
State列挙型・パターンマッチングで効果的だが複雑な場合は困難
Chain of ResponsibilityIteratorチェーン・Result型で表現可能
Composite木構造の相互参照が課題、Rc<RefCell<T>>で解決可能
Bridgeトレイトオブジェクトで実現可能だが動的ディスパッチコスト
Proxyスマートポインタで実現可能だが用途限定
Iterator標準機能として既に組み込み済み
Actor Modeltokioやactixで実現可能だが、所有権との調整が必要
Event Sourcing実装可能だが、永続化との組み合わせで複雑化
CQRS (Command Query Responsibility Segregation)トレイト分離で実現可能、設計次第で効果的
Publish-Subscribeチャネルやイベントバスで実現、所有権管理要注意
MVC/MVP/MVVMWeb frameworkで実現可能だが、GUI分野では限定的
Plugin Architecture動的ロードが制限的、traitオブジェクトで静的プラグイン
Lazy LoadingOnceCell、lazy_staticで実現可能
Object Pool実装可能だが、所有権システムで必要性が低下
Circuit Breaker実装可能、非同期処理との組み合わせで有効
Throttling/Rate Limitingtokioのセマフォ等で実現可能
SingletonNGグローバル状態は非推奨、依存性注入を推奨
PrototypeNGCloneトレイトで可能だが必要性低い
Abstract FactoryNG複雑な継承階層が必要、設計思想に不適合
VisitorNGdouble dispatchが困難、パターンマッチングで代替
FlyweightNG内部可変性管理が複雑、Rc/Arcが重い
MementoNG内部状態カプセル化困難、所有権システムと不適合
MediatorNG複雑な相互参照で所有権管理困難
InterpreterNG動的型変更が必要、静的型システムに不向き
Active RecordNGORM的アプローチ、Rustでは非推奨(データ競合リスク)
Data Access Object (DAO)NGRepository patternで代替、直接的実装は非推奨
Mixin PatternNG多重継承不可、トレイトで部分的に代替
Aspect-Oriented ProgrammingNG動的な横断的関心事が困難、マクロで部分的代替
Reflection PatternNG実行時リフレクション制限的、マクロで静的メタプログラミング
Dynamic ProxyNG実行時動的生成が困難、静的なトレイトオブジェクトで代替

こんな感じですね。◎はRustの言語仕様レベルで対応されているものです。

個人的によく使う/見るパターンを色付きにしてますが、ほとんど非推奨で草。

特にSingleton,Flyweight,ObserverあたりはRustの所有権システムと本当に相性が悪いので、使い慣れたパターンを捨てて新しいパターンを学ぶ必要が出てくるわけですよ。この辺が「Rustの学習コストが高い」って言われる所以なんじゃないか思います。

救いはないのか

AbstractFactoryパターンなどOOPにかなり依拠したパターンはどうしようもないですが、それ以外はRust固有の機能で解決できたり、他のパターンで解消出来ることがほとんどです。いくつか例を上げてみたいと思います。

Observerパターン

PHPだと無いと困るレベルなObserverパターンですけど、Rustにはmpscというマルチスレッド用の組み込み機能を使ったイベント処理のパターンがあります。

use std::sync::mpsc;
use std::thread;

#[derive(Debug, Clone)]
enum Event {
    UserLoggedIn(String),
    UserLoggedOut(String),
    DataUpdated(String),
}

struct EventPublisher {
    senders: Vec<mpsc::Sender<Event>>,
}

impl EventPublisher {
    fn new() -> Self {
        Self {
            senders: Vec::new(),
        }
    }
    
    fn subscribe(&mut self) -> mpsc::Receiver<Event> {
        let (tx, rx) = mpsc::channel();
        self.senders.push(tx);
        rx
    }
    
    fn publish(&self, event: Event) {
        // 失効したチャネルは自動的に除去される
        self.senders.retain(|sender| {
            sender.send(event.clone()).is_ok()
        });
    }
}

// 使用例
fn main() {
    let mut publisher = EventPublisher::new();
    
    // 複数のオブザーバーを登録
    let observer1 = publisher.subscribe();
    let observer2 = publisher.subscribe();
    
    // 各オブザーバーを別スレッドで実行
    thread::spawn(move || {
        while let Ok(event) = observer1.recv() {
            println!("Observer 1 received: {:?}", event);
        }
    });
    
    thread::spawn(move || {
        while let Ok(event) = observer2.recv() {
            println!("Observer 2 received: {:?}", event);
        }
    });
    
    // イベント発行
    publisher.publish(Event::UserLoggedIn("Alice".to_string()));
    publisher.publish(Event::DataUpdated("config.json".to_string()));
}

デフォで並列処理なのでObserverパターンより効率的です。

Singletonパターン

RustではStaticな変数・型をあまり推奨していません。(多分ライフサイクルをまたいで生存してしまうので、コンパイル時のエラーチェックに向いていないからだと想像します)

表にも書いてますけど依存性の注入(Dependency Injection; DI)を使うのがスタンダードなようです。

trait Logger {
    fn log(&self, message: &str);
}

trait DatabaseConnection {
    fn query(&self, sql: &str) -> Result<Vec<String>, String>;
}

struct ConsoleLogger;
impl Logger for ConsoleLogger {
    fn log(&self, message: &str) {
        println!("[LOG] {}", message);
    }
}

struct PostgresConnection {
    connection_string: String,
}

impl DatabaseConnection for PostgresConnection {
    fn query(&self, sql: &str) -> Result<Vec<String>, String> {
        // 実際のDB処理
        Ok(vec![format!("Result for: {}", sql)])
    }
}

// Singletonの代わりに、依存性を注入される構造体
struct UserService<L: Logger, D: DatabaseConnection> {
    logger: L,
    db: D,
}

impl<L: Logger, D: DatabaseConnection> UserService<L, D> {
    fn new(logger: L, db: D) -> Self {
        Self { logger, db }
    }
    
    fn get_user(&self, id: u32) -> Result<String, String> {
        self.logger.log(&format!("Getting user {}", id));
        let query = format!("SELECT * FROM users WHERE id = {}", id);
        self.db.query(&query)
            .map(|results| results.get(0).cloned().unwrap_or_default())
    }
}

// 使用例 - アプリケーションのエントリーポイントで依存性を構築
fn main() {
    let logger = ConsoleLogger;
    let db = PostgresConnection {
        connection_string: "postgres://localhost:5432/mydb".to_string(),
    };
    
    let user_service = UserService::new(logger, db);
    let _ = user_service.get_user(123);
}

組み込み系だとメモリ的に優しくない感じがしますが、どうやって捌いてるんでしょうね。

Reflectionパターン

HTTPアクセスのルーティングなどで使われたりするReflectionですが、これもRustの「コンパイル時に全ての動作が決定している」という特性に相性が良くないパターンです。実装うんぬんより、コンパイル時エラーが出ないのでRustの良いところが活かされにくいというか。

代替手法として、Strategyパターンが推奨されています。

あらかじめ設定として静的データファイル(JSONなど)でハンドラーを呼び分けるような感じになりますので実装の手間はちょっと増えます。しかしゼロコスト抽象化が活用できるので、無理にReflectionするよりも速度や消費メモリの面でもメリットが大きいようです。

use std::collections::HashMap;
use serde::Deserialize;

// Strategy trait
trait ProcessingStrategy {
    fn process(&self, data: &str) -> Result<String, String>;
    fn name(&self) -> &'static str;
}

// 具体的なStrategyの実装
struct DatabaseStrategy;
impl ProcessingStrategy for DatabaseStrategy {
    fn process(&self, data: &str) -> Result<String, String> {
        Ok(format!("Saved to database: {}", data))
    }
    fn name(&self) -> &'static str { "database" }
}

struct FileStrategy;
impl ProcessingStrategy for FileStrategy {
    fn process(&self, data: &str) -> Result<String, String> {
        Ok(format!("Saved to file: {}", data))
    }
    fn name(&self) -> &'static str { "file" }
}

struct CacheStrategy;
impl ProcessingStrategy for CacheStrategy {
    fn process(&self, data: &str) -> Result<String, String> {
        Ok(format!("Cached: {}", data))
    }
    fn name(&self) -> &'static str { "cache" }
}

// Strategy Registry(Reflectionの代替)
struct StrategyRegistry {
    strategies: HashMap<String, Box<dyn ProcessingStrategy>>,
}

impl StrategyRegistry {
    fn new() -> Self {
        let mut strategies: HashMap<String, Box<dyn ProcessingStrategy>> = HashMap::new();
        
        // Strategyを登録(Reflectionの動的インスタンス生成の代替)
        strategies.insert("database".to_string(), Box::new(DatabaseStrategy));
        strategies.insert("file".to_string(), Box::new(FileStrategy));
        strategies.insert("cache".to_string(), Box::new(CacheStrategy));
        
        Self { strategies }
    }
    
    fn get_strategy(&self, name: &str) -> Option<&dyn ProcessingStrategy> {
        self.strategies.get(name).map(|s| s.as_ref())
    }
    
    fn list_strategies(&self) -> Vec<&str> {
        self.strategies.keys().map(|s| s.as_str()).collect()
    }
}

// 設定ドリブンのプロセッサー(Reflectionの代替)
#[derive(Deserialize)]
struct ProcessConfig {
    strategy_name: String,
    data: String,
}

struct DataProcessor {
    registry: StrategyRegistry,
}

impl DataProcessor {
    fn new() -> Self {
        Self {
            registry: StrategyRegistry::new(),
        }
    }
    
    // 設定に基づいて動的に処理(Reflectionの代替)
    fn process_with_config(&self, config: &ProcessConfig) -> Result<String, String> {
        let strategy = self.registry.get_strategy(&config.strategy_name)
            .ok_or_else(|| format!("Strategy '{}' not found", config.strategy_name))?;
        
        strategy.process(&config.data)
    }
    
    // 文字列名から動的に処理(Reflectionの代替)
    fn process_by_name(&self, strategy_name: &str, data: &str) -> Result<String, String> {
        let strategy = self.registry.get_strategy(strategy_name)
            .ok_or_else(|| format!("Strategy '{}' not found", strategy_name))?;
        
        strategy.process(data)
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let processor = DataProcessor::new();
    
    // 利用可能なStrategy一覧(Reflectionのクラス一覧の代替)
    println!("Available strategies: {:?}", processor.registry.list_strategies());
    
    // 動的な処理実行(Reflectionの代替)
    let result1 = processor.process_by_name("database", "user data")?;
    println!("Result 1: {}", result1);
    
    let result2 = processor.process_by_name("file", "log entry")?;
    println!("Result 2: {}", result2);
    
    // 設定駆動の処理(Reflectionの代替)
    let config_json = r#"
    {
        "strategy_name": "cache",
        "data": "cached item"
    }
    "#;
    
    let config: ProcessConfig = serde_json::from_str(config_json)?;
    let result3 = processor.process_with_config(&config)?;
    println!("Config-driven result: {}", result3);
    
    // 存在しない戦略のテスト
    match processor.process_by_name("nonexistent", "data") {
        Ok(_) => println!("Unexpected success"),
        Err(e) => println!("Expected error: {}", e),
    }
    
    Ok(())
}

結論

AIに訊けばなんとかしてくれる

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です