Программирование
Небольшое предупреждение SwiftUI и долгий путь к его пониманию
По-настоящему понять такое можно только тогда, когда перестаешь мириться с предложенным решением и начинаешь задаваться вопросом, почему оно работает.
Я переходил на более новую версию The Composable Architecture, а это означало, что нужно было исправить ряд устаревших функций. Одним из пунктов этого списка было добавление InferSendableFromCaptures в качестве флага будущей функции Swift для всех наших таргетов.
SE-0418 действительно интересен. Он делает так, чтобы ссылки на методы более точно участвовали в проверке возможности отправки при использовании в качестве значений. Это одно из тех предложений, которое кажется очевидно правильным, как только вы его прочтете.
Я добавил его во время подготовки к Swift 6, надеясь выявить проблемы до начала работы над TCA 2.0. Я был готов исправлять код. Но я не был готов к еще одному путешествию в мир concurrency. К такому путешествию, которое бросает вызов тому, что я уже знал, просто чтобы убедиться, что я действительно это знаю.
Это один из тех постов, которые я пишу, потому что писать — значит думать. Даже рискуя ошибиться. Это даже лучше, потому что я узнаю больше. Поэтому не воспринимайте это как руководство или истину. Используйте критическое мышление, читая дальше.
Предупреждения
Появилось множество предупреждений одинаковой формы, все они возникали в местах, где я передавал инициализатор представления SwiftUI в качестве ссылки на функцию модификатору навигации.
.navigationLinkDestination(
item: $store.scope(...),
destination: ChildView.init(store:) // ⚠️
)
Предупреждение выглядело примерно так: «вызов основного инициализатора init(store:), изолированного от актора, в синхронном неизолированном контексте» (call to main actor-isolated initializer init(store:) in a synchronous nonisolated context).
Чтобы понять, что происходит, мне пришлось нормально разобраться, что именно меняет SE-0418 в ссылках на функции (function references). Предупреждение возникает, потому что ChildView.init(store:) — это не просто «функция, которая создаёт view». ChildView соответствует протоколу SwiftUI View, а это делает его инициализатор изолированным в MainActor. При этом параметр destination: ожидает @escaping (StoreOf) -> some View, без аннотации @MainActor. Когда я передаю инициализатор напрямую, я фактически прошу компилятор использовать main actor-изолированный инициализатор как обычный синхронный колбек. В режиме Swift 5 это ещё допускается, но появляется warning о том, что изоляция не соответствует контексту, в котором я её использую.
По крайней мере, это была первая модель в моей голове. Звучало достаточно убедительно…
Когда я посмотрел на это с такой точки зрения, решение стало очевидным: просто обернуть в замыкание.
.navigationLinkDestination(
item: $store.scope(...),
destination: { ChildView(store: $0) } // ✅
)
Значит, всё готово, да? Я применил это решение везде, но по ходу дела мой любопытный ум продолжал задаваться вопросом: а почему?
Почему closure сработал? В обоих случаях вызывается один и тот же инициализатор. В обоих случаях в итоге создаётся один и то же представление @MainActor. Почему один вариант валиден, а другой — нет?
SE-0418 — пропозал, лежащий в основе InferSendableFromCaptures — добавляет type inference именно для ссылок на функции и методы (function/method references), когда они используются как значения.
До включения фиче-флага ChildView.init(store:), используемый как значение, «проскальзывал» как non-sendable function value. После включения — ссылка начинает нести больше информации о декларации. Поскольку инициализатор изолирован в MainActor, ссылка на функцию тоже считается main actor-isolated.
И это корректное поведение. Инициализатор помечен как @MainActor — значит, и ссылка должна это отражать. Но параметр, в который я его передаю, имеет тип @escaping (StoreOf) -> some View, без аннотации @MainActor. Поэтому, в моей первоначальной интерпретации, компилятор ругался, потому что ссылка на функцию теперь «притащила» с собой main actor isolation, а параметр этого не ожидает.
С closure-литералами всё работает иначе. У closure нет заранее существующей function reference, которую нужно «впихнуть» в тип параметра. Компилятор создаёт её с нуля, под конкретный тип параметра — @escaping (StoreOf) -> some View — и уже затем проверяет тело closure в месте объявления. Предупреждение не возникает, потому что компилятор больше не пытается использовать сам изолированный инициализатор как значение колбека.
Это вроде бы объясняет разницу между двумя синтаксисами. Но при этом остаётся более глубокий вопрос.
Вопрос, который меня зацепил
Если closure-литерал создаётся под тип @escaping (StoreOf) -> some View, без аннотации @MainActor, то как он вообще может безопасно вызывать ChildView(store:) внутри? Этот инициализатор — @MainActor. Я нахожусь внутри closure, который не помечен @MainActor. Почему это не проблема?
В строгом режиме Swift 6 вызов @MainActor функции из nonisolated-контекста — это уже ошибка, а не warning.
Я переключился на .swiftLanguageMode(.v6), ожидая, что всё сломается.
Не сломалось.
Замыкание скомпилилось чисто. Ни предупреждения, ни ошибки. И в этот момент я понял, что думал не о том.
Closure — не nonisolated
У замыкания нет @MainActor в типе, но это не значит, что он выполняется в nonisolated-контексте. Конечно, нет — и я это знал. Но предупреждение компилятора увело меня не туда. Поразительно, как быстро разваливается слабая ментальная модель, если поверить первому «правдоподобному» объяснению.
Я объявил closure внутри body. А body — это @MainActor (в контексте SwiftUI). Closure-литерал наследует actor context того скоупа, в котором он объявлен, а не только тип параметра, которому он соответствует. То есть фактически этот closure выполняется как @MainActor, даже если это не отражено в его типе.
Вызов ChildView(store:) внутри такого closure — это вызов @MainActor функции из @MainActor контекста. Это всегда безопасно. Компилятор это понимает. Поэтому в Swift 6 нет ошибки. И раньше не было предупреждения. Всё выглядело достаточно консистентно, чтобы меня запутать.
На этом этапе казалось, что всё понятно. Замыкание работает, потому что наследует main actor context. Ссылка на функцию даёт предупреждение, потому что не может воспользоваться этим контекстом так же.
Красивое объяснение.
Или нет?
Объяснение затрещало по швам
Позже я объяснял всё это коллеге. И в процессе почувствовал знакомый дискомфорт: слова складываются, но что-то не сходится.
Потому что если closure работает за счёт наследования main actor context от view, то почему это же не применимо к method reference?
В итоге я упростил пример — сделал его меньше и одновременно более раздражающим.
struct ParentView: View {
var body: some View {
Text("parent")
}
func makeView() -> some View {
doSomethingOnTheMainActor()
let inferredReference = ChildView.init(store:)
let typedReference: (StoreOf<Int>) -> ChildView = ChildView.init(store:)
let mainActorReference: @MainActor (StoreOf<Int>) -> ChildView = ChildView.init(store:)
let mainActorClosure: @MainActor (StoreOf<Int>) -> ChildView = { @MainActor in
ChildView(store: $0)
}
_ = (inferredReference, typedReference, mainActorReference, mainActorClosure)
return ChildView(store: StoreOf())
}
}
@MainActor
func doSomethingOnTheMainActor() {}
Важна даже не ссылка на функцию, а вот эта:
doSomethingOnTheMainActor()
doSomethingOnTheMainActor() — это @MainActor. Я вызываю его без await. И это компилируется.
Это означает, что makeView() уже рассматривается как main actor-isolated, несмотря на то что я не писал @MainActor. Значит, моё предыдущее объяснение было неполным. Окружающий метод — это не какой-то случайный nonisolated context. Компилятор уже трактует его как main actor-isolated.
Дальше стало ещё страннее.
В режиме Swift 5 с включенной функцией InferSendableFromCaptures предупреждение выдается при использовании выведенной ссылки на функцию. Предупреждение также выдается при использовании явно типизированной ссылки на обычную функцию. Даже явно типизированная ссылка на функцию с аннотацией @MainActor вызывает предупреждение.
А вот замыкание @MainActor — не даёт warning.
И вот тут всё начало выглядеть подозрительно. mainActorReference и mainActorClosure имеют абсолютно одинаковый тип:
@MainActor (StoreOf<Int>) -> ChildView
Но одно предупреждение появилось, а другое — нет.
Вот тут-то и разрушилось простое объяснение. Если бы проблема заключалась просто в том, что «вы находитесь в неизолированном контексте», то метод doSomethingOnTheMainActor() должен был бы потребовать await. Но этого не произошло. А если бы проблема заключалась просто в том, что «это изолировано от основного актора, но параметр этого не принимает», то замыкание тоже должно было бы дать сбой, как только я понял бы, что оно изолировано от основного актора. По моему мнению, если одно не сработало, то и другое должно было бы не сработать. Если одно работало, то и другое должно было бы работать.
Затем я явно добавил аннотацию @MainActor к методу makeView().
Все предупреждения исчезли.
Затем я снова удалил эту явную аннотацию, но переключил пакет в языковой режим Swift 6.
Все предупреждения тоже исчезли.
Это и была настоящая подсказка.
Переходный момент
Так что я больше не считаю, что интересный урок заключается в том, что «замыкания наследуют контекст актора, а ссылки на методы — нет». Это слишком просто. И это не совсем верно.
Мне кажется, что это предупреждение — артефакт режима миграции Swift 5. Включение InferSendableFromCaptures в режиме Swift 5 заставляет ссылки на методы участвовать в более строгой проверке параллелизма, но эта проверка, похоже, не совсем согласуется с выводом изоляции акторов, который применяется в Swift 6. Окружающий метод SwiftUI уже изолирован основным актором. Swift 6 всё это понимает и принимает. Swift 5 с грядущим флагом функции видит достаточно, чтобы выдать предупреждение, но недостаточно, чтобы распознать то, что распознаёт Swift 6.
Обходной путь с использованием замыкания всё ещё имеет смысл. Он позволяет избежать предупреждения и выражает код таким образом, который устраивает проверяющую систему Swift 5. Но это не доказательство того, что исходная ссылка на функцию была небезопасной. Это скорее форма кода, которая позволяет избежать диагностики переходного процесса.
И это тот тонкий момент, который, как мне кажется, легко упустить.
Предстоящие фиче флаги, по крайней мере те, которые связаны со сложной параллельной обработкой данных, не всегда означают полный переход на тот языковой режим, к которому относится эта функция. Они призваны выявлять будущие проблемы на ранней стадии, но могут также показывать взаимодействие со старым режимом, которое не совсем соответствует окончательной модели.
Что я понял
Само исправление было небольшим. Одна строка изменений на каждый вызов функции.
Но чтобы понять, почему это сработало, пришлось проложить настоящий обходной путь через несколько частей модели параллельного программирования: как SE-0418 добавляет вывод к ссылкам на функции, как по-разному ведут себя литералы замыканий, как контекст актора наследуется, а не просто объявляется, и как проверка миграции в Swift 5 может отличаться от Swift 6, даже если задействован флаг будущей функции.
Самое забавное, что я уже знал большинство этих моментов. Я знал, что замыкания наследуют контекст актора. Я знал, что представления SwiftUI main actor-isolated. Я знал, что флаги будущих функций являются частью процесса миграции. И все же мне удалось убедить себя в объяснении, которое соответствовало тому, что я видел.
В этом и заключается опасность. Когда первое объяснение кажется правдоподобным, очень легко остановиться на этом. Я мог бы изменить все места вызовов, написать «замыкания наследуют контекст актора» и получить в итоге модель, достаточно близкую к правильной, но достаточно ошибочную, чтобы ввести меня в заблуждение в следующий раз.
Но что-то продолжало меня беспокоить. Результат не казался полностью правильным. Поэтому я продолжал работать. Я уменьшил пример, сравнил ссылку на функцию и замыкание с одним и тем же типом, попробовал явное использование @MainActor, попробовал режим Swift 6, и только тогда модель склеилась.
Предупреждение не указывало на ошибку в коде. Сначала я подумал, что оно говорит о простом несоответствии изоляции. Это было близко. Но не совсем. Более глубокий урок заключался в том, что режим компилятора имеет значение. В режиме Swift 5 с включенной этой новой функцией я видел предупреждение о переходе. В режиме Swift 6 тот же код имел смысл для компилятора. Добавление @MainActor явно также сделало его понятным.
По-настоящему понять такое можно только тогда, когда перестаешь мириться с предложенным решением и начинаешь задаваться вопросом, почему оно работает. И не просто до тех пор, пока не найдешь объяснение, а до тех пор, пока не найдешь такое, которое выдержит следующий вопрос.
-
Новости3 недели назадВидео и подкасты о мобильной разработке 2026.13
-
Разработка4 недели назад10 ошибок, которые Android-разработчики до сих пор допускают при работе с Jetpack Compose
-
Разработка3 недели назадЯ купил самый дешёвый MacBook от Apple и попробовал заняться настоящей разработкой
-
Видео и подкасты для разработчиков2 недели назадЗачем нужны Vim и NeoVim в 2026 — Своя среда разработки вместо готовой IDE
