함수
함수
하스켈은 함수형 언어라서 함수가 정말 중심이에요. 함수는 일급 시민이라 인자로 넘기고, 결과로 돌려주고, 자료구조에 담을 수도 있어요. 여기서는 하스켈 함수의 핵심인 커링(currying), 부분 적용, 고차 함수, 그리고 람다와 연산자 섹션까지 차근차근 풀어볼게요.
본문
기본 정의와 커링
두 인자를 더하는 함수를 정의해 볼게요.
add :: Integer -> Integer -> Integer
add x y = x + y
이건 **커링(curried)**된 함수의 예시예요. (커리라는 이름은 이 개념을 널리 퍼뜨린 Haskell Curry에서 왔어요.) 커링되지 않은 함수를 원하면 튜플을 쓰면 돼요.
add (x,y) = x + y
하지만 이 버전의 add는 실제로 인자가 하나인 함수일 뿐이에요! add를 적용하는 add e1 e2 형태는 함수 적용이 왼쪽 결합이라 (add e1) e2와 동등해요. 다시 말해 add에 인자 하나를 적용하면 새 함수가 나오고, 그 함수에 두 번째 인자를 적용하는 거예요. 이것이 add의 타입 Integer->Integer->Integer가 Integer->(Integer->Integer)와 동등한 이유, 즉 ->가 오른쪽 결합이라는 사실과 일치해요.
커링 함수의 부분 적용으로 inc를 다르게 정의할 수 있어요.
inc = add 1
부분 적용은 함수를 값으로 돌려주는 한 방법이기도 해요.
고차 함수
함수를 인자로 넘기는 게 유용한 대표적 예가 바로 map이에요.
map :: (a->b) -> [a] -> [b]
map f [] = []
map f (x:xs) = f x : map f xs
map은 다형성 함수이고, 타입이 첫 인자가 함수라는 걸 명확히 드러내요. 두 a는 같은 타입으로, 두 b도 같은 타입으로 치환돼야 해요. 이렇게 함수를 값처럼 다루는 걸 고차 함수라고 불러요. 사용 예를 보면,
map (add 1) [1,2,3] => [2,3,4]
람다 추상화
방정식 대신 함수를 "익명으로" 정의할 수도 있어요. 람다 추상화를 쓰는 거예요. inc와 동등한 함수는 \x -> x+1로 쓸 수 있어요. 방정식은 사실 람다의 줄임 표기예요.
inc x = x+1
add x y = x+y
는 각각
inc = \x -> x+1
add = \x y -> x+y
와 동등해요. 일반적으로 x가 타입 t1이고 exp가 타입 t2일 때 \x->exp는 타입 t1->t2를 가져요.
중위 연산자도 사실 함수라서 방정식으로 정의할 수 있어요. 리스트 이어붙이기 연산자 ++를 정의해 볼게요.
(++) :: [a] -> [a] -> [a]
[] ++ ys = ys
(x:xs) ++ ys = x : (xs++ys)
함수 합성 연산자 (.)도 자주 쓰이는 중위 연산자예요.
(.) :: (b->c) -> (a->b) -> (a->c)
f . g = \ x -> f (g x)
연산자 섹션
중위 연산자도 함수이므로 부분 적용할 수 있어요. 하스켈에서 중위 연산자의 부분 적용을 **섹션(section)**이라고 불러요.
| 표현 | 의미 |
|---|---|
(x+) |
\y -> x+y |
(+y) |
\x -> x+y |
(+) |
\x y -> x+y |
괄호가 필수라는 점에 주의해야 해요. 마지막 형태는 중위 연산자를 함수 값으로 바꿔 주는 거라, map (+) [1,2,3]처럼 함수 인자로 넘길 때 특히 유용해요. 타입 시그니처를 적을 때도 필요해요. 그러고 보면 앞서 정의한 add는 사실 (+)이고, inc는 (+1)이에요.
inc = (+ 1)
add = (+)
반대 방향도 가능해요. 함수 값을 백쿼트(backquote)로 감싸면 중위로 쓸 수 있어요. x \add` y는 add x y와 같아요. 미리 정의된 리스트 소속 판정 함수 elem도 x `elem` xs`처럼 읽으면 "x는 xs의 원소다"로 자연스럽게 읽혀요.
fixity 선언
중위 연산자나 생성자에는 fixity 선언을 줄 수 있어요. 우선순위(0~9, 9가 가장 강함, 일반 적용은 10으로 가정)와 결합 방향을 지정하는 거예요. 예를 들어 ++와 .의 fixity는 이렇게 선언해요.
infixr 5 ++
infixr 9 .
둘 다 오른쪽 결합이에요. 왼쪽 결합은 infixl, 비결합은 infix로 지정하고요. fixity 선언을 안 주면 기본값은 infixl 9예요.
비엄격(지연) 함수와 무한 자료구조
bot이 bot = bot으로 정의된, 즉 종료하지 않는 표현식이라고 해 볼게요. 그런 표현식의 값을 추상적으로 _|_(bottom)이라고 불러요. 1/0 같은 런타임 오류도 이 값을 가져요. 이런 오류는 복구 불가능해서 프로그램이 계속 진행되지 않아요.
함수 f가 종료하지 않는 표현식에 적용돼도 종료하지 않으면 **엄격(strict)**하다고 해요. 즉 f bot의 값이 _|_일 때 스트릭트예요. 대부분의 언어에서는 모든 함수가 스트릭트하지만 하스켈은 달라요. 예를 들어,
const1 x = 1
에서 const1 bot의 값은 하스켈에서 1이에요. const1은 인자의 값을 "필요로" 하지 않으므로 결코 그걸 평가하지 않아요. 이런 이유로 비엄격 함수를 lazy(지연) 함수라고도 부르고, 인자를 "필요할 때" 또는 "요구에 따라" 평가한다고 말해요.
하스켈에서 오류와 비종료 값은 의미상 같으므로 const1 (1/0)도 제대로 1로 평가돼요. 비엄격 함수는 프로그래머를 평가 순서에 대한 걱정에서 해방시켜 줘요. 무거운 계산값도 필요 없으면 계산되지 않을 테니, 인자로 안심하고 넘길 수 있고, 중요한 예로 무한 자료구조를 다룰 수 있어요.
하스켈은 배정(assignment)이 아니라 **정의(definition)**로 계산한다고 이해하는 게 도움이 돼요. v = 1/0을 "1/0을 계산해서 v에 저장"이 아니라 "v를 1/0으로 정의"라고 읽는 거예요. 값이 필요할 때만 0으로 나누기 오류가 발생해요. 정의라서 순서에 상관없이 프로그램의 의미가 변하지 않아요.
데이터 생성자도 비엄격이에요. 그래서 개념적으로 무한한 자료구조를 정의할 수 있어요. 1로만 가득한 무한 리스트를 볼게요.
ones = 1 : ones
더 흥미로운 건 연속 정수를 만드는 numsFrom이에요.
numsFrom n = n : numsFrom (n+1)
numsFrom n은 n부터 시작하는 무한 정수 리스트예요. 여기서 무한 제곱 리스트도 만들 수 있어요.
squares = map (^2) (numsfrom 0)
물론 실제 계산을 위해선 유한 부분을 잘라내야 하고, 하스켈에는 take, takeWhile, filter 같은 함수가 미리 정의되어 있어요. 예를 들어 take는 리스트에서 처음 n개를 가져와요.
take 5 squares => [0,1,4,9,16]
ones 정의는 **원형 리스트(circular list)**의 예시예요. 대부분의 경우 지연성이 효율성에 큰 영향을 주는데, 구현체가 리스트를 진짜 원형 구조로 구현해 공간을 아낄 수 있거든요.
원형성의 또 다른 예로, 피보나치 수열을 무한 리스트로 효율적으로 계산할 수 있어요.
fib = 1 : 1 : [ a+b | (a,b) <- zip fib (tail fib) ]
zip은 두 리스트를 원소끼리 쌍으로 묶어주는 표준 함수예요.
zip (x:xs) (y:ys) = (x,y) : zip xs ys
zip xs ys = []
fib라는 무한 리스트가 자기 자신을 마치 "꼬리를 쫓는" 것처럼 정의돼요.
error 함수
하스켈에는 타입이 String->a인 내장 함수 error가 있어요. 다소 특이한 함수인데, 타입상으로는 아무것도 모르는 다형성 타입 a의 값을 돌려주는 것처럼 보여요. 실제로는 항상 _|_ 값을 반환해요. 다만 합리적인 구현이라면 진단 목적으로 인자로 받은 문자열을 출력해 줄 거예요. 프로그램에서 뭔가 "잘못됐을 때" 종료시키는 데 유용해요. 표준 라이브러리의 head 실제 정의를 보면,
head (x:xs) = x
head [] = error "head{PreludeList}: head []"