본문 바로가기

학습 노트/Swift (2021)

159 ~ 161. Generics (제네릭)

Generic Function (제네릭 함수)

제네릭을 사용하면 형식에 의존하지 않는 범용 코드를 작성할 수 있다.
코드의 재사용성과 유지보수가 간편해진다는 장점이 있다.

func swapInteger(lhs: inout Int, rhs: inout Int) {
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

swapInteger(lhs:rhs:) 함수는 두 개의 정수를 받을 경우 의도대로 동작한다.
하지만 정수 형태가 아니라면 에러가 발생한다.
파라미터에 지정된 값의 형태 외엔 전달 받을 수 없기 때문이다.
따라서 같은 형태의 함수를 파라미터 형식을 다르게 하여 여러 개 만들어야 한다.

다만 같은 코드를 중복해 사용했다는 점에서 작업의 불필요한 반복이 늘어나게 된다.
이 문제를 새결할 수 있는 것이 Generic Function(제네릭 함수)이다.

Syntax

func name<T>(parameters) -> Type {
    code
}

함수 이름 뒤에 '< >'와 T로 이루어진 부분이 존재한다.
해당 부분은 Type Parameter(타입 파라미터)로 파라미터 형식이나 반환형으로 사용된다.
T가 아닌 다른 이름으로 사용해도 괜찮지만 UpperCamelCase가 권장된다.
또한 개수의 제한도 없다.

func swapInteger<T>(lhs: inout T, rhs: inout T) {
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

이렇게 되면 자료형에 관계없이 파라미터를 전달할 수 있게 된다.

func swapInteger<T>(lhs: inout T, rhs: inout T) {
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

//-----

var a = "first"
var b = "second"

swapInteger(lhs: &a, rhs: &b)
a
b
결과

"second"
"first"
func swapInteger<T>(lhs: inout T, rhs: inout T) {
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

//-----

var a = 10
var b = 20

swapInteger(lhs: &a, rhs: &b)
a
b
결과

20
10

문자열이나 정수 어떤 자료형이 와도 함수가 맞춰서 대응하고 있는 것을 볼 수 있다.

Type Parameter (타입 파라미터)

타입 파라미터는 실제 자료형으로 대체되는 Placeholder(플레이스홀더)이다.
따라서 타입 파라미터를 T로 선언했다고 T라는 새로운 자료형이 생기는 것은 아니다.

Type Constraints (형식 제한)

앞에서 작성한 코드는 개선할 여지가 남아있다.
예를 들어 전달되는 두 파라미터의 값이 같다면 값을 번거롭게 고칠 필요가 없다.
따라서 조건문을 사용하여 두 파라미터를 비교 후 분기하도록 수정한다.

func swapInteger<T>(lhs: inout T, rhs: inout T) {
	guard lhs != rhs else {
		return
	}
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}
결과

//error

하지만 의도처럼 잘 되지 않는다.
에러 내용은 'Binary operator '!=' cannot be applied to two 'T' operands'로, 제네릭 함수끼리는 비교할 수 없다는 말이다.
두 파라미터를 비교할 수 있는 이유는 코드 상에서 두 파라미터의 자료형을 확신할 수 있기 때문이지만,
제네릭 함수의 경우 전달이 되는 순간에서야 두 파라미터의 자료형을 확인할 수 있다.
따라서 비교 기능이 구현되어 있지 않은 자료형이 전달될 수 있기 때문에 에러가 발생한다.
따라서 타입 파라미터의 자료형을 Equatable 프로토콜을 채용한 자료형으로 제한하면 문제는 해결된다.

이때 사용하는 것이 Type Constraints(형식 제약)이다.

 

Syntax

<TypeParameterClassName>
<TypeParameterProtocolName>

 

클래스의 이름을 작성하면 해당 클래스와 해당 클래스를 상속받는 클래스로,
프로토콜 이름을 작성하면 해당 프로토콜을 채용하고 있는 형식으로 제한된다.

func swapValue<T: Equatable>(lhs: inout T, rhs: inout T) {
	guard lhs != rhs else {
		return
	}
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

이렇게 되면 Equatable 프로토콜을 선언하고 있는 형식임을 type parameter에서 보증하고 있기 때문에,
비교 연산자를 사용할 수 있다.

Specialization (특수화)

제네릭 함수는 기본적으로 형식에 관계없이 동일한 코드를 실행한다.
예를 들어 문자열이 전달되면 비교를 진행할 때에도 정수가 전달됐을 때와 차이를 둘 수 없다는 이야기이다.
따라서 대소 구분을 없애기 위해 caseInsensitive 속성을 사용하고 싶어도 해당 속성은 String에서만 지원되는 속성이니 적용할 수 없다.
이 때는 Specialization(특수화)를 통해 특성 형식에 대한 별도의 함수를 구현한다.

func swapValue<T: Equatable>(lhs: inout T, rhs: inout T) {
	print("generic version")
	
	guard lhs != rhs else {
		return
	}
	
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

func swapValue(lhs: inout String, rhs: inout String) {
	print("swap string")
	
	guard lhs.caseInsensitiveCompare(rhs) != .orderedSame else {
		return
	}
	
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

따라서 타입 파라미터를 제외한 기존의 함수형을 사용해 String 전용 함수를 새로 만들었다.
이 둘은 이름이 같지만, 파라미터의 자료형이 달라 이를 통해 구별한다.

func swapValue<T: Equatable>(lhs: inout T, rhs: inout T) {
	print("generic version")
	
	guard lhs != rhs else {
		return
	}
	
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

func swapValue(lhs: inout String, rhs: inout String) {
	print("swap string")
	
	guard lhs.caseInsensitiveCompare(rhs) != .orderedSame else {
		return
	}
	
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

var a = 1
var b = 2
swapValue(lhs: &a, rhs: &b)

a
b
결과

generic version
-----
2
1

따라서 String이 아닌 파라미터를 전달하면 기존에 선언한 제네릭 함수를 호출하고,

func swapValue<T: Equatable>(lhs: inout T, rhs: inout T) {
	print("generic version")
	
	guard lhs != rhs else {
		return
	}
	
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

func swapValue(lhs: inout String, rhs: inout String) {
	print("swap string")
	
	guard lhs.caseInsensitiveCompare(rhs) != .orderedSame else {
		return
	}
	
	let tmp = lhs
	lhs = rhs
	rhs = tmp
}

var a = "banana"
var b = "apple"
swapValue(lhs: &a, rhs: &b)

a
b
결과

swap string
-----
"apple"
"banana"

String을 전달하면 별도로 생성한 함수를 호출한다.
이렇게 특수화로 구현한 함수는 제네릭 함수보다 우선순위가 높기 때문에 함수는 모든 종류의 파라미터를 받지만 String의 경우 특수화 함수를 호출하게 된다.

 

Generic Types (제네릭 타입)

swift의 컬렉션은 모두 구조체로 구현되어 있으며 Generic Type(제네릭 타입)이다.
컬렉션에는 동일한 형식의 값만 저장할 수 있는 이유가 여기에 있다.

 

Syntax

class Name<T> {
    code
}

struct Name<T> {
    code
}

enum Name<T> {
    code
}

 

종류에 상관없이 키워드를 제외하면 동일한 문법을 사용한다.
형식 내부에서 사용하는 형식들을 타입 파라미터로 대체할 수 있다.

형식 표기 방식

struct Color<T> {
	var red: T
	var green: T
	var blue: T
}

var c = Color(red: 12, green: 34, blue: 56)
type(of: c)
결과

Color<Int>

문법대로 타입 파라미터를 선언한 구조체를 만들고 인스턴스를 생성했다.
이때 c의 타입은 Color 형식에 <Int>가 붙은 형태이다.
이는 인스턴스를 생성하면서 전달한 파라미터의 자료형이 Int였기 때문이다.

이번엔 다시 실수를 전달한다.

struct Color<T> {
	var red: T
	var green: T
	var blue: T
}

var c = Color(red: 12, green: 34, blue: 56)

c = Color(red: 12.0, green: 34.0, blue: 45.0)
결과

//error

원래대로라면 자료형에 관계없이 파라미터가 전달되어야 하지만 에러가 발생한다.
에러는 'Cannot convert value of type 'Double' to expected argument type 'Int''
라는 내용으로 Int 타입의 아규먼트에 Double을 저장할 수 없다는 이야기이다.

컴파일러는 파라미터의 형식을 통해 적합한 자료형의 형식을 자동으로 생성한다.
따라서 파라미터의 형식이 Int인 형식과 Double인 형식 두 개가 만들어진다.
그리고 이 둘은 다른 형식으로 취급된다.

struct Color<T> {
	var red: T
	var green: T
	var blue: T
}

var c = Color(red: 12, green: 34, blue: 56)

let d = Color(red: 12.0, green: 34.0, blue: 45.0)
결과

Color<Int>
Color<Double>

따라서 새로운 인스턴스에 저장하거나

struct Color<T> {
	var red: T
	var green: T
	var blue: T
}

var c = Color(red: 12, green: 34, blue: 56)

let d: Color = Color(red: 12.0, green: 34.0, blue: 45.0)
결과

Color<Int>
Color<Double>

형식을 직접 선언하거나,

struct Color<T> {
	var red: T
	var green: T
	var blue: T
}

var c = Color(red: 12, green: 34, blue: 56)

let d: Color<Double>
결과

Color<Int>
Color<Double>

추론할 값이 없는 경우 타입 파라미터를 직접 선언할 수 있다.

let arr: Array<Int>

Array는 여러 가지 형식에 대응할 수 있도록 제네릭 타입으로 구현되어 있고,
위와 같이 <Int>를 추가하면 해당 자료형에 맞는 형식을 자동으로 생성한다.
이는 Dictionaty등의 여러 컬렉션이 동일하다.

제네릭 타입의 확장

struct Color<T> {
	var red: T
	var green: T
	var blue: T
}

extension Color {
	func getCompo() -> [T] {
		return [red, green, blue]
	}
}

속성에 저장된 값을 배열로 반환하는 메서드를 포함하는 익스텐션을 작성했다.
이때 대상 구조체의 이름 뒤에 타입 파라미터를 작성하지 않는다.
단, 반환형에는 타입 파라미터를 동일하게 T를 적용해야 한다.

struct Color<T> {
	var red: T
	var green: T
	var blue: T
}

extension Color {
	func getCompo() -> [T] {
		return [red, green, blue]
	}
}

//-----

let intC = Color(red: 12, green: 34, blue: 56)
type(of: intC.getCompo())
let doubleC = Color(red: 12.0, green: 34.0, blue: 56.0)
type(of: doubleC.getCompo())
결과

Array<Int>.Type
Array<Double>.Type

익스텐션은 타입 별로 따로 선언하지 않아도 자동으로 적용된다.
따라서 전달하는 파라미터의 자료형에 따라 대응하여 동작을 수행한다.

익스텐션 또란 형식 제한을 사용할 수 있다.

struct Color<T> {
	var red: T
	var green: T
	var blue: T
}

extension Color where T: FixedWidthInteger {
	func getCompo() -> [T] {
		return [red, green, blue]
	}
}

let intC = Color(red: 12, green: 34, blue: 56)
type(of: intC.getCompo())
let doubleC = Color(red: 12.0, green: 34.0, blue: 56.0)
type(of: doubleC.getCompo())
결과

//error

이번엔 익스텐션에 where 절을 추가해 제한을 걸었다.
해당 프로토콜은 정수형에서만 채용하고 있는 프로토콜이므로 실수 자료형인 DoubleC는 사용할 수 없어 에러가 발생한다.

struct Color<T> {
	var red: T
	var green: T
	var blue: T
}

extension Color where T == Int {
	func getCompo() -> [T] {
		return [red, green, blue]
	}
}

let intC = Color(red: 12, green: 34, blue: 56)
type(of: intC.getCompo())
let doubleC = Color(red: 12.0, green: 34.0, blue: 56.0)
type(of: doubleC.getCompo())

FixedWidthInteger 프로토콜을 채용하고 있는 형식이 대상이 아니라 자료형 자체를 제한하고 싶다면,
where절에 타입 파라미터를 비교하도록 구현하면 된다.

 

Associated Types (연관 형식)

Generic Protocol

protocol QueueCompatible {
	func enqueue(value: Int)
	func dequeue() -> Int?
}

QueueCompatible 프로토콜은 Int 파라미터를 갖는 enqueue method와,
Optional Int의 반환형을 가진 dequeue method를 가지고 있다.
따라서 이 프로토콜을 채용하는 형식은 두 메서드를 모두 구현해야 한다.
이 말은 구현할 때에도 파라미터의 자료형이나 반환형의 자료형이 달라지면 안 됨을 의미한다.

이를 간편하게 구현하기 위해 프로토콜을 제네릭 타입으로 선언해 보자

protocol QueueCompatible<T> {
	func enqueue(value: T)
	func dequeue() -> T?
}
결과

//error

당연히 이런 문법은 존재하지 않기 때문에 에러가 발생한다.
프로토콜에서 제네릭 타입을 사용하기 위해선 Associated Type(연관 형식)을 사용해야 한다.

 

Syntax

associatedtype Name

 

associatedtype 키워드가 이름 앞에 오고,
이름은 UpperCamelCase 형태로 작성한다.
연관 형식도 해당 형식 내에서 사용되는 새로운 형식이 아닌 플레이스 홀더이다.

protocol QueueCompatible {
	associatedtype Element
	func enqueue(value: Element)
	func dequeue() -> Element?
}

이렇게 연관 형식을 선언하고 타입 파라미터를 사용했던 것 같이 자료형으로 지정해 주면 된다.

protocol QueueCompatible {
	associatedtype Element
	func enqueue(value: Element)
	func dequeue() -> Element?
}

class Test: QueueCompatible {
	typealias Element = Int
	
	func enqueue(value: Int) {
    
	}
	func dequeue() -> Int? {
		return 0
	}
}

채용할 때에는 typealias를 사용하여 선언했던 연관 형식을 새로운 자료형으로 대체해 줘야 하거나,

protocol QueueCompatible {
	associatedtype Element
	func enqueue(value: Element)
	func dequeue() -> Element?
}

class Test2: QueueCompatible {
	func enqueue(value: Double) {
    
	}
	func dequeue() -> Double? {
		return 0
	}
}

직접 자료형을 지정해 형식 추론이 가능하도록 만들어 줘야 한다.

만약 적용 대상을 제한하고 싶다면, 이 또한 가능하다.

protocol QueueCompatible {
	associatedtype Element: Equatable
	func enqueue(value: Element)
	func dequeue() -> Element?
}

연관 형식 이름 뒤에 제한 대상을 작성한다.

 


Log

2021.09.14.
블로그 이전으로 인한 글 옮김 및 수정