어휘 구조

어휘 구조 (Lexical Structure)

Haskell의 아주 낮은 층위, 즉 프로그램이 텍스트 파일로 쓰였을 때 문자 하나하나가 어떻게 묶여 토큰이 되는지를 다루는 장이에요. 이 장의 세부 규칙은 처음 리포트를 읽을 때는 건너뛰어도 괜찮아요. 다만 나중에 "왜 이게 문법 오류일까" 궁금할 때 되돌아올 자리가 바로 이 장이에요.

출처: Haskell 2010 언어 리포트

본문

이 장에서는 Haskell의 저수준 어휘 구조(low-level lexical structure)를 설명해요. 대부분의 세부 사항은 리포트를 처음 읽을 때는 건너뛰어도 돼요.

2.1 표기 규약 (Notational Conventions)

문법을 제시할 때 쓰는 표기 규약이에요:

  • [pattern] — 선택 사항(optional)
  • {pattern} — 0회 이상 반복(zero or more repetitions)
  • (pattern) — 묶음(grouping)
  • pat1 | pat2 — 선택(choice)
  • pat⟨pat′⟩ — 차이(difference): pat이 만들어내는 원소 중 pat′이 만들어내는 것을 뺀 것
  • fibonacci — 타자기체(terminal syntax)

이 절의 문법은 어휘 문법(lexical syntax)을 다루기 때문에 모든 공백이 명시적으로 표현돼요. 나란히 붙은 기호 사이에 암묵적인 공백은 없어요. 리포트 전체가 BNF 유사 문법을 쓰는데, 생성 규칙(production)의 형태는 다음과 같아요:

nonterm → alt1 | alt2 | … | altn

메타 논리 문법(metalogical syntax)인 |[…]와, 구체 종단 문법(concrete terminal syntax, 타자기체로 표기)인 |[...]를 구분하는 데 주의해야 해요. 다행히 문맥이 대개 그 차이를 분명하게 해 줘요.

Haskell은 유니코드(Unicode) [2] 문자 집합을 사용해요. 다만 현재 소스 프로그램은 이전 Haskell 버전들이 쓰던 ASCII 문자 집합에 치우쳐 있어요. 이 문법은 유니코드 컨소시엄이 정의한 유니코드 문자의 성질에 의존해요. 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가 예약어(reserved word)라도 cases는 예약어가 아니에요. 마찬가지로 =는 예약돼 있지만 ==~=는 아니에요.

어떤 종류의 공백도 lexeme 사이의 적절한 구분자(delimiter)로 쓰여요. ANY 범주에 속하지 않는 문자는 Haskell 프로그램에서 유효하지 않으며, 렉싱 오류(lexing error)를 일으켜야 해요.

2.3 주석 (Comments)

주석은 유효한 공백(whitespace)이에요.

일반 주석(ordinary comment)은 두 개 이상의 연속된 대시(dash, --)로 시작해 다음 newline까지 이어져요. 이런 대시 시퀀스는 유효한 lexeme의 일부가 되면 안 돼요. 예를 들어 -->|--는 둘 다 유효한 lexeme이기 때문에 주석을 시작하지 않아요. 하지만 --foo는 주석을 시작해요.

중첩 주석(nested comment)은 {-로 시작해 -}로 끝나요. {-로 시작하는 유효한 lexeme은 없기 때문에, 예를 들어 {---도 뒤에 대시가 붙어 있어도 중첩 주석을 시작해요.

주석 자체는 어휘 분석되지 않아요. 대신 문자열 -}의 첫 번째 짝이 맞지 않는(unmatched) 등장이 중첩 주석을 끝내요. 중첩 주석은 어떤 깊이로든 중첩될 수 있어요: 주석 안의 {- 등장은 새 중첩 주석을 시작하고, -}로 끝나요. 중첩 주석 안에서 각 {-는 대응하는 -} 등장과 짝을 이뤄요.

일반 주석 안에서 {--} 문자 시퀀스는 특별한 의미가 없고, 중첩 주석 안에서 대시 시퀀스도 특별한 의미가 없어요.

중첩 주석은 12장에서 설명하듯 컴파일러 프라그마(pragma)에도 사용돼요. 어떤 코드를 중첩 주석으로 주석 처리했다면, 그 코드의 문자열 안이나 그 줄 끝 주석 안에 있는 {--}는 중첩 주석과 충돌할 수 있어요.

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개 이상의 글자·숫자·밑줄·홑따옴표가 뒤따르는 형태예요. 식별자는 어휘적으로 두 네임스페이스로 구분돼요(1.4절): 소문자로 시작하는 것(변수 식별자)과 대문자로 시작하는 것(생성자 식별자). 식별자는 대소문자를 구분해요. name, naMe, Name은 서로 다른 세 식별자예요(처음 둘은 변수 식별자, 마지막은 생성자 식별자).

밑줄 _은 소문자로 취급되며, 소문자가 올 수 있는 곳이면 어디든 올 수 있어요. 다만 _ 단독은 예약 식별자로, 패턴에서 와일드 카드(wild card)로 쓰여요. 사용되지 않는 식별자에 대해 경고를 주는 컴파일러는 밑줄로 시작하는 식별자에 대해서는 그런 경고를 억제하는 것을 권장해요. 이렇게 하면 프로그래머가 사용하지 않을 매개변수에 _foo 같은 이름을 쓸 수 있어요.

varsym     → ( symbol⟨:⟩ {symbol} )⟨reservedop | dashes⟩
consym     → ( : {symbol})⟨reservedop⟩
reservedop → .. | : | :: | = | \ | | | <- | -> | @ | ~ | =>

연산자 기호는 위에 정의한 대로 하나 이상의 기호 문자로 이뤄져요. 그리고 어휘적으로 두 네임스페이스로 구분돼요(1.4절):

  • 콜론으로 시작하는 연산자 기호는 생성자(constructor)예요.
  • 다른 어떤 문자로 시작하는 연산자 기호는 일반 식별자(ordinary identifier)예요.

콜론 하나 :는 Haskell 리스트 생성자로 쓰기 위해 단독으로 예약돼 있어요. 이렇게 함으로써 [][a,b] 같은 다른 리스트 문법과 취급이 일관돼요.

전위 부정(prefix negation)을 위한 특별 문법을 빼면, 모든 연산자는 중위(infix)예요. 다만 각 중위 연산자는 섹션(section)에서 쓰여 부분 적용(partially applied) 연산자가 될 수 있어요(3.5절). 표준 중위 연산자는 전부 미리 정의된 기호일 뿐이고 다시 바인딩(rebind)될 수 있어요.

리포트의 나머지 부분에서는 여섯 종류의 이름이 사용돼요:

varid                      (variables)
conid                      (constructors)
tyvar   → varid            (type variables)
tycon   → conid            (type constructors)
tycls   → conid            (type classes)
modid   → {conid .} conid  (modules)

변수와 타입 변수는 소문자로 시작하는 식별자로 표현되고, 나머지는 대문자로 시작하는 식별자로 표현돼요. 또한 변수와 생성자는 중위(infix) 형태가 있지만 나머지 네 종류는 중위 형태가 없어요. 모듈 이름은 점으로 구분된 conid들의 시퀀스예요. 네임스페이스에 대해서는 1.4절에서도 논의해요.

이름은 특정 상황에서 모듈 식별자를 앞에 붙여 **한정(qualify)**할 수 있어요. 이것은 변수·생성자·타입 생성자·타입 클래스 이름에 적용되지만, 타입 변수나 모듈 이름에는 적용되지 않아요. 한정 이름은 5장에서 자세히 다뤄요.

qvarid  → [modid .] varid
qconid  → [modid .] conid
qtycon  → [modid .] tycon
qtycls  → [modid .] tycls
qvarsym → [modid .] varsym
qconsym → [modid .] consym

한정 이름은 하나의 lexeme이므로 한정자(qualifier)와 이름 사이에 공백이 허용되지 않아요. 샘플 어휘 분석은 아래와 같아요:

This   Lexes as
f.g    f . g (three tokens)
F.g    F.g (qualified 'g')
f..    f .. (two tokens)
F..    F.. (qualified '.')
F.     F . (two tokens)

한정자는 이름의 구문적 취급을 바꾸지 않아요. 예를 들어 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)와 부동소수점(floating) 두 종류가 있어요. 정수 리터럴은 십진수(기본), 팔진수(0o0O 접두사), 십육진수(0x0X 접두사) 표기로 쓸 수 있어요. 부동소수점 리터럴은 항상 십진수예요. 부동소수점 리터럴은 소수점 앞과 뒤 모두에 숫자를 포함해야 해요. 이 덕분에 소수점이 점 문자(.)의 다른 용법과 혼동되지 않아요. 음수 숫자 리터럴은 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"처럼 쌍따옴표 사이에 써요.

이스케이프 코드(escape code)는 문자와 문자열에서 특수 문자를 표현하는 데 쓰여요. 주의할 점: 홑따옴표 '는 문자열 안에서 쓸 수 있지만 문자에서는 이스케이프해야 해요. 마찬가지로 쌍따옴표 "는 문자 안에서 쓸 수 있지만 문자열에서는 이스케이프해야 해요. \는 항상 이스케이프해야 해요. charesc 범주는 "alert"(\a), "backspace"(\b), "form feed"(\f), "new line"(\n), "carriage return"(\r), "horizontal tab"(\t), "vertical tab"(\v) 같은 문자들의 이식 가능한(portable) 표현도 포함해요.

\^X 같은 제어 문자를 포함한 유니코드 문자 집합의 이스케이프 문자도 제공돼요. 십진 표현 137인 문자를 지정하려면 \137 같은 숫자 이스케이프를 쓰고, 팔진수(\o137)와 십육진수(\x37) 표현도 허용돼요.

"maximal munch" 규칙에 따라 문자열 안의 숫자 이스케이프 문자는 연속된 모든 숫자로 이뤄지며 임의의 길이가 될 수 있어요. 마찬가지로 유일하게 모호한 ASCII 이스케이프 코드 "\SOH"는 길이 1의 문자열로 파싱돼요. 이스케이프 문자 \&는 "null character"로 제공되는데, "\137\&9""\SO\&H"(둘 다 길이 2) 같은 문자열을 만들 수 있게 해 줘요. 그래서 "\&"""와 동등하고 문자 '\&'는 허용되지 않아요. 문자들의 추가 동등성은 6.1.2절에서 정의돼요.

문자열은 "gap" — 두 개의 백슬랜트(backslant)로 감싼 공백 문자 — 을 포함할 수 있고, 이것은 무시돼요. 이 덕분에 한 줄의 끝과 다음 줄의 시작에 백슬랜트를 써서 긴 문자열을 여러 줄에 나눠 쓸 수 있어요. 예:

"Here is a backslant \\ as well as \137, \
\a numeric escape character, and \^X, a control character."

문자열 리터럴은 실제로는 문자 리스트의 약어(abbreviation)예요(3.7절).

2.7 레이아웃 (Layout)

Haskell은 몇몇 문법 생성 규칙에서 쓰이는 중괄호와 세미콜론을 생략할 수 있게 해 주는데, 레이아웃(layout)으로 같은 정보를 전달하기 때문이에요. 이렇게 하면 레이아웃에 민감한(layout-sensitive) 코딩 스타일과 민감하지 않은(layout-insensitive) 스타일을 모두 쓸 수 있고, 한 프로그램 안에서 자유롭게 섞을 수도 있어요. 레이아웃이 필수는 아니기 때문에, Haskell 프로그램은 다른 프로그램이 쉽게 만들어낼 수도 있어요.

레이아웃이 Haskell 프로그램의 의미에 미치는 영향은, 레이아웃이 정하는 자리에 중괄호와 세미콜론을 추가하는 것으로 완전히 명시될 수 있어요. 이렇게 확장된 프로그램의 의미는 이제 레이아웃에 민감하지 않아요.

비공식적으로 말하면 중괄호와 세미콜론은 다음과 같이 삽입돼요. 레이아웃("오프사이드, off-side") 규칙은 키워드 where, let, do, of 뒤에 여는 중괄호가 생략될 때마다 발동돼요. 이때 다음 lexeme(새 줄에 있든 아니든)의 들여쓰기가 기억되고, 생략된 여는 중괄호가 삽입돼요(lexeme 앞의 공백은 주석을 포함할 수 있어요). 이후 각 줄에 대해:

  • 공백만 있거나 더 많이 들여쓰여 있으면 이전 항목이 계속돼요(아무것도 삽입되지 않음).
  • 같은 양만큼 들여쓰여 있으면 새 항목이 시작돼요(세미콜론 삽입).
  • 더 적게 들여쓰여 있으면 레이아웃 리스트가 끝나요(닫는 중괄호 삽입).

만약 where, let, do, of 바로 다음에 오는 중괄호가 아닌( non-brace) lexeme의 들여쓰기가 현재 들여쓰기 수준보다 작거나 같으면, 레이아웃을 시작하는 대신 빈 리스트 {}가 삽입되고, 현재 수준에 대해 레이아웃 처리가 발생해요(즉 세미콜론이나 닫는 중괄호를 삽입). 레이아웃 리스트를 포함하는 구문 범주가 끝날 때도 닫는 중괄호가 삽입돼요. 즉 닫는 중괄호가 유효해질 지점에서 불법 lexeme을 만나면 닫는 중괄호가 삽입돼요. 레이아웃 규칙은 레이아웃이 삽입한 여는 중괄호에만 짝을 맞춰요. 명시적인 여는 중괄호는 명시적인 닫는 중괄호로 짝을 이뤄야 해요. 이렇게 명시적인 중괄호 안에서는, 줄이 이전 암묵적 여는 중괄호보다 왼쪽으로 들여쓰여 있어도, 그 중괄호 바깥의 구문에 대해서는 레이아웃 처리가 수행되지 않아요.

10.3절이 레이아웃 규칙을 더 정밀하게 정의해요.

이 규칙들 덕분에, 하나의 newline이 실제로 여러 레이아웃 리스트를 끝낼 수 있어요. 또한 이 규칙들은 다음을 허용해요:

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열 들여쓰기 때문에 삽입돼요.

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.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: 레이아웃을 확장한 샘플 프로그램

더 알아보기 (Learn more)