Search

[Swift] Generic

The Swift Programming Language Documents를 참고해서 정리했습니다.
contents

️ 들어가며

여러분, Generic 잘 사용하시나요?
저는 Generic를 잘 사용하지 못합니다. 저에게 Generic이라는 개념은 어려운 개념이었어요. Generic이 가지고 있는 모습이 퍽 생소했기 때문에 시작도 안해보고 쫄아 버린거죠.
그렇기 때문에 제 코드는 유연하지 못했습니다. 제 코드는 항상 Concrete 했어요.
[Swift] Protocol(1) 공부를 하면서 제가 작성한 Protocol 요구 사항들이 너무 현재 케이스에 맞춰진 것 같다는 생각이 들었습니다. 그래서 현재 케이스가 아닌 비슷한 케이스에서도 요구 사항을 사용할 수 있게 만들어주려고 합니다. Generic를 통해서요!
Generic과 함께 유연해져봅시다. (ง˙∇˙)ว

Generic

Generic이 유연하다는데 왜 유연할까요?
Generic를 사용하면 여러 타입들이 사용할 수 있게 코드를 작성할 수 있기 때문입니다.
우리는 보통 하나의 타입만 들어올 수 있도록 타입을 설정해둡니다.
예를 들어서, 함수 파라미터로 들어올 수 있는 타입을 String으로 설정해두면 파라미터는 항상 String 타입의 값만 들어오게 됩니다. 같은 일을 하지만 다른 타입을 받고 싶다면 다른 타입을 받는 함수를 따로 만들어야 하는거죠.
func swapTwoStrings(_ a: inout String, _ b: inout String) { let temporaryA = a a = b b = temporaryA }
Swift
복사
하지만, 함수 파라미터로 여러 타입이 들어올 수 있도록 설정해둔다면 어떨까요?
사용자가 정의한 요구 사항에 따라 모든 타입에서 작동할 수 있는 유연하고 재사용 가능한 함수가 완성될 겁니다. 모든 타입이 함수를 사용할 수 있기 때문에 같은 코드로 다른 타입을 받는 여러 개의 함수를 만들지 않아도 됩니다. 즉, 중복 코드가 사라집니다.
모든 타입을 받을 수 있는 함수가 더 유용하고 훨씬 유연합니다. 물론, 모든 타입을 받을 수 있다고 해서 아무렇게나 값을 집어넣으면 안됩니다. 하단에 있는 swapTwoValues 함수는 파라미터로 아무 타입이나 받을 수 있지만, 파라미터로 들어오는 두 개의 값이 가지는 타입이 동일해야 합니다. a의 타입은 Int, b의 타입은 String이면 안된다는 뜻입니다.
func swapTwoValues<T>(_ a: inout T, _ b: inout T) { let temporaryA = a a = b b = temporaryA }
Swift
복사
Generic은 Swift의 강력한 기능 중에 하나입니다. 대부분의 Swift standard library가 Generic으로 구축되었을 정도입니다.
우리가 잘 아는 Array, Dictionary 타입도 모두 Generic Collection 입니다.
Array, Dictionary는 Element로 여러 타입들을 받기 때문에 Generic으로 구축되어 있어야 합니다. Element가 특정 타입으로 되어 있다면 우리는 해당 타입을 위한 Array, Dictionary 밖엔 못 만들겠죠?
그렇다면 Generic은 어떻게 쓰는걸까요?
Generic은 placeholder type name를 사용해서 나타냅니다. 함수 이름 뒤에 <T>를 붙여주는 거죠.
T가 어떤 타입이 되어야 하는지 명시하지 않습니다. 그냥 T라는 Type 이름을 나타낼 뿐입니다. T가 무슨 타입이 될 지는 함수 호출 시에 함수에 전달된 Value Type으로 결정됩니다.
위에서 만든 swapTwoValues 메서드를 예로 들자면 a, b 매개 변수는 T를 타입으로 가지고 있습니다. 즉, 아직 a, b가 어떤 타입이 될 지 모릅니다.
이러한 상황에서 Int, String 값을 함수에 각각 넣어주게 된다면,
var someInt = 3 var anotherInt = 107 swapTwoValues(&someInt, &anotherInt) // someInt is now 107, and anotherInt is now 3 var someString = "hello" var anotherString = "world" swapTwoValues(&someString, &anotherString) // someString is now "world", and anotherString is now "hello"
Swift
복사
Int가 들어올 때는 T는 Int 타입이 될 것이고, String 타입의 값이 들어올 때는 T가 String 타입이 될 겁니다.
이런 T를 type parameter 라고 부릅니다.
Type Parameter는 함수의 매개 변수로도 사용할 수 있고, 함수의 반환 타입으로도 사용할 수 있습니다. 함수 본문 안에서 사용될 수도 있습니다.
func swapTwoValues<T>(_ a: T, _ b: T) -> (a: T, b: T) { var a: T = a var b: T = b let temporaryA: T = a // ... return (a, b) }
Swift
복사
또한, 여러 개의 Type Parameter를 만드는 것도 가능합니다. <> 안에서 쉼표(,)를 사용해서 구분해주기만 하면 됩니다.
func compareDifferentValue<T, U>(_ a: T, b: U) -> Bool
Swift
복사
왜 T, U로 작성하는 건가요? 규칙인가요?
Type Parameter도 이름을 가질 수 있습니다.
대표적인 예가 Array<Element>Dictionary<Key, Value> 입니다. 이름이 있으니 의미를 알기 훨씬 쉽네요. 저런 이름을 descriptive name 이라고 합니다. 사용되는 Generic Type, function과의 관계를 이름을 통해서 알려주는 겁니다.
하지만, 대부분의 경우 이들 사이에 큰 의미가 없을 겁니다. 그런 경우에 T, U, V와 같은 단일 문자를 사용한 Type Parameter 이름을 짓게 됩니다. 단일 문자를 사용해서 이름을 짓는 경우에도 Type Placeholder라는 걸 나타내기 위해서 항상 Upper Camel Case를 사용해서 네이밍을 해야 합니다.

Generic Types

Swift를 사용하면 Generic Type를 정의할 수 있습니다. 사용자가 정의한 Class, Struct, enum 어느 타입에서나 사용 가능합니다.
저는 Struct로 자료 구조를 하나 만들어 보려고 합니다. 바로, Stack 입니다.
일단, non-generic하게 Stack를 만들어 보겠습니다. Int 타입을 위한 Stack을 만들겁니다.
Stack은 끝 부분에 새 항목을 추가(push)할 수 있고, 끝 부분에서 항목을 삭제(pop)할 수도 있습니다.
push, pop하는 기능까지 가진 Stack를 만들어 본다면, 이렇겠네요.
struct IntStack { var items: [Int] = [] mutating func push(_ item: Int) { items.append(item) } mutating func pop() -> Int { return items.removeLast() } }
Swift
복사
하지만, 해당 Stack은 Int 타입만 사용할 수 있습니다. String 타입의 값들을 Stack에 쌓고 싶다고 해도 IntStack 에 넣을 수 없습니다. 넣고 싶다면 StringStack를 새로 만들어야 합니다. 하지만, StringStack의 코드는 IntStack과 거의 동일합니다. 들어 있는 타입만 다를 뿐이죠.
우리는 모든 Type이 사용할 수 있는 Stack이 필요합니다.
Generic를 사용해봅시다.
struct Stack<Element> { var items: [Element] = [] mutating func push(_ item: Element) { items.append(item) } mutating func pop() -> Element { return items.removeLast() } }
Swift
복사
이제 Stack은 Generic Type이기 때문에 검증된 타입이라면 어떤 타입이든지 Stack으로 만들 수 있습니다.
var intStack = Stack<Int>() intStack.push(2) intStack.push(3) var stringStack = Stack<String>() stringStack.push("hello") stringStack.push("안녕하세요")
Swift
복사
이전보다 훨씬 유연하고 재사용하기 좋아졌습니다. ⸜( ˙ ˘ ˙)⸝♡
이번엔 Stack 가장 위에 위치하는 Item이 무엇인지 나타내는 속성을 하나 만들어 보려고 합니다. 해당 프로퍼티는 Generic extension 내부에 작성해볼게요.
extension에서는 Type Parameter를 제공하지 않아도 사용할 수 있습니다. 이전에 작성해둔 Original Type Parameter가 있기 때문에 extension에서도 해당 Type Parameter를 사용합니다.
extension Stack { var topItem: Element? { return items.isEmpty ? nil : items[items.count - 1] } }
Swift
복사
Element가 무엇인지 extension 어디에도 정의하지 않았지만 Element를 사용할 수 있습니다.
가장 위에 있는 Item도 잘 찾아내는군요. ᕕ( ՞ ᗜ ՞ )ᕗ
이번에는 Stack 안에 들어 있는 값 중에서 제가 찾고자 하는 값이 몇번째 인덱스에 위치하는지 찾는 메서드를 하나 만들어 볼겁니다.
func findIndex(of valueToFind: Element) -> Int? { for (index, value) in items.enumerated() { if value == valueToFind { return index } } return nil }
Swift
복사
찾고자 하는 값이 Stack 안에 들어가는 타입과 동일해야 하니 Type를 Element로 설정해주었습니다.
하지만, findIndex 메서드를 만들면 컴파일 에러 메시지를 볼 수 있습니다.
Binary operator '==' cannot be applied to two 'Element' operands 2개의 Element 피연산자에 “==” 연산자를 적용할 수 없습니다.
“==” 연산자를 적용할 수 없다니 이게 무슨 말일까요?
모든 Value Type이 == operator를 사용해서 비교 가능하지 않습니다. 즉, 모든 타입에서 비교 연산자를 사용했을 때 코드가 돌아간다는 보장이 없습니다.
우리가 만든 Class, Struct가 비교 가능한지, 불가능한지 Swift가 추측할 수 없기 때문에 선뜻 == operator로 두 값을 비교할 수 있게끔 해주지 않는거죠.
그렇다면 두 값이 비교할 수 있는 상태라는 건 어떻게 설정할 수 있을까요?
비교할 수 있는 상태라는 걸 나타내는 프로토콜이 있습니다. 바로 Equatable 입니다.
해당 프로토콜을 준수하는 타입이라면 == operator를 사용할 수 있습니다. 우리가 잘 사용하는 기본 타입들은 모두 Equatable 프로토콜을 준수하고 있습니다. 그렇기 때문에, 우리가 추가적인 설정을 해주지 않고 두 값을 비교할 수 있었던거죠.
우리가 위에서 만든 findIndex 에서 비교 연산자를 사용하기 위해서는 Type Constraints를 걸어주어야 합니다. Stack에서 사용하는 타입 파라미터 Element가 Equatable 를 준수하고 있다고 제약을 걸어주는 겁니다.
struct Stack<Element: Equatable> { ... }
Swift
복사
Equatable 준수하게 된 Element는 비교 가능한 상태라는 걸 Swift에서 추측할 수 있기 때문에 이전에 났던 컴파일 에러가 발생하지 않습니다. Equatable를 준수하는 어떤 타입이든 Stack의 Element로 사용할 수 있게 됩니다.
실행도 잘 되네요. ٩(ˊᗜˋ*)و

Associated Type

Associated Type이라는 걸 보신 적 있으신가요?
아마 이런 모습으로 프로토콜에서 흔히 보셨을 겁니다.
protocol SomeProtocol { associatedtype T }
Swift
복사
Associated Type은 프로토콜 정의 시에 Protocol의 일부로 사용되는 Type에 placeholder name를 제공해줍니다. Type Parameter같이 실제 타입이 지정되기 전까지 Associated Type에는 아무런 Type도 들어가지 않습니다.
Protocol은 Type, function과는 다르게 Type Parameter를 가지지 못하기 때문에 Associated Type으로 그 역할을 대신한다고 봐주시면 됩니다. 따라서, Associated Type은 Type Parameter가 하는 역할과 흡사한 역할을 합니다.
Associated Type에 대해서 자세하게 설명하기 위해 Container 라는 프로토콜을 만들었습니다. Container는 3가지 요구 사항을 가지고 있고, associatedtype도 가지고 있습니다.
protocol Container { associatedtype Item var count: Int { get } subscript(i: Int) -> Item { get } mutating func append(_ item: Item) }
Swift
복사
Container에 있는 associatedtype Item어떻게 저장되어야 하는지, 무슨 타입을 따르는지 지정하지 않습니다. 물론, Container 프로토콜을 준수하는 타입이 있다면 Item에 올바른 타입을 지정해주어야 합니다.
굳이 associated type으로 지정할 필요가 있을까요? 특정 타입으로 지정하면 되지 않나요?
subscript가 반환하는 타입이나, append가 매개 변수로 받는 타입을 특정 타입으로 지정할 수도 있겠지요.
하지만, Container를 준수하는 타입이 해당 타입을 사용하지 않는다면 큰 문제가 될 겁니다.
Container가 특정 타입만을 반환한다고 가정했을 때,
protocol Container { var count: Int { get } subscript(i: Int) -> String { get } mutating func append(_ item: String) }
Swift
복사
위에서 만들었던 non-generic한 IntStack 구조체가 해당 프로토콜을 준수한다고 해봅시다.
struct IntStack: Container { var items: [Int] = [] mutating func push(_ item: Int) { items.append(item) } mutating func pop() -> Int { return items.removeLast() } var count: Int { return items.count } subscript(i: Int) -> String { // compile error return items[i] } mutating func append(_ item: String) { // compile error self.push(item) } }
Swift
복사
subscript, append는 컴파일 에러가 발생해서 사용할 수 없을겁니다. 아마도 Int 타입을 반환하고, Int 타입을 매개 변수로 받는 프로토콜을 새로 만들어야겠지요?
하지만, 중복되는 요구 사항이 있는 프로토콜을 하나 더 만드는 것은 좋지 않아보입니다. 따라서, Type Parameter처럼 사용하는 곳에서 타입을 지정해서 사용할 수 있도록 해주는 겁니다.
그렇게 된다면, IntStack도 이런 식으로 사용할 수 있겠네요.
struct IntStack: Container { var items: [Int] = [] mutating func push(_ item: Int) { items.append(item) } mutating func pop() -> Int { return items.removeLast() } typealias Item = Int var count: Int { return items.count } subscript(i: Int) -> Int { return items[i] } mutating func append(_ item: Int) { self.push(item) } }
Swift
복사
Item를 Int 타입으로 지정하면서 실제 타입이 생겼습니다.
Swift는 타입 추론을 할 수 있기 때문에 Item = Int 로 선언해주지 않아도 됩니다. Item = Int 없이 위에 코드를 사용해도 subscript가 반환하는 타입, append가 매개 변수로 받는 타입을 확인하여 Int 타입으로 유추할 수 있습니다.
그렇다면, 위에서 만든 Generic Type Stack에서 Container를 채택해봅시다.
struct Stack<Element: Equatable>: Container { // MARK: - property var items: [Element] = [] var count: Int { return items.count } subscript(i: Int) -> Element { return items[i] } // MARK: - func mutating func push(_ item: Element) { items.append(item) } mutating func pop() -> Element { return items.removeLast() } mutating func append(_ item: Element) { self.push(item) } }
Swift
복사
Stack은 Generic Type이기 때문에 Type Parameter를 가집니다. 따라서, Container에서 associated type으로 사용하게 될 타입이 Type Parameter와 동일한 타입이 되게 됩니다.
이번엔 Container를 상속받는 SuffixableContainer라는 프로토콜을 만들어 보겠습니다. 해당 프로토콜은 suffix 라는 메서드를 가지고 Suffix associated type를 가집니다.
protocol SuffixableContainer: Container { associatedtype Suffix: SuffixableContainer where Suffix.Item == Item func suffix(_ size: Int) -> Suffix }
Swift
복사
Suffix는 이전에 봤던 Type Parameter처럼 제약이 걸려 있습니다.
1.
SuffixableContainer 를 준수해야 함.
2.
Suffix의 Item 타입과 Container의 Item 타입이 동일해야 함.
그렇다면 Stack이 SuffixableContainer를 준수하도록 합시다. extension를 사용해서 SuffixableContainer를 채택하도록 했습니다.
extension Stack: SuffixableContainer { func suffix(_ size: Int) -> Stack { var result = Stack() for index in (count - size)..<count { result.append(self[index]) } return result } }
Swift
복사
suffix 메서드가 반환하는 타입이 Stack이기 때문에 Suffix는 Stack 타입이라고 볼 수 있습니다. 위의 제약처럼 SuffixableContainer 프로토콜을 준수하고, Stack의 Element 타입과 Container의 Item 타입이 Element로 동일합니다. 따라서, Stack를 Suffix의 실제 타입으로 지정할 수 있는 겁니다.
아까 만들어둔 IntStack이 SuffixableContainer를 준수하게 된다면 다른 타입을 반환하게 될 겁니다.
IntStack이 SuffixableContainer 를 준수하고 Suffix의 Item이 Int 타입이고 Container의 Item도 Int 이기 때문에 IntStackSuffix 로 사용할 수 있게 됩니다.
extension IntStack: SuffixableContainer { func suffix(_ size: Int) -> IntStack { var result = IntStack() for index in (count - size)..<count { result.append(self[index]) } return result } }
Swift
복사
같은 뜻으로, Stack<Int> 도 IntStack에서 Suffix 로 사용 가능합니다.
extension IntStack: SuffixableContainer { func suffix(_ size: Int) -> Stack<Int> { var result = Stack<Int>() for index in (count - size)..<count { result.append(self[index]) } return result } }
Swift
복사

Where

위에서 associated type에 제약을 걸어줄 때, where 이라는 단어를 보셨을 겁니다.
where은 제약 사항을 걸 때, 요구 사항을 정의해주는 역할을 합니다.
⓵ 해당 타입이 특정 프로토콜을 준수해야 한다든지 ⓶ 특정 타입과 해당 타입이 동일해야 한다든지 하는 요구 사항들로 구성되어 있습니다.
SuffixableContainer에 있는 Suffix에는 해당 Item 타입과 Container의 Item 타입이 동일해야 한다는 요구 사항을 구성했습니다.
associatedtype Suffix: SuffixableContainer where Suffix.Item == Item
Swift
복사
아까 만들어둔 Container 프로토콜을 준수하는 타입 파라미터를 사용해서 Container 안에 들어있는 Item이 동일한지 확인하는 함수를 만들어 보려고 합니다.
allItemMatch 라는 메서드는 Container 프로토콜을 준수하는 타입 파라미터를 매개 변수로 받습니다.
func allItemsMatch<C1: Container, C2: Container>(_ someContainer: C1, _ anotherContainer: C2) -> Bool where C1.Item == C2.Item, C1.Item: Equatable { if someContainer.count != anotherContainer.count { return false } for i in 0..<someContainer.count { if someContainer[i] != anotherContainer[i] { return false } } return true }
Swift
복사
메서드 { } 전 부분에 where로 제약 사항이 적혀 있습니다.
1.
매개변수 C1과 매개변수 C2의 Item은 같은 타입을 가짐.
2.
매개변수 C1의 Item은 Equatable 프로토콜을 준수함.
1, 2번을 보면 C1과 C2는 같은 Item 타입을 가지고 해당 타입이 Equatable 프로토콜을 준수하기 때문에 두 매개변수는 함수 내부에서 비교 연산자를 사용해서 비교가 가능합니다.
그렇다면, allItemMatch 메서드에 매개 변수를 넣어서 사용해볼까요. 하나는 Stack<String> 타입이고, 하나는 Array<String> 타입입니다.
*Array는 Container를 채택한 상태입니다.
Container 내부에 추가적으로 메서드를 만들려고 합니다.
이번에 만들 메서드는 해당 Item으로 시작하는지 확인하는 메서드입니다. 매개 변수로 넣은 Item으로 시작하지 않으면 Bool 값을 반환합니다.
extension Container where Item: Equatable { func startsWith(_ item: Item) -> Bool { return count >= 1 && self[0] == item } }
Swift
복사
startsWith 메서드에서는 두 값을 비교합니다. 이전에 Container에 포함되어 있던 Item은 비교 가능한지 Swift에서 추측할 수 없는 상태였기 때문에 where를 사용해서 Item이 Equatable를 준수하도록 했습니다.
이제 Item은 Equatable을 준수해야만 합니다.
잘 작동하는 군요. ♪( 'ω' و(و”
Container 프로토콜에 Item들의 평균을 계산하는 메서드를 하나 더 만들어 볼게요. 평균을 계산하기 때문에 들어오는 값의 타입이 특정됩니다.
String 타입의 평균을 낼 순 없으니깐요. 저는 소수점 아래에 들어오는 숫자까지 알고 싶기 때문에 해당 타입을 Double 타입으로 설정하겠습니다.
extension Container where Item == Double { func average() -> Double { var sum = 0.0 for index in 0..<count { sum += self[index] } return sum / Double(count) } }
Swift
복사
Item에 들어오는 타입이 Double 이라면 average 메서드를 사용할 수 있도록 했습니다. 맞지 않는 타입이 average 메서드를 사용하려고 한다면 컴파일 에러가 발생합니다.
두 개의 메서드는 Item이 무엇인지 정의하지 않아도 됩니다. original declaration에 associated type에 대한 선언이 있어서 그대로 사용할 수 있는겁니다.
또한, 위처럼 extension 선언 시에 제약을 걸지 않아도 괜찮습니다.
위에서는 제약 조건이 다르기 때문에 각각 다른 조건으로 extension 시 제약을 걸었습니다. 하지만 contextual where를 사용하게 된다면 다른 제약 조건을 한 데 묶어서 볼 수 있습니다.
각각 넣는 것보다 한 곳에서 사용하는 것이 가독성 측면에서도 더 좋을 겁니다.
extension Container { func startsWith(_ item: Item) -> Bool where Item: Equatable { return count >= 1 && self[0] == item } func average() -> Double where Item == Double { var sum = 0.0 for index in 0..<count { sum += self[index] } return sum / Double(count) } }
Swift
복사

마치며

이번 포스팅에서는 Generic 타입에서 알아봤습니다.
특히, Associated Type은 Protocol과 함께 사용되기 때문에 더 어렵게 느껴졌던 거 같습니다. 제가 짜던 코드는 항상 IntStack 이였기에 Generic에 대한 공부가 Generic Stack 타입으로 나아가는 한 발자국이라고 생각합니다.
위의 예시 코드들은 깃허브 레포에 올려뒀습니다.
완성된 코드로 되어 있기 때문에 한 단계씩 진행해보고 싶으신 분들은 Documentation 예시나 포스팅 코드를 보면서 직접 따라해보시는게 좋을 것 같네요.

참고 자료