어휘 분석
어휘 분석
Nim 소스 코드를 컴파일러가 실제로 어떻게 읽는지 들여다보는 구간이에요. 사람 눈에는 그냥 글자처럼 보이지만, 컴파일러는 이 글자들을 작은 조각(토큰, token)으로 잘라내고 각 조각의 의미를 판별하죠. 이번 섹션에서는 그 토큰들이 정확히 어떻게 생겼는지 하나씩 살펴볼게요. 인코딩, 들여쓰기, 주석, 식별자, 그리고 문자열이나 숫자 같은 리터럴을 다룹니다.
출처: Nim Manual
본문
인코딩 (Encoding)
모든 Nim 소스 파일은 UTF-8 인코딩(또는 그 ASCII 부분집합)을 사용해요. 다른 인코딩은 지원하지 않아요. 줄바꿈은 플랫폼 표준 시퀀스 어느 것이든 쓸 수 있는데, 유닉스의 ASCII LF(linefeed), 윈도우의 ASCII CR LF(return + linefeed), 옛 매킨토시의 ASCII CR(return)까지 전부 허용돼요. 이 세 형태는 플랫폼과 무관하게 동등하게 취급됩니다.
들여쓰기 (Indentation)
Nim의 표준 문법은 들여쓰기에 민감한(indentation sensitive) 언어를 기술해요. 다시 말해 모든 제어 구조는 들여쓰기로 구분된다는 뜻이에요. 들여쓰기는 공백(space)으로만 이루어지며, 탭(tabulator)은 허용되지 않아요.
들여쓰기 처리는 이렇게 구현됩니다. 렉서(lexer)가 다음에 오는 토큰에 앞선 공백 개수를 주석처럼 붙여주고, 들여쓰기 자체는 별도의 토큰이 아니에요. 이 요령 덕분에 Nim은 단 1토큰의 lookahead만으로 파싱할 수 있습니다.
파서는 들여쓰기 수준을 담는 스택을 사용해요. 이 스택은 공백 수를 세는 정수로 구성된답니다. 들여쓰기 정보는 파서의 전략적인 지점에서만 조회되고, 그 외에는 무시돼요. 의사-터미널(pseudo-terminal) IND{>}는 스택 맨 위 항목보다 공백이 더 많은 들여쓰기를, IND{=}는 같은 공백 수를 뜻해요. DED는 또 다른 의사 터미널로, 스택에서 값을 꺼내는(pop) 동작을 나타내고, IND{>}는 그 반대로 스택에 밀어 넣는(push) 동작을 암시해요.
이 표기법으로 이제 문법의 핵심을 쉽게 정의할 수 있어요. 문장 블록을 (단순화한 예로) 표현하면 이렇답니다:
ifStmt = 'if' expr ':' stmt
(IND{=} 'elif' expr ':' stmt)*
(IND{=} 'else' ':' stmt)?
simpleStmt = ifStmt / ...
stmt = IND{>} stmt ^+ IND{=} DED # list of statements
/ simpleStmt # or a simple statement
주석 (Comments)
주석은 문자열이나 문자 리터럴 밖이면 어디서든 해시 문자 #로 시작해요. 주석은 주석 조각(comment piece)들이 이어 붙은 형태죠. 한 조각은 #로 시작해 줄 끝까지 이어지고, 줄바꿈 문자도 그 조각에 속해요. 만약 다음 줄이 주석 조각만으로 되어 있고, 그 사이에 다른 토큰이 없다면 새 주석이 시작되는 게 아니라 이어서 합쳐집니다:
i = 0 # This is a single comment over multiple lines.
# The lexer merges these two pieces.
# The comment continues here.
문서 주석(documentation comment)은 ## 두 개로 시작하는 주석이에요. 문서 주석은 토큰이며, 입력 파일의 특정 위치에서만 허용되는데, 그 이유는 문법 트리(syntax tree)에 속하기 때문이에요.
여러 줄 주석 (Multiline comments)
Nim 0.13.0 버전부터 여러 줄 주석을 지원해요. 모양은 이렇습니다:
#[Comment here.
Multiple lines
are not a problem.]#
여러 줄 주석은 중첩(nesting)도 지원해요:
#[ #[ Multiline comment in already
commented out code. ]#
proc p[T](x: T) = discard
]#
여러 줄 문서 주석도 존재하고 역시 중첩을 지원해요:
proc foo =
##[Long documentation comment
here.
]##
discard 문과 세 개의 따옴표 문자열 리터럴을 조합해 여러 줄 주석처럼 쓰는 방법도 있어요:
discard """ You can have any Nim code text commented
out inside this with no indentation restrictions.
yes("May I ask a pointless question?) """
이 방식이 0.13.0 이전에 여러 줄 주석을 처리하던 방법이고, 지금도 testament 테스트 프레임워크에 스펙(specification)을 제공하는 용도로 쓰입니다.
식별자와 키워드 (Identifiers & Keywords)
Nim의 식별자는 문자, 숫자, 밑줄로 이루어진 문자열인데, 다음과 같은 제약이 있어요:
- 문자(letter)로 시작한다
- 밑줄
_로 끝나지 않는다 - 밑줄이 곧바로 두 번 연속(
__) 오는 것은 허용되지 않는다
letter ::= 'A'..'Z' | 'a'..'z' | '\x80'..'\xff'
digit ::= '0'..'9'
IDENTIFIER ::= letter ( ['_'] (letter | digit) )*
현재로서는 서수값이 127보다 큰(비-ASCII) 유니코드 문자는 전부 letter로 분류되어 식별자에 들어갈 수 있어요. 다만 이후 버전의 언어에서는 일부 유니코드 문자를 연산자 문자로 분류할 수도 있어요.
다음 키워드는 예약어라서 식별자로 쓸 수 없어요:
addr and as asm
bind block break
case cast concept const continue converter
defer discard distinct div do
elif else end enum except export
finally for from func
if import in include interface is isnot iterator
let
macro method mixin mod
nil not notin
object of or out
proc ptr
raise ref return
shl shr static
template try tuple type
using
var
when while
xor
yield
일부 키워드는 현재 미사용이라서 언어의 향후 확장을 위해 예약되어 있어요.
식별자 동등성 (Identifier equality)
두 식별자는 다음 알고리즘이 참을 반환하면 동등하다고 봐요:
proc sameIdentifier(a, b: string): bool =
a[0] == b[0] and
a.replace("_", "").toLowerAscii == b.replace("_", "").toLowerAscii
즉, 첫 글자만 대소문자를 구분해 비교하고, 나머지 글자는 ASCII 범위 안에서 대소문자를 구분하지 않으며, 밑줄은 무시해요.
이렇게 파격적인 방식의 식별자 비교를 부분 대소문자 무감지(partial case-insensitivity) 라고 불러요. 일반적인 대소문자 구분 방식과 비교하면 몇 가지 장점이 있습니다:
- 프로그래머가
humpStyle이든snake_style이든 각자 선호하는 표기 스타일을 대부분 그대로 쓸 수 있고, 서로 다른 프로그래머가 작성한 라이브러리끼리도 호환되지 않는 관례를 쓸 걱정이 없어요. - Nim을 인식하는 에디터나 IDE는 식별자를 개발자가 선호하는 모습으로 보여줄 수 있어요.
- 또 식별자의 정확한 철자를 기억할 필요가 없어져요.
첫 글자에 대한 예외는 일반적인 코드 var foo: Foo가 모호함 없이 파싱되도록 해줍니다.
이 규칙은 키워드에도 동일하게 적용돼요. 그래서 notin은 notIn이나 not_in과 같은 것으로 취급됩니다. 다만 키워드는 전부 소문자로 쓰는 방식(notin, isnot)이 선호돼요.
역사적으로 Nim은 완전히 스타일을 무감지(style-insensitive)하는 언어였어요. 대소문자를 구분하지 않았고 밑줄도 무시했으며, foo와 Foo 사이에 아무런 구분조차 없었습니다.
키워드를 식별자로 쓰기 (Keywords as identifiers)
키워드를 역따옴표(backtick)로 감싸면 키워드 성질을 잃고 평범한 식별자가 돼요.
예시:
var `var` = "Hello Stropping"
type Obj = object
`type`: int
let `object` = Obj(`type`: 9)
assert `object` is Obj
assert `object`.`type` == 9
var `var` = 42
let `let` = 8
assert `var` + `let` == 50
const `assert` = true
assert `assert`
문자열 리터럴 (String literals)
문법에서의 종단 기호(terminal symbol)는 STR_LIT이에요.
문자열 리터럴은 짝이 맞는 큰따옴표로 감싸며, 다음과 같은 이스케이프 시퀀스를 포함할 수 있어요:
| 이스케이프 시퀀스 | 의미 |
|---|---|
\p |
플랫폼별 개행: Windows에서 CRLF, Unix에서 LF |
\r, \c |
캐리지 리턴(carriage return) |
\n, \l |
라인 피드(개행, line feed) |
\f |
폼 피드(form feed) |
\t |
탭(tabulator) |
\v |
수직 탭(vertical tabulator) |
\\ |
백슬래시 |
\" |
큰따옴표(quotation mark) |
\' |
작은따옴표(apostrophe) |
\ '0'..'9'+ |
십진수 값 d를 가진 문자; 곧바로 이어지는 십진수 전부가 그 문자에 사용됨 |
\a |
알림(alert) |
\b |
백스페이스(backspace) |
\e |
이스케이프 [ESC] |
\x HH |
16진수 값 HH를 가진 문자; 정확히 두 자리 16진수만 허용 |
\u HHHH |
16진수 값 HHHH를 가진 유니코드 코드포인트; 정확히 네 자리 16진수만 허용 |
\u {H+} |
유니코드 코드포인트; {} 안의 모든 16진수가 코드포인트에 사용됨 |
Nim의 문자열은 내장된 0(zero)을 포함한 어떤 8비트 값이든 담을 수 있어요. 다만 일부 연산은 첫 번째 이진 0을 종료자로 해석할 수도 있습니다.
세 개의 따옴표 문자열 리터럴 (Triple quoted string literals)
문법에서의 종단 기호는 TRIPLESTR_LIT이에요.
문자열 리터럴은 세 개의 큰따옴표 """ ... """로도 감쌀 수 있어요. 이 형태의 리터럴은 여러 줄에 걸칠 수 있고, "를 포함할 수 있으며, 어떤 이스케이프 시퀀스도 해석하지 않아요. 편의상 여는 """ 뒤에 개행이 오면(여는 """와 개행 사이에 공백이 있어도 됩니다) 그 개행(및 바로 앞 공백)은 문자열에 포함되지 않아요. 문자열 리터럴의 끝은 패턴 """ + "가 아닌 문자로 정의돼요. 그래서 이것은:
""""long string within quotes""""
다음과 같은 결과를 만들요:
"long string within quotes"
원시 문자열 리터럴 (Raw string literals)
문법에서의 종단 기호는 RSTR_LIT이에요.
글자 r(또는 R)이 앞에 붙는 원시 문자열 리터럴도 있어요. 일반 문자열 리터럴처럼 짝 맞는 큰따옴표로 감싸되, 이스케이프 시퀀스를 해석하지 않아요. 정규 표현식이나 Windows 경로처럼 백슬래시가 잔뜩 나오는 경우 특히 편리합니다:
var f = openFile(r"C:\texts\text.txt") # a raw string, so ``\t`` is no tab
원시 문자열 리터럴 안에서 " 하나를 만들려면 두 번 연속으로 써야 해요:
r"a""b"
이는 다음을 만듭니다:
a"b
r""""는 이 표기법으로는 불가능해요. 맨 앞의 따옴표 세 개가 세 개의 따옴표 문자열 리터럴을 도입해 버리기 때문이에요. r"""는 """와 동일한데, 어차피 세 개의 따옴표 문자열 리터럴도 이스케이프를 해석하지 않기 때문이에요.
일반화된 원시 문자열 리터럴 (Generalized raw string literals)
문법에서의 종단 기호는 GENERALIZED_STR_LIT, GENERALIZED_TRIPLESTR_LIT이에요.
identifier"string literal" 형태(식별자와 여는 따옴표 사이에 공백이 없는)는 일반화된 원시 문자열 리터럴이에요. 이것은 identifier(r"string literal")의 축약형이라서, 원시 문자열 리터럴을 유일한 인자로 받는 루틴 호출을 뜻합니다. 일반화된 원시 문자열 리터럴은 미니 언어를 Nim에 직접 내장할 때 특히 편리해요(예: 정규 표현식).
identifier"""string literal""" 형태도 존재해요. 이것은 identifier("""string literal""")의 축약형입니다.
문자 리터럴 (Character literals)
문자 리터럴은 작은따옴표 ''로 감싸고, 문자열과 같은 이스케이프 시퀀스를 포함할 수 있어요. 단 하나의 예외가 있는데, 플랫폼 의존 개행(\p)은 한 문자보다 넓을 수 있어서(CR/LF 쌍이 될 수 있으므로) 허용되지 않아요. 문자 리터럴의 유효한 이스케이프 시퀀스는 다음과 같습니다:
| 이스케이프 시퀀스 | 의미 |
|---|---|
\r, \c |
캐리지 리턴 |
\n, \l |
라인 피드 |
\f |
폼 피드 |
\t |
탭 |
\v |
수직 탭 |
\\ |
백슬래시 |
\" |
큰따옴표 |
\' |
작은따옴표 |
\ '0'..'9'+ |
십진수 값 d를 가진 문자; 곧바로 이어지는 십진수 전부가 그 문자에 사용됨 |
\a |
알림 |
\b |
백스페이스 |
\e |
이스케이프 [ESC] |
\x HH |
16진수 값 HH를 가진 문자; 정확히 두 자리 16진수만 허용 |
문자는 유니코드 문자가 아니라 단일 바이트예요.
이유(rationale): array[char, int]나 set[char]를 효율적으로 지원할 수 있게 해줍니다.
Rune 타입은 어떤 유니코드 문자든 표현할 수 있어요. Rune은 unicode 모듈에 선언되어 있습니다.
'로 끝나지 않는 문자 리터럴은, 바로 앞에 역따옴표 토큰이 있으면 '로 해석돼요. 앞의 역따옴표 토큰과 문자 리터럴 사이에는 공백이 있으면 안 돼요. 이 특수한 경우 덕분에 proc 'customLiteral(s: string) 같은 선언이 유효해집니다. proc 'customLiteral(s: string)은 proc '''customLiteral(s: string)과 같은 의미예요.
사용자 정의 숫자 리터럴(custom numeric literals)도 함께 보세요.
숫자 리터럴 (Numeric literals)
숫자 리터럴은 다음과 같은 형태를 가져요:
hexdigit = digit | 'A'..'F' | 'a'..'f'
octdigit = '0'..'7'
bindigit = '0'..'1'
unary_minus = '-' # See the section about unary minus
HEX_LIT = unary_minus? '0' ('x' | 'X' ) hexdigit ( ['_'] hexdigit )*
DEC_LIT = unary_minus? digit ( ['_'] digit )*
OCT_LIT = unary_minus? '0' 'o' octdigit ( ['_'] octdigit )*
BIN_LIT = unary_minus? '0' ('b' | 'B' ) bindigit ( ['_'] bindigit )*
INT_LIT = HEX_LIT
| DEC_LIT
| OCT_LIT
| BIN_LIT
INT8_LIT = INT_LIT ['\''] ('i' | 'I') '8'
INT16_LIT = INT_LIT ['\''] ('i' | 'I') '16'
INT32_LIT = INT_LIT ['\''] ('i' | 'I') '32'
INT64_LIT = INT_LIT ['\''] ('i' | 'I') '64'
UINT_LIT = INT_LIT ['\''] ('u' | 'U')
UINT8_LIT = INT_LIT ['\''] ('u' | 'U') '8'
UINT16_LIT = INT_LIT ['\''] ('u' | 'U') '16'
UINT32_LIT = INT_LIT ['\''] ('u' | 'U') '32'
UINT64_LIT = INT_LIT ['\''] ('u' | 'U') '64'
exponent = ('e' | 'E' ) ['+' | '-'] digit ( ['_'] digit )*
FLOAT_LIT = unary_minus? digit (['_'] digit)* (('.' digit (['_'] digit)* [exponent]) |exponent)
FLOAT32_SUFFIX = ('f' | 'F') ['32']
FLOAT32_LIT = HEX_LIT '\'' FLOAT32_SUFFIX
| (FLOAT_LIT | DEC_LIT | OCT_LIT | BIN_LIT) ['\''] FLOAT32_SUFFIX
FLOAT64_SUFFIX = ( ('f' | 'F') '64' ) | 'd' | 'D'
FLOAT64_LIT = HEX_LIT '\'' FLOAT64_SUFFIX
| (FLOAT_LIT | DEC_LIT | OCT_LIT | BIN_LIT) ['\''] FLOAT64_SUFFIX
CUSTOM_NUMERIC_LIT = (FLOAT_LIT | INT_LIT) '\'' CUSTOM_NUMERIC_SUFFIX
# CUSTOM_NUMERIC_SUFFIX is any Nim identifier that is not
# a pre-defined type suffix.
위 규칙에서 볼 수 있듯이 숫자 리터럴은 가독성을 위해 밑줄을 포함할 수 있어요. 정수·부동소수점 리터럴은 십진수(접두사 없음), 이진수(접두사 0b), 팔진수(접두사 0o), 십육진수(접두사 0x) 표기로 나타낼 수 있어요.
-1 같은 숫자 리터럴에서 단항 마이너스 -가 해당 리터럴의 일부로 간주되는 것은 언어에 늦게 추가된 기능이에요. 그 이유는 -128'i8 같은 표현식이 유효해야 하는데, 이 특별한 경우가 없으면 불가능하기 때문이에요 — 128은 유효한 int8 값이 아니고 -128만 유효하니까요.
unary_minus 규칙에는 형식 문법에 담기지 않은 추가 제약이 있어요. -가 숫자 리터럴의 일부가 되려면 바로 앞의 문자가 {' ', '\t', '\n', '\r', ',', ';', '(', '[', '{'} 집합에 속해야 해요. 이 집합은 대부분의 경우를 자연스럽게 처리하도록 설계되었어요.
다음 예시들에서 -1은 하나의 토큰이에요:
echo -1
echo(-1)
echo [-1]
echo 3,-1
"abc";-1
다음 예시들에서 -1은 두 개의 토큰(-와 1)으로 파싱돼요:
echo x-1
echo (int)-1
echo [a]-1
"abc"-1
작은따옴표(')로 시작하는 접미사를 타입 접미사(type suffix)라고 불러요. 타입 접미사가 없는 리터럴은, 소수점이나 E|e를 포함하지 않으면 정수 타입이고, 포함하면 float 타입이에요. 그 정수 타입은 리터럴이 low(int32)..high(int32) 범위 안에 있으면 int, 아니면 int64예요. 표기상의 편의를 위해 타입 접미사의 작은따옴표는 모호하지 않으면 생략할 수 있어요(모호할 수 있는 건 타입 접미사가 붙은 16진수 부동소수점 리터럴뿐이에요).
미리 정의된 타입 접미사는 다음과 같습니다:
| 타입 접미사 | 리터럴 결과 타입 |
|---|---|
'i8 |
int8 |
'i16 |
int16 |
'i32 |
int32 |
'i64 |
int64 |
'u |
uint |
'u8 |
uint8 |
'u16 |
uint16 |
'u32 |
uint32 |
'u64 |
uint64 |
'f |
float32 |
'd |
float64 |
'f32 |
float32 |
'f64 |
float64 |
부동소수점 리터럴은 이진수, 팔진수, 십육진수 표기로도 나타낼 수 있어요. 0B0_10001110100_0000101001000111101011101111111011000101001101001001'f64는 IEEE 부동소수점 표준에 따르면 대략 1.72826e35예요.
리터럴은 데이터 타입과 일치해야 해요. 예를 들어 333'i8은 유효하지 않은 리터럴이에요. 10진수 이외의 리터럴은 주로 플래그나 비트 패턴 표현에 쓰이므로, 검사는 값 범위가 아니라 비트 폭(bit width) 기준으로 이루어져요. 그래서 0b10000000'u8 == 0x80'u8 == 128이지만, 0b10000000'i8 == 0x80'i8 == -1이 되고 오버플로 오류는 발생하지 않아요.
사용자 정의 숫자 리터럴 (Custom numeric literals)
접미사가 미리 정의된 것이 아니라면, 그 접미사는 리터럴을 담은 문자열을 받는 proc, template, macro 등의 호출 가능한 식별자에 대한 호출로 간주돼요. 그 호출 가능한 식별자는 특별한 ' 접두사로 선언되어야 합니다:
import std/strutils
type u4 = distinct uint8 # a 4-bit unsigned integer aka "nibble"
proc `'u4`(n: string): u4 =
# The leading ' is required.
result = (parseInt(n) and 0x0F).u4
var x = 5'u4
좀 더 형식적으로 말하면, 사용자 정의 숫자 리터럴 123'custom은 파싱 단계에서 r"123".'custom`으로 변환돼요. 이 변환에 대응하는 AST 노드 종류는 없어요. 이 변환은 호출받는 쪽에 추가 인자가 전달되는 경우도 자연스럽게 처리해요:
import std/strutils
type u4 = distinct uint8 # a 4-bit unsigned integer aka "nibble"
proc `'u4`(n: string; moreData: int): u4 =
result = (parseInt(n) and 0x0F).u4
var x = 5'u4(123)
사용자 정의 숫자 리터럴은 CUSTOM_NUMERIC_LIT이라는 문법 규칙으로 다뤄지며, 단일 토큰이에요.
연산자 (Operators)
Nim은 사용자 정의 연산자를 지원해요. 연산자는 다음 문자들의 어떤 조합이든 가능합니다:
= + - * /
@ $ ~ & % |
! ? ^ . : \
(문법은 여기 정의된 연산자 기호를 가리키기 위해 종단 기호 OPR을 사용해요.)
다음 키워드들도 연산자예요: and or not xor shl shr div mod in notin is isnot of as from.
., =, :, ::는 일반 연산자로 쓸 수 없어요. 다른 표기 목적에 사용되기 때문이에요.
*:는 특별한 경우로, 두 토큰 *와 :로 취급돼요(var v*: T를 지원하기 위해서입니다).
not 키워드는 항상 단항 연산자라서, a not b는 (a) not (b)가 아니라 a(not b)로 파싱돼요.
유니코드 연산자 (Unicode Operators)
다음 유니코드 연산자들도 연산자로 파싱돼요:
∙ ∘ × ★ ⊗ ⊘ ⊙ ⊛ ⊠ ⊡ ∩ ∧ ⊓ # same priority as * (multiplication)
± ⊕ ⊖ ⊞ ⊟ ∪ ∨ ⊔ # same priority as + (addition)
유니코드 연산자는 비-유니코드 연산자 기호와 결합할 수 있어요. 그러면 평소의 우선순위 확장 규칙이 적용되는데, 예를 들어 ⊠=는 *=처럼 대입 성격의 연산자가 돼요.
유니코드 정규화(normalization) 단계는 수행되지 않아요.
기타 토큰 (Other tokens)
다음 문자열들은 기타 토큰을 나타내요:
` ( ) { } [ ] , ; [. .] {. .} (. .) [:
슬라이스 연산자 ..는 점(dot)을 포함한 다른 토큰보다 우선순위를 가져요. 그래서 {..}는 세 개의 토큰 {, .., }이지, 두 개의 토큰 {., .}이 아니에요.
더 알아보기 (Learn more)
- Nim Manual — 3. Lexical Analysis — 원문 전체에서 문자열·숫자 리터럴의 규칙을 확인할 수 있어요.
- Nim by Example — 실제 코드로 문법을 익히기 좋아요.
- 네이밍 규칙(
foovsFoo)이 느슨한 이유가 궁금하다면 이 문서의 "식별자 동등성" 항목을 다시 보세요.