Функциональный Swift — это просто

    image


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


    Недостаток один — высокий порог входа. Пытаясь разобраться в ФП, я столкнулся с огромным количеством теории, функторами, монадами, теорией категорий и алгебраическими типами данных. А как применять ФП на практике, было неясно. Кроме того, примеры приводились на незнакомых мне языках — хаскеле и скале.


    Тогда я решил разобраться в ФП самого начала. Разобрался и рассказал на codefest о том, что ФП — это на самом деле просто, что мы уже им пользуемся в Swift и можем пользоваться еще эффективнее.


    Функциональное программирование: чистые функции и отсутствие состояний


    Определить, что означает писать в той или иной парадигме — нелегкая задача. Парадигмы формируются десятилетиями людьми с разным видением, воплощаются в языках с непохожими подходами, обрастают инструментами. Эти инструменты и подходы считаются неотъемлемой частью парадигм, но на самом деле ими не являются.


    Например, считается, что объектно-ориентированное программирование стоит на трех китах — наследование, инкапсуляция и полиморфизм. Но инкапсуляция и полиморфизм реализуется на функциях с той же легкостью, что и на объектах. Или замыкания — они родились в чистых функциональных языках, но так давно перекочевали в промышленные языки, что перестали ассоциироваться с ФП. Монады тоже пробираются в промышленные языки, но пока не утратили принадлежность к условному хаскелю в умах людей.


    В итоге получается, что невозможно четко определить, что конкретно представляет из себя та или иная парадигма. Я в очередной раз столкнулся с этим на codefest 2019, где все эксперты ФП, говоря о функциональной парадигме, называли разные вещи.


    Лично мне понравилось определение из вики:


    «Функциона́льное программи́рование — раздел дискретной математики и парадигма программирования, в которой процесс вычисления трактуется как вычисление значений функций в математическом понимании последних (в отличие от функций как подпрограмм в процедурном программировании)».


    Что такое математическая функция? Это функция, результат которой зависит только от данных, к которым она применена.


    Пример математической функции в четыре строки кода выглядит так:


    func summ(a: Int, b: Int) -> Int {
        return a + b
    }
    let x = summ(a: 2, b: 3)

    Вызывая функцию summ с входными аргументами 2 и 3, получим 5. Этот результат неизменен. Поменяйте программу, поток, место исполнения — результат останется прежним.


    А нематематическая функция — это когда где-то объявлена глобальная переменная.


    var z = 5

    Функция суммирования теперь складывает входные аргументы и значение z.


    func summ(a: Int, b: Int) -> Int {
        return a + b + z
    }
    let x = summ(a: 2, b: 3)

    Добавилась зависимость от глобального состояния. Теперь нельзя однозначно предсказать значение x. Оно будет постоянно меняться в зависимости от того, когда была вызвана функция. Вызовем функцию 10 раз подряд, и каждый раз можем получить разный результат.


    Еще один вариант нематематической функции:


    func summ(a: Int, b: Int) -> Int {
        z = b - a
        return a + b
    }

    Помимо возврата суммы входных аргументов, функция меняет глобальную переменную z. Эта функция имеет сайд-эффект.


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


    Чистые функции — краеугольный камень ФП, все остальное уже вторично. Предполагается, что, следуя этой парадигме, используем только их. А если никак не работать с глобальными или изменяемыми состояниями, то их и не будет в приложении.


    Классы и структуры в функциональной парадигме


    Изначально, я думал, что ФП — это только про функции, а классы и структуры используются только в ООП. Но оказалось, классы тоже вписываются в концепцию ФП. Только и они должны быть, скажем так, «чистыми».


    «Чистый» класс — класс, все методы которого являются чистыми функциями, а свойства неизменяемы. (Это неофициальный термин, придуман во время подготовки к докладу).


    Взглянем на такой класс:


    class User {
        let name: String
        let surname: String
        let email: String
    
        func getFullname() -> String {
            return name + " " + surname
        }   
    }

    Его можно рассматривать как инкапсуляцию данных...


    class User {
        let name: String
        let surname: String
        let email: String   
    }
    

    и функций по работе с ними.


    func getFullname() -> String {
        return name + " " + surname
    }

    С точки зрения ФП, использование класса User ничем не отличается от работы с примитивами и функциями.


    Объявим значение — пользователя Ваню.


    let ivan = User(
        name: "Иван",
        surname: "Иванов",
        email: "ivanov@example.com"
    )

    Применим к нему функцию getFullname.


    let fullName = ivan.getFullname()

    В результате получим новое значение — полное имя пользователя. Так как изменить параметры свойства ivan нельзя, результат вызова getFullname неизменен.


    Конечно внимательный читатель может сказать: «Постой-ка, метод getFullname возвращает результат на основе глобальных для него значений — свойств класса, а не аргументов». Но на самом деле метод — это просто функция, в которую в качестве аргумента передается объект.


    Swift даже поддерживает эту запись в явном виде:


    let fullName = User.getFullname(ivan)()

    Если же нам понадобиться изменить какое-то значение у объекта, например email, то придется создавать новый объект. Это можно делать соответствующим методом.


    class User {
        let name: String
        let surname: String
        let email: String
    
        func change(email: String) -> User {
            return User(name: name, surname: surname, email: email)
        }    
    }
    let newIvan = ivan.change(email: "god@example.com")

    Функциональные атрибуты в Swift


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


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


    Зависимых типов нет, хотя я бы не отказался от проверки компилятором строки, что она email, массива, что он не пустой, словаря, что он содержит ключ «apple». Кстати, в Haskell зависимых типов тоже нет.


    Алгебраические типы данных имеются, и это крутая, но сложная для понимания математическая штука. Прелесть в том, что ее не надо понимать математически, чтобы использовать. Например Int, enum, Optional, Hashable — это алгебраические типы. И если Int есть во многих языках, а Protocol есть и в Objective-C, то enum со связанными значениями, протоколы с дефолтной реализацией и ассоциативными типами есть далеко не везде.


    Проверку корректности во время компиляции часто упоминают, говоря о таких языках, как rust или haskell. Подразумевается, что язык настолько выразителен, что позволяет описать все краевые случаи так, чтобы их проверил компилятор. А значит, если программа скомпилировалась, то она обязательно будет работать. Никто не спорит, что она может содержать ошибки в логике, потому что вы неправильно отфильтровали данные для показа пользователю. Но она не будет падать, потому что вы не получили данные из БД, сервер вернул вам не тот ответ, на который вы рассчитывали, или пользователь ввел дату своего рождения строкой, а не числом.


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


    Монады: не часть парадигмы ФП, а инструмент (необязательный)


    Довольно часто ФП и монады используются в одном и том же приложении. Одно время я даже думал, что монады и есть функциональное программирование. Когда же я их понял (но это не точно), то сделал несколько выводов:


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

    В Swift уже есть две стандартные монады — Optional и Result. Обе нужны для борьбы с сайд-эффектами. Optional защищает от возможного nil. Result — от различных исключительных ситуаций.


    Рассмотрим на примере, доведенном до абсурда. Пусть у нас есть функции, возвращающие целое число из базы данных и от сервера. Вторая может вернуть nil, но мы используем неявное извлечение получая поведение времен Objective-C.


    func getIntFromDB() -> Int
    func getIntFromServer() -> Int!

    Продолжаем игнорировать Optional и реализуем функцию для суммирования этих чисел.


    func summInts() -> Int! {
        let intFromDB = getIntFromDB()
        let intFromServer = getIntFromServer()!
        let summ = intFromDB + intFromServer
        return summ
    }

    Вызываем итоговую функцию и используем результат.


    let result = summInts()
    print(result)

    Сработает ли этот пример? Ну, он определенно скомпилируется, а вот получим мы креш во время выполнения или нет — никому неизвестно. Этот код хорош, он отлично показывает наши намерения (нам необходима сумма каких-то двух чисел) и при этом не содержит ничего лишнего. Но он опасен. Поэтому так пишут только джуниоры и уверенные в себе люди.


    Изменим пример, сделав его безопасным.


    func getIntFromDB() -> Int
    func getIntFromServer() -> Int?
    
    func summInts() -> Int? {
        let intFromDB = getIntFromDB()
        let intFromServer = getIntFromServer()
    
        if let intFromServer = intFromServer {
            let summ = intFromDB + intFromServer
            return summ
        } else {
            return nil
        }
    }
    
    if let result = summInts() {
        print(result)
    }

    Этот код хорош, он безопасен. Используя явное извлечение, мы защитились от возможного nil. Но он стал громоздким, и среди безопасных проверок уже сложно разглядеть наше намерение. Нам все еще необходима сумма каких-то двух чисел, а не проверки безопасности.


    На этот случай у Optional есть метод map, доставшийся ему от типа Maybe из Haskell. Применим его, и пример изменится.


    func getIntFromDB() -> Int
    func getIntFromServer() -> Int?
    
    func summInts() -> Int? {
        let intFromDB = getIntFromDB()
        let intFromServer = getIntFromServer()
    
        return intFromServer.map { x in x + intFromDB }
    }
    
    if let result = summInts() {
        print(result)
    }

    Или еще компактнее.


    func getIntFromDB() -> Int
    func getintFromServer() -> Int?
    
    func summInts() -> Int? {
        return getintFromServer().map { $0 + getIntFromDB() }
    }
    
    if let result = summInts() {
        print(result)
    }

    Мы использовали map, чтобы преобразовать intFromServer в необходимый нам результат без извлечения.


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


    Извлечь


    if let result = summInts() {
        print(result)
    }
    

    Использовать значение по умолчанию


    print(result ?? 0)

    Или вывести предупреждение если, данные не получены.


    if let result = summInts() {
        print(result)
    } else {
        print("Ошибка")
    }

    Теперь код в примере не содержит лишнего, как в первом примере, и безопасен, как во втором.


    Но map не всегда работает так, как нужно


    let a: String? = "7"
    let b = a.map { Int($0) }
    type(of: b)//Optional<Optional<Int>>

    Если в map передать функцию, результат которой опционален, мы получим двойной Optional. Но нам не нужна двойная защита от nil. Достаточно одной. Решить проблему позволяет метод flatMap, это аналог map с одним отличием, он разворачивает матрешки.


    let a: String? = "7"
    let b = a.flatMap { Int($0) }
    type(of: b)//Optional<Int>.

    Еще один пример, где map и flatMap не очень удобно использовать.


    let a: Int? = 3
    let b: Int? = 7
    let c = a.map { $0 + b! }
    

    Что, если функция принимает два аргумента и они оба опциональные? Конечно, у ФП есть решение — это аппликативный функтор и каррирование. Но эти инструменты довольно неуклюже смотрятся без использования специальных операторов, которых нет в нашем языке, а писать кастомные операторы считается дурным тоном. Поэтому рассмотрим более интуитивный способ: напишем специальную функцию.


    @discardableResult
    func perform<Result, U, Z>(
        _ transform: (U, Z) throws -> Result,
        _ optional1: U?,
        _ optional2: Z?) rethrows -> Result? {
    
        guard
            let optional1 = optional1,
            let optional2 = optional2
            else {
                return nil
        }
    
        return try transform(optional1, optional2)
    }

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


    let a: Int? = 3
    let b: Int? = 7
    let result = perform(+, a, b)

    У второй монады, Result, тоже имеются методы map и flatMap. А значит, с ней можно работать точно так же.


    func getIntFromDB() -> Int
    func getIntFromServer() -> Result<Int, ServerError>
    
    func summInts() -> Result<Int, ServerError> {
        let intFromDB = getIntFromDB()
        let intFromServer = getIntFromServer()
    
        return intFromServer.map { x in x + intFromDB }
    }
    
    if case .success(let result) = summInts() {
        print(result)
    }

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


    Пример: сокращаем число «грязных» функций


    К сожалению, в реальных программах повсюду встречаются глобальные состояния и сайд-эффекты — сетевые запросы, источники данных, UI. И только чистыми функциями обойтись нельзя. Но это не значит, что ФП для нас полностью недоступно: мы можем постараться уменьшить число грязных функций, которых обычно очень много.


    Рассмотрим небольшой пример, приближенный к продакшн-разработке. Построим UI, конкретно форму входа. Форма имеет некоторые ограничения:


    1) Логин не короче 3 символов
    2) Пароль не короче 6 символов
    3) Кнопка «Войти» активна, если оба поля валидны
    4) Цвет рамки поля отражает его состояние, черная — валидно, красная — не валидно


    Код, описывающий эти ограничения, может выглядеть так:


    Обработка любого пользовательского ввода


    @IBAction func textFieldTextDidChange() {
        // 1. Зависимость от глобального стейта
        // 2. Явное извлечение
        guard
            let login = loginView.text,
            let password = passwordView.text else {
    
            // 3. Сайд-эффект            
            loginButton.isEnabled = false
            return
        }
    
        let loginIsValid = login.count > constants.loginMinLenght
    
        if loginIsValid {
            // 4. Сайд-эффект
            loginView.layer.borderColor = constants.normalColor
        }
    
        let passwordIsValid = password.count > constants.passwordMinLenght
    
        if passwordIsValid {
            // 5. Сайд-эффект
            passwordView.layer.borderColor = constants.normalColor
        }
    
        // 6. Сайд-эффект
        loginButton.isEnabled = loginIsValid && passwordIsValid
    }

    Обработка завершения ввода логина:


    @IBAction func loginDidEndEdit() {
        let color: CGColor
        // 1. Зависмость от глобального стейта
        // 2. Явное извлечение
        if let login = loginView.text, login.count > 3 {
            color = constants.normalColor
        } else {
            color = constants.errorColor
        }
        // 3. Сайд эфект
        loginView.layer.borderColor = color
    }

    Обработка завершения ввода пароля:


    @IBAction func passwordDidEndEdit() {
        let color: CGColor
        // 1. Зависимость от глобального стейта
        // 2. Явное извлечение
        if let password = passwordView.text, password.count > 6 {
            color = constants.normalColor
        } else {
            color = constants.errorColor
        }
        // 3. Сайд-эффект
        passwordView.layer.borderColor = color
    }

    Нажатие на кнопку войти:


    @IBAction private func loginPressed() {
        // 1. Зависимость от глобального стейта
        // 2. Явное извлечение
        guard
            let login = loginView.text,
            let password = passwordView.text else {
                return
        }
    
        auth(login: login, password: password) { [weak self] user, error in
            if let user = user {
                /* успех */
            } else if error is AuthError {
                guard let `self` = self else { return }
                // 3. Сайд-эффект
                self.passwordView.layer.borderColor = self.constants.errorColor
                // 4. Сайд-эффект
                self.loginView.layer.borderColor = self.constants.errorColor
            } else {
                /* Другие ошибки */
            }
        }
    }

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


    • 4 явных извлечения;
    • 4 зависимости от глобального стейта;
    • 8 сайд-эффектов;
    • неочевидные конечные состояния;
    • нелинейный флоу.

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


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


    Попробуем изменить пример, сделав его более функциональным.


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


    struct LoginOutputModel {
        let login: String
        let password: String
    
        var loginIsValid: Bool {
            return login.count > 3
        }
    
        var passwordIsValid: Bool {
            return password.count > 6
        }
    
        var isValid: Bool {
            return loginIsValid && passwordIsValid
        }
    }

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


    struct LoginInputModel {
        let loginBorderColor: CGColor?
        let passwordBorderColor: CGColor?
        let loginButtonEnable: Bool?
        let popupErrorMessage: String?
    }

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


    enum Event {
        case textFieldTextDidChange
        case loginDidEndEdit
        case passwordDidEndEdit
        case loginPressed
        case authFailure(Error)
    }

    Теперь опишем главный метод изменения. Эта чистая функция на основе события текущего состояния собирает новое состояние экрана.


    func makeInputModel(
        event: Event,
        outputModel: LoginOutputModel?) -> LoginInputModel {
    
        switch event {
        case .textFieldTextDidChange:
            let mapValidToColor: (Bool) -> CGColor? = { $0 ? normalColor : nil }
        return LoginInputModel(
                loginBorderColor: outputModel
                    .map { $0.loginIsValid }
                    .flatMap(mapValidToColor),
                passwordBorderColor: outputModel
                    .map { $0.passwordIsValid }
                    .flatMap(mapValidToColor),
                loginButtonEnable: outputModel?.passwordIsValid
                )
        case .loginDidEndEdit:
            return LoginInputModel(/**/)
        case .passwordDidEndEdit:
            return LoginInputModel(/**/)
        case .loginPressed:
            return LoginInputModel(/**/)
        case .authFailure(let error) where error is AuthError:
            return LoginInputModel(/**/)
        case .authFailure:
            return LoginInputModel(/**/)
        }
    }

    Самое важное в том, что этот метод единственный, кому позволено заниматься конструированием нового состояния — и он чистый. Его можно изучить шаг за шагом. Увидеть, как события преобразуют экран из точки А в точку Б. Если что-то сломается, то проблема точно здесь. И это легко тестировать.


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


    var outputModel: LoginOutputModel? {
        return perform(LoginOutputModel.init, loginView.text, passwordView.text)
    }

    Добавим еще один «грязный» метод для создания сайд-эффектов изменения экрана.


    func updateView(_ event: Event) {
    
        let inputModel = makeInputModel(event: event, outputModel: outputModel)
            if let color = inputModel.loginBorderColor {
            loginView.layer.borderColor = color
        }
        if let color = inputModel.passwordBorderColor {
            passwordView.layer.borderColor = color
        }
        if let isEnable = inputModel.loginButtonEnable {
                loginButton.isEnabled = isEnable
        }
        if let error = inputModel.popupErrorMessage {
                showPopup(error)
        }
    }

    Хотя метод updateView и не является чистым, но это единственное место, где меняются свойства экрана. Первый и последний пункт в цепочке вычислений. И если что-то пошло не так, именно тут будет стоять брейкпоинт.


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


    @IBAction func textFieldTextDidChange() {
        updateView(.textFieldTextDidChange)
    }
    
    @IBAction func loginDidEndEdit() {
        updateView(.loginDidEndEdit)
    }
    
    @IBAction func passwordDidEndEdit() {
        updateView(.passwordDidEndEdit)
    }

    Метод loginPressed вышел немного уникальным.


    @IBAction private func loginPressed() {
    
        updateView(.loginPressed)
    
        let completion: (Result<User, Error>) -> Void = { [weak self] result in
            switch result {
            case .success(let user):
                /* успех */                 
            case .failure(let error):
                self?.updateView(.authFailure(error))
            }
        }
    
        outputModel.map {
            auth(login: $0.login, password: $0.password, completion: completion)
        }
    
    }

    Дело в том, что нажатие на кнопку «Войти» запускает две цепочки вычислений, что не запрещается.


    Заключение


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


    Сейчас мне кажется, что все те вещи, что я считал частью той или иной парадигмы — довольно условны. Главное — это чистый, понятный код. Для достижения этой цели можно использовать все, что возможно: чистые функции, классы, монады, наследование, композиция, вывод типов. Все они хорошо уживаются вместе и делают код лучше — достаточно применять их к месту.


    Что еще почитать по теме


    Определение функционального программирования из википедии
    Книга о языке Haskell для начинающих
    Объяснение функторов, монад и апликативных функторов на пальцах
    Книга о практиках использования Maybe(Optional) в языке Haskell
    Книга о функциональной природе Swift
    Определение алгебраических типов данных из вики
    Статья о алгебраических типах данных
    Еще одна статья об алгебраических типах данных
    Доклад яндекса о функциональном программировании на Swift
    Реализация стандартной библиотеки Prelude (Haskell) на Swift
    Библиотека с функциональными инструментами на Swift
    Еще одна библиотека
    И еще одна

    • +10
    • 5,8k
    • 8
    Redmadrobot
    116,91
    №1 в разработке цифровых решений для бизнеса
    Поделиться публикацией

    Комментарии 8

      0

      Сейчас потихоньку осваиваю Rust и что-то не увидел сейчас вообще ни одного отличия :) Даже синтаксис похож :)

        +1
        Раст маленький и быстрый, Свифт большой и медленный. Свифт настолько богат фичами, что можно взять любой другой язык и сказать что он похож на свифт.
        0
        Спасибо за статью, понял что такое ФП.
          0
          Поправьте меня, если я ошибаюсь, но map использовать как в последнем сниппете — очень неоднозначная затея, потому что map должен преобразовывать данные, а в данном случае он просто вызывает функцию.
            0
            Вы правы, это неоднозначно. Auth возвращает объект, осуществляющий запрос к серверу. Он игнорируется в примере. Можно рассматривать это как преобразование данных. Но для читаемости, можно использовать явное извлечение.
              0

              Да, я лучше бы предпочёл использовать “guard let” или “if let” формы. Функции .map предназначаются для трансформированная данных, а не для безопасного извлечения optionals.


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

              0

              perform можно реализовать короче (хотя без опыта ФП будет менее понятно):


              a.map { x in b.map { transform(x, $0) }}
                0
                Да, разумеется. Только вот так:

                return try optional1.flatMap { x in try optional2.map { try transform(x, $0) }}

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

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

              Самое читаемое