[iOS/Combine] Transforming Operators

2024. 12. 5. 15:01·IOS - Swift/Combine
반응형

Transforming Operators의 핵심 개념

  • Transforming operators는 upstream publisher로부터 받은 값들을 변형해서 downstream으로 전달하는 연산자입니다.
  • 모든 operator는 publisher이며, upstream 이벤트를 받아서 조작한 후 downstream으로 전달합니다.
  • 에러 처리가 필요한 경우가 아니라면 upstream의 에러를 그대로 downstream으로 전달합니다.

Map

map은 업스트림 퍼블리셔의 모든 값의 타입을 변환시키는 Operator입니다.

특징

map 연산자는 업스트림 퍼블리셔로부터 전달받은 각 요소를 지정된 클로저를 사용해 변환합니다.

Swift 문법 map 함수와 비슷하게 동작하고, 한 종류의 데이터를 다른 종류로 변환할 때 사용됩니다.

구성

 public func map<Result>(
        _ transform: @escaping (Output) -> Result
    ) -> Publishers.Map<Self, Result> {
        return Publishers.Map(upstream: self, transform: transform)
    }

transform 클로저는 업스트림 Publisher의 Output을 인자로 가지고 있는데 이 클로저에서 원하는 형태의 타입으로 리턴시켜주면, Publishers.Map<Self, T> 타입으로 Publisher가 변환됩니다.

 

구조

extension Publishers {
    /// A publisher that transforms all elements from the upstream publisher with
    /// a provided closure.
    public struct Map<Upstream: Publisher, Output>: Publisher {

        public typealias Failure = Upstream.Failure

        /// The publisher from which this publisher receives elements.
        public let upstream: Upstream

        /// The closure that transforms elements from the upstream publisher.
        public let transform: (Upstream.Output) -> Output

        public init(upstream: Upstream,
                    transform: @escaping (Upstream.Output) -> Output) {
            self.upstream = upstream
            self.transform = transform
        }

        public func receive<Downstream: Subscriber>(subscriber: Downstream)
            where Output == Downstream.Input, Downstream.Failure == Upstream.Failure
        {
            upstream.subscribe(Inner(downstream: subscriber, map: transform))
        }
    }

결국 Publishers.Map<Self, T> 는 Publishers.Map<Upstream, Ouput> 이고,

receive 메서드에서 subscriber을 전달받을 때에는 Map의 Output이 Subscriber의 Input, 그리고 Upstream의 Failure이 Subscriber의 Failure이 전달됩니다

 

사용

사용 예시: 로마 숫자로 변환하기

let numbers = [5, 4, 3, 2, 1, 0]
let romanNumeralDict: [Int : String] = [1:"I", 2:"II", 3:"III", 4:"IV", 5:"V"]

let cancellable = numbers.publisher // 스트림 시작
    .map { romanNumeralDict[$0] ?? "(unknown)" } // 각 Int를 로마자로 변환
    .sink { print("\\($0)", terminator: " ") } // 결과출력

// 출력: "V IV III II I (unknown)"

map 연산자는 숫자 배열의 각 정수를 로마 숫자로 변환하고 변환할 수 없는 경우에는 "(unknown)"을 반환하여 스트림을 계속 유지합니다.

  1. Int 배열을 publisher로 변환하여 스트림을 시작합니다.
  2. 여기서 numbers 배열이 publisher로 변환되어 데이터 스트림의 시작점이 됩니다.
  3. map 연산자를 사용하여 각 Int 값을 로마 숫자 문자열로 변환합니다.
  4. 스트림을 통해 전달되는 각 정수 값에 대해 romanNumeralDict 딕셔너리를 참조하여 해당하는 로마 숫자로 변환합니다. 만약 딕셔너리에 해당 키가 존재하지 않으면 "(unknown)" 문자열을 사용합니다.
  5. sink를 사용하여 구독을 생성하고, 스트림을 구독하여 결과를 출력합니다.
  6. 구독이 시작되면 스트림이 활성화되고, 최종적으로 "V IV III II I (unknown)"이 출력됩니다.

흐름

TryMap

업스트림 퍼블리셔로부터 모든 요소를 제공된 에러를 던질 수 있는 클로저로 변환하는 퍼블리셔

public struct TryMap<Upstream: Publisher, Output>: Publisher {

public typealias Failure = Error

/// 이 퍼블리셔가 요소를 수신하는 원본 퍼블리셔.
public let upstream: Upstream

/// 원본 퍼블리셔의 요소를 변환하는 에러를 던질 수 있는 클로저.
public let transform: (Upstream.Output) throws -> Output

public init(upstream: Upstream,
            transform: @escaping (Upstream.Output) throws -> Output) {
    self.upstream = upstream
    self.transform = transform
}
}

map이랑 비슷한데 앞에 try가 붙었다는 차이가 있습니다.

네

    public func tryMap<Result>(
        _ transform: @escaping (Output) throws -> Result
    ) -> Publishers.TryMap<Upstream, Result> {
        return .init(upstream: upstream) { try transform(self.transform($0)) }
    }
}

이런식으로 에러를 던질수가 있습니다~!

예시

import Combine

//에러정의
enum SoptError: Error {
    case invalidNumber(Int)
}

let numbers = [5, 4, 3, 2, 1, 0]
let romanNumeralDict: [Int : String] = [1:"I", 2:"II", 3:"III", 4:"IV", 5:"V"]

// 변환 함수 정의 : num이 nil이면 SoptError 던짐
func handleNumber(_ num: Int) throws -> String {
    guard let roman = romanNumeralDict[num] else {
        throw SoptError.invalidNumber(num)
    }
    return roman
}

let cancellable = numbers.publisher
    .tryMap { num in
        try handleNumber(num)  // 에러가 발생할 수 있는 변환 수행
    }
    .sink(
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("\\n모든 변환이 완료되었습니다.")
            case .failure(let error):
                print("\\n에러 발생: \\(error)")
            }
        },
        receiveValue: { value in
            print(value, terminator: " ")
        }
    )

// 출력:
// V IV III II I 
// 에러 발생: invalidNumber(0)

handleNumber가 에러를 던질 수 있게 만들어졌고, 만약 num이 nil이면 SoptError을 던지게 해놨어요.

없는 값 만남 > SoptError던짐 > tryMap이 에러 받음 > failure 발생> publish종료.

이런 flow 입니다.

흐름

여기까지 요약

map vs tryMap:

  • map: 단순 변환 시 사용. 변환 실패 시 기본값을 반환하거나, 에러를 던지지 않음.
  • tryMap: 변환 과정에서 에러를 던질 수 있음. 에러 발생 시 스트림이 .failure 상태로 종료되어 즉시 에러를 처리할 수 있음.

flatMap

trymap이 try하는 map이였으니까 flatmap은 flat한 맵이겟다(?)

정의

public func flatMap<Result, Child: Publisher>(
    maxPublishers: Subscribers.Demand = .unlimited,
    _ transform: @escaping (Output) -> Child
) -> Publishers.FlatMap<Child, Self>
    where Result == Child.Output, Failure == Child.Failure {
        return .init(upstream: self,
                     maxPublishers: maxPublishers,
                     transform: transform)
}

maxPublisher로 최대로 동시에 가질 수 있는 Publisher의 수를 지정하거나 아니면 무제한으로 지정할 수 있습니다(기본값은 무제한)

transform 클로저는 새로운 Publisher(Publishers.FlatMap)를 리턴한다.

특징

  • 이벤트가 정상적으로 완료되어도 전체 스트림이 완료되지는 않는다
  • 하지만 flatMap이 실패(Publisher변환이 실패)하면 전체 스트림이 실패처리된다.
  • Demand 값을 조절할 수 있다.

Publishers.FlatMap 의 receive(subscriber:) 부분을 보면

public struct FlatMap<NewPublisher, Upstream> : Publisher where 
NewPublisher : Publisher, 
Upstream : Publisher, 
NewPublisher.Failure == Upstream.Failure 
{
  public func receive<S>(subscriber: S) where S : Subscriber,
																						  NewPublisher.Output == S.Input,
																						  Upstream.Failure == S.Failure
}

NewPublisher의 Output을 Subscriber의 Input으로, Upstream의 Failure은 Subscriber의 Failure로 삼게됩니다!

→ Publisher(NewPublisher)의 Output 타입은 구독자(Subscriber)가 받을 수 있는 Input 타입과 같아야 한다

⇒ 데이터 타입과 에러 타입이 서로 호환되도록 보장된다! (에러핸들링이 편해진다)

사용

public struct WeatherStation {
    public let stationID: String
}

//3개의 값이 publish된다고 가정
var weatherPublisher = PassthroughSubject<weatherstation, urlerror="">()  // 1

let cancellable = weatherPublisher
  .flatMap { station -> URLSession.DataTaskPublisher in  // 2 
    let url = URL(string:"<https://weatherapi.example.com/stations/\\(station.stationID)/observations/latest>")!
    return URLSession.shared.dataTaskPublisher(for: url)
  }
  .sink(  // 3
    receiveCompletion: { completion in
      switch completion {
      case .finished:
        print("finished")
      case .failure(let failure):
        print(failure)
      }
    },
    receiveValue: { value in
      print(value)
    }
 )

//출력: 여러 도시의 WeatherStation을 발행
weatherPublisher.send(WeatherStation(stationID: "KSFO")) // San Francisco, CA
weatherPublisher.send(WeatherStation(stationID: "EGLC")) // London, UK
weatherPublisher.send(WeatherStation(stationID: "ZBBB")) // Beijing, CN
</weatherstation,>
  1. WeatherStation 타입을 받는 PassthroughSubject를 통해 퍼블리셔 발행
  2. 이를 flatMap을 통해 URLSession.DataTaskPublisher 로 변환한다.
  3. sink는 이제 초기 Publisher의 Output인 WeatherStation이 아니라**,** flatMap에서 리턴되는 새로운 Publisher인 ****URLSession.DataTaskPublisher를 구독합니다.

결론

즉, Publisher가 또 다른 Publisher를 반환할 때 발생하는 중첩을 평탄화(flat) 하게 만들고 (Publisher<Publisher<String, Error>, Error> → Publisher<String, Error> )

  • 네트워크 요청처럼 각각이 Publisher를 반환하는 비동기 작업들을 연결할 때 사용
  • WeatherStation 케이스처럼, 각 날씨 정보에 대해 새로운 네트워크 요청을 만들 때 사용

CompactMap

    public func compactMap<ElementOfResult>(
        _ transform: @escaping (Output) -> ElementOfResult?
    ) -> Publishers.CompactMap<Self, ElementOfResult> {
        return .init(upstream: self, transform: transform)
    }

기능:

  • 입력 스트림의 각 요소를 클로저에 전달하고, 해당 클로저가 반환한 값이 nil이 아니면 그대로 방출.
  • nil 값은 필터링되어 스트림에서 제외.

→ compactMap은 nil을 걸러내고 싶을 때 사용하는 연산자이다!

사용

let numbers = (0...5)
let romanNumeralDict: [Int: String] = [1: "I", 2: "II", 3: "III", 5: "V"]

let cancellable = numbers.publisher
    .compactMap { romanNumeralDict[$0] } // 딕셔너리에서 값 조회, 없으면 nil 반환
    .sink { print("\\($0)", terminator: " ") }// 0과 4는 딕셔너리에 없어서 nil -> 필터링됨
    
    
// 출력: "I II III V"

코드 설명:

  • 숫자 배열을 Publisher로 변환.
  • 각 숫자를 romanNumeralDict에서 키로 사용해 값을 검색. 값이 없으면 nil 반환.
  • nil 값은 필터링되고, 나머지 값은 출력.

 

 

엥 map이랑 compactmap이랑은 그러면 뭐가 다른거지?

// map 예제
let numbers = PassthroughSubject<String, Never>()
numbers
    .map { Int($0) } // 결과는 Optional<Int>
    .sink { print($0) }

numbers.send("123")  // Optional(123)
numbers.send("abc")  // nil

---

// compactMap 예제
let numbers = PassthroughSubject<String, Never>()
numbers
    .compactMap { Int($0) } // nil이 아닌 Int 값만 전달
    .sink { print($0) }

numbers.send("123")  // 123
numbers.send("abc")  // 아무것도 출력되지 않음
  • map:

nil 값을 그대로 downstream으로 전달, Optional을 포함한 모든 타입 가능

  • compactMap:

nil 값을 필터링하여 제거, non-Optional 타입만 전달

scan

구성

public func scan<T>(
  _ initialResult: T,
  _ nextPartialResult: @escaping (T, Publishers.Sequence<Elements, Failure>.Output) -> T
) -> Publishers.Sequence<[T], Failure>

scan은 클로저가 반환한 마지막 값과 현재 값을 클로저에 제공하여 업스트림 Publisher의 요소를 반환하는 Operator

⇒ swift 기본 함수인 reduce 와 비슷한 개념!

기능

  • 발행된 값에 대해 실행될 클로저(nextPartialResult) 가 존재.
  • 이 클로저에서 이전값(없으면 scan의 initialResult)을 인자로 받고 새로운 값 발행.
  • 각 단계에서 축적된 결과를 순차적으로 발행.

nextPartialResult의 결과값 T는 다음 연산의 initialResult로 사용되며, Publisher의 모든 값이 처리될 때까지 이 과정이 반복됩니다.

사용

let arrayPublisher = [1,2,3,4,5].publisher

arrayPublisher.scan(0) { result, value in
  return result + value   // 이전 결과값과 현재값을 더하기
}
.sink { value in
  print(value)
}
.store(in: &cancellables)

// 결과
1
3
6
10
15

scan의 인자에는 숫자가 들어가는데, 이것이 초기값으로 사용됩니다.

result에는 클로저에서 리턴하는 결과값이 들어가게 됩니다.

그래서 0+1, 1+2, 3+3, 6+4, 10+5 의 결과를 보여주게 됩니다.

reduce와 매커니즘은 같지만, reduce는 연산의 완료값 (마지막 값인 15)만 나오게 되지만

  • scan은 실시간으로 데이터를 처리하고 결과를 즉시 사용할 수 있게 해줍니다
  • reduce는 최종 결과값만 반환하는 반면, scan은 모든 중간 계산 결과를 발행합니다

replaceNil

구성

public func replaceNil<ElementOfResult>(
    with output: ElementOfResult
) -> Publishers.Sequence<[Elements.Element], Failure>
    where Elements.Element == ElementOfResult?
{
    return .init(sequence: sequence.map { $0 ?? output })
}

replaceNil은 Optional 값에서 nil을 대체값으로 교체하여 non-optional 값을 반환하는 Operator입니다.

⇒ Swift의 nil 병합 연산자(??)를 Publisher에 적용한 것과 같은 개념!

기능

  • Optional 값을 가진 Publisher에서 nil이 발생할 경우 지정된 기본값으로 대체
  • nil이 아닌 값은 그대로 다운스트림으로 전달
  • Optional 타입을 non-optional 타입으로 변환

예시

let numbers = [1, nil, 3, nil, 5].publisher
numbers
    .replaceNil(with: 0) // nil 값을 0으로 대체
    .sink { print("\\\\($0)", terminator: " ") }
    
// 출력: "1 0 3 0 5"

  • Optional Int 배열을 Publisher로 변환
  • nil 값을 0으로 대체
  • 결과적으로 모든 nil이 0으로 대체되어 출력

흐름


전체 요약

Operator 주요 기능 특징 사용 예시

 

Operator 주요기능 특징 예시
Map 데이터 타입변환 • 단순 변환 수행
• 에러를 던지지 않음
• 실패 시 기본값 반환 가능
Int → String 변환
(숫자를 로마 숫자로)
tryMap 에러처리가 가능한 변환 • map과 유사하나 에러 처리 가능
• 에러 발생 시 스트림 종료
• failure 상태로 전환
데이터 변환 중 예외 처리 필요 시
flatMap 중첩된 Publisher 평탄화 • Publisher를 반환하는 Publisher를 단일 스트림으로
• maxPublishers로 동시 처리 제한 가능
• 개별 이벤트 완료되어도 전체 스트림 유지
네트워크 요청 체이닝
compactMap nil필터링과 변환 • nil 값 자동 필터링
• Optional 언래핑
• 성공한 변환만 downstream으로 전달
String → Int 변환 시 실패 케이스 제거
scan 누적 계산 수행 • 이전 결과값 활용한 계산
• 중간 결과 모두 방출
• reduce와 유사, 중간값 제공
실시간 합계 계산
replacenil nil값 대체 • Optional 값의 nil을 지정값으로 대체
• non-optional로 변환
• nil 아닌 값은 그대로 전달
nil 값을 기본값으로 대체
반응형
저작자표시 비영리 변경금지 (새창열림)

'IOS - Swift > Combine' 카테고리의 다른 글

[iOS/Combine] Combine - Error Handling  (0) 2024.12.12
subscription의 이해  (0) 2024.11.25
Publisher - Just, Empty, Failure  (1) 2024.10.03
Combine - Publisher 기초  (2) 2024.10.01
[iOS/Swift] Combine - 시작하기 (Publisher ,Operator, Subscriber)  (1) 2024.04.17
'IOS - Swift/Combine' 카테고리의 다른 글
  • [iOS/Combine] Combine - Error Handling
  • subscription의 이해
  • Publisher - Just, Empty, Failure
  • Combine - Publisher 기초
게게겍
게게겍
열심히 공부해보고 있습니다
  • 게게겍
    코더라도 되어보자
    게게겍
  • 전체
    오늘
    어제
    • 분류 전체보기
      • IOS - Swift
        • UIkit
        • SwiftUI
        • Combine
      • 혼자 공부한거
      • inflearn
      • 기타
      • 일기
      • firebase
      • CS
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    trymap
    compactMap
    launchscreen
    uikit
    combine
    UIHostingController
    UICollectionView Custom Cell with Horizontal Scroll
    replacemap
    subscription
    #GDSC #캐치카페 #대관 # 대학생 #취준생 #진학사
    viewBuilder
    scan
    ios
    swift openapigenerator
    SwiftUI
    UIViewRepresentable
    flatMap
    Swift
    private
    open
    map
    fileprivate
    INTERNAL
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
게게겍
[iOS/Combine] Transforming Operators
상단으로

티스토리툴바