본문 바로가기

Haskell

지정된 횟수만큼 처리를 반복하기

온라인 저지 사이트의 문제들은 주어진 task를 수행하는 코드를 작성하게 한 다음, 지정된 포맷의 입력을 처리하게 한다. 이 때 입력은 전통적으로 다음과 같은 형식을 취하는 경우가 많다.

  1. 최초 입력되는 값은 정수값으로 몇 번 반복할 것인지를 결정한다.
  2. 이후 매 행마다 데이터를 입력하고, 이것을 1에서 입력한 값 만큼 제공한다.

일반적인 프로그래밍 언어에서는 이 과정이 별다른 테크닉을 요구하지 않는다. 기본적인 입력 방법과 반복문을 사용해서 처리하게 된다. 예를 들어 파이썬이라면 다음과 같이 코딩할 수 있겠다.

n = int(input())
for _ in range(n):
    x = input()
    solve(x)

그런데 하스켈에서는 이를 어떻게 처리할 수 있을까? 간단한 문제가 하나 있다고 하자. 정수 하나를 입력받아서 짝수이면 "Even"을 홀수이면 "Odd"를 출력하는 프로그램을 만든다고 하자. 이 프로그램의 동작 시나리오가 먼저 시행횟수 t를 입력받고 이후에 t 번 만큼 다시 정수를 입력받아서 짝/홀수를 판별한 결과를 출력해야 한다.

하스켈에서는 루프문이 없는데, 어떻게 처리할 수 있을까? t를 입력받았을 때 t만큼 어떤 처리를 반복하는 것에 대해 맵을 생각해볼 수 있을 것이다. 만약 정수 t 가 있다면 [1..t] 를 통해서 t 개의 정수를 원소로 갖는 리스트를 만들 수 있다. 이 리스트에 대해서 map을 적용하고, 그 결과를 무시해버리는 방법을 생각해 볼 수 있을 것이다.

solve :: Int -> String
solve n = if even n then "Even" else "Odd"

-- 인자를 무시하고 입력된 값에 대한 solve의 결과를 출력하는 함수
process :: a -> IO ()
process _ = do
  x <- read <$> getLine :: IO Int -- <$> 는 fmap 
  putStrLn . solve $ x

main = do
  lineT <- getLine
  fmap process [1..(read lineT)] 

위 코드면 충분할까? 실제로 컴파일은 되지만 우리가 원하는대로 동작하지는 않을 것이다. 왜냐하면 fmap f [1..t] 부분이 실제로 평가될 필요가 없기 때문에 첫번째 입력만 입력받고 프로그램이 종료되어 버린다. 따라서 fmap f [1..t] 부분의 각 원소를 평가할 필요가 있으며 이를 위해서는 >> 연산자를 왼쪽이나 오른쪽 원소부터 적용할 필요가 있다.

main = do
  t <- read <$> getLine :: IO Int
  foldr1 (>>) . fmap process $ [1..t]

반복문을 흉내내기

그런데 아무래도 이런 방식으로 입력을 구현하는 것은 좀 귀찮기 때문에 조금 더 간편한(?) 방법을 알아보도록 하자. Control.Monad 에는 forM, forM_ 이라는 함수가 있다. 이들 함수는 리스트나 트리 같이 순회가능한 데이터에 대해서 모나드를 출력하는 함수를 맵핑하는데, forM은 맵핑된 결과를 리턴하고, forM_ 은 빈 모나드(m ())를 리턴한다. 즉 위에서 foldr1 (>>) . fmap 과 똑같은 역할을 하는 셈이다.

  • forM :: (Monad m, Traversal t) => t a -> (a -> m b) -> m (t b)
  • forM_ :: (Monad m, Traversal t) => t a -> (a -> m b) -> m ()
import Control.Monad

-- 각각의 함수는 이전과 동일
solve :: Int -> String
solve n = if even n then "Even" else "Odd"

-- 모양을 약간 바꿔서 간단하게 다시 작성했다.
process :: a -> IO ()
process _ = solve . read <$> getLine >>= putStrLn
-- 이렇게 바뀔 수 있는 과정에 대해서는 따로 다룰 예정

main = do
  t <- read <$> getLine :: IO Int
  forM_ [1..t] process

forM_ 은 마치 파이썬의 for ... in 구문과 매우 비슷하게 보이게 된다. 하지만 이 코드에서도 여전히 거슬리는 점이 있는데, 입력을 무시하는 process라는 함수가 영 신경쓰인다. 우리는 같은 작업을 그 저 t번 반복하면 되지, 1, 2, 3,... t 에 대한 값을 처리하는 중이 아니지 않던가.

forM / forM_ 과 유사한데 리스트에 대한 반복이 아니라 지정된 횟수만큼의 반복을 처리하는 replicateM / replicateM_ 함수가 Monad 모듈에 포함되어 있다. 이를 사용해서 위 코드를 다시 정리하면 다음과 같아진다.

main = do
  t <- read <$> getLine :: IO Int
  replicateM_ t $ do
    x <- read <$> getLine
    putStrLn . solve $ x

replicateM_ 은 함수가 아닌 모나드 값을 마지막 인자로 받기 때문에 다음과 같이 축약할 수 도 있다. 비교를 위해서 전체 코드를 다시 써보자.

import Control.Monda

solve :: Int -> String
solve n = if even n then "Even" else "Odd"

main = do
  t <- read <$> getLine
  replicateM_ t $ solve . read <$> getLine >>= putStrLn

정리

Control.Monad 모듈에 있는 forM_, replicateM_ 함수들은 for ... in 문이나 지정된 횟수만큼 반복되는 처리와 닮은 로직을 작성하는데 유용하게 쓸 수 있다.

다른 접근법

지금까지의 접근은 어찌보면 고전적인 방식의 접근일 수 있다. 순차적으로 횟수값을 입력받고 그 횟수만큼 입출력 처리를 반복한 것이다. 하지만 이렇게 생각해보면 어떨까?

우리가 집중하는 것은 입력을 처리해서 출력을 내놓는 함수이다. 이 함수가 입출력을 수행하는 것을 n회 반복하는 대신에, n회 만큼 입력된 데이터에 대해 해당 함수를 맵핑하고, 그 결과를 하나로 합쳐서 한 번에 출력하는 것이다. 즉 문제는 t를 입력 받은 후에 t 번 만큼 입력받고, 계산해서 출력하고... 이걸 반복하라는 것인데, 그냥 t 번 더 입력받은 다음에 출력을 수행하게끔 하는 것이다.

getContents는 표준입력으로부터 EOF를 만날 때까지 데이터를 읽고 그것을 문자열로 변환하여 돌려준다. (물론 IO String타입이다.) 따라서 우리는 앞으로 입력될 여러 줄의 입력이 있다고 가정하고, 거기서 t 개 만큼만 취해서 처리하는 것이다.

자, 다시 찬찬히 생각해보자.

  1. t를 먼저 입력받는다.
  2. 이후에 입력을 getContents를 사용해서 충분히 받는다고 가정하자.
  3. lines를 이용해서 그 충분한 입력을 줄 단위로 자르고
  4. take t 를 써서 그 중에 t 개를 가져온다.
  5. 각각의 문자열 입력에 대해서 정수로 변환하고 solve 를 적용한다.
  6. 이상의 결과를 unlines로 합친 다음
  7. 출력한다.

여기서 실제 입력은 IO String이 될 것이기 때문에 우리는 3~6의 과정을 처리하는 함수를 만들고 이를 맵핑하고 바인딩하면 깔끔하게 정리될 것 같다. 3~6의 과정은 차례로 아래와 같이 쓸 수 있다.

unlines . fmap (solve . read) . take t . lines -- <- getContents

이 함수는 String -> String 이기 때문에 IO String인 getContents에 적용하려면 맵핑을 해야 한다. 그러면 그 결과는 IO String이 될 것이고, >>= 연산자를 써서 putStrLn으로 연결하면 그 내용이 출력될 것이다. 따라서 모나드 모듈 없이 다음과 같이 쓸 수 있다.

main = do
  t <- read <$> getLine
  unlines . fmap (solve . read) .take t . lines <$> getContents >>= putStrLn

즉 (String -> String) 타입의 함수 f를 만들고 f <$> getContents >>= putStrLn 이라고 하면 입력된 콘텐츠를 적절히 처리할 수 있게 된다.

참고로 온라인 저지 사이트들은 제출된 코드를 평가할 때 적절하게 검증된 입력을 사용한다. 예를 들어 위 문제의 경우 첫줄이 9라면 이후에는 9개 줄이 더 있는 식이다. 따라서 사실 t를 사용할 필요도 없고 입력되는 내용 전체를 쓰되, 첫번째 행을 무시해버리면 된다. 그래서 이렇게 써도 동작한다. (do 도 필요없다.)

main = getLine >> unlines . fmap (solve . read) . lines <$> getContents >>= putStrLn