Haskell에서는 전통적 의미의 ‘문장’을 별도의 구문 요소가 아닌 일반 데이터(표현식)로 다룬다. IO와 각종 조합기, 병렬·예외 처리, DSL 등을 통해 제어 흐름을 함수로 구성·변환·결합하는 방법과, 컴파일·실행 모델까지 개관한다.
Haskell에서 제가 늘 정말 높이 평가하는 점 하나는, Haskell의 모든 “문장”(또는 다른 언어에서 문장이라 부를 만한 것)이 언어의 일급 구성원이라는 사실입니다. 즉, (명령형) 문장은 말 그대로 숫자, 리스트, 불리언과 다를 바 없는 평범한 객체입니다. 변수에 저장할 수 있고, 함수에 넘길 수 있으며, 일반 함수를 써서 변환하고, 복사할 수 있습니다. Haskell에는 ‘문장’이 없습니다 — 모든 것이 표현식이며, 평범한 데이터를 나타냅니다! 덕분에 코드에 대한 추론만이 아니라 병렬성, 동시성, 예외, DSL 같은 맥락에서 아이디어를 새롭게 틀 지을 수 있는 가능성이 활짝 열립니다.
여기서 “문장(statement)”이라 함은, 전통적인 명령형 프로그래밍에서 제어 흐름이 그 지점에 도달했을 때 어떤 동작을 수행하거나 상태를 수정하는 “명령”의 의미입니다. 위키피디아 문서가 좋은 설명을 제공합니다. 흔한 명령형 언어의 전형적인 문장 예시는 다음과 같습니다:
int a = 4; // 선언과 대입
a += 5; // 수정
printf("hello world"); // 호출
return false; // 종료 지점
이들 언어에서는 제어가 해당 문장에 도달할 때마다 무언가가 일어납니다. 우리는 이 문장을 ‘평가(evaluating)’하는 행위(그 문장이 무엇인지 알아내는 것)와 이 문장을 ‘실행(executing)’하는 행위를 구분하지 않습니다. 대입을 보면 그냥 무언가가 ‘일어납니다’.
이런 언어에서 이러한 문장에는 분명 언어 차원의 마법 같은 특별함이 있습니다. 정수나 불리언과는 전혀 다른 존재죠. 시스템의 “객체”나 “데이터”와는 다릅니다.
설사 여러분의 언어가 일급 함수를 지원하더라도, printf 자체는 일급 값일 수 있지만 그 ‘호출’(보통 괄호 printf()로 표시되는)은 분명히… 전혀 다른 무엇입니다. 언어 안에 서브언어를 만들어 이를 흉내 낼 수는 있지만, 두 체계의 상호작용은 피할 수 없습니다. ‘문장’과 ‘데이터’ 사이의 이분법은 항상 남습니다.
Haskell에서 putStrLn "hello world"는 정말로 평범하고 지루한 객체이며, 타입은 IO ()입니다(만약 OOP 배경이라면 IO a는 템플릿/제네릭의 IO<a>쯤으로 볼 수 있습니다). Int, Bool, String, [Double](Double의 리스트)처럼요. 이것을 ‘평가’한다고 해서 실제로 무언가가 일어나지 않습니다. 숫자 1을 평가하거나, 표현식 2 + 5를 평가하는 것과 같습니다. 멋지죠 — 2 + 5를 평가하면 7이 됩니다. 그래서 뭐가 일어나나요? 딱히 없습니다. 여전히 그건 그냥 Int일 뿐이죠.
putStrLn "hello world"는 컴퓨터가 문자열 "hello world"를 표준 출력으로 찍는 행위를 (그 구체적 표현은 중요치 않은 어떤 추상적 표현을 통해) 나타내는, 정상적인 데이터/항(term)/값입니다.
타입 IO ()는 “컴퓨터가 ()를 계산하는 행위를 나타내는 객체/추상 데이터 구조”를 의미합니다 — 달리 말하면 “컴퓨터가 ()를 산출하도록 하는 지시사항”이죠. ()는 빈 튜플과 비슷합니다… 단 하나의 값 ()만이 존재하는 타입입니다. 다른 언어의 “void를 반환”하는 함수에 해당한다고 생각해도 좋습니다.
객체지향 언어의 템플릿/제네릭에 익숙하다면, IO a는 대략 IO<a>에 대응한다고 볼 수 있습니다.
IO Int나 IO String 타입의 값들도 확실히 많이 존재하며, 각각 Int나 String을 산출하는 행위를 나타냅니다. 흔한 예로 getLine은 타입이 IO String입니다 — getLine은 컴퓨터가 표준 입력에서 입력을 얻는 행위를 나타내는 객체이고, 그 행위의 “결과”는 String입니다. IO Int는 CPU 계산/IO 기반 계산을 통해 Int를 산출하는 것을 나타냅니다.
이 글에서는 논의의 편의를 위해 IO ()만을 다루겠지만, 실제로는 다른 타입들도 똑같이 자주 등장합니다.
Haskell은 이러한 IO ()(그리고 일반적으로 IO a)를 ‘다루기’ 위한 수많은 조합기/함수를 제공합니다. 이들을 조작하고, 합치고, 순서대로 나열하고, 합성하고… 상상하는 무엇이든요!
가장 널리 쓰이는 조합기는 (>>)이며, 보통 중위 연산자로 사용합니다. 흔한 상황: “hello”를, 그다음 “world”를 출력하는 IO 액션을 만들고 싶다고 해 봅시다. 하지만 지금은 “hello”를 출력하는 것을 나타내는 putStrLn "hello"와, “world”를 출력하는 것을 나타내는 putStrLn "world"만 가지고 있습니다.
이 두 개의 IO ()가 있다면 (>>) 조합기를 써서 이를 “합쳐” 새로운 IO ()를 만들 수 있습니다.1 이 경우는 다음과 같습니다:
-- :: 는 "타입이 …이다"를 뜻함
-- putStrLn "hello"는 타입이 IO ()인 객체입니다.
putStrLn "hello" :: IO ()
putStrLn "world" :: IO ()
(>>) :: IO () -> IO () -> IO ()
(>>)의 타입 시그니처는 (간단히 말해) 두 개의 IO ()를 받아서 반짝이는 새 IO ()를 돌려주는 함수라는 뜻입니다. 즉, 두 개의 객체를 받아 하나를 반환하는 함수죠.
(>>)를 중위 연산자로 적용할 수 있습니다:
helloThenWorld :: IO ()
helloThenWorld = putStrLn "hello" >> putStrLn "world"
-- `helloThenWorld`를 putStrLn "hello" >> putStrLn "world"로 정의했습니다;
-- 타입은 `IO ()`입니다.
그 새로운 IO ()는 “hello”를 출력한 다음, “world”를 출력하는 행위를 나타내는 데이터 구조입니다.
이 새로운 값 역시 여전히 평범한 객체일 뿐임을 기억하세요. putStrLn "hello" >> putStrLn "world"를 평가한다고 해서 실제로 아무 것도 “출력”되지 않습니다. Haskell 프로그램에서 그 표현식에 도달하더라도… 아무 것도 출력되지 않죠. 그저 평범한 데이터 값 두 개를 함수에 통과시켜 세 번째 값을 얻을 뿐입니다. helloThenWorld를 정의하는 과정 — 심지어 나중에 그것을 평가하는 과정조차 — 아무 일도 일으키지 않습니다. 이들은 관성적인(inert) 데이터 구조입니다.
다른 많은 언어에서 동작을 순서대로 나열하는 것은 문법 차원의 특별한 요소 — 보통 세미콜론 — 입니다. Haskell에서는 순차 실행이 특별하지 않습니다 — 그냥 평범한 데이터 구조에 대한 평범한 함수일 뿐이죠.
심지어 직접 “일급” 제어 흐름을 만들 수도 있습니다!
when :: Bool -> IO () -> IO ()
when True p = p
when False _ = return ()
(return ()는 아무 것도 하지 않는 행위를 나타내는 IO ()입니다… 많은 언어의 return 키워드와는 관련이 없습니다. 사실상 no-op을 나타냅니다.)
when은 그냥 평범한 함수입니다! Bool과 IO ()를 받아서, Bool이 참이면 결과로 같은 IO ()를 돌려줍니다.
when (4 > 0) (putStrLn "it's True!") 호출을 손으로 평가해 볼 수 있습니다:
when (4 > 0) (putStrLn "it's True!") :: IO ()
when True (putStrLn "it's True!") :: IO () -- 4 > 0 평가
putStrLn "it's True!" :: IO () -- when True 의 정의
when (4 < 0) (putStrLn "it's True!") :: IO ()
when False (putStrLn "it's True!") :: IO () -- 4 < 0 평가
return () :: IO () -- when False 의 정의
위는 ‘실행’이 아니라 ‘평가’입니다. 실행은 컴퓨터 위에서 동작을 실행하는 것이고, 평가는 단순한 축약(reduction)입니다(예: 1 + 1 ==> 2). when은 Bool과 IO () 객체를 받아, 불리언이 참일 때 그 IO () 객체로 ‘평가’되는 함수입니다. 하지만 기억하세요, when을 호출해도 실제로 아무 것도 실행되지 않습니다! 그냥 평범한 함수, 평범한 표현식일 뿐이죠. IO ()가 들어가고, IO ()가 나옵니다. 평범한 데이터에 대한 평범한 함수일 뿐입니다. 우리가 맨바닥부터 직접 썼으니, 이게 평범한 함수라는 걸 아는 겁니다!
자바스크립트 같은 언어에서는 이렇게 순진한 방식으로 when을 쓸 수 없습니다:
var when = function(cond, act) { if (cond) { act; } };
when(false, console.log("hello"));
// 조건이 거짓이어도 "hello"가 출력됩니다
(함수가 아닌 “문장”을 전달할 수 없기 때문에, when이 문장이 아니라 함수를 인자로 받도록 해서 비슷하게 흉내 낼 수는 있습니다… 하지만 그게 요점입니다! “문장”을 전달할 수 없고, 함수/데이터를 전달해야만 합니다. 이런 의미에서 문장과 데이터는 완전히 다르게 동작합니다.)
기초적인 함수형 프로그래밍 지식만 있어도(대략 fold/reduce/inject 또는 재귀), 이런 함수를 쉽게 작성할 수 있습니다:
sequence_ :: [IO ()] -> IO ()
이는 “IO ()의 리스트를 주면, 그것들을 하나씩 차례로 실행하는 행위를 나타내는 새로운 IO ()를 돌려주겠다”는 뜻입니다.
궁금하다면, fold를 사용한 sequence_의 정의는 다음과 같습니다:
sequence_ :: [IO ()] -> IO ()
sequence_ xs = foldr (>>) (return ()) xs
fold/reduce에 익숙하다면, return ()가 “기저 값”이고, (>>)가 “누적 함수”입니다.
sequence_ [putStrLn "hello", putStrLn "world", putStrLn "goodbye!"]
-- 로 평가되면:
putStrLn "hello" >> (putStrLn "world" >> (putStrLn "goodbye!" >> return ()))
이 모든 함수들은 타입이 IO ()인 무엇이든 받을 수 있다는 점에 주목하세요… 따라서 이름 붙인 IO ()를 넘길 수도 있고, 조합기의 결과를 넘길 수도 있고…
hello :: IO ()
hello = putStrLn "hello"
world :: IO ()
world = putStrLn "world"
helloworld :: IO ()
helloworld = hello >> world
helloworldhelloworld :: IO ()
helloworldhelloworld = sequence_ [hello, world, helloworld]
기억하세요 – 아무 것도 호출되거나 실행되지 않습니다. 전부 평범한 데이터에 대한 평범한 함수일 뿐입니다. 입력은 데이터, 출력도 데이터입니다.
하지만 잠깐! 두 개의 IO ()로 할 수 있는 일이, 단지 순서대로 실행하는 것만 있는 건 아니죠. 둘을… ‘병렬’로 합칠 수도 있습니다!
다음 같은 조합기를 쓸 수 있습니다:
bothPar :: IO () -> IO () -> IO ()
두 개의 IO ()를 받아, 그것들을 ‘병렬로’ 실행하는 행위를 나타내는 반짝이는 새 IO ()를 만듭니다.
그 다음 새로운 sequencePar도 쓸 수 있겠죠:
sequencePar :: [IO ()] -> IO ()
IO ()들의 리스트를 받아, 그것들을 모두 ‘병렬로’ 실행하는 행위를 나타내는 새로운 IO ()를 돌려줍니다!
이것이 IO-데이터-로서 접근의 큰 장점입니다: 많은 IO 동작을 가지고 있을 때, 그것들을 ‘어떻게’ “나열”하거나 “결합”할지 ‘선택’할 수 있습니다. Haskell에서는 여러 동작을 순차로 결합하든 병렬로 결합하든, 단지 결합 함수만 바꾸면 됩니다! 문법 차원에서는 아무 차이도 없습니다!
다른 언어들은 문장을 순차로 나열하는 문법 — 세미콜론 — 과 여러 병렬 동작을 실행하기 위한 문법이 분명히 다릅니다. Haskell에서는 “순차 실행”이 문법(세미콜론)의 일부가 아니라 — 그냥 ‘보통의 함수’일 뿐입니다!
sequencePar의 구현은 sequence_와 거의 동일하지만, (>>) 대신 bothPar를 쓰는 것입니다:
sequencePar :: [IO ()] -> IO ()
sequencePar xs = foldr bothPar (return ()) xs
참고로 bothPar는 기본으로 정의되어 있지 않지만, 곧 직접 정의해 보겠습니다.
IO ()들을 서로 합치고, 나열하고, 다루는 수많은 조합기가 있습니다. 그리고 그중 상당수는 여러분이 직접 바닥부터 작성할 수도 있습니다.
또한 IO ()에 적용할 수 있는 여러 “IO 액션 변환기(transformer)”도 있습니다 — 그중 하나가 바로 makePar입니다:2
makePar :: IO () -> IO ()
IO ()를 받아서, 그것을 “병렬” IO ()로 “변환”합니다. 혹은, 컴퓨터 동작을 나타내는 객체를 받아, 그 동작을 병렬 분기에서 실행하도록 하는 객체를 돌려줍니다.
그렇다면 bothPar를 다음과 같이 직접 작성할 수 있습니다:3
bothPar :: IO () -> IO () -> IO ()
bothPar x y = makePar x >> makePar y
bothPar는 두 개의 IO ()(컴퓨터 동작을 나타냄)를 받아, 두 동작을 병렬로 시작하는 것을 나타내는 새 IO ()를 돌려줍니다. 그 방법은 간단히 둘을 하나 다음 하나로 ‘시작’시키면 됩니다!
IO ()에 대한 또 다른 흔한 변환기는 catch입니다:4
catch :: IO () -> (SomeException -> IO ()) -> IO ()
-- ^ ^ ^
-- | | +-- 변환된 객체
-- | +-- 핸들러 함수
-- +-- 원본 객체
이는 IO () 객체와 핸들러 함수를 받아서, 그 IO ()에 “에러 처리 능력”을 부여합니다. 즉, 원래와 같은 일을 하되, 잘못될 경우 내장 에러 처리를 갖춘 새로운 IO () 객체를 돌려줍니다. 멋지죠!
그러니 catch (putStrLn "hello world") myHandler를 사용하면… 문자열을 콘솔에 출력하는 것을 나타내는 IO ()(putStrLn "hello world")를, 어떤 이유로 문제가 생겨도 내장 에러 처리를 갖춘, 콘솔에 문자열을 출력하는 것을 나타내는 새로운 IO ()로 “변환”하는 것입니다.
다시 한 번 — 실행은 전혀 일어나지 않습니다. 우리는 IO 동작을 나타내는 객체를 받아, 살짝 다른 IO 동작을 나타내는 새 객체(수정본)를 반환하고 있을 뿐입니다.
이건 꽤 곁다리 얘기라, 건너뛰어도 좋습니다!
아직 언급하지 않은 특히 중요한 조합기가 하나 있는데, 바로 “bind”라 부르는 (>>=)입니다.
표준 입력에서 한 줄을 읽은 다음 곧바로 출력하고 싶다고 합시다. getLine :: IO String과 putStrLn :: String -> IO ()로 할 수 있겠죠. 하지만 잠깐! 이렇게 하면 안 됩니다:
getLine >> putStrLn "hello?"
(>>)는 세미콜론처럼 동작합니다… 그저 둘을 하나 다음 하나로 나열합니다. 유닉스 파이프 같은 무언가가 있으면 좋지 않을까요? 둘을 “나열”하되, 첫 번째의 결과를 두 번째가 쓸 수 있게 하는 방식 말이죠?
좋습니다, (>>)가 bash의 세미콜론 ;이라면, (>>=)는 bash의 파이프 |입니다!
getLine >>= putStrLn
:: IO ()
우리가 원하는 일을 정확히 해 줍니다!
(>>=)의 타입은 다음과 같습니다:
(>>=) :: IO a -> (a -> IO b) -> IO b
우리의 구체적 경우에는:
(>>=) :: IO String -> (String -> IO ()) -> IO ()
즉, “IO String과 String을 받아 IO ()를 내는 함수 하나를 주면, 반짝이는 새 IO ()를 줄게”라는 뜻입니다. 처음엔 좀 낯설게 들릴지 모르지만, getLine :: IO String과 putStrLn :: String -> IO ()가 여기에 딱 들어맞는 것을 보면, 여러 면에서 유닉스 파이프처럼 동작함을 알 수 있습니다.
알고 보면 (>>=)는 처음 보기에 비해 훨씬 강력합니다. 일단 (>>=) 조합기가 여러분의 도구 상자에 들어오면… 다양한 IO a들을 이용해 구성할 수 있는 프로그램의 공간이 말도 못 하게 확장됩니다. 파이프 없이 세미콜론만 있는 bash를 상상해 보세요! 만약 여러분이 일급 문장 체계를 구현하려 한다면, (>>=) 없이 명령형 계산을 진술/모델링하기가 꽤 까다롭다는 걸 알게 될 겁니다.
“데이터로서의 문장”으로 할 수 있는 일들은 이 글에서 소개한 게 극히 일부에 불과합니다. 사실, 문장 전체를 완전히 추상화해 버리는 프레임워크도 많습니다. 예를 들어, 단순한 DSL로 시스템을 선언적으로 “구성”하고, 문장이나 IO를 전혀 신경 쓰지 않을 수도 있죠. 상호작용에 대한 완전한 설명을 갖춘 ‘전체 프로그램’을, IO 타입을 한 번도 ‘손대지’ 않고도 명세할 수 있습니다. DSL은 여러분의 프로그램을 단순한 용어로 고수준의 개요로 표현하는 수단을 제공할 수 있습니다. DSL이 복잡한 IO 동작을 추상화/래핑하고, 여러분은 단순한 API만 보게 되는 경우도 있죠.
그리고 어쩌면 DSL -> IO () 같은 함수가 있을 겁니다. 간단한 용어로 정교한 고수준 구조를 만들고… 마지막에 그것을 ‘IO () 객체로 변환’하는 것이죠. 그러면 그걸 복사하거나, 클론하거나, 함수에 넣거나, 여기서 언급한 무엇이든 할 수 있습니다!
그 DSL이 여러분의 전체 프로그램이라면, DSL 라이브러리가 사용자가 임의의 IO를 섞어 쓰지 못하게 제한할 경우, 프로그램이 할 수 있는 IO에 대한 일정한 보장도 얻게 됩니다.
멋진 얘기입니다. 문장을 데이터로 일급 취급하면 조작과 추상화 등에 유용하다는 걸 봤습니다. 하지만 데이터 구조가 관성적으로만 남아 실제로 아무 것도 하지 않는다면 다소 무의미해 보이기도 하죠.
다행히, Haskell 컴파일러를 거대한 함수 IO () -> Binary로 생각할 수 있습니다. Haskell 컴파일러에 IO ()를 주면, 특정 아키텍처/컴퓨터/CPU를 위한 “바이너리”로 변환해 줍니다. 즉, ‘계산의 표현’을 컴퓨터가 실제로 실행할 수 있는 구체적 바이트코드로 “번역”합니다.
그렇게 생성된 바이너리를 컴퓨터가 실행하면… 출발!
慣례적으로 모든 Haskell 프로그램은 ‘오직 하나의 IO ()’를 컴파일합니다. 즉, 프로그램 안에는 ‘많은’ IO a가 있을 수 있지만, 컴파일러에 ‘하나의’ IO ()를 “제공”하고, 그것을 컴파일합니다. 그래서 다양한 IO 계산을 하고 싶다면, 이를 계속해서 차례로 나열하고, 합치고, 파이핑하고, 결합하고, 변환하고… 해서, 원하는 전체 계산을 나타내는 최종 IO () 하나를 얻습니다. 그리고 그 이름을 “main”이라고 짓습니다. 컴파일러가 Haskell 파일을 컴파일할 때, “main”이라는 이름의 IO ()를 찾아 그것을 컴파일합니다.
어떤 의미에서 Haskell은 바이트코드를 “생성”하기 위한, 매우 정교한 메타프로그래밍 시스템이자 DSL을 제공한다고 볼 수 있습니다.
그런데, IO ()가 그 바이너리 표현과 ‘완전히 분리’되어 있다는 점이 눈에 띄지 않나요? 흠. 어째서 굳이 바이너리로 컴파일해야만 하죠?
여기서 ‘다른 Haskell 컴파일러’가 등장합니다. ghcjs 같은 것이죠 — 이 컴파일러는 IO () -> Binary가 아니라 IO () -> Javascript입니다! 즉, 어떤 IO ()든(프로세서/CPU를 위한 것과 동일한 것을) 주면, 바이트코드/바이너리로 번역하는 대신 자바스크립트로 번역합니다! 이는 IO ()가, 결국 실행될 아키텍처나 컴퓨터와 독립적인 추상 객체이기 때문에 가능한 또 하나의 힘입니다. 아주 추상적이기에… 사실상 ‘무엇으로든’ 컴파일하고 실행할 수 있습니다!
질문이나 코멘트가 있다면, 댓글을 남기거나, freenode의 #haskell, #nothaskell, #haskell-beginners에 들르시거나, 트위터에서 저를 찾아주세요.
이 글에서 저는 IO ()가 자신이 나타내는 “행위”를 어떤 추상적인 방식으로 저장하는 데이터 구조라고 제안했습니다. 만약 이 표현/저장이 구체적으로 어떻게 생겼는지 궁금하다면, IO 액션 타입(구현 가능성 중 하나)의 내부 표현을 “슬쩍 들여다보면” 무엇을 볼 수 있는지에 관해 Chris Taylor의 글이 있습니다 — 이를 이용해 여러분이 선택한 언어에서 일급 문장을 구현할 수도 있을 겁니다
이 글은 예전에 제가 썼던 다른블로그 글에서 언급한 개념들을 정제한 것입니다. 두 글을 쓰고 나서 새로 든 생각이 많아, 그것들을 압축해 새로운 아이디어를 더 간결하게 요약한 새 글로 정리해 한데 모아두고 싶었습니다. 어쨌든, 이 주제를 더 자세히 파고들고 싶다면 위 글들이 도움이 될지도 모릅니다!
Haskell을 배우고 싶다면 Learn You a Haskell을 읽어 보세요. 꽤 쉽게 읽힙니다! bitemyapp의 가이드도 Haskell 학습을 위한 멋진 로드맵을 제시합니다.
또, 가능한 언어라면 직접 “일급 IO” 시스템을 구현해 보기를 권합니다! 일반 데이터 구조(Chris Taylor의 글처럼)로 하든, 추상화된 함수 호출로 하든요. 여러분이 선택한 언어에서 이를 구현해 본 결과나 시도에 대해 듣고 싶습니다(심지어 Haskell이 여러분의 언어라 해도, MyIO 타입을 직접 쓸 수 있겠죠 :D). 댓글이나 트위터로 알려주세요!
(교정/유용한 제안을 해 주신 computionist와 bitemyapp께 감사드립니다)
(>>)의 타입은 사실 더 일반적입니다: (>>) :: IO a -> IO b -> IO b. 이 글에서는 IO ()에 대해서만 사용했습니다.사실, 진짜 Haskell에서의 타입은 이보다도 더 일반적이라면 화내실까요? 실제로는 `m a -> m b -> m b`이며, `Monad` 타입클래스의 멤버인 임의의 타입 `m`에 대해 성립합니다. 해당 타입이 `(>>)` 등을 “구현”하고, 어떻게 동작해야 하는지에 대한 몇 가지 규칙을 따른다고 생각하면 됩니다.
아! 이 토끼굴은 꽤 깊으니, 너무 걱정하지 않으시길 권합니다
2. makePar는 표준 Haskell 라이브러리에 실제로 forkIO라는 이름으로… 어느 정도 존재합니다. forkIO :: IO () -> IO ThreadId. 우리가 말한 makePar는 기본적으로 forkIO인데, 반환값을 무시한 버전입니다. 즉, makePar x = forkIO x >> return ()와 같습니다↩︎
이 액션은 두 스레드가 모두 끝날 때까지 “기다리지” 않는다는 점에 유의하세요; 하는 일은 두 스레드를 시작하는 것뿐입니다.↩︎
다시 말하지만, 여기의 실제 정의는 약간 더 일반적입니다.↩︎
성능상의 이유로, 해당 글에서 설명된 IO 구현 방식은 인기 있는 Haskell 컴파일러인 GHC에서 실제로 쓰는 방식과는 다릅니다. GHC의 구현은 “해키(hacky)”하다고 묘사하는 편이 더 정확하며, IO ()가 ‘나타내야 하는 것’의 의미론적 그림과 꼭 들어맞지는 않습니다. 하지만 이것이 어디까지나 (보기에도 좀 흉한) ‘구현 세부’라는 점을 기억하세요. IO () 타입에 대해 외부에 제공되는 API는 기대하는 대로 동작합니다, 물론이죠. 그러길 바랄 뿐입니다! 어쨌든, ‘언어로서의 Haskell’과, IO 타입이 ‘나타내려는 것’, 그리고 추한 구현 세부를 추상화하여 언어의 의미론을 제공하는 ‘구현으로서의 Haskell’을 구분해서 생각하는 것이 중요합니다.↩︎