온라인 저지 사이트의 문제들은 주어진 task를 수행하는 코드를 작성하게 한 다음, 지정된 포맷의 입력을 처리하게 한다. 이 때 입력은 전통적으로 다음과 같은 형식을 취하는 경우가 많다.
- 최초 입력되는 값은 정수값으로 몇 번 반복할 것인지를 결정한다.
- 이후 매 행마다 데이터를 입력하고, 이것을 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 개 만큼만 취해서 처리하는 것이다.
자, 다시 찬찬히 생각해보자.
- t를 먼저 입력받는다.
- 이후에 입력을 getContents를 사용해서 충분히 받는다고 가정하자.
- lines를 이용해서 그 충분한 입력을 줄 단위로 자르고
- take t 를 써서 그 중에 t 개를 가져온다.
- 각각의 문자열 입력에 대해서 정수로 변환하고 solve 를 적용한다.
- 이상의 결과를 unlines로 합친 다음
- 출력한다.
여기서 실제 입력은 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