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

Я — руководитель платформенной команды в компании, одним из основных продуктов которой являются 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. Основным контраргументом обычно выступает посыл, что проблема не в коде, а в неумении программистов нормально пользоваться инструментами, мол, «нормально делай — нормально будет». Но как показывает моя практика, далеко не во всех ситуациях такой посыл применим, ведь не бывает идеальных людей и идеальных проектов.

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

Комментарии (2)


  1. IOlga1
    13.06.2023 10:18
    +1

    Спасибо автору за полезную статью.
    Пишу на TS и действительно поначалу встречала ошибки которые возникали из-за того, что я пользовалась "всеми благами" typecript и его гибкостью в настройке, и тем самым утихомиривала стогую типизацию (ошибки молодости). В самый неподходящий момент приходил null - я была беззащитна перед компилятором и перед боссом))


    1. kitako4
      13.06.2023 10:18

      Ни Вы, ни автор статьи так и не сообщили, что делать, когда может "прийти null".