Pull to refresh

Списки захвата в Swift: в чём разница между ссылками weak, strong и unowned?

Reading time7 min
Views44K
Original author: Paul Hudson

Джозеф Райт, «Пленный» — иллюстрация «сильного» захвата

Список «захваченных» значений находится перед списком параметров замыкания и может «захватить» значения из области видимости тремя разными способами: используя ссылки «strong», «weak» или «unowned». Мы часто его используем, главным образом для того, чтобы избежать циклов сильных ссылок («strong reference cycles» aka «retain cycles»).
Начинающему разработчику бывает сложно принять решение, какой именно применить способ, так что вы можете потратить много времени, выбирая между «strong» и «weak» или между «weak» и «unowned», но, со временем, вы поймёте, что правильный выбор — только один.

Для начала создадим простой класс:

class Singer {
    func playSong() {
        print("Shake it off!")
    }
}

Затем напишем функцию, которая создаёт экземпляр класса Singer и возвращает замыкание, которое вызывает метод playSong() класса Singer:

func sing() -> () -> Void {
    let taylor = Singer()

    let singing = {
        taylor.playSong()
        return
    }

    return singing
}

Наконец, мы можем где угодно вызвать sing(), чтобы получить результат выполнения playSong()

let singFunction = sing()
singFunction()


В результате будет выведена строка «Shake it off!».

«Сильный» захват (strong capturing)


До тех пор, пока вы явно не указываете способ захвата, Swift использует «сильный» захват. Это означает, что замыкание захватывает используемые внешние значения и никогда не позволит им освободиться.

Давайте опять взглянем на функцию sing()

func sing() -> () -> Void {
    let taylor = Singer()

    let singing = {
        taylor.playSong()
        return
    }

    return singing
}

Константа taylor определена внутри функции, так что при обычных обстоятельствах занимаемое ей место было бы освобождено как только функция закончила свою работу. Однако эта константа используется внутри замыкания, что означает, что Swift автоматически обеспечит её присутствие до тех пор, пока существует само замыкание, даже после окончания работы функции.
Это «сильный» захват в действии. Если бы Swift позволил освободить taylor, то вызов замыкания был бы небезопасен — его метод taylor.playSong() больше невалиден.

«Слабый» захват (weak capturing)


Swift позволяет нам создать "список захвата", чтобы определить, каким именно образом захватываются используемые значения. Альтернативой «сильному» захвату является «слабый» и его применение приводит к следующим последствиям:

1. «Слабо» захваченные значения не удерживаются замыканием и, таким образом, они могут быть освобождены и установлены в nil.

2. Как следствие первого пункта, «слабо» захваченные значения в Swift всегда optional.
Мы модифицируем наш пример с использованием «слабого» захвата и сразу же увидим разницу.

func sing() -> () -> Void {
    let taylor = Singer()

    let singing = { [weak taylor] in
        taylor?.playSong()
        return
    }

    return singing
}

[weak taylor] — это и есть наш "список захвата", специальная часть синтаксиса замыкания, в которой мы даём инструкции о том, каким именно образом должны быть захвачены значения. Здесь мы говорим, что taylor должен быть захвачен «слабо», поэтому нам необходимо использовать taylor?.playSong() – теперь это optional, потому что может быть установлен в nil в любой момент.

Если вы теперь выполните этот код, вы увидите, что вызов singFunction() больше не приводит к выводу сообщения. Причина этого в том, что taylor существует только внутри sing(), а замыкание, возвращаемое этой функцией, не удерживает taylor «сильно» внутри себя.

Теперь попробуйте изменить taylor?.playSong() на taylor!.playSong(). Это приведёт к принудительной распаковке taylor внутри замыкания, и, соответственно, к фатальной ошибке (распаковка содержимого, содержащего nil)

«Бесхозный» захват (unowned capturing)


Альтернативой «слабому» захвату является «бесхозный».

func sing() -> () -> Void {
    let taylor = Singer()

    let singing = { [unowned taylor] in
        taylor.playSong()
        return
    }

    return singing
}

Этот код закончится аварийно схожим образом с принудительно развернутым optional, приведенным несколько выше — unowned taylor говорит: «Я знаю наверняка, что taylor будет существовать все время жизни замыкания, так что мне не нужно удерживать его в памяти». На самом деле taylor будет освобождён практически немедленно и этот код закончится аварийно.

Так что используйте unowned крайне осторожно.

Частые возможные проблемы


Есть четыре проблемы, с которыми сталкиваются разработчики при использования захвата значений в замыканиях:

1. Сложности с расположением списка захвата в случае, когда замыкание принимает параметры


Это общее затруднение, с которым можно столкнуться в начале изучения замыканий, но, к счастью, в этом случае нам поможет Swift.

При использовании совместно списка захвата и параметров замыкания сначала идет список захвата в квадратных скобках, затем параметры замыкания, затем ключевое слово in, отмечающее начало «тела» замыкания.

writeToLog { [weak self] user, message in 
    self?.addToLog("\(user) triggered event: \(message)")
}

Попытка поместить список захвата после параметров замыкания приведёт к ошибке компиляции.

2. Возникновение цикла сильных ссылок, приводящее к утечке памяти


Когда сущность A обладает сущностью B и наоборот — у вас ситуация, называемая циклом сильных ссылок («retain cycle»).

В качестве примера рассмотрим код:

class House {
    var ownerDetails: (() -> Void)?

    func printDetails() {
        print("This is a great house.")
    }

    deinit {
        print("I'm being demolished!")
    }
}

Мы определили класс House, который содержит одно свойство (замыкание), один метод и деинициалайзер, который выведет сообщение при уничтожении экземпляра класса.

Теперь создадим класс Owner, аналогичный предыдущему, за исключением того, что его свойство-замыкание содержит информацию о доме.

class Owner {
    var houseDetails: (() -> Void)?

    func printDetails() {
        print("I own a house.")
    }

    deinit {
        print("I'm dying!")
    }
}

Теперь создадим экземпляры этих классов внутри блока do. Нам не нужен блок catch, но использование блока do обеспечит уничтожение экземпляров сразу после }

print("Creating a house and an owner")

do {
    let house = House()
    let owner = Owner()
}

print("Done")

В результате будут выведены сообщения: “Creating a house and an owner”, “I’m dying!”, “I'm being demolished!”, затем “Done” – всё работает, как надо.

Теперь создадим цикл сильных ссылок.

print("Creating a house and an owner")

do {
    let house = House()
    let owner = Owner()
    house.ownerDetails = owner.printDetails
    owner.houseDetails = house.printDetails
}

print("Done")

Теперь появятся сообщения “Creating a house and an owner”, затем “Done”. Деинициалайзеры не будут вызваны.

Это произошло в результате того, что у дома есть свойство, которое указывает на владельца, а у владельца есть свойство, указывающее на дом. Поэтому ни один из них не может быть безопасно освобождён. В реальной ситуации это приводит к утечкам памяти, которые приводят к снижению производительности и даже к аварийному завершению приложения.

Чтобы исправить ситуацию, нам нужно создать новое замыкание и использовать «слабый» захват в одном или двух случаях, вот так:

print("Creating a house and an owner")

do {
    let house = House()
    let owner = Owner()
    house.ownerDetails = { [weak owner] in owner?.printDetails() }
    owner.houseDetails = { [weak house] in house?.printDetails() }
}

print("Done")

Нет необходимости объявлять оба значения захваченным, достаточно сделать и в одном месте — это позволит Swift уничтожить оба класса, когда это необходимо.

В реальных проектах редко возникает ситуация столь очевидного цикла сильных ссылок, но это тем более говорит о важности использования «слабого» захвата при грамотной разработке.

3. Непредумышленное использование «сильных» ссылок, обычно при захвате нескольких значений


Swift по умолчанию использует «сильный» захват, что может приводить к непредусмотренному поведению.
Рассмотрим следующий код:

func sing() -> () -> Void {
    let taylor = Singer()
    let adele = Singer()

    let singing = { [unowned taylor, adele] in
        taylor.playSong()
        adele.playSong()
        return
    }

    return singing
}

Теперь у нас два значения захвачены замыканием, и оба их мы используем одинаковым образом. Однако, только taylor захвачен как unowned – adele захвачена сильно, потому что ключевое слово unowned должно использоваться для каждого захватываемого значения.

Если вы сделали это намеренно, то всё в порядке, но, если вы хотите, чтобы оба значения были захвачены "unowned", вам нужно следующее:

[unowned taylor, unowned adele]

4. Копирование замыканий и разделение захваченных значений


Последний случай, на котором спотыкаются разработчики, это то, каким образом замыкания копируются, потому что захваченные ими данные становятся доступными для всех копий замыкания.
Рассмотрим пример простого замыкания, которое захватывает целочисленную переменную numberOfLinesLogged, объявленную снаружи замыкания, так что мы можем увеличивать её значение и распечатывать его всякий раз при вызове замыкания:

var numberOfLinesLogged = 0

let logger1 = {
    numberOfLinesLogged += 1
    print("Lines logged: \(numberOfLinesLogged)")
}

logger1()

Это выведет сообщение “Lines logged: 1”.
Теперь мы создадим копию замыкания, которая разделит захваченные значения вместе с первым замыканием. Таким образом, вызываем мы оригинальное замыкание или его копию, мы увидим растущее значение переменной.

let logger2 = logger1
logger2()
logger1()
logger2()

Это выведет сообщения “Lines logged: 1”...“Lines logged: 4”, потому что logger1 и logger2 указывают на одну и туже захваченную переменную numberOfLinesLogged.

В каких случаях использовать «сильный» захват, «слабый» и «бесхозный»


Теперь, когда мы понимаем, как всё работает, попробуем подвести итог:

1. Если вы уверены, что захваченное значение никогда не станет nil при выполнении замыкания, вы можете использовать «unowned capturing». Это нечастая ситуация, когда использование «слабого» захвата может вызвать дополнительные сложности, даже при использовании внутри замыкания guard let к слабо захваченному значению.

2. Если у вас случай цикла сильных ссылок (сущность А владеет сущностью B, а сущность B владеет сущностью А), то в одном из случаев нужно использовать «слабый» захват («weak capturing»). Необходимо принять во внимание, какая из двух сущностей будет освобождена первой, так что если view controller A представляет view controller B, то view controller B может содержать «слабую» ссылку назад к «А».

3. Если возможность цикла сильных ссылок исключена, вы можете использовать «сильный» захват («strong capturing»). Например, выполнение анимации не приводит к блокированию self внутри замыкания, содержащего анимацию, так что вы можете использовать «сильное» связывание.

4. Если вы не уверены, начните со «слабого» связывания и измените его только в случае необходимости.

Дополнительно — официальное руководство по Swift:
Замыкания
Автоматический подсчёт ссылок
Tags:
Hubs:
Total votes 8: ↑8 and ↓0+8
Comments18

Articles