제네릭 (Generics)

YeongHyeon Jo·2024년 2월 27일
0

Swift

목록 보기
6/6

제네릭이 필요한가..?

아래의 예시를 보면서 비교를 해보자! 공식 문서 참조

// 두 숫자를 스왑(서로 교환)하는 함수의 정의
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let tempA = a
    a = b
    b = tempA
}

// Double을 스왑하는 함수의 정의
func swapTwoDoubles(_ a: inout Double, _ b: inout Double) {
    let tempA = a
    a = b
    b = tempA
}

// (정수가 아닌) 문자열을 스왑하는 함수의 정의
func swapTwoStrings(_ a: inout String, _ b: inout String) {
    let tempA = a
    a = b
    b = tempA
}

위의 함수를 비교해보면 함수 명은 다르지만 각자가 하는 역할은 동일하다!
앞에 데이터와 뒤의 데이터를 변경하는 기능이다.

inout 매개변수

함수를 호출 시 매개변수에 대한 참조를 전달하는 방법.
함수 내에서 매개변수의 값을 직접 변경을 할 수 있는 기능을 제공한다.

var num1 = 123     // 전역변수
var num2 = 456     // 전역변수

func swap(a: Int, b: Int) {
    var c = a
    a = b
    b = c
}

swap(a: num1, b: num2)
// 실행시 에러
error: cannot assign to value: 'a' is a 'let' constant
    a = b
    ^
error: cannot assign to value: 'b' is a 'let' constant
    b = c
    ^

위의 코드를 실행했을 때, 함수 내의 파라미터는 기본적으로 값타입이며, 임시상수이기 때문에 이를 변경할 수 없다!
그리하여 다음과 같이 변경을 해야한다.

num1 = 123
num2 = 456

func swapNumbers(a: inout Int, b: inout Int) {
    var temp = a
    a = b
    b = temp
}

// 함수 실행시에는 앰퍼샌드를 꼭 붙여야함
swapNumbers(a: &num1, b: &num2)

print(num1)     // 출력: 456
print(num2)     // 출력: 123

파라미터의 복사본이 전달되는 것이 아니라 원본이 전달이 된다!
이 원본이 전달이되는 의미를 가지는 것이 &(앰퍼샌드)를 붙여야한다.
그리하여 결과적으로 함수 외부에 선언한 전역변수 num1, num2는 값이 변경이 되어 출력이 된다.

위의 코드와 함께 비교하였을 때, 동일한 기능이지만 각 타입마다 모든 경우를 정의해야하므로, 개발자의 할 일이 늘어나고, 유지보수와 재사용 관점에서 보았을 때도 너무 힘들게된다!

제네릭 문법

  • 형식에 관계없이, 한번의 구현으로 모든 타입을 처리하여, 타입에 유연한 함수 작성가능 (유지보수/재사용성 증가)

  • (함수 뿐만아니라) 구조체 / 클래스 / 열거형도 제네릭으로 일반화 가능

  • 보통은 T를 사용하지만 다른 이름을 사용하는 것도 문제가 없음, 형식이름이기 때문에 UpperCamelcase로 선언
    (관습적으로 Type(타입)의 의미인 대문자 T를 사용하지만, 다른 문자를 사용해도 됨.
    ex) <U> <A> <B> <Anything> (Upper camel case 사용해야함 - 맨 앞글자를 대문자로 표기)

  • 2개이상을 선언하는 것도 가능
    ex) <T, U> <A, B>

  • 제네릭은 타입에 관계없이, 하나의 정의(구현)로 모든 타입(자료형)을 처리할 수 있는 문법
    제네릭 함수, 제네릭 구조체/클래스

  • 타입 파라미터는 실제 자료형으로 대체되는 플레이스 홀더(어떤 기호같은것) ===> 새로운 형식이 생성되는 것이 아님.
    코드가 실행될때 문맥에 따라서 실제 형식으로 대체되는 "플레이스 홀더"일뿐

그렇다면 위에 선언하였던 함수를 제네릭 문법을 통해 변경해보려고한다.

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

var string1 = "hello"
var string2 = "world"
swapTwoValues(&string1, &string2)     // 같은 타입이라면, 어떠한 값도 전달 가능 해짐
print(string1) // 출력 : world
print(string2) // 출력 : hello

var number1 = 123
var number2 = 456
swapTwoValues(&number1, &number2)
print(number1) // 출력 : 456
print(number2) // 출력 : 123

함수 뒤에 <T> 를 붙여 제네릭 문법을 적용하였다.

위의 그림과 같이 내가 선언했던 T로 보이는 것을 확인할 수 있다.
하지만, 내가 아래와 같이 코드를 변경한다면,

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

표시될 때도 이와같이 Hello로 변경되는 것처럼 상관은 없지만 대부분이 T를 사용한다.

제네릭 구조체 / 클래스 / 열거형

// 구조체로 제네릭의 정의하기
struct GenericMember<T> {
    var members: [T] = []
}

var member1 = GenericMember(members: ["Jobs", "Cook", "Musk"]) // String
var member2 = GenericMember(members: [1, 2, 3]) // Int

// 클래스로 제네릭의 정의하기
class GridPoint<A> {
    var x: A
    var y: A
    
    init(x: A, y: A){
        self.x = x
        self.y = y
    }
}

let aPoint = GridPoint(x: 10, y: 20) // Int
let bPoint = GridPoint(x: 10.4, y: 20.5) // Double

// 열거형에서 연관값을 가질때 제네릭으로 정의가능
// (어차피 케이스는 자체가 선택항목 중에 하나일뿐(특별타입)이고, 그것을 타입으로 정의할 일은 없음)

enum Pet<T> {
    case dog
    case cat
    case etc(T)
}

let animal = Pet.etc("고슴도치")

제네릭 확장

제네릭 타입을 확장도 할 수 있다.
이때, 타입 파라미터 명시 없이 확장을 할 수 있다. 아래의 예시를 보자!

struct Coordinates<T> {
    var x: T
    var y: T
}

// 제네릭을 Extension(확장)에도 적용할 수 있다.
extension Coordinates {     // Coordinates<T> (X)
    
    // 튜플로 리턴하는 메서드
    func getPlace() -> (T, T) {
        return (x, y)
    }
}

let place = Coordinates(x: 5, y: 5)
print(place.getPlace()) // 출력: (5, 5)

// where절 추가도 가능
// Int타입에만 적용되는 확장과 getIntArray() 메서드
extension Coordinates where T == Int {     // Coordinates<T> (X)
    
    // 배열로 리턴하는 메서드
    func getIntArray() -> [T] {
        return [x, y]
    }
}

let place2 = Coordinates(x: 3, y: 5)
print(place2.getIntArray()) // 출력: [3, 5]

코드상에서 확장 시에는 타입 파라미터를 작성하지 않아도된다! 그리고 확장 시 사용되는 타입은 위에서 적용되는 타입과 동일하게 된다.

타입 제약 (Type Constraint)

제네릭에 타입을 제약할 수 있다.
타입 매개 변수 이름 뒤에 콜론으로 "프로토콜" 제약 조건 또는 "단일 클래스"를 배치할 수 있다.

  1. 프로토콜 제약 `<T: Equatable>
  • 특정 프로토콜을 따르는 타입만 가능하다고 제약
// Equatable 프로토콜을 채택한 타입만 해당 함수에서 사용 가능 하다는 제약
func findIndex<T: Equatable>(item: T, array:[T]) -> Int? {     // <T: Equatable>
    for (index, value) in array.enumerated() {
        if item == value {
            return index
        }
    }
    return nil
}

let aNumber = 5
let someArray = [1, 2, 3, 4, 5, 6, 7]

if let index = findIndex(item: aNumber, array: someArray) {
    print("밸류값과 같은 배열의 인덱스: \(index)")
}
  1. 클래스 제약 <T: SomeClass>
  • 특정 클래스와 상속관계 내에 속하는 클래스 타입만 가능하도록 제약
class Person {
    let name: String
    
    init(name: String) {
        self.name = name
    }
}

class Student: Person {
    let studentID: Int
    
    init(name: String, studentID: Int) {
        self.studentID = studentID
        super.init(name: name)
    }
}

// 특정 클래스 및 해당 클래스를 상속받은 클래스만을 대상으로 하는 제네릭 함수
func printAllowedPeople<T: Person>(array: [T]) {
    print("Allowed people:")
    for person in array {
        print("- \(person.name)")
    }
}

let person1 = Person(name: "John")
let person2 = Person(name: "Alice")

let student1 = Student(name: "Bob", studentID: 123)
let student2 = Student(name: "Eve", studentID: 456)

printAllowedPeople(array: [person1, person2]) // Person 클래스만을 포함한 배열
// 실행 결과
// Allowed people:
// - John
// - Alice
printAllowedPeople(array: [student1, student2]) // Student 클래스를 포함한 배열
// 실행 결과
// Allowed people:
// - Bob
// - Eve

구체 / 특정화 (Specialization) 함수 구현

위에서 선언했던 findIndex<T: Equatable>(item: T, array:[T]) -> Int?에서 대소문자를 무시하고 비교하고 싶어서 좀더 구체적으로 구현하기위한다면 특정 문자열로 구체적인 타입을 명시하면 해당 함수가 실행이 된다.

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

let aString = "jobs"
let someStringArray = ["Jobs", "Musk"]

if let index2 = findIndex(item: aString, array: someStringArray) {
    print("문자열의 비교:", index2)
}
// 실행 결과: 문자열의 비교: 0

만약에 구체적인 함수를 설정하지 않는다면, if let 구문에서 실행을 했을 때 nil의 결과가 출력되어 print가 실행되지 않았을 것이다.
하지만 String라는 구체적인 타입을 설정하여 문자열인 경우 해당 함수를 실행할 수 있도록 구체적으로 선언을 하였을 경우 해당 함수가 실행이 되며 결과값도 출력이 된다.

프로토콜에서의 제네릭 문법

⭐️ 프로토콜은 타입들이 채택할 수 있는 한차원 높은 단계에서 요구사항만을 선언하는 개념
-> 제네릭 타입과는 다른 <연관 타입>이라는 약속을 사용.
<T> -> associatedtype T

protocol RemoteControl {           // <T>의 방식이 아님
    associatedtype T               // 연관형식은 대문자로 시작해야함(UpperCamelcase)
    func changeChannel(to: T)      // 관습적으로 Element를 많이 사용
    func alert() -> T?
}

// 연관형식이 선언된 프로토콜을 채용한 타입은, typealias로 실제 형식을 표시해야함
struct TV: RemoteControl {
    typealias T = Int       // 생략 가능
    
    func changeChannel(to: Int) {
        print("TV 채널바꿈: \(to)")
    }
    
    func alert() -> Int? {
        return 1
    }

}
TV().alert()
TV().changeChannel(to: 10)

class Aircon: RemoteControl {
    // 연관형식이 추론됨. 생략됨
    // typealias T = String
    
    func changeChannel(to: String) {
        print("Aircon 온도바꿈: \(to)")
    }

    func alert() -> String? {
        return "1"
    }

}
Aircon().changeChannel(to: "10도")
Aircon().alert()

연관 형식에 제약을 추가한다면

protocol RemoteControl3 {
    associatedtype T: Equatable     // <T: Equatable> 제약조건 추가
    func changeChannel(to: T)
    func alert() -> T?
}

결론

제네릭은 서로 다른 타입에 대해 동일한 기능을 하는 함수나 타입을 작성할 때 사용된다!
이를 통해 각 타입에 맞게 모든 함수를 구현하지 않아도 되어 코드 유지보수와 재사용성을 높일 수 있다!!!

profile
my name is hyeon

0개의 댓글