Rust初心者が適当に書いても動いてしまうコードの書き方とより良い書き方を考えてみる
この記事は Rust Advent Calender 2024 の 11 日目の記事です。
こんにちは。NTT コミュニケーションズの @ucchy です。普段は SkyWay という WebRTC プラットフォームの開発や WebRTC リサーチャーをしています。
記事の背景
私は、2024 年に入ってから Media over QUIC Transport のプロトコル実装(moq-wasm)を Rust で書き始めました。Python や TypeScript の経験はあるものの、Rust は初めてだったため、多くの点で「良くない」コードを書いてしまいました。
1 年間 Rust を書いていて、少しずつ「より良い書き方」も分かってきたので、「とりあえず動いてしまう書き方」と自分が考える「より良い書き方」をまとめてみたいと思います。
「とりあえず動いてしまう書き方」と「より良い書き方」
1. 変数の型を手動でつける
例えば、以下のようなコードです。
let x: i32 = 5;
println!("{}", x);
i32 型を手動でつけていますが、コンパイラが推論できるところは任せましょう。
Rust のコンパイラは i32 が標準となっており、型を付けなかった場合には i32 型として推論されるようです。i8 など他の型を明示することも可能です。
let x = 5; // コンパイラがi32と推論
println!("{}", x);
2. 型変換のやり方が統一できていない
i32 型の変数を、i8 型として扱いたい場合に、as
によるキャストを使っていました。これでも動く時は動いてしまいます。
let x: i32 = 100;
let y: i8 = x as i8;
この書き方は、安全でない書き方です。i8 型で表現しきれない数値を変換しようとすると、意図しない値になります。
TryFrom もしくは TryInto を使うことで、安全な書き方になります。
let x = 500;
match i8::try_from(x) {
Ok(x) => println!("x = {}", x),
Err(e) => println!("変換エラー: {}", e),
}
let x = 100;
match TryInto::<i8>::try_into(x) {
Ok(x) => println!("x = {}", x),
Err(x) => println!("変換エラー: {}", e),
}
// もしくは一度Result型で受ける
let x = 100;
let result: Result<i8, _> = x.try_into();
match result {
Ok(x) => println!("x = {}", x),
Err(e) => println!("変換エラー: {}", e),
}
3. 型変換のやり方が統一できていない
エラーが起きずに変換できることが自明である場合、try_from
try_into
ではなく from
into
が使えます。
例えば、i32 を i64 に変換する時などです。
let x: i32 = 100;
let y = i64::from(x);
println!("y = {}", y);
let x: i32 = 100;
let y: i64 = x.into();
println!("y = {}", y);
from
を使う場合、式の左項で型定義をして、それに向けて into させることも可能ですし、左項で型定義をせずに右項で明示することも可能です。
let x: i32 = 100;
let y: i64 = x.into();
let x: i32 = 100;
let y = Into::<i64>::into(x);
どの書き方をするのかは好みな気がしますが、個人的には左項を読まないとどんな肩に変換されるかわからないよりかは、右項に型定義がされていて、右項だけ読めばどんな型に変換されるのかがわかりやすい方が好みです。Into::<i64>::into(x)
みたいな書き方は冗長に思うので i64::from(x)
が綺麗に見えます。
let x: i32 = 100;
let y = i64::from(x);
println!("y = {}", y);
4. &str と String の違いを理解していない
Rust では、String は文字列操作をしたい時に使うもので、操作しないのであれば&str の方が軽量ですし、文字列操作しないことが型を見ればわかるので、可読性も高まります。
これを、とりあえず String として宣言してしまっていました。
String はヒープ上に保存される文字列バッファであり、&str は文字列そのものではなく文字列を参照するポインタです。String で定義するとヒープにアロケートする処理が発生しますが、&str はすでに存在する文字列に対するポインタなので、コピーが発生しません。
let x: String = String::from("hoge");
println!("{}", x);
下記のようにすることで、より軽量に扱うことができ、文字列を操作しないことを型定義から伝えることができます。
また、型定義をしなかった場合には &'static str
という形で型推論されます。
これは、プログラムに文字列リテラルとして定義しているため、プログラム全体で有効な、最も長いライフタイムを持つ&str
と解釈できそうです。
let x: &str = "hoge";
println!("{}", x);
今回のようなサンプルであればどのような型を付けてもパフォーマンスに大差はないですが、String と&str の違いを理解しておけば、無駄に String をたくさんに生成せずに済みそうですね。
5. とりあえず可変変数にする
とある変数を変更する際に、「とりあえず可変にする」ようにしてしまっていました。
let mut count = 0;
count = count + 1;
println!("{}", count);
上記の書き方でも間違いではない時も当然あるのですが、同じ変数名で値を上書きすることも可能です。
let x = 100;
println!("x = {}", x);
let x = 101;
println!("x = {}", x);
6. シャドーイングを使わない
別のスコープに値が渡され、それを操作した際に、わざわざ少し名前を変えて別の変数として定義していました。
Python などでは書き方によっては、グローバルに扱われてしまって元の変数が書き換えられてしまうことがあるので、こういう書き方をしていました。
let x = 5;
{
let x_clone = x * 2;
println!("ブロック内のx_clone: {}", x_clone); // 10
}
println!("ブロック外のx: {}", x); // 5
しかし、Rust ではシャドーイング
が可能です。
シャドーイングとは、同じ名前の変数を宣言することにより、前のスコープで定義されていた同名の変数をそのスコープ内で新たな変数として「上書き」する機能です。
元の変数は操作されないので、スコープを抜けた後でx
を読み込むと操作されていないx
の値が読み込まれます。
let x = 5;
{
let x = x * 2;
println!("ブロック内のx: {}", x); // 10
}
println!("ブロック外のx: {}", x); // 5
7. if 文や while 文で if let, while let を使わない
Rust では条件文で変数代入を同時に行うことが可能なのですが、これを使っていませんでした。処理を二重に書かなければいけなくなり、コードが膨らんでしまっていました。
Python などではセイウチ演算子と呼ばれる書き方もありますが、Python3.8 から導入された書き方なのでイマイチ慣れていなかったのが理由です。
if value.is_some() {
let val = value.unwrap(); // 条件文とほぼ同じ処理
println!("The value is {}", val);
} else {
println!("No value");
}
if let Some(val) = maybe_value { // 条件文と変数代入を同時にできる
println!("The value is {}", val);
} else {
println!("No value");
}
loop / while 文ではこのような書き方でした。
loop {
let value = iter.next();
if value.is_none() {
break;
}
let val = value.unwrap();
println!("{}", val);
}
while let 構文を使うと break 条件も分かりやすくシンプルに書けます。
while let Some(val) = iter.next() {
println!("{}", val);
}
8. match 文ではなく if 文を使う
match 文が使えるのに、無理に if 文でコードを書いていました。
(Python では 3.10 からしか match 文が使えなかったのでこれも Pythonista の癖ではあります。)
let value = 10;
if value > 5 {
println!("greater than 5");
} else {
println!("not greater than 5");
}
rust では match 文が使えるので、match 文を使うことでよりすっきりしたコードになります。(場合による)
let value = 10;
match value {
v if v > 5 => { println!("greater than 5") },
_ => { println!("not greater than 5") },
};
9. Result 型をとりあえず unwrap する
Result 型に慣れていなかったため、適切なエラーハンドリングをすることなく、とりあえず unwrap()
でエラーが起きない前提でコードを書いてしまっていました。
let value: Option<i32> = Some(10);
println!("{}", value.unwrap()); // panicの可能性がある
expect
などで panic が起きた際にエラー文言をカスタマイズすることは可能ですが、こちらもプログラムが落ちて良いような場合にのみ使うべきだと思います。
let value: Option<i32> = Some(10);
println!("{}", value.unwrap().expect("value is error")); // panicの可能性がある
エラーハンドリングを適切にしなくても、動くときは動いてしまいます。
しかし、当然良いコードではありません。ちゃんとエラーハンドリングをしましょう
let maybe_value: Option<i32> = Some(10);
if let Some(val) = maybe_value {
println!("{}", val);
} else {
println!("No value");
}
?
を使いこなせない
10. Rust では、エラーハンドリングや早期リターンをシンプルに書く?
構文があります。これは糖衣構文と呼ばれ、簡潔かつ可読性の高い形で記述できるようにした構文上の“甘い”書き方です。
しかし、Rust の書き始めは頑張って match 文で Err を返していました。
fn read_file_contents(path: &str) -> io::Result<String> {
let mut file = match File::open(path) {
Ok(f) => f,
Err(e) => return Err(e),
};
let mut contents = String::new();
match file.read_to_string(&mut contents) {
Ok(_) => Ok(contents),
Err(e) => Err(e),
}
}
?
糖衣構文を使うことで、かなりスッキリ書けます。
fn read_file_contents(path: &str) -> io::Result<String> {
let mut file = File::open(path)?; // Okならfile取得、Errならここでreturn Err(e)
let mut contents = String::new();
file.read_to_string(&mut contents)?; // 同上、失敗すれば即return Err(e)
Ok(contents)
}
厳密には、上記のコードはFile::open(path)?
のエラー型と file.read_to_string(&mut contents)?
のエラー型が異なるので、それらをまとめた独自のエラー型を定義する必要はあるかもしれません。
enum MyError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl From<std::io::Error> for MyError {
fn from(err: std::io::Error) -> MyError {
MyError::Io(err)
}
}
impl From<std::num::ParseIntError> for MyError {
fn from(err: std::num::ParseIntError) -> MyError {
MyError::Parse(err)
}
}
もしくは、これらの独自のエラー型を定義するのが面倒であれば、まとめて anyhow::Error
型にまとめてくれる anyhow
クレートという便利なライブラリも存在します。この anyhow
は、Rust でのエラーハンドリングを簡素化するライブラリで、私が知る限りではかなりメジャーで色々な場所で使われています。
11. とりあえず clone
変数を別の関数に渡した後に元の変数を扱おうとしたらコンパイルが通らないので、とりあえずコピーすることでライフタイムなどの考慮を無視することが多くありました。
let s = String::from("hello");
let t = s.clone(); // 2. とりあえずcloneしてしまう
println!("{}", s); // 1. ここでコンパイルエラーが出るからとりあえずcloneするか・・・
println!("{}", t);
無駄にコピーが走ってしまっていてパフォーマンスに影響がありますし、変数宣言も膨れ上がってしまいます。
let s = String::from("hello");
println!("{}", &s); // 参照で借用
println!("{}", s); // 所有権はsが持ったまま
終わりに
Rust 初心者である私が陥った「良くないコードの書き方」をまとめてみました。
勿論、状況によってはその限りではありませんが、違う書き方も知っておくと、見やすい形でコードを書けるかもしれません。
他にも、「より良いコードの書き方」があればぜひコメントなどで教えてください!
Discussion