본 글은 야곰의 스위프트 프로그래밍: Swift5 교재를 토대로 공부한 내용과 찾아본 내용을 요약한 것입니다.
제네릭(Generic)은 스위프트의 강력한 기능 중 하나입니다. 제네릭을 이용해 코드를 구현하면 어떤 타입에도 유연하게 대응할 수 있습니다. 제네릭은 타입에 의존하지 않는 코드 작성 방식을 제공합니다. 이를 통해 함수, 구조체, 열거형, 클래스 등을 작성할 때 특정 타입에 제한되지 않고 여러 타입에 대해 동작할 수 있도록 합니다. 제네릭을 사용하면 코드의 재사용성이 높아지고, 타입 안정성을 유지할 수 있습니다. 스위프트의 표준 라이브러리 또한 수많은 제네릭 코드로 구성되어 있으며 Array, Dictionary, Set 등의 타입은 모두 제네릭 컬렉션입니다. (Int나 String 타입이나, 그 외 어떤 타입도 배열을 요소로 갖는 배열을 만드는 것은 모두 제네릭 덕분입니다.)
다음은 제네릭을 사용하지 않은 두 변수의 값을 바꾸는 함수의 예시입니다.
// Any 타입은 매우 유연하지만, 타입 안전성을 유지하려면 특정 타입으로 캐스팅하거나 타입 체크를 해야 할 때가 있습니다. 다음은 타입을 체크하고 안전하게 캐스팅하는 예시입니다.
func swapTwoValues(_ a: inout Any, _ b: inout Any) {
// 안전하게 타입을 체크하고 캐스팅
guard type(of: a) == type(of: b) else {
print("Error: Both values must be of the same type.")
return
}
let temp = a
a = b
b = temp
}
var firstValue: Any = 10
var secondValue: Any = 20
swapTwoValues(&firstValue, &secondValue)
print(firstValue) // 20
print(secondValue) // 10
var firstStringValue: Any = "hello"
var secondStringValue: Any = "world"
swapTwoValues(&firstStringValue, &secondStringValue)
print(firstStringValue) // "world"
print(secondStringValue) // "hello"
// 타입이 다른 경우
var firstInt: Any = 10
var firstString: Any = "hello"
swapTwoValues(&firstInt, &firstString) // Error: Both values must be of the same type.
Any 타입을 사용하면 모든 타입을 허용하지만, 런타임에 타입 검사를 해야 하므로 타입 안전성이 떨어집니다. 잘못된 타입이 전달되었을 때 런타임 오류가 발생할 수 있습니다.
다음은 제네릭을 사용한 두 변수의 값을 교환하는 함수의 예시입니다.
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
let temporaryA = a
a = b
b = temporaryA
}
var firstInt = 1
var secondInt = 2
swapTwoValues(&firstInt, &secondInt)
// firstInt는 이제 2, secondInt는 이제 1
var firstString = "hello"
var secondString = "world"
swapTwoValues(&firstString, &secondString)
// firstString은 이제 "world", secondString은 이제 "hello"
(여기서 inout은 매개변수를 참조로 받엤다는 키워드, 변수 앞에 &를 붙인 것은 값을 참조로 전달한다는 것입니다. 스위프트에선 기본적으로 값을 복사하여 전달합니다.)
제네릭 타입은 제네릭 파라미터를 사용하여 정의할 수 있는 구조체, 클래스, 열거형 등을 의미합니다. 다음은 제네릭을 사용하는 스택 구조체의 예입니다.
struct Stack<Element> {
var items = [Element]()
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element? {
return items.popLast()
}
}
var intStack = Stack<Int>()
intStack.push(1)
intStack.push(2)
print(intStack.pop()) // 2
print(intStack.pop()) // 1
var stringStack = Stack<String>()
stringStack.push("hello")
stringStack.push("world")
print(stringStack.pop()) // "world"
print(stringStack.pop()) // "hello"
만약 익스텐션을 통해 제네릭을 사용하는 타입에 기능을 추가하고자 한다면 익스텐션 정의에 타입 매개변수를 명시하지 않아야 합니다. 대신 원래의 제네릭 정의에 명시한 타입 매개변수를 익스텐션에 사용할 수 있습니다.
// 제네릭 타입인 Stack<Element> 정의
struct Stack<Element> {
private var elements = [Element]()
mutating func push(_ element: Element) {
elements.append(element)
}
mutating func pop() -> Element? {
return elements.popLast()
}
func peek() -> Element? {
return elements.last
}
}
// Stack<Element> 타입을 확장하여 특정한 동작을 추가. 예를 들어, Stack<Element>를 확장하여 요소들을 역순으로 반환하는 기능을 추가
extension Stack {
func reversed() -> Stack<Element> {
var stack = Stack<Element>()
for element in elements.reversed() {
stack.push(element)
}
return stack
}
}
// 위의 reversed() 메서드는 Stack 타입을 확장하여 역순으로 뒤집은 새로운 Stack 인스턴스를 반환합니다. 이제 기존의 Stack 인스턴스를 사용하여 역순으로 뒤집은 새로운 Stack을 만들 수 있습니다.
var stack = Stack<Int>()
stack.push(1)
stack.push(2)
stack.push(3)
let reversedStack = stack.reversed()
print(reversedStack.peek()) // 출력: 1
이렇게 제네릭 타입을 확장하여 추가적인 기능을 제공하거나 특정한 동작을 추가함으로써 코드의 재사용성과 유연성을 높일 수 있습니다.
제네릭 함수가 처리해야 할 기능이 특정 타입에 한정되어야만 처리할 수 있다던가, 제네릭 타입을 특정 포로토콜을 따르는 타입만 사용할 수 있도록 제약을 두어야 하는 상황이 발생할 수 있습니다. 예를 들어, 타입 매개변수 자리에 사용할 실제 타입이 특정 클래스를 상속받은 타입이어야 한다든지, 특정 프로토콜을 준수하는 타입이어야 한다는 등의 제약을 줄 수 있습니다. 타입 제약(Type Constraints)은 클래스 타입 또는 프로토콜로만 줄 수 있습니다.
특정 프로토콜을 준수하는 타입만 사용하도록 제약
//아래의 예시에서는 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
}
// 위의 findIndex 함수는 제네릭 타입 T에 대한 제약으로 Equatable 프로토콜을 가지고 있습니다. 따라서 T는 == 연산자를 사용하여 동등성 비교를 지원해야 합니다. 이를 통해 findIndex 함수는 동등성 비교가 가능한 타입에 대해서만 동작하도록 제한됩니다.
let strings = ["Apple", "Orange", "Banana"]
if let index = findIndex(of: "Orange", in: strings) {
print("Found at index \(index)") // 출력: Found at index 1
}
특정 클래스 타입에 대한 제약
또한 클래스 타입에 대한 제네릭 제약도 가능합니다. 예를 들어, 특정 클래스를 상속받는 타입으로 제한할 수 있습니다.
class Vehicle {
var name: String
init(name: String) {
self.name = name
}
}
class Car: Vehicle { }
func printVehicleName<T: Vehicle>(vehicle: T) {
print(vehicle.name)
}
// 위의 예시에서 printVehicleName 함수는 Vehicle 클래스를 상속받는 타입만을 인자로 받을 수 있습니다. 이를 통해 printVehicleName 함수는 Vehicle 클래스의 속성에 접근할 수 있는 타입에 대해서만 동작하도록 제한됩니다.
프로토콜의 연관 타입은 프로토콜 내에서 제네릭 타입을 사용하여 연관 타입을 정의하는 기능을 말합니다. 이를 통해 프로토콜을 정의할 때 해당 프로토콜을 채택하는 타입이 특정한 타입을 연관 타입으로 사용하도록 할 수 있습니다. 이것은 프로토콜을 더 유연하고 재사용 가능하도록 만들어줍니다.
예를 들어, 스위프트에서 Container라는 프로토콜을 정의하고 제네릭 연관 타입을 사용하여 해당 프로토콜을 채택하는 타입이 어떤 타입을 저장하는지 지정할 수 있습니다.
protocol Container {
associatedtype Item
mutating func addItem(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}
// 위의 예시에서 Container 프로토콜은 제네릭 연관 타입인 Item을 정의합니다. 이제 Container 프로토콜을 채택하는 타입은 Item에 대한 실제 타입을 정의해야 합니다.
struct Stack<Element>: Container {
typealias Item = Element
private var elements = [Element]()
mutating func addItem(_ item: Element) {
elements.append(item)
}
var count: Int {
return elements.count
}
subscript(i: Int) -> Element {
return elements[i]
}
}
var stack = Stack<Int>()
stack.addItem(1)
stack.addItem(2)
stack.addItem(3)
print(stack.count) // 출력: 3
print(stack[0]) // 출력: 1
위의 예시에서 Stack 구조체는 Container 프로토콜을 채택하고 제네릭 연관 타입 Item을 Element로 정의합니다. 이제 Stack 구조체는 Item이 Element 타입임을 알게 되며, 따라서 Element 타입의 요소를 저장하고 관리할 수 있습니다.
제네릭 연관 타입을 사용하면 프로토콜을 정의할 때 실제 타입을 명시하지 않고도 프로토콜을 사용하는 타입이 자체적으로 적합한 타입을 선택할 수 있도록 합니다. 이는 코드의 유연성과 재사용성을 높여줍니다.
서브스크립트도 제네릭을 활용하여 타입에 큰 제한 없이 유연하게 구현할 수 있습니다. 물론 타입 제약을 사용하여 제네릭을 활용하는 타입에 제약을 줄 수도 있습니다.
// Stack 구조체의 제네릭 서브스크립트 구현과 사용
extension Stack {
subscript<Indices: Sequence>(indices: Indices) -> [ItemType]
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]
위 예시에서 Stack 구조체의 익스텐션으로 서브스크립트를 추가했습니다. 서브스크립트는 Indices라는 플레이스홀더를 사용하여 매개변수를 제네릭하게 받아들일 수 있습니다. Indices는 Sequence 프로토콜을 준수하는 타입으로 제약이 추가되어 있습니다. 또, Indices 타입 Iterator의 Element 타입이 Int 타입이어야 하는 제약이 추가되었습니다. 서브스크립트는 이 Indices 타입의 indices라는 매개변수로 인덱스 값을 받을 수 있습니다. 그 결과 indices 시퀀스의 인덱스 값에 해당하는 스택 요소의 값을 배열로 반환합니다.