Замыкания в Swift

Перевод статьи Donny Wals – https://www.donnywals.com/closures-in-swift-explained/

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

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

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

Понимание того, что такое замыкания в программировании

Замыкания ни в коем случае не являются уникальной концепцией Swift. Например, такие языки, как JavaScript и Python, поддерживают замыкания. Замыкание в программировании определяется как исполняемый код, который захватывает значения из своего окружения. В некотором смысле вы можете думать о замыкании как об экземпляре функции, которая имеет доступ к определенному контексту и/или захватывает определенные значения и может быть вызвана позже.
Давайте посмотрим на пример кода, чтобы понять, что я имею в виду:

var counter = 1

let myClosure = {
    print(counter)
}

myClosure() // выведет 1
counter += 1
myClosure() // выведет 2

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

Мы также можем зафиксировать(захватить) значение счетчика во время создания замыкания следующим образом:

var counter = 1

let myClosure = { [counter] in
    print(counter)
}

myClosure() // выведет 1
counter += 1
myClosure() // выведет 1

Написав [counter] in, мы создаем список захвата, который делает снимок текущего значения 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. Внутри addingFunction(amountToAdd:) я создаю замыкание, которое принимает один аргумент с именем input, и это замыкание возвращает amountToAdd + input. Таким образом, он фиксирует любое значение, которое мы передали для amountToAdd, и добавляет это значение к input. Созданное замыкание затем возвращается.

Это означает, что мы можем создать функцию, которая всегда добавляет 3 к своим входным данным, следующим образом:

let addThree = addingFunction(amountToAdd: 3)
let output = addThree(5)
print(output) // выведет 8

В этом примере мы взяли функцию, которая принимает два значения (основание 3 и значение 5), и преобразовали ее в две отдельно вызываемые функции. Та, которая берет основание и возвращает замыкание, и та, которую мы вызываем со значением. Этот акт называется каррированием (прим. перев. – статья на Вики). Я пока не буду вдаваться в подробности, но если вам интересно узнать больше, вы знаете, что искать в Google.

В этом примере хорошо то, что замыкание, созданное и возвращенное addingFunction, может вызываться так часто и с таким количеством входных данных, как мы хотели бы. Результатом всегда будет то, что к нашему вводу будет добавлено число три.

Хотя пока не весь синтаксис может быть очевиден, принцип замыканий должен постепенно начать обретать смысл. Замыкание — это не что иное, как фрагмент кода, который захватывает значения из своей области видимости и может быть вызван позднее. В этом посте я покажу вам больше примеров замыканий в 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 addingFunction(amountToAdd: Int) -> (Int) -> Int может выглядеть немного странно, теперь вы знаете, что addingFunction возвращает (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.

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

Один из способов — последовать примеру и захватить self:

class ExampleClass {
    var counter = 1

    lazy var closure: () -> Void = { [self] in
        print(counter)
    } 
}

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

В этом примере есть проблема сильного захвата self. Это означает, что self имеет ссылку на замыкание, а замыкание имеет сильную ссылку на self. Мы можем исправить это двумя способами:

  1. Мы слабо фиксируем себя
  2. Захватываем счетчик напрямую

В этом случае, вероятно, нам нужен первый подход:

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() // выведет 1
instance.counter += 1
instance.closure() // выведет 2

Обратите внимание, что внутри замыкания я использую обычный Swift синтаксис guard let для разворачивания self.

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

class ExampleClass {
    var counter = 1

    lazy var closure: () -> Void = { [counter] in
        print(counter)
    } 
}

let instance = ExampleClass()
instance.closure() // выведет 1
instance.counter += 1
instance.closure() // выведет 1

Само замыкание теперь выглядит немного чище, но значение counter фиксируется при первом доступе к lazy var closure. Это означает, что замыкание захватит любое значение counter в это время. Если мы увеличим counter перед доступом к замыканию, напечатанное значение будет увеличенным значением:

let instance = ExampleClass()
instance.counter += 1
instance.closure() // prints 2
instance.closure() // prints 2

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

По этой причине пример, в котором для избегания цикла сохранения использовался weak self, считывал последнее значение counter.

Если вы хотите узнать больше о weak self, взгляните на этот пост, который я написал ранее.

Далее несколько реальных примеров замыканий в Swift, которые вы, возможно, уже видели.

Функции высшего порядка и замыкания

Хотя название этого раздела звучит очень красиво, функция более высокого порядка — это, по сути, просто функция, которая берет на себя другую функцию. Или, другими словами, функция, которая принимает замыкание в качестве одного из своих аргументов.

Хотите увидеть как выглядит этот, вероятно, необычный шаблон в Swift?

let strings = [1, 2, 3].map { int in 
    return "Value \(int)"
}

Очень вероятно, что вы уже писали что-то подобное раньше, не зная, что map — это функция более высокого порядка, и что вы передавали ей замыкание. Замыкание, которое вы передаете в map, принимает значение из вашего массива и возвращает новое значение. Сигнатура функции 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")
}

Одна из доступных перегрузок функции async в DispatchQueue определяется следующим образом:

func async(execute: () -> Void)

Как видите, это просто функция, которая принимает замыкание; ничего особенного.

Как вы видели ранее, определить собственную функцию, которая принимает замыкание, довольно просто:

func performClosure(_ closure: () -> Void) {
    closure()
}

Иногда функция, которая принимает замыкание, сохраняет это замыкание или передает его куда-то еще. Эти замыкания отмечены @escaping, потому что они выходят за рамки, в которые они были первоначально переданы. Чтобы узнать больше о замыканиях @escaping, посмотрите этот пост.

Короче говоря, всякий раз, когда вы хотите передать полученное замыкание другой функции или хотите сохранить свое замыкание, чтобы его можно было вызвать позже (например, в качестве обработчика завершения), вам нужно пометить его как @escaping.

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

Хранение замыканий, чтобы их можно было использовать позже

Часто, когда мы пишем код, мы хотим иметь возможность внедрить какую-то абстракцию или объект, который позволит нам отделить определенные аспекты нашего кода. Например, сетевой объект может создавать URLRequests, но у вас может быть другой объект, который обрабатывает токены аутентификации и устанавливает соответствующие заголовки авторизации в URLRequest.

Вы можете внедрить весь объект в свой Networking объект, но вы также можете внедрить замыкание, которое аутентифицирует 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")) // токен

Что хорошо в этом коде, так это то, что замыкание, которое мы передаем в 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)
    }
}

Обратите внимание, что в приведенном выше примере мы на самом деле не храним замыкание completion. Однако он помечен как @escaping. Причина этого в том, что мы вызываем замыкание из другого замыкания. Это другое замыкание является новой областью видимости, что означает, что она выходит за рамки нашей функции doSomethingSlow.

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

Итого

Ух ты! Вы многому научились в этом посте. Несмотря на то, что замыкания — сложная тема, я надеюсь, что этот пост помог вам лучше понять их. Чем больше вы используете замыкания и чем больше вы подвергаете себя им, тем увереннее вы будете себя в них чувствовать. На самом деле, я уверен, что вы уже часто сталкиваетесь с замыканиями, но просто можете не осознавать этого. Например, если вы пишете SwiftUI, вы используете замыкания для указания содержимого ваших VStacks, HStacks, ваших действий Button и многого другого.

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

Не стесняйтесь обращаться ко мне в Твиттере, если у вас есть какие-либо вопросы об этом посте. Я хотел бы узнать, что я мог бы улучшить, чтобы сделать это руководство лучшим по замыканиям в Swift.

 

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *