普段Pythonなどをメインにお仕事をしていますが、Rustのごく基本的な文法や挙動などをPythonと比較しつつ学んでいってみます(前も入門書消化したタイミングで少し記事にしたりしていますが復習も兼ねて書いておきます)。
※Rust初心者なため誤解している点などがあるかもしれません。その辺はご容赦ください。
※長くなりそうなので記事を分割しています。本記事は2記事目となります(過去の記事で触れた点はスキップします)。
1記事目:
※今回はライフタイムなどのRust特有のトピックが多めでPython関係はあまり出てこず若干タイトル詐欺的な側面があるかもしれません・・・w
Rustでのエラーハンドリング
詳細までは触れませんがRustのエラーハンドリングについて軽く触れておきます。
Pythonだとエラーになりうる箇所に関してはtry-exceptを使ったりしますが、Rustでは関数の返却値でOkとErrというenumの値を使用する形となるようです。
正常なパターンであればOkの方を返却し引数には返却したい値を設定、不正な場合にはErrの方を返却する形となります(Errの方にもエラーメッセージなどの引数を設定する形となるようです)。
サンプルとして雑ですが引数に指定された整数が10以上であればOk、10未満であればErrを返却する関数を書いてみます。Okの返却値には引数に指定された整数をそのまま指定する形とします。
fn main() {
let result = check_int_is_gte_10(50);
}
fn check_int_is_gte_10(int_value: i32) -> Result<i32, &'static str> {
if int_value >= 10 {
return Ok(int_value);
}
return Err("指定された値が10未満です。");
}
OkとErrはResultのEnumとなっているようで、返却値の型にはResult型を指定します。また、Resultの中には1つ目はOkの場合の返却値の型、2つ目にはErrの場合の引数の型を指定します。
&'static str
という他の言語では見慣れない記述(ただしRust本やサンプルコードには割と序盤から見かける)が出てきていますが、&
部分は所有権の参照関係の&str
などと同じ記述となります。
'static
の部分の記述は追加すると静的な値として扱われるようです。この指定をするとプログラム全体の生存期間中に値が有効になり、また不変の値となります。ライフタイムなどに依存して参照に失敗する・・・といったことが無くなるため、Errとなった場合にその結果をきっちりと残しておくことができる・・・という認識でいます(エラーが発生したらその辺は確実に残して処理したい・・・という感じでエラーと'static
の相性が良いので使われるというところでしょうか?)。
返却された値はResult<i32, &str>
といったような型として扱われますが、実際の値を参照したい場合にはいくつか方法があります。
まずは結果がエラーになっているかどうかはis_err
メソッドで確認が取れます。また、Pythonでいうところのraise
のようにエラーを発生させて処理を停止させたい場合にはpanic!
のマクロを使うことで対応することができます。また、その後の値はunwrap
メソッドを使うとOkの場合の値を取ることができます(今回の例ではResult<i32, &str>
という型になっているので整数が取れます)。
fn main() {
let result = check_int_is_gte_10(50);
if result.is_err() {
panic!("返却値がErrになっています。");
}
let int_value = result.unwrap();
println!("{int_value}");
}
fn check_int_is_gte_10(int_value: i32) -> Result<i32, &'static str> {
if int_value >= 10 {
return Ok(int_value);
}
return Err("指定された値が10未満です。");
}
50
試しにわざとErrの分岐に入るようにlet result = check_int_is_gte_10(9);
といったように引数指定を調整して実行してみると以下のように出力されます。
thread 'main' panicked at '返却値がErrになっています。', src\main.rs:5:9
また、is_errメソッドでの分岐を設けずにコンパイルした場合でもunwrapの部分でエラーで止まります。Err
で設定されていたエラーメッセージも一緒に表示されるようです。
fn main() {
let result = check_int_is_gte_10(9);
let int_value = result.unwrap();
println!("{int_value}");
}
fn check_int_is_gte_10(int_value: i32) -> Result<i32, &'static str> {
if int_value >= 10 {
return Ok(int_value);
}
return Err("指定された値が10未満です。");
}
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: "指定された値が10未満です。"', src\main.rs:4:28
Err
で設定されているエラーメッセージ以外にも呼び出し元などでエラーメッセージを追加で設定したい場合にはunwrap
の代わりにexpect
メソッドを指定することで対応することもできます。expect
メソッドの引数には追加設定したいエラーメッセージを指定します。
fn main() {
let result = check_int_is_gte_10(9);
let int_value = result.expect("返却された値がErrになっています。");
println!("{int_value}");
}
fn check_int_is_gte_10(int_value: i32) -> Result<i32, &'static str> {
if int_value >= 10 {
return Ok(int_value);
}
return Err("指定された値が10未満です。");
}
thread 'main' panicked at '返却された値がErrになっています。: "指定された値が10未満です。"', src\main.rs:4:28
所有権の話
Rustを扱う上では所有権(ownership)も避けては通れないので所有権周りも軽く触れていきます。
まず基本的な挙動にRustでは変数の所有権周りで以下のような挙動になっています。Pythonだとガベージコレクションなどで特に細かいとこを気にする機会無く書けますがRustでは色々と考えることがあります。
- 値はその値がアサインされた変数が基本的に所有します。
- 変数がスコープ外になったらすぐにメモリの開放が実施されます(開放後の変数は参照できなくなります)。
- Copy(値のコピー)、Move(所有権の移動)、Immutable borrow(イミュータブルな借用)、Mutable borrow(ミュータブルな借用)をした場合には他の変数で元の変数の参照や操作などを行うことができます(これらの後の節で順番に触れていきます)。
スコープ外になった際の挙動の確認
スコープ外になったらすぐにメモリから開放されるという点の動作確認用に検証用のコードを書いていってみます。
Rustでは{}
の括弧で囲むことでスコープ領域の表現ができるためそれを使用していきます。
fn main() {
{
let mut int_value = 10;
println!("{int_value}");
int_value += 10;
}
println!("{int_value}");
}
上記のコードではint_value
変数はmain関数の{}
の括弧内部で宣言されているため{}
の括弧内でのみ有効です。つまり}
の括弧部分に達した時点でメモリが開放されてしまい、その後に記述されているスコープ外でのprintln!("{int_value}");
での参照はコンパイルエラーになります。
--> src\main.rs:7:16
|
7 | println!("{int_value}");
| ^^^^^^^^^ not found in this scope
letでの宣言が{}
の括弧外でされており、且つ値の設定などは{}
の括弧内というパターンであればエラーにはなりません。Pythonだとletやvarみたいな他の言語のキーワードが無くグローバル変数を参照したい場合などにはglobal
のキーワードを使ったりという形なのでこの辺は少し感覚が異なります(Pythonの方が初見だとスコープ周りの挙動で戸惑うときはあると言えばあるようには感じます)。
fn main() {
let mut int_value;
{
int_value = 10;
println!("{int_value}");
int_value += 10;
}
println!("{int_value}");
}
10
20
Copy(値のコピー)
所有権の変動する操作の1つとしてまずはCopy(値のコピー)があります。シンプルにこれはディープコピー的な挙動でコピーされる場合にはコピー前とコピー後のそれぞれの変数で所有権を持つ形となります。
RustではCopyトレイトを持つ型であれば他の変数にアサインした時点でこのコピーが走ります。つまりコピー前後の各変数で値の更新はそれぞれに影響せずに独立した状態になります。コピー後の変数は値の所有権を持つ形になるので自由に値の更新なども行えます。
例えばi32
などの整数型であればCopyトレイトを持っているので以下のコードではコピーが走っています。この辺はPythonと同じような感覚ですね。
fn main() {
let int_value_1 = 10;
let mut int_value_2 = int_value_1;
int_value_2 += 10;
println!("{int_value_1}");
println!("{int_value_2}");
}
10
20
Move(所有権の移動)
Pythonと結構感覚がずれる点として、RustではString
型などがCopyトレイトを持っていません。つまり別の変数にアサインした際にCopyによるディープコピー的な挙動になりません。
例えば以下のようなコードではstr_1という変数の所有権がstr_2という変数に移る形となり、コピーなどはされません。その状態でstr_1の変数を参照してprintln!
のマクロを使用しているのでエラーとなります。
fn main() {
let str_1: String = String::from("Hello");
let str_2: String = str_1;
println!("{str_1}");
}
error[E0382]: borrow of moved value: `str_1`
--> src\main.rs:4:16
|
2 | let str_1: String = String::from("Hello");
| ----- move occurs because `str_1` has type `String`, which does not implement the `Copy` trait
3 | let str_2: String = str_1;
| ----- value moved here
4 | println!("{str_1}");
| ^^^^^ value borrowed here after move
この辺は所有権が結構絡んできてstr
、String
、&str
と色々考える必要があるのでとりあえずstr
だけ使っていれば大体OKなPythonと比べると戸惑いやすく感じています。一方でメモリ効率の面で厳密に扱えるのでシビアなところとかではこちらの方が良いのだろうな・・・とも思います。
また、上記のようにlet str_2: String = str_1;
といったようにCopyトレイトを持っていない変数で代入を行うとMoveと呼ばれる処理が走って所有権が新しい変数側に移ります。左辺側の変数はその後も扱えますが右辺側の値は所有権を失っているので利用しようとするとコンパイル時にエラーとなります。
こういった場合の対策としてはいくつか方法がありますが、1つ目としてString
型であればcloneメソッドなどを使ってディープコピーするという方法が考えられます。この場合は両方の変数が所有権を持つ形になるのでエラーにはなりません。
fn main() {
let str_1: String = String::from("Hello");
let str_2: String = str_1.clone();
println!("{str_1}");
}
Hello
もしくはto_ownedメソッドなどを使うこともできるようです。こちらもコピー的な処理が入るようです。cloneとto_ownedそれぞれでどう違うのだろう?と軽く調べていたのですがどうやらcloneはそのままの型でコピー、to_ownedはコピーしつつ所有権も持った形となる・・・といった挙動をするようです。
つまり&str
などの借用した型の値に対して処理を実行した場合、cloneの場合は新しい変数は&str
型となりますがto_ownedを使った場合には結果の値は所有権を得た形でString型となります。
fn main() {
let str_1: String = String::from("Hello");
let str_2: String = str_1.to_owned();
println!("{str_1}");
println!("{str_2}");
}
Hello
Hello
Immutable borrow(イミュータブルな借用)
前節で触れたように所有権が移ってエラーになる場合に、編集しない値であれば&
の記号を使って借用(参照)することでも対応することができます。編集の効かない借用となるためImmutable borrowと呼ばれます。
fn main() {
let str_1: String = String::from("Hello");
let str_2: &String = &str_1;
println!("{str_1}");
println!("{str_2}");
}
Hello
Hello
イミュータブルとある通り参照はできるものの更新しようとすると怒られます。
fn main() {
let str_1: String = String::from("Hello");
let mut str_2: &String = &str_1;
str_2 += " world";
println!("{str_1}");
println!("{str_2}");
}
error[E0368]: binary assignment operation `+=` cannot be applied to type `&String`
--> src\main.rs:4:5
|
4 | str_2 += " world";
| -----^^^^^^^^^^^^
| |
| cannot use `+=` on type `&String`
Mutable borrow(ミュータブルな借用)
今度は変更が効く形での借用をする形です。Mutable borrowと呼ばれます。
使い方としては引数として指定する際に&mut
の記述が必要になります(呼び出し元でも関数側でも)。
また、更新時には*
の記号を変数名の前に付ける必要があります。これはdereference(間接参照、逆参照などとも訳される?ようです)という指定になります。*
を指定しないとx
の変数は参照を指すポインタとなり、値の更新とはならずエラーとなります。
fn main() {
let mut int_val: i8 = 10;
add(&mut int_val);
println!("{int_val}");
}
fn add(x: &mut i8) {
*x += 10;
}
20
借用時の複数の引数に対してライフタイムを一致させたい場合の制御
以下のmax
関数のように複数の借用された変数の引数を受け付けて且つそのどれかを返却する・・・という制御をする場合、普通に引数や返却値部分に借用の&
記号を付けただけだとエラーになります。
fn main() {
let first_num: i8 = 10;
let second_num: i8 = 20;
let max_num: &i8 = max(&first_num, &second_num);
println!("{max_num}");
println!("{first_num}");
println!("{second_num}");
}
fn max(first_num: &i8, second_num: &i8) -> &i8 {
if first_num > second_num {
return first_num;
} else {
return second_num;
}
}
error[E0106]: missing lifetime specifier
--> src\main.rs:10:44
|
10 | fn max(first_num: &i8, second_num: &i8) -> &i8 {
| --- --- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `first_num` or `second_num`
help: consider introducing a named lifetime parameter
|
10 | fn max<'a>(first_num: &'a i8, second_num: &'a i8) -> &'a i8 {
| ++++ ++ ++ ++
これに関してはエラーメッセージにも書かれていますが'a
などの指定を関数の各部分に設定することで解決することができます。型の指定(i8
など)とはスペースを空けます。
独特な記法ですがPythonのジェネリック(TypeVar
)的なものをイメージすると少し挙動が近いかもしれません(ジェネリックのような型ではなくライフタイム関係となりますが)。
fn main() {
let first_num: i8 = 10;
let second_num: i8 = 20;
let max_num: &i8 = max(&first_num, &second_num);
println!("{max_num}");
println!("{first_num}");
println!("{second_num}");
}
fn max<'a>(first_num: &'a i8, second_num: &'a i8) -> &'a i8 {
if first_num > second_num {
return first_num;
} else {
return second_num;
}
}
20
10
20
<'a>
部分はPythonのジェネリックで言うとTypeVar
での宣言部分で、T = TypeVar('T')
のT
に該当するような定義となります。ここで定義したものを引数や返却値にも指定することで「指定されたものは同じライフタイムが設定される」という挙動をします。
なぜこういった指定が必要なのか?という感じですが、Rustではスコープを外れたら基本的に即時で値がメモリから開放されるという挙動と各引数と返却値は必要なスコープの範囲がずれることがある・・・ということに起因します。
たとえば前述のmax関数ではfirst_numもしくはsecond_numのどちらかしか返却されないため分岐によって破棄タイミングがずれうる形になります。
一方で基本的に返却されていない方の値も後続の処理や関数呼び出しなどで利用することがあり、どちらかの引数の値が先に破棄されていると困る・・・といった具合になるため'a
といったライフタイムの寿命を一致させるための記述が必要になります。
これによってたとえば以下のような複数回引数に指定した値を参照するといったことができるようになります。
fn main() {
let mut first_num: i8 = 10;
let second_num: i8 = 20;
let mut max_num: &i8 = max(&first_num, &second_num);
println!("{max_num}");
first_num += 20;
max_num = max(&first_num, &second_num);
println!("{max_num}");
}
fn max<'a>(first_num: &'a i8, second_num: &'a i8) -> &'a i8 {
if first_num > second_num {
return first_num;
} else {
return second_num;
}
}
20
30
※長くなってきたので残りは次の記事に回します!
参考文献・サイトなど