本セッションの登壇者
私はマップボックスジャパン合同会社という会社でソフトウェアエンジニアをしているyukiです。Twitterでは@helloyuki_で、TechFeedではRustの公認エキスパートとして活動しています。最近は本や連載記事を書くことも多かったのですが、そのほかにはRust Tokyoの運営をしたり、「This Week in Rust」というRustの注目記事をピックアップしたWebマガジンの日本語記事レビュアーをしたり、TwitterでRustのお役立ち情報を発信したりしています。
MapboxでのRust
本日のイベントはMapboxがスポンサーをしていますので、どのようなことをしている会社かを簡単に説明すると、主に地図技術やナビゲーションのAPIを作っているアメリカの会社です。実は社内ではRustをメインで使用しているわけではなく、どちらかというとTypeScript、JavaScript、C++のコードが多く、一部コンポーネントでRustを使用しています。最近ベラルーシオフィスのC++エンジニアがRustを書いているのを見て少しテンションが上がりました。
弊社では高トラフィックなサーバや検索などにRustを利用しており、負荷が高い/速度が求められるところにRustを利用する傾向が高まりつつあります。また、Linux向けの組み込みソフトウェアをRustで書いてみてはどうかという提案が社内であり、このような使い方も広がるのではないかと思っています。
発表の前提と目的
本日は中上級者向けの発表として、Rustの基礎文法に習熟していることを前提としており、既存の文法や機能の解説はほぼしません。また、私自身の理解でお話しますので、もし間違っているところがあればご指摘ください。
今回の発表では、私個人が「入って嬉しかった」、またはRFCなどを読んだうえで「入った背景がおもしろかった」と思った機能/変更を紹介しようと思います。
2022年の変更をおさらいする - 標準ライブラリ/文法機能面/周辺ツール
2022年は2021年に比べると多くの機能が入ったという印象があります。「GATs (Generic Associated Types)がよかった」「let-elseが便利すぎる」「cargo addが嬉しい」「Mutex周りがちょっとおもしろかった」など2022年は総じて豊作な1年だったと思います。
そこで、2022年の変更を次の3つのグループに分けてご紹介し、おさらいしたいと思います。
- 標準ライブラリへの変更
- 文法機能面への変更
- 周辺ツール(
cargoなど)への変更
標準ライブラリへの変更
標準ライブラリへの変更の中で個人的に大きいと思ったのは、次の2つです。
- [1.62, 1.63]
Mutexの内部実装への変更とconst文脈化 - [1.63] Scoped Threadの導入
[1.62, 1.63] Mutexの内部実装への変更とconst文脈化
Mutex(およびCondvar/RwLock)の内部実装はバージョン1.62で変更され、同時にLinuxのシステムコールのひとつであるfutexを使うように修正されました。また他のOSでも相応に修正されていて、修正の恩恵を受けられます。これにより、メモリ使用量の削減とパフォーマンスの向上が見込めます。
さらにこの修正の副産物として、ヒープ領域を使う必要がなくなるため、Mutexがconst文脈で使えるようになりました。従来はMutexをOnceCellやlazy_staticで囲ってstaticにする必要がありましたが、その必要がなくなります。
従来、MutexはBox<pthread_mutex_t>をラップしたものでしたが、pthread_mutex_tを直接利用することはやめ、Linuxにおいてはfutexを直接扱うように調整したことでBoxを剥がすことに成功しています。左の修正前のコードではBoxがありましたが、右の修正後の実装ではAtomicI32に変わっており、Boxが剥がれています。この点については今日は詳しく解説しませんが、後ほどご紹介するZennのスクラップに詳しく書いています。
この修正はRustコアチームのMara Bos氏が中心になって実施されましたが、そのときの話をRustConf 2021の動画で知ることができるのでおすすめです。課題を細かく分割し、解決できそうなものから解決するというエンジニアリングのアプローチをとった話があり、プルリクエストの差分と合わせて見ていくと大変おもしろいかと思います。ちなみにこの修正にはけっこうな年月がかかっており、大変な苦労もうかがえます。
この修正で内部実装にBoxが必要なくなったため、従来はOnceCellなどのクレートと組み合わせてstaticでMutexを利用していましたが、その必要はなくなり、Mutexを使うコードがきれいに書けるようになりました。これは個人的にも大きな修正だと感じていて、 経緯も含めて自分の開発でも参考にしたいものでした。
[1.63] Scoped Threadの導入
Scoped Threadはバージョン1.63で導入されました。従来のstd::thread::spawnによるスレッドの利用では、キャプチャする変数にstaticライフタイムが求められるため不便な場面がありました。この修正により、std::thread::scopeならびにスコープのspawnを経由したスレッドの利用が可能になり、これらを利用するとstaticライフタイムは求められなくなりました。あまり実装を深く追えていませんが、ライフタイム周りをきれいに整理して設計し直した良い例だと思います。
こちらは従来の実装例です。std::thread::spawnはクロージャのライフタイムにstaticを求める(スレッドはローカル変数を借用できない)ため、cloneするかArcに包んで渡す必要がありました。
scoped_threadを利用すると、このケースにおいてはcloneは不要になり、スコープの終わりで勝手にjoinするようになっているので、joinの呼び出しも同時に不要になります。
余談ですが、実はRust1.0時点でもScoped Threadは存在していました。しかし、「Leakpocalypse」と呼ばれる問題が発見されたことで削除され、数年の時を経て標準ライブラリに戻ってきたという経緯があります。
このLeakpocalypse (Leak + Apocalypse)はRustでは有名な話で、「参照カウンタで循環参照を作ってJoinGuardをリークさせると、Scoped Threadが解放されたメモリにアクセスできてしまう」というものです。現在のRustは静的検査などでメモリリークを防ぐことを保証していませんが、Leakpocalypseが理由のひとつといわれています。詳しくはPre-Pooping Your Pants With Rustや当時の焦り具合がよくわかるIssueをご参照ください。
文法機能面への変更
文法機能面への変更からは次の4つをご紹介します。
- [1.65] Generic Associated Typesが利用可能に
- [1.65]
let-elseが利用可能に - [1.62]
enumのデフォルトのヴァリアントを指定可能に - [1.64]
awaitはIntoFuture::into_futureに脱糖されるように
これらのうち1と2は最近入ったもので、これから使っていきたいと思える変更です。
[1.65] Generic Associated Typesが利用可能に
Generic Associated Types(GATs)は関連型にジェネリクス(型引数や型パラメータという言い方をすることもあります)、あるいはライフタイム注釈を付与できるようになるもので、バージョン1.65から利用可能になりました。GATsにより、Rust全体の型まわりの抽象化の表現力が向上しました。
次の例は、イテレータをGATsを使って実装し直すもので、従来はtraitの関連型にライフタイム注釈や型引数をつけることはできなかったのですが、それがGATsで可能になったことで、関連型単位でのライフタイムを持たせるように実装できるようになりました。
私は実務でGATsを使ったことはまだないのですが、どのような使い方ができるかは考えているところです。
このような機能は「高階カインド型(Higher-Kineded Types)」と呼ばれるもので、関数型プログラミングがお好きな方からすると「モナドが実装できる?」と思われるかもしれません。しかし、最近GATsが安定してからいろいろと実装しながら調査している方もいて、その結果によるとGATsだけでモナドを実装するのはまだ難しそうということです。また、RFCにも「GATsがすぐさまモナドを実装可能にするものではない」という記述があります。
[1.65] let-elseが利用可能に
let-elseも1.65から利用可能になり、if let構文(例: if let Some(b) = a {} else {})をlet Some(b) = a else {}と書けるようになりました。この変更により、ネストが減ってコードの見た目がスッキリしたり、処理の流れが読みやすくなったりというメリットを享受できるようになります。
こちらのコードはif let Someをlet-elseで書き換えた例です。コメントアウトされた元のコードにはネストがありますが、let-elseを使うことでスッキリと書けていて、便利なのでこれから使っていけると思います。
ただし注意点として、elseの中には発散する型(never型; !)を返す式を書く必要があります。先ほどの例では、else内でreturn Ok()としていますが、これをOk()だけにすることはできません。let-elseはあくまでif letの拡張/類種のようなものですので、else側が返す型がlet側の型と合っていなければならず、型を合わせるために発散する式(never型)を使うことになっています。
ここでnever型について解説しておきます。Rustでは!として表現される型をnever型と呼び、loop / continue / returnは実はこの型を返しています。never型は値を何も返さないことを表現します。雑な言い方をすると、never型を返すとどの型にでもなることができるということで、たとえば返り値の型としてi32型を要求する関数内でnever型を返しても問題はありません(never型がi32型と同等とみなされます)。todo!マクロもnever型を返し、まだ実装していない・これから実装することを示すために利用されています。let-elseのelse側でも同様の理屈で型合わせが行われています。
このnever型はコンパイル時のデータフロー解析に使われ、never型を返す関数のあとにある処理には決して到達しないことを示しておき、以降の命令を成果物に含める必要がない、などの判断のために存在しています。
[1.62] enumのデフォルトのヴァリアントを指定可能に
この変更はバージョン1.62で利用可能になり、#[default]というアトリビュートをヴァリアントに指定して、Default::defaultに対応するヴァリアントを指定できるようになりました。ただし、ユニット型のヴァリアントのみに付与できるという制約があります。
この例のように、State::default()を呼び出すと#[deafult]がついたヴァリアントが取得できます。
[1.64] awaitはIntoFuture::into_futureに脱糖されるように
この変更はバージョン1.64で導入され、個人的に気になりました。for式は内部でIteratorに脱糖されますが、同様に**awaitでもIntoFutureトレイトを実装しておけば脱糖される**ようになります。HTTPクライアントのクレートなどで、sendを呼び出してFuture化していた部分はawaitと書くだけでよくなります(現時点ではreqwestはまだ直っていないようですが、このような効果が期待できます)。
なお、脱糖とは、左のコードのように書くと、Futureで処理をするように内部で右のようなコードに直してくれるようなイメージです。
周辺ツールへの変更
私が注目しているのは次の3つです。
- [1.60]
cargo build –timingsが安定化 - [1.62]
cargo addでクレートの依存を追加できるように - [1.64] ワークスペースの共通設定が書けるように
[1.60] cargo build –timingsが安定化
cargo build –timings はどのクレートのビルドにどれくらい時間がかかったか計測したり、ビルド時のリソースの使用状況などを計測したりできる機能です。nightlyにはすでにありましたが、バージョン1.60で安定化しました。
このコマンドを使うと、各クレートのビルドにかかった時間を把握できるので、ビルドのパフォーマンスチューニングなどに利用できます。
[1.62] cargo addでクレートの依存を追加できるように
これは嬉しかった機能で、コマンドひとつでクレートの依存をCargo.tomlに追記してくれるものです。もともとcargo-editというプラグインでしたが、その一部のコマンドをRust本体に取り込んだという経緯があります(ただし、cargo-edit本体の挙動とは微妙に異なるものもあります。また、そもそもまだ移植されていないコマンドもあります)。
[1.64] ワークスペースの共通設定が書けるように
cargoにはワークスペース機能がありますが、ワークスペースのルート側のCargo.tomlに共通設定や共通依存クレートの設定を定義できるようになりました。たとえば、anyhowなどはプロジェクトをまたいでも利用するクレートですが、プロジェクト間で共通の設定を利用できるようになるので便利です。
こちらはルート側のCargo.tomlにanyhowとtokioを共通依存クレートとして設定しておき、使用するプロジェクト側のCargo.tomlではanyhow.workspace = trueとして共通設定を使用することを指定しています。もちろん、プロジェクト個別で使用するクレートも記述できます。
実務での新機能への対応
最後に、私が実務で新機能にどのように対応しているかを共有したいと思います。
正直なところあまりまじめにやっているわけではなく、取り入れたいものがあったら取り入れるようにしていて、無理に新機能を取り入れることはありません。たとえば、先日let-elseを使いたくなったので、チームでRustのバージョンを1.65に上げて、これからはlet-elseに変えられるところは変えていこう、ということになりました。
新機能への対応/キャッチアップのスタンスは下記の通りで、基本的にはリリースノートに軽く目を通しておいて、使いたいときに使う程度で、積極的にキャッチアップしているわけではないです。
- 標準ライブラリへの変更 - docくらいは読んでおいて、使えるときに思い出して使う
- 文法や機能への変更 - リリースノートは読んでおいて、使えるときに思い出して使う
- 周辺ツールへの変更 - 何が入ったかは覚えておいて、使えるときに思い出して使う
なお、Mutexの変更のようにパフォーマンス面で恩恵を受けられる修正が入ることもあるので、バージョン自体はこまめに上げるようにしておいたほうがいいかもしれません。ただしバージョンを上げると一部のクレートがビルドできないなどの問題がでることもあるので、受けられる恩恵とのトレードオフがあります。
まとめ
今回の発表では、2022年にRustに入った機能/変更を駆け足で振り返りました。時間の都合上、細かい議論は省略しましたが、詳しくはZennのスクラップにまとめていますので、よろしければご参照いただき、本日のスライドに含まれる各資料へのリンクもたどってみてください。
以上で発表を終了します。ありがとうございました。