今年入ったRustの機能について調べごとをいろいろする
Option#unwrap_unchecked & Result#unwrap_unchecked
unwrap_unchecked
というメソッドが生えた。unwrap
とは違い、None
や Err
に対してこれを実行すると未定義動作になる。したがって、この関数は unsafe になっている。
では unwrap
と何が違うか。Option
を例にとってコードを読むと違いが少しわかる。
pub const unsafe fn unwrap_unchecked(self) -> T {
debug_assert!(self.is_some());
match self {
Some(val) => val,
// SAFETY: the safety contract must be upheld by the caller.
None => unsafe { hint::unreachable_unchecked() },
}
}
pub const fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic("called `Option::unwrap()` on a `None` value"),
}
}
違いは None
の部分。unwrap
は None
だった場合には単に panic
を起こさせるので、この部分のジャンプがアセンブラなどに吐かれた状態になりそう。
一方で unwrap_unchecked
は hint::unreachable_unchecked()
が None
だったケースに記述されている。hint::unreachable_unchecked()
は、コンパイラに「この部分は未定義動作である」ことをあらかじめ伝えておき、この部分が最適化可能であることを通知するために利用できる。unwrap
とは違い、パニック呼び出し分だけコードの生成を減らせて結果最適化につながるケースがあるかもしれない。この関数を利用した場合、呼び出す側できちんと安全性を保証してから呼び出しする必要がある。
実験してアセンブラを読んで確かめると早い。ただ、普通のコンパイラがそうであるように、明らかに通らない分岐は最適化でカットされてしまう。たとえば、下記のようなコードを書いたとしてもコンパイル時点で None になりえないことは確定しているので、このコードでは panic にジャンプするようなアセンブラは生成されない。
fn main() {
let some = Some(42);
println!("{}", some.unwrap());
}
というわけで、わざとコンパイル時に値が確定しないような処理を書く必要がある。Option
ではなく Result
の unwrap
に関する例に変わってしまうが、たとえば下記のようなコードを書いたとする。
fn main() {
let args = std::env::args().collect::<Vec<String>>();
let num: i32 = args[1].parse().unwrap();
println!("{}", num);
}
このとき生成されるアセンブラを Playground で生成した。このアセンブラを注意深く読むと、.LBB10_39
というブロック内で、from_str
関数を呼び出した後に次のような条件付きジャンプが設定されていることが確かめられるはずだ。
callq *core::num::<impl core::str::traits::FromStr for i32>::from_str@GOTPCREL(%rip)
testb $1, %al
jne .LBB10_42
shrq $32, %rax
.LBB10_42
の命令は次のように実装されている。
.LBB10_42:
movb %ah, 96(%rsp)
leaq .L__unnamed_4(%rip), %rdi
leaq .L__unnamed_5(%rip), %rcx
leaq .L__unnamed_6(%rip), %r8
leaq 96(%rsp), %rdx
movl $43, %esi
callq *core::result::unwrap_failed@GOTPCREL(%rip)
# 下記は読み解くのに必要そうなラベル
.L__unnamed_4:
.ascii "called `Result::unwrap()` on an `Err` value"
.L__unnamed_5:
.quad core::ptr::drop_in_place<core::num::error::ParseIntError>
.asciz "\001\000\000\000\000\000\000\000\001\000\000\000\000\000\000"
.quad <core::num::error::ParseIntError as core::fmt::Debug>::fmt
.L__unnamed_8:
.ascii "src/main.rs"
要するにパニック時の処理がコードに現れている、と考えて問題ないだろう。
次に unwrap_unchecked
を見てみよう。これも同様に Playground でアセンブラを生成した。ちなみにこのコードは、実行時の第一引数に数値以外を入れると未定義動作になるのでこのようなコードを書いてはいけない。
fn main() {
let args = std::env::args().collect::<Vec<String>>();
let num: i32 = args[1].parse().unwrap_unchecked();
println!("{}", num);
}
.LBB10_39
ブロック内の from_str
関数が呼び出される近辺の処理を再度確認してみる。すると、先ほどあったジャンプ命令がなくなっていることを確認できるはずだ。パニック分の呼び出しがなくなっているので、このコードでは panic に使われた文字列 .L__unnamed_4
が空になっている(と思われる)。
callq *core::num::<impl core::str::traits::FromStr for i32>::from_str@GOTPCREL(%rip)
shrq $32, %rax
matchやifなどの条件分岐は計算コストが高い傾向にある。条件分岐は数を減らすと最適化につながったりするので、パフォーマンスを極限まで切り詰めたい状況などでは有効かもしれない。
フォーマット文字列
要するに下記ができるようになった。ただし、関数の呼び出しなどはできない。
fn main() {
let a = 42;
let b = 31;
let ans = a + b;
// 従来
println!("{} + {} = {}", a, b, ans);
// 以降
println!("{a} + {b} = {ans}");
// 下記はできない。
// (関数や関数の適用、演算を含めることはできない)
// println!("{a} + {b} = {a + b}");
}
cargo build --timings
ビルド時にどの処理に時間がかかったのかや、どのようにリソースを利用したのかを知ることができる。この機能が cargo に入った。昔は -Ztimings
というパラメータだった気がする。
cargo build --timings
と入力して実行され、ビルド後に target ディレクトリの配下に HTML ファイルが生成されて閲覧できる。
たとえば https://github.com/yuk1ty/stock-metrics というリポジトリのビルドに timings を利用して実験計測したのが下記の画像である。
(長くなったのでスケールを調整してビルドに約1秒以上かかっていそうなもののみを抽出)
そのほかにも、ビルドに時間がかかったクレートをランキング表示してくれたりする。ビルド時間を最適化したい際にまず使用するとよいオプションだと思う。だいたいのケースで、AWS SDK が悪さをしているか、あるいは tokio を入れてビルド時間が伸びたというオチになりそうだが…
ExitCode を指定できるようになった
1.61 からの追加。
これまでは Result 型にエラー情報を含めて返させる、あるいは std::process::exit を使うなどして失敗の通知をしていたが、使い勝手が悪かった。ExitCode が追加されて、自分で好みの終了コードを選択できるようになった。
use std::{fs::File, io::Read, process::ExitCode};
// 2 でファイル読み込み時の失敗、3 でファイルが見つからなかったエラーを示すものとする。
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let file_path = &args[1];
let file = File::open(file_path);
match file {
Ok(mut f) => {
let mut content = String::new();
let r = f.read_to_string(&mut content);
if r.is_err() {
return 2.into();
}
println!("{}", content);
ExitCode::SUCCESS
}
Err(_) => 3.into(),
}
}
std::process::exit は !
型を返すので現在のスタックや他のスレッドのスタックのデストラクタが走らない。という意味で不便。
pub fn exit(code: i32) -> ! {
crate::rt::cleanup();
crate::sys::os::exit(code)
}
ちなみに全然知らなかったが、Rust では main 関数は型情報が次のように定義されている。fn() -> T
。T は Termination
トレイトを実装している必要がある。というか、Termination
を実装していれば main 関数の返り値として返せる。
#[cfg(not(test))]
#[lang = "start"]
fn lang_start<T: crate::process::Termination + 'static>(
main: fn() -> T,
argc: isize,
argv: *const *const u8,
#[cfg(not(bootstrap))] sigpipe: u8,
) -> isize {
let Ok(v) = lang_start_internal(
&move || crate::sys_common::backtrace::__rust_begin_short_backtrace(main).report().to_i32(),
argc,
argv,
#[cfg(bootstrap)]
2, // Temporary inlining of sigpipe::DEFAULT until bootstrap stops being special
#[cfg(not(bootstrap))]
sigpipe,
);
v
}
Termination
はたとえば、Result
型 や ExitCode
型に実装されている。
enum に(条件付きで)デフォルトのヴァリアントが何かを指定できるようになった
enum に Default トレイトを実装した際に、どれをデフォルトのヴァリアントにするかを決められるようになった。ただし、ヴァリアントにフィールドを持たないものしか指定できない。
#[derive(Debug, Default)]
enum State {
#[default]
Idle,
Fetch,
Exec,
}
// できそうだができない
// #[derive(Default)]
// struct Instruction;
// #[derive(Default)]
// enum State2 {
// Idle,
// Fetch,
// #[default]
// Exec(Instruction),
// }
fn main() {
println!("{:?}", State::default());
}
ヴァリアントにフィールドをもたせたものまで許可しようと思うと、フィールドがさらに Default
トレイトを実装しているかをチェックすればいいだけのように感じてしまうが、何か特別な実装できない事情があったりするのだろうか。
RFC はこれみたい。
In the interest of forwards compatibility, this RFC is limited to only exhaustive unit variants. Were this not the case, adding a field to a #[non_exhaustive] variant could lead to more stringent bounds being generated, which is a breaking change.
ユニットでないヴァリアントに対する自動導出は検討されてはいるらしい。
主に追加できない理由は構造体に対して Default
トレイトを自動導出させるとたまに変なものを生んでしまうから、という感じらしい。この問題かな?
Mutex などの内部実装が大幅に変更された。ならびに Mutex が const fn 化した
1.62 & 1.63。私の理解が浅く、少し間違いを含む可能性があるが、簡単に何が起きたのかを説明しておく。
Rust の Mutex は、元々は POSIX 向けでは Box<Mutex>
= (UnsafeCell<libc::pthread_mutex_t>
) という型をつくって利用されていた。pthread_mutex_t
は C 言語のライブラリ関数だが、これを無理くり Rust で呼び出すということをしていた。無理くりポイントは下記2つ。
- そもそもこの関数は C 向けのものであり、Rust 用に設計されていない。Rust ではオブジェクトがムーブする。
- ロックオブジェクト一般に言えることだが、メモリのムーブやコピーなどを想定した作りになっていない。
無理くりポイント2番目を解消するため、ムーブさせないために、ヒープに寄せる Box を使用して対処したという経緯だった模様(ヒープに寄せることで、裏側のシステムは Mutex がムーブしたとは検知しない、みたいな仕組みらしい)。だが、Box はランタイム時にヒープにメモリを確保する都合上、const にするのが今度は難しくなっていた。あと単純にヒープに寄せられてしまっているので、スタックに寄せた場合と比較するとアクセスするのに時間がかかる。ならびに、スタックに寄せた場合と比較するとメモリ使用量も増える。
Rust 向けに実装を直した。具体的には Box をはがせるように実装し直された。パフォーマンスの向上と const fn にすることを可能にした。futex のシステムコールを直接 Rust のコードから扱って、必要な処理をすべて Rust で扱えるようにいろいろ実装を追加して対応したようだ。下記のように Mutex が変化した。https://github.com/m-ou-se/rust/blob/650315ee8801e650c049312a899de4202f54a701/library/std/src/sys/unix/locks/futex.rs#L11-L16
pub struct Mutex {
/// 0: unlocked
/// 1: locked, no other threads waiting
/// 2: locked, and other threads waiting (contended)
futex: AtomicI32,
}
元々パフォーマンスを向上させられるという目論見は parking_lot というクレートでの成果からわかっていてやるつもりだったが、なかなか作業が進まなかった。が、Mara Bos 氏が一気に進めたという経緯があったようだ。
- https://github.com/rust-lang/rust/pull/76932
- この修正で、「MovableMutex」のようなタイプエイリアスを作っておいて、いくつかのプラットフォームでは「Box<Mutex>」が呼ばれなくてよくなるように修正している: https://github.com/rust-lang/rust/pull/77380
- この修正で Unix 側が futex を利用した実装に切り替わった: https://github.com/rust-lang/rust/pull/95035
- https://github.com/rust-lang/rust/pull/76919
- https://github.com/rust-lang/rust/pull/77618
scoped thread が使用可能になった
1.63 より導入された。端的にいうと 'static
でないデータを使った並列計算が可能になった。もともとは crossbeam というトレイトですでに実装済みではあったが、それが標準ライブラリにも入ってきたというようなイメージだろうか。
let mut a = vec![1, 2, 3];
let mut x = 0;
std::thread::scope(|s| {
s.spawn(|| {
println!("hello from the first scoped thread");
// We can borrow `a` here.
dbg!(&a);
});
s.spawn(|| {
println!("hello from the second scoped thread");
// We can even mutably borrow `x` here,
// because no other threads are using it.
x += a[0] + a[2];
});
println!("hello from the main thread");
});
// After the scope, we can modify and access our variables again:
a.push(4);
assert_eq!(x, a.len());
公式のアナウンスより引用: https://blog.rust-lang.org/2022/08/11/Rust-1.63.0.html
spawn に直してみると、a とか x のライフタイムの解決がうまくいかなくてコンパイルエラーになるはず。
RFC を読んでみると、もともと Rust. 1.0 には scoped thread を提供する関数があったようだが、use-after-free を招きかねない問題があり一度取り除かれた。具体的にどういう問題だったかは下記の記事に詳しい(あとでじっくり読む)。ところで記事のタイトルが汚いw
内部的な実装の追加はシンプルなデザインで、'scope
というライフタイムをもたせたスレッドをモデリングする新しい構造体を逐一追加していくと実装できるらしい。
ただ、プルリクエストを追っていると実装中に不健全性が見つかったりしてなかなか大変そうではあった。
通常の std::thread::spawn との比較。下記は通常版を利用したもの。
use std::thread;
// NG case
fn main() {
let greeting = String::from("Hello world!");
// thread::spwan は static なライフタイムを保つため、スレッドは
// ローカル変数を借用できないという規則がある。
let handle1 = thread::spawn({
// そのため、1度クローンしておいてスコープ外の greeting の
// 所有権が移動しないようにする必要がある。
// let greeting = greeting.clone();
move || {
println!("thread #1 says: {}", greeting);
}
});
let handle2 = thread::spawn(move || {
println!("thread #2 says: {}", greeting);
});
handle1.join().unwrap();
handle2.join().unwrap();
}
spawn ひとつひとつは static なライフタイムを求めるため、上記のように書くと2回目のスレッド起動時に greeting の所有権がなく怒られてしまう。
error[E0382]: use of moved value: `greeting`
--> src/bin/scoped_thread.rs:18:33
|
5 | let greeting = String::from("Hello world!");
| -------- move occurs because `greeting` has type `String`, which does not implement the `Copy` trait
...
13 | move || {
| ------- value moved into closure here
14 | println!("thread #1 says: {}", greeting);
| -------- variable moved due to use in closure
...
18 | let handle2 = thread::spawn(move || {
| ^^^^^^^ value used here after move
19 | println!("thread #2 says: {}", greeting);
| -------- use occurs due to use in closure
For more information about this error, try `rustc --explain E0382`.
error: could not compile `techfeed-night-9` due to previous error
scoped_thread を利用すると、greeting の clone が不要になることがわかる。
fn main() {
let greeting = String::from("Hello world!");
// scope を使うと greeting を clone せずに済んでいることがわかる。
thread::scope(|s| {
let handle1 = s.spawn(|| {
println!("thread #1 says: {}", greeting);
});
let handle2 = s.spawn(|| {
println!("thread #2 says: {}", greeting);
});
handle1.join().unwrap();
handle2.join().unwrap();
});
}
あんまり詳しく分析していない(というか単に時間がない。実は発表に使いたいが前日である)ので適当を言っているかもしれないが、クロージャーにライフタイムを設定しておいて、thread::scope のクロージャーに一度外側の変数をキャプチャ('env ライフタイムに入る)、その後さらに spawn のクロージャーにキャプチャ('scope ライフタイムに入る)みたいな感じでなんとかしていそう。Ghost Cell の論文を読んだ時も思ったが、Rust では適切にライフタイムを設計しつつデータ構造を見直すと意外とスッと通るケースが多いかもしれない。問題は適切にライフタイムを設計するところなんですけど…
std::thread::spawn と比べると、もしかすると若干メモリの使用が多くなるのかもしれない?ScopedThread のスコープ内で生成されたスレッドの管理に ScopeData
というデータを追加で要することになるが、その分だけメモリ利用が乗ることにはなりそう。微々たるものかもしれないが。
cargo 周りの変更点
- 1.62 よりcargo-edit が標準で入るようになった: これにより
Cargo.toml
にクレート依存を追加する際に使えるcargo add
が標準で使えるようになった。 - 1.64 より cargo-workspace のルート部分にクレートで共通して使用する設定を追記できるようになった。
.await
が IntoFuture::into_future
の呼び出しに脱糖されるようになった
イメージ:
async fn a() -> i32 {
1
}
// どこかの関数内で呼び出し
let y = a().await;
// これが、下記のように脱糖される
let task = a().into_future();
let y = loop {
match task.poll(&mut cx) {
Poll::Pending => {},
Poll::Ready(x) => break x,
}
};
何が嬉しいかというと、IntoFuture
トレイトを実装しておけば裏で勝手に変換してくれるようになったところ。これによって、従来わざわざ Future 化していたような reqwest
の send
のような関数が、軒並み .await
を書くだけで済むようになる。下記みたいなやつの send()
をなくせる(らしいのだが、RequestBuilder
に IntoFuture
がまだ実装されていない関係で、0.11.13 ではうまくいかない?)。
Generic Associated Types (GATs)
1.65 から。関連型に型パラメータを与えられるようになる。
たとえば下記のような実装をしたくなったとする。が、下記のようなコードは &mut T
のライフタイムがわからないため、コンパイルエラーになってしまう。
trait StreamingIterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
struct VecWrapper<T> {
inner: Vec<T>,
cur: usize,
}
impl<T> StreamingIterator for VecWrapper<T> {
type Item = &mut T;
fn next(&mut self) -> Option<Self::Item> {
self.inner.get_mut(self.cur).map_or(None, |item| {
self.cur += 1;
Some(item)
})
}
}
回避方法としては T: 'static
とする手がある。ただ、'static
なライフタイムはちゃんと意図して使う必要があると思うので、普通に使い勝手が悪い。
下記のようにすると回避できそうだが、next
メソッドの外まで Item
が生き残れないのでダメ。
use std::marker::PhantomData;
// NG
trait StreamingIterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
struct VecWrapper<'a, T> {
inner: Vec<T>,
cur: usize,
_marker: PhantomData<&'a T>,
}
// NG
// error: lifetime may not live long enough
// --> src/bin/gats.rs:36:9
// |
// 33 | impl<'a, T> StreamingIterator for VecWrapper<'a, T> {
// | -- lifetime `'a` defined here
// 34 | type Item = &'a mut T;
// 35 | fn next(&mut self) -> Option<Self::Item> {
// | - let's call the lifetime of this reference `'1`
// 36 | / self.inner.get_mut(self.cur).map_or(None, |item| {
// 37 | | self.cur += 1;
// 38 | | Some(item)
// 39 | | })
// | |__________^ associated function was supposed to return data with lifetime `'a` but it is returning data with lifetime `'1`
impl<'a, T> StreamingIterator for VecWrapper<'a, T> {
type Item = &'a mut T;
fn next(&mut self) -> Option<Self::Item> {
self.inner.get_mut(self.cur).map_or(None, |item| {
self.cur += 1;
Some(item)
})
}
}
コンパイルを通すなら下記のように実装することもできる。これはライフタイムは next
メソッドのそれと一致することになる。ただ、next の戻り値が必ず &mut Self::Item
となってしまうため、抽象化の方法としてはイマイチかもしれない。Self::Item
としておいて、ムーブでも参照でも扱えるようにする方が抽象化としてはグッドな感じがする。
trait StreamingIterator {
type Item;
fn next(&mut self) -> Option<&mut Self::Item>;
}
struct VecWrapper<T> {
inner: Vec<T>,
cur: usize,
}
impl<T> StreamingIterator for VecWrapper<T> {
type Item = T;
fn next(&mut self) -> Option<&mut Self::Item> {
self.inner.get_mut(self.cur).map_or(None, |item| {
self.cur += 1;
Some(item)
})
}
}
GATs を使うと下記のように実装できるようになる。個々の Item
単位でライフタイムを保持できるようになるためだ。抽象化としてはこちらの方がグッドかもしれない。
trait StreamingIterator {
type Item<'a>
where
Self: 'a;
fn next(&mut self) -> Option<Self::Item<'_>>;
}
struct VecWrapper<T> {
inner: Vec<T>,
cur: usize,
}
impl<T> StreamingIterator for VecWrapper<T> {
type Item<'a> = &'a mut T where Self: 'a;
fn next(&mut self) -> Option<Self::Item<'_>> {
self.inner.get_mut(self.cur).map_or(None, |item| {
self.cur += 1;
Some(item)
})
}
}
GATs は高階型と呼ばれる型に分類される。これを利用すると一般にはモナドが実装できるとしてよく話題になる。が、Rust の GATs は現実的にはモナドを実装するには関連型ゆえに微妙に制約が足りなかったり、あるいは必要な要素が足りていないらしい。というわけで、モナドを完全に実装するのは現時点では難しいようだ。
ちなみに私はまだこれを実務では使ったことがなく、知っている実装のバリエーションに乏しい。もし何かよいユースケースがあるようだったら他にも知りたい。
let-else
1.65 から入った。let { パターンマッチで取り出したい何か } = { 値 } else { マッチしなかった場合に実行される処理 };
と1行で書けるようになった。Swift にすでに guard
という同等の機能があり、それを Rust に輸入した形と個人的には理解している(RFC でも prior art として言及されている)。
下記の記事がよくまとまっているので参照できる。
たとえば下記のようにコードを書き直せる。
pub async fn close(&mut self) -> Result<()> {
let Some(mut file) = self.file.take() else { return Ok(()); };
debug!("File {} closed", self.filename());
file.flush().await
// 元のコード
// if let Some(mut file) = self.file.take() {
// debug!("File {} closed", self.filename());
// file.flush().await?;
// }
// Ok(())
}
新機能をどう捉えるか
最後に個人的な意見を書き留めておく。Rust は機能が多い。そして、追加されるペースも早い。「これらの機能を全部覚える必要があるの?」と疑問に思うユーザーもいるかもしれない。
私の考えでは、
- 文法の追加は使いたくなったら使えばよい。頭の片隅に置いておいて、思い出したら使う程度で十分ではないだろうか。
-
unwrap_unchecked
のようなパフォーマンス切り詰める系は頭の片隅に置いておく程度でよい。ただこの手の話は引き出しが多いに越したことはないので、暇を見つけて doc を読むなどしてチェックしておくと良い。 -
Mutex
の裏側の修正のような話は、小話として知っておくだけでよい。ユーザーが利用する分についてはあまり関係がない。ただ、読み物としては大変面白いことが多いので、興味があれば一読すると世界が広がって楽しい。 - ツール周りの修正は頭の片隅に置いておいて使える場面で使っていくでよい。
かなと考えている。完璧を追求するあまり「より Rust らしい書き方」を追求したい、みたいな記事やコメントをたまにみるが、個人的には追求してもよいししなくてもよく、どちらでもよいと思う。(趣味プロジェクトは別として実務での利用の場合、)その時点でそのコードが大きなボトルネックになっているわけではない場合、その時点ではそのコードがベストと考えて素直にそのままマージすればよいと思う。
それより重要なのは、データ構造やアルゴリズムの方だ。これらが筋悪く作られていたり、保守性が低くデザインされていた場合、そちらの方がはるかに致命的なはずだからだ。Rust のコードの書き方如何でネストの深さとかで多少保守性の問題を生んだりするかもしれないが、そもそもネストが深くなるのは考案したデータ構造やアルゴリズムに問題があることが多い。見直すべきはむしろそちらなので、そちらにきちんと集中した方が得られるものが大きいかと、個人的には思っている。