Haskell 초급 예제 — todo

Jooyung Han (한주영)
8 min readJun 9, 2018

--

회사서 작은 하스켈 학습 모임을 진행하고 있다. 일주일에 한번 모이는 정도, 그리고 교재로 사용중인 하스켈북의 설명이 너무 장황한 덕에 진도가 느리다.

그래서 간단한 프로그램이라도 돌아가는 프로그램을 살펴보는게 좋겠다 싶어서 http://www.haskellforall.com/2015/10/basic-haskell-examples.html 페이지에 소개된 Todo 프로그램 같이 살펴봤다.

마침 이날 모여서 다룬 내용이 타입클래스였고, Todo 예제에 나오는 show/read 함수는 각각 Show/Read 타입클래스를 사용하는 것이라서 아주 생뚱맞은 예제는 아니었다.

짧은 프로그램이지만 초급 학습자에겐 약간의 설명이 필요할 것 같아서 여기다 정리해본다.

이 프로그램은 todo.hs 파일 하나로 만든 다음 runghc todo.hs 명령으로 실행할 수 있다.

$ runghc todo.hs
Commands:
+ <String> - Add a TODO entry
- <Int> - Delete the numbered entry
q - Quit
Current TODO list:
_

도움말을 보여주고, 이어서 현재 할일목록을 보여준다. 마지막으로 명령 입력을 기다리는 프롬프트를 보여준다.

도움말을 보여주는 건 putStrLn “Hello, World” 명령만 이해하면 어려울 게 없다. 다만 하스켈에서 이런 출력은 IO 액션이라고 부르고 IO () 타입이라는 점, 그리고 여러 명령을 순차적으로 실행할 때 do 블록을 사용한다는 점만 알면 된다.

main :: IO ()
main = do
putStrLn "Commands:"
putStrLn "+ <String> - Add a TODO entry"
putStrLn "- <Int> - Delete the numbered entry"
putStrLn "q - Quit"
prompt []

이렇게 처음 도움말을 출력한 다음에 prompt [] 를 호출하였다. prompt 함수는 현재 할일 목록을 인자로 받아서, 그것을 출력한 다음 사용자 입력을 처리하는 함수다. 할일목록은 단순하게 문자열 리스트 [String] 타입이다.

prompt :: [String] -> IO ()
prompt todos = do
putStrLn ""
putStrLn "Current TODO list:"
mapM_ putTodo (zip [0..] todos)
command <- getLine
interpret command todos

여기서 조금 어려운 문장이 나오는데, mapM_ putTodo (zip [0..] todos) 부분이다. 먼저 zip [0..]todos 를 알아야 한다. zip 함수는 리스트 두 개를 인자로 받아서 순서대로 짝지어주는 함수다.

zip [0,1,2] “abc” == [(0,’a’), (1,’b’), (2,’c’)]

할일목록에 넘버링 하는 것이 zip [0..] todos 가 하는 일이다.

그 다음 mapM_ action list는 list의 항목들에 대해 action을 순차적으로 적용하는 함수다. 여기서는 todos를 넘버링(zip [0..])한 다음 그것들을 putTodo 액션 함수를 이용하여 출력한다.

putTodo :: (Int, String) -> IO ()
putTodo (n, todo) = putStrLn (show n ++ ": " ++ todo)

putTodo 함수는 넘버링된 할일 하나를 화면에 출력한다. 이때 사용된 show 함수는 Show a => a -> String 타입의 함수로, 무엇이든 문자열로 바꿔준다고 보면 된다.

다시 prompt 함수로 돌아가서 command <- getLine 명령을 보자. getLine은 콘솔에서 한 줄 읽어들이는 액션이고, 그 결과를 command에 저장한다. putStrLn 의 반대라고 할수 있다.

putStrLn :: String -> IO ()
getLine :: IO String

putStrLn 은 입력 문자열을 출력하는 액션이고, getLine은 그 자체로 사용자 입력을 읽어들이는 액션이다.

사용자 입력을 읽어들인 다음 command 를 해석하는 interpret 액션 함수를 호출했다. interpret 함수가 이 작은 프로그램에서 가장 복잡한 함수다.

interpret :: String -> [String] -> IO ()
interpret ('+':' ':todo) todos = prompt (todo:todos)
interpret ('-':' ':num ) todos =
case delete (read num) todos of
Nothing -> do
putStrLn "No TODO entry matches the given number"
prompt todos
Just todos' -> prompt todos'
interpret "q" todos = return ()
interpret command todos = do
putStrLn ("Invalid command: `" ++ command ++ "`")
prompt todos

아직 하스켈을 보기 시작한지 얼마되지 않았다면 이렇게 함수 인자 위치에 다양하게 사용된 패턴이 눈에 잘 들어오지 않을 것 같다. 정의 몸체 부분을 빼고 보면 조금 이해하기 쉽다.

interpret :: String -> [String] -> IO ()
interpret ('+':' ':todo) todos = ...
interpret ('-':' ':num ) todos = ...
interpret "q" todos = ...
interpret command todos = ...

사용자 입력 문자열(명령)을 case 별로 나눠놓았다. 도움말과 거의 똑같이. 첫번째는 문자열이 “+(공백)”으로 시작하는 경우, 두번째는 “-(공백)”으로 시작하는 경우, 그리고 세번째는 입력 문자열이 “q”인 경우, 네번째는 나머지 모든 경우다.

첫번재 경우는 “+(공백)” 뒷부분을 todo란 이름으로 바인딩해주니까, 이를 이용해서 현재 할일목록(todos)에 붙여주면 된다. 맨 앞에 붙이고 싶다면 todo:todos 로 하면 될 것이고, 맨 뒤에 붙이고 싶다면 todos ++ [todo] 라고 할수 있을 것이다. 그런 다음, 다시 prompt 를 호출하여 프로그램이 반복 실행되게 만든다.

interpret ('+':' ':todo) todos = prompt (todo:todos)

호출을 보면, main -> prompt -> interpret -> prompt -> interpret -> … 처럼 재귀 호출인데, 하스켈은 이렇게 함수 끝에서 재귀호출을 사용해도 스택오버플로가 발생하지 않으니 걱정할 필요는 없다.

두번째 경우는 “-(공백)” 뒷부분을 num이란 이름으로 사용할 수 있는데, 여기서 num은 삭제할 할일의 번호를 나타낸다. 그런데 아직 num은 String 타입이므로 인덱스로 사용하려면 Int로 바꿔줘야 한다. putTodo 함수에서 사용한 show 와는 반대의 일을 하는 함수가 필요한데, 그것이 바로 read 함수다.

show :: Show a => a -> String
read :: Read a => String -> a

read 함수를 사용하면 해당 문맥에 필요한 타입으로 바꿔준다. 물론 그러기 위해서는 문자열이 올바른 형식을 한다. 여기서는 read num 이라고 하여 사용하면 하스켈 컴파일러는 올바르게 Int 타입으로 변환해준다.(혹은 파싱)

여기서는 지정된 위치의 할일 하나를 지워야 하는데, 우선 간단한 도움 함수로 delete라는 것을 가정해보자. delete 함수는 Int -> [String] -> [String] 의 타입으로 리스트에서 특정 위치의 항목을 삭제하여 반환하게 만들수도 있지만, Int가 올바른 인덱스가 아닌 경우도 고려해야 하므로 Int -> [String] -> Maybe [String] 타입으로 만드는 것이 낫다.

interpret ('-':' ':num ) todos =
case delete (read num) todos of
Nothing -> do
putStrLn "No TODO entry matches the given number"
prompt todos
Just todos' -> prompt todos'

Maybe a 타입은 Just a 나 Nothing 두 가지 값 중 하나가 되므로 case ~~~ of를 이용하여 결과를 검사하였다. 지우기를 실패한 경우에는 delete가 Nothing을 반환하므로 실제로 지워지는 것 없이 그대로 적당한 메시지를 출력한 다음 prompt 루프로 진행한다. 제대로 지워졌다면 Just todos’ 패턴으로 매치하여 새로운 할일목록 todos’ 를 다음 prompt 루프에 전달한다.

세번째 경우는 prompt를 호출하지 않는 것 만으로 쉽게 프로그램을 종료시킬 수 있다.

interpret  "q"           todos = return ()

여기서 return () 부분은 아무것도 하지 않는 IO 액션을 만들어준다.

마지막 경우는 이해할 수 없는 명령이 입력된 것이므로, 적절한 메시지를 출력한 다음 prompt 루프를 반복한다.

이제 마지막으로 도움 함수 delete 를 구현하기만 하면 되는데, 이건 연습문제로 좋아보여서 설명없이 끝내기로 한다.

delete :: Int -> [a] -> Maybe [a]

--

--

Jooyung Han (한주영)

가끔 함수형 프로그래밍 관련 글을 쓰거나 번역합니다. “개미 수열을 푸는 10가지 방법"이란 책을 썼습니다. https://leanpub.com/programming-look-and-say