83
56

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

純粋値型Swift

Last updated at Posted at 2017-07-23

これは Swift Tweets 2017 Summer での発表を編集したものです。


Swift は値型を中心とした言語です。この発表では、仮に参照型を使わずに値型だけを使った場合、 Swift でどれだけのことができるかを探ります。

Swift が登場した当初、僕は Swift が値型を中心としていることに驚きました。参照型でないとできないことがあるし、比較的新しい言語は値型を参照型に見せかけてすべてが参照型であるかのように振る舞うことが多いように感じていたからです。

しかし、 Swift を使うにつれて、値型だけでできることは当初考えていたよりもずっと幅広いことがわかってきました。また、値型の特性を活かすことで、参照型では面倒なケースでコードを簡潔に保てることもわかってきました。

Swift が最も利用されている iOS アプリ開発では、 UIKit 等の Objective-C 由来の Framework に引っ張られて参照型中心のプログラミングになってしまうことが多いと思います。

そのため、せっかく値型中心の言語なのに Swift で実際に値型中心のプログラミングをしている人は多くないように思います。この発表を通して、値型中心でプログラミングをすることに対して興味を持っていただけるとうれしいです。

参照型で起こりがちな問題

まず初めに、値型だと何がうれしいのかを説明するために、参照型で辛いケースをいくつか紹介します。

ケース1

次のコードを実行したとき何が起こるでしょうか?最後の行で age をインクリメントしていますが、その結果は group に反映されるでしょうか?

class User {
  let name: String
  var age: Int
  init(...) { ... }
}

let owner: User = group.owner
owner.age += 1 // 何が起こる?

答えは「わからない」です。 group.owner の実装によります。内部に保持している User インスタンスをそのまま返していれば反映されますし、それを避けるためにインスタンスをコピーして返していれば反映されません。

このように、ミュータブルな参照型のインスタンスを取り回すと、状態が共有されるのかを型で表現することができません。つまり、コメントやドキュメントで管理しなければならないということです。裏を返せば、状態の共有について曖昧にできる余地があるということです。 owner を変更したらそれが group に反映されるかもしれないしされないかもしれない、でも多くのコードでは関係ないから気にしない、そんな状況です。

これは確信を持って言えることですが、仕組み上曖昧にできるものはいつか必ず曖昧になります。 ドキュメントに記載していたとしても、一箇所でも更新されてない箇所があれば、もはや全体が信頼できなくなります。

個別にドキュメントするのではなく、オブジェクトを return するときには必ずコピー(防御的コピー)するポリシーに統一するとどうでしょう?当初はうまく機能するかもしれません。しかし、ディープコピーでなくシャローコピーになっている箇所があったら?利用しているライブラリのポリシーが異なったら?開発を引き継いだ人がポリシーを理解していなかったら?緊急対応時にコピーを忘れてしまったら?機械的な保証なしに、常にプロジェクト全体でミスが一つもなくポリシーが守られていると自信を持てるでしょうか。多大な労力を支払って人力でチェックをすれば100%ではなくてもある程度の保証はできるかもしれません。しかし、それでもなお不安は残ります。機械に任せることができれば、ノーコストで曖昧さをなくし、安心・安全を手に入れることができます。

曖昧さの原因はミスだけではありません。パフォーマンスの問題でコピーが許容できない箇所で意図的にコピーを避けるかもしれません。一箇所でも例外を設けると常に例外を意識しなければならなくなります。そして、例外はドキュメントに記し、メンテを漏らしてはいけません。

値型であればそのような問題は起こりません。状態が共有されることがあり得ないからです。先の例で Userstruct だったら、実装によらず age の変更が group に反映されることはありません。

struct User {
  let name: String
  var age: Int
}

var owner: User = group.owner
owner.age += 1 // group には反映されない

ケース2

参照型でもイミュータブルな型を使ってそのような問題に対処できます。イミュータブルな型のインスタンスは変更することができないので、コピーを返さずそのインスタンスそのものを返しても、共有された状態が変更されてしまうことはありません。

Haskell のように言語全体でイミュータブルという例もありますが、そこまでしなくてもミュータブルな範囲を最小限に留めイミュータブル中心のコードにすることで、状態が共有されてしまう問題を気にせずにコードを書ける範囲を広げられます。

イミュータブルな型を使う場合には、値を書き換えたい場合にはインスタンスごと作り変えます(↓のコードで uservar なのはイミュータビリティとは関係なく変数か定数かの話です。異なる概念なので注意して下さい)。

class User {
  let name: String
  let age: Int // let にして User をイミュータブルに
  init(...) { ... }
}

var user: User = ...
// インスタンスを作り変えて値を更新
user = User(name: user.name, age: user.age + 1)

しかし、先程のように group の中の age を変更したいとするとどうなるでしょう? Group もイミュータブルな class だとすると次のようになります。さすがに面倒です。

var group: Group = ...
var owner: User = group.user
owner = User(name: owner.name, age: owner.age + 1)
group = Group(..., owner: owner, ...)

値型なら簡単です。もし GroupUserstruct なら次のように書けます。たとえ groupuservar にして変更可能にしても、値型であれば共有された状態が変更されることはありません。

group.user.age += 1

余談ですが、イミュータブルな世界でこれを簡単にするために編み出された Lens という手法があります。去年の @tryswiftconf のスピーカーである @chriseidhof さんが解説しているので紹介します。

さて、今のコードの書き換えは示唆的です。イミュータブルな class を使って書かれたコードが struct を使ったコードに書き換えられました。これは、 イミュータブルな class とミュータブルな struct が対応する ことを示しています。

値型では代入時に値がコピーされるためインスタンスが共有されません。そのため、次のように b を書き換えても a が影響を受けることはありません。イミュータブルな参照型のコードと対応しているのがわかると思います。

// 値型
var a: User = ...
var b: User = a
b.age += 1 // a は変更されない

// 参照型(イミュータブル)
var a: User = ...
var b: User = a
b = User(name: b.name, age: b.age + 1) // a は変更されない

この対応については僕や @omochimetaru が過去に投稿しているので詳しくはそちらを御覧ください。

ミュータブルな値型とイミュータブルな参照型が等価だということは、値型を使えばイミュータブルと等しい安全性を手に入れられるということです。しかも、値を更新するときに面倒なことをする必要はありません。素晴らしいですね!

なお、他の言語に目を向ければ、たとえば Kotlin の data class では更新のためのメソッドを自動生成してくれるなど、イミュータブルプログラミングの負担を軽減してくれる言語機能があったりします。

// Kotlin
// val は Swift の let に相当
data class User(val name: String, val age: Int)

var user = User(name = "name", age = 42)
// copy は指定されたプロパティが更新された新しいインスタンスを返すメソッド
user = user.copy(age = user.age + 1)

しかし、幾分か軽減されたとはいえ、ネストされた値を更新しようとすると面倒だし、可読性も低いです。

// Kotlin
group = group.copy(owner = group.owner.let { it.copy(age = it.age + 1) })

再掲しますが、 struct だと↓です。

group.user.age += 1


### ケース3

参照型でミュータブルな世界とイミュータブルな世界を分けるためには、両方でほしいものは二重に実装して使い分ける必要があるということです。その代表格がコレクションです。

ミュータブルなコレクションとイミュータブルなコレクションを別々の `class` で実装することを考えてみましょう。このとき気をつけないといけないのは、イミュータブルなコレクションを継承してミュータブルなコレクションを作ってはいけないということです。イミュータブルなコレクションを継承して `append` 等のメソッドを追加してしまってはいけません。まさにそのような関係になってしまっているのが `NSArray` と `NSMutableArray` です。 `NSArray` クラスのインスタンスはイミュータブルですが、 `NSArray` 型変数には `NSMutableArray` のインスタンスも代入できるため、 `NSArray` 型としてはイミュータブルであることを保証できません。

では、派生関係のない完全に独立な `class` としてそれらを実装すれば良いのでしょうか。しかし、ミュータブル/イミュータブルなコレクションをまとめて取り扱いたいこともあるはずです。そのためには、次のように共通のスーパータイプが必要になります。なお、 `ImmutableList` が `final` になっているのは、継承を許すとミュータブルなサブタイプが作れてしまうからです。

```java
// Java
interface List<T> { ... } // 共通のスーパータイプ
final class ImmutableList<T> implements List<T> { ... }
class MutableList<T> implements List<T> { ... }

値型だとこのような三つの型は必要なく、一つの型で済ませられます。 varlet かでミュータビリティをコントロールできますし、 var であっても共有された状態が変更されてしまうことはありません。

コレクションは標準ライブラリが用意してくれるので、多重実装が必要でも特に面倒なことはないと思うかもしれません。しかし、同じことは自作の型でも起こります。

たとえば、僕は EasyImagy という、画像を簡単に扱うためのライブラリを作っています。次のように簡単に画像を取り扱えるものですが、特筆すべきはこの Imagestruct で実装されており値型なことです。

var image = Image<RGBA>(named: "ImageName")!

print(image[x, y])
image[x, y] = RGBA(red: 255, green: 0, blue: 0, alpha: 127)
image[x, y] = RGBA(0xFF00007F) // red: 255, green: 0, blue: 0, alpha: 127

// Iterates over all pixels
for pixel in image {
    // ...
}

値型なので、 Array などと同じく、 Imagevar ならミュータブルに、 let ならイミュータブルになります。三つの型は必要ありません。一つで十分です。

小まとめ

  • 参照型では状態共有問題を避けるためにイミュータブルを使う
  • イミュータブルには特有の煩わしさがある
  • 値型はイミュータブルな参照型と似ている
  • 値型なら状態共有の問題もなく更新も簡単

値型の問題と解決策

値型は確かに、状態の共有に関するややこしい問題を回避するという点では優れています。しかし、値型ならではの次のような問題もあります。

  • コピーにコストがかかる
  • 継承してサブタイプを作れない

コピーコストについては、値のサイズが大きければ大きいほど問題は深刻になります。値型の値は代入される度に丸ごとコピーされます。もし、 100 万個の要素を持つコレクションを引数に渡しただけで 100 個の要素分のコピーが実行されるならとても実用には耐えません。

継承については、オブジェクト指向言語において継承によるサブタイピングは抽象化を実現するための中心的な手段です。抽象化できないコードは冗長になり、メンテも難しくなります。

コピーコストについて

Swift のコレクション( Array, Set, Dictionary など)は Copy-on-write (以下 COW )という仕組みによってコピーコストを最小限に留めています。

たとえば、次のコードでは Array は値型であるにも関わらず代入の時点ではコピーは発生しません(厳密には、 Array という struct のコピーは発生しますが、そこから参照されているバッファのコピーは発生しません)。

let a = [2, 3, 5, 7]
var b = a // ここでコピーは発生しない
print(b)

しかし、次のように b を変更しようとすると、その時点で b はバッファのコピーを作成し、自分が参照するバッファをコピーしたものに差し替えます。

let a = [2, 3, 5, 7]
var b = a // ここでコピーは発生しない
print(b)
b[2] = 4 // ここでコピーが発生する

ba が代入された時点では ab はバッファを共有していますが、 b を変更しようとした瞬間に b がバッファのコピーを作成し、共有をやめるわけです。

COW がおもしろいのは、次のように b を再度変更してもコピーが発生しないことです。

let a = [2, 3, 5, 7]
var b = a // ここでコピーは発生しない
print(b)
b[2] = 4 // ここでコピーが発生する
b[3] = 5 // ここではコピーは発生しない

このとき、 ab はそれぞれ別々のバッファを参照しており、それらは共有されていません。そのため b[3] = 5 でバッファを直接書き換えてしまっても共有状態が書き換えられてしまうことはなく、 Array の値型としての振る舞いに問題は起こりません。

このような仕組みで、Swiftはコレクションのような巨大な値でもコピーコストを避けながら値型として振る舞わせているわけです。

先程紹介した EasyImagy でもこの特性を活かしています。画像のような巨大な値をイミュータブルな class で表現しようとすると変更の度にとんでもないコストがかかってしまいます。

// Image がイミュータブルな class の場合
var image = image.updatePixel(newPixel, at: x, y) // 1ピクセルのために画像丸ごと再生成

また、イニシャライザで防御的コピーをすると、(数百万画素の画像なら)数百万要素を無駄にコピーしてしまうこともあります。

// Java
// Image がイミュータブルな class でコレクションが参照型の場合
class Image<Pixel> {
  private List<Pixel> pixels;
  public Image(int width, int height, List<Pixel> pixels) {
    ...
    this.pixels = new ArrayList<>(pixels); // 防御的コピー
  }
}

Image に渡す pixels は型の上ではミュータブルだとしても実は変更されないかもしれません。しかし、 Image をイミュータブルに保つには防御的コピーで先行してコストを支払わなければなりません。コレクションが値型で COW なら本当に必要になるまでコピーを遅延できます。

COW に関しては、 "ライブコーディングでArrayを実装してCopy-on-writeの挙動を理解する" というタイトルで iOSDC に応募しているので、採用されれば9月に詳しく話すことになるかもしれません。

継承とサブタイピングについて

前置きが長くなりましたがここからが本題です。オブジェクト指向にどっぷり浸かった身としては、継承なしでどこまでコードが書けるのか不安になります。

実は、 Swift では値型でもプロトコルを使えばサブタイピングができます。次のような Animal , Cat, Dog を考えてみましょう。

protocol Animal { ...}
struct Cat: Animal { ... }
struct Dog: Animal { ... }

なお、前に AnimalCat を使って説明したら昔ながらのオブジェクト指向みたいだと言われたので一応断っておきますが、オブジェクト指向を知らない人に AnimalCat を使って継承の説明するのとは話が違います。オブジェクト指向を理解している人に対する説明で、型の派生関係だけが必要なときは、 AnimalCat, Dog は、どれがスーパータイプでどれがサブタイプがひと目でわかるという意味で優れていると思います。(おことわり終わり)

プロトコルを使えば、値型である CatDog であっても、抽象型であるAnimalとして値を取り回し処理を記述することができます。

func useAnimal(_ animal: Animal) { ... }

しかし、今は試しにプロトコル型の変数(や引数、戻り値)を作ることも禁止してみましょう( Animal というプロトコルを作るのは OK だけど、 Animal 型変数は禁止)。サブタイピングによるポリモーフィズムがない、そんな世界です。これはトリッキーな制約ではなくて、 Swift では associatedtype を持ったプロトコル型の変数を作ることができません( Swift 4 時点)。( associatedtype 等を持たない)普通のプロトコルにだけそれが許可されていることの方が奇妙だとも言えます。

このような状態(値型しか使わず、プロトコル型変数も使わない)の Swift を 「純粋値型Swift」 と呼ぶことにしましょう。

「純粋値型Swift」では、プロトコルである Animal 型変数が使えません。それだと、一見 CatDog を抽象的に扱うコードが書けないように思います。抽象化できないとコードが冗長になるので困ります。しかし、 Animal 型変数自体が使えなくても、実はジェネリクスを使えば次のように抽象化することができます。

func useAnimal<A: Animal>(_ animal: A) { ... }

この animal の型は Animal ではなく Animal を満たす具象型 A となります。 AAnimal を満たすので、 Animal のメソッドはすべて使えることとなり、 Animal を引数に受けた場合と同じようにコードを記述することができます。

この方法を使えば、 associatedtype を持つプロトコルに対しても抽象的なコードを書くことができます。

// sequence から index 番目の要素を取り出す関数
func element<S: Sequence>(of sequence: S, at index: Int) -> S.Iterator.Element {
  precondition(index >= 0)
  var iterator = sequence.makeIterator()
  for _ in 0..<index {
    _ = iterator.next()
  }
  return iterator.next()!
}

いかがでしょう?サブタイプポリモーフィズムがなくても結構コードが書けそうな気持ちになってきませんか?

「純粋値型Swift」は Haskell とよく似ています。先程の話の通り、値型はイミュータブルな参照型と等価です。 Haskell ではすべてがイミュータブルです。また、「純粋値型Swift」と同じく Haskell にはサブタイピングがありません( GHC という事実上標準となっている処理系の拡張を使えば RankNTypes を使って同様のことはできます)。しかし、それでも Haskell を使って問題なくコードを書くことができます。

これはつまり、「純粋値型Swift」でも相当のことは問題なく行えるということです。 Swift の標準ライブラリでも、プロトコル型で受けるものはほとんどなく、多くがジェネリクスを使った方法で実装されています。

しかも、ジェネリクスを使った方法にはパフォーマンス上の利点もあります。プロトコル型で値を取り回すと動的ディスパッチになってしまいますが、ジェネリクスであればスペシャライズして静的ディスパッチにできることがあります。

// 先程のジェネリックな element(of:at:) を
element(of: ["a", "b", "c"], at: 1)
// に対して↓を呼び出しているようにスペシャライズできる。
// <S: Sequence> が消えて S が [String] に置き換わっている。
func element(of sequence: [String], at index: Int) -> String {
  precondition(index >= 0)
  var iterator = sequence.makeIterator()
  for _ in 0..<index {
    _ = iterator.next()
  }
  return iterator.next()!
}

これなら sequenceiterator の型をコンパイル時にすべて決定できるので、それらに対するメソッドコールを(動的ディスパッチではなく)静的ディスパッチにすることができます。

値型だけを使うことで

  • 動的ディスパッチによるオーバーヘッドの回避
  • Haskell と同じような堅牢性
  • 状態の更新のためにイミュータブルなオブジェクトを作り変えるコーディング上の煩わしさやオーバーヘッドの回避

が実現できるならおもしろくないですか?

補足的トピック

値型ならinoutが活きる

inout (参照渡し)はあまり使いみちがないと思われがちですが、値型と組み合わせることで威力を発揮します。

Swift 4 では inout を利用した新しい reduce が加わるのですが、それを使うとこれまで [User] から idUser[String: User] を作るのに次のようにしないといけなかったのが、

var result: [Int: User] = [:]
for user in users {
    result[user.id] = user
}

このように書くことができるようになります。 inout のおかげでループを書かなくても高階関数/メソッドを使って値型を逐次編集できるということです。

let result = users.reduce(into: [Int: User]()) { $0[$1.id] = $1 }

これについてはこちらの投稿に詳しく書いたので御覧ください。

他にも、次のような update メソッドを用意すれば mapArray やその要素を作り変えなくても、ループを回すのと同じパフォーマンスで簡単に各要素の更新を実行することができます。

extension Array {
    public mutating func update(_ operation: (inout Element) throws -> ()) rethrows {
        for i in startIndex..<endIndex {
            try operation(&self[i])
        }
    }
}

array.update { $0.foo *= 2 }

値型と再帰の辛さ

値型で問題になるのが再帰した型を作れないことです。たとえば、次のような型は無限に再帰してしまい、サイズが発散してしまいます。

struct Foo {
  var bar: Int
  var foo: Foo // 無限に再帰する
}

再帰データ構造を表せないのは値型の弱点です。そのため Swift には indirect というキーワードがあります。次のようにして連結リストを作ることができます。

enum List<T> {
  case none
  indirect case some(T, List<T>)
}

indirect だと何が起こるのでしょうか? Swift Programming Language には "insert the necessary layer of indirection" と書かれていますが、ヒープに領域が確保され値がコピーされるものと考えられます。

問題になるのが struct です。 enum では indirect が使えますが、 struct では indirect が使えません( Swift 4 時点)。そのため、次のようなことはできません。

struct List<T> {
  let element: T
  indirect let next: List<T>?
}

struct で再帰をするには、次のような Box を作って一度 indirect を挟む必要があります。これはちょっと面倒です。

enum Box<T> {
  indirect case value(T)
}

struct List<T> {
  let element: T
  let next: Box<List<T>>?
}

再帰に関する煩わしさが生まれるのは、イミュータブルな参照型にはない値型のデメリットです。

クロージャと状態の共有

Swiftで値型だけでコードを書こうとしたときの 最大の壁 やっかいなのがクロージャです。

@rintaro さんに教えていただいて、発表時から内容を変更しています。)

Swift のクロージャ(や関数)は参照型です。純粋な関数であればイミュータブルなので参照型であっても値型のように振る舞うことに問題はありません。しかし、状態をキャプチャしたクロージャはそうはいきません。

たとえば、次のようなコードを書くとaという状態が共有されてしまいます。

var a: Int = 0

let b: () -> Int = {
  return a
}

a += 1

print(a)   // 1
print(b()) // 1

これを解決するにはクロージャの capture list を使います。 capture list によって状態が共有されなくなります(このような capture list の使い方は @rintaro さんの指摘と、↑を読んで今回初めて知りました)。

var a: Int = 0

let b: () -> Int = { [a] in
  return a
}

a += 1

print(a)   // 1
print(b()) // 0

ジェネリクスでできないこと

ジェネリクスがあればサブタイピングと似た抽象化ができましたが、ジェネリクスではできないこともあります。

サブタイピングを使えば次のような関数を作れます。

func useAnimals(_ animals: [Animal]) { ... }

しかし、もしジェネリクスで同じような関数を作ったとしましょう。

func useAnimals<A: Animal>(_ animals: [A]) { ... }

このとき、次のように CatDog が混ざった Array を受け取れるのは [Animal] の場合だけです。 [A]A はあくまで特定の具象型を表すので、 CatDog を同時に満たすことはできません。

useAnimals([Cat(), Dog()])

本当に継承がなくても大丈夫か

継承によって具象型を拡張したい場合はどうすれば良いでしょうか?たとえば、 UIViewController は具象クラスですが拡張された様々なサブクラスが存在します。継承することのできない値型ではそのようなことはできません。

しかし、本当に具象型の継承が必要でしょうか?継承によって class を拡張することは問題を引き起こしがちなので、基本的には final class にして継承は最小限に留めるというのが最近の潮流だと思います。

型の複雑な派生関係はプロトコル間の継承で表し、実装は mix-in 的に protocol extension で挿し込むことで大抵のケースに対応できるのではないでしょうか。逆に、具象型を継承したいと思ったときに見直すと設計がまずかったということも多いと思います。

どんなケースでも不要とまでは言えませんが、多くのケースでは具象型の継承ができなくても困らないのではないでしょうか。

(余談)HaskellにできてSwiftにできないこと

値型しか使わず、プロトコル型変数も使わない Swift は Haskell に似ていると言いましたが、 Haskell にはできるけど Swift にはできないこともあります。

ArrayOptionalmap を持っています。次のコードのようにとても似ていることがわかると思います(コードは rethrows など話題と関係ない部分を省略し簡略化しています)。

struct Array<T> {
  func map<U>(_ f: (T) -> U) -> Array<U>
}

enum Optional<T> {
  func map<U>(_ f: (T) -> U) -> Optional<U>
}

似たものは抽象化したいですが、 Swift は ArrayOptionalmap に関して抽象化することができません。 Haskell にはできます。 map を持つ型を抽象化したものを Functor と呼びますが、任意の Functor に対して抽象的にコードを書くことができます。

ただ、これはトピックとしてはおもしろいですが、 Swift では class を使おうが Functor のような抽象化ができるわけではないので、値型だけを使って Swift でコードを書いてみるというこの発表の主題とはあまり関係がありません。

まとめ

  • 参照型では状態共有問題を避けるためにイミュータブルを使う
  • イミュータブルには特有の煩わしさがある
  • 値型はイミュータブルな参照型と似ている
  • 値型なら状態共有の問題もなく更新も簡単
  • コレクションは COW でコピーコストを低減
  • サブタイピングを禁止して値型だけで書いた Swift は Haskell に似ている
  • サブタイピングがなくてもジェネリクスを使って大抵の抽象化はできる
  • 値型と inout を組み合わせると楽しい
  • 再帰データ構造に関しては値型に煩わしさがある

以上です。これをきっかけに値型でプログラミングすることに興味を持っていただけるとうれしいです。

83
56
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
83
56

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?