본문 바로가기

Apple/Swift

[Swift] 제너릭(Generic)

해당 포스팅은 The Swift Programming Language (5.9.2) 버전으로 작성되었습니다.

 

 

 

 

안녕하세요! 마루입니다. 😬

 

봄이 시작되는 3월이 왔습니다. 🌱

날씨가 조금은 따듯해졌지만, 꽃샘추위는 역시나 춥습니다. 🥶

 

그래도 3월은 개학, 개강도 있는,

뭔가 새롭게 시작하는 느낌이 드는 기분 좋은 달이니까!

우리도 열심히 공부를 해볼까나요!? 🤓

 

 

 

오늘의 주제는

제너릭(Generic)

입니다!!!!

 

 

제가 제너릭을 처음 접해봤던 것은 컬렉션 타입의 Set을 공부할 때 였습니다.

다른 컬랙션 타입인 ArrayDictionary는 비교적 쉬운 모양으로 타입 명시를 했는데,

Set만 좀 특이한 모습이었죠. 🤨

// Swift의 컬렉션 타입들
let someArray: [Int] = [1, 3, 4, 5]
let someDictionary: [Int: String] = [0: "마루", 1: "호두", 2: "몽글"]

// 나에겐 조금 특이했던 Set의 타입 명시
let someSet: Set<String> = ["Hello", "world", "!"]

 

Set의 타입 명시를 할 때는 꺾쇠 괄호(< >)를 사용합니다.

이 꺽쇠 괄호를 사용하는 것이 바로 제너릭입니다!!! 🤩

 

.

.

.

아, Set의 타입 명시에 대괄호([ ])를 사용 못하냐구요??

Array와 구분이 필요하기 때문에 Set은 타입 명시를 할 때,

꺾쇠 괄호를 사용해야 합니다.

 

자, 그럼 다시 본론으로 들어가서 제너릭에 대해서 자세하게 알아봅시당~~

 

 

1️⃣ 제너릭이란?

 

Apple의 공식문서에 제너릭에 대한 정의는 다음과 같이 나와있습니다. 📄

Generic code enables you to write flexible, reusable functions and types that can work with any type, subject to requirements that you define.
You can write code that avoids duplication and expresses its intent in a clear, abstracted manner.

제네릭 코드를 사용하면 당신이 정의한 요구 사항에 따라 어떠한 타입에서도 작동할 수 있는 유연하고 재사용 가능한 함수 및 타입을 작성할 수 있습니다.
당신은 코드의 중복을 줄이고, 의도를 명확하게 추상화된 방식으로 표현할 수 있는 코드를 작성할 수 있습니다.

duplication: 중복 / intent: 목적 / abstracted: 추상적인 / manner: 방법

 

 

정의는 항상 어려운 법이죠...😅

예시를 보면 왜 제너릭을 사용하는지 확실하게 아실 겁니다!

 

.

.

.

아래의 코드는 두 개의 Int 변수를 맞바꾸는 함수를 작성하고 사용하는 예시입니다.

// 두 개의 Int 변수를 서로 맞바꾸는 함수
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temporaryA = a
    a = b
    b = temporaryA
}

// 사용 예시
var someInt = 11
var anotherInt = 999

swapTwoInts(&someInt, &anotherInt)
print("someInt is now \(someInt), and anotherInt is now \(anotherInt)")
// Prints "someInt is now 999, and anotherInt is now 11"

자, 위 함수에는 한계가 있습니다! 😧

무엇일까요??

 

바로 Int 타입의 데이터만 값을 맞바꿀 수 있다는 것입니다!!!

 

 

String이나 Double 타입의 데이터도 맞바꾸고 싶다면,

아래처럼 함수를 추가적으로 더 만들면 될까요?

// 두 개의 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
}

작성하고 보니 함수의 바디 부분은 동일하고,

파라미터의 타입 부분만 다르군요.

 

함수의 바디가 중복되니, 뭔가 깔끔한 방법이 없을까? 🤔 하고 생각이 되시죠!?

 

.

.

.

이때 사용하는 것이 바로 제너릭 입니다. 😆

 

 

2️⃣ 제너릭 함수 사용법

 

제너릭을 어떻게 사용하는지 바로 보도록 하죠!

// 제너릭 함수 만들기
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temporaryA = a
    a = b
    b = temporaryA
}

대문자 T가 새롭게 등장했네요!

당황하지 않고, 하나씩 분석해 봅시다 🤓

 

분석 1️⃣

String, Int, Double과 같은 실제 타입 이름 대신에

T라는 임의의 타입 이름이 작성되어 있습니다.

 

분석 2️⃣

T가 실제로 무슨 타입인지는 나와있지 않지만,

파라미터 a와 b는 모두 같은 타입 T라는 것은 알 수 있지요.

그리고 이 T라는 타입은 함수가 호출될 때 실제 타입이 정해지는 겁니다!

 

분석 3️⃣

함수 이름 바로 다음에 꺾쇠 괄호도 놓쳐서는 안 될 부분이지요.

이 꺾쇠가 뜻하는 것은 Swift의 컴파일러한테 다음과 같이 말하는 것입니다.

 

"T는 임의의 타입 이름이니까 실제로 T라는 타입을 찾지는 마!"

 

 

작성 방법을 파악했으니 실제로 제너릭 함수를 사용해 볼까요?

// 제너릭 함수 사용해보기
var someInt = 11
var anotherInt = 999

// 제너릭 함수에 Int 타입의 데이터를 파라미터로 전달하여 사용
swapTwoValues(&someInt, &anotherInt)
// someInt is now 999, and anotherInt is now 11


var someString = "hello"
var anotherString = "world"

// 제너릭 함수에 String타입의 데이터를 파라미터로 전달하여 사용
swapTwoValues(&someString, &anotherString)
// someString is now "world", and anotherString is now "hello"

우리는 이제 Int든, String든 어떠한 타입의 데이터에서도

만능으로 사용 가능한 제너릭 함수를 만든 것입니다!!! 🥳

 

.

.

.

제너릭 코드와 조금 더 친해지기 위해

임의의 타입 T라는 친구에 대해서도 더 알아보면 좋을 것 같습니다!

 

 

3️⃣ 타입 파라미터(Type Parameter)

 

위의 예제에서 사용한 임의의 타입 T는 타입 파라미터의 예시입니다.

 

타입 파라미터를 사용함으로써

임의의 타입을 지정하게 되고,

임의의 타입 이름을 지정하게 되고,

이는 함수 이름 바로 다음에 꺾쇠 괄호 안에 적는 것이지요.

 

그리고 꺾쇠 괄호 안에서 콤마(,)를 사용하여 여러 개의 타입 파라미터를 작성할 수도 있답니다.

// 꺾쇠 괄호 안에서 콤마(,)를 사용하여 여러 개의 타입 파라미터를 작성
Dictionary<Key, Value>

 

위의 예제에서는 타입 파라미터의 이름이 T가 아니라

Key와 Value라는 타입 파라미터 이름으로 되어 있습니다.

 

.

.

.

이쯤 되면 문뜩 생각이 듭니다.

 

"타입 파라미터의 이름은 어떻게 지으면 좋을까? 🤔"

 

여러분이 원하시는 대로 이름을 지어도 상관없지만,

보통은 T, U, V와 같은 단일 문자를 사용합니다.

 

위의 예시에서 단일 문자를 사용하지 않은 이유는

타입 파라미터와 *제너릭 타입,

타입 파라미터와 함수

간의 관계를 나타내기 위해 Key, Value, Element와 같은 이름을 쓰기도 하는 것이지요!

*제너릭 타입은 밑에서 배웁니다 :)

 

그리고 눈치채셨겠지만

타입 파라미터는 타입임을 표시하기 위해

UpperCamelCase 방식으로 표기합니다. 🙂

 

.

.

.

제너릭은 함수에서 사용될 뿐만 아니라

타입에서도 사용되는데요!

 

 

4️⃣ 제너릭 타입(Generic Type)

 

제너릭 타입이 제가 맨 처음에 이상하게 느꼈다고 말한

Set의 타입 명시에 사용된 것입니다.

// 나에겐 조금 특이했던 Set의 타입 명시
let someSet: Set<String> = ["Hello", "world", "!"]

 

 

 Apple의 공식적인 정의는 다음과 같습니다. 📄

In addition to generic functions, Swift enables you to define your own generic types.
These are custom classes, structures, and enumerations that can work with any type, in a similar way to Array and Dictionary.

제너릭 함수 외에도, Swift에서는 당신만의 고유한 제너릭 타입을 정의할 수 있습니다.
이것은 Array나 Dictionary와 비슷한 방식으로, 어떠한 타입에서도 동작하는 사용자 정의 클래스, 구조체 그리고 열거형입니다.

 

역시나 어려운 정의...😓

제너릭 타입 또한 예시로 보면 쉽게 이해하실 수 있을 겁니다!

 

 

Int 타입을 담을 수 있는 *Stack을 직접 만들어 봅시다.

*Stack: Array와 비슷하지만, 맨 위에(Array에서는 맨 뒤)만 요소를 추가할(push) 수 있고,

맨 위의 요소만 제거할(pop) 수 있다. 이러한 방식을 LILO(Last-In Last-Out)라고 한다.

// Int 타입을 다룰 수 있는 Stack 만들어보기
struct IntStack {
    var items: [Int] = []
    
    mutating func push(_ item: Int) {
        items.append(item)
    }
    
    mutating func pop() -> Int {
        return items.removeLast()
    }
}

// 구조체의 프로퍼티인 items를 수정하기 위해 mutating을 사용했다.

 

이제는 이 구조체의 한계가 무엇인지 바로 느낌이 오시죠!?

아직 감이 잘 안 오신다면 제너릭 함수부터 차근차근 읽어보시는 것도 좋습니다. 🙂

 

구조체의 이름도 그렇지만, 위 구조체는 Int 데이터 타입만 Stack으로 사용할 수 있는 한계가 있습니다.

이 한계를 극복하기 위해서 제너릭 타입을 사용하는 겁니다!
(제너릭 함수와 느낌이 비슷하죠??)

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

Int라는 실제 타입 대신 Element라는 타입 파라미터를 사용했습니다.

그리고 타입의 이름 바로 뒤에 꺾쇠 괄호를 사용해서 타입 파라미터의 이름을 정의했죠!

 

이렇게 만든 제너릭 타입은 다음과 같이 사용할 수 있습니다.

// 우리가 직접 만든 제너릭 타입의 인스턴스 생성
var stackOfStrings = Stack<String>()

stackOfStrings.push("uno")
stackOfStrings.push("dos")
stackOfStrings.push("tres")
stackOfStrings.push("cuatro")
// the stack now contains 4 strings

let fromTheTop = stackOfStrings.pop()
// fromTheTop is equal to "cuatro", and the stack now contains 3 strings

제너릭 타입에 대해서 감이 좀 오시나요?

 

.

.

.

이런 제너릭 타입은 일반적인 타입들과 비슷하게 확장을 할 수도 있답니다.

(Extending a Generic Type)

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

// 제너릭 타입의 확장
extension Stack {
    var topItem: Element? {
        return items.isEmpty ? nil : items[items.count - 1]
    }
}

제너릭 타입을 확장할 때는 따로 타입 파라미터의 이름을 정의하지 않고,

기존 타입에서 사용한 타입 파라미터의 이름을 그대로 사용합니다.

 

.

.

.

확장도 가능한 것 보니 제너릭 타입에도 제약(Type Constraint)을 걸 수도 있겠군요!?

 

맞습니다!

다음과 같이 말이죠!!!

func someFunction<T: SomeClass, U: SomeProtocol>(someT: T, someU: U) {
    // function body goes here
}

위 제너릭 함수의

첫 번째 타입 파라미터인 T는  SomeClass를 상속받고,

두 번째 타입 파라미터인 U는 SomeProtocol을 준수하죠.

 

.

.

.

제너릭 타입의 제약은 언제 사용하냐구요???

다음 예시를 보면 타입 제약을 언제 사용해야 하는지 알 수 있지요!

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

// 함수 사용
let strings = ["cat", "dog", "llama", "parakeet", "terrapin"]
if let foundIndex = findIndex(ofString: "llama", in: strings) {
    print("The index of llama is \(foundIndex)")
}
// Prints "The index of llama is 2"

함수가 좀 복잡해 보일 수 있지만 차근차근 해석해 보면

이 함수가 배열 내에서 동일한 문자열의 요소가 몇 번째 인덱스에 있는지

찾는 함수라는 것을 파악할 수 있습니다.

 

이 함수를 한 번 여러분이 직접 제너릭 함수로 바꿔볼까요?

(코딩 공부는 이론도 좋지만 직접 코딩해 보는 것이 정말 중요합니다. 😆)

 

.

.

.

// 제너릭 함수 구현
func findIndex<T>(ofString valueToFind: T, in array: [T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind {
            return index
        }
    }
    return nil
}

혹시 이렇게 작성을 했는데, 컴파일 오류가 나신 분이 있죠? ⛔️

컴파일 오류 나는 것이 정상입니다! 걱정하지 마세요!!!

 

위와 같은 컴파일 오류가 나는 이유는

Swift의 모든 타입들이 다 비교 연산을 할 수 있는 것이 아니기 때문입니다.

따라서 비교 연산이 가능한 타입들,

즉! Equatable프로토콜을 따르는 타입들만 비교 연산이 가능한 것입니다.

 

 

그래서 위의 예시에서

타입 파라미터가 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
}

// 프로토콜을 준수하는 제너릭 함수 사용
let doubleIndex = findIndex(of: 9.3, in: [3.14159, 0.1, 0.25])
// doubleIndex is an optional Int with no value, because 9.3 isn't in the array
let stringIndex = findIndex(of: "Andrea", in: ["Mike", "Malcolm", "Andrea"])
// stringIndex is an optional Int containing a value of 2

 

 

 

 

= = = = = = = = = =

 

이렇게 제너릭에 대해서 배워봤습니다.

 

이해를 돕기 위해 예시 코드를 적다 보니 글이 많이 길어졌군요 😅

그래도 예시 코드를 통해 제너릭에 대한 이해가 쉬워지셨으면 좋겠습니다!!!

 

공식 문서에 대해서는 조금 더 자세하게

제너릭에 대해 나와있으니, 공식 문서도 꼭 참고해 보세요! 📄

 

오탈자나 설명의 틀린 부분, 추가적으로 해주고 싶으신 설명이 있으시다면

주저 없이 댓글을 달아주세요. 🙏

 

 

긴 글 읽어주셔서 감사합니다.

'Apple > Swift' 카테고리의 다른 글

[Swift] 데이터 타입_문자형  (1) 2024.02.15
[Swift] 데이터 타입_숫자형  (0) 2024.02.03
[Swift] 타입 명시와 타입 추론  (0) 2024.01.21
[Swift] 상수와 변수  (2) 2024.01.07
[Swift] 마루의 공부 방법  (0) 2024.01.06