오버로드 해석

오버로드 해석

같은 이름의 프로시저를 여러 개 만들어도 컴파일러가 어떤 걸 골라 쓸지 어떻게 정할까요? 그 답이 바로 오버로드 해석(overload resolution)이에요. Nim은 후보들 사이에서 가장 잘 맞는 하나를 고르기 위해 여러 단계의 시도를 순서대로 거치는데, 그 규칙을 하나씩 살펴볼게요.

출처: Nim Manual

본문

호출 p(args)에서 p가 둘 이상의 후보(candidate)를 가리킬 수 있을 때, 그걸 심볼 선택(symbol choice) 이라고 해요. 오버로드 해석은 가장 잘 맞는 후보를 찾아서 심볼 선택을 해석된 심볼(resolved symbol) 로 바꾸는 과정이에요. 가장 잘 맞는 루틴 p는 아래에서 설명하는 시도들을 순서대로 거쳐 선택돼요. 순서는 카테고리 매칭(Category matching) → 계층 순서 비교(Hierarchical Order Comparison) → 복잡도 분석(Complexity Analysis) 입니다.

모든 시도를 마쳤는데도 여러 후보가 똑같이 잘 맞는다면, 의미 분석(semantic analysis) 단계에서 모호성(ambiguity) 오류가 보고돼요.

1차 시도: 카테고리 매칭

args의 모든 인자는 하나씩 매칭되어야 하고, 매칭에는 여러 카테고리가 있어요. 형식 매개변수의 타입을 f, 인자의 타입을 a라고 해볼게요.

  1. 정확 매칭(Exact match): af가 같은 타입인 경우예요.
  2. 리터럴 매칭(Literal match): a가 값 v인 정수 리터럴인데 f가 부호 있거나 없는 정수 타입이고 vf의 범위 안에 있는 경우예요. 또는 a가 값 v인 부동소수점 리터럴이고, f가 부동소수점 타입이며 vf의 범위 안에 있는 경우예요.
  3. 제네릭 매칭(Generic match): f가 제네릭 타입이고 a가 매칭되는 경우예요. 예를 들어 aint인데 f가 (제약이 있는) 제네릭 매개변수 타입일 때죠. ([T][T: int|char] 같은 형태요.)
  4. 부분범위·서브타입 매칭(Subrange or subtype match): arange[T]인데 Tf와 정확히 매칭되는 경우, 또는 af의 서브타입인 경우예요.
  5. 정수 변환 매칭(Integral conversion match): af로 변환 가능하고, fa가 어떤 정수 또는 부동소수점 타입인 경우예요.
  6. 변환 매칭(Conversion match): af로 변환 가능한 경우예요. 사용자가 정의한 converter를 통해서도 가능해요.

각 피연산자(operand)는 위 카테고리 중 하나에 들어가는데, 피연산자에게는 우선순위가 가장 높은 카테고리가 적용돼요. 위 목록이 곧 우선순위 순서예요. 어떤 후보가 다른 모든 후보보다 우선순위 높은 매칭을 더 많이 가진다면, 그 후보가 해석된 심볼로 선택돼요.

예를 들어, 정확 매칭 하나를 가진 후보와 제네릭 매칭만 여러 개, 정확 매칭은 0개인 후보를 비교하면, 정확 매칭이 있는 후보가 이겨요.

아래는 카테고리 매칭을 의사코드로 표현한 거예요. count(p, m)은 루틴 p에 대해 매칭 카테고리 m의 매칭 수를 세는 함수예요. 루틴 p가 루틴 q보다 더 잘 매칭된다는 건 다음 알고리즘이 참을 반환한다는 뜻이에요:

for each matching category m in ["exact match", "literal match",
                                "generic match", "subtype match",
                                "integral match", "conversion match"]:
  if count(p, m) > count(q, m): return true
  elif count(p, m) == count(q, m):
    discard "continue with next category m"
  else:
    return false
return "ambiguous"

2차 시도: 계층 순서 비교

타입의 계층 순서(hierarchical order) 는 상대적 구체성(relative specificity)과 비슷한 개념이에요. 다음 타입을 생각해 볼게요:

type A[T] = object

이 타입에 매칭되는 형식 매개변수에는 T, object, A, A[...], A[C]가 있어요. 여기서 C는 구체 타입, A[...]는 제네릭 타입클래스 조합, T는 제약 없는 제네릭 타입 변수예요. 이 목록은 A에 대한 구체성 순서로 정렬되어 있는데, 뒤로 갈수록 매칭 집합에 속하는 타입의 범위가 좁아지기 때문이에요.

이 시도에서는 후보들의 형식 매개변수를 순서대로(1번째, 2번째, ...) 비교하면서, 비교할 수 없을 정도로 구체적인 매개변수를 가진 후보를 찾아요. 그런 형식 매개변수를 찾으면, 그게 속한 후보가 해석된 심볼로 선택돼요.

3차 시도: 복잡도 분석

한 가지 짚고 넘어갈게요. 카테고리 매칭은 후보의 모든 형식 매개변수를 한 번에 (순서와 무관하게) 처리해요. 하지만 구체성 비교와 복잡도 분석은 형식 매개변수를 하나씩 처리해요. 아래는 한 쌍의 형식 매개변수가 같은 계층 순서를 가질 때 심볼 선택을 가르는 마지막 시도예요.

타입의 복잡도(complexity) 는 본질적으로 수식어(modifier)의 개수와 형태의 깊이를 말해요. 복잡도가 가장 높은 정의가 이겨요. 다음 타입들을 생각해 볼게요:

type
  A[T] = object
  B[T, H] = object

참고로 아래 예시는 전부가 아니에요. 정리하면 이렇게 돼요:

  1. A[T]A보다 복잡도가 높아요.
  2. var A[T]A[T]보다 복잡도가 높아요.
  3. A[A[T]]A[T]보다 복잡도가 높아요.
  4. B[T, H]A[T]보다 복잡도가 높아요. (AB는 여기서 호환되지 않지만, 이런 게 꼬인 버전도 존재해요.)
  5. B[ptr T, H]B[T, H]보다 복잡도가 높아요.

예시 몇 가지

proc takesInt(x: int) = echo "int"
proc takesInt[T](x: T) = echo "T"
proc takesInt(x: int16) = echo "int16"

takesInt(4) # "int"
var x: int32
takesInt(x) # "T"
var y: int16
takesInt(y) # "int16"
var z: range[0..4] = 0
takesInt(z) # "T"

인자 ap의 매개변수 타입 fq의 매개변수 타입 g 양쪽 모두에 서브타입 관계로 매칭된다면, 상속 깊이(inheritance depth) 가 고려돼요:

type
  A = object of RootObj
  B = object of A
  C = object of B

proc p(obj: A) =
  echo "A"

proc p(obj: B) =
  echo "B"

var c = C()
# not ambiguous, calls 'B', not 'A' since B is a subtype of A
# but not vice versa:
p(c)

proc pp(obj: A, obj2: B) = echo "A B"
proc pp(obj: B, obj2: A) = echo "B A"

# but this is ambiguous:
pp(c, c)

마찬가지로 제네릭 매칭에서는, 여전히 매칭되는 제네릭 타입 중 가장 특수화된 것이 선호돼요:

proc gen[T](x: ref ref T) = echo "ref ref T"
proc gen[T](x: ref T) = echo "ref T"
proc gen[T](x: T) = echo "T"

var ri: ref int
gen(ri) # "ref T"

타입 변수 매칭

오버로드 해석이 후보들을 고려할 때, 타입 변수의 정의는 빠뜨리지 않아요. 그 정의가 변수 치환(variable substitution)을 통해 형식 매개변수의 타입을 정하기 때문이에요. 예시를 볼게요:

type A
proc p[T: A](param: T)
proc p[T: object](param: T)

이 두 시그니처는 구체 타입 A에 대해 모호하지 않아요. 형식 매개변수가 ("T" == "T")로 서로 매칭되는 것처럼 보여도요. 대신 T는 오버로드 해석 시점에 T의 바운드 타입(bound type)에 따라 (T ?= T)처럼 변수로 취급돼요.

'var T' 기반 오버로딩

형식 매개변수 fvar T 타입이라면, 일반적인 타입 검사에 더해서 인자가 l-value 인지도 확인해요. 그러면 var T는 그냥 T보다 더 잘 매칭돼요.

proc sayHi(x: int): string =
  # matches a non-var int
  result = $x
proc sayHi(x: var int): string =
  # matches a var int
  result = $(x + 10)

proc sayHello(x: int) =
  var m = x # a mutable version of x
  echo sayHi(x) # matches the non-var version of sayHi
  echo sayHi(m) # matches the var version of sayHi

sayHello(3) # 3
            # 13

untyped를 위한 지연 타입 해석

참고: unresolved(미해결) 표현식이란 심볼 조회도 타입 검사도 전혀 수행되지 않은 표현식을 말해요.

immediate로 선언되지 않은 템플릿과 매크로는 오버로딩 해석에 참여하기 때문에, 미해결 표현식을 템플릿이나 매크로에 넘길 방법이 꼭 필요해요. 이것이 바로 메타타입 untyped의 역할이에요:

template rem(x: untyped) = discard

rem unresolvedExpression(undeclaredIdentifier)

untyped 타입의 매개변수는 (인자가 전달되는 한) 어떤 인자라도 항상 매칭돼요.

하지만 주의할 점이 있어요. 다른 오버로드가 인자의 해석을 촉발할 수 있거든요:

template rem(x: untyped) = discard
proc rem[T](x: T) = discard

# undeclared identifier: 'unresolvedExpression'
rem unresolvedExpression(undeclaredIdentifier)

untypedvarargs[untyped]만이 이런 의미에서 lazy 한 메타타입이에요. 나머지 메타타입인 typedtypedesc는 lazy 하지 않아요.

Varargs 매칭

Varargs 항목을 참고하세요.

iterable

타입 T를 yield 하는 호출된 iteratoruntyped로 타입이 지정된 매개변수(미해결 표현식용)나 타입클래스 iterable 또는 iterable[T](타입 검사와 오버로드 해석 이후)를 통해 템플릿이나 매크로에 전달될 수 있어요.

iterator iota(n: int): int =
  for i in 0..<n: yield i

template toSeq2[T](a: iterable[T]): seq[T] =
  var ret: seq[T]
  assert a.typeof is T
  for ai in a: ret.add ai
  ret

assert iota(3).toSeq2 == @[0, 1, 2]
assert toSeq2(5..7) == @[5, 6, 7]
assert not compiles(toSeq2(@[1,2])) # seq[int] is not an iterable
assert toSeq2(items(@[1,2])) == @[1, 2] # but items(@[1,2]) is

더 알아보기 (Learn more)

  • 오버로드 디스앰비규에이션(Overload disambiguation): 오버로드 해석보다 약한 형태의 해석으로, 추가 타입 정보가 있는 맥락에서 수행돼요. 이어지는 매뉴얼 섹션에 자세히 나와요.
  • 변환기(Converters): converter가 '암시적 변환 가능' 타입 관계를 어떻게 확장하는지 매뉴얼에서 다뤄요.
  • 제네릭(Generics): 제네릭 오버로딩의 기반이 되는 타입 매개변수 개념을 제네릭 섹션에서 살펴볼 수 있어요.