Найти тему
Nuances of programming

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

Оглавление

Источник: Nuances of Programming

Мы постоянно применяем проверки на доступность 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, которое позволит обеспечить подходящий откат для устройств с более старыми версиями.

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

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

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

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

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

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

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

-2

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Если вам интересно увидеть, как это происходит на практике, то можете попросить компилятор отправить промежуточный язык 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