[Rust 入門] #03 所有権 — ムーブ、Copy、clone を実際に動かして確かめる
はじめに
第2回の最後に、こう書きました。
let s = String::from("hello");のあと、sを、別の変数に渡すと、何が起きるのか
今回は、その答えにあたる、所有権です。Rust が、GC なしでメモリの安全さを守る仕組みで、Java には無い考え方です。
所有権とは
所有権には、3つの決まりがあります。
- 値には、持ち主(所有者)が、1つだけ決まっている
- 持ち主は、同時に、1つだけ
- 持ち主がスコープ(
{ }の範囲)を出ると、値は捨てられる(drop)
Java との違いを並べると、こうなります。
| 観点 | Java | Rust |
|---|---|---|
| 値を片付けるのは | GC | スコープの終わりで、自動で |
| いつ片付くか | GC が決める | コードの書き方で、決まっている |
b = a と書くと | 同じ物を、2つの変数が指す | 値が b に移り、a は使えなくなる |
| 同じ物を、2つの名前から書き換えること | できる | ふつうの書き方では、できない(コンパイルエラー) |
表の下の2行を、このあと、1つずつ確かめます。
スコープと drop
「スコープを出ると、値が捨てられる」ことを、目で見られるようにします。捨てられるときに、文字を出す入れ物を作ります。
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!("drop: {}", self.0);
}
}
fn main() {
let _a = Noisy("a");
{
let _b = Noisy("b");
println!("内側のブロックの終わり");
}
let _c = Noisy("c");
println!("main の終わり");
}
struct は第5回、impl Drop for は第7回で扱います。ここでは、「捨てられるときに drop: と出す入れ物」とだけ読んでください。
内側のブロックの終わり
drop: b
main の終わり
drop: c
drop: a
| 出力 | 起きていること |
|---|---|
drop: b | 内側の { } を出た時点で、_b が捨てられます |
drop: c → drop: a | main の終わりで、作った順の逆に、捨てられます |
捨てる処理を、free のように書く必要は、ありません。スコープの終わりで、コンパイラが差し込みます。
文字列の型
第2回で、&str と String の違いを、「第3回で扱う」としていました。ここで回収します。
let lit: &str = "hello";
let mut s: String = String::from("hello");
s.push_str(", world");
println!("{lit} / {s}");
println!(
"&str={} String={} Vec<i32>={}",
std::mem::size_of::<&str>(),
std::mem::size_of::<String>(),
std::mem::size_of::<Vec<i32>>()
);
hello / hello, world
&str=16 String=24 Vec<i32>=24
| 型 | 内容 | 変更 |
|---|---|---|
&str | 文字列の一部を、見ているだけ。"hello" のような文字列リテラルは、この型です | できない |
String | 文字列を、自分で持っている。長さを変えられます | mut なら、できる |
大きさは、&str が 16 バイト、String が 24 バイトです。どちらも、文字列の中身そのものではなく、中身を指す情報の大きさです。String の 24 は、8 バイト × 3 で、位置・長さ・容量の3つ分、と説明されます。Vec<i32> も 24 バイトで、同じ作りです。
この「指す情報だけが動く」ことが、次のムーブの話につながります。
String を変更するには、mut が要ります。mut を付け忘れると、こうなります。
let s = String::from("hello");
s.push_str(", world");
error[E0596]: cannot borrow `s` as mutable, as it is not declared as mutable
--> mutarg_err.rs:3:5
|
3 | s.push_str(", world");
| ^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
2 | let mut s = String::from("hello");
| +++
第2回の let mut と、同じ話です。
ムーブ
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, {s2}");
}
error[E0382]: borrow of moved value: `s1`
--> mv_err.rs:4:16
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 | println!("{s1}, {s2}");
| ^^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
読むところは、次のとおりです。
value moved here: 3行目で、値がs2に移りました(ムーブ)value borrowed here after move: 移ったあとのs1を、4行目で使おうとしていますhelp: 複製したいなら、.clone()がある、と教えています
let s2 = s1; は、s1 の持ち主の立場を、s2 に譲る操作です。 譲ったあとの s1 は、使えません。
なぜ使えなくするのか
s1 と s2 が、どちらも持ち主のままだと、スコープの終わりで、同じ値を2回捨てることになります。2回目は、すでに無い物を捨てるので、危険です。持ち主を1人にする決まりは、これを防ぎます。
中身はコピーされない
ムーブのとき、文字列の中身は、コピーされているでしょうか。メモリ上の位置を比べて、確かめました。
let s1 = String::from("hello");
let s2 = s1.clone();
println!("clone: 同じ場所? {}", s1.as_ptr() == s2.as_ptr());
let s3 = String::from("hello");
let p3 = s3.as_ptr();
let s4 = s3;
println!("move: 同じ場所? {}", p3 == s4.as_ptr());
clone: 同じ場所? false
move: 同じ場所? true
as_ptr() は、文字列の中身が置かれている位置を返します。
| 操作 | 中身の位置 | 起きていること |
|---|---|---|
.clone() | 別の場所 | 中身を、複製した |
ムーブ(let s4 = s3;) | 同じ場所 | 中身は、そのまま。持ち主だけが移った |
ムーブは、24 バイトの「指す情報」を渡すだけで、中身は、動きません。重い物を渡しても、渡す側の手間は、変わりません。
Java と並べる
Java で、同じ形のコードを書いて、動かしました。
StringBuilder a = new StringBuilder("hello");
StringBuilder b = a;
b.append(", world");
System.out.println(a);
System.out.println(a == b);
hello, world
true
Java では、b = a のあとも、a は使えます。2つの変数が、同じ物を指していて、b で変えた結果が、a にも見えます。
Rust では、let b = a; のあと、a は使えません。同じ物を、2つの名前から変えてしまう状況が、ふつうの書き方では、そもそも作れません。
Copy
整数は、どうでしょうか。
let a = 5;
let b = a;
println!("{a} {b}");
let p = (1, 2.5);
let q = p;
println!("{:?} {:?}", p, q);
5 5
(1, 2.5) (1, 2.5)
エラーになりません。整数のような小さくて単純な型は、代入のときに、複製されます。これが、Copy 型です。
確かめた型は、次のとおりです。
| 型 | 代入のあと、元の変数 |
|---|---|
整数、小数、bool、char | 使える(Copy) |
&str | 使える(Copy) |
タプル (i32, f64) | 使える(Copy) |
配列 [i32; 3] | 使える(Copy) |
String | 使えない(ムーブ) |
Vec<String> | 使えない(ムーブ) |
タプル (i32, String) | 使えない(ムーブ) |
配列 [String; 2] | 使えない(ムーブ) |
タプルと配列は、中身がすべて Copy 型のときだけ、Copy になります。1つでも String が入ると、全体がムーブになります。
let t = (1, String::from("x"));
let u = t;
println!("{:?} {:?}", t, u);
error[E0382]: borrow of moved value: `t`
--> tup_err.rs:4:27
|
2 | let t = (1, String::from("x"));
| - move occurs because `t` has type `(i32, String)`, which does not implement the `Copy` trait
3 | let u = t;
| - value moved here
エラーの中の「does not implement the Copy trait」は、「Copy の性質を持っていない」という意味です。トレイトは、第7回で扱います。
Java でも、int の代入は、複製です。
int x = 5;
int y = x;
y++;
System.out.println(x + " " + y);
5 6
clone
ムーブせずに、両方を使いたいときは、.clone() で、複製します。
let s1 = String::from("hello");
let s2 = s1.clone();
println!("{s1} {s2}");
hello hello
中身が複製されるので、前の節の確認のとおり、別の場所に、新しい中身ができます。文字列が長いほど、手間は増えます。エラーメッセージの help に、「if the performance cost is acceptable(手間が許せるなら)」と添えられているのは、そのためです。
関数に渡す
関数に渡すときも、代入と、同じ決まりです。
fn takes(s: String) {
println!("takes: {s}");
}
fn gives() -> String {
String::from("from gives")
}
fn takes_and_gives_back(s: String) -> String {
println!("pass: {s}");
s
}
fn copy_it(n: i32) {
println!("copy_it: {n}");
}
fn main() {
let a = String::from("hello");
takes(a);
let b = gives();
println!("{b}");
let c = takes_and_gives_back(b);
println!("{c}");
let n = 5;
copy_it(n);
println!("{n}");
}
takes: hello
from gives
pass: from gives
from gives
copy_it: 5
5
| 呼び出し | 所有権 |
|---|---|
takes(a) | a の所有権が、関数に移る。関数の終わりで、捨てられる |
gives() | 関数の中で作った値の所有権が、戻り値として、呼び出し側に移る |
takes_and_gives_back(b) | 渡して、戻り値で、受け取り直す |
copy_it(n) | i32 は Copy なので、複製が渡る。n は、そのまま使える |
takes_and_gives_back の中で、println!("pass: {s}"); のあとでも、s を返せています。println! は、引数を借りるだけで、所有権を取りません。
takes(a) のあとで、a を使うと、エラーです。
let a = String::from("hello");
takes(a);
println!("{a}");
error[E0382]: borrow of moved value: `a`
--> fn_err.rs:8:16
|
6 | let a = String::from("hello");
| - move occurs because `a` has type `String`, which does not implement the `Copy` trait
7 | takes(a);
| - value moved here
8 | println!("{a}");
| ^ value borrowed here after move
|
note: consider changing this parameter type in function `takes` to borrow instead if owning the value isn't necessary
--> fn_err.rs:1:13
|
1 | fn takes(s: String) {
| ----- ^^^^^^ this parameter takes ownership of the value
| |
| in this function
help: consider cloning the value if the performance cost is acceptable
|
7 | takes(a.clone());
| ++++++++
note が、「所有権が要らないなら、借りる型にしてはどうか」と言っています。これが、次回の内容(借用と参照)です。
for ループでも動く
for に Vec を渡すときも、ムーブが起きます。
let v = vec![String::from("a"), String::from("b")];
for s in v {
println!("{s}");
}
println!("{}", v.len());
error[E0382]: borrow of moved value: `v`
--> for_err.rs:6:20
|
2 | let v = vec![String::from("a"), String::from("b")];
| - move occurs because `v` has type `Vec<String>`, which does not implement the `Copy` trait
3 | for s in v {
| - `v` moved due to this implicit call to `.into_iter()`
...
6 | println!("{}", v.len());
| ^ value borrowed here after move
|
help: consider iterating over a slice of the `Vec<String>`'s content to avoid moving into the `for` loop
|
3 | for s in &v {
| +
for s in v は、v の中身を、1つずつ取り出して、v ごと使い切ります。help が、&v と書けば、ムーブしない、と教えています。これも、次回です。
代入と drop
値が捨てられるタイミングを、もう一度、目で見ます。
fn take(n: Noisy) {
println!("take の中: {}", n.0);
}
fn main() {
let mut x = Noisy("old");
println!("代入の前: {}", x.0);
x = Noisy("new");
println!("代入の後");
let y = Noisy("y");
let z = y;
println!("ムーブの後: {}", z.0);
take(z);
println!("take から戻った後");
println!("main の終わり: {}", x.0);
}
(Noisy と impl Drop は、前の節と同じです)
代入の前: old
drop: old
代入の後
ムーブの後: y
take の中: y
drop: y
take から戻った後
main の終わり: new
drop: new
| 出力 | 起きていること |
|---|---|
drop: old | x = Noisy("new"); の代入の瞬間に、古い値が捨てられます |
drop: y | take(z) の中で、所有権が関数に移り、関数の終わりで捨てられます。main に戻ってからではありません |
drop: new | main の終わりで、x が捨てられます |
let z = y; のムーブでは、drop は出ていません。持ち主が変わっただけで、値は、捨てられていません。
サンプル: 買い物リスト
所有権を、「渡して、返してもらう」形で、使い切るサンプルです。
cargo new shop
cd shop
src/main.rs を、次のようにします。
fn add_item(mut list: Vec<String>, item: &str) -> Vec<String> {
list.push(String::from(item));
list
}
fn print_and_return(list: Vec<String>) -> Vec<String> {
println!("{:?}({} 品目)", list, list.len());
list
}
fn consume(list: Vec<String>) {
println!("片付けます: {} 品目", list.len());
}
fn main() {
let list = Vec::new();
let list = add_item(list, "りんご");
let list = add_item(list, "みかん");
let list = print_and_return(list);
let list = add_item(list, "ぶどう");
consume(list);
}
cargo run
["りんご", "みかん"](2 品目)
片付けます: 3 品目
| 関数 | 所有権の動き |
|---|---|
add_item | リストを受け取り、品目を足して、返す |
print_and_return | リストを受け取り、表示して、返す |
consume | リストを受け取り、返さない。関数の終わりで、捨てられる |
let list = ... を何度も書いているのは、第2回のシャドーイングです。返ってきた所有権を、同じ名前で受けています。list.len() は、中身を借りるだけで、所有権を取りません。Vec::new() の中身の型は、add_item に渡すことで、Vec<String> と決まります。
item: &str は、品目の文字列を借りて、String::from(item) で、自分の String を作っています。
consume(list); のあとで、list を使うと、エラーです。
consume(list);
println!("{}", list.len());
error[E0382]: borrow of moved value: `list`
--> shop_err.rs:22:20
|
20 | let list = add_item(list, "ぶどう");
| ---- move occurs because `list` has type `Vec<String>`, which does not implement the `Copy` trait
21 | consume(list);
| ---- value moved here
22 | println!("{}", list.len());
| ^^^^ value borrowed here after move
このサンプルの書き方は、動きますが、使うたびに、渡して、受け取り直す必要があります。次回の「借用」は、この手間をなくす仕組みです。
cargo clippy と cargo fmt --check は、指摘なしで通りました。
歴史
| 年 | できごと |
|---|---|
| 1994 | Bjarne Stroustrup が、著書『The Design and Evolution of C++』で、RAII(「スコープを出たら、自動で後始末する」C++ の書き方)を紹介する。名付け親も、Stroustrup |
| 2010 | Rust の所有権の仕組みが、すでに入っていた(Graydon Hoare の話として、MIT Technology Review が伝えている) |
| 2013 | GC がほとんど使われなくなり、言語から取り除かれる。所有権が中心になる |
第1回の年表にあった「2013年に、GC が、言語から取り除かれた」と、同じ出来事です。
RAII は、「持ち主が消えるときに、後始末も一緒に行う」という考え方で、今回の「スコープを出ると、drop が呼ばれる」動きと、同じ形をしています。Rust は、これに、「持ち主は1人だけ」という決まりを足して、コンパイル時に、誤りを見つける形にした、と見ることができます。
また、「持ち主は1人だけで、譲ったら使えない」という決まりから、Rust の型は、アフィン型(各値を、高々1回しか使えない型)と呼ばれます。
動作確認
確認できたこと
- 上のコードと出力は、すべて、Linux(Rust 1.97.0、Cargo 1.97.0)で、実際に実行した結果です
- Java の比較(
StringBuilderの代入、intの代入)は、同じ環境の Java で、実際に実行した結果です dropのタイミング(スコープの終わり、作った順の逆、代入の瞬間、関数の終わり)- ムーブでは、
as_ptr()が同じ値になり、cloneでは、違う値になること - 型の大きさ(
&str= 16、String= 24、Vec<i32>= 24。64 ビットの環境) - Copy になる型(整数・小数・
bool・char・&str・Copy 型だけのタプルと配列)と、ムーブになる型(String・Vec<String>・Stringを含むタプルと配列) - 6つのエラー(
E0382を5種類の形、E0596)のメッセージ - 買い物リストのサンプルが、
cargo runで動くこと。cargo clippyとcargo fmt --checkが、指摘なしで通ること
歴史の出典
- RAII を Stroustrup が名付けたこと、C++ の考え方であること、1994年の著書で紹介されたことは、cardinalpeak.com の記事(RAII Introduction)の記述です。この1つの出典だけで、他の資料とは、突き合わせていません
- 所有権が2010年には入っていたこと、2013年に GC が取り除かれたこと、Rust の型がアフィン型と呼ばれることは、Wikipedia「Rust (programming language)」の記述です。2010年の件は、同ページが引く MIT Technology Review の記事の内容で、元の記事までは、確認していません
- Wikipedia のページには、Rust の所有権が、アフィン型の研究から作られたとは、書かれていません。この記事も、そうは書いていません
確認できていないこと
Stringの 24 バイトが、位置・長さ・容量の3つ分であること(大きさの数字は測りましたが、内訳は、一般的な説明です)- 「同じ値を2回捨てる危険を防ぐため」という、ムーブの理由。これは、言語の設計の説明で、この記事のコードでは、確かめていません
- 「RAII と Rust の所有権は、同じ形をしている」という、歴史の節の説明。これは筆者の解釈で、出典では確認していません
- Windows と macOS での実行結果。出力の内容は、同じになるはずですが、実行はしていません
dropの順序の細かい決まり(構造体のフィールドの順など)。この記事では、触れていません
次回
次回は、借用と参照です。今回のエラーで、何度も出てきた「借りる」を、& と &mut の使い方から、見ていきます。買い物リストのサンプルを、渡して返すのをやめた形に、書き直します。
連載の目次(予定)
入門編(8回)
- Rust とは / 環境構築 / Hello World
- 変数・型・関数(Java との対比)
- 所有権(ムーブ)(今回)
- 借用と参照
- 構造体・enum・match
- Result / Option とエラー処理
- トレイトとジェネリクス(Java の interface との違い)
- ライフタイム入門と、総まとめの CLI(ファイル一覧ツール)