Привет, Хабр. Меня зовут Максим, я iOS-разработчик в компании FINCH. Сегодня я покажу вам некоторые практики использования функционального программирования, которые мы наработали у себя в отделе.
Сразу хочу отметить, что я не призываю вас повсеместно использовать функциональное программирование — это не панацея от всех проблем. Но мне кажется, в некоторых случаях, ФП может дать наиболее гибкие и элегантные решения нестандартных задач.
ФП – популярная концепция, поэтому я не буду объяснять основы. Уверен, что вы и так применяете map, reduce, compactMap, first(where:) и подобные технологии в своих проектах. В статье речь пойдет о решении проблемы множественных запросов и работе с reducer.
Я работаю в аутсорс-продакшне, и бывают ситуации, когда клиент со своими субподрядчиками берет на себя создание бэкенда. Это далеко не самый удобный бэкенд и приходится делать множественные и параллельные запросы.
Иногда я мог написать что-то вроде:
Отвратительно, правда? Но это та реальность с которой мне нужно было работать.
Мне нужно было отправить три последовательных запроса для авторизации. Во время рефакторинга я подумал, что хорошей идеей будет разбить каждый запрос на отдельные методы и вызвать их внутри completion, разгрузив, тем самым, один огромный метод. Получилось что-то вроде:
Видно, что в каждом из приватных методов мне приходится проксировать
и это мне не очень нравится.
Тогда же мне в голову пришла мысль — «а почему бы не прибегнуть к функциональному программированию?» К тому же swift, с его магией и синтаксическим сахаром, позволяет интересно и удобоваримо разбивать код на отдельные элементы.
Функциональное программирование тесно связано с концепцией композиции – смешиванием, соединением чего-либо. В функциональном программировании композиция предполагает, что мы комбинируем поведение из отдельных блоков, а затем, в дальнейшем, работаем с ним.
Композиция с математической точки зрения — это что-то вроде:
Есть функции f и g, которые внутри себя задают выходные и входные параметры. Мы хотим получить какое-то результирующее поведение от этих входных методов.
Как пример, можно сделать два closure, один из которых увеличивает входное число на 1, а второй перемножает на само себя.
В результате мы хотим применить обе эти операции:
К сожалению мой пример не является ассоциативным
( если мы поменяем местами increment и multiply, то получим число 121), но пока опустим этот момент.
P.S. Я специально стараюсь сделать мои примеры более простыми, чтобы было максимально понятно)
На практике же часто нужно сделать что-то вроде этого:
Это и есть композиция. Мы задаём входное воздействие и получаем некоторое выходное воздействие. Но это не просто сложение каких-то объектов – это сложение целого поведения.
В нашем приложении у нас есть какое-то состояние (state). Это может быть экран который в данный момент видит пользователь или текущие данные, которые хранятся в приложении и тд.
Помимо этого у нас есть action — это то действие, которое может сделать пользователь (нажать на кнопку, скрольнуть коллекцию, закрыть приложение и тд). В результате мы оперируем этими двумя понятиями и связываем их друг с другом, то есть комбинируем, хммм комбинируем (где-то я уже это слышал).
А что если создать сущность, которая как раз скомбинирует мой state и action вмест��?
Так мы получим Reducer
На вход метода reduce мы будем давать текущий state и action, а на выходе получим новый state, который образовался внутри reduce.
Описать эту структуру мы можем несколькими способами: задав новый state, используя функциональный метод или используя мутабельные модели.
Первый вариант «классический».
Второй – более функциональный. Смысл заключается в том, что мы возвращаем не state, а метод, который принимает action, который уже в свою очередь возвращает state. По сути это каррирование метода reduce.
Третий вариант – работа со state по ссылке. При таком подходе, мы не просто выдаем state, а работаем с ссылкой на объект который приходит на вход. Мне кажется, что этот способ не очень хорош, потому что подобные (мутабельные) модельки – это плохо. Лучше пересобирать новый state(instance) и возвращать его. Но для простоты и демонстрации дальнейших примеров, условимся использовать последний вариант.
Применим концепцию Reducer на существующий код – создадим RequestState, затем инициализируем его, и зададим.
Для синхронности запросов я добавил DispatchSemaphore
Идем дальше. Теперь нам нужно создать RequestAction с, допустим, тремя запросами.
Теперь создаем Reducer, у которого есть RequestState и RequestAction. Задаем поведение – что мы хотим делать при первом, втором, третьем запросе.
В конце – вызываем эти методы. Получается более декларативный стиль, при котором видно, что идет первый, второй и третий запросы. Все читаемо и наглядно.
Не бойтесь изучать новое и не бойтесь изучать функциональное программирование. Я думаю, что самые лучшие практики находятся на стыке технологий. Старайтесь комбинировать и брать лучше из разных парадигм программирования.
Если стоит какая-то нетривиальная задача, то есть смысл посмотреть на неё с другого угла.
Сразу хочу отметить, что я не призываю вас повсеместно использовать функциональное программирование — это не панацея от всех проблем. Но мне кажется, в некоторых случаях, ФП может дать наиболее гибкие и элегантные решения нестандартных задач.
ФП – популярная концепция, поэтому я не буду объяснять основы. Уверен, что вы и так применяете map, reduce, compactMap, first(where:) и подобные технологии в своих проектах. В статье речь пойдет о решении проблемы множественных запросов и работе с reducer.
Проблема множественных запросов
Я работаю в аутсорс-продакшне, и бывают ситуации, когда клиент со своими субподрядчиками берет на себя создание бэкенда. Это далеко не самый удобный бэкенд и приходится делать множественные и параллельные запросы.
Иногда я мог написать что-то вроде:
networkClient.sendRequest(request1) { result in switch result { case .success(let response1): // ... self.networkClient.sendRequest(request2) { result in // ... switch result { case .success(let response2): // ... что - то делаем со вторым response self.networkClient.sendRequest(request3) { result in switch result { case .success(let response3): // ... тут что-то делаем с конечным результатом completion(Result.success(response3)) case .failure(let error): completion(Result.failure(.description(error))) } } case .failure(let error): completionHandler(Result.failure(.description(error))) } } case .failure(let error): completionHandler(Result.failure(.description(error))) } }
Отвратительно, правда? Но это та реальность с которой мне нужно было работать.
Мне нужно было отправить три последовательных запроса для авторизации. Во время рефакторинга я подумал, что хорошей идеей будет разбить каждый запрос на отдельные методы и вызвать их внутри completion, разгрузив, тем самым, один огромный метод. Получилось что-то вроде:
func obtainUserStatus(completion: @escaping (Result<AuthResponse>) -> Void) { let endpoint= AuthEndpoint.loginRoute networkService.request(endpoint: endpoint, cachingEnabled: false) { [weak self] (result: Result<LoginRouteResponse>) in switch result { case .success(let response): self?.obtainLoginResponse(response: response, completion: completion) case .failure(let error): completion(.failure(error)) } } } private func obtainLoginResponse(_ response: LoginRouteResponse, completion: @escaping (Result<AuthResponse>) -> Void) { let endpoint= AuthEndpoint.login networkService.request(endpoint: endpoint, cachingEnabled: false) { [weak self] (result: Result<LoginResponse>) in switch result { case .success(let response): self?.obtainAuthResponse(response: response, completion: completion) case .failure(let error): completion(.failure(error)) } } private func obtainAuthResponse(_ response: LoginResponse, completion: @escaping (Result<AuthResponse>) -> Void) { let endpoint= AuthEndpoint.auth networkService.request(endpoint: endpoint, cachingEnabled: false) { (result: Result<AuthResponse>) in completion(result) } }
Видно, что в каждом из приватных методов мне приходится проксировать
completion: @escaping (Result<AuthResponse>) -> Void
и это мне не очень нравится.
Тогда же мне в голову пришла мысль — «а почему бы не прибегнуть к функциональному программированию?» К тому же swift, с его магией и синтаксическим сахаром, позволяет интересно и удобоваримо разбивать код на отдельные элементы.
Композиция и Reducer
Функциональное программирование тесно связано с концепцией композиции – смешиванием, соединением чего-либо. В функциональном программировании композиция предполагает, что мы комбинируем поведение из отдельных блоков, а затем, в дальнейшем, работаем с ним.
Композиция с математической точки зрения — это что-то вроде:
func compose<A,B,C>(_ f: @escaping (A) -> B, and g: @escaping (B) -> C) -> (A) -> C { return { a in g(f(a)) } }
Есть функции f и g, которые внутри себя задают выходные и входные параметры. Мы хотим получить какое-то результирующее поведение от этих входных методов.
Как пример, можно сделать два closure, один из которых увеличивает входное число на 1, а второй перемножает на само себя.
let increment: (Int) -> Int = { value in return value + 1 } let multiply: (Int) -> Int = { value in return value * value }
В результате мы хотим применить обе эти операции:
let result = compose(multiply, and: increment) result(10) // в результате имеем число 101
К сожалению мой пример не является ассоциативным
( если мы поменяем местами increment и multiply, то получим число 121), но пока опустим этот момент.
let result = compose(increment, and: multiply) result(10) // в результате имеем число 121
P.S. Я специально стараюсь сделать мои примеры более простыми, чтобы было максимально понятно)
На практике же часто нужно сделать что-то вроде этого:
let value: Int? = array .lazy .filter { $0 % 2 == 1 } .first(where: { $0 > 10 })
Это и есть композиция. Мы задаём входное воздействие и получаем некоторое выходное воздействие. Но это не просто сложение каких-то объектов – это сложение целого поведения.
А теперь подумаем более абстрактно :)
В нашем приложении у нас есть какое-то состояние (state). Это может быть экран который в данный момент видит пользователь или текущие данные, которые хранятся в приложении и тд.
Помимо этого у нас есть action — это то действие, которое может сделать пользователь (нажать на кнопку, скрольнуть коллекцию, закрыть приложение и тд). В результате мы оперируем этими двумя понятиями и связываем их друг с другом, то есть комбинируем, хммм комбинируем (где-то я уже это слышал).
А что если создать сущность, которая как раз скомбинирует мой state и action вмест��?
Так мы получим Reducer
struct Reducer<S, A> { let reduce: (S, A) -> S }
На вход метода reduce мы будем давать текущий state и action, а на выходе получим новый state, который образовался внутри reduce.
Описать эту структуру мы можем несколькими способами: задав новый state, используя функциональный метод или используя мутабельные модели.
struct Reducer<S, A> { let reduce: (S, A) -> S } struct Reducer<S, A> { let reduce: (S) -> (A) -> S } struct Reducer<S, A> { let reduce: (inout S, A) -> Void }
Первый вариант «классический».
Второй – более функциональный. Смысл заключается в том, что мы возвращаем не state, а метод, который принимает action, который уже в свою очередь возвращает state. По сути это каррирование метода reduce.
Третий вариант – работа со state по ссылке. При таком подходе, мы не просто выдаем state, а работаем с ссылкой на объект который приходит на вход. Мне кажется, что этот способ не очень хорош, потому что подобные (мутабельные) модельки – это плохо. Лучше пересобирать новый state(instance) и возвращать его. Но для простоты и демонстрации дальнейших примеров, условимся использовать последний вариант.
Применение reducer
Применим концепцию Reducer на существующий код – создадим RequestState, затем инициализируем его, и зададим.
class RequestState { // MARK: - Private properties private let semaphore = DispatchSemaphore(value: 0) private let networkClient: NetworkClient = NetworkClientImp() // MARK: - Public methods func sendRequest<Response: Codable>(_ request: RequestProtocol, completion: ((Result<Response>) -> Void)?) { networkClient.sendRequest(request) { (result: Result<Response>) in completion?(result) self.semaphore.signal() } semaphore.wait() } }
Для синхронности запросов я добавил DispatchSemaphore
Идем дальше. Теперь нам нужно создать RequestAction с, допустим, тремя запросами.
enum RequestAction { case sendFirstRequest(FirstRequest) case sendSecondRequest(SecondRequest) case sendThirdRequest(ThirdRequest) }
Теперь создаем Reducer, у которого есть RequestState и RequestAction. Задаем поведение – что мы хотим делать при первом, втором, третьем запросе.
let requestReducer = Reducer<RequestState, RequestAction> { state, action in switch action { case .sendFirstRequest(let request): state.sendRequest(request) { (result: Result<FirstResponse>) in // 1 Response } case .sendSecondRequest(let request): state.sendRequest(request) { (result: Result<SecondResponse>) in // 2 Response } case .sendThirdRequest(let request): state.sendRequest(request) { (result: Result<ThirdResponse>) in // 3 Response } } }
В конце – вызываем эти методы. Получается более декларативный стиль, при котором видно, что идет первый, второй и третий запросы. Все читаемо и наглядно.
var state = RequestState() requestReducer.reduce(&state, .sendFirstRequest(FirstRequest())) requestReducer.reduce(&state, .sendSecondRequest(SecondRequest())) requestReducer.reduce(&state, .sendThirdRequest(ThirdRequest()))
Вывод
Не бойтесь изучать новое и не бойтесь изучать функциональное программирование. Я думаю, что самые лучшие практики находятся на стыке технологий. Старайтесь комбинировать и брать лучше из разных парадигм программирования.
Если стоит какая-то нетривиальная задача, то есть смысл посмотреть на неё с другого угла.