swiftlang

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars123
mentions40
05101520Oct 5Oct 19Nov 3
UTC days · repositories swiftlang owns6 repositories

Package downloads

No package download history available yet.

Top repositories

swiftlang’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside swiftlang’s account where swiftlang opened or reviewed pull requests in the past 365 days.

Who noticed swiftlang

Everyone who starred or forked swiftlang’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

39posts
Nov 40 posts
  1. SwiftPMに、DebugとReleaseで依存先を切り替える提案が出ています! 開発時だけ使う機能と、配布時に必要な構成を分けやすくする内容です。 現在はSE-0560としてレビュー中で、承認・リリースはまだです。 出典元:github.com/swiftlang/swift-evolution/blob/main/pr…

    14 likes · 5 reposts · 775 views
  2. От 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, а код экспериментов и результаты замеров оставил в репозитории с бенчмарками.

  3. 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.

  4. 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.

  5. 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)を補助として使いました。記事の構成と主張は筆者が決め、計測結果と内容は筆者が確認しています。

  6. 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
  7. @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
  8. Что нового в 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.

  9. 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
  10. Package.resolved の originHash は何のためにあるのか Renovate が依存を上げた PR をマージしたあと、tuist install を流すと Package.resolved の 1 行だけが書き換わるようになりました。 Package.resolved { - "originHash" : "b51d86e0d60dc9c07d7da15f1bb995c680b75f60f09f0ee586aea8f14852849d", - "originHash" : "9ea100c33d0673a7cbf638a61115d34ce142720e95d1761c390aa31ae6ec796b", "pins" : [ この originHash が何のための値なのかを、SwiftPM のソースを読み、実際に計測して確かめました。分かったことは次の 4 つです。 originHash は「この Package.resolved がどの Package.swift に対して作られたか」の目印で、改ざん検知の値ではない 一致していると、SwiftPM は依存のバージョンを選び直す計算を省ける。差が出るのは checkout が足りないとき(clone 直後や .build をキャッシュしない CI)で、今回の環境(pin 60 個)では約 20 秒だった SwiftPM 自身は、pin が変わらない限り originHash を書き直さない。だから古いまま残りやすい 実際に解決しないまま originHash だけを今の値にするのは危ない。Tuist は一致した hash を信じて推移的な依存の要求を確かめず、要求を満たさない pin をそのまま使う(再現して確かめた) 後半では、依存を自動で上げる Renovate と Dependabot で、この値の扱いがどう違うかも比べます。調べる途中で見立てを 3 回間違えたので、その話も最後に書きます。 ! SwiftPM のコードは 2026 年 10 月時点の main(7137197)、計測は macOS・Xcode 27.2(Swift 6.4.2)で行いました。 originHash はルートの manifest の SHA-256 originHash は、Package.resolved の書式のバージョン 3 で入った値です。SwiftPM は、ルートの Package.swift の tools-version が 5.10 以上のときにこの書式で書き出します。 中身は、SwiftPM の computeResolvedFileOriginHash を読むと分かります。 Sources/Workspace/Workspace+Dependencies.swift private func computeResolvedFileOriginHash(root: PackageGraphRootInput) throws -> String { var content = try root.packages.reduce(into: "") { partial, element in let path = try ManifestLoader.findManifest( packagePath: element, fileSystem: self.fileSystem, currentToolsVersion: self.currentToolsVersion ) try partial.append(self.fileSystem.readFileContents(path)) } content += root.dependencies.reduce(into: "") { partial, element in partial += element.locationString } return content.sha256Checksum } ルートパッケージの manifest のファイル内容をそのままつなげ、ルートに追加の依存があればその URL を足して、SHA-256 をとっています。ふつうのパッケージでは追加の依存は無いので、Package.swift のファイル内容の SHA-256 そのものになります。手元でも確かめられます。 $ grep originHash Package.resolved "originHash" : "9ea100c33d0673a7cbf638a61115d34ce142720e95d1761c390aa31ae6ec796b", $ shasum -a 256 Package.swift 9ea100c33d0673a7cbf638a61115d34ce142720e95d1761c390aa31ae6ec796b Package.swift ここから 3 つのことが言えます。 pin(各依存のバージョンとコミット)は計算に入っていない。Package.resolved の中身が書き換えられても、originHash では気づけない コメントを 1 文字足しただけでも値が変わる Package@swift-6.0.swift のような Swift のバージョンごとの manifest があると、使っている toolchain が選んだファイルの hash になる 一致すると、SwiftPM はバージョンの選び直しを省く originHash を入れた SwiftPM の PR(swiftlang/swift-package-manager#6698、2023 年 8 月)には、狙いがこう書かれています。 when a package.resolved file exists the dependencies resolution should stick to it, unless requested otherwise 実装では、swift package resolve やビルドが使う標準の解決(コード上は .bestEffort)の中で、この hash を比べて分岐しています(抜粋して簡略化しています)。 let currentHash = try self.computeResolvedFileOriginHash(root: root) guard storedHash == currentHash else { // 'Package.resolved' origin hash does do not match manifest dependencies. resolving and updating accordingly return try await resolveAndUpdateResolvedFile(forceResolution: false) } // 'Package.resolved' origin hash matches manifest dependencies, attempting resolution based on this file let (manifests, precomputationResult) = try await self.tryResolveBasedOnResolvedVersionsFile(...) コメントにしたメッセージは、--very-verbose を付けると実際にログに出ます(「does do not」は原文のままです)。 originHash 通る経路 やること 一致 tryResolveBasedOnResolvedVersionsFile pin のコミットで checkout し、pin が今の要求を満たすかを確かめる 不一致 resolveAndUpdateResolvedFile 足りない checkout があれば resolver を走らせる(今の pin を優先して選ぶ)。揃っていれば pin が要求を満たすかを確かめるだけ どちらの経路でも、pin が今の要求を満たすかは確かめられ(precomputeResolution か resolver)、満たさなければ解決し直します。今の pin が要求を満たしていれば、どちらの経路でもその pin がそのまま使われます。違うのは、そこにたどり着くまでの手間です。後で書く計測でも、hash が一致していてもずれていても、最終的な pin は同じでした。 なお、Package.resolved を正として扱う --force-resolved-versions と、swift package update は originHash を読みません。update は値を計算して書き込むだけです。 差が出るのは checkout が足りないとき 手間の違いがどれくらいかを、仕事で開発しているアプリの依存で測りました。Package.swift に直接書いている依存が 25 個、推移的な依存も含めた pin が 60 個あり、すべて git のリポジトリです。 まず、.build に checkout がある状態です。5 回ずつ流しました。 originHash swift package resolve 一致 0.46〜0.51 秒 古い 0.43〜0.51 秒 差はありませんでした。前の節の分岐のとおり、checkout が揃っていれば、hash がずれていても resolver は走らないからです。checkout が揃った手元の開発では、originHash が古くても遅くなりません。 次に、.build を空にした状態です。git のリポジトリは SwiftPM のキャッシュ(--cache-path)に入っているので、clone の時間はほぼ含みません。順番を入れ替えて 2 回ずつ流しました。 originHash 1 巡目 2 巡目 ログの「Computing version for」 一致 66 秒 29 秒 0 件 古い 87 秒 53 秒 60 件 一致していると、SwiftPM は pin のコミットでそのまま作業コピーを作ります。古いと resolver が走り、60 パッケージすべてについて、git のタグの一覧と、候補になるバージョンの Package.swift を読みます。ログの「Computing version for」がその作業で、これが 2 回とも約 20 秒の差になりました。clone 直後の手元や、.build をキャッシュしない CI は、毎回この分だけ遅くなります。 Tuist ではもっと差が開きます。Tuist の依存解決(SwifterPM)は、hash が一致すると swift package resolve を起動せず、自前のキャッシュから戻すだけで済ませるからです。.build が空の状態で 1 回ずつ測ると、一致で 7.4 秒、古いと 134 秒でした。 ! ネットワークから clone し直す場合は測っていません。その場合は clone の時間が大きく、差の割合は小さくなると考えています。 計測の手順と条件 コマンドは swift package --package-path <dir> --scratch-path <dir>/.build --cache-path <cache> --disable-scm-to-registry-transformation resolve で、時間は /usr/bin/time -p で測りました。 hash を一致させるときは、Package.resolved の originHash を shasum -a 256 Package.swift の値に書き換えました。古い hash は、Package.swift を変える前の値です。pin はどちらも同じです。 checkout がある状態は、同じ .build で続けて 5 回ずつ流しました。 checkout が無い状態は、毎回新しいディレクトリを作り、--scratch-path を空にしました。--cache-path は共通なので、git のリポジトリはキャッシュから取ります。1 巡目は一致→古い、2 巡目は古い→一致の順です。 Tuist の数字は、Tuist の依存解決部分を swifterpm コマンドとしてビルドし、空の --scratch-path で 1 回ずつ測りました。 回数が少なく、同じ条件でも 29 秒と 66 秒のように大きくぶれています。絶対値はマシンや負荷で変わるので、2 巡とも約 20 秒の差が出た、という点だけを読み取ってください。 SwiftPM は pin が変わらないと originHash を書き直さない これだけ効く値なのに、古いまま残りやすい理由は、SwiftPM の保存処理にあります。saveResolvedFile は、保存済みの pin と今の pin を比べて、同じなら何もせずに抜けます(抜粋です。比べている部分は日本語のコメントに置き換えました)。 Sources/Workspace/Workspace+ResolvedPackages.swift // try to load the Package.resolved store from disk so we can compare for any changes // this is needed as we want to avoid re-writing the resolved files unless absolutely necessary var needsUpdate = false if let persistedResolvedPackagesStore = try? self.resolvedPackagesStore.load() { // pin の数と、各 pin の状態を比べる(originHash は比べない) } // exist early is there is nothing to do if !needsUpdate { return } 比べているのは pin だけなので、Package.swift を変えても pin が変わらなければ、originHash は前の値のまま残ります。小さなパッケージで再現できます。 mkdir -p repro/Sources/Repro && cd repro cat > Package.swift <<'EOF' // swift-tools-version: 6.0 import PackageDescription let package = Package( name: "Repro", dependencies: [ .package(url: "apple/swift-algorithms", from: "1.1.0"), ], targets: [ .target(name: "Repro", dependencies: [.product(name: "Algorithms", package: "swift-algorithms")]), ] ) EOF echo 'import Algorithms' > Sources/Repro/Repro.swift swift package resolve grep originHash Package.resolved # shasum -a 256 Package.swift と同じ値 要求だけを変える(今の pin 1.2.1 はどちらでも満たす) sed -i '' 's/from: "1.1.0"/from: "1.2.0"/' Package.swift swift package resolve --very-verbose 2>&1 | grep "origin hash" debug: 'Package.resolved' origin hash does do not match manifest dependencies. resolving and updating accordingly grep originHash Package.resolved # 前の値のまま。何回流しても変わらない swift package update でも、pin が変わらなければ書き直しません。同じ不満は SwiftPM の Issue(#7644「V3 originHash should update more eagerly」、2024 年 6 月)にも出ていて、まだ直っていません。 依存を自動で上げるツールを使っていると、この状態にすぐなります。Renovate の Swift 対応は、Package.swift の要求を上げるときに Package.resolved の該当する pin も書き換えます。ただし originHash には触りません。このことは renovatebot/renovate#41534 に既知の制約として書かれています。マージした後に SwiftPM が解決しても、pin はすでに新しいので、originHash は古いまま残ります。 Dependabot は事情が違います。Dependabot の Swift 対応は、書き換えた Package.swift を作業用のディレクトリに置き、swift package resolve <依存> --version <バージョン> を実際に走らせて、その結果の Package.resolved を commit します(lockfile_updater.rb)。originHash も SwiftPM が計算するので、正しい値になります。以前 Dependabot を使っていたときの commit を見返すと、Package.swift を書き換えた PR でも、originHash は Package.swift の SHA-256 と一致していました。 冒頭の 1 行の diff は、ここに Tuist が絡んで出ていました。Tuist は tuist install で解決するたびに、自分で計算した originHash でファイルを書き直します。SwiftPM と違って値を最新に保つので、Renovate の PR の後に古い値が見つかると、書き直して diff を出すわけです。 解決しないまま originHash だけを直してはいけない この値がただの SHA-256 なら、Renovate が Package.swift を書き換えるついでに計算すればよさそうに見えます。私も最初はそう考えて、Renovate に提案しました(後で取り下げています)。ところが、これは Tuist を使っていると危ない直し方です。 Tuist の依存解決は、hash が一致すると swift package resolve を起動せずに pin をそのまま使います。この速い経路で確かめているのはミラーとローカルパッケージの条件だけで、推移的な依存の要求は確かめません。 一方、Renovate が書き換えるのは、上げた依存の pin だけです。たとえば GoogleSignIn-iOS を 10.0.0 に上げたとします。10.0.0 は AppAuth-iOS に from: "3.0.0" を要求しますが、Package.resolved には AppAuth-iOS 2.1.0 の pin が残ります。実際に私たちのリポジトリでも、この状態の PR がマージされていました。 この状態で originHash だけを今の値にするとどうなるかを、小さな Tuist のプロジェクトで試しました。GoogleSignIn-iOS を from: "9.2.0" で入れた状態から、Renovate と同じように Package.swift の要求と GoogleSignIn-iOS の pin だけを 10.0.0 に書き換えました。そのうえで、Tuist 4.212.0 の tuist install を流しています。 originHash tuist install checkout された AppAuth-iOS GTMAppAuth 古いまま 29 秒、成功 3.0.0 6.0.0 今の値に書き換え 3 秒、成功 2.1.0 5.0.0 古いままだと、Tuist は SwiftPM に解決を任せ、SwiftPM が AppAuth-iOS を 3.0.0 に直しました。今の値にしてあると、Tuist は速い経路で済ませました。checkout されたのは、GoogleSignIn-iOS 10.0.0 の要求(from: "3.0.0")を満たさない AppAuth-iOS 2.1.0 で、エラーも出ませんでした。古い originHash が、ちょうど「確かめ直せ」という合図になっていたわけです。 SwiftPM 自身は、どちらの経路でも pin を確かめます。同じ Package.resolved を素の swift package resolve で解決すると、hash が一致していても(ログは「origin hash matches」)、AppAuth-iOS は 3.0.0 に直りました。 再現の手順 Tuist.swift と、Tuist/Package.swift に .package(url: "google/GoogleSignIn-iOS", from: "9.2.0") だけを書いたプロジェクトで tuist install する。GoogleSignIn-iOS 9.2.0、AppAuth-iOS 2.1.0、GTMAppAuth 5.0.0 が pin される 別のプロジェクトで from: "10.0.0" にして tuist install し、10.0.0 系の依存をキャッシュに入れておく 1 の Package.resolved の GoogleSignIn-iOS の pin だけを、2 のもの(10.0.0)に書き換える。Package.swift は from: "10.0.0" にする originHash を 1 の値のまま残したものと、shasum -a 256 Tuist/Package.swift の値に書き換えたものを用意し、それぞれ空の Tuist/.build で tuist install する 結果の Package.resolved と Tuist/.build/workspace-state.json で、checkout されたバージョンを確かめる 同じ更新を Dependabot がすると、SwiftPM が解決するので AppAuth-iOS も 3.0.0 に上がり、originHash も正しい値になります。pin と originHash を一緒に、実際の解決結果で書き換えるので、この問題は起きません。 ほかにも、自動で上げる PR がそれぞれ originHash を書き換えると、どの PR も Package.resolved の 2 行目を別の値に変えることになります。そのため、1 本マージするたびに残りの PR が conflict します。 originHash を変えてよいのは、その Package.swift で実際に解決して、pin と一緒に書き込むときだけです。 どう付き合うか 使っている道具ごとに、現実的な扱い方が違います。 SwiftPM と Xcode だけを使っている場合は、古い originHash でも結果は変わらず、checkout が無いときに遅くなるだけです。ただ、今の SwiftPM には、pin を変えずに originHash だけを更新するコマンドがありません。Package.resolved を消して作り直せば hash は新しくなりますが、範囲内の新しいバージョンに pin が上がることがあります。 Tuist を使っている場合は、tuist install が書き直した 1 行の diff を、そのまま commit するのがよいと考えています。見た目だけの差分に見えますが、これを入れておくと、clone 直後や CI の install が速い経路に戻ります。 Renovate と Dependabot の違い 依存を自動で上げているなら、どちらのツールかで扱いが変わります。 Renovate Dependabot 方式 SwiftPM を動かさず、pin の文字列を書き換える Linux の上で SwiftPM を実際に動かす originHash 古いまま残る SwiftPM が計算した正しい値になる 推移的な依存の pin 取り残される SwiftPM が揃える Linux でだけ使う依存 入らない 入る PR 同士の conflict originHash の行ではぶつからない Package.swift を変える PR 同士が originHash の行でぶつかる ※ Package.swift を持つパッケージを更新する場合の比較です。Xcode プロジェクトの Package.resolved については後で書きます。 Dependabot の方が Package.resolved を正しく保てますが、代わりに Linux で解決した結果になります。Dependabot は Ubuntu の上の Swift 6.3.1 で解決します(Dockerfile)。Linux で解決するので、Apple のプラットフォームでは使わない依存まで pin に入ることがあります。 私たちのアプリでは OpenCombine がそうでした。依存している combine-schedulers は、Darwin 以外でだけ OpenCombine を使う trait を既定で有効にしています(Package@swift-6.1.swift)。macOS で解決すると OpenCombine は pin に入りませんが、Dependabot の PR には入ってきて、手で消していました。この食い違いを避けるなら、使わない OpenCombine をあえてルートの依存に書いておく手もあります。macOS で解決しても pin に入るので、Linux の結果と揃います(target を持たない manifest で確かめました)。 Dependabot の Swift も、手元の Xcode より古くなりがちです。依存に Package@swift-6.4.swift のような新しい Swift 向けの manifest が増えると、Linux と手元で読むファイルが変わり、結果がずれる可能性があります。 なお、Package.swift を持たない Xcode プロジェクトの Package.resolved を書き換える経路(xcode_lockfile_updater.rb)は、SwiftPM を動かさず、originHash を扱うコードもありません。こちらは Renovate と同じく、pin だけを書き換えます。 どちらのツールでも、いちばんきれいなのは、その PR の中で macOS で実際に解決して、結果を commit することです。たとえば Renovate のブランチで macOS の CI に tuist install を流させ、書き換わった Package.resolved を bot が PR に commit・push します。こうすると originHash だけでなく、取り残された推移的な依存の pin も一緒に直り、Linux でだけ入る依存も混ざりません。ただし swift package resolve は pin が変わらなければ originHash を書き直さないので、Tuist を使わない場合は、この方法でも hash が古いまま残ることがあります。 SwiftPM 側の修正 根本を直すため、SwiftPM に PR(swiftlang/swift-package-manager#10718)を出しました。pin が要求を満たすと確かめた後で、originHash がずれていたら hash だけを書き直す変更です。入れば Xcode でも originHash が最新に保たれるようになります。ただし代わりに、Package.swift を変えた後に 1 行の diff が出るようになり、Package.swift を触ったブランチ同士はこの行でぶつかりやすくなります。checkout が無いときの解決の速さと conflict のどちらを取るか、というトレードオフです。 見立てを 3 回間違えた話 最初は「Tuist が余計な diff を出している」と思いました。調べると、Tuist の値は SwiftPM の定義どおりの SHA-256 で、古い値を残していたのは Renovate の側でした。 次に「Renovate が originHash を計算し直せばいい」と考えました。これは前の節のとおり、Tuist では壊れた pin を通してしまうので取り下げました。 最後に「SwiftPM と Xcode では古い originHash でも遅くならない。書き直して diff を出す Tuist の方を SwiftPM に合わせればいい」と考えて、Tuist に PR(tuist/tuist#14475)を出しました。根拠は checkout がある状態の計測だけでした。checkout が無い状態を測ってみると、Tuist の書き直しこそが速さを保っていたと分かり、PR は閉じました。 近道が効くのは、ふだんの手元ではなく clone 直後や CI です。性能を確かめるときは、効いてほしい状態で測るべきだった、というのがいちばんの反省です。 originHash は「近道を使ってよいか」の目印 originHash は、Package.resolved が今の Package.swift 用かどうかを SwiftPM に伝える目印です。値が正しければ、clone 直後や CI で依存のバージョンの選び直しを省けます。ただ、SwiftPM 自身は pin が変わるまで更新しないので、古くなりがちです(#7644)。 古い値に気づいたら、実際に解決した結果と一緒に commit する。解決しないまま値だけを書き換えない。この 2 つを守れば、diff に紛れ込むこの 1 行が、クリーンな環境での解決を速くしてくれます。依存を自動で上げるなら、Dependabot は実際に解決して書き換えるので前者に当たります。ただし Linux で解決した結果になる点には気をつけてください。

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.