Хотим мы того или нет, но современное программирование, а точнее современная разработка программного обеспечения, — это в первую очередь бизнес, а не искусство. Это бизнес, который построен на производстве программного продукта. В том или ином виде. И как в любом производстве, в нём существуют свои процессы, управленческие хитрости, критерии качества, конкуренция и истории успехов и поражений.
Я — руководитель платформенной команды в компании, одним из основных продуктов которой являются iOS, Android и веб-фреймворки. Также по совместительству я один из авторов курса по iOS-разработке в Яндекс Практикуме. В этой статье хочу поделиться одним подходом или стилем написания программного кода, который помог лично мне в трудной ситуации, описать набор инструментов, которые могут его обеспечить, поговорить о результатах, которые можно с помощью него достичь, и, разумеется, о цене, которую приходится за это заплатить. Одно из распространённых названий этого стиля — defensive programming (англ. защищённое программирование).
В статье привожу примеры кода на Swift, но это не означает, что такой же подход не может быть применим к другому языку и стеку технологий. Просто автору этой статьи удобнее приводить примеры именно из мира iOS-разработки. Ну, поехали.
Обозначаем проблему
Автоматизация и диджитализация с помощью программных продуктов проникла уже практически во все сферы нашей жизни, поэтому область, в которой будет использоваться ваш программный код, может быть любой — от приложений для конференций до систем управления автомобилем или самолётом, от обработчика фотографий до биржевых роботов, которые торгуют миллиардами рублей. И для каждого из таких проектов существует цена ошибки программиста.
Если программист допустит ошибку в приложении для редактирования фотографий и приложение начнёт падать или некорректно работать, то по большей части никто особо не пострадает. Пользователи смогут пожаловаться на проблему, ошибка будет исправлена, опубликована новая версия приложения, и всё закончится небольшим падением трафика пользователей и дырами в аналитических отчётах.
Другое дело, когда такая ошибка может привести к непоправимым последствиям — некорректной работе автопилота самолёта или утечке секретных данных. В таких случаях ошибка программиста становится практически непоправимой. К сожалению, современная история знает такие случаи, компания Boeing не даст соврать.
Как же бороться и подойти к написанию кода, если ваш проект именно такой — проект, в котором цена ошибки программиста велика?
Определение
Одним из подходов является как раз defensive programming, концепция, при которой команда разработки предполагает, что если в коде есть что-то, что гипотетически может пойти не так, то оно обязательно пойдёт не так, и в самый неподходящий момент. Звучит как Закон Мёрфи, не правда ли?
Соответственно, команда заранее обрабатывает все такие места в коде, которые гипотетически могут привести к нежелательному результату, и тем самым обеспечивает стабильность кода.
Но разве при обычной разработке программисты не должны этим же и заниматься? Учитывать все возможные крайние случаи, обрабатывать ошибки и писать безопасный и расширяемый код? Во-первых, ситуации бывают разные и проекты тоже бывают разные. На этапе прототипирования чего только не бывает, а потом этот код может стать основой будущего большого бизнеса. Во-вторых, вся суть в глубине погружения в то, что гипотетически может пойти не так. И тут нам уже не обойтись без примеров.
Примеры
1. Самая распространённая проблема, которая случается с программой, — это NullPointerException, состояние, когда мы обращаемся к объекту, а его значения уже нет в памяти. Рискну предположить, что это самая распространённая ошибка в современной истории программирования. Благо практически все языки, которые активно развиваются сейчас, предоставляют инструменты, как бороться с этой напастью. Давайте посмотрим на вот эти примеры на Swift:
var firstName: String = "Alex"
var lastName: String? = "Smith"
firstName = nil // Компилятор выдаст ошибку, так как firstName не может быть nil
lastName = nil // Компилятор не выдаст ошибку, так как lastName может иметь опциональное значение
Возможность указания того, что в переменной может быть значение, а может и не быть, поддерживается в большом количестве языков, например, в TypeScript, Dart и Kotlin, так что это не что-то Swift-специфичное. Но также разработчики языков дали возможность указать компилятору свою «уверенность» в том, что значение всё-таки не может быть нулевым и именно эта конструкция может вызвать тот самый NullPointerException:
var firstName: String? = "Alex"
var lastName: String? = nil
print(firstName!) // Выведет Alex
print(lastName!) // А тут произойдёт падение программы, так как lastName — это nil
И вот именно такие так называемые force unwrapp’ы и force cast’ы — наш первый кандидат на потенциальную проблему и, соответственно, на исключение из кодовой базы.
2. Следующей очевидной ошибкой может стать IndexOutOfBoundsException при работе с коллекциями, состояние, когда вы пытаетесь получить элемент, например, массива по индексу, который больше, чем длина этого массива. Если так сделать, то программа в большинстве языков просто упадёт:
let numbers = [1, 1, 2, 3, 5]
let someNumber = numbers[10] // Произойдёт падение программы, так как 10-го элемента в массиве не существует
Благо в Swift можно создать свой безопасный оператор, чтобы взять элемент по индексу:
extension Array {
subscript (safe index: Index) -> Element? {
0 <= index && index < count ? self[index] : nil
}
}
let numbers = [1, 1, 2, 3, 5]
let someNumber = numbers[safe: 10] // Программа не упадёт, а вернётся опционально значение
3. Еще одна распространённая ошибка, которая может привести к падению программы, — это использование небезопасных атрибутов для работы с памятью. Swift реализует концепцию «автоматического счётчика ссылок». Для того чтобы не создавать циклических ссылок на объекты, в языке есть два атрибута — weak и unknown. Первый всегда обнуляет ссылку, если объект был удалён из памяти, второй этого не делает, и именно тут кроется та самая потенциальная ошибка. Ведь как удобно не заниматься работой с опциональными значениями и просто написать что-то такое:
final class MyViewController: UIViewController {
var buttonPressClosure: (() -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
buttonPressClosure = { [unowned self] in
self.doActionOnButtonPress() // Если self к этому моменту пропадёт из памяти, то произойдёт падение программы
}
}
private func doActionOnButtonPress() {
// Действие
}
}
Но если объект, на который указывает unknown ссылка, пропадёт из памяти, а мы попытаемся к нему обратиться, то программа также упадёт. При работе с weak ссылками компилятор попросит вас сделать такую ссылку опциональной, и её можно будет корректно обработать:
final class MyViewController: UIViewController {
var buttonPressClosure: (() -> Void)?
override func viewDidLoad() {
super.viewDidLoad()
buttonPressClosure = { [weak self] in
guard let self else { return } // Обрабатываем опциональный self
self.doActionOnButtonPress() // Если self к этому моменту пропадёт из памяти, то падения уже не будет
}
}
private func doActionOnButtonPress() {
// Действие
}
}
Кстати, сами циклические ссылки тоже могут создавать нежелательные сторонние эффекты, и их также лучше исключить.
Эти примеры достаточно очевидны. Часто правила, как безопасно работать с такими конструкциями, уже существуют в серьёзных проектах. Давайте пойдём дальше в поиске потенциально опасных конструкций.
4. И первым пунктом будет принципиальное сокращение всех возможных участков кода, которые полагаются на рантайм языка. Чем больше всего может быть проверено и отсечено в процессе компиляции — тем лучше. В случае со Swift под этот принцип попадает достаточно много языковых конструкций.
Некоторые из них существуют из-за совместимости с Objective-C. Например, Key-Value Observing или сокращённо KVO. Эта языковая конструкция позволяет отслеживать изменения значения поля объекта и присылает оповещения, если значение изменилось. Для этого мы должны передать имя поля в виде строки:
final class User: NSObject {
@objc dynamic var age: Int = 0
}
final class UserObserver: NSObject {
func observe(user: User) {
user.addObserver(self, forKeyPath: "age", options: .new, context: nil)
}
override func observeValue(forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey : Any]?,
context: UnsafeMutableRawPointer?) {
if keyPath == "age", let age = change?[.newKey] {
print("New age is: (age)")
}
}
}
Но что будет, если кто-то изменит поле age, не изменив имя константы? Ничего фатального не произойдёт, но мы не узнаем, что наш обсервер перестал работать, без тестирования этого участка кода. Поэтому в Swift придумали новый синтаксис, который позволит вашей программе всегда проверять, что мы следим за изменениями существующих полей:
final class User: NSObject {
@objc dynamic var age: Int = 0
}
final class UserObserver {
private var token: NSKeyValueObservation?
func observe(user: User) {
token = user.observe(.age, options: .new) { (person, change) in
guard let age = change.newValue else { return }
print("New age is: (age)")
}
}
deinit {
token?.invalidate() // При удалении объекта надо обязательно отписаться от обсервинга
}
}
Но в новом синтаксисе появляется новая проблема: разработчику надо обязательно отписываться от наблюдения за изменениями свойства, иначе оно так и останется в памяти. Но отписываться надо всего один раз, иначе программа упадёт. То есть в такой конструкции о безопасности кода не может идти и речи.
5. Мы хотим, чтобы наш код не содержал потенциальных ошибок, поэтому должны быть уверены в каждой строчке, которую предоставляем конечным пользователем. Но как быть, если ваш проект использует код, который написан не вами? Ведь проект может содержать сторонние зависимости, и код этих зависимостей не всегда отвечает правилам безопасности, которые нужны вам в проекте. В таких случаях есть несколько вариантов:
Полный отказ от сторонних зависимостей.
В проекте останется только ваш собственный код, и вы сможете гарантировать его безопасность.Копирование исходного кода зависимостей в проект и исправление этого кода.
Код зависимости становится уже вашим, и вы можете гарантировать его безопасность. Важно не забывать смотреть на лицензию этой зависимости, не с каждым кодом такое можно сделать легально. Также обновлять зависимость будет проблематично.Исправить код сторонней зависимости в отдельном форке репозитория.
Код будет отвечать вашим требованиям. Также будет возможность обновлять эту зависимость и предлагать изменения разработчикам самой зависимости, чтобы не пользоваться форком.
6. Большое количество скрытых проблем возникает в приложениях, которые написаны на нескольких языках, в случае iOS это могут быть Swift, Objective-C, C и C++, если мы говорим о нативных языках. Проблемы могут возникать на стыке языков, то есть когда мы используем типы и функции одного языка в другом. Например, указатели из C в Swift-коде. Самый очевидный ответ — мигрирование кодовой базы на один язык. Если по каким-то причинам это невозможно, то целью должна стать максимальная изоляция кодовых баз, написанных на разных языках, друг от друга и взаимодействие между ними через безопасный и тщательно оттестированный протокол.
Эти примеры уже достаточно необычные и радикальные, но давайте пойдём еще дальше и попробуем найти откровенно безумные претензии к конструкциям в коде, которые мы используем каждый день, но которые тем не менее могут привести к проблемам в программах.
7. В Swift есть 4 типа объектов: классы, структуры, перечисления и акторы. Акторы и классы являются ссылочными типами данных, структуры и перечисления передаются по значению. Обычно, если в программе есть какой-то стейт — значение, которое надо сохранять и изменять в течение работы программы, то оно сохраняется в каком-то классе или акторе. Наступает потенциальная проблема, если программа работает в многопоточной среде (а теоретически любая программа работает в многопоточной среде), то хранить значения в классе может быть не очень безопасно, потому что сразу несколько потоков могут работать с этим классом и асинхронно переписывать значения переменной:
final class Counter {
var count = 0
func updateCounter(newNumber: Int) {
count = newNumber
}
}
let counter = Counter()
// Мы можем легко напрямую менять поле count из разных потоков
DispatchQueue.global().async {
counter.count = 1
}
DispatchQueue.global().async {
counter.count = 2
}
counter.count = 3
Обычно такую проблему решают просто — все операции с переменными, которые могут быть изменены из разных потоков, должны проходить синхронно, для этого во всех языках есть инструменты. Но цель defensive programming — исключить даже гипотетическую возможность ошибки. Ведь разработчик может просто забыть обратиться к переменной синхронно. Эту проблему как раз решают акторы — программа просто не соберётся, если не написать работу с переменной корректным образом:
actor Counter {
var count = 0
func updateCounter(newNumber: Int) {
count = newNumber
}
}
let counter = Counter()
counter.count = 10 // Компилятор не даст синхронно изменить значение поля count
await counter.updateCounter(newNumber: 2) // И даже функция, которая изменяет значение count, должна быть вызвана асинхронно
Получается, само наличие классов в коде программы является потенциальной проблемой? Кажется, что да. Но ведь акторы не поддерживают наследование! И это ещё одна потенциальная проблема классов. Разработчик может переписать поведение какого-то метода и забыть вызвать метод предка. Это можно решить, используя атрибут final
для классов, который не даст возможность наследования от класса и заставит разработчиков использовать, например, композицию объектов.
Приведённые выше случаи — только примеры, и далеко не всё, что можно выделить. Как в iOS-разработке, так и на других платформах и языках можно найти много таких конструкций, которые потенциально могут привести к проблемам. Но ведь это всё нужно, только когда потенциальная ошибка разработчика стоит очень дорого, правда? По умолчанию да, но есть и другие случаи, когда defensive programming или какие-то его элементы могут помочь.
Когда ещё может понадобиться defensive programming?
Хочется выделить ещё несколько сценариев использования defensive programming, которые чаще могут встретиться в реальной жизни.
Разработка продукта, для которого получить сообщения и отчёт об ошибках мы, как разработчики, физически не можем, в частности — crash-логи. Пример такого проекта — разработка фреймворков.
Если ваша команда разрабатывает iOS-фреймворк, который потом будут использовать разработчики в своих продуктах, то не существует физической возможности получить отчёты о падениях кода фреймворка. Единственный способ — ждать, когда о проблемах вам сообщат пользователи. В таких случаях приходится действовать на опережение и быть проактивным — просто убрать теоретическую возможность возникновения таких проблем.
Еще один сценарий — это проекты с большим техническим долгом и проблемами со стабильностью. Если ваш проект такой, то, возможно, defensive programming сможет помочь в короткие сроки навести какой-то порядок.
Вместо того чтобы разбираться с каждой проблемой по отдельности, можно запретить к использованию в коде проекта потенциально опасные конструкции. Также модуль за модулем, файл за файлом исправить все потенциально опасные участки в текущем коде. Это не решит проблему технического долга, зато заметно повысит стабильность продукта и обеспечит эту стабильность на время исправлений, которые нужно внести, чтобы исправить технический долг.
А теперь давайте поговорим об инструментах, которые помогут найти проблемы в коде и не позволят возникать такому коду в дальнейшем.
Инструменты
Основными инструментами для поиска и исправления проблем в коде являются линтеры, форматтеры кода и непосредственно компилятор.
Форматтеры приводят код к единообразному виду, а также исправляют очевидные простые проблемы в коде.
Линтеры находят большие и комплексные проблемы в коде и могут заблокировать сборку программы, если эти проблемы критичны.
Компилятор содержит большое количество как стандартных, так и дополнительных проверок, которые не позволят вам собрать программу, если она не отвечает определенным требованиям.
Для iOS самым распространённым форматтером является SwiftFormat, а линтером — SwiftLint. Тут можно прочитать про дополнительные настройки компилятора.
Важно, чтобы нарушение правил, которые вы определили для вашего форматтера, линтера и компилятора, не давало возможности собрать ваш проект. Тогда это исключит потенциальную возможность попадания кода, нарушающего эти правила, в общий репозиторий и тем более в продакшен.
Личный опыт
В моём опыте работы случились сразу все три аспекта, при которых стоит воспользоваться defensive programming, — iOS-фреймворк с большим техническим долгом и достаточно дорогой стоимостью ошибки, а также не очень большим временем на исправление текущей ситуации в связи с реалиями рынка.
Решением было вводить элементы defensive programming как можно скорее. В результате за два месяца получилось кардинально изменить ситуацию в проекте и тем самым улучшить показатели самого бизнеса.
Технический долг остался, но перестал негативно влиять на сам продукт. Его исправлением мы успешно занимаемся параллельно с разработкой новой функциональности. Когда вы пишете код — никогда не забывайте, что в первую очередь он должен решать бизнес-задачи, а уже потом быть красивым и выразительным.
Цена
Может показаться, что defensive programming — это «серебряная пуля» от большинства проблем, которые могут случиться с проектом. Но, разумеется, за всё приходится платить, и плата за defensive programming достаточно высока.
В первую очередь на обработку даже самых невероятных сценариев работы программы нужно тратить много времени, а объём кодовой базы может значительно увеличиваться.
Во-вторых, при обработке всех возможных сценариев может начать страдать быстродействие самой программы и другие метрики, например размер приложения, — для некоторых проектов это критично.
Многие удобные и понятные языковые конструкции, которые разработчики привыкли использовать каждый день, попадают «под нож», и приходится заново привыкать к языку программирования и платформе, под которую ведётся разработка. Это может негативно отразиться на настроении команды, и важно заранее объяснить, зачем нужны такие радикальные правила в данный момент.
В любом случае технические лидеры, которые будут принимать решение о внедрении такого подхода, должны взвесить все за и против, оценить стоимость ошибки программиста, понять настроения команды и цену, которая будет заплачена за внедрение такого подхода. И только потом принимать решение.
Заключение
В этой статье мы посмотрели на один спорный, но временами эффективный стиль разработки программного обеспечения — defensive programming. Основным контраргументом обычно выступает посыл, что проблема не в коде, а в неумении программистов нормально пользоваться инструментами, мол, «нормально делай — нормально будет». Но как показывает моя практика, далеко не во всех ситуациях такой посыл применим, ведь не бывает идеальных людей и идеальных проектов.
Я надеюсь, что для начинающих программистов эта статья даст понимание того, что в программировании всегда есть место для разных производственных концепций и что всегда есть куда углубляться в безопасность кода. А для опытных разработчиков статья покажет образ мышления, который поможет с вашим проектом в критических ситуациях. Буду рад услышать обратную связь, обсудить подход в комментариях и ответить на вопросы.