💎

Swiftのstructのイミュータビリティ

2025/01/23に公開
struct Foo {
    var value: Int
}

この struct はプロパティ valuevar で宣言されているので、一般的にはミュータブルな struct と呼ばれます。しかし、 struct はミュータブルでも本質的にはイミュータブルクラスと同じ ような性質を持ちます。このことを順を追って説明します。

struct のミュータビリティ

var プロパティを持つ structlet プロパティを持つ struct を比較し、 struct のミュータビリティ(インスタンスの状態を変更可能かどうか)について考えます。

var プロパティを持つ struct

まず、先の var プロパティ value を持つ struct Foo を考えます。

struct Foo {
    var value: Int
}

次に、この Fooincrement メソッドを実装します。

extension Foo {
    mutating func increment() {
        value += 1
    }
}

value += 1 するだけの何の変哲もないメソッドです。 ただし、 struct のプロパティを変更するメソッドを宣言するには mutating キーワードが必要なため、 mutating func となっていることに注意 してください。

この increment メソッドは、次のように使うことができます。

var foo: Foo = .init(value: 0)
foo.increment()
print(foo.value) // 1

↑のコードでは value の初期値が 0 なので、 increment を呼び出すと foo.value1 に変化します。

let プロパティを持つ struct

次に、 valuevar から let に変えてみましょう。

struct Foo {
    let value: Int
}

この struct は、プロパティ value の値を変更できないため、一般的にはイミュータブルな struct と呼ばれます。

もちろん、 var value のときと同じように increment を実装しようとするとエラーになります。

extension Foo {
    mutating func increment() {
        value += 1 // ⛔️ コンパイルエラー
    }
}

valuelet で宣言されているので、 value += 1 で変更することができません。

しかし、実は次のようにすると increment メソッドを実装することができます。

extension Foo {
    mutating func increment() {
        self = Foo(value: value + 1) // ✅ これならOK
    }
}

↑のやっていることは、 value + 1 で新しい Foo インスタンスを作り、 self 自体をそれで上書きするということです。

どうしてこんなことができるのでしょうか?それを知るためには

  • メソッドとは何か
  • mutating func とは何か

の二つを知る必要があります。

メソッドとは何か

ここで、次のようなメソッド bar を考えてみましょう。

extension Foo {
    func bar() -> Int {
        value * value
    }
}

これも、 value を2乗して return するだけのメソッドです。

このメソッドは次のようにして使うことができます。

let foo: Foo = .init(value: 3)
print(foo.bar()) // 9

value3 として初期化しているので、 foo.bar() の値は 3 * 39 になります。

さて、この bar メソッドは、丁寧に(明示的に self. を)書くと次のように書くことができます。

extension Foo {
    func bar() -> Int {
        self.value * self.value
    }
}

↑のように、メソッド内では self にアクセスすることができます。この self はどこから来たのでしょうか?

実は、 メソッドとは、暗黙の第1引数 self を持つ関数のようなもの です。

つまり、 bar メソッドは次のような bar 関数として書くことができます。

func bar(_ self: Foo) -> Int {
    self.value * self.value
}

この場合、第1引数に Foo インスタンスを渡して呼び出すことになります。

let foo: Foo = .init(value: 3)
print(bar(foo))  // 9

この 関数呼び出し bar(foo) と見比べると、メソッド呼び出し foo.bar() とは、 bar メソッドの暗黙の第1引数 selffoo を渡して bar メソッドを呼び出すこと だと理解できます。

mutating func とは何か

var プロパティを持つ struct Foo について、 increment メソッドは次のように書けました。

extension Foo {
    mutating func increment() {
        self.value += 1
    }
}

↑のコードでも、明示的に self. を追加してあります。

この increment メソッドは、 value の値を変更するので mutating キーワードがないとコンパイルエラーになります。

extension Foo {
    func increment() {
        self.value += 1 // ⛔️ コンパイルエラー
    }
}

なぜ、わざわざ mutating キーワードを付けないといけないのでしょうか?これは、 increment メソッドを関数の形で書き直してみるとわかります。

increment メソッドを、明示的に第1引数 self を持つ関数の形で書き換えると次のようになります。

func increment(_ self: Foo) {
    self.value += 1 // ⛔️ コンパイルエラー
}

self は引数として渡されますが、引数は定数( let で宣言されている)扱いなので、その値を変更することはできません。そのため、 self.value += 1 でエラーとなります。

これをコンパイル可能にするには、次のように selfinout を付与します。

func increment(_ self: inout Foo) {
    self.value += 1 // ✅ これならOK
}

これを、 mutating 付きで実装された increment メソッド(↓に再掲)と見比べてみましょう。

extension Foo {
    mutating func increment() {
        self.value += 1
    }
}

つまり mutating とは、メソッドの持つ暗黙の第1引数 selfinout を付与するもの だと理解することができます。

(続) let プロパティを持つ struct

さて、ここで let プロパティ value を持つ struct Foo に話を戻しましょう。

たとえ valuelet で宣言されていても、次のようにすれば increment メソッドを実装できるという話でした。

extension Foo {
    mutating func increment() {
        self = Foo(value: self.value + 1) // ✅ これならOK
    }
}

↑のコードでは self 自身を書き換えるような実装をしていますが、これがなぜ問題ないのか、このメソッドを関数の形で書き換えてみるとわかります。

func increment(_ self: inout Foo) {
    self = Foo(value: self.value + 1) // ✅ inout引数は変更可能
}

inout が付与された引数には値を代入して呼び出し元に反映させることができます。 mutating func では暗黙の第1引数 selfinout が付与されているので、 self 自体を書き換えることも可能ということです。

このように宣言された increment メソッドは、たとえ value プロパティが let で宣言されていたとしても、 var で宣言されていた場合とまったく同じように使うことができます。

var foo: Foo = .init(value: 0)
foo.increment() // ✅ valueがletでもOK
print(foo.value) // 1

struct Foolet プロパティのみを持つのでイミュータブルだったはずなのに、まるでミュータブルな struct と同じように状態を変更できてしまいました。

structのミュータビリティを決めるもの

var value のときと let value のときを比べてみると、 Foo のミュータビリティはプロパティを var で宣言するか let で宣言するかで決まらないと考えられます。では、 struct のミュータビリティを決めるものは何でしょうか?

struct のミュータビリティを決めるのは、変数宣言時の var / let です。

たとえ Foovalue プロパティが var で宣言されていても let で宣言されていても、次のコードはコンパイルが通ります。

var foo: Foo = .init(value: 0)
foo.increment() // ✅ fooがvarで宣言されているのでOK
print(foo.value) // 1

しかし、 foolet で宣言されていると、どちらの場合でも increment メソッドの呼び出しはコンパイルエラーとなります。

let foo: Foo = .init(value: 0)
foo.increment() // ⛔️ fooがletで宣言されているのでエラー
print(foo.value)

このように、 struct のミュータビリティは変数宣言時に var を使うか let を使うかで決まります。これは値型の性質によるものです。

クラスなどの参照型では、変数にはインスタンスのアドレスが格納され、インスタンスの実体は別の領域に存在します。しかし、値型では変数のために確保されたメモリ領域に直接インスタンスのデータが書き込まれます。言い換えると、 値型のインスタンスは変数のために確保されたメモリ領域と一体 であると言えます。

そのため、変数のために確保されたメモリ領域が変更可能かどうか、つまり変数が var / let のどちらで宣言されているかが、インスタンスを変更可能かどうか(ミュータビリティ)を決めることになります。これは、参照型との大きな違いです。参照型(クラスなど)では、インスタンスと変数は独立に存在するため、プロパティが var / let のどちらで宣言されるかがインスタンスが変更可能かを決めます。

値型のミュータビリティを参照型と同じように考えてはいけない ということに注意してください。インスタンスと変数が一体である値型では、変数自体の可変性がインスタンスのミュータビリティを決めるのです。

イミュータブルクラスとの比較

初めに struct はミュータブルでも本質的にはイミュータブルクラスと同じ ような性質を持つと言いましたが、それを説明するために今度はイミュータブルクラスについて考えてみます。

今度は Foo をクラスとして宣言します。

final class Foo {
    let value: Int
}

value プロパティが let で宣言されており、 let プロパティのみを持つので、この Foo クラスはイミュータブルクラスです。

このイミュータブルクラス Foo に、先ほどと同じように mutating を使って increment メソッドを実装できないか考えてみます。

extension Foo {
    // ⛔️ これはSwiftの構文の問題でできない
    mutating func increment() {
        self = Foo(value: self.value + 1)
    }
}

残念ながら、Swiftでは参照型のメソッドに mutating を付与することきません。そのため、↑はコンパイルエラーとなります。

しかし、これは本質的な問題ではなく、単にSwiftが構文上禁止しているだけです。 increment を関数の形で実装することはできます。

// ✅ これならOK
func increment(_ self: inout Foo) {
    self = Foo(value: self.value + 1)
}

この increment 関数は、次のように使うことができます。

var foo: Foo = .init(value: 0)
increment(&foo) // ✅ これならfooを変更可
print(foo.value) // 1

なんと、イミュータブルクラスでも foo.value の値を変更することができました。

もちろん、これはイミュータブルクラスのインスタンスの value を書き換えたわけではありません。 increment(&foo)foo = Foo(value: foo.value + 1) したのと同じであり、新しいインスタンスを作って変数 foo に再代入しただけです。しかし、見かけ上は struct の場合と同じことが起こっています。

イミュータブルクラスでは、インスタンスの状態を変更することはできません。そのため、 イミュータブルクラスの型の変数に書かれたアドレスは、その変数が表す状態と一体 であると言えます。

つまり、 struct でもイミュータブルクラスでも、変数が表す状態を変更可能かどうかは、その変数が var / let のどちらで宣言されているかで決まる ということになります。このことから、状態の可変性について、 struct とイミュータブルクラスは本質的に同じ ということができます。

ミュータブルクラスとの比較

struct とイミュータブルクラスが似ていることを、ミュータブルクラスとの比較で見てみましょう。

今度は、次のようなミュータブルクラス Foo を考えます。

final class Foo {
    var value: Int
}

value プロパティが var で宣言されているのでミュータブルクラスです。

この Foo クラスの increment メソッドは次のように実装できます。

extension Foo {
    func increment() {
        value += 1
    }
}

valuevar で宣言されているので、単純に value += 1 と書くことができます。

この increment メソッドを使うコードは次のようになります。

let foo: Foo = .init(value: 0)
foo.increment()
print(foo.value) // 1

次に、 foo.increment() する前に、 foo を別の変数 foo2 に代入すると、↓のような結果となります。

// Fooがミュータブルクラスの場合
let foo: Foo = .init(value: 0)
let foo2 = foo

foo.increment()

print(foo.value)  // 1
print(foo2.value) // 1

Foo は参照型で、変数 foo に格納されているのはインスタンスのアドレスなので) foo2foo を代入した結果、 foofoo2 は同じインスタンスを参照することになります。そのため、 increment の結果、 foo.value だけでなく foo2.value も 1 となります。

もし Foostruct なら次の通りです。

// Fooがstructの場合
var foo: Foo = .init(value: 0)
var foo2 = foo

foo.increment()

print(foo.value)  // 1
print(foo2.value) // 0

struct は値型で、変数 foo に格納されているのはインスタンスそのものなので) foo2foo を代入すると foo の内容が foo2 にコピーされ、 foo2foo のコピーである新しいインスタンスを表すことになります。そのため、 increment の結果は(コピーである) foo2 には影響を与えません( foo2.value0 のままとなります)。

Foo がイミュータブルクラスの場合は次の通りです。

// Fooがイミュータブルクラスの場合
var foo: Foo = .init(value: 0)
var foo2 = foo

increment(&foo)

print(foo.value)  // 1
print(foo2.value) // 0

この場合、 foo2 = foo の結果、 foofoo2 は同じインスタンスを参照することになりますが、その後、 increment メソッドで foo には新しいインスタンス( value1 のもの)のアドレスが代入されます。その結果、 foo2 だけが value0 の古いインスタンスを参照したままとなり、このような挙動となります。

struct の場合とイミュータブルクラスの場合は、背後で起こっていることは異なりますが、見かけ上は同じような挙動をします。言い換えると、 struct の場合とイミュータブルクラスの場合では、その背後の実装詳細は異なるけれども、それらの表しているセマンティクスは同じ であるとも言えます。

一方で、 struct やイミュータブルクラスをミュータブルクラスと比較すると、ミュータブルクラスはまったく異なる挙動をする ことがわかります。

struct とイミュータブルクラスはshared mutable stateを作らない

↑の例では、ミュータブルクラスの場合には foo.value を変更すると foo2.value も変更されました。このように、複数箇所から参照され共有されている状態のことをshared mutable stateと呼びます。

一方で、 struct やイミュータブルクラスの場合には foo.value を変更しても foo2.value は変更されませんでした。 struct ではインスタンスが共有されないため(sharedでないため)、イミュータブルクラスではインスタンスを変更できないため(mutableでないため)、それらを用いてshared mutable stateを作ることはできません。

そのような、 struct とイミュータブルクラスに通じる共通の性質は、たとえば両者がSwift Concurrencyにおいて Sendable に準拠できるなど、その他の共通性も導きます。

final class User: Sendable { // ⛔️ コンパイルエラー
    var name: String
}

final class User: Sendable { // ✅ OK
    let name: String
}

struct User: Sendable { // ✅ OK
    var name: String
}

struct のイミュータビリティ

Swiftは値型中心の言語ですが、多くの言語は参照型中心です。それらの言語では、shared mutable stateのもたらす問題を回避するために、イミュータブルクラスが使われることが多いです。

しかし、イミュータブルクラスを用いると状態を変更するのが大変です。 foo.value += 1foo = Foo(value: foo.value + 1) では、前者の方が簡潔でわかりやすいのは言うまでもないでしょう。

struct であれば、 foo.value += 1 のように簡潔に書くことを可能でありながら、shared mutable stateを作りません。つまり、 struct を使えばミュータブルでもイミュータブルクラス同様の利益を享受可能 なのです。

そして、Swiftは値型中心の言語なので、 struct をはじめとした値型を便利に使うための機能が充実しています。

まとめ

  • struct は本質的にイミュータブルクラスと同じ
  • struct を使えばミュータブルでもイミュータブルクラスと同じ利益を享受できる

Discussion