【MacでRust入門】Rust日本語版チュートリアルの要点まとめ ~ 第9回 所有権 Ownership
目次
こんにちは。
最近流行りのRustを初めてみよう!ということでチュートリアルを読み始めました。
内容を忘れないように & チュートリアルがTL;DRな方向けにブログ記事に要点をまとめていこうと思います。
前回はRustの制御フローであるif式、ループについてまとめました。
今回は第9回です。Rustで最もユニークな機能である「所有権」について要点をまとめます。
Rustの所有権とは
簡単に言うと、メモリの安全性担保を自動で行う機能です。
他の言語では、プログラマが明示的にメモリ確保したり解放したりしますが、Rustでは、そのためのコードを書かなくとも、コンパイラがコンパイル時にチェックする一定の規則とともに所有権システムを通じてメモリ上のデータが管理されます。
所有権について理解することで、より安全で効率的なコードを書けるようになります。
スタックとヒープ
所有権について学ぶ前提知識として、メモリ上にデータを置く一般的な方法である、スタックとヒープについて簡単にまとめます。
どちらも実行時にコードが使用できるメモリの一部を指しますが、データの置き方・取り出し方に以下のような違いがあります。
スタックの特徴
- 得た順番に値を並べ(push)、逆順で値を取り除く(pop)
- 同じ大きさの皿を積み上げるイメージ
- サイズ固定でアクセス先は常に一番上とわかっているため、高速
ヒープの特徴
- データを置くときに十分なサイズの空きスペースを探してポインタ(アドレス)を返す 領域確保(allocate)
- スペース確保はレストランの空き席を探すイメージ
- データを取り出すのは1テーブルずつ注文をとる&届けに行くイメージ
- サイズの大きな領域を確保したり、隔離されているデータをポインタを追って探していくため、低速
所有権の目的
ヒープデータを管理することが所有権の目的です。
Rustは所有権によって以下のようなヒープデータの問題を解決できます。
- どのコードがどのヒープデータを使用しているか把握する
- ヒープ上で重複するデータを最小化する
- メモリ不足にならないよう、ヒープ上の未使用データをクリアする
所有権のルール
Rustの所有権に関して以下の3つのルールがあります。
- Rustの各値は所有者と呼ばれる変数と対応する
- 所有者は常に1つ
- 所有者がスコープから外れたら値は破棄される
スコープとは要素が有効になるプログラム内の範囲のことです。基本的に{}
で括られた内側の範囲のことと考えてOKだと思います。
わかりやすくするため、3つのルールを少し言い換えてみます。
- 変数を値とともに宣言(
let
)したとき、その変数は「所有者」となり、値の「所有権」を持ちます。 - 値の所有権は1つの変数にしか所属できません。所有者である変数を他の変数に代入すると、所有権は新しい方の変数に移り、その変数が新たな所有者となります。
- 所有者である変数が
}
に到達したとき、所有していた値はメモリから破棄されます。
まだよくわからないと思いますので、次の章からこれらのルールに基づく挙動を具体的に見ていきます。
Rustのメモリの確保と返還
ここではString型という文字列型を例に所有権によるメモリの確保と返還について見ていきます。
メモリの確保
第6回でまとめたデータ型はどれもスタックに保管されますが、String型という文字列型はヒープに保管されます。
String型がヒープに保管されることによって、サイズが可変なので、以下のように変数宣言後に長さを伸ばすことが可能になります。
let mut s = String::from("hello"); // ::でString型直下のfrom関数を使用する
s.push_str(", world!"); // リテラルをStringに付け加える
println!("{}", s); // hello, world!と出力される
文字列リテラルはバイナリファイルにハードコードされますが、String型は可変で伸長可能な文字に対応するため、コンパイル時にサイズ不明のヒープを確保します。
- メモリは実行時にOSに要求される
- String型を使用終了したら、OSにメモリ返還する方法が必要
メモリの解放
通常はallocate(メモリ確保)とfree(メモリ解放)が1:1対応するはずですが、Rustの場合、メモリ解放の手続きは必要なく、スコープを抜けたら自動でメモリ返還する関数(drop
関数)を呼びます。
自動でやってくれるなら簡単そう、と思いがちですが、これによって複数のコードが使う変数の挙動が予期せぬものになる可能性もありますので、注意が必要です。
Rustの所有権の移動 ムーブ
所有権の移動のことをムーブと言います。引き続きString型で例を見ていきます。
let s1 = String::from("hello");
let s2 = s1;
String型はメモリのポインタ、長さ(byte)、許容量(capacity, OSから受け取った全メモリbyte)といった3つの要素からできています。
上のコード例のようにs1
をs2
にコピーするということは、この3つの要素(スタックにあるポインタ、長さ、許容量)がコピーされます。
このとき、ヒープデータ(hello
という5文字のデータ)はコピーされず、ポインタと長さでs1
と同じhello
を指します。
普通、スコープを抜けてメモリ解放のとき、s1
とs2
が同じメモリを開放しようとすると二重解放エラーという安全性上のバグが起きます。
しかし、Rustではこのような二重解放が起きないように、s2 = s1
とした時点で変数のムーブ(所有権の移動)が起きて、s1
が無効な変数になります。
これにより、同じポインタを指している有効な変数はs2
だけになるので、メモリ解放は一度しか起きず、安全性が保証されることになります。
クローン
スタック上のデータだけでなく、String型のヒープデータのコピーが必要な場合はclone
メソッドが使えます。
let s1 = String::from("hello");
let s2 = s1.clone();
pandasのcopy
みたいなやつですね。
スタックのみのデータはcloneしなくてもcopyされる
一方、整数型のようなスタックのみにデータを保持する型では以下のコードがエラーになりません。
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
ヒープに値を保持するString型ではy
にムーブしてx
が無効な変数になりましたが、スタックのみで完結する(コンパイル時に既知のサイズを持つ)整数型では、clone
しなくてもx
は有効なままです。
整数型の他にも、論理値型、浮動小数点型、文字型char
、そしてこれらの型のみを含むタプルはCopyされます。
(チュートリアルでは、Copyトレイトに適合している型」や「Copyの型」という表現がされています。トレイトの詳細は今後の記事で扱う予定です。)
Rustの所有権と関数
関数に値を渡すときも、変数に値を代入するのと同様に、ムーブやコピーが発生します。
fn main() {
let s = String::from("hello"); // sがスコープに入る
takes_ownership(s); // sは関数にムーブして無効になる
// ここでsを呼ぶと、すでに無効なのでコンパイルエラー
let x = 5; // xがスコープに入る
makes_copy(x); // xは整数型でCopyの型なので有効のまま
// ここでsを呼んでも、エラーにならない
} // x,sがスコープを抜ける。sはすでに無効なので何も起こらない
fn takes_ownership(st: String) { // stがスコープに入る。
println!("{}", st);
} // ここでstがスコープを抜け、dropが呼ばれメモリが解放される。
fn makes_copy(n: i32) { // nがスコープに入る
println!("{}", n);
} // ここでnがスコープを抜ける。何も特別なことはない。
上で触れたように、「所有者がスコープから外れたら値は破棄される」という所有者のルールに従っていることがわかります。
関数の戻り値
値を返すことでも所有権が移動します。
fn main() {
let s1 = gives_ownership(); // 戻り値をs1にムーブする
let s2 = String::from("hello"); // s2がスコープに入る
let s3 = takes_and_gives_back(s2); // s2は関数に、戻り値はs3にムーブする
} // ここでスコープを抜け、s1とs3はドロップ。s2はムーブされているので何も起きない
fn gives_ownership() -> String { // 戻り値を呼び出した関数にムーブする
let st = String::from("hello"); // stがスコープに入る
st // stが返され、呼び出し元関数にムーブする
}
fn takes_and_gives_back(a_st: String) -> String { // a_stがスコープに入る。
a_st // a_stが返され、呼び出し元関数にムーブされる
}
このように、すべての変数の所有権が辿るパターンは、以下のようになっています。
- 別の変数に値を代入するとムーブする
- ヒープにデータを含む変数がスコープを抜けるとムーブされていない限りdropされる
しかし、すべての関数で毎回所有権を移したり戻したりしていたら面倒なので、「値は使わせるが所有権は移動したくない」や「変数を再利用したい」と思うようになります。そこで参照という機能が役立ちます。
Rustの参照と借用
変数名の前に&
をつけることで、所有権を移動せずに値を参照できます。
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // この & が参照を表す
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
} // sがスコープを抜けるが、参照なので(所有権を持っていないので)何も起きない
変数を所有していないので、参照がスコープを抜けてもdropされません。
また、関数の引数に参照をとることを借用と言います。借用した値は変更することはできません。(変数がデフォルトでimmutableなのと同じです。)
可変な参照
しかし、借用した値を可変にすることもできます。方法は変数の宣言と同じで、変数名の前にmut
をつけるだけです。
fn main() {
let mut s = String::from("hello");
change(&mut s); // ここでmutをつける
}
fn change(some_string: &mut String) { // ここでmutをつける
some_string.push_str(", world");
}
可変な参照は1スコープ内・1データに対して1つしか参照できません。この制約により、Rustはコンパイル時にデータ競合するエラーを防ぐことができます。データ競合は複数のポインタが同じデータに同時アクセスし、いずれかのポインタが書き込み中で、アクセスの同期機構が使用されていないときに発生します。
以下の例ではスコープを抜けているのでエラーになりません。
{
let r1 = &mut s;
}
let r2 = &mut s;
また、(不変な)参照中は、以下のように不変な参照は何度でも可能ですが、可変な参照はできません。
let mut s = String::from("hello");
let r1 = &s; // OK
let r2 = &s; // OK
let r3 = &mut s; // エラー
ダングリングポインタ
参照に関して、以下のような戻り値のコードを書くと、コンパイルエラーになります。
fn dangle() -> &String {
let s = String::from("hello");
&s
} // ここでsはスコープを抜けてdropされる(メモリから消される)
エラー出力
error[E0106]: missing lifetime specifier
...
this function's return type contains a borrowed value, but there is no
value for it to be borrowed from
エラーの内容は、ライフタイムどうこうは一旦置いておいて(今後の記事で触れます)、戻り値が借用した値なのにその値が存在しないことを示唆しています。
このコードの問題点は、「スコープを抜けるとメモリ解放されてしまう値への参照を、戻り値で返そうとしている」ことです。&s
ではなくs
を戻せばエラーになりません。
このようなメモリ解放してしまった(他人に渡されてしまった可能性のある)ポインタのことはタングリングポインタと呼ばれ、Rustのコンパイラはこれを防ぐようにエラーで阻止してくれます。
なので、Rustの参照先は常に有効である必要があります。
スライス型
所有権のないデータ型に、スライス型があります。
文字列スライスはString型の一部に参照します。
let s = String::from("hello world");
let hello = &s[0..5]; // これが文字列スライス
let world = &s[6..11]; // 同上
スライスは、[開始..終了]
という書き方(範囲記法)によって、開始の位置から終了よりも1小さい位置までを参照します。データ構造としては、開始位置の参照とスライスの長さを保持しています。
Rustの範囲記法については以下の特徴があります。
- 開始(
..
の前)に何も書かなければ、最初の番号から始まる - 終了(
..
の後)に何も書かなければ、最後の番号まで含める [..]
(開始と終了を省略)の場合、全体のスライスを得る
文字列スライスの型 &str とその使用例
文字列スライスを意味する型は&str
です。
例えば「ある文章中の最初の単語(スペースが入るまでの文字列)」を返す関数first_word
は以下のように書けます。
fn main() {
let s = String::from("hello world");
let word = first_word(&s[..]);
println!("first word is {}",word);
}
fn first_word(s:&str)->&str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
コードの解説は以下の通りです。
- 引数は
&String
ではなく&str
型にしておきスライスを渡せるようにすると汎用性が高まる as_bytes
メソッドはStringオブジェクトをバイト配列に変換- 配列の
iter
メソッドでイテレータを生成 - イテレータの
enumerate
メソッドで、配列の(添字,要素への参照)
というタプルを返す - if式でバイトリテラル表記の空白
b' '
を検索し、空白バイトの位置までの文字列スライスを返す
Rustの文字列リテラルはスライスの一種
文字列はバイナリに埋め込まれますが、これはバイナリの特定の位置を指すスライスとなっています。なので、これも型は&str
です。
let s = "Hello World!" // これの型は&strで、不変な参照。
文字列以外のスライス
文字列以外の配列でもスライス型をとることができます。
let a = [1, 2, 4, 5, 6]
let slice = &a[1..3]
この場合、スライスの型は&[i32]
で、整数配列の要素への参照を表します。
このように様々なデータ型の配列でスライス型をとることができます。
文字列スライスと同様に、データ構造は最初の要素の参照と長さを保持します。
まとめ
というわけで、今回はRust独特なメモリ管理機能である「所有権」と、所有権に関連する参照とスライスについてまとめました。
- Rustの変数は所有権によってヒープデータの管理を行い、メモリの安全性を保っている
- 所有権には3つのルールが存在する
- 値は所有者と1:1対応
- 所有者は常に1つ (代入や引数を渡すことで所有権は移動する)
- 所有者がスコープから抜けると値は破棄
- スコープを抜けると自動的にデータをdrop(メモリから片付け)させるため、メモリ解放のためのコードが必要ない
- 所有権を移動せずに値を読むには参照を使う
- 参照は1スコープ内・1データに対し、
&mut
による可変参照1回か、&
による不変参照を複数回、のどちらかを行える - 参照先は常に有効である必要がある(タングリングポインタはコンパイルエラー)
- スライスは所有権のないデータ型で、所有権を渡さずに値の一部を参照することができる
かなり細かくてボリュームのある内容でしたが、コンパイルエラーを正しく理解して安全なプログラムを書く上でとても重要な内容でした。
次回はRustの「構造体」についてまとめます。
それでは〜
Rust オススメの参考書籍
▼ 入門者向け。Pythonとの比較あり。Kindleでも読める。
手を動かして考えればよくわかる 高効率言語 Rust 書きかた・作りかた
▼ 中級者向け。オライリーならではの詳解。硬派な方向け。
参考リンク
Rustチュートリアル (日本語版 非公式) https://doc.rust-jp.rs/book-ja/