SOLID в iOS разработке. Принцип единственной ответственности

Введение

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

Немного истории

В начале 2000-х Роберт Мартин, также известный как дядюшка Боб, придумал список из 11 принципов хорошего объектно-ориентированного дизайна. Первые 5 принципов описывали, как сделать хороший дизайн класса. Позже они стали известны под говорящей аббревиатурой SOLID, придуманной Майклом Физерсом. Эти принципы помогают писать надежный, гибкий код.

Надежность означает простоту и понятность кода, что позволяет легко вносить в него изменения, упрощает его поддержку, работу в команде.

Гибкость позволяет с минимальными усилиями масштабировать проект, увеличивая кодовую базу без вреда для надежности.

SOLID

Что же обозначает аббревиатура?

  • S — Принцип единственной ответственности (The Single Responsibility Principle, SRP)
  • O — Принцип открытости/закрытости (The Open Closed Principle, OCP)
  • L — Принцип подстановки Б. Лисков (The Liskov Substitution Principle, LSP)
  • I — Принцип разделения интерфейса (The Interface Segregation Principle, ISP)
  • D — Принцип инверсии зависимостей (The Dependency Inversion Principle, DIP)

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

Принцип единственной ответственности

Роберт Мартин описывал его так:

Каждый класс должен иметь только одну причину для изменения

Другими словами, объект должен нести только одну ответственность. Его интерфейс и имплементация должны быть направлены только на эту ответственность.

Принцип должен соблюдаться всегда: не только при проектировании класса, но и при его багфиксе. Очень часто класс создается, следуя принципу, но потом в него попадает всё, что только возможно.

Как принцип выглядит на практике? Для начала я приведу простой пример для общего понимания. После этого мы рассмотрим более приближенную к реальности ситуацию, часто встречающуюся в iOS разработке.

Простой пример: Больница

Итак, давайте представим, что у нас есть больница, в которой работают врачи, а к ним ходят пациенты.

Врач может записать пациента к себе на прием, а также принять его:

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

Следуя определению от дядюшки Боба, имплементация класса Doctor должна меняться только при изменении способа лечения пациента. Но в данном примере любые изменения в порядке регистрации также будут менять класс Doctor.

Вынесем функционал регистрации в отдельный класс Registry:

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

А доктора стали заниматься только своей главной обязанностью — лечить людей:

Другой пример: ViewController

Рассмотрим более реальный пример, с которым когда-то сталкивался любой iOS разработчик. При создании нового проекта (например, single view application), Apple предоставляет нам первый контроллер, намекая на использование архитектурного паттерна MVC. Не буду сейчас углубляться в рассуждения о плюсах и минусах этого подхода, но скажу, что некоторые неопытные разработчики часто размещают в таких контроллерах большую часть логики приложения. Возьмем в качестве примера экран для редактирования пользовательских данных. Первые наброски контроллера могут выглядеть так:

Контроллер еще не очень большой, но потенциальные проблемы уже видны

Может показаться, что всё не так плохо: контроллер содержит аутлеты и обработку пользовательских действий, а также методы бизнес логики приложения.

Но через какое-то время нужно настроить работу с данными по сети. Тут наш контроллер начинает создавать и посылать запросы. А если понадобится хранить локальный кэш? Теперь в контроллере строятся запросы в базу данных, создаются сущности и происходит прочая работа с БД. Кроме этого, вспомним, что с самого начала в контроллере присутствует логика отрисовки интерфейса, пользовательских действий, а также бизнес логика приложения. В короткие сроки мы уже имеем нечто похожее:

Кажется, что-то пошло не так

Отходя от темы, замечу, что разработка с такой архитектурой безусловно будет идти быстрее. Более того, существуют ситуации, когда это может быть осознанным и правильным выбором. Например, набросать прототип проекта с минимальным функционалом (minimal viable product, MVP), чтобы показать заказчику, но не более того.

Масштабировать такой продукт с каждой строчкой кода становится всё труднее. А что насчет рефакторинга? Что произойдет, если мы решим поменять способ получения данных из сети? А если решим изменить способ хранения данных? Что если поменяется логика приложения? Или же добавим новые элементы интерфейса? Все эти вопросы имеют сходство — они являются причиной для изменения нашего контроллера. Не слишком ли много причин? Вспомните описание принципа. Дядюшка Боб утверждает, что такая причина должна быть одна.

Исправляем ситуацию

Начнем с работы с сетью. Конечно, этим не должен заниматься контроллер. Создадим класс NetworkClient, который будет заниматься всей этой работой. Тем самым мы освободили контроллер от одной лишней ответственности: теперь он просит сетевой клиент получить или отправить данные по сети, ничего не подозревая о реализации. Ответственность мы заменили на зависимость. (О правильной реализации зависимостей между классами я расскажу в рамках одной из последующих статей данного цикла, принципе инверсии зависимостей)

Заметьте, что в интерфейсе сетевого клиента остались только общие методы, о которых следует знать другим классам. Все подробности реализации теперь скрыты внутри класса.

Сделаем тоже самое с хранением данных. Создадим класс DataStore и вынесем в него все подробности реализации сохранения и получения данных из хранилища, при этом оставив в публичном интерфейсе только абстрактные методы:

Обратите внимание, с таким интерфейсом ни один из классов, использующих наше хранилище, не знает, как именно хранятся данные. Это может быть Core Data, Realm, NSUserDefaults или даже виртуальное хранилище в оперативной памяти.

Давайте посмотрим, как стал выглядеть контроллер после рефакторинга:

Мы вернулись к начальному состоянию, где, казалось, всё было не так уж плохо. В принципе, такой результат уже можно считать приемлемым и остановиться. Но давайте подумаем, сколько ответственностей сейчас выполняет контроллер? Он реализует бизнес логику приложения, а также отвечает за отрисовку интерфейса с пользовательскими нажатиями, соответственно, выполняет две ответственности. Контроллер не должен содержать в себе бизнес логику, поэтому её также следует вынести в отведенный для этого класс. Существует множество вариантов решения этой задачи: интеракторы, сервисы, вью модели и много других вариантов. Мне нравится сервис-ориентированная архитектура, поэтому я решил создать ProfileService и вынести бизнес логику туда:

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

Ну а в контроллере теперь осталась его одна главная ответственность и причина для изменения: взаимодействие с пользователем:

А если оставить как было?

Вспомните, как начал расти контроллер после добавления в него работы с сетью и БД. У принципа единственной ответственности существует противоположность: антипаттерн God Object. Так в шутку называют объект, который отвечает за всё и сразу. Основная проблема заключается в поддержке такого объекта. Впервые взглянув на его интерфейс, невозможно ответить на вопрос: за что же он отвечает? В имплементации класса каждой из ответственностей открыты подробности реализации всех остальных. Это способствует переплетению между ними, ведь нет необходимости работать через строгие публичные интерфейсы, когда всё рядом, по соседству. Неочевидные связи начинают создаваться неосознанно, нарастая, как снежный ком. В результате система становится хрупкой, запутанной и непредсказуемой: изменение кода в одном месте приводит к появлению багов в других, казалось, никак не связанных друг с другом местах.

God Object

Вывод

В обоих примерах нам пришлось рефакторить классы, изначально содержащие несколько ответственностей. Принцип единственной ответственности помогает нам в этом, но в первую очередь он придуман для предотвращения таких ситуаций. Его, как и остальные принципы, можно сравнить с рекомендациями по техническому обслуживанию авто. Только вместо авто — ваш проект. Следуйте рекомендациям, регулярно задумываясь об ответственностях классов, не бойтесь разделять их, создавая новые классы, и код проекта будет служить вам долго и исправно.