[Combine] Operators - 01

rbw·2023년 12월 17일
0

Combine

목록 보기
5/11

Operators

https://www.apeth.com/UnderstandingCombine/operators/operatorsstart.html

이번엔 대망의 연산자에 대해 알아보겠슴미다.


오퍼레이터는 업스트림의 끝에 있는 초기 퍼블리셔와 다운스트림의 끝에 있는 최종 구독자 사이의 파이프라인 내에 있는 객체임니다. 오퍼레이터는 오퍼레이터 메서드에 의해 생성되며, 실제로 메서드를 마치 오퍼레이터인 것처럼 말하는 것이 일반적인 용어임니다.

예를 들어 Publishers.Map 연산자를 생성하려면 .map 메서드를 사용함니다.

let url = URL(string:"https://www.apeth.com/pep/manny.jpg")
URLSession.shared.dataTaskPublisher(for: url!)
    .map {$0.data}
    .sink(receiveCompletion: { _ in }){ print($0) }
    .store(in:&self.storage)
// 12212 bytes

따라서 .map을 연산자로 사용하는 것은 자연스러운 일이지만, 실제로는 Publishers.Map 개체가 실제 연산자임니다. 실제 연산자 객체를 직접 인스턴스화하여 퍼블리셔에 수동으로 연결할 수도 있슴니다. 이렇게 하려면 해당 연산자 유형의 init을 호출하면 됨니다. init은 upstream: 파라미터를 포함하고 있슴니다.

  • 어떤 퍼블리셔를 구독할지를 알아야 함니다.
  • 그 퍼블리셔의 출력 및 실패 유형을 알아야 합니다.

아래 코드가 Map 오퍼레이터를 초기화하는 예시임니다.

let url = URL(string:"https://www.apeth.com/pep/manny.jpg")
let pub = URLSession.shared.dataTaskPublisher(for: url!)
let map = Publishers.Map(upstream:pub) { $0.data }
map.sink(receiveCompletion: { _ in }){ print($0) }
    .store(in:&self.storage)

sink 메서드는 구독자인 sink 객체를 생성하고 이를 Map 객체에 구독함니다 Map 객체는 업스트림인 URLSession.DataTaskPublisher 객체에 receive(subscriber:)를 전송하여 응답하므로 이제 파이프라인이 작동하기 시작하고 URLSession이 일부 네트워킹을 수행하며 값이 파이프라인의 끝에서 튀어나오게 됨니다.

Combine의 사용 기술은 크게 파이프라인을 구성할 연산자를 적절하게 선택하고 구성하는 것임니다. 퍼블리셔의 신호를 변환하여 올바른 유형의 신호가 올바른 순서로 파이프라인의 끝에 있는 구독자에게 도달하도록 해야합니다.

Transformers and Blockers

  • Transformers: 업스트림에서 들어오는 값을 다른값으로 다운스트림에 전달하는 업무를 하는 연산자를 의미함니다. 대표적으로 .map 이있슴니다.
  • Blockers: 얘네는 들어오는 값이 다운스트림으로 전달되는 것을 막는 업무를 하는 연산자를 의미함니다. 대표적으로 filter가 있슴니다. 각 값을 검사하여 통과시키거나 막슴니다.

얘네들을 함께 취급하는것이 합리적임니다. 일부 오퍼레이터는 두 가지 기능을 함께 수행합니다. 예를 들어 .compactMap은 값을 중지하거나 값을 대체하고 대체된 값을 다운스트림으로 전달할 수 있는 트랜스포머이자 블로커임니다.

Map

.map(Publsihers.Map)은 업스트림에서 게시된 값을 받아들이는 함수를 사용하여 다른 유형일 수 있는 새 값을 생성함니다. 새 값은 다운스트림에 전송되며, 사실상 업스트림 값을 대체함니다.

URLSession.shared.dataTaskPublsiher(for: url)
    .map { $0.data }

.dataTaskPublisher는 반환유형이 (data: Data, response: URLResponse) 임니다. 위 예제에서는 응답을 무시하고 데이터만 다운스트림으로 진행하고 있슴니다.

tryMap

.map의 변형으로 .tryMap(Publishers.TryMap)이 있슴니다. 이 친구는 맵 함수에 오류를 발생시킬 수 있는 기회를 제공하며, 오류가 발생하면 업스트림을 취소하고 오류를 실패로 파이프라인에 전달함니다. 생성된 오류 타입은 업스트림에서 도착하는 오류 타입과 동일할 필요는 없슴니다. 실제로 Error 타입으로 입력댐니다. 보통 이름에 try 가 포함된 경우의 작업은 위와 마찬가지임니다.

따라서 tryMap은 다운스트림의 오류 타입과 값 타입을 변경할 수 있는 기회임니다.

enum MyError : Error { case oops }
let pub = URLSession.shared.dataTaskPublisher(for: url!)
    .tryMap { tuple -> Data in
        let resp = tuple.response
        if (resp as? HTTPURLResponse)?.statusCode != 200 {
            throw MyError.oops
        }
        return tuple.data
    }

위 코드에서 업스트림 퍼블리셔의 에러 타입은 URLError 이지만 tryMap 에서 에러가 발생하면 MyError가 발생함니다. 업스트림 오류 타입이 Never인 경우에도 tryMap을 사용하여 오류를 생성할 수 있슴니다.

반면에 생성된 오류를 특정 타입의 오류로 던지고 싶다면 아래 연산자를 tryMap 뒤에 사용하면 됨니다. 예를 들어 .mapError { $0 as! URLError } 로 작성하면 다운스트림에서는 에러 타입을 URLError로 예상함니다.

Scan

.scan(Publishers.Scan) 이 친구는 업스트림에서 값을 수신하고 업스트림 값 대신 다운스트림에 전송할 값을 반환하는 맵 함수를 사용하는 점에서 .map과 유사함니다. 그러나 이 맵 함수는 업스트림 퍼블리셔가 내보낸 값을 받지만 함수 자체가 이전에 생성한 값도 받는 두 개의 매개변수를 받슴니다. 스캔이 처음 호출될 때는 이전에 생성된 값이 없으므로 이를 첫 번째 매개변수로 제공해야 함니다.

예를 들어 타이머 퍼블리셔가 현재 날짜와 시간을 계산한다고 할 때 타이머가 몇 번 실행되었는지 체크하는 코드가 있다고 해봅시당

Timer.publish(every: 1, on: .main, in: .common)
    .autoconnect()
    .scan(0) { count, date in count + 1 }

// 결과는 1, 2, 3, ... 1초 간격으로 생성됨니다.

count + 1의 값이 계속 전달되기 떄문에 위와 같은 결과가 나옴니다. 위 코드에서는 들어오는 값(date)를 무시하고 있슴니다. 같이 사용하는 일반적인 경우는 튜플을 생성하는 경우가 있슴니다.

Timer.publish(every: 1, on: .main, in: .common)
.autoconnect()
    .scan((count:0, date:Date.distantPast)) {
        tuple, date in (count:tuple.count + 1, date:date)
    }
// OUTPUT
(count: 1, date: 2023~~)
(count: 2, date: 2023~~)
...

이렇게 값을 생성하고 다운스트림에서 또 원하는 대로 조작할 수 있슴니다. 일반적인 경우로 현재 값과 이전 값으로 구성된 튜플을 만들어서 전달하는 경우임니다.

Timer.publish(every: 1, on: .main, in: .default)
    .autoconnect()
    // Date.distnatPast: 매우 먼 과거라고 하네요 0001-01-01? 이렇다고함
    .scan((prev:Date.distantPast, now:Date())) { (prev: $0.now, now: $1) }
    .map { $0.now.timeIntervalSince($0.prev) }

위 코드에서 스캔은 현재 값과 이전 값을 연결하고, 뒤에 나오는 맵 연산자는 두 값 사이의 시간 차를 추출하므로 파이프라인 끝에 도달하는 값은 이전 타이머 실행 이후의 간격 입니다.

스캔의 첫 매개변수의 초기 값을 일관성 있게 제공할 수 없는 상황에서는 옵셔널을 사용하여 nil을 제공할 수 있슴니다. 위 코드에서도 가짜 값(Date.distantPast)을 사용하고 있지만 Optional<Date>.none 으로 제공하는게 더 좋습니다.

Filter

.filter(Publsihers.Filter) 이 친구는 업스트림에서 게시된 값을 수신하는 함수를 사용해서 일부 테스트를 통과하지 않으면 더 이상 진행되지 않도록 함니다. 이 함수는 해당 값이 파이프라인을 따라 내려가도록 허용할지 여부를 나타내는 불린 값을 반환함니당.

아래 코드는 스위치 값에 따라 신호가 통과할지 말지가 정해짐니다.

Timer.publish(every: 1, on: .main, in: .default)
    .autoconnect()
    .filter {_ in self.mySwitch.isOn}

RemoveDuplicates

.removeDuplicates(Publishers.RemoveDuplicates) 이 친구는 .filter의 특수한 형태로 생각할 수 있으며 scan과 마찬가지로 이전 값을 추적함니다. .filter와 마찬가지로 업스트림에서 도착하는 현재 값을 보낼지 말지를 결정하지만, 이전 값에 대한 정보를 기반으로 이를 수행할 수 있슴니다.

동작과정은 다음과 같슴니다.

  • 업스트림의 첫 번째 값은 의심의 여지없이 .removeDuplicates를 통과함니다. 또한 이 값은 기억됨니다.
  • 업스트림에서 새 값이 도착할 때마다 가장 최근에 기억된 이전 값과 비교하여 새로운 값을 밑으로 전달할지 여부를 결정함니다.
  • 만약 전달하지 않는다면 업스트림의 값이 버려짐니다. 전달한다면 저장된 값은 업스트림의 값으로 업데이트 됨니당
// 필터 조건이 없다면 그냥 중복 제거함니다
let numbers = [0, 1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 0]
cancellable = numbers.publisher
    .removeDuplicates()
    .sink { print("\($0)", terminator: " ") }

// Prints: 0,1,2,3,4,0

// 조건을 정해주면 해당 값이 true인 경우에 중복으로 판단하므로 전달하지 않슴니다. 
let points = [Point(x: 0, y: 0), Point(x: 0, y: 1),
              Point(x: 1, y: 1), Point(x: 2, y: 1)]
cancellable = points.publisher
    .removeDuplicates { prev, current in
        prev.x == current.x
    }
    .sink { print("\($0)", terminator: " ") }

// Prints: Point(x: 0, y: 0) Point(x: 1, y: 1) Point(x: 2, y: 1)

주의할 부분은 true인 경우에 전달하지 않는다는 점임니다. filter랑 반대라는 점을 유의해야함니당.

CompactMap

.compactMap(Publishers.CompactMap) 이 친구는 .map, .filter와 유사한 친구임니다. 맵 함수와 마찬가지로 값을 변환할 수 있고, 필터와 마찬가지로 값이 더 진행되지 않게 하는 기능도 있슴니다.

따라서 이 친구는 어떤 값으로 내려갈지 말지를 한 번에 정해야 합니다. 이를 위해 이 함수가 생성하는 값은 반드시 옵셔널이어야 하는 규칙사항을 따름니다. 옵셔널이 nil 아닌 경우 래핑이 해제되고 해제된 값이 파이프라인을 따라 내려감니다. nil 인 경우에 값이 더 이상 진행되지 않슴니다.

이 친구는 안전하게 캐스팅할 때 유용함니다. 일반적인 사용 사례는 Notifcation을 예로 들 수 있슴니다. userInfo 자체는 옵셔널이며, 키를 바탕으로 값을 가져오면 옵셔널이 생성됨니다. 아래 코드 처럼 사용이 가능함니다

NotificationCenter.default.publisher(for: .zipCodeDidChange)
    .compactMap { $0.userInfo?["zip"] as? String }

userInfo가 없거나 zip키가포함되어 있지 않거나 String이 아니라면 값이 아래로 전달되지 않슴니다.

flatMap, Buffer는 저번에 한 번 게시글을 작성했어서 넘어가겠슴니다.

역압력을 행사할 수 있는 연산자이며, 퍼블리셔를 반환해야하는 부분을 알아두면 좋슴니다.

SwitchToLatest

.switchToLatest(Publishers.SwitchToLatest) 는 실제로 변환 연산자는 아니지만, 게시자를 가져와서 게시를 시작한다는 점에서 .flatMap과 밀접한 관련이 있기 떄문에 가지고왔씀니다. 차이점으로는 아래와 같습니다.

  • .flatMap은 게시자를 만드는 반면, 이 친구는 업스트림에서 수신하는 값으로 게시자를 기대합니다
  • .flatMap은 만든 게시자를 유지하지만, 얘는 가장 최근 게시자를 제외한 모든 게시자를 버림니다.

얘가 하는 일은 업스트림에서 퍼블리셔가 값을 가져올때까지 기다립니다. 가져오면, 해당 퍼블리셔를 유지하고 퍼블리싱을 시작하며, 다운스트림에는 해당 퍼블리셔가 생성한 값을 보게됩니다. 동시에 이미 퍼블리셔를 보유하고 있었다면 새로운 퍼블리셔로 대체합니다. 여기서 인풋 아웃풋 타입은 모두 동일해야함니다.

퍼블리셔의 스트림이라고 생각할 수 있습니다. 주로 .map이 바로위에서 퍼블리셔를 반환하고, 이 친구를 사용해서 밑으로 전달합니다. flatMap과 매우 유사합니다. 차이점은 생성되는 퍼블리셔를 처리하는 방식입니다.

얘는 또 한 퍼블리셔를 다른 퍼블리셔로 바꿀 수 있는 연산자입니다. 여기서는 타이머 퍼블리셔로 예시를 들었슴니다. 유저가 움직이지 않으면 10초 타이머를 두어서 10초가 경과하면 어떤 일이 일어나게 하고 싶습니다. 근데 유저가 움직이면 다시 이 타이머를 초기화해줘야겠져? 해당 기능을 .switchToLatest를 사용해서 만드는 코드입니다.

self.userMoved
    .map { _ -> AnyPublisher<Date,Never> in
        let t = Timer.publish(every: 10, on: .main, in: .common)
        _ = t.connect()
        return AnyPublisher(t)
    }
    .switchToLatest()

userMovedPassthroughSubject 변수임니다. 움직일 때마다 이벤트를 방출하여 타이머를 생성합니다. 이 때 .switchToLatest를 사용하면 이전의 타이머 퍼블리셔는 버려지고 새 타이머 퍼블리셔로 대체됩니다.

No Values

두 개의 매우 간단한 연산자가 값이 없는 상황을 관리함니다.

.ignoreOutput(Publishers.IgnoreOutput) 이 친구는 다운스트림에 값이 없음을 보장합니다 이 함수는 들어오는 모든 값을 삼키고 아무것도 출력하지 않습니다. 단지 업스트림에서 받은 완료값을 전달할 뿐입니다.

.replaceEmpty(Publishesrs.ReplaceEmpty)는 값이 없는 업스트림을 처리함니다. 위에 이그노어랑은 반대되는 개념으로 무(無)를 유(有)로 변환합니다. 이 연산자는 업스트림 출력 유형의 단일 값으로 초기화되어 저장됩니다.

값이나 오류가 파이프라인을 따라 내려오면 이 연산자는 이를 아래로 전달하고 다른 작업을 수행하지 않습니다. 그러나 이전에 파이프라인을 따라 내려온 값이나 오류없이 .finished가 파이프라인을 따라 내려오면 이 연산자는 저장된 값을 아래로 전달한 다음 완료 .finished 를 보냅니다.

아무일도 하지않는 파이프라인이 무언가를 할 수 있도록 만들어줌니다.

myPublisher
    .map(Optional.init)
    .replaceEmpty(with:nil)
profile
hi there 👋

0개의 댓글