9장 인터랙티브 프로그램

박준규·2023년 3월 11일
2

일러두기

이 글은 2007년에 나온 책 Programming in Haskell 초판의 일부 내용을 인터넷 아카이브에서 열람하여 읽고 의역한 것이다. Hugs라든지 derived primitives라든지 2023년 현재와는 맞지 않는 부분이 있음을 감안하고 이 글을 읽기 바란다.

개요

이 장에서는 하스켈로 인터랙티브 프로그램(interactive programs)을 어떻게 만드는지 다음과 같은 순서로 설명한다.

  • 인터랙티브 프로그램이 무엇인지
  • 인터랙티브 프로그램을 어떻게 자연스럽게 함수로 바라볼 수 있는지
  • 몇 가지 기본적인 인터랙티브 프로그램과 고차(higher-order) 함수를 정의해서 인터랙티브 프로그램들을 서로 합치기
  • 계산기랑 생명 게임 만들기

상호 작용(Interaction)

배치(batch) 프로그램은 실행될 때 사용자와 상호 작용하지 않는다. 예전에는 대부분의 프로그램이 배치 프로그램이었다. 컴퓨터가 작업하는 시간을 최대한 확보하기 위해서 사용자에게 뭔가 물어보지 않고 그냥 실행되기만 하면 됐다. 예를 들어 내비게이션 프로그램은 출발지와 도착지만 입력하면 조용히 계산만 해서 추천 경로를 결과로 출력하기만 하면 된다.

이렇게 모든 입력을 눈에 보이게 인자로 넣고 모든 출력을 그 결과로 리턴하는 프로그램을 순수(pure) 함수로 만들 수 있다. 예를 들어 위에서 설명한 내비게이션 프로그램은 아래와 같은 타입을 가지는 함수로 만들 수 있다.

(Point, Point) -> Route

반면에 인터랙티브(interactive) 프로그램은 실행되는 동안 사용자에게 추가 입력을 받거나 별도의 출력을 주는 프로그램이다. 요즘은 대부분의 프로그램이 인터랙티브 프로그램인데 문답식으로 실행돼서 다양한 상황에 유연하게 대처할 수 있다.

실행 중에 추가 입력을 받고 별도의 출력이 필요한 부수 효과(side effects) 때문에 순수 함수로 인터랙티브 프로그램을 만드는 건 어려워 보인다.

순수 함수와 부수 효과가 있는 함수를 합치는 문제에 대한 여러 해법이 있는데 여기에서는 하스켈에서 도입한 방법을 소개한다. 이 방법은 이전 장에서 소개한 파서(parsers)가 사용한 것과 비슷하다.

입력/출력 타입

하스켈에서 인터랙티브 프로그램은 입력으로 "현실 세계의 상태"를 넣으면 출력으로 수정된 현실 세계의 상태가 나오는 순수 함수라고 볼 수 있다.

type IO = World -> World

그런데 일반적으로 인터랙티브 프로그램은 부수 효과뿐만 아니라 어떤 값을 리턴하기도 한다. 예를 들어 키보드로 입력된 글자를 읽는 프로그램은 값으로 그 글자를 리턴한다. 이런 이유로 인터랙티브 프로그램의 타입을 다음과 같이 일반화 할 수 있다.

type IO a = World -> (a, World)

타입이 IO a인 표현식을 액션(actions)이라고 한다. 예를 들어 타입 IO Char는 문자를 리턴하는 액션이고 타입 IO ()()을 리턴하는 액션이다. ()은 비어 있는 튜플 타입이자 값인데 유닛(unit)이라고 읽는다. 타입 IO ()은 아무 값도 리턴하지 않고 오직 부수 효과만을 가지는 액션이라고 볼 수 있다. 이 액션은 하스켈에서 인터랙티브 프로그램을 만들 때 자주 사용된다.

인터랙티브 프로그램은 값을 리턴하기도 하지만 프로그램에 인자로 값을 넣을 수도 있다. 문자를 입력으로 받아 정수를 리턴하는 인터랙티브 프로그램의 타입은 다음과 같다.

Char -> IO Int

액션을 풀어서 적으면 아래와 같다.

Char -> World -> (Int, World)

하스켈 컴파일러가 액션을 구현하는 방식은 위에서 설명한 것보다 더 복잡하고 효율적이지만 여기에서는 이 정도 관점으로만 봐도 괜찮다.

기본 액션

다음과 같은 세 가지 기본 액션을 소개한다.

  • getChar
  • putChar
  • return

액션 getChar은 키보드에서 문자를 읽고 화면에 출력한 후 결과 값으로 그 문자를 리턴한다.

getChar :: IO Char
getChar = ...

getChar의 구현은 컴파일러에 내장되어 있어서 하스켈 문법만으로는 정의할 수 없다. getChar는 사용자가 키보드로 문자를 입력할 때까지 계속 기다린다.

getChar와 반대 액션인 putChar c는 문자 c를 화면에 출력하고 계산 결과로 유닛을 리턴한다.

putChar :: Char -> IO ()
putChar c = ...

마지막으로 return v는 계산 결과로 v를 리턴하고 아무런 상호 작용도 하지 않는다.

return :: a -> IO a
return v = \world -> (v, world)

함수 return은 부수 효과가 없는 순수 표현식에서 부수 효과가 있는 순수하지 않은 액션으로 가는 다리를 제공한다. 그런데 반대 방향으로 가는 길은 없다. 한번 순수함을 잃게 되면 다시 순수해질 수 없다! 이런 식이라면 금방 이 불결함(?)이 프로그램 전체로 퍼질 것 같지만 하스켈 프로그램에서는 순수 함수가 대부분이고 액션은 최상위 레벨에서 비교적 적게 사용하기 때문에 괜찮다.

GHCi에서 액션을 평가하면 부수 효과를 수행하고 그 결과는 버린다. 예를 들어 putChar 'a'는 문자 'a'를 화면에 출력하고 그 결과 값인 유닛을 버린다.

ghci> putChar 'a'
'a'

차례대로 실행하기(Sequencing)

두 액션을 합치는 자연스러운 방법은 첫 번째 액션을 먼저 수행하고 그 다음에 두 번째 액션을 수행하는 것이다. 첫 번째 액션의 결과로 나온 수정된 현실 세계가 이어서 실행되는 두 번째 액션의 입력이 된다. 이때 아래와 같은 함수 >>=를 사용하고 "then"이라고 읽는다. 요즘에는 "bind"라고 읽는다.

(>>=) :: IO a -> (a -> IO b) -> IO b
f >>= g = \world -> case f world of
                      (v, world') -> g v world'
  • 액션 f를 현재 세계에 적용하고
  • 그 결과 값 v를 함수 g에 적용하면 새로운 액션이 나오는데
  • 그 액션을 수정된 세계 world'에 적용하면 최종 결과가 나온다.

>>=로 엮은 액션들을 표현할 때 do 표기법을 사용하면 더 읽기 좋은 코드를 작성할 수 있다. 예를 들어 getChar를 여러 액션으로 쪼개서 do 표기법으로 다음과 같이 표현할 수 있다.

getChar :: IO Char
getChar = do
  x <- getCh
  putChar x
  return x

getCh는 글자 하나를 읽고 화면에 출력은 안 하는 액션인데 기본 라이브러리에 포함되어 있지는 않다. 예전에 하스켈 컴파일러 중에 Hugs라는 게 있었는데 그 시절에 아래처럼 입력하면 사용할 수 있었던 모양이다.

primitive getCh :: IO Char

Derived primitives

앞에서 소개한 세 가지 액션을 조합해서 여러 유용한 액션을 만들 수 있다. 먼저 문자열을 입력 받는 액션 getLine을 만들어 보자.

getLine :: IO String
getLine = do
  x <- getChar
  if x == '\n' then
    return []
    else do
      xs <- getLine
      return (x:xs)

글자 '\n'은 줄바꿈을 의미한다. 문자열을 화면에 출력하는 액션 putStrputStrLn도 아래와 같이 구현할 수 있다.

putStr :: String -> IO ()
putStr []     = return ()
putStr (x:xs) = do
  putChar x
  putStr xs
  
putStrLn :: String -> IO ()
putStrLn xs = do
  putStr xs
  putChar '\n'

다음과 같이 프롬프트를 출력하고 입력 받은 문자열의 길이를 출력하는 액션을 만들 수 있다.

strlen :: IO ()
strlen = do
  putStr "Enter a string: "
  xs <- getLine
  putStr "The string has "
  putStr (show (length xs))
  putStrLn " characters"
ghci> strlen
Enter a string: abcde
The string has 5 characters

기본 라이브러리에는 없지만 뒤에서 만들 프로그램에 사용할 수 있는 경고음을 내는 액션과 화면을 지우는 액션을 구현할 수 있다.

beep :: IO ()
beep = putStr "\BEL"

cls :: IO ()
cls = putStr "\ESC[2J"

화면에서 글자의 위치는 양의 정수 순서쌍으로 표현하고 좌표 (1, 1)이 제일 왼쪽 위를 가르킨다. 아래와 같은 타입으로 나타낼 수 있다.

type Pos = (Int,Int)

적절한 제어 문자를 이용해서 커서를 원하는 곳으로 이동시킬 수 있다.

goto :: Pos -> IO ()
goto (x,y) =
  putStr ("\ESC[" ++ show y ++ ";" ++ show x ++ "H")

아래 액션은 입력 받은 문자열을 특정 좌표에 출력한다.

writeat :: Pos -> String -> IO ()
writeat p xs = do
  goto p
  putStr xs

함수 seqn는 액션 리스트를 인자로 받아서 차례대로 실행하고 결과는 버린다.

seqn :: [IO a] -> IO ()
seqn []     = return ()
seqn (a:as) = do
  a
  seqn as

seqn와 리스트 컴프리헨션을 이용하면 앞서 정의했던 putStr을 더 간결하게 작성할 수 있다.

putStr xs = seqn [putChar x | x <- xs]
profile
코딩하는 물총새

0개의 댓글