프로토콜

프로토콜

'이 타입은 무슨 일을 할 수 있어야 하고, 어떤 요구사항을 지켜야 해'라는 설계도를 그리고 싶을 때 프로토콜(protocol)을 써요. 프로토콜은 특정 작업이나 기능에 맞는 메서드·프로퍼티·기타 요구사항의 청사진을 정의하고, 클래스·구조체·열거형이 이를 **채택(adopt)**해서 실제 구현을 제공해요. 요구사항을 만족하는 타입은 그 프로토콜을 **준수(conform)**한다고 말해요. 프로토콜을 확장해 일부 요구사항을 기본 구현으로 채우는 것도 가능해요.

출처: 공식문서

본문

프로토콜 문법

프로토콜은 클래스, 구조체, 열거형과 아주 비슷한 방식으로 정의해요.

protocol SomeProtocol {
    // protocol definition goes here
}

사용자 타입이 특정 프로토콜을 채택한다고 밝힐 때는 타입 이름 뒤에 콜론을 붙이고 프로토콜 이름을 적어요. 여러 개는 쉼표로 구분해요.

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

클래스에 슈퍼클래스가 있다면 프로토콜보다 먼저 그 이름을 적고, 쉼표로 이어요.

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

프로퍼티 요구사항

프로토콜은 준수 타입이 특정 이름과 타입의 인스턴스 프로퍼티나 타입 프로퍼티를 제공하도록 요구할 수 있어요. 프로토콜은 저장 프로퍼티인지 계산 프로퍼티인지는 정하지 않고, 필수 이름과 타입만 정해요. 또 각 프로퍼티가 읽기 전용(gettable)이어야 하는지, 읽기·쓰기(gettable and settable) 모두 가능해야 하는지도 정해요.

읽기·쓰기를 요구하면 그 요구사항은 상수 저장 프로퍼티나 읽기 전용 계산 프로퍼티로는 충족할 수 없어요. 읽기만 요구하면 어떤 종류의 프로퍼티로도 충족되고, 필요하다면 쓰기도 가능하게 해도 유효해요.

프로퍼티 요구사항은 항상 var 키워드로 선언해요. 읽기·쓰기는 타입 선언 뒤에 { get set }을, 읽기만은 { get }을 써서 나타내요.

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

타입 프로퍼티 요구사항은 프로토콜에서 정의할 때 항상 static 키워드를 붙여요. 클래스에서 구현할 때는 classstatic 모두 쓸 수 있지만요.

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

인스턴스 프로퍼티 요구사항 하나를 가진 프로토콜 예시를 볼게요. FullyNamed는 완전한 이름을 제공할 것을 요구해요. 프로토콜은 준수 타입의 성격에 대해 다른 건 지정하지 않아요. 다만 타입이 자기 이름을 제공할 수 있어야 하고, String 타입의 읽기 가능한 인스턴스 프로퍼티 fullName을 가져야 한다고 정해요.

protocol FullyNamed {
    var fullName: String { get }
}

이 프로토콜을 채택·준수하는 간단한 구조체를 볼게요.

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

PersonfullName이라는 String 저장 프로퍼티 하나를 가져요. 이게 FullyNamed 프로토콜의 단일 요구사항과 일치하므로 Person은 올바르게 준수한 거예요. (요구사항을 충족하지 못하면 Swift가 컴파일 타임에 오류를 보고해요.)

더 복잡한 클래스도 볼게요. Starship은 우주선의 fullNameprefix가 있으면 앞에 붙이는 계산 프로퍼티로 구현해요.

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"

Starship 인스턴스는 필수 name과 옵셔널 prefix를 저장해요. fullNameprefix가 있으면 그 값으로 시작하는 전체 이름을 만들어 내죠.

메서드 요구사항

프로토콜은 준수 타입이 구현해야 할 특정 인스턴스 메서드와 타입 메서드를 요구할 수 있어요. 이 메서드들은 일반 인스턴스·타입 메서드와 똑같이 쓰되, 중괄호와 메서드 본문이 없어요. 가변 매개변수는 일반 메서드와 같은 규칙으로 허용되지만, 메서드 매개변수에 기본값을 지정할 수는 없어요.

타입 메서드 요구사항도 프로토콜에서 정의할 때 static 키워드를 붙여요.

protocol SomeProtocol {
    static func someTypeMethod()
}

인스턴스 메서드 요구사항 하나를 가진 프로토콜을 볼게요. RandomNumberGenerator는 준수 타입이 호출할 때마다 Double을 반환하는 random 인스턴스 메서드를 가져야 해요.

protocol RandomNumberGenerator {
    func random() -> Double
}

이 프로토콜은 난수가 어떻게 생성되는지 가정하지 않아요. 그저 새 난수를 만드는 표준적인 방법을 제공하기만 하면 돼요. 선형 합동 생성기(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".

변경 메서드 요구사항

메서드가 속한 인스턴스를 수정(mutate)해야 할 때가 있어요. 구조체·열거형 같은 값 타입의 인스턴스 메서드에선 func 키워드 앞에 mutating을 붙여 그 메서드가 인스턴스와 그 프로퍼티를 수정하는 게 허용된다고 표시해요.

프로토콜 인스턴스 메서드 요구사항이 채택 타입의 인스턴스를 수정하기 위한 것이라면 프로토콜 정의에서도 mutating 키워드를 붙여요. 그래야 구조체와 열거형이 그 프로토콜을 채택해 그 요구사항을 충족할 수 있어요.

Togglable 프로토콜을 볼게요. 상태를 뒤집는 toggle() 메서드를 요구하는데, 이름에서 알 수 있듯 준수 타입의 상태(보통은 프로퍼티)를 바꾸는 게 목적이에요. 그래서 프로토콜 정의에서 mutating으로 표시해요.

protocol Togglable {
    mutating func toggle()
}

구조체나 열거형에서 이 프로토콜을 구현하려면 toggle() 구현에도 mutating을 붙여 프로토콜 요구사항에 맞추면 돼요. 열거형 OnOffSwitch는 두 상태 offon을 오가면서 토글해요.

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

이니셜라이저 요구사항

프로토콜은 준수 타입이 구현할 특정 이니셜라이저를 요구할 수 있어요. 일반 이니셜라이저와 똑같이 쓰되 중괄호와 본문이 없어요.

protocol SomeProtocol {
    init(someParameter: Int)
}

클래스에서 프로토콜 이니셜라이저 요구사항을 designated 이니셜라이저로 구현하든 convenience 이니셜라이저로 구현하든, 구현에 required 수식어를 반드시 붙여야 해요.

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

required 수식어는 준수 클래스의 모든 서브클래스에 이니셜라이저 요구사항의 명시적 또는 상속된 구현이 있어서, 서브클래스도 프로토콜에 준수하도록 보장해요.

서브클래스가 슈퍼클래스의 designated 이니셜라이저를 오버라이드하면서 동시에 프로토콜의 일치하는 이니셜라이저 요구사항을 구현한다면, 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
    }
}

프로토콜은 실패 가능한 이니셜라이저 요구사항도 정의할 수 있어요. 실패 가능한 이니셜라이저 요구사항은 준수 타입의 실패 가능한 또는 실패하지 않는 이니셜라이저로 충족할 수 있고, 실패하지 않는 요구사항은 실패하지 않는 이니셜라이저나 암시적 언랩 실패 가능한 이니셜라이저로 충족할 수 있어요.

의미적 요구사항만 가진 프로토콜

지금까지 프로토콜은 메서드나 프로퍼티를 요구했지만, 프로토콜 선언에 요구사항이 아예 없을 수도 있어요. 값이 어떻게 동작하는지, 어떤 연산을 지원하는지 같은 의미적(semantic) 요구사항을 묘사하기 위해 프로토콜을 쓰는 경우죠.

Swift 표준 라이브러리에는 필수 메서드·프로퍼티가 없는 프로토콜이 몇 가지 있어요. 동시성 영역을 가로질러 공유할 수 있는 값의 Sendable, 함수에 넘길 때 Swift가 복사할 수 있는 값의 Copyable, 비트 단위로 복사할 수 있는 값의 BitwiseCopyable이 그런 예예요.

채택 문법은 다른 프로토콜과 똑같아요. 차이는 요구사항을 구현하는 메서드·프로퍼티 선언이 없다는 점뿐이에요.

struct MyStruct: Copyable {
    var counter = 12
}


extension MyStruct: BitwiseCopyable { }

Copyable은 의미적 요구사항만 있으므로 구조체 선언 안에 채택을 위한 코드가 없어요. BitwiseCopyable도 마찬가지라 채택하는 확장의 본문이 비어 있어요. 보통은 이런 프로토콜에 직접 준수를 쓸 필요가 없어요 — Swift가 대부분 자동으로 준수를 더해 주거든요.

프로토콜을 타입으로 쓰기

프로토콜은 스스로 기능을 구현하지 않지만, 코드에서 타입으로 쓸 수 있어요. 실제 사용에서 프로토콜은 이런 자리를 차지해요. 함수·메서드·이니셜라이저의 매개변수 타입이나 반환 타입, 상수·변수·프로퍼티의 타입, 배열·딕셔너리·기타 컨테이너의 항목 타입으로 쓰여요. 프로토콜을 타입으로 쓰면 그 프로토콜을 준수하는 어떤 타입의 값이든 그 자리에 올 수 있어요.

프로토콜 요구사항의 선택 사항과 확장

일부 프로토콜 요구사항은 구현하지 않아도 되는 선택적(optional) 요구사항이 될 수 있어요. 선택적 요구사항은 @objc 속성과 함께 쓸 수 있고, 그 프로토콜은 클래스 전용(@objc protocol)이 돼요. 준수 타입이 선택적 메서드를 구현하지 않으면 그 메서드는 Optional로 취급돼요 — 옵셔널 체이닝을 통해 '존재하는 경우에만 호출'할 수 있죠.

프로토콜 확장(protocol extension)은 프로토콜에 메서드·계산 프로퍼티·서브스크립트·이니셜라이저 구현을 더할 수 있게 해요. 이를 통해 준수 타입에 기본 구현(default implementation)을 제공할 수 있어요. 준수 타입이 그 요구사항을 직접 구현하면 자기 구현이 우선하고, 아니면 확장의 기본 구현이 쓰여요. 이렇게 하면 프로토콜 준수가 필수 메서드마다 반복 구현을 요구하지 않아도 돼요.

더 알아보기

  • 여러 타입에 걸쳐 요구사항을 일반화하는 Generics와 짝을 이뤄요.
  • 프로토콜을 값의 타입처럼 숨기는 Opaque and Boxed Protocol Types도 참고해요.
  • 프로토콜 확장의 활용은 Extensions에서 더 다뤄요.