Что такое Runtime?
Наверное, вы уже использовали словосочетание «Библиотека Runtime». Можно предположить, что это и есть какая-то библиотека, а значит — у неё есть исходный код. Очевидно, что он находится в репозитории Swift.
Идём туда и видим кучу-кучу-кучу каталогов файлов. Когда я туда попал, почувствовал себя как маленький малыш Йода — стало немножко страшно и неуютно.
Если вдруг вы там ещё не бывали, то вот вам мини-гайд. Самое интересное, что там можно посмотреть, это:
/doсs
— документация: README-файлики по различным тематикам. Но она не особо полная. Чтобы вы понимали, насколько не полная, то иногда, после долгих поисков, вы найдёте тот самый README-файлик, ту самую секцию, а там стоит TODO-шка из разряда «Саня! Не забудь дописать, мы тут оставили»/lib
— исходники самого компилятора. Наверное, это самая сложная часть, ведь понять, как работает компилятор не просто. Поэтому сюда я бы рекомендовал залезать в самом конце. Самое любопытное лежит в каталоге/stdlib/public/
./stdlib/public/core
— стандартная библиотека./stdlib/public/runtime
— Рантайм! Его-то мы и искали.
Далее открываем исходный код — то, что лежит в каталоге /stdlib/public/runtime
.
И сначала немножко путаемся, потому что глазу не за что зацепиться — какие-то незнакомые функции. Но я потратил некоторое время и накопал вот такие функции, например:
HeapObject *swift::swift_nonatomic_retain(HeapObject *object)
Она принимает один объект и один объект отдаёт.
По названию и по телу функции можно предположить, что это работа механизма ARC — мы видим там какой-то инкремент ссылки.
if (isValidPointerForNativeRetain(object))
object->refCounts.incrementNonAtomic(1);
return object;
Там же можно накопать аналогичные функции для strong ссылок, для weak ссылок и т.д.
Идём дальше и находим вот такую функцию:
/// Dynamically cast a class metatype to a Swift class metatype.
static const ClassMetadata *
_dynamicCastClassMetatype(const ClassMetadata *sourceType,
const ClassMetadata *targetType)
Судя по названию, она выполняет динамическое преобразование одного класса к другому, и в теле…
do {
if (sourceType == targetType) {
return sourceType;
}
sourceType = sourceType->Superclass;
} while (sourceType);
return nullptr;
… просто проходится по супер-классам вверх, что полностью соответствует названию и нашему представлению о работе такого механизма в Swift. Можно предположить, что это нечто похожее на as?
в нашем Swift.
Так что такое Runtime?
Возвращаясь к вопросу «Что такое Runtime», можно сказать, что это написанная на C++ библиотека, которая занимается обслуживанием встроенных в сам язык Swift функций. В частности, тут можно выделить как минимум две больших категории: работа с памятью (ARC), и работа с типами данных.
Конечно, там есть и другие функции, но они не представляют такого интереса в рамках данного разбора Runtime’а языка.
Теперь переходим к главному вопросу — как происходит это взаимодействие?
Где появляется Runtime и зачем?
Для поиска ответа нам придется погрузиться в процесс компиляции. Как обычно она у нас происходит? У нас есть исходный код → мы нажимаем Command+B → магия → получаем то, что можно запустить.
Но хватит шуток, мы все понимаем, что в реальности там довольно много этапов. Если очень упрощенно их описать, то можно выделить вот такие:
AST — абстрактное синтаксическое дерево.
SIL — Swift Intermediate Language.
IR — Intermediate Representation.
При этом важно подчеркнуть, что к компилятору Swift относятся первые три шага (поэтому они выделены). Дальше, когда компилятор выдает то, что называется IR (Intermediate Representation), он отдаёт это в LLVM и там оно уже преобразуется в объектный файл. Поэтому мы будем рассматривать первые три шага.
Я бы ещё откинул AST, потому что, фактически, это скорее результат парсинга исходного кода и он не представляет достаточного интереса для изучения. Если вам кажется, что этот этап имеет значение, напишите в комментариях, возможно, я заблуждаюсь.
Ищем зацепки
Runtime реализует работу с памятью. Напишем довольно простой исходный код, в котором точно будет ARC. Скомпилируем и посмотрим, как он выглядит на уровне SIL и IR.
Где будем искать? Здесь.
class MyClass {}
func main() {
let object = MyClass()
}
main()
Конкретнее — внутри тела метода main. Там происходит создание и уничтожение объекта, что должно сопровождаться инкрементом и декрементом ссылки на него.
let object = MyClass()
Вот функция main на уровне SIL.
// main()
sil hidden @$s4mainAAyyF : $@convention(thin) () -> () {
bb0:
%0 = metatype $@thick MyClass.Type
// function_ref MyClass.__allocating_init()
%1 = function_ref @$s4main7MyClassCACycfC : $@convention(method) (@thick MyClass.Type) -> @owned MyClass
%2 = apply %1(%0) : $@convention(method) (@thick MyClass.Type) -> @owned MyClass
debug_value %2 : $MyClass, let, name "object"
strong_release %2 : $MyClass
%5 = tuple ()
return %5 : $()
} // end sil function '$s4mainAAyyF'
Разберём её по строкам.
Первое, что происходит — берётся сам класс, с помощью которого дальше будет создан экземпляр...
%0 = metatype $@thick MyClass.Type
Дальше вызывается конструктор, куда передается метатип. В итоге мы получаем экземпляр класса.
// function_ref MyClass.__allocating_init()
%1 = function_ref @$s4main7MyClassCACycfC : $@convention(method) (@thick MyClass.Type) -> @owned MyClass
%2 = apply %1(%0) : $@convention(method) (@thick MyClass.Type) -> @owned MyClass
Ну и третья строчка — самая важная.
strong_release %2 : $MyClass
Мы видим, что компилятор проставил некое ключевое слово strong_release
. То есть на уровне SIL неявный механизм работы ARC стал явным.
Но мы понимаем, что это лишь ключевое слово — в дальнейшем оно может быть преобразовано во всё, что угодно. Во что, нам неизвестно, поэтому идём дальше и смотрим на IR.
define hidden swiftcc void @"$s4file4mainyyF"() #0 {
entry:
%object.debug = alloca %T4file7MyClassC*, align 8
%0 = bitcast %T4file7MyClassC** %object.debug to i8*
call void @llvm.memset.p0i8.i64(i8* align 8 %0, i8 0, i64 8, i1 false)
%1 = call swiftcc %swift.metadata_response @"$s4file7MyClassCMa"(i64 0) #4
%2 = extractvalue %swift.metadata_response %1, 0
%3 = call swiftcc %T4file7MyClassC* @"$s4file7MyClassCACycfC"(%swift.type* swiftself %2)
store %T4file7MyClassC* %3, %T4file7MyClassC** %object.debug, align 8
call void bitcast (void (%swift.refcounted*)* @swift_release to void (%T4file7MyClassC*)*)(%T4file7MyClassC* %3) #2
ret void
}
Я не буду здесь показывать каждую строку, потому что это будет слишком подробно. Лишь подчеркну самое важное.
В последней строке мы видим вызов функции @swift_release. А чуть ниже есть декларация (именно декларация, без определения) функции:
declare void @swift_release(%swift.refcounted*) #2
Таким образом получается, что компилятор на месте ключевого слова strong_release оставил вызов неизвестной нам функции swift_release, реализация которой будет найдена уже на этапе линковки.
Предполагаем, что эта функция есть в исходном коде Runtime’а и идём искать там. Благодаря обычному поиску по тексту находится вот такая функция:
static void _swift_release_(HeapObject *object)
И всё, что происходит внутри, это декремент ссылки:
if (isValidPointerForNativeRetain(object))
object->refCounts.decrementAndMaybeDeinit(1);
Ура! Мы нашли то, что искали. Осталось только собрать всё воедино.
Общая картина
Итак, если составлять общую картину на основе того, что мы изучили, можно описать процесс примерно так.
У нас есть исходный код (сверху слева).
На уровне SIL компилятор явно реализует неявные внутренние механизмы языка Swift.
На уровне IR компилятор преобразует ключевые слова из SIL в конкретные вызовы Runtime’а (упрощённое описание).
Динамический Линковщик соединяет наш вызов функции с её реализацией в библиотеке Runtime’а, которая есть в системе.
Ответ на второй вопрос «Где появляется Runtime и зачем?» будет таким: «Компилятор неявно для нас проставляет вызовы к Runtime библиотеке там, где это требуется для реализации встроенных в язык Swift функций. Например, ARC или работа с типами данных. Во время динамической линковки эти вызовы соединяются с реализацией»
Итак, Swift Runtime…
Если подводить некоторые итоги, в целом, по Runtime, то можно сказать, что:
Это библиотека, написанная на языке C++.
Она реализует внутренние механизмы работы самого языка Swift.
Принцип работы основан на внедрении вызовов на этапах компиляции.
Интересные факты
В процессе изучения исходного кода я наткнулся на несколько любопытных решений в языке Swift, которыми хотел бы поделиться.
Сломать всё одной функцией
Когда я изучал символьные таблицы у полученных объектных файлов, я задался вопросом: а что если в мой исходный код добавить функцию swift_release
? Ведь компилятор, проставляя вызов, рассчитывает, что функция с таким названием найдётся только в Runtime библиотеке. А я возьму и создам свою функцию с аналогичным именем. Что будет?
Я добавил в свой код вот такую функцию, которая принимает один параметр (как и в требуемой сигнатуре) и печатает строку “Release”
.
func swift_release(_ objet: AnyObject) {
print("Release")
}
С первой попытки сломать всё у меня не получилось, но я продолжил и кое-что выяснил: в символьной таблице указано не просто имя функции, а её идентификатор, называемый mangled name. По сути, это строка, которая содержит в себе всё описание сигнатуры функции, включая язык программирования, имя файла, принимаемые параметры и тип возвращаемого значения.
И вот моя функция swift_release
на уровне символьной таблице уже имела совершенно другое имя:
func swift_release(_ objet: AnyObject)
↓
"$s4file13swift_releaseyyyXlF"
В итоге из-за того, что я не учёл ‘name mangling’
, моя функция и не была слинкована с тем самым вызовом. Но в Swift есть возможность переопределить это поведение с помощью специального атрибута:
@_silgen_name("swift_release")
func swift_release(_ objet: AnyObject) {
print("Release")
}
Дальше, при запуске моей программы с такой функцией в исходном коде, произошла магия — хотя эта функция ниоткуда не вызывалась, в консоль печаталась строка “Release”! И, очевидно, все объекты просто перестали уничтожаться.
Что ещё интереснее — если то же самое сделать с функцией swift_retain
, то при запуске программы вы получите ошибку сегментации. Причина — теперь все объекты не могут произвести инкремент ссылки, из-за чего получается некоторая несостыковка состояния памяти. Как по мне, это очень забавно.
Исключения type-checker’а
Дальше расскажу о том, что мне понравилась, наверное, больше всего.
Предполагаю, что вы пользовались функцией type(of:)
. Вот так выглядит её сигнатура:
public func type<T, Metatype>(of value: T) -> Metatype
И вот, что интересно, она ведь реализована в стандартной библиотеке, которая, в свою очередь, написана на Swift. Но возникает вопрос — а как её реализовать?
А если глянуть на реализацию в стандартной библиотеке, мы увидим такой комментарий:
// This implementation is never used, since calls to `Swift.type(of:)` are
// resolved as a special case by the type checker.
Builtin.unreachable()
По комментарию можно понять, что на самом деле эта функция, можно сказать, ненастоящая, и вызовы к ней обрабатываются каким-то особым образом.
Стоит ещё заменить необычный атрибут @_semantics("typechecker.type(of:)")
- он понадобится чуть позже.
@_semantics("typechecker.type(of:)")
public func type<T, Metatype>(of value: T) -> Metatype
Идём искать в исходник компилятора. И что мы там видим?
DeclTypeCheckingSemantics
TypeChecker::getDeclTypeCheckingSemantics(ValueDecl *decl) {
// Check for a @_semantics attribute.
if (auto semantics = decl->getAttrs().getAttribute<SemanticsAttr>()) {
if (semantics->Value.equals("typechecker.type(of:)"))
return DeclTypeCheckingSemantics::TypeOf;
if (semantics->Value.equals("typechecker.withoutActuallyEscaping(_:do:)"))
return DeclTypeCheckingSemantics::WithoutActuallyEscaping;
if (semantics->Value.equals("typechecker._openExistential(_:do:)"))
return DeclTypeCheckingSemantics::OpenExistential;
}
return DeclTypeCheckingSemantics::Normal;
}
Функция, которая парсит тот самый атрибут @_semantics
и для трёх уникальных значений выдаёт три уникальных способа обработки вызова к функции. Или тип ‘Normal’, имя в виду обычный вызов обычной функции.
Чтобы больше не тратить ваше время на погружения в исходный код компилятора поясню это таким образом: в языке Swift есть три функции-исключения, особенности которых связаны с одновременной необходимостью быть доступными прямо из Swift-кода и невозможностью реализации в самом Swift-коде. Поэтому для таких трёх функций компилятор делает особое исключение - в стандартной библиотеке находится, по сути, декларация, с пустым телом, а на этапе компиляции вызовы к таким функции заменяются специальными конструкциями.
По сути, эти три функции-исключения скорее стоит определить, как инструмент самого языка программирования (подобно as?
, await
и тд), который просто для нашего с вами удобства представлен не в виде особого синтаксиса, а в виде обычной функции.
Магия AnyHashable
Последний занимательный факт, который я нашел, связан с AnyHashable
.
Возьмём самую обычную конструкцию из структуры, которая реализует протокол Hashable
, и переменной типа AnyHashable
, который мы присваиваем её экземпляр.
struct Model: Hashable {}
let hashable: AnyHashable = Model()
Казалось бы, что в этом необычного? А вот то, что AnyHashable
— это структура. Поэтому возникает вопрос, каким образом мы присваиваем переменной с типом структуры другую структуру?
@frozen
public struct AnyHashable {
internal var _box: _AnyHashableBox
internal init(_box box: _AnyHashableBox) {
self._box = box
}
}
Оказывается, если посмотреть SIL, то можно увидеть, как компилятор «заботливо» оборачивает правую сторону выражения в функцию _convertToAnyHashable
, благодаря которой у нас и получается бесшовное присвоение одной структуры в переменную другого типа.
Почему я нахожу это забавным? Потому что это по-своему уникальное исключение из общего принципа работы языка.
Итог
У меня давно было желание разобраться в тех вопросах, что я описал в статье. Но подтолкнул меня к ней доклад моего коллеги Максима Крылова (на его основе Максим подготовил статью). Ведь если он может рассказать на большую аудиторию о своих исследованиях, значит и я могу. И я хочу верить, что для кого-нибудь из вас моя статья также станет отправной точкой вашего собственного исследования.
Если у вас есть вопросы, пожелания, проклятия — пишите в комментариях, буду отвечать по мере возможностей.
Рекомендованные статьи:
Хочу в iOS-разработку: к чему готовиться на собеседовании в продуктовую команду
100 дней из жизни новичка: как устроен онбординг в мобильной разработке
Как катить фичи без релизов. Часть 2: про низкоуровневый Server Driven UI
Как снимать логи с устройств на Android и iOS: разбираемся с инструментами
Также подписывайтесь на Телеграм-канал Alfa Digital — там мы постим новости, опросы, видео с митапов, краткие выжимки из статей, иногда шутим.