OkCupid набирает сотрудников для iOS Нажмите здесь, чтобы узнать больше

Введение

Здесь, в OkCupid, мы постоянно стремимся внедрять лучшие в своем классе методы Swift. Одним из таких понятий является стирание типа. Вы можете подумать про себя: Мне никогда в жизни не приходилось использовать стирание шрифта…, но вы ошибаетесь! Мало ли вы знали, что на самом деле это протокол, который стирает конкретный тип любого объекта. Возможно, вы даже видели префикс Any, разбросанный по стандартной библиотеке Swift (AnyHashable, AnyIterator, AnyCollection и т. д.).

Эта мощная конструкция — палка о двух концах. С одной стороны, поскольку все классы соответствуют AnyObject, он действует как универсальная оболочка для всех типов, что может быть полезно, если вам нужно работать с неизвестными типами. С другой стороны, это устраняет зависимость от системы безопасности типов Swift. Они не называют это «безопасностью» без причины! Есть важные преимущества в том, чтобы знать тип объекта, с которым вы работаете, и мы все слишком хорошо знакомы со старым сбоем Objective-C из-за «Нераспознанный селектор, отправленный в экземпляр», когда вы вызываете метод, не реализованный для типа.

Но может ли быть лучшее решение из обоих миров? Здесь, в OkCupid, мы думаем, что ответ — твердое ДА. Мы можем воспользоваться силой стирания типов и применить ее к конкретному варианту использования, чтобы получить преимущества гибких коллекций и при этом сохранить жесткость надежной системы безопасности типов Swift.

Для иллюстрации мы собрали пример проекта, который включает код из нашего приложения, реализующего специализированную форму Type Erasure для нашего контроллера представления Conversations.

https://github.com/OkCupid/swift-type-erasure

Проблема

В этом примере мы собираемся построить модель, представляющую беседу в приложении OkCupid для iOS.

При создании слоя модели для нашего экрана разговоров мы преследовали несколько целей:

  1. Нам нужна была хорошая абстракция, чтобы поддерживать постоянство функции в будущем.
  2. Модель должна быть легко мокабельна для модульного тестирования.
  3. Архитектура должна быть расширяемой, чтобы мы могли расширять логику по мере необходимости.

Создание простой структуры было нашим первым подходом:

struct Conversation {
    let threadId: String
    let isUnread: Bool
    let correspondent: User
    // .. and so on
}

Однако мы быстро поняли, что этот подход не отвечает всем нашим целям. В частности, это не дало нам необходимой расширяемости. Например, если бы OkCupid представил новый тип разговора, нам пришлось бы реорганизовать много кода для его поддержки.

Некоторыми примерами этих «будущих» типов могут быть PersistingConversation, GroupConversation, SecretConversation и т. д. Поскольку Conversation — это конкретная структура, для поддержки нескольких типов диалогов в приложении потребуется значительный рефакторинг.

Поскольку мы знаем, что любой тип диалога будет иметь один и тот же набор основных свойств, давайте абстрагируем конкретный тип с помощью протокола.

Протокол-ориентированный подход

Давайте создадим ConversationProtocol, протокол, который содержит все общие свойства диалога.

protocol ConversationProtocol {
    var threadId: String { get }
    var correspondent: UserProtocol { get }
    var isUnread: Bool { get } // etc..
}

Протокол ConversationProtocol идеально подходит для выражения требований к общему имуществу, но мы также хотим, чтобы в наших разговорах были некоторые общие функциональные возможности. Например, нам нужен способ отличить один разговор от другого. К счастью, для этой цели Swift предоставляет нам протокол Hashable (наследуемый от протокола Equatable).

Нам нужна единая реализация Hashable, которую можно было бы совместно использовать с текущими и будущими конкретными реализациями ConversationProtocol. Однако протокол ( ConversationProtocol) не может реализовать другой протокол ( Hashable). Нам нужен конкретный тип

Вместо этого мы можем предоставить реализацию протокола по умолчанию для другого протокола, используя объявление where в расширении, которое специально применяется к конкретным типам:

extension ConversationProtocol where Self: Hashable {

    func hash(into hasher: inout Hasher) {
        hasher.combine(threadId)
    }

    func isEqualTo(_ other: ConversationProtocol) -> Bool {
        guard let otherConversation = other as? Self else {
            return false
        }

        return threadId == otherConversation.threadId
    }

    static func ==(lhs: Self, rhs: Self) -> Bool {
        return lhs.isEqualTo(rhs)
    }
}

Это сообщает компилятору, что любой конкретный тип, который реализует ConversationProtocol и Hashable, получает реализацию по умолчанию бесплатно! Это чрезвычайно мощно, поскольку позволяет использовать единую центральную реализацию Hashable среди всех конкретных типов ConversationProtocol.

Чтобы использовать его, все, что нам нужно сделать, это создать конкретный тип:

struct Conversation: ConversationProtocol {
    var threadId: String
    var correspondent: UserProtocol
    var isUnread: Bool
}

Теперь, чтобы использовать нашу общую реализацию Hashable, все, что нам нужно сделать, это:

// This is where the magic happens 🎩✨ 
extension Conversation: Hashable {}

Теперь у нас есть конкретная структура Conversation, которая автоматически получает нашу реализацию Hashable, просто предоставляя пустую реализацию.

НО… Это все замечательно, пока мы не попытаемся сохранить наш экземпляр ConversationProtocol в наборе… в конце концов, мы все еще хотим использовать абстрактный тип для максимальной гибкости.

var conversationSet = Set<ConversationProtocol>()

Компилятор кричит на нас…

error: type 'ConversationProtocol' does not conform to protocol 'Hashable'

Это имеет смысл, поскольку протокол не может реализовать протокол. Итак, как сделать набор из ConversationProtocol с? Здесь в игру вступает стирание шрифта.

Нам нужен конкретный тип, который соответствует Hashable для хранения в наборе.

struct AnyHashableConversation: ConversationProtocol {

    var threadId: String {
        return conversation.threadId
    }

    var correspondent: UserProtocol {
        return conversation.correspondent
    }

    var isUnread: Bool {
        return conversation.isUnread
    }

    private let conversation: ConversationProtocol

    init(conversation: ConversationProtocol) {
        self.conversation = conversation
    }
}

// Use our existing Hashable implementation
extension AnyHashableConversation: Hashable {}

Вот и все! Теперь мы можем создать набор любого типа диалога, и он будет хеширован с использованием одной общей реализации Hashable!

let conversations = Set<AnyHashableConversation>()

Вывод

Использование протоколов в сочетании со стиранием типов является полезным методом абстрагирования общей функциональности среди многих конкретных типов. Это позволяет нам сохранять наш код слабо связанным, расширяемым и легко тестируемым. Есть ли у вас опыт использования стирания типов или других абстракций для решения подобных задач? Оставьте нам свои мысли, мы хотели бы узнать больше!

Первоначально опубликовано на https://tech.okcupid.com 4 ноября 2019 г.