메서드

메서드

프로시저(proc)는 항상 정적 디스패치(static dispatch) 를 쓰고, 메서드(method)는 동적 디스패치(dynamic dispatch) 를 써요. 무슨 말이냐 하면, 프로시저는 호출 시점에 컴파일러가 어떤 구현을 부를지 결정해 버리는 반면, 메서드는 실행 시점에 실제 객체의 타입을 보고 알맞은 구현을 고른다는 뜻이에요. 그래서 동적 디스패치가 객체에 대해 제대로 동작하려면 그 객체가 참조 타입(reference type) 이어야 해요. 값 타입으로는 메서드의 다형성이 살아나지 않으니, 이 부분만 기억해 두면 됩니다.

type
  Expression = ref object of RootObj ## abstract base class for an expression
  Literal = ref object of Expression
    x: int
  PlusExpr = ref object of Expression
    a, b: Expression

method eval(e: Expression): int {.base.} =
  # override this base method
  raise newException(CatchableError, "Method without implementation override")

method eval(e: Literal): int = return e.x

method eval(e: PlusExpr): int =
  # watch out: relies on dynamic binding
  result = eval(e.a) + eval(e.b)

proc newLit(x: int): Literal =
  new(result)
  result.x = x

proc newPlus(a, b: Expression): PlusExpr =
  new(result)
  result.a = a
  result.b = b

echo eval(newPlus(newPlus(newLit(1), newLit(2)), newLit(4)))

이 예제에서 생성자 역할을 하는 newLitnewPlus는 프로시저예요. 생성은 정적 바인딩이면 충분하니까요. 반면 eval은 메서드로 선언했는데, 동적 바인딩이 필요하기 때문이에요. 같은 이름 eval이 여러 타입에 대해 오버로드되어 있지만, 실제로 어떤 버전이 불릴지는 실행 중에 넘겨지는 객체의 타입에 따라 달라집니다.

예제에서 보듯이 base 메서드에는 반드시 {.base.} 프래그마를 붙여야 해요. 이 base 프래그마는 동시에 프로그래머에게 하나의 역할을 상기시켜 줍니다. 즉 base 메서드 m이 바로 '어떤 m 호출이 일으킬 수 있는 모든 효과(effect)'를 판단하는 기준이 된다는 뜻이에요. base 메서드의 시그니처와 효과가 나머지 구현들이 따르는 뼈대가 되는 셈이죠.

Note: 메서드는 아직 컴파일 타임 실행을 지원하지 않아요.

Note: Nim 0.20부터 제네릭 메서드는 더 이상 권장되지 않아요(deprecated).

Multi-methods

보통 메서드는 첫 번째 인자만으로 디스패치를 하지만, 멀티 메서드(multi-method)에서는 객체 타입을 가진 모든 인자가 디스패치에 사용됩니다. 다만 Nim 0.20부터 멀티 메서드를 쓰려면 컴파일 시에 --multimethods:on 옵션을 명시적으로 넘겨야 해요.

type
  Thing = ref object of RootObj
  Unit = ref object of Thing
    x: int

method collide(a, b: Thing) {.base, inline.} =
  quit "to override!"

method collide(a: Thing, b: Unit) {.inline.} =
  echo "1"

method collide(a: Unit, b: Thing) {.inline.} =
  echo "2"

var a, b: Unit
new a
new b
collide(a, b) # output: 2

여기서 collide(a, b)를 호출할 때 ab는 둘 다 Unit 타입이에요. 그런데 출력은 2가 나옵니다. 만약 첫 번째 인자만으로 디스패치하는 단순 메서드였다면 어떤 결과가 나왔을지 짐작하기 어렵죠. 그런데 실제로는 2가 나옵니다. 이게 바로 모든 객체 인자가 디스패치에 관여한다는 뜻이에요. 두 인자 (Unit, Unit) 조합은 정확히 일치하는 정의가 없으니, 가장 구체적으로 일치하는 후보를 골라 echo "2" 쪽으로 해석이 진행됩니다.

여기서 한쪽 인자(b)를 Thing 파라미터에 넘기는 건 자연스러운 상향 변환(upcast)이라 문제없이 허용되고요. 정리하면, 멀티 메서드는 여러 인자를 함께 따져 가장 구체적으로 일치하는 시그니처를 고른다는 점이 핵심이에요.

procCall로 동적 메서드 해석 억제하기

동적 메서드 해석(dynamic method resolution)은 내장 함수인 system.procCall로 억제할 수 있어요. 전통적인 OOP 언어들이 제공하는 super 키워드와 비슷한 역할이라고 보면 됩니다. 즉 동적 디스패치를 멈추고, 명시적으로 base 메서드를 호출하고 싶을 때 쓰는 거예요.

type
  Thing = ref object of RootObj
  Unit = ref object of Thing
    x: int

method m(a: Thing) {.base.} =
  echo "base"

method m(a: Unit) =
  # Call the base method:
  procCall m(Thing(a))
  echo "1"

여기서 m(a: Unit) 안에서 procCall m(Thing(a))를 호출하면, 동적 디스패치 없이 파라미터를 Thing으로 명시적으로 캐스팅해서 base 구현을 직접 부릅니다. 그래서 "base"에 이어 "1"이 출력되죠. procCall을 쓰지 않고 그냥 m(a)를 호출했다면 base가 아닌 지금 구현이 다시 불리는 무한 재귀에 빠졌을 거예요.

더 알아보기 (Learn more)

  • Nim Manual: Methods
  • 정적/동적 디스패치의 차이가 잘 안 와닿는다면, 프로시저(proc)와 함께 보면 메서드의 역할이 더 또렷해져요.