본문으로 건너뛰기

IO 다루기

이전 장에서는 텍스트 문자열을 하스켈 표현으로 변환하는 파서를 만들었고, HTML 코드를 쉽게 작성할 수 있는 EDSL을 만들었습니다. 하지만 이 프로그램은 다른 사용자에게는 아직 유용하지 않습니다. 왜냐하면 이 기능을 어떤 사용자 인터페이스로도 제공하지 않기 때문입니다.

이번에는 사용자의 입력을 받아서 HTML로 변환하는 프로그램을 만들고자 합니다. 이런 인터페이스를 설계하는 방법은 여러 가지가 있습니다. 예를 들어:

  • 표준 입력으로부터 텍스트를 읽어서 표준 출력으로 HTML을 출력합니다.
  • 명령줄 인자로 두 개의 파일 이름을 받아서 첫 번째 파일의 내용을 읽어서 두 번째 파일에 출력합니다.
  • 더욱 편리한 명령줄 인터페이스를 제공하고 파일 이름 앞에 플래그를 붙여서 어떤 파일인지 알려줍니다.
  • 화려한 GUI 인터페이스를 제공합니다.
  • 위의 모든 것을 조합합니다.

우선 다음과 같은 인터페이스부터 시작해 보겠습니다:

  1. 사용자가 인자 없이 프로그램을 호출하면 표준 입력에서 읽고 표준 출력으로 출력합니다.
  2. 사용자가 두 개의 인자를 주면 첫 번째 인자는 입력 파일 이름이고 두 번째 인자는 출력 파일 이름입니다.
  3. 만약 출력 파일이 이미 존재한다면 덮어쓸지 물어봅니다.
  4. 그 외의 경우에는 사용법을 설명하는 메시지를 출력합니다.

이후 장에서는, 라이브러리를 사용해서 더 화려한 명령줄 인터페이스를 만들고, 단일 파일뿐만 아니라 디렉토리 전체를 처리하는 방법을 배울 것입니다.

하지만 먼저, 하스켈에서 I/O를 어떻게 다루는지, 특별한 점이 무엇인지, 다른 프로그래밍 언어와 어떻게 다른지 알아보겠습니다.

순수 함수형 언어

하스켈은 원래 비엄격적 의미론(non-strict semantics)을 가진 오픈 표준(open standard) 함수형 언어로 설계되었습니다. 이는 미래의 함수형 언어 설계 연구를 위한 통합 언어로 사용하기 위해 만들어졌습니다.

GHC에서는, 비엄격적 의미론을 구현하기 위해 지연 평가(lazy evaluation) 전략을 사용합니다. (지연 평가에 대해서는 이전 장에서 다루었습니다.)

비엄격적 의미론을 위한 요구사항을 충족하고자 한다면 다음과 같은 고민을 해야 합니다:

  • 어떻게 하면 단순히 표현식을 평가하는 것 이상의 일을 할 수 있는 언어를 설계할 수 있을지
  • 어떻게 하면 외부 세계와 상호작용을 모델링할 수 있을지
  • 어떻게 하면 I/O를 다룰지

지연평가 전략을 가진 언어에서 I/O 연산을 한다는 것은, 프로그램이 점점 커질 때, 평가 순서를 파악하기가 더욱 어려워진다는 것을 의미합니다. 다음과 같은 가상의 코드 예제를 생각해 봅시다 (실제로는 하스켈에서는 타입 에러가 발생합니다. 그 이유는 곧 알아보겠습니다):

addWithInput :: Int -> Int
addWithInput n = readIntFromStdin + n

main =
let
result1 = addWithInput 1
result2 = addWithInput 2
in
print (result2 - result1)

이 가상의 프로그램은 표준 입력에서 두 개의 정수를 읽은 다음, 두 번째 정수(+2)에서 첫 번째 정수(+1)를 뺀 결과를 출력합니다. 만약 이 프로그램이 엄격한 언어라면, 우리는 연산 순서가 위에서 아래로 일어날 것이라고 예상할 수 있습니다.

하지만 지연 평가를 사용하는 언어에서는, 표현식의 결과가 필요해질 때까지 평가를 미룹니다. 따라서 result1result2는 둘 다 뺄셈 결과를 출력할 때까지 평가되지 않습니다. 그리고 뺄셈을 평가할 때, 두 인자를 왼쪽에서 오른쪽으로 평가합니다. 그래서 result2가 먼저 평가됩니다.

result2를 평가하기 위해, addWithInput 함수에서 n2로 치환한 다음, 최상위 함수인 +를 평가합니다. 그리고 그 인자들을 평가합니다. 즉 readIntFromStdinn을 평가하는데, 이때 표준 입력에서 첫 번째 정수를 읽습니다.

평가 완료되면, result1의 평가로 넘어가며 표준 입력에서 두 번째 정수를 읽습니다. 이는 우리가 원하는 것과 정반대의 결과입니다!

이처럼, 지연 평가는 부작용(side effect)이 있는 코드를 다루기가 어렵습니다. 부작용이 있는 코드는 외부 세계에 영향을 줄 수 있거나, 외부 세계에 영향을 받을 수 있습니다. 이는 가변 메모리를 읽거나 쓰거나, I/O 연산을 수행하는 것을 포함합니다.

우리는 addWithInput과 같은 부작용이 있는 함수를 호출했으며, 이를 비순수 함수(impure function)라고 합니다. 비순수 함수는 동일한 입력을 받았을 때에도 다른 결과를 반환할 수 있습니다.

비순수 함수의 존재는 지연 평가를 이해하는 것을 더욱 어렵게 만들며, 또한 등식 추론(equational reasoning)을 사용하여 프로그램을 이해하는 것을 방해합니다.

그러므로, 하스켈은 부작용이 없는 순수한 함수와 표현식만을 허용합니다. 순수 함수는 항상 동일한 출력을 반환하며, 순수 표현식을 평가하는 것은 결정론적(deterministic)입니다.

그렇다면 어떻게 I/O 연산을 수행할 수 있을까요? 가능한 해결책은 많습니다.

하스켈에서는 IO라 불리는 인터페이스를 디자인해 이를 해결합니다. IO의 인터페이스는 I/O 표현과 비-I/O 표현을 구분하도록 강제하며, 또한 여러 IO 작업을 결합하기 위해서는 작업 순서를 지정해야 합니다.

IO

IOHtml 타입처럼 사용자에게 내부 표현을 숨기는 불투명 타입(opaque type)입니다. 하지만 이번에는 IO가 모듈이 아닌 하스켈 언어에서 제공하는 내장 타입입니다.

Maybe와 유사하게, IOIO 연산의 결과를 나타내는 페이로드 타입을 가집니다. 의미있는 결과가 없을 때는, 이를 나타내기 위해 유닛 타입인 ()을 사용합니다. (오직 () 하나만 값으로 가지는 타입입니다.)

다음은 몇 가지 IO 연산과 IO 연산을 반환하는 함수들입니다:

putStrLn :: String -> IO ()

getLine :: IO String

getArgs :: IO [String]

lookupEnv :: String -> IO (Maybe String)

writeFile :: FilePath -> String -> IO ()

더 많은 유용한 IO 함수와 연산은 base 패키지에 포함된 System.IO 모듈에서 찾을 수 있습니다.

각 함수가 IO <something>을 반환하는 것을 주목하세요. 이것은 무슨 의미일까요?

IO a의 의미는 실행될 때 어떤 타입 a의 값을 생성하고, 실행 중에 I/O 효과를 가질 수 있는 프로그램(또는 서브루틴)의 설명입니다.

IO a를 실행한다는 것과 평가한다는 것은 서로 다릅니다. IO a 표현식을 평가하는 것은 순수한 연산입니다 - 평가는 항상 동일한 프로그램의 설명으로 대체될 수 있습니다. 이는 순수성과 등식 추론을 유지하는 데 도움이 됩니다!

하스켈 런타임은 IO 작업이 실행되도록 하기 위해 프로그램의 진입점인 main 표현식(타입 IO ()을 가져야 함)을 실행합니다. 이를 위해 IO 작업은 main 표현식에 결합되어야 합니다. 이게 어떤 의미인지 살펴보겠습니다.

IO 표현식을 결합하기

Html.Structure 처럼, IO 인터페이스는 작은 IO 연산을 결합하여 더 큰 IO 연산을 만드는 결합자(combinator)를 제공합니다. 이 인터페이스는 또한 연산의 순서가 잘 정의되어 있는지를 보장합니다!

Html.Structure를 위한 <> 연산자를 정의했던 것처럼, IO를 위한 결합자는 특정한 타입에 특화된 변형이 아니라 타입클래스 인스턴스(type-class instance)로 구현됩니다. (예를 들어 append_ 함수는 Structure에만 맞춰진 <>의 특수한 버전이었습니다).

이번 장에서는 특수한 타입 시그니처를 먼저 소개할 것입니다. 이는 툭수한 타입을 소개하는 것이 이해하기 쉬울 것이라고 생각하기 때문입니다. 하지만 이후 일반적인 버전에 대해서도 설명할 예정입니다.

>>=

첫 번째 결합자는 >>=(bind) 입니다. 이는 가장 유용한 결합자입니다:

(>>=) :: IO a -> (a -> IO b) -> IO b

이 결합자는 두 개의 인자를 받습니다. 첫 번째는 IO 연산이며, 두 번째는 첫 번째 IO 연산의 결과를 받아 새로운 IO b를 반환하는 함수입니다.

위 함수를 사용하는 몇 가지 예제를 살펴보겠습니다:

  1. Echo

    getLine >>= (\line -> putStrLn line)

    >>=의 왼쪽의 getLine을 통해 표준입력으로부터 한 줄을 읽습니다. 이후 해당 입력은 >>=의 오른쪽의 람다 함수의 인자로 전달됩니다. 그리고 람다 함수가 실행되어 표준출력으로 해당 입력을 출력합니다. >>=의 역할은 왼쪽의 IO 연산의 결과를 오른쪽의 IO 연산을 반환하는 함수의 인자로 전달하는 것입니다.

    >>=연산의 순서를 정의합니다 - 왼쪽에서 오른쪽으로 실행됩니다.

    각 하위 표현식의 타입은 다음과 같습니다:

    getLine :: IO String

    putStrLn :: String -> IO ()

    (>>=) :: IO String -> (String -> IO ()) -> IO ()

    line :: String
    • 질문: 전체 표현식의 타입은 무엇일까요?
      Answer
      IO ()

    이 예제는 getLine >>= putStrLn 처럼 좀 더 간결한 형태인 포인트프리 스타일로 작성할 수 있습니다.

  2. 두 입력을 합하기

    getLine >>= (\honorific -> getLine >>= (\name -> putStrLn ("Hello " ++ honorific ++ " " ++ name)))

    이 서브루틴은 여러 연산을 결합합니다. 표준입력으로부터 두 줄을 읽고, 인사말을 출력합니다.

    • >>= 를 사용해서 연산이 왼쪽에서 오른쪽으로 실행되도록 합니다.
    • 하스켈의 범위 규칙에 의해 honorific은 가장 안쪽의 함수에서도 사용할 수 있습니다.

    위 코드는 약간 읽기 불편하기에, 괄호를 제거하고 들여쓰기를 추가해서 읽기 쉽게 만들 수 있습니다:

    getLine >>= \honorific ->
    getLine >>= \name ->
    putStrLn ("Hello " ++ honorific ++ " " ++ name)

더 많은 결합자를 살펴보겠습니다!

*> 와 >>

(*>) :: IO a -> IO b -> IO b
(>>) :: IO a -> IO b -> IO b

*>>>IO에 대해 같은 타입 시그니처를 가지며 같은 의미를 가집니다. 사실, *>>>의 약간 더 일반화된 버전으로 항상 >> 대신에 사용할 수 있습니다. >>는 이전 버전과의 호환성을 위해 남아있는 것입니다.

*>는 첫 번째 IO 연산을 실행하고, 그 결과를 버립니다. 이후 두 번째 연산을 실행합니다. 이 함수는 >>=를 사용해서 구현할 수 있습니다:

a *> b = a >>= \_ -> b

이 결합자는 putStrLn처럼 유의미한 결과를 반환하지 않는 여러 IO 연산을 순서대로 실행할 때 유용합니다.

putStrLn "hello" *> putStrLn "world"

pure 와 return

pure :: a -> IO a

*>>>처럼, purereturn은 더 일반화된 버전입니다. pure는 다른 언어의 관련 없는 키워드와 유사하지 않다는 장점이 있습니다.

IO a는 프로그램의 설명이며, 실행될 때 a 타입의 값을 생성하고, 실행 중에 I/O 효과를 가질 수 있다는 것을 기억하시나요?

pure를 사용하면, I/O를 하지 않는 IO a를 만들 수 있습니다. 이 IO a는 실행 시 특정 타입 a의 값을 생성합니다. pure에 전달한 값입니다!

이 함수는 IO에 의존하지만 효과가 없는 계산이 필요할 때 유용합니다.

예제:

confirm :: IO Bool
confirm =
putStrLn "Are you sure? (y/n)" *>
getLine >>= \answer ->
case answer of
"y" -> pure True
"n" -> pure False
_ ->
putStrLn "Invalid response. use y or n" *>
confirm

여기서 단순히 TrueFalse를 반환하려 하면 >>=타입으로 인해 에러가 발생합니다:

(>>=) :: IO a -> (a -> IO b) -> IO b

예제에서 >>=의 오른쪽(\answer -> case ...)의 타입은 String -> IO Bool이어야 합니다. 왜냐하면:

  1. getLine :: IO String>>=의 타입을 통해 aString입니다.
  2. confirm :: IO Bool으로 인해 bBool입니다.

fmap 과 <$>

fmap :: (a -> b) -> IO a -> IO b

<$>fmap의 중위(infix) 버전입니다. 필요에 따라 사용하면 됩니다.

만약 표준입력으로부터 한 줄을 읽고, 마지막에 !를 붙여서 출력하고 싶다면 어떻게 해야 할까요? >>=pure를 사용해서 다음과 같이 작성할 수 있습니다:

getLine >>= \line -> pure (line ++ "!")

위와 같은 패턴은 fmap을 사용해 통합할 수 있습니다:

fmap (\line -> line ++ "!") getLine

fmapIO 작업에서 반환될 값에 함수를 적용하며, 이를 "매핑(mapping)"이라고도 합니다.

(혹시 fmapmap :: (a -> b) -> [a] -> [b] 사이의 유사성을 눈치채셨나요?)

요약

지금까지 살펴본 IO 결합자들의 목록입니다:

-- IO 연산들의 체이닝: 왼쪽 IO 연산의 *결과*를 오른쪽 함수의 인자로 전달합니다.
-- "bind"라고 읽습니다.
(>>=) :: IO a -> (a -> IO b) -> IO b

-- 두 연산을 순서대로 실행하며, 첫 번째 연산의 결과를 버립니다.
(*>) :: IO a -> IO b -> IO b

-- 특정한 값을 IO 문맥으로 리프트(lift)합니다. I/O 효과를 추가하지는 않습니다.
pure :: a -> IO a

-- IO 연산의 페이로드 값에 함수를 적용(mapping)합니다.
fmap :: (a -> b) -> IO a -> IO b

IO는 일급

IO의 아름다운 점은 언어에서 완전히 일급(first class)인 구조체이며, Maybe, EitherStructure와 별반 다르지 않다는 것입니다. 즉 IO를 함수에 전달하거나, 컨테이너에 넣을 수 있습니다. IO는 프로그램의 설명을 나타내며, 어떤 방식으로든 main에 결합하지 않으면 실제로 아무것도 하지 않습니다. 그저 값일 뿐입니다!

다음은 IO 연산을 입력으로 받는 함수의 예입니다:

whenIO :: IO Bool -> IO () -> IO ()
whenIO cond action =
cond >>= \result ->
if result
then action
else pure ()

다음과 같이 사용할 수 있습니다:

main :: IO ()
main =
putStrLn "This program will tell you a secret" *>
whenIO confirm (putStrLn "IO is actually pretty awesome") *>
putStrLn "Bye"

putStrLn "IO is actually pretty awesome"는 즉시 실행되지 않고, whenIO가 반환하는 것이 무엇인지에 따라 실행됩니다. 그리고 그 결과는 *>결합되어 main 표현식의 일부가 됩니다.

IO로 부터 벗어나기?

위에서 살펴본 내용은 하스켈 언어에 큰 영향을 미칩니다. Html 타입에서, 우리는 Html을 문자열로 바꿀 수 있는 render :: Html -> String 함수를 가지고 있었습니다.

하스켈에서는 순수성과 등식추론을 유지하기 위해 execute :: IO a -> a와 같은 함수를 구현할 수 없습니다!

또한 IO불투명합니다. 우리는 IO의 내부를 살펴볼 수 없습니다. 따라서 우리는 IO가 허용하는 하스켈 API만 사용할 수 있습니다.

이는 우리가 IO를 특별하게 사용해야 한다는 것을 의미합니다!

하스켈에서는 한번 IO에 들어가면 더 이상 나오지 못합니다. 할 수 있는 일은 오직 여러 IO 연산들을 결합해서 더 큰 IO 연산을 만드는 것뿐입니다.

또한 IO aa로 대체할 수 없습니다. 예를 들어, getLine ++ "!"와 같이 작성할 수 없습니다. 왜냐하면 ++는 양쪽 모두 String이어야 하기 때문입니다. 하지만 getLine의 타입은 IO String이므로 타입이 일치하지 않습니다! 따라서 fmap을 사용해야 하며, 반환 타입은 IO String이어야 합니다.

하스켈에서는 IO 사용을 최소화하고 프로그램의 가장자리로 밀어 넣는 것을 선호합니다. 이 패턴을 흔히 기능적 코어, 명령형 쉘이라고 합니다.

기능적 코어, 명령형 쉘

우리의 블로그 생성기 프로그램에서는 파일을 읽고, 파싱하고, HTML로 변환하며, 결과를 콘솔에 출력하고자 합니다.

많은 프로그래밍 언어에서는 파일로부터 읽는 것과 파싱하는 것을, 콘솔에 출력하는 것과 HTML로 변환하는 것을 번갈아 처리합니다. 하지만 하스켈에서는 이를 분리합니다. 파싱작업은 파일 핸들이 아닌 String 값을 대상으로 하며, Html은 콘솔에 직접 출력하는 대신 String으로 변환합니다.

IO를 분리하고 프로그램의 가장자리로 밀어 넣는 방식은 많은 유연성을 제공합니다. IO가 없는 함수는 테스트와 검증이 쉽고, (결정적인 평가를 보장하기 때문입니다!) 더 모듈화되며, 다양한 상황에서 작동할 수 있습니다(표준 입력에서 읽기, 네트워크 소켓에서 읽기, HTTP 응답전송 등).

이러한 패턴은 하스켈 프로그램을 만들 때 자주 사용되며, 특히 배치 프로그램에 적합합니다.

블로그 생성기 만들기

이제 블로그 생성기를 만들어 봅시다. 다음과 같은 인터페이스를 제공하고자 합니다:

  1. 사용자가 인자 없이 프로그램을 호출하면, 표준 입력에서 읽고, 표준 출력에 씁니다.
  2. 사용자가 프로그램을 두 개의 인자와 함께 호출하면, 첫 번째 인자는 입력 파일 이름이 되고, 두 번째 인자는 출력 파일 이름이 됩니다.
  3. 출력 파일이 이미 존재하는 경우, 사용자에게 덮어쓸 것인지 물어봅니다.
  4. 그 외 입력의 경우, 올바른 사용법을 설명하는 일반적인 메시지를 출력합니다.

이를 위해 다음과 같은 함수가 필요합니다:

getArgs :: IO [String] -- 프로그램 인자를 가져옵니다.

getContents :: IO String -- 표준 입력에서 모든 내용을 읽습니다.

readFile :: FilePath -> IO String -- 파일의 모든 내용을 읽습니다.

writeFile :: FilePath -> String -> IO () -- 문자열을 파일에 씁니다.

doesFileExist :: FilePath -> IO Bool -- 파일이 존재하는지 확인합니다.

그리고 다음과 모듈을 가져와야 합니다:

import System.Directory (doesFileExist)
import System.Environment (getArgs)

다음 가져오기 구문은 Prelude에서 이미 제공하기 때문에 추가할 필요가 없습니다:

-- Prelude에서 가져옴
import System.IO (getContents, readFile, writeFile)

  1. 문서를 마크업으로 파싱하고, HTML로 변환하고, HTML을 문자열로 렌더링하는 함수를 만듭니다. 이 함수는 process :: Title -> String -> String 형태를 갖습니다.
정답
process :: Html.Title -> String -> String
process title = Html.render . convert title . Markup.parse
  1. 블로그 생성기를 위한 "명령형 쉘"을 구현하세요. main에서 시작해서, getArgs의 결과를 패턴 매칭하고, 무엇을 할지 결정하세요. 이전 예제를 참고하세요.
정답
-- Main.hs
module Main where

import qualified Markup
import qualified Html
import Convert (convert)

import System.Directory (doesFileExist)
import System.Environment (getArgs)

main :: IO ()
main =
getArgs >>= \args ->
case args of
-- 인자가 없는경우: 표준 입력에서 읽고, 표준 출력에 씁니다.
[] ->
getContents >>= \content ->
putStrLn (process "Empty title" content)

-- 인자로 입력과 출력 파일 경로가 있는 경우
[input, output] ->
readFile input >>= \content ->
doesFileExist output >>= \exists ->
let
writeResult = writeFile output (process input content)
in
if exists
then whenIO confirm writeResult
else writeResult

-- 그 외 입력의 경우
_ ->
putStrLn "Usage: runghc Main.hs [-- <input-file> <output-file>]"

process :: Html.Title -> String -> String
process title = Html.render . convert title . Markup.parse

confirm :: IO Bool
confirm =
putStrLn "Are you sure? (y/n)" *>
getLine >>= \answer ->
case answer of
"y" -> pure True
"n" -> pure False
_ -> putStrLn "Invalid response. use y or n" *>
confirm

whenIO :: IO Bool -> IO () -> IO ()
whenIO cond action =
cond >>= \result ->
if result
then action
else pure ()

Do 표기법

IO액션을 연결하기 위해 >>=를 사용할 수도 있지만, 하스켈은 명령형 프로그래밍과 비슷한 더 편리한 문법적 설탕인 do 표기법을 제공합니다.

do 블록do 키워드로 시작하고, 다음과 같은 형태의 하나 이상의 "문장"으로 이어집니다:

  1. IO () 타입의 표현식, 예를 들어:
    • putStrLn "Hello"
    • if True then putStrLn "Yes" else putStrLn "No"
  2. let 블록, 예를 들어
    • let x = 1
    • 또는 다음과 같이 여러 let 선언:
      let
      x = 1
      y = 2
      여기서 in은 생략합니다.
  3. 바인딩 <변수> <- <표현식>, 예를 들어
    line <- getLine

그리고 마지막 "문장"은 IO <무언가> 타입의 표현식이어야 합니다. 이것은 do 블록의 결과 타입이 됩니다.

위 세 가지 문장은 하스켈 컴파일러를 통해 다음과 같은 형태로 변환됩니다:

  1. <표현식> *>,
  2. let ... in
  3. <표현식> >>= \<변수>

예를 들어 다음 코드는:

greeting :: IO ()
greeting = do
putStrLn "Tell me your name."
let greet name = "Hello, " ++ name ++ "!"
name <- getLine
putStrLn (greet name)

다음 코드와 동일합니다:

greeting :: IO ()
greeting =
putStrLn "Tell me your name." *>
let
greet name = "Hello, " ++ name ++ "!"
in
getLine >>= \name ->
putStrLn (greet name)

let<-의 차이를 이해하는 것은 중요합니다. let은 표현식에 새로운 이름을 지정하는데 사용되고 이후 라인에서 접근할 수 있습니다. 그리고 <-IO a 안의 결과 a를 새로운 이름에 바인딩하는데 사용되고 이후 라인에서 접근할 수 있습니다.

codeoperator왼쪽 타입오른쪽 타입설명
let gretting = "hello"=StringString양쪽 교환 가능
let mygetline = getLine=IO StringIO StringgetLine에 새로운 이름을 지정
name <- getLine<-StringIO StringgetLine의 결과를 변수에 바인딩

Do 표기법은 매우 흔하고, >>=를 직접 사용하는 것보다 선호하는 경우가 많습니다.


  1. 연습문제: 이 장의 예제를 do 표기법으로 변경하세요.

  2. 연습문제: 블로그 생성기에 대한 코드를 do 표기법으로 변경하세요.

정답
-- Main.hs
module Main where

import qualified Markup
import qualified Html
import Convert (convert)

import System.Directory (doesFileExist)
import System.Environment (getArgs)

main :: IO ()
main = do
args <- getArgs
case args of
-- 인자가 없는경우: 표준 입력에서 읽고, 표준 출력에 씁니다.
[] -> do
content <- getContents
putStrLn (process "Empty title" content)

-- 인자로 입력과 출력 파일 경로가 있는 경우
[input, output] -> do
content <- readFile input
exists <- doesFileExist output
let
writeResult = writeFile output (process input content)
if exists
then whenIO confirm writeResult
else writeResult

-- 그 외 입력의 경우
_ ->
putStrLn "Usage: runghc Main.hs [-- <input-file> <output-file>]"

process :: Html.Title -> String -> String
process title = Html.render . convert title . Markup.parse

confirm :: IO Bool
confirm = do
putStrLn "Are you sure? (y/n)"
answer <- getLine
case answer of
"y" -> pure True
"n" -> pure False
_ -> do
putStrLn "Invalid response. use y or n"
confirm

whenIO :: IO Bool -> IO () -> IO ()
whenIO cond action = do
result <- cond
if result
then action
else pure ()

요약

이번 장에서는 "순수 함수형"이 무엇인지, 순수 함수형을 추구하는 이유는 무엇인지, 그리고 하스켈의 I/O 인터페이스가 프로그램의 명세를 만들 수 있게 하는지 살펴보았습니다.

우리는 또한 중요한 이정표를 달성했습니다. 우리는 프로그램을 하나의 문서에서 실행하고 HTML로 렌더링된 결과를 얻을 수 있도록 필요한 구성 요소들을 충분히 구현했습니다!

하지만, 우리의 명령줄 인터페이스는 여전히 부족합니다. 여러 게시글로 구성된 블로그를 렌더링하고, 인덱스 페이지를 만들고, 더 많은 작업을 해야합니다. 아직 이 프로그램을 블로그 생성기라고 부르기에는 더 많은 작업이 남아 있습니다.

계속 진행해봅시다!

Git 커밋을 통해 이번에 수정한 내역현재까지 코드 를 확인할 수 있습니다.