なんか考えてることとか

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

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

  • 2021/07/18
    • Rustのムーブによるエラーの解決策に参照を使った方法を追加
    • シャローコピーが採用されているプログラミング言語の例を記述
  • 2022/07/10
    • シャローコピーの認識が大きく異なっていたので修正
    • シャローコピーの例のJavaScriptコードをより見やすいものに変更
    • 「シャローコピー」項の変更に伴い「ディープコピー」項の冒頭を変更

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

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

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

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

まず大前提として、プログラミング言語のコードは多くの構文や式で成り立っている。たとえば以下の式は、「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つのセマンティクスに分かれる。今回書きたかったのは「これに注意してほしい」という意味で書いたのが大きい。

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

コピーセマンティクス

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

ディープコピー

おそらくRustを除くガベージコレクション(以下GC)のない言語ではほとんどの場合において代入演算子=はディープコピーである。GCのある言語でディープコピーなのはPerlPHPなど*1

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

値の変更

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

なお、ディープコピーは「値渡し」と呼ばれることもある*2

シャローコピー

JavaC#の参照型や、PythonRubyJavaScriptといった言語は基本シャローコピーである。このようにGCがある言語に多いが、先述したようにGCのある言語のすべてがシャローコピーというわけではなく、PerlPHPといった例外もある。

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

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

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

値の参照

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

シャローコピー

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

参照された値の変更

JavaScriptでの例を示す。

let a = [1];
let b;

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

console.log(`After shallowcopy: a[0] = ${a[0]}, b[0] = ${b[0]}`);

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

console.log(`After changeng a : a[0] = ${a[0]}, b[0] = ${b[0]}`);
After shallowcopy: a[0] = 1, b[0] = 1
After changing a : a[0] = 2, b[0] = 2

シャローコピーの注意点

実はシャローコピーでこのような挙動が発生するのは配列などの複合型の場合で、値だけのスカラー型では起こらない。これはJavaC#においては、値型と参照型とで区別されており、複合型は基本参照型であることが多いためである*3

しかし、PythonRubyJavaScriptなどの一部の言語ではすべてが参照型である。こういった言語は以下の図のように=演算子を使うごとに新しい値の参照を代入していることによってあたかもディープコピーしているかのような挙動になっている

新しい値の参照の代入

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

ムーブセマンティクス

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

ムーブセマンティクス

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

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

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

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

このようにすべてのプリミティブ型には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トレイト*5が実装されているのでそのメソッドを使う(ディープコピー)
/* -------------- */
/* エラー解決法(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:ただ、PerlPHPは少し特殊である。Copy On Writeという仕組みによって渡し先の変数が書き換えられるまでは渡す変数の参照を渡す、つまり後述するシャローコピーをしており、これによって無駄なメモリ消費を抑えている。ただ、渡し先の変数の書き換えが発生するとディープコピーとなるので、ここではディープコピーとして扱う

*2:厳密にはディープコピーそのものが「値渡し」というわけではないが、実質的にそうである

*3:ただし、C#の構造体は複合型であるものの、値型である。つまり、C#の構造体ではシャローコピーは起こらない

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

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