콘텐츠로 건너뛰기
Home » (Swift) 프로토콜 그 자체가 자신을 따르지 않는다

(Swift) 프로토콜 그 자체가 자신을 따르지 않는다

Swift 5.1에 추가된 some 키워드 (불투명 리턴 타입)에 관한 Swift 공식 문서를 살펴보다가 이상한 구절을 발견했다.

Another problem with this approach is that the shape transformations don’t nest. The result of flipping a triangle is a value of type Shape, and the protoFlip(_:) function takes an argument of some type that conforms to the Shape protocol. However, a value of a protocol type doesn’t conform to that protocol; the value returned by protoFlip(_:) doesn’t conform to Shape. This means code like protoFlip(protoFlip(smallTriange)) that applies multiple transformations is invalid because the flipped shape isn’t a valid argument to protoFlip(_:).

프로토콜 타입의 값이 그 프로토콜을 따르지 않는다는 것이다. 왱? 이게 뭔말이지? 특정한 프로토콜을 따르는 객체들은 그 실제 타입에 상관없이 해당 프로토콜을 타입처럼 사용할 수 있다고 했는데, 이번에는 프로토콜 타입의 값이 그 프로토콜을 따르지 않는다라니?

https://docs.swift.org/swift-book/LanguageGuide/Protocols.html#ID275

실제로 이게 안되는 예를 찾아서 간단하게 테스트해보자. 비어있는 프로토콜 P와 이를 따르는 빈 구조체 S, S1을 정의한다. 그런다음 P 타입을 리턴하는 함수 foo()와 P를 준수하는 제네릭 타입을 인자로받는 bar() 함수를 다음과 같이 작성한다.

protocol P {  }
struct S: P {  }
struct S1: P {  }
func foo() -> P {
  return Bool.random() ? S() : S1()
}
func bar<T: P>(_ x: T) {
  print("baz!")
}
let i: P = foo()
print(i is P) // always true
bar(i)  // ERROR

foo() 함수의 리턴값은 그 랜덤하게 S 이거나 S1의 타입이 된다. 그리고 이 둘은 언제나 P를 따른다. 따라서 실제 타입이 S냐 S1이냐를 따지기 전에 P라는 타입으로 묶을 수 있다. 여기까지는 별 무리가 없다.

그런데 bar(i)를 실행하려 하면 다음과 같은 에러가 발생한다.

protocol type ‘P’ cannot conform to ‘P’ because only concrete types can conform to protocols

아니 왜 프로토콜 타입 자체가 그 자신을 따르지 않는다고 하는 것일까?

참고로 이 예에서 i: P 라고 타입을 명시하지 않고, 타입 시스템이 원래의 구체적인 타입을 유추하도록 한 경우에는, 즉 i를 S나 S1으로 판별한 경우에는 에러가 발생하지 않는다.

왜 안되는가

프로토콜과는 상관없지만 다음 예제를 보자. 이 예제는 올바른 Swift 코드가 물론 아니며, 컴파일도 되지 않는다.

func isEmpty(xs: Array) -> Bool {
  return xs.count == 0
}

만약 이 코드가 작동한다고 가정하면 Swift는 하스켈처럼 Functor나 모나드를 만들 수 있을 것이다. 물론 아직 Swift에서는 이들을 만들 수 없다.(흉내는 낼 수 있을지 몰라도) 이런 코드가 동작할 수 없는 이유는 Swift의 타입 시스템에는 first-class 메타타입이 존재하지 않기 때문이다. 위 예제가 올바른 코드가 되려면 Array를 concrete type으로 만들어야 하고 그러기 위해서는 제네릭 표기를 적용해서 아래와 같이 타입 시그니처를 변경해야 한다.

func isEmpty<T>(xs: [T]) -> Bool {
  return xs.count == 0
}

실질적으로 이 코드이 맥락상 타입T는 이 함수의 실제 동작에 아무런 영향도 상관도 없으므로 타입 파라미터인 T는 필요하지 않다고 생각할 수 있다. 실제로도 함수 본체에서 T와 관련된 정보는 전혀 쓰이지 않는다. 다만 Swift의 타입 시스템은 제네릭 타입인 Array를 구체적인 타입인 Array<T>로 만들기 위해서 이를 요구한다.

처음 예에서 에러메시지도 이와 비슷한 맥락이다. 프로토콜 P를 따르기 위해서는 타입 P가 “구체적인 타입“이어야 한다는 것이다. 왜 이런 제약이 필요할까?

자, 그렇다면 다시 처음 문제의 코드로 돌아와보자. 함수 bar()는 그 정의에서 파라미터에 제네릭 타입을 적용하고 있다. 그 말은 bar()가 받은 인자의 타입이 프로토콜 타입이 아니라 P를 따르는 구체적인 모종의 타입으로 확정되어 있어야 한다는 것이다. 물론 ‘결정된 제네릭’에서의 타입 파라미터는 isEmpty()의 경우와 똑같이 함수 코드 내에서 사용하지도 않고 관심도 없다. 단지 Swift 컴파일러가 컴파일 타임에 해당 타입이 concrete한지를 요구할 뿐이다.

만약 아예 함수 bar()의 타입이 제네릭이 아닌 프로토콜 타입을 요구했다면 아무런 문제가 생기지 않는다. func bar(_ x: P) { .. } 로 작성하면 문제없이 컴파일 된다.(다만 항상 그런것은 아니다. Swift에는 first-class 메타타입이 없기 때문에, 만약 P가 이 예제와 달리 연관타입을 요구하거나, 내부에서 Self를 참조하는 프로토콜이라면 함수의 인자 타입으로 사용할 수도 없다.)

그럼 여기서 다시 근본적인 질문을 살펴보자. 프로토콜 타입은 왜 그 스스로를 준수하지 않을까?(혹은 그럴 수 없을까?) 처음에 제시한 예제에서는 문제가 될 것 같은 부분이 전혀 보이지 않았기 때문에 이 상황을 이해하는 것이 어려워보인다. 이제 문제가 직접적으로 드러나보이는 예를 살펴보자.

protocol SomeProtocol {
    static var fizz: Int { get }
}
struct SomeGeneric<T: SomeProtocol > {
    func bar() {
        print(T.fizz)
    }
}

여기까지는 아무런 문제가 없고 심지어 컴파일도된다. 이 코드를 앞으로 사용할 때, 어떤 타입 T는 SomeProtocol을 따를테니 fizz라는 정적 프로퍼티 요구사항을 충족할 것이고, 이를 타입 내에서 액세스하는 것은 문제가 없다.

그런데 프로토콜 자체를 타입으로 보고, T 자리에 SomeProtocol을 넣었다고 생각해보자. 그렇다면 bar()에서 참조하는 T.fizz 는 어디에 있는가?

SomeGeneric<SomeProtocol>().bar()

여기서는 모순이 발생한다. 프로토콜은 정적 프로퍼티인 fizz를 정의했지만, 그 어디에도 SomeProtocol 타입의 fizz에 대한 세부 구현이 존재하지 않는다. 심지어 SomeProtocol을 확장하여 fizz값에 대한 디폴트 구현을 제공하더라도 여전히 컴파일 되지 않는다.

요구사항을 선언만 하고 구현을 제공할 필요가 없는 프로토콜은 그 자신의 요구 사항을 충족하지 못하기 때문에 그 자신을 따르지 못한다는 이야기와도 일맥상통한다. 물론 여기서 보인 예가 특별할 수도 있겠지만, 반대로 틀림없이 동작할 것 같은 코드 역시 특별한 경우일 수 있다. Swift는 아마도 프로토콜 그 자체가 자신을 따르는 타입으로 취급되지 않도록 예외적으로 강제하는 것 같다.

현실적으로 프토토콜이 정적인 요구사항 (정적 메소드나 정적 프로퍼티)를 가지고 있을 때, 프로토콜 타입이 그 자신을 따르도록 하는 것이 모순되는 상황이 생기며, 이 때문에 Swift에서는 프로토콜이 항상 그 자신을 따르지 않는다고 예외를 두어 처리하고 있는 것 처럼 보이는 것이다.

해결 방법

프로토콜이 그 자신을 따르지 않는 것으로 간주되는 이 상황은 프로토콜에 정적 요구사항이 존재할 때 발생하는 모순을 피하기 위한 것이다. 논리적으로는 어떤 프로토콜이 정적 요구사항 (혹은 Self를 참조해야 하는 상황이나 연관 타입을 정의한 상황)이 없다면 그 자신을 따르는 것으로 간주해도 괜찮다. 어떤 객체 인스턴스가 프로토콜 P를 따른다고 한다면 프로토콜 P의 타입으로 볼 수 있는 것이다. (그리고 이것은 Swift 공식 문서 상에서도 언급되어 있다.)

이는 안전을 위한 예외처리로 사실상 언어 차원에서 제약을 풀 수 있을 것으로 보이며, 당장은 아니더라도 Swift의 미래의 어떤 버전에서는 허용될지도 모를 일이다. (더 좋은건 first-class 메타타입이 가능하게끔 타입 시스템을 발전 시키는 것이겠지만…)

대신에 당장에 이 문제를 해결하고 싶다면 타입을 지운 컨테이너를 사용하는 방법이 있다. 실제로 Swift 표준 라이브러리에도 Sequence 프로토콜을 구체적인 타입으로 사용하기 위한 AnySequence라는 구조체가 존재하며 내부적으로 많이 쓰인다.

타입을 지운 컨테이너는 다음과 같이 작성할 수 있다.

  1. 프로토콜이 연관타입을 요구하는 경우, 컨테이너 자체가 제네릭 타입이어야 한다.
  2. 프로토콜이 요구하는 인터페이스에 대한 private한 프로퍼티들을 준비한다.
  3. 해당 프로토콜을 따르는 객체를 받아 프로토콜 요구사항들을 참조하도록 만드는 이니셜라이저를 작성한다.
  4. 프로토콜을 따르도록 프로퍼티 및 메소드들을 정의한다. 이니셜라이저에서 참조한 속성들로 포워딩하도록 하면 된다.

위 조건에 따라 예를 들어보자면 다음과 같은 기계적인 작성이 필요하다. 보면 알겠지만, 유별나거나 특별한 테크닉이 아니라, 실질적으로 프록시를 만드는 셈이다.

protocol SomeProtocol {
  associatedtype Consequence
  var foo: Int { get }
  func bar() -> Consequnce
}
struct AnySomeProtocol<T>: SomeProtocol {
  private var _foo: Int
  private var _bar: () -> T
  var foo: Int { return _foo }
  func bar() -> T {
    return _bar()
  }
  init<A: SomeProtocol> (_ base: A) where A.Consequnce == T {
    self._foo = base.foo
    self._bar = base.bar
  }
}