概要
この記事ではRust初心者が驚いたり混乱させられたりするようなRustの文法を10項目集めてみました。
これらの項目は知らないと理解できなかったりコンパイルエラーに悩まされたりする一見厄介なものたちなのですが、そのような直感的でない挙動を敢えてさせているところには重要な意味が込められていることが多いです。
そのため、これらの項目を通してRustが目指しているものや実現したい機能の一部を垣間見ることができると思います。
1. デフォルトの代入がムーブ
Rustの最大の特徴が所有権の概念であることは有名ですが、それでもなお初心者殺しになるのがムーブです。
以下のコードがコンパイルエラーになるメジャーな言語は現状Rustくらいしか無いでしょう。
let mut a = vec![1, 2, 3];
let mut b = a; // ここでaの持つベクタの所有権がbにムーブされ、aは無効に!
a[0] = 2; // ここでエラー -> error[E0382]: borrow of moved value: `a`
// コピーしたい場合は、let mut b = a.clone();とすればOK
このような所有権のムーブの概念はC++において既にunique_ptrとして実装されていましたが、所有権を失った変数にアクセスしてもコンパイルの段階ではエラーになりません。
ムーブは無駄なメモリコピーを防ぐだけでなく、単一リソースの管理やスレッド競合の防止において重要な役割を果たします。
そのためコンパイル時に所有権が有効であることを保証してくれるRustでは、そのようなムーブの恩恵を最大限享受するためデフォルトの代入操作がムーブになっています。
しかし慣れないとコンパイルエラーに引っかかることが多々あるでしょう。
たとえば+演算子による文字列連結の第1オペランドもムーブされます。
let a = String::from("a");
let b = a + "b"; // ここでaの所有するStringがbにムーブされ、aは無効に!
println!("{}", a); // error[E0382]: borrow of moved value: `a`
// なお+演算子ではなくlet b = format!("{}{}", a, "b");としておけばこの問題は起こりません
また、メソッドのselfに&をつけ忘れるとこんなエラーも。
struct Foo {}
impl Foo {
fn foo(self) { // &self(参照)とすべきところをselfとしたためムーブに!
println!("foo");
}
}
fn main() {
let f = Foo {};
f.foo(); // ここでfがfoo(self)関数内のスコープにムーブされてしまう
f.foo(); // error[E0382]: use of moved value: `f`
}
2. デフォルトでコピーされるものもある(Copyトレイト)
デフォルトの代入操作がムーブであることを学ぶんだばかりのタイミングで以下のコードを実行すると正常に処理され、コンパイルエラーにならないことに混乱するでしょう。
let a = 1;
let b = a; // ここはムーブではなく値がコピーされている
println!("{}", a); // これはOK
let tp = (1, true, 'a');
let tp2 = tp; // ここも値がコピーされている
println!("{:?}", tp); // これもOK
これは数値型やbool型、char型に対してCopyトレイトが適用されているためで、これらCopyトレイトが提供されている型とそれらの型のみで構成されたタプル型はデフォルトの代入操作がムーブではなくコピーとなります。
こうなっているのは、コピーしてもコストにならないし困らない基本的な値を示す型までムーブしようとすると無駄にコードが煩雑になってしまうからでしょう。
とはいえこのことを知らないと混乱してしまうこと間違いなしです。
3. Hello, worldでいきなり出てくる謎のビックリマーク(マクロ呼び出し)
// RustのHello, world
fn main() {
println!("Hello, world");
}
これからRustを学び始めようと最初にprintln!("Hello, world")
を見ると、Rustでは関数呼び出しにビックリマークをつける必要があるのかと勘違いしそうになりますが、これはマクロ呼び出しの文法です。
printlnは関数ではなくマクロであり、マクロを呼び出す際に!をつけるというルールなのでprintln!と書くのです。
printlnはprintln!("({}, {})", foo, bar);
のように可変個の引数を取ることができるのですが、Rustでは可変個の引数を取る関数は定義できないので、printlnがマクロだと分かります。
他にもベクタを作るときにlet v = vec![1, 2, 3];
のような書き方も出てきますが、これも可変個の引数を取るマクロの例だといえます。
マクロは簡単に言うと、コンパイル時に記載された手続きに従ってソースコードを自動的に生成する仕組みのことで、メタプログラミングと呼ばれます。
たとえばlet v = vec![1, 2, 3];
のようにマクロ呼び出しをした場合、別の場所(vec!の場合はRustの標準ライブラリのソース)に記述されたvecマクロの定義にしたがって{ let mut v = Vec::new(); v.push(1); v.push(2); v.push(3); v }
のようなコード(実際はパフォーマンス最適化のためもっと複雑なコードになりますが)を生成します。
他にもRustでは可変個の引数を取る便利な処理がマクロで多く実装されており、初心者もいきなり多くのマクロを目にすることになるので戸惑うかもしれません。
4. 戻り値があるのにreturn文の無い関数
Rustでは戻り値があるのにreturn文の無い関数を見かけます。そしてよく見ると文末にセミコロンがありません。
fn foo() -> String {
String::from("foo!!") // <- 文末を示すセミコロンが無い
}
fn main() {
println!("{}", foo()); // foo!!
}
これは最後の式で評価された値が戻り値になるというRustの仕組みによるものです。
「最後の」式なので、戻り値にする場合は関数の末尾に書く必要があります。
ですので関数の末尾以外で値を返したい場合はreturn文が必要になります。(末尾でreturn文を使ってもOK)
さて、ここでRustにおいて式とは何かについても説明しておきます。
式(expression)は結果となる値を返すもので、文(statement)は値を返しません。
式の末尾にセミコロンを加えると文になるので、式にはセミコロンをつけないのです。
Rustには式となるものが多くあり、式の評価値は以下のように変数に代入することができます。
// ブロックも式なので、xに4が代入されます
let x = {
let a = 2;
a * a
}
// ifも全体として式になっているので、nまたは-nが戻り値として返されます
fn abs(n: i32) -> i32 {
if n >= 0 {
n
} else {
-n
}
}
// if式を三項演算子のように使うこともできます(Rustに専用の三項演算子はありません)
let n = if n >= 0 { n } else { -n };
// loop式は特殊で、breakに渡した数値を返します
let a = loop {
break 3;
};
// matchも式として値を返します。
let n = 1;
let x = match n {
0 => 1,
_ => 0,
};
このように式と文を区別し、様々なものが式となるRustは式指向言語とも呼ばれています。
5. おまじない扱いされるunwrap
標準入力を受け付けるコードを書こうとサンプルコードを探すとunwrapというメソッドに出くわすと思います。
let mut line = String::new();
std::io::stdin().read_line(&mut line).unwrap();
let n: i64 = line.trim().parse().unwrap();
これは標準入力で入力された文字列を数値として解釈するプログラムです。
ひとまずおまじないとしてunwrap関数を呼び出しておけばうまくいくのですが、なぜこれが必要なのでしょうか?
それはread_lineやparseが、本来エラーハンドリングを必要とする関数だからです。
read_lineでは標準入力の受け取りに失敗する可能性、そしてparseでは入力された値が数値に変換できない可能性があります。
そのためread_line関数もparse関数も、成功した場合はその値を、失敗した場合はエラー内容を返すのです。(具体的にはResult型というenumが返されます。)
したがってひとまず成功する場合だけを想定し、エラー処理は省略したいという場合にunwrap関数を使用することになります。
unwrap関数は正常な場合はその値を返し、エラーだった場合はプログラムを強制終了するという関数になっています。
Result型とunwrap関数の中身は(実質的には)以下のコードで表現できます。
pub enum Result<T, E> {
Ok(T),
Err(E),
}
impl<T, E: fmt::Debug> Result<T, E> {
pub fn unwrap(self) -> T {
match self {
Ok(t) => t,
Err(e) => panic!(※ここにeを含むメッセージ),
}
}
}
ちなみに以下のようなOption型にもunwrap関数があり、Noneが返されたらpanic、正常な値が返されたらその値を返すという、同じような動作をします。
pub enum Option<T> {
None,
Some(T),
}
もちろんプロダクションにリリースするコードで実行時エラーを無視できない場合は、unwrap関数は使わずにエラーハンドリングを実装する必要があります。
※エラーハンドリングの書き方については以下の記事が分かりやすいです。(ここでは触れてませんがエラーハンドリングで便利な?演算子についても説明されています。)
"Rust のエラーハンドリングはシンタックスシュガーが豊富で完全に初見殺しなので自信を持って使えるように整理してみたら完全に理解した"
しかしなぜRustはこのようなエラーハンドリングを要求するのでしょうか?
それはRustが例外もnullも持たないからです。
Javaを使ったことのある人であれば、null値からメソッド呼び出しを行って意図しないNullPointerExceptionが発生したり、例外のcatchが漏れてプログラムが落ちてしまったりした経験があると思います。
Rustはこのような不具合をコンパイル時に防止するため、Result型やOption型を用意してエラーハンドリングを強制しているのです。
そのためunwrapは本来のエラーハンドリングを無視してエラーが起きたらプログラムを強制終了するというリスクのある書き方なのですが、unwrapという関数呼び出しがあることでそのリスクを明示しているといえます。
そしてunwrapの意味を理解しunwrapを使わないエラーハンドリングを書くには以下に続く2項目についての理解が要求されるため、初心者に向けた情報ではunwrapはおまじないとして扱われることが多いです。
6. 複数の型を持つenum
Rustのenumは以下のようにそれぞれの列挙子が異なる型の値を持つことができます。
#[derive(Debug)] // printlnマクロでデバッグ出力できるようにするアトリビュート
enum Foo {
A(i32),
B(String),
C(Vec<i32>),
D { x: f64, y: f64 }
}
fn main() {
let a = Foo::A(10);
let b = Foo::B(String::from("foo"));
let c = Foo::C(vec![1, 2, 3]);
let d = Foo::D {x: 0.1, y: 0.2};
println!("{:?}", a); // A(10)
println!("{:?}", b); // B("foo")
println!("{:?}", c); // C([1, 2, 3])
println!("{:?}", d); // D { x: 0.1, y: 0.2 }
let v = vec![a, b, c, d]; // Vec<Foo>として格納できる
println!("{:?}", v); // [A(10), B("foo"), C([1, 2, 3]), D { x: 0.1, y: 0.2 }]
}
これは最初見たときにかなり驚くと思います。
しかも異なる型の値を持つ列挙子はまとめてベクタに格納することもできます。
ただし、enumの値はどの列挙子を保持しているかのtag領域に8バイト使用する上、データ領域は列挙子のうち最も大きい型に合わせられるため、メモリ消費は大きくなります。
ただ、異なる型の値を一緒に扱えるという利便性は大きいですね。
先ほどの項目で登場したResultやOptionもその最たる例だといえます。
7. 何が起きているのか分かりづらいif let
先ほど生成したenumの値を取り出したい時、どのようにすれば良いでしょうか?
以下のようにif letを使うと簡単ですが、初見では理解しづらいと思います。
let a = Foo::A(10);
let b = Foo::B(String::from("foo"));
let c = Foo::C(vec![1, 2, 3]);
let d = Foo::D {x: 0.1, y: 0.2};
if let Foo::A(n) = a {
println!("{}", n); // 10
}
if let Foo::B(s) = b {
println!("{}", s); // foo
}
if let Foo::C(v) = c {
println!("{}, {}, {}", v[0], v[1], v[2]); // 1, 2, 3
}
if let Foo::D {x, y} = d { // {x, y}は{x: x, y: y}の省略記法
println!("{}, {}", x, y); // 0.1, 0.2
}
if let Foo::A(n) = d { // dはFoo::DなのでFoo::Aにはマッチしない
println!("{}", n); // これはif文が成立せず、実行されない
}
一番上のif letは変数nを宣言してaの値で初期化しています。
なぜifがついているのかというと、aがFoo::A(n)というパターンに合致する場合のみ変数nが有効となるからです。
一番最後のif letはdがFoo::DなのでFoo::Aにはマッチせず、このif式は実行されません。
さて、そもそもlet Foo::A(n) = a
という記述が初見では変数nの初期化には見えないと思います。
これはRustのデストラクト(destructuring)という機能です。
実はRustでは以下のように変数を初期化(変数に値を束縛)することができます。
struct Buzz { t: f64, u: f64 }
struct Bar { x: f64, y: Buzz }
fn main() {
let [a, b, c] = [1, 2, 3]; // デストラクト(destructuring)
println!("{}, {}, {}", a, b, c); // 1, 2, 3
let (n, s) = (10, "foo"); // デストラクト(destructuring)
println!("{}, {}", n, s); // 10, foo
let bar = Bar { x: 0.1, y: Buzz {t: 0.2, u: 0.3} };
let Bar { x, y: Buzz { t, u } } = bar; // デストラクト(destructuring)
println!("{}, {}, {}", x, t, u); // 0.1, 0.2, 0.3
}
これは変数に値を束縛する時にパターンマッチを使用できることを意味します。
そしてRustではこのパターンマッチによる変数の束縛をmatchや各種ループ、関数引数など様々な箇所で利用できます。
パターンマッチについては"Rustのパターンマッチを完全に理解した"という記事が分かりやすいです。
最後に、if letはOption型やResult型を使用したエラーハンドリングで活用できます。
enumとif letを理解しないとunwrapを使わないパターンを書けないのは初心者にはしんどいですね。(unwrapがおまじない化してしまう原因です)
fn foo() {
let mut line = String::new();
let result = std::io::stdin().read_line(&mut line);
if let Err(e) = result {
println!("{:?}", e);
return;
}
if let Ok(n) = line.trim().parse::<i64>() {
println!("{}", n);
} else {
println!("Please input an integer")
}
}
8. 先頭にある不思議なシングルクォート(ライフタイム注釈記法)
Rustでは'a
や'static
のようにシングルクォートで始まる記法をよく見かけるのですが、普通シングルクォートは文字列を囲うように2つセットで使うので違和感を感じると思います。
// 2つの文字列のうち長い方を返す関数
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
} // The Rust Programming Languageより引用
しかしこのシングルクォートはライフタイム注釈といってRustでは重要な役割を果たしている文法なのです。
ライフタイムとは名前から想像される通り、変数の寿命(正確にはその参照が有効になるスコープのこと)を表します。
そしてRustでは寿命が尽きてしまった変数への参照をコンパイル時に検出するため、ライフタイムという概念が存在します。
コード例における'a
はジェネリックなライフタイム引数で、与えられた2つの引数x, yのうち短い方のライフタイムを表し、戻り値はその短い方のライフタイムを持つことを示します。
そのため以下のコードはエラーになります。(resultはstring2よりも長いライフタイムを持ってしまっているため)
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
// error[E0597]: `string2` does not live long enough
result = longest(string1.as_str(), string2.as_str()); // エラー!!
}
println!("The longest string is {}", result);
// The Rust Programming Languageより引用
C++では上記のようなコードを書いてもコンパイルエラーにはならず、人間が気づかない限り実行時まで問題がわかりません。
その上、正確には未定義動作なので必ずしも実行時に(segmentation faultで)落ちるとは限らず、何も起こらないかもしれないし、何が起こってもおかしくないという厄介な状況になります。
ライフタイム注釈は一見面倒に見えますし、実際複雑なプログラムを書くとライフタイム注釈周りでコンパイルエラーにぶつかりまくるのですが、それでもC++で発生する実行時の問題の凶悪さに比べれば多少面倒な書き方になってもコンパイルエラーで矯正してくれる方がマシという考え方です。
ライフタイム注釈についてはC++経験者ならそのありがたみが分かるのですが、そうでない人にとってはこのライフタイム注釈がRust学習上の最も大きなハードルになるといっても過言ではないと思います。
最後に'static
という最長のライフタイム注釈に触れておきます。
たとえばlet s = "foo";
としたときのs
の型は、ライフタイム注釈を省略すると&str
となるのですが、ライフタイムを明記すると&'static str
となります。
これは"foo"
という文字列リテラルで生成された変数s
が'static
という最長のライフタイムを持っているためプログラムの開始時から終了時までずっと生き続けることを意味します。
このように文字列リテラルが破棄されずにずっと生き続けることはC++と共通している点です。
9. いろいろな場所に登場するアンダースコア(アンダーバー)
Rustにおいてアンダースコアは何かしらのものを省略したり、無視したりする意味を持っており、様々な利用法があります。
以下のコードでその主要な例を列挙します。
// アンダースコアで始まる変数は使用しなくてもコンパイル時のwarningが出なくなる
let _a = "a";
// 型のワイルドカード
// let v = (1..4).collect(); // error[E0282]: type annotations needed
let v = (1..4).collect::<Vec<_>>(); // 1..4はi32なのでVecだけ指定したらOK
println!("{:?}", v);
// matchのワイルドカード
let a = 1;
match a {
0 => println!("zero!"),
_ => println!("non zero!") // _は0以外の全てのパターンを指す
}
let b = (1, 2);
match b {
(_, n) => println!("{}", n) // ワイルドカードは部分的に使うことも可能
}
let (_, n) = b; // (matchと同じくパターンマッチなので)デストラクトする際にも使用可能
println!("{}", n); // 2
// 数値の区切り文字
let n1 = 1_000_000; // _は単に見た目を分かりやすくするための区切りで無視される
let n2 = 0b1000_0010; // 0bは2進数の接頭辞で、4bit毎に区切って見やすくしている
println!("{}, {}", n1, n2); // 1000000, 130
// forループでmapの値のみを使う
use std::collections::HashMap;
let mut map: HashMap<String, String> = HashMap::new();
map.insert(String::from("key"), String::from("value"));
for (_, value) in map {
println!("{}", value);
}
// asで型推定に任せる
let f = 0.1; // f64
let mut n = 0; // i32
// n = f; // error[E0308]: mismatched types
n = f as _; // n = f as i32;と型推定してくれる
println!("{}", n); // 1
最後に、匿名ライフタイムというものがあります。
ライフタイムの記述を簡素化したり、参照の存在を明確化するなどの役割を持ちます。
struct Foo<'a> {
message: &'a str
}
// impl<'a> Foo<'a>の省略形。このimplブロック内で'aを一切使わない場合は楽
impl Foo<'_> {
fn foo(&self){
println!("{}", self.message);
}
// <'_>によって戻り値のFoo構造体が何らかの参照を持つことを明示している(Rust 2018より推奨の記法)
fn create(message: &str) -> Foo<'_> {
Foo { message }
}
}
匿名ライフタイムについてのより詳しい内容は以下の記事が参考になります。
"あの日見た匿名ライフタイム"
10. 混乱する自動参照と参照外し
Rustでは参照のライフタイムを意識しながらコードを書く必要があるので、参照であることを明記する文法が多いです。
たとえばC++では関数引数が参照である場合でも変数をそのまま渡せましたが、Rustでは以下のように&
をつけたり、参照を外すために*
をつけることが必要になったりします。
fn foo(n: &i32) {
if (*n == 1) { // nは参照なので*で参照外しをしてi32型の値を取得する
println!("one!");
} else {
println!("not one!");
}
}
fn main() {
let n = 1;
foo(&n); // 参照渡しをするために&をつける
foo(&2); // 同上
let mut v = vec![0, 1, 2];
for i in &mut v {
*i += 1; // 可変参照に対する加算代入には参照外しが必要
}
}
ただし、さすがにメソッド呼び出しにまでいちいち&
をつけるのは煩わしいため、自動参照という機能で暗黙的に参照として扱ってくれます。
struct Foo {}
impl Foo {
fn foo(&self) {
println!("foo!");
}
}
fn main() {
let f = Foo {};
(&f).foo(); // fooの引数であるselfが参照であることを明記する書き方
f.foo(); // 自動参照機能による省略記法(普通はこちらの書き方を採用)
}
逆に、値と参照の両方をサポートしている関数や演算子では暗黙的に参照が外されるように見えます。
fn plus_one(n: &i32) {
println!("{}", n + 1); // i32型の加算は参照外しの必要無し
}
fn main() {
let mut v1 = vec![1, 2, 3];
let mut v2 = vec![1, 2, 3];
let v1_ref = &v1;
let v2_ref = &v2;
// ベクタの等価比較演算子では勝手に参照を外して中の値を比べてくれる
let result = if v1_ref == v2_ref { "equal" } else { "not equal" };
println!("{}", result); // equal
// ただし片方だけ参照の場合は参照外しが必要
let result = if *v1_ref == v2 { "equal" } else { "not equal" };
println!("{}", result); // equal
}
ここら辺は分かりやすさと利便性のトレードオフがあって最初は混乱しますね。
他にも参照外し型強制という機能があったりしますが、初心者の枠をはみ出ているので省略します。
おまけ(Rustには無い機能)
Rustには以下の機能が無いことにも初心者は驚くことが多いと思います。
- コンストラクタが無い
- 関数オーバーロードが無い
- 関数のデフォルト引数が無い
- 可変長引数関数の機能が無い
- 例外(Exception)機構が無い
- structの継承が無い
ただ、これらはRust開発者が意図的に省いているものがほとんどです。
特に例外(Exception)を省いていることはその最たる例です。(unwrapの項目で書いた通り実行時安全性のため)
また、関数オーバーロードについては以前に書いた記事で詳しく説明しています。
"Rustで関数オーバーロードは頑張れるのか"
structの継承が無いことへの対処法についても別記事を書きました。
"Rustで継承を使いたい人への処方箋"
感想
Rustで初心者殺しだと感じた箇所は調べると本当に奥が深く、学習ハードルの高さをひしひしと感じます。
ただ、それだけ実行時の安全性はC++と比べて飛躍的に高まっており、そして実行時パフォーマンスは(C/C++を除く)他言語の追随を許さないため、とにかくコンパイルが通るコードを書ききりさえすれば、他の言語では得られない品質を体験できるのかなと思います。
とにかくメモリ安全性やゼロコスト抽象化などの実利的な機能を追求しつつ、できるだけそれら高度な機能を簡潔に書けるように工夫された結果、独特な記法がたくさん生じているように感じます。
そしてそのように実利的なメリットを提供している点でRustに魅力を感じる人は多いのではないかなと思います。