お手伝いの @helloyuki_ です。今回はポエムです。
今回は、Rust を始めた当時、プログラミング言語は Java しかまともに触ったことがない新米若手 Java エンジニアだった私[*1]が「見たことがなく、使いどころがわからなく理解が難しい」と感じたポイントについて紹介します。対象とするソフトウェアのレイヤーが低いか高いかを問わず、とにかく Rust をやってみて理解するまでに時間がかかり、難しいと感じたポイントについて紹介します。
Rust の「メモリ安全」って、結局何
そもそも論ですが、Rust が取り組んだ課題領域が実は意外とわからないというケースは、思ったよりあると思います。私もその一人でした。
暴露するとそもそも GC 畑で育ったのもあって、当時は「メモリ安全じゃないコード」がよくわかっていませんでした。つまり、use-after-free や dangling pointer を知らなかったので、結果 Rust がどういった課題を解決している言語かよくわかっていませんでした。加えて、その結果何が問題になるのか、たとえば脆弱性の話なども理解していませんでした。この点を理解しないことには Rust を使う嬉しさは「高速性」「イケてるモダンな言語機能が入ってる」くらいになってしまうかもしれません。
ではなぜ Rust を始めたのか、という話なのですが、どちらかというと当時 Java の GC に関連したパフォーマンス劣化のチューニングを業務で任されることが多く、その際に「すぐに溜め込んだオブジェクトを解放してくれたらいいのに」と思うことが多かったです。「明らかにもう解放してもよいのに解放してくれないのは困る、限定的な領域なら GC いらないな」みたいな。
そうした中で Rust が GC を持たないものの、GC を持っている言語のように安全で高速らしいということを知りました。今抱えている課題を言語処理系のレベルで解決してくれる言語があるのか、と思ったのがきっかけでした。裏を返せば C や C++ への理解度が低かったとも言います。
この辺りの議論は下記の記事を読んでやっとわかったように思います。
所有権とライフタイム
これは言わずもがな Rust を代表する関門の一つです。元々のアイディアは別のプログラミング言語にあったもので、Rust が完全に新規にこの概念を生み出したわけではないのですが、Java エンジニア的には見慣れない概念ではないかと思います。
普通にプログラミングしていると、「どこかでなぜか行き詰まるポイントが出てくる」という難しさがあると思います。しかも、初学者のうちは解決策がよくわからないものも…えい、clone してしまおう!
「このパズルを解くことになんの意味があるのだろう?他のプログラミング言語で書けば、この程度の実装はすぐにできるのに…」と感じてしまう初学者がいてもやむなしとさえ思います。気持ちはとてもわかります。が、このパズルを解く意味は、上で紹介したメモリ安全性でリソースの使用効率が良く高速なコードのためです。突破するとすばらしいコードが待っています。がんばりましょう。
所有権とライフタイムは、実はしばらく Rust を書いてくるとだんだん慣れてくるものです。地道にコンパイルエラーを分析して何度か修正を試していると、自然と解決のパターンが見えてくるようになります。周囲に Rust ユーザーのご友人や同僚の方がいるようでしたら、その方に聞くと早く解決できますが、私は当時はそういう友人はいなかったので苦労しました。
ちなみにライフタイム関連で言うと 'static
も当初はよくわからなかったです。果たしてどの場面で 'static
にしておくべきかというのが特に難しかったように思います。ずいぶん悩んだ後、下記の記事でだいぶ頭の中が整理された記憶があります。
参照
Java しか触ったことがないとあまり意識的にこれを操作することがない代表格のひとつでしょうか。どういった場面で使うべきかが難しかったように感じます。これについてはそもそも当時のプログラミングという行為に対する理解度が低かったので、仕方のない面ではあったかなと思いますが。しばらくしてどういう嬉しさがあるかなどをゆっくり理解しました。
引数として渡す際は参照にしておき、返り値として返す場合は所有したオブジェクトを返すようにするとだいぶ楽になります。Rust ではライフタイム推論の兼ね合いで参照をあまり乱用しようとすると、コンパイルを通すのが途端に難しくなります。したがって、自分の認知できる範囲で参照を使うようにするのがコツかもしれません。
スマートポインタ
これも最初使いどころがわからなかったものの代表格でした。たとえば Box
は、ドキュメントを一見すると再帰的なデータ構造のような「コンパイル時にサイズが決定できないもの」に対する利用例が書かれていてわかりやすいように見えます。しかし本来は「ヒープに確保する」役割を持つもので、「ではいつどのタイミングでヒープに確保するのか?何が嬉しいのか?」が当初わからなかったです。
あとでわかったことではあるのですが、これはたとえば「スタック」「ヒープ」といったメモリ領域に関する基礎知識が足りていなかったことや、「参照カウント」などの概念の使い所や嬉しさをまったく知らなかったことに起因していたように思います。Java ではこの辺りは、あまり明示的に使い分けることはないからですかね。熟練の Java プログラマの方々は上手に使い分けていたのかもしれませんが。
所有権、参照、スマートポインタについては下記の記事を一度読まれると理解が深まるのではないかと考えています。また、Rust の公式ドキュメントを一度きちんと目を通すことを強くお勧めします。少なくとも初見で使いこなせる機能ではありません。
代数的データ型
具体的には enum
などです。この概念はすぐに理解自体はでき、使うと便利だというところまでは理解できました。しかし、これを使って現実のアプリケーションをどのように設計していくか?の実感がしばらく湧きませんでした。
私の場合は、実はしばらくのちに Scala エンジニアとしてのキャリアが始まり、そこで初めて具体的な使い方がわかっていったように思います。Scala の現場で使われるコードの中に登場する ADT の実例を見ながらそこで学んでいったと言う側面が大きそうです。というわけで、この概念を理解する上で一番いいのは Rust の OSS 等のコードを読むことだと思います。
あるいは簡易的なインタープリターを実装してみると ADT をたくさん使うことになるのでいい練習になると思います。昔そのような話を盛り込んだ発表を LT 会でしたことがあります。[*2]
関数が第一級である
これも最初のうちは慣れませんでした。というのも、当時の Java 8 では、class 外に何かを宣言することはできなかったからです。したがって、どのような場面で使ったら良いかが当時はわかりませんでした。
モジュールシステム
Java のパッケージとは別物で、まずそもそもモジュールの切り方がよくわからなかったです。
あとそもそもモジュールシステムは使いこなすには少しクセがあるように思っていて、ドキュメントをちゃんと読んで何パターンか自分で試してみるのが重要だと思っています。
公式ドキュメントには解説の章があります。ちなみに私が入門した当時は TRPL 第1版だったので、まだこの章は読めなかったです。
より細かく理解したい場合は下記のシリーズがおすすめです。
self
いわゆる「レシーバー」ですね。レシーバーそのものも馴染みがなかったです。Python などの経験がある方は多少わかるかもしれませんね。このレシーバーを経由して構造体や列挙型の値を取り出すというのがなかなか苦労しました。構造体の中に構造体が何個もあると頭の中が混乱する感じがしました。もっともこれは Java で言う this
と同じなのですが、そのことに気づいてからようやく理解が進みました。
加えてこのレシーバーが「self
」「&self
」「mut self
」「&mut self
」の4種類になりうるわけですが、それぞれどういう場面でどう使い分けたらよいかが当時はしばらくわからなかったです。とくに self
と &self
の使い分けがうまくできずに当時はよくわからないコードやコンパイルエラーに見舞われていた記憶があります。
この辺りは所有権と借用への理解が進み、「不変/可変でムーブさせたいか」「不変/可変でムーブさせずに借用で済ませておきたいか」の選択とそれぞれの意味の理解を丁寧にできるようになったタイミングで、ようやく適切に使い分けられるようになってきたと感じています。これといった定型パターンは少なく、今でもその時々で使い分けをしている印象です。
型クラスという側面でのトレイト
trait
は Java でいう interface
に非常に近い概念ですが、プラスアルファで別の機能を持ちます。それが型クラスです。
型クラスを利用した代表例は拡張トレイト(Extension Trait)でしょうか。既存の構造体等のデータ型に対して、XXExt
のようなトレイトを用意し、それを経由して既存のデータ型の実装を一時的に(アドホックに)拡張することができる実装パターンです。
このように Rust のトレイトは Java のインターフェースと比較するとできることが多いです。当時はこのことがあまり理解できていませんでした。Java のインターフェースのように使って「うまく実装できないなあ」と思って、結果抽象化を諦めたことが何度もあったことを今では思い出します。当時はこの話題についてとくに資料もなく難しかったのですが、今日ですと下記の記事はいい参考になると思います。
余談ですが、Scala にも同様にトレイトが存在します。この Scala のトレイトと implicit という機能を組み合わせると実現できることを、Rust ではトレイト一つでできます。
まとめ
私が Rust をある程度使いこなせるようになるまでの話
Rust を始めた当時(2017年)にあまりわかっていなかったと思われることを思い出して書いてみました。Java には登場しない概念を一から理解する必要があったので、とても大変でした。しかも当時は日本語での資料が少なかったので、英語で検索して覚える必要がありました。今は書籍が充実してきたり、ユーザーが増えた関係で記事も増えて検索しやすくなったでしょうか?
私がこれらの概念をある程度理解し使いこなせるようになったのは、しばらく Rust のコードをめげずに書き続けたからだと思っています。仕事ではなく趣味で使っていたので上達が遅かったですが、使いこなせるようになるまでには入門してから半年くらいはかかったように思います。仕事だと1ヶ月くらいでおおむね理解し、使いこなせるようになるとよく聞きます。時間がかかるのは間違いないので、辛抱強くひとつひとつを理解していく姿勢が求められると思います。
「難しい」って何?、の話
Rust の習得の「難しさ」については時折 SNS 等で言及されることがありますが、今回はその「難しい」について、私の体験から「どこが難しかったのか」を考えてみました。Rust は習得が難しいといたずらに言われていますが、果たしてどの部分が難しいのか、なぜ難しいと感じるのかについては議論の対象になることは少ないように見受けられます[*3]。あるいはそれを「プログラミングという行為そのものがそもそも難しく、そこから来る難しさを浮き彫りにするのだ」と若干議論を逸らしてしまっているケースがあるのもまた、かえって敷居を高めてしまっている可能性があると感じています。[*4]半分くらいは正しいのですが。
私が思うRustの「習得の難しさ」は、Rust が対象とする領域の難しさ(= システムプログラミングの難しさ、プログラミングそのものの難しさ)以外に、他の多くの言語とは異なるプログラミングパラダイムを持つという点にあると思っています。有名な所有権やライフタイムはこちらに含まれると言って良いでしょう。少し前のバージョンの Java や Python のようなプログラミング言語しか触ってこなかったエンジニアにとって、見慣れないプログラミングの手法やコンセプトが多く含まれていると感じています。これらは一度理解すれば便利に使いこなせる上、導入した理由も合理的だと感じられるものですが、理解するまでに時間がかかります。最初は拒否反応を示してしまうのも無理はないと思います。
そして、このふたつのキャッチアップを学習コストと呼んでいいと思います。
Rust はあらゆる領域で利用できるため、Python や Java などで書かれたアプリケーションをあらためて書き直してみるなどして「難しい」と感じる方が多そうに見受けられます。こうしたケースで苦戦することになるのはシステムプログラミングそのもの(≒ プログラミングという行為の難しさ)ではなく、普段見慣れないパラダイムに基づいたプログラミングスタイルを強いられることではないか?[*5]と私は考えています。
実は Rust の言語機能やパラダイムは、他のプログラミング言語で「よいとされてきたもの」の上澄みを寄せ集めたものだったりします。Scala や Haskell 、あるいは C++ の経験があって、元ネタとなったパラダイムをすでに知っている方にとっては、「Rust はそこまで学習コスト[*6]は高くないよ」という結果になるでしょうし、C や Java 、 Python のように Rust の言語設計とは程遠いプログラミング言語しか経験のない方にとっては「すべてが新しく感じられる。Rust は学習コストが高い」となっても無理はないと思います。
私自身は Rust は決して簡単な言語ではないと思っていて、普通に難しい部類に入ると思っています。ただ、「難しい」の意味はよく考えたいと思っています。難しいの要素を分解してみるといくつかの要因に分けられ、ひとつひとつ確認していけばだんだん理解が進んでいくはずです。まったく手のつけられない難しさではないとも思っています。
この記事が Rust をがんばりたい初学者の方の手助けになれば幸いです。
*1:あまり強調する話ではないと思いますが、前提の共有のために註を入れておきます。そもそもエンジニア志望&エンジニア採用ではなかったのと哲学系が専攻の文系卒でしたので、情報科学の知識は当時はほぼなかったです。業務で触れていた話は多少理解していましたが、解像度も低かったと思います。
*2:今見返すと、理解が浅くて厳密には誤りなポイントがいくつかありますね。大目に見てください。
*3:あるいは逆に「簡単」と名言する方もいらっしゃいます。私の本業の方の同僚も Rust は簡単と言っていました。ただこれはそれまでのプログラミング言語経験のバックグラウンドに完全によると思っていて、とくに C++ をやっていた人にとっては簡単に感じるようです。それ以外の方は大半は習得が難しいと思っていそうなので、この手の価値判断には正解はありませんが一般に難しい部類に入ると考えるのが妥当ではないかと思います。
*4:というかよくわからない、となるのも無理はないと思います。突き詰めるとメモリ管理を正確にミスなく行うのが難しい、という話になるのですが、そもそも Java や Python などのように処理系が裏で暗黙的にそのあたりの管理をしてくれる言語しか経験がない場合、実感がわかず主張を理解するのが難しいでしょう。これも前提知識によって解像度の変わる、ある人々にとっての抽象的な議論のうちの一つかもしれません。
*5:所有権やライフタイムもいわゆる「パラダイムシフト」と私は考えているのでこちらに分類されます。
*6:学習コストとは、ここではそのまま「これまで経験がなく、新しく学ばなければならない概念や言語機能」を指すものとします。