はじめに
この記事は「プログラミング技術の変化で得られた知見・苦労話【PR】パソナテック Advent Calendar 2020」のために書かれたものです。
僕は去年の11月から一念発起してRustの勉強を初めて趣味で同人ゲームを開発しています。元々C++を4年程使っていて「C++最高、みんなC++使おう」とか友人に布教していました。しかし、C++プログラマは「一番自分たちの言語の批判に対して強くなる」と言われるほどC++はよくディスられます。もちろん僕も例外ではありませんでした(笑)。
一応僕もPythonを適当に使うようになってからC++のcppとhppを組み合わせるようなCの名残を感じるところや他言語と比べたときの標準ライブラリの貧弱さ、コードが冗長になりやすい点など使いにくいなあと思いはじめましていました。ですがPythonはあくまでも適当に使ってただけでしたし、一通り書けるJavaもC++と比べると性能面が満足できないのでしょうがなくC++をずっと使っていました。しかしC++に取り残されて老害と言われるのも癪なので、C++の後継として勢いのあるRustを使うかーと思って勉強を始めました。そんで、どうせなら何かを作りながら勉強したいと思ったので、今は同人ゲームを作っています。
ということでC++からRustに移行して得られた知見・苦労話を書いていきます。
Rustを使うメリット
1年Rustを使ってきて思ったRustを使うメリットを簡単に紹介します。
Optionが標準ライブラリにキチンと取り入れられている
例えばRustのVecでランダムアクセスをしようとすると、Option<&型>で返ってきます。Optionは素晴らしい機能で値が存在しないかもしれないことを表現でき、Rustのパターンマッチングで容易に分岐できます。C++にもstd::optional
なるものがありますが、std::vectorの返り値はoptional型ではありません。また、optional型はC++17の機能なので、一部のコンパイラではサポートされません。なので、範囲外アクセスしたときの尻拭いがRustのほうが圧倒的に楽で安全です。C++では下手なコードを書くと、その時点でクラッシュします。もう一度言うと、Rustの良いところは標準ライブラリがキチンと同じく標準ライブラリの一つであるOption型で値を返してくれるところです。ちなみに、Rustには似た機能としてOk
, Err
もあります。
let vec = vec![1, 2, 3];
let a: Option<&i32> = vec.get(0);
if let Some(num) = vec.get(0) {
println!("{}", num);
}
cargoがものすごく便利
Rustにはプロジェクト管理ツールとしてcargoが提供されます。cargoはプロジェクトのビルド、実行から、依存パッケージの解決、プロジェクトの設定まで何でもかんでもやってくれます。特に、依存パッケージの解決は非常に便利でその利便性はC++を圧倒していると思います。
例えば、C++で何らかのライブラリを使いたいなと思ったとします。Linuxならパッケージ管理ソフトである程度はインストールできますよね。しかし、最新版を使おうとしてソースからビルドしようとすると途端に大変になります。依存関係もごちゃごちゃでとんでもないことになります。しかも、ビルド用のライブラリパッケージをLinuxのパッケージ管理ソフトで行うとそのライブラリのバージョンは固定になってしまい、様々な環境に対応しようとすると面倒くさくてしょうがありません。cargoはこの悩みを全て解決してくれます。プロジェクトのトップディレクトリにCargo.tomlを配置して使いたいライブラリとそのバージョン(細かい指定も可)を記述すればOKです。プロジェクトごとにライブラリはダウンロード・ビルドされるため、プロジェクトごとに違うバージョンのライブラリを利用できることは当たり前なのです。それじゃあディスクの容量を食うという意見もありますが、現代のブロックデバイスの容量からすれば大したことは無いのではないでしょうか。
ちなみに、Cargo.tomlはTOML形式で記述し、以下のようになります。
The Cargo Book - https://doc.rust-lang.org/cargo/guide/dependencies.html より
[package]
name = "hello_world"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
edition = "2018"
[dependencies]
time = "0.1.12"
regex = "0.1.41"
また、cargoにはcargo check
というサブコマンドがあり、こいつが結構便利です。とりあえず文法上OKかどうか確かめるときは、このコマンドを実行します。もちろんコマンドラインから入力するのではなくエディタから実行しますが。これにより、コンパイルするよりも高速に、コードの問題を指摘してくれます。
クロスコンパイルが簡単にできる
同人ゲームを作っていると様々なプラットフォームに対応したいなと思うようになります。例えば、Linux上のg++でWindows用のアプリケーション用にクロスコンパイルしようとするとMingwなどを利用することになると思いますがセットアップが少し面倒くさいです。しかも、Mingwを利用するとディスクIOが遅くなったりして性能が低下することもあるそうです。実際にWindows上で動作するEmacsのファイルIOはめちゃくちゃ遅いです。Rustならcargoでクロスコンパイラを手軽にインストールできCargo.tomlに少し追記することで簡単にクロスコンパイルすることができます。これについては参考になる情報がインターネット上に発信されているので、興味がある人は調べてみると良いかもしれません。実際に僕もLinux上で開発していたときは、Windows用にクロスコンパイルしていて動作も特に問題無かったです。
ライブラリSerdeが驚くほど便利
RustにはSerdeという構造体を特定のファイル形式(JSONやTOML)にシリアライズ/デシリアライズできるライブラリがあります。これが半端なく便利です。構造体を定義して#[derive]
を用いてこれらを実装すると、何もしなくてもシリアライズやデシリアライズができるようになります。便利すぎます。C++にはこのようなライブラリは無かったような気がします。
参照が外れていてクラッシュすることが無くなった
Rustでは文法上、参照が外れることはありません。これは変数のライフタイムの概念や参照チェッカによってコンパイル時にしっかりと指摘してくれるからです。C++ではスタック変数の参照が残っていても文法上問題なければコンパイルできて実行もできてしまいます。最初はこれらの概念がよくわからず苦労しましたが、1ヶ月ほど使っているとだんだん慣れていきました。それと、実行時エラーって普通に面倒くさいんですよね。Linux上で開発しているときはgdbが使いにくくてなんだかなぁと思ってました。
生ポインタへの制約が強く、スマートポインタがデフォルトなのが良い
Rustで生ポインタを扱うことは殆どありません。そもそも生ポインタの利用は推奨されずスマートポインタを使うことが当たり前です。C++でもstd::unique_ptr
などのスマートポインタはありますがC++の文化として生ポインタを扱うことは未だに残っていると思います。加えて歴史のあるC++プログラムの生ポインタが全てスマートポインタに書き換わることは全く想像できません。Rustはこれらの教訓を得て設計されているのでスマートポインタが当たり前になっています。なのでメモリリークの心配からも解放されヌルポインタもありません。しかも、これによりガベージコレクションも不要になっています。余談ですが厳密に言うと、C++とRustのスマートポインタやメモリ管理は少し異なり、Rustはメモリ所有権を追跡し所有権が放棄された時点でメモリを破棄します。これはゼロコスト抽象化とか言われています。以下のウェブページが参考になります。
実装言語を「Go」から「Rust」に変更、ゲーマー向けチャットアプリ「Discord」の課題とは
https://www.atmarkit.co.jp/ait/articles/2002/10/news038.html
ゼロコスト抽象化とは何なのか?
https://qiita.com/kichion/items/d5d87b30176e1d2d5f54
こんなに高級なのにC++と同等の性能
そもそもRustはC++と同じくコンパイル言語なので、計算機上ではネイティブに動作します。なので理論的には同等の性能を発揮できます。つまりコンパイラがどれだけ最適化できるかという話になってきます。でもRustコンパイラはg++やVC++程成熟していないのでは?とお思いの方もいると思いますが、RustコンパイラはバックエンドにLLVMを用いているので最適化もガッツリかかり、十分に安定していると思います。ちなみにC++のLLVMバックエンドコンパイラとしてはClangが挙げられます。LLVMを利用していることからデバッガのLLDBも利用できるのでデバッガも問題ありません。時折ライバルとして挙げられるGoと比べてもガベージコレクションが無いなどの理由で性能的には軍配が上がると思います。詳しくは上の「実装言語を「Go」から「Rust」に変更、ゲーマー向けチャットアプリ「Discord」の課題とは」に詳しく書かれています。
型推論はやっぱり便利
C++にもauto
がありますがあんまり使わない印象。Rustは以下のようなコードでもしっかり型推論してくれるのでキレイに書けます。
// ※コード自体に意味はありません
let mut vec = Vec::new();
for i in 0..5 {
vec.push(i);
}
cargo doc
は重宝する
cargoにはcargo doc
というサブコマンドがあります。これはRustプロジェクトをhtml形式で文書化してくれます。C++にもdoxygenなどありますが、cargo doc
の方が見やすいと思います。ちなみに、Rustのcrateのドキュメントが集められている https://docs.rs と同じ形式で出力されます。ちなみに依存関係があるcrateのドキュメントもちゃんと出力してくれます。以下にドキュメントの例を挙げておきます。特に特別な処理を施さなくてもこんな感じになります。型には全てリンクが貼られていてワンクリックでジャンプできます。コメントの仕方によっては、ここに追加でテキストを入れることもできます。結構重宝してます。
イテレータがモダン
RustのイテレータはmapやfilterなどJavaならStreams, C#ならLINQと呼ばれる機能が使えます。ちなみに、C++にはありません。こんな感じです。
let a = [-2, 1, 2];
let ans = a.iter()
.filter(|x| x.is_positive())
.map(|x| x * 2)
.fold(0, |m, n| m + n);
println!("{}", ans);
この結果は6です。これを文章化すると、「配列の要素の中で自然数を取り出し」、「それらの値を2倍し」、「それらの総和を取る」ということになります。モダンな言語には取り入れられている記法ですが、C++には無かったりします。この記法は人間の思考の流れとマッチして非常に読みやすいです。これは僕がC++は古いと感じた一つの理由でもあります。
修正(2020/12/15) @yumetodo さんのコメントを受けて
C++20ではThe One Ranges Proposalによりrangesが標準ライブラリに追加され似たようなことが可能になります。
コンパイラ対応状況 - https://en.cppreference.com/w/cpp/compiler_support
仕様 - https://wg21.link/P0896R4
Rustでつまづいたこと
以上のようにRustは高級かつ高速な素晴らしい言語です。僕は利用したことはありませんがWebアプリケーションも作れるようです。しかし、Rustを「普通に」書けるようになるには1ヶ月ほどかかります。一見当たり前っぽいですが、C++で4年間ほどプログラムを書いていてもこのくらいかかりました。ここではRust習得で苦労したことを簡単に書きたいと思います。
Cargo.tomlに抵抗 - 慣れるまで30分
当時はTOMLを全く書いたことが無かったので少し抵抗がありました。しかしながらC++の開発をしていたときはmakeを使っていたので、似たもんだろうと捉えて慣れました。
参照を持つ構造体にハマった - StackOverflowで質問して解決
ゲーム開発をしていたところ、参照をメンバに持つ構造体を定義して使っていたところ大量のコンパイルエラーを吐いてビルドが失敗することが結構ありました。正直、エラーの情報も日本語のもので探そうとするとほとんど見つからず、StackOverflowでの情報をかき集めながら解決していました。Rustの参照チェッカはC++プログラマからすると非常に厳しい制約をかけてきます。なので、なぜこれがエラーになるのかという疑問でいっぱいでした。幸い、StackOverflowにはRustを布教しようと頑張っているユーザさんがいらっしゃるので、質問すると数十分で解答が付きます。最終手段としてはおすすめです。
開発支援プログラムrlsが貧弱だった - rust-analyzerで解決
現代では開発支援プログラムは必須な状況です。C++にはclangdやcqueryなどがあります。僕はclangdを使っていたのですが、結構使えます。Rustにもrlsと呼ばれるプログラムがあるのですが、貧弱であまり使えませんでした。例えば、構造体のメンバ補完は1層分しかできない(構造体のメンバの構造体のメンバは補完できない)など。rust-analyzerという選択肢もあったのですが2019年当時は未完成であまり使えませんでした。ところが2020年始め頃から僕の愛用しているエディタであるEmacs上で問題なく動作するようになり、それ以降はrust-analyzerをずっと使っています。今のrlsは改善されているかもしれませんが、rust-analyzerはrls-2.0とも呼ばれておりrust-analyzerが台頭していくことは明らかです。
コンパイルが結構遅い - cargo check
で我慢
Rustはそこそこコンパイルが遅いです。C++よりも同じか遅いくらいだと思ってます。まあ高級な言語ですし参照チェッカも厳しいので時間がかかるのでしょう。C++のコンパイル速度が遅いこともRustに移行した要因の一つだったのですが、これは期待通りとは行きませんでした。やはり、g++もかなり最適化されているということですね。こればかりはしょうがないですが、コンパイルが通るかどうか確認したいときはcargo check
を使って文法チェックすることで無駄なコンパイル作業を減らすことで我慢しています。
ん?継承はどうやるんですかね - 無いので、トレイト、列挙体を駆使する
C++やJavaで言う継承はできません。なので、C++のプログラマは最初つまづくかもしれません。一応、トレイトには他のトレイトの実装を前提とする制限をかける機能があり、これが少し継承に似ています。これを用いれば、メソッドの継承(みたいなもの)はできます。構造体の継承はできないので、移譲を替わりに使うことになります。また、複数の型を一つの型で扱うという面では列挙体が効果的です。例えば、次のように書けます。この列挙体はパターンマッチングを使って分解することが可能なので、複数の型を一つの列挙体型でまとめることができます。慣れてくると、継承が使えなくても違和感を感じなくなってきます。
enum A {
Type1(StructA),
Type2(StructB),
Type3(f32),
}
C++もRustもできること
C++にもRustにもできることを適当にリストとして書こうと思います。
- コンピュータ上でネイティブ実行
- いい感じにデバッグ
- ベアメタルでも行ける
- マルチプラットフォーム
- 演算子オーバーロード
- ジェネリクス
- etc...
Rustに期待していたことは実現できたのか?
Rustに期待していたことについて主観ですが簡単に10点満点評価したいと思います。
- 9点/10点 標準ライブラリの充実さ
- 6点/10点 コードの冗長さ
- 7点/10点 プロジェクトのモジュール構成
- 10点/10点 関連ツール
- 9点/10点 高級な文法
まとめ
以上、Rustのメリットとつまづいたところを紹介しました。少なくともRustを勉強して良かったと思います。私は個人的な開発がほとんどですが、開発効率は確実に上がったと思います。また、新しい文法、概念を使う事自体も楽しいです。ちなみにRustは学習曲線がきついと言われますが、まあ実際そうだと思います。ですが、The Rust Programming Languageも無料で公開されてますし学習する環境は整っています。僕はRustの書籍は購入せずにインターネットの力で勉強しましたが問題ありませんでした。そんなわけでC++からRustに移行した話でした。みんな!Rustに移行しよう!