Swiftで値型と参照型の違いを理解する

  • 66
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

Swiftで値型と参照型の違いを理解する

↓の挙動の違いの理由を説明できますか?

struct (値型)の場合

struct Foo {
    var a: UInt8
    var b: UInt8
}
let foo = Foo(a: 3, b: 127)
foo.a = 5 // コンパイルエラー!!

class (参照型)の場合

class Bar {
    var a: UInt8
    var b: UInt8
    init(a: UInt8, b: UInt8) { self.a = a; self.b = b }
}
let bar = Bar(a: 3, b: 127)
bar.a = 5 // OK

ことのはじめ

仕事でコードレビューをしていて、次のようなコードを見かけました( Bar は↑の Bar とします)。

var bar = Bar(a: 2, b: 3)

...

bar.a = 5

「どうして let じゃなくて var にしたの?」と聞いてみたところ、「 bar を変更するから let じゃいけないと思った」という返事が返ってきました。値型と参照型を混同してしまっているようです。

そういうわけで、値型と参照型の違いについて説明することにしました。

値型の場合

変数を定義すると、その変数の値を格納するためにメモリ上に領域が確保されます。上記の Foo であれば、 1 バイトの UInt8 を二つ持っているので 2 バイト分の領域が確保されます。

let foo = Foo(a: 3, b: 127)

とすると、その 2 バイトの領域の最初の 1 バイトには 3 が、次の 1 バイトには 127 が格納され、 16 進数で書くと 037F という 2 バイトが格納されることになります。

# メモリ上のバイト列のイメージ
|    | 03 | 7F |    |    |
       ^    ^
       foo のための領域

let とは、その変数のために確保された領域を、後から変更できないようにするという意味の宣言です。 037F という値は、変数 foo が存在している限り変更されません。

foo.a = 5

を実行するためには 037F を変更して 057F に書き換えなければなりません。それは let に違反するのでコンパイルエラーとなります。

# メモリ上のバイト列のイメージ
|    | 03 | 7F |    |    |    |
       ^
       ここを 05 に書き換えないといけない

参照型の場合

参照型とは何か

値型が値を直接変数の領域に格納するのに対して、参照型では別の場所に値を格納し、そのアドレス(どこに値を格納したか)を変数に格納します。

let bar = Bar(a: 3, b: 127)

とすると、メモリ上のどこか(変数のための領域とは別の場所)に 037F という 2 バイトのデータが格納されます。ここではメモリ上の 7FAB52C4 番地からの 2 バイトに格納されたとしましょう。 7FAB52C4 番地には 03 が、 7FAB52C5 番地には 7F が格納されます。

では、変数 bar のために確保された領域には何が書かれるのでしょうか。それは 7FAB52C4 という値(アドレス、番地)です1

# メモリ上のバイト列のイメージ
|    | 7F | AB | 52 | C4 | ... | 03 | 7F |    |
       ^    ^    ^    ^          ^
       bar のための領域            7FAB52C4番地
println(bar.a)

とすると何が起こるでしょうか。 Bar は参照型なので、まず bar に格納された値 7FAB52C4 が読み出されます。次に、そのアドレス 7FAB52C4 番地にアクセスして、 03 という値が読み出されます。その結果 3 という値が取得され表示されます。

このように、参照型ではアドレスを介して間接的に値にアクセスすることになります。

参照型と let

参照型である Bar 型の変数 barlet にする意味は、 bar に格納された 7FAB52C4 を変更できないようにするという意味です。

もし、

bar = Bar(a: 5, b: 255)

とするとどうなるでしょうか。 Bar(a: 5, b: 255) は新たにメモリ上のどこか(ここでは 8B3E4CA9 番地としましょう)に 2 バイトの領域を確保し、そこに値 05FF を書き込みます。そして、 bar に格納された 7FAB52C48B3E4CA9 に書き換えようとします。しかし、これは barlet で宣言されていることに違反するのでコンパイルエラーとなります。

# メモリ上のバイト列のイメージ
|    | 7F | AB | 52 | C4 | ... | 03 | 7F | ... | 05 | FF |    |
       ^    ^    ^    ^          ^               ^
       8B 3E 4C A9 に書き換えたい  7FAB52C4番地     8B3E4CA9番地

しかし、

bar.a = 5

としても、 7FAB52C4 番地に格納された 03 から 05 に書き換えるだけで、 bar に格納された 7FAB52C4 を書き換えるわけではありません。そのため、 barlet で宣言されていても bar.a = 5 は許されるのです。

# メモリ上のバイト列のイメージ
      bar
|    | 7F | AB | 52 | C4 | ... | 05 | 7F |    |
       ^    ^    ^    ^          ^
       ここはそのまま              ここを 03 から 05 に書き換えた

なお、 7FAB52C4 番地の値が書き換えられるかどうかは、 avar で宣言されているか let で宣言されているかによります。今は var なので書き換えられますが、 let で宣言されていると bar.a = 5 はコンパイルエラーとなります。

参照型がうれしい理由

参照型に慣れてない人にとっては、一見参照型は複雑なだけで何がうれしいのかわかりづらいと思います。参照型がうれしい理由の一つは代入のコストが低いことです。

代入とは、代入先の変数のために確保された領域に、値をまるごとコピーする操作です。

let foo1 = Foo(a: 3, b: 127)
let foo2 = foo1

とすると、 foo1foo2 のために 2 バイトずつの領域が確保されます。最初の行で foo1 の領域には 037F が格納されます。次の行で、 foo2 の 2 バイトの領域に、 foo1 の領域に格納されている 037F をまるごとコピーします。

# メモリ上のバイト列のイメージ
|    | 03 | 7F |    | ... |    | 03 | 7F |    |
       ^    ^                    ^    ^
       foo1 のための領域           foo2 のための領域に 03 7F をコピー

では、もし次のような struct だとどうなるでしょうか。

struct Baz {
    let v1: UInt8
    let v2: UInt8
    let v3: UInt8
    ...
    let v100: UInt8
}
let baz1 = Baz(...)
let baz2 = baz1

Baz 型の変数を宣言すると 100 バイトの領域が確保されます。 baz1 から baz2 へ代入すると、 100 バイト分もデータをコピーしないといけません。

プログラミングをしているともっと大きなデータを扱わなければならないことがあります。例えば、配列で 100 万個の要素が必要なこともあるでしょう。その代入(引数に渡したり、戻り値で受け取ったりも含みます)の度にすべてをコピーしていると、プログラムのパフォーマンスはひどいことになってしまいます。

では、参照型だとどうでしょうか。

class Qux {
    let v1: UInt8
    let v2: UInt8
    let v3: UInt8
    ...
    let v1000: UInt8
    init(...)
}
let qux1 = Qux(...)
let qux2 = qux1

この場合、 qux1 から qux2 にコピーされるのは qux1 に格納されたアドレスの値だけです。アドレスは、 32 bit アーキテクチャなら 4 バイト、 64 bit アーキテクチャなら 8 バイトのデータです。 qux1 から qux2 への代入は、たとえそのアドレスが参照する値がどれだけ巨大でも、わずか 4 バイト(または 8 バイト)のコピーで済みます。

# メモリ上のバイト列のイメージ
| 7F | AB | 52 | C4 | ... | 7F | AB | 52 | C4 |    |
  ^    ^    ^    ^          ^    ^
  qux1 のための領域           qux2 のための領域に 7F AB 52 C4 をコピー

参照型の副作用(または、参照型がうれしい理由その2)

参照型は、次のような現象を引き起こします。

// 値型の場合
var foo1 = Foo(a: 3, b: 127)
var foo2 = foo1
foo2.a = 5
println(foo1.a) // 3 ( foo2 を変更しても foo1 は変更されない)
// 参照型の場合
var bar1 = Bar(a: 3, b: 127)
var bar2 = bar1
bar2.a = 5
println(bar1.a) // 5 ( bar2 を変更したら bar1 も変更された)

これは、 bar1bar2 には同じアドレスの値が格納されており、その実体となる値はメモリ上に一つしか存在しないためです。

例えば、 bar1bar2 に格納されている値(アドレス)が 098E6F1A だったとします。すると、 2 行目が終了した時点では 098E6F1A 番地からの 2 バイトには 037F が格納されているはずです。次に 3 行目で bar2.a = 5 が実行されると、 098E6F1A 番地からの 2 バイトは 057F に書き換えられてしまいます。 bar1 も同じ 098E6F1A 番地を参照しているので、 bar1.a5 になってしまうわけです。

# メモリ上のバイト列のイメージ

# bar2.a = 5 の実行前
| 09 | 8E | 6F | 1A | ... | 09 | 8E | 6F | 1A | ... | 03 | 7F |    |
  ^    ^    ^    ^          ^    ^    ^    ^          ^
  bar1 のための領域           bar2 のための領域           098E6F1A 番地

# bar2.a = 5 の実行後
| 09 | 8E | 6F | 1A | ... | 09 | 8E | 6F | 1A | ... | 05 | 7F |    |
  ^    ^    ^    ^          ^    ^    ^    ^          ^
  bar1 のための領域           bar2 のための領域           05 に書き換えた

このような挙動は、参照型に慣れていない人にとってはわかりづらく感じられるでしょうし、参照型の挙動を正しく理解していないと思わぬバグを生む可能性があります。

ただし、参照型のそのような挙動は悪いことだとは限りません。プログラム全体で状態を共有して、ある箇所で値を書き換えたら他の箇所にも反映されてほしいということもあります。大切なのは、値型と参照型の違いを理解して、正しく使い分けることです。



  1. エンディアンについては本題とは関係なく話が複雑になるだけなので意図的に無視しています。