Как работает проверка доступности API в Swift

Мы постоянно применяем проверки на доступность API, чтобы обеспечить откаты ПО для пользователей, использующих старые версии iOS. А задавались ли вы вопросом, как эту процедуру обрабатывает компилятор Swift? В этой статье мы углубленно изучим внутреннее функционирование условия #availability, выясним, откуда компилятор узнает, доступен ли определенный символ для использования, и как выглядит написанный код после оптимизации. 

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

Почему проверки #available необходимы?

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

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

Xcode 12 includes Swift 5.3 and SDKs for iOS 14, iPadOS 14, tvOS 14, watchOS 7 and maccOS Catalina.

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

if #available(iOS 14.0, *) {
    SomeiOS14NewType()
} else {
    SomeOlderType()
}

Здесь (iOS 14.0, *) означает “если это устройство iOS, вернуть true, только если оно содержит iOS 14 SDK. Всегда возвращать true, если это другая платформа (*)”.

Вы можете использовать только те платформы, которые жестко закодированы в Swift (iOS, OSX, tvOS и watchOS), но при этом в выборе их версии вы не ограничены. Для препятствия же выполнению того или иного кода в компиляторе Swift используются абсурдные номера версий:

if #available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) {
  expectTrue(isP(CFBitVector.makeImmutable(from: [10, 20])))
  expectTrue(isP(CFMutableBitVector.makeMutable(from: [10, 20])))
}

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

@available(iOS, introduced: 999)
final class HologramCreator {}

HologramCreator()
// 'HologramCreator' доступен только в iOS 999 или новее

Как работает определение доступности

В компиляторе доступность символов оценивается в фазе проверки типов. 

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

Контексты уточнения типов для узлов AST

Конечно, проверка доступности вызываемого вами типа тоже входит в эту фазу. Для выполнения этой проверки компилятор создает контексты уточнения типов (Type Refinement Contexts), являющиеся особыми структурами, способными содержать любую подходящую дополнительную информацию, которая должна присутствовать в области. На данный момент это используется только для интересующих нас символов. 

Данный процесс начинается, когда компилятор хочет выполнить проверку типов инструкции, содержащей проверку доступности. Давайте рассмотрим пример:

if #available(iOS 14.0, *) {

} else {

}

В этой фазе цель компилятора  —  найти любые условия доступности и при необходимости создать подходящий уточняющий контекст. Из каждого условия компилятор извлекает данные о собираемой в данный момент платформе и пробует создать диапазон допустимых номеров версий. В этом случае диапазоном будет просто minimumTarget...iOS 14, при этом ветка else будет хранить свой родительский уточняющий контекст, если только ваше условие не будет проверять на контекст меньшего уровня, чем текущий (что будет наоборот понижать его).

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

if #available(iOS 14.0, *) {
 // Доступность символа: minimumTarget...iOS 14
} else {
 // Доступность символа: 0...minimumTarget (The default)
}

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

if #available(iOS 14.0, *), #available(iOS 13.0, *) {
 // (iOS 13.0) Необязательная проверка на 'iOS'; охватывающая область гарантирует, что guard всегда будет true
 // Доступность символа: minimumTarget...iOS 14
} else {
 // Доступность символа: 0...minimumTarget (The default)
}

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

if #available(iOS 9.0, *) {
  // Доступность символа: minimumTarget...iOS 9
  if #available(iOS 13.0, *) {
    // Доступность символа: minimumTarget...iOS 13
  } else {
    // Доступность символа: minimumTarget...iOS 9
  }
} else {
  // Доступность символа: 0...minimumTarget (The default)
}

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

// Стек уточняющего контекста: [MinimumTarget]
if #available(iOS 9.0, *) {
  // Push: iOS 9 ([MinimumTarget, iOS 9])
  if #available(iOS 13.0, *) {
    // Push: iOS 13 ([MinimumTarget, iOS 9, iOS 13])
  } else {
    // Pop: iOS 13 ([MinimumTarget, iOS 9])
  }
} else {
  // Pop: iOS 9 ([MinimumTarget])
}

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

guard #available(iOS 14, *) else {
    // Доступность символа: 0...minimumTarget (The default)
    return
}
// Доступность символа: minimumTarget...iOS 14

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

let isAvailable: Bool = #available(iOS 13.0, *)
if isAvailable {
  // ?????
}
// Ошибка: #available можно использовать только в качестве условия инструкций 'if', 'guard' или 'while'

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

Определение доступности символа

Создав контекст уточнения типов, компилятор может проверить доступность чего-либо сопоставлением текущего статуса доступности проверяемого элемента с верхним контекстом стека уточнений. Доступность рассматриваемого объявления определяется наличием в его типе атрибута @availability. Если таковой отсутствует, тип будет доступен всегда:

Optional<AvailabilityContext> AnnotatedRange = annotatedAvailableRange(D, Ctx);
if (AnnotatedRange.hasValue()) {
  return AnnotatedRange.getValue();
}
// Рассматривать неаннотированные объявления как всегда доступные.
return AvailabilityContext::alwaysAvailable();

Чтобы проверить, доступно или нет конкретное объявление, компилятор извлекает его текущий уточняющий контекст и проверяет, содержится ли он в собственном диапазоне доступности этого объявления. Именно здесь в процесс вступает минимальная целевая система развертки приложения: если уточняющий контекст отсутствует (так как условия доступности еще не рассматривались), то компилятор создаст такой, в котором минимальной целевой версией будет значиться последняя возможная. 

И наконец, если эта проверка доступности возвращает false, компилятор выдаст ошибку и предложит исправление, включающее добавление условия доступности:

// Код несколько изменен для лучшей читаемости
bool TypeChecker::isDeclAvailable(const Decl *D,
                                  const DeclContext *referenceDC) {
  ASTContext &Context = referenceDC->getASTContext();
  AvailabilityContext declAvailability{
      AvailabilityInference::availableRange(D, Context)};
  AvailabilityContext currentAvailability =
      overApproximateAvailabilityAtLocation(referenceDC);
  return currentAvailability.isContainedIn(declAvailability);
}

Кроме того, если вы создаете что-либо вне Xcode, то минимальной целевой платформой будет текущая версия того, в чем вы работаете. Например, при выполнении скриптов .swift минимальной целью будет версия вашей macOS:

/// Возвращает минимальную версию платформы, в которой будет развернут код.
///
/// Реализуется только на определенных ОС. Если цель не была 
/// настроена, возвращает v0.0.0.
llvm::VersionTuple getMinPlatformVersion() const {
  unsigned major = 0, minor = 0, revision = 0;
  if (Target.isMacOSX()) {
    Target.getMacOSXVersion(major, minor, revision);
  } else if (Target.isiOS()) {
    Target.getiOSVersion(major, minor, revision);
  } else if (Target.isWatchOS()) {
    Target.getOSVersion(major, minor, revision);
  }
  return llvm::VersionTuple(major, minor, revision);
}

Перевод #available в логическое значение

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

// До
if #available(iOS 14.0, *) {
}
// После
if _stdlib_isOSVersionAtLeast(14, 0, 0)
}

Очевидно, что _stdlib_isOSVersionAtLeast работает путем определения текущей версии ОС и проверки ее соответствия переданному значению. Вот как компилятор пробует определить текущую версию ОС:

static os_system_version_s getOSVersion() {
  auto lookup =
    (int(*)(struct os_system_version_s * _Nonnull))
    dlsym(RTLD_DEFAULT, "os_system_version_get_current_version");
  struct os_system_version_s vers = { 0, 0, 0 };
  lookup(&vers);
  return vers;
}

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

swiftc -emit-sil myFile.swift

Выполнив это, вы увидите, что все условия доступности заменены на низкоуровневую проверку версии ОС.

// function_ref _stdlib_isOSVersionAtLeast(_:_:_:)
%5 = function_ref @$ss26_stdlib_isOSVersionAtLeastyBi1_Bw_BwBwtF

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

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Bruno Rocha: How Swift API Availability Works Internally