簡単な Windows GUI アプリを作りながら Rust を学ぶ (1)

投稿: 2021年11月04日、更新: 2021年11月11日
タグ: 
  Developer(Java等)  Windows  技術メモ

Rust の勉強を始めた。少しずつアウトプットしていきたい。

公式ページを見ると、 the bookopen_in_new というとても纏まったドキュメントがあったので、それをざっと読んでみた。 非公式ながら 日本語版open_in_new も作成されており、とても読みやすい(と個人的には思う)

ある程度 用語と仕様は分かったが、まだ書いてある内容全てを理解できておらず、腹落ちしていない。 動くものを作ると理解も進むので、Rust で「Windows GUI ネイティブアプリ」を作りながら理解したい。
Rust の持っている特徴やライブラリの充実度を考えるとネイティブGUI アプリは

全く向いてない分野
だと思うが、動くものが見えると分かりやすいので GUIを作っていきたい。

尚、以降 全て Windows を前提にしている。Linux 等でも同様とは想定しているが、同じ動きになっているかは確認していない。

native-windows-gui を使って Windows 上でウィンドウを表示する

Windows 上でネイティブGUI を動かすには Win32 API を叩くことになるが、その部分を実装してくれている native-windows-guiopen_in_new というライブラリがあったので、それを使うことにした。

Win32 API を呼び出すライブラリは他にも複数あり、UWP を Rust で実装するライブラリなんかもあったが、 個々の Win32 API を直接呼ばなくてよい(ちょっと作ってみるには楽な) native-windows-gui を使ってみることにする。

まずは何のコントロールもない、ただのウィンドウを表示する。 出力結果と実装した Rustコードは以下のような感じ。(Windows 11 上で実行してるので角丸ウィンドウになっている)

以下コードを実行するとタイトルバーだけが表示された新規ウィンドウが表示され、×ボタンを押すことでウィンドウが閉じられる。

画像_rust_gui_1.jpg
Rust
extern crate native_windows_gui as nwg; use std::rc::Rc; fn main() { nwg::init().expect("Native Windows GUI の Init 失敗"); let mut window = Default::default(); nwg::Window::builder() .size((500, 115)) // ウィンドウ初期サイズ(横,縦) .title("Windows GUI ウィンドウ in Rust") .build(&mut window) .unwrap(); let rc_window = Rc::new(window); let events_window = rc_window.clone(); let handler = nwg::full_bind_event_handler( &rc_window.handle, move |evt, _evt_data, handle| { use nwg::Event as E; match evt { E::OnWindowClose => if &handle == &events_window as &nwg::Window { nwg::stop_thread_dispatch(); }, _ => {} } }); nwg::dispatch_thread_events(); nwg::unbind_event_handler(&handler); }

このコードから把握できる Rust の仕様

たったこれだけのコードでも把握することがたくさんある。
nwg::init や nwg::Window などは native_windows_gui の話のため、ここでは除外する。 Rust 言語の範囲で挙げていくと以下がある。

1.  変数は let キーワードで定義する (7行目など)
2.  変数や参照を可変にしたい場合は mut キーワードを指定する (11行目など)
3.  値を参照したい場合は & 演算子を指定する (11行目など)
4.  関数の引数として値を渡すと、値の所有権が関数に移動する (11行目など)
5.  Rust では Result型を返すように関数実装することがよくあり、Result型のヘルパーメソッドとして expect と unwrap がよく使用される (5行目、12行目)
6.  他の構造体の関数(関連関数) を実行する際は、Rc::new のようにコロン2つで表記する (14行目など) 
7.  モジュールパスを省略する際は use 宣言を使用する (2行目など)
8.  use 宣言でインポートしたものに as をつけることで別名を指定できる (19行目)
9.  match 式でパターンマッチができる (21行目)
10. match 式の中で _ を指定することでどの条件にも合致しない場合を表現できる (26行目)
11. | 引数 | { 処理 } という書式でクロージャ―が定義できる (18行目~27行目)
12. クロージャに move キーワードをつけることでクロージャ内で使用する値の所有権を移動させられる

それでも、今回のコードの範囲では、Rust の最も特徴的な機能の1つと思われるライフサイクルの話はほぼ出てこない。 まずはミニマムなところから、1つ1つ順に見ていきたい。

1. 変数は let キーワードで定義する

まず 1.について、Rust は let で変数を定義する。JavaScript(ES6+)/TypeScript と一緒だ。
特徴的なのは let で定義した変数は既定で不変になるという点。以下のコードはコンパイルエラーになる。

Rust
let value = "default"; value = "updated";" // この行でコンパイルエラーになる

Rust の リファレンスopen_in_new の言葉を借りると let は「Bind a value to a variable (値を変数に縛りつける)」キーワードになっている。 1度 値を設定すると縛りつけられて変更できない。

他の言語だと、変数初期化後に値を変更しない場合に、それを明示しなかったとしてもエラーになることは(通常) ない。 final や const にし忘れても問題なくコンパイルはされる。 Rust の場合は、変更できるかできないかの明示が必須(つけ忘れを許容しない) で、かつ その既定値が「不変」になっている。

もう1つの面白い特徴として、Rust では以下のコードがコンパイルできる。 1、2、3行目で同じ変数名を使っているが、新しい不変な変数が定義される(shadowing と呼ばれる)。 5行目の println!マクロで標準出力される値は最後の値が利用され value=0 になる。

Rust
let value = "default"; let value = "updated"; let value = 0; println!("value={}", value);

2. 変数を可変にしたい場合は mut キーワードを指定する

1. で記載した通り、Rust の変数は既定で不変だ。
変更できないと困るので、その場合は mut キーワードopen_in_new が出てくる。 mut は Mutable (変更可能) を表しており、以下のコードはコンパイルエラーにならない(value に無駄に値が設定されているので警告は出る)。

Rust
let mut value = "default"; value = "updated";"

mut をつけても shadowing はできる。以下コードはコンパイルできる。

Rust
let value = "val"; let mut value = "abc"; let mut value = "xyz"; println!("value={}", value);

ここで 1つ疑問が。上記 1行目、2行目の変数はメモリ上に残っているのだろうか? それとも 3行目が実行された時点でメモリ上から破棄されるだろうか?
Rust 変数のスコープを考えればメモリ上に残ってそうだが、実際に確認してみる。

確認は Windows デバッガ―(WinDbg) でバイナリ(.exe) にアタッチしてメモリを参照する。 5行目にブレークポイントを貼って変数の状態を確認したのが以下で、 dv はローカル変数を表示するデバッガ―のコマンドだ。

WinDbg
0:000> dv /V 0000009a`2c72fb40 @rsp+0x0040 value = "xyz" 0000009a`2c72fba8 @rsp+0x00a8 value = "abc" 0000009a`2c72fb98 @rsp+0x0098 value = "val"

shadowing しても変数はメモリ上に全て存在していた。
Rust の実装上 (基本的には)アクセスできない変数になるが、あくまでも変数としてのスコープ外になって初めて破棄されるようだ。

3. 値を参照したい場合は & 演算子を指定する

他のほぼ全てのプログラミング言語と同様に Rust にも「 参照open_in_new 」の仕組みがあり、『&変数名』という表記をする。例えば、以下のように書ける。

Rust
let val1 = 100; let val2 = &val1; let val3 = &val2;

上記の場合、val1 は 100 という値が入る。そして、val2 は val1 への、val3 は val2 への参照を持つことになる。 簡単な絵にすると以下のようになる。

※ 絵だけ見ると、ただのポインタだが、Rust の場合、ポインタそのものを扱える pointer型open_in_new も別にあるため、参照とポインタという言葉を分けて使用する必要がある。 参照(reference) はライフサイクルの管理下にあり、ポインタ(pointer) を直接利用するのは一般的ではない(、とリファレンスには記載されている)。

画像_rust_gui_2.jpg

実際のプロセス上のメモリも確認する。

WinDbg
0:000> dv /V /n 0000008f`6f72f6bc @rsp+0x003c val1 = 0n100 0000008f`6f72f6c0 @rsp+0x0040 val2 = 0x0000008f`6f72f6bc 0000008f`6f72f6c8 @rsp+0x0048 val3 = 0x0000008f`6f72f6c0

イメージと同様に val1 には 100 の値が格納され、val2 は val1 のアドレス(0x0000008f`6f72f6bc) へのポインタ、 val3 には val2 のアドレス(0x0000008f`6f72f6c0) へのポインタが格納されている。

ちなみに IDE の IntelliJ Rustopen_in_new は各変数の型/参照を補完表示してくれるのでとても分かりやすい。 以下のように val3 が i32 の値を参照×2 しているのが分かる。

画像_rust_gui_3.jpg

あと、上の例で val1 の値を文字列ではなく val1 = 100 と i32(32ビットサイン付き Integer) にしたのは Integer の方が簡単だからだ。 Rust では以下のように文字列リテラルを宣言すると &str 型になる。 そのため、val1 は value という値への参照であり、参照のネストが 1つ増えるので、Integer を例にとった。

画像_rust_gui_4.jpg

4. 関数の引数として値を渡すと、値の所有権が関数に移動する

まさに Rust の特徴的な言語仕様だ。まずはコードから。
Rust では以下のコードはコンパイルエラーになる。

Rust
let val1 = String::from("value"); let val2 = val1; println!("{}", val1);

1行目で value という文字列を生成して変数 val1 に格納し、2行目で val1 を val2 に格納している。 そして、3行目で val1 を標準出力する箇所でコンパイラがエラーを出力する。

「なにそれ。Rust 分からない」に繋がりやすい要素の 1つだと思う。

どういうエラーになるかというと以下のエラーになる。ムーブ(移動) された値を使用しようとしたためにエラーになる。

コンパイルエラーメッセージ
error[E0382]: borrow of moved value: `val1`

以下のコードも4行目で同じコンパイルエラーになる。
3行目で value という文字列を引数に関数を呼び出しており、その後 4行目で変数の値を標準出力しようとしている。

Rust
fn main() { let val1 = String::from("value"); do_nanika_no_shori(val1); println!("{}", val1); } fn do_nanika_no_shori(param: String) { }

所有権とは何か、ムーブとは何か、については以下ドキュメントがとても参考になるが、 私の場合は上記のコードをステップ実行することで 多少腹落ちした。

所有権とは? - The Rust Programming Language 日本語版
https://doc.rust-jp.rs/book-ja/ch04-01-what-is-ownership.htmlopen_in_new

上記例の場合、do_nanika_no_shori 関数を呼び出すと引数の val1 の所有権が関数に移動する。 do_nanika_no_shori 関数(7行目) に遷移した時点で main 関数では val1 が参照できなくなる。

7行目、8行目はコード上 何も処理を記載していないがデバッガ―でステップを追ってみると、 8行目のタイミングで drop_in_place (core::ptr::drop_in_place)open_in_new 関数が呼び出されていた。drop_in_place 関数はポイントされた値(変数の値) を破棄(デストラクト) する処理だ。 引数として渡された param (val1) の値を8行目で破棄している。

コンパイルが通るようにコードを修正して注釈を書き込むと以下のようになる。

画像_rust_gui_5.jpg

所有権は慣れない仕組みだが、変数が知らないうちに drop(破棄) されているのを見ることで、頭の良い仕組みだな、と思うようになった。 変数の所有者(Owner) を 1人にすることで、メモリアクセスエラーを回避しながらメモリ上の値をガツガツ破棄していける。 上記の場合、もし main関数からも do_nanika_no_shori関数からも同じアドレスの値にアクセスできるとなるとメモリアクセス違反は簡単に起きる(そして、起きると調査が面倒)。

Rust は「ガベージコレクション」を行わず、「マニュアルでのメモリ解放」も行わず、『第3の選択肢』を採っている、とドキュメントに書いてあったが、少し腹落ちしてきた。

関数を呼ぶたびに所有権を取られると不都合な場合の方が多いので、参照を引数として渡すこともできる。 以下のコードはコンパイルできる。

Rust
fn main() { let val1 = String::from("value"); do_nanika_no_shori(&val1); println!("{}", val1); } fn do_nanika_no_shori(param: &str) { }

3行目で、関数呼び出しの引数として値ではなく参照(&val1) を渡している。 do_nanika_no_shori関数は参照しか持っておらず、破棄(drop) すべき値がない。 val1 の値は更新されずに4行目でそのままアクセスできる。

  • native-windows-gui を使って Windows 上でウィンドウを表示する
  • コードから把握できる Rust の仕様
  • 1. 変数は let キーワードで定義する
  • 2. 変数を可変にしたい場合は mut キーワードを指定する
  • 3. 値を参照したい場合は & 演算子を指定する
  • 4. 関数の引数として値を渡すと、値の所有権が関数に移動する
  • だいぶ長くなってきたので、残りは今度 時間がある際に纏めたい。

    続きを以下に記載した。