🔥 제네릭의 타입 제약

806자
10분

swapTwoValues(_:_:) 함수와 Stack 타입은 어떤 타입과도 함께 동작할 수 있습니다. 하지만 때로는 제네릭 함수와 제네릭 타입에서 사용할 수 있는 타입에 대해 특정한 타입 제약을 두는 것이 유용할 수 있죠. 타입 제약은 타입 매개변수가 특정 클래스를 상속받아야 한다거나, 특정 프로토콜 또는 프로토콜 조합을 준수해야 함을 명시합니다.

예를 들어, Swift의 Dictionary 타입은 딕셔너리의 키로 사용될 수 있는 타입에 제한을 둡니다. Dictionaries에서 설명한 것처럼, 딕셔너리 키의 타입은 해시 가능해야 합니다. 즉, 스스로를 유일하게 표현할 수 있는 방법을 제공해야 하는 거죠. Dictionary는 특정 키에 대한 값이 이미 포함되어 있는지 확인하기 위해 키가 해시 가능할 것을 요구합니다. 이 요구사항이 없다면 Dictionary는 특정 키에 대해 값을 삽입해야 할지 교체해야 할지 알 수 없고, 딕셔너리에 이미 있는 주어진 키에 대한 값을 찾을 수도 없을 것입니다.

이러한 요구사항은 Dictionary의 키 타입에 대한 타입 제약으로 강제되는데, 이는 키 타입이 Swift 표준 라이브러리에 정의된 특별한 프로토콜인 Hashable 프로토콜을 준수해야 함을 명시합니다. Swift의 모든 기본 타입(String, Int, Double, Bool 등)은 기본적으로 해시 가능합니다. 사용자 정의 타입을 Hashable 프로토콜을 준수하도록 만드는 방법에 대한 정보는 Conforming to the Hashable Protocol을 참조하세요.

사용자 정의 제네릭 타입을 만들 때 고유한 타입 제약을 정의할 수 있으며, 이러한 제약은 제네릭 프로그래밍의 강력함을 제공합니다. Hashable과 같은 추상적인 개념은 구체적인 타입이 아닌 개념적 특성 측면에서 타입을 특징짓습니다.

타입 제약 문법

타입 매개변수 목록의 일부로, 타입 매개변수 이름 뒤에 콜론으로 구분하여 단일 클래스 또는 프로토콜 제약을 배치함으로써 타입 제약을 작성할 수 있습니다. 제네릭 함수에 대한 타입 제약의 기본 문법은 다음과 같습니다(제네릭 타입에 대한 문법도 동일합니다):

func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
    // 함수 본문
}
swift

위의 가상의 함수에는 두 개의 타입 매개변수가 있습니다. 첫 번째 타입 매개변수 T에는 TSomeClass의 서브클래스여야 한다는 타입 제약이 있습니다. 두 번째 타입 매개변수 U에는 USomeProtocol 프로토콜을 준수해야 한다는 타입 제약이 있죠.

실제 동작하는 타입 제약

다음은 findIndex(ofString:in:)이라는 비제네릭 함수입니다. 이 함수는 찾을 String 값과 그 값을 찾을 String 값의 배열을 받습니다. findIndex(ofString:in:) 함수는 옵셔널 Int 값을 반환하는데, 이는 배열에서 일치하는 첫 번째 문자열의 인덱스이거나 문자열을 찾을 수 없는 경우 nil입니다.

func findIndex(ofString valueToFind: String, in array: [String]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}
swift

findIndex(ofString:in:) 함수는 문자열 배열에서 문자열 값을 찾는 데 사용될 수 있습니다:

let strings = ["cat", "dog", "llama", "parakeet", "terrapin"]
if let foundIndex = findIndex(ofString: "llama", in: strings) {
    print("The index of llama is \(foundIndex)")
}
// "The index of llama is 2" 출력
swift

그러나 배열에서 값의 인덱스를 찾는 원리는 문자열에만 유용한 것이 아닙니다. 문자열에 대한 모든 언급을 어떤 타입 T의 값으로 대체하여 동일한 기능을 제네릭 함수로 작성할 수 있습니다.

다음은 findIndex(ofString:in:)의 제네릭 버전인 findIndex(of:in:)가 어떻게 작성될 것으로 예상되는지 보여줍니다. 이 함수의 반환 타입이 여전히 Int?인 것에 주목하세요. 이는 함수가 배열에서 옵셔널 값이 아닌 옵셔널 인덱스 번호를 반환하기 때문입니다. 그러나 주의하세요. 이 함수는 예제 뒤에 설명된 이유로 컴파일되지 않습니다:

func findIndex<T>(of valueToFind: T, in array:[T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}
swift

위와 같이 작성된 이 함수는 컴파일되지 않습니다. 문제는 "if value == valueToFind"라는 동등 비교에 있습니다. Swift에서 모든 타입이 동등 연산자(==)로 비교될 수 있는 것은 아닙니다. 예를 들어, 복잡한 데이터 모델을 표현하기 위해 고유한 클래스나 구조체를 만든다면 해당 클래스나 구조체에 대한 "같음"의 의미는 Swift가 추측할 수 없는 것입니다. 이로 인해 이 코드가 모든 가능한 타입 T에 대해 동작한다고 보장할 수 없으며, 코드를 컴파일하려고 할 때 적절한 오류가 보고됩니다.

그러나 모든 것을 잃어버린 것은 아닙니다. Swift 표준 라이브러리는 Equatable이라는 프로토콜을 정의하는데, 이는 준수하는 모든 타입이 동등 연산자(==)와 부등 연산자(!=)를 구현하여 해당 타입의 두 값을 비교할 것을 요구합니다. Swift의 모든 표준 타입은 자동으로 Equatable 프로토콜을 지원합니다.

Equatable인 모든 타입은 findIndex(of:in:) 함수와 안전하게 사용될 수 있는데, 이는 동등 연산자를 지원한다는 것이 보장되기 때문입니다. 이러한 사실을 표현하기 위해, 함수를 정의할 때 타입 매개변수의 정의 부분에 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
}
swift

findIndex(of:in:)의 단일 타입 매개변수는 T: Equatable로 작성되는데, 이는 "Equatable 프로토콜을 준수하는 모든 타입 T"를 의미합니다.

이제 findIndex(of:in:) 함수는 성공적으로 컴파일되며 Double이나 String과 같이 Equatable인 모든 타입과 함께 사용될 수 있습니다:

let doubleIndex = findIndex(of: 9.3, in: [3.14159, 0.1, 0.25])
// doubleIndex는 값이 없는 옵셔널 Int입니다. 9.3은 배열에 없기 때문이죠.
let stringIndex = findIndex(of: "Andrea", in: ["Mike", "Malcolm", "Andrea"])
// stringIndex는 값이 2인 옵셔널 Int입니다.
swift

이처럼 타입 제약은 제네릭 코드에 강력한 표현력을 제공합니다. 특정 타입이 갖춰야 할 요구사항을 명시함으로써, 해당 요구사항을 만족하는 타입만 사용될 수 있도록 할 수 있죠. 이는 컴파일 시점에 타입 안전성을 보장하면서도 코드의 유연성과 재사용성을 높입니다.

타입 제약의 또 다른 장점은 프로토콜 지향 프로그래밍을 가능케 한다는 점입니다. 프로토콜은 타입이 준수해야 할 요구사항의 청사진을 제공하며, 제네릭 코드에서 이러한 프로토콜을 타입 제약으로 사용함으로써 구체적인 타입에 의존하지 않고도 프로토콜이 정의한 기능을 사용할 수 있게 됩니다. 이는 코드의 추상화 수준을 높이고 결합도를 낮추는 데 큰 도움이 됩니다.