궁금한 내용을 검색해보세요!
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
서근 개발노트
티스토리에 팔로잉
SWIFT/Grammar

Swift : 기초문법 [제네릭 - Generic]

서근
QUOTE THE DAY

-
Written by SeogunSEOGUN

반응형

본 게시글은 yagom님의 Swift 프로그래밍 3판을 참고하여 작성되었습니다.

 

제네릭

제네릭(Generic)은 Swift의 아주 강력한 기능 중에 하나이다!

(C++은 STL로 Queue가 있지만, Swift는 없기 때문에 제네릭을 사용해서 만들어 써야 한다)

 

그래서 제네릭의 뜻이 무엇이냐?

일반적인, 포괄적이라는 뜻인데... Apple에 의하면 제네릭을 사용해 코드를 구현하면, 아주 유연하고 재사용성 높은 코드를 작성할 수 있다고 한다. 또, 제네릭으로 구현한 기능과 타입은 재사용하기도 쉽고, 중복을 줄일 수 있기 때문에 깔끔하고 추상적인 표현이 가능하다.

 

실제로 Swift 표준 라이브러리 또한 수많은 제네릭 코드로 구성되어 있는 것을 확인할 수 있다.

(사실 지금까지 제네릭 기능을 계속 사용하고 있었다...!  😳  )

 

그 예로, 배열(Array), 딕셔너리(Dictionary), 세트(Set) 등의 타입은 모두 제네릭 컬렉션이다.

 

IntString 타입을 요소로 갖는 배열을 만들거나 그 외 어떤 타입도 배열을 요소로 가질 수 있었던 것 모두 Gneric 덕분임!!!(딕셔너리, 세트도 마찬가지)

 

제네릭을 사용하고자 할 때는 제네릭이 필요한 타입 또는 메서드의 이름 뒤의 홀화살괄호(<>) 사이에 제네릭을 위한 타입 매개변수를 써줘 제네렉을 사용할 것이라고 표시해주면 된다.

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

Apple 예제를 먼저 보려고 하는데, 아래 예제는 제네릭을 사용하지 않은 swapTwoInts(_:_:) 함수가 있다.

 

일단, 두 Int 값을 바꾸는 함수가 있다. 인자로 넣는 두 개의 파라미터는 inout 파라미터이다. 그래서 두 값의 원본을 변경하게 된다.

TIP
 
 

inout 매개변수 및 앰퍼샌드(&)
함수의 매개변수는 기본적으로 상수인데, 이 매개변수 값을 해당 함수의 본문 내에서 변경하려 하면 컴파일 오류가 생긴다. 함수에서 매개변수의 값을 수정하고 함수 호출이 종료된 후에도 변경 사항을 유지하려면 inout 파라미터를 사용하면 된다.

inout 매개변수는 함수에 전달된 값을 가지며, 함수에 의해 수정되며 원래 값을 대체하기 위해 함수에 다시 전달된다.

inout 매개변수는 변수 var 에만 전달할 수 있고, 상수 및 리터럴은 수정 불가하므로 인수로 전달할 수 없다. 변수 이름 앞에 앰퍼샌드(&)를 사용해 수정될 수 있음을 나타낸다.

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

// error! swapToInts의 파라미터는 Int 타입이다!
var numberOne: Double = 5.4
var numberTwo: Double = 10.4

swapTwoInts(&numberOne, &numberTwo)

하지만 파라미터에 Double 값을 넣게 되면 에러가 난다! 당연히 이유는 swapTwoInts의 파라미터는 Int 타입 이기 때문.

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

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

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

이렇게 하면 정상적으로 출력할 수 있다. 그럼 Double형이나 String형으로 바꿔주는 함수를 만들고 싶다면?

/* ==== Double type ==== */
func swapTwoDouble(_ a: inout Double, _ b: inout Double) {
    let temporaryA = a
    a = b
    b = temporaryA
}

var doubleOne: Double = 5.2
var doubleTwo: Double = 10.2

swapTwoDouble(&doubleOne, &doubleTwo)
print("\(doubleOne), \(doubleTwo)") // 10.2, 5.2

/* ==== String type ==== */
func swapTwoString(_ a: inout String, _ b: inout String) {
    let temporaryA = a
    a = b
    b = temporaryA
}

var strungOne: String = "A"
var strungTwo: String = "B"

swapTwoString(&strungOne, &strungTwo)
print("\(strungOne), \(strungTwo)") // "B", "A"

그렇다면 타입마다 따로 함수를 매번 바꿔줘야 하나?? 

 

아니다. 

 

이럴 때 쓰는 게 제네릭! 

 

제네릭을 사용하고자 할 때는 제네릭이 필요한 타입 또는 메서드의 이름 뒤의 홑화살괄호(<>) 사이에 제네릭을 위한 타입 매개변수를 써줘 제네렉을 사용할 것이라고 표시해주면 된다.

TIP
 
 

swap 함수
Swift 표준 라이브러리에는 swapTwoValues(_:_:)와 같은 기능을 실행하는 더 안전한 함수인 swap(_:_:)이 따로 구현되어 있다. 이 함수의 정의는 다음과 같다.

public func swap<T>(_ a: inout T, _ b: inout T)

이 함수를 사용하는 것이 따로 값 교환 함수를 구현하여 사용하는 것보다 안전하므로 표준 함수를 사용하는 것을 권장한다.

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

var numberOne = 10
var numberTwo = 5

var doubleOne = 10.5
var doubleTwo = 5.5

var stringOne = "A"
var stringTwo = "B"

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

swapTwoValues(&doubleOne, &doubleTwo)
print("\(doubleOne), \(doubleTwo)") // 5.5, 10.5

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

이러면 이 함수 하나로 Int, Double, String 타입의 변수들의 값을 바꿔줄 수 있다. 

 

진짜 제네릭을 사용하니까 유연함과 재사용성이 높아지군...! (타입마다 따로 함수를 작성한 것과 비교해보면 엄청 유연함!)

 

코드를 상세하게 알아보기 전에 한 가지 주의해야 할 것이 있는데, 반드시 같은 타입끼리만 구현 가능하다는 것이다.

swapTwoValues(&numberOne, &stringOne)
//error! 'String' 유형의 값을 예상 인수 유형 'Int'로 변환할 수 없습니다.

이제 코드를 상세하게 보자!

 

저 위 코드에서 사용한 <T> 부분이 바로 제네릭인데 이 T 를 Placeholder 타입 '이름'인 타입 파라미터 라고 부른다. 

swapTwoValues<T>

이 부분이 제네릭 함수와 일반 함수의 가장 큰 차이점이라고 볼 수 있는데, 제네릭 함수는 함수 이름 옆에 <T> Placeholder 타입 이름이 온다.

 

여기서 홑화살괄호(<>)로 묶어준 이유는, Swift에게 함수 정의 내의 Placeholder이기 때문에 Swift는 "T" 라는 실제 타입을 신경 쓰지 않는다.

 

무슨 말이냐 하면!

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

<T> 부분에 요상한 <SeogunBlog> 타입 이름을 적어줘도, 파라미터에 정확히 같은 타입 이름만 정의해준다면 문제없다는 의미!!

 

이렇게 _ a:_ b:T 타입과 반드시 일치해야 한다. 그래서 아까 위에서 &numberOne, &stringOne 이 부분의 타입이 안 맞았기 때문에 오류가 난 것이다.

(_ a: inout T, _ b: inout T)

그리고 이 T의 실제 타입은 함수가 호출되는 그 순간에 결정된다. 즉, numberOneInt 타입이었는데 이 변수가 전달 인자로 전달되었으면 T 타입은 Int가 되는 것이고. stringOnt이 전달되면 TString 타입으로 결정된다. 

 

그래서! TPlaceholder라고 불리는 것.

 

보통 타입 파라미터에는 TUV와 같은 '단일문자(대문자로)' 를 사용하는 것이 일반적이다. 또한 Upper Camel Case를 사용한다.

이렇기 때문에 Swift는 안전한 언어이고 타입에 굉장히 민감하다고 하는 것임!

 

만약 여러 개의 타입 이름이 필요하면 <T, B, V> 이런 식으로 " , " 로 구분해주면 된다. 

타입 매개변수 대부분은 의미 있는 이름을 갖게 되는 경우가 많다. 예를 들어 딕셔너리에 쓰이는 Key, Value와 같은 이름들이 있다.

var dic = [1: "first", 2: "second"]

for (key, value) in dic {
   print(key, value)
}

Dictionary<Key, Value>와 같이 표현했던 것, 그리고 배열에서 요소를 표현하기 위해 Array<Element>와 같은 것들이 그것이다.

 

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

 

그런데 만약 관계의 의미를 이름으로 표현하기 힘들 때는 아까 말했듯이 T, U, V 와 같은 '단일문자(대문자로)' 를 사용하는 것이 일반적이다. 

제네릭 타입(Generic Type)

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

 

Stack이라는 제네릭 컬렉션 타입을 어떻게 만들어 가는지 보면서 이해하면 쉬운데, 스택은 배열과 유사하게 순서가 있는 값들의 모음이다.

 

배열은 중간의 요소(Element)를 빼거나 삽입할 수 있지만, 스택은  컬렉션의 끝 부분에서만 요소를 빼거나 삽입 가능하다.

 

이를 푸시(추가), 팝(삭제)라고 칭한다.

스택의 요소가 어떤식으로 푸시되고 팝 되는지에 대한 모식도

Stack에 대한 예제를 보자면. 아래 IntStack 타입은 Int 타입을 요소로 가질 수 있는 스택기능을 구현한 구조체이다.

 

내부에 items라는 이름의 Int 타입 배열을 가지고 있으며, Int 타입의 요소들을 팝 하고 푸시할 수 있는 기능을 구현했다.

//제네릭을 사용하지 않은 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(2)
print(integerStack.items) //[2]

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

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

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

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

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

이제 모든 타입을 대상으로 동작할 수 있는 스택 기능을 구현하려고 한다.

 

'모든 타입을 대상으로 동작할 수 있다' 하는 건 그럼 모든 타입이 섞여 들어올 수 있다는 것이냐?? 그건 아니다.

 

만약 요소로 모든 타입을 수용할 수 있도록 구현하려고 했다면, 위 코드에서 items 배열의 타입을 Any로 지정해주면 그만이였겠지만!!!! 스택의 요소로 한 타입을 지정해주면 그 타입으로 계속 스택이 동작하길 원하기 때문에 처음 지정해 주는 타입은 스택을 사용하고자 하는 사람 마음대로 지정할 수 있도록 제네릭을 사용한다는 것임!

//제네릭을 사용 한 Stack<T> 구조체 타입

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

Stack구조체는 IntStack과 는 다르게 타입으로 <T> 라는 매개변수를 사용했고, 이 매개변수는 items Array의 요소 타입으로 지정했으며 각 메서드의 매개변수와 반환 타입으로도 지정했다.

 

그렇기 때문에 이제 사용자가 처음 지정해주는 타입은 그 타입으로 자동 결정되는 것이다.

 

이제 위 구조체와 메서드를 호출해보면 다음과 같다.

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

doubleStack.push(0.2) //[0.2]
print(doubleStack.items)

doubleStack.push(1.2) //[0.2, 1.2]
print(doubleStack.items)

doubleStack.pop()
print(doubleStack.items) //[0.2]

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

stringStack.push("서근개발노트")
print(stringStack.items) //["서근개발노트"]

/*====Any====*/
var anyStack: Stack<Any> = Stack<Any>()

anyStack.push(100.2)
print(anyStack.items) //[100.2]

anyStack.push("서근")
print(anyStack.items) //[100.2, "서근"]

anyStack.push(12)
print(anyStack.items) //[100.2, "서근", 12]

Stack의 인스턴스를 생성할 때는 ElementT 대신 어떤 타입을 사용할지 명시해주는 방법을 사용해야 한다. 사용법은 Stack <Type>처럼 선언해주면 된다.

 

그래서 Stack<Double>은 더블 타입, Stack<String> 또는 Stack<Any> 는 각각 StringAny 타입이 되는 것이다.

 

마지막에 Any부분을 보면 Stackitems 배열을 Any로 사용하는 것보다 제네릭을 사용하는 것이 훨~씬 유연하고 광범위하게 사용할 수 있다는 것을 알 수 있다.

 

이 타입 매개변수를 정해주면 그 타입에만 동작하도록 제한할 수 있어서 더 안전하고 의도한 대로 기능을 사용할 수 있도록 유도할 수 있다.

제네릭 타입 확장

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

 

바로 위에서 사용했던 코드를 활용해볼 건데, 위 코드 아래에 extension 구현부를 추가하면 확인 가능하다.

struct Stack<T> {
    ...
}
var doubleStack: Stack<Double> = Stack<Double>()
...
var stringStack: Stack<String> = Stack<String>()
...
var anyStack: Stack<Any> = Stack<Any>()
...

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

print(doubleStack.topElement) //Optional(0.2)
print(stringStack.topElement) //Optional("서근개발노트")
print(anyStack.topElement) //Optional(12)

이 익스텐션은 Stack 구조체를 확장한 것이다. Stack은 제네릭 타입이지만 익스텐션의 정의에는 따로 타입 매개변수인 <T>를 명시해주지 않았다. 그 대신! 기존의 제네릭 타입에 정의되어있는 T라는 타입을 사용할 수 있는 있다.

 

전체 코드

<hide/>

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

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

doubleStack.push(0.2) //[0.2]
print(doubleStack.items)

doubleStack.push(1.2) //[0.2, 1.2]
print(doubleStack.items)

doubleStack.pop()
print(doubleStack.items) //[0.2]

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

stringStack.push("서근개발노트")
print(stringStack.items) //["서근개발노트"]

/*====Any====*/
var anyStack: Stack<Any> = Stack<Any>()

anyStack.push(100.2)
print(anyStack.items) //[100.2]

anyStack.push("서근")
print(anyStack.items) //[100.2, "서근"]

anyStack.push(12)
print(anyStack.items) //[100.2, "서근", 12]

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

print(doubleStack.topElement) //Optional(0.2)
print(stringStack.topElement) //Optional("서근개발노트")
print(anyStack.topElement) //Optional(12)

타입 제약(Type Constraint)

아까 초반에 구현한 swapTwoValues를 다시 한번 보면 제네릭으로 구현되어 있기 때문에 모든 타입에서 동작하는 걸 확인했었다.

 

하지만, 종종 제네릭 함수가 처리해야 할 기능이 특정 타입에만 한정되어야만 처리할 수 있다던가, 제네릭 타입을 특정 프로토콜을 따르는 타입만 사용할 수 있도록 제약을 두어야 한다면?

 

이때 사용하는 것이 타입 제약 이 있다.

 

타입 제약이란? 타입 매개변수가 가져야 할 제약사항을 지정할 수 있는 방법

 

이를 통해, 타입 파라미터(ex. T)가 특정 클래스로 상속되거나, 특정 프로토콜을 준수해야만 제네릭 함수를 쓸 수 있도록 제약을 걸 수 있다. 쉽게 말해 타입 제약은 클래스 타입 또는 프로토콜로만 줄 수 있다 는 것!!!!!

 

즉, 열거형, 구조체 등의 타입은 타입 제약의 타입으로 사용할 수 없다.

 

예를 들어 위에서 Dictionary도 제네릭 컬렉션이라고 했었는데 key 부분에 Int 타입이, value에는 String이 들어오는데 각 경우마다 딕셔너리는 따로 구현되어 있지 않다. 제네릭을 사용함으로써 다양한 타입들을 한 번에 받을 수 있는 것이다.

 

사실, 우리가 자주 사용하는 이 제네릭 타입의 딕셔너리의 키는 Hashable 프로토콜을 준수해야만 Key로 들어올 수 있다.

public struct Dictionary<Key: Hashable, Value> : Collection, 
ExpressibleByDictionaryLiteral { /*...*/ }

위 코드를 보면 딕셔너리의 두 타입 매개변수는 KeyValue이다.

 

그런데 Key : 뒤에 Hashable이라고 명시되어있는데, 이는 타입 매개변수인 Key 타입은 Hashable 프로토콜을 준수해야 한다는 의미이다.

 

Hashable은 Swift 표준 라이브러리에 정의되어 있는 프로토콜이며 Swift 기본 타입(String, Int, Double, Bool 등..)은 모두 Hashable 프로토콜을 준수한다.

 

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

// 제약이 없는 함수
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
     // 함수 구현
}

// 제약이 있는 함수
func someTwoValues<T: SomeClass, U: SomeProtocol>(_ a: inout T, _ b: inout T) {
     // 함수 구현
}

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

기존의 제약이 없는 함수와 제약이 있는 함수를 보면 차이점 구별이 가능하다. 보다시피 타입 매개변수 뒤에 콜론을 붙였고, 제약조건으로 주어질 타입을 명시해줬다.

 

여러 제약을 추가하고 싶다면 콤마(,)로 구분하는 것이 아닌!! where 절을 사용해야 한다.

 

그리고 Stack 구조체에서 T타입 매개변수의 타입을 Hashable 프로토콜을 준수하는 타입으로 제약을 줬다면, Any 타입은 Hashable을 준수하지 않기 때문에 위에 타입 확장에서 쓴 예제처럼 var anyStack: Stack<Any> = Stack<Any>() 이런 Any 타입은 사용 불가하다.

// T는 BinaryInteger 프로토콜을 준수하고, FloatingPoint 프로토콜도 준수하는 타입만 사용가능 하다는 의미
func swapTwoValues<T: BinaryInteger>(_ a: inout T, _ b: inout T) where T: FloatingPoint {
    // 함수 구현
}

이런 식으로 where 절을 사용해서 제약을 추가할 수 있다. 우리가 특별하게 사용자 정의 타입을 만들어 구현하지 않는 이상, 저 조건에 맞는 기본 타입은 없다...! 결국에 이런 상황에서는 앞에 말했듯이 함수를 중복 정의하거나 새로운 타입을 정의해서 사용하는 등 다른 방법을 사용해야 한다.

 

그럼 실제로 사용할 법한 예는 뭐가 있을까? 야곰님의 예제를 가져와서 보자면..

func substractTwoValue<T>(_ a: T, _ b: T) -> T {
    return a - b
}
//error!: 이항 연산자 '-' 는 두 개의 'T' 피연산자에 적용할 수 없습니다.

이 함수를 실행하면 오류가 나는데 '뺄셈을 하려면 뺄셈 연산자를 사용할 수 있는 타입이어야 연산이 가능하다'는 의미다. 즉, T 타입 매개변수가 실제로 받아들일 수 있는 타입은 '뺄셈' 연산자를 사용할 수 있는 타입이 여야 한다는 것.

 

타입 매개변수인 T의 타입을 BinaryInteger(이진법이 있는 정수 유형) 프로토콜을 준수하는 타입으로 한정하면 뺄셈 연산이 가능해진다.

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

TIP
 
 

프로토콜 중 타입 제약에 자주 사용하는 프로토콜
Hashable, Equatable, Comparable, Indexable, IteratorProtocol, Error, Collection, CustomStringConvertible 등..

프로토콜의 연관 타입

프로토콜을 정의할 때 연관 타입(Associated Type)을 함께 정의하면 유용할 때가 있다. 연관 타입은 프로토콜에서 사용할 수 있는 Placeholder 이름이다.

 

이게 무슨 소리냐!

 

즉, 제네릭에서 어떤 타입이 들어올지 모를 때 이 타입 매개변수를 통해 '어떤 종류의 타입인지는 모르지만, 그 어떤 타입이 여기에 쓰일 것이다.'라고 표현해줬었는데!,

 

그거에 반해 연관 타입은 타입 매개변수의 그 역할을 프로토콜에서 수행할 수 있도록 만들어진 기능이다.

protocol SomeProtocol {
    var name: String { get }
}

SomeProtocol 프로퍼티에 name 읽기 전용 저장 프로퍼티가 있으니 어떠한 종류의 프로퍼티든 상관없이 이 요구조건을 만족할 수 있다. 근데 nameString 타입이 아닌 다른 타입이 될 수 있는 여지가 있다면? 이때 쓰는 게 Associated Type 인 것.

 

연관 타입을 사용하려면 associatedtype 키워드를 사용 후 이름을 사용한다.

protocol SomeProtocol {
    associatedtype MyType
    var name: MyType { get }
}

associatedtype은 타입 별칭이 아닌 타입이 되는 것이다. (아직 어떠한 타입인지는 결정하지 않았기 때문에 모름)

struct SomeStruct: SomeProtocol {
    var name: Double {
       return 300.2
    }
}

nameget만 요구했기 때문에 연산 프로퍼티를 set 없이 구현 가능하고, name 변수의 타입이 연관 타입이였기 때문에 꼭 String 타입이 아니어도 된다.

struct SomeStruct: SomeProtocol {
    var name: String {
       return "서근"
    }
}

그리고 제네릭의 타입 제약에서 했던 것처럼 이 연관 타입에도 제약을 줄 수 있다.

protocol SomeProtocol {
    associatedtype MyType: Equatable
    var name: MyType { get }
}

이러면 MyTypeEquatable 프로토콜을 준수하는 타입이어야 한다는 의미가 된다.


이제 예제를 보자면..

protocol Container {
    associatedtype ItemType //존재하지 않은 타입인 ItemType을 연관 타입으로 정의
    var count: Int { get } //count 읽기 전용 프로퍼티
    mutating func append(_ item: ItemType)
    subscript(i: Int) -> ItemType { get }
}

Container 프로토콜은 존재하지 않는 타입인 ItemType을 연관 타입으로 정의해서 프로토콜 정의에서 타입 이름으로 활용하게 된다. 제네릭 타입의 매개변수와 아주 유사한 기능으로써, 프로토콜 정의 내부에서 사용할 타입이 '어떤 것이어도 상관없지만, 하나의 타입임은 분명하다.'라는 의미이다.

 

Container 프로토콜을 준수하는 타입이 반드시 구현해야 하는 기능은 다음과 같다.

  • 아이템 개수를 확인할 수 있도록 Int 타입 값을 갖는 Count 프로퍼티를 구현해야 함 (두 번째 줄)
  • Container의 새로운 아이템을 append(_:) 메서드를 통해 추가할 수 있어야 함 (세 번째 줄)
  • Int 타입의 인덱스 값으로 특정 인덱스에 해당하는 아이템을 가져올 수 있는 서브 스크립트를 구현해야 함 (네 번째 줄)

 

이 세 가지 조건을 충족하게 되면 Container 프로토콜을 준수하는 타입이 된다. 근데 컨테이너가 어떤 타입의 아이템을 저장해야 하는지에 대해 정의하지 않았다는 것을 확인할 수 있다.

 

잠깐 정리해보자!

 

Container 프로토콜 안에 ItemType이라는 연관 타입이 있고, Count 읽기 전용 저장 프로퍼티가 있으며, append 메서드의 파라미터로 연관 타입인 ItemType을 넣어줬고, 읽기 전용 서브스크립트의 반환 타입도 ItemType을 반환하도록 해줬다.

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]
    }
}

MyContainerContainer프로토콜을 채택했고, 컨테이너가 요구하는 준수하기 위해 필요한 것을 모두 갖추었다. 연관 타입인 ItemType 대신 Int 타입으로 구현해주었고, 이는 프로토콜 요구사항을 모두 충족하기 때문에 문제가 없다.

 

왜냐면, ItemType이라는 연관 타입만 정의했을 뿐, 특정 타입을 지정하지 않았으니까...!

 

아까 위에서 IntStack 구조체를 만들었었는데(푸시 팝 했던 코드), 이 구조체를 Container 프로토콜을 준수하도록 구현해보자면 다음과 같다.

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

만약 ItemType을 어떤 타입으로 사용할지 명확히 해주고 싶다면 타입 별칭(typealias)을 사용하여 typealias ItemType = Int라고 지정해 줄 수 있다.

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

 

 

읽어주셔서 감사합니다 🤟

 

 

 


잘못된 내용이 있으면 언제든 피드백 부탁드립니다.


서근


위처럼 이미지 와 함께 댓글을 작성할 수 있습니다.