О себе
Приветствую всех. Меня зовут Вячеслав, работаю в IT 11 лет в
направлении Android. Трогал и гладил динозавров в лице Android 1.5
и 1.6, прошел все этапы становления MVP MVVM Retrofit и многих
других библиотек. Смотрел на свой старый код как на кучу г... много
раз и все еще продолжаю изучать новое и развиваться. Мне удалось
выучить не один десяток, не побоюсь этого слова, сильных ребят, с
хорошим потенциалом и головой на плечах, в процессе обучения были
сформированы правила и рекомендации, которыми я и хочу
поделиться.
О статье
В последнее время сталкиваюсь с множество проектов разной
сложности и вижу закономерную проблему. Начинающие программисты не
видят ценности таких понятий как Clean Code, KISS и SOLID. Можно
согласиться с тем что Clean Code - это далеко не для начинающих,
однако считаю что в общих чертах, знание данного подход необходимо.
Программисты среднего уровня - не в полной мере применяют данные
подходы. Опытные программисты зачастую слишком сильно углубляются в
детали и забывают о самом важном. Для начинающих: эта статья
поможет собрать для себя правила, которым стоит уделить
внимания.
Для опытных: пересмотреть свои взгляды или углубиться в детали
современных подходов к написанию кода.
Для профессионалов: взглянуть на современные подходы под другим
углом (надеюсь). Иногда полезно сделать шаг назад и убедиться что
ты идешь верным путем.
Я не стану вдаваться во все аспекты разработки, больше времени
будет уделено самим идеям и правилам, которым стоит уделить
внимание во время разработки. Затрону некоторые современные
библиотеки и решения в области реактивного программирования.
Выскажу мнение в отношении архитектур и Clean Code.
Подходы
Clean Code
Итак, предлагаю для начала поговорить о современных подходах,
что под ними подразумевается и что действительно важно.
Начнем пожалуй с наиболее часто упоминаемого Clean Code.
Желающие могут изучить данный материал за авторством Роберта
Мартина, вдаваться же в детали не буду. Однако хочу вынести
наиболее важный момент. Чистый код - подразумевает написание кода,
который легко читаться и также легко дорабатываться (написать же
при этом такой код зачастую довольно сложно). Во время обучения и
работы - я всегда думаю о названиях функций и переменных, о целях
классов и их назначениях. Было введено одно довольно интересное
правило: Правило двух прочтений. Суть правила - если внимательно
прочитав код 2 раза, кто-то не понял назначения либо реализации
кода - это плохой код. Поставьте себя на место нового разработчика,
или себя-же но через несколько лет. В любой ситуации код должен
легко читаться. Код не должен быть замудренным однострочным
решением, но и раздувать сортировку пузырьком на 100 строк тоже не
стоит. Для особо сложных элементов всегда есть комментарии. Почему
2 раза? - первый раз мы вникаем в структуру, второй - в логику, обе
пункта должны быть прозрачными для читающего. Как же добиться
такого кода.. Начинающие программисты редко уделяют внимание
довольно простой вещи - именованию, ведь оно отвечает за половину
от читаемости кода. Всем понятно что делает функция
transformDateToString и мало кто определяет назначение функции
transDTS. Не все понимают что больше кода - не значит хуже, и
меньше кода - не всегда хорошо. Никогда не измеряйте качество кода
его количеством, кода должно быть достаточно для решения задачи и
сохранения читаемости. Именно такие мелочи зачастую становятся
преградами в понимании кода. Не стоит бояться длинных имен, не
стоит недооценивать важность комментариев. Не забывайте: если это
очевидно сейчас - это не значит что оно останется очевидным
позже.
KISS
Таким образом мы плавно переходим к KISS (keep it simple,
stupid). Как бы весело и немногозначно звучал этот принцип, я
рекомендую ставить его на одно из первых мест при разработке ПО.
Сделайте свой код настолько простым - насколько это возможно, это
упростит жизнь, вам, вашим коллегам а может и следующему
программисту на проекте. И вот тут я хочу отметить частую ошибку
программистов среднего и старшего звена. В попытках следования
таким направлениям как SOLID, многие забывают, что код, хоть с ним
и работает машина, пишут все-же люди, и в первую очередь код должен
быть читаемым. Не стоит излишне усложнять код.
interface Factory<out T> { fun create(): T } typealias PrinterFun = (String) -> Unit interface PrinterFactory : Factory<PrinterFun> interface MessageFactory : Factory<String> interface MessagePrinter { fun print(pf: PrinterFactory, mf: MessageFactory) } class PrinterFactoryImpl : PrinterFactory { override fun create(): PrinterFun = ::print } class MessageFactoryImpl : MessageFactory { companion object { const val DEFAULT_MESSAGE = "Hello World" } override fun create(): String = DEFAULT_MESSAGE class MessagePrinterImpl : MessagePrinter { override fun print(pf: PrinterFactory, mf: MessageFactory) { pf.create().invoke(mf.create()) } } class ImplProvider { private val impls = HashMap<KClass<out Any>, Any>() fun <T : Any> setImpl(clazz: KClass<T>, t: T) { impls[clazz] = t } fun <T : Any> getImpl(clazz: KClass<T>): T { return (impls[clazz] as? T) ?: throw Exception("No impl") } } fun main(args: Array<String>) { val implProvider = ImplProvider() implProvider.setImpl(PrinterFactory::class, PrinterFactoryImpl()) implProvider.setImpl(MessageFactory::class, MessageFactoryImpl()) implProvider.setImpl(MessagePrinter::class, MessagePrinterImpl()) implProvider.getImpl(MessagePrinter::class) .print(implProvider.getImpl(PrinterFactory::class), implProvider.getImpl(MessageFactory::class)) }
Много ли найдется желающих дорабатывать ТАКОЙ Hello world? Чем
менее сложный код - тем легче его дорабатывать.
class TimeFormatter { private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) fun formatTime() = timeFormat.format(Date()) }
И вот тут мы сталкиваемся с излишней простотой. Слишком простой
код может быть сложно протестировать (UNIT тестами) из за
отсутствие возможности подменять зависимости либо сильной связности
кода. В примере выше имеет смысл добавить время, как параметр
функции конвертации и для упрощения использования выставить
параметру значение по умолчанию. Всегда старайтесь найти ту самую
золотую середину.
SOLID
Вот мы и дошли до бича современной разработки: SOLID! В понятие
вложено довольно большой объем знаний и понятий, но степень
важности некоторых очень сильно недооценивают, а способы решения
иных - слишком сильно возводят в абсолют. Конкретно данному набору
принципов я бы хотел уделить особое внимание. Для начинающих этот
набор выглядит как монстр и становится стеной непонимания, для
средних - опорой, для профессионалов - постулатом, истинна же не в
том как этим орудовать, а скорее в понимании для чего это нужно.
Чтобы лучше что-то понять, нужно увидеть границы и исключения. Если
же всё время показывать как надо, мы никогда не поймем а как НЕ
надо, так что дальше мы разберем каждый пункт с примерами хорошего
и плохого использования.
S - single responsibility
[WIKI] Принцип единственной ответственности (single
responsibility principle). Для каждого класса должно быть
определено единственное назначение. Все ресурсы, необходимые для
его осуществления, должны быть инкапсулированы в этот класс и
подчинены только этой задаче.
Есть и иное трактование: Модуль должен иметь одну и только одну
причину для изменения.
Принцип разделяй и властвуй, в целом кажется довольно простым -
пиши классы под определенные задачи, полезно и практично, однако
зачастую можно столкнуться с паранойей. В своей практике встречал
ситуацию когда в пакете утилит было около 20 классов с 1-2 методами
(TextEditUtils, TextTransformUtils, TextConcatUtils и тд) - почему
бы не объединить в TextUtils так и осталось загадкой. Не возводите
этот принцип в абсолют, у всего есть границы, даже у безумия. Но и
не стоит забывать что GOD-CLASS тоже плохо. Хоть и решение таких
вопросов остается на совести разработчика, я не могу дать точных
метрик и ограничений, так как каждый случай уникален.
Ориентируйтесь на общий объем и связность. Если же взглянуть на
второй вариант трактовки - возможно станет чуть более понятней.
Проектируйте ваш код таким образом, чтобы причиной его изменить -
могла быть только одна определенная задача. На примере выше, класс
утилита для работы с текстом может иметь только одну логическую
причину измениться - модификация взаимодействия со строками
(добавление новой утилиты для удаления цифр в строке, удаление
неиспользуемого метода и иные задачи относящиеся к манипуляциям
текстом).
O - openclosed
[WIKI] Принцип открытости/закрытости. Программные сущности
должны быть открыты для расширения, но закрыты для модификации.
Более простыми же словами данный принцип можно сформулировать
как Мы должны быть в состоянии наследовать классы без изменения
базового класса (того от кого наследуемся). Более простая
формулировка, к сожалению, частично скрывает истинный смысл
принципа.
Довольно неочевидный пункт для большинства. За начинающими
программистами был замечен довольно интересный вопрос а зачем
закрывать доступ?. И если подумать - а действительно, зачем? Если
оставить все открытым и дозволенным, мы получим систему - в которой
будет доступ ко всем компонентам без проблем, делай что хочешь. В
такой ситуации стоит привести контрпример из практики преподавания.
Я попросил своих студентов сделать довольно простой компонент -
задачей стояло в зависимости от данных - отображать текст с
картинкой, либо кнопку. Типовым решение стал вот такой код:
open class UiComponent() { var mode : Int = 0 fun showTextAndImage(text:String, image: Image){ mode = 0 ... } fun showButton(text:String, action: Runnable){ mode = 1 ... } ...}
Фокусы же начались после того, как был написан довольно просто
класс-наследник:
class MyUiComponent(): UiComponent(){ fun doMagic(){mode = 3}}
Вызов одной функции полность ломал поведение оригинала а
студенты как один начали утверждать - флаг так менять нельзя, на
логичный вопрос Почему? Флаг же открыт для изменения, почему я не
могу его менять? так и не был дан полноценный ответ. Вот мы и
пришли к выводу, не всё и не всегда должно быть открыто к
модификации, иногда часть данных, участвующих в промежуточных
расчетах или состояниях, могут меняться только в определенной части
кода и по определенным правилам, и должны быть скрыты от внешнего
взаимодействия и модификации. В данном примере стоило сделать
переменную mode закрытой, а функции - переопределяемыми. Таким
образом, можно было бы расширить функционал (например добавить
форматирование текста перед отображением), но не
модифицировать.
Довольно простой принцип, но нельзя забывать о его важности,
если не хотите впоследствии проводить уйму времени в отладке, в
надежде найти то самое внешнее взаимодействие, ломающее логику.
L - Liskov substitution
[WIKI] Принцип подстановки Лисков. Объекты в программе должны
быть заменяемыми на экземпляры их подтипов без изменения
правильности выполнения программы. Производный класс должен быть
взаимозаменяем с родительским классом.
Почти все программисты в той или иной степени осознают важность
наследования классов, однако далеко не все придают этому факту
должное внимание. Когда, как и зачем выделять абстракции и делать
родительские классы. К сожалению данную проблему довольно часто
можно заметить у программистов начального и среднего уровней.
Многие считают что выделить родительский класс это лишняя и
ненужная работа. На практике такая необходимость хоть и встречается
редко, однако нужен опыт и понимание, когда выносить, а когда не
нужно. Приведу два примера.
Мы писали довольно крупное приложение с возможностью скачивания
файлов. Изначально это было одно место в коде и просто ссылка на
файл. Не долго думая был реализован класс Downloader с функцией
downloadFile(url). Позже появились новые типы файлов, вместе со
ссылкой нужно было передавать параметры и хедеры для запроса, а для
некоторых файлов нужно было еще дешифрование. По итогу был получен
Downloader с кучей лишних функций на скачивание каждого типа
файлов, а расширение или доработка становились адом. Решение (в
упрощенном виде) было в вынесении абстракции Downloadable:
class DownloadManager() { fun download(downloadable: Downloadable) { val stream = downloadable.openStream() val file = File(downloadable.getFileName()) //логика записи в файл }}interface Downloadable { fun openStream(): InputStream fun getFileName(): String}class SimpleDownloadableFile(val name: String, val url: String) : Downloadable { override fun openStream() = URL(url).openStream() override fun getFileName() = name}class HeaderFile(val name: String, val url: String, val headers: Map<String, String>) : Downloadable { override fun openStream(): InputStream { /*формирование запроса и получении потока*/ } override fun getFileName() = name}
Таким образом за счет данного принципа мы ушли от общих проблем
скачивания (за счет подстановки объектов как имплементацию
интерфейса) к частным задачам получения потока для каждого
конкретного случая (по урл, по урл + хедеры и тд)
В противовес первому примеру - приведу обратное, ситуацию когда
слишком сильное абстрагирование и выделение приводят лишь к
непониманию и усложнению кода. К сожалению этим чаще страдают
профессионалы. В попытке подготовиться ко всему - часто можно
встретить цепочку наследования вида:
interface Somethinginterface SomethingSpecific : Somethinginterface WritableSomething : SomethingSpecific { fun writeToFile()}interface GetableWritable<T> : WritableSomething { fun obtain(): T}abstract class ObtainableFile(val name: String) : GetableWritable<File> { override fun obtain() = File(name) override fun writeToFile() = obtain().write(getStream()) abstract fun getStream(): InputStream}class UrlFile(url: String, name: String) : ObtainableFile(name) { override fun getStream(): InputStream = URL(url).openStream()}
В момент разработки это хоть и может казаться необходимым, но
все же стоит провести рефактор и избавиться от ненужных абстракций.
Не стоит усложнять иерархию, это прямое нарушение принципа KISS.
PS: я видел похожее в реальном проекте
I - interface segregation
[WIKI] Принцип разделения интерфейса. Много интерфейсов,
специально предназначенных для клиентов, лучше, чем один интерфейс
общего назначения.
Один из самых сложных в понимании принципов. И самое сложное в
данном принципе - это понять а кто есть клиент и осознать что
зачастую мы сами и являемся клиентами. Вторая же сложность - это
осознание значения слова интерфейс, которое зачастую воспринимается
буквально interface / abstract class.
Смысл слова интерфейс в названии стоит воспринимать как точка
доступа для более четкого осознания сути принципа. Точкой доступа
может быть огромный класс на 1000 строк, но лишь с одной публичной
функцией, а может быть обычный java interface, имплементацию
которого мы скрыли.
Что же в отношении клиента - мы пишем классы и сами же ими
пользуемся, а значит мы и клиент и производитель в одном лице. Мы
производим части приложения (например класс Utils) и сами же
потребляем и используем этот код. Сложность же в разграничении этих
понятий. Нужно четко разделять код и выделять то что будет для
клиента, при таком подходе будет получатся раделенный читабельный и
структурированный код.
На самом же деле принцип довольно легко выводится из предыдущих
принципов. Предоставляй интерфейс отдельной функциональности а не
всех, вытекает из принципа S (Single responsibility). Open-close же
говорит о том что не стоит давать доступ ко всему и стоит либо
верно организовывать доступность методов и параметров либо выделить
абстракцию. Liskov substitution же обязует такую абстракцию быть
функциональной и расширяемой.
D - dependency inversion
[WIKI] Принцип инверсии зависимостей. Зависимость на
Абстракциях. Нет зависимости на что-то конкретное.
Каждый раз, вспоминая этот принцип, я чувствую боль. Самый
недооцененный и в то же время заезженный принцип. Для правильного
понимания и использования данного принципа необходимо максимально
четкое понимания причины его существования. Причин же в целом можно
выделить много, но я остановлюсь на двух. Первая: следую принципу
single responsibility, большая часть логики разбита по классам и
нам необходимо объединить логику работы разных классов в одном
(допустим класс для работы с базой данных и класс для работы с
сервером, должны быть в классе для работы с данными, например,
запросить данные с сервера и положить в базу). Вторая:
тестируемость. Вопрос тестирования стоит рассматривать отдельно
однако для полноценного тестирования нам необходимо заменять части
логики, в данном случае используя принцип Liskov substitution мы
можем подменить, к примеру, реализацию работы с сервером на ее
виртуальный аналог с фиксированными результатами на определенные
запросы.
Рассмотрим простой пример: нам необходимо получить с сервера
данные и сохранить их в файл. Следуя принципам выше у нас
получиться примерно такой код:
open class ServerManager { open fun getData(): String = "запрос на сервер"}open class CacheManager { open fun saveData(data: String) {/*сохранение в файл/базу данных */}}class DataManager{ fun getDataAndCache(){ val data = ServerManager().getData() CacheManager().saveData(data) }}
Недостатком же данного решения будет невозможность тестирования,
так как мы не сможем заменить/подменить данные и сильная
связанность, возникающая в результате создания других классов в
теле метода.
Самым древним и простым способом реализации данного принципа -
является способ передачи зависимостей через конструктор.
Модифицируем DataManager из примера выше:
class DataManager(private val serverManager: ServerManager, private val cacheManager: CacheManager) { fun getDataAndCache() { val data = serverManager.getData() cacheManager.saveData(data) }}
Таким образом мы очень сильно снизили расход памяти за счет того
что нам нет необходимости пересоздавать другие классы и значительно
снизили связность. Также мы расширили возможность протестировать
класс, так как для тестов мы можем передать переопределенные классы
(например заменить класс сервера и возвращать фиксированную строку,
либо класс кэша - и и провести проверку сохраняемых данных).
Согласно Clean Architecture стоило бы выделить интерфейсы для
каждого из классов менеджеров, однако это усложнило бы последующую
разработку. Приведу пример идеального решения для ознакомления:
interface ServerManager { fun getData(): String}open class ServerManagerImpl : ServerManager { override fun getData(): String = "запрос на сервер"}interface CacheManager { fun saveData(data: String)}open class CacheManagerImpl : CacheManager { override fun saveData(data: String) { /*сохранение в файл/базу данных */ }}interface DataManager { fun getDataAndCache()}class DataManagerImpl( private val serverManager: ServerManager, private val cacheManager: CacheManager,) : DataManager { override fun getDataAndCache() { val data = serverManager.getData() cacheManager.saveData(data) }}fun foo(){ val dataManager: DataManager = DataManagerImpl( ServerManagerImpl(), CacheManagerImpl() ) dataManager.getDataAndCache()}
Хоть это и самый простой подход (внедрение зависимостей через
параметры конструктора) , он может иметь ряд недостатков
(зависимость от большого числа классов).
Реализаций данного принципа довольно много (Dagger, Koin,
ServiceLocator и тд), однако не стоит и перебарщивать. Зачастую
можно заметить, как непонимание первопричин, приводят к появлению
внедрения странных зависимостей:
interface TextProvider { fun getText(): String}class SimpleTextProvider(private val text: String) : TextProvider { override fun getText(): String = text}class Printer(private val textProvider: TextProvider) { fun printText() { println(textProvider.getText()) }}fun main() { Printer(SimpleTextProvider("text")).printText()}
В данном примере вместо простой передачи текста был реализован
класс, предоставляющий текст, далее согласно принципам SOLID
выделен интерфейс, и проведен процесс Dependency injection. Однако
очевидно что в данном случае мы получаем излишнюю функциональность
и вырожденность кода. Гораздо проще передать текст для печати
напрямую. Это и есть пример внедрения зависимостей ради внедрения,
как можно заметить - излишнее стремление к совершенству лишь
усложняет код и делает его трудно расширяемым и абсолютно
противоречивым принципу KISS.
Самой большой проблемой данного подхода является определения
звисимости, непонимание причин внедрения и целей приводит к тому,
что программисты начинают внедрять всё. Нужно четко понимать цель
внедрения - ослабления связанности и повышение тестируемости и не
делать внедрение ради внедрения. Те же кто свято верят в постулат
внедря всё и везде лишь делаю код абсолютно несвязанным и
нечитаемым, усложняя работу себе и другим, забывая что полное
отсутствие связности гораздо хуже слабой связности. Нет
необходимости внедрять связанные компоненты (к примеру для Андроида
- нет необходимости во внедрении Adapter-а, если сам адаптер не
нуждается в зависимостях, просто используем конструктор и не
мудрим).
О Важности архитектур
Начну пожалуй с того, что важности архитектуре приложения, к
сожалению, не придают достаточного внимания. Понимание же смысла
архитектуры оказывается важным пунктом в написания стабильного и
качественного кода. На практике часто встречаются люди, решившие
что архитектура это всего лишь набор правил или классов которые
нужно реализовать. Хоть это и не далеко от истины - однако
понимание назначения классов играет очень важную роль. Само же
понятие и потребность в архитектуре - напрямую вытекают из
рассмотренных выше подходов. Разделив наш код на классы для
выполнения поставленных задач, необходимо правильно объединить и
организовать данный код, сами же классы должны быть реализованы для
выполнения достижения строго определенных целей - это по сути и
есть архитектура: организация и целенаправленность кода. Известные
архитектуры (MVP, MVVM и тд) это лишь набор правил, устоявшихся и
сформулированных правил (сделать класс-модель, сделать
класс-перзентер ). Важно понимать что архитектура позволяет
значительно упростить и структурировать подход к разработке,
выработать стратегию и правила. Известные архитектуры позволяют
членам команды с большей эффективность работать над кодом, зная его
структуру. Выбор же самой архитектуры должен осуществляться на
основе поставленных задач.
Есть очень замечательная книга Clean architecture. И я ее
торжественно ненавижу. Нет, не потому что она плохая или учит
чему-то неправильному. К сожалению очень часто встречается Clean
architecture головного мозга, чтение данной книги будет полезно для
продвинутых программистов, для начинающих же это может стать
постулатом и по итогу превратить в монстров, которые пишут
внедрение зависимостей из примеров выше. Идеальную архитектуру
написать можно - но работа с такой архитектурой будет занимать
огромное количество времени. Тут стоит снова вернуться к примеру
Hello World выше, аритектурненько ведь?
Не старайтесь сделать всё идеально, целью любой архитектуры
должно быть решение конкретных задач и нужд, даже несмотря на
нарушения некоторых принципов(в меру, например не выносить
абстракции для внедрения зависимостей). И вот тут мы подошли к
пониманию самого слова архитектура. Архитектура - это организация и
структура кода, для выполнения поставленной задачи.
О том как думать
Меня часто спрашивают А как ты решаешь сложные задачи?, трудно
ответить простыми словами. Важен подход, важен опыт, но алгоритм до
боли известен: разбей сложную задачу на простые. На практике же,
всегда нужно сводить сложные задачи к простым и понятным, искать
наиболее простые решения. Боязнь ошибиться не должна останавливать
от попыток. Даже самые сложные задачи можно свести к простым.
Возьмем к примеру распознавание лиц, казалось бы довольно сложной
задачей, а если подумать? Что есть лицо - 2 глаза, нос рот.. задачу
найти лицо уже можно свести к задаче поиска частей лица ведь
распознать нос гораздо проще чем лицо целиком. Как найти нос -
задать шаблон и сравнивать. Как задать шаблон? Сделать фото носов,
уменьшить, обесцветить и получить несколько шаблонных изображений.
Таким образом даже самые сложные задачи всегда сводятся к более
простым.
Не пытайтесь решить всё и сразу. Поэтапная разработка позволяет
увидеть потенциал и ошибки на ранних стадиях разработки.
Во время обучения - важным фактором является понимание
исключений и ограничений. Всегда нужно знать почему так НЕ надо
делать, почему так плохо. Знания того как делать не надо становятся
опорой во время разработки. Знания только лишь как надо -
ограничениями. Всегда: если есть решение проблемы - необходимо
понимать суть проблемы, с каждой решенной проблемой ваш багаж
знаний будет расти, типовые проблемы станут мелочами и ваш опыт
будет становиться ценнее.
Отдых - очень важный фактор в нашей работе. Порой во премя
отдыха (рекомендую душ) приходят самый лучшие решения нашим
задачам. Иногда нужно просто выгрузить всё, иногда нужен свежий
взгляд. Не забывайте про уточку (метод уточки/утёнка).
Заключение
В заключении хочется вспомнить очень полезный совет, найденный
на просторах интернета: Пишите код так, будто его будет читать
маньяк, знающий где вы живете. Пишите хороший код, и да прибудет с
вами кофе и печеньки.