ここ数年の間,瞬く間に多くのプログラミング言語が登場し,話題を呼んでいます. その中でも特に人々の目を引いたものに, Rust があります. この記事では,そんな Rust を少しだけ紹介します. ここで紹介する Rust の機能は ほんの一部 であり,まだまだ優れた機能を数多く持っています!
今までのプログラミング言語との決定的な違い
多くの人々の目を引くほど話題になるからには,なにか決定的な違いがあるはずです. その一つが, 革新的で安全なメモリ管理システム です.
プログラミング言語とプログラマは,長い間メモリ管理に悩まされてきました.
例えば C では, malloc
関数や calloc
関数を用いることで, OS の許す範囲で自由にメモリ空間を確保できます.
こうして確保された空間は非常に柔軟ですが, メモリリークやバグの温床 となります.
コンパイラはその空間を使う時点でアクセスが有効かどうかをチェックしないからです.
プログラムは,プログラマの記述通りに動作します. 自分で考えることも,動作を変えることもしません. プログラマがミスをすれば,それが確実にプログラムへ反映されます. 先述したようなメモリリークやバグも,もちろんこれに含まれるでしょう.
Rust では,このような問題を防ぐための様々な言語機能が提供されています. それらを使うことで,柔軟な変数や参照を,コンパイラによるチェックを受けたまま安全に保持できるのです.
普段高レイヤを扱う人々にとって,ここまで述べてきたような話題はあまり興味がないかもしれません, 私もその一人でした. しかし, Rust は低レイヤに限られない言語です. すでに, Web をはじめとした様々な実行環境用のフレームワークがリリースされています. Rust によるメモリ管理は高レイヤでも力を発揮します. 結果として, 高機能で速く,安全なプログラム を作り上げられるのです!
環境構築
では,さっそく Rust を書くための環境を整えていきましょう.
Rust は,環境の構築も非常に簡単です.
まずは, Rust のツールチェイン (コンパイラや標準ライブラリなどのセット) を管理するためのツールである rustup
をインストールします.
macOS や Linux などの Unix オペレーティングシステムでは以下のコマンドでインストールできます:
curl https://sh.rustup.rs -sSf | sh -s
Windows では以下を使います:
$uri = "https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe"
$destination = "$env:TEMP\rustup-init.exe"
$client = New-Object System.Net.WebClient
$client.DownloadFile($uri, $destination)
. $destination
あとはインストーラ CLI の指示に従うだけです. 詳細なカスタマイズもできますが,とりあえず今はデフォルトでいいでしょう. インストール先に拘りのある方は,適宜調整してください.
rustc --version
コマンドが正常に実行できれば,インストールは成功です!
$ rustc --version
rustc 1.46.0-nightly (8aa18cbdc 2020-07-08)
実際にプログラムを記述するためのワークスペースも作成しておきましょう. 以下のコマンドを実行するだけです:
$ cargo new <ワークスペース名>
現在のディレクトリに指定した名前のフォルダが作成され,そこがワークスペースとなります.
$ cd <ワークスペース名>
$ tree .
.
├── Cargo.toml
└── src
└── main.rs
最初の一歩: 所有権
一般的な言語チュートリアルでは,ここで Hello, world!
を標準出力に書き込むことでしょう.
その言語の標準ライブラリに定義された関数を呼べばその文字列が出力されるということは誰でもわかるので,ここでは Rust の特徴的な一面から始めます.
Rust では,メモリ空間が必要でなくなった瞬間に開放されます. いつ開放されるかというタイミングが重要です. 以下のコードを考えてみます:
fn main() {
{
let s = String::from("Hello");
println!("{}", s)
}
println!("{}", s)
}
お察しの通り,このコードは動作しません. もっと言うと,コンパイルエラーを起こすため実行すらできません. Rust では, スコープを抜けた瞬間 ,そのスコープで定義された変数により束縛されたメモリ空間が開放されます. ここまでは,他の言語と大差はありません.
次に,以下のコードを見てみましょう:
fn main() {
let s = String::from("Hello");
foo(s);
println!("main says: {}", s)
}
fn foo(bar: String) {
println!("foo says: {}", bar)
}
実際にコンパイルしてみると分かりますが,このコードも,実は動作しません.
ここで,他のプログラミング言語との違いが見えてきます.
s
という変数は, main
関数のスコープで定義されたため,この関数が終了するまでは有効なように思えます.
しかし, main
関数最後の println!
は動作しません.
cargo run
や cargo check
コマンドを実行すると,以下のようなエラーが発生します:
error[E0382]: borrow of moved value: `s`
--> src\main.rs:5:31
|
2 | let s = String::from("Hello");
| - move occurs because `s` has type `std::string::String`, which does not implement the `Copy` trait
3 |
4 | foo(s);
| - value moved here
5 | println!("main says: {}", s)
| ^ value borrowed here after move
foo
関数を呼び出し, s
変数を渡すと,この変数は main
関数のスコープから foo
関数のスコープへと所有権が移動します.
Rust では,一度他のスコープへ所有権を渡した変数は,もう自身の所有権がないのでアクセスできません.
また,エラーメッセージにもありますが, Copy
トレイトを実装した型 (数値型など) ではこの問題が発生しません.
所有権を渡す前に変数に格納された値をコピーするからです.
では,同じ変数を複数回使いたいときはどのようにするのでしょうか. それを解決するための言語機能が存在します. 借用 (borrowing) です. 借用を行うことで,所有権を完全に渡さずに変数 (に束縛されたメモリ空間) へのアクセスを認可できます.
先ほどの例で,借用を使ってみます:
fn main() {
let s = String::from("Hello");
foo(&s);
println!("main says: {}", s)
}
fn foo(bar: &String) {
println!("foo says: {}", bar)
}
このコードは無事に実行できます.
このように, Rust では,所有権という概念を導入することで,メモリ安全性を確保しています. 所有権を持つ所有者は必ず 1 つに保たれるため,メモリの確保から開放までのプロセスがコンパイル時点で明示化されるのです. そして,スコープから外れたタイミングで破棄されることにより無駄なメモリ空間を残すことなくリソースを使用できます.
もう少し先へ: エラー処理と Result<T, E> 型
ここでは少しメモリ管理から離れます. プログラマを悩ますもう一つの要素に,エラー処理が挙げられます. 言語によっては 例外 という呼称を使います. エラーは,プログラムを記述する段階で意図しない動作を起こしたときに発生するものです. プログラマは,エラーが発生したときに場合に応じて復帰したり,エラーメッセージを出して異常終了したりします.
このようなエラー処理をきちんと行っていない場合,プログラムは突然終了します.
Java のような VM 言語でない限り,エラーメッセージも出さないことが多いでしょう.
特に,メモリ関連の深刻なエラーでは SIGSEGV
(Segmentation Fault) シグナルとともに終了します.
しかし,これではあまりにも不親切ですし,不具合の原因特定にも寄与しません.
プログラマは,できるだけエラーが発生しないよう,また外部要因によって仕方なく発生したエラーを適切に処理する必要があります.
Rust では,こういったエラー処理も非常に安全かつ柔軟に行うことができます.
その際に用いられるのが, Result<T, E>
型と Option<T>
型です.
以下の例を見てみましょう:
use std::fs::File;
use std::io::Read;
fn main() {
let file = File::open("foobar.txt"); // 変数はデフォルトで変更不可
let mut bytes: Vec<u8> = Vec::new(); // 変更されるものは mut が必要
file.unwrap().read_to_end(&mut bytes);
}
現在のディレクトリに foobar.txt
が 存在しない ことを確認してから実行してください.
プログラムは以下のようなエラーで異常終了します:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "The system cannot find the file specified." }', src\main.rs:8:10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: process didn't exit successfully: `target\debug\RustSandbox.exe` (exit code: 101)
これを パニックする といいます. 元のルーチンに復帰する手段が定義されていないために,終了するしかなくなるのです.
実は, File::open
メソッドは File
型ではなく, Result<File, Error>
型を返します.
その代わり,多くの言語にある例外という概念は存在しません.
この型は,処理が成功したか失敗したかという情報と,結果またはエラーを持ちます.
read_to_end
メソッドは結果側の File
に存在するので,これを取り出す必要があります.
unwrap
メソッドを使えば強制的に取り出すことができますが,エラーの場合はそこでパニックします.
したがって,上記のコードは動作するものの, Rust ではあまり推奨されません.
以下は親切なエラーメッセージを出力する例です:
use std::error::Error;
use std::fs::File;
use std::io::Read;
use std::process::exit;
fn main() {
let result = File::open("foobar.txt");
let mut bytes: Vec<u8> = Vec::new();
match result {
Ok(mut file) => {
println!("Read {} bytes", file.read_to_end(&mut bytes).unwrap());
},
Err(error) => {
println!("Failed to open foobar.txt: {}", error);
exit(1)
},
}
}
実行すると以下のようなエラーが発生して終了します:
Failed to open foobar.txt: The system cannot find the file specified. (os error 2)
error: process didn't exit successfully: `target\debug\RustSandbox.exe` (exit code: 1)
親切なエラーメッセージを出力することができました. ユーザ側から見ても分かりやすく,プログラマ側から見てもデバッグしやすいメッセージが一番良いでしょう.
でも実は,このコードにはまだ危険な箇所が潜んでいます.
read_to_end
メソッドは,実は正常終了する場合としない場合があります.
もしこれがエラーを起こした場合, unwrap
メソッドを呼ぶとパニックしてしまいます.
ではこれを処理するようにしてみましょう:
use std::error::Error;
use std::fs::File;
use std::io::Read;
use std::process::exit;
fn main() {
let result = File::open("foobar.txt");
let mut bytes: Vec<u8> = Vec::new();
match result {
Ok(mut file) => {
let result = file.read_to_end(&mut bytes);
match result {
Ok(length) => println!("Read {} bytes", length),
Err(error) => {
println!("Failed to read from foobar.txt: {}", error);
exit(1)
}
}
},
Err(error) => {
println!("Failed to open foobar.txt: {}", error);
exit(1)
},
}
}
なんと!ネストがとても深くなってしまいました.
いくら安全といえど,ネストの深いコードは良いコードではありません.
Rust には,こういったことを避けるために, Result<T, E>
型のための便利な実装がたくさんあります.
簡潔にしてみましょう:
use std::fs::File;
use std::io::Read;
use std::process::exit;
fn main() {
let mut bytes: Vec<u8> = Vec::new();
let result = File::open("foobar.txt").and_then(|mut f| f.read_to_end(&mut bytes));
match result {
Ok(length) => println!("Read {} bytes", length),
Err(error) => {
println!("Failed to read from foobar.txt: {}", error);
exit(1)
}
}
}
and_then
メソッドは,その Result<T, E>
が成功 (Ok
) だった場合に与えられた関数を呼び出し,その関数による新しい Result<T, E>
を返します.
失敗 (Err
) だった場合はそこでその Result<T, E>
を返し,与えられた関数は呼び出されません.
プログラム全体で Result<T, E>
型を使うようにしておけば,エラーメッセージを表示する処理は main
関数に書くだけで済みます.
エラーの種類ごとにメッセージを変えたければ,プロジェクトの名前空間に独自の Error
型を定義して, Result<T, Error>
として使えばよいでしょう.
このように, Rust では,エラー処理でさえも安全に行えるようになっています. もう Segmentation Fault の文字列を見てエラー原因箇所を必死に探すことは (Rust の不具合がない限り) する必要がありません.
深渕へ: ライフタイム
最後に,私が Rust で最も難しいと感じた ライフタイム という概念を紹介します.
序文で述べたように, C などでは malloc
や static
といった機能で静的なメモリ空間を確保してきました.
しかし, Rust では手動で静的なメモリ空間を自由に確保することができません.
すべての変数は所有権を持ち,スコープから抜けた瞬間に開放されます .
ライフタイムを使えば,変数が存在するスコープを明示することができます. つまり,より長い時間参照を確保しておけるのです. その時間の長さを指定するのが ライフタイム指定子 です.
以下の例を考えてみましょう:
struct Foo {
bar: &String,
}
fn main() {
let s = String::from("Hello");
let f = Foo { bar: &s };
println!("{}", f.bar)
}
このコードは動作しません. 以下のようなコンパイルエラーが発生します:
error[E0106]: missing lifetime specifier
--> src\main.rs:2:10
|
2 | bar: &String,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 | struct Foo<'a> {
2 | bar: &'a String,
|
構造体に参照を保持するには,その構造体が有効な間, 参照先の値がメモリ空間上に存在している必要 があります.
構造体が有効なまま s
変数のメモリ空間が開放されてしまうと, f.bar
にアクセスできなくなってしまいます.
これではメモリ管理上の安全性を確保できません.
また,ここまでエラーメッセージを見てきてお気づきの方もいるかと思いますが, Rust のコンパイラは非常に丁寧なメッセージを出力します. 上記の例では解決方法も教えてくれるのです.
さて,ライフタイム指定子を書いた例がこちらです:
struct Foo<'a> {
bar: &'a String,
}
fn main() {
let s = String::from("Hello");
let f = Foo { bar: &s };
println!("{}", f.bar)
}
このコードは正常に動作し,標準出力に Hello
を書き込みます.
ライフタイム指定子 'a
によって, Foo
構造体 (のインスタンス) と bar
メンバの参照は同じライフタイムを持ちます.
言い換えると, Foo
構造体が有効な間, bar
メンバの参照も有効であることが保証されるようになります.
ライフタイムが正常に機能することを確かめるために,あえて bar
メンバの参照,つまり s
を無効化してみましょう:
struct Foo<'a> {
bar: &'a String,
}
fn main() {
let f: Foo;
{
let s = String::from("Hello");
f = Foo { bar: &s };
}
println!("{}", f.bar)
}
このコードは動作しません. 以下のようなコンパイルエラーが発生します:
error[E0597]: `s` does not live long enough
--> src\main.rs:10:24
|
10 | f = Foo { bar: &s };
| ^^ borrowed value does not live long enough
11 | }
| - `s` dropped here while still borrowed
12 |
13 | println!("{}", f.bar)
| ----- borrow later used here
スコープを分けることで,中のブロックから抜けたときに s
変数は破棄 (メモリ空間が開放) されます.
そして, f
に保持した s
への参照は有効でなくなります.
このように, Rust では,ライフタイムを導入することによって決まった時間だけメモリ空間を確保した上で,その間は無効な参照が生まれないことを保証できるのです.
まとめ
今回の記事では,あえて簡単な言語事項の説明を省いた上で, Rust の特徴的な機能をいくつか紹介しました.
これを読んで, Rust が難しいと感じた方がいるかもしれません. 確かに,他の言語と違う革新的な要素が多いため,困惑することもあるでしょう. しかし,なぜその機能が存在するのかや,どのように安全性が担保されているかを理解すれば,決して難しい機能たちではないと思います.
速さの代償に難解な言語を書くわけではありません. 安全性の代償に長いエラー処理を書くわけでもありません. Rust は,速くて安全なロジックをいかにプログラマフレンドリな形で記述できるかを目指した言語だと私は考えています.
私自身も, Rust を書き始めてまだ少ししか経っていません. Rust にはまだまだ魅力的な言語機能やベストプラクティスがあると思っています. そして,これからも進化し,使われていく言語だと感じています!