Rustが嫌いです。
0. 前書き - リモートデスクトッププロジェクトとの悲しき邂逅
私がremote-desktop-rs
というクロスプラットフォームのリモートデスクトッププロジェクトを始めたとき、Rustの評判を信じていた。「メモリ安全性とパフォーマンスの素晴らしい組み合わせ」「優しいコンパイラエラー」「素晴らしいエコシステム」——本当にそうだったのか?
1. 学習曲線は「少し急」ではなく「エベレスト級」
「所有権の概念を理解すれば、あとは簡単です」と言われ続けた。嘘だ。絶対に嘘だ。
1.1 所有権地獄
私はcommon/src/lib.rs
で単純な「ビルド情報」構造体を作成しようとした:
// 素晴らしい所有権システムと戦った結果の姿
pub fn build_info() -> BuildInfo {
let build_date = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
// この黒魔術がなければコンパイルすらできない
BuildInfo {
version: VERSION,
build_date: &*Box::leak(build_date.into_boxed_str()), // メモリリークを起こさないと静的文字列が作れない皮肉
commit_hash: option_env!("GIT_HASH"),
rust_version: &*Box::leak(format!("{}", rustc_version_runtime::version()).into_boxed_str()),
}
}
「メモリ安全な言語」なのに、静的文字列を生成するために意図的にメモリリークを起こすBox::leak
を使わざるを得ない皮肉。これが「直感的」で「安全」なコードの書き方なのか?
初心者だった私は、なぜか各フィールドを&'static str型で定義するという罠にはまったが、実は単にString型を使えば解決する問題だったのだ
// 正しい実装はこんなに簡単
pub struct BuildInfo {
version: String,
build_date: String,
commit_hash: Option<String>,
rust_version: String,
}
pub fn build_info() -> BuildInfo {
BuildInfo {
version: VERSION.to_string(),
// コンパイル時の時刻を埋め込む
build_date: env!("BUILD_DATE").to_string(),
commit_hash: option_env!("GIT_HASH").map(|s| s.to_string()),
rust_version: rustc_version_runtime::version().to_string(),
}
}
同じコードをGoで書くなら、こんなにシンプル:
Pythonならさらに簡単:
1.2 ライフタイム地獄
一度ライフタイムの概念を理解したと思っても、次のようなエラーに頭を抱える:
error[E0597]: `data` does not live long enough
--> src/protocol.rs:248:13
|
248 | &data[..]
| ^^^^^^^ borrowed value does not live long enough
249 | };
| - `data` dropped here while still borrowed
あなたはこのエラーを見て、すぐに解決策がわかりますか?私には分からなかった。3時間後(誇張)、スタックオーバーフローの助けを借りて、やっと解決策を見つけた:データをクローンするか、所有権を明示的に移動させる必要があった。
// 解決策1: クローンする(パフォーマンスに影響)
let data_clone = data.clone();
let slice = &data_clone[..];
// 解決策2: 所有権を移動し、新しい参照を作成
let owned_data = data;
let slice = &owned_data[..];
他の言語ではこんな問題は存在しない。例えばJavaScriptでは:
const data = getDataFromSomewhere();
const slice = data.slice(); // 何も考えずに脳死で使える
2. マルチプラットフォーム対応 = イベント駆動型悪夢
2.1 #[cfg]の迷宮
server/src/input/system.rs
は、条件付きコンパイルの迷宮と化した:
/// システム入力処理
pub struct SystemInput {
// プラットフォーム固有の実装
#[cfg(target_os = "windows")]
impl_: WindowsSystemInput,
#[cfg(target_os = "linux")]
impl_: LinuxSystemInput,
#[cfg(target_os = "macos")]
impl_: MacOsSystemInput,
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
impl_: DummySystemInput,
}
impl SystemInput {
pub fn new() -> Result<Self, SystemError> {
Ok(Self {
#[cfg(target_os = "windows")]
impl_: WindowsSystemInput::new()?,
#[cfg(target_os = "linux")]
impl_: LinuxSystemInput::new()?,
#[cfg(target_os = "macos")]
impl_: MacOsSystemInput::new()?,
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
impl_: DummySystemInput::new()?,
})
}
}
このコードを見て、美しいと感じるだろうか?私にはコードの20%が実際の機能実装で、残りの80%は条件付きコンパイルの呪文にしか見えない。
対照的に、TypeScriptでは優雅なファクトリーパターンで実装できる:
// システム入力ファクトリー
class SystemInputFactory {
static create(): SystemInput {
const platform = process.platform;
if (platform === 'win32') return new WindowsSystemInput();
if (platform === 'linux') return new LinuxSystemInput();
if (platform === 'darwin') return new MacOsSystemInput();
return new DummySystemInput();
}
}
2.2 Feature Flag地獄
server/Cargo.toml
は、依存関係の条件付き宣言でごった煮状態になった:
[features]
default = ["system-tray", "clipboard", "screen-capture", "system-info", "file-transfer"]
full = [
"system-tray",
"clipboard",
"screen-capture",
"system-info",
"file-transfer",
"webrtc-support",
"webp-support",
"async-support",
"windows-capture",
"x11-support",
"macos-support"
]
windows-capture = ["dep:windows-capture", "dep:windows", "dep:winapi"]
x11-support = ["x11rb", "xcb"]
macos-support = ["core-foundation", "objc", "cocoa"]
これを書いた私は、エディターで開いたCargo.toml
の数が多すぎて、どれが正しいバージョンなのかわからなくなり、.backup
、.temp
、.standalone
というファイルを作り始めた。web-client/Cargo.toml.backup
はその悲痛な証拠だ。
Nodeプロジェクトならpackage.json
一つで済む:
{
"name": "remote-desktop-web",
"scripts": {
"build:windows": "cross-env PLATFORM=windows webpack",
"build:linux": "cross-env PLATFORM=linux webpack",
"build:macos": "cross-env PLATFORM=macos webpack"
},
"optionalDependencies": {
"windows-capture": "^1.0.0",
"x11rb": "^1.0.0",
"cocoa": "^1.0.0"
}
}
3. エコシステムの断片化 - "Cargo cult" programming
3.1 依存関係地獄
PS C:\Users\e2258\python\remote-desktop\remote-desktop-rs\web-client> npm run start
# ... 省略 ...
Error: failed to start `cargo metadata`: program not found
Caused by: failed to start `cargo metadata`: program not found
Caused by: program not found
どこかで見たことのあるエラーメッセージではないだろうか?Rustの「素晴らしいエコシステム」の現実は、依存関係がバージョン競合を起こし、wasm-pack
のような準必須ツールがインストールできないことが日常茶飯事だということだ。
解決策を探すと、単に「wasm-pack
をインストールしてください」では終わらない:
# 最初は単純に思えた
cargo install wasm-pack
# しかし実際は...
rustup target add wasm32-unknown-unknown
cargo install -f wasm-pack
rustup override set nightly
npm install --global wasm-pack
コントラストとして、Node.jsのエコシステムでは:
# 通常はこれだけでOK
npm install
npm start
3.2 ドキュメント地獄
READMEには簡単に見える手順が書かれている:
cd web-client
npm install
npm run build
しかし現実は:
- 「
wasm-pack
をインストールしてください」 - 「いや、別のバージョンです」
- 「もしかしてWindowsですか?ならこの特殊な手順を...」
- 「あ、その依存関係はnightly版のRustでしか動きません」
- 「rustup default nightly」を実行
- 一晩寝て起きる(誇張)
- 「そのconfigは古いです」
4. ビルド時間 = 賢者タイム
4.1 ビルドプロファイル地獄
.cargo/config.toml
には、開発環境でも動くことを祈って書いた呪文がある:
[profile.release]
lto = true
codegen-units = 1
opt-level = 3
panic = "abort"
strip = true
Pythonならpython setup.py build
、GoはシンプルにRustと比較して約3倍の速さでビルドが完了する:
# Rust: 30秒以上かかる小さなプロジェクト
cargo build --release
# Go: 同等の機能なら10秒以内
go build -ldflags="-s -w" .
4.2 素晴らしいビルド体験
> cargo build --release
Updating crates.io index
Updating git repository `https://github.com/some-abandoned-crate`
# ... 30分後 ...
error: failed to compile `remote-desktop-rs v0.1.0`, 99 warnings, 1 error
エラーの原因は、鬼のように深い依存関係ツリーのどこかにある、使われていないマクロだった。解決策:
# 依存関係の特定のバージョンに固定
cargo update -p problematic-crate --precise 0.1.2
# さらに問題が続く場合、依存関係を手動で解決
cargo tree -i problematic-crate
cargo build --verbose # 詳細なエラーを確認
Node.jsならnpm audit fix
で多くの問題が自動修正される。
5. エラーメッセージは「親切」ではなく「冗長」
5.1 エラーメッセージ地獄
error[E0382]: borrow of moved value: `client_hello`
--> src/encryption.rs:128:20
|
123 | let client_hello = ClientHello { random, cipher_suites };
| ------------ move occurs because `client_hello` has type `ClientHello`, which does not implement the `Copy` trait
...
128 | let message = serialize_message(&client_hello)?;
| ^^^^^^^^^^^^^^^ value borrowed here after move
「素晴らしいエラーメッセージ」の現実は、問題解決のために関連性のない情報が山ほど表示され、初心者は途方に暮れる。
修正は実は簡単:#[derive(Clone, Copy)]
を追加するか、明示的にクローンする:
let message = serialize_message(&client_hello.clone())?;
JavaScriptやPythonなら、そもそもこのようなエラーは発生しない。
6. デバッグ = 苦行
6.1 デバッグ地獄
Rustのデバッグ体験は、1990年代のC++よりも悪い場合がある。特にWebAssemblyでは、単なるprintln!
デバッグすら至難の業となる。
// デバッグするためにコンソールAPIを呼び出す黒魔術
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
macro_rules! console_log {
($($t:tt)*) => (log(&format!($($t)*)))
}
一方、Pythonでは:
print(f"デバッグ情報: {variable}")
JavaScriptでも:
console.log(`デバッグ情報: ${variable}`);
7. あとがき - 絶望の先にあるもの
remote-desktop-rs
プロジェクトは、技術的には素晴らしいものになる可能性があった。しかし、開発の苦痛と引き換えに得られるものが本当に価値あるのか疑問に思うようになった。
同じ機能をTypeScriptやPythonで実装していたら、おそらく半分の時間で2倍の機能を実装できていただろう。確かにメモリ安全性は低下するかもしれないが、開発者のメンタルヘルスは保たれていただろう。
もしくはGoを選ぶべきだったかもしれない。Goはシンプルな構文、高速なコンパイル、実用的な並行処理モデル、そして何より「合理的に動く」という強みがある。Rustのような完璧なメモリ安全性はないが、ガベージコレクションの予測可能性と良好なパフォーマンスが得られる。
Rustは素晴らしい言語だ...もしあなたが苦痛を楽しめるマゾヒストであるなら。
私がそうでないことを知ったのは、このプロジェクトの途中だった・・・。
Discussion
BuildInfo
のメンバの型を&'static str
ではなくString
にすればリークさせる必要は一切ないと思うのですが、あえて&'static str
を使わなければいけない事情があったのでしょうか……?あと記事の趣旨とはそれますが、この
BuildInfo
という構造体はなんの用途で使っているのでしょうか?ビルド時間としてプログラム実行時の現在時刻が指定されているのはおかしい気がする。
おっしゃる通りです!これは完全に私の設計ミスでした。「静的文字列参照を返す関数」という固定観念にとらわれていました。ご指摘いただき、ありがとうございます。
こちらも本来なら
build_date
はビルド時の時刻を示すべきで、実行時の時刻ではおかしいですね。正しい実装であれば、ビルド時の情報を埋め込むマクロを使うべきでした。この構造体は主にログ出力やアプリのAbout画面、デバッグ情報表示に使っていました。実際にはビルドスクリプトでコンパイル時の環境変数を設定し、それを埋め込むのが正しいアプローチですね。私の設計と理解の問題だったと認めざるを得ません。
Rustを趣味のプログラムで使っているRust信者です。
ビルド時間の部分は特にすごくわかりますね。本当に遅いし、依存関係周りで問題が起こると本当に分かりづらい。本当に...。ただ、Rustは実行時の速度とビルドの速度を天秤にかけて実行時の速度に重きをおいているという点は補足しておきたいです。プログラマーにではなく、ユーザーに優しい言語です。
あと、デバッグが大変なのもわかります。wasmはまだ触ってないのでprintデバックすら厳しいのは知らなかったですが、スレッド内のパニックとかも要因がなかなか掴めなくてしんどいです。ここはネットで検索する限りRustの弱点として言われてることが(観測範囲では割と)多く、解決策らしい解決策も少なくともWindows環境では知らないです。Linux、macだと割とあるらしいですが。
所有権やライフタイムも初心者が苦しむところですね。というか自分も
'a
、'b
とかを指定するときはまだ苦しんでいます。ただ、基本的にBox::leakを使わないといけない時点で設計のほうが間違えてるのでは?と自分なら考えますね。オブジェクト指向プログラミング並に結構プログラムの考え方自体を変えないといけない部分がある言語なので。このあたりが学習難易度を上げている要因だと思います。Rustには思想がくっついています。なので、基本的に「嫌なニオイ」がしたら、自分が間違えていて思想に則った方法があるはずだという考えで進めると割とRust君は怒ってきません。優しい子になってくれます。Rustのエコシステムは本来、Rust内で閉じてさえいれば問題が起こったことがないので、「Rustで完結していれば」本当に優秀なエコシステムだと思っています。ただ、C/C++だったり、wasmだったり外部の連携をしようとすると、しんどいことはあります。ただこれはある程度仕方ない部分が大きい気がしています。外部の側が悪いこともありますし、そもそも全く別の言語同士をつなげること自体に少し無理がある気がするからです。
マルチプラットフォーム対応はしたことがないのでわかりませんが、Rustライブラリのexampleを見たときに
#[cfg]
の嵐で面食らったことはあります。ただ、トレイトかEnumを使って抽象化すればある程度#[cfg]
の嵐は防げるのかな...?ここは本当に詳しくないのでなんとも言えませんが。エラーメッセージは好み分かれますね。たまに長すぎて読む気の起きないやつはあります。特にトレイト系は。ただ、基本は必要十分だと思います。VSCode使ってると、ざっくりとしたエラーだけも確認できるので、必要だったら見るという感覚でした。
それぞれの章に言いたいことがあったから全部書いてたらめっちゃ長くなっちゃいましたが、言いたいことはこんな感じです!改めてRustについて考えさせてくれる良い記事をありがとうございます!やっぱり、批判なしに良い言語コミュニティは作れないので。
ぱっと見クローンで躓いているようなので所有権がまだ分かっていないのかなと思いました。
メモリ安全性等のメリットはあくまでC++比較ということでその他のGCを使っている言語からすれば複雑なことをひたすらやっているように感じます。仕方のないことです。
正直個人で使うメリットは薄いです。大規模開発で保守性が活きてくるようなイメージです
この設定ですが意味を理解して書かれてます?
LTOはリンク時最適化なので有効にすればビルドは遅くなります。
codegen-units
は最大何並列でコード生成するかなので1にすれば当然ビルドは遅くなります。過去に node-gyp のエラーで2日無駄にしたので、Rust エコシステムと npm だけで解決してる方が良くないすか?と思ってしまう
過激な言葉で批判している割に、所有権やライフタイムを全く理解しておらず、出鱈目で無知を晒しているだけの面白みに欠ける記事だと思いました。
TypeScriptやPythonで済むのであれば、Rustを使う必要はないでしょうね。でも、TypeScriptやPythonしか書けない人は、それらのインタプリタを修正することすらできないでしょうね。
CPythonのコードを読んだことありますか?自分でメモリの確保と解放をしなければならず、少し注意が散漫になったら不正なコードを書いてしまいそうになります。Rustの所有権システムは、CやC++で人間が注意しなければならなかったことをコンパイラーが代替するものです。
慣れるとRustのエラーメッセージは親切にしかみえなくなる。
たしかに学習曲線は中級者まで角度が高めかもですね。私も最初は本を読みながらやってたけどバージョンを同じにしてもビルドで動かず結構詰みました。手前味噌ですがRustをやる前にC++を学んだほうが良いかもしれないです。私は遠回りしてC++から入ってRustを学びました。なぜRustが誕生しなければならなかったのかを体験することでありがたみを理解できました。ここはやはりメモリ管理の地獄を渡り歩く経験をしておくとRustの所有権がなんて素晴らしい仕組みなんだとありがたみを知ることで恩恵を受けられます。何のアドバイスにもならないですがRustはただ速いとか安全とかだけで踏み込むとそこはジャングルですぐ帰りたくなるかもしれないそんな気持ちは確かに理解できます。
なんかおすすめの本とかないかな。結局私は公式のチュートリアルとRustのソースコードを読んで乗り切ったのでなんともですが良いのを見つけたらここに書きますね。
うーん、Cを学びましょう
Rustで書かれたDenoは実質RustなのでDeno使おう!(?)
#[cfg]
については記事中にあるように細かい単位で切り替えようと思うと大変なので、ファイル単位くらいにすると楽かもしれません。WASMは大変ですよね…。Rustのエコシステムの中でも結構ユーザ体験の悪い部類な気はしています。
「CLIやサーバサイド向けに書いたコードがこの程度の手間でブラウザでも動く」という観点では結構ありがたいのですが、
ブラウザをメインターゲットにするならやはりJS/TSが楽だろうと思います。
色んなコメントがありますが、個人的に面白かったです!
学習コスト高いのに、(Web系の)フリーランス案件数が少ないからRustやることはなさそうという思いました
いろいろありますが誰も指摘してない点を1つ
メモリリークはRust的には「安全」です
Rustについては、所有権の概念というより、それに基づいたデザインを考えることが最も難しいとされる部分なのではないかと思います。
もっとも、
BuildInfo
については、Rustらしい書き方をすることで解決すると考えます。この場合、すべてのフィールドはビルド時のメタデータを含むので、ランタイムで処理を行う必要はありません。
すべてのフィールドを静的ライフタイムとして定義するのがパフォーマンスの観点からしても適切な実装です。
Cでは、明示的に定義実装しない限り、すべての文字列は基本的に静的なライフタイムを持ちます。このRustプログラムでは、意味論的には等価のことを行っています。
[build-dependencies]
はビルド時のみに必要な依存関係を解決します。これにより、製品バイナリに不要なコードが含まれることを回避します。BuildInfo
はライフタイムを明示的に指定することでもっと安い実装に置換できます。String
は基本的にヒープを使うので、ビルド時メタデータに使用するには高価すぎます。さらに、
build_info
関数はconst
関数にすることができます。これにより、関数はランタイムではなくビルド時に評価されます。env!
マクロを使用するこれらの環境変数はビルド時に定義されている必要があるので、build.rs
を用意します。これはビルドスクリプトと呼ばれるもので、
cargo build
を実行したときに実行されます。ここで、必要な環境変数をセットします。このスクリプトはビルド環境でのみ実行され、ランタイムの実行バイナリに影響を与えません。
この方法でビルドしたバイナリは非常にクリーンで、全体を通してパニックフリーです。
このように、Rustにおいては設計思想が重要というのは、他の方が仰っているとおりかなと思います。
なんでもかんでもRustを使えばいい、という思考から、適切な選択をできるようになった頃にRustは強力な味方になります。
Rust学習、もし続けられるのであれば応援しています。
私は半分の時間で2倍の機能どころじゃなかったです。使い慣れたDenoならライブラリ不要で1時間でできるようなことに何日も費やしました。そんなんだからRustを諦めるべきかどうかで1ヶ月くらい悩みました・・Rustにしかないライブラリを使うために何とか続けられた感じです。
この気持ちめっちゃわかります!Rustやりながら詰まったらついついGoについて調べちゃうんですよね😄
c++で組んでいたとき、たった一文字抜けていただけで鬼のようなコンパイルエラーやらリンクエラーが出て途方に暮れたことを思い出した。
2.1 に関してはすでにご覧になっていると思いますが、
こちらの記事のように trait を実装することで緩和される可能性がある気がしました。個人的にはダッグタイピングを行うような感覚で DI していくと良い気がしています。
素人意見で恐縮です。
Rust入門したところなのでドキドキしながら読みました。
私はまだそこまで難しいことにぶつかっていないほどの入門者なので、今後覚悟して進んでいこうと思います…。