$ sudo teach IT

Зачем нужен закрытый список вариантов

Представьте, что вы храните статус заказа строкой: "new", "paid", "shipped". Компилятор не мешает написать "paidd" с опечаткой или сравнить статус с числом — строка ничего не проверяет, любое значение для неё законно. А по смыслу вариантов на самом деле конечное число, и хочется, чтобы компилятор сам следил: значение — одно из перечисленных, и никакое другое.

Для этого в Swift есть перечисления — тип enum, который описывает закрытый список вариантов, называемых кейсами (case). Переменная такого типа не может принять значение вне списка, а switch по перечислению компилятор проверяет на полноту: забыли разобрать вариант — ошибка компиляции, а не сюрприз в продакшене.

Объявление и простой enum

enum OrderStatus {
    case new
    case paid
    case shipped
    case cancelled
}

var status = OrderStatus.new
status = .paid // тип уже известен, точку можно не повторять

Кейсы можно перечислить в одну строку через запятую: case new, paid, shipped, cancelled. Как только компилятор знает тип переменной, писать полное имя OrderStatus.paid не обязательно — короткая форма .paid означает то же самое.

switch по перечислению

Главный способ работать со значением перечисления — switch. Он обязан разобрать все кейсы:

func describe(_ status: OrderStatus) -> String {
    switch status {
    case .new:
        return "Заказ создан"
    case .paid:
        return "Оплата получена"
    case .shipped:
        return "Заказ отправлен"
    case .cancelled:
        return "Заказ отменён"
    }
}

Уберите один case — компилятор откажется собирать код, пока вы не разберёте его тоже (или не добавите default). Это и есть исчерпывающий switch: перечисление гарантирует, что вы не забыли ни одну ветку логики.

Raw values: значение под капотом

Иногда у кейса удобно иметь готовое представление — число или строку, которые с ним хранятся всегда. Это называется raw value:

enum Direction: Int {
    case north = 0
    case east = 1
    case south = 2
    case west = 3
}

enum Weekday: String {
    case monday = "Понедельник"
    case tuesday = "Вторник"
}

let code = Direction.east.rawValue // 1
let label = Weekday.monday.rawValue // "Понедельник"

Если тип raw value — Int, следующий кейс без явного значения продолжает счёт с предыдущего плюс один, а первый по умолчанию начинается с нуля. Для String без явного значения имя кейса становится строкой самим по себе.

Обратная операция — получить кейс по значению — возвращает опционал, потому что значения может не быть ни у одного кейса:

let parsed = Direction(rawValue: 2) // Optional(.south)
let bad = Direction(rawValue: 9)    // nil

Важно не путать rawValue с чем-то, что перечисление хранит дополнительно к кейсу: это именно представление самого кейса, одно и то же для всех значений этого варианта.

Associated values: данные разной формы для разных кейсов

Raw value одинаков для всех значений одного кейса. Но иногда каждому конкретному значению нужны свои, отдельные данные — и у разных кейсов эти данные разной формы. Для этого у кейса можно объявить associated values, как у функции — параметры:

enum PaymentMethod {
    case cash
    case card(number: String, expiry: String)
    case transfer(account: String)
}

let method = PaymentMethod.card(number: "1234", expiry: "12/27")

Достать эти данные можно через switch с привязкой значений (value binding):

switch method {
case .cash:
    print("Наличными")
case .card(let number, let expiry):
    print("Карта \(number), до \(expiry)")
case .transfer(let account):
    print("Перевод на \(account)")
}

Ключевая разница с raw value: у raw value значение прибито к кейсу заранее и одно на всех (Direction.east всегда 1), а associated values задаются в момент создания конкретного значения и у двух значений одного кейса могут отличаться (card("1234", ...) и card("5678", ...) — оба .card, но с разными числами внутри). У перечисления может быть либо raw value, либо associated values у кейсов — не то и другое сразу.

CaseIterable: перебрать все варианты

Когда у перечисления нет associated values, часто хочется получить список всех кейсов сразу — например, для меню выбора. Для этого перечисление объявляют соответствующим протоколу CaseIterable, и компилятор сам добавляет статическое свойство allCases:

enum Direction: Int, CaseIterable {
    case north = 0
    case east = 1
    case south = 2
    case west = 3
}

for d in Direction.allCases {
    print(d)
}
print(Direction.allCases.count) // 4

Подробно про протоколы и как они устроены будет отдельный разговор дальше в курсе — здесь достаточно знать, что CaseIterable просто просят у компилятора, дописав его через запятую после типа raw value (или сразу после имени, если raw value нет).

Методы и вычисляемые свойства у enum

Перечисление — полноценный тип, у него могут быть свои методы и вычисляемые свойства, как у структуры:

enum Direction: Int, CaseIterable {
    case north = 0, east = 1, south = 2, west = 3

    var opposite: Direction {
        switch self {
        case .north: return .south
        case .south: return .north
        case .east: return .west
        case .west: return .east
        }
    }
}

let back = Direction.north.opposite // .south

indirect: перечисление, которое ссылается само на себя

Иногда значение перечисления по смыслу должно содержать другое значение того же перечисления — например, узел дерева или математическое выражение. Просто так это не скомпилируется: размер значения enum не может зависеть от самого себя. Ключевое слово indirect просит компилятор хранить такой кейс через ссылку, а не встраивать напрямую:

indirect enum ArithmeticExpression {
    case number(Int)
    case addition(ArithmeticExpression, ArithmeticExpression)
}

let expr = ArithmeticExpression.addition(.number(2), .number(3))

indirect можно поставить перед всем enum, если так устроены несколько кейсов, или только перед конкретным кейсом, если рекурсивный только он. На этом уроке достаточно понимать идею — подробно с рекурсивными структурами данных вы встретитесь позже.

Частые ошибки

  • Ждать, что у перечисления одновременно будут и raw value, и associated values у кейсов — так нельзя, это взаимоисключающие способы задать данные.
  • Путать rawValue с associated value: Direction.east.rawValue — это заранее заданное число 1, одно и то же для всех .east, а не что-то, что вы передали при создании.
  • Забывать, что инициализатор Тип(rawValue:) возвращает опционал: значения с таким raw value может не быть ни у одного кейса, поэтому результат нужно разворачивать.
  • Оставлять switch неполным и добавлять default просто чтобы компилятор замолчал — так теряется главная польза перечисления: гарантия, что новый кейс не забудут разобрать нигде.
  • Пытаться сравнить значения с associated values оператором == без явной поддержки сравнения — компилятор не выводит его сам, если данные внутри кейсов не позволяют это сделать автоматически.

Резюме

  • enum задаёт закрытый список вариантов — кейсов; значение может быть только одним из них.
  • switch по перечислению исчерпывающий: компилятор заставляет разобрать каждый кейс.
  • Raw value — одно и то же значение (число или строка), навсегда закреплённое за кейсом; получить кейс по значению можно через Тип(rawValue:), результат — опционал.
  • Associated values — данные, которые задаются отдельно для каждого конкретного значения кейса; достаются через switch с привязкой.
  • CaseIterable даёт готовый список всех кейсов через allCases.
  • У перечисления могут быть свои методы и вычисляемые свойства, как у структуры.
  • indirect позволяет кейсу ссылаться на значение того же перечисления.

Проверьте себя

4 вопроса

Направление по компасу

Есть перечисление направлений света с raw value типа Int: north = 0, east = 1, south = 2, west = 3 — назовите его Direction.

Реализуйте функцию opposite(_ direction: Direction) -> Direction, которая возвращает противоположное направление (север и юг меняются местами, восток и запад — тоже).

Также реализуйте функцию direction(fromDegrees degrees: Int) -> Direction?, которая по числу градусов возвращает направление: 0 — север, 90 — восток, 180 — юг, 270 — запад. Для любого другого значения градусов верните nil.

Калькулятор фигур

Объявите перечисление Shape с двумя кейсами и associated values: circle(radius: Double) и rectangle(width: Double, height: Double).

Реализуйте функцию area(_ shape: Shape) -> Double, которая возвращает площадь фигуры (площадь круга — 3.14159 * radius * radius, площадь прямоугольника — width * height).

Также реализуйте функцию describe(_ shape: Shape) -> String, которая возвращает "круг" для circle и "прямоугольник" для rectangle, независимо от размеров.