Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

Reasons to love functional programming

Reasons to love functional programming

⚙️ Swift is a multi-paradigm programming language. It provides opportunities to approach solving problems from different angles. I suggest you consider a practical task from the life of an iOS developer and use it as an example to see the advantages that become available if you are inspired by the concepts of functional programming.

CocoaHeads Ukraine

November 02, 2024
Tweet

More Decks by CocoaHeads Ukraine

Other Decks in Programming

Transcript

  1. Про мене • В iOS розробці з 2013 року •

    FP-enjoyer • Мало не став математиком https://www.linkedin.com/in/dmytro-kovryhin-8298b5218/
  2. Про що поговорим о? 1. Що розуміємо під функціональним програмуванням

    2. Приклад задачі 3. Запропоноване рішення 4. Теоретичні концепції, задіяні в рішенні 5. Висновки
  3. Перш, ніж почнем • Задача розглядається навмисно заплутана, але висновки

    можуть бути застосовані і на простіших • Сподіваюсь, всім подобається Combine • Код з прикладом буде доступний на Github
  4. Що розуміємо під функціональним програмуванням • Функції мають бути максимально

    наближеними до математично визначених функцій • Складні задачі вирішуємо шляхом композиції чистих функцій • Декларації і вирази замість виконання послідовності інструкцій
  5. Алгоритм, який необхідно реалізувати • Мобільний застосунок ініціює запит на

    прийняття замовлення, відправляючи PUT-запит • Цей запит може мати наступні результати: • відповідь з результатом "замовлення прийнято", • відповідь з результатом "замовлення не прийнято” • відповідь з результатом "результат операції ще невідомий” • помилка запиту з результатом "замовлення не прийнято” • інша помилка запиту (невідомо, чи сервер встиг взяти наш запит в роботу) • …
  6. Алгоритм, який необхідно реалізувати • Впродовж певного тайм-ауту застосунок чекає

    результату операції по TCP (результат може бути як позитивний, так і негативний) • В разі неотримання результату операції після тайм-ауту застосунок починає робити GET-запити на отримання результату операції з певними затримками між запитами. • Цей запит може мати наступні результати: • відповідь з результатом "замовлення прийнято", • відповідь з результатом "замовлення не прийнято" • відповідь з результатом "результат операції ще невідомий" • помилка запиту з результатом "замовлення не прийнято" • інша помилка запиту • …
  7. Алгоритм, який необхідно реалізувати • В разі отримання підтвердження прийняття

    замовлення, необхідно отримати його деталі окремим GET-запитом • Цей запит може мати наступні результати: • відповідь з деталями прийнятого замовлення • будь-яка помилка, запит необхідно повторити з затримкою
  8. Моделі public struct EtherOrder { public let uid: UUID public

    init(uid: UUID) { self.uid = uid } // ... }
  9. HTTPClient public struct EtherOrderTakeResponseDto { public enum EtherOrderTakeResponse { case

    accepted, processing, declined(String) } public let result: EtherOrderTakeResponse public init(result: EtherOrderTakeResponse) { self.result = result } } public struct HTTPError: Error { public let userInfo: [AnyHashable: Any] public init(userInfo: [AnyHashable: Any]) { self.userInfo = userInfo } // ... }
  10. HTTPClient public protocol HTTPClientProtocol { func take( etherOrder: UUID )

    -> AnyPublisher<EtherOrderTakeResponseDto, HTTPError> func takingOperationStatus( etherOrder: UUID ) -> AnyPublisher<EtherOrderTakeResponseDto, HTTPError> func getAcceptedOrderDetails( _ uid: UUID ) -> AnyPublisher<AcceptedOrderDetailsDto, HTTPError> }
  11. TCPClient public struct EtherOrderDeclineInfo { public let uid: UUID public

    let reason: String public init(uid: UUID, reason: String) { self.uid = uid self.reason = reason } } public protocol TCPClientProtocol { var etherOrderAccepted: AnyPublisher<UUID, Never> { get } var etherOrderDeclined: AnyPublisher<EtherOrderDeclineInfo, Never> { get } // ... }
  12. TCWWWH struct OperationUseCase { let httpClient: HTTPClientProtocol let tcpClient: TCPClientProtocol

    init(httpClient: HTTPClientProtocol, tcpClient: TCPClientProtocol) { self.httpClient = httpClient self.tcpClient = tcpClient } func take( etherOrder: EtherOrder ) -> AnyPublisher<AcceptedOrder, OrderTakingFailure> { // ... } }
  13. Mocks class TCPClientMock: TCPClientProtocol { var etherOrderAcceptedSubject = PassthroughSubject<UUID, Never>()

    var etherOrderDeclinedSubject = PassthroughSubject<EtherOrderDeclineInfo, Never>() var etherOrderAccepted: AnyPublisher<UUID, Never> { etherOrderAcceptedSubject .eraseToAnyPublisher() } var etherOrderDeclined: AnyPublisher<EtherOrderDeclineInfo, Never> { etherOrderDeclinedSubject .eraseToAnyPublisher() } }
  14. Mocks class HTTPClientMock: HTTPClientProtocol { var takeEtherOrderClosure: ((UUID) -> AnyPublisher<EtherOrderTakeResponseDto,

    HTTPError>)? var takingOperationStatusClosure: ((UUID) -> AnyPublisher<EtherOrderTakeResponseDto, HTTPError>)? var getAcceptedOrderDetailsClosure: ((UUID) -> AnyPublisher<AcceptedOrderDetailsDto, HTTPError>)? func take( etherOrder: UUID ) -> AnyPublisher<EtherOrderTakeResponseDto, HTTPError> { return takeEtherOrderClosure.map({ $0(etherOrder) })! } func takingOperationStatus( etherOrder: UUID ) -> AnyPublisher<EtherOrderTakeResponseDto, HTTPError> { return takingOperationStatusClosure.map({ $0(etherOrder) })! } func getAcceptedOrderDetails( _ uid: UUID ) -> AnyPublisher<AcceptedOrderDetailsDto, HTTPError> { return getAcceptedOrderDetailsClosure.map({ $0(uid) })! } }
  15. Preparations struct Experiment { let putRequestResults: [EtherTakingResult] let getRequestResults: [EtherTakingResult]

    let orderDetailsResults: [AcceptedOrderDetailsResult] let httpClient = HTTPClientMock() let tcpClient = TCPClientMock() init( putRequestResults: [EtherTakingResult], getRequestResults: [EtherTakingResult] = [EtherTakingResult](), orderDetailsResults: [AcceptedOrderDetailsResult] = [AcceptedOrderDetailsResult]() ) { self.putRequestResults = putRequestResults self.getRequestResults = getRequestResults self.orderDetailsResults = orderDetailsResults } }
  16. Preparations enum EtherTakingResult { case accepted case processing case longProcessing(TimeInterval)

    case declined(String) case error(HTTPError) case longError(HTTPError, TimeInterval) }
  17. Preparations func setup() -> OperationUseCase { // PUT-request mock responses

    var putEtherTakingResponses: [AnyPublisher<EtherOrderTakeResponseDto, HTTPError>] = etherTakingResponseDTOs( putRequestResults ) httpClient.takeEtherOrderClosure = { _ -> AnyPublisher<EtherOrderTakeResponseDto, HTTPError> in putEtherTakingResponses.popFirst()! } // GET-request mock responses var getEtherTakingResponses: [AnyPublisher<EtherOrderTakeResponseDto, HTTPError>] = etherTakingResponseDTOs( getRequestResults ) httpClient.takingOperationStatusClosure = { _ -> AnyPublisher<EtherOrderTakeResponseDto, HTTPError> in getEtherTakingResponses.popFirst()! } // GET order details mock responses var orderDetailsResponses: [AnyPublisher<AcceptedOrderDetailsDto, HTTPError>] = acceptedOrderDTOs( orderDetailsResults ) httpClient.getAcceptedOrderDetailsClosure = { _ -> AnyPublisher<AcceptedOrderDetailsDto, HTTPError> in return orderDetailsResponses.popFirst()! } return OperationUseCase( httpClient: httpClient, tcpClient: tcpClient ) }
  18. Preparations func expectSuccess( _ useCase: OperationUseCase, intervalBetweenRetries: TimeInterval = 0.001,

    delayBeforeFirstGetRequest: TimeInterval = 0.001, for expectation: XCTestExpectation ) -> AnyCancellable { useCase.take( etherOrder: etherOrder, intervalBetweenRetries: intervalBetweenRetries, delayBeforeFirstGetRequest: delayBeforeFirstGetRequest ) .sink { _ in } receiveValue: { acceptedOrder in XCTAssertEqual(acceptedOrder.uid, self.etherOrder.uid) expectation.fulfill() } }
  19. Preparations func expectFailure( _ useCase: OperationUseCase, for expectation: XCTestExpectation, reason:

    String ) -> AnyCancellable { useCase.take(etherOrder: etherOrder) .sink { completion in if case .failure(let decline) = completion { XCTAssertEqual(decline.reason, reason) expectation.fulfill() } } receiveValue: { _ in } }
  20. Unit tests func testSimpleTakeOnFirstPutRequest() throws { let expectation = XCTestExpectation(description:

    "Order successfully accepted on PUT-request") expectation.assertForOverFulfill = true let experiment = Experiment( putRequestResults: [ .accepted ], orderDetailsResults: [ .details(etherOrder.uid) ] ) let useCase = experiment.setup() let cancellable = expectSuccess(useCase, for: expectation) RunLoop.main.run(until: Date().addingTimeInterval(0.1)) wait(for: [expectation], timeout: 0.1) }
  21. Unit tests func testSimpleFailOnFirstPutRequest() throws { let expectation = XCTestExpectation(description:

    "Order declined on PUT-request") expectation.assertForOverFulfill = true let experiment = Experiment( putRequestResults: [ .error(error422(declineReason: "order_cancelled")) ] ) let useCase = experiment.setup() let cancellable = expectFailure(useCase, for: expectation, reason: "order_cancelled") RunLoop.main.run(until: Date().addingTimeInterval(0.1)) wait(for: [expectation], timeout: 0.1) }
  22. Unit tests func testSimpleTakeOnFifthPutRequest() throws { let expectation = XCTestExpectation(description:

    "Order successfully accepted on PUT-request after 4 retries") expectation.assertForOverFulfill = true let experiment = Experiment( putRequestResults: [ .error(error500()), .error(error500()), .error(error500()), .error(error500()), .accepted ], orderDetailsResults: [ .details(etherOrder.uid) ] ) let useCase = experiment.setup() let cancellable = expectSuccess(useCase, for: expectation) RunLoop.main.run(until: Date().addingTimeInterval(0.1)) wait(for: [expectation], timeout: 0.1) }
  23. Unit tests func testTCPAcceptDuringFirstPutRequest() throws { let expectation = XCTestExpectation(description:

    "Order successfully accepted by tcp packet while PUT-request returns processing") expectation.assertForOverFulfill = true let experiment = Experiment( putRequestResults: [ .longProcessing(0.1) ], orderDetailsResults: [ .details(etherOrder.uid) ] ) let useCase = experiment.setup() let cancellable = expectSuccess(useCase, for: expectation) experiment.sendTCPAccept(uid: etherOrder.uid, after: 0.01) RunLoop.main.run(until: Date().addingTimeInterval(0.2)) wait(for: [expectation], timeout: 0.2) }
  24. Unit tests func testTCPDeclineDuringPutRequestFailing() throws { let expectation = XCTestExpectation(description:

    "Order declined by tcp packet while PUT-request fai expectation.assertForOverFulfill = true let experiment = Experiment( putRequestResults: [ .longError(error500(), 0.1), .longError(error500(), 0.1), .longError(error500(), 0.1) ] ) let useCase = experiment.setup() let cancellable = expectFailure(useCase, for: expectation, reason: "order_cancelled") experiment.sendTCPReject( uid: self.etherOrder.uid, after: 0.18, reason: "order_cancelled" ) RunLoop.main.run(until: Date().addingTimeInterval(0.2)) wait(for: [expectation], timeout: 0.2) }
  25. Unit tests func testNoDelayBetweenPUTSuccessAndDetailsRequest() throws { let expectation = XCTestExpectation(description:

    "Order details are requested right after it is known to be accepted") expectation.assertForOverFulfill = true let experiment = Experiment( putRequestResults: [ .accepted ], orderDetailsResults: [ .details(etherOrder.uid) ] ) let useCase = experiment.setup() let cancellable = expectSuccess( useCase, intervalBetweenRetries: 0.2, delayBeforeFirstGetRequest: 0.2, for: expectation ) RunLoop.main.run(until: Date().addingTimeInterval(0.1)) wait(for: [expectation], timeout: 0.1) }
  26. OperationUseCase func pause(for delay: TimeInterval) -> AnyPublisher<Void, Never> { Timer

    .publish( every: delay, on: RunLoop.main, in: .common ) .autoconnect() .first() .map({ _ in () }) .eraseToAnyPublisher() }
  27. OperationUseCase private func getAcceptedOrderDetails( uid: UUID, intervalBetweenRetries: TimeInterval ) ->

    AnyPublisher<AcceptedOrder, OrderTakingFailure> { return httpClient.getAcceptedOrderDetails(uid) .map({ dto in AcceptedOrder(uid: dto.uid) }) .catch { _ in return pause(for: intervalBetweenRetries) .setFailureType(to: OrderTakingFailure.self) .flatMap { _ in return getAcceptedOrderDetails( uid: uid, intervalBetweenRetries: intervalBetweenRetries ) } .eraseToAnyPublisher() } }
  28. OperationUseCase private enum EtherTakingOperationResult { case acceptedNoDetails case declined(String) }

    private enum EtherTakingOperationState { case putRequestNotAcknowledgedYet case putRequestAcknowledgedGetNotStarted case runningGetRequests case result(EtherTakingOperationResult) }
  29. OperationUseCase private func publish( result: EtherTakingOperationResult, for etherOrder: EtherOrder, intervalBetweenRetries:

    TimeInterval ) -> AnyPublisher<AcceptedOrder, OrderTakingFailure> { switch result { case .acceptedNoDetails: return self.getAcceptedOrderDetails( uid: etherOrder.uid, intervalBetweenRetries: intervalBetweenRetries ) case .declined(let reason): return Fail<AcceptedOrder, OrderTakingFailure>(error: OrderTakingFailure(reason: reason)) .eraseToAnyPublisher() } }
  30. OperationUseCase private func tcpPacketAboutAccepted( order: EtherOrder ) -> AnyPublisher<EtherTakingOperationResult, Never>

    { tcpClient.etherOrderAccepted .filter({ $0 == order.uid }) .map({ _ in EtherTakingOperationResult.acceptedNoDetails }) .eraseToAnyPublisher() } private func tcpPacketAboutDeclined( order: EtherOrder ) -> AnyPublisher<EtherTakingOperationResult, Never> { tcpClient.etherOrderDeclined .filter({ $0.uid == order.uid }) .map({ info in EtherTakingOperationResult.declined(info.reason) }) .eraseToAnyPublisher() }
  31. OperationUseCase private struct EtherTakingOperation { let state: EtherTakingOperationState let order:

    EtherOrder let intervalBetweenRetries: TimeInterval let delayBeforeFirstGetRequest: TimeInterval func move( to updatedState: EtherTakingOperationState ) -> EtherTakingOperation { return EtherTakingOperation( state: updatedState, order: order, intervalBetweenRetries: intervalBetweenRetries, delayBeforeFirstGetRequest: delayBeforeFirstGetRequest ) } }
  32. OperationUseCase private func parseOperation( from error: HTTPError, starting operation: EtherTakingOperation

    ) -> EtherTakingOperation { if let declineReason = error.userInfo["decline_reason"] as? String { return operation.move( to: .result(.declined(declineReason)) ) } else { return operation } }
  33. OperationUseCase private func parseOperation( dto: EtherOrderTakeResponseDto, starting operation: EtherTakingOperation )

    -> EtherTakingOperation { return switch dto.result { case .accepted: operation.move( to: .result(.acceptedNoDetails) ) case .declined(let reason): operation.move( to: .result(.declined(reason)) ) case .processing: switch operation.state { case .putRequestNotAcknowledgedYet: operation.move( to: .putRequestAcknowlegedGetNotStarted ) default: operation.move( to: .runningGetRequests ) } } }
  34. OperationUseCase private func attemptPutRequest( starting operation: EtherTakingOperation ) -> AnyPublisher<EtherTakingOperation,

    Never> { return httpClient.take(etherOrder: operation.order.uid) .map { dto in self.parseOperation(dto: dto, starting: operation) } .catch({ error in Just(self.parseOperation(from: error, starting: operation)) .setFailureType(to: Never.self) }) .eraseToAnyPublisher() }
  35. OperationUseCase private func attemptGetRequest( starting operation: EtherTakingOperation ) -> AnyPublisher<EtherTakingOperation,

    Never> { return httpClient.takingOperationStatus(etherOrder: operation.order.uid) .map { dto in self.parseOperation(dto: dto, starting: operation) } .catch({ error in Just(self.parseOperation(from: error, starting: operation)) .setFailureType(to: Never.self) }) .eraseToAnyPublisher() }
  36. OperationUseCase private func iterate( operation: EtherTakingOperation ) -> AnyPublisher<EtherTakingOperation, Never>

    { switch operation.state { case .putRequestNotAcknowledgedYet: return putRequest( starting: operation ) case .putRequestAcknowledgedGetNotStarted: return firstGetRequest( starting: operation ) case .runningGetRequests: return commonGetRequest( starting: operation ) case .result: return Just(operation) .setFailureType(to: Never.self) .eraseToAnyPublisher() } }
  37. OperationUseCase private func putRequest( starting operation: EtherTakingOperation ) -> AnyPublisher<EtherTakingOperation,

    Never> { return attemptPutRequest(starting: operation) .flatMap { operation in if case .putRequestNotAcknowledgedYet = operation.state { return pause(for: operation.intervalBetweenRetries) .flatMap { _ in return iterate( operation: operation ) } .eraseToAnyPublisher() } else { return iterate( operation: operation ) } } .eraseToAnyPublisher() }
  38. OperationUseCase private func firstGetRequest( starting operation: EtherTakingOperation ) -> AnyPublisher<EtherTakingOperation,

    Never> { return pause(for: operation.delayBeforeFirstGetRequest) .flatMap { _ in attemptGetRequest(starting: operation) .flatMap { operation in if case .result = operation.state { return iterate( operation: operation ) } else { return pause(for: operation.intervalBetweenRetries) .flatMap { _ in return iterate( operation: operation ) } .eraseToAnyPublisher() } } } .eraseToAnyPublisher() }
  39. OperationUseCase private func commonGetRequest( starting operation: EtherTakingOperation ) -> AnyPublisher<EtherTakingOperation,

    Never> { return attemptGetRequest(starting: operation) .flatMap { operation in if case .result = operation.state { return iterate( operation: operation ) } else { return pause(for: operation.intervalBetweenRetries) .flatMap { _ in return iterate( operation: operation ) } .eraseToAnyPublisher() } } .eraseToAnyPublisher() }
  40. OperationUseCase private func attemptToAccept( order: EtherOrder, intervalBetweenRetries: TimeInterval, delayBeforeFirstGetRequest: TimeInterval

    ) -> AnyPublisher<EtherTakingOperationResult, Never> { iterate( operation: EtherTakingOperation( state: .putRequestNotAcknowledgedYet, order: order, intervalBetweenRetries: intervalBetweenRetries, delayBeforeFirstGetRequest: delayBeforeFirstGetRequest ) ) .compactMap { operation in switch operation.state { case .result(let etherTakingOperationResult): return etherTakingOperationResult default: return nil } } .eraseToAnyPublisher() }
  41. OperationUseCase public func take( etherOrder: EtherOrder, intervalBetweenRetries: TimeInterval = 0.001,

    delayBeforeFirstGetRequest: TimeInterval = 0.001 ) -> AnyPublisher<AcceptedOrder, OrderTakingFailure> { let tcpAccept = tcpPacketAboutAccepted(order: etherOrder) let tcpDecline = tcpPacketAboutDeclined(order: etherOrder) let httpPublisher = attemptToAccept( order: etherOrder, intervalBetweenRetries: intervalBetweenRetries, delayBeforeFirstGetRequest: delayBeforeFirstGetRequest ) return httpPublisher .merge(with: tcpAccept) .merge(with: tcpDecline) .first() .flatMap({ result in return publish( result: result, for: etherOrder, intervalBetweenRetries: intervalBetweenRetries ) }) .receive(on: DispatchQueue.main) .eraseToAnyPublisher() }
  42. Що цікавого в коді • Жоден метод не модифікував змінні

    поза своїм скоупом • Кожен метод вміщається в слайд* • Кожен метод може легко покриватись тестами окремо • При цьому реалізований функціонал включає в себе синхронізацію паралельних процесів з затримками і тайм-аутами
  43. Декларативне програмування • В описі бажаних результатів помилитися важче, ніж

    в реалізації алгоритму досягнення бажаного результата • В загальному випадку - декларативний код простіше читається, ніж імперативний
  44. Підхід до розробки за циклом Red-Green- Refactor, де спочатку з’являється

    API, потім тести і лише після них - реалізація
  45. Чисті функції • Просте покриття тестами • Потокобезпечність • В

    загальному випадку - простіше читаються • Локальність поведінки
  46. Перетворення функції, що може мати сайд-ефекти, на еквівалентну чисту функцію*

    * зовсім це не визначення, а всього лише властивість
  47. Умови для формального визначення терміну “монада” • Категорія (об’єкти, морфізми

    + аксіоми) • Комутативні діаграми (для візуалізації правил композиції) • Функтор (перетворення категорій, що зберігають композиції) • Природні перетворення (перетворення функторів, що “не погіршують їх властивості”) • Горизонтальна композиція природних перетворень • Монада - триплет ендофунктор + пара природних перетворень, що задовольняють наступним комутативним діаграмам: *не намагатись запам’ятати
  48. Reader-Writer монада • Замість модифікації глобального стану, приймаємо, серед іншого,

    попередній стан на вхід, і віддаємо наступний на вихід. За рахунок цього навіть операції, що передбачають модифікацію глобального стейту можуть бути представлені у вигляді чистих функцій (див. EtherTakingOperation для прикладу) https://bartoszmilewski.com/2014/12/23/kleisli-categories/
  49. Висновки • Чисті функції особливо просто тестуються, є потокобезпечними і,

    якщо достатньо декомпозовані - простими у сприйнятті • FRP надає інструменти для реалізації маніпуляцій з потоками івентів у термінах чистих функцій • Навіть там, де сайд-ефектів не уникнути, монади можуть допомогти знайти еквівалентне рішення у термінах чистих функцій