하스켈 소스 들여 쓰기

Posted on July 12, 2020

Haskell Indentation - wikibook

※ 에디터들의 탭tab 옵션을 스페이스로 채워 넣는 것으로 바꿔야 합니다. ex) vim의 경우 set expandtab
※ 여기 포스트는 코드 블록의 폰트가 고정폭이 나와야 하는데, 모바일 사파리등에선 제대로 폰트가 나오지 않아 띄어 쓰기 숫자가 정확히 눈에 들어오지 않는 경우가 있습니다. 고정폭monospace 폰트가 나오는 환경에서 봐야합니다. 모바일에서도 강제로 monospace 폰트를 지정하는 방법을 찾고 있는 중입니다.

들여 쓰기가 의미를 가지는 언어를 쓰는게 처음이라 은근히 헤매게 만든 요소였습니다. 내가 작성할 때 복잡하다면, 전통적으로 많이 쓰이는 레이아웃이 의미가 없는 스타일, 즉 {,},;으로 레이아웃이 필요 없게 할 수도 있지만, 다른 하스켈러들은 대부분 레이아웃을 이용합니다. 상식을 벗어나는 규칙이 없긴 한데, 완전히 직관으로만 알 수 없는 규칙도 있습니다. 한 번쯤은 보고 지나가야 합니다.

  1. 위키북스에 나온 풀이
    1. 끝나지 않은 표현식을 줄바꿈 해서 이어 갈 때는 현재 표현식의 시작보다는 들여 쓰기 해야합니다 (1)
    2. 같은 레벨에 있는 딸린 표현식들은 모두 같은 들여 쓰기값을 가져야 합니다 (2)
    3. 들여 쓰기는 공백만 보는게 아닙니다 (3)
    4. 표현식의 시작이 라인 시작이 아닐 때 (4)
    5. 레이아웃 키워드 let, where, of, do (5)
    6. let과 do, where의 동작이 다르다 (6)
  2. 실제 스펙
    1. L 함수의 첫 번째 인자 - 토큰 스트림
      1. {n}을 스트림에 추가
      2. <n>을 스트림에 추가
    2. L 함수의 두 번째 인자 - 레이아웃 컨텍스트 스택
    3. 토큰 스트림에서 <n>을 만나면
    4. 토큰 스트림에서 {n}을 만나면
    5. 토큰 스트림에서 {,}를 만나면
    6. 다음 토큰이 문법상 이어지는 토큰이 아닐 때
    7. 종료 조건

위키북스에 나온 풀이

끝나지 않은 표현식을 줄바꿈 해서 이어 갈 때는 현재 표현식의 시작보다는 들여 쓰기 해야합니다 (1)

같은 레벨에 있는 딸린 표현식들은 모두 같은 들여 쓰기값을 가져야 합니다 (2)

-- error
let x = a
  y = b
-- error
let x = a
     y = b
-- ok
let x = a
    y = b

얼핏 보면, 표현식expression의 일부라면 시작보다 몇 칸이든 들여 쓰기만 하면 연결된 표현식이라 판단 해 줄 것 같은데, 그렇지 않습니다. 딸린 표현식은 첫 번째로 나온 딸린 표현식의 들여 쓰기와 나머지 딸린 표현식들이 들여 쓰기가 같아야 합니다. 여기서 첫 번째 딸린 표현식의 들여 쓰기를 보는 방법을 알면 거의 헤매지 않습니다.

들여 쓰기는 공백만 보는게 아닙니다 (3)

x = a 이전에 공백 하나 있다고 들여 쓰기가 한 칸인게 아니라 (3)let까지 포함해서 들여 쓰기로 판단합니다. let + 공백 하나 해서 총 4칸 들여 쓰기를 한 상태입니다. 그래서 위의 마지막 코드만 ok입니다. 첫 번째 딸린 표현식으로 판단한다 했으니 다음의 경우도 가능합니다.

-- ok
let
 x = a
 x = b

첫 번째 딸린 표현식은 한 칸 들여 쓰기 했으므로, 두 번째도 한 칸 들여 쓰면 ok입니다.

그 다음, 한 라인에 쓰여 있는 딸린 표현식의 문제입니다. 위 규칙 적용하면 다음처럼 쓸 수 있습니다.

-- ok
func arg1 arg2 = do foo
                    bar

do를 내리면 아래처럼 쓸 수 있습니다.

-- ok
func arg1 arg2 = 
    do foo
       bar 

-- ok
func arg1 arg2 = 
    do 
        foo
        bar 

아래처럼 do보다는 들여 쓰는 건 되고,

-- ok
func arg1 arg2 = do 
                    foo
                    bar

표현식의 시작이 라인 시작이 아닐 때 (4)

다음 들여 쓰기는 안 될 것처럼 보입니다. 하지만, (4)표현식의 시작이 라인의 시작에 있지 않을 때1는, 표현식의 시작을 포함하고 있는 라인보다만 들여 쓰기 하면 됩니다.

-- ok
func arg1 arg2 = do 
 foo
 bar

-- ok
func = 100
  - 10

Q. func를 표현식의 시작으로 보면 (1)번 규칙 끝나지 않은 수식으로 봐야하지 않을까요?
100을 시작으로 본다면 (4)번 규칙을 적용해야 합니다.
A. 실제 스펙을 보고나면 do 구문은 레이아웃을 새로 시작하는 do의 특별한 동작 때문이고,
100 - 는 끝나지 않은 수식입니다.

그럼 다음 where절의 들여 쓰기는 어떨까요?

-- error
main = do
    myPrint
  where myPrint = -- 끝나지 않은 표현식 
    putStrLn "ok"

myPrint 정의가 라인 시작에 있지 않으니, (4)번 규칙에 따라 가능해야 할 것 같은데, 다음처럼 들여 써야만 합니다.

-- ok
main = do
    myPrint
  where myPrint =
         putStrLn "ok" -- 한 칸이라도 myPrint 함수명보다는 들여 쓰기 해야합니다.

(4)번 규칙이 아닌 (1)번 규칙에 걸려듭니다. where까지도 들여 쓰기에 포함해서 봐야하니, myPrint의 첫 글자 m보다는 들여 쓰기 해야합니다. 하지만, do를 써주면 함수명과 동일한 시작점까진 됩니다.

-- ok
main = do
    myPrint
  where myPrint = do
        putStrLn "ok" -- do를 쓰면 함수명과 갈은 위치,
                      -- 즉 where로 바뀐 컨텍스트까지는 됩니다. (실제 스펙 참고)

이쯤되면 규칙이 뭘지 혼란스럽습니다.

레이아웃 키워드 let, where, of, do (5)

그냥 직관으론 다 알 수 없는 규칙이 있습니다. 바로 (5) 레이아웃 키워드 let, where, of, do 중에 하나가 오면 현재 레이아웃을 기억해 두고, 새로운 레이아웃을 시작한다는 규칙2입니다. 위 예들은 where로 새 레이아웃을 시작하고 첫 딸린 라인이 (3)번 규칙에 따라 공백 2 + where 5글자 + 공백 1 해서 총 8칸 들여 쓰기로 시작했으니, 현재 레이아웃을 8칸으로 바꾸고, 이 후에도 8칸 들여 쓰기 해야합니다. 첫 번째 where 예시는 do가 없는 경우는 새 레이아웃이 시작되는게 아니니, 아직 끝나지 않은 표현식은 (1)번 규칙에 따라 더 들여 써야 합니다. 완전히 왼쪽으로 붙이는 게 아니라 이전 레이아웃까지만 들여 씁니다.

let과 do, where의 동작이 다르다 (6)

※ 그런데 테스트를 해보니 let과 do의 동작이 다릅니다.

-- error
func = let 
a = 1 -- 여기와
in a -- 여길 한 칸이라도 들여 쓰면 ok

-- ok
  func2 = 
      a
   where
  a = 1 -- 이렇게 func2까진 내어 써도 ok
  -- 이건 func2에 딸린 문장이 아니라, 탑레벨로 정의한 상태가 됩니다.
  -- 아래 설명 참고

-- ok
func3 = do
putStrLn "ok" -- func3까지 내어 써도 ok

let에 딸린 식은 함수명(이 전 컨텍스트)보다 반드시 들여 써야하지만, dowhere는 그렇지 않습니다. 스펙에 따르면 같은 동작을 할 것으로 쓰여 있는데 그렇지 않습니다. 바로 이 전 컨텍스트보다는 들여 써야 하니 do, where 구문도 한 칸이상 들여 쓰기 해야 할 것 같은데, 함수명까지 가능합니다. 어딘가 놓친 규칙이 있을 것 같은데 아직 찾지 못했습니다.

2022.6.26 추가 GHC와 haskell2010 리포트가 다르게 동작한다는 걸 알게 되었습니다. 특별히 do구문이 let과 다르게 동작하는 이유를 NondecreasingIndentation를 보시면 알 수 있습니다. do 체이닝 할 때를 위해 특이하게 동작하는 걸로 보입니다. 이 뿐만 아니라, GHC와 haskell2010 리포트가 다른 부분이 꽤 있습니다. 링크 페이지에서 확인하실 수 있습니다. (Ailrun님 감사합니다.)

do의 들여 쓰기에 따라 다음 구문이 영향을 받습니다.

-- ok
func2 = do
putStrLn "ok" 

-- error
-- 위 putStrLn을 한 칸이라도 들여 쓰면 ok로 바뀝니다.
func3 = a
 where
   a = 1

새 함수 정의의 시작인 func3 = afunc2안에 속하는 구문으로 해석해서 다음 에러가 납니다.

test13.hs:9:7: error:
    parse error on input ‘=
    Perhaps you need a 'let' in a 'do' block?
    e.g. 'let x = 5' instead of 'x = 5'
  |
9 | func3 = a
  |    

추측으론, 한 레이아웃이 끝나고 다음 레이아웃이 시작됨을 표시하는 방법이 한 단계 내어 쓰기해야 하는데, func2do구문을 들여 쓰기 0칸으로 했기 때문에, 이 보다 더 내어 쓰기를 할 수 없어 func2do구문을 끊지 못하는 상황입니다. 빈 라인을 두 번, 세 번 써준다고 컨텍스트가 바뀌는 건 아닙니다.

여기까지 wikibook 내용을 요약하며 곰곰히 보니, 결국 스펙을 제대로 이해하는 수밖에 없을 것 같습니다. wikibook은 실제 스펙 그대로가 아니라 풀어서 설명한 것입니다. 실제 스펙은 이해하기에 다소 난해하긴 합니다.

실제 스펙

haskell.org haskell2010 들여 쓰기

※ 입문중이시라면 위 위키북스 풀이만 보는 걸 추천드립니다. 들여 쓰기 하나 이해하는데도 이렇게 길게 이론을 봐야하나 싶은 생각이 듭니다. 저처럼 소스를 작성하다 이해할 수 없는 들여 쓰기 동작을 만났다 싶으면 그 때 가서 봐도 됩니다.

하스켈은 레이아웃으로 의미를 두는 방식과 C언어처럼 {,;,}를 써서 문장의 끝이나 블록을 표시하는 방법(이 때는 모든 토큰을 나누는 공백이 아닌 그외 공백들은 무시됩니다.) 두 가지 모두 쓸 수 있습니다. 스펙 설명을 보니, 내부에서는 레이아웃 방식에 {,;,}를 달아 의미 표시를 하는 방식으로 처리를 하고 있습니다. 어떤 규칙에 따라 어떤 걸 붙이는지 살펴보면 레이아웃의 의미를 알 수 있습니다.

가장 눈여겨 볼 표시가 {가 어떨 때 붙는지 보면 들여 쓰기를 이해할 수 있습니다.

정해진 토큰 규칙에 따라 어휘를 분석해서 어휘 스트림을 만드는데, 여기에 들여 쓰기 컨텍스트가 바뀔때마다 {n}을 넣고, 현재 토큰의 들여 쓰기값을 뜻하는 <n>토큰을 집어 넣습니다.

let a = 1[....공백....]b = 1[줄바꿈....공백....]in a + b 소스를
let , {4} , a , = , 1 , <4> , b , = , 1 , <0> , in , {3} , a , + , b 토큰열로 만들고
let , { , a , = , 1 , ; , b , = , 1 , } , in , { , a , + , b , } 으로 바꾼다.

실제 구현은 L이라는 함수를 이용해 레이아웃이 의미가 있는 코드 형태를 {,},;를 쓰는 코드로 바꿉니다. 일단 L함수가 받는 두 개의 인자를 보면

L 함수의 첫 번째 인자 - 토큰 스트림

첫 번째 인자는 아래 처럼 레이아웃을 {n},<n>으로 바꾼 토큰 스트림이 들어옵니다.

{n}을 스트림에 추가

let, where, of, do 4개 키워드 뒤에 중괄호{가 딸려 들어오지 않으면 {n}이란 토큰을 집어 넣습니다. 여기서 n은 이어지는 다음 구문의 들여 쓰기 칸 수가 들어갑니다.

let x = a -- let뒤에 {가 없으므로, x = a의 들여 쓰기 칸 수 {4}를 토큰열에 넣습니다.

만일, 다음 구문 없이 파일 끝이라면 0이 들어갑니다. 이런 경우가 뭐가 있을까 싶은데, 예를들면 where키워드 뒤에 아무것도 없어도 에러는 나지 않습니다. 이 걸 말하는 걸까요?

-- ok
func3 = 1 where

module의 첫 번째 어휘가 {module이 아니면, 앞에 {n}이 옵니다. n은 첫 번째 어휘의 들여 쓰기 칸 수가 들어갑니다. 아래와 같이 쓰면, {2}를 넣어놔서 컨텍스트를 바꾸니 다음 func2도 바뀐 컨텍스트 2칸에 맞춰야 합니다.

  func1 = 1
  func2 = 2 
^^

정리하면, 블록의 시작을 알릴만한 위치에 {가 없으면 {n}을 넣습니다. n칸 들여 써서 블록{}을 나타내겠다는 말입니다.

<n>을 스트림에 추가

어휘의 시작 앞에 공백만 있으면, 이 어휘 앞에 <n>을 넣습니다. n은 이 어휘의 들여 쓰기 칸 수입니다. 단 위의 단계로 이미 {n}이 있을 때는 넣지 않습니다. 그리고 또 다음같은 경우도 넣지 않습니다. 아래 예를 보면 \Bill은 완성된 어휘의 첫글자도 아니고, 콤마,도 아니므로 <n>을 넣지 않습니다. 이어지는 문장은 \Bill이 아니라 Some처럼 (와 들여쓰기를 맞춘다는 얘기입니다.

-- ok
f = ("Hello \
        \Bill", "Jake")
    Some

{n}이 들어오면 레이아웃 스택에 n을 추가하고(다른 말로 하면, 지금까지의 컨텍스트를 기억하고, 현재 컨텍스트를 n으로 바꾸고), <n>은 현재 구문의 들여쓰기를 나타냅니다.

L 함수의 두 번째 인자 - 레이아웃 컨텍스트 스택

레이아웃을 관리하는 스택을 만들고, {n}을 받을 때, n값을 넣어 뒀다가 하나씩 꺼내거나 넣으면서 들여 쓰기 관리를 합니다. 이 스택의 제일 위에 있는 숫자가 현재 ’레이아웃 컨텍스트’입니다.

레이아웃을 지정하는 “layout context”3 스택은
Zero : 명시적으로 중괄호{를 쓴 경우는 0입니다. 가장 안 쪽 컨텍스트가 0이면, 감싸진 컨텍스트가 끝나거나 새로운 컨텍스트가 생기기 전까지 레이아웃 토큰이 들어갈 일은 없습니다. ※ 레이아웃을 보고 GHC가 알아서 {를 추가 했을 때도 0으로 리셋된다고 오해했습니다. 프로그래머가 명시적으로 {를 써줄 때만 0이 되고, GHC가 추가 했을 때는 {n}안의 n이 됩니다.

양의 정수 : enclosing 컨텍스트(블록을 의미합니다.)의 들여 쓰기를 뜻합니다.
let, where, of, do등은 {으로 감싸고 있습니다. 이 걸 enclosing context라 합니다.

하스켈 일반 문법과 마찬가지로 토큰 스트림을 리스트의 cons(:)로 표시하고, 레이아웃 규칙도 일반 하스켈 함수처럼 설명합니다. 입문을 막 시작했을 때 보기는 썩 편하지 않을 것 같습니다. 저도 역시 편하지 않습니다.

이제 이 두 인자들의 값을 비교해 가면서, 적절한 위치에 {,;,}를 넣어 변환하게 됩니다.
들어 오는 인자 모양에 따른 L함수 정의를 보면

토큰 스트림에서 <n>을 만나면

(가)
-- L은 레이아웃 스트림을 {;}방식 스트림으로 바꿉니다.
-- 토큰열에서 꺼낸 현재 토큰이 <n>이면
L (<n> : ts) (레이아웃 스택 m : ms) =  ;  : (L ts (m : ms))          만일 m = n
                                    =  }  : (L (<n> : ts) ms)        만일 n < m

토큰열에 <n>이 들어왔다면, 현재 컨텍스트 값이랑 비교해서 같다면, 같은 레벨의 문장이니 ;를 결과 스트림에 넣습니다.

let a = 1 -- 토큰a 앞에 토큰{4}가 들어있는데,
    b = 1 -- 들여 쓰기가 <4>이므로 m = n 입니다. 그럼 앞에 ;를 넣습니다.
in a + b -- n은 0인데 현재 컨텍스트 m은 4인 상태입니다. 그럼 앞에 }를 넣습니다. 

스펙 페이지에 명확한 예시는 없지만, 위와 같은 코드는 let, {4}, a, =, 1, <4>, b, =, 1, <0>, in, {3}, a, +, b 토큰열이 들어와서 let , { , a , = , 1 , ; , b , = , 1 , } , in , { , a , + , b , } 으로 바뀐다고 보면 됩니다.

(나)
L (<n> : ts) ms = L ts ms

토큰열에 <n>이 들어왔을 때, (가)조건에 걸려들지 않으려면 n > m 인 경우 뿐이 없습니다. 현재 컨텍스트보다 더 들여 쓰기 된 경우입니다.

f = ("Hello \
        \Bill", "Jake") -- 컨텍스트가 4 < 현재 들여쓰기가 <8>일때는 아무 작업 없이 바로 다음 토큰으로

위 코드의 \Bill은 새로운 구문의 시작이 아닙니다.

토큰 스트림에서 {n}을 만나면

(다)
L ({n} : ts) (m : ms) = { : (L ts (n : m : ms)) 만일 n > m 일때는 Note 1

{n}이 들어오면 블록의 시작이니 {를 넣어 주고, 지금까지의 컨텍스트였던 m을 다시 넣고, 그 다음 n을 넣어 컨텍스트를 바꿔 놓습니다. 그러면 현재는 n 컨텍스트에 있는 상태고, 나중에 }를 명시적, 또는 암시적으로 만나면 m 컨텍스트가 된다는 얘기입니다.

Note 1.
안에 들어 있는nested 컨텍스트(B)는 enclosing 컨텍스트(A)보다 더 들여쓰기 해야 합니다.

-- error
f x = let ---- (A)
         h y = let ---- (B)
  p z = z
               in p
      in h

p 정의 부분이 enclosing 컨텍스트보다 덜 들여 쓰기 되어 있어 에러입니다. h보다는 들여 써야 됩니다.

(라)
L ({n} : ts) [] = {  :  (L ts [n]) 만약 n > 0 (Note 1) 

스택이 비었고[] n0이 아니면 스택에 n을 넣고 다음 토큰으로

(마)
 L ({n} : ts) ms = {  :  }  :  (L (< n >: ts) ms) (Note 2) 

(다), (라)에 걸려들지 않았다면, {}를 넣어주고, <n>이 들어온것으로 간주하고 진행합니다.

Note 2.
where 뒤의 첫 번째 토큰이 enclosing 컨텍스트보다 덜 들여 쓰여 있다면

    func = some
      where
a = 1

a = 1where에 딸린 구문이 아니고,where 뒤는 빈 블록 {}으로 간주합니다.

토큰 스트림에서 {,}를 만나면

(바)
L (} : ts) (0 : ms) = }  :  (L ts ms) (Note 3)
L (} : ts) ms = parse-error (Note 3)
L ({ : ts) ms = {  :  (L ts (0 : ms)) (Note 4) 

Note 3.
닫는 중괄호가 들어 왔다면, 그 전에 명시적으로 여는 중괄호를 써서 레이아웃 컨텍스트에 0을 넣어 놨어야 합니다. 그렇지 않으면 매치되는 중괄호를 연 적이 없다고 판단해서 parse-error입니다.

Note 4.
여는 중괄호가 들어 왔다면, 레이아웃 스택에 0을 넣습니다.

다음 토큰이 문법상 이어지는 토큰이 아닐 때

(사)
L (t : ts) (m : ms) = }  :  (L (t : ts) ms) if m ∕= 0 and parse-error(t) (Note 5) 

Note 5.
위 조건들에 모두 걸리지 않았다면, 그리고 현재 레이아웃 컨텍스트가 0이 아니고, parse-error(t)가 참이면, 레아아웃 컨텍스트를 하나 꺼내서 버리고, }을 결과에 붙이고, 다시 현재 토큰을 가지고 L을 부릅니다.
parse-error(현재 토큰) 해석은 다음과 같습니다. 만일 지금까지 L로 생성한 토큰과 다음 토큰t를 붙인 게 문법에 맞지 않는 prefix를 나타내고, 지금까지 생성한 토큰에 }를 붙인 게 문법에 맞는 prefix라면, parse-error(현재토큰t)true입니다. 말이 복잡한데, 문법상 다음 토큰이 이어지는게 아니라면 }를 넣고 다음 토큰을 다시 살펴 본다는 말입니다.

(아)
L (t : ts) ms = t  :  (L ts ms) 

위 조건 모두에 걸리지 않았다면, 현재 토큰을 붙이고, 레이아웃 스택 그대로 다음 토큰으로 진행

종료 조건

(자)
L [] [] = [] -- 빈 문자열 들어오면 종료
L [] (m : ms) = }  :  L [] ms if m≠0 (Note 6)

Note 6.
토큰은 끝났는데, 레이아웃 스택이 비어 있지 않으면 }를 붙입니다. 그리고 레이아웃 스택이 비워질 때까지 하나씩 꺼내며 }를 붙입니다. 여기까지 도달했는데, non-layout context (예 m = 0) 라면 에러입니다-보통 다음 구문을 보고 컨텍스트를 끊기 위해 }를 넣는데, }를 넣기 전에 이미 컨텍스트가 0이라면 무언가 잘 못 됐다는 얘기입니다.

{
    ....
}
} -- 이렇게 닫는 괄호를 한 번 더 쓴 것과 같은 상황입니다.

  1. wikibook엔 (4)번 표현식 시작이 라인 시작에 없을 때의 규칙 설명이 있습니다. 하지만, 없어도 (1)번과 (5)번 규칙으로 정해질 것 같은데, GHC 내부에 구현되어 있는 규칙이 뭔지 궁금합니다.↩︎

  2. wikibook 설명엔 다음과 같이 되어 있습니다.
    If you see one of the layout keywords, (let, where, of, do), insert an open curly brace (right before the stuff that follows it) C언어처럼 let {, where {, of {, do { 중괄호를 넣어서 읽으면 된다고 되어 있는 걸, 동작에 맞춰 말을 바꿨습니다. { 중괄호를 넣는다는 건 새로운 레이아웃을 시작한다는 말입니다.↩︎

  3. 들여 쓰기에 따라 코드에 의미가 있기 때문에 layout이란 용어를 씁니다. 코드가 어떻게 배치 되었냐는 말입니다. {}을 써서 블록을 표현하던 걸, {}없이 동일한 들여 쓰기만으로 같은 블록안의 코드임을 나타냅니다. 이 때 한 블록을 나타내는 동일한 들여 쓰기 값을 layout context라 합니다. 문서에 따라 layout이라 표현하는데도 있고, context라 표현하는데도 있고, layout context, 현재 컨텍스트 등등으로 표현합니다.↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com