컨셉트
컨셉트 (Concepts)
Nim의 **컨셉트(concept)**는 사용자가 직접 커스텀 타입 클래스를 정의할 수 있게 해주는 메커니즘이에요. 특정 형태의 바인딩(binding) 집합을 기준으로, 어떤 타입이 이 컨셉트에 '맞는지'를 판별해주죠. 제네릭 타입 검사나 오버로드 해석 같은 곳에서 컴파일러가 타입에 대한 정보를 얻을 수 있게 하는 게 핵심 역할이에요. 이 장에서는 컨셉트가 무엇인지부터 시작해서, 오버로드 해석에서 어떻게 쓰이는지까지 하나씩 살펴볼게요.
출처: Nim Manual
본문
컨셉트는 "주어진 바인딩 집합에 따라 다른 타입들을 매칭하는 커스텀 타입 클래스를 사용자가 정의할 수 있게 해주는 메커니즘"이에요. 말로만 들으면 어려우니, 실제 코드를 먼저 볼게요.
type
Comparable = concept # Atomic concept
proc cmp(a, b: Self): int
Indexable[I, T] = concept # Container concept
proc `[]`(x: Self; at: I): T
proc `[]=`(x: var Self; at: I; newVal: T)
proc len(x: Self): I
Index = concept
proc inc(x: var Self)
proc `<`(a, b: Self): bool
proc sort*[I: Index; T: Comparable](x: var Indexable[I, T])
위 예시에서 Comparable과 Indexable은, 컨셉트 본문에 선언된 각 정의를 바인딩할 수 있는 타입이라면 무엇이든 매칭되는 타입이에요. 여기서 등장하는 특별한 Self 타입은 매칭 대상이 되는 타입을 가리키는데, 이 타입을 컨셉트의 "구현(implementation)"이라고도 불러요.
컨셉트에 매칭되는 구현들은 제네릭 매칭(generic match)으로 취급돼요. 그리고 컨셉트 타입클래스 자체는 제네릭 타입 변수와 비슷하게 동작하는데, Indexable[int, int]처럼 구체적인 타입 파라미터를 갖고 있더라도 스스로는 절대 구체 타입(concrete type)이 되지 않아요. 그래서 위 예시의 proc sort 본문 안에서 typeof(x) 같은 표현을 쓰면, 그 결과는 컨셉트 타입클래스가 아니라 실제 구현의 타입을 돌려줘요.
컨셉트는 제네릭 컨텍스트에서 컴파일러에 정보를 제공하는 데 유용해요. 특히 제네릭 타입 검사와 오버로드 해석에서 두드러지게 쓰이죠. 다만 제네릭 타입 검사는 아직 도입 예정이라, 이 매뉴얼에서는 우선 오버로드 해석 기준으로 설명을 이어갈게요.
원자적 컨셉트와 컨테이너 컨셉트
예시에서 "원자적(atomic)"과 "컨테이너(container)"라는 표현이 나왔는데요, 이 두 종류는 컨셉트가 가진 제네릭 타입 변수에 따라 결정돼요. 원자적 컨셉트는 정의가 구체 타입만을 포함하고, Self 타입이 구체 타입으로 추론되는 경우예요. 컨테이너 타입은 같은 조건에 더해, 자신의 제네릭 변수들이 구체 타입에 바인딩되어 적절히 치환되는 경우를 가리켜요.
물론 프로그래머는 이런 구체성 규칙을 깨는 컨셉트를 자유롭게 정의할 수도 있어요. 그렇게 만들어진 것을 "회색(gray)" 컨셉트라고 불러요.
type
Processor = concept
proc process[T](s: Self; data: T)
위 컨셉트는 제네릭 변수가 없는데, 본문 정의에는 구체 타입이 아닌 T가 들어 있어요. 이런 종류의 컨셉트는 컴파일러가 제네릭 컨텍스트를 타입 검사하는 능력을 방해할 수는 있지만, 오버로드 해석에는 유용하게 쓰여요.
여기서 Indexable[I, T]와 Processor의 차이를 짚어볼게요. Indexable은 주어진 구현이 그 컨셉트의 인스턴스화로 설명돼요. 예를 들어 Indexable[int, int]처럼요. 반면에 Processor 컨셉트는 여러 종류의 데이터 타입 T를 처리하도록 설계된 구현을 묘사해요.
컨셉트 오버로드 해석 (Concept overload resolution)
어떤 연산자(operand)의 타입이 컨셉트와 매칭될 때, 그 연산자 타입은 "잠재적 구현(potential implementation)"으로 설정돼요. 컨셉트 본문의 각 정의마다, Self를 그 잠재적 구현으로 치환해 오버로드 해석을 수행해서 일치하는 정의를 찾아요. 이 과정이 성공하면 컨셉트가 매칭된 거예요. 중요한 건, 구현이 컨셉트 안의 정의와 정확히 일치할 필요는 없다는 점이에요. 예를 볼게요.
type
C1 = concept
proc p(s: Self; x: int)
Implementation = object
proc p(x: Implementation; y: SomeInteger)
proc spring(x: C1)
spring(Implementation())
이 코드는 바인딩이 성립해요. spring(Implementation())을 평가할 때 p(Implementation(), 0)이 바인딩되기 때문이죠. 반대로 컨테이너 타입들은, 평소의 제네릭 매칭 규칙대로, 제네릭 제약과 바인딩이 허용하는 범위 안에서 덜 구체적인 정의에도 바인딩돼요.
여기서부터 조금 복잡해져요. 오버로드 해석의 "계층적 순서 비교(Hierarchical Order Comparison)", 즉 구체성 비교 단계에 들어가면 컴파일러는 각 오버로드의 proc 정의 안에 등장하는 온갖 타입과 타입클래스를 컨셉트와 비교하게 돼요. 대부분의 상황에서 이는 혼란스럽고 실용적이지 못한 결과를 낳아서, 규칙을 단순화했어요. 그 규칙은 다음과 같아요.
- 컨셉트가
T또는 다른 모든 타입을 받아들이는 타입(auto)과 비교되는 경우 → 컨셉트가 더 구체적이에요. - 컨셉트가 다른 컨셉트와 비교되는 경우 → 결과는 컨셉트 부분집합 매칭에 위임돼요.
- 그 외의 모든 경우 → 컨셉트가 경쟁 상대보다 덜 구체적이에요.
현재 컨셉트 평가 메커니즘은, 각 정의된 바인딩에 대해 첫 번째로 수용 가능한 후보를 만나면 성공으로 평가해요. 여기서 두 가지 눈여겨볼 효과가 생겨요.
- 제네릭 파라미터는, 다른 후보들도 매칭되어 서로 다른 파라미터를 바인딩할 수 있는 상황에서도, 첫 번째로 매칭된 후보로 채워져요.
- 상속 가능한 객체는 평소 오버로드 해석처럼 매칭되지만, "깊이(depth)"는 고려되지 않아요. 깊이를 반영하려면 매칭되는 모든 바인딩의 최소 깊이를 계산해야 하기 때문이에요.
컨셉트 부분집합 매칭 (Concept subset matching)
이 매칭은 단순해요. 컨셉트 C1과 C2를 비교할 때, C1의 유효한 구현이 모두 C2의 유효한 구현이기도 하지만 그 반대는 성립하지 않는다면, C1이 C2의 부분집합이에요. 즉 C1은 C2로 매칭될 수 있고, 그래서 모호성 해소(disambiguation) 과정에서 더 구체적인 C2 쪽을 우선하게 돼요.
만약 둘 다 서로의 부분집합이 아니라면, 모호성 해소는 복잡도 분석(complexity analysis)으로 넘어가요. 이때는 정의를 더 많이 가진 컨셉트가 이기게 돼요. 만약 아무도 확실한 승자가 되지 못하면, 컴파일 타임에 모호성(ambiguity) 오류가 발생해요.
재귀 컨셉트 (Recursive concepts)
컨셉트는 자기 자신을 정의 안에서 참조할 수 있어요. 이 덕분에 재귀적인 타입 제약이 가능해지죠. 특히 기본 타입(base type)에서 특성을 물려받아야 하는 distinct 타입을 매칭할 때 유용해요.
import std/typetraits
type
PrimitiveBase = SomeNumber | bool | ptr | pointer | enum
# Matches PrimitiveBase directly, or any distinct type whose base is Primitive
Primitive = concept x
x is PrimitiveBase or distinctBase(x) is Primitive
# Application: a handle type that should be treated like a primitive
Handle = distinct int
SpecialHandle = distinct Handle
assert int is Primitive
assert Handle is Primitive
assert SpecialHandle is Primitive # works through 2 levels
assert not (string is Primitive)
이 예시는 2단계 깊이를 넘어서 SpecialHandle까지도 Primitive로 매칭되는 걸 보여줘요. 컨셉트가 자기 자신을 참조하는 방식이 이제 감이 오시죠.
재귀는 혼자서만 가능한 게 아니에요. 서로를 참조하는, 즉 상호 의존적인(mutually recursive, co-dependent) 컨셉트도 만들 수 있어요.
type
Serializable = concept
proc serialize(s: Self; writer: var Writer)
Writer = concept
proc write(w: var Self; data: Serializable)
이런 상호 참조가 무한 재귀로 이어지지 않는 건, 컴파일러가 순환 감지(cycle detection)를 사용해서 이런 경우를 처리해주기 때문이에요.
더 알아보기 (Learn more)
- 이 문서는 Nim Manual의 Concepts 섹션 번역이에요. 컨셉트는 타입 클래스와 오버로드 해석에 긴밀하게 연결되어 있으니, 같은 매뉴얼의 해당 섹션과 함께 보면 이해가 더 깊어져요.
- 실제 활용 예시는 Nim 표준 라이브러리나 공식 튜토리얼에서 인덱싱, 정렬, 직렬화 같은 범용 인터페이스를 정의할 때 컨셉트를 쓰는 패턴을 찾아보면 좋아요.