ХабрПостыНовостиМегапосты?

Фантомные типы в Swift

Не каждый язык со статической системой типов обладает такой строгой типобезопасностью, как Swift. Это стало возможным благодаря таким особенностям Swift, как фантомные типы (phantom types), расширения универсальных типов и перечисления со связанными типами. На этой неделе мы узнаем, как использовать фантомные типы для создания типобезопасных API.

Основы

Фантомный тип — это универсальный тип, который объявляется, но никогда не используется внутри типа, в котором он объявлен. Обычно он используется как общее ограничение для создания более надежного и безопасного API. Рассмотрим простой пример.

struct Identifier<Holder> {
    let value: Int
}

В приведенном выше примере у нас есть структура Identifier с объявленным универсальным типом Holder. Как видите, мы не используем тип Holder внутри типа Identifier. Поэтому этот тип называют фантомным. Теперь давайте подумаем о преимуществах использования подобных типов.

struct User {
    let id: Identifier<Self>
}

struct Product {
    let id: Identifier<Self>
}

let product = Product(id: .init(value: 1))
let user = User(id: .init(value: 1))

user.id == product.id

Создадим типы User (пользователь) и Product (продукт), воспользовавшись ранее созданной структурой Identifier. Установим значение идентификатора равным 1 для новых типов user и product. Но если мы попытаемся их сравнить, компилятор Swift выдаст ошибку:

Двоичный оператор «==» не может применяться к операндам типа Identifier-User и Identifier-Product.

И это здорово, поскольку нам не нужно сравнивать идентификаторы «пользователя» и «продукта». Мы можем это сделать только случайно. Благодаря фантомному типу компилятор Swift не позволяет нам смешивать эти идентификаторы и распознает их как совершенно разные типы. Вот еще один пример, когда компилятор Swift не позволяет нам смешивать идентификаторы.

func fetch(_ product: Identifier<Product>) -> Product? {
    // return product by id
}

fetch(user.id)

Типобезопасность в HealthKit

Мы изучили основы фантомных типов. Теперь мы можем перейти к более сложным примерам. Я создал пару приложений для поддержания здоровья, которые используют HealthKit для хранения и запроса данных о состоянии пользователя от Apple Watch. Рассмотрим типичный пример кода, получающий данные из приложения Apple Health.

import HealthKit

let store = HKHealthStore()
let bodyMass = HKQuantityType.quantityType(
    forIdentifier: HKQuantityTypeIdentifier.bodyMass
)!
let query = HKStatisticsQuery(
    quantityType: bodyMass,
    quantitySamplePredicate: nil,
    options: .discreteAverage
) { _, statistics, _ in
    let average = statistics?.averageQuantity()
    let mass = average?.doubleValue(for: .meter())
}

store.execute(query)

В приведенном выше примере мы создаем запрос для получения веса пользователя из приложения Apple Health. В обработчике завершения мы пытаемся получить среднее значение и преобразовать его в метры. Как нетрудно догадаться, преобразовать массу тела в метры невозможно, и здесь приложение вылетает. Постараемся решить эту проблему, введя фантомный тип для создания более типобезопасного API.

enum Distance {
    case mile
    case meter
}

enum Mass {
    case pound
    case gram
    case ounce
}

struct Statistics<Unit> {
    let value: Double
}


extension Statistics where Unit == Mass {
    func convert(to unit: Mass) -> Double {

    }
}

extension Statistics where Unit == Distance {
    func convert(to unit: Distance) -> Double {

    }
}

let weight = Statistics<Mass>(value: 75)
weight.convert(to: Distance.meter)

Вот возможное решение для фреймворка HealthKit, где для повышения безопасности API используется фантомный тип. Мы вводим перечисления Mass (масса) и Distance (расстояние), чтобы работать с различными единицами измерения. Как только вы попытаетесь преобразовать массу в расстояние, компилятор Swift остановит вас, отобразив сообщение об ошибке:

Невозможно преобразовать значение типа Distance в ожидаемый тип аргумента Mass.

Заключение

Сегодня мы изучили фантомные типы, одну из моих любимых функций в языке Swift. Очевидно, существует множество возможных применений фантомных типов. Не стесняйтесь рассказать о своих способах повышения безопасности API с помощью фантомных типов. Надеюсь, вам понравится этот пост. Читайте мои посты в Twitter и задавайте вопросы по этой статье. Спасибо за внимание и до следующей недели!


В преддверии старта курса "iOS Developer. Professional", приглашаем всех желающих на бесплатный демо-урок по теме: "Machine Learning в iOS с помощью CoreML и CreateML".

ЗАПИСАТЬСЯ НА ДЕМО-УРОК


@GolovinDS
12.05.2021 19:08 UTC
Первоисточник

Комментарии

@cbelkin
12.05.2021 18:19 UTC
+4
Важно помнить, что подобные решения — это злоупотребление системой типов. Своего рода хак. Любой такой хак представляет собой неочевидный подход для решения критически важных проблем, жертвуя самодокументированием кода.
Возможно в каких-то ситуациях такой подход и может выполнить роль предохранителя, но главным образом фантомные типы используют для разгрузки рантайма от лишних проверок, делегируя эту задачу компилятору.

Кроме этого, в статье приведены неудачные примеры, демонстрирующие отсутствие понимания культуры программирования на Swift.
1) Пример с User и Product. Для сравнения двух бизнес объектов не требуется в явном виде сравнивать их идентификаторы. Достаточно лишь унаследовать протокол Equatable и сравнивать объекты напрямую. При желании можно переопределять дефолтный алгоритм сравнения (если не требуется сравнение по всем полям структуры).
struct User {
  let id: UUID
}

struct Product {
  let id: UUID
}

extension User: Equatable {
    static func == (lhs: User, rhs: User) -> Bool {
        return lhs.id == rhs.id
    }
}

extension Product: Equatable {
    static func == (lhs: Product, rhs: Product) -> Bool {
        return lhs.id == rhs.id
    }
}

let product = Product(id: UUID())
let user = User(id: UUID())

user == product

Проверка произойдет также на уровне компилятора и не даст собрать неверный код.

2) Пример с fetch. Для методов получения бизнес объектов из хранилища, также вместо идентификатора обычно передаётся объект целиком, либо используется специальный класс для задания параметров поиска NSPredicate.
func fetch(_ product: Product) -> Product? {
    // return product
}

fetch(user)

Проверка тоже на уровне компилятора — никаких проблем.

3) Пример с HealthKit. Тут всё просто. Проблема из ничего. Для проверки возможности скастить единицу измерения у этого же класса есть специальный метод is(compatibleWith:) developer.apple.com/documentation/healthkit/hkquantity/1615508-is
let average = statistics?.averageQuantity()
var mass = 0.0
if let qty = average,
   qty.is(compatibleWith: .meter())
{
  mass = qty.doubleValue(for: .meter())
}

Это конечно райнтайм, но зато без замусоривания кода лишними абстракциями.

Поэтому к такому способу жонглирования типами нужно подходить очень избирательно.

И кстати Generics во всем цивилизованном мире зовутся обобщениями (обобщенными типами), а не универсальными типами.
@
13.05.2021 09:12 UTC
0
НЛО прилетело и опубликовало эту надпись здесь
13.05.2021 14:32 UTC
0
К сожалению нет. Введение фантомного типа не допускает динамического изменения свойств родительского типа. В этом и весь смысл. У нас есть два структурно одинаковых объекта, но нам нужно разделить их семантически. Тогда мы декларируем обобщение, которое никак не используется и получаем уже два несовместимых типа.