절차적 프로그래밍에 익숙한 사람에게도 친절한, 무섭지 않은 Haskell 입문 글입니다.
「왜 Haskell을 배우면 좋은가」도 참고해 주시면 감사하겠습니다.
Haskell이라고 들으면, 무엇이 떠오르나요?
여러 가지가 떠오를지 모르지만, Haskell이 훌륭한 건 모나드를 사용하기 때문도, 지연 평가형의 순수 함수형 언어이기 때문만도 아닙니다.
여러 가지 “Haskell다움”이 모여, 그 결과 Haskell에만 있는 훌륭한 매력을 제공합니다.
그리고 그것은 결코 지금까지의 패러다임과 대립하는 것이 아니라,
같은 기존의 편리한 도구들을 잘 추상화하며 통합해 만들어진 것입니다.
등에 당신이 쏟아온 시간은 결코 헛되지 않습니다.
함수형이거나, 모나드를 쓴다고 해서, 무서운 것이 아닙니다.
하지만 세상에는 “객체지향과 함수형의 종교전쟁” 같은, 우리와는 다른 세계선의 사건을 들먹이며 우리를 혼란스럽게 하려는 어둠의 조직도 존재합니다.
그런 배경도 있어서인지, 새로 Haskell을 배우는 사람은
를 알고 싶어 하는 경우가 많고, 그래서 많은 입문서도 기존의 절차적(물론 객체지향도 절차적 기반입니다) 언어와 다른 점을 강조합니다.
그렇지만, 슬슬 Haskell도 문호를 열고, “니와카(초짜)”의 참전을 허해도 좋을 때가 아닐까요.
아마 얌전한 Haskell이라면, 니와카가 늘어도 PHP처럼 처참한 일이 되지 않을 겁니다.
그래서, 무서운 형님들에게서 마사카리(손도끼)가 날아오는 걸 두려워하지 않고,
를 뿌리에 둔 Haskell 입문 글을 써 보려 합니다.
Haskell과 절차적의 관계에 대해서는, 나중에 @ruicc 님의 모나드 입문 이전쯤을 읽어 보면 재미있을지도요.
Haskell, 무섭지 않아요.
아주 편리해요.
학습 장벽도 아주 낮아요.
그런데도, 스크립트 언어처럼 척척 쓸 수 있고, 컴파일 언어의 실행 성능을 갖고 있어요.
게다가 흔한 버그 대부분은 컴파일러가 찾아서 알려줘요.
버그가 들어가기 어려워서 금융권 같은 데서도 쓰이기 시작했다나 봐요1
Haskell은 컴파일 언어와 스크립트 언어의 좋은 점을 둘 다 취했습니다.
그래서 스크립트 언어처럼 가볍게 실행해서 놀 수 있습니다.
runghc foobar.hs arg1 arg2
물론, 컴파일해서 실행 파일을 만들 수도 있습니다.
ghc foobar.hs
./foobar arg1 arg2
하지만 Haskell로 놀려면 GHC라는 컴파일러가 설치된 환경이 필요합니다.
구글에서 검색해 보세요.
라고 해서 여기서 탈락자를 늘려봐야 의미가 없으니, Haskell로 놀 수 있는 환경을 준비해 두었습니다.
위에 온라인 에디터, 아래에 Linux 터미널이 보이죠?
아래 터미널에 ls라고 쳐 보세요.
밑에 표시된 파일 중 main.txt가 Haskell 프로그램입니다.
보통 Haskell 파일은 확장자를 .hs로 하는 게 관례지만, 이번에는 피치 못할 사정2으로 .txt가 되어 있습니다.
신경 쓰지 마세요.
vim에 익숙한 분은 vim으로 main.txt를 열어보세요.
vim 같은 거 써본 적 없다고요?
그런 당신을 위해, 위에 에디터가 준비되어 있습니다.
에디터 왼쪽에 파일 목록이 있죠?
main.txt를 클릭하면 내용을 편집할 수 있게 됩니다.
편집했으면, 그 위의 "Save and Run" 버튼을 클릭해 확정할 수 있습니다.
main.hs
main = do
putStrLn "Hello Haskell"
시험 삼아 아래 터미널에
runghc main.txt
라고 치고 엔터를 눌러 보세요.
에디터 위의 초록색 "Run" 버튼을 클릭해도 실행됩니다.
Hello Haskell이 표시되면, 당신도 이제 Haskell 프로그래머입니다.
야호!
mainHaskell에는 main 함수가 있습니다.
C++나 Java랑 똑같죠. 봐요, 아무것도 안 달라요.
이 main 함수가 프로그램 실행 시 가장 먼저 호출됩니다.
문법은
main.hs
main = do
뭔가를
여기에
명령으로
잔뜩
쓸 거예요
입니다.
보통 {} 같은 걸로 함수의 “덩어리”를 명시하는 언어가 있는데, Haskell은 행頭의 공백에 의한 들여쓰기로 프로그램의 “덩어리”를 표시합니다.
괄호로만 해두면, 일단 돌아가긴 하지만, 들여쓰기를 엉망으로 해서 읽기 힘든 코드를 쓰는 사람이 있잖아요.
그런 똥 제조기를 근절하기 위해, 들여쓰기에 의미를 부여합니다.
오지랖이지만 이해해 주세요.
프로그램에 주석을 안 쓰는 사람은 뭘 시켜도 못합니다.
저도 주석 쓰는 걸 종종 빼먹습니다. 미안합니다.
comment.hs
-- 코멘트예요
{-
여러 줄의
코멘트예요
-}
사실 Haskell에는 python의 pydoc이나 doctest 같은 장치가 있어서,
프로그램 안에 특별한 형식의 주석으로 함수 설명을 적어두면 그걸 잘 문서화해 주거나(haddock),
프로그램 안에 함수가 만족해야 할 성질을 살짝 써 두면 자동으로 테스트해 주거나(doctest, QuickCheck) 해주는 편리한 것들이 있습니다.
조금 더 공부한 뒤, 나중에 그 편리 기능을 써 봅시다.
샘플을 실행해 봅시다.
getLine:이라고 출력되면 아무 글자나 적당히 치고 Enter를 누르세요.
같은 내용이 아래에 반복됩니다.
이어 getContents:가 출력되면, 또 적당히 치고 Enter를 눌러 보세요.
이번에도 같은 내용이 반복되었습니다.
그런데 계속 뭔가 입력할 수 있는 것 같네요.
몇 번 놀아 봅시다.
질리면 Ctrl+D로 종료입니다. Ctrl+D가 잘 안 먹을 때는 Ctrl+Shift+D를 눌러 보세요.
stdio.hs
main = do
putStrLn "getLine:" -- 표준 출력에 문자열을 출력
l <- getLine -- 한 줄의 표준 입력을 상수 `l`에 대입
putStrLn l -- 문자열이 들어 있는 상수 `l`의 내용을 표준 출력에 출력
putStrLn "getContents:" -- 표준 출력에 문자열을 출력
c <- getContents -- Ctrl+D가 눌릴 때까지의 여러 줄 표준 입력을 상수 `c`에 대입
putStrLn c -- 문자열이 들어 있는 상수 `c`의 내용을 표준 출력에 출력
뭔가 ← 같은 낯선 기호가 있네요,
일단, 입력을 받는 계열의 함수로부터 값을 받을 때는 <-라는 화살표 같은 걸 쓴다고 기억해 둡시다.
또, 문자열을 표준 출력하는 함수 putStrLn에, 함수에서 흔한 인자 괄호 ()가 붙어 있지 않네요.
그런 건 장식입니다.
아니, ()가 없는 건 커링이라는 초절정 편의 기능 때문이지만, 지금은 신경 쓰지 마세요.
(@igrep 님 지적 감사합니다)
아니, ()가 없는 건 커링이라는 초절정 편의 기능을 쓰기 쉽게 해주는 효과가 있지만, 지금은 신경 쓰지 마세요.
file.hs
main = do
i <- readFile "input.txt" -- "input.txt"라는 파일의 내용을 문자열로 대입
putStrLn i -- 표준 출력으로 출력해 보기
writeFile "output.txt" i -- "output.txt"라는 파일에 `i`의 내용을 기록
writeFile은 인자를 2개 받는 함수입니다. 여러 개의 인자를 받는 함수는 인자를 공백으로 구분해 뒤에 쓰기만 하면 됩니다.
콤마는 필요 없습니다.
runghc main.txt를 실행한 뒤 cat output.txt로 제대로 내용이 써졌는지 확인해 보세요.
이미 표준 입출력 부분에 나왔습니다. 상수입니다.
언어에 따라 변수 선언 시 const나 final 같은 걸 붙여 선언하는, 그겁니다.
즉, 거의 변수처럼 쓸 수 있지만, 값을 대입할 기회가 처음 한 번만 주어지는, 변수의 열화판 같은 녀석이죠.
그럴 바엔 변수를 쓰라고요?
지금까지의 인생에서, 변수에 이상한 값을 다시 대입해 버리는 계열의 버그를 몇 번이나 썼는지 세어 보세요.
그런 버그를 쓴 기억이 없다면, 당신은 초절정 대단한 프로그래머이거나,
아니면 자신의 실수 원인을 금방 잊어버리는 똥 제조기입니다.
const.hs
main = do
let foo = "똥" -- 일반적인 대입(문자열)
let bar = 3.4 -- 일반적인 대입(실수)
let baz = True -- 일반적인 대입(참/거짓)
c <- getContents -- 표준 입력을 받을 때의 서식은 이쪽
putStrLn foo -- "똥"이라고 표시
putStrLn (show bar) -- `show`는 문자열이 아닌 것을 문자열로 변환하는 함수
print baz -- `show`하고 나서 `putStrLn`하는 편의 함수 `print`도 있어요
표준 입력이 얽힐 때만, 문법에 주의하세요.
물론, 변수도 쓸 수 있습니다.
누가 그랬나요. Haskell은 변수의 부작용을 “절대 용서하지 않는다”고.
var.hs
import Data.IORef -- 변수를 쓸 때는 맨 처음에 이걸 씀
main = do
v <- newIORef 0 -- 새 변수 `v`의 내용을 `0`으로 초기화
c <- readIORef v -- 변수 `v`의 내용을 상수 `c`에 대입
print c -- `v`의 내용 `0`이 표시됨
writeIORef v (c + 1) -- `c`의 값에 `+ 1`한 값을 변수 `v`에 대입
c2 <- readIORef v -- 변수 `v`의 내용을 상수 `c2`에 대입
print c2 -- `1`이라고 표시됨
파일 입출력과 비슷하죠!
변수를 의미하는 특수한 파일에 대해 읽고 쓰고 있다고 생각하면, 뭔가 통일감이 있어서 멋지지 않나요?
“일일이 상수에 대입해서 쓰는 게 번거롭다”고요?
괜찮습니다.
“고차 함수”와 “커링”, “람다식” 정도를 쓸 수 있게 되면, modifyIORef라는 편리한 함수를 손에 넣을 수 있습니다.
그리고, Haskell에 익숙해져서 “변수 같은 거 진짜 안 씀”이라고 말하는 당신이 제 울트라 해피한 뇌내 꽃밭에는 이미 있습니다.
함수에는 2종류가 있습니다.
입니다.
전자는 함수의 인자에 대해 함수의 반환값이 일의적으로 정해지지 않기 때문에, 정말 의도한 동작을 하는지 테스트하기 어렵다는 특성이 있습니다.
왜냐하면 표준 입력을 쓰고 있다면, 인자뿐만 아니라, 사용자가 터미널에 무엇을 치느냐에 따라 함수 결과가 바뀌어 버리잖아요.
반면 후자는, 아무거나 적당히 인자를 줘서 그 결과를 보면, 언제나 같은 값을 돌려주기 때문에, 아주 테스트하기 쉽습니다.
이후, 전자를 “순수하지 않은 함수”, 후자를 “순수한 함수”라고 부르겠습니다.
전자는 main 함수의 형식으로 정의합니다.
인자가 있는 경우는 함수명 뒤에 반각 공백으로 구분해 가짜 이름을 붙입니다.
또, 반환값이 있는 경우는 return을 써서 그 값을 돌려줍니다.
io.hs
함수명 인자명1 인자명2 = do
여기에
뭔가를
처리로
쓸 거예요
return 무언가
이 함수를 호출할 때는, 입력계 함수의 사용법과 같이 ← 화살표를 사용합니다.
물론, 이 새로 정의한 notPure도 입력계 함수이므로, notPure를 호출하는 함수도 같은 형식으로 정의합니다.
io.hs
main = do
name <- getName "☆"
putStrLn name
getName str = do
putStrLn "성: "
lastName <- getLine
putStrLn "이름: "
firstName <- getLine
return (lastName ++ str ++ firstName)
테스트 등의 용이성 때문에, 될 수 있으면 이쪽 타입의 함수를 정의해 사용하도록 합시다.
pure.hs
함수명 인자1 인자2 = 지역상수1이나 지역상수2를 쓴 식
where
지역상수1 = 뭔가
지역상수2 = 뭔가
where 아래에 정의한 상수는 이 함수 내부에서만 사용할 수 있습니다.
좀 더 구체적인 예를 봅시다.
sample.hs
main = do
let message = howOldAreYou "타무라 유카리" 17
putStrLn message
howOldAreYou name age = nameSan ++ ageSai
where
nameSan = name ++ "씨"
ageSai = show age ++ "세"
쓸 때는, show 함수를 쓸 때처럼, let으로 상수에 대입합니다.
이런 단순한 예로는 고마움을 느끼기 어렵지만, 제어 구문이나 패턴 매칭을 공부하면 고마움에 몸서리치게 될 겁니다.
이쯤에서 배열에 대해 다뤄봅시다.
Haskell에서는 실제로는 그다지 “배열”을 쓰지 않습니다.
배열 비슷한 “단방향 리스트”라고 불리는 것을 씁니다.
그래도 일단은 배열이라고 생각해도 됩니다.
나중에 검색해 봅시다.
list.hs
main = do
let ls = [1..4]
print ls -- [1,2,3,4]
let ls2 = [1,3..8]
print ls2 -- [1,3,5,7]
let ls3 = ["foo", "bar", "baz"]
print ls3 -- ["foo", "bar", "baz"]
print (ls3 !! 1) -- "bar"
기본적으로는 []로 감싸고 콤마로 구분할 뿐입니다.
숫자 리스트용 특수한 표기가 있어서, ..를 쓰면 그 사이의 숫자를 채워 줍니다.
for 문Haskell도 for 문을 쓸 수 있습니다.
조금 특수한 표기지만, 우선은 이 형태로 익혀 버립시다.
foreach 문이라고 하면 더 와닿는 분도 많을지 모릅니다.
for.hs
import Control.Monad -- for 문을 쓸 때 필요
import Data.IORef -- 변수를 쓸 때 필요
main = do
printList [1..5] -- 1부터 5까지 표준 출력
s <- getSum [6..10] -- 6부터 10까지의 총합을 구함
print s
printList ls = do
forM_ ls $ \i -> do -- 리스트 내 각 요소 `i`에 대해
print i
getSum ls = do
s <- newIORef 0 -- 총합을 저장할 변수를 초기화
forM_ ls $ \i -> do -- 리스트 내 각 요소 `i`에 대해
c <- readIORef s
writeIORef s (c + i) -- 총합을 갱신
ret <- readIORef s -- 최종 총합을 얻어서
return ret -- 반환값으로 함
printList와 getSum이 for 문을 사용하고 있습니다.
아쉽게도 for 문은 순수하지 않은 함수 안에서만 쓸 수 있습니다.
이러한 for 문의 사용법은 버그의 원인이 되기 때문에, 별로 좋은 사용법이 아닙니다.
더 나은 반복 방법은 다음 회 이후에 소개합니다.
if 문if 문은 흔한 형태입니다.
for 문은 순수하지 않은 함수 안에서만 쓸 수 있었지만, if 문은 순수한 함수 안에서도 쓸 수 있습니다.
if.hs
main = do
putStrLn "인사라고 하면?: "
greeting <- getLine
answerToGreeting greeting
putStrLn "아무 숫자나: "
num <- getLine
putStrLn (checkNum num)
-- 순수하지 않은 함수 안의 `if`
-- `then`, `else` 뒤에 `do`를 붙임
answerToGreeting greeting = do
if greeting == "Hi"
then do
putStrLn "영어 하시네, 그렇죠?"
else do
putStrLn "영어로 부탁"
-- 순수한 함수 안의 `if`
-- `then`, `else` 뒤에 `do`를 붙이지 않음
checkNum num =
if num == "0"
then "제로"
else "비제로"
case 문case.hs
main = do
putStrLn "number: "
num <- getLine
printInEnglish num
putStrLn (numInEnglish num)
printInEnglish num = do
case num of
"1" -> do
putStrLn "one"
"2" -> do
putStrLn "two"
"3" -> do
putStrLn "three"
_ -> do
putStrLn "모르겠어"
numInEnglish num =
case num of
"1" -> "one"
"2" -> "two"
"3" -> "three"
_ -> "모르겠어"
위에서부터 순서대로 보다가, 가장 먼저 매치한 줄의 화살표 -> 이하가 평가됩니다.
_는 모든 값에 매치하므로, 마지막 줄에 써서 그때까지 매치하지 못한 값에 매치시킬 수 있습니다.
반대로 맨 위에 써 버리면, 1이어도 2이어도 항상 _의 화살표 이후의 값이 평가되고 맙니다.
마지막으로 지금까지의 총복습을 겸해 FizzBuzz를 써 봅시다.
표준 입력으로 숫자 n을 받아, 1부터 n까지
라고 표시합니다.
fizzBuzz.hs
import Control.Monad
main = do
putStrLn "몇까지?: "
numStr <- getLine -- 문자열로서 수치를 받음
let num = read numStr -- `read`는 문자열을 수치로 변환하는 함수
fizzBuzz num
fizzBuzz num = do
forM_ [1..num] $ \i -> do
putStrLn (show i ++ ": " ++ toFizzBuzz i)
toFizzBuzz num =
case mod num 15 of -- `mod`는 나머지를 구하는 함수
0 -> "FizzBuzz"
3 -> "Fizz"
5 -> "Buzz"
6 -> "Fizz"
9 -> "Fizz"
10 -> "Buzz"
12 -> "Fizz"
_ -> ""
곧 리라이트해서, 더 무섭지 않은 내용으로 바꾸겠습니다.