Closed14

今年入ったRustの機能について調べごとをいろいろする

yukiyuki

Option#unwrap_unchecked & Result#unwrap_unchecked

unwrap_unchecked というメソッドが生えた。unwrap とは違い、NoneErr に対してこれを実行すると未定義動作になる。したがって、この関数は 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 の部分。unwrapNone だった場合には単に panic を起こさせるので、この部分のジャンプがアセンブラなどに吐かれた状態になりそう。

一方で unwrap_uncheckedhint::unreachable_unchecked()None だったケースに記述されている。hint::unreachable_unchecked() は、コンパイラに「この部分は未定義動作である」ことをあらかじめ伝えておき、この部分が最適化可能であることを通知するために利用できる。unwrap とは違い、パニック呼び出し分だけコードの生成を減らせて結果最適化につながるケースがあるかもしれない。この関数を利用した場合、呼び出す側できちんと安全性を保証してから呼び出しする必要がある。

実験してアセンブラを読んで確かめると早い。ただ、普通のコンパイラがそうであるように、明らかに通らない分岐は最適化でカットされてしまう。たとえば、下記のようなコードを書いたとしてもコンパイル時点で None になりえないことは確定しているので、このコードでは panic にジャンプするようなアセンブラは生成されない。

fn main() {
    let some = Some(42);
    println!("{}", some.unwrap());
}

というわけで、わざとコンパイル時に値が確定しないような処理を書く必要がある。Option ではなく Resultunwrap に関する例に変わってしまうが、たとえば下記のようなコードを書いたとする。

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などの条件分岐は計算コストが高い傾向にある。条件分岐は数を減らすと最適化につながったりするので、パフォーマンスを極限まで切り詰めたい状況などでは有効かもしれない。

yukiyuki

フォーマット文字列

要するに下記ができるようになった。ただし、関数の呼び出しなどはできない。

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}");
}
yukiyuki

cargo build --timings

ビルド時にどの処理に時間がかかったのかや、どのようにリソースを利用したのかを知ることができる。この機能が cargo に入った。昔は -Ztimings というパラメータだった気がする。

cargo build --timings と入力して実行され、ビルド後に target ディレクトリの配下に HTML ファイルが生成されて閲覧できる。

たとえば https://github.com/yuk1ty/stock-metrics というリポジトリのビルドに timings を利用して実験計測したのが下記の画像である。

(長くなったのでスケールを調整してビルドに約1秒以上かかっていそうなもののみを抽出)

そのほかにも、ビルドに時間がかかったクレートをランキング表示してくれたりする。ビルド時間を最適化したい際にまず使用するとよいオプションだと思う。だいたいのケースで、AWS SDK が悪さをしているか、あるいは tokio を入れてビルド時間が伸びたというオチになりそうだが…

yukiyuki

ExitCode を指定できるようになった

1.61 からの追加。

https://doc.rust-lang.org/stable/std/process/struct.ExitCode.html

これまでは 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)
}
yukiyuki

ちなみに全然知らなかったが、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 型に実装されている。

https://doc.rust-lang.org/std/process/trait.Termination.html

yukiyuki

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 トレイトを実装しているかをチェックすればいいだけのように感じてしまうが、何か特別な実装できない事情があったりするのだろうか。

yukiyuki

RFC はこれみたい。
https://rust-lang.github.io/rfcs/3107-derive-default-enum.html

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.

ユニットでないヴァリアントに対する自動導出は検討されてはいるらしい。

https://rust-lang.github.io/rfcs/3107-derive-default-enum.html#non-unit-variants

主に追加できない理由は構造体に対して Default トレイトを自動導出させるとたまに変なものを生んでしまうから、という感じらしい。この問題かな?

https://qnighy.hatenablog.com/entry/2017/06/01/070000

yukiyuki

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://www.youtube.com/watch?v=DnYQKWs_7EA

yukiyuki

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

https://cglab.ca/~abeinges/blah/everyone-poops/

内部的な実装の追加はシンプルなデザインで、'scope というライフタイムをもたせたスレッドをモデリングする新しい構造体を逐一追加していくと実装できるらしい。

https://rust-lang.github.io/rfcs/3151-scoped-threads.html

ただ、プルリクエストを追っていると実装中に不健全性が見つかったりしてなかなか大変そうではあった。

https://github.com/rust-lang/rust/pull/94644

通常の 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 というデータを追加で要することになるが、その分だけメモリ利用が乗ることにはなりそう。微々たるものかもしれないが。

yukiyuki

cargo 周りの変更点

  • 1.62 よりcargo-edit が標準で入るようになった: これにより Cargo.toml にクレート依存を追加する際に使える cargo add が標準で使えるようになった。
  • 1.64 より cargo-workspace のルート部分にクレートで共通して使用する設定を追記できるようになった。
yukiyuki

.awaitIntoFuture::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 化していたような reqwestsend のような関数が、軒並み .await を書くだけで済むようになる。下記みたいなやつの send() をなくせる(らしいのだが、RequestBuilderIntoFuture がまだ実装されていない関係で、0.11.13 ではうまくいかない?)。

https://github.com/yuk1ty/connpass-rs/blob/dce6d73d5d7d387bcad028d6091877348bb0c2b3/src/client.rs#L77-L78

yukiyuki

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 は現実的にはモナドを実装するには関連型ゆえに微妙に制約が足りなかったり、あるいは必要な要素が足りていないらしい。というわけで、モナドを完全に実装するのは現時点では難しいようだ。

https://zenn.dev/yyu/articles/f60ed5ba1dd9d5

ちなみに私はまだこれを実務では使ったことがなく、知っている実装のバリエーションに乏しい。もし何かよいユースケースがあるようだったら他にも知りたい。

yukiyuki

let-else

1.65 から入った。let { パターンマッチで取り出したい何か } = { 値 } else { マッチしなかった場合に実行される処理 }; と1行で書けるようになった。Swift にすでに guard という同等の機能があり、それを Rust に輸入した形と個人的には理解している(RFC でも prior art として言及されている)。

下記の記事がよくまとまっているので参照できる。
https://qiita.com/namn1125/items/ccedf9cc06b8cef71557

たとえば下記のようにコードを書き直せる。

    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(())
    }
yukiyuki

新機能をどう捉えるか

最後に個人的な意見を書き留めておく。Rust は機能が多い。そして、追加されるペースも早い。「これらの機能を全部覚える必要があるの?」と疑問に思うユーザーもいるかもしれない。

私の考えでは、

  • 文法の追加は使いたくなったら使えばよい。頭の片隅に置いておいて、思い出したら使う程度で十分ではないだろうか。
  • unwrap_unchecked のようなパフォーマンス切り詰める系は頭の片隅に置いておく程度でよい。ただこの手の話は引き出しが多いに越したことはないので、暇を見つけて doc を読むなどしてチェックしておくと良い。
  • Mutex の裏側の修正のような話は、小話として知っておくだけでよい。ユーザーが利用する分についてはあまり関係がない。ただ、読み物としては大変面白いことが多いので、興味があれば一読すると世界が広がって楽しい。
  • ツール周りの修正は頭の片隅に置いておいて使える場面で使っていくでよい。

かなと考えている。完璧を追求するあまり「より Rust らしい書き方」を追求したい、みたいな記事やコメントをたまにみるが、個人的には追求してもよいししなくてもよく、どちらでもよいと思う。(趣味プロジェクトは別として実務での利用の場合、)その時点でそのコードが大きなボトルネックになっているわけではない場合、その時点ではそのコードがベストと考えて素直にそのままマージすればよいと思う。

それより重要なのは、データ構造やアルゴリズムの方だ。これらが筋悪く作られていたり、保守性が低くデザインされていた場合、そちらの方がはるかに致命的なはずだからだ。Rust のコードの書き方如何でネストの深さとかで多少保守性の問題を生んだりするかもしれないが、そもそもネストが深くなるのは考案したデータ構造やアルゴリズムに問題があることが多い。見直すべきはむしろそちらなので、そちらにきちんと集中した方が得られるものが大きいかと、個人的には思っている。

このスクラップは2022/11/25にクローズされました