콘텐츠로 건너뛰기
Home » enum 타입 사용법 정리 – Swift

enum 타입 사용법 정리 – Swift

  • Swift

Emumerations

“열거”타입은 임의의 관계를 맺는 값들을 하나의 타입으로 묶어서 타입-안전한 방식으로 다룰 수 있게 해준다. C에서도 enum 키워드를 이용해서 열거체를 선언할 수 있었는데, C의 열거체는 개별 정수에 대해서 다른 이름을 붙인 상수처럼 취급했다. 반면 Swift의 열거타입은 보다 유연하며 열거 타입 내의 개별 케이스가 단일 값을 대신해서 코드 상에서 구분하기 쉬운 이름을 갖는 것 보다 더 폭넓게 사용될 수 있다. 우선 각각의 케이스가 정수값이 아닌 실수나 문자열등의 다른 Swift 기본 타입의 값을 사용할 수 있다.

또한 각 케이스가 값 하나가 되는 것 외에 각각의 케이스가 연관 값을 갖는 것이 허용된다. 이 때에는 같은 열거형 내의 각각의 케이스가 서로 다른 타입의 연관 값을 갖는 것도 허용된다.

열거 타입은 Swift내에서 일급 클래스로 취급되며, 클래스가 가지는 계산 프로퍼티나 메소드를 포함하는 것도 가능하며, 이니셜라이저를 가지거나 확장도 할 수 있으며 따라서 프로토콜을 따르도록 정의될 수 있다.

문법

열거 타입은 enum 키워드를 이용해서 선언한다. 열거 타입 역시 타입으로 간주되므로 타입의 이름은 대문자로 시작하며, 내부 케이스는 case 키워드로 선언하는데 각 케이스의 이름은 소문자로 시작하는 관례를 따른다. 다음은 동서남북 내 방향을 구분하기 위한 네 개의 케이스로 구성된 열거형을 정의하는 예를 보여준다.

enum CompassPoint {
  case north
  case south
  case east
  case west
}
/// 혹은 한줄에 쓸 수 있다.
enum CompassPoint {
  case north, south, east, west
}

enum 타입에서 정의된 각각의 케이스들은 자동으로 0, 1, 2, 3의 값을 할당받지 않는다. 단지 같은 타입 내에서 서로가 구분될 뿐이다. 각각의 케이스들은 CompassPoint.north, CompassPoint.east와 같은 식으로 사용된다. (만약 CompassPoint라는 타입의 값을 사용한다는 것이 명백하다면 .north로 줄여쓸 수 있다.)

만약 C에서와 같이 각각의 케이스가 정수형 값에 대응하도록 하려면 다음과 같이 쓸 수 있다.

enum CompassPoint: Int {
  case north=0, south=1, east, west
}

물론 이 경우에도 .north == 1이 성립하는 것은 아니다. .northCompassPoint 타입이므로 Int 타입과 동등 비교를 할 수 없다.

enum CompoassPoint: String {
///               ^^^^^^^^^ 열거 사례들의 raw value의 타입
  case north // 물론 raw value를 지정하지 않아도 상관없다. 원값이 문자열 타입으로 정의돼 있을 때에는 케이스 이름이 원값이 된다. 따라서 이 때는 "north"가 됨
  case south="South", east="East", west="West"
}
let p = CompassPoint.north
dump(p.rawValue) // "north"
dump(p.south.rawValue) // "South"

위 예에서 보듯이 열거의 각 사례들은 열거이름.사례이름의 형식으로 쓸 수 있다. 또 선언된 각각의 enum 들은 개별적인 타입으로 동작한다. 만약 raw value 타입과 사례가 동일해도 다른 열거형에 속해 있다면 1, "1"이 다르듯 다른 타입의 값으로 취급된다.

enum SomeEnums {
  case one, two, three, four
}
enum AnotherEnums {
  case one, two, three, four
}
var a: SomeEnums = .one // 타입이 분명하므로 헷갈릴 염려가 없다.

패턴 매칭에서

열거 타입이 가장 흔히 쓰이는 곳은 어떤 임의의 타입의 값에 대해서 여러 경우를 구분하는 경우일 것이다. 따라서 switch 문과 가장 많이 쓰인다고도 볼 수 있다. 특히 후술할 연관값과 함께 쓰이는 경우에는 매턴 매칭에서의 활용도가 매우 높아진다.

let directionToHead: CompassPoint = .south
switch directionoToHead {
case .north:
  print("Lots of planets have a north.")
case .south:
  print("Watch out for penguins.")
case .east:
  print("Where the sun rises.")
case .west:
  print("Where the skies are blue")
}

연관값

연관값은 열거 내의 하나의 사례에 대해 각 사례 인스턴스마다 다른 값들을 가지게 하고 싶을 때 사용한다. 이는 개별 사례와 함께 임의의 값들을 함께 저장하게 하여 일반 스칼라 타입의 값들보다 강력하고 엄격한 타입 구분을 적용하면서 매우 유연하게 사용할 수 있게 해준다.
Swift 공식 가이드 문서에서는 enum의 연관값을 이용해서 바코드를 훌륭하게 모델링하는 것을 보여준다. 바코드는 다음과 같은 성질을 가진다.

  1. 바코드는 크게 1차원 바코드와 2차원 바코드인 QR코드로 나뉜다.
  2. 1차원 바코드는2 12개의 숫자를 표현하는 체계이다.
  3. 2차원 바코드는 임의의 문자열을 표현할 수 있다. (따라서 흔히 긴 URL을 시각적 단위로 표현하는데 많이 쓰인다.)
enum Barcode {
  case upc(Int, Int, Int, Int)
  case qrCode(String)
}

만약 바코드와 QR코드를 각각 구조체나 클래스로 표현했을 때는 이는 다른 타입이겠지만, enum을 사용하면 단일 타입 내의 다른 사례로 처리하여 개별적으로 다룰 수 있을 뿐만아니라, 코드 내에서는 이 둘을 같은 타입으로 다루는 것이 가능해진다는 말이다.
switch 구문 내에서 각 사례 매칭 시에 case let ... 이나 case .name(let ... ) 과 같은 패턴을 사용해서 사례를 구분하고 연관값을 패턴에 매칭하여 사용하는 것이 가능하다.

var productBarcode = Barcode.upc(3, 25, 153, 9)
productBarcode = .qrCode("helloworld")
switch productBarcode {
case .upc(let a, let b, let c, let d):
  print("UPC: \(a), \(b), \(c), \(d)")
case let .qrCode(message):
  print("QRCode: \(message)")
}

연관값을 이용한 타입 구분

위의 바코드 예제에서도 알 수 있지만, enum 자체가 독립된 하나의 타입이 되기 때문에 연관값을 이용하면 기존의 일반 타입을 특정한 용도의 타입으로 한정하는 것이 가능하며, 심지어 똑같은 타입의 값을 용도에 맞게 구분하는 것이 가능하다.
예를 들어서 같은 실수값을 사용하는 값중에서 각도라든지 온도와 관련된 함수를 쓴다고 생각해보자. 각도의 경우에는 도(degree) 혹은 라디안(radian)단위가 각각 쓰인다. 물론 주로 수학에서는 거의 라디안 단위를 쓰지만, 일상적인 용도에서는 degree 단위의 값을 상정할 수도 있는 것이다. 혹은 온도의 경우에도 섭씨를 쓰느냐 화씨를 쓰느냐에 따라서 아마도 같은 Double 타입의 스칼라값을 쓰겠지만, 단위에 따라서는 그 크기가 달라질 수 있는 것이다.
Foundation에는 이러한 단위에 따라 양이 달라지는 값을 처리하기 위해서 NSMeasurement라는 클래스를 도입하고 있다. 이 클래스는 NSUnit 값에 따라서 길이단위, 무게단위 등의 값을 구분하고 변환하고 필요에 따라서는 출력용으로 포맷팅까지 하는 기능들을 제공하는데, Swift에서는 좀 더 간단히 enum을 이용해서 쓸 수 있다.
온도의 경우를 상정해보자. 온도에는 크게 세 가지 단위가 쓰인다.

  1. 절대온도 : -273도부터 시작하며, 스케일 단위는 섭씨온도와 동일하다.
  2. 섭씨온도 : 일반적인 실험실 상황에서 물이 어는점을 0도, 끓는점을 100도로 하고 그 사이를 100등분한 크기를 1도 단위로 한다.
  3. 화씨온도 : 물이 어는점을 32도 끓는점을 180도로 하는 단위

이 세가지 온도는 모두 그 크기 값으로 Double 타입을 쓸 수 있지만, 헷갈려서 사용되어서는 안될 것이다. 이를 구분하여 사용하는 방법으로는 세가지 접근법을 쓸 수 있다.

  1. NSMeasurement를 통해서 값과 단위를 한 덩어리로 취급한다.
  2. 유령타입을 만든다.
  3. enum으로 처리한다.

여기서는 유령타입의 경우, 구현부가 없는 타입을 만들어서 세 가지 단위의 온도값들을 아예 각각 다른 타입으로 구분해버리는 방법이다.

struct Temperature<A> {
    var value: Double
}

위 예에서 구조체 Temperature는 제네릭 타입으로, value라는 실수값을 감싸는 매우 단순한 구조이다. Swift의 타입 시스템에서 제네릭 타입의 어떤 구체적 타입은 서로 다른 타입으로 구분된다. 따라서 각 단위를 구분해주는 빈 타입을 만드는 것이다.

struct Kelvin {}
struct Fernheit {}
struct Celcius {}

그러면 다음과 같이 쓸 수 있다.

let temp_c : Temperature<Celcius> = Temperature(value: 28.9)

temp_c는 섭씨온도 28.9도로 한정된다. 그리고 만약 화씨온도나 절대온도를 요구하는 함수가 있다면, 타입이 다르기 때문에 그대로 넘겨질 수 없다.별도의 함수를 만들어주어야 한다. (아직까지 제네릭 타입에 대해서 특정한 조건에서만 사용가능한 확장은 존재하지 않기 때문에 확장으로 구현할 수는 없다.)
이는 단위나 성격이 다른 숫자값들이 혼동되어 사용되지 않도록 하기 때문에 단순히 타입이 정수냐 실수냐를 떠나서 더욱 엄격한 타입 검사를 적용할 수 있는 좋은 방법인데 좀 번거로울 수도 있다.
대신에 다음과 같이 enum을 통해서 구현하는 것도 가능하다.

enum Temperature {
  case celcius(Double)
  case fernheit(Double)
  case kelvin(Double)
}

이 구현의 좋은 점은 다음과 같다.

  1. 세 단위의 온도들을 모두 한 타입으로 정의할 수 있다. (대신에 다른 길이나 무게용 enum을 정의한다면 그것들과는 완전히 격리(?)된다.)
  2. 적절히 확장하여 올바른 단위의 값을 항상 사용할 수 있게 할 수 있다.
  3. enum 사례의 연관값이기 때문에 항상 사용하기 전에 패턴 매칭을 통해 확인하게 된다.

위 구현에서 다음과 같이 확장을 통해서 특정한 단위 값을 쓰도록 보장할 수 있다.

extension Temperature {
    var celciusValue: Double {
        switch self {
        case let .celcius(value): return value
        case let .fernheit(value): return (value - 32) * 5 / 9
        case let .kelvin(value); return value + 273
        }
    }
}

그렇다면 다음과 같이 API를 만들 수 있다.

func doSomething(with temperature: Temperature) -> Result {
    ...
    let value = temperature.celciusValue
    ...
}

유령타입

여기서 잠깐 삼천포로 빠져서 유령타입을 사용하기 좋은 경우를 생각해보자. 유령타입과 enum을 쓰는 것의 차이는 enum은 서로 다른 성격의 값을 (호환이 될 수도 있고, 안될 수도 있다.) 하나의 타입으로 묶어서 처리하는 것이고 유령 타입은 아예 서로 다른 타입으로 구분해버리는 것이다.
예를 들어 NSFileHandle을 생각해보자. 이는 파일 디스크립터를 객체화한 것인데, 문제는 읽기용으로 파일을 열었을 때나 쓰기용으로 파일을 열었을 때, 이 클래스의 서브 클래스로 나뉘어지는 것이 아니라 그냥 NSFileHandle 객체를 얻게 된다는 것이다.4
따라서 파일에 쓰는 함수와 파일에서 읽는 함수를 각각 만들 때, 사용해야 하는 핸들 객체는 타입으로 구분되지 않는다. 물론 enum의 연관값으로 넣어버려도 상관없긴하지만, 아예 API 상에서 호환되지 않도록 구분해버리려면 유령타입을 쓰면 된다.

struct Read {}
struct Write {}
struct FileHandle<A> {
    let fileHandle: NSFileHandle
}

이제 아예 파일을 열때 용도에 따라 타입을 구분해버릴 수 있다.

func openForReading(at path: String) -> FileHandle<Read>? { ... }
func openForWriting(at path: String) -> FileHandle<Write>? { ... }

같은 식으로 파일을 읽거나 쓸 때, 이를 아예 구분하여 매칭되지 않는 함수로부터 생성된 파일 핸들을 쓰지 못하게 미리 구분해버릴 수 있다.

func readDataToEndOfFile(_ handle: FileHandle<Read>) -> NSDate { ... }
func writeDateToFile(_ handle: FileHandle<Wrtie>) { ... }

이 경우에도 물론 enum을 사용해서 두 케이스를 구분할 수도 있겠지만, 문맥상 두 핸들은 기능하는 것이 다르고 상호간의 변환도 불가능하므로 이처럼 구분해버리는 쪽이 좋다고 하겠다.

rawValue

앞에서 enum의 각 케이스들은 C의 그것과는 달리 암시적으로 0, 1, 2… 의 정수들과는 차이가 있다고 했다. 하지만 내부적으로는 암시적으로 고유한 값을 가지고 있고 이를 명시해준다면 각각의 rawValue에 의해 구분하여 사용할 수 있다. 명시적으로 rawValue를 사용하려면 enum 타입을 선언할 때 :Type을 써서 내부적인 rawValue의 타입을 명시해주어야 하며, 각각의 케이스의 값을 지정해주어야 한다. 5

enum ASCIIControllCharacter: Character {
    case tab = "\t"
    case lineFeed = "\n"
    case carriageReturn = "\r"
}
enum CompassPoint : String {
    case north = "north"
    case east, west, south // 각각 "east", "west", "south"
}
enum Fruit : Int {
    case apple, banana, cherry // 각각 0, 1, 2
    case oragne=7, pineapple, prune // 각각 7, 8, 9
}

raw value값을 이용하여 열거 사례를 초기화하는 것도 가능하다. 명시적인 rawValue 타입을 지정한 열거타입에 한정하여 init?(rawValue:)가 암시적으로 지원되는데, 주의해야 할 점은 이는 failable initializer라는 점이다. 열거 타입 내 사례의 개수는 한정적이나 값의 수는 통상 이것보다 훨씬 많기 때문이다.

재귀적 데이터 타입

Swift 2에서부터 indirect 키워드를 이용해서 재귀적 열거타입을 만드는 것이 가능해졌다. 즉 특정한 사례의 연관값의 멤버중에 자기 자신을 넣는 것이 가능해졌다. 이를 이용하면 손쉽게 트리나 리스트(하스켈의 리스트와 비슷한 그것)를 만드는 것이 가능하다. indirect 키워드는 재귀 표현이 들어가는 사례 앞에 붙거나 enum 앞에 붙을 수 있다.

리스트

재귀 열거 타입을 이용해서 리스트를 구현해보자. A 타입의 원소를 갖는 리스트는 다음과 같이 정의할 수 있다.

  1. 빈 리스트
  2. 하나의 원소와 그 오른쪽의 리스트

즉 그 자체로 빈 리스트가 있거나, 리스트의 첫 원소와 나머지 리스트로 구성된 원소가 있는 리스트 두 가지 경우가 된다. 그 나머지 리스트가 원소가 있는지 비어있는지에 따라서 리스트가 얼마나 길어질 수 있는지가 결정될 것이다. (이것은 연결리스트와 매우 비슷한 구조이나, mutable하지 않다는 특징이 있다.)

indirect enum List<A> {
    case empty
    case list(A, List<A>)
}
// 빈 리스트
let sentinel: List<Int> = .empty
let oneTwoThree: List<Int> = .list(1, .list(2, .list(3, empty)))

몇 가지 기능을 좀 더해보자. 리스트의 맨 앞에 원소를 추가하거나, 두 개의 리스트를 연결(concatenate)하는 연산을 구현한다. 그리고 보통 리스트는 head와 tail을 구분하는 처리도 많이 하더라.

precedencegroup ListPrecedence {
    associativity: left
}
infix operator ++ : ListPrecedence
infix operator +> : ListPrecedence
func ++<A>(lhs: List<A>, rhs: List<A>) -> List<A> {
    switch lhs {
    case .empty: return rhs
    case let .list(x, xs): return .node(x, xs ++ rhs)
    }
}
func +><A>(lhs: A, rhs: List<A>) -> List<A> {
    return .list(lhs, rhs)
}
func head<A>(_ list: List<A>) -> A? {
    switch list {
    case .empty: return nil
    case let .list(x, _): return x
    }
}
func tail<A>(_ list: List<A>) -> List<A>? {
    switch list {
    case .empty: return nil
    case let .list(_, xs): return xs
}
extension CustomStringConvertible {
    var description: String {
        return "[(\tempDescription)"
    }
    var tempDescription: String {
        switch self {
        case .empty: return "]"
        case let .list(x, .empty): return "\(x)]"
        case let .list(x, xs): return "\(x), \(xs.tempDescription)"
        }
    }
}

그외에 last, initial, foldl, foldl1, take(_:to:), drop(_:to:),takeWhile(_:_:),dropWhile(_:_:) 등의 함수를 작성해보는 것도 재밌을 것 같다.


  1. 다만 fileHandleForReadingAtPath(_:), fileHandleForWritingAtPath(_:) 의 생성함수만 다를 뿐이다. 
  2. 이 때도 항상 명시해야 하는 것은 아니다. 정수 타입을 사용하는 경우 C와 비슷하게 0부터 시작하는 숫자를 가지게 되며, 문자열의 경우에는 케이스명 그 자체를 raw value로 갖게 된다. 
태그: