【MacでRust入門】Rust日本語版チュートリアルの要点まとめ ~ 第9回 所有権 Ownership

2022-09-05
Main Image

目次

こんにちは。

最近流行りの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つの要素からできています。

上のコード例のようにs1s2にコピーするということは、この3つの要素(スタックにあるポインタ、長さ、許容量)がコピーされます。

このとき、ヒープデータ(helloという5文字のデータ)はコピーされず、ポインタと長さでs1と同じhelloを指します。

普通、スコープを抜けてメモリ解放のとき、s1s2が同じメモリを開放しようとすると二重解放エラーという安全性上のバグが起きます。

しかし、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 第2版

参考リンク

Rustチュートリアル (日本語版 非公式) https://doc.rust-jp.rs/book-ja/

ads【オススメ】未経験からプログラマーへ転職できる【GEEK JOBキャンプ】
▼ Amazonオススメ商品
ディスプレイライト デスクライト BenQ ScreenBar モニター掛け式
スマートLEDフロアライト 間接照明 Alexa/Google Home対応

Author

Penta

都内で働くITエンジニアもどき。好きなものは音楽・健康・貯金・シンプルでミニマルな暮らし。AWSクラウドやデータサイエンスを勉強中。学んだことや体験談をのんびり書いてます。TypeScript / Next.js / React / Python / AWS / インデックス投資 / 高配当株投資 More profile

Location : Tokyo, JPN

Contact : Twitter@penguinchord

Recommended Posts

Copy Right / Penguin Chord, ペンギンコード (penguinchord.com) 2022 / Twitter@penguinchord