Майже ручне керування ARC у Swift. Модифікатори володіння та гарантії часу життя

Майже ручне керування ARC у Swift. Модифікатори володіння та гарантії часу життя

Модифікатори володіння та гарантії часу життя у Swift

або

Опанування withExtendedLifetime і модифікаторів володіння параметрами (borrowing та consuming) у Swift

Будьмо чесними: в епоху клікбейтних заголовків доводиться вдаватися до різних хитрощів. «Майже ручне керування ARC у Swift. Модифікатори володіння та гарантії часу життя» звучить так, ніби ми відкриваємо секрет, який Apple приховувала з 2011 року. Але якщо завдяки цьому ви читаєте далі, мети досягнуто.😄

Swift продовжує розвиватися: нові можливості поліпшують керування пам’яттю, продуктивність і зрозумілість коду. Серед них withExtendedLifetime і модифікатори володіння параметрами (borrowing та consuming). Ці інструменти дають розробникам точніший контроль над часом життя об’єктів і семантикою володіння, допомагаючи писати безпечніший та ефективніший код. У цій статті ми розглянемо спрощену й повну сигнатури withExtendedLifetime, а також практичне застосування borrowing та consuming із прикладами й поясненнями.


Як працює withExtendedLifetime

Функція withExtendedLifetime гарантує, що переданий екземпляр не буде звільнено з пам’яті до завершення виконання замикання. Це особливо корисно, коли синхронна робота залежить від того, чи залишається ресурс живим, наприклад файл або мережеве з’єднання. Функція не чекає завершення асинхронної роботи, запланованої всередині замикання.

Спрощена сигнатура (псевдокод для демонстрації)

func withExtendedLifetime<T>(_ x: T, _ body: () -> Void)

Цю спрощену версію часто використовують, щоб пояснити принцип роботи. Вона приймає об’єкт x і замикання body та гарантує, що x залишатиметься живим до завершення виконання body.

Повна сигнатура (реальна реалізація)

Ось форма сигнатури withExtendedLifetime зі стандартної бібліотеки Swift 6:

public func withExtendedLifetime<T, E, Result>(
    _ x: borrowing T, 
    _ body: () throws(E) -> Result
) throws(E) -> Result 
where E : Error, T : ~Copyable, Result : ~Copyable

А ось її варіант із параметром, який передається в замикання:

public func withExtendedLifetime<T, E, Result>(
    _ x: borrowing T, 
    _ body: (borrowing T) throws(E) -> Result
) throws(E) -> Result 
where E : Error, T : ~Copyable, Result : ~Copyable

Ключові деталі:

  • borrowing T : Означає, що функція позичає значення, не перебираючи володіння ним.
  • throws(E) : Дозволяє функції викидати помилки типу E.
  • Result : Тип результату замикання, який також повертає withExtendedLifetime.
  • T : ~Copyable : Скасовує неявну вимогу Copyable для T, тож функція може приймати як копійовані, так і некопійовані типи. Це не вимагає, щоб тип був некопійованим.

Для простоти в прикладах ми використовуватимемо версію з псевдокоду, але повна сигнатура показує можливості й гнучкість withExtendedLifetime.

Практичний приклад: використання withExtendedLifetime

Розгляньмо ситуацію, коли ми створюємо тимчасовий ресурс і використовуємо його в асинхронній задачі. Слабке захоплення не утримує його в пам’яті, тож ресурс може бути звільнений до того, як задача його використає.

import Foundation
import Combine

final class Resource: Sendable {
    let name: String
    
    init(name: String) {
        self.name = name
        print("Resource '\(name)' created")
    }
    
    deinit {
        print("❌ Resource '\(name)' deallocated")
    }
    
    func use() {
        print("🎯 Using resource '\(name)'")
    }
}

func createResource() -> Resource {
    return Resource(name: "TemporaryResource")
}

func exampleWithoutExtendedLifetime() {
    // Create a temporary resource
    let resource = createResource()
    
    // Use the resource outside the block
    DispatchQueue.global().async { [weak resource] in
        print("Starting work with the resource")
        resource?.use()
        print("Finishing work with the resource")
    }
    
    print("Block completed")
}

exampleWithoutExtendedLifetime()

Один із можливих варіантів виводу без withExtendedLifetime:

Resource 'TemporaryResource' created
Block completed
❌ Resource 'TemporaryResource' deallocated
Starting work with the resource
Finishing work with the resource

У цьому варіанті виконання ресурс звільняється до того, як асинхронна задача його використає, тому повідомлення 🎯 Using resource 'TemporaryResource' відсутнє. Час виконання задачі не гарантований: під час іншого запуску вона може встигнути використати ресурс до його звільнення.

Тепер поєднаймо сильне захоплення з withExtendedLifetime:

func exampleWithExtendedLifetime() {
    // Create a temporary resource
    let resource = createResource()
    
    // Keep the resource alive while the queued work waits to run
    DispatchQueue.global().async { [resource] in
        // Extend its lifetime through the synchronous work below
        withExtendedLifetime(resource) {
            print("Starting work with the resource")
            resource.use()
            print("Finishing work with the resource")
        }
    }
    
    print("Block completed")
}

exampleWithExtendedLifetime()

Один із можливих варіантів виводу з withExtendedLifetime:

Resource 'TemporaryResource' created
Block completed
Starting work with the resource
🎯 Using resource 'TemporaryResource'
Finishing work with the resource
❌ Resource 'TemporaryResource' deallocated

Сильне захоплення утримує ресурс у пам’яті до виконання замикання в черзі. Усередині цього замикання withExtendedLifetime гарантує, що ресурс залишатиметься живим протягом усієї синхронної роботи, зокрема під час виведення останнього повідомлення в лог. Обгортання лише виклику, який планує задачу, не дає такої гарантії. Повідомлення «Block completed» може з’явитися в іншому місці, оскільки планування й виконання відбуваються паралельно.

Як працюють borrowing і consuming у моделі володіння Swift

Swift розвиває свою систему володіння, щоб підвищити безпеку та продуктивність. Два важливі поняття в цій сфері: borrowing і consuming для параметрів. У цій статті ми розберемо, що вони означають, як ними користуватися та що потрібно знати, аби вже сьогодні поекспериментувати з цими можливостями.

Що таке borrowing і consuming?

  • borrowing означає тимчасовий доступ до значення без перебирання володіння ним. Код, що викликає функцію, забезпечує час життя значення на час виклику. Це може усунути retain, потрібний для передавання володіння, але не гарантує, що тіло функції не виконуватиме операцій ARC або копіювання.

  • consuming означає перебирання володіння значенням. Некопійований аргумент не можна повторно використати після його споживання. Для копійованого аргументу Swift може передати копію або посилання з додатковим retain, тож код, що викликає функцію, зможе знову використати своє початкове значення.

Разом ці модифікатори допомагають писати безпечніший та ефективніший код Swift, особливо під час роботи з ресурсами, які не слід дублювати або використовувати неналежним чином.

Вимоги до версії Swift

Ці модифікатори володіння доступні у стабільній Swift 5.9 і новіших версіях. Повні сигнатури, наведені вище, також використовують типізовані помилки та узагальнення з некопійованими типами, які з’явилися у Swift 6.0. Перевірити актуальні підтримувані версії Swift та їхні можливості можна на офіційному сайті: swift.org/download; і, звісно, версію Swift на своєму комп’ютері можна перевірити командою термінала swift --version.

Уже використовуються в стандартній бібліотеці Swift

borrowing та consuming є стабільними можливостями мови; їх також використовують у стандартній бібліотеці Swift.

Наприклад, у тулчейнах Swift 6 сигнатура withExtendedLifetime має таку форму (обмеження узагальнених типів тут опущено):

public func withExtendedLifetime<T, E, Result>(
  _ x: borrowing T,
  _ body: (borrowing T) throws(E) -> Result
) throws(E) -> Result

Це показує, як borrowing застосовують у стандартній бібліотеці, щоб уникнути зайвих операцій ARC, тоді як функція гарантує час життя значення. Ви також можете використовувати borrowing і consuming у сигнатурах власних функцій без експериментальних прапорців.

Як використовувати їх у власному коді

Щоб писати власні функції з модифікаторами borrowing або consuming:

  1. Використовуйте стабільний тулчейн Swift 5.9 або новішої версії для модифікаторів параметрів і наведеного нижче прикладу з некопійованим Person.
  2. Використовуйте Swift 6.0 або новішої версії для наведених вище сигнатур із типізованими помилками та узагальненнями з некопійованими типами.

У попередніх розробницьких тулчейнах для узагальнень із некопійованими типами використовували такий прапорець:

-enable-experimental-feature NoncopyableGenerics

У стабільній Swift 6.0 і новіших версіях цей прапорець для цих можливостей не потрібний. Він був розробницьким прапорцем для узагальнень із некопійованими типами, а не вимогою для модифікаторів параметрів borrowing і consuming.

Докладніше про ці можливості можна прочитати в обговоренні за участю розробників Swift:

Спробуйте самі: Swift-пакет у стилі Playground

Щоб вам було простіше експериментувати, я створив проєкт Swift Package Manager (SPM) зі старішою конфігурацією прапорця, наведеною нижче. У стабільному тулчейні Swift 6.0 або новішої версії цей прапорець не потрібний. Його можна клонувати з мого репозиторію на GitHub

Цей проєкт налаштовано як легкий SPM-застосунок у стилі Playground із такою конфігурацією прапорця:

swiftSettings: [
  .unsafeFlags(["-enable-experimental-feature", "NoncopyableGenerics"])
]

Приклад коду та вивід у консолі

struct Person: ~Copyable {
    let name: String
}

// borrowing: use the value without taking ownership
func printPersonName(_ person: borrowing Person) {
    print("Hello, \(person.name)")
}

// consuming: we take the object, it can no longer be used afterwards
func sayGoodbye(_ person: consuming Person) {
    print("Goodbye, \(person.name)")
}

func test() {
    let person = Person(name: "John Doe")

    printPersonName(person)   // ✅ person remains available
    sayGoodbye(person)        // ✅ object is now considered "consumed"
    
    printPersonName(person)   // ❌ error: person has already been used
}

test()

Діагностика компілятора:

Цей приклад навмисно не компілюється. Формулювання діагностики може відрізнятися залежно від версії компілятора:

error: 'person' used after consume

Щоб виконати коректний варіант, видаліть останній виклик printPersonName після sayGoodbye. Тоді вивід у консолі буде таким:

Hello, John Doe
Goodbye, John Doe

Спроба використати person після sayGoodbye призводить до помилки компіляції ('person' used after consume), яка запобігає небезпечному повторному використанню переміщених даних.

Підсумок

borrowing та consuming є потужними модифікаторами в моделі володіння Swift, яка продовжує розвиватися. Вони допомагають забезпечити безпечну роботу з пам’яттю, керуючи тим, як значення використовуються й передаються. Для модифікаторів володіння й прикладу з некопійованим Person потрібна Swift 5.9 або новіша версія. Для наведених тут сигнатур із типізованими помилками та узагальненнями з некопійованими типами потрібна Swift 6.0 або новіша версія; експериментальні прапорці не потрібні.

Напишіть мені, якщо хочете, щоб я додав більше прикладів або допоміг усе налаштувати!

Додаткові матеріали

https://github.com/nikolay-dementiev/OwnershipDemo_Mastering_with_extended_lifetime_and_parameter_ownership_modifiers

https://developer.apple.com/documentation/swift/withextendedlifetime%28_%3A_%3A%29-59dz3

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0377-parameter-ownership-modifiers.md

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0413-typed-throws.md

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0427-noncopyable-generics.md

Востаннє оновлено

На цій сторінці