Apple 제공 Swift 프로그래밍 가이드(3.0.1)의 Generics 부분을 공부하며 정리한 글입니다. 개인적인 생각도 조금 들어가있습니다.
들어가며
제네릭을 이용하면 특정 타입에 종속되지 않는 / 유연하고 / 재사용성 높고 / 명확한 의도를 가진 함수를 작성할 수 있다.
제네릭은 Swift의 powerful feature
대부분의 Swift 기본 라이브러리는 제네릭 코드를 내장한다
사실 Array와 Dictionary 타입도 제네릭 콜렉션
Array에는 Int도, String도, 커스텀 타입도, 그리고 이것들을 전부 포함할 수 있는 Any 타입도 넣을 수 있다. 넣을 수 있는 타입에는 제한이 없다. Array가 제네릭 콜렉션이기 때문에 이런 게 가능한 것이다.
제네릭으로 해결할 수 있는 문제The Problem That Generics Solve
Int 변수 두 개의 레퍼런스를 inout 파라미터로 받은 다음, 두 변수의 값을 교환해주는 함수를 하나 살펴보자.
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
let temporaryA = a
a = b
b = temporaryA
}
var someInt = 3
var anotherInt = 107
swapTwoInts(&someInt, &anotherInt)
print("이제 someInt의 값은 \(someInt), anotherInt의 값은 \(anotherInt)")
// 이제 someInt의 값은 107, anotherInt의 값은 3
기대한 대로 동작하는 것을 볼 수 있다.
그러면 이번에는 Int 가 아니라 String 두 개의 값을 교환하는 함수를 만들어보자.
func swapTwoStrings(_ a: inout String, _ b: inout String) {
let temporaryA = a
a = b
b = temporaryA
}
이 함수는 잘 동작할 것이다.
이번에는 Double 두 개의 값을 교환하는 함수를 만들어보자.
func swapTwoDoubles(_ a: inout Double, _ b: inout Double)
let temporaryA = a
a = b
b = temporaryA
}
이번에도 요구사항을 만족시키는 함수를 잘 만들었다.
하지만 슬슬 찝찝한 기분이 들 것이다. swapTwoInts, swapTwoStrings, swapTwoDoubles 세 함수는 파라미터로 받는 타입 이름만이 다를 뿐이지 동일한 역할과 동일한 함수 본문을 가지고 있기 때문이다.
이럴 때 제네릭 코드를 사용하면 좀 더 유용하고 유연하며 심지어 하나의 함수만으로 모든 타입에 대해 같은 기능을 수행하게 할 수 있다. 제네릭 버전으로 작성한 "두 값을 교환해주는 함수"를 아래에서 살펴보자.
<note> 참고로 위 함수에서 서로 교환할 a와 b의 타입은 항상 같아야 한다. Swift는 type-safe 언어이기 때문에 Double 타입을 String 타입의 변수에 대입하는 등의 행위는 에러를 발생시킨다.
제네릭 함수
제네릭 함수는 어떠한 타입이라도 함께 잘 동작한다. 위에서 살펴본 swapTwoInts 함수의 제네릭 버전인 swapTwoValues 를 보자.
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
let temporaryA = a
a = b
b = temporaryA
}
보이는 것처럼 함수의 본문은 차이가 없다. 차이가 있는 것은 첫 번째 라인 뿐이다. 자세히 살펴보자,
func swapTwoInts(_ a: inout Int, _ b: inout Int)
func swapTwoValues<T>(_ a: inout T, _ b: inout T)
일반 함수가 Int, String 같은 실제 타입 이름을 쓰는 반면 제네릭 함수는 placeholder 타입 이름(이 예제에서는 T)을 사용하고 있다. placeholder 타입 이름은 "T는 반드시 이 타입이다"가 아니라 "T는 어떤 타입이라도 상관없지만 a와 b는 반드시 같은 타입인 T가 되어야 한다"를 가리키고 있다. T에 들어올 실제 타입은 제네릭 함수가 호출될 때에 결정된다.
placeholder 타입 이름은 꺽쇠(<>)안에 넣는다. 이 예제에서는 T를 사용했다. Swift는 T가 꺾쇠 안에 들어가 있으니 T를 placeholder 타입 이름으로 인지하고, 따라서 T라는 실제 타입이 있는지 찾아보지 않는다.
제네릭 함수는 앞서 정의했던 swapTwoInts, swapTwoStrings, swapTwoDoubles 와 같은 방식으로 사용될 수 있으며, 두 파라미터의 타입이 같기만 하면 그 외의 다른 타입도 전부 같은 방식으로 사용할 수 있다. 이 제네릭 함수가 호출될 때마다 파라미터로 넘어온 값에 따라 T의 타입이 유추된다.
var someInt = 3
var anotherInt = 107
swapTwoValues(&someInt, &anotherInt)
// 이제 someInt는 107, anotherInt는 3
var someString = "hello"
var anotherString = "world"
swapTwoValues(&someString, &anotherString)
// 이제 someString은 "world", anotherString은 "hello"
<note> 지금까지 살펴본 제네릭 함수 swapTwoValues는 사실 Swift의 기본 라이브러리에 정의된 제네릭 함수 swap 을 흉내내서 만들어 본 것이다.
Type Parameters
타입 파라미터
위에서 살펴본 제네릭 함수 swapTwoValues 에서의 T 같은 것
타입 파라미터는 placeholder 타입을 가리키는 데 쓰이며 그것에 이름을 붙이는 데 쓰인다
함수이름 바로 뒤에 명시해주고, 꺽쇠(<>) 사이에 넣어준다 (ex: <T>)
함수의 파라미터 타입이나 리턴 타입으로 쓸 수 있고, 함수 본문 안에서의 type annotation으로도 사용할 수 있다
함수가 호출될 때마다 타입 파라미터는 실제 타입으로 교체된다 (위 예제에서는 T가 Int, String 등으로 교체되었다)
타입 파라미터는 한 개 이상을 지정할 수 있다. 여러 개가 필요할 때는 꺽쇠(<>) 사이에 콤마(,)로 구분하여 명시한다
타입 파라미터에는 적절한 이름을 붙여라
타입 파라미터의 이름은 역할을 잘 표현할 수 있는 것이 좋다. Dictionary<Key, Value>의 Key와 Value, 또는 Array<Element>의 Element처럼 말이다. 그러나 타입 파라미터가 별다른 역할을 갖지 않는다면 T, U, V 같이 문자 한 개로 표현하곤 한다.
<note> 타입 파라미터의 이름은 upper camel case를 적용하라. (ex: T, MyTypeParameter) 그렇게 함으로써 이것이 value가 아니라 type을 위한 placeholder라는 것을 알려줄 수 있다.
제네릭 타입
제네릭 함수만이 아니라 제네릭 타입을 직접 정의할 수도 있다. 어떠한 타입이라도 상관없이 동작하는 커스텀 클래스, 구조체, ENUM이 제네릭 타입이다. (ex: Array, Dictionary)
이제부터 제네릭 타입 중 하나인 Stack을 만드는 과정을 살펴보겠다. Push, Pop과 같은 스택의 특징에 대해서는 이 포스트에서는 언급을 생략하겠다.
<note> 참고로 스택의 컨셉은 UINavigationController의 네비게이션 스택에서 뷰컨트롤러를 push, pop할 때도 사용된다. 스택은 LIFO(Last-In First-Out) 접근이 필요할 때 유용한 컬렉션 모델이다.
우선 Int 타입만을 다루는 non-generic 버전의 Int 스택은 이런 식으로 구현할 수 있다.
struct IntStack {
var items = [Int]()
mutating func push(_ item: Int) {
items.append(item)
}
mutating func pop() -> Int {
return items.removeLast()
}
}
이제 이 스택을 조금 수정하여, Int 만이 아닌 모든 타입을 다룰 수 있는 제네릭 스택으로 만들어보자.
struct Stack<Element> {
var items = [Element]()
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
}
생각보다 간단하게 제네릭 스택으로 바꿀 수 있었다. 타입 파라미터인 Element를 정의하고, 코드 상의 Int를 Element로 치환했을 뿐이다. 타입 파라미터인 Element는 Int도 String도 그 외의 어떤 타입이라도 될 수 있다.
제네릭 스택은 이런 식으로 쓸 수 있다.
var stackOfStrings = Stack<String>()
//Element는 String이 된다
stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")
stackOfStrings.push("cuatro")
// <참고>
stackOfStrings.push(10)
// push(item: String)에 어긋난다
// 따라서 컴파일 에러
let fromTheTop = stackOfStrings.pop()
// fromTheTop = "cuatro"
제네릭 타입 확장하기
제네릭 타입을 확장할 때는 확장의 정의부에 타입 파라미터 리스트를 정의하지 않더라도 기존의 본래 타입original type에 정의되어 있는 타입 파라미터에 접근할 수 있다.
위에서 보았던 제네릭 스택을 예로 들어보자. 이 스택에 topItem이라는 프로퍼티를 더하는 확장이다.
extension Stack {
var topItem: Element? {
return items.isEmpty ? nil : items[items.count - 1]
}
}
확장의 정의부에서는 아무런 타입 파라미터가 정의되어 있지 않지만, 기존 타입에 정의되어 있던 타입 파라미터(Element)에 접근할 수 있는 것을 볼 수 있다.
타입 제약Type Constraints
제네릭은 기본적으로 어떤 타입이든 동일하게 동작할 수 있게 해주는 것이 기본 컨셉이지만, 가끔은 특정 타입만 특정 제네릭 함수/타입을 사용할 수 있게 제약을 거는 것이 유용할 때도 있다.
타입 제약Type constraints은 이런 케이스를 위한 것이다. 타입 제약을 통해 특정 클래스를 상속받은 타입이나 특정 프로토콜을 따르는 타입만이 특정 제네릭을 사용할 수 있게 제약을 걸 수 있다.
예를 들면 Swift에서의 Dictionary 타입은 제네릭 타입이고 Key는 어떤 타입이든 들어올 수 있는 타입 파라미터지만, 사실 Key에 들어올 타입은 반드시 hashable 해야 한다는 제약이 걸려있다. 즉 유일성을 식별 가능한 타입만이 Key가 될 수 있다. (그래야 Dictionary의 특징에 맞는 구현이 가능하다)
Swift의 Dictionary는 위의 요구사항을 만족시키기 위해 Key에 들어올 타입은 반드시 Hashable 프로토콜을 따라야 한다는 타입 제약을 걸어놓았다. 참고로 String, Int, Double, Bool 등의 Swift의 베이직 타입들은 모두 디폴트로 hashable하기 때문에 모두 Dictionary의 Key 타입이 될 수 있는 것이다.
이처럼 타입 제약을 활용하면 제네릭 프로그래밍의 장점을 훨씬 살릴 수 있다. Dictionary에서의 타입 제약(Hashable한 Key)은 Dictionary의 개념적인 특징을 잘 드러내준다.
<타입 제약 문법>
타입 파라미터의 이름 뒤에 콜론(:)을 붙이고 타입 제약을 정의할 수 있다.
아래는 제네릭 함수에 타입 제약을 건 일반적 형태이다.
func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
// 함수 본문
}
위 함수는 두 개의 타입 파라미터를 받는다. T는 반드시 SomeClass를 상속받은 타입이여야 한다. U는 반드시 SomeProtocol를 따르는 타입이여야 한다.
이 문법은 제네릭 타입에도 동일하게 적용된다.
<타입 제약 실제로 써보기>
findStringIndex 라는 non-generic 함수를 살펴보자. 주어진 스트링 배열과 스트링 값 하나를 파라미터로 받아서 스트링 배열을 돌며 스트링 값과 일치하는 Index 를 반환하는 함수이다. 일치하는 값이 없다면 nil을 반환한다. 따라서 함수의 리턴 타입은 Int? 이다.
func findIndex(ofString valueToFind: String, in array: [String]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}
let strings = ["고양이", "강아지", "햄스터"]
if let fonudIndex = findStringIndex(ofString: "햄스터", in: strings) {
print("햄스터의 Index는 \(fonudIndex)")
}
// "햄스터의 Index는 2"
이제 이 함수를 제네릭하게 만들어보자. String 대신 타입 파라미터 T를 사용하고, 함수 본문은 똑같이 놔둔다.
그러나 이 함수는 컴파일 에러가 발생한다.
에러가 발생하는 곳은 "if value == valueToFind" 부분이다. Swift의 모든 타입이 == 연산자(equal to)를 통해 비교될 수 있는 것은 아니기 때문에 발생한 에러다.
예를 들어 복잡한 구조를 가진 커스텀 클래스나 구조체 타입의 경우 타입 간의 equal to 를 어떻게 판단할지 Swift는 알 수가 없다. 이런 경우 때문에 위 함수가 모든 타입 T에 대해 동작한다고 보장할 수 없는 것이다.
그러나 방법이 없는 것은 아니다. Swift의 스탠다드 라이브러리에 Equatable 이라는 프로토콜이 정의되어 있다. 이 프로토콜을 따르는 타입은 == (equal to) 연산자와 != (not equal to) 연산자를 구현하고 있다는 것이 보장된다. Swift의 모든 기본 타입은 이 프로토콜을 따르고 있다.
위 함수에서 T가 Equatable 프로토콜을 따르는 타입이라는 제약을 건다면, 타입 T가 항상 == 연산자를 지원한다는 것이 보장된다.
func findIndex<T: Equatable>(of valueToFind: T, in array:[T]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}
let doubleIndex = findIndex(of: 9.3, in: [3.14159, 0.1, 0.25])
// optional Int with no value
let stringIndex = findIndex(of: "Andrea", in: ["Mike", "Malcolm", "Andrea"])
// optional Int containing a value of 2
Associated Types
Associated Type에 대해 알아보자.
프로토콜을 정의할 때 associated type을 1개 이상 정의해서 유용하게 활용할 수 있다.
프로토콜 내부에서 쓰일 타입에게 placeholder name을 제공한다.
실제 타입은 프로토콜의 구현 전까지는 알 수 없다.
associatedtype 키워드를 사용해서 정의한다.
아래 예제를 보며 이해해보자.
<Associated Types in Action>
Container 라는 프로토콜이다. ItemType 이라는 associated type 이 정의되어 있다. associated type이 프로토콜 정의에서 어떤 식으로 쓰이는지 볼 수 있다.
protocol Container {
associatedtype ItemType
mutating func append(_ item: ItemType)
var count: Int { get }
subscript(i: Int) -> ItemType { get }
}
이 프로토콜은 다음과 같은 요구사항을 정의한다.
append 메서드 : Container에 새로운 Item을 추가할 수 있어야 함
count 프로퍼티 : Container의 Item 개수를 Int 값으로 반환해야 함
Int 값을 받는 서브스크립트 : Index에 따른 Container Item을 반환해야 함
Container 프로토콜은 위 세 가지 요구사항만 만족한다면 그 구현 방법이나 아이템 타입은 어떻든간에 신경쓰지 않는다. 또한 이 프로토콜을 따르는 타입은 세 가지 요구사항만 만족한다면 그 외의 다른 기능을 마음껏 제공해도 상관없다.
Container 프로토콜을 따르는 타입은 반드시 Container에 저장할 타입을 구체적으로 지정해야 한다. 특히, 지정한 타입의 아이템만이 Container에 더해질 수 있고, 서브스크립트를 통해 반환되는 아이템의 타입 역시 그 타입이어야 한다는 것이 반드시 보장되어야 한다.
즉 Container 프로토콜은 특정 Container가 가질 아이템의 실제 타입을 미리 알지는 못하더라도, 그 실제 타입이 append 메서드로 넘어올 파라미터의 타입, 서브스크립트로 반환될 아이템의 타입과 반드시 일치해야 한다는 것을 요구사항으로서 명시해야 했다. 그런 요구사항을 위해 associated type인 ItemType이 사용되었다.
정리 : Container 프로토콜은 associated type인 ItemType이 실제로 어떤 타입인지는 정의하지 않는다. 그러나 이 ItemType이라는 별명alias을 통해서, Container에 저장될 타입, append 메서드에서 사용되는 타입, 서브스크립트에서 사용되는 타입이 전부 일치하는 것을 보장할 수 있다.
이제 예제를 살펴보자.
앞서 만들었던 non-generic 타입 IntStack에게 위 프로토콜을 따르게 해보자.
struct IntStack: Container {
// 원래 구현했던 부분
var items = [Int]()
mutating func push(_ item: Int) {
items.append(item)
}
mutating func pop() -> Int {
return items.removeLast()
}
// Container 프로토콜 구현
typealias ItemType = Int
mutating func append(_ item: Int) {
self.push(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> Int {
return items[i]
}
}
IntStack 은 Container 프로토콜의 세 가지 요구사항을 모두 만족시킨다.
또한 "typealias ItemType = Int" 정의를 통해서 ItemType의 추상적 타입abstract type을 Int라는 구체적 타입concrete type으로 구체화했다.
이 코드에서는 Int가 append 메서드의 파라미터와 서브스크립트의 리턴 타입으로 사용되었다. 따라서 굳이 ItemType을 Int로 따로 선언하지 않아도, Swfit의 타입 추론type inference 기능은 ItempType이 Int임을 명백하게 추론할 수 있다. 따라서 "typealias ItemType = Int" 라인을 코드에서 지우더라도 동작에는 문제가 없다.
제네릭 타입인 Stack 역시 Container 프로토콜을 따를 수 있다.
struct Stack: Container {
// 원래 구현했던 부분
var items = [Element]()
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
// Container 프로토콜 구현
mutating func append(_ item: Element) {
self.push(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> Element {
return items[i]
}
}
이번에는 타입 파라미터인 Element 가 append 메서드의 파라미터와 서브스크립트의 리턴 타입으로 사용되었다. 따라서 Swift는 ItemType이 Element라는 것을 추론할 수 있다.
<Associated Type을 지정하기 위해 기존 타입 확장하기Extending an Existing Type to Specify an Associated Type>
프로토콜 단원에서 살펴본 것처럼, 기존 타입을 확장해서 프로토콜을 따르게 할 수 있다. 이것은 associated Type을 가진 프로토콜도 마찬가지로 적용된다.
Swift의 Array 타입은 이미 append 메서드와 count 프로퍼티, Index로 아이템을 반환해주는 서브스크립트를 제공하고 있다. 따라서 이미 Container 프로토콜의 요구사항을 만족하고 있는 셈이다. 따라서 다음과 같이 Array의 빈 확장을 통해 간단히 Container 프로토콜을 따르게 할 수 있다.
Array의 append 메서드와 서브스크립트 정의는 다음과 같이 되어있다.
public mutating func append(_ newElement: Element)
public subscript (index: Int) -> Element
따라서 제너럴 Stack과 마찬가지로, Swift는 Container에서의 ItemType의 타입이 Element라는 것을 추론할 수 있다.
Where절
타입 제약Type constraints을 활용하면 제네릭 함수/타입의 타입 파라미터에 대해 제약을 줄 수 있다는 것을 앞서 살펴보았다.
associated type도 마찬가지로 타입에 대한 제약을 걸어 정의할 수 있다. where절을 써서 제약조건을 정의할 수 있는데, associated type이 반드시 특정 프로토콜을 따르고 있어야 한다든지, 특정 타입 파라미터와 똑같은 타입이어야 한다든지 등을 제약조건으로 만들 수 있다.
예제를 통해 자세히 알아보자.
제네릭 함수 allItemsMatch는 두 개의 Container 인스턴스를 받아서 그 안의 아이템들이 같은 순서로 들어있는지를 체크해주는 기능을 제공한다. 이 기능을 제공하기 위한 조건은 다음과 같다.
두 개의 Container 타입은 달라도 되지만, 그 안의 아이템의 타입은 일치해야 한다. (비교를 위해)
아이템은 Equatable해야 한다. (비교를 위해)
위 조건들을 where절을 통해 나타내고 있다. 살펴보자.
func allItemsMatch<C1: Container, C2: Container>
(_ someContainer: C1, _ anotherContainer: C2) -> Bool
where C1.ItemType == C2.ItemType, C1.ItemType: Equatable {
// 먼저 두 개의 Container에 든 Item 개수가 일치하는지부터 확인
if someContainer.count != anotherContainer.count {
return false
}
// 같은 Index에 같은 아이템이 들어있는지를 확인
for i in 0..<someContainer.count {
if someContainer[i] != anotherContainer[i] {
return false
}
}
return true
}
이 함수는 someContainer(C1), anotherContainer(C2)를 파라미터로 받고 있으며 C1, C2의 실제 타입이 정해지는 시점은 함수가 호출되는 시점이다.
이 함수의 타입 파라미터는 다음과 같은 요구사항을 만족해야 한다.
C1: Container : C1은 Container 프로토콜을 따름
C2: Container : C2는 Container 프로토콜을 따름
C1.ItemType == C2.ItemType : C1과 C2의 ItemType은 같음
C1.ItemType: Equatable : C1의 ItemType은 Equatable 프로토콜을 따름
1번, 2번 요구사항은 함수의 타입 파라미터 리스트에 정의되었다. 3번, 4번 요구사항은 where절을 통해 정의되었다.
위 요구사항은 다음과 같은 의미를 가진다.
someContainer는 C1 타입 인스턴스
anotherContainer는 C2 타입 인스턴스
someContainer와 anotherContainer는 같은 타입의 아이템을 저장함
someContainer의 아이템은 != (not equal) 연산자로 비교될 수 있다
anotherContainer도 마찬가지
따라서 C1과 C2의 타입이 달라도 그 안의 아이템 비교는 가능
allItemsMatch 함수는 다음처럼 사용될 수 있다.
var stackOfStrings = Stack<String>()
stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")
// 앞서 Stack이 Container 프로토콜을 따르게 했었다
var arrayOfStrings = ["uno", "dos", "tres"]
// 앞서 Array를 확장하여 Container 프로토콜을 따르게 했었다
// Stack<String>과 [String] 타입 모두
// Container 프로토콜을 따르고 있으며
// ItemTyped으로 String을 가진다
if allItemsMatch(stackOfStrings, arrayOfStrings) {
print("모든 아이템이 일치")
} else {
print("불일치")
}
'Swift 공식 가이드 > Swift 3' 카테고리의 다른 글
Advanced Operators (0) | 2017.06.05 |
---|---|
Access Control (2) | 2017.04.01 |
Protocols (3) (2) | 2017.03.25 |
Protocols (2) (0) | 2017.03.25 |
Protocols (1) (0) | 2017.03.23 |