Yesterday I hit a bug of the Swift Foundation on Windows that causes a 15 min hang when doing a POST with > 64KB of data, turns out someone reported that in April, so i had to use WinHTTP from Swift to workaround the problem in Swift 6.4.. but i’ve also spent some tokens to provide a patch and better reproducer+document describing the problem swift-corelibs-foundation#5532 hopefully someone from @swiftlang #swift can take a look and get this resolved in the upcoming Swift release.
1 favourite
SwiftPMに、DebugとReleaseで依存先を切り替える提案が出ています!
開発時だけ使う機能と、配布時に必要な構成を分けやすくする内容です。
現在はSE-0560としてレビュー中で、承認・リリースはまだです。
出典元:github.com/swiftlang/swift-evolution/blob/main/pr…
14 likes · 5 reposts · 775 views
От 798 до 16 нс: как оптимизация debounce привела меня в Swift Runtime
Я прочитал статью Side Effect про debounce на Swift Concurrency и решил проверить, какой прирост производительности дает предложенный там подход. Идея простая: вместо того чтобы создавать новую Task на каждое входящее значение, можно использовать одну и ту же Task для всех событий и продлевать время ожидания.
В моем тесте такая замена сократила среднее время выполнения с 798 до 216 нс. Уже хороший результат, но оставалось непонятно, на что уходят эти 216 нс. В большинстве вызовов Task больше не создается: нужно получить текущее время, сохранить новое значение и пересчитать deadline (момент окончания ожидания).
В итоге я дошел до 16 нс. По пути пришлось разобраться, что происходит при выполнении этих нескольких строк Swift-кода: как runtime работает с типами, что компилятор может оптимизировать и почему расположение блокировки в памяти влияет на результат. В статье я покажу, как проверял свои гипотезы и что обнаружил в экспериментах. Надеюсь, этот опыт пригодится тем, кто хочет разобраться в производительности своего Swift-кода.
Сразу обозначу, что именно здесь ускорилось в 50 раз. Оптимизация относится к среднему времени выполнения одного вызова метода отправки значения. В тесте этот метод вызывался 100 000 раз подряд (без пауз). Метод обновляет состояние debounce и возвращает управление, а обработчик вызывается позже, после заданной задержки с момента последнего значения.
В этой статье
Переиспользуем одну и ту же Task
Результат переиспользования одной Task
Может быть, все дело в блокировке?
Конкретный тип еще не означает известный layout
Заменяем ContinuousClock.Instant на UInt64
Как межмодульная оптимизация влияет на специализацию generic-кода
Как расположение блокировки в памяти влияет на производительность
Что дали оптимизации на практике
Итог
Переиспользуем одну и ту же Task
Представим поле поиска, в котором мы не хотим отправлять запрос после каждого нажатия на клавиатуре. Для этого используем trailing debounce: после каждого изменения текста откладываем запрос на 500 мс. Если за это время пользователь ничего не ввел, отправляем запрос с последним значением, а если продолжил печатать, то откладываем его еще раз.
Самая простая реализация отменяет предыдущую Task и создает следующую:
task?.cancel()
task = Task {
do {
try await Task.sleep(for: dueTime)
await output(value)
} catch {
// Отмененная задача завершается без передачи значения.
}
}
sourcecraft.dev
Это фрагмент реализации с Task на каждое значение. В таком варианте созданные Task будут отменяться, если новые значения поступают раньше, чем истекает задержка debounce. Но создание Task, запрос на ее отмену и обработка этого запроса все равно требуют ресурсов. Отмена в Swift кооперативная: вызов cancel() не означает, что Task сразу завершилась.
Предложенный подход из статьи Side Effect переносит последнее значение и deadline в общее состояние. Первая отправка запускает рабочую задачу. Следующие отправки только меняют состояние. Когда Task просыпается, она проверяет, пора ли вызывать обработчик: если за время сна пришли новые значения, то будет ждать дальше; если после последнего значения прошла заданная задержка, то вызовет обработчик.
Одна последовательность входящих значений и два способа управления Task. Рабочая задача может проснуться несколько раз, прежде чем передаст последнее значение.
Чтобы отложить обработку, достаточно обновить состояние. Прерывать текущий вызов Task.sleep не требуется. Это важная часть экономии: частые входящие события не превращаются в столь же частые действия планировщика.
Реализация emit с переиспользованием TaskВот метод emit исходной реализации с рабочей задачей:
public func emit(value: T) {
let (shouldStartWorker, initialDeadline) = stateMachine.apply { machine in
machine.newValue(value)
}
guard shouldStartWorker else {
return
}
let worker = Task { [stateMachine, output] in
var deadline = initialDeadline
while true {
try? await Task.sleep(until: deadline, clock: .continuous)
let nextStep = stateMachine.apply { machine in
machine.sleepIsOver()
}
switch nextStep {
case .finishDebouncing(let latestValue):
await output(latestValue)
return
case .continueDebouncing(let newDueTime):
deadline = newDueTime
}
}
}
task.set(stored: worker)
}
sourcecraft.dev
Во время уже начатой серии большая часть вызовов заканчивается на guard. Машина состояний (конечный автомат) обновляет ожидающее значение и сообщает, что новая Task не нужна.
Результат переиспользования одной Task
Сначала сравним две стратегии: Task на каждое значение и одна рабочая задача на всю серию.
100 000 целочисленных значений от одного отправителя, задержка debounce 10 мс. Медиана 15 прогонов после прогрева на M1 Max, macOS 26.7.1, Swift 6.4, release-сборка. Сохраненные результаты.
При переходе к одной рабочей задаче среднее время выполнения emit сократилось примерно в 3,7 раза, а CPU-время примерно в 13 раз.
Окружение и условия сравненияРезультаты выше получены 13 октября 2026 года на версии кода из коммита 990cf04.
При сравнении нужно учитывать несколько отличий:
В две исходные реализации я добавил атомарные счетчики созданных Task и завершений Task.sleep. В моей реализации из библиотеки swift-debounce таких счетчиков нет, поэтому дополнительная работа по подсчету есть только у исходных реализаций.
Для модуля References включена межмодульная оптимизация (CMO). Но проверка сгенерированного кода показала, что компилятор не создал версии emit, специализированные под Int. Другие оптимизации при этом могли применяться.
Реализации по-разному выполняют обработчики: исходная допускает их одновременное выполнение, а swift-debounce выполняет их последовательно.
В этом тесте проверяется, что обработчик вызывается один раз и получает последнее отправленное значение.
Переиспользование Task дало заметное ускорение, но оставалось понять, на что уходят 216 нс. Дальнейшие эксперименты проводились на разных нагрузках и версиях прототипа.
Может быть, все дело в блокировке?
В исходной реализации Side Effect общее состояние защищено NSRecursiveLock. При каждом значении мы захватываем блокировку и отпускаем ее, поэтому это было первым местом для проверки.
Я сравнил NSRecursiveLock с os_unfair_lock в двух тестах: при увеличении счетчика под блокировкой и при выполнении всего метода emit.
Операция, один поток
NSRecursiveLock
os_unfair_lock
Захват, увеличение счетчика, освобождение
17,1 нс
8,0 нс
Полный emit
188,0 нс
177,7 нс
Ранний эксперимент с блокировками. Полный метод измерялся на миллионе отправок. Его исходные 188 нс относятся к другому тесту, чем 216 нс выше.
На отдельной блокировке разница выглядит внушительно: почти вдвое. В полном методе выигрыш составил 10,3 нс, около 5,5%. Большая часть стоимости осталась на месте.
Блокировку имело смысл заменить, но оставшиеся затраты одного отправителя она не объясняла. Я перешел к чтению времени и обновлению состояния.
Конкретный тип еще не означает известный layout
При профилировании метода emit я обнаружил вызовы следующих функций Swift Runtime:
type metadata accessor for ContinuousClock.Instant
swift_getGenericMetadata
swift_getTupleTypeMetadatasourcecraft.dev
На первый взгляд странно. deadline хранится в значении конкретного типа ContinuousClock.Instant. В тесте входящее значение тоже конкретное: Int. Откуда работа с метаданными типов?
Компилятор может знать конкретный тип, но не иметь гарантий о его размере и расположении полей в памяти.
Layout типа определяет его размер, выравнивание и расположение полей в памяти. В режиме library evolution библиотека может менять внутреннее устройство типа-значения без @frozen, сохраняя совместимость с уже собранными клиентами. Для этого клиентский код не должен полагаться на текущий размер структуры и смещения ее полей. Атрибут @frozen дает более жесткие гарантии layout, но в определении ContinuousClock.Instant его нет.
В машине состояний deadline хранится внутри enum вместе со значением:
struct DebounceStateMachine<T> {
enum State {
case idle
case debouncing(
value: T,
dueTime: ContinuousClock.Instant,
isValueDuringSleep: Bool
)
}
// ...
}
sourcecraft.dev
При новом значении метод получает текущее время, заменяет содержимое enum и возвращает новый deadline. Для замены значения машинному коду, сгенерированному компилятором, нужно определить расположение полей и записать новые данные.
В Swift метаданные типа связаны с value witness table (подробнее о ее роли в generic-коде). В ней есть информация о layout значения и операции копирования, перемещения и уничтожения. Когда layout типа неизвестен при компиляции, сгенерированный машинный код может использовать операции из value witness table для копирования, перемещения и уничтожения значения. Связь метаданных и value witness table описана в документации Swift ABI.
Схема объясняет механизм работы с неизвестным layout.
В месте вызова компилятору был известен конкретный тип Int. Однако проверка сгенерированного кода показала, что вызывалась generic-версия LongLivedTaskDebounce.emit(value:), рассчитанная на любой допустимый T.
В версии, специализированной под Int, компилятор может использовать известные размер и расположение входного значения. Layout Instant при этом остается неизвестным компилятору клиентского модуля.
При этом обращение к метаданным не означает, что на каждой отправке создается новый объект или для хранения deadline обязательно выделяется отдельный участок памяти в куче. Метаданные могут переиспользоваться, даже если код продолжает к ним обращаться.
Как профилировать самостоятельноВ репозитории есть режим profile:
cd Benchmarks
swift build -c release
.build/release/Locks profile 10 &
sample "$!" 5 1 -file debounce-sample.txtsourcecraft.dev
Тест работает десять секунд; sample собирает стеки пять секунд с интервалом в одну миллисекунду. В историческом профиле исходная версия с рабочей задачей называется OptimizedDebounce. Состав символов в новом запуске может отличаться от сохраненного профиля.
Заменяем ContinuousClock.Instant на UInt64
Следующим шагом я решил проверить работу со временем: как мы получаем текущее значение и храним deadline. Сначала сравнил ContinuousClock.now и mach_continuous_time(). Каждый из них вызывался в цикле. Среднее время одной итерации составило 28,9 нс для ContinuousClock.now и 5,0 нс для mach_continuous_time().
Медиана семи однопоточных прогонов по пять миллионов итераций после прогрева. Тесты по-разному обрабатывают полученное время, поэтому числа включают не только вызов функции часов, но и дополнительную работу внутри цикла.
Вместо ContinuousClock.Instant я стал хранить deadline в UInt64. На Darwin текущее время теперь получаю через mach_continuous_time(). Значение deadline хранится как обычное целое число, layout которого известен компилятору.
Чтение времени и преобразование тиковТак выглядит wrapper над часами:
@usableFromInline
enum _MonotonicClock {
@inlinable
@inline(__always)
static func now() -> UInt64 {
#if canImport(Darwin)
return mach_continuous_time()
#else
var time = timespec()
clock_gettime(CLOCK_BOOTTIME, &time)
let secondsInNanoseconds = UInt64(time.tv_sec) &* 1_000_000_000
return secondsInNanoseconds &+ UInt64(time.tv_nsec)
#endif
}
}
sourcecraft.dev
mach_continuous_time() возвращает время в тиках, которые не обязательно равны наносекундам. При инициализации заданная задержка debounce переводится в тики. Перед вызовом Task.sleep рабочая задача преобразует оставшееся время обратно в наносекунды. Поэтому при каждом вызове send пересчитывать единицы времени не нужно.
Я одновременно изменил способ получения текущего времени и тип deadline, поэтому по этим замерам нельзя определить, какой вклад внесло каждое изменение. При повторном профилировании я снова обнаружил обращения к метаданным. Теперь они были связаны с полем Value?, в котором сохраняется последнее значение для обработки.
Как межмодульная оптимизация влияет на специализацию generic-кода
Теперь нужно было разобраться с обновлением Value?. В тесте используется Int, но метод emit мог по-прежнему выполняться как общая generic-функция. Я проверил, поможет ли здесь межмодульная оптимизация -cross-module-optimization (CMO). Она позволяет оптимизировать код через границы модулей, в том числе создавать специализированные версии generic-функций.
Для сравнения я собрал каждую реализацию с CMO и без него. В каждом запуске тест примерно три секунды подряд вызывал emit, передавая новые значения, и подсчитывал количество вызовов.
Переработанный прототип с CMO успел выполнить около 258 млн вызовов вместо 28 млн. У исходной реализации с одной рабочей задачей результат почти не изменился: около 16 млн вызовов в обоих случаях.
Реализация
Без CMO, вызовов emit за ≈3 с
С CMO, вызовов emit за ≈3 с
Прототип с deadline в UInt64
27 580 000
257 900 000
Исходная рабочая задача
15 770 000
15 680 000
Для каждого варианта сохранен результат одного запуска, поэтому судить о разбросе результатов пока нельзя. Каждый запуск был рассчитан примерно на три секунды; время проверялось после каждых 10 000 вызовов emit. Результаты эксперимента.
Это измерение пропускной способности отдельного прототипа, а не еще один множитель, который можно применить к исходным 798 нс.
CMO заметно помог переработанному прототипу, но почти не повлиял на исходную реализацию. Далее, чтобы разобраться в причине, я решил проверил, какой машинный код создал компилятор.
SIL исходной реализации: generic-вызов emitНиже фрагмент SIL. Для читаемости я перенес длинные строки и убрал служебные комментарии после инструкций:
// function_ref LongLivedTaskDebounce.emit(value:)
%6 = function_ref @$s10References21LongLivedTaskDebounceC4emit5valueyx_tF :
$@convention(method) <τ_0_0 where τ_0_0 : Sendable>
(@in_guaranteed τ_0_0,
@guaranteed LongLivedTaskDebounce<τ_0_0>) -> ()
%7 = apply %6<Int>(%4, %0) :
$@convention(method) <τ_0_0 where τ_0_0 : Sendable>
(@in_guaranteed τ_0_0,
@guaranteed LongLivedTaskDebounce<τ_0_0>) -> ()
sourcecraft.dev
function_ref ссылается на generic-версию emit: в сигнатуре остается параметр типа τ_0_0. Следующая инструкция, apply %6<Int>, вызывает ее с типом Int. Запись <Int> здесь означает передачу конкретного типа в generic-функцию, а не вызов отдельной специализации. Несмотря на включенный CMO, в этом месте вызывается общий метод.
Один из способов оптимизации generic-кода — специализация. Компилятор создает версию функции для конкретного типа. Например, для Debouncer<Int> он может заменить общие операции над Value и Value? операциями над Int и Optional<Int>.
В отдельном эксперименте я сохранил ContinuousClock.Instant, но заменил обертку SafeStorage с NSRecursiveLock на вариант с os_unfair_lock. С включенным CMO компилятор создал специализацию emit под Int. Получается, специализация была возможна и без замены Instant на UInt64.
SIL после замены NSRecursiveLock на os_unfair_lockВо втором варианте той же проверки я заменил блокировку внутри SafeStorage на os_unfair_lock. ContinuousClock.Instant, алгоритм debounce и параметры компиляции оставил прежними. Теперь вызывающий модуль содержит такой фрагмент:
// function_ref specialized LongLivedTaskDebounce.emit(value:)
%4 = function_ref @$s10References21LongLivedTaskDebounceC4emit5valueyx_tFSi_Tg5 :
$@convention(method)
(Int, @guaranteed LongLivedTaskDebounce<Int>) -> ()
%5 = apply %4(%1, %0) :
$@convention(method)
(Int, @guaranteed LongLivedTaskDebounce<Int>) -> ()
sourcecraft.dev
В комментарии к function_ref появилось specialized, а в сигнатуре вместо параметра типа указаны Int и LongLivedTaskDebounce<Int>. Инструкция apply %4 вызывает именно эту версию emit.
Вызов отдельной функции сохранился: здесь видна специализация, а не подстановка ее тела. Оба варианта собраны с включенным CMO.
Еще одна оптимизация — inlining, то есть подстановка тела функции в место вызова. Она может помочь дальнейшим оптимизациям, но специализация возможна и без нее: компилятор может создать отдельную функцию для конкретного типа и оставить ее вызов.
В swift-debounce я пометил функции, которые вызываются из send, атрибутом @inlinable. Благодаря этому их код доступен оптимизатору при компиляции модуля, использующего библиотеку. Это дает возможность выполнить inlining или специализацию, но не гарантирует их. CMO тоже может сделать тела функций доступными для оптимизации без @inlinable. Подробнее эти механизмы описаны в SE-0193.
Функция send получилась такой:
@inlinable
public func send(_ value: Value) {
let now = _MonotonicClock.now()
storage.withLock { state in
let shouldStartWorker = state.machine.send(value, now: now)
if shouldStartWorker {
core.startWorker(&state)
}
}
}
sourcecraft.dev
Недостаточно сделать доступным оптимизатору только тело send. Операция замены trailingValue: Value? находится глубже, в машине состояний, а доступ к ней проходит через withLock. Если тела этих функций недоступны, оптимизатор не сможет проанализировать обновление состояния через эту цепочку вызовов.
Как оптимизатор видит обновление состоянияПоэтому метод withLock тоже помечен @inlinable:
@inlinable
@inline(__always)
func withLock<Result>(_ body: (inout State) -> Result) -> Result {
withUnsafeMutablePointers { statePointer, lockElements in
let lock = lockElements + Self.lockIndex
_lock(lock)
defer {
_unlock(lock)
}
return body(&statePointer.pointee)
}
}
sourcecraft.dev
В машине состояний новое значение обновляет deadline и заменяет сохраненное значение, если предыдущий deadline еще не наступил. Ветку else здесь опускаю:
@inlinable
mutating func send(_ value: Value, now: UInt64) -> Bool {
let newDeadline = _deadline(after: now, adding: interval)
if let currentDeadline = burstDeadline, now < currentDeadline {
burstDeadline = max(currentDeadline, newDeadline)
if edges.contains(.trailing) {
trailingValue = value
}
} else {
// Начало новой серии опущено.
}
if isWorkerActive {
return false
}
isWorkerActive = true
return true
}
sourcecraft.dev
Стрелки показывают доступность кода для компилятора приложения. @inlinable открывает тело функции для анализа с учетом конкретного типа Int. Inlining и специализация не гарантированы; CMO тоже может обеспечить доступ к телам функций.
Здесь важно различать @inlinable и @usableFromInline. Второй атрибут позволяет ссылаться на internal-декларацию из доступного клиентам тела и делает ее частью ABI. Сам по себе он не раскрывает тело функции для оптимизации.
Метод startWorker, который создает рабочую Task, помечен @usableFromInline. Это позволяет вызывать его из @inlinable-метода send, не открывая тело startWorker для оптимизации. Большинство вызовов send только обновляют состояние и до startWorker не доходят.
Как расположение блокировки в памяти влияет на производительность
На влияние расположения блокировки в памяти я наткнулся случайно, когда уже готовил репозиторий к публикации на GitHub. Я сравнивал несколько способов хранения состояния и блокировки, чтобы выбрать реализацию с поддержкой старых версий iOS.
В одном из вариантов состояние и блокировка размещались в одной аллокации с помощью ManagedBuffer. В другом память для блокировки выделялась отдельно от состояния.
При сравнении обнаружился неожиданный результат: отдельная аллокация для блокировки замедляла отправку значений из одного потока, но ускоряла ее при одновременных вызовах из нескольких потоков.
Я предположил, что при нескольких потоках помогает именно расстояние между блокировкой и состоянием. Чтобы это проверить, я оставил их в одной аллокации, но добавил между ними свободное место (padding размером 128 байт). Так можно было отодвинуть блокировку от состояния, не выделяя для нее память отдельно. В реализации хранилища положение блокировки вычисляется так:
@inlinable
@inline(__always)
static var lockIndex: Int {
let paddingBytes = 128
let lockStride = MemoryLayout<_PlatformLock>.stride
return (paddingBytes + lockStride - 1) / lockStride
}
sourcecraft.dev
Без padding блокировка находится в начале области элементов ManagedBuffer. В варианте с padding перед ней зарезервированы 128 байт. Состояние остается на прежнем месте. Размеры блоков показаны условно.
При одновременной работе восьми потоков получились такие результаты:
Тест
Без padding
С padding
Захват блокировки, увеличение счетчика и освобождение блокировки
24,9 нс
37,1 нс
Вызов emit в упрощенной реализации debounce
79,7 нс
54,2 нс
В тесте с восемью потоками padding увеличил время операции со счетчиком под блокировкой, но сократил время выполнения emit. При этом расход памяти вырос со 160 до 288 байт на экземпляр тестового BoxedDebounce. Для времени показаны медианы девяти запусков; в каждом запуске общее время теста делилось на число операций всех потоков. Результаты опыта с хранилищем.
В тесте блокировки вариант без padding оказался быстрее: 24,9 нс против 37,1 нс. Но при вызове emit результат получился обратным: с padding 54,2 нс, без него 79,7 нс. Поэтому выбирать расположение блокировки стоит по результатам той операции, которую она защищает.
Возможная причина — это false sharing. Кэш процессора работает с блоками памяти (кэш-линиями). Если блокировка и защищаемое состояние попадают в одну кэш-линию, потоки могут мешать друг другу: один обновляет состояние, а другие в это время пытаются захватить блокировку. Padding позволяет разнести блокировку и состояние, что может уменьшить эти помехи.
Такой случай описан в документации ядра Linux. Он подходит под наблюдаемый результат, но я не проверял эту гипотезу.
Сравнение пяти вариантов хранения и разброс результатовВ тесте сравнивались пять вариантов хранения состояния и блокировки. Во всех использовалась одна и та же упрощенная реализация debounce.
Ниже показано время на один вызов emit: медиана девяти запусков, а в скобках минимальный и максимальный результаты. Память измерялась на экземпляр тестового BoxedDebounce.
Хранилище
Один поток, нс/вызов
Восемь потоков, нс/вызов
Память на экземпляр, байт
Synchronization.Mutex
13,2 [13,1–13,3]
86,8 [77,5–94,3]
128
ManagedBuffer, без padding
13,1 [13,0–13,2]
79,7 [73,7–97,7]
160
ManagedBuffer, блокировка с padding
13,2 [13,2–13,3]
54,2 [51,9–62,6]
288
OSAllocatedUnfairLock
13,1 [13,1–13,2]
84,6 [71,3–118,8]
160
Отдельная аллокация блокировки
17,9 [17,8–18,0]
65,7 [62,7–67,5]
144
В однопоточном тесте выполнялись два миллиона вызовов emit, в многопоточном по 100 000 из каждого из восьми потоков. Задержка debounce составляла 20 мс. Перед замерами выполнялся прогрев, затем варианты проверялись по очереди в девяти раундах. Время на вызов рассчитано по общей длительности теста и суммарному числу вызовов. Точка на графике медиана, отрезок минимум и максимум.
При работе из одного потока оба варианта ManagedBuffer показали около 13 нс на вызов. При восьми потоках диапазон результатов с padding составил 51,9–62,6 нс, без padding 73,7–97,7 нс. В этих девяти запусках диапазоны не пересекались.
В swift-debounce я оставил вариант с padding. В тестах он ускорил обновление состояния при работе из нескольких потоков, практически не повлияв на результат одного потока.
Что дали оптимизации на практике
Теперь сравним три реализации: новую Task на каждое значение, одну рабочую задачу из статьи Side Effect и swift-debounce.
Реализация
Среднее время одного вызова
Время цикла отправки
CPU-время процесса
Task на каждое значение
798 нс
79,82 мс
280,81 мс
Одна рабочая задача
216 нс
21,61 мс
21,91 мс
swift-debounce
16 нс
1,57 мс
1,72 мс
100 000 целочисленных значений от одного отправителя, задержка debounce 10 мс. Медиана 15 прогонов на M1 Max, macOS 26.7.1, Swift 6.4, release-сборка. Сохраненные результаты. CPU-время включает работу всех потоков за период отправки значений, ожидания результата и завершения отмененных задач.
При 100 000 вызовов подряд среднее время выполнения send в swift-debounce составило 16 нс. При 1000 вызовах уже 41 нс: затраты на создание рабочей задачи распределяются на меньшее число вызовов.
Затем я проверил другой сценарий: 200 значений с интервалом 5 мс. Суммарное CPU-время составило 31,10 мс для реализации с новой Task на каждое значение, 21,65 мс для одной рабочей задачи и 21,22 мс для swift-debounce.
Здесь основную экономию дало переиспользование Task. Дополнительные оптимизации send повлияли на суммарное CPU-время гораздо меньше.
Итог
Все началось с простой идеи: перестать создавать новую Task для каждого значения. Уже это сократило среднее время отправки с 798 до 216 нс. Но мне стало интересно, куда уходят оставшиеся наносекунды. Так оптимизация debounce постепенно превратилась в исследование Swift Runtime и работы компилятора.
Больше всего меня удивило, насколько результаты могут расходиться с первоначальными ожиданиями. Я думал, что основная проблема в блокировке, но ее замена объяснила лишь небольшую часть затрат. Позже оказалось, что даже расположение блокировки в памяти влияет на результат, причем отдельный тест блокировки и обновление состояния debounce показали противоположную картину. А чтобы разобраться с остальными затратами, пришлось смотреть уже не только на Swift-код, но и на то, что из него генерирует компилятор.
В итоге в тесте со 100 000 вызовов подряд среднее время выполнения одного вызова сократилось с 798 до 16 нс, то есть примерно в 50 раз.
Для меня самое интересное в этой истории - это сколько всего обнаружилось за несколькими строками обновления состояния. Даже простой на вид метод может привести к вопросам о layout типов, специализации generic-кода и работе процессорного кэша. Получившуюся реализацию я опубликовал в swift-debounce, а код экспериментов и результаты замеров оставил в репозитории с бенчмарками.
Swift 6 Strict Concurrency: Production Migration Playbook
Swift 6 Strict Concurrency: Production Migration Playbook
Swift 6.0's strict concurrency looks, at first glance, like an opt-in compile flag. But in reality? It's a serious refactor mandate touching every one of your classes, singletons, delegate patterns, and dependency-injection architecture.
This article walks through the 12 common pitfalls you are likely to hit when moving a large production iOS codebase to strict concurrency mode, their fixes, and the correct actor migration order. Spoiler: @MainActor is not the cure for everything.
> Pro Tip: Before starting the migration, don't begin with SWIFT_STRICT_CONCURRENCY = complete — start with targeted. Targeted mode only checks Sendable; complete mode also enforces actor isolation. Starting targeted cuts your first-pass error count in half.
---
1. The Three Levels of Strict Concurrency Mode
Swift 6 strict concurrency is controlled by the SWIFT_STRICT_CONCURRENCY build setting, and it has three levels:
# Minimal — Swift 5.x default behavior
SWIFT_STRICT_CONCURRENCY = minimal
# Targeted — only Sendable explicit marked types check
SWIFT_STRICT_CONCURRENCY = targeted
# Complete — full Swift 6 strict concurrency
SWIFT_STRICT_CONCURRENCY = complete
Practical migration strategy:
1. Weeks 1-2: Move the whole project from minimal to targeted. Fix Sendable warnings.
2. Weeks 3-4: Turn on complete mode module by module. The UI layer goes last.
3. Week 5: Full project on complete. Swift 6 language mode active.
Migrating small modules first (Networking, Persistence) reduces production risk — changes that leak into the UI layer are usually the most expensive.
---
2. Sendable: The End of Invisible Leaks
A large share of the migration work comes down to Sendable conformance. A type being Sendable means it's safe to share across multiple threads.
// ❌ Strict mode error: User isn't Sendable
class User {
var name: String
var preferences: [String: Any] // [String: Any] isn't Sendable!
}
// ✅ Solution: struct + typed properties
struct User: Sendable {
let id: UUID
let name: String
let preferences: UserPreferences // Sendable Codable struct
}
Common Sendable pitfalls:
- [String: Any] — Any isn't Sendable; use a typed dictionary
- URL — Sendable ✓, URLRequest Sendable ✓
- Date — Sendable ✓ (Swift 5.5+)
- UIImage — not Sendable (reference type, mutable)
- NSData — not Sendable; Data is Sendable ✓
- Closure types — require the @Sendable annotation
> Pro Tip: Don't try to make legacy Objective-C types (NSObject subclasses) Sendable — they're reference types and mutable. Wrap them in a pure-Swift struct instead.
---
3. Actor Migration Order: The Right Way
Actors are the gold standard for concurrent state in Swift 6. But migrating them in the wrong order means walking into a maze of rework. Recommended order:
1. Stateless services (URLSession wrapper, JSON decoder) — not an actor, use struct or enum with static methods
2. Stateful services (cache, session manager) — make them an actor
3. DI container / service locator — global state, actor or @MainActor class
4. ViewModels — @MainActor (they have a UI dependency)
5. Views — SwiftUI is already MainActor-isolated
// Stateful cache → actor (Correct)
actor ImageCache {
private var cache: [URL: UIImage] = [:]
func image(for url: URL) -> UIImage? {
cache[url]
}
func setImage(_ image: UIImage, for url: URL) {
cache[url] = image
}
}
// Usage: await required
let image = await imageCache.image(for: url)
Pitfall: don't try to preserve your old synchronous APIs when converting to actor. Every public method becomes async. If call sites are too scattered, use a transition wrapper:
@MainActor
final class ImageCacheBridge {
private let cache: ImageCache
nonisolated func imageSync(for url: URL) -> UIImage? {
// Sync fallback for legacy paths (deprecated)
return MainActor.assumeIsolated {
self.legacyCacheStorage[url]
}
}
}
---
[[demo:actor-race-sim]]
4. @MainActor Pitfalls
@MainActor is the most powerful tool for UIKit/SwiftUI interop. But misuse hurts performance badly.
Pitfall 1: Marking the entire class @MainActor
// ❌ Wrong — JSON parsing blocks the main thread
@MainActor
final class APIClient {
func fetchUsers() async throws -> [User] {
let data = try await URLSession.shared.data(from: usersURL).0
return try JSONDecoder().decode([User].self, from: data) // 200ms main thread block!
}
}
// ✅ Correct — only UI-bound method @MainActor
final class APIClient {
nonisolated func fetchUsers() async throws -> [User] {
let data = try await URLSession.shared.data(from: usersURL).0
return try JSONDecoder().decode([User].self, from: data)
}
@MainActor
func updateUI(with users: [User]) {
// UI update logic
}
}
Pitfall 2: Incorrectly capturing values in @MainActor closures
Task { @MainActor in
// This whole block runs on the main thread — don't do heavy work here!
let processed = self.heavyProcessing() // main blocking!
self.updateView(with: processed)
}
// ✅ Correct — heavy work in background, only UI update on main
Task {
let processed = await self.heavyProcessing() // arbitrary executor
await MainActor.run {
self.updateView(with: processed)
}
}
Pitfall 3: MainActor.assumeIsolated is for synchronous compatibility, but misuse causes a runtime crash. Only use it where you can guarantee the code is already running on the main thread.
---
5. Async/Await and Existing Callback Patterns
A large, older codebase can easily have hundreds of closure-based APIs. Migrating all of them to async/await at once isn't feasible.
Bridge pattern:
// Old callback API
func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
// legacy code
}
// Async wrapper
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
fetchData { result in
continuation.resume(with: result)
}
}
}
Watch out — withCheckedThrowingContinuation pitfalls:
- The continuation is one-shot — resuming it twice by mistake crashes
- If you store the closure and never resume it, the async caller hangs forever
- Cancellation handling must be explicit — use withTaskCancellationHandler
In production, use @CheckedContinuation to catch continuation leaks (Swift 6 default, has a debug check).
---
6. Does the Singleton Pattern Survive Strict Concurrency?
You're told singletons are an anti-pattern, but in a codebase with 200K LOC and 8 years of history, they exist and aren't going anywhere. How do they survive strict concurrency?
// ❌ Strict mode error
class AnalyticsTracker {
static let shared = AnalyticsTracker() // mutable global state warning
var sessionID: String = UUID().uuidString
}
// ✅ Solution 1: actor
actor AnalyticsTracker {
static let shared = AnalyticsTracker()
var sessionID: String = UUID().uuidString
func setSession(_ id: String) {
sessionID = id
}
}
// ✅ Solution 2: immutable
final class AnalyticsTracker: Sendable {
static let shared = AnalyticsTracker()
let sessionID = UUID().uuidString // immutable, Sendable safe
private init() {}
}
Decision matrix:
- Internal state gets mutated → actor
- Pure read-only → Sendable final class
- UI-bound singleton → @MainActor final class
---
7. Delegate Pattern → AsyncSequence Migration
Most Apple frameworks are delegate-based. Under strict concurrency, delegate methods need Sendable conformance.
// Old pattern
class LocationTracker: NSObject, CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
// delegate callback
}
}
// Modern pattern — AsyncSequence
class LocationTracker {
var locations: AsyncStream<CLLocation> {
AsyncStream { continuation in
let bridge = LocationBridge { location in
continuation.yield(location)
}
// CLLocationManager setup
}
}
}
// Usage — clean
for await location in tracker.locations {
await viewModel.updateLocation(location)
}
This pattern works the same way for MapKit, BLE, Camera, and Audio — wrap the old delegates in an AsyncStream.
---
8. Test Mocking + Sendable
Test mocks need to be Sendable, otherwise tests fail under the concurrent runner.
// Test double — Sendable conform
final class MockUserService: UserServicing, Sendable {
let stubUsers: [User]
init(users: [User] = []) {
self.stubUsers = users
}
func fetchUsers() async throws -> [User] {
stubUsers
}
}
Pro Tip: XCTest's default test parallelization does a Sendable check. If a test class holds state, mark it @MainActor or move that state into an actor.
---
9. Production Profiling: The Performance Trade-off
Strict concurrency isn't free. Actor hops add a small cost to every async call. These are the metrics to compare in your own app before and after the migration:
| Metric | Why it matters | How to measure |
|---|---|---|
| Launch time | Actor hops added to the main thread can delay the first screen | Instruments App Launch template + MetricKit |
| Memory baseline | Extra tasks and actor objects can raise the memory floor | Xcode Memory Report + MetricKit |
| CPU under load | Frequent actor hops can raise CPU share in busy flows | Instruments Time Profiler |
| Build time | Complete mode makes type checking heavier and builds slower | Xcode Build Timeline |
Build time is usually the most noticeable increase. Fix: incremental build caching and modularization.
Runtime overhead stays negligible for most mobile apps; still, verify hot paths with Time Profiler.
On the crash side, the expected gain is that data races closed at compile time no longer surface as runtime crashes; measure the effect by comparing crash analytics before and after the migration.
---
10. Migration Checklist
A recommended sequential checklist for migrating a production app:
- [ ] Phase 0: Project on SWIFT_STRICT_CONCURRENCY = targeted, clean build
- [ ] Phase 1: Migrate the networking layer, Sendable conformance
- [ ] Phase 2: Persistence layer (Core Data, SwiftData) actor migration
- [ ] Phase 3: Service container (DI) as actor or @MainActor
- [ ] Phase 4: Mark ViewModels @MainActor
- [ ] Phase 5: Mark UIKit ViewControllers @MainActor (SwiftUI views already are)
- [ ] Phase 6: Turn on SWIFT_STRICT_CONCURRENCY = complete
- [ ] Phase 7: Swift 6 language mode (SWIFT_VERSION = 6)
- [ ] Phase 8: Enforce strict mode in the CI/CD pipeline
- [ ] Phase 9: Two weeks of crash-analytics observation
- [ ] Phase 10: Publish the performance baseline
> Pro Tip: Don't do the migration on a feature branch — go gradual on main. On a large codebase the migration can take weeks or even months, so conflicts on a long-lived branch are unavoidable. Merging module-by-module into main is far more sustainable.
---
Conclusion
Strict concurrency is the delivery on Swift's 8-year-old safety promise. The ability to catch data races at compile time is valuable because it closes this class of runtime crashes at the source. But the migration isn't cheap — on a large codebase, budget for effort that can stretch over months, noticeably longer build times, and a small runtime overhead.
The pragmatic call: for new projects, Swift 6 + complete strict concurrency by default. For existing projects, a targeted → complete transition on a phased, module-by-module plan. @MainActor isn't the cure for everything; used correctly, it means keeping heavy work off the main thread with nonisolated.
Related resources:
- Apple Swift Concurrency Migration Guide
- Swift Evolution SE-0337
- Strict Concurrency Performance Benchmarks (Point-Free)
---
Originally published at muhittincamdali.com.
Swift 6.3 Migration: Actor Isolation Warnings and Escape Hatches
Don't be surprised if a project that compiled cleanly on Swift 6.2 starts throwing actor isolation warnings after you move to Swift 6.3 without changing a line — it's not a coincidence. Swift 6.3 introduced no new language rule or default for actor isolation; even so, some patterns silent on 6.2 now warn on 6.3 — and in the documented case below, the root isn't a new rule at all but the conservative behavior of region isolation analysis. This isn't a generic "what is strict concurrency" explainer; it shows, directly, which code patterns broke in the 6.2 → 6.3 delta, with real compiler messages and real escape hatches.
> 💡 Pro Tip: Whether a piece of code is isolated or nonisolated is part of its signature — changing it is almost always a source-breaking change, and that's exactly where you get hit hardest during a version upgrade.
Today's reality: 6.3.3 is shipped, 6.4 is on the way
Swift 6.3 was officially released on March 24, 2026, signed by Holly Borla and Joe Heck. The headline items weren't about concurrency — more flexible C interop, cross-platform build tooling, embedded-environment enhancements, and an official Android SDK. So the official announcement doesn't even mention concurrency; yet the documented warning is real — you'll see why below, in the conservative rule of region isolation analysis.
The release chain today (early September 2026): after 6.3 came the 6.3.1 and 6.3.2 patches, then 6.3.3 on June 30, 2026 — still the current stable tag on swift.org's install page. Meanwhile Swift 6.4 prep continues: Xcode 27 was still in beta at the end of August 2026 (beta 6, August 24, 2026), so 6.4 isn't an officially released version yet. swift.org's August 20, 2026 post "Embedded Swift Improvements Coming in Swift 6.4" even says "coming" right in the title — 6.4 is still a version tried via development snapshots.
Point-Free's post from their Xcode 27 beta days is a live example: the team had to explicitly mark something @MainActor that should already have been so, because of an isolation-checking regression in Swift 6.4, then quickly merged the fix and shipped a release.
Summary of 6.2 → 6.3 language changes
To understand Swift 6.3's tightening of actor isolation, you first need what changed in 6.2 — half of the configuration that triggers the warning, the module's default-isolation setting, arrived in 6.2 via SE-0466.
SE-0466 — Module-wide default actor isolation. This proposal reached "Implemented" status in Swift 6.2 and added a compiler setting that lets @MainActor inference become the default within a module. The goal was to reduce false-positive data-race errors in single-threaded code — in the proposal's own words: "The easiest and best way to model single-threaded code is with a global actor. Everything on a global actor runs sequentially."
SE-0461 — nonisolated(nonsending) default. Also "Implemented" in 6.2, this proposal changed the behavior of nonisolated async functions: such functions now run on the caller's actor by default instead of hopping to a separate executor. The NonisolatedNonsendingByDefault upcoming-feature flag turns this behavior on.
Together these two changes make 6.2's philosophy clear: the compiler now runs on "sequential by default, parallel when needed" logic. 6.3 announced no new concurrency default; even so, some patterns silent on 6.2 now produce new isolation warnings.
Documented case: the @Sendable × @escaping isolation warning
A thread opened by Itai Ferber on the Swift Forums is the most concrete documented example. Ferber's description is precise, in his own words: "This only reproduces on Swift 6.3 in a project with nonisolated default isolation and concurrency checking set to Complete; earlier Swift versions, default MainActor isolation, or minimal/targeted concurrency checking don't produce this warning."
This alone gives a practical hint: if your project uses the -default-isolation MainActor flag (i.e., you've turned on SE-0466's MainActor-default at the module level), you won't see this specific warning. The producing configuration: module default isolation still nonisolated, target compiled with SWIFT_STRICT_CONCURRENCY=complete.
// Reproducer from Ferber's forum thread: silent on 6.2, warns on 6.3
class External {
// System-provided, Obj-C-originated signature (in reality, Timer.scheduledTimer)
static func ƒ1(_ work: @escaping @Sendable () -> Void) {
work()
}
}
@MainActor
final class Internal {
func ƒ2(_ completion: @escaping () -> Void) {
completion()
}
func ƒ3() {
Task { @MainActor in
var complete = false
External.ƒ1 {
MainActor.assumeIsolated {
self.ƒ2 {
MainActor.assumeIsolated {
// ⚠️ Sending 'complete' risks causing data races
complete = true
}
}
}
}
print(complete)
}
}
}
Ferber's actual compiler output reads: "Sending 'complete' risks causing data races; this is an error in the Swift 6 language mode ... Task-isolated 'complete' is captured by a main actor-isolated closure." Notice: the warning isn't about the closure itself, but the capture of the var complete variable defined inside the Task { @MainActor in } body.
Two fixes are documented: removing @Sendable from External.ƒ1(), or @escaping from Internal.ƒ2(). In Ferber's words: "Removing @Sendable from External.ƒ1() or @escaping from Internal.ƒ2() resolves the warning." Why: because complete passes through a nonisolated context, it must be implicitly sent to MainActor, and sending semantics require statically verifying the value can no longer be used from the source context — impossible in nonisolated contexts. Ferber accepts the diagnosis: "warning about such a mutable var capture seems reasonable."
| Configuration | 6.2 behavior | 6.3 behavior |
| ------------------------------------------- | ----------------- | ------------------------------------ |
| nonisolated default + Complete checking | Compiles silently | Warning on Sendable+escaping closure |
| -default-isolation MainActor (SE-0466 on) | Compiles silently | Compiles silently (no warning) |
| Minimal/targeted checking | Compiles silently | Compiles silently |
| @Sendable OR @escaping alone | No issue | No issue |
Takeaway: check -default-isolation — but as diagnosis, not a fix. Flipping to the MainActor-default makes the warning vanish; that doesn't prove the code race-free. As jamieQ put it, "this is a limitation of how the region isolation analysis implementation applies its conservative rules" — if the code really is safe, a deliberate escape hatch (nonisolated(unsafe)) is reasonable. An alternative from the same thread: instead of the implicit box the compiler generates for the closure capture, put the value in your own reference type with matching isolation — a wrapper like @MainActor final class Workaround<T>.
Escape hatches: nonisolated(unsafe) and @preconcurrency
nonisolated(unsafe) is one of the most commonly reached-for escape hatches in Swift 6 migrations — but sprinkling it everywhere can hide real data races instead of speeding up the migration. A real migration account on the Swift Forums shows the distinction: "A few legacy/spaghetti code issues came up but were resolvable with nonisolated(unsafe)." The same post notes third-party libraries: "Dealing with third-party libraries was pretty easy — it was usually enough to just add @preconcurrency to the import or protocol and move on." Neither is specific to 6.3 — general Swift 6 migration practice — but both remain your first line of defense moving to 6.3.
import Foundation
// Quickly silencing a legacy singleton (use with care — can hide a real data race)
final class LegacyCache {
nonisolated(unsafe) static var shared = LegacyCache()
private var storage: [String: Data] = [:]
}
// A representative third-party protocol not yet Sendable-conformant
protocol RequestDelegate {
func didFinish(_ payload: Data)
}
@MainActor
final class APIClient: @preconcurrency RequestDelegate {
// @preconcurrency defers the conformance's isolation check to runtime
func didFinish(_ payload: Data) {
print(payload.count)
}
}
Protocol conformance isolation is a separate layer: SE-0470 "Global-actor isolated conformances" — status "Implemented (Swift 6.2)" — pins down the rules for a type conforming to a protocol in a global-actor-isolated way (an @MainActor class conforming to a nonisolated protocol, say), turned on via the InferIsolatedConformances upcoming-feature flag. Effect: an @MainActor-marked type conforming to a protocol that doesn't require isolation may now need explicit annotation.
The root of the same case: region isolation analysis
The root of the warning has less to do with which actor a closure "resumes" on, and more with how the compiler classifies the captured variable: the Task-isolated complete is considered sent the moment it's captured by an @MainActor-isolated closure.
John McCall from the Swift team describes this in the same thread: "The problem is simply that Swift treats a capture in a closure with different isolation as a send, when what we actually need to do is reason about the isolation of the functions where the captured variable is used." The distinction matters: this only shows up with indirect captures — a variable captured by the outer closure only because it's used in a more deeply nested one. Point-Free's note on the Xcode 27/Swift 6.4 beta process shows that, in a library adopting NonisolatedNonsendingByDefault, 6.4 keeps tightening checking: "Swift 6.4 seems to have started catching more issues in async code related to this setting" — meaning closure/async isolation checking keeps tightening from 6.3 toward 6.4, a gradual trend rather than a one-time jump.
import Foundation
// Counter-example: isolation chain is NOT broken here — shows what the
// indirect-capture pattern above does NOT look like
actor DownloadManager {
func fetch(completion: @escaping @Sendable (Result<Data, Error>) -> Void) {
Task {
let data = await performFetch()
// Task inherits the actor's isolation; completion is @Sendable,
// so there's no mutable capture being sent across a boundary
completion(.success(data))
}
}
private func performFetch() async -> Data { Data() }
}
The first question to ask in practice when you hit warnings like this: "Which actor should this closure live on, and is its isolation determined explicitly or by inference?"
Gradual migration without turning warnings into errors
The most common mistake moving to 6.3 is pulling the entire target into Swift 6 language mode in one shot. swift.org's migration guide states the rule explicitly: "In targets that adopt the Swift 6 language mode, complete checking is unconditionally on and requires no setting change." So there's no in-between; the line from a Swift Forums account — "First I switched the language version in Xcode to 6 and set concurrency checking to minimal" — actually contradicts this rule. The gradual path is the reverse: keep the target in Swift 5 mode, raise "Strict Concurrency Checking" in Xcode from minimal → targeted → complete, clean up warnings, then switch the language mode to 6.
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MyPackage",
targets: [
// Step 1: v5 language mode + complete checking (warnings only)
.target(
name: "LegacyModule",
swiftSettings: [
.swiftLanguageMode(.v5),
.enableUpcomingFeature("StrictConcurrency")
]
),
// Step 2: swiftSettings removed, target falls back to default v6 language mode
.target(name: "MigratedModule")
]
)
Step two: walk back carelessly sprinkled @MainActor annotations. The same forum post admits: "Looking back later, I realized I could remove some of the mainactor annotations I'd added carelessly." Reaching for @MainActor everywhere as a "silence the error" reflex creates debt to clean up later — better to understand the actual data-race point the compiler flags and fix it there.
Step three: when a team hits a compiler regression or an overly conservative diagnosis, open an issue and apply a temporary workaround without waiting for a language-team fix. Point-Free sums it up: "We've filed issues about these problems, but that doesn't help our users. So we've applied workarounds in the meantime." If you get stuck on a regression, add the annotation and keep moving — track the issue separately.
Managing version conflicts in SPM packages
A version upgrade affects not just your own code but the SPM packages you depend on — and their maintainers may have hit the same regressions first. Point-Free's experience with ComposableArchitecture and StructuredQueries is a good example. For StructuredQueries: "Swift 6.4 seems to have introduced a small type-checking regression that broke compilation of @Table macro code. The fix was simple ... and we shipped a release within 2 days." A similar quick-fix cycle played out for ComposableArchitecture too (the @MainActor example above).
This shows a concrete order to follow for dependency management when moving to 6.3 (or later 6.4):
- Check package versions first: look in the changelogs of your critical packages (ones like ComposableArchitecture, StructuredQueries) for a patch note mentioning "Swift 6.3" or "isolation."
- Don't upgrade the target without updating the package: before changing your compiler version, run swift package update to pull dependencies to their latest patch — maintainers have usually hit the regression before you.
- If a version is locked, track the issue: if your Package.resolved has locked a version predating 6.3, search the package's GitHub issues for "6.3" or "isolation."
- Temporary workaround in your own fork: if a critical package hasn't shipped a fix yet — with a reflex similar to what Point-Free did on the maintainer side — open the issue and add a temporary nonisolated(unsafe) or an explicit @MainActor in your own integration layer while you wait for the package's official fix, rather than forking the package itself.
These two examples confirm something else: isolation regressions during upgrades aren't specific to your codebase — popular production libraries hit the same friction. Knowing you're not alone helps morale during the migration.
What to expect in Swift 6.4
No official GA date for Swift 6.4 has been announced — Xcode 27 is still in beta as of early September 2026. But swift.org's August 20, 2026 post, signed by Doug Gregor, documents concrete 6.4 Embedded Swift improvements: "Embedded Swift previously only supported existential (any) types with the AnyObject constraint ... Now all any types, including Any itself, are usable in Embedded Swift." The same post expands untyped-throws support: "Untyped throws is equivalent to throwing a value of type any Error. With the generalization of any types, Embedded Swift now fully supports untyped throws." Metatypes get their own section too: "Swift 6.4 brings complete support for metatypes in Embedded Swift."
These changes don't target actor isolation directly, but matter for teams using Swift in embedded systems (microcontrollers, WebAssembly) — and Point-Free's experience shows 6.4's concurrency checking keeps up 6.3's tightening trend, sometimes surfacing new regressions. Advice: don't jump to the 6.4 beta before finishing your 6.3 migration — tracking the two versions' isolation-behavior differences separately makes it easier to tell which warning came from where.
If your team is following the Xcode 27 betas, watching the monthly digests on the swift.org/blog page (like the September 4, 2026 "What's new in Swift: August 2026 Edition") is the most reliable way not to miss the moment 6.4 approaches stable — the official announcement usually lands in sync with Xcode's own GA.
🏆 GOLDEN TIP: The fastest way to isolate a warning
When you hit a new isolation warning, ask this first: at which checking level (minimal/targeted/complete) and under which default-isolation setting (nonisolated/MainActor) does it appear? As in Itai Ferber's forum finding, the same code can produce four different outcomes under four different configurations. Temporarily flipping your target's SWIFT_STRICT_CONCURRENCY and -default-isolation flags and recompiling the same file lets you tell, within a few minutes, whether the warning is a real data race or a configuration-dependent compiler behavior. But remember: this is a diagnostic tool, not a fix — a configuration where the warning disappears doesn't prove the code is safe.
Checklist
We put together a short checklist you can keep on hand to figure out whether your project is really ready to move to Swift 6.3. Check off the items below in order; each one corresponds to a real warning case or escape hatch mentioned in this post.
- Note your target's default-isolation setting (nonisolated or MainActor) and its concurrency checking level (minimal/targeted/complete)
- List every function that uses a Sendable + escaping closure combination
- Flag every line using nonisolated(unsafe) and re-review whether it's genuinely safe
- Scan third-party imports for places that need @preconcurrency added
- Check the Swift 6.3 compatibility notes for your critical dependencies (especially actor/concurrency-heavy packages)
- Staying in Swift 5 language mode, raise the checking level from minimal to targeted to complete, and switch the language mode to 6 last
- Re-review any @MainActor annotations added carelessly during migration once the migration is done
- Fully finish your 6.3 migration before moving to the Xcode 27 beta — don't mix the isolation behavior of the two versions
FAQ
What code breaks when moving from Swift 6.2 to 6.3?
The most common breaking point is code using @Sendable @escaping closures under the combination of nonisolated module default and Complete concurrency checking. In a case documented on the Swift Forums, this combination compiled silently on 6.2 but starts producing a "risks causing data races" warning on 6.3. The general rule: since a declaration's isolation status (isolated/nonisolated) is part of its API contract, a version upgrade can change how that contract is checked.
Why is actor isolation stricter in Swift 6.3?
The official 6.3 announcement doesn't declare a new concurrency feature; even so, some patterns that compiled silently on 6.2 produce warnings on 6.3. In the documented case, the source isn't a new rule at all, but the conservative behavior of region isolation analysis: a mutable variable captured in a closure with different isolation is treated as "sent."
When will Swift 6.4 be released?
No exact GA date has been officially announced. What's known: Xcode 27 (which includes Swift 6.4) has been in beta since June 2026 and was at beta 6 as of August 24, 2026. swift.org's August 20, 2026 Embedded Swift post also describes 6.4 as still "coming" — as of early September 2026, 6.4 has not been officially released.
When should I use nonisolated vs. @concurrent?
nonisolated(nonsending) is suited for helper functions that are a logical part of the caller's work and don't need to hop to a separate thread — such functions stay on the caller's actor. @concurrent, on the other hand, is for genuinely expensive work that must run in parallel and shouldn't block the caller's actor — it's used to explicitly request the old "always run on a separate executor" behavior. When the NonisolatedNonsendingByDefault flag is on, plain nonisolated async functions stay on the caller's actor by default; you need to explicitly mark any function where you want to keep the old behavior with @concurrent.
Which build settings should I check before moving to 6.3?
Check your target's -default-isolation flag (MainActor or nonisolated) and its SWIFT_STRICT_CONCURRENCY level (minimal/targeted/complete). Per the forum finding in this post, the same code produces different results on 6.3 depending on the combination of these two settings — the documented warning doesn't appear in projects with the MainActor-default turned on.
Should I clean up my existing nonisolated(unsafe) usages before moving to 6.3?
Not mandatory, but recommended. nonisolated(unsafe) should remain a temporary escape hatch; the 6.3 migration is a good opportunity to review whether these annotations are still necessary or are simply leftovers from an old "silence it and move on" reflex.
Conclusion
Migrating to Swift 6.3 is less about learning a new language feature and more about understanding how the compiler now checks the actor isolation defaults introduced by 6.2 (SE-0466, SE-0461). The documented case teaches the lesson on its own: a mutable variable indirectly captured behind a Sendable+escaping signature runs into the conservative rule of region isolation analysis. Protocol conformance isolation (SE-0470) ties back to the same root: isolation is now a checked part of the API contract. If you want to refresh Swift's strict concurrency foundations before this migration, the Swift 6 Strict Concurrency Deep Guide is a good starting point; if you're looking for a step-by-step migration plan, the Swift 6 Strict Concurrency Migration Playbook complements this post.
If you want a deeper look at how actors fundamentally work, check out Swift Concurrency and Actors; for the Task and TaskGroup side of structured concurrency, see Swift Structured Concurrency. For projects using actors in distributed systems, Swift Distributed Actors covers the beyond-the-network isolation scenarios this post doesn't. You can also refresh the background on the general concurrency updates that came with Swift 6.2 at WWDC26 from Swift 6.2 Concurrency Innovations.
One last note: every example in this article was written against 6.3.3 (today's stable release). If you've started trying Swift 6.4 with the Xcode 27 beta, keep in mind that isolation behavior may differ from 6.3 — keeping the two versions' regressions separate in the same migration process makes it easier to tell where each warning is coming from.
Sources
- Swift 6.3 Released — official 6.3 announcement, March 24, 2026, summary of language changes
- Announcing Swift 6.3.3 — June 30, 2026 patch announcement, current stable release
- Embedded Swift Improvements Coming in Swift 6.4 — August 20, 2026, Doug Gregor, official evidence that 6.4 hasn't shipped yet
- SE-0466: Control Default Actor Isolation — module-wide default isolation proposal, Implemented in 6.2
- SE-0461: Run nonisolated async functions on the caller's actor — the nonisolated(nonsending) proposal, Implemented in 6.2
- New Swift 6.3 isolation warnings with Sendable x escaping escape hatch — Itai Ferber, Swift Forums, February 25, 2026, the primary source for this post
- My experience (attempting) a migration to swift 6 — Swift Forums, April 3, 2025, a gradual-migration account from the Swift 6.0 era
- Xcode 27 Support in the Point-Free Ecosystem — Point-Free, real regression-fix examples from ComposableArchitecture/StructuredQueries
- Enable data-race safety checking — swift.org migration guide, language mode and checking level rules
- What's new in Swift: August 2026 Edition — swift.org, September 4, 2026, monthly digest
---
Originally published at muhittincamdali.com.
SwiftUI の @State はなぜマクロになったのか ― 初期値の遅延評価と、その代償
!
確認日:2026-10-24
Xcode 26.6(Build 17F113)、Xcode 27.1(Build 27A9275)、macOS 26.7.1 で確認した内容です。記事の内容は、Apple の資料の改訂や Xcode の更新によって変わる可能性があります。
Xcode 27(iOS 27 SDK)から、SwiftUI の @State はプロパティラッパーではなくマクロとして実装されています。
Apple は、WWDC26 のセッションと Technote TN3211 で、この変更の目的を初期値の遅延評価だと説明しています。View の値が作り直されるたびに @State の初期値を評価し直す無駄をなくすためです。
この記事の主張は次の 3 点です。
目的:@State のマクロ化は、初期値を遅延評価するための変更である。
条件:ただし、遅延評価になるのは、private(または fileprivate)で宣言し、初期値を宣言に書いた @State だけである。イニシャライザで代入した値は対象外で、デプロイメントターゲットも iOS 17 以降(macOS 14 以降など、同時期の各 OS を含む)である必要がある。
代償:遅延評価を実現するために生成されるコードが変わり、Xcode 26 で動いていた書き方のいくつかがコンパイルエラーになる。なかには、エラーを素直に直すと実行時の値が変わってしまう書き方もある。また、Apple が勧める修正方法を使うと、遅延評価の恩恵を失う。
実務上は、@State を private で宣言し、初期値を宣言に書いてください。宣言に初期値を持つ private な @State に、イニシャライザで self.x = … と代入している箇所は見直してください。他の格納プロパティより先に代入していれば、Xcode 27 でコンパイルエラーになります。後に代入していれば、代入した値は保持されません(Xcode 26 でも同じです)。外部から値を受け取りたい場合の書き方は、第 5 章で目的別に示します。
以下では、この主張の根拠を順に示します。根拠は、Apple の資料と、Xcode 26.6 と Xcode 27.1 での実測です。Apple の資料は、WWDC26 のセッション 269 と 102、Technote TN3211、Xcode に含まれるエージェント向けスキルの 4 つです(参考資料)。計測の環境と手順は付録にまとめました(付録 A 以降)。
この記事での用語
View の値の再作成:親 View の body が再び評価され、子 View の構造体がイニシャライザから作り直されること。SwiftUI は、同じ位置にある子 View を同じ View とみなし、@State の値を引き継ぐ。
実体:@State の値を実際に保持する格納プロパティ。
遅延評価:宣言に書いた初期値の式を、View の値を作るたびではなく 1 回だけ評価すること。この記事で測ったのは評価の回数で、評価されるタイミングは測っていない。
保存場所の割り当て:SwiftUI が @State の値を保持する領域を用意すること。この記事では、イニシャライザで後から代入した値が保持されない理由を、割り当ての前に書き込んだためだと説明する。これは計測結果をもとにした筆者の理解で、SwiftUI の内部の仕組みを確かめたものではない。
1. 何が問題だったのか
SwiftUI の View は、軽い値として頻繁に作り直されます。親 View の body が再び評価されると、子 View の構造体もイニシャライザから作り直されます。このとき SwiftUI は、同じ位置にある子 View を同じ View とみなし、@State の値を引き継ぎます(View の値の再作成)。
問題は、@State の宣言に書いた初期値の式も、View の値を再作成するたびに評価されていたことです。
struct StoreView: View {
@State private var store = Store() // Store は @Observable なクラス
var body: some View { ... }
}
Xcode 26.6 でビルドしたアプリでは、StoreView の値が作り直されるたびに Store() が呼ばれます。セッション 269 の説明によれば、SwiftUI が使うのは最初に作られたインスタンスだけで、2 回目以降に作られたインスタンスは捨てられます。
StoreView と同じ形の View(private の @State にクラスを保持する View)で計測しました。親 View の値を 5 回再作成すると、クラスのイニシャライザは 6 回呼ばれました(最初の作成の 1 回と、再作成の 5 回)。計測の方法は第 2 章で示します。TN3211 も説明しているとおり、値型なら害は小さいものの、参照型ではそのたびにヒープ割り当てと初期化処理が発生します。
WWDC26 のセッション 269「What's new in SwiftUI」は、@Observable なクラスを @State に保持する例で、この無駄を説明しています。セッション 102「Platforms State of the Union」も、不要な処理を避けるための変更だと述べています。TN3211 も、この無駄の解消を @State をマクロにした理由として挙げています。
2. マクロ化で何が変わったのか
計測の方法
初期値の式が評価された回数を数えるため、評価のたびにカウンタを増やす式を @State の初期値に書きました。計測対象は、次の 3 つの子 View です(抜粋。count は回数を記録する関数で、全体は付録 C)。
@Observable final class Model {
init(_ tag: String) { count(tag) } // 評価のたびに回数を数える
}
func makeNumber(_ tag: String) -> Int {
count(tag)
return 0
}
struct PrivateClassChild: View { // private・クラス
let n: Int
@State private var model = Model("private class")
var body: some View { Text("(n)") }
}
struct InternalClassChild: View { // internal・クラス
let n: Int
@State var model = Model("internal class")
var body: some View { Text("(n)") }
}
struct PrivateValueChild: View { // private・値型
let n: Int
@State private var number = makeNumber("private value")
var body: some View { Text("(n)") }
}
各子 View の n は、親 View から受け取る表示用の整数で、親 View の値を再作成するたびに 1 ずつ増やしました。3 つの子 View を並べた親 View を NSHostingView に載せ、その NSHostingView をウィンドウに配置しました。ウィンドウは画面には表示していません。そのうえで、親 View の値を 5 回と 10 回再作成しました。再作成のきっかけは、次の 2 通りです。
rootView の差し替え:NSHostingView の rootView に、新しい親 View の値を代入する。
@Observable の値の変化:親 View が読んでいる @Observable なオブジェクトの値を変え、SwiftUI に親の body を再評価させる。アプリの中でもよく起きる更新のきっかけである。
同じプログラムを Xcode 26.6 と 27.1 でビルドし、同じ macOS 26.7.1 上で実行しました。実行した OS が同じなので、結果の差はビルド時に生成されるコードの違いによるものです。プログラムの全体は付録 C に載せました。
結果
表の数値は、すべて初期値の式が評価された回数です。どの値も、再作成のきっかけ(rootView の差し替えと @Observable の値の変化)によらず同じでした。全条件の出力は付録 C に載せました。
Xcode 27.1 では、初期値の式を 1 回だけ評価する
private のクラス(@State private var model = Model(...))で、Xcode 26.6 と 27.1 を比べます。
親 View の値を再作成した回数
Xcode 26.6 での評価回数
Xcode 27.1 での評価回数
5 回
6 回
1 回
10 回
11 回
1 回
Xcode 26.6 では、評価回数は View の値を作った回数(最初の 1 回と、再作成の回数の合計)に等しくなります。Xcode 27.1 では、再作成の回数によらず 1 回です。これが遅延評価です。
遅延評価になるのは private の場合だけ
Xcode 27.1 で、private と internal のクラスを比べます。
@State の宣言
5 回再作成したときの評価回数
10 回再作成したときの評価回数
@State private var model = Model(...)
1 回
1 回
@State var model = Model(...)(internal)
6 回
11 回
internal の @State は、Xcode 27.1 でも 26.6 と同じく、View の値を作るたびに評価されます。
クラスだけでなく、値型も遅延評価される
Xcode 27.1 で、private の @State の初期値をクラスと値型で比べます。値型は、評価のたびに回数を数えて Int を返す関数 makeNumber(_:) で作りました。
@State の宣言
5 回再作成したときの評価回数
10 回再作成したときの評価回数
@State private var model = Model(...)(クラス)
1 回
1 回
@State private var number = makeNumber(...)(値型)
1 回
1 回
セッション 269 はクラスの例で説明していますが、遅延されるのは初期値の式そのものです。
以上から、評価回数は特定のサンプルに固有の値ではないことが分かります。この計測の条件(宣言に初期値を書き、macOS 26 を対象にビルド)では、評価回数はビルドに使った Xcode と @State のアクセスレベルで決まります。
計測は macOS の NSHostingView で、ウィンドウを画面に表示しない状態で行いました。iOS の実機やシミュレータでは確かめていません。
Apple の資料との対応
遅延評価の条件について、Apple の資料はいずれも private を条件として明記していません。
資料
遅延評価の条件についての説明
実測と一致するか
WWDC26 セッション 269
@State に保持したクラスは 1 回だけ初期化されると説明している。コード例はすべて private だが、private が条件だとは述べていない
internal の場合に一致しない
WWDC26 セッション 102
コードを変えなくても、この改善が得られると説明している
internal の場合に一致しない
TN3211(2026-06-08 公開)
条件を付けずに、@State に保持したオブジェクトは 1 回だけ初期化されると説明している
internal の場合に一致しない
スキル swiftui-specialist(Xcode 27.1、Build 27A9275)
@State を常に private にするよう指示している。ただし、遅延評価の条件としては説明していない
条件を述べていない
4 つの資料のいずれも、遅延評価が private に限られることを説明していません。private が条件であることは、この記事の実測から分かったことです。スキルの指示どおり @State を常に private にし、初期値を宣言に書いていれば、結果として遅延評価の恩恵を受けられます(iOS 17 以降を対象とする場合)。
セッション 269 は、この挙動が @Observable と同じリリース(iOS 17、macOS 14 と、同時期の各 OS)までさかのぼって使えると説明しています。デプロイメントターゲットの条件は、コンパイル結果でも確かめました。@State private var model = Model() を、デプロイメントターゲットを変えて Xcode 27.1 でコンパイルすると、実体の型は次のようになります。
デプロイメントターゲット
実体の型
初期値の式が評価されるタイミング(型からの推定)
iOS 16.0
State<Model>
View の値を作るたびに評価される
iOS 17.0
LazyState<Model>
1 回だけ評価される(遅延評価)
3 列目は、実体の型から推定したもので、実行して測ってはいません。
イニシャライザで代入した値は遅延評価されない
遅延評価の対象は、宣言に書いた初期値だけです。イニシャライザで @State に代入した値は、private でも遅延評価されません。宣言の初期値を外してイニシャライザで代入する形は、セッション 269 と TN3211 がコンパイルエラーの修正方法として示している形です。
同じ @Observable クラス Page を、3 つの書き方で @State に保持し、親 View の値を 5 回再作成しました(計測プログラムは付録 D)。表の数値は、Page のイニシャライザが呼ばれた回数です。
@State の書き方
Xcode 26.6 での Page の生成回数
Xcode 27.1 での Page の生成回数
@State private var page = Page(...)(宣言に初期値を書く)
6 回
1 回
@State private var page: Page と宣言し、イニシャライザで self.page = Page(...) と代入する
6 回
6 回
@State private var page: Page と宣言し、イニシャライザで _page = State(initialValue: Page(...)) と代入する
6 回
6 回
イニシャライザの本体は、View の値を作るたびに実行されます。Page(...) はイニシャライザの中で評価されるので、評価済みの値が @State に渡ります。マクロが遅延させられるのは、宣言に書かれた式だけです。
親から受け取った値を使って、イニシャライザで @State のオブジェクトを作る限り、Xcode 27 でも View の値を作るたびにインスタンスが作られます。Apple 以外の情報源では、Point-Free もこの点を指摘しています。外部から値を受け取る場合の書き方は、第 5 章で示します。
3. マクロで何が可能になったのか
Apple の資料は、@State をマクロにした理由を遅延評価としか説明していません。プロパティラッパーのままでは実現できなかったのかどうかは、述べていません。この章は、展開結果と SDK をもとにした筆者の分析です。
遅延評価そのものは、マクロでなくても実現できた可能性があります。Xcode 26.6 の SDK にも、LazyState の次のシンボルが含まれているからです。
初期値を @autoclosure で受け取る init(wrappedValue:)
wrappedValue と projectedValue
これらは、プロパティラッパーと同じ形のメンバーです。
マクロで可能になったのは、宣言ごとに生成するコードを変えることです。プロパティラッパーでは、実体の型は付けた属性の型に固定されます。@State なら、実体は必ず State<Value> です。マクロは宣言の構文を見て、アクセスレベルと初期値の有無に応じて、実体の型と初期値の扱いを切り替えられます。さらに、従来の _x や $x という名前を残して、既存のコードとの互換性を保てます。
展開の 3 つの形
Xcode 27.1 のコンパイラが出力した展開結果は、次の 3 通りに分かれました(取得方法は付録 B)。
形
宣言の例
実体
初期値の式が評価されるタイミング
宣言したプロパティに init accessor が付くか
A
@State private var x: T(宣言に初期値を書かない)
_x: State<T>
宣言に初期値がないので、評価する式もない
付く
B
@State var x = v(private 以外で、初期値を書く)
_x: State<T>
View の値を作るたびに評価される
付く
C
@State private var x = v(private で、初期値を書く)
__x: LazyState<T>
1 回だけ評価される(遅延評価)
付かない
A と B は、Xcode 26.6 のプロパティラッパーが合成していたものと同じ構成です。C だけが新しい形で、遅延評価はこの形で実現されています。
C の形の展開結果(抜粋)は次のとおりです。
@State private var count = 0
// 実体。初期値の式はクロージャに包まれて保存される
private var __count = SwiftUICore.State._makeStorage({
0
})
// count 自身は計算プロパティになる。init accessor はない
private var count: Int {
get { __count.wrappedValue }
nonmutating set { __count.wrappedValue = newValue }
}
_makeStorage は、デプロイメントターゲットが iOS 17・macOS 14 以降なら LazyState を返します。LazyState は、初期値をクロージャのまま保持できる型です。
@frozen public struct LazyState<Value> : DynamicProperty {
public enum Storage {
case thunk(() -> Value)
case value(Value)
}
...
}
LazyState は、Xcode 27 で新しく作られた型ではありません。Xcode 26.6 の SDK でも、公開インタフェースには出ていないものの、SwiftUICore.tbd にシンボルとして含まれています。Xcode 27.1 の宣言には @available(iOS 17.0, macOS 14.0, ...) が付いています。つまり、iOS 17 以降の OS にすでに入っていた型を、Xcode 27 のマクロが使い始めたということです。遅延評価が iOS 17 以降を対象とするアプリで使えるのは、このためです。セッション 269 の説明(iOS 17 までさかのぼって使える)とも一致します。
遅延評価が private に限られる理由
資料には、遅延評価が private に限られる理由は書かれていません。筆者は、memberwise init との両立が理由だと推測しています。
B の形のように private でないプロパティは、memberwise init の引数として外から値を受け取れます。たとえば、@State var count = 0 を持つ View V は、V(count: 3) と初期化できます。このため、B の形の count には init accessor が付き、Xcode 26.6 と同じ使い方ができます。一方、C の形の count には init accessor がなく、memberwise init の引数としては値を受け取りません。イニシャライザの中で _count に代入する経路だけが残っています。
マクロ特有の工夫
C の形では、マクロが型情報を持たないことを補う工夫も見られます。
型の穴:マクロは構文しか参照できないので、count の型が Int であることを判別できない。そこで _count: State<_>! と型を空けて宣言し、nil を返すだけの関数 _stateNil(of: { State(initialValue: 0) }) で型推論に型を伝えている。
base64 の文字列引数:補助マクロ(_StatePropertyWrapperStorage など)は、初期値の式などのソースコードを base64 の文字列として受け取る。ネストしたマクロは他の宣言の展開結果を参照できないため、と筆者は推測している。
互換性のための _count:実体は __count に移ったが、_count は init accessor を持つ計算プロパティとして残る。イニシャライザで _count = State(initialValue: x) と書くと、この init accessor を通って __count が初期化される。
4. 遅延評価の代償
TN3211 の説明では、マクロ化によって、@State は他の格納プロパティと同じ規則で初期化されるようになりました。その結果、以前は受け入れられていた書き方の一部を、コンパイラが診断するようになりました。
注意点 1、4、5 は、第 3 章の展開の形から説明できます。注意点 3 は、展開の形の変形(初期値を書かない Optional)によるもので、その節で説明します。注意点 2 は、マクロ化の前からある挙動です。コンパイル結果と実行時の値は、Xcode 26.6 と 27.1 で実測しました。
注意点 1:private で初期値を持つ @State に、他のプロパティより先に代入するとエラーになる
struct ContentView: View {
var name: String
@State private var counter: Int = 0
init(name: String) {
self.counter = 42
self.name = name
}
var body: some View { Text("(name): (counter)") }
}
項目
Xcode 26.6 での結果
Xcode 27.1 での結果
コンパイル
成功する
エラーになる:variable 'self.name' used before being initialized
body が読む counter の値
42
コンパイルエラーのため実行できない
C の形の counter には init accessor がないので、self.counter = 42 は setter 呼び出しになります。setter を呼べるのは、self のすべての格納プロパティを初期化した後です。そのため、name の初期化より前に書くとエラーになります。
private 以外(B の形)の場合は、counter に init accessor があるので、Xcode 27.1 でもコンパイルが成功します。body が読む値は Xcode 26.6 と同じでした(注意点 2 の表)。
注意点 2:エラーを代入の順番の入れ替えで解消すると、値が変わる
注意点 1 のエラーは、代入の順番を入れ替えると消えます。
init(name: String) {
self.name = name
self.counter = 42 // 順番を入れ替えた
}
項目
Xcode 26.6 での結果
Xcode 27.1 での結果
コンパイル
成功する
成功する
body が読む counter の値
0
0
しかし、body が読む値は 42 から 0 に変わります。この位置の代入は、Xcode 26.6 でも setter 呼び出しでした。筆者の理解では、イニシャライザの実行中は、SwiftUI がまだ @State の値の保存場所を割り当てていません。そのため、書き込んだ 42 は保持されないと考えられます。このとき、コンパイラはエラーと警告のどちらも出しません。
TN3211 も、宣言に初期値を持つ @State にイニシャライザで代入した値は破棄されると注意しています。
代入の順番で値が変わるという現象は、マクロ化の前からありました。private 以外の @State では、Xcode 27.1 でも同じ現象が起きます。マクロ化で変わったのは、private の場合に、代入した 42 が保持される書き方(他の格納プロパティより先に代入する書き方)がコンパイルエラーになった点です。
正しい修正方法は、宣言から初期値を削除して A の形にし、他の格納プロパティを初期化した後で代入することです。
struct ContentView: View {
var name: String
@State private var counter: Int
init(name: String) {
self.name = name
self.counter = 42 // init accessor による初期化
}
var body: some View { Text("(name): (counter)") }
}
このコードは、Xcode 26.6 と 27.1 のどちらでもコンパイルが成功し、body は 42 を読みました。セッション 269 と TN3211 も、宣言の初期値を外す修正方法を示しています。
ただし、この修正には代償があります。イニシャライザで代入した値は遅延評価されません(第 2 章)。@State に保持するのが参照型なら、View の値を作るたびにインスタンスが作られます。宣言の初期値で足りる場合は、イニシャライザで代入しないでください。外部から値を受け取る場合の書き方は、第 5 章で示します。
A の形では、代入を他のプロパティより先に置いても、Xcode 27.1 ではコンパイルが成功しました。それでも後に置くのは、TN3211 が、@State 以外の格納プロパティをすべて初期化してから @State に代入することを、修正方法として示しているからです。後に置けば、TN3211 の説明と実測のどちらにも反しません。
private 以外の @State(@State var counter = 0)では、代入の順番で値の変わる現象が、Xcode 27.1 でもそのまま残ります。
イニシャライザでの代入
Xcode 26.6 で body が読む値
Xcode 27.1 で body が読む値
他の格納プロパティより先に self.counter = 42
42
42
他の格納プロパティより後に self.counter = 42
0
0
self.counter = 42(他に格納プロパティがない View)
0
0
private の @State で、代入した 42 が保持される書き方は次の 2 つです。どちらも Xcode 26.6 と 27.1 の両方で、body は 42 を読みました。
counter の宣言
イニシャライザでの代入
body が読む値
@State private var counter: Int(宣言に初期値を書かない)
self.counter = 42
42
@State private var counter = 0
_counter = State(initialValue: 42)
42
注意点 3:暗黙の nil を持つ Optional があると、private な memberwise init は合成されない
struct Foo: View {
@State private var bar: String? // 暗黙に nil
private let baz: Int
var body: some View { Text("") }
}
extension Foo {
init(value: Int) {
self.init(baz: value)
}
}
項目
Xcode 26.6 での結果
Xcode 27.1 での結果
コンパイル
成功する
エラーになる:incorrect argument label in call (have 'baz:', expected 'value:')
Swift は、すべてのメンバーが private の構造体にも、private な memberwise init を合成します。この memberwise init は、同じファイルの extension から呼び出せます。
初期値を書かない Optional の @State も C の形になります。ただし、この場合だけは bar と _bar の両方に、__bar を初期化する init accessor が付きます。SE-0400 の規則では、同じ格納プロパティを初期化する init accessor が複数あると、memberwise init は合成されません。
Xcode 27.1 では、次の場合に memberwise init が合成されることを確かめました。
@State private var bar: String? = nil のように、明示的に = nil を書く(bar 側に init accessor が付かない)
@State private var bar = 0 のように、Optional ではない初期値を書く
他のメンバーに internal のものがあり、memberwise init が internal になる
修正方法は、= nil を明示的に書くか、memberwise init を自分で定義するかのどちらかです。
注意点 4:他のプロパティラッパーと組み合わせると、名前が衝突する
@propertyWrapper struct Logged<T> { var wrappedValue: T }
struct V: View {
@State @Logged private var n = 0
var body: some View { Text("") }
}
項目
Xcode 26.6 での結果
Xcode 27.1 での結果
コンパイル
成功する
エラーになる:invalid redeclaration of synthesized property '_n' と property wrapper cannot be applied to a computed property
@State マクロと @Logged プロパティラッパーが、どちらも _n という名前の宣言を生成しようとして衝突します。さらに、マクロによって n は計算プロパティになるので、プロパティラッパーを付けられません。修正方法は、どちらかの属性を削除するか、@Logged を付けたプロパティを別に定義するかのどちらかです。
注意点 5:C の形では、_x を読むと保存場所を参照しない値が返る
ここでは、第 3 章の @State private var count = 0 を例にします。Xcode 26.6 では、_count は実体そのものでした。Xcode 27.1 の C の形では、_count の getter は、現在の値を初期値とする新しい State を作って返します。
get {
SwiftUICore.State(initialValue: __count.wrappedValue)
}
この State は、SwiftUI が割り当てた保存場所を参照しません。_count を他の View や関数に渡しているコードは、意図どおりに動かない可能性があります。Binding が必要な場合は $count を使います。この項目は展開結果から読み取った内容で、実行時の挙動は確かめていません。
5. 外部から値を注入したい場合
ここまでの結果から、次の疑問が生じます。親から受け取った値を使って @State を初期化したい場合は、どう書けばよいのでしょうか。
答えは、受け取った値をどう扱いたいかによって変わります。書き方ごとの違いを確かめるため、次の手順で計測しました(計測プログラムは付録 E)。
親 View は、子 View に文字列 title を渡す。最初に親 View を作るときの title は "v0" とする。
親 View の値を 5 回再作成し、そのたびに title を "v1" から "v5" まで 1 つずつ変えて渡す。
最後に "v5" を渡した後で、子 View が body で読んでいる値を記録する。あわせて、参照型 Page のイニシャライザが呼ばれた回数を数える。
子 View が "v5" を読んでいれば、親が渡した最新の値に追従しています。"v0" を読んでいれば、最初に受け取った値のままです。
次の表は、Xcode 27.1 での結果です。
目的
書き方
親が最後に "v5" を渡した後に、子 View が読む値
Page の生成回数
親の値に追従させる
@State を使わず、let title: String で受け取る
"v5"(親の値に追従する)
Page を作らない
最初の値だけを初期値として使う
@State private var title: String と宣言し、イニシャライザで self.title = title と代入する
"v0"(最初に受け取った値のまま)
Page を作らない
親の値から参照型を 1 回だけ作る
@State private var page: Page? と宣言し、.task { if page == nil { page = Page(...) } } で作る
"v0"(最初に受け取った値で作った Page のまま)
1 回
親の値が変わるたびに参照型を作り直す
@State private var page: Page? と宣言し、.task(id: title) { page = Page(...) } で作る
"v5"(親の値が変わるたびに作り直す)
6 回
生成を親に移す
親が @State private var page = Page(...) で作り、子は let page: Page で受け取る
親が作った Page の値(この書き方では title を使わない)
1 回
Xcode 26.6 でも、最後の行を除いて同じ結果でした。最後の行(生成を親に移す)だけは、Xcode 26.6 では Page が 6 回作られました。親の @State の宣言の初期値が、遅延評価されないためです。
親の値に追従させたいなら、@State を使わない
イニシャライザで @State に代入した値は、最初の 1 回だけが使われます。親が後から別の値を渡しても、子の @State は最初の値のままです。これは Xcode 26.6 でも 27.1 でも同じで、マクロ化とは関係のない @State の性質です。
親の値を表示するだけなら、let で受け取ります。子から親の値を書き換える必要があるなら、@Binding で受け取ります。
最初の値だけを使うなら、イニシャライザで代入する
親の値を初期値として受け取り、その後は子が独自に変更する場合は、A の形(初期値を書かず、イニシャライザで代入する)を使います。数値や文字列のように生成の負荷が小さい値なら、この書き方で問題ありません。
ただし、代入する式はイニシャライザの中で評価されるので、View の値を作るたびに評価されます(第 2 章)。生成の重い参照型には向きません。
親の値から重い参照型を作るなら、.task で作る
@State を Optional にしておき、.task の中で 1 回だけ作ります。計測では、親を 5 回再作成しても Page の生成は 1 回でした。
struct PageView: View {
let title: String
@State private var page: Page?
var body: some View {
Group {
if let page {
Text(page.title)
} else {
ProgressView()
}
}
.task {
if page == nil {
page = Page(title: title)
}
}
}
}
親の値が変わったときに作り直したい場合は、.task の代わりに .task(id: title) { page = Page(title: title) } と書きます。計測では、親の値が変わるたびに作り直されました。
この書き方には、次の制約があります。
.task の処理は非同期に実行されるので、最初の body の評価では page が nil になる。nil のときの表示を用意する必要がある。
計測は macOS の NSHostingView で、ウィンドウを画面に表示しない状態で行った。この状態でも .task は実行されたが、画面に表示した状態や iOS では確かめていない。
そのほかの選択肢
生成を親に移す:オブジェクトを、値を持っている側(親や、親が保持するモデル)で作り、子には let で渡す。計測では、親が @State private var と宣言の初期値で作った Page は、親を 5 回再作成しても 1 回だけ作られた(Xcode 26.6 では 6 回)。ただし、この方法が使えるのは、親が子に渡す値を使わずに、宣言の初期値だけで Page を作れる場合に限る。
Apple 以外のライブラリ:Point-Free の @LazyState マクロは、親から受け取った値で @State を遅延して作るためのものである。2026-10-24 時点では、会員向けのベータ版として非公開リポジトリで提供されている。
6. Apple の資料を読むときの注意
TN3211 の @State に関する説明を、実測結果と照らし合わせました。TN3211 のコード例は、そのままの形で Xcode 26.6 と 27.1 でコンパイルしました。
TN3211 の説明
実測の結果
実測と一致するか
初期値のない @State private var counter: Int に、他の格納プロパティより先に代入するとエラーになる
両方の Xcode でコンパイルに成功。エラーになるのは、宣言に初期値がある場合(注意点 1)
一致しない
すべてのメンバーが private の View で、extension から self.init(bar: bar, baz: baz) を呼ぶとエラーになる
Xcode 26.6 でも同じ呼び出しでエラー。メッセージは extraneous argument label 'bar:' で、TN3211 の no exact matches in call to initializer とは異なる(注意点 3)
一致しない
初期値を持つ @State にイニシャライザで代入した値は破棄され、宣言側の初期値が優先される
他の格納プロパティの後に代入すると、Xcode 26.6 と 27.1 のどちらでも body は初期値の 0 を読む(注意点 2)
一致する
他のプロパティラッパーと組み合わせると invalid redeclaration of synthesized property エラーになる
Xcode 27.1 で同じエラー(注意点 4)
一致する
@State に保持したオブジェクトは 1 回だけ初期化される
詳しくは第 2 章の「Apple の資料との対応」を参照
一部だけ一致する
WWDC26 セッション 269 の修正例も、TN3211 の説明と食い違います。この修正例は、宣言の初期値を外したうえで、@State への代入を他の格納プロパティより先に書いています。上の表の 1 行目の説明(初期値のない @State に、他の格納プロパティより先に代入するとエラーになる)に従えばエラーになる書き方ですが、実測ではコンパイルが成功しました。セッションの例と実測は一致しています。
TN3211 の改訂履歴には、2026-06-08 の初版しか記載されていません。一致しない項目が、TN3211 の記述の誤りなのか、Xcode 27.0 の時点の挙動なのかは判別できません。筆者は Xcode 27.0 を確かめていません。ただし、同じ時期に公開されたセッション 269 の例は、上の表の 1 行目の説明に反しています。
TN3211 は、注意点 1 のエラーメッセージとして、次のメッセージも挙げています。
error: 'self' used in property access '_counter' before 'super.init' call
このメッセージは、筆者が確かめたどの Xcode でも出ませんでした。
Apple の資料どうしで説明が異なる場合や、資料の例が手元で再現しない場合は、実際にコンパイル・実行して確かめることをお勧めします。
まとめ
@State のマクロ化の目的は、初期値の遅延評価です。マクロは、宣言の構文とデプロイメントターゲットに応じて実体の型を State と LazyState から選ぶことで、これを実現しています。この記事の結果から、次の書き方を勧めます。
@State は private で宣言する。 遅延評価になるのは private(または fileprivate)の場合だけである。デプロイメントターゲットも iOS 17・macOS 14 以降である必要がある。
遅延評価させたい初期値は、宣言に書く。 イニシャライザで代入した値は遅延評価されない。
外部から値を受け取るときは、目的に応じて書き方を選ぶ。 親の値に追従させるなら let か @Binding、最初の値だけを使うなら初期値を書かずにイニシャライザで代入する。親の値から重い参照型を作るなら、.task で作るか、生成を親に移す(第 5 章)。
private の @State で起きたコンパイルエラーを、代入の順番の入れ替えで解消しない。 Xcode 26.6 で保持されていた値が保持されなくなる。しかも、コンパイラはエラーと警告のどちらも出さない。
初期値の式の副作用に依存しない。 private の @State では、初期値の式は 1 回だけ評価されるので、式の副作用(ログ出力など)も 1 回だけ起きる。
Xcode 26.6 から 27.1 で変わったことを表にまとめます。
項目
Xcode 26.6 での挙動
Xcode 27.1 での挙動
private の @State の、宣言に書いた初期値の式
View の値を作るたびに評価される
1 回だけ評価される
宣言に初期値を書いた private な @State に、他の格納プロパティより先にイニシャライザで代入する
コンパイルが成功し、代入した値が保持される
コンパイルエラーになる
初期値を書かない private な Optional の @State を持ち、すべてのメンバーが private の型の memberwise init
合成される
合成されない
@State と他のプロパティラッパーを同じプロパティに付ける
コンパイルが成功する
コンパイルエラーになる
private で初期値を書いた @State の _x を読む
実体そのものが返る
保存場所を参照しない新しい State が返る(展開結果から読み取った内容)
次の項目は、Xcode 26.6 と 27.1 で変わりません。
項目
Xcode 26.6 と 27.1 での挙動
private 以外の @State の、宣言に書いた初期値の式
View の値を作るたびに評価される
イニシャライザで @State に代入する式
View の値を作るたびに評価される
宣言に初期値を書いた private 以外の @State に、他の格納プロパティより先にイニシャライザで代入する
コンパイルが成功し、代入した値が保持される
付録 A:確認した環境
項目
Xcode 26.6
Xcode 27.1
ビルド番号
17F113
27A9275
Swift の版
6.3.3
6.4
@State の実装
プロパティラッパー
マクロ
コンパイル診断:iOS Simulator 向けに swiftc -emit-sil でコンパイルした。デプロイメントターゲットは iOS 26.0 で、第 2 章の型の比較だけ iOS 16.0 と 17.0 を使った。第 5 章のサンプル PageView は、iOS 17.0 向けにコンパイルできることを確かめた
Xcode 27.1:App Store で配布されている正式版を使った
実行時の挙動:それぞれの Xcode で macOS 向けにビルドし、同じ macOS 26.7.1 上で実行した。NSHostingView を載せたウィンドウは、画面には表示していない
実行時の計測のデプロイメントターゲット:macOS 向けのビルドでは -target を指定せず、デフォルトの arm64-apple-macosx26.0 を使った。遅延評価が働く条件(macOS 14 以降)を満たしている
展開結果:Xcode 27.1 で、デプロイメントターゲットを iOS 27.0 にして出力した(付録 B)
記事中のコード片は、コンパイラが出力した展開結果から該当部分を抜き出したものです。読みやすくするため、一部を整形しています。
付録 B:展開結果を出力する方法
Xcode の「Expand Macro」は、選択した 1 か所の展開結果を表示します。ファイル内のすべての展開結果を一度に出力するには、次のコマンドを使います。
xcrun swiftc -typecheck \
-sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
-target arm64-apple-ios27.0-simulator \
-parse-as-library \
-Xfrontend -dump-macro-expansions \
Sample.swift
環境変数 DEVELOPER_DIR に各 Xcode の Contents/Developer を指定すると、同じファイルを別の Xcode でコンパイルできます。-target の OS の版は、確かめたいデプロイメントターゲットに合わせて変えてください。
!
「used before being initialized」は、確定初期化(definite initialization)の診断です。この診断は SIL の生成段階で出ます。そのため、-typecheck ではエラーが出ずに成功します。 コンパイルエラーを確かめるときは、-typecheck を -emit-sil -o /dev/null に置き換えてください。
付録 C:遅延評価の計測プログラム
第 1 章と第 2 章の計測には、次のプログラムを使いました。再作成の回数(5 回と 10 回)と、再作成のきっかけ(rootView の差し替えと @Observable の値の変化)の組み合わせごとに、初期値の式の評価回数を出力します。
計測プログラムの全体(Probe.swift)
Probe.swift
import SwiftUI
import AppKit
// 初期値の式が評価された回数を数える
nonisolated(unsafe) var counts: [String: Int] = [:]
func count(_ tag: String) { counts[tag, default: 0] += 1 }
@Observable final class Model {
init(_ tag: String) { count(tag) }
}
func makeNumber(_ tag: String) -> Int {
count(tag)
return 0
}
// 計測対象の子 View
struct PrivateClassChild: View {
let n: Int
@State private var model = Model("private class")
var body: some View { Text("(n)") }
}
struct InternalClassChild: View {
let n: Int
@State var model = Model("internal class")
var body: some View { Text("(n)") }
}
struct PrivateValueChild: View {
let n: Int
@State private var number = makeNumber("private value")
var body: some View { Text("(n)") }
}
struct Parent: View {
let n: Int
var body: some View {
VStack {
PrivateClassChild(n: n)
InternalClassChild(n: n)
PrivateValueChild(n: n)
}
}
}
// 再作成のきっかけ 2:@Observable の値の変化で親の body を再評価させる
@Observable final class Driver { var n = 0 }
struct Root: View {
let driver: Driver
var body: some View { Parent(n: driver.n) }
}
@MainActor func host<V: View>(_ view: V) -> NSHostingView<V> {
let host = NSHostingView(rootView: view)
host.frame = NSRect(x: 0, y: 0, width: 200, height: 100)
let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false)
window.contentView = host
host.layoutSubtreeIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
return host
}
@MainActor func report(_ trigger: String, _ times: Int) {
let tags = ["private class", "internal class", "private value"]
print("(trigger), recreated (times): " + tags.map { "($0) (counts[$0] ?? 0)" }.joined(separator: ", "))
counts = [:]
}
@main struct Probe {
@MainActor static func main() {
_ = NSApplication.shared
for times in [5, 10] {
// きっかけ 1:rootView を差し替える
let h1 = host(Parent(n: 0))
for i in 1...times {
h1.rootView = Parent(n: i)
h1.layoutSubtreeIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
}
report("rootView", times)
// きっかけ 2:@Observable の値を変える
let driver = Driver()
let h2 = host(Root(driver: driver))
for i in 1...times {
driver.n = i
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
h2.layoutSubtreeIfNeeded()
}
report("observable", times)
}
}
}
次のコマンドでビルドして実行します。
xcrun swiftc -parse-as-library -sdk "$(xcrun --sdk macosx --show-sdk-path)" Probe.swift -o probe
./probe
出力は次のとおりです。
Xcode 26.6 でビルド
rootView, recreated 5: private class 6, internal class 6, private value 6
observable, recreated 5: private class 6, internal class 6, private value 6
rootView, recreated 10: private class 11, internal class 11, private value 11
observable, recreated 10: private class 11, internal class 11, private value 11
Xcode 27.1 でビルド
rootView, recreated 5: private class 1, internal class 6, private value 1
observable, recreated 5: private class 1, internal class 6, private value 1
rootView, recreated 10: private class 1, internal class 11, private value 1
observable, recreated 10: private class 1, internal class 11, private value 1
付録 D:イニシャライザで代入した値の計測プログラム
第 2 章の「イニシャライザで代入した値は遅延評価されない」の計測には、次のプログラムを使いました。ビルドと実行の方法は付録 C と同じです。
計測プログラムの全体(Probe.swift)
Probe.swift
import SwiftUI
import AppKit
nonisolated(unsafe) var counts: [String: Int] = [:]
@Observable final class Page { init(_ tag: String) { counts[tag, default: 0] += 1 } }
// 宣言の初期値で作る(C の形)
struct DefaultValue: View {
let title: String
@State private var page = Page("default value")
var body: some View { Text(title) }
}
// 初期値を外して init で代入する(A の形。WWDC26 セッション 269 の修正例と同じ形)
struct InitAssign: View {
let title: String
@State private var page: Page
init(title: String) { self.title = title; self.page = Page("init assign") }
var body: some View { Text(title) }
}
// init で _page = State(initialValue:) と書く
struct InitUnderscore: View {
let title: String
@State private var page: Page
init(title: String) { self.title = title; _page = State(initialValue: Page("init _page")) }
var body: some View { Text(title) }
}
struct Parent: View {
let n: Int
var body: some View { VStack { DefaultValue(title: "(n)"); InitAssign(title: "(n)"); InitUnderscore(title: "(n)") } }
}
@main struct Probe {
@MainActor static func main() {
_ = NSApplication.shared
let host = NSHostingView(rootView: Parent(n: 0))
host.frame = NSRect(x: 0, y: 0, width: 200, height: 100)
let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false)
window.contentView = host
host.layoutSubtreeIfNeeded()
for i in 1...5 {
host.rootView = Parent(n: i)
host.layoutSubtreeIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
}
for (k, v) in counts.sorted(by: { $0.key < $1.key }) { print("parent recreated: 5 (k): Page.init (v)") }
}
}
出力は次のとおりです。
Xcode 26.6 でビルド
parent recreated: 5 default value: Page.init 6
parent recreated: 5 init _page: Page.init 6
parent recreated: 5 init assign: Page.init 6
Xcode 27.1 でビルド
parent recreated: 5 default value: Page.init 1
parent recreated: 5 init _page: Page.init 6
parent recreated: 5 init assign: Page.init 6
付録 E:外部から値を注入する書き方の計測プログラム
第 5 章の計測には、次のプログラムを使いました。親が子に渡す文字列 title を "v0" から "v5" まで変え、書き方ごとに、子 View の body が最後に読んだ値と Page の生成回数を出力します。5 つめのケースは、親が Page を作って子に渡す形です。ビルドと実行の方法は付録 C と同じです。
計測プログラムの全体(Probe.swift)
Probe.swift
import SwiftUI
import AppKit
nonisolated(unsafe) var counts: [String: Int] = [:]
nonisolated(unsafe) var lastSeen: [String: String] = [:]
@Observable final class Page {
let title: String
init(_ tag: String, title: String) { self.title = title; counts[tag, default: 0] += 1 }
}
// 1. init で @State に代入(値型)
struct InitValue: View {
@State private var title: String
init(title: String) { self.title = title }
var body: some View { lastSeen["1 init value"] = title; return Text(title) }
}
// 2. 親の値をそのまま let で受け取る
struct LetValue: View {
let title: String
var body: some View { lastSeen["2 let value"] = title; return Text(title) }
}
// 3. Optional の @State を .task で 1 回だけ作る
struct TaskOnce: View {
let title: String
@State private var page: Page?
var body: some View {
lastSeen["3 task once"] = page?.title ?? "nil"
return Text(page?.title ?? "").task { if page == nil { page = Page("3 task once", title: title) } }
}
}
// 4. Optional の @State を .task(id:) で、値が変わったときに作り直す
struct TaskID: View {
let title: String
@State private var page: Page?
var body: some View {
lastSeen["4 task id"] = page?.title ?? "nil"
return Text(page?.title ?? "").task(id: title) { page = Page("4 task id", title: title) }
}
}
// 5. 生成を親に移す:親が宣言の初期値で作り、子には let で渡す
struct OwnerParent: View {
@State private var page = Page("5 parent owns", title: "owned")
var body: some View { OwnedChild(page: page) }
}
struct OwnedChild: View {
let page: Page
var body: some View { lastSeen["5 parent owns"] = page.title; return Text(page.title) }
}
struct Parent: View {
let n: Int
var body: some View {
VStack { InitValue(title: "v(n)"); LetValue(title: "v(n)"); TaskOnce(title: "v(n)"); TaskID(title: "v(n)"); OwnerParent() }
}
}
@main struct Probe {
@MainActor static func main() {
_ = NSApplication.shared
let host = NSHostingView(rootView: Parent(n: 0))
host.frame = NSRect(x: 0, y: 0, width: 200, height: 200)
let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false)
window.contentView = host
host.layoutSubtreeIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.2))
for i in 1...5 {
host.rootView = Parent(n: i)
host.layoutSubtreeIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.2))
}
print("parent recreated: 5 (last title passed: v5)")
for k in lastSeen.keys.sorted() { print("(k): body sees (lastSeen[k]!) Page.init (counts[k] ?? 0)") }
}
}
出力は次のとおりです。5 つめのケースだけ、Xcode 26.6 と 27.1 で結果が異なります。
Xcode 26.6 でビルド
parent recreated: 5 (last title passed: v5)
1 init value: body sees v0 Page.init 0
2 let value: body sees v5 Page.init 0
3 task once: body sees v0 Page.init 1
4 task id: body sees v5 Page.init 6
5 parent owns: body sees owned Page.init 6
Xcode 27.1 でビルド
parent recreated: 5 (last title passed: v5)
1 init value: body sees v0 Page.init 0
2 let value: body sees v5 Page.init 0
3 task once: body sees v0 Page.init 1
4 task id: body sees v5 Page.init 6
5 parent owns: body sees owned Page.init 1
参考資料
What's new in SwiftUI(WWDC26 セッション 269)
developer.apple.com
「Data flow and performance」の章(19:58 から)で、@State のマクロ化と遅延評価、iOS 17 への対応、コンパイルエラーの修正方法を説明している
Platforms State of the Union(WWDC26 セッション 102)
developer.apple.com
SwiftUI の速度に関する部分で、@State の遅延評価に触れている
TN3211: Resolving SwiftUI source incompatibilities for State and ContentBuilder(Apple、2026-06-08 公開)
developer.apple.com
Xcode に含まれるエージェント向けスキル(Apple、Xcode 27.1、Build 27A9275)
swiftui-specialist/references/dataflow.md の節「View-local state with @State」
@State を常に private にするよう指示している。
swiftui-whats-new-27/references/state-macro.md
TN3211 の @State の節と同じ 3 つの問題を扱う。
初期化の順番の例だけは、宣言に初期値 = 0 がある形で示されている。
取得方法は「スキルの取得方法」に記載した。
SE-0400: Init Accessors
github.com/swiftlang/swift-evolution/blob/main/pr…
WWDC26: The @LazyState Macro(Point-Free、Apple 以外の情報源)
pointfree.co
Beta Preview: LazyState(Point-Free、Apple 以外の情報源)
pointfree.co
@LazyState は、Point-Free の会員向けに非公開リポジトリで配布されているベータ版である(2026-10-24 時点)
スキルの取得方法
Xcode 27 に含まれるスキルは、次のコマンドで指定したディレクトリに書き出せます。このコマンドは、起動中の Xcode に接続して書き出します。筆者は Xcode 27.1 を起動した状態で実行しました。
xcrun agent skills export ~/xcode-skills
接続先は、xcode-select で選ばれている Xcode です。複数の Xcode を起動している場合は、環境変数 MCP_XCODE_PID に対象の Xcode のプロセス ID を指定します。
スキルの内容は Xcode の版によって変わります。引用するときは、Xcode の版とビルド番号を明記してください。
この記事の作成について
この記事の調査・計測・執筆には、Anthropic の Claude(Claude Code)を補助として使いました。記事の構成と主張は筆者が決め、計測結果と内容は筆者が確認しています。
SE-0556 separates two low-level needs: Cell for aliased storage and Volatile for required memory accesses. Volatile is not a lock or an atomic operation. Still under review.
#SwiftLang
github.com/swiftlang/swift-evolution/blob/main/pr…
1 like · 1 repost
@available(macOS 15.0, *)
@available(MyLib_v1_0, *)
public func api() {}
The above Swift snippet may work for normal build. But will break the swiftinterface for binary integration.
Fix PR: swift#93418
4 likes · 321 views
Что нового в Swift: сентябрь 2026
Что нового в Swift: сентябрь 2026 года
Добро пожаловать в «Что нового в Swift» — кураторский дайджест релизов, видео и обсуждений в проекте Swift и сообществе. Веб-фреймворк Vapor недавно отметил десятилетие и отпраздновал это Vapor Week! Мы пригласили одного из авторов Vapor стать гостевым автором этого выпуска: Привет, я Тим из основной команды Vapor! 10 лет назад, в сентябре, был выпущен Vapor 1.0, и за эти годы Vapor превратился в комплексный фреймворк для создания бэкендов и API на Swift. К годовщине мы выпустили первую бету Vapor 5 — следующую мажорную версию Vapor, которая представляет собой полную переработку, устраняющую 6 лет технического долга, добавляющую полную поддержку структурированной конкурентности и (наконец-то!) прощающуюся с EventLoopFutures. С самого начала цели Vapor не изменились: выразительный, простой в использовании и мощный фреймворк для создания серверных приложений на Swift. С Vapor 5 мы можем использовать новый Swift HTTP Server для обработки фактической HTTP-части и сосредоточиться на том, чтобы быть отличным веб-фреймворком, используя новейшие возможности Swift там, где это имеет смысл. Удивительно видеть круг людей, использующих Vapor, от независимых разработчиков до крупных компаний. Полную историю, включая обновлённую, локализованную и более доступную документацию, можно найти в Vapor Week. Если вам интересен серверный Swift, попробуйте бету Vapor 5 и расскажите нам, что вы думаете! А теперь к другим новостям о Swift:
Релиз Swift 6.4
В сентябре главной новостью проекта стал релиз Swift 6.4, который приносит более глубокую интероперабельность, более сильную поддержку платформ и более простой повседневный код. Ключевые моменты включают Swift Build в качестве нового выбора по умолчанию в Swift Package Manager, достижение версии 1.0 для Subprocess и ускорение моста WebAssembly с JavaScriptKit до 40 раз. В недели, предшествовавшие релизу, мы также поделились двумя глубокими погружениями в некоторые его возможности:
Отслеживание модулей в отладочной информации Swift объясняет, как точные, основанные на путях импорты модулей делают поиск в LLDB более надёжным и уменьшают размер dSYM-файлов и бинарников. Мейнтейнерам систем сборки (Bazel, Buck, CMake) стоит заменить -modulewrap/-add_ast_path на -debug-module-path.
Улучшения Embedded Swift, грядущие в Swift 6.4 подводит итоги того, что ожидается для Swift на микроконтроллерах и других ограниченных средах.
Основные события сообщества
Google выпустил новый SDK: официальные клиентские библиотеки Google Cloud для Swift. Построенный на SwiftNIO и Swift concurrency, он предоставляет бэкенд-сервисам и CLI-инструментам доступ через async/await к Cloud Storage, IAM, Secret Manager, Gemini и более чем 100 другим сервисам Google Cloud на Linux и macOS.
Любопытно, что нужно для запуска Swift вообще без ОС? Минимальное ядро на Swift, работающее в QEMU, использует Embedded Swift и атрибут @c для загрузки ядра, которое печатает сообщение и эхо-выводит введённые символы.
Solbach Leads поделились своей историей внедрения Swift, включая то, как они перенесли производственный конвейер данных AI с Kotlin и Spring Boot на Swift и Vapor. Спустя шесть месяцев их конвейер обрабатывает более 52 миллионов задач в месяц.
Хватит спать: Детерминированные тесты для конкурентного кода на Swift показывает, как заменить Task.sleep в тестах на тестовые шпионы, чтобы тесты могли проверять тайм-ауты, ошибки и отмену, не дожидаясь часов.
Вышел ежемесячный отчёт Swift for Wasm. Обновления за сентябрь 2026 освещают экспериментальную поддержку потоков в Wasm Swift SDK (в настоящее время в ночных снапшотах), новую опцию uWASI в JavaScriptKit и пользовательские элементы в ElementaryUI.
Новые релизы пакетов
WasmKit — это среда выполнения WebAssembly, написанная на Swift. Релиз 0.4 удваивает скорость интерпретатора, и теперь он может работать на микроконтроллерах ESP32-C6 и Playdate.
Был выпущен Swift AWS Lambda Runtime 3.0, добавляющий плагины SwiftPM для инициализации, сборки и развёртывания. Плагин сборки теперь может упаковывать в zip или OCI-образ, и вы можете выбрать способ компиляции: с помощью Docker, Apple’s container или кросс-компиляции с использованием Swift Static Linux SDK.
Swift Evolution
Проект Swift добавляет новые языковые возможности через процесс Swift Evolution. Вот некоторые из предложений, находящихся на рассмотрении или недавно принятых для будущего релиза Swift.
На активном рассмотрении:
SE-0554 Условная компиляция по целевому значению развёртывания — Swift может проверять, доступен ли API во время выполнения с помощью if #available(...), но это не может выбирать между импортами, псевдонимами типов, соответствиями, хранимыми свойствами или полными объявлениями; этот выбор должен быть сделан во время компиляции модуля. Это предложение добавляет #if deploymentTargetAtLeast(...), чтобы библиотека могла компилировать различный исходный код для разных минимальных версий ОС без поддержки отдельного флага системы сборки.
Недавно приняты:
SE-0546 Расширения memberwise-инициализатора в том же файле — когда memberwise-инициализатор структуры должен быть public, его можно определить только в её базовом объявлении; определение в расширении является ошибкой повторного объявления, и для структур, сгенерированных макросами, нет способа сделать их memberwise-инициализаторы публичными. Это предложение позволяет определять memberwise-инициализаторы и default init() в расширениях в том же файле.
SE-0548 resignRemoteID для ссылок на удалённых распределённых акторов — сегодня DistributedActorSystem наблюдает за жизненным циклом локальных распределённых акторов через assignID(_:) / resignID(_:) и участвует в создании удалённых ссылок через resolve(id:as:), но нет способа наблюдать, когда удалённая ссылка была деинициализирована. Это предложение добавляет resignRemoteID(_:), вызываемый при деинициализации прокси удалённого распределённого актора, чтобы системы могли поддерживать соединения живыми только пока хотя бы одна удалённая ссылка всё ещё использует их.
ST-0029 Включение дополнительных метаданных о проблеме в поток событий — такие инструменты, как Xcode и VS Code, потребляют поток JSON-событий Swift Testing, но структурированной информации недостаточно, чтобы различать разные виды проблем, например, выброшенную ошибку и вызов Issue.record. Это предложение добавляет новые поля в тип события проблемы, включая error, confirmation miscount, exceeded time limit и expression, чтобы инструменты могли отображать более богатый контекст сбоя.
Недавно приняты с изменениями:
SE-0540 Настройки цели по умолчанию (рассматривалось как «Настройки пакета по умолчанию») — очень часто пакеты Swift используют одни и те же флаги настроек для всех своих целей, что сегодня означает их дублирование для каждой цели или изменение package.targets после определения пакета. Это предложение позволяет определять настройки по умолчанию как часть инициализатора Package (defaultSwiftSettings, defaultCSettings, defaultCXXSettings и defaultLinkerSettings) с заполнителем .defaults, который управляет тем, как каждая цель наследует их.
Авторы
Тим Кондон входит в SSWG (Swift Server Workgroup) и является частью основной команды Vapor. Дэйв Лестер — старший менеджер по продукту в Apple и член рабочей группы сайта Swift.
SE-0540 is now finally merged! This was a fun one to work on and not as hard as I feared going in.
Not quite sure if it will make it into Swift 6.5, and may be a while before it's practical to adopt anyways. But cleaning up all those settings in package files will be very satisfying.
22 likes
Mentions
Yesterday I hit a bug of the Swift Foundation on Windows that causes a 15 min hang when doing a POST with > 64KB of data, turns out someone reported that in April, so i had to use WinHTTP from Swift to workaround the problem in Swift 6.4.. but i’ve also spent some tokens to provide a patch and better reproducer+document describing the problem swift-corelibs-foundation#5532 hopefully someone from @swiftlang #swift can take a look and get this resolved in the upcoming Swift release.
SwiftPMに、DebugとReleaseで依存先を切り替える提案が出ています! 開発時だけ使う機能と、配布時に必要な構成を分けやすくする内容です。 現在はSE-0560としてレビュー中で、承認・リリースはまだです。 出典元:github.com/swiftlang/swift-evolution/blob/main/pr…
От 798 до 16 нс: как оптимизация debounce привела меня в Swift Runtime Я прочитал статью Side Effect про debounce на Swift Concurrency и решил проверить, какой прирост производительности дает предложенный там подход. Идея простая: вместо того чтобы создавать новую Task на каждое входящее значение, можно использовать одну и ту же Task для всех событий и продлевать время ожидания. В моем тесте такая замена сократила среднее время выполнения с 798 до 216 нс. Уже хороший результат, но оставалось непонятно, на что уходят эти 216 нс. В большинстве вызовов Task больше не создается: нужно получить текущее время, сохранить новое значение и пересчитать deadline (момент окончания ожидания). В итоге я дошел до 16 нс. По пути пришлось разобраться, что происходит при выполнении этих нескольких строк Swift-кода: как runtime работает с типами, что компилятор может оптимизировать и почему расположение блокировки в памяти влияет на результат. В статье я покажу, как проверял свои гипотезы и что обнаружил в экспериментах. Надеюсь, этот опыт пригодится тем, кто хочет разобраться в производительности своего Swift-кода. Сразу обозначу, что именно здесь ускорилось в 50 раз. Оптимизация относится к среднему времени выполнения одного вызова метода отправки значения. В тесте этот метод вызывался 100 000 раз подряд (без пауз). Метод обновляет состояние debounce и возвращает управление, а обработчик вызывается позже, после заданной задержки с момента последнего значения. В этой статье Переиспользуем одну и ту же Task Результат переиспользования одной Task Может быть, все дело в блокировке? Конкретный тип еще не означает известный layout Заменяем ContinuousClock.Instant на UInt64 Как межмодульная оптимизация влияет на специализацию generic-кода Как расположение блокировки в памяти влияет на производительность Что дали оптимизации на практике Итог Переиспользуем одну и ту же Task Представим поле поиска, в котором мы не хотим отправлять запрос после каждого нажатия на клавиатуре. Для этого используем trailing debounce: после каждого изменения текста откладываем запрос на 500 мс. Если за это время пользователь ничего не ввел, отправляем запрос с последним значением, а если продолжил печатать, то откладываем его еще раз. Самая простая реализация отменяет предыдущую Task и создает следующую: task?.cancel() task = Task { do { try await Task.sleep(for: dueTime) await output(value) } catch { // Отмененная задача завершается без передачи значения. } } sourcecraft.dev Это фрагмент реализации с Task на каждое значение. В таком варианте созданные Task будут отменяться, если новые значения поступают раньше, чем истекает задержка debounce. Но создание Task, запрос на ее отмену и обработка этого запроса все равно требуют ресурсов. Отмена в Swift кооперативная: вызов cancel() не означает, что Task сразу завершилась. Предложенный подход из статьи Side Effect переносит последнее значение и deadline в общее состояние. Первая отправка запускает рабочую задачу. Следующие отправки только меняют состояние. Когда Task просыпается, она проверяет, пора ли вызывать обработчик: если за время сна пришли новые значения, то будет ждать дальше; если после последнего значения прошла заданная задержка, то вызовет обработчик. Одна последовательность входящих значений и два способа управления Task. Рабочая задача может проснуться несколько раз, прежде чем передаст последнее значение. Чтобы отложить обработку, достаточно обновить состояние. Прерывать текущий вызов Task.sleep не требуется. Это важная часть экономии: частые входящие события не превращаются в столь же частые действия планировщика. Реализация emit с переиспользованием TaskВот метод emit исходной реализации с рабочей задачей: public func emit(value: T) { let (shouldStartWorker, initialDeadline) = stateMachine.apply { machine in machine.newValue(value) } guard shouldStartWorker else { return } let worker = Task { [stateMachine, output] in var deadline = initialDeadline while true { try? await Task.sleep(until: deadline, clock: .continuous) let nextStep = stateMachine.apply { machine in machine.sleepIsOver() } switch nextStep { case .finishDebouncing(let latestValue): await output(latestValue) return case .continueDebouncing(let newDueTime): deadline = newDueTime } } } task.set(stored: worker) } sourcecraft.dev Во время уже начатой серии большая часть вызовов заканчивается на guard. Машина состояний (конечный автомат) обновляет ожидающее значение и сообщает, что новая Task не нужна. Результат переиспользования одной Task Сначала сравним две стратегии: Task на каждое значение и одна рабочая задача на всю серию. 100 000 целочисленных значений от одного отправителя, задержка debounce 10 мс. Медиана 15 прогонов после прогрева на M1 Max, macOS 26.7.1, Swift 6.4, release-сборка. Сохраненные результаты. При переходе к одной рабочей задаче среднее время выполнения emit сократилось примерно в 3,7 раза, а CPU-время примерно в 13 раз. Окружение и условия сравненияРезультаты выше получены 13 октября 2026 года на версии кода из коммита 990cf04. При сравнении нужно учитывать несколько отличий: В две исходные реализации я добавил атомарные счетчики созданных Task и завершений Task.sleep. В моей реализации из библиотеки swift-debounce таких счетчиков нет, поэтому дополнительная работа по подсчету есть только у исходных реализаций. Для модуля References включена межмодульная оптимизация (CMO). Но проверка сгенерированного кода показала, что компилятор не создал версии emit, специализированные под Int. Другие оптимизации при этом могли применяться. Реализации по-разному выполняют обработчики: исходная допускает их одновременное выполнение, а swift-debounce выполняет их последовательно. В этом тесте проверяется, что обработчик вызывается один раз и получает последнее отправленное значение. Переиспользование Task дало заметное ускорение, но оставалось понять, на что уходят 216 нс. Дальнейшие эксперименты проводились на разных нагрузках и версиях прототипа. Может быть, все дело в блокировке? В исходной реализации Side Effect общее состояние защищено NSRecursiveLock. При каждом значении мы захватываем блокировку и отпускаем ее, поэтому это было первым местом для проверки. Я сравнил NSRecursiveLock с os_unfair_lock в двух тестах: при увеличении счетчика под блокировкой и при выполнении всего метода emit. Операция, один поток NSRecursiveLock os_unfair_lock Захват, увеличение счетчика, освобождение 17,1 нс 8,0 нс Полный emit 188,0 нс 177,7 нс Ранний эксперимент с блокировками. Полный метод измерялся на миллионе отправок. Его исходные 188 нс относятся к другому тесту, чем 216 нс выше. На отдельной блокировке разница выглядит внушительно: почти вдвое. В полном методе выигрыш составил 10,3 нс, около 5,5%. Большая часть стоимости осталась на месте. Блокировку имело смысл заменить, но оставшиеся затраты одного отправителя она не объясняла. Я перешел к чтению времени и обновлению состояния. Конкретный тип еще не означает известный layout При профилировании метода emit я обнаружил вызовы следующих функций Swift Runtime: type metadata accessor for ContinuousClock.Instant swift_getGenericMetadata swift_getTupleTypeMetadatasourcecraft.dev На первый взгляд странно. deadline хранится в значении конкретного типа ContinuousClock.Instant. В тесте входящее значение тоже конкретное: Int. Откуда работа с метаданными типов? Компилятор может знать конкретный тип, но не иметь гарантий о его размере и расположении полей в памяти. Layout типа определяет его размер, выравнивание и расположение полей в памяти. В режиме library evolution библиотека может менять внутреннее устройство типа-значения без @frozen, сохраняя совместимость с уже собранными клиентами. Для этого клиентский код не должен полагаться на текущий размер структуры и смещения ее полей. Атрибут @frozen дает более жесткие гарантии layout, но в определении ContinuousClock.Instant его нет. В машине состояний deadline хранится внутри enum вместе со значением: struct DebounceStateMachine<T> { enum State { case idle case debouncing( value: T, dueTime: ContinuousClock.Instant, isValueDuringSleep: Bool ) } // ... } sourcecraft.dev При новом значении метод получает текущее время, заменяет содержимое enum и возвращает новый deadline. Для замены значения машинному коду, сгенерированному компилятором, нужно определить расположение полей и записать новые данные. В Swift метаданные типа связаны с value witness table (подробнее о ее роли в generic-коде). В ней есть информация о layout значения и операции копирования, перемещения и уничтожения. Когда layout типа неизвестен при компиляции, сгенерированный машинный код может использовать операции из value witness table для копирования, перемещения и уничтожения значения. Связь метаданных и value witness table описана в документации Swift ABI. Схема объясняет механизм работы с неизвестным layout. В месте вызова компилятору был известен конкретный тип Int. Однако проверка сгенерированного кода показала, что вызывалась generic-версия LongLivedTaskDebounce.emit(value:), рассчитанная на любой допустимый T. В версии, специализированной под Int, компилятор может использовать известные размер и расположение входного значения. Layout Instant при этом остается неизвестным компилятору клиентского модуля. При этом обращение к метаданным не означает, что на каждой отправке создается новый объект или для хранения deadline обязательно выделяется отдельный участок памяти в куче. Метаданные могут переиспользоваться, даже если код продолжает к ним обращаться. Как профилировать самостоятельноВ репозитории есть режим profile: cd Benchmarks swift build -c release .build/release/Locks profile 10 & sample "$!" 5 1 -file debounce-sample.txtsourcecraft.dev Тест работает десять секунд; sample собирает стеки пять секунд с интервалом в одну миллисекунду. В историческом профиле исходная версия с рабочей задачей называется OptimizedDebounce. Состав символов в новом запуске может отличаться от сохраненного профиля. Заменяем ContinuousClock.Instant на UInt64 Следующим шагом я решил проверить работу со временем: как мы получаем текущее значение и храним deadline. Сначала сравнил ContinuousClock.now и mach_continuous_time(). Каждый из них вызывался в цикле. Среднее время одной итерации составило 28,9 нс для ContinuousClock.now и 5,0 нс для mach_continuous_time(). Медиана семи однопоточных прогонов по пять миллионов итераций после прогрева. Тесты по-разному обрабатывают полученное время, поэтому числа включают не только вызов функции часов, но и дополнительную работу внутри цикла. Вместо ContinuousClock.Instant я стал хранить deadline в UInt64. На Darwin текущее время теперь получаю через mach_continuous_time(). Значение deadline хранится как обычное целое число, layout которого известен компилятору. Чтение времени и преобразование тиковТак выглядит wrapper над часами: @usableFromInline enum _MonotonicClock { @inlinable @inline(__always) static func now() -> UInt64 { #if canImport(Darwin) return mach_continuous_time() #else var time = timespec() clock_gettime(CLOCK_BOOTTIME, &time) let secondsInNanoseconds = UInt64(time.tv_sec) &* 1_000_000_000 return secondsInNanoseconds &+ UInt64(time.tv_nsec) #endif } } sourcecraft.dev mach_continuous_time() возвращает время в тиках, которые не обязательно равны наносекундам. При инициализации заданная задержка debounce переводится в тики. Перед вызовом Task.sleep рабочая задача преобразует оставшееся время обратно в наносекунды. Поэтому при каждом вызове send пересчитывать единицы времени не нужно. Я одновременно изменил способ получения текущего времени и тип deadline, поэтому по этим замерам нельзя определить, какой вклад внесло каждое изменение. При повторном профилировании я снова обнаружил обращения к метаданным. Теперь они были связаны с полем Value?, в котором сохраняется последнее значение для обработки. Как межмодульная оптимизация влияет на специализацию generic-кода Теперь нужно было разобраться с обновлением Value?. В тесте используется Int, но метод emit мог по-прежнему выполняться как общая generic-функция. Я проверил, поможет ли здесь межмодульная оптимизация -cross-module-optimization (CMO). Она позволяет оптимизировать код через границы модулей, в том числе создавать специализированные версии generic-функций. Для сравнения я собрал каждую реализацию с CMO и без него. В каждом запуске тест примерно три секунды подряд вызывал emit, передавая новые значения, и подсчитывал количество вызовов. Переработанный прототип с CMO успел выполнить около 258 млн вызовов вместо 28 млн. У исходной реализации с одной рабочей задачей результат почти не изменился: около 16 млн вызовов в обоих случаях. Реализация Без CMO, вызовов emit за ≈3 с С CMO, вызовов emit за ≈3 с Прототип с deadline в UInt64 27 580 000 257 900 000 Исходная рабочая задача 15 770 000 15 680 000 Для каждого варианта сохранен результат одного запуска, поэтому судить о разбросе результатов пока нельзя. Каждый запуск был рассчитан примерно на три секунды; время проверялось после каждых 10 000 вызовов emit. Результаты эксперимента. Это измерение пропускной способности отдельного прототипа, а не еще один множитель, который можно применить к исходным 798 нс. CMO заметно помог переработанному прототипу, но почти не повлиял на исходную реализацию. Далее, чтобы разобраться в причине, я решил проверил, какой машинный код создал компилятор. SIL исходной реализации: generic-вызов emitНиже фрагмент SIL. Для читаемости я перенес длинные строки и убрал служебные комментарии после инструкций: // function_ref LongLivedTaskDebounce.emit(value:) %6 = function_ref @$s10References21LongLivedTaskDebounceC4emit5valueyx_tF : $@convention(method) <τ_0_0 where τ_0_0 : Sendable> (@in_guaranteed τ_0_0, @guaranteed LongLivedTaskDebounce<τ_0_0>) -> () %7 = apply %6<Int>(%4, %0) : $@convention(method) <τ_0_0 where τ_0_0 : Sendable> (@in_guaranteed τ_0_0, @guaranteed LongLivedTaskDebounce<τ_0_0>) -> () sourcecraft.dev function_ref ссылается на generic-версию emit: в сигнатуре остается параметр типа τ_0_0. Следующая инструкция, apply %6<Int>, вызывает ее с типом Int. Запись <Int> здесь означает передачу конкретного типа в generic-функцию, а не вызов отдельной специализации. Несмотря на включенный CMO, в этом месте вызывается общий метод. Один из способов оптимизации generic-кода — специализация. Компилятор создает версию функции для конкретного типа. Например, для Debouncer<Int> он может заменить общие операции над Value и Value? операциями над Int и Optional<Int>. В отдельном эксперименте я сохранил ContinuousClock.Instant, но заменил обертку SafeStorage с NSRecursiveLock на вариант с os_unfair_lock. С включенным CMO компилятор создал специализацию emit под Int. Получается, специализация была возможна и без замены Instant на UInt64. SIL после замены NSRecursiveLock на os_unfair_lockВо втором варианте той же проверки я заменил блокировку внутри SafeStorage на os_unfair_lock. ContinuousClock.Instant, алгоритм debounce и параметры компиляции оставил прежними. Теперь вызывающий модуль содержит такой фрагмент: // function_ref specialized LongLivedTaskDebounce.emit(value:) %4 = function_ref @$s10References21LongLivedTaskDebounceC4emit5valueyx_tFSi_Tg5 : $@convention(method) (Int, @guaranteed LongLivedTaskDebounce<Int>) -> () %5 = apply %4(%1, %0) : $@convention(method) (Int, @guaranteed LongLivedTaskDebounce<Int>) -> () sourcecraft.dev В комментарии к function_ref появилось specialized, а в сигнатуре вместо параметра типа указаны Int и LongLivedTaskDebounce<Int>. Инструкция apply %4 вызывает именно эту версию emit. Вызов отдельной функции сохранился: здесь видна специализация, а не подстановка ее тела. Оба варианта собраны с включенным CMO. Еще одна оптимизация — inlining, то есть подстановка тела функции в место вызова. Она может помочь дальнейшим оптимизациям, но специализация возможна и без нее: компилятор может создать отдельную функцию для конкретного типа и оставить ее вызов. В swift-debounce я пометил функции, которые вызываются из send, атрибутом @inlinable. Благодаря этому их код доступен оптимизатору при компиляции модуля, использующего библиотеку. Это дает возможность выполнить inlining или специализацию, но не гарантирует их. CMO тоже может сделать тела функций доступными для оптимизации без @inlinable. Подробнее эти механизмы описаны в SE-0193. Функция send получилась такой: @inlinable public func send(_ value: Value) { let now = _MonotonicClock.now() storage.withLock { state in let shouldStartWorker = state.machine.send(value, now: now) if shouldStartWorker { core.startWorker(&state) } } } sourcecraft.dev Недостаточно сделать доступным оптимизатору только тело send. Операция замены trailingValue: Value? находится глубже, в машине состояний, а доступ к ней проходит через withLock. Если тела этих функций недоступны, оптимизатор не сможет проанализировать обновление состояния через эту цепочку вызовов. Как оптимизатор видит обновление состоянияПоэтому метод withLock тоже помечен @inlinable: @inlinable @inline(__always) func withLock<Result>(_ body: (inout State) -> Result) -> Result { withUnsafeMutablePointers { statePointer, lockElements in let lock = lockElements + Self.lockIndex _lock(lock) defer { _unlock(lock) } return body(&statePointer.pointee) } } sourcecraft.dev В машине состояний новое значение обновляет deadline и заменяет сохраненное значение, если предыдущий deadline еще не наступил. Ветку else здесь опускаю: @inlinable mutating func send(_ value: Value, now: UInt64) -> Bool { let newDeadline = _deadline(after: now, adding: interval) if let currentDeadline = burstDeadline, now < currentDeadline { burstDeadline = max(currentDeadline, newDeadline) if edges.contains(.trailing) { trailingValue = value } } else { // Начало новой серии опущено. } if isWorkerActive { return false } isWorkerActive = true return true } sourcecraft.dev Стрелки показывают доступность кода для компилятора приложения. @inlinable открывает тело функции для анализа с учетом конкретного типа Int. Inlining и специализация не гарантированы; CMO тоже может обеспечить доступ к телам функций. Здесь важно различать @inlinable и @usableFromInline. Второй атрибут позволяет ссылаться на internal-декларацию из доступного клиентам тела и делает ее частью ABI. Сам по себе он не раскрывает тело функции для оптимизации. Метод startWorker, который создает рабочую Task, помечен @usableFromInline. Это позволяет вызывать его из @inlinable-метода send, не открывая тело startWorker для оптимизации. Большинство вызовов send только обновляют состояние и до startWorker не доходят. Как расположение блокировки в памяти влияет на производительность На влияние расположения блокировки в памяти я наткнулся случайно, когда уже готовил репозиторий к публикации на GitHub. Я сравнивал несколько способов хранения состояния и блокировки, чтобы выбрать реализацию с поддержкой старых версий iOS. В одном из вариантов состояние и блокировка размещались в одной аллокации с помощью ManagedBuffer. В другом память для блокировки выделялась отдельно от состояния. При сравнении обнаружился неожиданный результат: отдельная аллокация для блокировки замедляла отправку значений из одного потока, но ускоряла ее при одновременных вызовах из нескольких потоков. Я предположил, что при нескольких потоках помогает именно расстояние между блокировкой и состоянием. Чтобы это проверить, я оставил их в одной аллокации, но добавил между ними свободное место (padding размером 128 байт). Так можно было отодвинуть блокировку от состояния, не выделяя для нее память отдельно. В реализации хранилища положение блокировки вычисляется так: @inlinable @inline(__always) static var lockIndex: Int { let paddingBytes = 128 let lockStride = MemoryLayout<_PlatformLock>.stride return (paddingBytes + lockStride - 1) / lockStride } sourcecraft.dev Без padding блокировка находится в начале области элементов ManagedBuffer. В варианте с padding перед ней зарезервированы 128 байт. Состояние остается на прежнем месте. Размеры блоков показаны условно. При одновременной работе восьми потоков получились такие результаты: Тест Без padding С padding Захват блокировки, увеличение счетчика и освобождение блокировки 24,9 нс 37,1 нс Вызов emit в упрощенной реализации debounce 79,7 нс 54,2 нс В тесте с восемью потоками padding увеличил время операции со счетчиком под блокировкой, но сократил время выполнения emit. При этом расход памяти вырос со 160 до 288 байт на экземпляр тестового BoxedDebounce. Для времени показаны медианы девяти запусков; в каждом запуске общее время теста делилось на число операций всех потоков. Результаты опыта с хранилищем. В тесте блокировки вариант без padding оказался быстрее: 24,9 нс против 37,1 нс. Но при вызове emit результат получился обратным: с padding 54,2 нс, без него 79,7 нс. Поэтому выбирать расположение блокировки стоит по результатам той операции, которую она защищает. Возможная причина — это false sharing. Кэш процессора работает с блоками памяти (кэш-линиями). Если блокировка и защищаемое состояние попадают в одну кэш-линию, потоки могут мешать друг другу: один обновляет состояние, а другие в это время пытаются захватить блокировку. Padding позволяет разнести блокировку и состояние, что может уменьшить эти помехи. Такой случай описан в документации ядра Linux. Он подходит под наблюдаемый результат, но я не проверял эту гипотезу. Сравнение пяти вариантов хранения и разброс результатовВ тесте сравнивались пять вариантов хранения состояния и блокировки. Во всех использовалась одна и та же упрощенная реализация debounce. Ниже показано время на один вызов emit: медиана девяти запусков, а в скобках минимальный и максимальный результаты. Память измерялась на экземпляр тестового BoxedDebounce. Хранилище Один поток, нс/вызов Восемь потоков, нс/вызов Память на экземпляр, байт Synchronization.Mutex 13,2 [13,1–13,3] 86,8 [77,5–94,3] 128 ManagedBuffer, без padding 13,1 [13,0–13,2] 79,7 [73,7–97,7] 160 ManagedBuffer, блокировка с padding 13,2 [13,2–13,3] 54,2 [51,9–62,6] 288 OSAllocatedUnfairLock 13,1 [13,1–13,2] 84,6 [71,3–118,8] 160 Отдельная аллокация блокировки 17,9 [17,8–18,0] 65,7 [62,7–67,5] 144 В однопоточном тесте выполнялись два миллиона вызовов emit, в многопоточном по 100 000 из каждого из восьми потоков. Задержка debounce составляла 20 мс. Перед замерами выполнялся прогрев, затем варианты проверялись по очереди в девяти раундах. Время на вызов рассчитано по общей длительности теста и суммарному числу вызовов. Точка на графике медиана, отрезок минимум и максимум. При работе из одного потока оба варианта ManagedBuffer показали около 13 нс на вызов. При восьми потоках диапазон результатов с padding составил 51,9–62,6 нс, без padding 73,7–97,7 нс. В этих девяти запусках диапазоны не пересекались. В swift-debounce я оставил вариант с padding. В тестах он ускорил обновление состояния при работе из нескольких потоков, практически не повлияв на результат одного потока. Что дали оптимизации на практике Теперь сравним три реализации: новую Task на каждое значение, одну рабочую задачу из статьи Side Effect и swift-debounce. Реализация Среднее время одного вызова Время цикла отправки CPU-время процесса Task на каждое значение 798 нс 79,82 мс 280,81 мс Одна рабочая задача 216 нс 21,61 мс 21,91 мс swift-debounce 16 нс 1,57 мс 1,72 мс 100 000 целочисленных значений от одного отправителя, задержка debounce 10 мс. Медиана 15 прогонов на M1 Max, macOS 26.7.1, Swift 6.4, release-сборка. Сохраненные результаты. CPU-время включает работу всех потоков за период отправки значений, ожидания результата и завершения отмененных задач. При 100 000 вызовов подряд среднее время выполнения send в swift-debounce составило 16 нс. При 1000 вызовах уже 41 нс: затраты на создание рабочей задачи распределяются на меньшее число вызовов. Затем я проверил другой сценарий: 200 значений с интервалом 5 мс. Суммарное CPU-время составило 31,10 мс для реализации с новой Task на каждое значение, 21,65 мс для одной рабочей задачи и 21,22 мс для swift-debounce. Здесь основную экономию дало переиспользование Task. Дополнительные оптимизации send повлияли на суммарное CPU-время гораздо меньше. Итог Все началось с простой идеи: перестать создавать новую Task для каждого значения. Уже это сократило среднее время отправки с 798 до 216 нс. Но мне стало интересно, куда уходят оставшиеся наносекунды. Так оптимизация debounce постепенно превратилась в исследование Swift Runtime и работы компилятора. Больше всего меня удивило, насколько результаты могут расходиться с первоначальными ожиданиями. Я думал, что основная проблема в блокировке, но ее замена объяснила лишь небольшую часть затрат. Позже оказалось, что даже расположение блокировки в памяти влияет на результат, причем отдельный тест блокировки и обновление состояния debounce показали противоположную картину. А чтобы разобраться с остальными затратами, пришлось смотреть уже не только на Swift-код, но и на то, что из него генерирует компилятор. В итоге в тесте со 100 000 вызовов подряд среднее время выполнения одного вызова сократилось с 798 до 16 нс, то есть примерно в 50 раз. Для меня самое интересное в этой истории - это сколько всего обнаружилось за несколькими строками обновления состояния. Даже простой на вид метод может привести к вопросам о layout типов, специализации generic-кода и работе процессорного кэша. Получившуюся реализацию я опубликовал в swift-debounce, а код экспериментов и результаты замеров оставил в репозитории с бенчмарками.
Swift 6 Strict Concurrency: Production Migration Playbook Swift 6 Strict Concurrency: Production Migration Playbook Swift 6.0's strict concurrency looks, at first glance, like an opt-in compile flag. But in reality? It's a serious refactor mandate touching every one of your classes, singletons, delegate patterns, and dependency-injection architecture. This article walks through the 12 common pitfalls you are likely to hit when moving a large production iOS codebase to strict concurrency mode, their fixes, and the correct actor migration order. Spoiler: @MainActor is not the cure for everything. > Pro Tip: Before starting the migration, don't begin with SWIFT_STRICT_CONCURRENCY = complete — start with targeted. Targeted mode only checks Sendable; complete mode also enforces actor isolation. Starting targeted cuts your first-pass error count in half. --- 1. The Three Levels of Strict Concurrency Mode Swift 6 strict concurrency is controlled by the SWIFT_STRICT_CONCURRENCY build setting, and it has three levels: # Minimal — Swift 5.x default behavior SWIFT_STRICT_CONCURRENCY = minimal # Targeted — only Sendable explicit marked types check SWIFT_STRICT_CONCURRENCY = targeted # Complete — full Swift 6 strict concurrency SWIFT_STRICT_CONCURRENCY = complete Practical migration strategy: 1. Weeks 1-2: Move the whole project from minimal to targeted. Fix Sendable warnings. 2. Weeks 3-4: Turn on complete mode module by module. The UI layer goes last. 3. Week 5: Full project on complete. Swift 6 language mode active. Migrating small modules first (Networking, Persistence) reduces production risk — changes that leak into the UI layer are usually the most expensive. --- 2. Sendable: The End of Invisible Leaks A large share of the migration work comes down to Sendable conformance. A type being Sendable means it's safe to share across multiple threads. // ❌ Strict mode error: User isn't Sendable class User { var name: String var preferences: [String: Any] // [String: Any] isn't Sendable! } // ✅ Solution: struct + typed properties struct User: Sendable { let id: UUID let name: String let preferences: UserPreferences // Sendable Codable struct } Common Sendable pitfalls: - [String: Any] — Any isn't Sendable; use a typed dictionary - URL — Sendable ✓, URLRequest Sendable ✓ - Date — Sendable ✓ (Swift 5.5+) - UIImage — not Sendable (reference type, mutable) - NSData — not Sendable; Data is Sendable ✓ - Closure types — require the @Sendable annotation > Pro Tip: Don't try to make legacy Objective-C types (NSObject subclasses) Sendable — they're reference types and mutable. Wrap them in a pure-Swift struct instead. --- 3. Actor Migration Order: The Right Way Actors are the gold standard for concurrent state in Swift 6. But migrating them in the wrong order means walking into a maze of rework. Recommended order: 1. Stateless services (URLSession wrapper, JSON decoder) — not an actor, use struct or enum with static methods 2. Stateful services (cache, session manager) — make them an actor 3. DI container / service locator — global state, actor or @MainActor class 4. ViewModels — @MainActor (they have a UI dependency) 5. Views — SwiftUI is already MainActor-isolated // Stateful cache → actor (Correct) actor ImageCache { private var cache: [URL: UIImage] = [:] func image(for url: URL) -> UIImage? { cache[url] } func setImage(_ image: UIImage, for url: URL) { cache[url] = image } } // Usage: await required let image = await imageCache.image(for: url) Pitfall: don't try to preserve your old synchronous APIs when converting to actor. Every public method becomes async. If call sites are too scattered, use a transition wrapper: @MainActor final class ImageCacheBridge { private let cache: ImageCache nonisolated func imageSync(for url: URL) -> UIImage? { // Sync fallback for legacy paths (deprecated) return MainActor.assumeIsolated { self.legacyCacheStorage[url] } } } --- [[demo:actor-race-sim]] 4. @MainActor Pitfalls @MainActor is the most powerful tool for UIKit/SwiftUI interop. But misuse hurts performance badly. Pitfall 1: Marking the entire class @MainActor // ❌ Wrong — JSON parsing blocks the main thread @MainActor final class APIClient { func fetchUsers() async throws -> [User] { let data = try await URLSession.shared.data(from: usersURL).0 return try JSONDecoder().decode([User].self, from: data) // 200ms main thread block! } } // ✅ Correct — only UI-bound method @MainActor final class APIClient { nonisolated func fetchUsers() async throws -> [User] { let data = try await URLSession.shared.data(from: usersURL).0 return try JSONDecoder().decode([User].self, from: data) } @MainActor func updateUI(with users: [User]) { // UI update logic } } Pitfall 2: Incorrectly capturing values in @MainActor closures Task { @MainActor in // This whole block runs on the main thread — don't do heavy work here! let processed = self.heavyProcessing() // main blocking! self.updateView(with: processed) } // ✅ Correct — heavy work in background, only UI update on main Task { let processed = await self.heavyProcessing() // arbitrary executor await MainActor.run { self.updateView(with: processed) } } Pitfall 3: MainActor.assumeIsolated is for synchronous compatibility, but misuse causes a runtime crash. Only use it where you can guarantee the code is already running on the main thread. --- 5. Async/Await and Existing Callback Patterns A large, older codebase can easily have hundreds of closure-based APIs. Migrating all of them to async/await at once isn't feasible. Bridge pattern: // Old callback API func fetchData(completion: @escaping (Result<Data, Error>) -> Void) { // legacy code } // Async wrapper func fetchData() async throws -> Data { try await withCheckedThrowingContinuation { continuation in fetchData { result in continuation.resume(with: result) } } } Watch out — withCheckedThrowingContinuation pitfalls: - The continuation is one-shot — resuming it twice by mistake crashes - If you store the closure and never resume it, the async caller hangs forever - Cancellation handling must be explicit — use withTaskCancellationHandler In production, use @CheckedContinuation to catch continuation leaks (Swift 6 default, has a debug check). --- 6. Does the Singleton Pattern Survive Strict Concurrency? You're told singletons are an anti-pattern, but in a codebase with 200K LOC and 8 years of history, they exist and aren't going anywhere. How do they survive strict concurrency? // ❌ Strict mode error class AnalyticsTracker { static let shared = AnalyticsTracker() // mutable global state warning var sessionID: String = UUID().uuidString } // ✅ Solution 1: actor actor AnalyticsTracker { static let shared = AnalyticsTracker() var sessionID: String = UUID().uuidString func setSession(_ id: String) { sessionID = id } } // ✅ Solution 2: immutable final class AnalyticsTracker: Sendable { static let shared = AnalyticsTracker() let sessionID = UUID().uuidString // immutable, Sendable safe private init() {} } Decision matrix: - Internal state gets mutated → actor - Pure read-only → Sendable final class - UI-bound singleton → @MainActor final class --- 7. Delegate Pattern → AsyncSequence Migration Most Apple frameworks are delegate-based. Under strict concurrency, delegate methods need Sendable conformance. // Old pattern class LocationTracker: NSObject, CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { // delegate callback } } // Modern pattern — AsyncSequence class LocationTracker { var locations: AsyncStream<CLLocation> { AsyncStream { continuation in let bridge = LocationBridge { location in continuation.yield(location) } // CLLocationManager setup } } } // Usage — clean for await location in tracker.locations { await viewModel.updateLocation(location) } This pattern works the same way for MapKit, BLE, Camera, and Audio — wrap the old delegates in an AsyncStream. --- 8. Test Mocking + Sendable Test mocks need to be Sendable, otherwise tests fail under the concurrent runner. // Test double — Sendable conform final class MockUserService: UserServicing, Sendable { let stubUsers: [User] init(users: [User] = []) { self.stubUsers = users } func fetchUsers() async throws -> [User] { stubUsers } } Pro Tip: XCTest's default test parallelization does a Sendable check. If a test class holds state, mark it @MainActor or move that state into an actor. --- 9. Production Profiling: The Performance Trade-off Strict concurrency isn't free. Actor hops add a small cost to every async call. These are the metrics to compare in your own app before and after the migration: | Metric | Why it matters | How to measure | |---|---|---| | Launch time | Actor hops added to the main thread can delay the first screen | Instruments App Launch template + MetricKit | | Memory baseline | Extra tasks and actor objects can raise the memory floor | Xcode Memory Report + MetricKit | | CPU under load | Frequent actor hops can raise CPU share in busy flows | Instruments Time Profiler | | Build time | Complete mode makes type checking heavier and builds slower | Xcode Build Timeline | Build time is usually the most noticeable increase. Fix: incremental build caching and modularization. Runtime overhead stays negligible for most mobile apps; still, verify hot paths with Time Profiler. On the crash side, the expected gain is that data races closed at compile time no longer surface as runtime crashes; measure the effect by comparing crash analytics before and after the migration. --- 10. Migration Checklist A recommended sequential checklist for migrating a production app: - [ ] Phase 0: Project on SWIFT_STRICT_CONCURRENCY = targeted, clean build - [ ] Phase 1: Migrate the networking layer, Sendable conformance - [ ] Phase 2: Persistence layer (Core Data, SwiftData) actor migration - [ ] Phase 3: Service container (DI) as actor or @MainActor - [ ] Phase 4: Mark ViewModels @MainActor - [ ] Phase 5: Mark UIKit ViewControllers @MainActor (SwiftUI views already are) - [ ] Phase 6: Turn on SWIFT_STRICT_CONCURRENCY = complete - [ ] Phase 7: Swift 6 language mode (SWIFT_VERSION = 6) - [ ] Phase 8: Enforce strict mode in the CI/CD pipeline - [ ] Phase 9: Two weeks of crash-analytics observation - [ ] Phase 10: Publish the performance baseline > Pro Tip: Don't do the migration on a feature branch — go gradual on main. On a large codebase the migration can take weeks or even months, so conflicts on a long-lived branch are unavoidable. Merging module-by-module into main is far more sustainable. --- Conclusion Strict concurrency is the delivery on Swift's 8-year-old safety promise. The ability to catch data races at compile time is valuable because it closes this class of runtime crashes at the source. But the migration isn't cheap — on a large codebase, budget for effort that can stretch over months, noticeably longer build times, and a small runtime overhead. The pragmatic call: for new projects, Swift 6 + complete strict concurrency by default. For existing projects, a targeted → complete transition on a phased, module-by-module plan. @MainActor isn't the cure for everything; used correctly, it means keeping heavy work off the main thread with nonisolated. Related resources: - Apple Swift Concurrency Migration Guide - Swift Evolution SE-0337 - Strict Concurrency Performance Benchmarks (Point-Free) --- Originally published at muhittincamdali.com.
Swift 6.3 Migration: Actor Isolation Warnings and Escape Hatches Don't be surprised if a project that compiled cleanly on Swift 6.2 starts throwing actor isolation warnings after you move to Swift 6.3 without changing a line — it's not a coincidence. Swift 6.3 introduced no new language rule or default for actor isolation; even so, some patterns silent on 6.2 now warn on 6.3 — and in the documented case below, the root isn't a new rule at all but the conservative behavior of region isolation analysis. This isn't a generic "what is strict concurrency" explainer; it shows, directly, which code patterns broke in the 6.2 → 6.3 delta, with real compiler messages and real escape hatches. > 💡 Pro Tip: Whether a piece of code is isolated or nonisolated is part of its signature — changing it is almost always a source-breaking change, and that's exactly where you get hit hardest during a version upgrade. Today's reality: 6.3.3 is shipped, 6.4 is on the way Swift 6.3 was officially released on March 24, 2026, signed by Holly Borla and Joe Heck. The headline items weren't about concurrency — more flexible C interop, cross-platform build tooling, embedded-environment enhancements, and an official Android SDK. So the official announcement doesn't even mention concurrency; yet the documented warning is real — you'll see why below, in the conservative rule of region isolation analysis. The release chain today (early September 2026): after 6.3 came the 6.3.1 and 6.3.2 patches, then 6.3.3 on June 30, 2026 — still the current stable tag on swift.org's install page. Meanwhile Swift 6.4 prep continues: Xcode 27 was still in beta at the end of August 2026 (beta 6, August 24, 2026), so 6.4 isn't an officially released version yet. swift.org's August 20, 2026 post "Embedded Swift Improvements Coming in Swift 6.4" even says "coming" right in the title — 6.4 is still a version tried via development snapshots. Point-Free's post from their Xcode 27 beta days is a live example: the team had to explicitly mark something @MainActor that should already have been so, because of an isolation-checking regression in Swift 6.4, then quickly merged the fix and shipped a release. Summary of 6.2 → 6.3 language changes To understand Swift 6.3's tightening of actor isolation, you first need what changed in 6.2 — half of the configuration that triggers the warning, the module's default-isolation setting, arrived in 6.2 via SE-0466. SE-0466 — Module-wide default actor isolation. This proposal reached "Implemented" status in Swift 6.2 and added a compiler setting that lets @MainActor inference become the default within a module. The goal was to reduce false-positive data-race errors in single-threaded code — in the proposal's own words: "The easiest and best way to model single-threaded code is with a global actor. Everything on a global actor runs sequentially." SE-0461 — nonisolated(nonsending) default. Also "Implemented" in 6.2, this proposal changed the behavior of nonisolated async functions: such functions now run on the caller's actor by default instead of hopping to a separate executor. The NonisolatedNonsendingByDefault upcoming-feature flag turns this behavior on. Together these two changes make 6.2's philosophy clear: the compiler now runs on "sequential by default, parallel when needed" logic. 6.3 announced no new concurrency default; even so, some patterns silent on 6.2 now produce new isolation warnings. Documented case: the @Sendable × @escaping isolation warning A thread opened by Itai Ferber on the Swift Forums is the most concrete documented example. Ferber's description is precise, in his own words: "This only reproduces on Swift 6.3 in a project with nonisolated default isolation and concurrency checking set to Complete; earlier Swift versions, default MainActor isolation, or minimal/targeted concurrency checking don't produce this warning." This alone gives a practical hint: if your project uses the -default-isolation MainActor flag (i.e., you've turned on SE-0466's MainActor-default at the module level), you won't see this specific warning. The producing configuration: module default isolation still nonisolated, target compiled with SWIFT_STRICT_CONCURRENCY=complete. // Reproducer from Ferber's forum thread: silent on 6.2, warns on 6.3 class External { // System-provided, Obj-C-originated signature (in reality, Timer.scheduledTimer) static func ƒ1(_ work: @escaping @Sendable () -> Void) { work() } } @MainActor final class Internal { func ƒ2(_ completion: @escaping () -> Void) { completion() } func ƒ3() { Task { @MainActor in var complete = false External.ƒ1 { MainActor.assumeIsolated { self.ƒ2 { MainActor.assumeIsolated { // ⚠️ Sending 'complete' risks causing data races complete = true } } } } print(complete) } } } Ferber's actual compiler output reads: "Sending 'complete' risks causing data races; this is an error in the Swift 6 language mode ... Task-isolated 'complete' is captured by a main actor-isolated closure." Notice: the warning isn't about the closure itself, but the capture of the var complete variable defined inside the Task { @MainActor in } body. Two fixes are documented: removing @Sendable from External.ƒ1(), or @escaping from Internal.ƒ2(). In Ferber's words: "Removing @Sendable from External.ƒ1() or @escaping from Internal.ƒ2() resolves the warning." Why: because complete passes through a nonisolated context, it must be implicitly sent to MainActor, and sending semantics require statically verifying the value can no longer be used from the source context — impossible in nonisolated contexts. Ferber accepts the diagnosis: "warning about such a mutable var capture seems reasonable." | Configuration | 6.2 behavior | 6.3 behavior | | ------------------------------------------- | ----------------- | ------------------------------------ | | nonisolated default + Complete checking | Compiles silently | Warning on Sendable+escaping closure | | -default-isolation MainActor (SE-0466 on) | Compiles silently | Compiles silently (no warning) | | Minimal/targeted checking | Compiles silently | Compiles silently | | @Sendable OR @escaping alone | No issue | No issue | Takeaway: check -default-isolation — but as diagnosis, not a fix. Flipping to the MainActor-default makes the warning vanish; that doesn't prove the code race-free. As jamieQ put it, "this is a limitation of how the region isolation analysis implementation applies its conservative rules" — if the code really is safe, a deliberate escape hatch (nonisolated(unsafe)) is reasonable. An alternative from the same thread: instead of the implicit box the compiler generates for the closure capture, put the value in your own reference type with matching isolation — a wrapper like @MainActor final class Workaround<T>. Escape hatches: nonisolated(unsafe) and @preconcurrency nonisolated(unsafe) is one of the most commonly reached-for escape hatches in Swift 6 migrations — but sprinkling it everywhere can hide real data races instead of speeding up the migration. A real migration account on the Swift Forums shows the distinction: "A few legacy/spaghetti code issues came up but were resolvable with nonisolated(unsafe)." The same post notes third-party libraries: "Dealing with third-party libraries was pretty easy — it was usually enough to just add @preconcurrency to the import or protocol and move on." Neither is specific to 6.3 — general Swift 6 migration practice — but both remain your first line of defense moving to 6.3. import Foundation // Quickly silencing a legacy singleton (use with care — can hide a real data race) final class LegacyCache { nonisolated(unsafe) static var shared = LegacyCache() private var storage: [String: Data] = [:] } // A representative third-party protocol not yet Sendable-conformant protocol RequestDelegate { func didFinish(_ payload: Data) } @MainActor final class APIClient: @preconcurrency RequestDelegate { // @preconcurrency defers the conformance's isolation check to runtime func didFinish(_ payload: Data) { print(payload.count) } } Protocol conformance isolation is a separate layer: SE-0470 "Global-actor isolated conformances" — status "Implemented (Swift 6.2)" — pins down the rules for a type conforming to a protocol in a global-actor-isolated way (an @MainActor class conforming to a nonisolated protocol, say), turned on via the InferIsolatedConformances upcoming-feature flag. Effect: an @MainActor-marked type conforming to a protocol that doesn't require isolation may now need explicit annotation. The root of the same case: region isolation analysis The root of the warning has less to do with which actor a closure "resumes" on, and more with how the compiler classifies the captured variable: the Task-isolated complete is considered sent the moment it's captured by an @MainActor-isolated closure. John McCall from the Swift team describes this in the same thread: "The problem is simply that Swift treats a capture in a closure with different isolation as a send, when what we actually need to do is reason about the isolation of the functions where the captured variable is used." The distinction matters: this only shows up with indirect captures — a variable captured by the outer closure only because it's used in a more deeply nested one. Point-Free's note on the Xcode 27/Swift 6.4 beta process shows that, in a library adopting NonisolatedNonsendingByDefault, 6.4 keeps tightening checking: "Swift 6.4 seems to have started catching more issues in async code related to this setting" — meaning closure/async isolation checking keeps tightening from 6.3 toward 6.4, a gradual trend rather than a one-time jump. import Foundation // Counter-example: isolation chain is NOT broken here — shows what the // indirect-capture pattern above does NOT look like actor DownloadManager { func fetch(completion: @escaping @Sendable (Result<Data, Error>) -> Void) { Task { let data = await performFetch() // Task inherits the actor's isolation; completion is @Sendable, // so there's no mutable capture being sent across a boundary completion(.success(data)) } } private func performFetch() async -> Data { Data() } } The first question to ask in practice when you hit warnings like this: "Which actor should this closure live on, and is its isolation determined explicitly or by inference?" Gradual migration without turning warnings into errors The most common mistake moving to 6.3 is pulling the entire target into Swift 6 language mode in one shot. swift.org's migration guide states the rule explicitly: "In targets that adopt the Swift 6 language mode, complete checking is unconditionally on and requires no setting change." So there's no in-between; the line from a Swift Forums account — "First I switched the language version in Xcode to 6 and set concurrency checking to minimal" — actually contradicts this rule. The gradual path is the reverse: keep the target in Swift 5 mode, raise "Strict Concurrency Checking" in Xcode from minimal → targeted → complete, clean up warnings, then switch the language mode to 6. // swift-tools-version: 6.0 import PackageDescription let package = Package( name: "MyPackage", targets: [ // Step 1: v5 language mode + complete checking (warnings only) .target( name: "LegacyModule", swiftSettings: [ .swiftLanguageMode(.v5), .enableUpcomingFeature("StrictConcurrency") ] ), // Step 2: swiftSettings removed, target falls back to default v6 language mode .target(name: "MigratedModule") ] ) Step two: walk back carelessly sprinkled @MainActor annotations. The same forum post admits: "Looking back later, I realized I could remove some of the mainactor annotations I'd added carelessly." Reaching for @MainActor everywhere as a "silence the error" reflex creates debt to clean up later — better to understand the actual data-race point the compiler flags and fix it there. Step three: when a team hits a compiler regression or an overly conservative diagnosis, open an issue and apply a temporary workaround without waiting for a language-team fix. Point-Free sums it up: "We've filed issues about these problems, but that doesn't help our users. So we've applied workarounds in the meantime." If you get stuck on a regression, add the annotation and keep moving — track the issue separately. Managing version conflicts in SPM packages A version upgrade affects not just your own code but the SPM packages you depend on — and their maintainers may have hit the same regressions first. Point-Free's experience with ComposableArchitecture and StructuredQueries is a good example. For StructuredQueries: "Swift 6.4 seems to have introduced a small type-checking regression that broke compilation of @Table macro code. The fix was simple ... and we shipped a release within 2 days." A similar quick-fix cycle played out for ComposableArchitecture too (the @MainActor example above). This shows a concrete order to follow for dependency management when moving to 6.3 (or later 6.4): - Check package versions first: look in the changelogs of your critical packages (ones like ComposableArchitecture, StructuredQueries) for a patch note mentioning "Swift 6.3" or "isolation." - Don't upgrade the target without updating the package: before changing your compiler version, run swift package update to pull dependencies to their latest patch — maintainers have usually hit the regression before you. - If a version is locked, track the issue: if your Package.resolved has locked a version predating 6.3, search the package's GitHub issues for "6.3" or "isolation." - Temporary workaround in your own fork: if a critical package hasn't shipped a fix yet — with a reflex similar to what Point-Free did on the maintainer side — open the issue and add a temporary nonisolated(unsafe) or an explicit @MainActor in your own integration layer while you wait for the package's official fix, rather than forking the package itself. These two examples confirm something else: isolation regressions during upgrades aren't specific to your codebase — popular production libraries hit the same friction. Knowing you're not alone helps morale during the migration. What to expect in Swift 6.4 No official GA date for Swift 6.4 has been announced — Xcode 27 is still in beta as of early September 2026. But swift.org's August 20, 2026 post, signed by Doug Gregor, documents concrete 6.4 Embedded Swift improvements: "Embedded Swift previously only supported existential (any) types with the AnyObject constraint ... Now all any types, including Any itself, are usable in Embedded Swift." The same post expands untyped-throws support: "Untyped throws is equivalent to throwing a value of type any Error. With the generalization of any types, Embedded Swift now fully supports untyped throws." Metatypes get their own section too: "Swift 6.4 brings complete support for metatypes in Embedded Swift." These changes don't target actor isolation directly, but matter for teams using Swift in embedded systems (microcontrollers, WebAssembly) — and Point-Free's experience shows 6.4's concurrency checking keeps up 6.3's tightening trend, sometimes surfacing new regressions. Advice: don't jump to the 6.4 beta before finishing your 6.3 migration — tracking the two versions' isolation-behavior differences separately makes it easier to tell which warning came from where. If your team is following the Xcode 27 betas, watching the monthly digests on the swift.org/blog page (like the September 4, 2026 "What's new in Swift: August 2026 Edition") is the most reliable way not to miss the moment 6.4 approaches stable — the official announcement usually lands in sync with Xcode's own GA. 🏆 GOLDEN TIP: The fastest way to isolate a warning When you hit a new isolation warning, ask this first: at which checking level (minimal/targeted/complete) and under which default-isolation setting (nonisolated/MainActor) does it appear? As in Itai Ferber's forum finding, the same code can produce four different outcomes under four different configurations. Temporarily flipping your target's SWIFT_STRICT_CONCURRENCY and -default-isolation flags and recompiling the same file lets you tell, within a few minutes, whether the warning is a real data race or a configuration-dependent compiler behavior. But remember: this is a diagnostic tool, not a fix — a configuration where the warning disappears doesn't prove the code is safe. Checklist We put together a short checklist you can keep on hand to figure out whether your project is really ready to move to Swift 6.3. Check off the items below in order; each one corresponds to a real warning case or escape hatch mentioned in this post. - Note your target's default-isolation setting (nonisolated or MainActor) and its concurrency checking level (minimal/targeted/complete) - List every function that uses a Sendable + escaping closure combination - Flag every line using nonisolated(unsafe) and re-review whether it's genuinely safe - Scan third-party imports for places that need @preconcurrency added - Check the Swift 6.3 compatibility notes for your critical dependencies (especially actor/concurrency-heavy packages) - Staying in Swift 5 language mode, raise the checking level from minimal to targeted to complete, and switch the language mode to 6 last - Re-review any @MainActor annotations added carelessly during migration once the migration is done - Fully finish your 6.3 migration before moving to the Xcode 27 beta — don't mix the isolation behavior of the two versions FAQ What code breaks when moving from Swift 6.2 to 6.3? The most common breaking point is code using @Sendable @escaping closures under the combination of nonisolated module default and Complete concurrency checking. In a case documented on the Swift Forums, this combination compiled silently on 6.2 but starts producing a "risks causing data races" warning on 6.3. The general rule: since a declaration's isolation status (isolated/nonisolated) is part of its API contract, a version upgrade can change how that contract is checked. Why is actor isolation stricter in Swift 6.3? The official 6.3 announcement doesn't declare a new concurrency feature; even so, some patterns that compiled silently on 6.2 produce warnings on 6.3. In the documented case, the source isn't a new rule at all, but the conservative behavior of region isolation analysis: a mutable variable captured in a closure with different isolation is treated as "sent." When will Swift 6.4 be released? No exact GA date has been officially announced. What's known: Xcode 27 (which includes Swift 6.4) has been in beta since June 2026 and was at beta 6 as of August 24, 2026. swift.org's August 20, 2026 Embedded Swift post also describes 6.4 as still "coming" — as of early September 2026, 6.4 has not been officially released. When should I use nonisolated vs. @concurrent? nonisolated(nonsending) is suited for helper functions that are a logical part of the caller's work and don't need to hop to a separate thread — such functions stay on the caller's actor. @concurrent, on the other hand, is for genuinely expensive work that must run in parallel and shouldn't block the caller's actor — it's used to explicitly request the old "always run on a separate executor" behavior. When the NonisolatedNonsendingByDefault flag is on, plain nonisolated async functions stay on the caller's actor by default; you need to explicitly mark any function where you want to keep the old behavior with @concurrent. Which build settings should I check before moving to 6.3? Check your target's -default-isolation flag (MainActor or nonisolated) and its SWIFT_STRICT_CONCURRENCY level (minimal/targeted/complete). Per the forum finding in this post, the same code produces different results on 6.3 depending on the combination of these two settings — the documented warning doesn't appear in projects with the MainActor-default turned on. Should I clean up my existing nonisolated(unsafe) usages before moving to 6.3? Not mandatory, but recommended. nonisolated(unsafe) should remain a temporary escape hatch; the 6.3 migration is a good opportunity to review whether these annotations are still necessary or are simply leftovers from an old "silence it and move on" reflex. Conclusion Migrating to Swift 6.3 is less about learning a new language feature and more about understanding how the compiler now checks the actor isolation defaults introduced by 6.2 (SE-0466, SE-0461). The documented case teaches the lesson on its own: a mutable variable indirectly captured behind a Sendable+escaping signature runs into the conservative rule of region isolation analysis. Protocol conformance isolation (SE-0470) ties back to the same root: isolation is now a checked part of the API contract. If you want to refresh Swift's strict concurrency foundations before this migration, the Swift 6 Strict Concurrency Deep Guide is a good starting point; if you're looking for a step-by-step migration plan, the Swift 6 Strict Concurrency Migration Playbook complements this post. If you want a deeper look at how actors fundamentally work, check out Swift Concurrency and Actors; for the Task and TaskGroup side of structured concurrency, see Swift Structured Concurrency. For projects using actors in distributed systems, Swift Distributed Actors covers the beyond-the-network isolation scenarios this post doesn't. You can also refresh the background on the general concurrency updates that came with Swift 6.2 at WWDC26 from Swift 6.2 Concurrency Innovations. One last note: every example in this article was written against 6.3.3 (today's stable release). If you've started trying Swift 6.4 with the Xcode 27 beta, keep in mind that isolation behavior may differ from 6.3 — keeping the two versions' regressions separate in the same migration process makes it easier to tell where each warning is coming from. Sources - Swift 6.3 Released — official 6.3 announcement, March 24, 2026, summary of language changes - Announcing Swift 6.3.3 — June 30, 2026 patch announcement, current stable release - Embedded Swift Improvements Coming in Swift 6.4 — August 20, 2026, Doug Gregor, official evidence that 6.4 hasn't shipped yet - SE-0466: Control Default Actor Isolation — module-wide default isolation proposal, Implemented in 6.2 - SE-0461: Run nonisolated async functions on the caller's actor — the nonisolated(nonsending) proposal, Implemented in 6.2 - New Swift 6.3 isolation warnings with Sendable x escaping escape hatch — Itai Ferber, Swift Forums, February 25, 2026, the primary source for this post - My experience (attempting) a migration to swift 6 — Swift Forums, April 3, 2025, a gradual-migration account from the Swift 6.0 era - Xcode 27 Support in the Point-Free Ecosystem — Point-Free, real regression-fix examples from ComposableArchitecture/StructuredQueries - Enable data-race safety checking — swift.org migration guide, language mode and checking level rules - What's new in Swift: August 2026 Edition — swift.org, September 4, 2026, monthly digest --- Originally published at muhittincamdali.com.
SwiftUI の @State はなぜマクロになったのか ― 初期値の遅延評価と、その代償 ! 確認日:2026-10-24 Xcode 26.6(Build 17F113)、Xcode 27.1(Build 27A9275)、macOS 26.7.1 で確認した内容です。記事の内容は、Apple の資料の改訂や Xcode の更新によって変わる可能性があります。 Xcode 27(iOS 27 SDK)から、SwiftUI の @State はプロパティラッパーではなくマクロとして実装されています。 Apple は、WWDC26 のセッションと Technote TN3211 で、この変更の目的を初期値の遅延評価だと説明しています。View の値が作り直されるたびに @State の初期値を評価し直す無駄をなくすためです。 この記事の主張は次の 3 点です。 目的:@State のマクロ化は、初期値を遅延評価するための変更である。 条件:ただし、遅延評価になるのは、private(または fileprivate)で宣言し、初期値を宣言に書いた @State だけである。イニシャライザで代入した値は対象外で、デプロイメントターゲットも iOS 17 以降(macOS 14 以降など、同時期の各 OS を含む)である必要がある。 代償:遅延評価を実現するために生成されるコードが変わり、Xcode 26 で動いていた書き方のいくつかがコンパイルエラーになる。なかには、エラーを素直に直すと実行時の値が変わってしまう書き方もある。また、Apple が勧める修正方法を使うと、遅延評価の恩恵を失う。 実務上は、@State を private で宣言し、初期値を宣言に書いてください。宣言に初期値を持つ private な @State に、イニシャライザで self.x = … と代入している箇所は見直してください。他の格納プロパティより先に代入していれば、Xcode 27 でコンパイルエラーになります。後に代入していれば、代入した値は保持されません(Xcode 26 でも同じです)。外部から値を受け取りたい場合の書き方は、第 5 章で目的別に示します。 以下では、この主張の根拠を順に示します。根拠は、Apple の資料と、Xcode 26.6 と Xcode 27.1 での実測です。Apple の資料は、WWDC26 のセッション 269 と 102、Technote TN3211、Xcode に含まれるエージェント向けスキルの 4 つです(参考資料)。計測の環境と手順は付録にまとめました(付録 A 以降)。 この記事での用語 View の値の再作成:親 View の body が再び評価され、子 View の構造体がイニシャライザから作り直されること。SwiftUI は、同じ位置にある子 View を同じ View とみなし、@State の値を引き継ぐ。 実体:@State の値を実際に保持する格納プロパティ。 遅延評価:宣言に書いた初期値の式を、View の値を作るたびではなく 1 回だけ評価すること。この記事で測ったのは評価の回数で、評価されるタイミングは測っていない。 保存場所の割り当て:SwiftUI が @State の値を保持する領域を用意すること。この記事では、イニシャライザで後から代入した値が保持されない理由を、割り当ての前に書き込んだためだと説明する。これは計測結果をもとにした筆者の理解で、SwiftUI の内部の仕組みを確かめたものではない。 1. 何が問題だったのか SwiftUI の View は、軽い値として頻繁に作り直されます。親 View の body が再び評価されると、子 View の構造体もイニシャライザから作り直されます。このとき SwiftUI は、同じ位置にある子 View を同じ View とみなし、@State の値を引き継ぎます(View の値の再作成)。 問題は、@State の宣言に書いた初期値の式も、View の値を再作成するたびに評価されていたことです。 struct StoreView: View { @State private var store = Store() // Store は @Observable なクラス var body: some View { ... } } Xcode 26.6 でビルドしたアプリでは、StoreView の値が作り直されるたびに Store() が呼ばれます。セッション 269 の説明によれば、SwiftUI が使うのは最初に作られたインスタンスだけで、2 回目以降に作られたインスタンスは捨てられます。 StoreView と同じ形の View(private の @State にクラスを保持する View)で計測しました。親 View の値を 5 回再作成すると、クラスのイニシャライザは 6 回呼ばれました(最初の作成の 1 回と、再作成の 5 回)。計測の方法は第 2 章で示します。TN3211 も説明しているとおり、値型なら害は小さいものの、参照型ではそのたびにヒープ割り当てと初期化処理が発生します。 WWDC26 のセッション 269「What's new in SwiftUI」は、@Observable なクラスを @State に保持する例で、この無駄を説明しています。セッション 102「Platforms State of the Union」も、不要な処理を避けるための変更だと述べています。TN3211 も、この無駄の解消を @State をマクロにした理由として挙げています。 2. マクロ化で何が変わったのか 計測の方法 初期値の式が評価された回数を数えるため、評価のたびにカウンタを増やす式を @State の初期値に書きました。計測対象は、次の 3 つの子 View です(抜粋。count は回数を記録する関数で、全体は付録 C)。 @Observable final class Model { init(_ tag: String) { count(tag) } // 評価のたびに回数を数える } func makeNumber(_ tag: String) -> Int { count(tag) return 0 } struct PrivateClassChild: View { // private・クラス let n: Int @State private var model = Model("private class") var body: some View { Text("(n)") } } struct InternalClassChild: View { // internal・クラス let n: Int @State var model = Model("internal class") var body: some View { Text("(n)") } } struct PrivateValueChild: View { // private・値型 let n: Int @State private var number = makeNumber("private value") var body: some View { Text("(n)") } } 各子 View の n は、親 View から受け取る表示用の整数で、親 View の値を再作成するたびに 1 ずつ増やしました。3 つの子 View を並べた親 View を NSHostingView に載せ、その NSHostingView をウィンドウに配置しました。ウィンドウは画面には表示していません。そのうえで、親 View の値を 5 回と 10 回再作成しました。再作成のきっかけは、次の 2 通りです。 rootView の差し替え:NSHostingView の rootView に、新しい親 View の値を代入する。 @Observable の値の変化:親 View が読んでいる @Observable なオブジェクトの値を変え、SwiftUI に親の body を再評価させる。アプリの中でもよく起きる更新のきっかけである。 同じプログラムを Xcode 26.6 と 27.1 でビルドし、同じ macOS 26.7.1 上で実行しました。実行した OS が同じなので、結果の差はビルド時に生成されるコードの違いによるものです。プログラムの全体は付録 C に載せました。 結果 表の数値は、すべて初期値の式が評価された回数です。どの値も、再作成のきっかけ(rootView の差し替えと @Observable の値の変化)によらず同じでした。全条件の出力は付録 C に載せました。 Xcode 27.1 では、初期値の式を 1 回だけ評価する private のクラス(@State private var model = Model(...))で、Xcode 26.6 と 27.1 を比べます。 親 View の値を再作成した回数 Xcode 26.6 での評価回数 Xcode 27.1 での評価回数 5 回 6 回 1 回 10 回 11 回 1 回 Xcode 26.6 では、評価回数は View の値を作った回数(最初の 1 回と、再作成の回数の合計)に等しくなります。Xcode 27.1 では、再作成の回数によらず 1 回です。これが遅延評価です。 遅延評価になるのは private の場合だけ Xcode 27.1 で、private と internal のクラスを比べます。 @State の宣言 5 回再作成したときの評価回数 10 回再作成したときの評価回数 @State private var model = Model(...) 1 回 1 回 @State var model = Model(...)(internal) 6 回 11 回 internal の @State は、Xcode 27.1 でも 26.6 と同じく、View の値を作るたびに評価されます。 クラスだけでなく、値型も遅延評価される Xcode 27.1 で、private の @State の初期値をクラスと値型で比べます。値型は、評価のたびに回数を数えて Int を返す関数 makeNumber(_:) で作りました。 @State の宣言 5 回再作成したときの評価回数 10 回再作成したときの評価回数 @State private var model = Model(...)(クラス) 1 回 1 回 @State private var number = makeNumber(...)(値型) 1 回 1 回 セッション 269 はクラスの例で説明していますが、遅延されるのは初期値の式そのものです。 以上から、評価回数は特定のサンプルに固有の値ではないことが分かります。この計測の条件(宣言に初期値を書き、macOS 26 を対象にビルド)では、評価回数はビルドに使った Xcode と @State のアクセスレベルで決まります。 計測は macOS の NSHostingView で、ウィンドウを画面に表示しない状態で行いました。iOS の実機やシミュレータでは確かめていません。 Apple の資料との対応 遅延評価の条件について、Apple の資料はいずれも private を条件として明記していません。 資料 遅延評価の条件についての説明 実測と一致するか WWDC26 セッション 269 @State に保持したクラスは 1 回だけ初期化されると説明している。コード例はすべて private だが、private が条件だとは述べていない internal の場合に一致しない WWDC26 セッション 102 コードを変えなくても、この改善が得られると説明している internal の場合に一致しない TN3211(2026-06-08 公開) 条件を付けずに、@State に保持したオブジェクトは 1 回だけ初期化されると説明している internal の場合に一致しない スキル swiftui-specialist(Xcode 27.1、Build 27A9275) @State を常に private にするよう指示している。ただし、遅延評価の条件としては説明していない 条件を述べていない 4 つの資料のいずれも、遅延評価が private に限られることを説明していません。private が条件であることは、この記事の実測から分かったことです。スキルの指示どおり @State を常に private にし、初期値を宣言に書いていれば、結果として遅延評価の恩恵を受けられます(iOS 17 以降を対象とする場合)。 セッション 269 は、この挙動が @Observable と同じリリース(iOS 17、macOS 14 と、同時期の各 OS)までさかのぼって使えると説明しています。デプロイメントターゲットの条件は、コンパイル結果でも確かめました。@State private var model = Model() を、デプロイメントターゲットを変えて Xcode 27.1 でコンパイルすると、実体の型は次のようになります。 デプロイメントターゲット 実体の型 初期値の式が評価されるタイミング(型からの推定) iOS 16.0 State<Model> View の値を作るたびに評価される iOS 17.0 LazyState<Model> 1 回だけ評価される(遅延評価) 3 列目は、実体の型から推定したもので、実行して測ってはいません。 イニシャライザで代入した値は遅延評価されない 遅延評価の対象は、宣言に書いた初期値だけです。イニシャライザで @State に代入した値は、private でも遅延評価されません。宣言の初期値を外してイニシャライザで代入する形は、セッション 269 と TN3211 がコンパイルエラーの修正方法として示している形です。 同じ @Observable クラス Page を、3 つの書き方で @State に保持し、親 View の値を 5 回再作成しました(計測プログラムは付録 D)。表の数値は、Page のイニシャライザが呼ばれた回数です。 @State の書き方 Xcode 26.6 での Page の生成回数 Xcode 27.1 での Page の生成回数 @State private var page = Page(...)(宣言に初期値を書く) 6 回 1 回 @State private var page: Page と宣言し、イニシャライザで self.page = Page(...) と代入する 6 回 6 回 @State private var page: Page と宣言し、イニシャライザで _page = State(initialValue: Page(...)) と代入する 6 回 6 回 イニシャライザの本体は、View の値を作るたびに実行されます。Page(...) はイニシャライザの中で評価されるので、評価済みの値が @State に渡ります。マクロが遅延させられるのは、宣言に書かれた式だけです。 親から受け取った値を使って、イニシャライザで @State のオブジェクトを作る限り、Xcode 27 でも View の値を作るたびにインスタンスが作られます。Apple 以外の情報源では、Point-Free もこの点を指摘しています。外部から値を受け取る場合の書き方は、第 5 章で示します。 3. マクロで何が可能になったのか Apple の資料は、@State をマクロにした理由を遅延評価としか説明していません。プロパティラッパーのままでは実現できなかったのかどうかは、述べていません。この章は、展開結果と SDK をもとにした筆者の分析です。 遅延評価そのものは、マクロでなくても実現できた可能性があります。Xcode 26.6 の SDK にも、LazyState の次のシンボルが含まれているからです。 初期値を @autoclosure で受け取る init(wrappedValue:) wrappedValue と projectedValue これらは、プロパティラッパーと同じ形のメンバーです。 マクロで可能になったのは、宣言ごとに生成するコードを変えることです。プロパティラッパーでは、実体の型は付けた属性の型に固定されます。@State なら、実体は必ず State<Value> です。マクロは宣言の構文を見て、アクセスレベルと初期値の有無に応じて、実体の型と初期値の扱いを切り替えられます。さらに、従来の _x や $x という名前を残して、既存のコードとの互換性を保てます。 展開の 3 つの形 Xcode 27.1 のコンパイラが出力した展開結果は、次の 3 通りに分かれました(取得方法は付録 B)。 形 宣言の例 実体 初期値の式が評価されるタイミング 宣言したプロパティに init accessor が付くか A @State private var x: T(宣言に初期値を書かない) _x: State<T> 宣言に初期値がないので、評価する式もない 付く B @State var x = v(private 以外で、初期値を書く) _x: State<T> View の値を作るたびに評価される 付く C @State private var x = v(private で、初期値を書く) __x: LazyState<T> 1 回だけ評価される(遅延評価) 付かない A と B は、Xcode 26.6 のプロパティラッパーが合成していたものと同じ構成です。C だけが新しい形で、遅延評価はこの形で実現されています。 C の形の展開結果(抜粋)は次のとおりです。 @State private var count = 0 // 実体。初期値の式はクロージャに包まれて保存される private var __count = SwiftUICore.State._makeStorage({ 0 }) // count 自身は計算プロパティになる。init accessor はない private var count: Int { get { __count.wrappedValue } nonmutating set { __count.wrappedValue = newValue } } _makeStorage は、デプロイメントターゲットが iOS 17・macOS 14 以降なら LazyState を返します。LazyState は、初期値をクロージャのまま保持できる型です。 @frozen public struct LazyState<Value> : DynamicProperty { public enum Storage { case thunk(() -> Value) case value(Value) } ... } LazyState は、Xcode 27 で新しく作られた型ではありません。Xcode 26.6 の SDK でも、公開インタフェースには出ていないものの、SwiftUICore.tbd にシンボルとして含まれています。Xcode 27.1 の宣言には @available(iOS 17.0, macOS 14.0, ...) が付いています。つまり、iOS 17 以降の OS にすでに入っていた型を、Xcode 27 のマクロが使い始めたということです。遅延評価が iOS 17 以降を対象とするアプリで使えるのは、このためです。セッション 269 の説明(iOS 17 までさかのぼって使える)とも一致します。 遅延評価が private に限られる理由 資料には、遅延評価が private に限られる理由は書かれていません。筆者は、memberwise init との両立が理由だと推測しています。 B の形のように private でないプロパティは、memberwise init の引数として外から値を受け取れます。たとえば、@State var count = 0 を持つ View V は、V(count: 3) と初期化できます。このため、B の形の count には init accessor が付き、Xcode 26.6 と同じ使い方ができます。一方、C の形の count には init accessor がなく、memberwise init の引数としては値を受け取りません。イニシャライザの中で _count に代入する経路だけが残っています。 マクロ特有の工夫 C の形では、マクロが型情報を持たないことを補う工夫も見られます。 型の穴:マクロは構文しか参照できないので、count の型が Int であることを判別できない。そこで _count: State<_>! と型を空けて宣言し、nil を返すだけの関数 _stateNil(of: { State(initialValue: 0) }) で型推論に型を伝えている。 base64 の文字列引数:補助マクロ(_StatePropertyWrapperStorage など)は、初期値の式などのソースコードを base64 の文字列として受け取る。ネストしたマクロは他の宣言の展開結果を参照できないため、と筆者は推測している。 互換性のための _count:実体は __count に移ったが、_count は init accessor を持つ計算プロパティとして残る。イニシャライザで _count = State(initialValue: x) と書くと、この init accessor を通って __count が初期化される。 4. 遅延評価の代償 TN3211 の説明では、マクロ化によって、@State は他の格納プロパティと同じ規則で初期化されるようになりました。その結果、以前は受け入れられていた書き方の一部を、コンパイラが診断するようになりました。 注意点 1、4、5 は、第 3 章の展開の形から説明できます。注意点 3 は、展開の形の変形(初期値を書かない Optional)によるもので、その節で説明します。注意点 2 は、マクロ化の前からある挙動です。コンパイル結果と実行時の値は、Xcode 26.6 と 27.1 で実測しました。 注意点 1:private で初期値を持つ @State に、他のプロパティより先に代入するとエラーになる struct ContentView: View { var name: String @State private var counter: Int = 0 init(name: String) { self.counter = 42 self.name = name } var body: some View { Text("(name): (counter)") } } 項目 Xcode 26.6 での結果 Xcode 27.1 での結果 コンパイル 成功する エラーになる:variable 'self.name' used before being initialized body が読む counter の値 42 コンパイルエラーのため実行できない C の形の counter には init accessor がないので、self.counter = 42 は setter 呼び出しになります。setter を呼べるのは、self のすべての格納プロパティを初期化した後です。そのため、name の初期化より前に書くとエラーになります。 private 以外(B の形)の場合は、counter に init accessor があるので、Xcode 27.1 でもコンパイルが成功します。body が読む値は Xcode 26.6 と同じでした(注意点 2 の表)。 注意点 2:エラーを代入の順番の入れ替えで解消すると、値が変わる 注意点 1 のエラーは、代入の順番を入れ替えると消えます。 init(name: String) { self.name = name self.counter = 42 // 順番を入れ替えた } 項目 Xcode 26.6 での結果 Xcode 27.1 での結果 コンパイル 成功する 成功する body が読む counter の値 0 0 しかし、body が読む値は 42 から 0 に変わります。この位置の代入は、Xcode 26.6 でも setter 呼び出しでした。筆者の理解では、イニシャライザの実行中は、SwiftUI がまだ @State の値の保存場所を割り当てていません。そのため、書き込んだ 42 は保持されないと考えられます。このとき、コンパイラはエラーと警告のどちらも出しません。 TN3211 も、宣言に初期値を持つ @State にイニシャライザで代入した値は破棄されると注意しています。 代入の順番で値が変わるという現象は、マクロ化の前からありました。private 以外の @State では、Xcode 27.1 でも同じ現象が起きます。マクロ化で変わったのは、private の場合に、代入した 42 が保持される書き方(他の格納プロパティより先に代入する書き方)がコンパイルエラーになった点です。 正しい修正方法は、宣言から初期値を削除して A の形にし、他の格納プロパティを初期化した後で代入することです。 struct ContentView: View { var name: String @State private var counter: Int init(name: String) { self.name = name self.counter = 42 // init accessor による初期化 } var body: some View { Text("(name): (counter)") } } このコードは、Xcode 26.6 と 27.1 のどちらでもコンパイルが成功し、body は 42 を読みました。セッション 269 と TN3211 も、宣言の初期値を外す修正方法を示しています。 ただし、この修正には代償があります。イニシャライザで代入した値は遅延評価されません(第 2 章)。@State に保持するのが参照型なら、View の値を作るたびにインスタンスが作られます。宣言の初期値で足りる場合は、イニシャライザで代入しないでください。外部から値を受け取る場合の書き方は、第 5 章で示します。 A の形では、代入を他のプロパティより先に置いても、Xcode 27.1 ではコンパイルが成功しました。それでも後に置くのは、TN3211 が、@State 以外の格納プロパティをすべて初期化してから @State に代入することを、修正方法として示しているからです。後に置けば、TN3211 の説明と実測のどちらにも反しません。 private 以外の @State(@State var counter = 0)では、代入の順番で値の変わる現象が、Xcode 27.1 でもそのまま残ります。 イニシャライザでの代入 Xcode 26.6 で body が読む値 Xcode 27.1 で body が読む値 他の格納プロパティより先に self.counter = 42 42 42 他の格納プロパティより後に self.counter = 42 0 0 self.counter = 42(他に格納プロパティがない View) 0 0 private の @State で、代入した 42 が保持される書き方は次の 2 つです。どちらも Xcode 26.6 と 27.1 の両方で、body は 42 を読みました。 counter の宣言 イニシャライザでの代入 body が読む値 @State private var counter: Int(宣言に初期値を書かない) self.counter = 42 42 @State private var counter = 0 _counter = State(initialValue: 42) 42 注意点 3:暗黙の nil を持つ Optional があると、private な memberwise init は合成されない struct Foo: View { @State private var bar: String? // 暗黙に nil private let baz: Int var body: some View { Text("") } } extension Foo { init(value: Int) { self.init(baz: value) } } 項目 Xcode 26.6 での結果 Xcode 27.1 での結果 コンパイル 成功する エラーになる:incorrect argument label in call (have 'baz:', expected 'value:') Swift は、すべてのメンバーが private の構造体にも、private な memberwise init を合成します。この memberwise init は、同じファイルの extension から呼び出せます。 初期値を書かない Optional の @State も C の形になります。ただし、この場合だけは bar と _bar の両方に、__bar を初期化する init accessor が付きます。SE-0400 の規則では、同じ格納プロパティを初期化する init accessor が複数あると、memberwise init は合成されません。 Xcode 27.1 では、次の場合に memberwise init が合成されることを確かめました。 @State private var bar: String? = nil のように、明示的に = nil を書く(bar 側に init accessor が付かない) @State private var bar = 0 のように、Optional ではない初期値を書く 他のメンバーに internal のものがあり、memberwise init が internal になる 修正方法は、= nil を明示的に書くか、memberwise init を自分で定義するかのどちらかです。 注意点 4:他のプロパティラッパーと組み合わせると、名前が衝突する @propertyWrapper struct Logged<T> { var wrappedValue: T } struct V: View { @State @Logged private var n = 0 var body: some View { Text("") } } 項目 Xcode 26.6 での結果 Xcode 27.1 での結果 コンパイル 成功する エラーになる:invalid redeclaration of synthesized property '_n' と property wrapper cannot be applied to a computed property @State マクロと @Logged プロパティラッパーが、どちらも _n という名前の宣言を生成しようとして衝突します。さらに、マクロによって n は計算プロパティになるので、プロパティラッパーを付けられません。修正方法は、どちらかの属性を削除するか、@Logged を付けたプロパティを別に定義するかのどちらかです。 注意点 5:C の形では、_x を読むと保存場所を参照しない値が返る ここでは、第 3 章の @State private var count = 0 を例にします。Xcode 26.6 では、_count は実体そのものでした。Xcode 27.1 の C の形では、_count の getter は、現在の値を初期値とする新しい State を作って返します。 get { SwiftUICore.State(initialValue: __count.wrappedValue) } この State は、SwiftUI が割り当てた保存場所を参照しません。_count を他の View や関数に渡しているコードは、意図どおりに動かない可能性があります。Binding が必要な場合は $count を使います。この項目は展開結果から読み取った内容で、実行時の挙動は確かめていません。 5. 外部から値を注入したい場合 ここまでの結果から、次の疑問が生じます。親から受け取った値を使って @State を初期化したい場合は、どう書けばよいのでしょうか。 答えは、受け取った値をどう扱いたいかによって変わります。書き方ごとの違いを確かめるため、次の手順で計測しました(計測プログラムは付録 E)。 親 View は、子 View に文字列 title を渡す。最初に親 View を作るときの title は "v0" とする。 親 View の値を 5 回再作成し、そのたびに title を "v1" から "v5" まで 1 つずつ変えて渡す。 最後に "v5" を渡した後で、子 View が body で読んでいる値を記録する。あわせて、参照型 Page のイニシャライザが呼ばれた回数を数える。 子 View が "v5" を読んでいれば、親が渡した最新の値に追従しています。"v0" を読んでいれば、最初に受け取った値のままです。 次の表は、Xcode 27.1 での結果です。 目的 書き方 親が最後に "v5" を渡した後に、子 View が読む値 Page の生成回数 親の値に追従させる @State を使わず、let title: String で受け取る "v5"(親の値に追従する) Page を作らない 最初の値だけを初期値として使う @State private var title: String と宣言し、イニシャライザで self.title = title と代入する "v0"(最初に受け取った値のまま) Page を作らない 親の値から参照型を 1 回だけ作る @State private var page: Page? と宣言し、.task { if page == nil { page = Page(...) } } で作る "v0"(最初に受け取った値で作った Page のまま) 1 回 親の値が変わるたびに参照型を作り直す @State private var page: Page? と宣言し、.task(id: title) { page = Page(...) } で作る "v5"(親の値が変わるたびに作り直す) 6 回 生成を親に移す 親が @State private var page = Page(...) で作り、子は let page: Page で受け取る 親が作った Page の値(この書き方では title を使わない) 1 回 Xcode 26.6 でも、最後の行を除いて同じ結果でした。最後の行(生成を親に移す)だけは、Xcode 26.6 では Page が 6 回作られました。親の @State の宣言の初期値が、遅延評価されないためです。 親の値に追従させたいなら、@State を使わない イニシャライザで @State に代入した値は、最初の 1 回だけが使われます。親が後から別の値を渡しても、子の @State は最初の値のままです。これは Xcode 26.6 でも 27.1 でも同じで、マクロ化とは関係のない @State の性質です。 親の値を表示するだけなら、let で受け取ります。子から親の値を書き換える必要があるなら、@Binding で受け取ります。 最初の値だけを使うなら、イニシャライザで代入する 親の値を初期値として受け取り、その後は子が独自に変更する場合は、A の形(初期値を書かず、イニシャライザで代入する)を使います。数値や文字列のように生成の負荷が小さい値なら、この書き方で問題ありません。 ただし、代入する式はイニシャライザの中で評価されるので、View の値を作るたびに評価されます(第 2 章)。生成の重い参照型には向きません。 親の値から重い参照型を作るなら、.task で作る @State を Optional にしておき、.task の中で 1 回だけ作ります。計測では、親を 5 回再作成しても Page の生成は 1 回でした。 struct PageView: View { let title: String @State private var page: Page? var body: some View { Group { if let page { Text(page.title) } else { ProgressView() } } .task { if page == nil { page = Page(title: title) } } } } 親の値が変わったときに作り直したい場合は、.task の代わりに .task(id: title) { page = Page(title: title) } と書きます。計測では、親の値が変わるたびに作り直されました。 この書き方には、次の制約があります。 .task の処理は非同期に実行されるので、最初の body の評価では page が nil になる。nil のときの表示を用意する必要がある。 計測は macOS の NSHostingView で、ウィンドウを画面に表示しない状態で行った。この状態でも .task は実行されたが、画面に表示した状態や iOS では確かめていない。 そのほかの選択肢 生成を親に移す:オブジェクトを、値を持っている側(親や、親が保持するモデル)で作り、子には let で渡す。計測では、親が @State private var と宣言の初期値で作った Page は、親を 5 回再作成しても 1 回だけ作られた(Xcode 26.6 では 6 回)。ただし、この方法が使えるのは、親が子に渡す値を使わずに、宣言の初期値だけで Page を作れる場合に限る。 Apple 以外のライブラリ:Point-Free の @LazyState マクロは、親から受け取った値で @State を遅延して作るためのものである。2026-10-24 時点では、会員向けのベータ版として非公開リポジトリで提供されている。 6. Apple の資料を読むときの注意 TN3211 の @State に関する説明を、実測結果と照らし合わせました。TN3211 のコード例は、そのままの形で Xcode 26.6 と 27.1 でコンパイルしました。 TN3211 の説明 実測の結果 実測と一致するか 初期値のない @State private var counter: Int に、他の格納プロパティより先に代入するとエラーになる 両方の Xcode でコンパイルに成功。エラーになるのは、宣言に初期値がある場合(注意点 1) 一致しない すべてのメンバーが private の View で、extension から self.init(bar: bar, baz: baz) を呼ぶとエラーになる Xcode 26.6 でも同じ呼び出しでエラー。メッセージは extraneous argument label 'bar:' で、TN3211 の no exact matches in call to initializer とは異なる(注意点 3) 一致しない 初期値を持つ @State にイニシャライザで代入した値は破棄され、宣言側の初期値が優先される 他の格納プロパティの後に代入すると、Xcode 26.6 と 27.1 のどちらでも body は初期値の 0 を読む(注意点 2) 一致する 他のプロパティラッパーと組み合わせると invalid redeclaration of synthesized property エラーになる Xcode 27.1 で同じエラー(注意点 4) 一致する @State に保持したオブジェクトは 1 回だけ初期化される 詳しくは第 2 章の「Apple の資料との対応」を参照 一部だけ一致する WWDC26 セッション 269 の修正例も、TN3211 の説明と食い違います。この修正例は、宣言の初期値を外したうえで、@State への代入を他の格納プロパティより先に書いています。上の表の 1 行目の説明(初期値のない @State に、他の格納プロパティより先に代入するとエラーになる)に従えばエラーになる書き方ですが、実測ではコンパイルが成功しました。セッションの例と実測は一致しています。 TN3211 の改訂履歴には、2026-06-08 の初版しか記載されていません。一致しない項目が、TN3211 の記述の誤りなのか、Xcode 27.0 の時点の挙動なのかは判別できません。筆者は Xcode 27.0 を確かめていません。ただし、同じ時期に公開されたセッション 269 の例は、上の表の 1 行目の説明に反しています。 TN3211 は、注意点 1 のエラーメッセージとして、次のメッセージも挙げています。 error: 'self' used in property access '_counter' before 'super.init' call このメッセージは、筆者が確かめたどの Xcode でも出ませんでした。 Apple の資料どうしで説明が異なる場合や、資料の例が手元で再現しない場合は、実際にコンパイル・実行して確かめることをお勧めします。 まとめ @State のマクロ化の目的は、初期値の遅延評価です。マクロは、宣言の構文とデプロイメントターゲットに応じて実体の型を State と LazyState から選ぶことで、これを実現しています。この記事の結果から、次の書き方を勧めます。 @State は private で宣言する。 遅延評価になるのは private(または fileprivate)の場合だけである。デプロイメントターゲットも iOS 17・macOS 14 以降である必要がある。 遅延評価させたい初期値は、宣言に書く。 イニシャライザで代入した値は遅延評価されない。 外部から値を受け取るときは、目的に応じて書き方を選ぶ。 親の値に追従させるなら let か @Binding、最初の値だけを使うなら初期値を書かずにイニシャライザで代入する。親の値から重い参照型を作るなら、.task で作るか、生成を親に移す(第 5 章)。 private の @State で起きたコンパイルエラーを、代入の順番の入れ替えで解消しない。 Xcode 26.6 で保持されていた値が保持されなくなる。しかも、コンパイラはエラーと警告のどちらも出さない。 初期値の式の副作用に依存しない。 private の @State では、初期値の式は 1 回だけ評価されるので、式の副作用(ログ出力など)も 1 回だけ起きる。 Xcode 26.6 から 27.1 で変わったことを表にまとめます。 項目 Xcode 26.6 での挙動 Xcode 27.1 での挙動 private の @State の、宣言に書いた初期値の式 View の値を作るたびに評価される 1 回だけ評価される 宣言に初期値を書いた private な @State に、他の格納プロパティより先にイニシャライザで代入する コンパイルが成功し、代入した値が保持される コンパイルエラーになる 初期値を書かない private な Optional の @State を持ち、すべてのメンバーが private の型の memberwise init 合成される 合成されない @State と他のプロパティラッパーを同じプロパティに付ける コンパイルが成功する コンパイルエラーになる private で初期値を書いた @State の _x を読む 実体そのものが返る 保存場所を参照しない新しい State が返る(展開結果から読み取った内容) 次の項目は、Xcode 26.6 と 27.1 で変わりません。 項目 Xcode 26.6 と 27.1 での挙動 private 以外の @State の、宣言に書いた初期値の式 View の値を作るたびに評価される イニシャライザで @State に代入する式 View の値を作るたびに評価される 宣言に初期値を書いた private 以外の @State に、他の格納プロパティより先にイニシャライザで代入する コンパイルが成功し、代入した値が保持される 付録 A:確認した環境 項目 Xcode 26.6 Xcode 27.1 ビルド番号 17F113 27A9275 Swift の版 6.3.3 6.4 @State の実装 プロパティラッパー マクロ コンパイル診断:iOS Simulator 向けに swiftc -emit-sil でコンパイルした。デプロイメントターゲットは iOS 26.0 で、第 2 章の型の比較だけ iOS 16.0 と 17.0 を使った。第 5 章のサンプル PageView は、iOS 17.0 向けにコンパイルできることを確かめた Xcode 27.1:App Store で配布されている正式版を使った 実行時の挙動:それぞれの Xcode で macOS 向けにビルドし、同じ macOS 26.7.1 上で実行した。NSHostingView を載せたウィンドウは、画面には表示していない 実行時の計測のデプロイメントターゲット:macOS 向けのビルドでは -target を指定せず、デフォルトの arm64-apple-macosx26.0 を使った。遅延評価が働く条件(macOS 14 以降)を満たしている 展開結果:Xcode 27.1 で、デプロイメントターゲットを iOS 27.0 にして出力した(付録 B) 記事中のコード片は、コンパイラが出力した展開結果から該当部分を抜き出したものです。読みやすくするため、一部を整形しています。 付録 B:展開結果を出力する方法 Xcode の「Expand Macro」は、選択した 1 か所の展開結果を表示します。ファイル内のすべての展開結果を一度に出力するには、次のコマンドを使います。 xcrun swiftc -typecheck \ -sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \ -target arm64-apple-ios27.0-simulator \ -parse-as-library \ -Xfrontend -dump-macro-expansions \ Sample.swift 環境変数 DEVELOPER_DIR に各 Xcode の Contents/Developer を指定すると、同じファイルを別の Xcode でコンパイルできます。-target の OS の版は、確かめたいデプロイメントターゲットに合わせて変えてください。 ! 「used before being initialized」は、確定初期化(definite initialization)の診断です。この診断は SIL の生成段階で出ます。そのため、-typecheck ではエラーが出ずに成功します。 コンパイルエラーを確かめるときは、-typecheck を -emit-sil -o /dev/null に置き換えてください。 付録 C:遅延評価の計測プログラム 第 1 章と第 2 章の計測には、次のプログラムを使いました。再作成の回数(5 回と 10 回)と、再作成のきっかけ(rootView の差し替えと @Observable の値の変化)の組み合わせごとに、初期値の式の評価回数を出力します。 計測プログラムの全体(Probe.swift) Probe.swift import SwiftUI import AppKit // 初期値の式が評価された回数を数える nonisolated(unsafe) var counts: [String: Int] = [:] func count(_ tag: String) { counts[tag, default: 0] += 1 } @Observable final class Model { init(_ tag: String) { count(tag) } } func makeNumber(_ tag: String) -> Int { count(tag) return 0 } // 計測対象の子 View struct PrivateClassChild: View { let n: Int @State private var model = Model("private class") var body: some View { Text("(n)") } } struct InternalClassChild: View { let n: Int @State var model = Model("internal class") var body: some View { Text("(n)") } } struct PrivateValueChild: View { let n: Int @State private var number = makeNumber("private value") var body: some View { Text("(n)") } } struct Parent: View { let n: Int var body: some View { VStack { PrivateClassChild(n: n) InternalClassChild(n: n) PrivateValueChild(n: n) } } } // 再作成のきっかけ 2:@Observable の値の変化で親の body を再評価させる @Observable final class Driver { var n = 0 } struct Root: View { let driver: Driver var body: some View { Parent(n: driver.n) } } @MainActor func host<V: View>(_ view: V) -> NSHostingView<V> { let host = NSHostingView(rootView: view) host.frame = NSRect(x: 0, y: 0, width: 200, height: 100) let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) window.contentView = host host.layoutSubtreeIfNeeded() RunLoop.main.run(until: Date().addingTimeInterval(0.05)) return host } @MainActor func report(_ trigger: String, _ times: Int) { let tags = ["private class", "internal class", "private value"] print("(trigger), recreated (times): " + tags.map { "($0) (counts[$0] ?? 0)" }.joined(separator: ", ")) counts = [:] } @main struct Probe { @MainActor static func main() { _ = NSApplication.shared for times in [5, 10] { // きっかけ 1:rootView を差し替える let h1 = host(Parent(n: 0)) for i in 1...times { h1.rootView = Parent(n: i) h1.layoutSubtreeIfNeeded() RunLoop.main.run(until: Date().addingTimeInterval(0.05)) } report("rootView", times) // きっかけ 2:@Observable の値を変える let driver = Driver() let h2 = host(Root(driver: driver)) for i in 1...times { driver.n = i RunLoop.main.run(until: Date().addingTimeInterval(0.05)) h2.layoutSubtreeIfNeeded() } report("observable", times) } } } 次のコマンドでビルドして実行します。 xcrun swiftc -parse-as-library -sdk "$(xcrun --sdk macosx --show-sdk-path)" Probe.swift -o probe ./probe 出力は次のとおりです。 Xcode 26.6 でビルド rootView, recreated 5: private class 6, internal class 6, private value 6 observable, recreated 5: private class 6, internal class 6, private value 6 rootView, recreated 10: private class 11, internal class 11, private value 11 observable, recreated 10: private class 11, internal class 11, private value 11 Xcode 27.1 でビルド rootView, recreated 5: private class 1, internal class 6, private value 1 observable, recreated 5: private class 1, internal class 6, private value 1 rootView, recreated 10: private class 1, internal class 11, private value 1 observable, recreated 10: private class 1, internal class 11, private value 1 付録 D:イニシャライザで代入した値の計測プログラム 第 2 章の「イニシャライザで代入した値は遅延評価されない」の計測には、次のプログラムを使いました。ビルドと実行の方法は付録 C と同じです。 計測プログラムの全体(Probe.swift) Probe.swift import SwiftUI import AppKit nonisolated(unsafe) var counts: [String: Int] = [:] @Observable final class Page { init(_ tag: String) { counts[tag, default: 0] += 1 } } // 宣言の初期値で作る(C の形) struct DefaultValue: View { let title: String @State private var page = Page("default value") var body: some View { Text(title) } } // 初期値を外して init で代入する(A の形。WWDC26 セッション 269 の修正例と同じ形) struct InitAssign: View { let title: String @State private var page: Page init(title: String) { self.title = title; self.page = Page("init assign") } var body: some View { Text(title) } } // init で _page = State(initialValue:) と書く struct InitUnderscore: View { let title: String @State private var page: Page init(title: String) { self.title = title; _page = State(initialValue: Page("init _page")) } var body: some View { Text(title) } } struct Parent: View { let n: Int var body: some View { VStack { DefaultValue(title: "(n)"); InitAssign(title: "(n)"); InitUnderscore(title: "(n)") } } } @main struct Probe { @MainActor static func main() { _ = NSApplication.shared let host = NSHostingView(rootView: Parent(n: 0)) host.frame = NSRect(x: 0, y: 0, width: 200, height: 100) let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) window.contentView = host host.layoutSubtreeIfNeeded() for i in 1...5 { host.rootView = Parent(n: i) host.layoutSubtreeIfNeeded() RunLoop.main.run(until: Date().addingTimeInterval(0.05)) } for (k, v) in counts.sorted(by: { $0.key < $1.key }) { print("parent recreated: 5 (k): Page.init (v)") } } } 出力は次のとおりです。 Xcode 26.6 でビルド parent recreated: 5 default value: Page.init 6 parent recreated: 5 init _page: Page.init 6 parent recreated: 5 init assign: Page.init 6 Xcode 27.1 でビルド parent recreated: 5 default value: Page.init 1 parent recreated: 5 init _page: Page.init 6 parent recreated: 5 init assign: Page.init 6 付録 E:外部から値を注入する書き方の計測プログラム 第 5 章の計測には、次のプログラムを使いました。親が子に渡す文字列 title を "v0" から "v5" まで変え、書き方ごとに、子 View の body が最後に読んだ値と Page の生成回数を出力します。5 つめのケースは、親が Page を作って子に渡す形です。ビルドと実行の方法は付録 C と同じです。 計測プログラムの全体(Probe.swift) Probe.swift import SwiftUI import AppKit nonisolated(unsafe) var counts: [String: Int] = [:] nonisolated(unsafe) var lastSeen: [String: String] = [:] @Observable final class Page { let title: String init(_ tag: String, title: String) { self.title = title; counts[tag, default: 0] += 1 } } // 1. init で @State に代入(値型) struct InitValue: View { @State private var title: String init(title: String) { self.title = title } var body: some View { lastSeen["1 init value"] = title; return Text(title) } } // 2. 親の値をそのまま let で受け取る struct LetValue: View { let title: String var body: some View { lastSeen["2 let value"] = title; return Text(title) } } // 3. Optional の @State を .task で 1 回だけ作る struct TaskOnce: View { let title: String @State private var page: Page? var body: some View { lastSeen["3 task once"] = page?.title ?? "nil" return Text(page?.title ?? "").task { if page == nil { page = Page("3 task once", title: title) } } } } // 4. Optional の @State を .task(id:) で、値が変わったときに作り直す struct TaskID: View { let title: String @State private var page: Page? var body: some View { lastSeen["4 task id"] = page?.title ?? "nil" return Text(page?.title ?? "").task(id: title) { page = Page("4 task id", title: title) } } } // 5. 生成を親に移す:親が宣言の初期値で作り、子には let で渡す struct OwnerParent: View { @State private var page = Page("5 parent owns", title: "owned") var body: some View { OwnedChild(page: page) } } struct OwnedChild: View { let page: Page var body: some View { lastSeen["5 parent owns"] = page.title; return Text(page.title) } } struct Parent: View { let n: Int var body: some View { VStack { InitValue(title: "v(n)"); LetValue(title: "v(n)"); TaskOnce(title: "v(n)"); TaskID(title: "v(n)"); OwnerParent() } } } @main struct Probe { @MainActor static func main() { _ = NSApplication.shared let host = NSHostingView(rootView: Parent(n: 0)) host.frame = NSRect(x: 0, y: 0, width: 200, height: 200) let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) window.contentView = host host.layoutSubtreeIfNeeded() RunLoop.main.run(until: Date().addingTimeInterval(0.2)) for i in 1...5 { host.rootView = Parent(n: i) host.layoutSubtreeIfNeeded() RunLoop.main.run(until: Date().addingTimeInterval(0.2)) } print("parent recreated: 5 (last title passed: v5)") for k in lastSeen.keys.sorted() { print("(k): body sees (lastSeen[k]!) Page.init (counts[k] ?? 0)") } } } 出力は次のとおりです。5 つめのケースだけ、Xcode 26.6 と 27.1 で結果が異なります。 Xcode 26.6 でビルド parent recreated: 5 (last title passed: v5) 1 init value: body sees v0 Page.init 0 2 let value: body sees v5 Page.init 0 3 task once: body sees v0 Page.init 1 4 task id: body sees v5 Page.init 6 5 parent owns: body sees owned Page.init 6 Xcode 27.1 でビルド parent recreated: 5 (last title passed: v5) 1 init value: body sees v0 Page.init 0 2 let value: body sees v5 Page.init 0 3 task once: body sees v0 Page.init 1 4 task id: body sees v5 Page.init 6 5 parent owns: body sees owned Page.init 1 参考資料 What's new in SwiftUI(WWDC26 セッション 269) developer.apple.com 「Data flow and performance」の章(19:58 から)で、@State のマクロ化と遅延評価、iOS 17 への対応、コンパイルエラーの修正方法を説明している Platforms State of the Union(WWDC26 セッション 102) developer.apple.com SwiftUI の速度に関する部分で、@State の遅延評価に触れている TN3211: Resolving SwiftUI source incompatibilities for State and ContentBuilder(Apple、2026-06-08 公開) developer.apple.com Xcode に含まれるエージェント向けスキル(Apple、Xcode 27.1、Build 27A9275) swiftui-specialist/references/dataflow.md の節「View-local state with @State」 @State を常に private にするよう指示している。 swiftui-whats-new-27/references/state-macro.md TN3211 の @State の節と同じ 3 つの問題を扱う。 初期化の順番の例だけは、宣言に初期値 = 0 がある形で示されている。 取得方法は「スキルの取得方法」に記載した。 SE-0400: Init Accessors github.com/swiftlang/swift-evolution/blob/main/pr… WWDC26: The @LazyState Macro(Point-Free、Apple 以外の情報源) pointfree.co Beta Preview: LazyState(Point-Free、Apple 以外の情報源) pointfree.co @LazyState は、Point-Free の会員向けに非公開リポジトリで配布されているベータ版である(2026-10-24 時点) スキルの取得方法 Xcode 27 に含まれるスキルは、次のコマンドで指定したディレクトリに書き出せます。このコマンドは、起動中の Xcode に接続して書き出します。筆者は Xcode 27.1 を起動した状態で実行しました。 xcrun agent skills export ~/xcode-skills 接続先は、xcode-select で選ばれている Xcode です。複数の Xcode を起動している場合は、環境変数 MCP_XCODE_PID に対象の Xcode のプロセス ID を指定します。 スキルの内容は Xcode の版によって変わります。引用するときは、Xcode の版とビルド番号を明記してください。 この記事の作成について この記事の調査・計測・執筆には、Anthropic の Claude(Claude Code)を補助として使いました。記事の構成と主張は筆者が決め、計測結果と内容は筆者が確認しています。
SE-0556 separates two low-level needs: Cell for aliased storage and Volatile for required memory accesses. Volatile is not a lock or an atomic operation. Still under review. #SwiftLang github.com/swiftlang/swift-evolution/blob/main/pr…
@available(macOS 15.0, *) @available(MyLib_v1_0, *) public func api() {} The above Swift snippet may work for normal build. But will break the swiftinterface for binary integration. Fix PR: swift#93418
Что нового в Swift: сентябрь 2026 Что нового в Swift: сентябрь 2026 года Добро пожаловать в «Что нового в Swift» — кураторский дайджест релизов, видео и обсуждений в проекте Swift и сообществе. Веб-фреймворк Vapor недавно отметил десятилетие и отпраздновал это Vapor Week! Мы пригласили одного из авторов Vapor стать гостевым автором этого выпуска: Привет, я Тим из основной команды Vapor! 10 лет назад, в сентябре, был выпущен Vapor 1.0, и за эти годы Vapor превратился в комплексный фреймворк для создания бэкендов и API на Swift. К годовщине мы выпустили первую бету Vapor 5 — следующую мажорную версию Vapor, которая представляет собой полную переработку, устраняющую 6 лет технического долга, добавляющую полную поддержку структурированной конкурентности и (наконец-то!) прощающуюся с EventLoopFutures. С самого начала цели Vapor не изменились: выразительный, простой в использовании и мощный фреймворк для создания серверных приложений на Swift. С Vapor 5 мы можем использовать новый Swift HTTP Server для обработки фактической HTTP-части и сосредоточиться на том, чтобы быть отличным веб-фреймворком, используя новейшие возможности Swift там, где это имеет смысл. Удивительно видеть круг людей, использующих Vapor, от независимых разработчиков до крупных компаний. Полную историю, включая обновлённую, локализованную и более доступную документацию, можно найти в Vapor Week. Если вам интересен серверный Swift, попробуйте бету Vapor 5 и расскажите нам, что вы думаете! А теперь к другим новостям о Swift: Релиз Swift 6.4 В сентябре главной новостью проекта стал релиз Swift 6.4, который приносит более глубокую интероперабельность, более сильную поддержку платформ и более простой повседневный код. Ключевые моменты включают Swift Build в качестве нового выбора по умолчанию в Swift Package Manager, достижение версии 1.0 для Subprocess и ускорение моста WebAssembly с JavaScriptKit до 40 раз. В недели, предшествовавшие релизу, мы также поделились двумя глубокими погружениями в некоторые его возможности: Отслеживание модулей в отладочной информации Swift объясняет, как точные, основанные на путях импорты модулей делают поиск в LLDB более надёжным и уменьшают размер dSYM-файлов и бинарников. Мейнтейнерам систем сборки (Bazel, Buck, CMake) стоит заменить -modulewrap/-add_ast_path на -debug-module-path. Улучшения Embedded Swift, грядущие в Swift 6.4 подводит итоги того, что ожидается для Swift на микроконтроллерах и других ограниченных средах. Основные события сообщества Google выпустил новый SDK: официальные клиентские библиотеки Google Cloud для Swift. Построенный на SwiftNIO и Swift concurrency, он предоставляет бэкенд-сервисам и CLI-инструментам доступ через async/await к Cloud Storage, IAM, Secret Manager, Gemini и более чем 100 другим сервисам Google Cloud на Linux и macOS. Любопытно, что нужно для запуска Swift вообще без ОС? Минимальное ядро на Swift, работающее в QEMU, использует Embedded Swift и атрибут @c для загрузки ядра, которое печатает сообщение и эхо-выводит введённые символы. Solbach Leads поделились своей историей внедрения Swift, включая то, как они перенесли производственный конвейер данных AI с Kotlin и Spring Boot на Swift и Vapor. Спустя шесть месяцев их конвейер обрабатывает более 52 миллионов задач в месяц. Хватит спать: Детерминированные тесты для конкурентного кода на Swift показывает, как заменить Task.sleep в тестах на тестовые шпионы, чтобы тесты могли проверять тайм-ауты, ошибки и отмену, не дожидаясь часов. Вышел ежемесячный отчёт Swift for Wasm. Обновления за сентябрь 2026 освещают экспериментальную поддержку потоков в Wasm Swift SDK (в настоящее время в ночных снапшотах), новую опцию uWASI в JavaScriptKit и пользовательские элементы в ElementaryUI. Новые релизы пакетов WasmKit — это среда выполнения WebAssembly, написанная на Swift. Релиз 0.4 удваивает скорость интерпретатора, и теперь он может работать на микроконтроллерах ESP32-C6 и Playdate. Был выпущен Swift AWS Lambda Runtime 3.0, добавляющий плагины SwiftPM для инициализации, сборки и развёртывания. Плагин сборки теперь может упаковывать в zip или OCI-образ, и вы можете выбрать способ компиляции: с помощью Docker, Apple’s container или кросс-компиляции с использованием Swift Static Linux SDK. Swift Evolution Проект Swift добавляет новые языковые возможности через процесс Swift Evolution. Вот некоторые из предложений, находящихся на рассмотрении или недавно принятых для будущего релиза Swift. На активном рассмотрении: SE-0554 Условная компиляция по целевому значению развёртывания — Swift может проверять, доступен ли API во время выполнения с помощью if #available(...), но это не может выбирать между импортами, псевдонимами типов, соответствиями, хранимыми свойствами или полными объявлениями; этот выбор должен быть сделан во время компиляции модуля. Это предложение добавляет #if deploymentTargetAtLeast(...), чтобы библиотека могла компилировать различный исходный код для разных минимальных версий ОС без поддержки отдельного флага системы сборки. Недавно приняты: SE-0546 Расширения memberwise-инициализатора в том же файле — когда memberwise-инициализатор структуры должен быть public, его можно определить только в её базовом объявлении; определение в расширении является ошибкой повторного объявления, и для структур, сгенерированных макросами, нет способа сделать их memberwise-инициализаторы публичными. Это предложение позволяет определять memberwise-инициализаторы и default init() в расширениях в том же файле. SE-0548 resignRemoteID для ссылок на удалённых распределённых акторов — сегодня DistributedActorSystem наблюдает за жизненным циклом локальных распределённых акторов через assignID(_:) / resignID(_:) и участвует в создании удалённых ссылок через resolve(id:as:), но нет способа наблюдать, когда удалённая ссылка была деинициализирована. Это предложение добавляет resignRemoteID(_:), вызываемый при деинициализации прокси удалённого распределённого актора, чтобы системы могли поддерживать соединения живыми только пока хотя бы одна удалённая ссылка всё ещё использует их. ST-0029 Включение дополнительных метаданных о проблеме в поток событий — такие инструменты, как Xcode и VS Code, потребляют поток JSON-событий Swift Testing, но структурированной информации недостаточно, чтобы различать разные виды проблем, например, выброшенную ошибку и вызов Issue.record. Это предложение добавляет новые поля в тип события проблемы, включая error, confirmation miscount, exceeded time limit и expression, чтобы инструменты могли отображать более богатый контекст сбоя. Недавно приняты с изменениями: SE-0540 Настройки цели по умолчанию (рассматривалось как «Настройки пакета по умолчанию») — очень часто пакеты Swift используют одни и те же флаги настроек для всех своих целей, что сегодня означает их дублирование для каждой цели или изменение package.targets после определения пакета. Это предложение позволяет определять настройки по умолчанию как часть инициализатора Package (defaultSwiftSettings, defaultCSettings, defaultCXXSettings и defaultLinkerSettings) с заполнителем .defaults, который управляет тем, как каждая цель наследует их. Авторы Тим Кондон входит в SSWG (Swift Server Workgroup) и является частью основной команды Vapor. Дэйв Лестер — старший менеджер по продукту в Apple и член рабочей группы сайта Swift.
SE-0540 is now finally merged! This was a fun one to work on and not as hard as I feared going in. Not quite sure if it will make it into Swift 6.5, and may be a while before it's practical to adopt anyways. But cleaning up all those settings in package files will be very satisfying.