타입

타입

Nim의 모든 표현식은 의미 분석(semantic analysis) 시점에 타입이 이미 정해져 있어요. Nim은 정적 타입 언어라서 그렇죠. 그리고 새 타입을 직접 선언할 수도 있는데, 이는 결국 "이 커스텀 타입을 가리키는 식별자를 정의하는 일"과 본질적으로 같아요.

큰 덩어리로 보면 타입은 이렇게 나뉩니다.

  • 서수 타입(ordinal type): 정수, bool, 문자, 열거형(그리고 그 하위 범위) 타입으로 구성돼요.
  • 부동소수점 타입
  • 문자열(string) 타입
  • 구조화(structured) 타입
  • 참조(포인터) 타입
  • 프로시저(procedural) 타입
  • 제네릭(generic) 타입

한 가지 짚고 넘어갈 점은, 컴파일러 내부의 타입 세계가 이 요약보다 훨씬 더 복잡하다는 거예요. 구조적으로는 똑같은 타입이라도 백엔드 표현이 서로 달라질 수 있거든요.

출처: Nim Manual

본문

서수 타입 (Ordinal types)

서수 타입은 다음과 같은 성질을 가져요.

  • 셀 수 있고 순서가 있어요. 이 성질 덕분에 inc, ord, dec 같은 함수를 서수 타입에 정의해서 쓸 수 있어요.
  • 가장 작은 값이 존재하고, low(type)으로 접근할 수 있어요. 이 최솟값보다 더 아래로 세려고 하면 패닉(panic)이나 정적 오류(static error)가 발생해요.
  • 가장 큰 값이 존재하고, high(type)으로 접근할 수 있어요. 최댓값보다 더 위로 세려고 하면 역시 패닉이나 정적 오류가 나요.

정수, bool, 문자, 열거형 타입(그리고 이들의 하위 범위)이 서수 타입에 속해요. distinct 타입도 기반 타입이 서수 타입이면 서수 타입이 돼요.

미리 정의된 정수 타입 (Pre-defined integer types)

기본으로 제공되는 정수 타입은 이러합니다.

int

  • 일반적인 부호 있는 정수 타입이에요. 크기는 플랫폼에 따라 달라지고, 포인터와 같은 크기를 가져요. 보통은 이 타입을 쓰면 돼요. 타입 접미사가 없는 정수 리터럴은 low(int32)..high(int32) 범위 안에 있으면 int 타입이 되고, 그 밖의 값은 int64 타입이 돼요.

intXX

  • XX 비트짜리 부호 있는 정수 타입들이 이 이름 규칙을 따라요. 예를 들어 int16은 16비트 폭의 정수예요. 현재 구현은 int8, int16, int32, int64를 지원해요. 이 타입들의 리터럴은 'iXX 접미사를 붙여요.

uint

  • 일반적인 부호 없는 정수 타입이에요. 크기는 플랫폼에 따라 달라지고 포인터와 같은 크기를 가져요. 'u 접미사가 붙은 정수 리터럴은 이 타입이 돼요.

uintXX

  • XX 비트짜리 부호 없는 정수 타입들이 이 이름 규칙을 따라요. 예를 들어 uint16은 16비트 폭의 부호 없는 정수예요. 현재 구현은 uint8, uint16, uint32, uint64를 지원해요. 이 타입들의 리터럴은 'uXX 접미사를 붙여요. 부호 없는 연산은 전부 래핑(wrap-around) 방식으로 동작해서, 오버플로나 언더플로 오류가 생길 수 없어요.

부호 있는/없는 정수용 일반 산술 연산자(+ - * 등) 외에도, 형식적으로는 부호 있는 정수를 다루되 인자를 부호 없는 것처럼 취급하는 연산자들이 있어요. 이들은 주로 부호 없는 정수 타입이 없던 옛 언어 버전과의 하위 호환을 위해 제공되는 거죠. 이렇게 부호 있는 정수에서 부호 없는 연산처럼 쓰는 연산자는 % 접미사를 붙이는 게 관례예요.

연산 의미
a +% b 부호 없는 정수 덧셈
a -% b 부호 없는 정수 뺄셈
a *% b 부호 없는 정수 곱셈
a /% b 부호 없는 정수 나눗셈
a %% b 부호 없는 정수 나머지 연산
a <% b a와 b를 부호 없는 값으로 보고 비교
a <=% b a와 b를 부호 없는 값으로 보고 비교

서로 다른 종류의 정수 타입이 섞인 표현식에서는 자동 타입 변환이 일어나요. 이때 더 작은 타입이 더 큰 타입으로 변환돼요.

범위를 줄이는 변환(narrowing)은 큰 타입을 작은 타입으로 바꾸는 거고(예: int32 -> int16), 범위를 넓히는 변환(widening)은 작은 타입을 큰 타입으로 바꾸는 거예요(예: int16 -> int32). Nim에서는 넓히는 변환만 암시적으로 일어나요.

var myInt16 = 5i16
var myInt: int
myInt16 + 34     # of type `int16`
myInt16 + myInt  # of type `int`
myInt16 + 2i32   # of type `int32`

그런데 int 리터럴은 그 값이 더 작은 정수 타입 안에 들어맞고, 다른 암시적 변환보다 비용이 덜 들 때 그 작은 타입으로 암시적으로 변환될 수 있어요. 그래서 myInt16 + 34int16 결과가 돼요.

더 자세한 내용은 Convertible relation을 참고하세요.

하위 범위 타입 (Subrange types)

하위 범위 타입은 서수 타입이나 부동소수점 타입(기반 타입)에서 나온 값의 범위를 말해요. 하위 범위 타입을 정의하려면 그 타입의 최솟값과 최댓값, 즉 경계 값을 지정해야 해요. 예시를 볼게요.

type
  Subrange = range[0..5]
  PositiveFloat = range[0.0..Inf]
  Positive* = range[1..high(int)] # as defined in `system`

Subrange는 0부터 5까지만 담을 수 있는 정수 하위 범위예요. PositiveFloat는 모든 양의 부동소수점 값을 담는 하위 범위를 정의해요. 참고로 NaN은 어떤 부동소수점 하위 범위에도 속하지 않아요. Subrange 타입 변수에 그 외 다른 값을 넣으면 패닉이 나요(의미 분석 단계에서 알 수 있으면 정적 오류). 기반 타입에서 그 하위 범위 타입으로(그리고 그 반대로)의 대입은 허용돼요.

하위 범위 타입은 기반 타입과 같은 크기를 가져요(위 Subrange 예시에서는 int 크기).

범위 타입으로의 암시적 "축소(downsizing)" 변환(예: int -> range[0..255] 또는 range[1..256] -> range[0..255])은 ImplicitRangeConversion 경고를 냅니다. 명백히 안전한 변환(예: range[0..255] -> range[0..65535])이나 명시적 캐스트는 이 경고를 일으키지 않아요. 또 int에서 Natural이나 Positive 같은 흔한 하위 범위로의 변환도 기본적으로는 경고하지 않지만, --warning:systemRangeConversion 옵션으로 켤 수 있어요.

미리 정의된 부동소수점 타입 (Pre-defined floating-point types)

기본 제공 부동소수점 타입은 다음과 같아요.

float

  • 일반적인 부동소수점 타입이에요. 과거엔 크기가 플랫폼에 따라 달랐지만, 지금은 항상 float64로 매핑돼요. 보통 이 타입을 쓰면 돼요.

floatXX

  • 구현에 따라 XX 비트짜리 부동소수점 타입을 이 이름 규칙으로 더 정의할 수 있어요(예: float64는 64비트 폭 float). 현재 구현은 float32float64를 지원해요. 이 타입들의 리터럴은 'fXX 접미사를 붙여요.

서로 다른 부동소수점 타입이 섞인 표현식에서는 자동 타입 변환이 일어나요. 자세한 내용은 Convertible relation을 참고하세요. 부동소수점 타입에 대한 산술 연산은 IEEE 표준을 따릅니다. 흥미롭게도 정수 타입과 부동소수점 타입은 서로 자동 변환되지 않아요.

IEEE 표준은 다섯 종류의 부동소수점 예외를 정의해요.

  • Invalid: 수학적으로 유효하지 않은 피연산자가 포함된 연산. 예: 0.0/0.0, sqrt(-1.0), log(-37.8).
  • Division by zero (0으로 나눔): 제수가 0이고 피제수가 유한한 0이 아닌 수. 예: 1.0/0.0.
  • Overflow: 지수 범위를 넘어서는 결과를 만드는 연산. 예: MAXDOUBLE+0.0000000000001e308.
  • Underflow: 정상 숫자로 표현하기엔 너무 작은 결과. 예: MINDOUBLE * MINDOUBLE.
  • Inexact: 무한 정밀도로는 표현할 수 없는 결과. 예: 2.0 / 3.0, log(1.1), 입력값 0.1.

이 IEEE 예외들은 실행 중 그냥 무시되거나, Nim 예외로 매핑돼요. 대응하는 예외는 FloatInvalidOpDefect, FloatDivByZeroDefect, FloatOverflowDefect, FloatUnderflowDefect, FloatInexactDefect예요. 이 예외들은 전부 FloatingPointDefect 기반 클래스에서 상속받아요.

Nim은 nanChecksinfChecks 프래그마를 제공해서, IEEE 예외를 무시할지 아니면 Nim 예외로 트랩할지를 제어할 수 있어요.

{.nanChecks: on, infChecks: on.}
var a = 1.0
var b = 0.0
echo b / b # raises FloatInvalidOpDefect
echo a / b # raises FloatOverflowDefect

참고로 현재 구현에서는 FloatDivByZeroDefectFloatInexactDefect는 절대 발생하지 않아요. 대신 FloatDivByZeroDefect 대신 FloatOverflowDefect가 발생합니다. 또 floatChecks라는 프래그마가 있는데, 이건 nanChecksinfChecks를 합쳐 놓은 단축 표기예요. floatChecks는 기본적으로 꺼져 있어요.

floatChecks 프래그마가 영향을 주는 연산은 부동소수점 타입의 +, -, *, / 연산자뿐이에요.

구현은 의미 분석 중 부동소수점 값을 평가할 때 항상 최대 정밀도를 사용해야 해요. 즉 상수 폴딩(constant folding) 중에 평가되는 0.09'f32 + 0.01'f32 == 0.09'f64 + 0.01'f64 같은 표현식은 참(true)이 돼요.

불리언 타입 (Boolean type)

Nim에서 불리언 타입은 bool이라고 부르고, truefalse 두 가지 미리 정의된 값 중 하나를 가져요. while, if, elif, when 문의 조건은 모두 bool 타입이어야 합니다.

그리고 이 조건이 항상 성립해요.

ord(false) == 0 and ord(true) == 1

not, and, or, xor, <, <=, >, >=, !=, == 연산자가 bool 타입에 정의돼 있어요. 이중 andor는 단락 평가(short-cut evaluation)를 수행해요. 예를 들어 볼게요.

while p != nil and p.name != "xyz":
  # p.name is not evaluated if p == nil
  p = p.next

bool 타입의 크기는 1바이트예요.

문자 타입 (Character type)

Nim에서 문자 타입은 char라고 부르고, 크기가 1바이트예요. 따라서 UTF-8 문자 하나 전체를 담을 수는 없고, 그 일부만을 나타낼 수 있어요.

유니코드 문자에는 Rune 타입을 써요. Rune은 모든 유니코드 문자를 표현할 수 있고, unicode 모듈에 선언돼 있어요.

열거 타입 (Enumeration types)

열거 타입은 지정한 값들로 구성된 새 타입을 정의해요. 값들은 순서를 가져요. 예시를 볼게요.

type
  Direction = enum
    north, east, south, west

그러면 다음이 성립해요.

ord(north) == 0
ord(east) == 1
ord(south) == 2
ord(west) == 3

# Also allowed:
ord(Direction.west) == 3

암시적인 순서는 north < east < south < west예요. 열거 타입에는 비교 연산자를 쓸 수 있어요. north처럼 그냥 쓰는 대신, 값이 속한 열거 타입으로 한정해 Direction.north처럼 쓸 수도 있어요.

다른 프로그래밍 언어와의 연동을 좋게 하려고, 열거 타입의 필드에 명시적인 서수 값을 부여할 수 있어요. 다만 서수 값은 오름차순이어야 합니다. 서수 값이 명시되지 않은 필드는 이전 필드 값 + 1로 정해져요.

명시적으로 순서를 준 열거 타입은 비어 있는 구멍(hole)을 가질 수 있어요.

type
  TokenType = enum
    a = 2, b = 4, c = 89 # holes are valid

다만 그러면 더 이상 서수 타입이 아니게 돼서, 이런 열거 타입은 배열의 인덱스 타입으로 쓸 수 없어요. inc, dec, succ, pred 프로시저도 쓸 수 없고요.

컴파일러는 열거 타입용으로 내장 문자열화 연산자 $를 지원해요. 이 문자열화 결과는 명시적으로 문자열 값을 지정해서 제어할 수 있습니다.

type
  MyEnum = enum
    valueA = (0, "my value A"),
    valueB = "value B",
    valueC = 2,
    valueD = (3, "abc")

예시에서 보듯이, 튜플을 사용해 필드의 서수 값과 문자열 값을 둘 다 지정할 수 있어요. 둘 중 하나만 지정하는 것도 가능하고요.

열거 타입은 pure 프래그마로 표시할 수 있는데, 그러면 그 필드들이 모듈 전용의 숨은 스코프에 추가돼요. 이 스코프는 마지막 수단으로만 조회됩니다. 모호하지 않은 심볼만 이 스코프에 추가되고요. 하지만 MyEnum.value처럼 타입으로 한정하면 언제든 접근할 수 있어요.

type
  MyEnum {.pure.} = enum
    valueA, valueB, valueC, valueD, amb

  OtherEnum {.pure.} = enum
    valueX, valueY, valueZ, amb

echo valueA # MyEnum.valueA
echo amb    # Error: Unclear whether it's MyEnum.amb or OtherEnum.amb
echo MyEnum.amb # OK.

열거 값의 이름은 루틴처럼 오버로드할 수 있어요. 열거 타입 TU에 모두 foo라는 멤버가 있으면, 식별자 fooT.fooU.foo 사이의 선택을 의미해요. 오버로드 해석 중에 foo의 올바른 타입이 문맥에서 결정되고, foo의 타입이 모호하면 정적 오류가 발생합니다.

type
  E1 = enum
    value1,
    value2
  E2 = enum
    value1,
    value2 = 4

const
  Lookuptable = [
    E1.value1: "1",
    # no need to qualify value2, known to be E1.value2
    value2: "2"
  ]

proc p(e: E1) =
  # disambiguation in 'case' statements:
  case e
  of value1: echo "A"
  of value2: echo "B"

p value2

어떤 경우에는 열거 타입의 모호성이 현재 스코프와 그 열거 타입이 정의된 스코프의 관계에 따라 해결돼요.

# a.nim
type Foo* = enum abc

# b.nim
import a
type Bar = enum abc
echo abc is Bar # true

block:
  type Baz = enum abc
  echo abc is Baz # true

열거 타입으로 비트 필드를 구현하려면 Bit fields를 참고하세요.

문자열 타입 (String type)

모든 문자열 리터럴은 string 타입이에요. Nim의 문자열은 문자 시퀀스와 아주 비슷해요. 다만 한 가지 다른 점은, Nim 문자열은 널 종료(0-terminated) 이면서 동시에 길이 필드도 갖고 있다는 거예요. 길이는 내장 len 프로시저로 얻을 수 있고, 이 길이는 종료 널을 세지 않습니다.

이 종료 널은 문자열을 먼저 cstring 타입으로 변환하지 않으면 접근할 수 없어요. 종료 널 덕분에 이 변환이 O(1)로, 할당 없이 가능해지는 거예요.

문자열의 대입 연산자는 항상 문자열을 복사해요. & 연산자는 문자열을 이어 붙입니다.

대부분의 네이티브 Nim 타입은 특별한 $ 프로시저로 문자열 변환을 지원해요. 예를 들어 echo 프로시저를 호출하면 그 인자에 대해 내장 문자열화 연산이 호출됩니다.

echo 3 # calls `$` for `int`

사용자가 특별한 객체를 만들 때, 이 프로시저를 구현하면 그 타입에 대한 문자열 표현을 제공하게 돼요.

type
  Person = object
    name: string
    age: int

proc `$`(p: Person): string = # `$` always returns a string
  result = p.name & " is " &
          $p.age & # we *need* the `$` in front of p.age which
                   # is natively an integer to convert it to
                   # a string
          " years old."

$p.name도 쓸 수 있지만, 문자열에 대한 $ 연산은 아무것도 하지 않아요. 그리고 주의할 점은, echo 프로시저처럼 int에서 string으로 자동 변환을 기대하면 안 된다는 거예요. 그런 자동 변환은 없으니까요.

문자열은 사전식 순서(lexicographical order)로 비교돼요. 모든 비교 연산자를 사용할 수 있어요. 문자열은 배열처럼 인덱싱할 수 있는데(하한은 0), 배열과 달리 case 문에서도 쓸 수 있어요.

case paramStr(i)
of "-v": incl(options, optVerbose)
of "-h", "-?": incl(options, optHelp)
else: write(stdout, "invalid command line option!\n")

관례상 모든 문자열은 UTF-8 문자열이지만, 이건 강제되는 규칙은 아니에요. 예를 들어 이진 파일에서 문자열을 읽으면 그것은 그냥 바이트 시퀀스일 뿐이죠. 인덱스 연산 s[i]s의 i번째 문자를 의미하지, i번째 유니코드 문자를 의미하지는 않아요. 모든 유니코드 문자를 순회하려면 unicode 모듈의 runes 이터레이터를 쓰면 됩니다.

cstring 타입

cstring 타입(compatible string)은 컴파일 백엔드에서 문자열의 네이티브 표현이에요. C 백엔드에서 cstring은 ANSI C의 char* 타입과 호환되는 널 종료 char 배열에 대한 포인터를 나타내요. 주된 목적은 C와 쉽게 연동하기 위함이에요. 인덱스 연산 s[i]s의 i번째 문자를 의미하지만, cstring에는 경계 검사가 없어서 이 연산은 안전하지 않아요.

편의를 위해 Nim 문자열은 cstring으로 암시적으로 변환돼요. 또 Nim 문자열을 C 스타일 가변 인자(varargs) 프로시저에 넘기면 그것도 암시적으로 cstring으로 변환됩니다.

proc printf(formatstr: cstring) {.importc: "printf", varargs,
                                  header: "<stdio.h>".}

printf("This works %s", "as expected")

변환이 암시적이라고 해서 안전한 건 아니에요. 가비지 컬렉터는 cstring을 루트로 간주하지 않아서, 그 밑에 있는 메모리를 수거해버릴 수 있어요. 그래서 이 암시적 변환은 앞으로 Nim 컴파일러의 향후 릴리스에서 제거될 예정이에요. const 문자열을 cstring으로 변환하는 같은 용법은 안전하므로 계속 허용될 거예요.

cstring에 대해 string을 반환하는 $ 프로시저가 정의돼 있어요. 그래서 cstring에서 Nim 문자열을 얻으려면:

var str: string = "Hello!"
var cstr: cstring = str
var newstr: string = $cstr

cstring 리터럴은 수정하면 안 돼요.

var x = cstring"literals"
x[1] = 'A' # This is wrong!!!

만약 cstring이 일반 메모리(읽기 전용 메모리가 아닌)에서 비롯된 것이라면, 수정할 수 있어요.

var x = "123456"
prepareMutation(x) # call `prepareMutation` before modifying the strings
var s: cstring = cstring(x)
s[0] = 'u' # This is ok

cstring 값은 문자열처럼 case 문에서도 쓸 수 있어요.

구조화 타입 (Structured types)

구조화 타입의 변수는 여러 값을 동시에 담을 수 있어요. 구조화 타입은 무한히 중첩할 수 있고요. 배열, 시퀀스, 튜플, 객체, 집합이 구조화 타입에 속합니다.

배열과 시퀀스 타입 (Array and sequence types)

배열은 동질(homogeneous) 타입이에요. 즉 배열의 각 요소가 모두 같은 타입이라는 뜻이죠. 배열은 항상 고정된 길이를 상수 표현식으로 지정해요(열린 배열은 예외). 배열은 어떤 서수 타입으로든 인덱싱할 수 있어요. 매개변수 A가 열린 배열이라면 정수 0부터 len(A)-1로 인덱싱됩니다. 배열 표현식은 배열 생성자 []로 만들 수 있어요. 이 배열 표현식의 요소 타입은 첫 번째 요소의 타입에서 추론되고, 나머지 요소들은 전부 이 타입으로 암시적 변환이 가능해야 해요.

배열 타입은 array[size, T] 문법으로 정의하거나, 0이 아닌 인덱스에서 시작하는 배열이라면 array[lo..hi, T]로 정의할 수 있어요.

시퀀스는 배열과 비슷하지만, 런타임 중에 바뀔 수 있는 동적 길이를 가져요(문자열처럼). 시퀀스는 자라날 수 있는 배열(growable array)로 구현돼서, 요소가 추가될 때마다 메모리 조각을 할당해요. 시퀀스 S는 항상 정수 0부터 len(S)-1로 인덱싱되고 경계가 검사됩니다. 시퀀스는 배열 생성자 []에 배열→시퀀스 연산자 @를 붙여 만들거나, 내장 newSeq 프로시저를 호출해 공간을 할당할 수 있어요.

시퀀스는 열린 배열 타입의 매개변수로 넘길 수 있어요.

예시를 볼게요.

type
  IntArray = array[0..5, int] # an array that is indexed with 0..5
  IntSeq = seq[int] # a sequence of integers
var
  x: IntArray
  y: IntSeq
x = [1, 2, 3, 4, 5, 6]  # [] is the array constructor
y = @[1, 2, 3, 4, 5, 6] # the @ turns the array into a sequence

let z = [1.0, 2, 3, 4] # the type of z is array[0..3, float]

배열이나 시퀀스의 하한은 내장 프로시저 low()로, 상한은 high()로 얻을 수 있어요. 길이는 len()으로 구해요. 시퀀스나 열린 배열에 대한 low()는 항상 0을 반환해요. 0이 유효한 첫 인덱스니까요. 시퀀스에 요소를 추가하려면 add() 프로시저나 & 연산자를 쓰고, 시퀀스의 마지막 요소를 제거(그리고 가져오)하려면 pop() 프로시저를 써요.

x[i] 표기로 x의 i번째 요소에 접근할 수 있어요.

배열은 항상 경계 검사가 돼요(정적으로든 런타임에든). 이 검사는 프래그마나 --boundChecks:off 커맨드라인 스위치로 끌 수 있어요.

배열 생성자는 가독성을 위해 명시적인 인덱스를 가질 수 있어요.

type
  Values = enum
    valA, valB, valC

const
  lookupTable = [
    valA: "A",
    valB: "B",
    valC: "C"
  ]

인덱스를 빼먹으면 succ(lastIndex)가 인덱스 값으로 사용돼요.

type
  Values = enum
    valA, valB, valC, valD, valE

const
  lookupTable = [
    valA: "A",
    "B",
    valC: "C",
    "D", "e"
  ]

열린 배열 (Open arrays)

고정 크기 배열은 종종 너무 융통성이 없어요. 프로시저는 서로 다른 크기의 배열을 다룰 수 있어야 하죠. openarray 타입이 이를 가능하게 해요. openarray는 매개변수에만 쓸 수 있어요. 열린 배열은 항상 정수로, 0번 위치부터 인덱싱됩니다. len, low, high 연산은 열린 배열에서도 쓸 수 있어요. 호환되는 기반 타입을 가진 어떤 배열이든 열린 배열 매개변수로 넘길 수 있고, 인덱스 타입은 상관없어요. 배열뿐 아니라 시퀀스도 열린 배열 매개변수로 넘길 수 있습니다.

openarray 타입은 중첩할 수 없어요. 다차원 열린 배열은 잘 필요하지도 않고 효율적으로 구현하기도 어렵기 때문에 지원하지 않아요.

proc testOpenArray(x: openArray[int]) = echo repr(x)

testOpenArray([1,2,3])  # array[]
testOpenArray(@[1,2,3]) # seq[]

가변 인자 (Varargs)

varargs 매개변수는 열린 배열 매개변수이면서, 추가로 프로시저에 가변 개수의 인자를 넘길 수 있게 해줘요. 컴파일러는 인자 목록을 암시적으로 배열로 변환합니다.

proc myWriteln(f: File, a: varargs[string]) =
  for s in items(a):
    write(f, s)
  write(f, "\n")

myWriteln(stdout, "abc", "def", "xyz")
# is transformed to:
myWriteln(stdout, ["abc", "def", "xyz"])

이 변환은 varargs 매개변수가 프로시저 헤더의 마지막 매개변수일 때만 일어나요. 이 문맥에서 타입 변환을 수행하는 것도 가능해요.

proc myWriteln(f: File, a: varargs[string, `$`]) =
  for s in items(a):
    write(f, s)
  write(f, "\n")

myWriteln(stdout, 123, "abc", 4.0)
# is transformed to:
myWriteln(stdout, [$123, $"abc", $4.0])

이 예시에서 $는 매개변수 a로 넘겨지는 어떤 인자에든 적용돼요. (문자열에 대한 $는 아무것도 하지 않는다는 점에 유의하세요.)

주의할 점은, varargs 매개변수에 전달된 명시적 배열 생성자는 또 다른 암시적 배열 생성으로 감싸지지 않는다는 거예요.

proc takeV[T](a: varargs[T]) = discard

takeV([123, 2, 1]) # takeV's T is "int", not "array of int"

varargs[typed]는 특별하게 다뤄져요. 임의 타입의 가변 인자 목록을 매칭하되 항상 암시적 배열을 만듭니다. 그래야 내장 echo 프로시저가 기대한 대로 동작하거든요.

proc echo*(x: varargs[typed, `$`]) {...}

echo @[1, 2, 3]
# prints "@[1, 2, 3]" and not "123"

미검사 배열 (Unchecked arrays)

UncheckedArray[T] 타입은 경계를 검사하지 않는 특별한 종류의 배열이에요. 필요에 따라 크기가 유연한 배열을 구현할 때 유용하죠. 추가로, 미검사 배열은 크기가 정해지지 않은 C 배열로 번역됩니다.

type
  MySeq = object
    len, cap: int
    data: UncheckedArray[int]

이 코드는 대략 이런 C 코드를 만들어냅니다.

typedef struct {
  NI len;
  NI cap;
  NI data[];
} MySeq;

미검사 배열의 기반 타입은 GC가 관리하는 메모리를 담을 수 없어야 하지만, 현재는 이게 검사되지 않아요.

향후 방향: 언체크 배열에 GC 메모리를 허용하고, GC가 배열의 런타임 크기를 어떻게 결정할지에 대한 명시적 표기를 추가하는 것.

튜플과 객체 타입 (Tuples and object types)

튜플이나 객체 타입의 변수는 이질적(heterogeneous) 저장 컨테이너예요. 튜플 또는 객체는 타입이 다른 여러 이름 붙은 필드를 정의해요. 튜플은 또한 필드의 사전식 순서를 정의합니다. 튜플은 추상화가 거의 없는 이질적 저장 타입을 위한 것이에요. () 문법으로 튜플을 구성할 수 있고, 생성자 안의 필드 순서는 튜플 정의의 순서와 일치해야 해요. 서로 다른 튜플 타입은 같은 타입의 필드를 같은 순서로 지정하면 동등해져요. 필드 이름도 같아야 하죠.

type
  Person = tuple[name: string, age: int] # type representing a person:
                                         # it consists of a name and an age.
var person: Person
person = (name: "Peter", age: 30)
assert person.name == "Peter"
# the same, but less readable:
person = ("Peter", 30)
assert person[0] == "Peter"
assert Person is (string, int)
assert (string, int) is Person
assert Person isnot tuple[other: string, age: int] # `other` is a different identifier

이름 없는 필드가 하나인 튜플은 괄호와 뒤따르는 쉼표로 구성할 수 있어요.

proc echoUnaryTuple(a: (int,)) =
  echo a[0]

echoUnaryTuple (1,)

사실 뒤따르는 쉼표는 모든 튜플 구성에서 허용됩니다.

구현은 접근 성능이 가장 좋도록 필드를 정렬(align)해요. 이 정렬은 C 컴파일러가 하는 방식과 호환됩니다.

객체 선언과의 일관성을 위해, 타입 섹션에서 튜플을 [] 대신 들여쓰기로 정의할 수도 있어요.

type
  Person = tuple   # type representing a person
    name: string   # a person consists of a name
    age: Natural   # and an age

객체는 튜플이 제공하지 않는 많은 기능을 제공해요. 객체는 상속을 지원하고, 다른 모듈로부터 필드를 숨길 수 있어요. 상속이 활성화된 객체는 런타임에 자기 타입에 대한 정보를 갖고 있어서, of 연산자로 객체의 타입을 알아낼 수 있어요. of 연산자는 Java의 instanceof 연산자와 비슷합니다.

type
  Person = object of RootObj
    name*: string   # the * means that `name` is accessible from other modules
    age: int        # no * means that the field is hidden

  Student = ref object of Person # a student is a person
    id: int                      # with an id field

var
  student: Student
  person: Person
assert(student of Student) # is true
assert(student of Person) # also true

정의 모듈 밖에서 보이게 하려는 객체 필드는 *로 표시해야 해요. 튜플과 달리 서로 다른 객체 타입은 절대 동등하지 않아요. 객체는 구조적 타입인 튜플과 대조적으로 명목적(nominal) 타입이기 때문이죠. 조상이 없는 객체는 암시적으로 final이며, 따라서 숨은 타입 정보가 없어요. system.RootObj 외에 새로운 객체 루트를 도입하려면 inheritable 프래그마를 쓸 수 있어요.

type
  Person = object # example of a final object
    name*: string
    age: int

  Student = ref object of Person # Error: inheritance only works with non-final objects
    id: int

튜플과 객체의 대입 연산자는 각 구성 요소를 복사해요. 이 복사 동작을 오버라이드하는 방법은 여기에 설명돼 있어요.

객체 생성 (Object construction)

객체는 T(fieldA: valueA, fieldB: valueB, ...) 문법의 객체 생성 표현식으로 만들 수도 있어요. T는 객체 타입 또는 ref 객체 타입이에요.

type
  Student = object
    name: string
    age: int
  PStudent = ref Student
var a1 = Student(name: "Anton", age: 5)
var a2 = PStudent(name: "Anton", age: 5)
# this also works directly:
var a3 = (ref Student)(name: "Anton", age: 5)
# not all fields need to be mentioned, and they can be mentioned out of order:
var a4 = Student(age: 5)

주의할 점은, 튜플과 달리 객체는 값과 함께 필드 이름이 꼭 필요하다는 거예요. ref 객체 타입이라면 system.new가 암시적으로 호출됩니다.

객체 변형 (Object variants)

어떤 상황에서는 객체 계층 구조가 과할 때가 있어요. 단순한 변형(variant) 타입만 필요한 경우죠. 객체 변형은 열거 타입으로 판별되는 태그가 있는 합집합(tagged union)이에요. 런타임 타입 유연성을 제공하면서, 다른 언어의 합 타입(sum type)이나 대수적 데이터 타입(ADT) 개념을 그대로 반영합니다.

예시를 볼게요.

# This is an example of how an abstract syntax tree could be modelled in Nim
type
  NodeKind = enum  # the different node types
    nkInt,          # a leaf with an integer value
    nkFloat,        # a leaf with a float value
    nkString,       # a leaf with a string value
    nkAdd,          # an addition
    nkSub,          # a subtraction
    nkIf            # an if statement
  Node = ref NodeObj
  NodeObj = object
    case kind: NodeKind  # the `kind` field is the discriminator
    of nkInt: intVal: int
    of nkFloat: floatVal: float
    of nkString: strVal: string
    of nkAdd, nkSub:
      leftOp, rightOp: Node
    of nkIf:
      condition, thenPart, elsePart: Node

# create a new case object:
var n = Node(kind: nkIf, condition: nil)
# accessing n.thenPart is valid because the `nkIf` branch is active:
n.thenPart = Node(kind: nkFloat, floatVal: 2.0)

# the following statement raises an `FieldDefect` exception, because
# n.kind's value does not fit and the `nkString` branch is not active:
n.strVal = ""

# invalid: would change the active object branch:
n.kind = nkInt

var x = Node(kind: nkAdd, leftOp: Node(kind: nkInt, intVal: 4),
                          rightOp: Node(kind: nkInt, intVal: 2))
# valid: does not change the active object branch:
x.kind = nkSub

예시에서 보듯이, 객체 계층 구조에 비해 좋은 점은 서로 다른 객체 타입 사이의 캐스팅이 필요 없다는 거예요. 하지만 유효하지 않은 객체 필드에 접근하면 예외가 발생합니다.

객체 선언에서 case의 문법은 case 문의 문법을 그대로 따릅니다. case 섹션의 분기도 들여쓸 수 있어요.

예시에서 kind 필드는 판별자(discriminator)라고 불러요. 안전을 위해 그 주소를 취할 수 없고, 대입도 제한됩니다. 새 값이 활성 객체 분기(active object branch)를 바꾸지 않아야 해요. 또 객체 생성 중에 특정 분기의 필드를 지정할 때는, 해당하는 판별자 값도 상수 표현식으로 지정해야 합니다.

활성 객체 분기를 바꾸는 대신, 메모리 속 옛 객체를 새 객체로 완전히 교체하는 방법도 있어요.

var x = Node(kind: nkAdd, leftOp: Node(kind: nkInt, intVal: 4),
                          rightOp: Node(kind: nkInt, intVal: 2))
# change the node's contents:
x[] = NodeObj(kind: nkString, strVal: "abc")

버전 0.20부터는 system.reset로 객체 분기 변경을 지원할 수 없게 됐어요. 이 방식은 완전한 메모리 안전성을 보장하지 못했기 때문이에요.

특별한 규칙으로, 판별자 kindcase 문을 사용해 범위를 제한할 수도 있어요. case 문 분기에서 판별자 변수의 가능한 값들이 선택한 객체 분기의 판별자 값들의 부분집합이면, 초기화가 유효한 것으로 간주됩니다. 이 분석은 서수 타입의 불변 판별자에만 동작하고 elif 분기는 무시해요. 판별자 값이 범위 타입이면, 컴파일러는 그 판별자 값의 가능한 전체 범위가 선택한 객체 분기에 유효한지 검사합니다.

작은 예시를 볼게요.

let unknownKind = nkSub

# invalid: unsafe initialization because the kind field is not statically known:
var y = Node(kind: unknownKind, strVal: "y")

var z = Node()
case unknownKind
of nkAdd, nkSub:
  # valid: possible values of this branch are a subset of nkAdd/nkSub object branch:
  z = Node(kind: unknownKind, leftOp: Node(), rightOp: Node())
else:
  echo "ignoring: ", unknownKind

# also valid, since unknownKindBounded can only contain the values nkAdd or nkSub
let unknownKindBounded = range[nkAdd..nkSub](unknownKind)
z = Node(kind: unknownKindBounded, leftOp: Node(), rightOp: Node())

cast uncheckedAssign

case 객체에 대한 일부 제한은 {.cast(uncheckedAssign).} 섹션으로 비활성화할 수 있어요.

type
  TokenKind* = enum
    strLit, intLit
  Token = object
    case kind*: TokenKind
    of strLit:
      s*: string
    of intLit:
      i*: int64

proc passToVar(x: var TokenKind) = discard

var t = Token(kind: strLit, s: "abc")

{.cast(uncheckedAssign).}:
  # inside the 'cast' section it is allowed to pass 't.kind' to a 'var T' parameter:
  passToVar(t.kind)

  # inside the 'cast' section it is allowed to set field 's' even though the
  # constructed 'kind' field has an unknown value:
  t = Token(kind: t.kind, s: "abc")

  # inside the 'cast' section it is allowed to assign to the 't.kind' field directly:
  t.kind = intLit

객체 필드의 기본값 (Default values for object fields)

객체 필드는 상수 기본값을 가질 수 있어요. 기본값이 주어지면 필드의 타입을 생략할 수 있습니다.

type
  Foo = object
    a: int = 2
    b: float = 3.14
    c = "I can have a default value"

  Bar = ref object
    a: int = 2
    b: float = 3.14
    c = "I can have a default value"

명시적 초기화는 이 기본값들을 사용해요. 다음 경우가 해당됩니다. 객체 생성 표현식이나 default 프로시저로 만든 객체, 객체 생성 표현식이나 new 프로시저로 만든 ref 객체, 그리고 기본값이 있는 하위 타입을 가진 배열이나 튜플을 default 프로시저로 만든 경우예요.

type
  Foo = object
    a: int = 2
    b = 3.0
  Bar = ref object
    a: int = 2
    b = 3.0

block: # created with an object construction expression
  let x = Foo()
  assert x.a == 2 and x.b == 3.0

  let y = Bar()
  assert y.a == 2 and y.b == 3.0

block: # created with an object construction expression
  let x = default(Foo)
  assert x.a == 2 and x.b == 3.0

  let y = default(array[1, Foo])
  assert y[0].a == 2 and y[0].b == 3.0

  let z = default(tuple[x: Foo])
  assert z.x.a == 2 and z.x.b == 3.0

block: # created with the procedure `new`
  let y = new Bar
  assert y.a == 2 and y.b == 3.0

집합 타입 (Set type)

집합 타입은 수학의 집합 개념을 모델링해요. 집합의 기반 타입은 특정 크기의 서수 타입만 될 수 있습니다. 구체적으로는:

  • int8-int16
  • uint8/byte-uint16
  • char
  • enum
  • 서수 하위 범위 타입, 즉 range[-10..10]

또는 동등한 타입이에요. 부호 있는 정수 리터럴로 집합을 구성할 때, 집합의 기반 타입은 0 .. DefaultSetElements-1 범위로 정의돼요. 여기서 DefaultSetElements는 현재 항상 2^8이에요. 집합의 기반 타입에 허용되는 최대 범위 길이는 MaxSetElements인데, 현재 항상 2^16이에요. 더 큰 범위 길이를 가진 타입은 0 .. MaxSetElements-1 범위로 강제 변환됩니다.

그 이유는 집합이 고성능 비트 벡터로 구현되기 때문이에요. 더 큰 타입으로 집합을 선언하려 하면 오류가 나요.

  var s: set[int64] # Error: set is too large; use `std/sets` for ordinal types
                    # with more than 2^16 elements

참고로 Nim은 해시 집합(hash set)도 제공해요. import std/sets로 임포트해야 하며, 이 해시 집합에는 그런 제한이 없어요.

집합은 집합 생성자로 구성할 수 있어요. {}는 빈 집합이고, 빈 집합은 어떤 구체적인 집합 타입과도 타입 호환이 돼요. 이 생성자는 요소(그리고 요소 범위)를 포함하는 데도 쓸 수 있습니다.

type
  CharSet = set[char]
var
  x: CharSet
x = {'a'..'z', '0'..'9'} # This constructs a set that contains the
                         # letters from 'a' to 'z' and the digits
                         # from '0' to '9'

std/setutils 모듈은 iterable에서 집합을 초기화하는 방법을 제공합니다.

import std/setutils

let uniqueChars = myString.toSet

집합이 지원하는 연산은 다음과 같아요.

연산 의미
A + B 두 집합의 합집합
A * B 두 집합의 교집합
A - B 두 집합의 차집합 (A에서 B의 요소를 뺀 것)
A == B 집합 동등
A <= B 부분집합 관계 (A가 B의 부분집합이거나 같음)
A < B 진부분집합 관계 (A가 B의 진부분집합)
e in A 집합 소속 (A가 요소 e를 포함)
e notin A A가 요소 e를 포함하지 않음
contains(A, e) A가 요소 e를 포함
card(A) A의 기수 (A의 요소 개수)
incl(A, elem) A = A + {elem}과 같음
excl(A, elem) A = A - {elem}과 같음

비트 필드 (Bit fields)

집합은 종종 프로시저의 플래그 타입을 정의하는 데 사용돼요. 이 방식은 OR로 결합해야 하는 정수 상수를 정의하는 것보다 더 깔끔하고(타입도 안전하고) 좋은 해법이에요.

Enum, 집합, 캐스팅을 함께 쓸 수 있어요.

type
  MyFlag* {.size: sizeof(cint).} = enum
    A
    B
    C
    D
  MyFlags = set[MyFlag]

proc toNum(f: MyFlags): int = cast[cint](f)
proc toFlags(v: int): MyFlags = cast[MyFlags](v)

assert toNum({}) == 0
assert toNum({A}) == 1
assert toNum({D}) == 8
assert toNum({A, C}) == 5
assert toFlags(0) == {}
assert toFlags(7) == {A, B, C}

집합이 어떻게 enum 값을 2의 거듭제곱으로 바꾸는지 볼 수 있죠.

C와 함께 enum과 집합을 쓴다면 distinct cint를 사용하세요.

C와의 상호운용성에 대해서는 bitsize 프래그마도 참고하세요.

참조와 포인터 타입 (Reference and pointer types)

참조(다른 언어의 포인터와 비슷)는 다대일(many-to-one) 관계를 도입하는 방법이에요. 즉 서로 다른 참조가 메모리의 같은 위치를 가리키고 수정할 수 있다는 뜻이죠(에일리어싱(aliasing)이라고도 불러요).

Nim은 추적 참조(traced)와 비-추적 참조(untraced)를 구분해요. 비-추적 참조는 포인터라고도 불러요. 추적 참조는 가비지 컬렉션되는 힙의 객체를 가리키고, 비-추적 참조는 수동으로 할당한 객체나 메모리 다른 곳의 객체를 가리켜요. 그래서 비-추적 참조는 안전하지 않아요. 다만 특정 저수준 연산(하드웨어 접근)에서는 비-추적 참조가 불가피합니다.

추적 참조는 ref 키워드로, 비-추적 참조는 ptr 키워드로 선언해요. 일반적으로 ptr Tpointer 타입으로 암시적으로 변환됩니다.

빈 첨자 [] 표기를 쓰면 참조를 역참조할 수 있고, addr 프로시저는 항목의 주소를 반환해요. 주소는 항상 비-추적 참조이므로, addr의 사용은 안전하지 않은 기능이에요.

.(튜플/객체 필드 접근 연산자)과 [](배열/문자열/시퀀스 인덱스 연산자) 연산자는 참조 타입에 대해 암시적 역참조를 수행해요.

type
  Node = ref NodeObj
  NodeObj = object
    le, ri: Node
    data: int

var
  n: Node
new(n)
n.data = 9
# no need to write n[].data; in fact n[].data is highly discouraged!

구조적 타입 검사를 단순하게 유지하기 위해, 재귀 튜플은 유효하지 않아요.

# invalid recursion
type MyTuple = tuple[a: ref MyTuple]

마찬가지로 T = ref T도 유효하지 않은 타입이에요.

문법 확장으로, 타입 섹션에서 ref objectptr object 표기를 쓰면 객체 타입을 익명으로 만들 수 있어요. 객체가 참조 의미론만 가져도 될 때 유용한 기능이죠.

type
  Node = ref object
    le, ri: Node
    data: int

새 추적 객체를 할당하려면 내장 프로시저 new를 써야 해요. 비-추적 메모리를 다루려면 alloc, dealloc, realloc 프로시저를 쓸 수 있어요. 자세한 내용은 system 모듈 문서를 참고하세요.

Nil

참조가 아무것도 가리키지 않으면 값은 nil이에요. nil은 모든 refptr 타입의 기본값이고, 다른 리터럴 값처럼 쓰일 수 있어요. 예를 들어 myRef = nil처럼 대입에 쓸 수 있죠.

nil을 역참조하는 것은 복구 불가능한 치명적 런타임 오류(패닉이 아니라)예요.

성공적인 역참조 연산 p[]pnil이 아님을 의미해요. 이 성질을 활용해서 구현은 아래 같은 코드를 최적화할 수 있어요.

p[].field = 3
if p != nil:
  # if p were nil, `p[]` would have caused a crash already,
  # so we know `p` is always not nil here.
  action()

이렇게요.

p[].field = 3
action()

참고로 이건 C의 NULL 포인터 역참조에 대한 "정의되지 않은 동작(undefined behavior)"과는 비교할 수 없어요.

GC 메모리와 ptr 섞기 (Mixing GC'ed memory with ptr)

비-추적 객체가 추적 참조, 문자열, 시퀀스 같은 추적 객체를 담고 있다면 특별히 주의해야 해요. 모든 걸 올바르게 해제하려면, 비-추적 메모리를 수동으로 해제하기 전에 내장 프로시저 reset을 호출해야 합니다.

type
  Data = tuple[x, y: int, s: string]

# allocate memory for Data on the heap:
var d = cast[ptr Data](alloc0(sizeof(Data)))

# create a new string on the garbage collected heap:
d.s = "abc"

# tell the GC that the string is not needed anymore:
reset(d.s)

# free the memory:
dealloc(d)

reset 호출이 없으면 d.s 문자열에 할당된 메모리는 영원히 해제되지 않아요. 이 예시는 저수준 프로그래밍의 두 가지 중요한 기능도 보여주죠. sizeof 프로시저는 타입이나 값의 크기를 바이트 단위로 반환해요. 또 cast 연산자는 타입 시스템을 우회할 수 있어요. 컴파일러가 alloc0 호출의 결과(타입 없는 포인터를 반환)를 마치 ptr Data 타입인 것처럼 취급하도록 강제하는 거죠. 캐스팅은 정말 불가피할 때만 해야 해요. 타입 안전성을 깨뜨리고, 버그가 생기면 알 수 없는 크래시로 이어질 수 있으니까요.

참고로 이 예시는 메모리가 0으로 초기화되기 때문에 동작해요(alloc 대신 alloc0이 그걸 하죠). 그래서 d.s가 이진 0으로 초기화되고, 문자열 대입이 그걸 처리할 수 있는 거예요. 가비지 컬렉션 데이터와 관리되지 않는 메모리를 섞을 때는 이렇게 저수준 세부 사항을 알아야 합니다.

프로시저 타입 (Procedural type)

프로시저 타입은 내부적으로 프로시저를 가리키는 포인터예요. nil은 프로시저 타입 변수의 허용 값입니다.

프로시저 호환성은 매개변수와 결과 타입의 소스 레벨 형태뿐 아니라 백엔드 표현도 검사해요. 예전 동작으로 되돌리려면 --legacy:procParamTypeBackendAliases를 사용하세요.

예시를 볼게요.

proc printItem(x: int) = ...

proc forEach(c: proc (x: int) {.cdecl.}) =
  ...

forEach(printItem)  # this will NOT compile because calling conventions differ
type
  OnMouseMove = proc (x, y: int) {.closure.}

proc onMouseMove(mouseX, mouseY: int) =
  # has default calling convention
  echo "x: ", mouseX, " y: ", mouseY

proc setOnMouseMove(mouseMoveEvent: OnMouseMove) = discard

# ok, 'onMouseMove' has the default calling convention, which is compatible
# to 'closure':
setOnMouseMove(onMouseMove)

프로시저 타입의 미묘한 문제는, 프로시저의 호출 규약(calling convention)이 타입 호환성에 영향을 준다는 거예요. 프로시저 타입은 호출 규약이 같을 때만 호환됩니다. 특별한 확장으로, nimcall 호출 규약의 프로시저는 closure 호출 규약을 기대하는 매개변수로 넘길 수 있어요.

Nim이 지원하는 호출 규약은 다음과 같아요.

nimcall

  • Nim 프로시저의 기본 규약이에요. fastcall과 같지만, fastcall을 지원하는 C 컴파일러에서만 그렇습니다.

closure

  • 프래그마 주석이 없는 프로시저 타입의 기본 호출 규약이에요. 프로시저에 숨은 암시적 매개변수(환경)가 있다는 뜻이에요. closure 호출 규약을 가진 proc 변수는 두 개의 머신 워드를 차지해요. 하나는 proc 포인터, 다른 하나는 암시적으로 전달되는 환경을 가리키는 포인터예요.

stdcall

  • Microsoft가 규정한 stdcall 규약이에요. 생성된 C 프로시저는 __stdcall 키워드로 선언됩니다.

cdecl

  • cdecl 규약은 프로시저가 C 컴파일러와 같은 규약을 쓰도록 해요. Windows에서 생성된 C 프로시저는 __cdecl 키워드로 선언됩니다.

safecall

  • Microsoft가 규정한 safecall 규약이에요. 생성된 C 프로시저는 __safecall 키워드로 선언됩니다. safe라는 단어는 모든 하드웨어 레지스터를 하드웨어 스택으로 밀어 넣어야 한다는 사실을 가리켜요.

inline

  • inline 규약은 호출자가 프로시저를 호출하는 대신 그 코드를 직접 인라인하라는 뜻이에요. 참고로 Nim은 인라인하지 않고 C 컴파일러가 하게 남겨둬요. __inline 프로시저를 생성하죠. 이는 컴파일러에 대한 힌트일 뿐이에요. 완전히 무시할 수도 있고, inline으로 표시되지 않은 프로시저를 인라인할 수도 있습니다.

noinline

  • 백엔드 컴파일러는 inline으로 표시되지 않은 프로시저를 인라인할 수 있어요. noinline 규약이 이를 막아줍니다.

fastcall

  • fastcall은 C 컴파일러마다 서로 다른 의미를 가져요. C의 __fastcall이 의미하는 것을 그대로 얻습니다.

thiscall

  • Microsoft가 규정한 thiscall 호출 규약으로, x86 아키텍처에서 C++ 클래스 멤버 함수에 사용돼요.

syscall

  • syscall 규약은 C의 __syscall과 같아요. 인터럽트에 사용됩니다.

noconv

  • 생성된 C 코드에 명시적 호출 규약이 없어서, C 컴파일러의 기본 호출 규약을 사용해요. Nim 프로시저의 기본 호출 규약이 속도를 위해 fastcall이기 때문에 이게 필요합니다.

대부분의 호출 규약은 Windows 32비트 플랫폼에서만 존재해요.

기본 호출 규약은 nimcall이에요. 단, 프로시저 안의 프로시저(inner proc)는 예외예요. inner proc은 자신의 환경에 접근하는지를 분석하고, 접근하면 closure 규약, 아니면 nimcall 규약을 갖게 됩니다.

Distinct 타입

distinct 타입은 기반 타입에서 파생되며 기반 타입과 호환되지 않는 새 타입이에요. 특히 distinct 타입의 핵심 성질은, 그 타입과 기반 타입 사이에 하위 타입 관계가 생기지 않는다는 거예요. distinct 타입에서 기반 타입으로(그리고 그 반대로)의 명시적 타입 변환은 허용됩니다. 반대 연산을 얻으려면 distinctBase도 참고하세요.

distinct 타입은 기반 타입이 서수 타입이면 서수 타입이 돼요.

통화 모델링 (Modeling currencies)

distinct 타입은 예를 들어 숫자 기반 타입으로 서로 다른 물리 단위를 모델링하는 데 쓸 수 있어요. 아래 예시는 통화를 모델링합니다.

서로 다른 통화는 화폐 계산에서 섞으면 안 되겠죠. distinct 타입은 서로 다른 통화를 모델링하는 완벽한 도구예요.

type
  Dollar = distinct int
  Euro = distinct int

var
  d: Dollar
  e: Euro

echo d + 12
# Error: cannot add a number with no unit and a `Dollar`

아쉽게도 d + 12.Dollar도 허용되지 않아요. +Dollar가 아니라 int(등)에 정의돼 있으니까요. 그래서 달러용 +를 정의해야 합니다.

proc `+` (x, y: Dollar): Dollar =
  result = Dollar(int(x) + int(y))

달러와 달러를 곱하는 건 말이 안 되지만, 단위 없는 숫자와는 곱할 수 있어요. 나눗셈도 마찬가지고요.

proc `*` (x: Dollar, y: int): Dollar =
  result = Dollar(int(x) * y)

proc `*` (x: int, y: Dollar): Dollar =
  result = Dollar(x * int(y))

proc `div` ...

이건 금방 지루해져요. 구현은 사소한데 컴파일러가 나중에 최적화로 없애려고 이 모든 코드를 생성하게 만들 필요가 없잖아요. 어차피 달러용 +int+와 같은 이진 코드를 만들어야 하니까요. 이 문제를 해결하려고 borrow 프래그마가 설계됐어요. 원칙적으로 위의 사소한 구현을 대신 생성해 주죠.

proc `*` (x: Dollar, y: int): Dollar {.borrow.}
proc `*` (x: int, y: Dollar): Dollar {.borrow.}
proc `div` (x: Dollar, y: int): Dollar {.borrow.}

borrow 프래그마는 컴파일러가 distinct 타입의 기반 타입을 다루는 프로시저와 같은 구현을 쓰게 해서, 코드를 생성하지 않아요.

하지만 이 모든 보일러플레이트 코드를 Euro 통화에도 반복해야 하는 것 같죠. 이건 템플릿으로 해결할 수 있어요.

template additive(typ: typedesc) =
  proc `+` *(x, y: typ): typ {.borrow.}
  proc `-` *(x, y: typ): typ {.borrow.}

  # unary operators:
  proc `+` *(x: typ): typ {.borrow.}
  proc `-` *(x: typ): typ {.borrow.}

template multiplicative(typ, base: typedesc) =
  proc `*` *(x: typ, y: base): typ {.borrow.}
  proc `*` *(x: base, y: typ): typ {.borrow.}
  proc `div` *(x: typ, y: base): typ {.borrow.}
  proc `mod` *(x: typ, y: base): typ {.borrow.}

template comparable(typ: typedesc) =
  proc `<` * (x, y: typ): bool {.borrow.}
  proc `<=` * (x, y: typ): bool {.borrow.}
  proc `==` * (x, y: typ): bool {.borrow.}

template defineCurrency(typ, base: untyped) =
  type
    typ* = distinct base
  additive(typ)
  multiplicative(typ, base)
  comparable(typ)

defineCurrency(Dollar, int)
defineCurrency(Euro, int)

borrow 프래그마는 distinct 타입에 주석을 달아 특정 내장 연산을 끌어올리도록 하는 데도 쓸 수 있어요.

type
  Foo = object
    a, b: int
    s: string

  Bar {.borrow: `.`.} = distinct Foo

var bb: ref Bar
new bb
# field access now valid
bb.a = 90
bb.s = "abc"

현재 이렇게 빌려올 수 있는 것은 점(.) 접근자뿐이에요.

SQL 인젝션 공격 피하기 (Avoiding SQL injection attacks)

Nim에서 SQL 데이터베이스로 전달되는 SQL 문을 문자열로 모델링할 수 있어요. 하지만 문자열 템플릿을 쓰고 값을 채워 넣으면 유명한 SQL 인젝션 공격에 취약해져요.

import std/strutils

proc query(db: DbHandle, statement: string) = ...

var
  username: string

db.query("SELECT FROM users WHERE name = '$1'" % username)
# Horrible security hole, but the compiler does not mind!

이건 SQL을 담은 문자열과 그렇지 않은 문자열을 구분하면 피할 수 있어요. distinct 타입은 string과 호환되지 않는 새 문자열 타입 SQL을 도입하는 수단을 제공합니다.

type
  SQL = distinct string

proc query(db: DbHandle, statement: SQL) = ...

var
  username: string

db.query("SELECT FROM users WHERE name = '$1'" % username)
# Static error: `query` expects an SQL string!

추상 타입의 핵심 성질은 추상 타입과 기반 타입 사이에 하위 타입 관계가 생기지 않는다는 거예요. string에서 SQL로의 명시적 타입 변환은 허용됩니다.

import std/[strutils, sequtils]

proc properQuote(s: string): SQL =
  # quotes a string properly for an SQL statement
  return SQL(s)

proc `%` (frmt: SQL, values: openarray[string]): SQL =
  # quote each argument:
  let v = values.mapIt(properQuote(it))
  # we need a temporary type for the type conversion :-(
  type StrSeq = seq[string]
  # call strutils.`%`:
  result = SQL(string(frmt) % StrSeq(v))

db.query("SELECT FROM users WHERE name = '$1'".SQL % [username])

이제 SQL 인젝션 공격에 대한 컴파일 타임 검사를 갖게 됐어요. "".SQLSQL("")으로 변환되므로, 보기 좋은 SQL 문자열 리터럴을 위해 새로운 문법이 필요하지 않아요. 이 가상의 SQL 타입은 사실 라이브러리에 db_sqlite 같은 모듈의 SqlQuery 타입으로 실제로 존재합니다.

auto 타입

auto 타입은 반환 타입과 매개변수에만 쓸 수 있어요. 반환 타입으로 쓰면 컴파일러가 루틴 본문에서 타입을 추론합니다.

proc returnsInt(): auto = 1984

매개변수로 쓰면 현재는 암시적으로 제네릭 루틴을 만들어요.

proc foo(a, b: auto) = discard

이것은 다음 코드와 같아요.

proc foo[T1, T2](a: T1, b: T2) = discard

다만 언어의 이후 버전에서는 이것이 "본문에서 매개변수 타입을 추론한다"로 바뀔 수도 있어요. 그러면 위 foo는 매개변수 타입을 비어 있는 discard 문에서 추론할 수 없으므로 거부될 거예요.

더 알아보기 (Learn more)