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

У меня за плечами большой опыт разработки приложений  —  от Sinclair Basic, Pascal, Delphi и Erlang до Objective-C и Java для мобильных приложений, а теперь еще к ним добавились Kotlin и Swift. За время своего карьерного пути я внедрил в свою повседневную практику несколько техник, которые вы можете начать использовать уже сегодня, чтобы повысить качество кода.

Но помимо всех этих принципов и советов, необходимо помнить одну важную вещь:

Единственное по-настоящему реальное существующее приложение  —  это то, которое было выпущено.

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

Кроме того, в некоторых случаях эффективность кода важнее степени его чистоты.

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

1. Упорядочивайте файлы проекта

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

На что следует обратить внимание, так это на последовательность в именовании. Не бойтесь длинных названий файлов.

Последовательность в именовании очень важна. Имена должны сразу давать представление о содержании и назначении файла. Например:

  • NetworkManager, NetworkHelper: для операций, связанных с сетью.
  • MainScreen, OtherScreen: для composable-компонентов Screen (или MainActivity, MainFragment для старого стиля).
  • MainViewModel, MenuViewModel: для классов ViewModel.

Такие соглашения об именовании позволяют легко проследить взаимосвязь между различными компонентами, например связь между MainActivity, MainScreen и MainViewModel (даже если они размещены в разных пакетах).

Эффективная структура пакета

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

В своих проектах я использовал структуру, в которой мне просто легче ориентироваться. Пакет UI организован иерархически таким образом:

Подобная структура, где связанные компоненты сгруппированы вместе, способствует быстрой навигации и лучшему пониманию кодовой базы.

2. Раз, два… Рефакторинг!

Другими словами, правило трех. Оно поможет понять, когда пришло время рефакторить код.

  1. Когда вы пишете код в первый раз, просто пишите.
  2. Во второй раз вы снова это делаете, но не теряйте бдительности.
  3. Третий раз? Остановитесь и сделайте рефакторинг.

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

3. Не допускайте глубокой вложенности: борьба с антипаттерном Arrowhead

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

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

fun getUserRole(userInput: UserInput): String? {
if (userInput.login.isNotEmpty()) {
if (userInput.password.isNotEmpty()) {
if (isUserExist(userInput.login)) {
if (isPasswordValid(userInput.password)) {
return userRole(userInput.login)
} else {
return UserRole.Unknown
}
}
return UserRole.Unknown
}
return UserRole.Unknown
}
return UserRole.Unknown
}

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

Рефакторинг кода с использованием метода ранних выходов упрощает его структуру и улучшает читабельность:

fun getUserRole(userInput: UserInput): String? {
if (userInput.login.isEmpty()) return UserRole.Unknown
if (userInput.password.isEmpty()) return UserRole.Unknown
if (!isUserExist(userInput.login)) return UserRole.NotExists
if (!isPasswordValid(userInput.password)) return UserRole.Unauthorized
return userRole(userInput.login)
}

В этой отрефакторенной версии нет ненужной сложности. Код стал более простым и понятным (не самый красивый код, который еще нуждается в оптимизации, но теперь это хорошо заметно).

В качестве альтернативы можно использовать Kotlin’овский оператор when:

fun getUserRole(userInput: UserInput): String? {
return when {
userInput.login.isEmpty() -> UserRole.Unknown
userInput.password.isEmpty() -> UserRole.Unknown
!isUserExist(userInput.login) -> UserRole.NotExists
!isPasswordValid(userInput.password) -> UserRole.Unauthorized
else -> userRole(userInput.login)
}
}

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

4. Документируйте код

Это спорный вопрос. Многие говорят: “Код  —  это как юмор. Когда его приходится объяснять, это плохо”. Такие люди начинают склоняться к мнению, что комментирование в целом  —  это что-то плохое.

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

Комментарии следует писать в следующих случаях:

  • Документирование методов и свойств с непонятными названиями.
  • Объяснение сложных алгоритмов или запутанной логики.
  • Работа с точками интеграции со сторонними библиотеками. Это не значит, что нужно документировать Retrofit. Однако, если вы пишете перехватчик, стоит дать ему поясняющее название и предоставить краткую заметку о том, что он делает и когда его нужно добавить.
  • Иногда код может содержать нестандартные решения или обходные пути для решения конкретных проблем. Хорошей идеей будет добавить ссылку на задачу в трекер проблем или отчет об ошибке.
  • Краткий комментарий, объясняющий цель или замысел определенного блока кода, особенно в больших или сложных функциях.

Вы можете сказать: “Мы же говорим о чистом коде. Он ведь не предполагает сложных или трудных для понимания функций”.

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

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

Например:

data class HardwareConnection(
val ip: String,
val port: Int
) {
/**
* Отправьте команду на закрытие соединения. Если соединение не закрыто,
* устройство будет заблокировано на 60 секунд после отправки
* последней команды. Эта блокировка выполняется на стороне устройства.
*/
fun sendCommand(command: String) {
val connection = HardwareSDK.connect(ip, port)
connection.sendCommand(command)
connection.close()
}
}

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

Не делайте так:

// Определяем переменную для хранения возраста пользователя
var userAge: Int = 25

И так тоже не нужно делать:

class UserManager(private val database: Database) {
fun createUser(user: User) {
// Сохранить пользователя в базе данных
database.save(user)
}

fun deleteUser(userId: String) {
// Удалить пользователя из базы данных
database.delete(userId)
}
}

5. Ограничьте использование глобального состояния и синглтонов

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

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

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

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

Сложности с тестированием. Мокинг сложных состояний в глобальных переменных сопряжен с трудностями, и тесты иногда мешают друг другу. Это может привести к ненадежным или неверным результатам тестирования.

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

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

Проблемы с восстановлением/переинициализацией. Если Android решит остановить и перезапустить приложение, нет никакой гарантии, что все переменные в глобальном состоянии будут правильно инициализированы. Это может привести к исключениям Null Pointer Exceptions даже при использовании ненулевых полей в Kotlin, что делает отладку довольно сложной.

Вместо того чтобы использовать синглтоны или глобальные объекты, рассмотрите возможность передачи экземпляров классов между компонентами приложения или использования классов-помощников/сервисов, которые не хранят данные самостоятельно, а получают их из определенных источников. Фреймворки для инъекции зависимостей, такие как Koin и Hilt, упростят управление объектами и их создание.

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

  • Если данные нужны только в рамках определенного жизненного цикла Activity, фрагментов, экранов или моделей представлений, их следует привязать к этому жизненному циклу.
  • Если нужно сохранить состояние приложения во время перезапуска, подумайте об использовании комбинации Helper/Service и общих предпочтений или базы данных.
  • Если у вас есть фоновый процесс, периодически получающий новые данные из бэкенда, и их нужно передавать в UI, эти данные можно хранить в базе данных и подключать к холодному или горячему потоку, выдавая обновления по мере их поступления. Тот же прием работает и для событий в масштабах всего приложения.

6. Избегайте сложных однострочников

Они не экономят место, а только усложняют понимание кода. Например:

val result = someList.filter { it.hasChildren }.map { it.children }.takeIf { it.size() > 2 } ?: listOf(0)

Разбивайте операции на отдельные шаги, чтобы сделать код более читабельным.

val result = someColdFlowOrList
.filter { it.hasChildren }
.map { it.children }
.takeIf { it.size() > 2 }

7. Используйте имена для значений вместо `it`

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

data class User(
val name: String,
val address: Address? = null,
val age: Int? = null
)

data class Address(val city: String)

val user = User("Alex", Address("Stockholm"))

user.let {
it.address?.let {
println("City: ${it.city}")
}
}

С именами будет легче читать код:

user.let { user ->
user.address?.let { address ->
println("City: ${address.city}")
}
}

Да, Android Studio поможет с определением подобных простых случаев. Однако при таком использовании, как указано ниже, эта среда ничего не скажет:

user.let {
println("Name: ${it.name}")
it.address?.let {
println("City: ${it.city}")
}
it.age?.let {
println("Age: ${it}")
}
}

Сравните с нижеприведенным кодом:

user.let { user ->
println("Name: ${user.name}")
user.address?.let { userAddress ->
println("City: ${userAddress.city}")
}
user.age?.let { userAge ->
println("Age: $userAge")
}
}

8. Избегайте “хакерского” или “гениального” кода

Я написал подобный код, работая над одним из проектов. Для меня он был довольно прост, но моим коллегам было сложно его понять. Что происходит с filters в нижеуказанном коде? Есть ли шанс на параллельное изменение filters?

filters
.indexOfFirst { it.id == id }
.takeIf { it != -1 }
?.let { index ->
filters[index] = filters[index].copy(
isActive = !filters[index].isActive
)
}

Код было бы легче читать, если бы было меньше цепочек.

val index = filters.indexOfFirst { it.id == id }
if (index != -1) {
filters[index] = filters[index].copy(isActive = !filters[index].isActive)
}

Как видите, теперь вопрос о возможности параллельного изменения отпадает.

9. Избегайте функций IfNeeded и Maybe

Эти типы функций содержат условную логику, вызывающую побочные эффекты.

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

Например:

class User(
val name: String,
val email: String,
val age: Int,
val emailMarketingEnabled: Boolean
)

class UserManager {
private var user: User = User.empty()
private val parentalControlManager = ParentalControlManager()
private val marketingManager = MarketingManager()
private val database = Database()

fun updateUserProfileIfNeeded() {
if (user.age < 18) {
parentalControlManager.checkPermissions(user) {
updateUser(user.copy(emailMarketingEnabled = true))
}
} else {
marketingManager.sendPromotionalEmail(user)
updateUser(user.copy(emailMarketingEnabled = true))
}
}

fun updateUser(user: User) {
database.update(user)
}
}

class MainViewModel(private val userManager: UserManager) : ViewModel() {
// ...
fun onActivatePromotionEmails() {
userManager.updateUserProfileIfNeeded()
}
}

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

Я видел такое много раз. Это что-то среднее между шаблонами “Композиция” и “Посредник”. Все размещается в одном месте и выглядит удобно для использования. Однако такой код порождает множество проблем.

  • Метод updateUserProfileIfNeeded отвечает за слишком многое: проверку возраста пользователя, обработку проверок родительского контроля, обновление профилей пользователей и взаимодействие с менеджером по маркетингу.
  • Метод выполняет различные действия в зависимости от возраста пользователя, что не сразу видно из его названия или сигнатуры. Из-за такой скрытой логики метод становится менее предсказуемым и более сложным для понимания.
  • Объект user изменяется  —  это побочный эффект проверки возраста. Подобные побочные эффекты могут привести к ошибкам и усложнить сопровождение кода.
  • Метод updateUserProfileIfNeeded зависит от текущего состояния объекта user. Это пример глобального или общего состояния, которое может привести к непредсказуемому поведению, особенно в многопоточной среде, что делает систему более склонной к ошибкам.
  • Метод тесно связан с ParentalControlManager и MarketingManager, из-за чего класс UserManager является сложным в плане тестирования и сопровождения.
  • Название метода updateUserProfileIfNeeded не совсем ясно передает его фактическую функциональность.

Подобные проблемы невозможно исправить с помощью “косметических” действий, таких как разбиение функции на более мелкие функции или передача менеджеров в качестве параметров. Этот класс необходимо полностью рефакторить, чтобы он содержал только функциональность обновления. Проверка разрешений и маркетинговые действия должны быть вынесены за пределы UserManager.

class ParentalControlManager {
fun canUpdateProfile(user: User): Boolean {
return user.age < 18 && hasPermissions(user)
}
}

class UserManager {
private var user: User = User.empty()
private val database = Database()

fun updateUser(user: User) {
database.update(user)
}

fun activatePromotionEmails(user: User) {
updateUser(
user.copy(emailMarketingEnabled = true)
)
}
}

class MainViewModel(
private val parentalControlManager : ParentalControlManager,
private val marketingManager : MarketingManager,
private val userManager : UserManager
) : ViewModel() {

// ...
fun onActivatePromotionEmails() {
if (parentalControlManager.canUpdateProfile(user)) {
userManager.activePromotionEmails(user)
marketingManager.sendPromotionalEmail(user)
}
}
}

Итак, здесь произошло следующее:

  • Логика, связанная с проверками родительского контроля, теперь инкапсулирована в ParentalControlManager.
  • UserManager теперь сосредоточен исключительно на операциях, связанных с пользователем, таких как обновление пользователя и активация рекламных писем, что делает его более согласованным.
  • Вынеся логику родительского контроля и маркетинга за пределы UserManager, мы снизили уровень связанности кода. UserManager больше не зависит напрямую от поведения ParentalControlManager и MarketingManager.
  • Названия методов, таких как canUpdateProfile и activatePromotionEmails, более описательны и точно отражают их функциональность. Это улучшает читабельность и удобство сопровождения кода.
  • MainViewModel теперь явно проверяет, можно ли обновить профиль, основываясь на результате parentalControlManager.canUpdateProfile(user). Логический поток становится более понятным и предсказуемым.
  • Новая структура позволяет избежать изменения состояния пользователя в качестве побочного эффекта в UserManager. Вместо этого изменения пользователя производятся явным и прозрачным образом.
  • ParentalControlManager, MarketingManager и UserManager больше не зависят друг от друга, и все они передаются в MainViewModel. Это хорошая практика для улучшения тестирования, которая позволяет использовать инъекцию зависимостей в явном виде вместо распределенной инициализации экземпляров или использования глобального состояния.

10. Высыпайтесь

Главные советы для тех, хочет писать более качественный и чистый код  —  отдыхайте и работайте по самочувствию. Если не будете достаточно отдыхать, можете легко потерять способность к принятию сложных решений и мотивацию к самосовершенствованию. Следите за балансом между работой и личной жизнью, старайтесь спать 7–8 часов в сутки и занимайтесь деятельностью, которая помогает расслабиться, например почитайте хорошую книгу (но только не с экрана монитора).

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи First I have fika, then I write apps: My Top 10 Clean Code Tips for Kotlin Mobile in 2024

Предыдущая статьяТоп-10 заданий по написанию кода для собеседования по React.js в 2024 году
Следующая статьяПредложение по стандартизации сигналов для TC39