Skip to content

7장: 헨들리-밀너 타입 시스템과 나

타입의 세계로

함수형 프로그래밍을 처음 접하는 분이라면 곧 타입 시그니처(type signature)의 깊은 물속에 빠져들게 될 것입니다. 타입은 다양한 배경을 가진 사람들이 간결하고 효과적으로 소통할 수 있게 해주는 메타 언어입니다. 대부분의 경우 이들은 '헨들리-밀너(Hindley-Milner)' 시스템으로 작성되며, 이번 장에서 함께 탐구해볼 예정입니다.

순수 함수를 다룰 때 타입 시그니처는 일상어로는 범접할 수 없는 표현력을 발휘합니다. 이 시그니처들은 함수의 숨겨진 비밀을 속삭입니다. 단 한 줄의 압축된 표현으로 동작과 의도를 드러냅니다. 타입으로부터 '자유 정리(free theorem)'를 유도할 수 있습니다. 타입 추론이 가능하므로 명시적 주석이 필요 없으며, 정밀하게 조정하거나 추상적으로 유지할 수 있습니다. 컴파일 타임 검사뿐 아니라 최고의 문서화 도구로 기능합니다. 따라서 타입 시그니처는 함수형 프로그래밍에서 예상보다 훨씬 중요한 역할을 담당합니다.

자바스크립트는 동적 언어이지만 타입을 완전히 배제하는 것은 아닙니다. 문자열, 숫자, 불리언 등 여전히 타입을 다룹니다. 다만 언어 수준의 통합이 부족할 뿐이므로 주석을 통해 이 정보를 관리할 수 있습니다.

자바스크립트에는 Flow나 타입이 지정된 언어 변종인 TypeScript 같은 도구가 존재합니다. 본 서의 목표는 함수형 코드 작성 능력을 함양하는 것이므로 FP 언어에서 널리 사용되는 표준 타입 시스템을 사용하겠습니다.

암호문 해독기

수학 교과서의 먼지 낀 페이지에서부터 백서의 광활한 바다, 토요일 아침 블로그 포스트를 거쳐 소스 코드 내부까지 헨들리-밀너 타입 시그니처를 발견할 수 있습니다. 이 시스템은 단순하지만 간결한 설명과 연습을 통해 완전히 이해할 필요가 있습니다.

js
// capitalize :: String -> String
const capitalize = s => toUpperCase(head(s)) + toLowerCase(tail(s));

capitalize('smurf'); // 'Smurf'

여기서 capitalizeString을 입력받아 String을 반환합니다. 구현 방식보다 타입 시그니처에 집중해주세요.

HM 시스템에서 함수는 a -> b 형태로 표기되며, 여기서 ab는 임의의 타입 변수입니다. 따라서 capitalize의 시그니처는 'String에서 String으로의 함수'로 해석됩니다.

추가 함수 시그니처 예시:

js
// strLength :: String -> Number
const strLength = s => s.length;

// join :: String -> [String] -> String
const join = curry((what, xs) => xs.join(what));

// match :: Regex -> String -> [String]
const match = curry((reg, s) => s.match(reg));

// replace :: Regex -> String -> String -> String
const replace = curry((reg, sub, s) => s.replace(reg, sub));

strLength는 앞선 예시와 유사하게 String을 받아 Number를 반환합니다.

처음 보시는 분들은 match 함수에서 약간 당황하실 수 있습니다. 마지막 타입이 반환값임을 기억하세요. RegexString을 입력받아 [String]을 반환한다고 이해하면 됩니다. 그러나 여기서 흥미로운 점이 하나 있습니다.

match 시그니처를 다음과 같이 그룹화할 수 있습니다:

js
// match :: Regex -> (String -> [String])
const match = curry((reg, s) => s.match(reg));

괄호로 묶으면 추가 정보를 파악할 수 있습니다. 이제 Regex를 입력받아 String[String]으로 변환하는 함수를 반환하는 것으로 해석됩니다. 커링 덕분에 실제 동작과 일치합니다. Regex를 주면 String 인수를 기다리는 함수가 반환됩니다.

js
// match :: Regex -> (String -> [String])
// onHoliday :: String -> [String]
const onHoliday = match(/holiday/ig);

각 인수는 시그니처 앞부분의 타입을 하나씩 소비합니다. onHoliday는 이미 Regex를 가진 match입니다.

js
// replace :: Regex -> (String -> (String -> String))
const replace = curry((reg, sub, s) => s.replace(reg, sub));

replace의 경우 괄호 표기가 다소 복잡해질 수 있으므로 생략합니다. Regex, String, 다른 String을 받아 String을 반환하는 함수로 이해하면 충분합니다.

마지막 참고사항:

js
// id :: a -> a
const id = x => x;

// map :: (a -> b) -> [a] -> [b]
const map = curry((f, xs) => xs.map(f));

id 함수는 어떤 타입 a든지 받아 동일한 타입 a를 반환합니다. 코드에서처럼 타입 변수를 사용할 수 있습니다. ab는 관례적인 변수명이지만 임의로 변경 가능합니다. 단 동일 변수는 같은 타입이어야 합니다. 예를 들어 idString -> String 또는 Number -> Number일 수 있지만 String -> Bool은 불가능합니다.

map도 타입 변수를 사용하지만 a와 같거나 다른 타입 b를 도입합니다. a에서 b로의 함수와 a 배열을 받아 b 배열을 반환합니다.

이 시그니처가 표현하는 아름다움에 감탄하셨길 바랍니다. 문자 그대로 함수의 동작을 설명합니다. 각 a에 함수를 적용하는 것 외에 다른 선택지는 없습니다.

타입 추론 능력은 함수형 세계에서 큰 힘을 발휘합니다. 문서 이해도가 높아질 뿐 아니라 시그니처 자체가 기능을 설명해줍니다. 연습을 통해 독자적인 분석 능력을 키운다면 RTFM(매뉴얼 읽기) 없이도 풍부한 정보를 얻을 수 있습니다.

직접 해석해볼 추가 예시:

js
// head :: [a] -> a
const head = xs => xs[0];

// filter :: (a -> Bool) -> [a] -> [a]
const filter = curry((f, xs) => xs.filter(f));

// reduce :: ((b, a) -> b) -> b -> [a] -> b
const reduce = curry((f, x, xs) => xs.reduce(f, x));

reduce는 가장 표현력이 뛰어나면서도 까다로운 함수입니다. 호기심이 있다면 영어 설명을 참고하되, 직접 시그니처를 분석해보는 것이 더 유익할 것입니다.

시그니처 첫 번째 인수는 ba를 받아 b를 생성하는 함수입니다. 입력 ba 배열로부터 값을 공급받으며, 최종 결과물은 b 타입이 됩니다. 실제 reduce 동작과 정확히 일치함을 확인할 수 있습니다.

가능성 축소

타입 변수가 도입되면 *매개변수성(parametricity)*이라는 특성이 등장합니다. 이는 함수가 모든 타입에 대해 균일하게 작동함을 의미합니다.

js
// head :: [a] -> a

head[a]a로 변환합니다. 배열 외에 다른 정보가 없으므로 동작이 제한됩니다. a에 대해 아무 정보가 없는 상태에서 가능한 동작은 배열의 첫 요소 추출뿐입니다. 이름 head가 힌트를 줍니다.

추가 예시:

js
// reverse :: [a] -> [a]

reverse의 가능한 동작은 무엇일까요? 타입 변환은 불가능하며, 정렬도 정보 부족으로 할 수 없습니다. 요소 재배열은 가능하지만 예측 가능한 방식으로 수행해야 합니다. 다형성 타입이 가능성을 크게 제한합니다.

이러한 특성 덕분에 《Hoogle》(https://hoogle.haskell.org/) 같은 타입 검색 엔진 사용이 가능합니다. 시그니처에 압축된 정보는 실로 강력합니다.

자유 정리의 세계

구현 추론을 넘어 자유 정리를 얻을 수 있습니다. 《Wadler의 논문》(http://ttic.uchicago.edu/~dreyer/course/papers/wadler.pdf)에서 발췌한 예시를 소개합니다.

js
// head :: [a] -> a
compose(f, head) === compose(head, map(f));

// filter :: (a -> Bool) -> [a] -> [a]
compose(map(f), filter(compose(p, f))) === compose(filter(p), map(f));

코드 없이 타입만으로 정리를 도출할 수 있습니다. 첫 번째 정리는 배열의 head를 추출한 후 f를 적용하는 것과 전체에 map(f)head를 취하는 것이 동등함을 나타냅니다.

상식적으로 당연해 보이지만, 컴퓨터는 이를 공식화해야 합니다. 수학은 직관을 형식화하는 데 탁월하여 컴퓨터 논리 세계에서 빛을 발합니다.

filter 정리도 유사합니다. p 조건자와 f 매핑을 조합한 필터링은 f 매핑 후 p 필터링과 동등함을 보입니다. filter 시그니처가 a 타입 변경을 금지하기 때문입니다.

두 예시에 불과하지만, 이 추론 방식은 모든 다형성 타입에 적용 가능합니다. 자바스크립트에서도 재작성 규칙을 선언할 수 있으며, compose 함수를 통해 실현 가능합니다. 쉽게 얻을 수 있는 성과와 무한한 가능성이 있습니다.

제약 조건

인터페이스로 타입을 제한할 수 있습니다.

js
// sort :: Ord a => [a] -> [a]

여기서 aOrd 인터페이스를 구현해야 합니다. Ord는 정렬 가능한 타입을 의미하며, 이 제약은 a의 특성과 sort 함수의 동작 범위를 정의합니다. 이를 타입 제약이라고 부릅니다.

js
// assertEqual :: (Eq a, Show a) => a -> a -> Assertion

여기서는 EqShow 두 제약을 사용합니다. a의 동등성 비교와 차이점 출력이 가능함을 보장합니다.

후속 장에서 제약 조건의 실제 사례를 더 살펴볼 예정입니다.

요약

헨들리-밀너 타입 시그니처는 함수형 세계에 편재합니다. 읽고 쓰기는 단순하지만 시그니처만으로 프로그램을 이해하는 데는 시간이 필요합니다. 앞으로 모든 코드 라인에 타입 시그니처를 추가해 나갈 예정입니다.

8장: 튜퍼웨어