C/C++エンジニアのための Rust のデータ所有権とライフタイム入門

  • 9
    いいね
  • 0
    コメント

初めに

Rust のキモであるライフタイムの概念において、ネットの解説ドキュメントでは自分には理解が困難だったため、自分なりに Rust を動かして確認した結果をまとめる。

使用した Rust は 1.17.0 である。

なお、ライフタイムを理解するには所有権の知識が必要なため、所有権の説明から始める。

所有権の理解が十分な場合は、前半は飛して構わない。

変数の制約

Rust は、データのライフタイムを厳密に管理することで、コンパイル時にメモリ破壊の可能性を検知する機能を持っている。

一般的にプログラムでは、生成したデータを変数に代入してデータを操作することになるが、Rust ではこの変数に次の制約を付けている。

  • 変数は、データに対して所有権(ownership)とアクセス権を持つ。
  • あるデータに対する所有権を持つ変数がスコープから外れた時に、そのデータは開放される。
  • あるデータに対する所有権を持つ変数は、アクセス権も持つ。
  • アクセス権には、読み出し専用(immutable)と、読み書き用(mutable)がある。
  • アクセス権のデフォルトは、読み出し専用 (immutable)。
  • 変数にデータを代入すると、所有権とアクセス権がその変数に移る。 (1つのデータの所有権を持てる変数は 1 つだけ。)
  • 代入元の変数は、何もデータを格納していない状態になり、この変数に対してデータ読み込みアクセスするとコンパイルエラーになる。
  • 変数は、別の変数にアクセス権だけを与えることができる。
    • 読み出し専用のアクセス権(Reference)は、複数の変数に与えることができる。
    • 読み書き用のアクセス権(Borrowing)は、1 つの変数に与えることができる。
    • あるデータの Reference と Borrowing は、どちらか一方しか同時に存在出来ない。
  • ある変数に格納されたデータへのアクセス権(Reference/Borrowing)を、そのデータのライフタイムより広いライフタイムを持つ変数に与えることはできない。

基本的には、この 12 個である。

C は、次のような処理が書けてしまう。

{
  int *pVal;
  {
     int val = 1;
     pVal = &val;
  }
  *pVal = 2;
}

これは、最後の *pVal への代入で val のアドレスにデータを書き込むが、そのアドレスは書き込み時点では既に無効になっているため、この動作による影響は不定でありメモリ破壊が発生する。

上記 C と同じような意味の処理を Rust で書くと次のようになるが、これはコンパイルエラーになる。

{
  let rval;
  {
     let mut val = 1;
     rval = &mut val; // error
  }
  *rval = 2;
}

これは、「ある変数に格納されたデータへのアクセス権(Reference/Borrowing)を、そのデータのライフタイムより広いライフタイムを持つ変数に与えることはできない。」の制約から外れているためエラーとなる。

ライフタイムについては後述するが、ここではスコープと同義であると考えて良い。

このように、Rust は、データのライフタイムを厳密に管理することで、コンパイル時にメモリ破壊の可能性を検知する機能を持っている。

以降では、上記 Rust の変数についての制約について説明する。

あるデータに対する所有権を持つ変数がスコープから外れた時に、そのデータは開放される。

データには所有権があり、その所有権を持つ変数がスコープから外れると、そのデータは開放される。

C のように malloc/free を明示的に呼び出す必要はない。

{
  let val = vec![1,2,3];
  println!( "{}", val[0] );
}

上記の例では、 val には Vec 型のデータの所有権が与えられ、println!() でデータを表示後、 val のスコープが外れるため、Vec 型のデータが開放される。

C++ では、似たように機能する unique_ptr がある。

「C++ の unique_ptr のようなもの」と説明したが、C++ で出来るなら Rust を使う意味はないじゃないか?という疑問を持つ方もいるだろう。だが、 C++ は unique_ptr が「使える」のであって、それを使うかどうかはプログラマ次第である。また、その使用方法に論理的な間違えがあったとしてもコンパイルは成功してしまい、実行時にエラーが発生することで初めて間違っていたことが分かる。

対して Rust は、プログラマ次第で「使える」のではなく、それしか「使えない」。また、使用方法に論理的な間違えがあればコンパイル時に分かる。

これは重要な違いである。

静的型付け言語を選択する理由の一つには、コンパイル時にさまざまな論理的なエラーを検知できることが大きい。これは、 Google が TypeScript を採用していることからも容易に想像できるだろう。

Rust は、コンパイル時検知可能なエラーに型エラーだけでなく、メモリ破壊も検知できることが特徴の言語である。

あるデータに対する所有権を持つ変数は、アクセス権も持つ。

アクセス権には、読み出し専用(immutable)と、読み書き用(mutable)がある。

アクセス権のデフォルトは、読み出し専用 (immutable)。

アクセス権とは、データの値を読み出す、書き込むことが出来る権利である。

C で const 宣言することで書き込みアクセスを禁止出来るように、Rust でもデータアクセスに対する制御が可能である。

多くの言語では、デフォルトのアクセス権は読み書き可能(mutable)であるのに対して、Rust のデフォルトは読み出し専用(immutable)である。

これは、上記で説明しているメモリ破壊検知を可能にする構文を前提に考えた場合、デフォルトを mutable にした方が、デフォルトを immutable にするよりもコーディング量が増える可能性があるためだと考えられる。

変数にデータを代入すると、所有権とアクセス権がその変数に移る。 (1つのデータの所有権を持てる変数は 1 つだけ。)

代入元の変数は、何もデータを格納していない状態になり、この変数に対してデータ読み込みアクセスするとコンパイルエラーになる。

{
  let val = vec![1,2,3];
  let sub = val;
  println!( "{}", val[0] ); // error
}

上記のソースは、println!() が val にアクセスしている箇所でコンパイルエラーが発生する。これは、1 行目で val に Vec データの所有権が設定されるが、2行目でその所有権が sub に移る。そして、その後に val に対してアクセスしているが、val には所有権がないためエラーとなっている。

ここで、次の main() 関数内の println!() はどうなるだろうか?

fn sub(val: Vec<i32>) {
    println!("sub: {}", val[0]);
}
fn main() {
    let v = vec![1, 2, 3];
    let xx = 1;
    if xx == 0 {
        sub(v);
        return;
    }
    println!("main: {}", v[0]);
}

v が持つ Vec の所有権は、if の条件成立時に sub() を呼び出すと sub の引数 val に移る。一方、条件不成立時に sub() は呼ばれない。

Rust は、このようなコードの意味解析を行なった上で所有権の検査を行なっているため、この場合はコンパイルエラーにならない。

変数は、別の変数にアクセス権だけを与えることができる。

データの所有権は 1 つの変数しか持てない。これだと関数にデータを渡した時に所有権も渡ってしまうため、関数の処理が戻った後の処理でデータにアクセスできないことになる。

fn sub( dat: Vec<i32> ) {
  println!( "{}", dat[0] );
}
fn main() {
  let val = vec![1,2,3];
  sub( val );
  println!( "{}", val[0] ); // error
}

これを回避するには、例えば次のように戻り値として所有権を返すことでも対応可能だが、これだと非常にコード量が多くなるし煩わしい。

fn sub( dat: Vec<i32> ) -> Vec<i32>  {
  println!( "{}", dat[0] );
  dat
}
fn main() {
  let val = vec![1,2,3];
  val = sub( val );
  println!( "{}", val[0] );
}

そこで、Rust では所有権を渡さずにアクセス権だけを渡すことができる。

具体的には次のようになる。

fn sub( dat: &Vec<i32> ) {
  println!( "{}", dat[0] );
}
fn main() {
  let val = vec![1,2,3];
  sub( &val );
  println!( "{}", val[0] );
}

アクセス権には読み出し専用のアクセス権(Reference)と、読み書き用のアクセス権(Borrowing)がある。

上記の例は読み出し専用 Reference の例である。

読み出し専用 Reference を渡す場合、渡す側と受け取る側に & を付ける。これにより、通常の所有権の代入とアクセス権の代入とが区別される。

読み書き用(Borrowing)の例は次のようになる。

fn sub( dat: &mut Vec<i32> ) {
  println!( "{}", dat[0] );
  dat[0] = 10;
}
fn main() {
  let mut val = vec![1,2,3];
  sub( &mut val );
  println!( "{}", val[0] ); // 10
}

読み書き用 Borrowing を渡す場合、渡す側と受け取る側に &mut を付ける。

アクセス権には次の特徴がある。

  • 読み出し専用のアクセス権(Reference)は、複数の変数に与えることができる。
  • 読み書き用のアクセス権(Borrowing)は、1 つの変数に与えることができる。
  • あるデータの Reference と Borrowing は、どちらか一方しか同時に存在出来ない。

公式ドキュメントでは、Reference を参照、 Borrowing を借用と訳している。

ここで次のコードを確認すると、

1  {
2      let mut aaa = 1;
3      {
4          let val = &mut aaa;
5          *val = 2;
6      }
7      println!("{}", aaa );
8  }

先ほどは「読み書き用 Borrowing を渡す場合、渡す側と受け取る側に &mut を付ける。」と説明したが、上記 4行目で &aaa に対して let val になっている。

let &mut val = &mut aaa; になるのではないか?と、思ってしまうが、これではコンパイルエラーになる。

ではどうするかというと、 &mut を付けるのは型の方になる。

let val: &mut i32 = &mut aaa;

ただ、 Rust では let で変数初期化する際は、型推論によって型を明示する必要がないため、let val = &mut aaa; という形になる。

では次の 2 つのケースで何が違うのか

let val = &mut aaa;
let mut val = &mut aaa;

これは、次のサンプルを見ると分かり易い。

{
    let mut aaa = 1;
    let mut bbb = 10;
    {
        let mut val = &mut aaa;
        *val = 2;
        val = &mut bbb;
        *val = 3;
    }
    println!("{} {}", aaa, bbb); // 2 3
}

上記のサンプルはコンパイルは成功する。

一方で、次のサンプルは 7 行目 val = &mut bbb; の箇所でコンパイルエラーになる。

 1  {
 2      let mut aaa = 1;
 3      let mut bbb = 10;
 4      {
 5          let val = &mut aaa;
 6          *val = 2;
 7          val = &mut bbb;
 8          *val = 3;
 9      }
10      println!("{} {}", aaa, bbb);
11  }

つまり let mut の mut は、let で宣言した変数そのものに対する書き込み許可宣言であり、変数が格納するデータの型には関係がない。

C の const int * pVal; と int * const pVal; の違いのようなものだ。

#include <stdio.h>

int main()
{
    int val = 0;
    const int * pVal1 = &val;
    int * const pVal2 = &val;
    *pVal1 = 1;   // エラー: pVal1 の参照先が書き込み禁止
    pVal1 = NULL; 
    *pVal2 = 1;
    pVal2 = NULL; // エラー: pVal2 そのものが書き込み禁止
    return 0;
}

ある変数に格納されたデータへのアクセス権(Reference/Borrowing)を、そのデータのライフタイムより広いライフタイムを持つ変数に与えることはできない。

これは、先に説明した通りである。

{
  let rval;
  {
     let mut val = 1;
     rval = &mut val; // error
  }
  *rval = 2;
}

上記の例では、 rval に対して val の Borrowing を渡している。rval は val が保持するデータのライフタイムよりも大きいため、エラーとなる。

Copy と 所有権の移動

先ほど、次の場合は所有権が移動するためコンパイルエラーになると説明した。

{
  let val = vec![1,2,3];
  let sub = val;
  println!( "{}", val[0] ); // error
}

一方で、次の場合はコンパイルエラーにならない。

{
  let val = [1,2,3];
  let sub = val;
  println!( "{}", val[0] );
}

この例の let sub = val; においては所有権の移動ではなく、データの Copy が行なわれるためである。

Copy では、コピー元と同じデータが複製され、それが変数に代入される。

これにより、元の変数には Copy 後も所有権が残るため、上記の場合はエラーが発生しない。

代入時に Copy されるか、所有権の移動が起るかは、そのデータ型の Copy トレイトが実装されているかどうかでコンパイル時に切り替わる。

なお、Rust のプリミティブ型は全て Copy トレイトが実装されている。

ライフタイム

Rust は、データのライフタイムを厳密に管理することで、コンパイル時にメモリ破壊の可能性を検知する機能を持っている。

コンパイル時に検知するということは、ソースコードの構文上にデータのライフタイムを示す情報が現われている、ということでもある。

関数宣言におけるライフタイム

次のソースコードを見てみる。

 1  fn sub(val1: &Vec<i32>, val2: &Vec<i32>) -> &Vec<i32> {
 2      val1
 3  }
 4  fn sub2(val1: &Vec<i32>, val2: &Vec<i32>) -> &Vec<i32> {
 5      val2
 6  }
 7  fn main() {
 8      let val0 = vec![0];
 9      let val1 = vec![1];
10      {
11          let mut val2 = &val0;
12          let mut val3 = &val0;
13          {
14              let val4 = vec![2];
15              val2 = sub(&val1, &val4);
16              val3 = sub2(&val1, &val4);
17          }
18          println!("{} {}", val2[0], val3[0]);
19      }
20  }

このソースコードはコンパイルエラーになる。

エラーになる原因は、sub, sub2 関数の戻り値の型が参照になっているが、その参照元のライフタイムが不明なことにある。

では、なぜライフタイムが不明だとコンパイルエラーになるのか?

上記のソースの 16 行目の val3 = sub2(&val1, &val4); の箇所を見ると、sub2() は第2引数を返していることから、この処理は val3 に &val4 を代入していることが分かる。これは、Rust の「ある変数に格納されたデータへのアクセス権(Reference/Borrowing)を、そのデータのライフタイムより広いライフタイムを持つ変数に与えることはできない」の規則に違反していることになる。しかし、sub2() が第二引数を返す、つまりは sub2() の戻り値のライフタイムが第二引数と同じであることが分からないと、変数の制約を満しているかどうかを判定することができない。そして、判定することができないから、コンパイルエラーになる。

このコンパイルエラーを回避するには、ライフタイムを明示する必要がある。

Rust では、次の 1, 4 行目のようにライフタイムを宣言する。

 1  fn sub<'a, 'b>(val1: &'a Vec<i32>, val2: &'b Vec<i32>) -> &'a Vec<i32> {
 2      val1
 3  }
 4  fn sub2<'a, 'b>(val1: &'a Vec<i32>, val2: &'b Vec<i32>) -> &'b Vec<i32> {
 5      val2
 6  }
 7  fn main() {
 8      let val0 = vec![0];
 9      let val1 = vec![1];
10      {
11          let mut val2 = &val0;
12          let mut val3 = &val0;
13          {
14              let val4 = vec![2];
15              val2 = sub(&val1, &val4);
16              //val3 = sub2(&val1, &val4); // error
17          }
18          println!("{} {}", val2[0], val3[0]);
19      }
20  }

ライフタイムは ' で宣言し、ライフタイムを識別するための名前を指定する。

上記の例では 'a, 'b がライフタイムであり、sub() の戻り値は第1引数と同じライフタイムで、sub2() の戻り値は第2引数と同じライフタイムであることを示している。

この宣言されているライフタイムの情報によって、Rust は上記 16 行目の処理が変数の制約を満していないことを判定することができる。

なお、宣言しているライフタイムと、異なるライフタイムの戻り値を返すとコンパイルエラーになる。例えば次の場合、関数は ライフタイム 'a の参照を返すと宣言しているが、実際に返している val2 のライフタイムは 'b である。これは、宣言と矛盾しているためコンパイルエラーとなる。

fn sub<'a, 'b>(val1: &'a Vec<i32>, val2: &'b Vec<i32>) -> &'a Vec<i32> {
    val2 // error
}

同じライフタイムの変数が複数ある場合、次のように宣言できる。

fn sub<'a>(val1: &'a Vec<i32>, val2: &'a Vec<i32>) -> &'a Vec<i32> {
    val2
}

ライフタイムを明示せずに省略すると、全てのライフタイムは同じになる。

例えば次の場合、

fn sub<'a>(val: &'a Vec<i32>) -> &'a Vec<i32> {
    val
}

次のように省略して記載できる。

fn sub(val: &Vec<i32>) -> &Vec<i32> {
    val
}

構造体宣言におけるライフタイム

構造体のメンバもライフタイムの明示が必要である。

 1  struct Foo<'a, 'b> {
 2      xx: &'a Vec<i32>,
 3      yy: &'b Vec<i32>,
 4  }
 5  impl<'a, 'b> Foo<'a, 'b> {
 6      fn x(&self) -> &'a Vec<i32> { self.xx }
 7  
 8      fn y(&self) -> &'b Vec<i32> { self.yy }
 9  }
10  fn main() {
11      let x = vec![1];
12      let mut z: &Vec<i32>;
13      {
14          let y = vec![2];
15          let q;
16          let mut f = Foo { xx: &x, yy: &y };
17          z = f.x();
18          q = f.y();
19          println!("{} {} {}", z[0], q[0], f.y()[0] ); // 1 2 1
20          //z = f.y(); // error
21          f.yy = &x;
22          println!("{} {} {}", z[0], q[0], f.y()[0] ); // 1 1 1
23          //z = f.y(); // error
24      }
25  }

上記は構造体 Foo と、そのメソッド x(), y() を定義している。構造体 Foo は参照型の変数 xx と yy の保持しており、メソッド Foo.x(), Foo.y() は Foo.xx, Foo.yy を返す。

16 行目で構造体が生成されるが、このとき Foo.xx は x、 Foo.yy は y の参照で初期化される。これによって、Foo.xx と Foo.yy のライフタイムも決定される。

次に 17, 18 行目で Foo.x(), Foo.y() をコールし、その結果を z, q に代入している。

この代入は変数の制約を満しているため、成功する。

一方で、20 行目の代入はコンパイルエラーとなる。これは、16 行目の段階で Foo.y() のライフタイムが y と同じであることが決定していて、z のライフタイムは y のライフタイムよりも大きいため、変数の制約を満さないのでエラーとなる。

一方で、21 行目の代入は成功する。Foo.yy のライフタイムは y と同じであるが、 x のライフタイムは y よりも大きい。よって、制限を満すため代入は成功する。ただし、この代入はデータ自体の代入を行なうが、Foo.yy のライフタイム自体は y と同じままである。

次に 23 行目の代入はコンパイルエラーとなる。これは先程説明した通り、 Foo.yy に x の参照を代入しても、Foo.yy のライフタイムは y のまま変わらないため、変数の制約を満さない。

構造体データの所有権、アクセス権移動

構造体データの所有権、アクセス権移動には、宣言したライフタイムの中で一番短いライフタイムが制約を満す必要がある。

 1  struct Foo<'a,'b> {
 2      xx: &'a Vec<i32>,
 3      yy: &'b Vec<i32>,
 4  }
 5  
 6  fn main() {
 7      let zzz = vec![2];
 8      let aaa;
 9      {
10          let bbb = vec![1];
11          let ccc = Foo { xx: &bbb, yy: &zzz };
12          // aaa = ccc; // error
13      }
14  }

上記の場合、 12行目の所有権移動は bbb と zzz のライフタイムの短かい方が、制約を満す必要がある。

bbb と zzz では、bbb の方がライフタイムが短い。また aaa は bbb よりもライフタイムが長いため、12 行目は制約を満さずにコンパイルエラーになる。

次の場合は、aaa よりも bbb のライフタイムが長くなるため、12 行目の代入は可能になる。

 1  struct Foo<'a,'b> {
 2      xx: &'a Vec<i32>,
 3      yy: &'b Vec<i32>,
 4  }
 5  
 6  fn main() {
 7      let zzz = vec![2];
 8      let bbb = vec![1];
 9      let aaa;
10      {
11          let ccc = Foo { xx: &bbb, yy: &zzz };
12          aaa = ccc;
13      }
14  }

ライフタイム 'static

ライフタイムには任意の名前を付けられるが、'static だけは予約されている。

'static は、その名の通り静的に存在することを示すライフタイムである。

具体的例としては、プリミティブな文字列のライフタイムは 'static である。

もう一つの 'static の例としてグローバル変数がある。

fn sub() -> &'static str {
  "abc"
}
fn main() {
  println!( "{}", sub() );  // abc
}