Замыкания в Swift: полное руководство по использованию

Замыкания – это блоки кода, которые могут быть многократно использованы. Понимание принципа работы замыканий является ключевым аспектом обучения в разработке под iOS.

Как работают замыкания?

Замыкания – это автономные блоки функциональности, которые можно передавать и использовать в вашем коде.

Иными словами, замыкание – это блок кода, который вы можете присвоить переменной. Затем вы можете передать его в своем коде, например, в другую функцию.

Давайте посмотрим на аналогию:

  • Боб говорит Алисе: «Помаши руками!» Алиса слышит инструкцию и машет руками. Размахивание руками – это функция, которую Боб вызвал напрямую.
  • Алиса записывает свой возраст на листе бумаги и передает его Бобу. Лист бумаги является переменной. Алиса использовала лист бумаги для сохранения данных.
  • Боб пишет: «Помашите руками!» на листе бумаги и дает его Алисе. Алиса читает инструкцию на листе бумаги и машет руками. Инструкция, переданная на листе бумаги, является замыканием.

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

Напишем замыкание:

let birthday = {
    print("С днем рождения!")
}
 
birthday()
// С днем рождения!
  • В первой строке мы определяем замыкание и назначаем его константе birthday. Замыкание – это код между фигурными скобками. Обратите внимание, что замыкание назначается birthday с помощью оператора присваивания =.
  • Когда вы запускаете код, замыкание вызывается с помощью синтаксиса birthday(). То есть мы используем имя константы birthday с круглыми скобками (). Это похоже на вызов функции.

Как и функции, замыкания могут иметь параметры.

let birthday: (String) -> () = { name in
    print("С днем рождения, \(name)!")
}
 
birthday("Александр")
// С днем рождения, Александр!
  • Как и раньше, мы объявляем замыкание в первой строке, затем назначаем его константе birthday и вызываем замыкание в последней строке.
  • Замыкание теперь имеет один параметр типа String. Этот параметр объявлен как тип замыкания – (String) -> ()
  • Затем вы можете использовать параметр name в замыкании. При вызове замыкания мы указываем значение параметра.

Здесь важны три вещи:

  • Тип замыкания – (String) -> ().
  • Код замыкания – { name in ··· }.
  • Вызов замыкания – birthday(···).

Параметры замыкания не имеют названий в отличие от функций. Когда вы объявляете замыкание, вы можете указать типы параметров, которые у него есть, например, String. В коде замыкания вы назначаете локальную переменную первому параметру. Это дает параметру имя в замыкании.

Мы можем опустить имя переменной и использовать сокращение для первого параметра – $0:

let birthday: (String) -> () = {
    print("С днем рождения, \($0)!")
}

В приведенном выше коде замыкание birthday имеет один параметр. Внутри замыкания сокращение используется для ссылки на значение первого параметра – $0.

Типы замыканий

Каждое замыкание имеет тип, как и любая другая переменная или константа.

Мы объявляем замыкание с одним параметром следующим образом:

let birthday: (String) -> () = { (name: String) -> () in
    ···
}

Замыкание имеет один параметр типа и возвращает (). Первый параметр замыкания также принимает данный тип (name: String) -> (). Ключевое слово in отделяет параметры замыкания от кода.

Синтаксис для типа замыкания состоит из типов параметров и типа возвращаемого значения:

  • (Int, Int) -> Double – имеет 2 параметра Double и возвращает значение Double.
  • () -> Int – не имеет параметров и возвращает целое число.
  • (String) -> String – принимает строку и возвращает строку.

Давайте рассмотрим код самого замыкания:

{ (имя параметра: тип параметра) -> тип возвращаемого значения in
    ···
}

Тип замыкания объявляется несколько иначе и включает имена параметров. Посмотрим на несколько примеров:

  • (Int, Int) -> Double становится { (width: Int, height: Int) -> Double in ···}
  • () -> Int становится { () -> Int in ···}
  • (String) -> String становится { (text: String) -> String in ···}

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

in проще всего воспринимать как “У нас есть параметры X, Y, Z в блоке кода, который является замыканием”.

Замыкание без параметров и без возвращаемого значения имеет следующий тип:

() -> ()

Вы также можете использовать Void в качестве типа возвращаемого значения. В Swift Void означает «ничего»:

() -> Void

Посмотрим на последний пример:

let greeting: (String, String) -> String = { (time: String, name: String) -> String in
    return "Отличное \(time), \(name)!"
}
 
let text = greeting("утро", "Александр")
print(text)
// Отличное утро, Александр!

Замыкание greeting имеет два параметра типа String. Также замыкание возвращает значение типа String. Тип greeting явно определен, как и параметры замыкания.

Когда вызывается замыкание, ему предоставляется два аргумента типа String, и его возвращаемое значение присваивается text, а затем выводится на консоль.

Замыкания и вывод типа

Swift может сам выводить типы. Когда вы не указываете явно тип переменной, Swift может самостоятельно определить, какой тип у данной переменной. Это зависит от контекста вашего кода.

let age = 104

Swift выводит тип age на основании контекста. 104 – это значение для целого числа, так что константа age имеет тип Int. Swift выясняет это самостоятельно без необходимости явно указывать тип.

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

let names = ["Zaphod", "Slartibartfast", "Trillian", "Ford", "Arthur", "Marvin"]
let sortedNames = names.sorted(by: <)
print(sortedNames)
// ["Arthur", "Ford", "Marvin", "Slartibartfast", "Trillian", "Zaphod"]

Мы создаем массив с именами, а затем сортируем их в алфавитном порядке, вызывая функцию sorted(by:). Параметр by: принимает замыкание <, которое используется для сортировки массива.

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

names.sorted(by: { (s1: String, s2: String) -> Bool in
    return s1 < s2
})

Здесь используется полный синтаксис замыкания, включая два имени параметра s1 и s2 типа String, а также тип возвращаемого значения Bool. Мы также используем ключевые слова in и return .

Этот код можно сократить:

names.sorted(by: { s1, s2 in return s1 < s2 } )

Здесь пропускаются типы параметров замыкания, поскольку они могут быть выведены из контекста. Поскольку мы сортируем массив строк, эти два параметра выводятся как String. Опуская типы, мы также можем опустить окружающие их скобки. Также мы опускаем тип возвращаемого значения.

names.sorted(by: { s1, s2 in s1 < s2 } )

Здесь опущен return, потому что замыкание представляет собой только одну строку кода.

names.sorted(by: { $0 < $1 } )

Мы используем сокращенные имена для первого и второго параметра замыкания.

names.sorted { $0 < $1 }

Когда замыкание является последним или единственным параметром функции, вы можете написать код замыкания вне скобок функции. Это называется выходящее замыкание (traling closure).

names.sorted(by: <)

Здесь мы используем оператор < в качестве замыкания. В Swift операторы являются функциями верхнего уровня. Его тип (lhs: (), rhs: ()) -> Bool, что соответствует типу sorted(by:).

Замыкания и захват значений

В Swift замыкания захватывают переменные и константы из окружающей их области видимости.

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

Любой код имеет глобальные и локальные области видимости. К примеру:

  • Свойство, определенное в классе, является частью глобальной области видимости. В любом месте этого класса вы можете установить и получить значение свойства.
  • Переменная, как определено в функции, является частью локальной области действия функции. В любом месте этой функции вы можете установить и получить значение переменной.

Рассмотрим пример того, как замыкание захватывает окружающую область видимости:

let name = "Александр"
 
let greeting = {
    print("Не паникуй, \(name)!")
}
 
greeting()
// Не паникуй, Александр!

Константа name присваивается значение “Александр” типа String. Затем создается замыкание и назначается константе greeting. Замыкание выводит некоторый текст. Наконец, замыкание выполняется путем вызова greeting().

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

Посмотрим на более сложный пример:

func addScore(_ points: Int) -> Int {
    let score = 42
 
    let calculate = {
        return score + points
    }
 
    return calculate()
}
 
let value = addScore(11)
print(value)
// 53

Сначала мы создаем функцию addScore(_:), которое возвращает новое значение на основании параметра и предустановленного значения внутри функции. Затем внутри функции определяется замыкание calculate, которое добавляет score и points, а затем возвращает результат. Функция возвращает замыкание calculate().

При этом замыкание calculate захватывает оба значения score и points. Ни одна из этих переменных не объявляется локально в замыкании, но замыкание может получить доступ к их значениям.

Сильные ссылки и захват значений

Когда замыкание захватывает значение, оно автоматически создает сильную ссылку на это значение.

Когда Боб имеет сильную ссылку на Алису, Алиса не удаляется из памяти, пока Боб не будет удален из памяти. Но что, если у Алисы есть сильная ссылка на Боба? Тогда и Боб, и Алиса не будут удалены из памяти, потому что они держатся друг за друга. Боб не может быть удален, потому что Алиса держит его, а Алиса не может быть удалена, потому что Боб держит ее.

Это называется сильным циклом ссылки (strong reference cycle) и вызывает утечку памяти. Представьте, что сотня Бобов и Алис занимают по 10 МБ в памяти и тогда у нас определенно возникнет проблема.

Память в iOS управляется с помощью концепции под названием автоматический подсчет ссылок (Automatic Reference Counting или ARC). Большая часть управления памятью с помощью ARC сделана за вас, но вы должны избегать сильных циклов ссылок.

Вы можете разорвать цикл сильных ссылок, связанных с захватом значений. Так же, как вы можете пометить свойство как weak, вы можете пометить захваченные значения в замыкании как weak или unowned ссылку.

class Database {
    var data = 0
}
 
let database = Database()
database.data = 11010101
 
let calculate = { [weak database] multiplier in
    return database!.data * multiplier
}
 
let result = calculate(2)
print(result)

Сначала мы определяем класс Database. У него есть одно свойство data. Затем мы создаем экземпляр Database с именем database и устанавливаем для его свойства data целочисленное значение. Далее мы создаем замыкание calculate. Замыкание принимает один аргумент multiplier. Внутри замыкания data умножается на multiplier. Наконец, замыкание вызывается с аргументом 2 и его результат присваивается result.

Ключевой частью кода здесь является список захвата:

··· { [weak database] ···

Список захвата – это список имен переменных, разделенных запятыми, с префиксом weak или unowned, заключенный в квадратные скобки.

[weak self]
[unowned navigationController]
[unowned self, weak database]

Мы используете список захвата, чтобы указать, что на конкретное захваченное значение нужно ссылаться как weak или unowned. weak или unowned нарушает цикл сильных ссылок, поэтому замыкание не будет удерживать захваченный объект.

  • Ключевое слово weak указывает на то, что захваченное значение может стать nil.
  • Ключевое слово unowned указывает на то, что захваченное значение не становится nil.

Мы обычно используем unowned, когда замыкание и захваченное значение будут ссылаться друг на друга и будут освобождены одновременно. Примером является [unowned self] в View Controller. Замыкание уничтожается вместе с контекстом.

Мы обычно используем weak, когда зафиксированное значение в какой-то момент становится nil. Это может произойти, когда замыкание переживает контекст, в котором оно было создано. Например, View Controller, который освобождается до завершения длительной задачи. В результате захваченное значение является опциональным.

Обработчки завершения (completion handlers)

Распространенным применением замыканий является обработчик завершения.

  • Вы выполняете длительную по времени задачу в своем коде, например, загружаете файл, делаете расчет или ждете ответа веб-сервиса.
  • Вы хотите выполнить некоторый код, когда долгая задача будет завершена, но вы не хотите постоянно выяснять статус, чтобы узнать, завершена ли она.
  • Вместо этого вы предоставляете замыкание для долгой задачи, которое будет вызвано, когда задача будет завершена (отсюда название «обработчик завершения»).
let task = session.dataTask(with: "http://example.com/api", completionHandler: { data, response, error in
 
    // 
})

В приведенном выше коде мы делаем сетевой запрос на загрузку некоторых данных и что-то делаем с этими данными, когда запрос будет завершен.

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

Запускается длинная задача, и мы определяем обработчик завершения. Обработчик завершения является замыканием, поэтому он захватывает переменные в окружающей его области. Длительная задача завершается, и ваш обработчик выполнения выполняется. Область действия замыкания сохраняется, поэтому мы можем использовать любые переменные и константы, определенные в области замыкания.

К примеру, мы хотим использовать данные сетевого запроса для отображения изображения:

let imageView = UIImageView()
 
HTTP.request("http://imgur.com/kittens", completionHandler: { data in
    imageView.image = data
})

Мы определям изображение с помощью UIImageView(), запускаете сетевой запрос и предоставляем обработчик завершения. Обработчик завершения выполняется, когда длинная задача будет завершена.

Замыкание захватило ссылку imageView, так что вы можете установить данные изображения, когда длинная задача будет завершена.

Читайте также:
Комментарии (2)
  1. У вас ошибка в статье –
    (Int, Int) -> Double – имеет 2 параметра Double и возвращает значение Double.

Добавить комментарий

Ваш адрес email не будет опубликован.