콜백

콜백 (Callbacks)

C 함수에는 함수 타입을 넘겨줄 수 있어요. C 선언 안에서 콜백의 시그니처를 지정하고, Crystal 쪽에서는 Proc을 만들어 전달해요. 다만 C로 넘기는 함수는 클로저(closure)를 만들 수 없고, 커스텀 데이터를 함께 보내고 싶을 땐 Proc을 '박스(box)'로 감싸는 패턴을 써요.

출처: Crystal 공식 문서 — C 바인딩: Callbacks

본문

C 선언에서 함수 타입을 사용할 수 있어요:

lib X
  # In C:
  #
  #    void callback(int (*f)(int));
  fun callback(f : Int32 -> Int32)
end

그러면 함수(Proc)를 이렇게 넘겨줄 수 있어요:

f = ->(x : Int32) { x + 1 }
X.callback(f)

같은 호출 안에서 함수를 인라인으로 정의하면 매개변수 타입을 생략할 수 있어요. 컴파일러가 fun 시그니처를 보고 타입을 채워넣어 주거든요:

X.callback ->(x) { x + 1 }

하지만 C로 넘기는 함수는 클로저를 만들 수 없어요. 컴파일러가 컴파일 시점에 클로저가 넘어가는 걸 감지하면 오류를 냅니다:

y = 2
X.callback ->(x) { x + y } # Error: can't send closure to C function

컴파일 시점에 감지하지 못하면 런타임에 예외가 발생해요.

콜백과 proc 타입에 쓰이는 표기법은 type grammar를 참고하세요.

콜백 대신 NULL을 넘기고 싶다면 그냥 nil을 넘기면 돼요:

# Same as callback(NULL) in C
X.callback nil

클로저를 C 함수에 넘기기 (Passing a closure to a C function)

대부분 콜백을 설정할 수 있는 C 함수는 커스텀 데이터를 받는 매개변수도 함께 제공해요. 이 커스텀 데이터는 나중에 콜백의 인자로 전달됩니다. 예를 들어 매 틱(tick)마다 콜백을 호출하면서 그 틱을 넘겨주는 C 함수를 생각해봐요:

lib LibTicker
  fun on_tick(callback : (Int32, Void* ->), data : Void*)
end

이 함수를 제대로 감싸는 래퍼를 정의하려면 Proc을 콜백 데이터로 보내고, 그 콜백 데이터를 다시 Proc으로 되돌린 뒤 마지막으로 호출해야 해요.

module Ticker
  # The callback for the user doesn't have a Void*
  @@box : Pointer(Void)?

  def self.on_tick(&callback : Int32 ->)
    # Since Proc is a {Void*, Void*}, we can't turn that into a Void*, so we
    # "box" it: we allocate memory and store the Proc there
    boxed_data = Box.box(callback)

    # We must save this in Crystal-land so the GC doesn't collect it (*)
    @@box = boxed_data

    # We pass a callback that doesn't form a closure, and pass the boxed_data as
    # the callback data
    LibTicker.on_tick(->(tick, data) {
      # Now we turn data back into the Proc, using Box.unbox
      data_as_callback = Box(typeof(callback)).unbox(data)
      # And finally invoke the user's callback
      data_as_callback.call(tick)
    }, boxed_data)
  end
end

Ticker.on_tick do |tick|
  puts tick
end

박스로 감싼 콜백을 @@box에 저장하는 이유를 주목하세요. 이렇게 하지 않으면 우리 코드가 더 이상 그 값을 참조하지 않을 때 GC가 수집해버려요. C 라이브러리가 콜백을 저장하고 있긴 하지만, Crystal의 GC는 그 사실을 알 방법이 없어요.

Raises 애너테이션 (Raises annotation)

C 함수가 사용자 제공 콜백을 실행할 때 그 콜백이 예외를 던질 수 있다면, 그 함수에 @[Raises] 애너테이션을 달아야 해요.

컴파일러는 메서드가 @[Raises]로 표시되어 있거나 (재귀적으로) 예외를 던지는 메서드를 호출하면 이 애너테이션을 추론해요.

하지만 어떤 C 함수들은 다른 C 함수가 실행할 콜백을 받아들이기도 해요. 예를 들어 가상의 라이브러리를 생각해봐요:

lib LibFoo
  fun store_callback(callback : ->)
  fun execute_callback
end

LibFoo.store_callback -> { raise "OH NO!" }
LibFoo.execute_callback

store_callback에 넘긴 콜백이 예외를 던지면 execute_callback도 예외를 던져요. 그런데 execute_callback이 예외를 던질 수 있다는 걸 컴파일러는 알지 못해요. @[Raises]로 표시되어 있지 않고, 컴파일러가 그 사실을 알아낼 방법이 없기 때문이에요. 이런 경우에는 함수를 직접 표시해줘야 합니다:

lib LibFoo
  fun store_callback(callback : ->)

  @[Raises]
  fun execute_callback
end

표시하지 않으면 이 함수 호출을 감싸는 begin/rescue 블록이 예상대로 동작하지 않아요.

더 알아보기