Как правильно объявлять API устаревшими и не сломать пользователям код

В разработке ПО неизменно одно: требования меняются.

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

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

Рассмотрим, какие изменения считаются критическими, изучим методы их безопасного распространения.

Что такое «критическое изменение»?

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

Простейший пример  —  изменение имени свойства в общедоступном API: во всех компонентах имя свойства нужно поменять на новое.

Критические изменения случаются на разных уровнях:

  • Изменения кода в подписи общедоступного или защищенного API. Ими обычно вызываются ошибки в компиляторе.
  • Изменения поведения в коде. Они малозаметны: код может продолжать создаваться без ошибок, но конечные результаты отличаются от предыдущей версии. Например, в Apple поменяли версию Swift на другую, порядок возвращаемых свойством Dictionary.key ключей keys. Это чревато сбоем снапшот-теста, поскольку эталонный тест получен со старой версией системы.
  • Изменения системы сборки в способе сборки файлов или во флагах, передаваемых компилятору. Если при переходе от одной версии к другой изменить способ настройки приложения для сборки, старые версии приложения на основе новых версий системы сборки создаваться не будут.

Рассмотрим первый тип  —  изменения кода. Каждому изменению требуется внимательное и должное обращение.

Что такое «некритическое изменение»?

Не все изменения кода критические.

Устранение багов, при котором не меняются API и связанное поведение,  —  это изменение не критическое.

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

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

Рассмотрим функцию add:

func add(a: Int, b: Int) -> Int {
return a + b
}

Если появляется требование поддержки разных оснований, меняем ее так:

enum Base {
case decimal
case octal

func convert(value: Int) -> Int {
switch self {
case .decimal:
return Int(String(value, radix: 10))!
case .octal:
return Int(String(value, radix: 8))!
}
}
}

func add(a: Int, b: Int, base: Base = .decimal) -> Int {
let result = a + b
return base.convert(value: result)
}

Здесь, создавая enum, мы определяем поддерживаемые основания Base, а затем, реализуя метод convert, преобразуем число из одного основания в другое.

Передавая в качестве основания Base по умолчанию .decimal, пользователи старой версии add  —  без параметра base  —  продолжат без проблем применять ее.

Изменения в ключевых словах private и fileprivate  —  не критические. В методы и свойства, защищенные этими ключевыми словами, никак* не заглянуть. Поэтому изменения, которые там случаются, обычно безопасны.

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

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

Типы критических изменений и решения

Рассмотрим типичные изменения и как не сломать ими код пользователей.

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

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

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

Изменения свойств

Простейшее место изменений  —  это, вероятно, свойства. Рассмотрим такой класс:

class Stack<T> {
private var content: [T] = []

public init() {}

public var count: Int {
return content.count
}

public func push(_ value: T) {
content.append(value)
}

public func pop() -> T? {
guard !content.isEmpty else { return nil }
return content.removeLast()
}
}

Это классический стек Stack с операциями push и pop. В нем имеется свойство count с возвращаемым числом элементов стека.

Переименование

Переименование свойства  —  это критическое изменение. Пользователи переменной count больше не задействуют ее, так как она уже не существует.

Переименуем ее так, чтобы это было не критичным:

  1. Добавляем новое свойство с правильным именем, например size, оно должно применяться вместе со свойством count.
  2. Добавляем в свойство count аннотацию об устаревании.
class Stack<T> {
private var content: [T] = []

public init() {}

// 1. Новое свойство
public var size: Int {
return content.count
}

// 3. Добавляем аннотацию об устаревании
@available(*, deprecated, renamed: "size")
public var count: Int {
// 2. Применяем свойства count и size вместе
return size
}
// Остальная часть стека
}

Так пользователи API продолжат применять старое свойство count, видя при этом такое сообщение:

С этой версией аннотации @available переменная переименовывается автоматически благодаря возможностям Xcode. Нажав треугольник, продолжим предупреждающее сообщение:

При нажатии fix место вызова обновится в Xcode автоматически.

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

Как только этот код готов, применяем двухэтапный подход к выпуску:

  1. Выпускаем новую версию свойства с аннотацией об устаревании. Если старый фреймворк версии N, выпускаем версию N+1.
  2. Удаляем старое свойство вместе с аннотацией об устаревании и выпускаем версию N+2.

Изменение типа свойства

Другой вид изменений  —  изменение типа свойства. Что, если в count возвращается пользовательский тип StackSize:

enum StackSize {
case none
case some(value: Int)
}

class Stack<T> {
// ...
public var count: StackSize {
return content.isEmpty ? .none : .some(value: content.count)
}
// ...
}

Это критическое изменение, ведь пользователи ожидают от API count число, а получают другой объект.

Эта проблема решается подходом с двумя фазами, сначала выполняем такие этапы:

  1. Вводим новую переменную newCount с правильным типом, она должна применяться вместе со старой count.
  2. Добавляем сообщение об устаревании с просьбой пользователям задействовать newCount.
// ФАЗА 1
class Stack<T> {
// ...

public var newCount: StackSize {
return content.isEmpty ? .none : .some(value: content.count)
}

@available(*, deprecated, message: "please use newCount")
public var count: Int {
switch self.newCount {
case .none:
return 0
case .some(let value):
return value
}
}
// ...
}

// ФАЗА 2
class Stack<T> {
// ...

@available(*, deprecated, message: "please use count")
public var newCount: StackSize {
return count
}

public var count: StackSize {
return content.isEmpty ? .none : .some(value: content.count)
}
// ...
}

Выпускаем версию N+1 с аннотацией об устаревании.

Затем, чтобы переименовать newCount в count, применяем стратегию другого критического изменения  —  переименования свойства.

Далее выпускаем версии N+2, теперь уже с переименованным сообщением об устаревании, и, наконец, версию N+3, с самим критическим изменением  —  правильным новым именем.

Чтобы применить необходимое изменение, этому подходу требуется три версии, но так пользователям хотя бы дается время для перехода на финальную версию API.

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

class PetFactory {
var pet: Pet { return Dog() }
}

class Pet {}
class Dog: Pet {}

Затем меняем свойство pet:

class PetFactory {
var pet: Dog { return Dog() }
}

По сути, это изменение не критическое, собака Dog  —  ведь это тоже домашнее животное Pet, поэтому выполнение кода всегда продолжится:

let p1 = PetFactory().pet
let p2: Pet = PetFactory().pet

Удаление свойства

Удаление общедоступного свойства  —  еще одно критическое изменение. Замены не требуется, но свойство обычно лучше аннотировать в версии N+1, предупредив пользователя о том, что скоро оно будет удалено, и удалив свойство в версии N+2:

class Stack<T> {
// ...
@available(*, deprecated, message: "you should not check how many items a Stack contains")
public var count: Int {
// ...
}

Изменения методов

Вторая по частотности критических изменений сущность  —  это методы.

Для некоторых изменений методов применяются те же стратегии, что и для свойств. Например:

  • Переименование метода аналогично переименованию свойства.
  • Изменение возвращаемого типа аналогично изменению типа свойства.
  • Удаление метода аналогично удалению свойства.

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

Изменение имени параметра

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

Чтобы не ломать код, применяется аналогичная переименованию метода техника: имена параметров  —  часть сигнатуры метода, а это формальное имя метода.

Этапы здесь такие:

  1. Добавляем новый метод с этой новой сигнатурой.
  2. Чтобы реализовать логику, из старого метода вызываем первый.
  3. Аннотируем старый метод переименованной renamed аннотацией об устаревании.

На практике, в примере со стеком Stack, это выглядит так:

class Stack<t> {
// ...

// 1. Добавляем новый метод
public func push(val: T) {
content.append(val)
}

// 3. Аннотируем старый метод сообщением об устаревании
@available(*, deprecated, renamed: "push(val:)")
public func push(_ value: T) {
// 2. Из старого метода вызываем новый
push(val: value)
}
}

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

Изменение типа параметра или удаление параметра

Это очень похоже на изменение имени параметра. Единственное отличие  —  нельзя использовать переименованную версию renamed аннотации @available, поскольку место вызова не обновится в Xcode автоматически.

Здесь этапы такие:

  1. Добавляем новый метод с новой сигнатурой.
  2. Чтобы реализовать логику, из старого метода вызываем первый.
  3. Аннотируем старый метод аннотацией об устаревании deprecated.

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

class Contacts {
private var list: [String: (String, String)] = [:]

public func add(contact: (name: String, number: String)) {
list[contact.name] = contact
}
}

В объекте contacts пользователь с помощью кортежа добавляет контакт. Затем вместо кортежа вы решаете использовать структуру Contact:

struct Contact {
let name: String
let number: String
}

Чтобы безопасно изменить код контактов Contacts, следуем таким правилам:

class Contacts {
private var list: [String: Contact] = [:]

public func add(contact: Contact) {
list[contact.name] = contact
}

@available(*, deprecated, message: "This version of add is deprecated. Please call add with a Contact object")
public func add(contact: (name: String, number: String)) {
let newContact = Contact(
name: contact.name,
number: contact.number
)
add(contact: newContact)
}

}

Чтобы из старой функции add вызвать новую, преобразуем кортеж в новый объект, затем вызываем старый метод.

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

Добавление параметра

Последний тип критических изменений проявляется при добавлении параметра. Я показал способ решения этой проблемы в разделе «Что такое “некритическое изменение”?»: указываем для нового параметра разумное значение по умолчанию, и изменение будет некритическим.

Когда это невозможно, решение может быть следующим:

  1. Продолжение поддержки старой функциональности для еще одной версии.
  2. Аннотирование метода сообщением об устаревании, в котором предлагается способ использования нового метода и создания нового параметра.

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

Когда показывается предупреждение, URL-адрес делается в Xcode доступным для просмотра и пользователю остается всего один клик, чтобы узнать, что нужно для использования новых API.

Заключение

Мы рассмотрели самые распространенные типы критических изменений на уровне API. Уделили основное внимание свойствам и методам, но критические изменения случаются и при переименовании класса, структуры, протокола и перечислений. Методы реализации этих изменений схожи:

  1. Добавьте новый тип, который нужно ввести.
  2. Приведите старый тип в соответствие с новым или расширьте новый. Если это невозможно, поддерживайте в следующей версии оба типа.
  3. Добавьте поверх старого типа сообщение об устаревании.

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

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

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

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

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

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

Читайте нас в TelegramVK и Дзен


Перевод статьи Riccardo Cipolleschi: How To Deprecate APIs the Right Way

Предыдущая статьяSniper-CSS: как избавиться от неиспользуемых стилей 
Следующая статья9 странностей Python и их объяснение