본문 바로가기
프로그래밍/Swift

[Swift] Protocols (1)

by 별준 2022. 2. 5.

References

Contents

  • Protocol Syntax
  • Property Requirements
  • Method Requirements
  • Mutating Method Requirements
  • Initializer Requirements
  • Protocols as Types
  • Delegation

Protocols

프로토콜(protocol)은 메소드(methods)와 속성(properties), 그리고 특정 태스크나 일부 기능에 적합한 다른 요구사항들의 청사진(blueprint)를 정의합니다. 프로토콜은 요구사항들이 실제로 구현되는 클래스, 구조체, 또는 열거형에서 채택될 수 있습니다. 프로토콜의 요구사항을 만족하는 모든 타입을 프로토콜을 따른다(conform;순응한다)라고 합니다. 

구현해야하는 요구사항을 지정하는 것 이외에도 이러한 요구사항 중의 일부를 구현하거나 추가 기능을 구현하도록 프로토콜을 확장할 수도 있습니다.

 


Protocol Syntax

프로토콜은 클래스와 유사한 방법으로 정의할 수 있습니다.

protocol SomeProtocol {
    // protocol definition goes here
}

특정 프로토콜을 따르는 커스텀 타입을 정의하려면 타입의 이름 뒤에 콜론(:)을 붙이고 프로토콜 이름을 작성하면 됩니다. 콤마(,)로 구분하여 여러 프로토콜을 나열할 수도 있습니다.

struct SomeStructure: FirstProtocol, AnotherProtocol {
    // structure definition goes here
}

만약 클래스가 superclass라면, 적용할 프로토콜을 작성하기 전에 superclass를 나열합니다.

class SomeClass: SomeSuperclass, FirstProtocol, AnotherProtocol {
    // class definition goes here
}

 


Proerty Requirements

프로토콜은 프로토콜을 따르는 타입에서 특정 이름과 타입의 인스턴스 속성이나 타입 속성을 제공합니다. 하지만 이 타입이 stored인지 computed 속성인지 지정하지는 않습니다. 오직 속성의 이름과 타입만 지정합니다. 또한, 이 속성이 gettable, settable할 수 있는지 지정해주어야 합니다.

 

만약 프로토콜이 속성에 대해 gettable, settable하다고 지정한다면, 해당 속성은 constant stored 속성이나 read-only computed 속성으로는 이 조건을 만족시킬 수 없습니다. 만약 속성이 gettable로만 지정된다면 모든 종류의 속성으로 이 조건을 만족할 수 있습니다.

 

property requirements는 항상 var 키워드를 앞에 붙여서 변수 속성으로 선언됩니다. Gettable / Settable 속성은 타입 선언 뒤에 '{ get set }을 작성해주고, gettable 속성은 '{ get }'을 작성해주면 됩니다.

protocol SomeProtocol {
    var mustBeSettable: Int { get set }
    var doesNotNeedToBeSettable: Int { get }
}

 

프로토콜의 타입 속성은 static 키워드를 작성하여 선언합니다.

protocol AnotherProtocol {
    static var someTypeProperty: Int { get set }
}

 

하나의 인스턴스 속성을 갖는 프로토콜의 예제를 살펴보겠습니다.

protocol FullyNamed {
    var fullName: String { get }
}

하나의 gettable 인스턴스 속성 fullName을 갖는 FullyNamed 프로토콜입니다. 아래는 FullyNamed 프로토콜을 따르는 하나의 구조체를 정의하고 있습니다.

struct Person: FullyNamed {
    var fullName: String
}
let john = Person(fullName: "John Appleseed")
// john.fullName is "John Appleseed"

Person이라는 구조체를 정의했고, 이는 지정된 이름을 갖는 사람을 의미합니다.

Person의 각 인스턴스는 하나의 fullName이라는 stored property를 가집니다. 이는 FullyNamed 프로토콜의 요구사항과 일치하고, Person 구조체가 이 프로토콜을 따른다는 것을 의미합니다.

(만약 프로토콜의 요구사항을 만족하지 않는다면 컴파일 에러가 발생합니다.)

 

다음은 FullyNamed 프로토콜을 따르는 조금 더 복잡한 클래스를 정의합니다.

class Starship: FullyNamed {
    var prefix: String?
    var name: String
    init(name: String, prefix: String? = nil) {
        self.name = name
        self.prefix = prefix
    }
    var fullName: String {
        return (prefix != nil ? prefix! + " " : "") + name
    }
}
var ncc1701 = Starship(name: "Enterprise", prefix: "USS")
// ncc1701.fullName is "USS Enterprise"

 


Method Requirements

프로토콜은 이 프로토콜을 따르는 타입이 구현해야하는 인스턴스 메소드(instance methods)와 타입 메소드(type methods)를 요구할 수 있습니다. 이 메소드들은 메소드의 body와 괄호를 제외하고 일반적인 인스턴스나 타입 메소드와 같은 방법으로 작성됩니다. 가변 파라미터(variadic parameters)도 허용됩니다. 그러나 default value는 설정될 수 없습니다.

 

타입 속성과 동일하게, static 키워드를 추가하여 타입 메소드를 선언할 수 있습니다.

protocol SomeProtocol {
    static func someTypeMethod()
}

 

아래 예제는 하나의 인스턴스 메소드 요구사항을 갖는 프로토콜을 정의합니다.

protocol RandomNumberGenerator {
    func random() -> Double
}

이 프로토콜을 따르는 타입은 random이라는 인스턴스 메소드를 가지고 있어야 하며, 이 메소드는 Double 값을 리턴합니다. RandomNumberGenerator 프로토콜은 난수가 어떻게 생성되는지 지정하지 않으며, 단지 generator가 새로운 난수를 생성하는 표준을 제공하기만하면 됩니다. (ex, 0.0 ~ 1.0 사이의 난수 생성은 프로토콜을 따르는 타입에서 지정)

 

아래 예제 코드는 RandomNumberGenerator 프로토콜을 따르는 클래스의 정의를 보여줍니다. 이 클래스는 linear congruential generator로 알려진 난수 생성 알고리즘을 구현합니다.

class LinearCongruentialGenerator: RandomNumberGenerator {
    var lastRandom = 42.0
    let m = 139968.0
    let a = 3877.0
    let c = 29573.0
    func random() -> Double {
        lastRandom = ((lastRandom * a + c)
            .truncatingRemainder(dividingBy:m))
        return lastRandom / m
    }
}
let generator = LinearCongruentialGenerator()
print("Here's a random number: \(generator.random())")
// Prints "Here's a random number: 0.3746499199817101"
print("And another one: \(generator.random())")
// Prints "And another one: 0.729023776863283"

 


Mutating Method Requirements

때때로 인스턴스 그 자체(self)를 수정해야하는 메소드가 필요할 수 있습니다. 구조체나 열거형과 같은 value 타입에서의 인스턴스 메소드는 func 키워드 앞에 mutating 키워드를 작성하여 해당 메소드가 인스턴스 자체나 그 인스턴스의 속성들을 수정하도록 할 수 있습니다.

(이와 관련된 내용은 아래 포스팅의 mutating을 참조해주세요.)

[Swift] Methods

 

[Swift] Methods

References https://docs.swift.org/swift-book/LanguageGuide/Methods.html Contents Instance Methods mutating Type Methods 메소드(methods)는 특정한 타입과 연관된 함수입니다. 클래스, 구조체, 열거형은 인..

junstar92.tistory.com

프로토콜에 mutating을 사용한 경우, 이 프로토콜을 따르는 클래스를 구현할 때에는 메소드에 mutating을 명시하지 않아도 됩니다.

 

아래에서 살펴볼 예제는 Togglable이라는 프로토콜을 정의합니다. 이 프로토콜은 toggle이라는 하나의 인스턴스 메소드 요구사항을 정의합니다. toggle() 메소드는 mutating 키워드가 추가되어 이를 따르는 인스턴스의 상태를 변경할 수 있다는 것을 의미합니다.

protocol Togglable {
    mutating func toggle()
}

 

다음 코드는 OnOffSwitch라는 열거형을 정의합니다. 이 열거형은 2개의 상태(on, off)를 토글합니다. value 타입이기 때문에 toggle 메소드를 구현할 때 mutating을 붙여주어야 합니다.

enum OnOffSwitch: Togglable {
    case off, on
    mutating func toggle() {
        switch self {
        case .off:
            self = .on
        case .on:
            self = .off
        }
    }
}
var lightSwitch = OnOffSwitch.off
lightSwitch.toggle()
// lightSwitch is now equal to .on

 


Initializer Requirements

프로토콜 요구사항에 지정된 이니셜라이저를 설정할 수 있습니다. 메소드와 마찬가지로 괄호와 이니셜라이저 body를 제외하고는 일반적인 이니셜라이저와 동일한 방법으로 작성하면 됩니다.

protocol SomeProtocol {
    init(someParameter: Int)
}

 

Class Implementations of Protocol Initializer Requirements

프로토콜을 따르는 클래스에 desinated 이니셜라이저 또는 convenience 이니셜라이저로 프로토콜 이니셜라이저 요구사항을 구현할 수 있습니다. 이 경우, 반드시 이니셜라이저 구현에 required modifier를 붙여주어야 합니다.

class SomeClass: SomeProtocol {
    required init(someParameter: Int) {
        // initializer implementation goes here
    }
}

required를 사용하면 이를 따르는 클래스의 모든 서브클래스에 명시적/암시적인 이니셜라이저 요구사항 구현을 제공하도록 할 수 있습니다. (즉, 서브클래스들도 프로토콜을 따르도록)

 

만약 서브클래스가 수퍼클래스로부터 designated initializer를 오버라이드하고 프로토콜로부터 일치하는 이니셜라이저 요구사항을 구현한다면, 이 이니셜라이저에는 requiredoverride를 둘 다 붙여주어야 합니다.

protocol SomeProtocol {
    init()
}

class SomeSuperClass {
    init() {
        // initializer implementation goes here
    }
}

class SomeSubClass: SomeSuperClass, SomeProtocol {
    // "required" from SomeProtocol conformance; "override" from SomeSuperClass
    required override init() {
        // initializer implementation goes here
    }
}

 

Failable Initializer Requirements

프로토콜에서 failable 이니셜라이저 요구사항을 정의할 수도 있습니다. failable initializer 요구사항은 이를 따르는 타입에서 failable 또는 nonfailable 이니셜라이저에 의해서 요구사항이 만족될 수 있습니다. nonfailable 이니셜라이저 요구사항은 nonfailable 이니셜라이저 또는 implicitly unwrapped failable 이니셜라이저로 만족될 수 있습니다.

 


Protocols as Types

프로토콜은 실제로 기능 그 자체를 구현하지는 않습니다. 그럼에도, 프로토콜을 코드에서 완전한 타입으로 사용할 수 있습니다. 프로토콜을 타입으로 사용하는 것을 existential type이라고 하는데, 이는 "there exists a type T such that T conforms to the protocol"이라는 구절에서 유래되었습니다.

 

따라서, 다음과 같이 다른 타입들이 허용되는 모든 곳에서 프로토콜을 사용할 수 있습니다.

  • As a parameter type or return type in a function, method, or initializer
  • As the type of a constant, variable, or property
  • As the type of items in an array, dictionary, or other container

 

다음은 프로토콜을 타입으로 사용한 예제 코드입니다.

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
    }
}

위 코드에서 Dice라는 새로운 클래스를 정의하며, 이는 보드게임 등에서 사용하는 n개의 면이 있는 주사위를 의미합니다. Dice 인스턴스는 sides라는 정수 속성을 가지고 이는 주사위 면의 개수를 나타냅니다. 그리고 generator라는 속성을 가지는데 이는 주사위를 굴렸을 때 난수를 생성하는 역할을 합니다.

 

generator 속성은 RandomNumberGenerator 타입인데, 일반적으로 프로토콜을 따르는 커스텀 타입을 만드는 것이 아닌 프로토콜 자체를 타입으로 사용하고 있습니다. 위 코드에서 상수인 RandomNumberGenerator 타입인 generator와 파라미터 타입으로 사용하고 있습니다.

 

아래 코드는 6면의 주사위를 정의하는데, 난수 생성기로 위에서 살펴봤던 RandomNumberGenerator 프로토콜을 따르는 LinearCongruentialGenerator를 사용하고 있습니다.

var d6 = Dice(sides: 6, generator: LinearCongruentialGenerator())
for _ in 1...5 {
    print("Random dice roll is \(d6.roll())")
}
// Random dice roll is 3
// Random dice roll is 5
// Random dice roll is 4
// Random dice roll is 5
// Random dice roll is 4

 


Delegation

Delegation(위임)은 클래스나 구조체가 책임(reponsibilities)의 일부를 다른 타입의 인스턴스에 위임(hand off or delegate)할 수 있도록 하는 디자인 패턴입니다. 이 디자인 패턴은 위임된 책임을 캡슐화하는 프로토콜을 정의하여 구현되며, 이를 통해 이 프로토콜을 따르는 타입이 위임된 기능을 제공하도록 보장합니다.

 

아래 예제 코드는 주사위로 하는 보드 게임에서 사용하기 위한 두 프로토콜을 정의합니다.

protocol DiceGame {
    var dice: Dice { get }
    func play()
}
protocol DiceGameDelegate: AnyObject {
    func gameDidStart(_ game: DiceGame)
    func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int)
    func gameDidEnd(_ game: DiceGame)
}

DiceGame 프로토콜은 주사위를 포함하는 어떠한 게임에서 적용될 수 있는 프로토콜입니다.

 

DiceGameDelegate는 DiceGame의 진행사항을 추적하기 위해 채택될 수 있습니다. 그리고 DiceGameDelegate를 AnyObject로부터 상속받도록 하여 클래스만 이 프로토콜을 따를 수 있도록 만들었습니다.

아래 코드는 Snakes and Ladders 게임을 구현한 클래스이며, 게임에 대한 자세한 내용은 넘어가도록 하겠습니다.

(이 게임의 구현은 Control Flow에서 나왔었습니다: Control Flow (제어문))

class SnakesAndLadders: DiceGame {
    let finalSquare = 25
    let dice = Dice(sides: 6, generator: LinearCongruentialGenerator())
    var square = 0
    var board: [Int]
    init() {
        board = Array(repeating: 0, count: finalSquare + 1)
        board[03] = +08; board[06] = +11; board[09] = +09; board[10] = +02
        board[14] = -10; board[19] = -11; board[22] = -02; board[24] = -08
    }
    weak var delegate: DiceGameDelegate?
    func play() {
        square = 0
        delegate?.gameDidStart(self)
        gameLoop: while square != finalSquare {
            let diceRoll = dice.roll()
            delegate?.game(self, didStartNewTurnWithDiceRoll: diceRoll)
            switch square + diceRoll {
            case finalSquare:
                break gameLoop
            case let newSquare where newSquare > finalSquare:
                continue gameLoop
            default:
                square += diceRoll
                square += board[square]
            }
        }
        delegate?.gameDidEnd(self)
    }
}

SnakesAndLadders 클래스는 DiceGame 프로토콜을 따르며, 이 프로토콜은 gettable dice 속성과 play() 메소드를 제공하도록 합니다. 그리고 DiceGameDelegate 타입으로 delegate 변수를 weak reference로 선언하여 사용하도록 하였습니다.

 

delegate 속성은 옵셔널 DiceGameDelegate로 정의되어 있는데, 이는 게임을 진행하기 위한 필수사항은 아니기 때문입니다. 옵셔널 타입이기 때문에 delegate 속성은 자동으로 nil의 초기값으로 설정됩니다. 그 이후에 적절한 delegate를 이 속성에 설정해줄 수 있습니다. DiceGameDelegate 프로토콜은 class-only이기 때문에 reference cycles를 방지하기 위해서 delegate를 weak로 선언할 수 있습니다.

 

다음은 DiceGameTracker라는 클래스를 정의하고 있으며, 이 클래스는 DiceGameDelegate 프로토콜을 따릅니다.

class DiceGameTracker: DiceGameDelegate {
    var numberOfTurns = 0
    func gameDidStart(_ game: DiceGame) {
        numberOfTurns = 0
        if game is SnakesAndLadders {
            print("Started a new game of Snakes and Ladders")
        }
        print("The game is using a \(game.dice.sides)-sided dice")
    }
    func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int) {
        numberOfTurns += 1
        print("Rolled a \(diceRoll)")
    }
    func gameDidEnd(_ game: DiceGame) {
        print("The game lasted for \(numberOfTurns) turns")
    }
}

DiceGameTracker는 DiceGameDelegate 프로토콜에서 요구되는 3개의 메소드를 모두 구현합니다.

 

DiceGameTracker를 사용하여 게임을 진행하는 코드는 다음과 같습니다.

let tracker = DiceGameTracker()
let game = SnakesAndLadders()
game.delegate = tracker
game.play()
// Started a new game of Snakes and Ladders
// The game is using a 6-sided dice
// Rolled a 3
// Rolled a 5
// Rolled a 4
// Rolled a 5
// The game lasted for 4 turns

 

 


다음 포스팅에 이어서 프로토콜에 대한 내용을 알아보도록 하겠습니다.

'프로그래밍 > Swift' 카테고리의 다른 글

[Swift] Generics (제네릭)  (0) 2022.03.07
[Swift] Protocols (2)  (0) 2022.02.05
[Swift] Extensions  (0) 2022.02.03
[Swift] Type Casting  (0) 2022.01.29
[Swift] Optional Chaining  (0) 2022.01.08

댓글