본문 바로가기
iOS/SWIFT

[Swift] Chapter 22. 제네릭

by 원만사 2021. 10. 13.
반응형

제네릭을 이용해 코드를 구현하면 어떤 타입에도 유연하게 대응할 수 있다. 또한 제네릭으로 구현한 기능과 타입은 재사용하기도 쉽고, 코드의 중복을 줄일 수 있기에 깔끔하고 추상적인 표현이 가능하다.

제네릭을 사용하고자 할 때는 제네릭이 필요한 타입 또는 메서드의 이름뒤의 <> 사이에 제네릭을 위한 타입 매개변수를 써주어 제네릭을 사용할 것임을 표시한다.

제네릭을 사용하고자 하는 타입 이름 <타입 매개변수>
제네릭을 사용하고자 하는 함수 이름 <타입 매개변수> (함수의 매개변수...)

 

/*
	코드 22-2. 프로토콜과 제네릭을 이용한 전위 연산자 구현과 사용
*/

prefix operator **

prefix func ** <T: BinaryInteger> (value: T) -> T {
    return value * value
}

let minusFive: Int = -5
let five: UInt = 5

let sqrtMinusFive: Int = **minusFive
let sqrtFive: UInt = **five

print(sqrtMinusFive) // 25
print(sqrtFive) // 25

 

[코드 22-2]의 경우 정수 타입 프로토콜, 즉 BinaryInteger 프로토콜에 해당하는 값이면 해당 연산자를 사용할 수 있도록 제네릭을 이용하여 구현한 것이다. 이렇게 하면 Int, UInt등 정수형 타입에 해당 연산자를 사용할 수 있다.

 

Int 타입의 두 변숫값을 교환하는 swapTwoInts(_: _:) 함수를 바꿔보자. 코드의 중복을 줄이고 타입에 유연하도록 구현할 수 있는지 제네릭 코드를 사용하여 살펴보자.

 

/*
	코드 22-3. 제네릭을 사용하지 않은 swapTwoInts(_: _:) 함수
*/

func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temporaryA: Int = a
    a = b
    b = temporaryA
}

var numberOne: Int = 5
var numberTwo: Int = 10

swapTwoInts(&numberOne, &numberTwo)
print("\(numberOne), \(numberTwo)") // 10, 5

 

위의 함수는 두 Int 타입의 변숫값을 교환하는 역할을 한다. 그렇지만 만약에 Int 타입이 아닌 Double이나 String 타입의 변숫값을 서로 교환하고 싶다면 별도의 함수를 다시 구현해주어야 한다.

 

/*
	코드 22-4. 제네릭을 사용하지 않은 swapTwoString(_: _:) 함수
*/

func swapTwoStrings(_ a: inout String, _ b: inout String) {
    let temporaryA: String = a
    a = b
    b = temporaryA
}

var stringOne: String = "A"
var stringTwo: String = "B"

swapTwoStrings(&stringOne, &stringTwo)
print("\(stringOne), \(stringTwo)") // B, A

 

[코드 22-4]의 함수도 String 타입끼리의 교환만 허용할 뿐이다. Double 타입의 값 교환을 원한다면 또 다른 함수를 구현해야 하고 타입마다 다른 함수를 써줘야 하는 불편함도 따른다.

Any를 사용하여 구현하면 어떻게 될까?

 

/*
	코드 22-5. 제네릭을 사용하지 않은 swapTwoValues(_: _:) 함수
*/

func swapTwoValues(_ a: inout Any, _ b: inout Any) {
    let temporaryA: Any = a
    a = b
    b = temporaryA
}

var anyOne: Any = 1
var anyTwo: Any = "Two"

swapTwoValues(&anyOne, &anyTwo)
print("\(anyOne), \(anyTwo)") // "Two", 1

var stringOne: String = "A"
var stringTwo: String = "B"

anyOne = stringOne
anyTwo = stringTwo

swapTwoValues(&anyOne, &anyTwo)
print("\(anyOne), \(anyTwo)") // "A, B"
print("\(stringOne), \(stringTwo)") // "B, A"

swapTwoValues(&stringOne, &stringTwo) // 오류 - Any 외 다른 타입의 전달인자 전달 불가

 

[코드 22-5]의 swapTwoValues(_: _:) 함수는 Any 타입의 두 변수의 값을 교환하는 데는 무리가 없다. 다만 우리는 Any 타입의 두 변수에 어떤 타입의 값이 들어있을지 모른다. Int면 Int끼리, String이면 String끼리 교환하고 싶지만 그런 제한을 줄 수 없다.

또 다른 문제점은 Any 타입의 inout 매개변수를 통해 전달될 전달인자의 타입은 Any로 전달외어야 한다. 다른 타입의 변수는 전달인자로 전달할 수가 없다.

 

22.1 제네릭 함수


제네릭을 사용하면 같은 타입인 두 변수의 값을 교환한다는 목적을 타입에 상관없이 할 수 있도록 단 하나의 함수로 구현할 수 있다.

 

/*
	코드 22-6. 제네릭을 사용한 swapTwoValues(_: _:) 함수
*/

func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temporaryA: T = a
    a = b
    b = temporaryA
}

var numberOne: Int = 5
var numberTwo: Int = 10

swapTwoValues(&numberOne, &numberTwo)
print("\(numberOne), \(numberTwo)") // 10, 5


var stringOne: String = "A"
var stringTwo: String = "B"

swapTwoValues(&stringOne, &stringTwo)
print("\(stringOne), \(stringTwo)") // "B, A"


var anyOne: Any = 1
var anyTwo: Any = "Two"

swapTwoValues(&anyOne, &anyTwo)
print("\(anyOne), \(anyTwo)") // "Two", 1

swapTwoValues(&numberOne, &stringOne) // 오류 - 같은 타입끼리만 교환 가능

 

제네릭 함수는 실제 타입 이름을 써주는 대신에 플레이스홀더(위 함수에서는 T)를 사용한다. 플레이스홀더는 타입의 종류를 알려주지 않지만 말 그대로 어떤 타입이라는 것을 알려준다. 즉, 매개변수로 플레이스홀더 타입이 T인 두 매개변수가 있으므로, 두 매개변수는 같은 타입이라는 정도는 알 수 있다. T의 실제 타입은 함수가 호출되는 그 순간 결정된다.

여러 개의 타입 매개변수를 갖고 싶다면 <> 기호 안쪽에 쉼표로 분리한 여러 개의 타입 매개변수를 지정해줄 수 있다. <T, U, V>처럼.

의미있는 이름으로 타입 매개변수의 이름을 지정해주면 제네릭 타입 및 제네릭 함수와 타입 매개변수와의 관계를 조금 더 명확히 표현해줄 수 있다.

 

22.2 제네릭 타입


제네릭 타입을 구현하면 사용자 정의 타입인 구조체, 클래스, 열거형 등이 어떤 타입과도 연관되어 동작할 수 있다.

이번 파트에서는 Stack이라는 제네릭 컬렉션 타입을 어떻게 만들어 가는지를 알아보자.

 

/*
	코드 22-7. 제네릭을 사용하지 않은 IntStack 구조체 타입
*/

struct IntStack {
    var items = [Int]()
    
    mutating func push(_ item: Int) {
        items.append(item)
    }
    
    mutating func pop() -> Int {
        return items.removeLast()
    }
}

var integerStack: IntStack = IntStack()

integerStack.push(3)
print(integerStack.items) // [3]

integerStack.push(2)
print(integerStack.items) // [3, 2]

integerStack.push(3)
print(integerStack.items) // [3, 2, 3]

integerStack.push(5)
print(integerStack.items) // [3, 2, 3, 5]

integerStack.pop()
print(integerStack.items) // [3, 2, 3]

integerStack.pop()
print(integerStack.items) // [3, 2]

integerStack.pop()
print(integerStack.items) // [3]

integerStack.pop()
print(integerStack.items) // []

 

[코드 22-7]의 IntStack 타입은 Int 타입을 요소로 가질 수 있는 간단한 스택 기능을 구현한 구조체 타입이다.

이제 모든 타입을 대상으로 동작할 수 있는 스택 기능을 구현해보자. 여기서 모든 타입 대상이란 모든 타입이 섞여 들어올 수 있는게 아니고 한 타입을 지정해주면 그 타입으로 계속 스택이 동작하는 것을 말한다. 처음 지정해주는 타입은 스택을 사용하고자 하는 사람 마음대로 지정할 수 있도록 제네릭을 사용한다는 것이다.

 

/*
	코드 22-8. 제네릭을 사용한 Stack 구조체 타입
*/

struct Stack<Element> {
    var items = [Element]()
    
    mutating func push(_ item: Element) {
        items.append(item)
    }
    
    mutating func pop() -> Element {
        return items.removeLast()
    }
}

var doubleStack: Stack<Double> = Stack<Double>()

doubleStack.push(1.0)
doubleStack.push(2.0)
print(doubleStack.items) // [1.0, 2.0]
doubleStack.pop()
print(doubleStack.items) // [1.0]


var stringStack: Stack<String> = Stack<String>()

stringStack.push("1")
stringStack.push("2")
print(stringStack.items) // ["1", "2"]
stringStack.pop()
print(stringStack.items) // ["1"]

 

22.3 제네릭 타입 확장


만약 익스텐션을 통해 제네릭을 사용하는 타입에 기능을 추가하고자 한다면 익스텐션 정의에 타입 매개변수를 명시하지 않아야 한다. 대신 원래의 제네릭 정의에 명시한 타입 매개변수를 익스텐션에서 사용할 수 있다.

 

/*
	코드 22-9. 익스텐션을 통한 제네릭 타입의 기능 추가
*/

extension Stack {
    var topElement: Element? {
        return self.items.last
    }
}

print(doubleStack.topElement) // Optional(1.0)
print(stringStack.topElement) // Optional("1")

 

[코드 22-9]의 익스텐션은 Stack 구조체를 확장한 것이다. Stack은 제네릭 타입이지만 익스텐션의 정의에는 따로 타입 매개변수를 명시해주지 않았다. 대신 기존의 제네릭 타입에 정의되어 있는 Element라는 타입을 사용할 수 있다.

 

22.4 타입 제약


제네릭 기능의 타입 매개변수는 실제 사용 시 타입의 제약 없이 사용할 수 있다. 그러나 종종 제네릭 함수가 처리해야 할 기능이 특정 타입에 한정되어야만 처리할 수 있다던가, 제네릭 타입을 특정 프로토콜을 따르는 타입만 사용할 수 있도록 제약을 두어야 하는 상황이 발생할 수 있다. 타입 제약은 타입 매개변수가 가져야 할 제약사항을 지정할 수 있는 방법이다. 타입 제약은 클래스 타입 또는 프로토콜로만 줄 수 있다. 즉 열거형, 구조체 등의 타입은 타입 제약의 타입으로 사용할 수 없다.

제네릭 타입에 제약을 주고 싶으면 타입 매개변수 뒤에 콜론을 붙인 후 원하는 클래스 타입 또는 프로토콜을 명시하면 된다.

 

/*
	코드 22-11. 제네릭 타입 제약
*/

func swapTwoValues<T: BinaryInteger>(_ a: inout T, _ b: inout T) {
    // 함수 구현
}

struct Stack<Element: Hashable> {
    // 구조체 구현
}

여러 제약을 추가하고 싶다면 콤마로 구분해주는 것이 아니라 where 절을 사용할 수 있다. Stack 구조체의 Element 타입 매개변수의 타입을 Hashable 프로토콜을 준수하는 타입으로 제약을 준다면, Any 타입은 Hashable 프로토콜을 준수하지 않기 때문에 Any 타입은 사용할 수 없다.

 

/*
	코드 22-12. 제네릭 타입 제약 추가
*/

func swapTwoValues<T: BinaryInteger>(_ a: inout T, _ b: inout T) where T: 
		FloatingPoint {
    // 함수 구현
}

 

[코드 22-12]의 T는 BinaryInteger 프로토콜을 준수하고, FloatingPoint 프로토콜도 준수하는 타입만 사용할 수 있다. 하지만 특별히 사용자 정의 타입을 만들어 구현하지 않는 한, 저 조건에 맞는 기본 타입은 없다. 결국 이런 상황에서는 함수를 중복 정의하거나 새로운 타입을 정의해서 사용하는 등 다른 방법을 사용해야한다.

타입 제약을 실제로 사용할 법한 예를 생각해보자.

 

/*
	코드 22-13. substractTwoValue 함수의 잘못된 구현
*/

func substractTwoValue<T>(_ a: T, _ b: T) -> T {
    return a - b
}

[코드 22-13]에는 중대한 실수가 있다. 뺄셈을 하려면 뺄셈 연산자를 사용할 수 있는 타입이어야 연산이 가능하다는 한계이다. 즉, 이 T가 실제로 받아들일 수 있는 타입은 뺄셈 연산자를 사용할 수 있는 타입이어야 한다.

 

/*
	코드 22-14. substractTwoValue 함수의 구현
*/

func substractTwoValue<T: BinaryInteger>(_ a: T, _ b: T) -> T {
    return a - b
}

 

[코드 22-14]에서 타입 매개변수인 T의 타입을 BinaryInteger 프로토콜을 준수하는 타입으로 한정해두니 뺄셈 연산이 가능하게 되었다. 이처럼 타입 제약은 함수 내부에서 실행해야 할 연산에 따라 적절한 타입을 전달받을 수 있도록 제약을 둘 수 있다.

 

/*
	코드 22-15. makeDictionaryWithTwoValue 함수의 구현
*/

func makeDictionaryWithTwoValue<Key: Hashable, Value> (key: Key, value: Value)
-> Dictionary<Key, Value> {
    let dictionary: Dictionary<Key, Value> = [key:value]
    return dictionary
}

 

[코드 22-15]의 makeDictionaryWithTwoValue(_: _:) 함수는 Key와 Value라는 타입 매개변수가 있는데, 두 타입 매개변수의 제약조건이 다르다는 것을 알 수 있다. 이처럼 타입 매개변수마다 제약조건을 달리해서 구현해줄 수도 있다.

 

22.5 프로토콜의 연관 타입


프로토콜을 정의할 때 연관 타입(Associated Type)을 함께 정의하면 유용할 때가 있다. 연관 타입은 프로토콜에서 사용할 수 있는 플레이스홀더 이름이다. 즉, 제네릭에서는 어떤 타입이 들어올지 모를 때, 타입 매개변수를 통해 '종류는 알 수 없지만, 어떤 타입이 여기에 쓰일 것이다'라고 표현해주었다면 연관 타입은 타입 매개변수의 그 역할을 프로토콜에서 수행할 수 있도록 만들어진 기능이다.

 

/*
	코드 22-16. Container 프로토콜 정의
*/

protocol Container {
    associatedtype ItemType
    var count: Int { get }
    mutating func append(_ item: ItemType)
    subscript(i: Int) -> ItemType { get }
}

 

[코드 22-16]의 Container 프로토콜은 존재하지 않는 타입인 ItemType을 연관 타입으로 정의하여 프로토콜 정의에서 타입 이름으로 활용한다. 이는 제네릭의 타입 매개변수와 유사한 기능으로, 프로토콜 정의 내부에서 사용할 타입이 '그 어떤 것이어도 상관없지만, 하나의 타입임은 분명하다'라는 의미이다. Container 프로토콜을 준수하는 타입이 꼭 구현해야 할 기능을 생각해보자.

  • 컨테이너의 새로운 아이템을 append(_:) 메서드를 통해 추가할 수 있어야 한다.
  • 아이템 개수를 확인할 수 있도록 Int 타입 값을 갖는 count 프로퍼티를 구현해야 한다.
  • Int 타입의 인덱스 값으로 특정 인덱스에 해당하는 아이템을 가져올 수 있는 서브스크립트를 구현해야 한다.

 

/*
	코드 22-17. MyContainer 클래스 정의
*/

class MyContainer: Container {
    var items: Array<Int> = Array<Int>()
    
    var count: Int {
        return items.count
    }
    
    func append(_ item: Int) {
        items.append(item)
    }
    
    subscript(i: Int) -> Int {
        return items[i]
    }
}

 

[코드 22-17]은 Container 프로토콜의 요구사항을 모두 충족하므로 큰 문제가 없다. 왜냐하면 프로토콜에서 ItemType이라는 연관 타입만 정의했을 뿐, 특정 타입을 지정하지 않았기 때문이다. 실제 프로토콜 정의를 준수하기 위해 구현할 때는 ItemType을 하나의 타입으로 일관성 있게 구현하면 된다.

 

/*
	코드 22-18. IntStack 구조체의 Container 프로토콜 준수
*/

protocol Container {
    associatedtype ItemType
    var count: Int { get }
    mutating func append(_ item: ItemType)
    subscript(i: Int) -> ItemType { get }
}

struct IntStack: Container {
    // 기존 IntStack 구조체 구현
    var items = [Int]()
    
    mutating func push(_ item: Int) {
        items.append(item)
    }
    
    mutating func pop() -> Int {
        return items.removeLast()
    }
    
    // Container 프로토콜 준수를 위한 구현
    mutating func append(_ item: Int) {
        self.push(item)
    }
    
    var count: Int {
        return items.count
    }
    
    subscript(i: Int) -> Int {
        return items[i]
    }
}

[코드 22-18]은 ItemType 대신 Int 타입을 사용하여 IntStack을 구현했다. 만약 ItemType을 어떤 타입으로 사용할지 조금 더 명확히 해주고 싶다면 IntStack 구조체 구현부에 typealias ItemType = Int라고 타입 별칭을 지정해줄 수 있다.

 

/*
	코드 22-19. IntStack 구조체의 typealias 사용
*/

struct IntStack: Container {
    typealias ItemType = Int
    
    // 기존 IntStack 구조체 구현
    var items = [ItemType]()
    
    mutating func push(_ item: ItemType) {
        items.append(item)
    }
    
    mutating func pop() -> ItemType {
        return items.removeLast()
    }
    
    // Container 프로토콜 준수를 위한 구현
    mutating func append(_ item: ItemType) {
        self.push(item)
    }
    
    var count: ItemType {
        return items.count
    }
    
    subscript(i: ItemType) -> ItemType {
        return items[i]
    }
}

 

프로토콜의 연관 타입에 대응하여 실제 타입을 사용할 수도 있지만, 제네릭 타입에서는 연관 타입과 타입 매개변수를 대응시킬 수도 있다.

 

/*
	코드 22-20. Stack 구조체의 Container 프로토콜 준수
*/

struct Stack<Element>: Container {
    // 기존 Stack<Element> 구조체 구현
    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]
    }
}

 

[코드 22-20]을 보면 Container 프로토콜을 준수하기 위해 Stack 구조체에서 ItemType이라는 연관 타입 대신에 Element라는 타입 매개변수를 사용했음을 볼 수 있다. 그럼에도 Stack 구조체는 Container 프로토콜을 완벽히 준수한다.

22.6 제네릭 서브스크립트


제네릭 함수(메서드)를 구현할 수 있었던 것처럼 서브스크립트도 제네릭을 활용하여 타입에 큰 제한 없이 유연하게 구현할 수 있다. 물론 타입 제약을 사용하여 제네릭을 활용하는 타입에 제약을 줄 수도 있다.

 

/*
	코드 22-21. Stack 구조체의 제네릭 서브스크립트 구현과 사용
*/

extension Stack {
    subscript<Indices: Sequence>(indices: Indices) -> [Element]
    where Indices.Iterator.Element == Int {
        var result = [ItemType]()
        
        for index in indices {
            result.append(self[index])
        }
        
        return result
    }
}


var integerStack: Stack<Int> = Stack<Int>()
integerStack.append(1)
integerStack.append(2)
integerStack.append(3)
integerStack.append(4)
integerStack.append(5)

print(integerStack[0...2]) // [1, 2, 3]

 

[코드 22-21]에서 서브스크립트는 Indices라는 플레이스홀더를 사용하여 매개변수를 제네릭하게 받아들일 수 있다. Indices는 Sequence 프로토콜을 준수하는 타입으로 제약이 추가되어 있다. 또, Indices 타입 Iterator의 Element 타입이 Int 타입이어야 하는 제약이 추가되었다.

서브스크립트는 이 Indices 타입의 indices라는 매개변수로 인덱스 값을 받을 수 있다. 그 결과 indices 시퀀스의 인덱스 값에 해당하는 스택 요소의 값을 배열로 반환한다.

 

 

 

 

스위프트 프로그래밍: Swift 5 - 교보문고

객체지향, 함수형, 프로토콜 지향 패러다임까지 한 번에! | 스위프트를 제대로 이해하고 싶은 개발자를 위한 책스위프트는 iOS와 macOS용 애플리케이션 개발에 주로 사용하는 프로그래밍 언어입니

www.kyobobook.co.kr

 

반응형

댓글