제네릭
제네릭
제네릭(Generics)은 프로시저(proc), 이터레이터(iterator) 또는 타입을 타입 파라미터:idx: 로 파라미터화하는 님(Nim)의 수단입니다. 문맥에 따라 대괄호는 타입 파라미터를 도입하거나, 제네릭 프로시저·이터레이터·타입을 인스턴스화하는 데 사용됩니다.
다음 예제는 제네릭 이진 트리(binary tree)를 어떻게 모델링하는지 보여줍니다:
type
BinaryTree*[T] = ref object # BinaryTree is a generic type with
# generic parameter `T`
le, ri: BinaryTree[T] # left and right subtrees; may be nil
data: T # the data stored in a node
proc newNode*[T](data: T): BinaryTree[T] =
# constructor for a node
result = BinaryTree[T](le: nil, ri: nil, data: data)
proc add*[T](root: var BinaryTree[T], n: BinaryTree[T]) =
# insert a node into the tree
if root == nil:
root = n
else:
var it = root
while it != nil:
# compare the data items; uses the generic `cmp` proc
# that works for any type that has a `==` and `<` operator
var c = cmp(it.data, n.data)
if c < 0:
if it.le == nil:
it.le = n
return
it = it.le
else:
if it.ri == nil:
it.ri = n
return
it = it.ri
proc add*[T](root: var BinaryTree[T], data: T) =
# convenience proc:
add(root, newNode(data))
iterator preorder*[T](root: BinaryTree[T]): T =
# Preorder traversal of a binary tree.
# This uses an explicit stack (which is more efficient than
# a recursive iterator factory).
var stack: seq[BinaryTree[T]] = @[root]
while stack.len > 0:
var n = stack.pop()
while n != nil:
yield n.data
add(stack, n.ri) # push right subtree onto the stack
n = n.le # and follow the left pointer
var
root: BinaryTree[string] # instantiate a BinaryTree with `string`
add(root, newNode("hello")) # instantiates `newNode` and `add`
add(root, "world") # instantiates the second `add` proc
for str in preorder(root):
stdout.writeLine(str)
T를 제네릭 타입 파라미터:idx: 또는 타입 변수:idx: 라고 부릅니다.
제네릭 프로시저 (Generic Procs)
정의된 용어에 동의하기 위해 제네릭 proc의 구조를 살펴보겠습니다.
p[T: t](arg1: f): y
p: 호출 대상 심볼(Callee symbol)[...]: 제네릭 파라미터T: t: 제네릭 제약(Generic constraint)T: 타입 변수[T: t](arg1: f): y: 형식 시그니처(Formal signature)arg1: f: 형식 파라미터f: 형식 파라미터 타입y: 형식 반환 타입
여기서 "형식(formal)"이라는 단어는 프로그래머가 정의한 대로의 심볼을 나타내며, 컴파일 시점 문맥에서의 모습을 뜻하지 않습니다. 제네릭은 인스턴스화되고 타입이 바인딩될 수 있으므로, 제네릭이 관여하면 생각해야 할 개체가 하나 이상입니다.
제네릭을 사용하면 형식적으로 정의된 표현식이 오직 구체 타입(concrete type)에 바인딩된 인스턴스로 해석됩니다. 이 과정을 "인스턴스화(instantiation)"라고 합니다.
제네릭의 형식 정의 자리에서의 대괄호는 다음과 같은 "제약(constraints)"을 지정합니다:
type Foo[T] = object
proc p[H;T: Foo[H]](param: T): H
제약 정의는 각 정의를 ;로 구분해 하나 이상의 심볼을 가질 수 있습니다. T가 H로 구성되고 p의 반환 타입이 H로 정의되는 것을 주목하세요. 이 제네릭 프로시저가 인스턴스화될 때 H가 구체 타입에 바인딩되어 T를 구체화하고, p의 반환 타입은 H를 정의하는 데 쓰인 동일한 구체 타입에 바인딩됩니다.
사용 자리에서의 대괄호는 제약에 심볼이 정의된 순서대로 구체 타입을 공급해 제네릭을 인스턴스화하는 데 쓰일 수 있습니다. 또는 타입 바인딩이 어떤 상황에서는 컴파일러에 의해 추론되어 더 깔끔한 코드를 가능하게 합니다.
is 연산자 (Is operator)
is 연산자는 시맨틱 분석 중에 평가되어 타입 동치(type equivalence)를 검사합니다. 따라서 제네릭 코드 내에서 타입 특수화에 매우 유용합니다:
type
Table[Key, Value] = object
keys: seq[Key]
values: seq[Value]
when not (Key is string): # empty value for strings used for optimization
deletedKeys: seq[bool]
타입 클래스 (Type classes)
타입 클래스(type class)는 오버로드 해석(overload resolution) 또는 is 연산자의 문맥에서 타입을 매칭하는 데 사용할 수 있는 특별한 유사 타입(pseudo-type)입니다. 님이 지원하는 내장 타입 클래스는 다음과 같습니다:
| 타입 클래스 | 매칭 대상 |
|---|---|
object |
모든 객체 타입 |
tuple |
모든 튜플 타입 |
enum |
모든 열거형 |
proc |
모든 proc 타입 |
iterator |
모든 이터레이터 타입 |
ref |
모든 ref 타입 |
ptr |
모든 ptr 타입 |
var |
모든 var 타입 |
distinct |
모든 distinct 타입 |
array |
모든 배열 타입 |
set |
모든 집합(set) 타입 |
seq |
모든 seq 타입 |
auto |
모든 타입 |
또한 모든 제네릭 타입은 자동으로 같은 이름의 타입 클래스를 만들며, 이 타입 클래스는 그 제네릭 타입의 모든 인스턴스화를 매칭합니다.
타입 클래스는 표준 불리언 연산자를 결합해 더 복잡한 타입 클래스를 만들 수 있습니다:
# create a type class that will match all tuple and object types
type RecordType = (tuple or object)
proc printFields[T: RecordType](rec: T) =
for key, value in fieldPairs(rec):
echo key, " = ", value
제네릭 파라미터의 타입 제약은 ,로 묶고 ;로 전파를 멈출 수 있습니다. 이는 매크로·템플릿의 파라미터와 유사합니다:
proc fn1[T; U, V: SomeFloat]() = discard # T is unconstrained
template fn2(t; u, v: SomeFloat) = discard # t is unconstrained
타입 클래스의 문법은 ML 계열 언어의 ADT/대수적 데이터 타입과 비슷해 보이지만, 타입 클래스는 타입 인스턴스화 시점에 적용되는 정적 제약임을 이해해야 합니다. 타입 클래스는 그 자체로 진짜 타입이 아니며, 궁극적으로 어떤 단일 타입으로 해석(resolve) 되는 제네릭 "검사"를 제공하는 체계입니다. 타입 클래스는 객체 변형(variant)이나 메서드와 달리 런타임 타입 동적성을 허용하지 않습니다.
예를 들어, 다음 코드는 컴파일되지 않습니다:
type TypeClass = int | string
var foo: TypeClass = 2 # foo's type is resolved to an int here
foo = "this will fail" # error here, because foo is an int
님은 타입 클래스와 일반 타입을 제네릭 타입 파라미터의 타입 제약:idx: 으로 지정할 수 있습니다:
proc onlyIntOrString[T: int|string](x, y: T) = discard
onlyIntOrString(450, 616) # valid
onlyIntOrString(5.0, 0.0) # type mismatch
onlyIntOrString("xy", 50) # invalid as 'T' cannot be both at the same time
proc 및 iterator 타입 클래스는 호출 규약(calling convention) 프래그마를 받아 매칭되는 proc 또는 iterator 타입의 호출 규약을 제한할 수도 있습니다:
proc onlyClosure[T: proc {.closure.}](x: T) = discard
onlyClosure(proc() = echo "hello") # valid
proc foo() {.nimcall.} = discard
onlyClosure(foo) # type mismatch
암시적 제네릭 (Implicit generics)
타입 클래스는 파라미터의 타입으로 직접 사용될 수 있습니다.
# create a type class that will match all tuple and object types
type RecordType = (tuple or object)
proc printFields(rec: RecordType) =
for key, value in fieldPairs(rec):
echo key, " = ", value
이런 방식으로 타입 클래스를 사용하는 프로시저는 암시적 제네릭:idx: 으로 간주됩니다. 이들은 프로그램 내에서 사용된 파라미터 타입의 각 고유 조합마다 한 번씩 인스턴스화됩니다.
기본적으로, 오버로드 해석 중 각 이름이 붙은 타입 클래스는 정확히 하나의 구체 타입에 바인딩됩니다. 이런 타입 클래스를 bind once(= 한 번 바인딩):idx: 타입이라고 부릅니다. 다음은 이를 설명하기 위해 system 모듈에서 직접 가져온 예제입니다:
proc `==`*(x, y: tuple): bool =
## requires `x` and `y` to be of the same tuple type
## generic `==` operator for tuples that is lifted from the components
## of `x` and `y`.
result = true
for a, b in fields(x, y):
if a != b: result = false
또는 distinct 타입 수식어를 타입 클래스에 적용하면, 타입 클래스를 매칭하는 각 파라미터가 서로 다른 타입에 바인딩되도록 할 수 있습니다. 이런 타입 클래스를 bind many(= 여러 번 바인딩):idx: 타입이라고 부릅니다.
암시적 제네릭 스타일로 작성된 proc은 매칭된 제네릭 타입의 타입 파라미터를 자주 참조해야 합니다. 점(dot) 문법으로 쉽게 접근할 수 있습니다:
type Matrix[T, Rows, Columns] = object
...
proc `[]`(m: Matrix, row, col: int): Matrix.T =
m.data[col * high(Matrix.Columns) + row]
암시적 제네릭을 보여주는 더 많은 예제입니다:
proc p(t: Table; k: Table.Key): Table.Value
# is roughly the same as:
proc p[Key, Value](t: Table[Key, Value]; k: Key): Value
proc p(a: Table, b: Table)
# is roughly the same as:
proc p[Key, Value](a, b: Table[Key, Value])
proc p(a: Table, b: distinct Table)
# is roughly the same as:
proc p[Key, Value, KeyB, ValueB](a: Table[Key, Value], b: Table[KeyB, ValueB])
파라미터 타입으로 사용된 typedesc 또한 암시적 제네릭을 도입합니다. typedesc는 자신만의 규칙을 가집니다:
proc p(a: typedesc)
# is roughly the same as:
proc p[T](a: typedesc[T])
typedesc는 "bind many" 타입 클래스입니다:
proc p(a, b: typedesc)
# is roughly the same as:
proc p[T, T2](a: typedesc[T], b: typedesc[T2])
typedesc 타입의 파라미터는 그 자체로 타입으로 사용할 수 있습니다. 타입으로 사용되면 그것은 기본(underlying) 타입입니다. 다시 말해, "typedesc" 성질 한 단계가 벗겨집니다:
proc p(a: typedesc; b: a) = discard
# is roughly the same as:
proc p[T](a: typedesc[T]; b: T) = discard
# hence this is a valid call:
p(int, 4)
# as parameter 'a' requires a type, but 'b' requires a value.
제네릭 추론 제한 (Generic inference restrictions)
var T 및 typedesc[T] 타입은 제네릭 인스턴스화에서 추론될 수 없습니다. 다음은 허용되지 않습니다:
proc g[T](f: proc(x: T); x: T) =
f(x)
proc c(y: int) = echo y
proc v(y: var int) =
y += 100
var i: int
# allowed: infers 'T' to be of type 'int'
g(c, 42)
# not valid: 'T' is not inferred to be of type 'var int'
g(v, i)
# also not allowed: explicit instantiation via 'var int'
g[var int](v, i)
제네릭에서의 심볼 조회 (Symbol lookup in generics)
열린 심볼과 닫힌 심볼 (Open and Closed symbols)
제네릭의 심볼 바인딩 규칙은 약간 미묘합니다: "open"(열린) 심볼과 "closed"(닫힌) 심볼이 있습니다. "closed" 심볼은 인스턴스화 문맥에서 다시 바인딩될 수 없고, "open" 심볼은 바인딩될 수 있습니다. 기본적으로 오버로드된 심볼은 open이고, 그 외 모든 심볼은 closed입니다.
open 심볼은 두 가지 다른 문맥에서 조회됩니다: 정의 시점의 문맥과 인스턴스화 시점의 문맥이 모두 고려됩니다:
type
Index = distinct int
proc `==` (a, b: Index): bool {.borrow.}
var a = (0, 0.Index)
var b = (0, 0.Index)
echo a == b # works!
이 예제에서 튜플용 제네릭 == (system 모듈에 정의된 것)은 튜플 구성 요소의 == 연산자를 사용합니다. 그러나 Index 타입의 ==는 튜플용 ==보다 나중에 정의됩니다. 그런데도 인스턴스화가 현재 정의된 심볼도 고려하기 때문에 이 예제는 컴파일됩니다.
Mixin 문 (Mixin statement)
mixin:idx: 선언으로 심볼이 open이 되도록 강제할 수 있습니다:
proc create*[T](): ref T =
# there is no overloaded 'init' here, so we need to state that it's an
# open symbol explicitly:
mixin init
new result
init result
mixin 문은 템플릿과 제네릭에서만 의미가 있습니다.
Bind 문 (Bind statement)
bind 문은 mixin 문의 대응물입니다. 일찍 바인딩되어야 하는 식별자(즉, 템플릿/제네릭 정의의 스코프에서 조회되어야 하는 식별자)를 명시적으로 선언하는 데 사용할 수 있습니다:
# Module A
var
lastId = 0
template genId*: untyped =
bind lastId
inc(lastId)
lastId
# Module B
import A
echo genId()
그러나 정의 스코프에서의 심볼 바인딩이 기본이므로 bind가 유용한 경우는 드뭅니다.
bind 문은 템플릿과 제네릭에서만 의미가 있습니다.
Bind 문 위임 (Delegating bind statements)
다음 예제는 제네릭 인스턴스화가 여러 다른 모듈을 가로지를 때 발생할 수 있는 문제를 설명합니다:
# module A
type O* = object
proc genericA*[T](x: T) =
mixin init
init(x)
# module C
import A
proc init*(x: O) = discard
# module B
import A, C
proc genericB*[T](x: T) =
# Without the `bind init` statement, C's `init` proc is not
# available when `genericA` is instantiated through `genericB`
# from `module main`, which does not import C:
bind init
genericA(x)
# module main
import A, B
genericB(O())
genericA가 mixin init을 사용하므로 init은 open 심볼이고, genericA가 인스턴스화될 때 해석됩니다. 여기서 genericA는 genericB를 통해 인스턴스화되고, genericB의 최종 인스턴스화는 module main에서 일어납니다. module main은 module C를 import하지 않으므로 그 시점에 init이 스코프에 없어 인스턴스화는 undeclared identifier: 'init' 으로 실패합니다. genericB 안의 bind init 문은 module B에서 보이는 init 심볼을 genericA의 인스턴스화로 전달(forward)하여, 이 예제가 컴파일되도록 만듭니다. 이처럼 심볼을 중첩된 제네릭 인스턴스화에 다시 노출시키는 bind를 delegating bind:idx: (위임 bind)라고 합니다.