В Swift 5.5 появилась возможность использовать обертки свойств на параметрах функций и замыканий. Это наконец позволило мне реализовать то, что я и многие другие люди всегда хотели видеть в Swift — способ каким-либо образом обеспечить, чтобы замыкание (closure) вызывалось ровно один раз.

Чтобы понять, зачем, вот вам простой пример:

// упс, здесь мы забываем вызвать comletion(...)!

// … бизнес-логика 

// упс, мы забыли поставить return в операторе if выше, поэтому выполнение функции продолжается, и мы второй раз вызываем замыкание (с неправильным результатом!).

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

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

Каюсь, я сам совершал эту ошибку много раз, и иногда это бывает достаточно сложно или требует много времени — точно выяснить, почему ваш код работает не так, как ожидалось. После небольшой отладки и просмотра кода вы понимаете: «О! Я забыл вызвать обработчик завершения!» или «О! Я забыл проставить return!» и вы довольные идете и исправляете свой код. Все хорошо!

Хотя было бы неплохо, если бы ваш код мог просигнализировать вам: «Эй! Вы забыли вызвать это замыкание!»? Вот где пригодится обертка свойств @Once.

Вот наивная реализация:

Вы можете аннотировать параметр своей функции с помощью этой обертки свойства:

И теперь вы получите ошибку во время выполнения, если замыкание не было вызвано в теле ровно один раз!

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

Одним из ограничений этой обертки свойств является то, что она не работает с изолирующими (non-escaping) замыканиями, поскольку замыкание необходимо сохранить внутри обертки свойства для последующего выполнения. Невозможно доказать компилятору, что замыкание не сбежит, ​​даже если вы точно знаете, что этого не произойдет (возможно, потому, что ваш код полностью синхронен). Поэтому вам нужно будет аннотировать ваши изолирующие замыкания @escaping, чтобы использовать эту технику, что на практике вам не совсем нужно, но, к сожалению, является единственным обходным путем.

Вы можете найти на GitHub полный код, который поддерживает как пробрасывающие (throwing), так и не пробрасывающие типы функций. Код доступен в виде пакета Swift, который при необходимости можно легко добавить в кодовую базу.


Материал подготовлен в рамках курса "iOS Developer. Professional".

Всех желающих приглашаем на двухдневный онлайн-интенсив «Пишем современное iOS приложение на SwiftUI». В первый день разберем особенности создания UI с помощью данного фреймворка. Во второй — напишем бизнес-логику с помощью нативных средств (Combine). Также будем использовать новинки, представленные на WWDC 2021, в том числе и async-await. Вебинар рассчитан на iOS разработчиков, уже имеющих опыт разработки iOS приложений и желающих познакомиться со SwiftUI. Также будет полезно тем, кто уже разрабатывает на SwiftUI, но хотел бы узнать о новинках. РЕГИСТРАЦИЯ

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


  1. antoo
    27.07.2021 00:16
    +3

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

    Кажется, что можно решить проблему куда проще и безопаснее, например так:

    class SomeService {
        func loadUser(completion: @escaping (Result<User, Error>) -> Void) {
            let result: Result<User, Error>
            defer { completion(result) }
            
            // make request and handle result
        }
    }

    Из минусов:

    • Приходится учитывать, что необходимо не вызывать completion самому, а использовать переменную result.

    Из плюсов:

    • Не пишем никаких хитрых оберток, все очень понятно и прямолинейно.

    • Работает, наверное, вообще начиная со Swift 2 (если отбросить Result)

    • Самое главное: все проверки на этапе компиляции — если где-то ниже не присвоим result, то ошибка вылезет сразу. Это, кстати, сильно поможет с описанным выше минусом — если ты по привычке сделаешь completion(...); return, то компилятор сразу ругнется, что result не присвоен, и вероятно тут придется вспомнить, что вызов надо делать через переменную.

    • Никаких ограничений на @escaping и @nonescaping- подход работает где хочешь, делается в 2 строки.

    Не знаю, реально ли автор пытался решать проблему, или это код чтобы потом сделать статью, но с учетом того, что оформлено оно в виде библиотеки на Github - даже не знаю, кто добровольно потащит себе такой костыль с fatalError'ами внутри ​


    1. bonyadmitr
      27.07.2021 06:24
      +2

      Как я понимаю Ваше решение только для синхронных функций подойдет.
      Любой банальный async и работать не будет.

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

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


    1. Speakus
      27.07.2021 19:14

      Вы зря фаталерроров боитесь. Это решение не отменяет ваше, а дополняет. Фаталеррор будет если кто-то применит Ваше решение неправильно. Если же правильно - то он никогда не сработает. То есть это последний рубеж обороны. Разбираться с двумя completion блоками или с невызванным блоком completion - то ещё сомнительное удовольствие - фаталеррор же во время QA сразу вылезет (ладно ладно может быть и не сразу). Впрочем тут про концепт. Впрочем всегда можно логирование ошибки вставить вместо фаталеррора - но тогда надо Void возвращать


  1. Gargo
    02.08.2021 14:06

    у вас опечатка - comletion