Swiftのプロトコル | 型としてのプロトコルと委譲デザインパターン

Swiftのプロトコル(Protocols) | 型としてのプロトコル

プロトコルはインターフェイスを提供する機能ですが、プロトコル自体をクラスや構造体のような独自型として取り扱うことが可能です。プロトコル内部に定義したプロパティやメソッド要求などを、普通のプロパティ等のように実装できます。プロトコル自体は中身がありませんので、実際には(プロトコルを採用した)クラス等を実装して、「何をするのか」という部分を決めてやる必要はあります。

ここでは、型としてのプロトコルについて具体例で簡単に触れた後に、委譲デザインパターン(delegation)について詳しく説明します。iOSアプリ開発では委譲デザインパターンが必須で、UI周りのソースコードでは必ず出て来ます。委譲の考え方を押さえておくと、UI周りのコードを触る時にも理解が深まりやすいのではないかと思います。

型としてのプロトコル | Protocol as Types

プロトコルは型でもある

プロトコルはインターフェイスのみで実装のないモノですが、「型」としてコードの中で使うことが出来ます。

具体的には、以下のような使い方があります。

このページで紹介するのは最初の2つです。コレクション型要素の型として使う場合は別ページで説明しますが、基本的な使い方は一緒です。

プロトコルを型として使う具体例 | サイコロクラス

この後使いたいので、公式にあるサイコロクラスをそのまま流用します。

// サイコロクラス
class Dice {
    let sides: Int // サイコロの面の数
    let generator: RandomNumberGenerator // 擬似乱数生成
    init(sides: Int, generator: RandomNumberGenerator) {
        self.sides = sides
        self.generator = generator
    }
    
    // サイコロを振る
    func roll() -> Int {
        return Int(generator.random() * Double(sides)) + 1
    }
}

順番に説明していきます。

サイコロクラスのプロパティは2つ

サイコロクラスDice格納プロパティ(stored properties)を2つ持っていて、

class Dice {
    let sides: Int // サイコロの面の数
    let generator: RandomNumberGenerator // 擬似乱数生成
    ....
}

1つはsidesというサイコロの面の数を決めるプロパティ、もう1つは擬似乱数生成のためのプロパティgeneratorで、この型がプロトコルのRandomNumberGeneratorになっています。

このgeneratorプロパティは、RandomNumberGeneratorプロトコルを採用したあらゆる独自型で初期化することが可能です。後で具体的に使用する際に少し詳しく見てみます。

また、初期化子でこれらのプロパティを初期化しており

init(sides: Int, generator: RandomNumberGenerator) {
    self.sides = sides
    self.generator = generator
}

という形になっています。しつこいですが、RandomNumberGeneratorはプロトコルです。Diceクラスインスタンスを作る時、2つ目のパラメータとして取るのはRandomNumberGeneratorプロトコルを採用した独自型(クラスや構造体など)です。

rollメソッドでサイコロを振る

// サイコロを振る
func roll() -> Int {
    return Int(generator.random() * Double(sides)) + 1
}

whileループを説明する時に擬似乱数を使ったサイコロを導入していましたが、基本的にはそれと同じです。

擬似乱数generatorのメソッドrandom()は、0から1の間の浮動小数点数(Double)を(ほぼ)一様に返します。サイコロを振った時に出る目の最大値は、面の数sidesで決まっているので

generator.random() * Double(sides)

となります。例えば、仮にsides=6とした場合、この演算結果で得られる値は0から6未満の浮動小数点数です。

実際にサイコロを振って欲しい数は1から6までの整数です。したがって、

Int(generator.random() * Double(sides)) + 1

このように、先程の演算全体をIntに変換して、さらに1を加えます。

Intに変換」と書いたのですが、Swiftの基本型は構造体であるということと、Int(...)という書式から、実は型変換に見える書式はInt(_: Double)という初期化子の仕事だということが理解できると思います。

実際にDiceクラスを作って実行してみます。公式では6面でしたが、ここでは12面で12回サイコロを振ってみます。

// 12面サイコロ
var d12 = Dice(sides: 12, generator: LinearCongruentialGenerator())
print("サイコロの目: ", terminator: "")
for _ in 1...12 {
    print(d12.roll(), terminator:" ")
}
print()
//サイコロの目: 5 9 8 10 7 2 7 4 1 7 3 9

出た目を見ると分かりますが、公式の出目の倍になっています。sides6にしてテストすると、出る目が公式と全く同じになるのでさらに分かりやすいかと思います。これは擬似乱数生成コードが全く同じで、乱数の初期値(seed)を変えていないからです。

委譲 | Delegation

委譲デザインパターン

委譲(delegation)というのはデザインパターンの1つで、クラスや構造体がその機能の一部を異なる型のインスタンスに任せることです。ポイントは

異なる型のインスタンス

で、特に「インスタンス」の部分です。実装方法によってはクラスの継承のようなことが可能になります。継承(inheritance)は型自体で機能の引き継ぎを行いますが、委譲ではインスタンスを作って機能を受け渡すことが出来ます。

継承と委譲を簡単な具体例で比較してみます。

// スーパークラス
class SuperClass {
    var name: String
    init(name: String) {
        self.name = name
    }
    
    func show() { print("this is class \(name)") }
}

// 継承で機能を引き継ぐ
class SubClass: SuperClass {
    
}

// 委譲: インスタンス経由で機能を引き継ぐ
class DelegatingClass {
    let instance = SuperClass(name: "DelegatingClass")
    func show() { instance.show() }
}

let inheritance = SubClass(name: "SubClass")
let delegate = DelegatingClass()
inheritance.show()
delegate.show()
//this is class SubClass
//this is class DelegatingClass

スーパークラスSuperClassは、文字列プロパティnameを定義して表示するメソッドshow()を持っています。継承先のSubClassは、単に継承しているだけなので、スーパークラスの機能をそのまま使う事が出来ます。

一方で、委譲を使ったDelegatingClassでは

class DelegatingClass {
    let instance = SuperClass(name: "DelegatingClass")
    func show() { instance.show() }
}

という風に、一度SuperClassのインスタンスを作って、同名のメソッドshow()からスーパークラスのインスタンス経由で実行しています。

上記の例のように、継承でも委譲でも同じ機能を実装可能なことが分かります。ただし、委譲の場合は、内部で作るインスタンスは何でも良いので、その分実装の幅が広がります。

この例だと、明示的に内部でインスタンス生成していますが、通常はインスタンスの参照だけを持っていて、外からインスタンスを代入することでそれを動的に切り替えられるようにします。そのようなケースで、設計図としてのプロトコルが非常に役に立つわけです。

プロトコルを使った委譲

Swiftにおいて委譲を実装する場合、基本的にはプロトコルを使います。プロトコルを使って委譲したい機能をカプセル化することにより、実際にプロトコルを採用するクラス等にそれらの機能が確実にあるということを保証しています。これは、プロトコルで定義した要求(requirements)を採用側で実装しないとコンパイルエラーになるためです。また、プロトコルを利用した委譲の利点は、委譲する側が委譲先(delegate)の型を気にしなくても良いということです。

Swiftでは(iOS/Macアプリ開発では、と言った方が正しいかもしれません)、UI周り(viewやview controller)で委譲デザインパターンを必ず使います。委譲デザインパターンを使う利点は、本体であるviewのオブジェクト(例えばテキストフィールドならUITextField)に変更を加えること無く、委譲先(delegate)で様々な実装が出来ることです。

委譲デザインパターンの下準備 | プロトコルを採用したクラスを作成

サイコロゲーム用プロトコルDiceGame

委譲用の独自型を作る前に、それを使うためのプロトコルとクラスを作っておきます。使うのは制御転送説明の時に作った人生ゲームです。公式マニュアルでは「Snakes and Ladders」というボードゲームを使っていますが、構造はほぼ同じです。

protocol DiceGame {
    var dice: Dice { get }
    func play()
}

DiceGameプロトコルは公式と全く同じです。プロパティ要求として変数diceを持ち、その型はDiceです。また、ゲーム本体を動かすためのメソッドplay()も要求しており、これはパラメータも返り値もないメソッドになります。

DiceGameプロトコルを採用したクラスJinseiGame

まずは、委譲デザインパターンを使わずに、DiceGameプロトコルを採用した人生ゲームを普通に実装します。

class JinseiGame: DiceGame {
    let dice = Dice(sides: 6, generator: LinearCongruentialGenerator())
    let maxBoardNumber: Int // ボードの総数
    var board: [Int] // 各ボードのスコア
    init(maxBoardNumber: Int) {
        self.maxBoardNumber = maxBoardNumber

        // 各ボードのスコアを初期化
        board = [Int](repeating:1000, count:maxBoardNumber)
        board[0] = 0 // スタートだけは0
    }
    
    func play() {
        // 人生ゲーム本体
        let finalBoard = maxBoardNumber-1
        
        var position = 0
        var score = 0
        print("スタート!")
        
        jinseiGame: while position != finalBoard   {
            // サイコロをふる 1...6
            let diceRoll = Int(arc4random_uniform(6))+1
            
            switch position + diceRoll {
            case finalBoard:
                // ぴったり最後のマスに止まったのでゲーム終了
                print("最後のマスです(\(position+diceRoll)マス目)")
                break jinseiGame
            case let newBoard where newBoard > finalBoard:
                print("\(finalBoard)マスを超えた(\(newBoard)>\(finalBoard))ので、サイコロを振り直します(サイコロの目:\(diceRoll), 現在地:\(position))")
                continue jinseiGame
                
            default:
                // 出た目の数だけススム
                position += diceRoll
                
                // 得点を加算
                score += board[position]
            }
            
            // サイコロの目、現在の位置、スコアを表示
            print("サイコロの目:\(diceRoll)| 今は\(position)マス目です| スコア=\(score)")
        }
        print("ゴール! あなたのスコアは\(score)点です\n")
    }
}

let game = JinseiGame(maxBoardNumber: 33)
game.play()

必要なのはプロパティdice、メソッドplay()です。ボードの総数だけは外から手でセットしたかったので、クラスプロパティとして定義して、初期化子からセットするように定義しています。メソッドplay()の中身は、制御転送のページで使ったコードほぼそのままです。後でプロトコル拡張する場合に必要なので、各ボードのスコア(整数の配列board)をプロパティとして定義し直しています。

DiceGameDelegateプロトコルで機能の委譲

ゲーム本体のメソッドと前後処理を分離

委譲デザインパターンを使って、ゲーム本体の前後処理を実装します。前後処理を実装する前に、それらがどういう形になるのかを仮のメソッドとして実装してみると、

class JinseiGame: DiceGame {
    let dice = Dice(sides: 6, generator: LinearCongruentialGenerator())
    let maxBoardNumber: Int // ボードの総数
    var board: [Int] // 各ボードのスコア
    init(maxBoardNumber: Int) {
        self.maxBoardNumber = maxBoardNumber

        // 各ボードのスコアを初期化
        board = [Int](repeating:1000, count:maxBoardNumber)
        board[0] = 0 // スタートだけは0
    }

    func play() {
        // ゲーム開始直後の処理
        gameDidStart()
        
        // ゲームスタート
        game()
        
        // ゲーム終了時の処理
        gameDidEnd()
    }
    func game() {
        // 人生ゲーム本体
        ....
    }
    
    func gameDidStart() { }
    func gameDidEnd() { }
}

このような感じになります。構造を見たいので、先程実装したゲーム本体をここでは省略しています。

メソッドplay()の中身では、3つのメソッドgameDidStart()game()gameDidEnd()を実行しています。ゲーム開始直後の処理はgameDidStart()で実行、その後ゲーム本体処理をgame()で実行し、最後にゲーム終了時の処理をgameDidEnd()で実行する予定になっています。今はメソッド実行の流れだけを見たいので、前後処理メソッドの形だけを作って中身は空です。

今回委譲デザインパターンを使うのは、主にゲーム本体の前後処理部分になります。今全てのメソッドをJinseiGameクラスに実装するような形にしていますが、これを委譲デザインパターンを使ってプロトコルのメソッドとして定義して、そのインスタンスから呼び出すように書き換えます。

DiceGameDelegateプロトコル

DiceGameの仕事の委譲先として、DiceGameDelegateプロトコルを作ります。DiceGameDelegateは公式と全く同じ構造です。

protocol DiceGameDelegate {
    func gameDidStart(_ game: DiceGame)
    func game(_ game: DiceGame, diceRoll: Int)
    func gameDidEnd(_ game: DiceGame)
}

前後処理を実行するメソッド(gameDidStart(_:)gameDidEnd(_:))、ループ中で実行する予定のメソッドgame(_:diceRoll:)を実装しています。また、全てのメソッドがパラメータとしてDiceGameプロトコルを持っています。

このDiceGameDelegateを使って、先程のJinseiGameクラスを書き換えると、

class JinseiGame: DiceGame {
    let dice = Dice(sides: 6, generator: LinearCongruentialGenerator())
    let maxBoardNumber: Int // ボードの総数
    var board: [Int] // 各ボードのスコア
    init(maxBoardNumber: Int) {
        self.maxBoardNumber = maxBoardNumber

        // 各ボードのスコアを初期化
        board = [Int](repeating:1000, count:maxBoardNumber)
        board[0] = 0 // スタートだけは0
    }
    
    var delegate: DiceGameDelegate?
    
    func play() {
        // ゲーム開始直後の処理
        delegate?.gameDidStart(self)
        
        // ゲームスタート
        game()
        
        // ゲーム終了時の処理
        delegate?.gameDidEnd(self)
    }
    func game() {
        // 人生ゲーム本体
        let finalBoard = maxBoardNumber-1
        
        var position = 0
        var score = 0
        print("スタート!")
        
        jinseiGame: while position != finalBoard   {
            // サイコロをふる 1...6
            let diceRoll = Int(arc4random_uniform(6))+1
            
            // ループ中の処理を委譲
            delegate?.game(self, diceRoll: diceRoll)
            
            switch position + diceRoll {
            case finalBoard:
                // ぴったり最後のマスに止まったのでゲーム終了
                print("最後のマスです(\(position+diceRoll)マス目)")
                break jinseiGame
            case let newBoard where newBoard > finalBoard:
                print("\(finalBoard)マスを超えた(\(newBoard)>\(finalBoard))ので、サイコロを振り直します(サイコロの目:\(diceRoll), 現在地:\(position))")
                continue jinseiGame
                
            default:
                // 出た目の数だけススム
                position += diceRoll
                
                // 得点を加算
                score += board[position]
            }
            
            // サイコロの目、現在の位置、スコアを表示
            print("サイコロの目:\(diceRoll)| 今は\(position)マス目です| スコア=\(score)")
        }
        print("ゴール! あなたのスコアは\(score)点です\n")

    }
}

let game = JinseiGame(maxBoardNumber: 10)
game.play()
//スタート!
//サイコロの目:5| 今は5マス目です| スコア=1000
//9マスを超えた(11>9)ので、サイコロを振り直します(サイコロの目:6, 現在地:5)
//最後のマスです(9マス目)
//ゴール! あなたのスコアは1000点です

このような感じになります。追加部分はdelegateプロパティの追加、delegateが持つ各メソッドの実行です。1つだけwhileループ内部で実行されています。追加された部分に関して、以下で詳しく説明します。

delegateインスタンスはオプショナル型

DiceGameDelegateのインスタンスはオプショナル型(optionals)で実装されています。

var delegate: DiceGameDelegate?

これはゲームの前後処理(gameDidStart()gameDidEnd()など)が、ゲーム自体をプレイするのに必須ではないということを表現しています。

オプショナルチェインで前後処理の実行

メソッドの実行部分を見ると、

delegate?.gameDidStart(self)

となっており、オプショナルチェイン(optional chaining)を使っているのが分かります。こうすることで、仮にdelegatenilの場合でも、この部分がスキップされるだけになり、delegatenilだからといって実行エラーが起きることはありません。オプショナル型とオプショナルチェインを使うことによって、追加的で必須じゃない要素を簡単に実装出来るので便利です。

また、パラメータとしてはselfプロパティを代入しています。パラメータの型はDiceGameプロトコルですから、それを採用しているJinseiGameインスタンスであるselfを代入することが可能です。

JinseiGameインスタンスを作ってゲームをプレイしてみる

JinseiGameクラスインスタンスを生成して、メソッドplay()を実行することで、ゲームをプレイ出来ます。

let game = JinseiGame(maxBoardNumber: 10)
game.play()
//スタート!
//サイコロの目:5| 今は5マス目です| スコア=1000
//9マスを超えた(11>9)ので、サイコロを振り直します(サイコロの目:6, 現在地:5)
//最後のマスです(9マス目)
//ゴール! あなたのスコアは1000点です

delegateとしての独自型を作っていませんので、そのインスタンスを代入していませんが、ゲーム本体は問題なく動いていることが分かります。つまり、delegate自体がオプショナル型としてちゃんと機能していること、またオプショナルチェインも想定通りに動いていることが分かります。

DiceGameDelegateを採用したクラスDiceGameTrackerを作る

DiceGameTrackerクラス定義

DiceGameDelegateはプロトコルですから、中身を実装するための独自型を作る必要があります。公式マニュアルのサンプルDiceGameTrackerをそのまま流用します。

class DiceGameTracker: DiceGameDelegate {
    var numberOfTurns = 0
    func gameDidStart(_ game: DiceGame) {
        numberOfTurns = 0
        if game is JinseiGame {
            print("人生ゲームを開始")
        }
        
        print("このゲームは\(game.dice.sides)面あるサイコロを使っています")
    }
    
    func game(_ game: DiceGame, diceRoll: Int) {
        numberOfTurns += 1
        print("\(numberOfTurns)ターン目: サイコロの目=\(diceRoll)")
    }
    
    func gameDidEnd(_ game: DiceGame) {
        print("\(numberOfTurns)ターンでゲーム終了")
    }
}

DiceGameTrackerはクラスで、DiceGameDelegateが要求しているメソッドを全て実装していることが分かります。

gameDidStartメソッド

DiceGameTrackerが持っている独自のプロパティnumberOfTurnsは、サイコロを振る度に1増加する整数なので、今自分が何巡目なのかを把握したり、最終的にサイコロを何回振ったかを知るためのプロパティになります。

gameDidStart(_:)メソッドには、ゲーム開始時に補足的な情報を表示させる機能を持たせています。

func gameDidStart(_ game: DiceGame) {
    numberOfTurns = 0
    if game is JinseiGame {
        print("人生ゲームを開始")
    }
    print("このゲームは\(game.dice.sides)面あるサイコロを使っています")
}

パラメータとしてDiceGameプロトコルを取っているので、DiceGameが要求しているプロパティやメソッドをgameDidStart(_:)内部で呼び出すことが可能です。ここでは型キャスト演算子isを使ってJinseiGameクラスかどうかの判定後、メッセージを表示するという機能を実装しています。

delegateにDiceGameTrackerインスタンスを代入してゲームスタート

実際にDiceGameTrackerを、JinseiGameが持っているdelegateDiceGameDelegateプロトコル)に入れてゲームを開始すると、

let game = JinseiGame(maxBoardNumber: 10)
game.delegate = DiceGameTracker()
game.play()

// 出力
// 人生ゲームを開始
// このゲームは6面あるサイコロを使っています <-- DiceGameTracker
// スタート!
// 1ターン目: サイコロの目=2 <-- DiceGameTracker
// サイコロの目:2| 今は2マス目です| スコア=1000
// 2ターン目: サイコロの目=4 <-- DiceGameTracker
// サイコロの目:4| 今は6マス目です| スコア=2000
// 3ターン目: サイコロの目=3 <-- DiceGameTracker
// 最後のマスです(9マス目)
// ゴール! あなたのスコアは2000点です
// 
// 3ターンでゲーム終了 <-- DiceGameTracker

上記の結果のように、ちゃんとGameDiceTrackerからのメッセージが表示されているのが分かります。

まとめ

  • プロトコルは型としても使える
  • 具体的にはパラメータや戻り値、プロパティ(変数/定数も)、コレクション型の要素の型として使える
  • 委譲デザインパターン(Delegation)の利点は、本体の変更無しに様々な機能を追加出来ること
  • Swiftの委譲デザインパターンではプロトコルを使う
  • 委譲でプロトコルを使う利点: 機能の確実な実装、委譲する側が委譲先の型を知らなくても良いこと