Enums + Associated Values = Swift

  • Tutorial

Swift — значит быстрый. Быстрый — значит понятный, простой. Но достичь простоты и понятности непросто: сейчас в Swift скорость компиляции так себе, да и некоторые моменты языка вызывают вопросы. Тем не менее возможность перечислений (enum'ов), про которую я расскажу (associated values — присоединяемые значения) — одна из самых крутых. Она позволяет сократить код, сделать его понятнее и надёжнее.




Исходная задача


Представим, что нужно описать структуру вариантов оплаты: наличными, картой, по подарочному сертификату, пейпалом. У каждого способа оплаты могут быть свои параметры. Вариантов реализации при таких слабо определенных условиях — бесконечное количество, что-то можно сгруппировать, что-то переобозначить. Не стоит придираться, эта структура тут живёт для примера.


class PaymentWithClass {
    // true, значит платим наличными, а false нужен, чтобы init() не писать
    var isCash: Bool = false

    // .some, значит платим картой, номер такой-то, параметры такие-то
    var cardNumber: String?
    var cardHolderName: String?
    var cardExpirationDate: String?

    // .some, значит платим подарочным сертификатом
    var giftCertificateNumber: String?

    // тут какой-то идентификатор и код авторизации (что бы это ни значило)
    var paypalTransactionId: String?
    var paypalTransactionAuthCode: String?
}

(Что-то много опшналов получилось)


Работать с таким классом тяжело. Swift мешает, ведь в отличие от многих других языков, он заставит обработать все опшналы (например, if let или guard let). Будет много лишнего кода, который спрячет бизнес-логику.


Неприятно такое и верифицировать. Представить себе код, проверяющий валидность этого класса ещё можно, а вот писать такое — уже совсем не хочется.


Структуры!


Вспомним, что в Swift есть value-типы, которые называются структуры. Говорят, их хорошо использовать для такого рода модельных описаний. Попробуем заодно разбить предыдущий код на несколько структур, соответствующих разным типам оплаты.


struct PaymentWithStructs {
    struct Cash {}

    struct Card {
        var number: String
        var holderName: String
        var expirationDate: String
    }

    struct GiftCertificate {
        var number: String
    }

    struct PayPal {
        var transactionId: String
        var transactionAuthCode: String?
    }

    var cash: Cash?
    var card: Card?
    var giftCertificate: GiftCertificate?
    var payPal: PayPal?
}

Получилось лучше. Корректность отдельных типов оплаты проверяется средствами самого языка (например, видно, что авторизационный код PayPal может отсутствовать, а вот для карты все поля обязательны), нам остаётся провалидировать только что одно (и только одно) из полей cash, card, giftCertificate, payPal — не нулевое.


Удобно? Вполне. В этом месте во многих языках можно поставить точку и считать работу выполненной. Но не в Swift.


Enum'ы. Ассоциированные значения


Свифт, как и любой современный язык, предоставляет возможность создавать типизированные перечисления (enum'ы). Их удобно использовать, когда нужно описать, что поле может быть одним из нескольких, заранее определенных, значений или вставить в switch, чтобы проконтролировать, что все возможные варианты перебраны. По идее в нашем примере максимум, что можно сделать — это вставить тип оплаты, чтобы ещё точнее валидировать структуру:


struct PaymentWithStructs {
    enum Kind {
        case cash
        case card
        case giftCertificate
        case payPal
    }

    struct Card {
        var number: String
        var holderName: String
        var expirationDate: String
    }

    struct GiftCertificate {
        var number: String
    }

    struct PayPal {
        var transactionId: String
        var transactionAuthCode: String?
    }

    var kind: Kind

    var card: Card?
    var giftCertificate: GiftCertificate?
    var payPal: PayPal?
}

Заметьте, что структура Cash пропала, она пустая и висела только для индикации типа, который мы теперь ввели явно. Стало лучше? Да, проще стало проверять, какой тип оплаты мы используем. Он прописан явно, не нужно анализировать, какое поле не nil.


Следующий шаг — попробовать как-то избавиться от необходимости отдельных опшналов для каждого типа. Было бы круто, если бы существовала возможность привязать к типу соответствующие структуры. К карте — номер и владельца, к PayPal — идентификатор транзакции и так далее.


Именно для этого в Swift есть ассоциированные значения (associated values, не путайте с associated types, которые используются в протоколах и совсем не про то). Записываются они вот так:


enum PaymentWithEnums {
    case cash
    case card(
        number: String,
        holderName: String,
        expirationDate: String
    )
    case giftCertificate(
        number: String
    )
    case payPal(
        transactionId: String,
        transactionAuthCode: String?
    )
}

Самый важный момент — точно определенный тип. Сразу видно, что PaymentWithEnums может принимать ровно одно из четырёх значений, и у каждого значения могут быть (или не быть) как дата или transactionAuthCode определённые параметры. Физически нельзя проставить параметры сразу и для карты и для подарочного сертификата, как это можно было сделать в варианте с классами или структурами.


Получается, что предыдущие варианты требуют дополнительных проверок. Также была возможность забыть обработать какой-нибудь из вариантов оплаты, особенно, если появятся новые. Enum'ы исключают все эти проблемы. Если добавится новый case, следующая перекомпиляция потребует добавить его во все switch'и. Никаких опшналов кроме действительно необходимых.


Пользоваться такими сложными enum'ами можно как обычно:


if case .cash = payment {
    // сделать что-то специфичное для оплаты наличными
}

Параметры получаются при помощи паттерн-матчинга:


if case .giftCertificate(let number) = payment {
    print("O_o подарочный сертификат! Номер: \(number)")
}

А перебрать все варианты можно свичом:


switch payment {
    case .cash:
        print("Оффлайновые деньги!")
    case .card(let number, let holderName, let expirationDate):
        let last4 = String(number.characters.suffix(4))
        print("Хехе, карточка! Последние цифры \(last4), хозяин: \(holderName)!")
    case .giftCertificate(let number):
        print("O_o подарочный сертификат! Номер: \(number)")
    case .payPal(let transactionId, let transactionAuthCode):
        print("Пейпалом платим! Транзакция: \(transactionId)")
}

В качестве упражнения можно написать похожий код для варианта с классом/структурой. Чтобы упражнение было полным, нужно не полениться и обработать все необходимые варианты, включая ошибочные.


Кайф. Swift. Ещё бы компилился побыстрее, и вообще будет счастье. :-)


Немного граблей


Enum'ы — не панацея. Вот несколько моментов, которые вам могут встретиться.


Во-первых, enum'ы не могут содержать хранимые поля. Если мы захотим добавить в PaymentWithEnums, к примеру, дату платежа (одно и то же поле для всех вариантов оплаты), то получим ошибку: enums may not contain stored properties. Как же быть? Можно положить дату в каждый кейс enum'а. Можно сделать структуру и положить туда enum и дату.


Во-вторых, если обычные enum'ы можно сравнивать оператором == (он синтезируется автоматически), то как только появляются ассоциированные значения, возможность сравнения «пропадает». Поправить это можно легко, поддержать протокол Equatable. Впрочем даже после этого сравнивать будет неудобно, так как нельзя просто написать payment == PaymentWithEnums.giftCertificate, потребуется создать правую часть целиком: PaymentWithEnums.giftCertificate(number: ""). Гораздо удобнее в таком случае создавать специальные методы, возвращающие Bool (isGiftCertificate(_ payment: PaymentWithEnums) -> Bool), и перенести туда if case. Если же нужно сравнивать несколько значений, то, возможно, switch будет удобнее.

Redmadrobot
127,13
№1 в разработке цифровых решений для бизнеса
Поделиться публикацией

Комментарии 17

    0
    Непонятно стоило ли объединять enum'ы и variant'ы в одну сущность.
    ИМХО, стоило эти вещи оставить отдельно: перечисления — как низкоуровневая конструкция, совместимая с int, варианты — как алгебраический тип данных («тип-сумма») со всеми фичами, присущими такому типу.
      +2
      Почему перечисления предполагается совмещать именно с int'ом? Какие недостатки у Свифтового подхода?
        –1
        В C и C++ так делают, enum там — это int, даже по стандарту, кажется, так. Наверное недостаток в том что свифтовский enum нельзя приводить к int, ведь там могут быть эти самые «Associated Values».
          +1
          В C и C++ так делают,

          И? Надо смотреть на преимущества, а не просто повторять. Иначе зачем вообще нужен Swift — можно дальше на С или С++ писать.


          Ну и в С++ "по стандарту" не (обязательно) int — это деталь реализации. Более того, тип можно и самому задать.


          Насчёт невозможности приводить — это далеко не всегда нужно. За Swift не скажу, зато могу сказать за Rust, где enumы похожим образом выглядят. Там можно указать repr© — тогда (если это возможно) такой enum будет совместим с C.

            +1
            Я сам в основном согласен и сам знаю как в Rust. Просто растолковал точку зрения стартера ветки, и обозначил это словом «наверное».
          0
          Ну потому что перечисления это более примитивная сущность чем варианты. Также потому что некоторые типы все-же должны быть POD, например для работы с бинарными форматами или сетевыми протоколами.
          Приведу вот такой пример. Можно отказаться от разнообразных числовых типов (int, short, long, unsigned int, float, double и т.д.) и перейти к единому числовому типу, скажем «numeric». Неограниченной длины, любой точности. Возможно это будет удобно. Но это будет сущность более сложная чем базовые типы, и безусловно, отказ от базовых типов уменьшит хакерскую мощь языка. Но в то же время добавление высокоуровневых вариантов с сохранением низкоуровневых перечислений не только не уменьшит, но и увеличит хакерские возможности языка программирования.
        0
        а как же паттерны, все дела?
          0
          Какие именно дела имеются в виду?
            0
            в паттернах не очень разбираюсь, но наверное — Стратегия
              0
              Цель статьи — не показать способы решения задачи, а продемонстрировать одну из возможностей языка.

              Про Стратегию. Чуть ниже обсуждают похожее решение. Конечно же, можно использовать и её, ни в коем случае не говорю, что нужно только так и описывать платёжные модели. Это просто пример. :-)
          0
          А при чем здесь блог .NET? Или я чего-то не понимаю?
            0
            Упс :) Спасибо!
            0
            Прошу прощение, если вопрос глупый, на Swift не программировал. А нельзя просто сделать базовый класс Payment с абстрактным методом Execute() (или другими общими методами), унаследовать от него CashPayment, CardPayment, GiftPayment, PayPalPayment и любое количество других вариантов оплаты?
              +1
              Не автор и тоже никогда не использовал Swift, но попробую ответить, т.к. стыкался с подобным. Если сделать по предложенному вами варианту, и если этот код является не частью закрытого энтерпрайз-софта, а, к примеру, библиотекой с открытым АПИ, то сторонний код, использующий библиотеку, сможет сделать свою реализацию базового абстрактного класса Payment (или интерфейса IPayment, не важно), которая (своя реализация) может поломать осн. бизнес-логику. Т.е. её можно будет подсунуть вместо предопределённых {CashPayment, CardPayment, GiftPayment, PayPalPayment} в какой-то метод библиотеки, принимающий на вход Payment, и поломать там что-то внутри.
                +1
                Да так можно сделать на Swift, но начиная с Swift 3 описанная проблема тоже решается — появилось дополнительное ключевое слово `open`, которое отличается от `public`.
                Все тот-же пример:
                Создается библиотека с открытым АПИ. В ней есть класс Payment и в ней реализованы: CashPayment, CardPayment, GiftPayment, PayPalPayment.
                Если разработчик библиотеки хочет дать возможность сторонним разработчикам наследоваться от всех этих классов, то их стоит объявить `open`.
                Если только от Payment то все будут `public`, а Payment `open`.
                И чтобы закрыть полностью данную возможность, надо все объявить `public`.

                То есть `public` позволяет наследовать внутри библиотеки, но не позволяет наследоваться за пределами библиотеки, а `open` позволяет наследоваться и внутри и за пределами.

                Поэтому описанная проблема, имеет решение на Swift 3.0
                0

                Я думаю, пример в статье стоит рассматривать больше с «клиентской» стороны.


                В предполагаемом мобильном приложении на языке Swift не будет никакой бизнес-логики, типа «Провести оплату». Т.е. наверняка будет какой-то вызов API для проведения оплаты, чтобы сервер провёл эту операцию, но реализовывать метод Execute() на стороне клиента не придётся.


                На стороне клиента придётся реализовывать ленту с историей проведенных операций оплаты, в которой должны содержаться сущности разного рода: Cash Payment, Card Payment, PayPal Payment и другие. И в данной ситуации важным является именно то, как организовать модель данных, полученных от сервера, чтобы их можно было единообразно отобразить в единой ленте.


                И перечисления в данной ситуации играют роль логических ограничений. Например, у сущности PaymentWithEnums физически не может быть воплощения, в котором будет содержаться и срок истечения карты expirationDate, и transactionAuthCode.

                0
                Самая большой напряг который можно встретить с перечеслениями и ассоциативными значениями это отсутствие опыта проектирования потока иммутабельных данных, то есть если вам нужно чтото мутировать надо всю структуру данных пересобрать с новым значением какого то поля

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое