なんか考えてることとか

変な人が主にプログラミング関連で考えていることをまとめる。

コピーセマンティクスとムーブセマンティクス

  • 2021/07/18
    • Rustのムーブによるエラーの解決策に参照を使った方法を追加
    • シャローコピーが採用されているプログラミング言語の例を記述

個人的に代入演算子=は甘く見ていると、自分の思っていた挙動とは違っていたために痛い目を見てしまうことが多いと思う。というのも、代入演算子=プログラミング言語によってさまざまな意味を持つためだ。

今回は、代入演算子=の挙動を理解する基本的なセマンティクスとしてコピーセマンティクスとムーブセマンティクスについて解説していこうと思う。

そもそもセマンティクスとは何か

そもそもセマンティクスって何だよ?急にそんなカタカナ語言われてもわかんねーって人のためにセマンティクスについて解説する。

まず大前提として、プログラミング言語のコードは多くの構文や式で成り立っている。たとえば以下の式は、「2」を意味している。

1 + 1;

この構文・式の持つ「意味」を、プログラミングでは「セマンティクス」と言う。
構文・式には一つのセマンティクスしか持たないが、同じセマンティクスを持つ構文・式はたくさんある。たとえば、Cによる以下の2つのコードはどちらも同じセマンティクスを持つ。

int i = 0;
while (i < 5) {
    printf("%d\n", i);
    ++i;
}
int i;
for (i = 0; i < 5; ++i) {
    printf("%d\n", i);
}

これらのコードは同様に以下のような出力がされる。

0
1
2
3
4

多くのプログラミング言語では、複雑な構文・式と同じセマンティクスを持つような単純な構文・式が用意されていることがある。この単純な構文・式を「糖衣構文(Syntax sugar, シンタックスシュガー)」と言う。
たとえば、Pythonclassも実は糖衣構文である。

class Class:
    var = 0

これは以下と同じセマンティクスを持つ。

Class = type('Class', (), {'var': 0})

コピーセマンティクス

ではセマンティクスについてわかったところで、コピーセマンティクスについて解説していく。
まず大半のプログラミング言語では代入演算子=はコピーセマンティクスである。これが何を意味するのかと言うと、大半の言語では代入演算子=コピーセマンティクスかムーブセマンティクスかどうか気にする必要がないことを示している
しかし、コピーセマンティクスには微妙に異なる2つのセマンティクスに分かれる。今回書きたかったのは「これに注意してほしい」という意味で書いたのが大きい。

まずコピーセマンティクスの基本的な動作について書くと、簡単に言えば代入演算子=を使うことで変数に値をコピーするのがコピーセマンティクスである。つまり、変数に別の変数を代入しても、代入する変数は消えることなく残り続けることを意味している

f:id:opaupafz2:20210717110836p:plain
コピーセマンティクス

これを基本として、コピーセマンティクスには2つのコピーがある。

ディープコピー

おそらくRustを除くほぼすべての静的型付け言語では代入演算子=はディープコピーである。動的型付け言語でディープコピーなのはPerlPHPなど。

このコピーの動作としては値そのものを渡す。そのため、各変数は個別の値を持っており、コピー元の変数の値が変わってもコピー先の変数の値が変わることはない

f:id:opaupafz2:20210717112632p:plain
値の変更

C++によるディープコピーの例を示す。

#include <iostream>
#include <vector>

int main()
{
    std::vector<int> a = {1};
    std::vector<int> b;
    
    // ディープコピー
    b = a;
    
    std::cout << "After deepcopy  : ";
    std::cout << "a[0] = " << a[0] << ", ";
    std::cout << "b[0] = " << b[0] << std::endl;
    
    // コピー元の値の変更
    a[0] = 2;
    
    std::cout << "After changing a: ";
    std::cout << "a[0] = " << a[0] << ", ";
    std::cout << "b[0] = " << b[0] << std::endl;

    return 0;
}
After deepcopy  : a[0] = 1, b[0] = 1
After changing a: a[0] = 2, b[0] = 1

なお、ディープコピーは別名「値渡し」とも呼ばれる(多分こちらのほうが聞き慣れている名前だと思う・・・)。

シャローコピー

採用されているのはPythonRubyJavaScriptといった動的型付け言語に多い。ただし先述したように動的型付け言語のすべてがシャローコピーというわけではなく、PerlPHPといった例外もある。

シャローコピーは値をコピーする、というところまではディープコピーと同じなのだが、コピーする値が違う。ディープコピーでは値そのものを値としてコピーするのに対し、シャローコピーは値の参照を値としてコピーする

たとえばある値があったとする。

f:id:opaupafz2:20210717115108p:plain

そして変数を用意する。このとき、変数は値の参照を代入する。

f:id:opaupafz2:20210717115608p:plain
値の参照

以上を踏まえてシャローコピーの動作を図示すると、こうなる。

f:id:opaupafz2:20210717140904p:plain
シャローコピー

そのため、コピー元の変数の値が変わるとコピー先の変数の値も変わる

f:id:opaupafz2:20210717143400p:plain
参照された値の変更

JavaScriptでの例を示す。

let a = [1];
let b;

// シャローコピー
b = a;

let result = "a[0] = " + a[0] + ", b[0] = " + b[0];
console.log("After shallowcopy: " + result);

// コピー元の値を変更
a[0] = 2;

result = "a[0] = " + a[0] + ", b[0] = " + b[0];
console.log("After changing a : " + result);
After shallowcopy: a[0] = 1, b[0] = 1
After changing a : a[0] = 2, b[0] = 2

シャローコピーは別名「参照渡し」とも呼ばれる。

シャローコピーの注意点

実はシャローコピーでこのような挙動が発生するのは配列などの複合型の場合で、値だけのスカラー型では起こらない。というのも、シャローコピーを用いるプログラミング言語での代入演算子=新しい値の参照を代入するというセマンティクスを持つために、あたかもディープコピーしているかのような挙動になるためである

f:id:opaupafz2:20210717153359p:plain
新しい値の参照の代入

ここからスカラー型の場合は値を代入しているように見えるが、実際に代入しているのは値そのものではなく、値の参照であるということを頭の片隅に置いておくと、シャローコピーを用いるプログラミング言語の代入演算子=の考え方がスッキリするかもしれない。

ムーブセマンティクス

次にムーブセマンティクスについて解説していく。コピーセマンティクスが値のコピーであれば、ムーブセマンティクスは値のムーブ(移動)である。つまり、ムーブ先の変数に値をムーブすると、その時点でムーブ元の変数はなくなる

f:id:opaupafz2:20210717161559p:plain
ムーブセマンティクス

ムーブセマンティクスの良いところは「メモリの節約ができる」という点にある。コピーセマンティクスの場合非常に巨大なデータをディープコピーする場合においてもディープコピーする分のメモリを必要とするため、それだけで無駄なメモリを消費してしまう。そこでムーブを使えば、ディープコピーする分のメモリは必要なくなるためその分メモリの節約ができるというわけだ。

ムーブセマンティクスがサポートされているのは主にC++C++11以降)、Rustであるが、ムーブセマンティクスを代入演算子=に言語レベルで採用しているのはおそらくRustだけだろうC++11以降でも値のムーブを行うことはできるが、あれは標準ライブラリでサポートしているのであって、言語レベルでサポートしているわけではない。

え?Rustはプリミティブ型に関してはコピーセマンティクスだろって?実はRustのプリミティブ型がコピーセマンティクスなのはプリミティブ型にCopyトレイト*1が実装されているからである。

適当にi32型についてのドキュメントを見てみると、ご覧の通り、Copyトレイトが実装されていることがわかるだろう。
doc.rust-lang.org

f:id:opaupafz2:20210717194427p:plain
このようにすべてのプリミティブ型にはCopyトレイトが実装されている

ではRustによるムーブの例を見てみよう。

fn main() {
    let a = vec![1];
    let b;
    
    // ムーブ
    b = a;
    
    a;  // aはなくなったのでこの時点でエラー
}

Rust Playground

コンパイルしてみると以下のようなエラーが発生する。

error[E0382]: use of moved value: `a`
 --> main.rs:8:5
  |
2 |     let a = vec![1];
  |         - move occurs because `a` has type `Vec<i32>`, which does not implement the `Copy` trait
...
6 |     b = a;
  |         - value moved here
7 |
8 |     a;  // aはなくなったのでこの時点でエラー
  |     ^ value used here after move

書いてあることを一言でまとめると「Vec<i32>Copyトレイトが実装されていないのでaの値をbに移動した後はaが使えない」と書かれている。

このエラーには以下の解決策がある。

  • ムーブ先の変数を使っていく
/* -------------- */
/* エラー解決法(1) */
/* -------------- */

fn main() {
    let a = vec![1];
    let b;
    
    // ムーブ
    b = a;
    
    // 今後はbしか使わない
    println!("{}", b[0]);
}

Rust Playground

  • 参照を使う(シャローコピー)
/* -------------- */
/* エラー解決法(2) */
/* -------------- */

fn main() {
    let a = vec![1];
    let b;
    
    // シャローコピー
    b = &a;
    
    println!("a[0] = {}, b[0] = {}", a[0], b[0]);
}

Rust Playground

  • Cloneトレイト*2が実装されているのでそのメソッドを使う(ディープコピー)
/* -------------- */
/* エラー解決法(3) */
/* -------------- */

fn main() {
    let a = vec![1];
    let b;
    
    // ディープコピー
    b = <Vec<i32> as Clone>::clone(&a);
    
    println!("a[0] = {}, b[0] = {}", a[0], b[0]);
}

Rust Playground

*1:簡単に言えば代入をコピーセマンティクスに変えるトレイト

*2:代入によってではなくcloneメソッドによって値をコピーするためのトレイト