ポエムです。
現在、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()を実装 <-機能がダブる
- Goblinクラスを継承したGoblinMageクラス
または魔法を使う機能を別クラスにして
- Magicianクラス
- castFirebolt()を実装
- castThunderbolt()を実装
- Monsterクラス
- MonsterクラスとMagicianクラスを継承したMageクラス
- Monsterクラスを実装したGoblinクラス
- GoblinクラスとMagicianクラスを継承したGoblinMageクラス
- castThunderbolt()は使えないようにする <-冗長
- 代わりにcastSummonSkelton()を実装 <-将来のバグの予感
- GoblinクラスとMagicianクラスを継承したGoblinMageクラス
- EliteMonsterクラス
- エリートモンスターは魔法も使えるようにしたい
- 魔法を使わないパワー系エリートモンスターも必要
- じゃぁElitePowerMonsterクラスとEliteMagicianMonsterクラスで分けるか
- 死亡フラグ
- じゃぁElitePowerMonsterクラスとEliteMagicianMonsterクラスで分けるか
- 回復魔法を使うモンスター用のクラスも必要
- Healerクラス作るか
- ひょっとしてEliteHealerMonsterも必要?
- Healerクラス作るか
- バフ・デバフモンスターのクラスも必要 ….(無限に続く)
この辺を継承で表現しようとすると、ダイヤモンド継承問題という「継承元で実装した同名の関数・変数がどっちが使われるのか」が発生しやすく、結局モンスターの種類数だけ専用クラスを作るハメになったりします。(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 | ◯ | 列挙型・トレイトとの組み合わせが自然 |
Decorator | ◯ | newtypeパターン、トレイト実装で安全 |
Command | ◯ | クロージャ・トレイトオブジェクトで効率的 |
RAII (Resource Acquisition Is Initialization) | ◎ | Rustの所有権システムの核となる概念、Drop traitで自動実装 |
Move Semantics | ◎ | Rustの所有権システムそのもの、コピー不要でゼロコスト |
Repository Pattern | ◎ | トレイトでの抽象化が自然、テスタビリティ向上 |
Dependency Injection | ◎ | トレイト・ジェネリクスで型安全なDI実現 |
Option/Maybe Pattern | ◎ | Option<T>として言語に組み込み済み |
Result/Either Pattern | ◎ | Result<T, E>として言語に組み込み済み |
Pipeline Pattern | ◎ | Iterator chainやメソッドチェーンで自然に表現 |
Functional Programming Patterns | ◎ | map, filter, fold等が標準、関数型プログラミング支援 |
Adapter | ▲ | newtypeパターンで型安全だが用途限定 |
Facade | ▲ | モジュールシステムと相性良いが必要性低い |
Observer | ▲ | チャネル・イベント駆動で可能だが所有権管理要注意 |
State | ▲ | 列挙型・パターンマッチングで効果的だが複雑な場合は困難 |
Chain of Responsibility | ▲ | Iteratorチェーン・Result型で表現可能 |
Composite | ▲ | 木構造の相互参照が課題、Rc<RefCell<T>>で解決可能 |
Bridge | ▲ | トレイトオブジェクトで実現可能だが動的ディスパッチコスト |
Proxy | ▲ | スマートポインタで実現可能だが用途限定 |
Iterator | ▲ | 標準機能として既に組み込み済み |
Actor Model | ▲ | tokioやactixで実現可能だが、所有権との調整が必要 |
Event Sourcing | ▲ | 実装可能だが、永続化との組み合わせで複雑化 |
CQRS (Command Query Responsibility Segregation) | ▲ | トレイト分離で実現可能、設計次第で効果的 |
Publish-Subscribe | ▲ | チャネルやイベントバスで実現、所有権管理要注意 |
MVC/MVP/MVVM | ▲ | Web frameworkで実現可能だが、GUI分野では限定的 |
Plugin Architecture | ▲ | 動的ロードが制限的、traitオブジェクトで静的プラグイン |
Lazy Loading | ▲ | OnceCell、lazy_staticで実現可能 |
Object Pool | ▲ | 実装可能だが、所有権システムで必要性が低下 |
Circuit Breaker | ▲ | 実装可能、非同期処理との組み合わせで有効 |
Throttling/Rate Limiting | ▲ | tokioのセマフォ等で実現可能 |
Singleton | NG | グローバル状態は非推奨、依存性注入を推奨 |
Prototype | NG | Cloneトレイトで可能だが必要性低い |
Abstract Factory | NG | 複雑な継承階層が必要、設計思想に不適合 |
Visitor | NG | double dispatchが困難、パターンマッチングで代替 |
Flyweight | NG | 内部可変性管理が複雑、Rc/Arcが重い |
Memento | NG | 内部状態カプセル化困難、所有権システムと不適合 |
Mediator | NG | 複雑な相互参照で所有権管理困難 |
Interpreter | NG | 動的型変更が必要、静的型システムに不向き |
Active Record | NG | ORM的アプローチ、Rustでは非推奨(データ競合リスク) |
Data Access Object (DAO) | NG | Repository patternで代替、直接的実装は非推奨 |
Mixin Pattern | NG | 多重継承不可、トレイトで部分的に代替 |
Aspect-Oriented Programming | NG | 動的な横断的関心事が困難、マクロで部分的代替 |
Reflection Pattern | NG | 実行時リフレクション制限的、マクロで静的メタプログラミング |
Dynamic Proxy | NG | 実行時動的生成が困難、静的なトレイトオブジェクトで代替 |
こんな感じですね。◎は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に訊けばなんとかしてくれる
コメントを残す