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

Список «захваченных» значений находится перед списком параметров замыкания и может «захватить» значения из области видимости тремя разными способами: используя ссылки «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:
Замыкания
Автоматический подсчёт ссылок

Комментарии (16)


  1. uxtuander
    21.03.2019 09:52

    “Lines logged: 1”...“Lines logged: 4”??
    Вроде будет “Lines logged: 2”...“Lines logged: 4”


    1. infund Автор
      21.03.2019 09:53

      Имеется в виду, что мы добавили этот код после вызова logger1(). Но спасибо за внимательное чтение )


  1. claustrofob
    21.03.2019 18:11

    Нужно было упомянуть и о capture list. Случай 4 тогда лечится таким способом:

    var numberOfLinesLogged = 0
    
    let logger1 = {[numberOfLinesLogged] in
        numberOfLinesLogged += 1
        print("Lines logged: \(numberOfLinesLogged)")
    }
    


    1. infund Автор
      21.03.2019 18:31

      Нужно было упомянуть и о capture list.

      Я в недоумении )) Вся публикация об этом. «Список захвата»
      код
      var numberOfLinesLogged = 0
      
      let logger1 = {[numberOfLinesLogged] in
          numberOfLinesLogged += 1
          print("Lines logged: \(numberOfLinesLogged)")
      }


      1. claustrofob
        21.03.2019 18:47

        Согласен, недоглядел. Но смысл в том, что [numberOfLinesLogged] захватывает значение переменной. У вас в статье это не указано.

        var numberOfLinesLogged = 0
        
        let logger1 = { [numberOfLinesLogged] in
            print("Lines logged: \(numberOfLinesLogged)")
        }
        
        numberOfLinesLogged += 1
        
        logger1()
        


        1. infund Автор
          22.03.2019 11:13

          Да, спасибо за любопытный момент, но, во-первых, это просто перевод, во-вторых, «сильный» захват упомянут в пункте 3, про ненамеренные действия в случае нескольких захваченных значений. Да, и мне не совсем ясен смысл capture list вообще без модификатора unowned или weak, ведь как раз его смысл — избегать циклов сильных ссылок. Руководство по Swift ясно говорит о том, что либо weak либо unowned должны быть:

          «Defining a Capture List
          Each item in a capture list is a pairing of the weak or unowned keyword with a reference to...»

          Отрывок из книги: Apple Inc. «The Swift Programming Language (Swift 5)». Apple Books.

          А по факту получается, что можно и без модификатора. И, насколько я понимаю, имеет значение то, что numberOfLinesLogged — value-type.
          Это все так, мои мысли вслух.


          1. claustrofob
            22.03.2019 12:45

            Да, и мне не совсем ясен смысл capture list вообще без модификатора unowned или weak, ведь как раз его смысл — избегать циклов сильных ссылок.

            Для reference типов особо смысла нет, а для value типов есть.
            https://docs.swift.org/swift-book/ReferenceManual/Expressions.html#ID544


  1. LLIo6oH
    22.03.2019 12:54

    Что не так в этом примере?


    1. infund Автор
      22.03.2019 12:54

      В котором из?


      1. LLIo6oH
        22.03.2019 12:56
        +1

        случайно нажал на отправить. не успел дописать

        func sing() -> () -> Void {
            let taylor = Singer()
        
            let singing = {
                taylor.playSong()
                return
            }
        
            return singing
        }


        здесь же все освободится и деинититься


        1. infund Автор
          22.03.2019 13:16

          Да, кстати. Но тут я виноват — вы же для тестирования используете sing()()? Это я уже от себя добавил, в оригинале этого не было. В этом случае освобождается и созданное замыкание, а вместе с ним деинится и taylor. Если использовать как автор написал

          let singFunction = sing()
          singFunction()

          то в таком случае taylor «зависает»
          Убрал из публикации sing()(). Спасибо.


          1. LLIo6oH
            22.03.2019 13:24

            мммм да я делал с sing()()

            Но! Мне кажется и в случае с

            let singFunction = sing()
            singFunction()


            taylor освободится и деинитится. А вот SELF останется закепчуреный, да.
            вот код для проверки

            func sing() -> () -> Void {
                        let taylor = Singer()
            
                        print("ARC count taylor = \(CFGetRetainCount(taylor))")
                        print("ARC count self = \(CFGetRetainCount(self))")
                        let singing = {
                            taylor.playSong()
                            print("Captured")
                            print("ARC count taylor = \(CFGetRetainCount(taylor))")
                            print("ARC count self = \(CFGetRetainCount(self))")
                            return
                        }
                        return singing
                    }
            
                    print("sing")
                    let singFunction = sing()
                    singFunction()
                    print("ARC count self = \(CFGetRetainCount(self))")
                    print("Finish")

            и лог с него

            sing
            ARC count taylor = 2
            ARC count self = 15
            song playing
            Captured
            ARC count taylor = 2
            ARC count self = 16
            ARC count self = 16
            Finish
            I'm deinited


            Кстати, а почему каунтер сразу 2 равен?


            1. infund Автор
              22.03.2019 13:37

              Как у меня выглядит лог в Playground (подсчёт ссылок на self закомментарен):
              sing
              ARC count taylor = 2
              Shake it off!
              Captured
              ARC count taylor = 3
              Finish

              Кстати, а почему каунтер сразу 2 равен?

              Не в курсе, расскажите!


              1. LLIo6oH
                22.03.2019 13:50

                Хм, странно ) дкйствительно в плэйграунде счетчик ссылок повышается и не освобождается

                Не в курсе, расскажите!

                Я не знаю ) Сам заметил только сейчас ) Раньше, мне казалось с 1 начиналось. Правда я это послежний раз делал ооочень давно


                1. infund Автор
                  22.03.2019 14:01

                  Я не знаю ) Сам заметил только сейчас ) Раньше, мне казалось с 1 начиналось. Правда я это послежний раз делал ооочень давно

                  stackoverflow


                1. infund Автор
                  22.03.2019 14:06

                  Хм, странно ) дкйствительно в плэйграунде счетчик ссылок повышается и не освобождается

                  Оборачиваем в «do»:

                  do {
                      print("sing")
                      let singFunction = sing()
                      singFunction()
                      print("Finish")
                  }

                  Получаем:

                  sing
                  ARC count taylor = 2
                  Shake it off!
                  Captured
                  ARC count taylor = 3
                  Finish
                  deinit…