Swift

[Swift] any 키워드는 왜 있는걸까? Existential Type은 뭔데?

하이D:) 2024. 10. 31. 09:00

some과 any를 공부하다 보니 any라는 키워드가 등장한 이유가 궁금해졌다.

궁금증이 의식의 흐름처럼 생기는 편.. ㅎㅎ

 

 

any 키워드에 대해서 공부하다보니 Existential type 이라는 것까지 공부하게 되었는데 Existential type의 특성과 함께 swift 5.6에서 any 키워드가 등장한 이유에 대해서도 알게되어 흥미로워 글을 적는다. Swift를 공부하면서 Existential 이라는 단어는 Existential Container 공부할 때 밖에 못 봤는데 (프로토콜 타입의 인스턴스를 저장하는 방법에 대한 내용) 역시나 이번에도 프로토콜에 관련한 내용이었다.

 

Existential Container 에 대한 내용이 궁금하다면 아래 블로그 글로!

프로토콜 채택한 구조체 Existential Container / ValueBuffer / VWT / PWT

 

 

 

 

 

 

📍 Existential Type이란?

위에서 살짝 힌트가 있었듯이 프로토콜을 타입으로서 사용할 때의 타입이다.

"해당 프로토콜을 준수하는 타입이 존재한다"라는 의미에서 "존재타입(ExistentialType)"이라고 부르는 것이다.

 

즉, ExistentialType은 Swift에서 프로토콜을 타입으로 사용할 때 생기는 개념으로, 반환 값이 특정 프로토콜을 준수한다는 사실만 보장하고 구체적인 타입은 숨기는 방식이다. 실제 사용되는 구체 타입에 대해서는 런타임에 알 수 있으며, 이 때문에 Concrete Type(구체타입)으로 정의했을 때보다 성능적으로 불리한 부분이 생길 수 있다는 점도 인지하고 넘어가 보자! (뒤에서 자세히 설명함)

 

 

swift 5.6에서 any 타입이 나왔다고는 했지만 프로토콜을 타입으로서 사용하는 건 그 이전에도 가능했던 것이다.

아래 p1과 pq1, circle과 cat은 모두 existential type이다.

protocol P {}
protocol Q {}
struct S: P, Q {}


let p1: P = S()
let pq1: P & Q = S()

 

protocol Shape { }
struct Circle : Shape { }

let circle : Shape = Circle()


protocol Pet { }
struct Cat : Pet { }

let cat : Pet = Cat()

 

 

 

 

그렇다면 왜 굳이 any 라는 키워드가 필요했을까?

 

 

 

 

 

 

 

 

📍 swift5.6 릴리즈 문서 (any 키워드가 등장한 이유)

왜 any 라는 키워드가 필요했을까? 라는 물음에 대한 답은 swift5.6 릴리즈 문서에 적혀있다.

 

https://www.swift.org/blog/swift-5.6-released/

 

Swift 5.6 Released!

Swift 5.6 is now officially released!

www.swift.org

 

여기서 Existential any 라는 섹션에서 아래 구문을 자세히 보자. 이 구문은 any 키워드가 등장한 이유에 대해 Existential  Type과 Genric의 관점에서 설명한다.

 

An existential type erases its underlying type information, which is useful when you need to dynamically change the underlying type, but it prohibits existential types from other useful capabilities such as conforming to protocols. The existing syntax is confusing because an existential type looks just like a generic conformance requirement, which doesn’t have these fundamental limitations.

 

 

아래 코드 처럼 우리가 익히 알듯이 그냥 일반 프로토콜로 표기해줌으로써도 Existential Type을 정의해 줄 수 있었는데, 

protocol DataSourceObserver { ... }

struct DataSource {
  var observers: [DataSourceObserver] { ... }
}

 

이렇게 정의해주는데는 한계가 있다는 점을 위의 구문에서 설명하고 있다. 영어를 직역해서 해석은 할 수 있다고 해도 역시 알고 있는 개념과 연관해서 이해하기란 쉽지가 않다. 그래서 내가 이해한 대로 최대한 풀어서 설명해보려 한다.

 

 

 

 

Existential type의 특성을 바탕으로 위 문장들을 이해해 보자면

An existential type erases its underlying type information, which is useful when you need to dynamically change the underlying type

 

Existential Type은 underlying type information를 지운다.

underlying type은 대체될 수 있는 특정 구체 타입(Concrete Type)인데 이 정보를 지우게 되면 아래 코드에서 볼 수 있는 것처럼 여러 구체 타입을 동적으로 바꾸고 싶을 경우에는 유용하기도 하다.

 

// existential type erases its underlying type information

protocol Shape {
    func area () -> Double
}
struct Circle : Shape {
    func area () -> Double {
        ...
    }
}
struct Square : Shape {
    func area () -> Double {
        ...
    }
}

func shapeSequence(value : [Shape]) {
    for shape in value {
        shape.area()
    }
}

//⭕️ 가능
shapeSequence(value: [Circle(), Square()])

 

 

 

 

이처럼 대체될 수 있는 특정 구체 타입에 대한 정보 (underlying type information)를 지우는 게 유용할 때도 있긴 하지만, 이 지점이 존재타입을 사용했을 때 한계가 될 수 있다는 것을 아래 문장으로부터 알 수 있다.

 

, but it prohibits existential types from other useful capabilities such as conforming to protocols.

 

프로토콜 내부에 associatedtype이나 Self같은 정보들이 있을 때는 이런 방식으로 정의해주지 못한다는 의미가 담겨있다.

왜냐하면 underlying type information을 지워버리기 때문에!! (( 대체될 수 있는 특정 구체 타입에 대한 정보를 모르는 상태이기 때문에 프로토콜의 associatedtype이나 Self같은 정보들이 어떻게 구현되었는지 알 수가 없어서 ))

 

예를 들어 associatedtype 정보를 가지며 구현되어 있는 Collection 프로토콜에 대해서는 이렇게 그냥 단순하게 프로토콜 이름을 작성해 줌으로써 Existential Type을 정의해 줄 수 없다는 것을 아래 코드를 통해 확인할 수 있다. 

func printUsersName(users : Collection) { //🚨
    for user in users {
//        print(user.name)
    }
}

 

 

 

그럼 어떻게 해줘야 했을까?

아래처럼 제네릭을 사용해서 where문으로 associatedtype에 대한 제약을 줌으로써 사용을 해줬어야 했다.

func printUsersName<C : Collection>(users : C) where C.Element == User {
    for user in users {
        print(user.name)
    }
}

 

 

 

즉 , associatedtype이나 Self 같은 정보를 요구하는 프로토콜에 대해서는 프로토콜 준수(conformance)와 같은 중요한 기능을 사용할 수 없다는 단점이 있다는 것을 위의 문장에서 함축하고 있는 것!

 

 

 

 

The existing syntax is confusing because an existential type looks just like a generic conformance requirement, which doesn’t have these fundamental limitations.

 

그리고 기존에 사용하던 existential type에 대한 신텍스가 다른 문법들(이 문장에서는 제네릭)과 비슷한 형태를 띠기 때문에 개발자로 하여금 혼란을 야기할 수도 있다고 한다.

 

아래처럼 제네릭과 existential type을 사용한다고 했을 때  둘 다 shape이라는 프로토콜을 사용하기 때문에 형태적으로 비슷해 보일 수 있다. 하지만, 존재 타입은 유연성을 제공하지만 타입 정보가 삭제되고, 제네릭은 타입 안정성을 유지하며 기능적 유연성을 제공한다는 차이점이 있을 뿐만 아니라 제네릭은 위에서 말한 존재타입의 한계 또한 가지고 있지 않는 차이점이 있다.

 

shapeExistentialType, shapeGeneric 두 함수의 목적과 쓰임이 이렇게 다름에도 불구하고 둘 다 shape이라는 프로토콜을 사용했기 때문에 형태적으로 비슷해보일 수 있고, 이로 인해 개발자가 혼란을 느낄 수도 있다.

 

protocol Shape {
    func area () -> Double
}
struct Circle : Shape {
    func area () -> Double {
        ...
    }
}
struct Square : Shape {
    func area () -> Double {
        ...
    }
}

//Existential Type
func shapeSequence(value : [Shape]) {
    for shape in value {
        shape.area()
    }
}
shapeSequence(value: [Circle(), Square()])

func shapeExistentialType(value : Shape) {
    value.area()
}
shapeExistentialType(value: Circle())

//Generic
func shapeGeneric<T : Shape>(value : T) {
    value.area()
}
shapeGeneric(value: Circle()) //제네릭은 호출부에서 컴파일 시점에 타입 구체적으로 알 수 있다.(타입 안정성 제공)

 

 

그리고 위에서 말한 것처럼 제네릭에서의 타입 제약에 대한 표현이 existential type과 헷갈릴 수 있을 뿐만 아니라, 아래 코드에서 볼 수 있는 것과 같이 기존의 Existential Type 표현법은 Concrete Type(구체 타입)을 표현하는 것과도 헷갈릴 수 있다.

//A와 B가 각각 구체타입(Concrete Type)인지 존재타입(Existential Type)인지 구분되지 않는다
var a : A
var b : B

 

 

 

 

 

Swift 5.6 릴리즈 문서에서 설명해 놓은 이러한 이유들로 Swift5.6에서는 Existential Type을 any 키워드로 명시적으로 표시할 수 있게 되었다.

 

 

휴우.. 이렇게까지 장황하게 설명했지만 이렇게까지 풀어서 설명한 이유는 문서에 있는 몇 문장의 설명만으로는 any가 등장한 이유에 대해 잘 이해가 가지 않아서 (( 내가 이해하기 위해 ㅎㅎ)) 길게 작성해 본것..ㅎㅎ

 

간단하게만 요약하자면!!

 

any 키워드를 사용함으로써, associatedtype, Self 정보가 있는 프로토콜도 Existential Type 으로 쓸 수 있게 되었고, 제네릭의 타입제약, 구체 타입등 다른 코드들과도 명시적으로 구분할 수 있게 된 것이다!

 

 

 

 

 

 

 

 

📍 Existential Type의 성능상 이슈

 

그럼 이렇게 any라는 키워드도 나왔겠다...

"제네릭도 필요 없고 그냥 any 사용해서 Existential Type으로 정의해서 사용해 주면 편한 거 아니야?" 라고 생각할 수도 있다.

 

하지만 이렇게 생각했다면 크나큰 오산!

 

제네릭, 존재 타입 이 둘 사이에는 성능상의 차이도 있기 때문에 Existential Type은 주의해서 사용해 줄 필요가 있다!

Existential Type은 어떤 점에서 성능상의 이슈가 있다는 것인지 살펴보자!

 

 

 

 

 

이 부분에 대해서는 swift evolution - proposal 에서 any를 소개하며 설명하고 있다.

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0335-existential-any.md

 

swift-evolution/proposals/0335-existential-any.md at main · swiftlang/swift-evolution

This maintains proposals for changes and user-visible enhancements to the Swift Programming Language. - swiftlang/swift-evolution

github.com

 

 

우선, 위에서 계속 설명했던 것처럼 단순하게 : 프로토콜이름 헝태로 나타내는 표현법은 개발자에게 혼란을 줄 수 있다고 설명한다.

 

 

 

 

Existential types in Swift have significant limitations and performance implications.

이 문장에 따르면 Swift의 Existential type은 심각한 제한사항이 있고 성능에 영향을 끼친다고 한다.

 

제한이 있다는 것은 윗글에서 설명했기 때문에 알겠는데 성능에 영향을 미친다는 것은 또 무슨 말일까??

 

 

 

Existential types are also significantly more expensive than using concrete types. ... (생략)...  existential types require dynamic memory unless the value is small enough to fit within an inline 3-word buffer. In addition to heap allocation and reference counting, code using existential types incurs pointer indirection and dynamic method dispatch that cannot be optimized away.

위에 문장들을 요약하면 Existential Type을 사용하는 건 Concrete Type을 사용하는 것보다 성능적을 비용이 많이 든다고 이야기하며, Existential type의 경우에 인스턴스 메서드는 dynamic dispatch로 동작하고, 3-word buffer가 넘어갈 경우에는 힙메모리에 프로퍼티에 대한 정보를 저장하기 때문에 동적 메모리가 필요하게 된다고 언급하고 있다.

 

 

 

이것만으로는 하나도 이해가 안 가죠?? :):) 이게 무슨 말인지 풀어서 설명해 보자면!

 

Existential Type의 경우에는 Existential Container 형식으로 저장되어 있고 Protocol Witness Table(PWT) 에 대한 포인터를 가지고 있다가 런타임에 어떤 인스턴스의 메서드를 실행시킬지 특정되고 인스턴스의 메서드를 실행시키기 때문에 메서드가 dymanic 하게 실행되게 됩니다. 그리고 값(프로퍼티)의 경우에는 Existential Container의 value buffer부분에 저장하게 되는데 3word를 넘어갈 경우에는 힙영역에 메모리를 할당해 저장해 놓기 때문에 동적 메모리가 필요한 것이고, 이 경우에는 reference counting에 대한 비용도 있기 때문에 성능에 영향을 끼치게 됩니다.

 

애플에서 소개한 'swift에서 성능을 좌우하는 대표적 세 가지 요소'가 1) 할당영역(stack or heap), 2) 참조계수 카운팅 여부, 3) 메서드 디스패치(static or dynamic)인데 Existential Type의 경우에는 최악의 경우에 이 세 가지 요소 모두 성능이 불리한 쪽으로 작동하기 때문에 성능적으로 비용이 많이 든다고 설명하고 있는 것입니다!

 

 

 

 

잘 이해가 안 된다면 이전 블로그 글을 참고하면 좋을 것 같습니다! ☺️

프로토콜 채택한 구조체 Existential Container / ValueBuffer / VWT / PWT

Swift Performance (1) | swift에서 성능을 좌우하는 3가지 요소와 구조체의 성능 변화

 

 

 

 

 

 

Despite these significant and often undesirable implications, existential types have a minimal spelling.

Syntactically, the cost of using one is hidden, and the similar spelling to generic constraints has caused many programmers to confuse existential types with generics.

위와 같은 성능적 문제 상황이 있음에도 불구하고 Existential Type이 minimal spelling를 가졌고, 구문적으로 볼 때, 이를 사용하는 데 드는 비용은 숨겨져 있으며, 제네릭 제약 조건과 철자가 비슷하기 때문에 많은 프로그래머가 존재타입과 제네릭을 혼동하게 되었다고 설명하고 있습니다.

 

 

The cost of using existential types should not be hidden, and programmers should explicitly opt into these semantics.

Existential Type을 사용하는 데 드는 비용은 숨겨져서는 안 되며, 프로그래머는 이러한 의미 체계를 명시적으로 선택해야 합니다.

 

Concrete Type보다 더 많은 비용이 요구됨에도 불구하고 구문적으로 비슷하게 쓰이는 것 때문에 Existential Type으로 사용했다가는 성능적인 비용이 많이 발생하기 때문에, Existential Type를 사용하는 것이 더욱 명시적이어야 한다고 설명합니다. 

 

그럼 어떻게 해결할 건데??

그래서 소개한 해결책이 바로 swift 5.6의 any 키워드입니다.

 

 

 

 

📍요약

 

🔸 단순하게  :프로토콜이름 으로 Existential Type을 나타냈을 때의 문제점

  • associatedtype, Self 정보가 담긴 프로토콜을 Existential Type으로 사용하지 못했음
  • 제네릭의 타입제약, 구체 타입 등 다른 코드들과 명시적으로 구분하기 힘듦
  • Existential Type은 성능적 비용이 많이 들어감에도 불구하고 다른 코드들과 명시적으로 구분될 수 있는 지점이 없었기 때문에 필요하지 않은 경우에도 개발자가 혼동하여 사용할 가능성이 있음

 

🔸 any 키워드가 등장한 후

  • associatedtype, Self 정보가 담긴 프로토콜도 Existential Type으로서 사용 가능
  • 제네릭의 타입제약, 구체 타입등 다른 코드들과 명시적으로 구분할 수 있게 되었고
  • 개발자로 하여금 성능적으로 비용이 많이 드는 Existential Type에 대해 명확히 인지하며 사용할 수 있게 함