Привет, Хабр. Меня зовут Максим, я 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()))
Вывод
Не бойтесь изучать новое и не бойтесь изучать функциональное программирование. Я думаю, что самые лучшие практики находятся на стыке технологий. Старайтесь комбинировать и брать лучше из разных парадигм программирования.
Если стоит какая-то нетривиальная задача, то есть смысл посмотреть на неё с другого угла.