「Rustでやると知らないうちに詰む設計」を避けるためのTipsを集めてみる
Rustはコンパイラが親切なので、局所的なミスは何とかなる。が、大域的に間違えている場合まではコンパイラには指摘できないので、そういうのは明示的な知識になっててほしいよねっていうことを前から考えていたら、ちょうどKOBA789氏がそういう話題を出していた。
というわけで目についたものを集めつつ、心当たりを思い出してみる。
イテレーターとストリーミングイテレーター
「イテレーターはVecにcollectできる」の対偶を考えると、「Vecにcollectできるか? → collectできないような出力をするようなイテレーターは実装できない」という思考パターンが得られる。これはいい言語化。
こういうのを集めたい。
とりあえず、よく言われてるやつから埋めていこうと思う。
構造体にライフタイムを持たせない
構造体にライフタイムを持たせるのは「基本的に」避けよ、というのが重要なのは間違いないのだけど、これをもう少し実践的な内容にしたい。ちょっと考えてみたけど、こういうのはどうだろうか。
-
ある関数呼び出しの中でしか絶対に使わない。returnするまでにその構造体のデータは全て破棄される。static変数に退避させることもできない。アロケーションもその関数が面倒を見る。そういう一蓮托生できる関数呼び出しに心当たりはあるか?
- ある→ 構造体にライフタイムを持たせてもよい。
- ない→ ライフタイム禁止。
そう考えてみると、DIとかReduxとかとも通じるところがあるかもしれない。「つべこべ言ってないで全部の責務を一番外側に持っていく」という決断ができるときは構造体ライフタイムが選択肢に入る。
親リンクを保存しない
「循環参照を避ける」の頻出パターンのひとつ。ポイントは「保存しない」というところ。親リンクはあってもいいけど、揮発性の概念なので必要なら毎回渡す、というのが重要な気がする。
オブジェクト間で相互通信しない
たとえばプラグイン機構を作るときに、プラグインオブジェクトとホストオブジェクトが互いに関数呼び出しをするという設計にしてしまうと、典型的な相互参照になってしまう。こういうのは以下のような解決方法がある。
- ホストオブジェクトをホストオブジェクトとコントローラーオブジェクトに分割してタイブレークする。
- 情報の流れを ホストオブジェクト → プラグインオブジェクト → コントローラーオブジェクト と一方向化することで循環参照を避ける。
- プラグインオブジェクト→ホストオブジェクト の方向の通信を、関数呼び出しではなくreturnによってのみ行うようにする。
- こうするとプラグインオブジェクト側の状態管理がつらくなる可能性がある。そういう場合はreturnのかわりにジェネレーターのyieldを使う手もある。
- アクターフレームワークなどを介して整理する。
メタプログラミング
Rustは強力なメタプログラミング能力を提供しているように見えて、意外と頑固なところがある。Rustはプログラムを書く側よりも読む側の味方なので、読む側に隠れてこそこそと何かをやるようなメタプログラミングはできないことが多い。
たとえば、「構造体フィールドの名前を変えただけで動作が変わってしまった……」というありがちな話を考える。Rustの場合でもこれ自体は起こりえる (serdeを使っている場合など) が、構造体についている derive
マクロと属性マクロをチェックしておけば影響範囲は見積もれる。 (Rustコンパイラの外で何かしてない限り)
これは書く側からすると「何も書かなくてもいい感じに◯◯したい→できない」という話になる。Rustも色々闇テクニックはあるので出来ないとも限らないが、だいたいメタプログラミングっぽい話で dtolnayがやってないことはできない と思ってよさそう。
traitのconflict
Rustのtraitは、一意に解決できないといけないという厳しいルールがある。ここで、プログラマには同じ実装だとわかっていてもRustにはそれを知る手段がないということが問題になる。
たとえば Into
で T
を U
に変換できるなら Option<T>
を Option<U>
に変換できてもよさそうだが、もしそのような実装があると Option<i32>
→ Option<i32>
に2つの解決方法が生じてしまう。
|x: Option<i32>| x
|x: Option<i32>| x.map(|x| x)
プログラマからしたら「どっちでもいいわ」となるところだが、Rustのtrait resolverはこれが「実質同じ」であることを知る手段がないのでconflictとするしかない。
このような理由から、一見実装できそうなtrait implが実装されていないことがある。
他の人の知見も読みたいのでぜひコメントをお願いします。「こういう風に困った」という事例でもいいです。
このスクラップに必要そうかわからないため、一旦タイトルだけ書かせてください。
伝わって気になったものを言っていただければ詳細を書かせていただきます。
Tips
- structやenumの宣言時のtrait境界は最小限にする
こういう風に困った
- async fnが不意にSend + Syncを失ってしまうことがある(awaitを挟んでSend + Syncが無いデータ型を扱うとなる)
- Cloneをderiveし忘れたときにわかりにくいエラーが出るときがある(https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=a531ce6c95cc0b0928feaf8d3795870b)
- 同じライブラリでバージョンが違う構造体同士でミスマッチしたときのエラーがわかりにくい