열거형
열거형 (Enumerations)
가능한 값의 목록을 정의하는 커스텀 타입을 만들고 싶을 때가 있어요. 가령 방향처럼 한정된 후보 중 하나를 나타내고 싶다면요. 이런 경우에 사용하는 게 바로 열거형(enumeration) 이에요. 열거형은 서로 관련된 값들의 그룹을 하나의 공통 타입으로 묶어 주고, 그 값들을 코드 안에서 타입에 안전한 방식으로 다루게 해 줍니다.
C 언어를 써 본 분이라면, C의 열거형이 정수 값 집합에 이름을 붙여 주는 걸 알고 계실 거예요. 스위프트의 열거형은 훨씬 더 유연해서, 각 케이스에 꼭 값을 제공하지 않아도 됩니다. 만약 각 케이스에 값(이걸 원시 값(raw value) 이라고 불러요)을 제공한다면, 그 값은 문자열이 될 수도 있고 문자 하나가 될 수도 있으며, 정수나 부동소수점 타입의 값이 될 수도 있어요.
반대로, 열거형 케이스는 각각의 케이스 값과 함께 저장할 연관 값(associated value) 으로 아무 타입이나 지정할 수 있어요. 이건 다른 언어에서 유니온(union)이나 배리언트(variant)라고 부르는 것과 비슷한 개념입니다. 하나의 열거형 안에 서로 관련된 케이스들을 모아 두고, 각 케이스마다 적절한 타입을 가진 서로 다른 값 묶음을 연결할 수 있는 거예요.
스위프트의 열거형은 그 자체로 일급 타입(first-class type)입니다. 전통적으로 클래스만 지원하던 기능들을 많이 갖추고 있어요. 계산 프로퍼티(computed property)로 열거형의 현재 값을 설명하는 추가 정보를 제공할 수도 있고, 인스턴스 메서드로 열거형이 나타내는 값과 관련된 기능을 제공할 수도 있습니다. 또 열거형은 초깃값을 제공하는 이니셜라이저를 정의할 수 있고, 익스텐션으로 원래 구현을 넘어 기능을 확장할 수 있으며, 프로토콜을 채택해 표준 기능을 갖출 수도 있어요.
본문
열거형 문법 (Enumeration Syntax)
열거형은 enum 키워드로 시작하고, 전체 정의를 한 쌍의 중괄호 안에 둡니다.
enum SomeEnumeration {
// enumeration definition goes here
}
나침반의 네 방향을 예로 들어 볼게요.
enum CompassPoint {
case north
case south
case east
case west
}
열거형 안에 정의된 값들(north, south, east, west 같은 것들)을 열거형 케이스(enumeration case) 라고 불러요. 새 케이스를 추가할 때는 case 키워드를 사용합니다.
참고: C나 Objective-C 같은 언어와 달리, 스위프트의 열거형 케이스는 기본적으로 정수 값을 가지지 않아요. 위
CompassPoint예시에서north,south,east,west는 암묵적으로0,1,2,3과 같지 않습니다. 대신 각 케이스는 그 자체로 하나의 값이고, 명시적으로 정의된 타입은CompassPoint예요.
여러 케이스는 쉼표로 구분해서 한 줄에 쓸 수도 있어요.
enum Planet {
case mercury, venus, earth, mars, jupiter, saturn, uranus, neptune
}
열거형 정의 하나는 새로운 타입 하나를 만들어 냅니다. 스위프트의 다른 타입처럼 이름(CompassPoint, Planet 같은)은 대문자로 시작해요. 그리고 열거형 타입 이름은 복수형보다는 단수형으로 지어서, 읽었을 때 뜻이 분명하게 만드는 게 좋아요.
var directionToHead = CompassPoint.west
directionToHead의 타입은 CompassPoint의 가능한 값 중 하나로 초기화하면서 추론됩니다. 일단 directionToHead가 CompassPoint로 선언되면, 짧은 점 문법을 사용해 다른 CompassPoint 값으로 바꿔 줄 수 있어요.
directionToHead = .east
directionToHead의 타입은 이미 알려져 있으니, 값을 바꿀 때 타입을 생략해도 되는거죠. 이 덕분에 명시적으로 타입이 정해진 열거형 값을 다룰 때 코드를 훨씬 읽기 좋게 쓸 수 있습니다.
switch 문으로 열거형 값 매칭하기 (Matching Enumeration Values with a Switch Statement)
switch 문으로 개별 열거형 값을 매칭할 수 있어요.
directionToHead = .south
switch directionToHead {
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")
}
// Prints "Watch out for penguins".
이 코드를 이렇게 읽으면 됩니다: "directionToHead의 값을 생각해 봐요. .north와 같을 때는 "Lots of planets have a north"를 출력해요. .south와 같을 때는 "Watch out for penguins"를 출력해요." 이렇게 쭉 이어지는 거죠.
<doc:ControlFlow>에서 설명하는 것처럼, switch 문은 열거형의 케이스들을 고려할 때 반드시 exhaustive(모든 경우를 다룸)해야 해요. 만약 .west에 대한 case를 빼먹으면, 이 코드는 CompassPoint의 케이스 전체 목록을 고려하지 않았기 때문에 컴파일되지 않습니다. 이렇게 모든 경우를 강제하면 열거형 케이스를 실수로 빠뜨리는 일을 막아 줘요.
모든 열거형 케이스에 case를 제공하는 게 적절하지 않을 때는, 명시적으로 다루지 않은 나머지 케이스를 덮어 주는 default 케이스를 제공할 수 있습니다.
let somePlanet = Planet.earth
switch somePlanet {
case .earth:
print("Mostly harmless")
default:
print("Not a safe place for humans")
}
// Prints "Mostly harmless".
열거형 케이스 순회하기 (Iterating over Enumeration Cases)
어떤 열거형에서는, 그 열거형의 모든 케이스를 담은 컬렉션을 갖는 게 유용할 때가 있어요. 열거형 이름 뒤에 : CaseIterable을 쓰면 이 기능을 쓸 수 있는데, 스위프트가 모든 케이스를 담은 컬렉션을 allCases 프로퍼티로 노출해 줍니다. 예시를 볼게요.
enum Beverage: CaseIterable {
case coffee, tea, juice
}
let numberOfChoices = Beverage.allCases.count
print("\(numberOfChoices) beverages available")
// Prints "3 beverages available".
위 예시에서 Beverage.allCases를 써서 Beverage 열거형의 모든 케이스를 담은 컬렉션에 접근했는데, allCases는 다른 컬렉션처럼 사용할 수 있어요. 컬렉션의 요소들은 열거형 타입의 인스턴스니까, 여기서는 Beverage 값들이죠. 위 예시는 케이스가 몇 개인지 세는 코드였고, 아래 예시는 for-in 반복문으로 모든 케이스를 순회해요.
for beverage in Beverage.allCases {
print(beverage)
}
// coffee
// tea
// juice
위 예시들에 쓰인 문법은 열거형이 CaseIterable 프로토콜을 따른다는 걸 표시합니다. 프로토콜에 대한 내용은 <doc:Protocols>에서 다뤄요.
연관 값 (Associated Values)
앞 절의 예시들은 열거형의 케이스가 그 자체로 정의되고 타이핑된 값이라는 걸 보여 줬어요. 상수나 변수를 Planet.earth로 설정하고, 나중에 이 값을 검사할 수 있는 거죠. 그런데 이 케이스 값 옆에 다른 타입의 값들을 함께 저장할 수 있으면 더 편리할 때가 있어요. 이 추가 정보를 연관 값(associated value) 이라고 부르고, 코드에서 그 케이스를 값으로 사용할 때마다 달라질 수 있습니다.
스위프트 열거형은 주어진 어떤 타입의 연관 값을 저장하도록 정의할 수 있고, 필요하다면 열거형의 케이스마다 값 타입을 다르게 할 수도 있어요. 이런 열거형은 다른 프로그래밍 언어에서 discriminated union, tagged union, 또는 variant라고 불리는 것과 비슷합니다.
예를 들어 재고 추적 시스템이 두 가지 종류의 바코드로 제품을 추적해야 한다고 가정해 볼게요. 어떤 제품에는 UPC 형식의 1D 바코드가 붙어 있는데, 이건 0부터 9까지의 숫자를 써요. 각 바코드는 숫자 시스템 자릿수 하나, 제조사 코드 자릿수 다섯 개, 제품 코드 자릿수 다섯 개로 이뤄지고, 마지막에 코드가 올바르게 스캔됐는지 확인하는 체크 자릿수가 따라옵니다. 다른 제품에는 QR 코드 형식의 2D 바코드가 붙어 있는데, ISO 8859-1 문자라면 어떤 거든 쓸 수 있고 최대 2,953자까지 문자열을 인코딩할 수 있어요.
재고 추적 시스템 입장에서는 UPC 바코드를 정수 네 개짜리 튜플로, QR 코드 바코드를 길이 제한 없는 문자열로 저장하는 게 편리합니다.
스위프트에서 두 종류 모두의 제품 바코드를 정의하는 열거형은 이렇게 생겼어요.
enum Barcode {
case upc(Int, Int, Int, Int)
case qrCode(String)
}
이건 이렇게 읽을 수 있어요: "Barcode라는 열거형 타입을 정의한다. 이 타입은 연관 값으로 (Int, Int, Int, Int) 타입을 가진 upc 값이 될 수도 있고, String 타입을 가진 qrCode 값이 될 수도 있다."
이 정의는 실제 Int나 String 값을 제공하지는 않아요. 그저 Barcode 상수와 변수가 Barcode.upc나 Barcode.qrCode와 같을 때 저장할 수 있는 연관 값의 타입만 정의하는 거죠. 그러면 두 종류 중 아무 걸로든 새 바코드를 만들 수 있어요.
var productBarcode = Barcode.upc(8, 85909, 51226, 3)
이 예시는 productBarcode라는 새 변수를 만들고, 연관 튜플 값이 (8, 85909, 51226, 3)인 Barcode.upc 값을 할당해요.
같은 제품에 다른 종류의 바코드를 할당할 수도 있습니다.
productBarcode = .qrCode("ABCDEFGHIJKLMNOP")
이 시점부터 원래의 Barcode.upc와 그 정수 값들은 새 Barcode.qrCode와 그 문자열 값으로 교체됩니다. Barcode 타입의 상수와 변수는 .upc나 .qrCode(그리고 그 연관 값) 중 하나를 저장할 수 있지만, 어느 한 시점에 둘 중 하나만 저장할 수 있어요.
switch 문으로 서로 다른 바코드 타입을 검사할 수 있는데, 앞에서 본 예시와 비슷해요. 다만 이번에는 연관 값이 switch 문의 일부로 함께 추출됩니다. 각 연관 값은 switch 케이스 본문 안에서 쓰려고 let 접두사를 붙여 상수로, 또는 var 접두사를 붙여 변수로 추출할 수 있어요.
switch productBarcode {
case .upc(let numberSystem, let manufacturer, let product, let check):
print("UPC: \(numberSystem), \(manufacturer), \(product), \(check).")
case .qrCode(let productCode):
print("QR code: \(productCode).")
}
// Prints "QR code: ABCDEFGHIJKLMNOP."
어떤 열거형 케이스의 연관 값이 전부 상수로 추출되거나 전부 변수로 추출된다면, 간결하게 케이스 이름 앞에 let이나 var 하나만 붙일 수 있어요.
switch productBarcode {
case let .upc(numberSystem, manufacturer, product, check):
print("UPC : \(numberSystem), \(manufacturer), \(product), \(check).")
case let .qrCode(productCode):
print("QR code: \(productCode).")
}
// Prints "QR code: ABCDEFGHIJKLMNOP."
열거형의 케이스 하나만 매칭할 때 — 예를 들어 그 연관 값을 추출하려고 할 때 — 전체 switch 문을 쓰는 대신 if-case 문을 사용할 수도 있어요. 이렇게 생겼습니다.
if case .qrCode(let productCode) = productBarcode {
print("QR code: \(productCode).")
}
앞서 본 switch 문에서처럼, 여기서도 productBarcode 변수가 .qrCode(let productCode) 패턴과 매칭돼요. switch 케이스에서와 마찬가지로 let을 쓰면 연관 값을 상수로 추출합니다. if-case 문에 대한 자세한 내용은 <doc:ControlFlow#Patterns>에서 볼 수 있어요.
원시 값 (Raw Values)
<doc:Enumerations#Associated-Values>의 바코드 예시는 열거형 케이스가 서로 다른 타입의 연관 값을 저장한다고 선언할 수 있다는 걸 보여 줬어요. 연관 값의 대안으로, 열거형 케이스는 모두 같은 타입인 기본 값(이걸 원시 값(raw value) 이라고 불러요)으로 미리 채워질 수도 있습니다.
이름이 붙은 열거형 케이스 옆에 원시 ASCII 값을 저장하는 예시를 볼게요.
enum ASCIIControlCharacter: Character {
case tab = "\t"
case lineFeed = "\n"
case carriageReturn = "\r"
}
여기서 ASCIIControlCharacter라는 열거형의 원시 값은 Character 타입으로 정의되고, 비교적 흔한 ASCII 제어 문자 몇 개로 설정되네요. Character 값에 대한 설명은 <doc:StringsAndCharacters>에서 다룹니다.
원시 값은 문자열, 문자, 또는 정수/부동소수점 숫자 타입 중 하나가 될 수 있어요. 각 원시 값은 그 열거형 선언 안에서 반드시 유일해야 합니다.
연관 값과 원시 값 모두 열거형에 추가 값을 주는 데 쓸 수 있지만, 둘의 차이를 이해하는 게 중요해요. 원시 값은 코드에서 그 열거형 케이스를 정의할 때 정합니다 — 위의 세 ASCII 코드처럼요. 특정 열거형 케이스의 원시 값은 항상 똑같습니다. 반면 연관 값은 열거형 케이스 중 하나를 사용해 새 상수나 변수를 만들 때 정하고, 그때마다 다른 값을 고를 수 있어요.
암묵적으로 할당되는 원시 값 (Implicitly Assigned Raw Values)
정수나 문자열 원시 값을 저장하는 열거형을 다룰 때는, 케이스마다 원시 값을 명시적으로 할당하지 않아도 됩니다. 할당하지 않으면 스위프트가 알아서 값을 채워 줘요.
예를 들어 원시 값으로 정수를 쓰면, 각 케이스의 암묵적 값은 이전 케이스보다 1 큰 값이에요. 첫 번째 케이스에 값이 설정되지 않았다면 그 값은 0입니다.
아래 열거형은 아까 본 Planet 열거형을 다듬은 것으로, 각 행성이 태양에서 떨어진 순서를 나타내는 정수 원시 값을 가져요.
enum Planet: Int {
case mercury = 1, venus, earth, mars, jupiter, saturn, uranus, neptune
}
위 예시에서 Planet.mercury는 명시적 원시 값 1을 갖고, Planet.venus는 암묵적 원시 값 2를 가지는 식이에요.
원시 값으로 문자열을 쓰면, 각 케이스의 암묵적 값은 그 케이스 이름의 텍스트 그대로입니다.
아래 열거형은 아까 본 CompassPoint 열거형을 다듬은 것으로, 각 방향의 이름을 나타내는 문자열 원시 값을 가져요.
enum CompassPoint: String {
case north, south, east, west
}
위 예시에서 CompassPoint.south는 암묵적 원시 값 "south"를 가지는 식이에요.
열거형 케이스의 원시 값은 rawValue 프로퍼티로 접근할 수 있습니다.
let earthsOrder = Planet.earth.rawValue
// earthsOrder is 3
let sunsetDirection = CompassPoint.west.rawValue
// sunsetDirection is "west"
원시 값에서 초기화하기 (Initializing from a Raw Value)
원시 값 타입으로 열거형을 정의하면, 그 열거형은 원시 값 타입의 값을 받아(rawValue라는 파라미터로) 열거형 케이스 또는 nil을 돌려주는 이니셜라이저를 자동으로 받아요. 이 이니셜라이저를 쓰면 열거형의 새 인스턴스를 만들어 보려고 시도할 수 있습니다.
이 예시는 원시 값 7에서 천왕성(Uranus)을 찾아냅니다.
let possiblePlanet = Planet(rawValue: 7)
// possiblePlanet is of type Planet? and equals Planet.uranus
하지만 모든 Int 값이 행성과 매칭되지는 않아요. 그래서 이 원시 값 이니셜라이저는 항상 옵셔널 열거형 케이스를 돌려줍니다. 위 예시에서 possiblePlanet은 Planet? 타입, 즉 "옵셔널 Planet"이에요.
참고: 원시 값 이니셜라이저는 failable(실패 가능한) 이니셜라이저예요. 모든 원시 값이 열거형 케이스를 돌려주는 건 아니기 때문입니다. 자세한 내용은
<doc:Declarations#Failable-Initializers>에서 다뤄요.
위치 11인 행성을 찾으려고 하면, 원시 값 이니셜라이저가 돌려주는 옵셔널 Planet 값은 nil이 됩니다.
let positionToFind = 11
if let somePlanet = Planet(rawValue: positionToFind) {
switch somePlanet {
case .earth:
print("Mostly harmless")
default:
print("Not a safe place for humans")
}
} else {
print("There isn't a planet at position \(positionToFind)")
}
// Prints "There isn't a planet at position 11".
이 예시는 옵셔널 바인딩을 사용해 원시 값 11의 행성에 접근하려고 해요. if let somePlanet = Planet(rawValue: 11) 구문은 옵셔널 Planet을 만들고, 그 옵셔널 Planet 값을 가져올 수 있으면 somePlanet에 그 값을 설정합니다. 이 경우 위치 11의 행성은 존재하지 않으니까, 대신 else 분기가 실행됩니다.
재귀 열거형 (Recursive Enumerations)
재귀 열거형(recursive enumeration) 은 한 개 이상의 열거형 케이스가 연관 값으로 그 열거형 자신의 또 다른 인스턴스를 갖는 열거형이에요. 재귀적인 열거형 케이스임을 나타낼 때는 그 앞에 indirect를 쓰는데, 이렇게 하면 컴파일러가 필요한 간접(indirection) 계층을 삽입하라고 지시됩니다.
예를 들어 단순한 산술 표현식을 저장하는 열거형이 여기 있어요.
enum ArithmeticExpression {
case number(Int)
indirect case addition(ArithmeticExpression, ArithmeticExpression)
indirect case multiplication(ArithmeticExpression, ArithmeticExpression)
}
열거형의 시작 부분에 indirect를 써서, 연관 값을 가진 모든 열거형 케이스에 간접(indirection)을 적용할 수도 있어요.
indirect enum ArithmeticExpression {
case number(Int)
case addition(ArithmeticExpression, ArithmeticExpression)
case multiplication(ArithmeticExpression, ArithmeticExpression)
}
이 열거형은 세 종류의 산술 표현식을 저장할 수 있어요: 단순한 숫자, 두 표현식의 덧셈, 두 표현식의 곱셈. addition과 multiplication 케이스는 연관 값으로 또 다른 산술 표현식을 갖는데, 이 연관 값 덕분에 표현식을 중첩할 수 있습니다. 예를 들어 (5 + 4) * 2라는 표현식은 곱셈의 오른쪽에 숫자가 있고, 왼쪽에는 또 다른 표현식이 있어요. 데이터가 중첩되어 있으니, 이 데이터를 저장하는 열거형도 중첩을 지원해야 합니다 — 즉 열거형이 재귀적이어야 한다는 뜻이에요. 아래 코드는 (5 + 4) * 2를 위해 ArithmeticExpression 재귀 열거형을 만드는 모습을 보여 줍니다.
let five = ArithmeticExpression.number(5)
let four = ArithmeticExpression.number(4)
let sum = ArithmeticExpression.addition(five, four)
let product = ArithmeticExpression.multiplication(sum, ArithmeticExpression.number(2))
재귀적인 구조를 가진 데이터를 다룰 때는 재귀 함수가 가장 직접적인 방법이에요. 예를 들어 산술 표현식을 계산하는 함수가 여기 있습니다.
func evaluate(_ expression: ArithmeticExpression) -> Int {
switch expression {
case let .number(value):
return value
case let .addition(left, right):
return evaluate(left) + evaluate(right)
case let .multiplication(left, right):
return evaluate(left) * evaluate(right)
}
}
print(evaluate(product))
// Prints "18".
이 함수는 단순한 숫자를 연관 값을 그대로 돌려주는 방식으로 계산하고, 덧셈이나 곱셈은 왼쪽 표현식과 오른쪽 표현식을 각각 계산한 뒤 더하거나 곱하는 방식으로 계산해요.
더 알아보기
- 프로퍼티(Properties): 열거형에 추가 정보를 제공하는 계산 프로퍼티
- 메서드(Methods): 열거형이 나타내는 값과 관련된 기능을 제공하는 인스턴스 메서드
- 초기화(Initialization): 열거형의 초기 케이스 값을 제공하는 이니셜라이저
- 익스텐션(Extensions): 원래 구현을 넘어 열거형 기능 확장하기
- 프로토콜(Protocols): 표준 기능을 제공하기 위해 프로토콜 채택하기
- 제어 흐름(Control Flow):
switch문의 exhaustive 규칙과if-case문