cv.cm

코딩 · 프로그래밍 언어

초보자를 위한 Haskell 기초 튜토리얼

11분 분량

핵심 답변

Haskell은 타입 추론과 지연 평가를 갖춘 순수 함수형 정적 타입 언어입니다. 프로그램은 순수 함수, 대수적 데이터 타입, 패턴 매칭으로 구성되며, 부수 효과는 IO 같은 타입으로 추적합니다. 이 페이지에서는 GHC 설치, 타입, 함수와 가드, 리스트와 고차 함수, 데이터 타입, 타입 클래스, Maybe와 Either, 완성된 예제를 설명합니다.

Haskell이란 무엇이며 어디에 쓰이는가

Haskell은 강력한 정적 타입 시스템, 타입 추론, 지연 평가를 갖춘 순수 함수형 프로그래밍 언어입니다. 연구자 위원회가 설계해 1990년에 처음 정의했으며, 현재 표준은 Haskell 2010이고 주요 구현체는 GHC(Glasgow Haskell Compiler)입니다. Haskell에서 함수는 타입에 명시하지 않는 한 부수 효과가 없으므로 프로그램을 추론하고 테스트하기가 더 쉽습니다.

Haskell은 금융과 핀테크, 컴파일러와 프로그래밍 언어 도구(Pandoc과 ShellCheck 린터는 Haskell로 작성되었습니다), 블록체인 프로젝트, 웹 백엔드, 연구에 쓰입니다. 대수적 데이터 타입, 타입 클래스, 순수 함수 같은 Haskell의 개념이 Rust, Swift, Scala, Kotlin, TypeScript에 영향을 주었기 때문에 배우는 사람도 많습니다.

GHC 설치와 Hello, world 실행

권장 설치 도구는 GHCup으로, GHC, 빌드 도구인 Cabal과 Stack, 편집기용 Haskell Language Server를 설치해 줍니다. 모든 프로그램에는 IO () 타입의 main 액션이 있습니다.

-- hello.hs
main :: IO ()
main = putStrLn "Hello, world!"
runghc hello.hs          # run without compiling
# Hello, world!
ghc hello.hs && ./hello  # compile to a native executable
ghci                     # interactive REPL; load a file with :load hello.hs

주석은 --로 시작합니다. GHCi에서 :type expression(또는 :t)은 모든 표현식의 타입을 보여 주며, 학습에 가장 좋은 방법 중 하나입니다.

값, 타입, 타입 추론

이름은 한 번 바인딩되면 바뀌지 않습니다. 변경 가능한 변수는 없습니다. 타입 시그니처는 ::로 쓰며, 컴파일러가 추론하므로 생략할 수 있지만 최상위 정의에는 적어 두는 것이 좋은 습관입니다.

name :: String
name = "Haskell"

year :: Int
year = 1990

ratio :: Double
ratio = fromIntegral year / 1000   -- convert Int to Double explicitly

main :: IO ()
main = do
  putStrLn (name ++ " " ++ show year)  -- Haskell 1990
  print ratio                          -- 1.99
  print (7 `div` 2, 7 `mod` 2)         -- (3,1)
  print (2 ^ 64 :: Integer)            -- 18446744073709551616
타입예시설명
Int42고정 크기 정수(일반적인 플랫폼의 GHC에서는 64비트)
Integer2 ^ 100임의 정밀도 정수
Double3.1464비트 부동소수점
BoolTrue, False대문자로 시작하는 생성자
Char'h'유니코드 문자 하나
String"text"Char의 리스트 [Char]이며, Data.Text가 더 빠릅니다

show는 값을 String으로 바꾸고, print는 putStrLn . show입니다. 숫자는 암묵적으로 변환되지 않으므로 fromIntegral, round, truncate를 사용하세요.

함수, 가드, 패턴 매칭

함수 적용은 괄호가 아니라 공백으로 씁니다. add 2 3처럼요. 모든 함수는 인자 하나를 받고 나머지를 위한 함수를 반환하는데, 이를 커링이라 하므로 add 2는 그 자체로 함수입니다. 패턴 매칭과 가드가 대부분의 if 연쇄를 대신합니다.

add :: Int -> Int -> Int
add x y = x + y

factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)

grade :: Int -> Char
grade score
  | score >= 90 = 'A'
  | score >= 70 = 'B'
  | otherwise   = 'C'

describe :: [a] -> String
describe []  = "empty"
describe [_] = "one item"
describe _   = "many items"

main :: IO ()
main = do
  print (add 2 3)                 -- 5
  print (map (add 10) [1, 2, 3])  -- [11,12,13] (partial application)
  print (factorial 20)            -- 2432902008176640000
  print (grade 75)                -- 'B'
  putStrLn (describe "hi")        -- many items
  print (if even 4 then "even" else "odd")  -- "even"

if는 표현식이며 항상 else가 필요합니다. 반복문은 없고, 반복은 재귀나 map, filter, foldr 같은 고차 함수로 합니다.

리스트, 튜플, 고차 함수

리스트는 한 가지 타입의 값을 담으며 가장 흔한 컬렉션입니다. 튜플은 서로 다른 타입일 수 있는 값을 정해진 개수만큼 묶습니다. 리스트 컴프리헨션, 범위, 함수 합성 연산자 .로 데이터 처리를 간결하게 쓸 수 있습니다.

import Data.List (sortOn)

main :: IO ()
main = do
  let nums = [5, 3, 8, 1] :: [Int]
  print (head nums, length nums, sum nums)       -- (5,4,17)
  print (0 : nums)                               -- [0,5,3,8,1] (prepend)
  print (filter even nums)                       -- [8]
  print (foldr (+) 0 [1 .. 10])                  -- 55
  print [x * x | x <- [1 .. 5], odd x]           -- [1,9,25]
  print (take 5 [1, 3 ..])                       -- [1,3,5,7,9] (lazy infinite list)
  let people = [("Ann", 31), ("Bo", 25)] :: [(String, Int)]
  print (map fst (sortOn snd people))            -- ["Bo","Ann"]
  print ((length . filter (> 2)) nums)           -- 3
  print (zip [1 :: Int ..] "abc")                -- [(1,'a'),(2,'b'),(3,'c')]

Haskell은 지연 평가를 하므로 값은 필요할 때만 계산됩니다. 그래서 take 5 [1, 3 ..]이 무한 리스트에서도 동작합니다. 지연 평가는 평가되지 않은 큰 표현식을 쌓아 둘 수도 있으므로, 긴 리스트의 합계에는 foldl 대신 Data.List의 foldl'을 쓰는 것이 좋습니다.

대수적 데이터 타입과 타입 클래스

data로 직접 타입을 정의합니다. 타입은 여러 생성자를 가질 수 있고(합 타입), 각 생성자는 필드를 가질 수 있습니다(곱 타입). Show, Eq, Ord, Functor 같은 타입 클래스는 인터페이스와 비슷하게 공통 동작을 기술하며, deriving은 흔한 인스턴스를 자동으로 생성합니다.

data Shape = Circle Double | Rect Double Double
  deriving (Show, Eq)

area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rect w h) = w * h

data User = User { userName :: String, userAge :: Int }
  deriving Show

class Describable a where
  describe :: a -> String

instance Describable Shape where
  describe (Circle _) = "a circle"
  describe (Rect _ _) = "a rectangle"

main :: IO ()
main = do
  print (map area [Circle 1, Rect 2 3])   -- [3.141592653589793,6.0]
  putStrLn (describe (Rect 1 1))          -- a rectangle
  let u = User { userName = "Ann", userAge = 31 }
  print (u { userAge = 32 })              -- User {userName = "Ann", userAge = 32}

Maybe, Either, IO

Haskell에는 null이 없고, 순수 코드에는 검사되지 않는 예외도 없습니다. 값이 없을 수 있으면 Maybe a 타입(Just x 또는 Nothing)을, 이유와 함께 실패할 수 있는 계산에는 Either e a 타입(Left err 또는 Right x)을 씁니다. 입력 읽기 같은 부수 효과는 IO 타입에 속하며, do 표기법으로 순서를 잇습니다.

import Text.Read (readMaybe)

safeDiv :: Int -> Int -> Either String Int
safeDiv _ 0 = Left "division by zero"
safeDiv x y = Right (x `div` y)

parseAge :: String -> Maybe Int
parseAge s = case readMaybe s of
  Just n | n >= 0 -> Just n
  _               -> Nothing

main :: IO ()
main = do
  print (safeDiv 10 2)          -- Right 5
  print (safeDiv 1 0)           -- Left "division by zero"
  print (parseAge "42")         -- Just 42
  print (parseAge "abc")        -- Nothing
  print (fmap (+ 1) (Just 41))  -- Just 42
  putStrLn "Your name?"
  n <- getLine                  -- an IO action that returns a String
  putStrLn ("Hi, " ++ n)

작은 완성 프로그램: 단어 빈도

이 프로그램은 표준 입력에서 텍스트를 읽어 Map으로 단어 빈도를 세고 가장 흔한 단어 세 개를 출력합니다. 함수 합성, GHC에 포함된 containers 패키지의 Data.Map, 정렬을 사용합니다.

import Data.Char (isAlpha, toLower)
import Data.List (sortBy)
import Data.Ord (comparing, Down (..))
import qualified Data.Map.Strict as Map

wordFreq :: String -> [(String, Int)]
wordFreq =
  sortBy (comparing (Down . snd) <> comparing fst)
    . Map.toList
    . Map.fromListWith (+)
    . map (\w -> (w, 1))
    . words
    . map (\c -> if isAlpha c then toLower c else ' ')

main :: IO ()
main = do
  text <- getContents
  mapM_ (\(w, n) -> putStrLn (w ++ ": " ++ show n)) (take 3 (wordFreq text))
echo "The cat and the dog. The dog sat, and the cat ran." | runghc wordfreq.hs
# the: 4
# and: 2
# cat: 2

다음 단계

  • haskell.org의 문서 페이지에는 튜토리얼, 책, GHC User's Guide로 가는 링크가 있습니다.
  • 패키지 저장소인 Hackage와 이름이나 타입으로 함수를 찾는 검색 엔진 Hoogle.
  • Learn You a Haskell for Great Good!와 Real World Haskell, 무료로 읽을 수 있는 두 권의 책입니다.
  • 정확한 언어 정의를 담은 Haskell 2010 Language Report.

자주 묻는 질문

Haskell은 배우기 어려운가요?

변경 가능한 변수와 반복문이 없고 표현력 높은 타입 시스템을 쓰기 때문에 낯설게 느껴집니다. 재귀, 패턴 매칭, 타입을 단계별로 배우면 핵심 언어는 작고 일관되다고 느끼는 경우가 많습니다.

순수 함수형이란 무슨 뜻인가요?

함수는 같은 입력에 항상 같은 결과를 반환하며 상태를 바꾸거나 입출력을 하지 않습니다. 효과는 여전히 가능하지만 IO 같은 타입으로 표현됩니다.

Haskell의 모나드란 무엇인가요?

모나드는 Maybe, Either, IO 같은 타입이 >>=로 계산을 연결할 수 있게 하는 타입 클래스입니다. do 표기법은 이 연결을 위한 문법입니다.

지연 평가란 무엇인가요?

표현식은 값이 필요할 때만 평가됩니다. 덕분에 무한 리스트를 다룰 수 있고 불필요한 작업을 피하지만, 메모리 사용량을 예측하기는 더 어려워질 수 있습니다.

Stack과 Cabal 중 무엇을 써야 하나요?

둘 다 Haskell 프로젝트를 빌드하며 GHCup으로 설치됩니다. Cabal이 표준 빌드 도구이고, Stack은 선별된 패키지 스냅샷을 추가로 제공합니다. 초보자에게는 어느 쪽이든 괜찮습니다.

코딩 튜토리얼 전체