Замыкания — это мощная концепция программирования, которая позволяет использовать множество различных шаблонов программирования. Однако для многих начинающих программистов замыкания могут быть сложными в использовании и понимании. Это особенно верно, когда замыкания используются в асинхронном контексте. Например, когда они используются в качестве обработчиков завершения или если они передаются в приложении, чтобы их можно было вызвать позже.
В этом посте я объясню, что такое замыкания в Swift, как они работают, и самое главное покажу вам различные примеры замыканий с возрастающей сложностью. К концу этого поста вы поймете все, что вам нужно знать, чтобы эффективно использовать замыкания в своем приложении.
Если к концу этого поста концепция замыканий все еще остается немного чуждой, ничего страшного. В таком случае я бы порекомендовал вам потратить день или два на то, чтобы обработать прочитанное и вернуться к этому посту позже; замыкания ни в коем случае не являются простой темой, и это нормально, если вам нужно прочитать этот пост более одного раза, чтобы полностью понять концепцию.
Понимание того, что такое замыкания в программировании
Замыкания ни в коем случае не являются уникальной концепцией Swift. Например, такие языки, как JavaScript и Python, поддерживают замыкания. Замыкание в программировании определяется как исполняемый код, который захватывает (или закрывает) значения из своего окружения. В некотором смысле вы можете думать о замыкании как об экземпляре функции, которая имеет доступ к определенному контексту и/или захватывает определенные значения и может быть вызвана позже.
Давайте посмотрим на пример кода, чтобы понять, что я имею в виду:
var counter = 1
let myClosure = {
print(counter)
}
myClosure() // prints 1
counter += 1
myClosure() // prints 2
В приведенном выше примере я создал простое замыкание с именем myClosure, которое печатает текущее значение моего свойства счетчика. Поскольку счетчик и замыкание существуют в одной области видимости, мое замыкание может считывать текущее значение счетчика. Если я хочу запустить свое закрытие, я вызываю его как функцию myClosure(). Это заставит код печатать текущее значение счетчика.
Мы также можем зафиксировать значение счетчика во время создания замыкания следующим образом:
var counter = 1
let myClosure = { [counter] in
print(counter)
}
myClosure() // prints 1
counter += 1
myClosure() // prints 1
Написав [counter], мы создаем список захвата, который делает снимок текущего значения counter, что заставит нас игнорировать любые изменения, внесенные в counter. Чуть позже мы подробнее рассмотрим списки захвата; пока это все, что вам нужно о них знать.
Преимущество замыкания в том, что с ним можно делать все что угодно. Например, вы можете передать замыкание функции:
var counter = 1
let myClosure = {
print(counter)
}
func performClosure(_ closure: () -> Void) {
closure()
}
performClosure(myClosure)
Этот пример немного глуповат, но он показывает, насколько замыкания «переносимы». Другими словами, их можно передавать и вызывать, когда это необходимо.
В Swift замыкание, переданное функции, может быть создано встроенным:
performClosure({
print(counter)
})
Или, при использовании синтаксиса завершающего замыкания Swift:
performClosure {
print(counter)
}
Оба этих примера дают точно такой же вывод, как и при передаче myClosure в PerformClosure.
Еще одно распространенное использование замыканий происходит из функционального программирования. В функциональном программировании функциональность моделируется с помощью функций, а не типов. Это означает, что создание объекта, который добавит какое-то число к входным данным, не выполняется путем создания такой структуры:
struct AddingObject {
let amountToAdd: Int
func addTo(_ input: Int) -> Int {
return input + amountToAdd
}
}
Вместо этого та же функциональность будет достигнута с помощью функции, которая возвращает замыкание:
func addingFunction(amountToAdd: Int) -> (Int) -> Int {
let closure = { input in
return amountToAdd + input
}
return closure
}
Приведенная выше функция — это обычная функция, которая возвращает объект типа (Int) -> Int. Другими словами, он возвращает замыкание, которое принимает один Int в качестве аргумента и возвращает другой Int. Внутри addFunction(amountToAdd:) я создаю замыкание, которое принимает один аргумент с именем input, и это замыкание возвращает суммуToAdd + input. Таким образом, он фиксирует любое значение, которое мы передали для суммыToAdd, и добавляет это значение к входным данным. Созданное замыкание затем возвращается.
Это означает, что мы можем создать функцию, которая всегда добавляет 3 к своим входным данным, следующим образом:
let addThree = addingFunction(amountToAdd: 3)
let output = addThree(5)
print(output) // prints 8
В этом примере мы взяли функцию, которая принимает два значения (основание 3 и значение 5), и преобразовали ее в две отдельно вызываемые функции. Тот, который берет базу и возвращает замыкание, и тот, который мы вызываем со значением. Этот акт называется каррированием. Я пока не буду вдаваться в подробности, но если вам интересно узнать больше, вы знаете, что искать в Google.
В этом примере хорошо то, что замыкание, созданное и возвращенное addFunction, может вызываться так часто и с таким количеством входных данных, как мы хотели бы. Результатом всегда будет то, что к нашему вводу будет добавлено число три.
Хотя пока не весь синтаксис может быть очевиден, принцип замыканий должен постепенно начать обретать смысл. Замыкание — это не что иное, как фрагмент кода, который захватывает значения из своей области видимости и может быть вызван позже. В этом посте я покажу вам больше примеров замыканий в Swift, так что не волнуйтесь, если это описание все еще немного абстрактно.
Прежде чем мы перейдем к примерам, давайте подробнее рассмотрим синтаксис закрытия в Swift.
Понимание синтаксиса закрытия в Swift
Хотя замыкания не уникальны для Swift, я решил, что лучше поговорить о синтаксисе в отдельном разделе. Вы уже видели, что тип замыкания в Swift использует следующую форму:
() -> Void
Это очень похоже на функцию:
func myFunction() -> Void
За исключением Swift, мы не пишем -> Void после каждой функции, потому что каждая функция, которая ничего не возвращает, неявно возвращает Void. Для замыканий мы всегда должны записывать возвращаемый тип, даже если замыкание ничего не возвращает.
Другой способ, которым некоторые люди любят писать замыкания, которые ничего не возвращают, заключается в следующем:
() -> ()
Вместо -> Void или «возвращает Void» этот тип указывает -> () или «возвращает пустой кортеж». В Swift Void — это псевдоним типа для пустого кортежа. Я лично предпочитаю всегда писать -> Void, потому что это гораздо яснее передает мое намерение, и, как правило, менее запутанно видеть () -> Void, а не () -> (). В этом посте вы больше не увидите -> () , но я хотел упомянуть об этом, так как мой друг указал, что это будет полезно.
Замыкание, которое принимает аргументы, определяется следующим образом:
let myClosure: (Int, Int) -> Void
Этот код определяет замыкание, которое принимает два аргумента типа Int и возвращает значение Void. Если бы мы написали это замыкание, оно выглядело бы следующим образом:
let myClosure: (Int, Int) -> Void = { int1, int2 in
print(int1, int2)
}
В замыканиях мы всегда пишем имена аргументов, за которыми следует in, чтобы сигнализировать о начале тела замыкания. Приведенный выше пример на самом деле является сокращенным синтаксисом для следующего:
let myClosure: (Int, Int) -> Void = { (int1: Int, int2: Int) in
print(int1, int2)
}
Или, если мы хотим быть еще более подробными:
let myClosure: (Int, Int) -> Void = { (int1: Int, int2: Int) -> Void in
print(int1, int2)
}
К счастью, Swift достаточно умен, чтобы понимать типы наших аргументов, и он достаточно умен, чтобы вывести возвращаемый тип нашего замыкания из тела замыкания, поэтому нам не нужно указывать все это. Однако иногда компилятор путается, и вы обнаружите, что добавление типов в ваш код может помочь.
Имея это в виду, код из более раннего теперь должен иметь больше смысла:
func addingFunction(amountToAdd: Int) -> (Int) -> Int {
let closure = { input in
return amountToAdd + input
}
return closure
}
Хотя func addFunction(amountToAdd: Int) -> (Int) -> Int может выглядеть немного странно, теперь вы знаете, что addFunction возвращает (Int) -> Int. Другими словами, замыкание, которое принимает Int в качестве аргумента и возвращает другое Int.
Ранее я упоминал, что в Swift есть списки захвата. Давайте посмотрим на них дальше.
Понимание списков захвата в замыканиях
Список захвата в Swift указывает значения для захвата из его среды. Всякий раз, когда вы хотите использовать значение, которое не определено в той же области, что и область, в которой создано ваше закрытие, или если вы хотите использовать значение, принадлежащее классу, вам нужно явно указать это, написав список захвата.
Вернемся к немного другой версии нашего первого примера:
class ExampleClass {
var counter = 1
lazy var closure: () -> Void = {
print(counter)
}
}
Этот код не будет компилироваться из-за следующей ошибки:
Reference to property `counter` requires explicit use of `self` to make capture semantics explicit.
Другими словами, мы пытаемся захватить свойство, принадлежащее классу, и нам нужно четко указать, как мы захватываем это свойство.
Один из способов — последовать примеру и запечатлеть себя:
class ExampleClass {
var counter = 1
lazy var closure: () -> Void = { [self] in
print(counter)
}
}
Список захвата записывается с использованием квадратных скобок и содержит все значения, которые вы хотите захватить. Списки захвата записываются перед списками аргументов.
В этом примере есть проблема, потому что он сильно захватывает self. Это означает, что self имеет ссылку на замыкание, а замыкание имеет сильную ссылку на self. Мы можем исправить это двумя способами:
- Мы слабо захватываем себя
- Мы захватываем счетчик напрямую
В этом случае, вероятно, нам нужен первый подход:
class ExampleClass {
var counter = 1
lazy var closure: () -> Void = { [weak self] in
guard let self = self else {
return
}
print(self.counter)
}
}
let instance = ExampleClass()
instance.closure() // prints 1
instance.counter += 1
instance.closure() // prints 2
Обратите внимание, что внутри замыкания я использую обычный синтаксис Guard Let Swift для разворачивания себя.
Если я выберу второй подход и счетчик захвата, код будет выглядеть следующим образом:
class ExampleClass {
var counter = 1
lazy var closure: () -> Void = { [counter] in
print(counter)
}
}
let instance = ExampleClass()
instance.closure() // prints 1
instance.counter += 1
instance.closure() // prints 1
Само замыкание теперь выглядит немного чище, но значение counter фиксируется при первом доступе к ленивому замыканию var. Это означает, что замыкание захватит любое значение счетчика в это время. Если мы увеличим счетчик перед доступом к замыканию, напечатанное значение будет увеличенным значением:
let instance = ExampleClass()
instance.counter += 1
instance.closure() // prints 2
instance.closure() // prints 2
На самом деле не очень часто хочется захватить значение, а не себя в замыкании, но это возможно. Предупреждение, о котором следует помнить, заключается в том, что список захвата будет захватывать текущее значение захваченного значения. В случае с self это означает захват указателя на экземпляр класса, с которым вы работаете, а не на значения в самом классе.
По этой причине пример, в котором для избегания цикла сохранения использовался слабый сам, считывал последнее значение счетчика.
Если вы хотите узнать больше о слабом я, взгляните на этот пост, который я написал ранее.
Далее несколько реальных примеров замыканий в Swift, которые вы, возможно, уже видели.
Функции высшего порядка и замыкания
Хотя название этого раздела звучит очень красиво, функция более высокого порядка — это, по сути, просто функция, которая берет на себя другую функцию. Или, другими словами, функция, которая принимает замыкание в качестве одного из своих аргументов.
Если вы думаете, что это, вероятно, необычный шаблон в Swift, как это выглядит?
let strings = [1, 2, 3].map { int in
return "Value \(int)"
}
Очень вероятно, что вы уже писали что-то подобное раньше, не зная, что map — это функция более высокого порядка, и что вы передавали ей замыкание. Замыкание, которое вы передаете на карту, принимает значение из вашего массива и возвращает новое значение. Сигнатура функции карты выглядит следующим образом:
func map<T>(_ transform: (Self.Element) throws -> T) rethrows -> [T]
Не обращая внимания на дженерики, вы можете видеть, что map принимает следующее замыкание: (Self.Element) throws -> T это должно выглядеть знакомо. Обратите внимание, что замыкания могут генерировать ошибки точно так же, как и функции. И способ, которым замыкание помечается как бросающее, точно такой же, как и для функций.
Функция map немедленно выполняет полученное замыкание. Другой пример такой функции — DispatchQueue.async:
DispatchQueue.main.async {
print("do something")
}
Одна из доступных перегрузок асинхронной функции в DispatchQueue определяется следующим образом:
func async(execute: () -> Void)
Как видите, это «просто» функция, которая принимает замыкание; ничего особенного.
Как вы видели ранее, определить собственную функцию, которая принимает замыкание, довольно просто:
func performClosure(_ closure: () -> Void) {
closure()
}
Иногда функция, которая принимает замыкание, сохраняет это замыкание или передает его куда-то еще. Эти замыкания отмечены @escaping, потому что они выходят за рамки, в которые они были первоначально переданы. Чтобы узнать больше о замыканиях @escaping, посмотрите этот пост.
Короче говоря, всякий раз, когда вы хотите передать полученное замыкание другой функции или хотите сохранить свое замыкание, чтобы его можно было вызвать позже (например, в качестве обработчика завершения), вам нужно пометить его как @escaping.
С учетом сказанного давайте посмотрим, как мы можем использовать замыкания для внедрения функциональности в объект.
Хранение замыканий, чтобы их можно было использовать позже
Часто, когда мы пишем код, мы хотим иметь возможность внедрить какую-то абстракцию или объект, который позволит нам отделить определенные аспекты нашего кода. Например, сетевой объект может создавать URLRequests, но у вас может быть другой объект, который обрабатывает маркеры аутентификации и устанавливает соответствующие заголовки авторизации в URLRequest.
Вы можете внедрить весь объект в свой сетевой объект, но вы также можете внедрить замыкание, которое аутентифицирует URLRequest:
struct Networking {
let authenticateRequest: (URLRequest) -> URLRequest
func buildFeedRequest() -> URLRequest {
let url = URL(string: "https://donnywals.com/feed")!
let request = URLRequest(url: url)
let authenticatedRequest = authenticateRequest(request)
return authenticatedRequest
}
}
Самое приятное в том, что вы можете заменить или имитировать свою логику аутентификации без необходимости имитировать весь объект (и вам не нужен протокол с этим подходом).
Сгенерированный инициализатор для Networking выглядит следующим образом:
init(authenticateRequest: @escaping (URLRequest) -> URLRequest) {
self.authenticateRequest = authenticateRequest
}
Обратите внимание, что authenticationRequest является закрытием @escaping, потому что мы храним его в нашей структуре, а это означает, что замыкание выходит за рамки инициализатора, которому оно передано.
В коде вашего приложения у вас может быть объект TokenManager, который извлекает токен, и затем вы можете использовать этот токен для установки заголовка авторизации в своем запросе:
let tokenManager = TokenManager()
let networking = Networking(authenticateRequest: { urlRequest in
let token = tokenManager.fetchToken()
var request = urlRequest
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return request
})
let feedRequest = networking.buildFeedRequest()
print(feedRequest.value(forHTTPHeaderField: "Authorization")) // a token
Что хорошо в этом коде, так это то, что замыкание, которое мы передаем в Networking, захватывает экземпляр tokenManager, поэтому мы можем использовать его внутри тела замыкания. Мы можем запросить у менеджера токенов его текущий токен, и мы можем вернуть полностью настроенный запрос из нашего замыкания.
В этом примере замыкание вводится как функция, которую можно вызывать всякий раз, когда нам нужно аутентифицировать запрос. Замыкание можно вызывать столько раз, сколько необходимо, и его тело будет запускаться каждый раз, когда мы это делаем. Так же, как функция запускается каждый раз, когда вы ее вызываете.
Как вы можете видеть в примере, authenticationRequest вызывается из buildFeedRequest для создания аутентифицированного URLRequest.
Сохранение замыканий и вызов их позже — очень мощный паттерн, но остерегайтесь циклов сохранения. Всякий раз, когда @escaping замыкание сильно захватывает своего владельца, вы почти всегда создаете цикл удержания, который должен решаться путем слабого захвата self (поскольку в большинстве случаев self является владельцем замыкания).
Когда вы объедините то, что вы уже узнали, вы можете начать рассуждать о замыканиях, которые вызываются асинхронно, например, как обработчики завершения.
Замыкания и асинхронные задачи
До того, как в Swift появился async/await, многие асинхронные API сообщали свои результаты обратно в виде обработчиков завершения. Обработчик завершения — это не что иное, как обычное замыкание, которое вызывается, чтобы указать, что какая-то часть работы завершена или дала результат.
Этот шаблон важен, потому что в кодовой базе без async/await асинхронная функция возвращается до того, как выдает результат. Типичным примером этого является использование URLSession для получения данных:
URLSession.shared.dataTask(with: feedRequest) { data, response, error in
// this closure is called when the data task completes
}.resume()
Обработчик завершения, который вы передаете функции dataTask (в данном случае с помощью завершающего синтаксиса закрытия), вызывается после завершения задачи данных. Это может занять несколько миллисекунд, но также может занять гораздо больше времени.
Поскольку наше закрытие вызывается позже, обработчик завершения, подобный этому, всегда определяется как @escaping, потому что он выходит из области, в которую он был передан.
Что интересно, так это то, что асинхронный код по своей сути сложен для понимания. Это особенно верно, когда этот асинхронный код использует обработчики завершения. Однако знание того, что обработчики завершения — это просто обычные замыкания, которые вызываются после завершения работы, может действительно упростить вашу ментальную модель.
Так как же тогда выглядит определение вашей собственной функции, которая принимает обработчик завершения? Давайте рассмотрим простой пример:
func doSomethingSlow(_ completion: @escaping (Int) -> Void) {
DispatchQueue.global().async {
completion(42)
}
}
Обратите внимание, что в приведенном выше примере мы на самом деле не храним замыкание завершения. Однако он помечен как @escaping. Причина этого в том, что мы вызываем замыкание из другого замыкания. Это другое замыкание является новой областью видимости, что означает, что она выходит за рамки нашей функции doSomethingSlow.
Если вы не уверены, должно ли ваше замыкание экранироваться или нет, просто попробуйте скомпилировать свой код. Компилятор автоматически обнаружит, когда ваше замыкание без экранирования на самом деле экранируется и должно быть помечено как таковое.
Резюме
Ух ты! Вы многому научились в этом посте. Несмотря на то, что замыкания — сложная тема, я надеюсь, что этот пост помог вам лучше понять их. Чем больше вы используете замыкания и чем больше вы подвергаете себя им, тем увереннее вы будете себя в них чувствовать. На самом деле, я уверен, что вы уже часто сталкиваетесь с замыканиями, но просто можете не осознавать этого. Например, если вы пишете SwiftUI, вы используете замыкания для указания содержимого ваших VStacks, HStacks, ваших действий Button и многого другого.
Если вы чувствуете, что закрытие еще не совсем вам помогло, я рекомендую вам вернуться к этому посту через несколько дней. Это непростая тема, и может потребоваться некоторое время, чтобы она усвоилась. Как только концепция сработает, вы обнаружите, что пишете замыкания, которые принимают другие замыкания, возвращая при этом еще больше замыканий в мгновение ока. В конце концов, замыкания можно передавать, удерживать и выполнять, когда вам захочется.
Не стесняйтесь обращаться ко мне в Твиттере, если у вас есть какие-либо вопросы об этом посте. Я хотел бы узнать, что я мог бы улучшить, чтобы сделать это руководство лучшим по замыканиям в Swift.