해당 게시글은 mostly-adequate-guide 의 시리즈들을 번역했습니다.
해당 게시글의 저작권은 mostly-adequate-guide 의 저작권인 Attribution-ShareAlike 4.0 International 을 준수합니다.
🤖 AI가 요약한 글이에요!
이 장에서는 함수형 프로그래밍에서 중요한 역할을 하는 타입 시그니처(type signature) 와 힌들리-밀너(Hindley-Milner) 타입 시스템에 대해 알아봅니다.
타입 시그니처는 함수의 입력과 출력을 명확하게 표현하여 코드의 동작을 이해하고 문서화하는 데 도움을 줍니다.
힌들리-밀너 시스템은 타입 추론을 지원하며, 타입 변수(a
,b
등)를 사용하여 일반적이고 추상적인 함수를 표현할 수 있게 합니다.
또한, 타입 시그니처로부터 함수의 동작에 대한 자유 정리(free theorems) 를 유도할 수 있으며, 타입 제약(type constraints) 을 통해 특정 인터페이스를 만족하는 타입만 사용하도록 제한할 수 있음을 설명합니다.
Chapter 07: 힌들리-밀너와 나
당신의 타입은 무엇인가요?
함수형 프로그래밍 세계에 처음 발을 들였다면, 머지않아 타입 시그니처의 깊은 물에 빠져 있는 자신을 발견하게 될 것입니다. 타입은 다양한 배경을 가진 사람들이 간결하고 효과적으로 소통할 수 있게 해주는 메타 언어입니다. 대부분의 경우, 타입은 "힌들리-밀너(Hindley-Milner)"라는 시스템으로 작성되며, 이 장에서 함께 살펴볼 것입니다.
순수 함수를 다룰 때, 타입 시그니처는 영어로는 도저히 따라잡을 수 없는 표현력을 가집니다. 이 시그니처들은 함수의 내밀한 비밀을 당신의 귀에 속삭여줍니다. 단 한 줄의 간결한 표현으로 함수의 동작과 의도를 드러냅니다. 우리는 타입 시그니처로부터 "자유 정리(free theorems)"를 도출할 수 있습니다. 타입은 추론될 수 있으므로 명시적인 타입 어노테이션이 필요 없습니다. 타입은 정밀하게 조정될 수도 있고, 일반적이고 추상적으로 남겨둘 수도 있습니다. 타입은 컴파일 타임 검사에 유용할 뿐만 아니라, 가능한 최고의 문서화 수단임이 밝혀졌습니다. 따라서 타입 시그니처는 함수형 프로그래밍에서 중요한 역할을 합니다 - 당신이 처음 예상했던 것보다 훨씬 더 중요합니다.
자바스크립트는 동적 언어이지만, 그렇다고 해서 우리가 타입을 완전히 피하는 것은 아닙니다. 우리는 여전히 문자열, 숫자, 불리언 등을 다루고 있습니다. 단지 언어 수준의 통합이 없기 때문에 이 정보를 머릿속에 담아두는 것뿐입니다. 걱정 마세요, 우리는 시그니처를 문서화 목적으로 사용하므로, 주석을 사용하여 우리의 목적을 달성할 수 있습니다.
자바스크립트에는 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
을 받아 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
를 기대하고 b
를 생성하는 함수입니다. 이 a
와 b
를 어디서 얻을까요? 음, 시그니처의 다음 인수들은 b
와 a
의 배열이므로, 그 b
와 각 a
들이 입력될 것이라고 가정할 수밖에 없습니다. 또한 함수의 결과가 b
임을 알 수 있으므로, 전달된 함수의 마지막 호출이 우리의 출력 값이 될 것이라고 생각합니다. reduce
가 무엇을 하는지 알면, 위의 조사가 정확하다고 말할 수 있습니다.
가능성 좁히기
타입 변수가 도입되면, 파라메트릭성(parametricity) 이라는 흥미로운 속성이 나타납니다. 이 속성은 함수가 모든 타입에 대해 균일한 방식으로 작동한다는 것을 명시합니다. 조사해 봅시다:
// head :: [a] -> a
head
를 보면, [a]
를 a
로 변환합니다. 구체적인 타입인 배열 외에는 다른 정보가 없으므로, 그 기능은 배열 자체에만 작동하도록 제한됩니다. a
에 대해 아무것도 모른다면 도대체 무엇을 할 수 있을까요? 즉, a
는 특정 타입이 될 수 없다고 말하며, 이는 어떤 타입이든 될 수 있음을 의미합니다. 이는 모든 상상 가능한 타입에 대해 균일하게 작동해야 하는 함수를 남깁니다. 이것이 바로 파라메트릭성의 핵심입니다. 구현을 추측해 보면, 유일하게 합리적인 가정은 배열에서 첫 번째, 마지막 또는 임의의 요소를 가져오는 것입니다. head
라는 이름이 힌트를 줄 것입니다.
다른 예시입니다:
// reverse :: [a] -> [a]
타입 시그니처만 보고 reverse
가 무엇을 할 수 있을지 추측해 보세요. 다시 말하지만, a
에 대해 특정 작업을 수행할 수 없습니다. a
를 다른 타입으로 변경할 수 없습니다. 그렇지 않으면 b
를 도입해야 할 것입니다. 정렬할 수 있을까요? 음, 아니요, 모든 가능한 타입을 정렬할 만큼 충분한 정보가 없을 것입니다. 재배열할 수 있을까요? 네, 그렇게 할 수 있다고 생각하지만, 정확히 동일하고 예측 가능한 방식으로 해야 합니다. 또 다른 가능성은 요소를 제거하거나 복제하기로 결정할 수 있다는 것입니다. 어쨌든 요점은, 가능한 동작이 다형성 타입에 의해 크게 좁혀진다는 것입니다.
이러한 가능성 좁히기는 Hoogle과 같은 타입 시그니처 검색 엔진을 사용하여 원하는 함수를 찾는 것을 가능하게 합니다. 시그니처에 꽉 채워진 정보는 실로 매우 강력합니다.
정리처럼 자유롭게
구현 가능성을 추론하는 것 외에도, 이러한 종류의 추론은 우리에게 자유 정리(free theorems) 를 제공합니다. 다음은 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
술어로 결과를 필터링하는 것과 동일하다는 것을 말합니다.
이것들은 단지 두 가지 예일 뿐이지만, 이 추론을 어떤 다형성 타입 시그니처에든 적용할 수 있으며 항상 성립합니다. 자바스크립트에는 재작성 규칙을 선언하는 몇 가지 도구가 있습니다. compose
함수 자체를 통해 이를 수행할 수도 있습니다. 과일은 낮게 매달려 있고 가능성은 무궁무진합니다.
제약 조건
마지막으로 주목할 점은 타입을 인터페이스로 제약할 수 있다는 것입니다.
// sort :: Ord a => [a] -> [a]
여기 뚱뚱한 화살표(=>
) 왼쪽에 보이는 것은 사실의 진술입니다: a
는 Ord
여야 합니다. 즉, a
는 Ord
인터페이스를 구현해야 합니다. Ord
는 무엇이고 어디서 왔을까요? 타입이 있는 언어에서는 값을 정렬할 수 있음을 나타내는 정의된 인터페이스일 것입니다. 이것은 a
와 sort
함수가 무엇을 하는지에 대해 더 많이 알려줄 뿐만 아니라 도메인을 제한합니다. 이러한 인터페이스 선언을 타입 제약(type constraints) 이라고 합니다.
// assertEqual :: (Eq a, Show a) => a -> a -> Assertion
여기에는 Eq
와 Show
라는 두 가지 제약 조건이 있습니다. 이들은 a
들의 동등성을 확인할 수 있고, 같지 않다면 차이를 출력할 수 있도록 보장합니다.
제약 조건의 더 많은 예를 보게 될 것이며, 이 아이디어는 다음 장들에서 더 구체화될 것입니다.
요약
힌들리-밀너 타입 시그니처는 함수형 세계에서 어디에나 존재합니다. 읽고 쓰기는 간단하지만, 시그니처만으로 프로그램을 이해하는 기술을 마스터하는 데는 시간이 걸립니다. 지금부터 코드의 각 줄에 타입 시그니처를 추가할 것입니다.