Rustをはじめよう その5(所有権・仕組み編)

三菱総研DCS デジタル企画推進部の加藤です。 この記事では、プログラミング言語Rust(ラスト)の所有権の仕組みをご説明します。

所有権とは何か

所有権はRustにおけるメモリ管理の仕組み・概念ですが、この総体について説明することは本記事がカバーする範囲を超えるものです。ただ、基本的な機能やルール自体は理解しやすいため、本記事の説明やサンプルコードを読んで、他のプログラミング言語との違いと併せて概要をご理解いただければと思います。

所有権が必要な場面

所有権を特に意識する必要があるのは、メモリ上のヒープ領域に置かれたデータを管理する必要がある場面です。Rustにおいてヒープに置かれるデータは、String型(文字列)やVec型(他言語におけるリストに相当)などの可変長のデータです。一方で、以前の記事で扱ったデータ型であるi32型(符号付き32bit整数型で、他の言語のint・integer型に相当)や、f64型(浮動小数点型)やbool型(真理値型)などはスタックに置かれる固定長データで、所有権の仕組みを意識する必要はさほどありません。まずは、以上のことをご理解ください。

以下では、スタックとヒープについて簡単にご説明します。Rustにおいても、他のプログラミング言語と考え方は基本的に同一ですので、ご存じの方は読み飛ばしてください。

コンピュータのメモリ領域は、その用途によってスタック領域(スタック)とヒープ領域(ヒープ)に大別されます。先に述べたとおり、プログラムにおけるこれらの領域の使い分け方としては、スタックには固定長のデータを、ヒープには可変長のデータを格納する、という方法が基本です。
より詳細には、スタックに格納されるデータは、プログラムのコンパイル時点であらかじめデータのサイズがわかっています。例えば、i32型の変数がプログラム上で一つ定義されている場合、コンパイルの時点でこのデータの格納に必要なサイズは32bitであるとわかります。なぜなら、i32型の値は最大(最小)値を32bitあれば表現できるためです。また、bool型は真理値型のため、1bit(0 or 1)あれば表現できますが、Rustでは諸般の都合で1バイト(8bit)のスタックを割り当てます。いずれにせよ、コンパイルの時点で割り当てるべき領域のサイズが判明しているという点に変わりはありません。
一方で、ヒープに格納されるデータは、プログラムのコンパイル時点であらかじめデータのサイズが(厳密には)わかりません。例えば、String型にユーザが入力した文字列を受け取り、コンソール出力するというプログラムがあったとします。この場合、ユーザが入力する文字が何文字か、コンパイルの時点で知ることはできません。同様に、ユーザが入力した文字列を一文字ずつ分割して、これらをVec型(リスト)に格納するプログラムでは、コンパイル時点でリストの要素数が何個になるのか知ることはできません。
もちろん、String型やVec型の場合であっても、そこに格納される値がコンパイル時点で事実上確定しているケースもあります(例えばユーザの入力ではなくプログラム側で決まった値を代入する場合など)。しかし、この場合であっても、これらの型が可変長(例えば文字列の後ろに追加の文字列を結合する、リストに要素を追加することが可能)であるという性質を担保するため、データはヒープ領域に格納されることになります。

スタックとヒープはプログラミングにおいて重要な概念ではありますが、所有権の概要を知るためには上記の性質を知っていれば十分なため、説明はこの程度に留めます。
ここで特にご理解いただきたいのは、所有権を特に意識すべき場面は、ヒープ領域にデータが格納されるデータ型を扱う場面である、ということです。

所有権規則

所有権は、以下のルールのセットで構成されます(以下、「The Rust Programming Language: 2nd Edition」, https://doc.rust-jp.rs/book-ja-pdf/book.pdfより引用)。

  • Rust の各値は、所有者と呼ばれる変数と対応している。
  • いかなる時も所有者は一つである。
    • (筆者注) スマートポインタという仕組みを使用すると所有権を共有できるため、一定の条件下で複数の所有者が存在することになります。ただし、説明が複雑になるため、本記事ではスマートポインタについては考慮しません。
  • 所有者がスコープから外れたら、値は破棄される。

(ここまで引用)

以下でこれらのルールについて、サンプルコードを通じて具体的な挙動を見ていきます。
その前に、今後のサンプルコードで使用するデータ型であるVec型とString型の使い方について、簡単なサンプルコードを示します。必要であれば、コマンドプロンプトから以下のコマンドを実行し、新規のプロジェクトを作成してください。

cargo new ownership_practice


以下のサンプルコードでは、Vec型を宣言し、これにString型の値を複数追加し、Vec型の各要素をコンソール出力しています。

main.rs

fn main() {
    let mut vector = Vec::new();
    vector.push(String::from("a"));
    vector.push(String::from("b"));
    vector.push(String::from("c"));

    for str in vector {
        println!("{}", str);
    }
}


出力結果

a
b
c


Vec::new()String::from(~)は、それぞれVec型とString型の値を生成する、という意味です。::はVec型、あるいはStringクラス配下の、という意味で、new()from(~)はnew関数とfrom関数を表します。
Vec型の値を宣言した後、Vec型のpushメソッドを実行し、String型の値を追加しています。
最後に、for文でVec型に格納したそれぞれの文字列を取り出し、コンソール出力しています。

次に、所有権の各ルールについて見ていきましょう。

ルール1: Rustの各値は、所有者と呼ばれる変数と対応している。

これは、上記のサンプルコードであれば変数vectorがVec型の値の所有権を持つ所有者である、ということです。この点については深く考えず、そのようなルールである、と捉えてください。

ルール2: いかなる時も所有者は一つである。

このルールは、他のプログラミング言語と異なる部分ですので、詳しく解説します。まずは、上記のルールについて、Rustにおける(値の所有権の)移動(move)と関連させて説明します。その後、補足としてRust以外のプログラミング言語の挙動を確認します。

まずは、下のサンプルコードを見てください。このコードでは、Vec型の値を変数vector1に代入し、要素を追加しています。その後、vector1を別の変数vector2に代入した後、代入元のvector1の要素をコンソール出力しようとしています。
このコードの実行結果はどうなるでしょうか。

main.rs

fn main() {
    let mut vector1 = Vec::new();
    vector1.push(String::from("a"));
    vector1.push(String::from("b"));
    vector1.push(String::from("c"));

    let vector2 = vector1;

    for str in vector1 {
        println!("{}", str);
    }
}


上記のコードを実行すると、以下のようなエラーメッセージが表示され、コンパイルが失敗するはずです。

出力結果

error[E0382]: use of moved value: `vector1`
 --> src\main.rs:9:16
  |
2 |     let mut vector1 = Vec::new();
  |         ----------- move occurs because `vector1` has type `std::vec::Vec<std::string::String>`, which does not implement the `Copy` trait
...
7 |     let vector2 = vector1;
  |                   ------- value moved here
8 | 
9 |     for str in vector1 {
  |                ^^^^^^^ value used here after move


error: aborting due to previous error


やや込み入ったエラーメッセージですが、注目すべきは「value」(値)の「move」(移動)という表現が複数回登場している点です(今後はvalueは値、moveは移動と呼びます)。エラーメッセージには、コードの7行目でvector1から(vector2へ)値が移動し、9行目で移動後(vector1)の値が使用されている(そのためエラーとなる)、と書かれています。

Rustでは、String型やVec型などヒープ上にデータを格納する型の多くは、変数への値の代入 (サンプルコードのケース)、関数に対する引数(値)の受け渡し、関数からの戻り値の受け取りの際に値の所有権が移動(move)します。 移動が発生すると、移動元(サンプルコードではvector1)は所有権を失い、未初期化状態になります。移動の後は、移動先の変数(サンプルコードではvector2)が所有権を持ちます。そのため、上記のサンプルコードでは、移動によって未初期化となった変数であるvector1を参照しようとしたことが原因でエラーとなっているのです。
移動の仕組みが働くことで、「いかなる時も所有者は一つである。」という所有権のルールが守られます。サンプルコードであれば、最初はvector1が所有権を持ち、その後は代入によってvector2へ所有権が移動します。つまり、Vec型の値の所有権を保持しているのは、このコードを通じて常に一つの変数(vector1またはvector2)だけです。
なお、移動については次回の記事で詳しく説明します。

続いて、ヒープ上にデータを格納する型を変数に代入する際に、他のプログラミング言語では値がどのように扱われるのかを確認します。他の言語では、所有権の移動という仕組みではなく、shallow copyまたはdeep copyと呼ばれる仕組みのいずれかによって処理されます。

まずは、Javaの例を見ていきます。Javaにおいて、リスト(ヒープに格納されるデータの例)を変数list1に格納して要素を追加した後、その変数を別の変数list2に代入します。その後、変数list2に対して要素を追加します。最後に、変数list1の内容を出力します。
サンプルコードと実行結果は以下のとおりです。

[Javaのコード]

public class Main {
    public static void main(String[] args) throws Exception {
        List list1 = new ArrayList();
        list1.add("a");
        list1.add("b");
        list1.add("c");

        List list2 = list1;
        list2.add("d");

        System.out.println(list1);
    }
}


出力結果

[a, b, c, d]


以上の結果から、ヒープ上にデータを格納する型(リストなど)を変数に代入する際に、Javaでは以下の挙動を取ることがわかります。

  • ヒープ上にデータを格納する型を格納した変数を別の変数に代入すると、ヒープ上の実データではなくポインタ(実データがヒープ領域のどこに格納されているかを示す値)がコピーされる
  • そのため、複数の変数から同一のデータを参照・操作できる状況が発生しうる(サンプルコードではlist1、list2のいずれの変数からも同一オブジェクトを操作できる)

Javaでは、ヒープ上にデータを格納する型を格納した変数の代入によって発生するコピーは、いわゆるshallow copy(参照のコピー)となります。ヒープ上の実データをコピーしないため動作は高速ですが、複数の変数から同じデータを参照・操作できてしまうため、データに対して意図しない操作がなされる場合があります。

次にC++において、上記のサンプルコードと同様の処理を実行した上で、list2の要素についてもコンソール出力します。
サンプルコードと実行結果は以下のとおりです。

[C++のコード]

#include <iostream>
#include <vector>
using namespace std;
int main(void){
    vector<string> list1;
    list1.push_back("a");
    list1.push_back("b");
    list1.push_back("c");

    vector<string> list2 = list1;
    list2.push_back("d");

    for(string &x:list1){
        cout << x << endl;
    }

    for(string &x:list2){
        cout << x << endl;
    }
}


出力結果

a
b
c
a
b
c
d


Javaの場合と異なり、list1に"d"という要素は追加されておらず、list1とlist2は独立であることがわかります。このC++の挙動をまとめると、以下のようになります。

  • ヒープ上にデータを格納する型を格納した変数を別の変数に代入すると、ヒープ上の実データがコピーされる
  • そのため、単に変数へ代入しただけでは、複数の変数から同一オブジェクトを参照・操作できる状況は発生しない(list1とlist2に対する参照・操作は、それぞれヒープ上の別領域に格納された別々のデータに対して行われる)

C++では、ヒープ上にデータを格納する型を格納した変数の代入によって発生するコピーは、いわゆるdeep copy(実データのコピー)となります(ただし、ポインタ変数の代入はshallow copy)。複数の変数から同じデータを参照・操作できる状況は発生しませんが、ヒープ上の実データをコピーするため動作にコスト(時間、メモリ領域)がかかるというデメリットがあります。

ルール3: 所有者がスコープから外れたら、値は破棄される。

Rustにおける所有者とは、所有権を持つ変数やオブジェクトのことです。本記事では、オブジェクトが持つ所有権については扱わないので、現時点では所有者とは変数のことである、という理解で問題ありません。
この変数がスコープを抜けると、所有者(変数)は破棄されます。これをドロップ(drop)と言います。ここで重要なのは、ドロップのタイミングで所有者によって保持されていた値自体も破棄されることです。これにより、ヒープ上のデータが破棄され、確保されていたメモリ領域が解放されます。

では、以下でサンプルコードを見ていきます。
Rustでは、{}を使ってブロックと呼ばれる単位(正確には式)を作成できます。ブロック内部ではスコープが形成され(JavaScriptのブロックスコープと同様)、この内部で宣言された変数はブロックの外部からアクセスできません。また、if {}for {}で使用する{}についても、同様の性質があります。
サンプルコードでブロックを使用して、ブロックの使い方と変数のスコープについて確認します。

main.rs

fn main() {
    {
        let mut vector1 = Vec::new();
        vector1.push(String::from("a"));
        vector1.push(String::from("b"));
        vector1.push(String::from("c"));
    }

    for str in vector1 {
        println!("{}", str);
    }
}


出力結果

error[E0425]: cannot find value `vector1` in this scope
 --> src\main.rs:9:16
  |
9 |     for str in vector1 {
  |                ^^^^^^^ not found in this scope


サンプルコードでは、ブロック内部のスコープで変数vector1を宣言しているため、ブロック外部のスコープからはvector1を見つけることができず、エラーが発生します。ただし、このサンプルコードでは、残念ながら変数がスコープを抜けたタイミングで所有権がドロップされていることを確認することはできません。

ここで特に注目したいのが、CやC++ではfreedeleteという命令を使用して明示的にメモリを解放する必要がある一方で、Rustにおいてはそのような命令が不要であるという点です。Rustでは変数がスコープを抜けるとそれに伴ってメモリも解放されることから、メモリの解放し忘れによるメモリリークやメモリの二重解放といった問題を未然に防ぐことができます。

まとめ

本記事では、Rustの所有権の仕組みについて紹介しました。
所有権はRustのユニークな考え方の一つで、他のプログラミング言語のメモリ管理の仕組みとは異なっています。そのため、仕組みを理解することが難しいと感じられる方も多かったかと思います。その場合は、本記事や以下に示す参考文献、あるいはネット上の記事を参照したり、また自分でコードを書いてみたりすると、より理解が深まるかと思います。

次回は、所有権の移動について解説します。

参考文献

  • 「The Rust Programming Language: 2nd Edition」, https://doc.rust-jp.rs/book-ja-pdf/book.pdf 2020 年 2 月 26 日アクセス
  • Jim Blandy, Jason Orendorff 著, 中田 秀基 訳(2018)『プログラミングRust』 オライリー・ジャパン
  • κeen,河野 達也,小松礼人(2019)『実践 Rust 入門[言語仕様から開発手法まで]』 技術評論社
その4 |  その5 | その6