챕터 2 — 어휘 구조
챕터 2 — 어휘 구조 (Lexical Structure)
Haskell 소스 코드가 토큰 하나하나로 쪼개지기까지, 즉 컴파일러가 처리하는 가장 낮은 수준의 단위인 **어휘 구조(lexical structure)**를 다루는 장이에요. 함수를 어떻게 쓰는지 같은 문법 이야기는 뒤 장에서 나오고요, 이 장은 그보다 더 밑바닥 — 글자가 모여 식별자가 되고, 숫자 리터럴이 되고, 주석이 되는 과정을 설명해요. 리포트를 처음 읽을 때는 대부분의 세부 내용을 건너뛰어도 괜찮다는 게 원문의 입장입니다.
출처: https://www.haskell.org/onlinereport/haskell2010/haskellch2.html
본문
이 장에서는 Haskell의 낮은 수준의 어휘 구조에 대해 설명할게요. 리포트를 처음 읽는 분이라면 이 장의 세부 내용은 대부분 건너뛰어도 됩니다.
2.1 표기 규약 (Notational Conventions)
문법을 표현할 때 쓰는 표기 규약부터 정리할게요.
| 표기법 | 의미 |
|---|---|
[pattern] |
선택 — 있어도 되고 없어도 돼요 (optional) |
{pattern} |
0회 이상 반복 (zero or more repetitions) |
(pattern) |
그룹 (grouping) |
pat₁ | pat₂ |
선택 — 둘 중 하나 (choice) |
pat⟨pat′⟩ |
차집합 — pat이 만들어내는 요소 중에서 pat′이 만들어내는 것을 뺀 나머지 (difference) |
fibonacci |
타자체로 표시된 터미널 구문 (terminal syntax in typewriter font) |
이 절에서 다루는 문법은 어휘(lexical) 문법이기 때문에, 모든 공백은 명시적으로 표현되어 있어요. 그래서 붙어 있는 기호 사이에 암묵적인 공백이 없죠. 전반적으로 BNF스러운 문법을 쓰는데, 생산 규칙(production)의 형태는 이렇게 생겼어요.
nonterm → alt₁ | alt₂ | … | altₙ
여기서 주의할 점이 하나 있어요. |나 […]처럼 **메타 문법(metalogical syntax)**과, 타자체로 표시된 구체적인 터미널 구문을 헷갈리면 안 됩니다. 예를 들어 메타 문법의 |와 터미널 기호인 |는 겉모습은 같아도 역할이 달라요. 다행히 보통은 문맥이 구분을 해 주니까 너무 걱정하지 않아도 됩니다.
Haskell은 Unicode 문자 집합을 사용해요. 다만 현재 소스 프로그램은 이전 버전 Haskell에서 쓰던 ASCII 문자 집합에 맞춰져 있는 편이고요. 이 문법은 Unicode Consortium이 정의하는 유니코드 문자의 속성에 의존하는데, Haskell 컴파일러들은 유니코드의 새 버전이 나오면 그것을 활용할 것이 기대됩니다.
2.2 어휘적 프로그램 구조 (Lexical Program Structure)
프로그램을 이루는 가장 큰 어휘 단위부터 작은 단위까지, 문법으로 한번 정리해 볼게요.
program → { lexeme | whitespace }
lexeme → qvarid | qconid | qvarsym | qconsym
| literal | special | reservedop | reservedid
literal → integer | float | char | string
special → ( ) , ; [ ] ` { }
whitespace → whitestuff {whitestuff}
whitestuff → whitechar | comment | ncomment
whitechar → newline | vertab | space | tab | uniWhite
newline → return linefeed | return | linefeed | formfeed
return → a carriage return
linefeed → a line feed
vertab → a vertical tab
formfeed → a form feed
space → a space
tab → a horizontal tab
uniWhite → any Unicode character defined as whitespace
comment → dashes [ any⟨symbol⟩ {any} ] newline
dashes → -- {-}
opencom → {-
closecom → -}
ncomment → opencom ANY seq {ncomment ANY seq} closecom
ANY seq → {ANY }⟨{ANY } ( opencom | closecom ) {ANY }⟩
ANY → graphic | whitechar
any → graphic | space | tab
graphic → small | large | symbol | digit | special | " | '
small → ascSmall | uniSmall | _
ascSmall → a | b | … | z
uniSmall → any Unicode lowercase letter
large → ascLarge | uniLarge
ascLarge → A | B | … | Z
uniLarge → any uppercase or titlecase Unicode letter
symbol → ascSymbol | uniSymbol⟨special | _ | " | '⟩
ascSymbol → ! # $ % & ⋆ + . / < = > ? @
| \ ^ | - ~ :
uniSymbol → any Unicode symbol or punctuation
digit → ascDigit | uniDigit
ascDigit → 0 | 1 | … | 9
uniDigit → any Unicode decimal digit
octit → 0 | 1 | … | 7
hexit → digit | A | … | F | a | … | f
어휘 분석은 "maximal munch" 규칙을 따라야 해요. 매 지점에서, lexeme 생산 규칙을 만족하는 가장 긴 lexeme을 읽어들이는 거죠. 그래서 case는 예약어지만 cases는 예약어가 아니고요, 마찬가지로 =는 예약돼 있지만 ==나 ~=는 예약돼 있지 않아요. 어떤 종류의 whitespace든 lexeme 사이의 적절한 구분자(delimiter)가 돼 주고요. ANY 범주에 속하지 않는 문자는 Haskell 프로그램에서 유효하지 않으니, lexing 에러를 내야 합니다.
2.3 주석 (Comments)
주석(comment)은 유효한 whitespace로 취급돼요.
**일반 주석(ordinary comment)**은 대시(dash) 둘 이상이 연속으로 이어지는 것으로 시작해서(-- 같은 것), 다음 줄바꿈까지 이어집니다. 다만 이 대시 나열이 합법적인 lexeme의 일부를 이루면 안 돼요. 예를 들어 -->나 |--는 둘 다 합법적인 lexeme이라서 주석을 시작하지 않아요. 반면 --foo는 주석을 시작하죠.
**중첩 주석(nested comment)**은 {- 로 시작해서 -}로 끝나요. 합법적인 lexeme 중에 {-로 시작하는 것은 없기 때문에, 예를 들어 {---는 끝에 대시가 붙어 있음에도 중첩 주석을 시작합니다. 주석 자체는 어휘 분석을 거치지 않아요. 대신 -}가 처음으로 짝이 안 맞는 지점에서 중첩 주석이 끝난다고 봅니다. 중첩 주석은 아무 깊이로나 중첩될 수 있어요 — 중첩 주석 안의 {-는 새로운 중첩 주석을 시작하고, 각 {-는 대응하는 -}와 짝을 이룹니다.
일반 주석 안에서는 {-와 -}가 특별한 의미를 갖지 않고, 중첩 주석 안에서는 대시 나열이 특별한 의미를 갖지 않아요.
중첩 주석은 컴파일러 프라그마(pragma)를 쓸 때도 사용되는데, 이 내용은 챕터 12에서 설명합니다.
한 가지 주의할 점이 있어요. 코드를 중첩 주석으로 주석 처리할 때, 그 코드 안에 문자열이나 줄 끝 주석에 {-나 -}가 들어 있으면 중첩 주석과 충돌하게 됩니다. 그럴 땐 중첩 주석이 예상보다 일찍 끝나버리니까요.
2.4 식별자와 연산자 (Identifiers and Operators)
varid → (small {small | large | digit | ' })⟨reservedid⟩
conid → large {small | large | digit | ' }
reservedid → case | class | data | default | deriving | do | else
| foreign | if | import | in | infix | infixl
| infixr | instance | let | module | newtype | of
| then | type | where | _
식별자(identifier)는 글자 하나로 시작해서, 뒤에 글자·숫자·밑줄·작은따옴표가 0개 이상 붙는 형태예요. 식별자는 어휘적으로 **두 개의 이름공간(namespace)**으로 나뉘는데(섹션 1.4), 소문자로 시작하는 것은 변수 식별자(variable identifier), 대문자로 시작하는 것은 **생성자 식별자(constructor identifier)**가 됩니다. 식별자는 대소문자를 구분해서, name, naMe, Name은 서로 다른 식별자예요. 처음 둘은 변수 식별자이고 마지막은 생성자 식별자죠.
밑줄 _은 소문자로 취급되며, 소문자가 올 수 있는 자리면 어디든 올 수 있어요. 그런데 _ 혼자 쓰이면 예약 식별자라서, 패턴에서 와일드카드로 쓰입니다. 미사용 식별자 경고를 제공하는 컴파일러라면 밑줄로 시작하는 식별자에 대해서는 그 경고를 끄는 걸 권장해요. 그래야 프로그래머가 안 쓸 게 뻔한 파라미터 이름을 _foo처럼 지을 수 있으니까요.
varsym → ( symbol⟨:⟩ {symbol} )⟨reservedop | dashes⟩
consym → ( : {symbol})⟨reservedop⟩
reservedop → .. : :: = \ | <- -> @ ~ =>
**연산자 기호(operator symbol)**는 위에서 정의한 기호 문자 하나 이상으로 만들어지고, 역시 두 이름공간으로 나뉘어요(섹션 1.4).
- 콜론으로 시작하는 연산자 기호는 **생성자(constructor)**예요.
- 다른 문자로 시작하는 연산자 기호는 일반 식별자입니다.
참고로 콜론 : 단독은 Haskell의 리스트 생성자로만 쓰도록 예약되어 있어요. 그래서 []나 [a,b] 같은 리스트 문법의 다른 부분들과 처리가 일관되죠.
전위 부정(prefix negation)의 특별한 문법을 제외하면, 모든 연산자는 **중위(infix)**예요. 다만 각 중위 연산자는 섹션(section) 안에서 쓰여 부분 적용(partially applied)된 연산자를 만들 수 있고요(섹션 3.5 참조). 표준 중위 연산자들은 그냥 미리 정의된 기호일 뿐이라 재바인딩할 수 있어요.
앞으로 리포트의 나머지 부분에서는 여섯 종류의 이름이 쓰입니다.
| 이름 | 뜻 |
|---|---|
varid |
변수 (variables) |
conid |
생성자 (constructors) |
tyvar → varid |
타입 변수 (type variables) |
tycon → conid |
타입 생성자 (type constructors) |
tycls → conid |
타입 클래스 (type classes) |
modid → {conid .} conid |
모듈 (modules) |
변수와 타입 변수는 소문자로 시작하는 식별자로 나타내고, 나머지는 대문자로 시작하는 식별자로 나타내요. 또 변수와 생성자는 중위 형태가 있지만, 나머지 네 종류는 중위 형태가 없습니다. 모듈 이름은 conid를 점으로 구분해 이어 붙인 형태이고요. 이름공간에 대한 이야기는 섹션 1.4에서도 다룹니다.
이름은 특정 상황에서 모듈 식별자를 앞에 붙여 **한정(qualified)**할 수 있어요. 변수·생성자·타입 생성자·타입 클래스 이름에는 적용되지만, 타입 변수나 모듈 이름에는 적용되지 않습니다. 한정된 이름에 대한 자세한 내용은 챕터 5에서 다뤄요.
qvarid → [modid .] varid
qconid → [modid .] conid
qtycon → [modid .] tycon
qtycls → [modid .] tycls
qvarsym → [modid .] varsym
qconsym → [modid .] consym
한정된 이름은 하나의 lexeme이기 때문에, 한정자와 이름 사이에 공백이 들어가면 안 됩니다. 몇 가지 예시 어휘 분석을 볼게요.
| 입력 | 분석 결과 |
|---|---|
f.g |
f . g (세 토큰) |
F.g |
F.g (한정된 g) |
f.. |
f .. (두 토큰) |
F.. |
F.. (한정된 .) |
F. |
F . (두 토큰) |
한정자는 이름의 문법적 취급을 바꾸지 않아요. 예를 들어 Prelude.+는 Prelude의 + 정의와 같은 fixity를 가진 중위 연산자입니다(섹션 4.4.2 참조).
2.5 숫자 리터럴 (Numeric Literals)
decimal → digit{digit}
octal → octit{octit}
hexadecimal → hexit{hexit}
integer → decimal
| 0o octal | 0O octal
| 0x hexadecimal | 0X hexadecimal
float → decimal . decimal [exponent]
| decimal exponent
exponent → (e | E) [+ | -] decimal
숫자 리터럴에는 **정수(integer)**와 부동소수(float) 두 종류가 있어요. 정수 리터럴은 십진법(기본), 팔진법(0o나 0O 접두사), 십육진법(0x나 0X 접두사)으로 쓸 수 있어요. 부동소수 리터럴은 항상 십진법이고요. 부동소수 리터럴은 소수점 앞뒤에 반드시 숫자가 있어야 해요. 그래야 소수점이 다른 용도의 점(.) 문자와 헷갈리지 않거든요. 음수 숫자 리터럴은 섹션 3.4에서, 숫자 리터럴의 타입 부여는 섹션 6.4.1에서 다룹니다.
2.6 문자·문자열 리터럴 (Character and String Literals)
char → ' (graphic⟨' | \⟩ | space | escape⟨\&⟩) '
string → " {graphic⟨" | \⟩ | space | escape | gap} "
escape → \ ( charesc | ascii | decimal | o octal | x hexadecimal )
charesc → a | b | f | n | r | t | v | \ | " | ' | &
ascii → ^cntrl | NUL | SOH | STX | ETX | EOT | ENQ | ACK
| BEL | BS | HT | LF | VT | FF | CR | SO | SI | DLE
| DC1 | DC2 | DC3 | DC4 | NAK | SYN | ETB | CAN
| EM | SUB | ESC | FS | GS | RS | US | SP | DEL
cntrl → ascLarge | @ | [ | \ | ] | ^ | _
gap → \ whitechar {whitechar} \
문자 리터럴은 작은따옴표 사이에 쓰는데 'a'처럼요, 문자열은 큰따옴표 사이에 쓰고 "Hello"처럼 됩니다.
이스케이프 코드를 쓰면 문자나 문자열 안에 특수 문자를 표현할 수 있어요. 주의할 점이 있습니다. 작은따옴표 '은 문자열 안에서는 그냥 써도 되지만 문자 안에서는 이스케이프해야 하고요, 반대로 큰따옴표 "는 문자 안에서는 써도 되지만 문자열 안에서는 이스케이프해야 합니다. 백슬래시 \는 항상 이스케이프해야 하고요.
charesc 범주에는 휴대 가능한(portable) 표현들이 포함돼 있는데, "alert"(\a), "backspace"(\b), "form feed"(\f), "new line"(\n), "carriage return"(\r), "horizontal tab"(\t), "vertical tab"(\v) 같은 것들이에요. \^X 같은 제어 문자를 포함한, Unicode 문자 집합을 위한 이스케이프 문자도 제공됩니다.
\137 같은 숫자 이스케이프는 십진 표현이 137인 문자를 가리키는 데 쓰여요. 팔진법(예: \o137)과 십육진법(예: \x37) 표현도 허용됩니다.
"maximal munch" 규칙에 맞춰, 문자열의 숫자 이스케이프 문자는 연속된 모든 숫자를 차지하며 길이가 임의로 길어질 수 있어요. 마찬가지로 딱 하나 모호한 ASCII 이스케이프 코드인 "\SOH"는 길이 1인 문자열로 파싱됩니다. 이스케이프 문자 \&는 "null 문자"로 제공되는데, "\137\&9"나 "\SO\&H" 같은 문자열을(둘 다 길이 2) 만들기 위해서예요. 그래서 "\&"는 ""와 동치이고, 문자 '\&'는 허용되지 않습니다. 문자의 추가 동치는 섹션 6.1.2에서 정의돼요.
문자열은 **"gap"**을 포함할 수 있는데, 이건 백슬래시 두 개가 공백 문자를 감싼 것으로 그냥 무시됩니다. 덕분에 긴 문자열을 여러 줄에 걸쳐 쓸 수 있어요. 한 줄 끝에 백슬래시를 그리고 다음 줄 시작에도 백슬래시를 쓰면 되죠. 예를 들면:
"Here is a backslant \\ as well as \137, \
\a numeric escape character, and \^X, a control character."
참고로 문자열 리터럴은 실제로는 문자들의 리스트를 줄여 쓴 것(축약)이에요(섹션 3.7 참조).
2.7 레이아웃 (Layout)
Haskell은 몇몇 문법 생산 규칙에서 쓰이는 중괄호와 세미콜론을 생략할 수 있는데, **레이아웃(layout)**이 그 정보를 대신 전달해요. 덕분에 레이아웃에 민감한 코딩 스타일과 그렇지 않은 스타일을 한 프로그램 안에서 자유롭게 섞어 쓸 수 있고요. 레이아웃이 필수는 아니기 때문에, 다른 프로그램이 Haskell 프로그램을 그대로 만들어내기도 쉬워요.
레이아웃이 Haskell 프로그램의 의미에 미치는 영향은, 레이아웃이 정해주는 위치에 중괄호와 세미콜론을 넣어주는 것으로 완전히 설명할 수 있어요. 그렇게 보강된 프로그램의 의미는 이제 레이아웃에 무관해지죠.
대략적으로 말하면, 중괄호와 세미콜론은 이렇게 들어갑니다. 레이아웃(또는 "off-side") 규칙은 where, let, do, of 다음에 여는 중괄호가 생략됐을 때 적용돼요. 그때 다음 lexeme의 들여쓰기(새 줄이 아니어도 됩니다)를 기억해 두고, 생략된 여는 중괄호를 그 자리에 넣습니다(앞의 공백에는 주석이 포함될 수 있어요). 그 이후 각 줄에 대해:
- 줄이 공백만으로 이뤄졌거나 더 많이 들여쓰여 있으면 → 이전 항목이 계속되는 것이라 아무것도 넣지 않아요.
- 같은 만큼 들여쓰여 있으면 → 새 항목이 시작되는 것이라 세미콜론을 넣어요.
- 덜 들여쓰여 있으면 → 레이아웃 리스트가 끝나는 것이라 닫는 중괄호를 넣어요.
만약 where, let, do, of 바로 다음에 오는 비-중괄호 lexeme의 들여쓰기가 현재 들여쓰기 수준 이하라면, 레이아웃을 시작하는 대신 빈 리스트 {}를 넣고, 현재 수준에서 레이아웃 처리를 계속합니다(즉 세미콜론이나 닫는 중괄호를 넣죠).
레이아웃 리스트를 포함하는 문법 범주가 끝나면 닫는 중괄호도 넣어집니다. 즉, 닫는 중괄호가 합법일 지점에 비합법적인 lexeme이 오면, 닫는 중괄호가 삽입돼요. 레이아웃 규칙은 자신이 넣은 여는 중괄호에만 대응합니다. 명시적인 여는 중괄호는 반드시 명시적인 닫는 중괄호로 짝지어져야 하고요. 그 명시적인 중괄호 안에서는, 밖의 구조를 위한 레이아웃 처리가 이뤄지지 않아요 — 줄이 이전의 암묵적 여는 중괄호보다 왼쪽으로 들여써져 있어도 말이죠. 레이아웃 규칙의 더 정밀한 정의는 섹션 10.3에 있습니다.
이 규칙들 덕분에, 하나의 줄바꿈이 사실 여러 레이아웃 리스트를 끝낼 수도 있어요. 또 아래 같은 코드가 허용되죠.
f x = let a = 1; b = 2
g y = exp2
in exp1
이렇게 하면 a, b, g가 모두 같은 레이아웃 리스트에 속하게 됩니다.
예를 들어, 그림 2.1은 (조금 억지로 만든) 모듈 하나를 보여주고, 그림 2.2는 거기에 레이아웃 규칙을 적용한 결과를 보여줘요. 특히 눈여겨볼 점은: (a) }};pop으로 시작하는 줄 — 이전 줄의 종료가 레이아웃 규칙을 세 번 적용시키는데, 이는 중첩된 where 절의 깊이(3)에 대응하고요, (b) 튜플과 case 식 안에 중첩된 where 절의 닫는 중괄호 — 튜플의 끝이 감지돼서 들어갔고, (c) 맨 끝의 닫는 중괄호 — 파일 끝 토큰의 들여쓰기가 0열이라서 들어갔죠.
그림 2.1 — 샘플 프로그램:
module AStack( Stack, push, pop, top, size ) where
data Stack a = Empty
| MkStack a (Stack a)
push :: a -> Stack a -> Stack a
push x s = MkStack x s
size :: Stack a -> Int
size s = length (stkToLst s) where
stkToLst Empty = []
stkToLst (MkStack x s) = x:xs where xs = stkToLst s
pop :: Stack a -> (a, Stack a)
pop (MkStack x s)
= (x, case s of r -> i r where i x = x) -- (pop Empty) is an error
top :: Stack a -> a
top (MkStack x s) = x -- (top Empty) is an error
그림 2.2 — 레이아웃이 전개된 샘플 프로그램:
module AStack( Stack, push, pop, top, size ) where
{data Stack a = Empty
| MkStack a (Stack a)
;push :: a -> Stack a -> Stack a
;push x s = MkStack x s
;size :: Stack a -> Int
;size s = length (stkToLst s) where
{stkToLst Empty = []
;stkToLst (MkStack x s) = x:xs where {xs = stkToLst s
}};pop :: Stack a -> (a, Stack a)
;pop (MkStack x s)
= (x, case s of {r -> i r where {i x = x}}) -- (pop Empty) is an error
;top :: Stack a -> a
;top (MkStack x s) = x -- (top Empty) is an error
}
더 알아보기 (Learn more)
- Haskell 2010 Language Report — 챕터 2 (Lexical Structure) 원문: https://www.haskell.org/onlinereport/haskell2010/haskellch2.html
- 챕터 2에서 언급된 다른 절: 식별자의 이름공간(섹션 1.4), 전위 부정·연산자(섹션 3.4), 섹션(3.5), fixity(4.4.2), 모듈과 한정 이름(챕터 5), 문자 동치(6.1.2), 숫자 리터럴 타입(6.4.1), 레이아웃 정밀 정의(10.3), 프라그마(챕터 12)
- 관련 주제: GHC에서의 어휘 처리(
lex), 레이아웃 규칙의 공식 알고리즘,{-# ... #-}프라그마 문법