07 장: 힌들리-밀너와 나
당신은 무슨 타입인가요?
당신이 함수형 세상에 처음 왔다면 머지않아 타입 시그니처에 깊이 빠지게 될 거에요. 타입은 서로 다른 배경을 가진 사람들이 간단명료하고 효과적으로 소통할 수 있게 해주는 메타 언어입니다. 대부분의 경우 이 장에서 다루는 "힌들리-밀너(Hindley-Milner, HM)" 체계를 이용해 타입을 표현할 것입니다.
순수 함수를 사용할 때 한국어로는 전혀 다루지 못할 것을 타입 시그니처를 통해 표현할 수 있어요. 타입 시그니처는 함수의 내밀한 비밀을 당신 귀에 속삭여 줄 것입니다. 간결한 한 줄만으로 행동과 의도를 알려줄 거에요. "공짜 정리"를 도출 할 수도 있습니다. 또 타입은 추론될 수도 있어서 명시적으로 적지 않아도 작동합니다. 아주 세밀하게 조절될 수도 있고 일반적이고 추상적으로 남을 수도 있어요. 컴파일 타임 체크에 유용할 뿐만 아니라 문서화에도 사용할 수 있고요. 타입 시그니처는 함수형 프로그래밍에서 당신이 처음에 생각하는 것보다 더 중요합니다.
자바스크립트는 동적 언어지만 이것이 타입을 전혀 고려하지 않는다는 말은 아닙니다. 우리는 문자열과 숫자, 불리언을 사용합니다. 그저 언어 차원에서 지원해주지 않기 때문에 이 정보들을 우리가 기억하고 있어야 된다는 것을 의미합니다. 그러나 걱정하지 마세요. 이미 문서화를 위해 사용하는 것처럼 주석을 통해 설명할 수 있습니다.
Flow나 TypeScript 같이 자바스크립트에서 이용할 수 있는 타입 검사 도구들도 있어요. 이 책의 목적은 여러 FP 언어에 걸쳐 사용할 수 있는 일반적인 타입 체계를 갖추는 것입니다.
비밀 이야기
낡은 수학 책에서, 햐얀 논문의 광활한 바다에서, 토요일 아침의 일상적인 블로그 글에서, 그리고 소스 코드의 밑바닥에서 우리는 힌들리-밀너 타입 시그니처를 볼 수 있어요. 이 체계은 매우 단순하지만 빠르게 설명할 수 있고 이 작은 언어를 배우기 위한 연습들도 많습니다.
// capitalize :: String -> String
const capitalize = (s) => toUpperCase(head(s)) + toLowerCase(tail(s));
capitalize("smurf"); // 'Smurf'
위의 capitalize
는 String
을 받아서 String
을 반환합니다. 구현은 신경쓰지 마세요. 우리가 관심있는 것은 타입입니다.
HM에서 함수는 a -> b
로 쓰여집니다. 여기서 a
와 b
는 어떤 타입을 나타내는 변수입니다. 따라서 capitalize
의 명세는 "String
에서 String
으로 가는 함수"라고 읽을 수 있어요. 또는 String
을 입력으로 받고 String
을 출력으로 반환한다고 볼 수도 있습니다.
또 다른 함수의 명세도 봅시다.
// 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
는 Regex
과 String
을 받아서 [String]
을 반환한다고 해석할 수 있습니다. 하지만 제가 따로 설명하고 싶은 재미있는 현상도 있어요.
match
의 명세를 다음과 같이 짝지을 수도 있어요.
// match :: Regex -> (String -> [String])
const match = curry((reg, s) => s.match(reg));
뒷부분을 괄호로 묶는것은 더 많은 정보를 드러내 줍니다. 이제 이 함수는 Regex
를 받아서 String
에서 [String]
으로 가는 함수를 반환하는 것으로 보입니다. 커링 덕분에 사실 이 함수에게 Regex
를 주면 우리는 String
을 받기를 기다리는 함수를 얻습니다. 당연히 이런식으로 생각할 필요는 없어요. 하지만 왜 마지막 타입을 반환 타입으로 볼 수 있는지 이해하고 있으면 좋습니다.
// match :: Regex -> (String -> [String])
// onHoliday :: String -> [String]
const onHoliday = match(/holiday/gi);
각각의 인자들이 타입 가장 앞의 하나를 차지합니다. onHoliday
는 Regex
를 받은 match
입니다.
// replace :: Regex -> (String -> (String -> String))
const replace = curry((reg, sub, s) => s.replace(reg, sub));
replace
의 모든 괄호처럼 추가적으로 명시하면 약간 지저분하고 불필요하게 보이기 때문에 그냥 생략하겠습니다. 모든 인자들을 한번에 주기로 생각한다면 replace
를 단순히 Regex
와 두개의 String
을 받아서 String
을 반환하는 함수로 볼 수 있답니다.
예제를 몇가지 더 볼까요.
// id :: a -> a
const id = (x) => x;
// map :: (a -> b) -> [a] -> [b]
const map = curry((f, xs) => xs.map(f));
id
함수는 a
타입을 받아서 같은 타입 a
의 어떤 것을 반환합니다. 우리는 코드 안에서 처럼 타입을 나타내는 변수를 사용할 수 있어요. a
나 b
같은 변수명은 관습이지만 당신이 원하는 다른 임의의 이름으로 바꿀 수 있어요. 만약 같은 변수라면 같은 타입입니다. 이것은 중요한 규칙이기 때문에 다시 한번 살펴보겠습니다. a -> b
는 어떤 타입 a
를 어떤 타입 b
로 보내지만 a -> a
는 서로 같은 타입임을 뜻합니다. 예를들어 id
는 String -> String
이나 Number -> Number
가 될 수 있지만 String -> Bool
은 될 수 없어요.
map
도 마찬가지로 타입 변수를 사용하지만 이번에는 a
와 같은 타입일수도, 다른 타입일 수도 있는 b
를 도입합니다. map
을 어떤 타입 a
를 받아서 같거나 다를 수 있는 타입 b
를 반환하는 함수를 받은 다음에 a
의 배열을 받아서 b
의 배열을 반환하는 함수로 볼 수 있어요.
이런 타입 시그니처의 풍부한 표현력의 아름다움에 압도당했기를 바랍니다. 함수가 무엇을 하는지 정확히 나태내 있어요. 이것은 a
에서 b
로 가는 함수와 a
의 배열을 받아서 b
의 배열을 주지요. 유일하게 합리적인 작동 방식은 이 함수를 a
에 각각 적용하는 거지요. 나머지는 어불성설일 거에요.
타입과 그 구현에 대해 생각할 수 있게 되면 함수형 세상 깊이 갈 수 있게 될거에요. 논문, 블로그, 문서 등을 좀 더 쉽게 이해하게 될 뿐만 아니라 타입 그 자체만으로 기능에 대해 알 수 있을 것입니다. 수월하게 읽으려면 연습을 해야겠지만 포기하지 않는다면 복잡한 매뉴얼을 읽지 않고도 많은 정보를 알 수 있을 거에요.
다음 예제를 스스로 해석할 수 있는지 한 번 연습해 보세요.
// 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
가 가장 표현력이 있을 거에요. 약간 어려우니까 이해하기 어렵더라도 당신이 멍청하다고 생각하지 마세요. 궁금하신 분들을 위해 제가 설명해 보겠지만 당신 스스로 해보는 것이 더 도움이 될겁니다.
흠.. 해 볼 수 있는 곳까지 해볼까요. 타입 시그니처를 보면 첫번째 인자는 b
와 a
를 받는 함수입니다. 이 a
와 b
는 어디서 가져올까요? 명세의 다음 인자는 b
와 a
의 배열이네요. b
와 각각의 a
가 입력된다고 생각해 볼 수 있을 거에요. 그리고 함수의 반환값이 b
니까 함수로 입력된 마지막 값이 결과값이라고 생각할 수 있을 거에요. reduce
가 무엇을 하는지 알게되면 위의 설명이 정확하다는 것을 알 수 있을 거에요.
가능성을 높이기
타입 변수가 도입되면 parametricity라고 불리는 특이한 속성이 등장합니다. 이 속성은 함수가 모든 타입에 걸쳐 일관된 방식으로 작동한다고 말합니다. 한번 알아볼까요.
// head :: [a] -> a
head
를 보면 [a]
를 받아서 a
를 반환합니다. 구체적 타입인 array
외의 다른 정보가 없어서 함수가 배열에 적용된다는 것 말고 다른 말은 못합니다. a
에 대해 아무것도 모르는데 a
를 가지고 무엇을 할 수 있겠어요? 다른 말로는 a
가 특정한 타입이 될 수 없고 따라서 어떤 타입도 될 수 있습니다. 그리고 이것은 함수가 생각할 수 있는 모든 타입에 대해 일관적으로 작동한다는 것을 말합니다. 이것이 parametricity가 말하는 전부입니다. 구현을 추측해볼 때 합리적인 가정은 배열의 첫번째 또는 마지막 또는 임의의 원소를 반환한다는 것 밖에 없습니다. 그리고 head
라는 이름이 귀뜸해주네요.
다른 것을 한번 봅시다.
// reverse :: [a] -> [a]
타입 시그니처만으로 reverse
가 무엇을 할 수 있을까요? 다시 한번 특정한 a
에 대해서만 적용되지는 않습니다. 또 a
를 다른 타입으로 바꿀 꾸거나 새로운 타입 b
를 도입할 수도 없습니다. 정렬할 수 있을까요? 흠.. 가능한 모든 타입에 대해 어떻게 정렬하는지 충분한 알 수 없기 때문에 하지 못합니다. 재배열을 할 수 있을까요? 할 수 있을 것 같군요. 하지만 마찬가지로 예상 가능하게 작동해야합니다. 중요한 점은 함수의 행동이 polymorphic type에 의해 많이 제약된다는 것입니다.
이렇게 가능성을 줄이는 것은 Hoogle 같이 타입 시그니처로 함수를 검색하는 검색 엔진이 가능하게 합니다. 실제로 타입이 내포하는 정보는 매우 강력합니다.
공짜로 정리 얻기
이런 종류의 사고 방식은 가능한 기능을 추측하는 것 이외에도 공짜 정리를 줄 수 있습니다. Wadler의 관련 논문에 나오는 몇가지 예제를 볼까요.
// 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
도 마찬가지입니다. f
와 p
를 합성해서 필터한 후 map
을 통해서 f
를 적용한 것(filter
는 원소를 바꾸지 않는다는 것을 명심하세요. 타입 시그니처가 a
가 변하지 않는다고 말합니다)과 f
를 매핑한 후 p
를 이용해 필터를 한 것은 언제나 같다고 말하고 있습니다.
두개의 예시일 뿐이지만 이런 사고 방식을 모든 polymorphic type에도 적용할 수 있고 언제나 올바른 답을 줄 것입니다. 자바스크립트에는 다시 쓰기 규칙을 선언하게 해 주는 몇가지 도구들이 있어요. compose
함수를 통해 직접 할 수도 있어요. 결과는 얻기 쉽지만 가능성은 끝이 없어요.
제약
마지막으로 말할 것은 타입을 어떤 인터페이스에 제한할 수 있다는 것입니다.
// sort :: Ord a => [a] -> [a]
뚱뚱한 화살표 왼쪽에 있는 것은 a
가 반드시 Ord
라고 말하고 있습니다. 또 다른 말로 a
는 반드시 Ord
인터페이스를 구현해야합니다. Ord
는 무엇이고 어디서 왔을까요? 타입이 있는 언어에서 Ord
는 순서를 매길수 있는 인터페이스를 지칭합니다. 이것은 우리에게 a
와 sort
함수가 무엇을 하는지 뿐 만 아니라 도메인을 제약하기도 합니다. 우리는 이런 인터페이스 선언을 타입 제약(type constraints)라고 부릅니다.
// assertEqual :: (Eq a, Show a) => a -> a -> Assertion
여기 Eq
와 Show
제약이 있습니다. 이는 a
의 동일성을 검사할 수 있고 만약 둘이 다르면 그 차이를 출력할수 있음을 보장합니다.
우리는 이후의 장에서 다른 제약들도 보게 될 것이고 좀 더 형태를 갖추게 될 거에요.
요약
힌들리-밀너 타입 시그니처는 함수형 세상에서 일반적으로 쓰입니다. 읽고 쓰기 쉽지만 타입 시그니처 만으로 프로그램을 이해하는 기술을 통달하기에는 시간이 걸립니다. 우리는 이제 모든 줄마다 타입 시그니처를 적을거에요.