Почему мы должны критически относиться к использованию протоколов
В Swift в моде протокольно-ориентированное программирование. Существует много кода Swift, который «протокольно-ориентирован», некоторые библиотеки с открытым исходным кодом даже заявляют об этом как о функции. Я думаю, что в Swift чрезмерно используются протоколы, и часто проблема может быть решена гораздо более простым способом. Короче говоря: не будьте догматичны в отношении использования (или избегания) протоколов.
Одна из самых влиятельных сессий на WWDC 2015 называлась «Протокольно-ориентированное программирование в Swift». Он показывает (среди прочего), что вы можете заменить иерархию классов (то есть суперкласс и некоторые подклассы) решением, ориентированным на протокол (то есть протоколом и некоторыми типами, которые соответствуют протоколу). Решение, ориентированное на протокол, проще и гибче. Например, у класса может быть только один суперкласс, а тип может соответствовать многим протоколам.
Давайте посмотрим на проблему, которую они решили в выступлении на WWDC. Ряд команд рисования необходимо было отобразить как графику, а также зарегистрировать в консоли. Поместив команды рисования в протокол, любой код, описывающий рисование, можно было бы сформулировать в терминах методов протокола. Расширения протокола позволяют вам определять новые функциональные возможности рисования с точки зрения базовой функциональности протокола, и каждый соответствующий тип получает новую функциональность бесплатно.
В приведенном выше примере протоколы решают проблему совместного использования кода несколькими типами. В стандартной библиотеке Swift для коллекций активно используются протоколы, и они решают точно такую же проблему. Поскольку dropFirst определен для типа коллекции, все типы коллекций получают это бесплатно! В то же время существует так много протоколов и типов, связанных с коллекциями, что бывает трудно найти что-то. Это один из недостатков протоколов, однако в случае со стандартной библиотекой преимущества легко перевешивают недостатки.
Теперь давайте проработаем пример. Здесь у нас есть класс Webservice. Он загружает сущности из сети, используя URLSession. (На самом деле он ничего не загружает, но вы поняли):
class Webservice {
func loadUser() -> User? {
let json = self.load(URL(string: "/users/current")!)
return User(json: json)
}
func loadEpisode() -> Episode? {
let json = self.load(URL(string: "/episodes/latest")!)
return Episode(json: json)
}
private func load(_ url: URL) -> [AnyHashable:Any] {
URLSession.shared.dataTask(with: url)
// etc.
return [:] // should come from the server
}
}
Приведенный выше код короткий и отлично работает. Нет проблем, пока мы не захотим протестировать loadUser и loadEpisode. Теперь нам нужно либо заглушить загрузку, либо передать фиктивный URLSession, используя внедрение зависимостей. Мы также можем определить протокол, которому соответствует URLSession, а затем передать тестовый экземпляр. Однако в этом случае решение намного проще: мы можем вытащить изменяющиеся части из веб-сервиса в структуру (мы также рассмотрим это в Swift Talk Episode 1 и в Advanced Swift):
struct Resource<A> {
let url: URL
let parse: ([AnyHashable:Any]) -> A
}
class Webservice {
let user = Resource<User>(url: URL(string: "/users/current")!, parse: User.init)
let episode = Resource<Episode>(url: URL(string: "/episodes/latest")!, parse: Episode.init)
private func load<A>(resource: Resource<A>) -> A {
URLSession.shared.dataTask(with: resource.url)
// load asynchronously, parse the JSON, etc. For the sake of the example, we directly return an empty result.
let json: [AnyHashable:Any] = [:] // should come from the server
return resource.parse(json)
}
}
Теперь мы можем протестировать пользователя и эпизод без необходимости что-либо имитировать: это простые структурные значения. Нам еще нужно протестировать нагрузку, но это только один метод (а не для каждого ресурса). Теперь давайте добавим несколько протоколов.
Вместо функции разбора мы могли бы создать протокол для типов, которые можно инициализировать из JSON.
protocol FromJSON {
init(json: [AnyHashable:Any])
}
struct Resource<A: FromJSON> {
let url: URL
}
class Webservice {
let user = Resource<User>(url: URL(string: "/users/current")!)
let episode = Resource<Episode>(url: URL(string: "/episodes/latest")!)
private func load<A>(resource: Resource<A>) -> A {
URLSession.shared.dataTask(with: resource.url)
// load asynchronously, parse the JSON, etc. For the sake of the example, we directly return an empty result.
let json: [AnyHashable:Any] = [:] // should come from the server
return A(json: json)
}
}
Приведенный выше код может выглядеть проще, но он менее гибкий. Например, как бы вы определили ресурс, содержащий массив значений User? (В приведенном выше примере, ориентированном на протокол, это пока невозможно, и нам придется ждать Swift 4 или 5, пока это не будет выражено). Протокол упрощает работу, но я думаю, что он не окупается, потому что резко сокращает количество способов, которыми мы можем создать Ресурс.
Вместо того, чтобы иметь пользователя и эпизод в качестве значений Resource, мы могли бы также сделать Resource протоколом и иметь структуры UserResource и EpisodeResource. Кажется, это популярно, потому что наличие типа вместо значения «просто кажется правильным»:
protocol Resource {
associatedtype Result
var url: URL { get }
func parse(json: [AnyHashable:Any]) -> Result
}
struct UserResource: Resource {
let url = URL(string: "/users/current")!
func parse(json: [AnyHashable : Any]) -> User {
return User(json: json)
}
}
struct EpisodeResource: Resource {
let url = URL(string: "/episodes/latest")!
func parse(json: [AnyHashable : Any]) -> Episode {
return Episode(json: json)
}
}
class Webservice {
private func load<R: Resource>(resource: R) -> R.Result {
URLSession.shared.dataTask(with: resource.url)
// load asynchronously, parse the JSON, etc. For the sake of the example, we directly return an empty result.
let json: [AnyHashable:Any] = [:]
return resource.parse(json: json)
}
}
Но если мы посмотрим на это критически, что мы действительно выиграли? Код стал длиннее, сложнее и менее прямым. И из-за связанного типа мы, вероятно, в конечном итоге определим AnyResource. Есть ли какая-то польза от использования структуры EpisodeResource вместо значения EpisodeResource? Они оба являются глобальными определениями. Для структуры имя начинается с заглавной буквы, а для значения — со строчной. В остальном особых преимуществ нет. Вы можете использовать оба имени (для автозаполнения). Так что в этом случае иметь значение определенно проще и короче.
Есть много других примеров, которые я видел в коде по всему Интернету. Например, я видел такой протокол:
protocol URLStringConvertible {
var urlString: String { get }
}
// Somewhere later
func sendRequest(urlString: URLStringConvertible, method: ...) {
let string = urlString.urlString
}
Что это дает вам? Почему бы просто не удалить протокол и напрямую передать urlString? Гораздо проще. Или протокол с одним методом:
protocol RequestAdapter {
func adapt(_ urlRequest: URLRequest) throws -> URLRequest
}
Немного более спорный вопрос: почему бы просто не удалить протокол и передать куда-нибудь функцию? Гораздо проще. (Если ваш протокол не является протоколом только для класса, и вам нужна слабая ссылка на него).
Я могу продолжать показывать примеры, но я надеюсь, что суть ясна. Часто есть более простые варианты. Говоря более абстрактно, протоколы — это всего лишь один из способов получения полиморфного кода. Есть много других способов: подклассы, дженерики, значения, функции и так далее. Значения (например, String вместо URLStringConvertible) — самый простой способ. Функции (например, адаптируются вместо RequestAdapter) немного сложнее, чем значения, но все равно просты. Дженерики (без каких-либо ограничений) проще, чем протоколы. И чтобы быть полным, протоколы часто проще, чем иерархия классов.
Полезной эвристикой может быть размышление о том, моделирует ли ваш протокол данные или поведение. Для данных структура, вероятно, проще. Для сложного поведения (например, делегат с несколькими методами) протокол часто проще. (Протоколы сбора стандартной библиотеки немного особенные: они на самом деле не описывают данные, а скорее описывают манипулирование данными.)
Тем не менее, протоколы могут быть очень полезными. Но не начинайте с протокола только ради протокольно-ориентированного программирования. Начните с рассмотрения вашей проблемы и попытайтесь решить ее самым простым способом. Пусть проблема определяет решение, а не наоборот. Протокольно-ориентированное программирование по своей сути не является ни хорошим, ни плохим. Как и любой другой метод (функциональное программирование, объектно-ориентированное программирование, внедрение зависимостей, создание подклассов), его можно использовать для решения проблемы, и мы должны попытаться выбрать правильный инструмент для этой работы. Иногда это протокол, но часто есть более простой способ.