출처 : https://docs.swift.org/swift-book/LanguageGuide/Generics.html
Remind
- 제네릭 타입은 static stored 프로퍼티를 정의할 수 없다
제네릭은 어떤 타입과도 작업할 수 있는 유연하고 재사용가능한 함수와 타입을 쓸 수 있게 해줍니다 ('어떤 타입'의 범위를 우리가 정의할 수 있습니다)
제네릭 코드를 통해, 반복코드를 피하고 명확하고 추상화된 방식으로 의도를 표현할 수 있습니다
제네릭은 Swift의 가장 강력한 기능 중 하나로, 많은 Swift 기본 라이브러리가 제네릭으로 만들어져 있습니다
예로, Array
와 Dictionary
타입은 모두 제네릭 Collection입니다
Array가 Int/String 등 어떤 타입이든 보관할 수 있도록 만들 수 있습니다
유사하게 Dictionary가 key와 value를 어떤 타입이로든 만들 수 있습니다
먼저 두 개의 Int를 swap하는 함수를 non-Generic으로 구현한 예시입니다
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
let temporaryA = a
a = b
b = temporaryA
}
이 함수는 유용하지만 Int 타입만 사용가능하다는 문제가 있습니다
만약 Double 타입 swap이 필요하다면 별도의 함수를 만들어주어야 합니다
func swapTwoDoubles(_ a: inout Double, _ b: inout Double) {
let temporaryA = a
a = b
b = temporaryA
}
우리는 두 함수의 body가 동일함을 알아챌 수 있습니다
유일한 차이점은 parameter와 retun 타입만 다릅니다
따라서, Any 타입이든 swap해주는 하나의 함수를 정의하는 것이 더 유용하고 훨씬 유연합니다
Generic은 그런 함수를 정의할 수 있게 해줍니다
Generic 함수는 어떤 타입과도 작업할 수 있습니다
예시를 봅시다
뭔가 첫 줄의 생김새가 다릅니다
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
let temporaryA = a
a = b
b = temporaryA
}
기존의 non-Generic 함수와 비교해봅시다
func swapTwoInts(_ a: inout Int, _ b: inout Int)
func swapTwoValues<T>(_ a: inout T, _ b: inout T)
Generic 함수는 Double/Int같은 실제 타입대신 placeholder
타입을 사용합니다
placeholder는 위 예제처럼 T
가 아니어도 되고, T가 어떤 타입인지에 대해선 아무말도 하지 않습니다
다만, 두 parameter가 동일한 타입일 것을 말하고 있습니다
T 대신 사용될 실제 타입은 Generic함수가 호출될 때 결정됩니다
swapTwoValues
는 parameter로 Any 타입이든 올 수 있다는 점과, 두 parameter의 타입이 같아야 한다는 점을 제외하면 swapTwoInts
와 같은 방식으로 호출됩니다
swapTowValues
가 호출될 때마다 T로 사용될 타입은 추론과정을 거칩니다
(argument로 전달되는 값의 타입을 기반으로)
위 예제에서 사용하던 placeholder, T
에 대해 알아봅시다
'placeholder type T'는 type parameter의 한 예시입니다
type paramter는 placeholder type을 명시하고 이름을 부여합니다
그리고 함수 이름 바로 뒤에 '<>'과 함께 붙습니다
한번 type paramter를 명시하면, Generic 함수에서 다뤄질 paramter/return/기타(body에서다룰) 타입을 정의하기 위해 이를 사용할 수 있습니다
함수가 호출될 때, 각 type parameter는 실제 타입으로 대체됩니다
type paramter는 여러 개 제공될 수 있습니다
--
대부분의 경우, type parameter의 이름은 Generic과 type paramter 간 관계에 대한 '설명'이 포함되도록 지어집니다
(ex. Dictionary의 <Key
, Value
> 혹은 Array의 <Element
>)
하지만, 둘 간에 유의미한 관계가 없다면 전통적으로 U
, T
, V
를 사용합니다
type paramter의 이름은 upper camel case를 사용합니다
--
Generic 함수에 더해 Generic 타입도 정의할 수 있습니다
예시로는 Array와 Dictionary가 있으며
어떤 타입과도 작업할 수 있는 custom class/struct/enum입니다
이번 section에서는 Stack을 예시로 Generic Collection
을 정의하는 방법을 다룹니다
...Stack과 Array의 차이..중략...
non-Generic 버전
Int외에 다른 타입을 element로 사용하려면 별도 정의가 필요하다
struct IntStack {
var items: [Int] = []
mutating func push(_ item: Int) {
items.append(item)
}
mutating func pop() -> Int {
return items.removeLast()
}
}
Generic 버전
struct Stack<Element> {
var items: [Element] = []
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
}
Generic타입의 인스턴스를 생성할 때는, type paramter를 대신할 '실제 타입'을 명시해주어야 합니다
var stackOfStrings = Stack<String>()
stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")
stackOfStrings.push("cuatro")
// the stack now contains 4 strings
Generic 타입을 extension할 때는
original 정의부에 명시된 type paramter를 그대로 가져오므로
type paramter를 별도로 적어주지 않습니다
extension Stack {
var topItem: Element? {
return items.isEmpty ? nil : items[items.count - 1]
}
}
extension 할 때도 다뤘던 내용인데
extension을 선택적으로 추가할 수 있습니다
아래는 Element가 Equatable 프로토콜을 준수하는 Stack인 경우에만 extension합니다
extension Stack where Element: Equatable {
func isTop(_ item: Element) -> Bool {
guard let topItem = items.last else {
return false
}
return topItem == item
}
}
위에서 다뤘던 예제의 swapTwoValues(_:_:)
함수와 Stack
은 Any 타입과도 작업할 수 있습니다
하지만, 특정 조건을 만족하는 타입들만 사용하고 싶을 수도 있습니다
'Type 제약'은 type paramter가 특정 class를 상속받거나, 특정 프로토콜을 준수해야 함을 명시할 수 있습니다
예로, Swift의 Dictionary
는 Key가 hashable해야 함을 명시하고 있습니다
즉, Key 간에 유일하게 구분할 방법을 제공해야한다는 뜻입니다
Dictionary는 어떤 key에 대해 이미 value를 가지고 있는 key인지 확인할 수 있도록 key가 hashable할 필요가 있습니다
제약은 하나만 걸 수 있습니다
하나의 class 혹은 하나의 protocol을 걸 수 있습니다
다만, protocol은 composition을 이용하면 여러 개 걸 수도 있습니다
func someFunction<T: SomeClass, U: SomeProtocolA & SomeProtocolB>(someT: T, someU: U) {
// function body goes here
}
1. non-Generic으로 우선
String말고도 광범위하게 사용될 수 있으므로 Generic으로 만들자
func findIndex(ofString valueToFind: String, in array: [String]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}
2. Generic으로 만들긴 했는데..
컴파일이 안된다
value == valueToFind
라는 '비교구문'이 있는데
이 비교 연산자는 아무 타입이나 갖고 있는 메서드가 아니므로
컴파일러 입장에서는 Swift의 모든 타입에 대해 허용할 수 없다
func findIndex<T>(of valueToFind: T, in array:[T]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}
3. 적절한 제약 추가
우리는 Equatable
프로토콜 제약을 추가함으로써
==
연산자 소유를 보장할 수 있다
func findIndex<T: Equatable>(of valueToFind: T, in array:[T]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToFind {
return index
}
}
return nil
}
프로토콜을 정의할 때, 때때로 associated type을 정의하는 것이 유용합니다
Associated Type
은 프로토콜에서 사용하는 type parameter입니다
기존에 다루던 Generic함수/타입에서처럼 구체적으로 '어떤 타입인지'는 정의하지 않습니다
반면, '이런저런 요구사항들은 서로 같은 타입을 사용해야 해'를 정의합니다
예제로 살펴봅시다
associatedtype
이라는 키워드로 정의됩니다
아래 예제는 associated type과 이를 사용하는 3가지 요구사항을 정의합니다
protocol Container {
associatedtype Item
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}
Container
프로토콜의 요구사항을 정리하면
Item
이 어떤 타입이든 상관없다이 요구사항을 준수하는 것은, Generic/non-Generic 타입 모두 가능합니다
프로토콜에서 Item
이 어떤 타입인지는 상관없고
적어도 하나의 타입에서 요구사항을 전부 구현하기만 하면 됩니다
그러므로 아래의 예제는 Container
프로토콜을 준수합니다
struct IntStack: Container {
// original IntStack implementation
var items: [Int] = []
mutating func push(_ item: Int) {
items.append(item)
}
mutating func pop() -> Int {
return items.removeLast()
}
// conformance to the Container protocol
typealias Item = Int
mutating func append(_ item: Int) {
self.push(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> Int {
return items[i]
}
}
❗️주의❗️
여기서typealias
구문은 정석적으로 명시해주는게 맞으나
'컴파일러가 추론 가능한 상황'이라면 생략할 수 있긴 합니다
위 예제처럼 Int에 대해서만 모든 요구사항을 맞췄다면
컴파일러는 Int가 Item임을 추론할 수 있습니다
아래는 Generic 타입 예시입니다
(여기서도 typealis Item = Element
를 써주는게 정석이나 생략할 수 있습니다)
struct Stack<Element>: Container {
// original Stack<Element> implementation
var items: [Element] = []
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element {
return items.removeLast()
}
// conformance to the Container protocol
mutating func append(_ item: Element) {
self.push(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> Element {
return items[i]
}
}
공식문서에서는 Array를 예시로 들었습니다
하지만, Array는 Container
의 요구사항을 이미 준수하고 있기에
빈 extension에 프로토콜만 추가채택시키면 됩니다
(typealias도 추론가능한 상태)
extension Array: Container {}
associatedtype을 정의할 때, 제약을 걸어주면 됩니다
protocol Container {
associatedtype Item: Equatable
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}
Container 프로토콜을 준수하기 위해선,
Item이 Equatable해야 한다는 추가 조건이 생겼습니다
어떤 프로토콜은 자신의 요구사항의 일부로 나타날 수도 있습니다
예를 들면 아래와 같이,
SuffixableContainer
의 요구사항에 SuffixableContainer이 등장합니다
protocol SuffixableContainer: Container {
associatedtype Suffix: SuffixableContainer where Suffix.Item == Item
func suffix(_ size: Int) -> Suffix
}
위 프로토콜의 요구사항을 해석해보면,
func suffix()
의 return타입인 Suffix
는 2가지 룰이 있습니다
이걸 준수하기 위한 대표적인 방법으론,
이 프로토콜을 채택하려는 타입, 자기자신 타입을 return하도록 만드는 것입니다.
물론 아래 예제와 같이,
SuffixableContainer을 채택하는 다른 타입이 와도 되긴 합니다
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
}
// Inferred that Suffix is Stack<Int>.
}
우선, 프로토콜 상속을 사용하므로
SuffixableContainer를 준수하려면, Container도 준수해야 합니다
Container를 준수시키느라 append() 등에 사용한 타입이 있을텐데
이 타입과 Suffix의 Item이 같은 타입이어야 합니다
물론, 위의 1번에서 대표적인 방법이라고 소개한 '자기자신 타입을 Suffix로 지정'한다면 이 조건은 신경쓸 필요가 없습니다 (같을 수 밖에 없기 때문)
하지만, '자기자신이 아닌 다른 타입을 Suffix로 지정'하고자 할 때는 조건이 맞지 않을 수 있습니다
Suffix로 쓰려는 다른 타입 또한 SuffixableContainer를 준수하므로
Container도 준수하려고 Item으로 지정한 타입이 있을텐데
그 타입과 현재 타입의 Item은 서로 다른 타입일 수 있기 때문입니다
역시 말로만 치면 힘드므로 예제를 만들어보았습니다
extension IntStack: SuffixableContainer {
func suffix(_ size: Int) -> Stack<Double> { //Error!
var result = Stack<Double>()
for index in (count-size)..<count {
result.append(self[index])
}
return result
}
// Inferred that Suffix is Stack<Int>.
}
위에서 Stack<Double>
역시
SuffixableContainer를 준수하므로 1번 조건은 맞춰졌지만
Item 타입이 서로 다르므로 (Double / Int) 2번 조건이 맞춰지지 않아
에러를 유발합니다
Type 제약은 Generic과 관련한 type paramter에 요구사항을 정의할 수 있게 해줍니다
이는 associated type에 대한 요구사항을 정의할 때도 유용합니다
'Generic Where 구문'을 정의함으로써 그렇게 할 수 있습니다
(바로 위 section에서도 다룸)
Generic where 구문을 사용하여, associated type에 걸 수 있는 조건
- 특정 프로토콜을 준수할 것
- 특정 type parameter와 동일한 타입일 것
where
키워드로 시작해서 constraint 혹은 equality 관계를 명시하면 됩니다
아래는 Generic 함수에 where 구문을 추가한 예시입니다
where구문은 return 타입 뒤에 옵니다
func allItemsMatch<C1: Container, C2: Container>
(_ someContainer: C1, _ anotherContainer: C2) -> Bool
where C1.Item == C2.Item, C1.Item: Equatable {
// Check that both containers contain the same number of items.
if someContainer.count != anotherContainer.count {
return false
}
// Check each pair of items to see if they're equivalent.
for i in 0..<someContainer.count {
if someContainer[i] != anotherContainer[i] {
return false
}
}
// All items match, so return true.
return true
}
보이는 바와 같이, allItemsMatch()
함수는 C1.Item과 C2.Item이 같으면서 Equatable해야 사용할 수 있습니다
Generic 타입/프로토콜에 어떤 기능을 추가하려는데
특정 조건에 부합하는 type parameter에 대해서만 추가하고 싶다면
extension에 where조건을 걸어 구현할 수 있습니다
extension에 Generic 조건을 걸 수 있습니다
(사실 위 section에서 이미 다뤘음)
아까봤던 예제를 다시 봅시다
extension Stack where Element: Equatable {
func isTop(_ item: Element) -> Bool {
guard let topItem = items.last else {
return false
}
return topItem == item
}
}
만약 이 예제에서 where 조건을 걸 수 없었다면,
==
연산자를 코드에 넣는데 있어 문제를 겪었을 것입니다
Protocol의 extension에도 where 조건을 걸 수 있습니다
이것도 이미 본 것 같은데..
extension Container where Item: Equatable {
func startsWith(_ item: Item) -> Bool {
return count >= 1 && self[0] == item
}
}
Item에 비교연산자를 사용하기 위해 Equatable해야 한다는 조건을 설정
조건은 프로토콜이 아닌 특정 타입인지 여부를 확인하는 것일 수 있습니다
extension Container where Item == Double {
func average() -> Double {
var sum = 0.0
for index in 0..<count {
sum += self[index]
}
return sum / Double(count)
}
}
print([1260.0, 1200.0, 98.6, 37.0].average())
// Prints "648.9"
조건을 여러 개 걸 수도 있습니다
콤마로 구분하면 됩니다
extension temptemp where Item: Equatable, Item == Int {
func printprint() {
print("good")
}
}
조건을 타입 통째로 걸지 않고
특정 subscript나 메서드에만 where 조건을 걸 수도 있습니다
(물론, 해당 type이 Generic type이어야 함)
이를 문서에선 contextual where clause
라고 표현합니다
아래 예제에서 Container 구조체는 Generic이며
extension으로 추가하는 메서드 별로 조건을 걸어주고 있습니다
extension Container {
func average() -> Double where Item == Int {
var sum = 0.0
for index in 0..<count {
sum += Double(self[index])
}
return sum / Double(count)
}
func endsWith(_ item: Item) -> Bool where Item: Equatable {
return count >= 1 && self[count-1] == item
}
}
average()와 endsWith()는 원본 정의부에 있는 Item
type paramter에 각각 type 제약을 걸고 있습니다
extension에 걸어서 분리시켜는 방식도 있다
위 예제에서 의도한 것처럼, 메서드별로 서로 다른 조건을 걸어주기 위한 방법으로
extension에 type 제약을 걸고 extension을 분리시켜버리는 방법도 있습니다
extension Container where Item == Int {
func average() -> Double {
var sum = 0.0
for index in 0..<count {
sum += Double(self[index])
}
return sum / Double(count)
}
}
extension Container where Item: Equatable {
func endsWith(_ item: Item) -> Bool {
return count >= 1 && self[count-1] == item
}
}
개인적으로 이게 가독성이 더 좋아보입니다
슬슬 익숙하니 바로 예제로 봅시다
protocol Container {
associatedtype Item
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
associatedtype Iterator: IteratorProtocol where Iterator.Element == Item
func makeIterator() -> Iterator
}
위 예제를 해석해보면
Container 프로토콜을 준수하려면 Iterator의 타입을 정의해야 하는데
이 Itertor는 IteratorProtocol을 준수하면서 Element가 Item과 동일한 타입이어야 합니다
프로토콜 상속관계가 있는 경우
부모 프로토콜(아래 예제에선 Container)의 associated type(Item)으로 조건을 걸 수도 있습니다
protocol ComparableContainer: Container where Item: Comparable { }
subscript 역시 generic으로 정의할 수 있고 where 조건을 걸 수 있습니다
extension Container {
subscript<Indices: Sequence>(indices: Indices) -> [Item]
where Indices.Iterator.Element == Int {
var result: [Item] = []
for index in indices {
result.append(self[index])
}
return result
}
}
위 예제를 해석하면
1. subscript의 index는 Sequence 프로토콜을 준수할 것
2. 또한, Sequence.Iterator.Element의 타입이 Int일 것
(self[index]
와 result
가 같은 index 타입을 사용하도록)
3. return 타입은 Container 원본 정의부에 정의한 Item과 같은 타입의 배열일 것
이 제약들을 종합해보면 indices로 전달되는 값은 integer의 sequence임을 의미합니다