트랜스듀서

트랜스듀서 (Transducers)

트랜스듀서(transducers)는 합성 가능한 알고리즘적 변환이에요. 그것들은 입력·출력 소스의 컨텍스트와 독립적이며, 개별 요소 단위로 변환의 본질만 명시해요. 트랜스듀서가 입력이나 출력 소스와 분리되어 있으므로, 컬렉션, 스트림, 채널, 옵저버블 등 많은 다른 프로세스에서 사용될 수 있어요. 트랜스듀서는 입력 인식이나 중간 집계물 생성 없이 직접 합성돼요.

입문용 블로그 게시물, 이 비디오, 그리고 FAQ의 트랜스듀서의 좋은 사용 사례에 관한 이 섹션도 참고하세요.

출처: Clojure 공식 문서 - Transducers

본문

용어 (Terminology)

축약 함수(reducing function)reduce에 전달할 그런 종류의 함수예요 — 누적된 결과와 새 입력을 받아 새 누적 결과를 반환하는 함수랍니다:

;; reducing function signature
whatever, input -> whatever

트랜스듀서(때로는 xform이나 xf)는 한 축약 함수를 다른 축약 함수로 변환하는 것이에요:

;; transducer signature
(whatever, input -> whatever) -> (whatever, input -> whatever)

트랜스듀서로 변환 정의하기 (Defining Transformations With Transducers)

Clojure에 포함된 대부분의 시퀀스 함수는 트랜스듀서를 생산하는 인자 수(arity)를 가져요. 이 arity는 입력 컬렉션을 생략해요; 입력은 트랜스듀서를 적용하는 프로세스가 제공해요. 참고: 이 축소된 arity는 커링(currying)이나 부분 적용(partial application)이 아니에요.

예를 들어:

(filter odd?) ;; returns a transducer that filters odd
(map inc)     ;; returns a mapping transducer for incrementing
(take 5)      ;; returns a transducer that will take the first 5 values

트랜스듀서는 보통의 함수 합성으로 합성돼요. 트랜스듀서는 그 연산을 수행한 다음, 감싸는 트랜스듀서를 몇 번 호출할지(또는 호출할지 말지)를 결정해요. 트랜스듀서를 합성하는 권장 방법은 기존 comp 함수를 사용하는 거예요:

(def xf
  (comp
    (filter odd?)
    (map inc)
    (take 5)))

트랜스듀서 xf는 프로세스가 일련의 입력 요소에 적용할 변환 스택이에요. 스택의 각 함수는 그것이 감싸는 연산 이전에 수행돼요. 변환기의 합성은 오른쪽에서 왼쪽으로 실행되지만, 왼쪽에서 오른쪽으로 실행되는 변환 스택을 만든답니다(이 예시에서 필터링은 매핑 전에 일어나요).

기억하는 방법으로, comp 안의 트랜스듀서 함수 순서는 pass:[->>] 안의 시퀀스 변환 순서와 같다는 점을 기억하세요. 위 변환은 다음 시퀀스 변환과 동등해요:

(->> coll
     (filter odd?)
     (map inc)
     (take 5))

다음 함수들은 입력 컬렉션이 생략되면 트랜스듀서를 생산해요: map cat mapcat filter remove take take-while take-nth drop drop-while replace partition-by partition-all keep keep-indexed map-indexed distinct interpose dedupe random-sample

트랜스듀서 사용하기 (Using Transducers)

트랜스듀서는 많은 컨텍스트에서 사용될 수 있어요 (새 것을 만드는 방법은 아래 참고).

transduce

트랜스듀서를 적용하는 가장 흔한 방법 중 하나는, 표준 reduce 함수와 유사한 transduce 함수를 사용하는 거예요:

(transduce xform f coll)
(transduce xform f init coll)

transducecoll에 대해 트랜스듀서 xform이 축약 함수 f에 적용된 상태로 즉시(지연이 아니라) 축약하며, init이 제공되면 초기 값으로 사용하고 그렇지 않으면 (f)를 사용해요. f는 결과를 어떻게 누적할지에 대한 지식을 제공하며, 이것은 reduce의 (잠재적으로 상태 있는) 컨텍스트에서 일어나요.

(def xf (comp (filter odd?) (map inc)))
(transduce xf + (range 5))
;; => 6
(transduce xf + 100 (range 5))
;; => 106

합성된 xf 트랜스듀서는 축약 함수 f에 대한 최종 호출과 함께 왼쪽에서 오른쪽으로 호출돼요. 마지막 예시에서 입력 값은 필터링된 다음, 증가되고, 마지막으로 합산돼요.

아래 그림은 중첩된 변환(nested transformations)이 어떻게 이루어지는지, 여러 트랜스듀서가 입력 데이터 스트림에 연쇄적으로 적용되는 모습을 보여줘요.

중첩된 변환 (Nested transformations)

eduction

트랜스듀서를 coll에 적용하는 프로세스를 포착하려면 eduction 함수를 사용해요. 그것은 임의 수의 xform과 최종 coll을 받고, 트랜스듀서를 coll의 항목들에 적용하는 reducible/iterable을 반환해요. 이 적용은 reduce/iterator가 호출될 때마다 수행돼요.

(def iter (eduction xf (range 5)))
(reduce + 0 iter)
;; => 6

into

트랜스듀서를 입력 컬렉션에 적용하고 새 출력 컬렉션을 구성하려면 into를 사용해요 (가능하면 reduce와 transients를 효율적으로 사용해요):

(into [] xf (range 1000))

sequence

트랜스듀서를 입력 컬렉션에 적용한 결과에서 시퀀스를 만들려면 sequence를 사용해요:

(sequence xf (range 1000))

결과 시퀀스 요소는 점진적으로 계산돼요. 이 시퀀스들은 필요에 따라 입력을 점진적으로 소비하고 중간 연산을 완전히 실현(realize)해요. 이 동작은 지연 시퀀스에서의 동등한 연산과 달라요.

트랜스듀서 만들기 (Creating Transducers)

트랜스듀서는 다음 형태를 가져요("..." 안의 사용자 지정 코드):

(fn [rf]
  (fn ([] ...)
      ([result] ...)
      ([result input] ...)))

많은 핵심 시퀀스 함수(map, filter 등)는 연산 특정 인자(술어, 함수, 개수 등)를 받고, 이 인자들을 닫는 이런 형태의 트랜스듀서를 반환해요. cat 같은 일부 경우에 핵심 함수 트랜스듀서 함수이고 rf를 받지 않아요.

내부 함수는 다른 목적에 사용되는 3개의 arity로 정의돼요:

  • Init(arity 0) - 중첩 변환 rf에 init arity를 호출해야 하며, 이것은 결국 변환 프로세스(transducing process)로 호출돼요.
  • Step(arity 2) - 이것은 표준 축약 함수이지만, 트랜스듀서에서 적절히 rf step arity를 0회 이상 호출해야 해요. 예를 들어 filter는 (술어에 기반해) rf를 호출할지 말지 선택해요. map은 항상 정확히 한 번 호출해요. cat은 입력에 따라 여러 번 호출할 수 있어요.
  • Completion(arity 1) - 일부 프로세스는 끝나지 않지만, 끝나는 프로세스(transduce 같은)를 위해 completion arity는 최종 값을 생산하거나 상태를 플러시하는 데 사용돼요. 이 arity는 rf completion arity를 정확히 한 번 호출해야 해요.

completion의 예시 용도는 partition-all인데, 입력 끝에 남은 요소들을 플러시해야 해요. completing 함수를 사용해 반환된 축약 함수를 기본 completion arity를 추가함으로써 변환 함수로 변환할 수 있어요.

조기 종료 (Early termination)

Clojure에는 reduce의 조기 종료를 지정하는 메커니즘이 있어요:

  • reduced - 값을 받아 축약이 멈춰야 함을 나타내는 reduced 값으로 반환해요
  • reduced? - 값이 _reduced_로 만들어졌으면 true 반환
  • deref 또는 @는 reduced 안의 값을 검색하는 데 사용될 수 있어요

트랜스듀서를 사용하는 프로세스는 step 함수가 reduced 값을 반환하면 검사하고 멈춰야 해요(Transducible Processes 만들기에서 더 다룸). 추가로, 중첩된 reduce를 사용하는 트랜스듀서 step 함수는 reduced 값을 만났을 때 검사하고 전달해야 해요. (cat의 구현을 참고하세요.)

축약 상태를 가진 트랜스듀서 (Transducers with reduction state)

일부 트랜스듀서(take, partition-all 등)는 축약 과정에서 상태를 요구해요. 이 상태는 변환 프로세스가 트랜스듀서를 적용할 때마다 생성돼요. 예를 들어 일련의 중복 값을 단일 값으로 접는 dedupe 트랜스듀서를 생각해 보세요. 이 트랜스듀서는 현재 값이 전달돼야 하는지 결정하려면 이전 값을 기억해야 해요:

(defn dedupe []
  (fn [xf]
    (let [prev (volatile! ::none)]
      (fn
        ([] (xf))
        ([result] (xf result))
        ([result input]
          (let [prior @prev]
            (vreset! prev input)
              (if (= prior input)
                result
                (xf result input))))))))

dedupe에서 prev는 축약 중 이전 값을 저장하는 상태 있는 컨테이너예요. prev 값은 성능을 위해 volatile이지만, atom일 수도 있어요. prev 값은 변환 프로세스가 시작될 때까지(예: transduce 호출에서) 초기화되지 않아요. 따라서 상태 있는 상호작용은 변환 프로세스의 컨텍스트 안에 포함돼요.

completion 단계에서 축약 상태를 가진 트랜스듀서는, 중첩 step에서 이미 reduced 값을 보지 않았다면 중첩 변환기의 completion 함수를 호출하기 전에 상태를 플러시해야 해요. 보았다면 보류 상태를 버려야 해요.

변환 프로세스 만들기 (Creating Transducible Processes)

트랜스듀서는 많은 종류의 프로세스에서 사용되도록 설계됐어요. 변환 프로세스(transducible process)는 각 단계가 입력을 섭취하는 단계들의 연속으로 정의돼요. 입력의 소스는 각 프로세스에 특정돼요(컬렉션, 반복자, 스트림 등에서). 마찬가지로 프로세스는 각 단계가 생산한 출력으로 무엇을 할지 선택해야 해요.

트랜스듀서를 적용할 새 컨텍스트가 있다면, 알아야 할 몇 가지 일반 규칙이 있어요:

  • step 함수가 reduced 값을 반환하면, 변환 프로세스는 step 함수에 더 이상 입력을 공급해서는 안 돼요. reduced 값은 completion 전에 deref로 풀어야 해요.
  • 완성 프로세스는 최종 누적 값에 완성 연산을 정확히 한 번 호출해야 해요.
  • 변환 프로세스는 트랜스듀서를 호출함으로써 반환된 함수에 대한 참조를 캡슐화해야 해요 — 이것들은 상태가 있을 수 있고 스레드 간 사용에 안전하지 않아요.

더 알아보기