- Rust は強力な安全保証により、大規模なコードベースでも リファクタリング を安心して行え、生産性と保守性を高める
- 非同期スケジューリングに関するバグをコンパイラが事前に検出し、未定義動作 を防いで安定性を強化
- TypeScript のような言語は緩い型システムのため、非同期バグが本番環境で見つかることが少なくない
- Rust の 型システム はコード変更の影響を明確に示し、複雑なプロジェクトで信頼性と実験への意欲を高める
- Zig は Rust と異なり、エラー処理で検査が緩いため、タイプミスによるバグを見逃すことがあり、信頼性が低い
要約と背景
- Lubeno のバックエンドは 100% Rust で書かれており、コードベースが大きくなって全体を頭の中で把握しにくい段階に達した
- 大規模プロジェクトでは一般に、変更の副作用を確認しにくく 生産性低下 が起こる
- Rust の 安全保証 はコード変更時の影響を明確に示し、リファクタリングへの恐れを減らす
- この記事は、Rust コンパイラが非同期バグを検出した事例から始まり、Rust の生産性上の利点を探る
Rust の安全保証の事例
- 問題の状況: 構造体を同時アクセスのためにミューテックスでラップし、ロック取得後に非同期処理を実行
let lock = mutex.lock();
db.insert_commit(commit).await;
- 問題の発見: rust-analyzer はエラーを表示しなかったが、ルーター定義ファイルでコンパイルエラーが発生
.route("/api/git/post-receive", post(git::post_receive))
^^^^^^^^^^^^^^^^^
error: future cannot be sent between threads safely
- 原因分析:
- Web フレームワークは HTTP 接続ごとに非同期タスクを生成し、タスクスケジューラ がタスクをスレッド間で移動させる
- ミューテックスは同一スレッドでロック解除する必要があり、
.await の時点でスレッド移動が起きると 未定義動作 が発生する可能性がある
- Rust コンパイラはロックの寿命を追跡し、別スレッドで解放される可能性を検出する
- 解決方法:
.await の前にロックを解放する
- 意義: Rust は開発環境で再現しにくい非同期バグをコンパイル時に防止する
TypeScript との比較事例
- 問題の状況: TypeScript コードで非同期リダイレクトバグが発生
if (redirect) {
window.location.href = redirect;
}
let content = await response.json();
if (content.onboardingDone) {
window.location.href = "/dashboard";
} else {
window.location.href = "/onboarding";
}
- 問題の原因:
window.location.href は即時にリダイレクトせずスケジュールされるため、コード実行は継続する
- レースコンディション により意図しないリダイレクトが発生する
- 解決方法:
if ブロックに return を追加
if (redirect) {
window.location.href = redirect;
return;
}
- 限界: TypeScript には ライフタイム追跡 や借用規則がないため、この種のバグをコンパイル時に検出できない
Rust のリファクタリング上の利点
- Web 開発では Python、Ruby、JavaScript/Node.js は初期生産性が高い一方、コードベース拡大時には疎結合すぎて変更が難しくなる
- 変更後に予期しないエラーが発生し、コード修正への意欲が下がる
- Rust は 型システム が変更の影響を明確に示すため、リファクタリング への恐れを減らす
- 例: 「この変更は他の部分に影響する可能性がある」という警告により問題を事前に防げる
- コードベースが成長しても 生産性が向上 し、既存コードを再利用しつつ変更時の安定性を保てる
テストとの比較
- テストは リファクタリング 時の回帰防止に有用だが、コンパイラが強制しないため省略可能
- テスト作成では抽象化レベル、振る舞い vs 実装詳細、エラー防止への有効性を判断する必要があり、精神的負担 が大きい
- Rust は コンパイラ が一般的なミスを事前に防ぐため、テストに伴う意思決定の負担を減らす
Zig との比較
- Zig は Rust に似たシステムプログラミング言語だが、エラー処理が緩い
- switch 文ではタイプミスを検出するが、if 文では無視され、信頼性の問題が生じる
- Rust はこのような設計上の抜け穴を防ぎ、タイプミス や論理的エラーを厳格に検査する
示唆
- Rust は 安全保証 と厳格な型システムにより、大規模プロジェクトの生産性と安定性を向上させる
- 非同期バグ のような複雑な問題もコンパイル時に検出し、保守コストを削減する
- TypeScript と Zig の事例は、検査の緩さによるリスクを示し、Rust の厳格なコンパイラの価値を強調している
- Rust は Web 開発においても、初期生産性だけでなく長期的な コードベース管理 に強力なツールとして位置づけられる
3件のコメント
これが最高だ、これは強力な言語だ!! というのを見るたびに感じるんだけど、
思ったよりRust開発者がかなり少ないから、Rustをやれって誘ってるのかな?? と思ってしまう
Rust関連のおすすめ記事って、食客の「チュライ! チュライ!」みたいなものだと思うのは私だけでしょうか?
Hacker Newsの意見
昨年、Rustで書かれたvirtio-hostネットワークドライバを移植した。バックエンド、割り込みメカニズムの切り替え、ライブラリからスタンドアロンプロセスへの変更を行った。メモリマッピング、VM割り込み、ネットワークソケット、マルチスレッドまで扱う複雑なプログラムだった。Rustの経験はほとんどなく、virtioの経験も浅かったが、プロジェクトがコンパイルできる頃には完璧に動作していた。
Drop関連のバグが1つあった以外は簡単に直せた。Rustのライブラリは誤用しにくい構造で作られていて、大いに助けられたのだと思うRustは素晴らしいと思う。しかし、href代入バグがTypeScriptのせいだという意見には同意しない。問題の核心は、hrefを設定してもページ遷移が即座に起きず、後で処理される点にある。Rustでもまったく同じ問題は起こりうる。もしRustに
set_href関数があり、この動作が後で処理されるなら、次のようなコードが可能だ:set_href('/foo')
if (some_condition) { set_href('/bar') }
Rustならこうは設計しないと思う。setterで動作が起きるのは良いライブラリ設計ではないし、href代入と同時にページ遷移が起きないのも奇妙だ。Rustの標準ライブラリならこんな馬鹿げた実装はないだろう。これはRust vs TypeScriptの問題ではなく、Rustの標準ライブラリとWeb Platform APIの違いだ。Rustならこういうユーザー体験は提供しないだろうという点には同意する
公式に言えば、setterで即座に動作が起きるよう設計するのは望ましくない。名前も
navigate_to(href)のようにするのが正しい。ブラウザ環境ではJSコードはすべてコールバックとして動作し、イベントループに制御されるので、即時に動作しないのも自然な状況だRustの例は興味深いが、TypeScriptの例だけでTSが大規模プロジェクトに向いているかどうかは分からない。私はRubyでランタイム時にしばしばバグを捕まえなければならず不安だが、結局コミット前にきちんと動いて、コードを読んで修正しやすいのが気に入っている。location移動の問題はJavaScriptの問題であり、TSが受け継いだものだ。JSがプロパティを好きに変更できるようにしているために起きた話だ。しかしページが即座に消えるわけでもないので、この挙動は知ってしまえば合理的だ
技術的には、Rustなら
set_hrefが()または!を返す設計によって意味をより明確に示唆できる。しかし条件付きリダイレクトでは、依然として誤用を防ぐのは難しい私の意図は、Rustの所有権モデルなら
window.set_href('/foo')呼び出し時にwindowの所有権を奪うことで、2回呼び出せないAPIを設計できるという点を言いたかった。TypeScriptにはそもそもライフタイム追跡という概念がないので、これは不可能だ。JS APIがすでに存在するため、TypeScript側で所有権システムを導入する方法もない。Rustの複数の機能が組み合わさって、より強い保証を与えられる例として示したかった君がRustのほうが優れていると主張する根拠が、結局は「Rustプログラマのほうが優秀だから」に聞こえる。Rustプログラマはこんな循環論法はしない気がする
代入後のコードは、明示的に早期リターンしない限り実行され続ける。正直、なぜ値の代入がスクリプト実行を止めると思うのか分からない。TSの例には文脈不足があるかもしれないが、「データレース」を持ち出すには妙な例だ
window.location.hrefに値を代入すると、ブラウザがそのリンクへ移動する副作用がある。こういう挙動は意外で、単なる代入が新しいページをロードするという点ではexecveに近い感覚があり、JS実行が即座に止まると考えても不思議ではない。プログラミングでそうした仮定に依存すべきではないが、挙動自体が実際かなり変なので、混乱するのは分かるそう考えるかどうかは別として、こういうバグは誰かに指摘されれば修正方法は明確だ。著者が言いたかった核心は、TSでは捕捉できないこうしたバグが、実際には見つけにくく、時間もかかりうるということだ
exit()やexecve()などは実際に即座に実行を止めるので、リダイレクトもそういう動作だと思ってしまうことはある自分の経験を共有したというだけで問題視するのはおかしい
この代入にはページを離れさせる大きな副作用がある。即時に動作する非同期アクションだと考えるのも無理はないと思う。私もそう仮定したことがある
開発者が静的型システムの有用性に気づいたという話だ。こういう記事を見るたび、いつも面白い
ほとんどの利点は、結局は静的型、つまりコンパイル言語を使うことで得られるものではないか。Java、Go、C++も同じだ。TypeScriptにはトリックがあり、JSにコンパイルされてJSの問題も引き継ぐが、それでも十分使い物になる。Rustは型システムがより厳格なので、追加のコンパイル時チェックを受けられるが、その分学びにくく、読みづらいとも思う
ある程度は同意するが、Rustには型システムに所有権、共有/排他的アクセス、スレッド安全性、sum type(直和型)など、さらに多くの次元がある。所有権/借用システムのおかげで、引数の受け渡しが一時的なビューなのか、完全に引き渡すのかが明確になる。大規模プログラムや外部ライブラリを使うときの利点は大きい。たとえばGoのスライスタイプは、どの操作がランタイムで許されるのか明確でなく、読み取り専用で借用する方法も曖昧だ。Rustは型システムのレベルでスレッド安全性を保証できるため、他の言語ならランタイムでも見つけにくいデータレースをコンパイル時に防げる
すべての静的型付き言語をまとめて一つのものとして見るのは、union(sum)型とパターンマッチングの本当の力をまだ実感していないからだ。一度union型に慣れると、他の伝統的な静的型付き言語では満足できなくなる
大きな利点の一つは
traits/impl traitsだ。RustではC#のExtension Methodのように、どんな型にも後からトレイトを追加できる。ほとんどの言語では型はライブラリで定義された時点で固定されるが、Rustでは単純な型にも機能を段階的に積み上げていける。このlate-boundな性質が型システムに動的さを吹き込む要素になっている。少し極端に言えば、Rustの本当の超能力は借用チェッカーよりも型システムの開放性と柔軟性にある。最初から全部を設計しきらなくても、段階的に拡張していけばよい静的型付き言語だからといって、どれも同じ効果を生むわけではない。Javaは結局
Objectとランタイムキャストに依存する。Goにはenumがない。C++にはvariantの概念が追加されたが、安全に使うにはtry/exceptのような手動処理が必要で、構造的に扱いづらいRustは学ぶのが難しいと言われるが、実際にはきちんと学べばそれほど難しくない。多少は雑に書いて何かを動かすことが、コーディング初期には重要だが、Rustはそのやり方に不親切な言語だ。入門用言語としては勧めないが、読むのが難しいわけではない
Rustの強い安全性のおかげで、コードベースに手を入れるときの自信が大きくなる。この自信によって中核部分のリファクタリングも怖くなくなり、結果として生産性と保守性が大きく向上する。しかし、そうした効果のためにこそテストを書くのではないか。テストがなければ厳格なコンパイラは大いに役立つだろうが、テストをしっかり書けばどんな言語でも自信を持ってリファクタリングできる
可能な部分はコンパイラが静的に証明してくれるほうがよい。テストは静的保証が難しい状況にだけ使うのが最適だ。理想の最終形はフォーマル検証だが、現実には非常に難しいので一般論にはならないものの、原則としては正しい
良いテストと適切に活用された型システムは、どちらもバグ検出に有効だ。ただ、テストを書く話になるとxkcdの「Standards」漫画を思い出すことがある。標準を増やして標準を直すように、コードをさらに書き足してバグを捕まえる感じだ。それでも型システムの保守は言語設計者が担ってくれるので、プロジェクトごとに管理する必要はない
コードをリファクタリングするたびにテストも一緒にリファクタリングしなければならず、仕事が二倍になる
RustやF#の型システムは、コードをリファクタリングするときに最も真価を発揮すると思う。Fearless refactoringという表現がぴったりだ
Zigの例は衝撃的だ。あまりに不安定に見えて、こんな設計をどうして良いと思えるのか理解できない
これはおそらくバグだと思う。しかしZigのような作者主導の言語では、バグ修正が行われるには、その作者自身がそれをバグだと認めることが重要だ。意図されたものだと考えられれば、その設計のまま進み続けるかもしれない
どの言語にも多少は不安定な設計がある。たとえばGoやZigでは
mutex.unlock()を常に明示的に呼ぶ必要があり、スコープを抜けても自動では解放されない。一方でRustのas演算子のように、数値型間の変換が容易で、そのせいで丸一日バグ探しに費やしたこともあった最初はそのエラーに気づかなかったが、このコメントを見て知った
リンターが、システム内に存在しないエラー参照を検出し、
switchの使用を勧めるような警告を出せるのではないかと思う関数シグネチャに基づいてエラーセットが生成されるものだと思っていた。少し独特だ
強力で健全な静的型システムが多様な機能を提供してくれる点が気に入っている。私もHaskellのコードベース(100万SLOC)で大規模なリファクタリングが容易だった経験がある。高度な機能がなくても、型システムだけで十分可能だった
Rustは
await境界でロックを保持していることをきちんと検出してくれたが、そのロックをawait前に解放することが実際に安全かどうかは、追加の文脈が必要だ。ロックはトランザクションコミットが生成されるまで保持すべきだと思うので、await前に解放すると並行性の問題が起きるかもしれない。Rust asyncには詳しくないが、コミット後はjoinやselectで防ぐべきなのではないかと思うawaitでロックを保持する必要があるなら、async対応mutexを使えばよい。futuresやtokiocrateがこうしたロックを実装している。長く保持したり、awaitの間もロックを維持する必要がある場合に主に使う。通常のロックよりコストは高いawait境界でもロックを保持する必要があるなら、Tokioのasync対応mutexを使える。tokio/sync/struct.Mutexのドキュメント を参照