Haskell의 매개 다형성과 자유 정리를 활용해 더 견고하고 예측 가능한 코드를 설계하는 방법을 살펴봅니다.
_Five-Point Haskell_에 다시 오신 것을 환영합니다! 이 글은 제가 Haskell에서 견고하고, 유지보수하기 쉽고, 정확하며, 명확하고, 효과적인 코드를 작성하는 원칙을 정립하고, 그동안 마주친 흔한 나쁜 관행(혹은 이단)을 불식하려는 시도입니다.
지난 글에서는 전적 타락에 관해 이야기했습니다. 이는 머릿속으로 추적하는 모든 제약이나 조건을 결국 재앙으로 이어질 것으로 여기고, 결함 있는 우리의 정신적 맥락 창에 의존하는 일을 거부하자는 내용입니다.
하지만 여기서 멈추면 그림이 불완전합니다. 첫째, 타입은 나쁜 동작을 막기 위한 것만이 아닙니다. 좋은 코드를 설계하기 위한 것이기도 합니다. 둘째, 신중한 구조를 고르고 잘못된 상태를 표현할 수 없게 만드는 데에는 한계가 있습니다. 이 역시 인간의 결함을 지닌 인간의 도구입니다.
제가 보기에는 다음 원칙이란 타입 시스템의 한 측면에 관한 것입니다. 이 측면은 거의 다뤄지지 않지만, 제가 작성하는 거의 모든 것에서 의지하는 설계 교리입니다. 이는 타입을 잘 구조화하지 못할 때조차 수학 _그 자체_의 굽히지 않는 성질을 활용하여 우리의 운명을 돌보게 하는 일입니다.
그러므로 Haskell을 작성할 때 무조건적 선택을 기억하세요.
무조건적 선택: 매개 다형성을 통해
forall이 구체화와 구현을 선택하거나 유기할 수 있는 힘입니다. 이 성질은 타입의 조건부 임시방편적 측면에 기반하지 않으며, 보편 양화에 의해 진정으로 무조건적이고 예정되어 있습니다.모든 일에서 매개 다형성에 통제권을 넘기세요. Haskell의 가장 위대한 뜻밖의 강점 중 하나인 타입 매개변수에서 비롯되는 “자유 정리”의 “자유”를 받아들이세요.
Haskell을 배우는 일은 놀라움으로 가득한 여정일 수 있지만, 이것은 제게 가장 큰 충격을 준 것 중 하나였습니다.
어떤 타입의 값이든 받아서 같은 타입의 무언가를 돌려주는 Java의 다형 함수에 대해 생각해 봅시다.
static <T> T foo(T x)
그 함수는 무엇을 할 수 있을까요?
물론 IO를 하거나 예외를 던지거나, 입력을 변경하거나, 끝나지 않을 수도 있습니다. 하지만 여기의 모든 다른 예시와 마찬가지로, 변경 없이 순수하게 값을 돌려주기만 한다고 가정합시다. 무엇을 할 수 있을까요?
답은 거의 무엇이든입니다. 받은 값을 그대로 돌려줄 수 있지만, 그것이 Integer라면 부호를 반전할 수도 있습니다.
static <T> T foo(T x) {
if (x instanceof Integer i) {
return (T) Integer.valueOf(-i);
}
return x;
}
TypeScript나 매개변수성이 없는 거의 모든 다른 정적 타입 언어도 마찬가지입니다.
function foo<T>(x: T): T
여기에서도 조건부 선택이 있습니다.
function foo<T>(x: T): T {
if (typeof x === "number") {
return (-x) as any as T;
}
return x;
}
하지만 Haskell은 어떨까요?
foo :: a -> a
(이 글의 나머지에서는 비종료1와 다른 탈출구2는 무시합시다.)
Haskell에는 타입 소거가 있고 실행 시간 리플렉션이 없으므로, 유일하게 가능한 구현은 단순히 다음뿐입니다.
foo :: a -> a
foo x = x
따라서 우리 코드 안에 어떻게든 하나의 불변성이 나타났습니다. forall a. a -> a 타입의 함수는 값을 반드시 바꾸지 않아야 합니다!
하지만 잠깐만요… 누가 그렇게 말하나요? 어떤 const 컴파일러 애너테이션을 넣었나요? 값을 변경할 수 없다는 애너테이션이나 사전·사후 조건을 추가했나요? 주어진 값의 예측 가능한 어떤 성질에 의존하고 있나요?
아니요. 이 동작은 실제로 본질적으로 고정되어 있습니다! 우리는 이 정리를 공짜로 얻었습니다. 어떤 작업도, 미리 예상한 충실성도 필요 없습니다. 가능한 모든 경우를 알기 전에 함수조차 작성할 필요가 없었습니다.
이것이 forall의 힘입니다. 위의 foo :: a -> a는 다음의 “문법 설탕”으로 볼 수 있습니다.
foo :: forall a. a -> a
forall a를 추가하지 않으면 암묵적으로 추가됩니다. PureScript나 Dhall 같은 일부 언어에서는 매번 forall을 명시해야 합니다. “forall”은 C++의 template <typename T>처럼, 어떤 타입 변수가 양화되는지를 선언하는 것이라고 생각할 수 있습니다.
어쨌든, 또 다른 타입 시그니처를 생각해 봅시다.
static <T> String foo(T x)
IO를 금지한다고 해도(시간이나 시스템 상태를 가져오는 것처럼), Java와 대부분의 다른 언어에서 이것은 다시 말 그대로 무엇이든 될 수 있습니다. toString으로 객체를 직렬화하거나 getClass로 클래스를 가져올 수도 있습니다.
static <T> String foo(T x) { return "hello"; }
static <T> String foo(T x) { return x.toString(); }
static <T> String foo(T x) { return x.getClass().getSimpleName(); }
Haskell에서는요? forall a. a -> String은 입력을 사용할 수 없습니다! 반드시 상수 문자열이어야 합니다.
foo :: a -> String
foo _ = "hello"
-- 또는
foo _ = "goodbye"
-- 또는
foo _ = "i always ignore my input"
실제로 String과 forall a. a -> String 사이에는 동형이 있습니다(재미있는 연습: 작성해 보세요!).
타입클래스를 사용해 능력을 “선택적으로” 가져올 수 있습니다.
foo :: Show a => a -> String
foo _ = "hello"
-- 또는
foo x = show x
-- 또는
foo x = "reversed: " <> reverse (show x)
하지만 여전히 강제할 수 있는 성질이 있습니다. 결과 문자열은 입력의 Show 인스턴스가 드러내는 범위에서만 입력에 의존할 수 있습니다. Show 인스턴스에 없는 모든 성질은 접근 금지입니다.
다르게는 foo :: Show a => a -> String을 다음처럼 생각할 수 있습니다.
foo :: (a -> String) -> a -> String
foo showVal x = -- ...
그러면 a를 검사할 수 있는 유일한 방법은 주어진 단 하나의 검사 렌즈 a -> String을 통하는 것뿐임을 알 수 있습니다. getClass()도, 뒷문도 없습니다.
Haskell 사용자들이 일상적으로 자주 하는 게임 중 하나는, 서로 다른 타입 시그니처에서 forall이 보장하는 성질을 “추측하는 것”입니다. 해봅시다!
예를 들면 다음과 같습니다.
mystery :: a -> b -> a
mystery는 반드시 \x _ -> x여야 합니다. 다른 선택지는 없습니다.
이것은 어떨까요?
turmeric :: ((a, b) -> c) -> a -> b -> c
생각해 보면 유일한 선택지는 다음입니다.
turmeric :: ((a, b) -> c) -> a -> b -> c
turmeric f x y = f (x, y)
Haskell을 정리 증명기로 사용하면 이 길을 꽤 멀리 갈 수 있습니다. 타입 시그니처는 명제를 나타내고 구현은 그 주장에 대한 증명을 나타냅니다. 하지만 실용적인 코드 대부분은 정리 증명이 아니므로, 지금은 그 길로 가지 않겠습니다.
다만 조금 더 구조적인 것을 봅시다. 이것은 어떨까요?
theThing :: [a] -> [a]
이것은 무엇을 할 수 있을까요?
결과 리스트의 모든 항목은 반드시 입력 리스트에서 와야 한다는 것을 압니다. “부분집합”이어야 합니다. 단, 순서나 중복도는 바뀔 수 있습니다. 더 중요하게는, 어떤 a의 성질에도 의존할 수 없습니다. 또한 입력이 비어 있으면 출력도 반드시 비어 있어야 합니다.
여기서 모든 구현이 반드시 가져야 하는 성질을 살펴보는 자유 정리를 도출할 수 있습니다. 즉, 리스트에 함수를 _매핑_하고 theThing을 호출하는 것은 theThing을 호출한 뒤 매핑하는 것과 동등해야 합니다.
theThing . map f
== map f . theThing
왜인지 알겠나요? reverse, take 3 등을 비롯한 가능한 모든 구현을 생각해 보고 왜 이것이 반드시 성립하는지 살펴보세요. 그러나 이것은 가령 sort :: [Int] -> [Int]에는 성립하지 않습니다. sort는 항목의 실제 성질에 의존하므로 map f가 sort가 의존하는 성질을 바꿀 수 있기 때문입니다!
ghci> sort . map abs $ [5,-1,3,-7]
[1,3,5,7]
ghci> map abs . sort $ [5,-1,3,-7]
[7,1,3,5]
take 3처럼 실제로 다형 타입을 가진 함수와 비교해 봅시다.
ghci> take 3 . map abs $ [5,-1,3,-7]
[5,1,3]
ghci> map abs . take 3 $ [5,-1,3,-7]
[5,1,3]
이것은 어떨까요?
doIt :: [a] -> Maybe a
이것이 할 수 없는 일을 생각해 보세요. 분명 하나의 항목을 선택하지만 다음과 같습니다.
Nothing을 돌려줘야 합니다.그리고 다시 같은 자유 정리 doIt . map f == fmap f . doIt을 얻습니다. doIt을 어떻게 구현하든, map과 fmap에 대해 교환 가능함이 보장됩니다!
-- 자유 정리 없음: `minimumMay :: [Int] -> Maybe Int`
ghci> minimumMay . map abs $ [5,-1,3,-7]
Just 1
ghci> fmap abs . minimumMay $ [5,-1,3,-7]
Just 7
-- 자유 정리: `listToMaybe :: [a] -> Maybe a`
ghci> listToMaybe . map abs $ [5,-1,3,-7]
Just 5
ghci> fmap abs . listToMaybe $ [5,-1,3,-7]
Just 5
-- 자유 정리: `lastMay :: [a] -> Maybe a`
ghci> lastMay . map abs $ [5,-1,3,-7]
Just 7
ghci> fmap abs . lastMay $ [5,-1,3,-7]
Just 7
또 하나 해봅시다.
collapse :: [a] -> Int
이것이 도대체 무엇을 할 수 있을까요? 값 자체의 어떤 성질도 사용할 수 없으므로 sum 같은 것은 제외할 수 있습니다. 이것이 반환할 수 있는 유일한 것은 상수 함수와 리스트의 _내용_이 아니라 _길이_에 의존하는 함수입니다. 또 다른 자유 정리 collapse . map f == collapse도 있습니다. 실제 값 중 어느 것도 중요하지 않으므로, 함수를 매핑해도 출력은 바뀌지 않아야 합니다.
-- 자유 정리 없음: `sum :: [Int] -> Int`
ghci> sum . map abs $ [5,-1,3,-7]
16
ghci> sum [5,-1,3,-7]
0
-- 자유 정리: `length :: [a] -> Int`
ghci> length . map abs $ [5,-1,3,-7]
4
ghci> length [5,-1,3,-7]
4
-- 자유 정리: `const 10 :: [a] -> Int`
ghci> const 10 . map abs $ [5,-1,3,-7]
10
ghci> const 10 [5,-1,3,-7]
10
반대 방향의 것은 어떨까요?
duper :: a -> [a]
이 타입 시그니처로부터 최종 리스트가 _같은 항목_을 담아야 한다고 결론낼 수 있습니다! 가능한 유일한 거주자는 어떤 n에 대한 replicate n 또는 repeat입니다.3
다시 자유 정리가 있습니다. map f . duper == duper . f
-- 자유 정리 없음: `take 3 . iterate (+1) :: Int -> [Int]`
ghci> take 3 . iterate (+1) . negate $ 4
[-4,-3,-2]
ghci> map negate . take 3 . iterate (+1) $ 4
[-4,-5,-6]
-- 자유 정리: `replicate 3 :: Int -> [Int]`
ghci> replicate 3 . negate $ 4
[-4,-4,-4]
ghci> map negate . replicate 3 $ 4
[-4,-4,-4]
연속성은 어떨까요?
consumeInt :: forall r. (Int -> r) -> r
이것은 이국적으로 보일 수 있지만, 반드시 무엇을 해야 하는지 생각해 보세요. Int -> r 함수를 받아 r을 만들어야 합니다. 가능한 모든 r에 대해 r을 만드는 유일한 방법은 주어진 함수를 미리 정한 어떤 고정 Int에 적용하는 것입니다. 이 타입은 사실 Int 자체와 동형입니다. 유일한 거주자는 고정된 어떤 x에 대한 \f -> f x입니다.4
consumeInt :: forall r. (Int -> r) -> r
consumeInt f = f 42 -- 고정된 `Int`여야 하며, 동적으로 바뀔 수 없음
consumeString :: forall r. (String -> r) -> r
consumeString f = f "hello" -- 고정된 `String`여야 하며, 동적으로 바뀔 수 없음
타입 변수 하나보다 더 좋은 것은 무엇일까요? 둘은 어떨까요?
mapMaybe :: (a -> Maybe b) -> [a] -> [b]
이것은 모든 a와 b에 대해 작동해야 하므로, 결과 리스트에서 찾을 수 있는 유일한 가능한 b는 a -> Maybe b 함수에서 얻는 b라는 것을 압니다. 따라서 구현이 a -> Maybe b가 생성할 수 있는 특정 것들 외에 임의의 b를 만들어내지 않는다고 확신할 수 있습니다. 예를 들어 짝수 정수만 반환하는 함수를 전달하면, 결과 리스트에는 오직 짝수 정수만 담기게 됩니다!
고차 종류 타입 변수를 사용한 마지막 예시입니다.
traverseIO :: (a -> IO b) -> [a] -> IO [b]
-- 대조
traverse :: Applicative f => (a -> f b) -> [a] -> f [b]
두 번째 것은 첫 번째에 비해 어떤 불변성을 추가할까요? 호출할 때 항상 IO만 사용할 계획이더라도, 두 번째는 새로운 불변성을 줍니다. a -> f b에 주어진 것 외의 “엉뚱한” IO 동작은 없을 것입니다. 첫 번째에서는 결과 IO 동작에 putStrLn "hello"나 launchMissiles가 포함될지 결코 알 수 없습니다. 어떤 함수도 뒤에서 몰래 IO를 수행하는 것은 절대 원치 않을 겁니다!
실용적으로 이는 최소 권한 원칙, 즉 작업에 필요한 최소한의 권한을 지닌 도구를 사용해야 한다는 생각과 비슷해집니다. 비즈니스 논리에 중요한, 항목 리스트를 섞는 함수를 작성한다고 해봅시다. 매개변수성을 점점 더 추가함으로써 정확히 어떤 비즈니스 논리가 수행되는지 인코딩할 수 있습니다.
[Int] -> [Int]라면, 함수가 할 수 있는 일에는 사실상 제약이 없다는 것을 압니다. 값의 기계 표현을 볼 수도 있습니다.Num a => [a] -> [a]라면, 리스트의 항목을 수치적으로 변환하거나 새 항목을 만들어낼 수도 있음을 압니다.Ord a => [a] -> [a]라면, 비즈니스 논리가 리스트 항목 사이의 순서를 볼 수는 있지만 원래 리스트에 없던 항목을 반환할 수는 없음을 압니다.[a] -> [a]라면, 논리는 리스트 항목의 순열과 중복도에만 영향을 줄 수 있음을 압니다.Foldable t => t a -> [a]라면, 결과가 입력에서 왔다는 것은 알지만 원소를 재정렬, 복제 또는 제거할 수 있습니다. 또한 결과 순열은 입력 항목을 받기 전에 “미리 결정”됩니다.Functor f => f Int -> f Int라면, []로 호출할 때 결과의 길이와 순서가 보존되고 Int의 모든 매핑도 순수하게 수행됨을 압니다.Monad m => m a -> m a라면, []로 호출할 때 결과 길이는 항상 입력 길이의 정수 거듭제곱이고 각 입력 원소는 똑같은 횟수로 복제됨을 압니다. 따라서 [1,2,3]을 주면 결과 길이는 3^k 꼴이어야 하며, 어떤 순서로든 1, 2, 3을 각각 3^(k-1)개씩 포함해야 합니다.구체 타입에서 매개 타입으로 천천히 전환함으로써, 함수가 할 수 있는 일에 대한 통제권을 내려놓고 점점 더 강한 보장을 만들어냅니다. 다른 언어나 정제 타입에서는 “최종 값은 모두 원래 리스트에서 와야 한다” 같은 사후 조건을 명시적으로 선언해야 할 수 있습니다. 매개 다형성에서는 구현이 무엇이든 이는 이미 보장되고 선택되어 있습니다.
이 게임을 반대 방향으로 할 수도 있습니다. “리스트 길이가 보존되어야 한다”는 사후 조건을 작성하는 대신, 그것을 얻기 위해 필요한 권한 수준을 알아낼 수 있습니다. 예를 들어 “결과는 같은 수의 Int 항목을 가져야 하지만(리스트를 준다면), 순서는 항목을 실제로 검사한 결과에 의존할 수 있다”를 보장하려 한다면, 어떤 타입을 주어야 할까요?
그리고 (스포일러입니다) Traversable f => f Int -> f Int는 왜 작동할까요? 또 Int의 트리를 전달했을 때 실제 Int 값에 따라 잎들 사이에서 자유롭게 섞는 것을 허용하면서도 트리의 _형태_까지 보존하는 이유는 무엇일까요?
곁가지로, 이 자유 정리를 어디서 얻었는지 궁금했나요? 위 예시에서는 _자연성_에서 나옵니다. 기본적으로 모든 forall a. (Functor f, Functor g) => f a -> g a는 범주론의 _자연 변환_에 대응하므로, 모든 fmap과 교환 가능해야 합니다.
기본적으로 Functor F와 Functor G가 있는 자연 변환 h :: forall a. F a -> G a가 있다면 다음을 얻습니다.
h . fmap f
== fmap f . h
위의 forall a. [a] -> [a], forall a. [a] -> Maybe a 등의 예시는 모두 여기서 나옵니다. 다만 forall a. a -> [a]가 사실 forall a. Identity a -> [a]라는 점을 보려면 신중히 생각해야 할 수 있습니다. 그리고 forall a. [a] -> Int에 자연성을 부여하는 Functor는 무엇인지 생각할 수 있나요?5
이 자유 정리가 존재한다는 사실보다 더 놀라운 것은, 그 뿌리가 “범주론”처럼 난해하고 비전적인 수학 분야와 본질적으로 연결되어 있다는 사실일지도 모릅니다!
다음과 같은 데이터 타입이 있다고 합시다.
data User = User
{ userId :: Int
, userName :: String
, userAge :: Int
}
그리고 다음처럼 사용자를 처리하는 함수가 있습니다.
processUser :: User -> IO User
userId가 변경되지 않도록 어떻게 강제할 수 있을까요?
C에 있다면 const 필드를 둘 수 있을지도 모릅니다.
struct User {
const int userId;
char userName[20];
int userAge;
};
하지만 이는 User 구조체의 모든 사용에 적용됩니다. 단일 함수에서만 이 성질을 보존하고 싶다면 어떨까요? 단일 인수에 대해 구조체 수준의 const를 선언할 수는 없습니다!
대신 userId의 타입을 매개변수화하여 이를 강제할 수 있습니다.
data User uid = User
{ userId :: uid
, userName :: String
, userAge :: Int
}
-- | ID를 바꾸지 않음이 보장됨
processUser :: User uid -> IO (User uid)
이 제약은 타입 구조를 신중하게 설계했기 때문에 강제되는 걸까요? 컴파일러 애너테이션이나 정제 타입, 정적 분석을 추가했기에 제약되는 걸까요? 꼭 그렇지는 않습니다! 정말 공짜로 얻어진 것입니다.
또는 체크리스트 항목을 생각해 봅시다.
data Checklist = Checklist
{ updated :: UTCTime
, items :: [(Status, String)]
}
항목을 추가하거나 제거하지 않고 처리하는 함수를 작성하고 싶다면 어떨까요? 각 항목을 제자리에서만 처리하는 것입니다.
-- | 불변성: 항목의 순서와 개수를 보존함.
updateItems :: Checklist -> IO Checklist
모든 구현이 items의 길이를 변경하지 않는 구현으로만 _선택_되도록 어떻게 보장할 수 있을까요?
다시 답은 양화를 추가하는 것입니다!
data Checklist t = Checklist
{ updated :: UTCTime
, items :: t (Status, String)
}
-- | 같은 수의 `items`(즉, 항목 "슬롯"의 수)를 보존함이 보장되지만,
-- 새 Status와 String을 얻기 위해 여전히 IO를 수행할 수 있음
updateItems :: Traversable t => Checklist t -> IO (Checklist t)
가능한 구현의 예시는 다음과 같습니다.
updateSingleItem :: (Status, String) -> IO (Status, String)
updateItems :: Traversable t => Checklist t -> IO (Checklist t)
updateItems c0 = do
newItems <- traverse updateSingleItem (items c0)
newUpdated <- getCurrentTime
pure (Checklist newUpdated newItems)
아무리 해도 시작할 때보다 다른 수의 항목을 만드는 구현은 만들 수 없습니다(재배열하거나 교체할 수는 있지만).
여기서 우리는 추상화를 위해서나 “이국적인 체크리스트”(Checklist Maybe)를 사용할 수 있게 하려고 타입 매개변수를 추가하는 것이 아닙니다. 대신 이를 처리하는 함수에서 보편 양화하여, 자동으로 강제되는 이러한 성질을 활용하려고 의도적으로 사용합니다.
이는 고차 종류 데이터 패턴과 많이 교차합니다. 실제로 여러 구조 버전을 갖고 싶은 데이터가 있을 수도 있습니다.
data UserF f = User
{ userName :: f String
, userAge :: f Int
}
type User = UserF Identity
type NullableUser = UserF Maybe
type UserParser = UserF Parser
type UserDocs = UserF (Const Doc)
type UserPrinter = UserF (Op String)
이 경우 다음과 같은 함수는
processUser :: Functor f => UserF f -> UserF f
사용자가 가진 모든 “형태”마다 서로 다르고 고유한 보장을 제공합니다.
UserF Maybe에서는 양화가 각 필드의 널 또는 존재 성질을 보존하도록 합니다.UserF Parser에서는 모든 “파싱” 논리와 유효하게 파싱되는 문자열 집합을 보존하도록 합니다.UserF (Const Doc)에서는 필드별 Doc/문서가 절대 변경되거나 갱신되지 않도록 합니다.예를 들어 다음과 같이 작성할 수 있습니다.
processUser :: Functor f => UserF f -> UserF f
processUser user = User
{ userName = fmap (map toUpper) (userName user)
, userAge = fmap (+ 1) (userAge user)
}
이는 UserF Maybe라면 널 필드를 널로 유지하고, UserF Parser라면 모든 성공적인 파싱을 보존하며, UserF (Const Doc)라면 모든 필드 수준 문서를 변경하지 않는 것이 보장됩니다.
이 모든 성질은 수학적으로, 무조건적으로 강제됩니다. 우리가 사용하는 타입이나 값의 예측 가능한 어떤 성질에도 의존하지 않습니다. 이러한 보장은 불변성이 깨질까 두려워하지 않고 이 함수를 자신 있게 사용할 수 있게 해 줍니다.
타입 변수 사이의 더 복잡한 관계를 표현할 수 있는 의존 타입을 고려하면, 이 게임은 훨씬 강력해집니다. 예를 들어 (이 singletons 튜토리얼처럼) 팬텀 타입이 있는 경우입니다.
data DoorState = Opened | Closed | Locked
data Door (s :: DoorState)
processDoor :: Door s -> IO (Door s)
processDoor는 forall s를 받는다는 사실만으로 문 상태를 반드시 그대로 둬야 합니다! 닫힌 문을 열거나 잠긴 문을 잠금 해제하는 등의 일은 절대 할 수 없습니다.
길이 n 매개변수가 크기인 고정 길이 벡터 같은 것에서 다음이 보존하는 불변성은 무엇이라고 생각하나요?
something :: Vector n a -> Vector n a
결과 길이는 입력 길이와 같아야 한다는 것을 압니다. 또한 forall a를 통해 결과의 모든 항목은 입력에서 와야 한다는 것도 알지만, 원래의 전체 개수가 같게 유지되는 한 발생 횟수를 재배열하거나 바꿀 수 있습니다. 이는 reverse 같은 함수의 좋은 후보일 수 있습니다.
또는 다음을 생각해 봅시다.
somethingElse :: Vector n a -> Vector (n - 1) a
여기서 원래 벡터는 반드시 비어 있지 않아야 함을 압니다! 어떤 n을 주든 타입이 흘러가야 하는 방식상, 이는 n >= 1을 요구합니다.
이제 타입 매개변수와 forall을 사용하는 것이 얼마나 유용한지 알았으니, 코드 내부에서도 메타 수준에서 이 사실을 _활용_할 수 있을까요?
UserF의 모든 항목에 IO 함수를 매핑하고 새 것을 반환하고 싶다고 합시다. 어떤 IO 함수를 사용하든 실제 “결과” 타입은 반드시 바뀌지 않아야 한다는 것을 압니다. 이는 forall a. f a -> h (f a)를 받아야 함을 뜻합니다.
traverseUser
:: Applicative h
=> (forall a. f a -> h (g a))
-> UserF f
-> h (UserF g)
traverseUser f u = User <$> f (userName u) <*> f (userAge u)
여기서도 위의 기법을 사용해 구체적인 IO 대신 모든 Applicative h에 대해 일반화하므로, 최종 동작이 엉뚱한 IO를 몰래 끼워 넣을 수 없음을 알 수 있습니다.
이 성질을 팬텀 타입에서 사용하여 어휘적으로 한정된 자원도 강제할 수 있습니다. IntMap에서 지역 변수를 시뮬레이션한다고 해봅시다.
newtype Var = Var Int
newtype Memory v = Memory { getMemory :: IntMap v }
initVar :: v -> State (Memory v) Var
initVar x = state $ \(Memory mp) ->
case IM.lookupMax mp of
Nothing -> (Var 0, Memory $ IM.insert 0 x mp)
Just (i, _) -> (Var (i + 1), Memory $ IM.insert (i + 1) x mp)
readVar :: Var -> State (Memory v) v
readVar (Var i) = gets ((IM.! i) . getMemory)
writeVar :: Var -> v -> State (Memory v) ()
writeVar (Var i) x = modify (Memory . IM.insert i x . getMemory)
runWithMemory :: State (Memory v) a -> a
runWithMemory = (`evalState` Memory IM.empty)
(그런데 상태가 v에 대해 매개적인 IntMap v인 것으로부터 무엇을 얻을까요? 어떤 보장/불변성을 얻고, 라이브러리 자체가 어떤 종류의 동작을 하지 못하게 하나요? 기본값으로 초기화된 변수를 가질 수 있을까요?)
다음과 같은 연산을 실행할 수 있습니다.
getFib :: Int -> State (Memory Int) Int
getFib n = do
a <- initVar 0
b <- initVar 1
replicateM_ n $ do
newSum <- (+) <$> readVar a <*> readVar b
writeVar a =<< readVar b
writeVar b newSum
readVar b
ghci> runWithMemory (getFib 10)
55
하지만 이제 변수는 실제로 스코프를 갖지 않습니다. 예를 들어 runWithMemory를 자기 안에서 실행할 수 있습니다.
myAction :: State (Memory String) a
myAction = do -- 새 메모리는 비어 있는 상태로 시작함
v <- initVar "hello" -- 메모리는 이제 (0, "hello")
let x = runWithMemory $ do -- 새 메모리는 비어 있는 상태로 시작함
readVar v -- 빈 맵에서 '0'을 조회하므로 실행 시간 오류!
-- ..
-- ..
이제 readVar v는 실패합니다! v가 Var 0이라는 점을 기억하세요. 하지만 0 키는 바깥 스코프에서만 의미가 있습니다. 안쪽 스코프에서 0은 정의되지 않은 다른 IntMap을 가리킵니다.
Var를 반환하는 어리석은 일도 할 수 있습니다.
ghci> runWithMemory (initVar "hello")
Var 0
이제 그 변수는 스코프 바깥에 존재합니다. 바인딩은 사라졌으므로 이름은 더 이상 의미 있는 어떤 것도 가리키지 않습니다.
모든 변수를 그것을 생성한 스코프와 연결하여 이를 막을 수 있습니다. 그러면 runWithMemory가 스코프 팬텀이 최종 출력의 일부가 되지 않도록 요구하게 만들 수 있습니다.
newtype Var s = Var Int
newtype Memory s v = Memory { getMemory :: IntMap v }
initVar :: v -> State (Memory s v) (Var s)
readVar :: Var s -> State (Memory s v) v
writeVar :: Var s -> v -> State (Memory s v) ()
runWithMemory :: (forall s. State (Memory s v) a) -> a
runWithMemory = (`evalState` Memory IM.empty)
여기서 Var s는 같은 스코프 s를 가진 Memory s v에서 와야 합니다. 그 스코프와 연결되며 다른 어느 스코프와도 연결되지 않습니다. 여기의 forall은 주어진 동작이 외부 s와 통합될 수 없음을 보장합니다. 스코프는 runWithMemory가 새로 만듭니다.
즉시 중첩 호출에 변수를 전달하는 일이 막힙니다(첫 번째 변수의 s는 안쪽 스코프의 s와 다릅니다). 하지만 변수 누수도 막습니다. 결과 타입 a는 s와 완전히 _독립적_이어야 하므로 Var s를 반환하는 것은 불법이기 때문입니다. 그렇게 하려면 a가 s에 의존해야 하고, 이는 forall의 스코프를 벗어납니다. (사실 이것이 GHC 표준 라이브러리의 ST 모나드가 작동하는 방식입니다.)
_호출자_가 s에 대한 통제권을 포기하도록 요구함으로써, 라이브러리와 사용자가 제공한 연속성 모두의 어휘적 한정을 보장합니다. 이제 이 안전성은 변수가 어디에서 왔는지 신중히 추적해서 얻어지는 것이 아닙니다. 대신 forall의 보편성과 그것이 강제하는 무조건적 성질을 통해 보장됩니다.
실제로 이 원칙을 인식하고 코드에서 사용하는 모습이 어떤지 봅시다. 환경에 새 Config를 배포할 때 사용할 수 있는 함수가 있다고 상상해 봅시다.
deployConfig :: Config -> IO ()
하지만 배포에는 비용이 조금 듭니다. 따라서 배포를 중복 제거하고 싶습니다. 같은 Config를 두 번 배포하면 아무 동작도 하지 않아야 합니다. IORef에 Config를 보관하여 이를 할 수 있습니다.
-- | 변경되었으면 True를, 이미 배포되었으면 False를 반환
updateConfig :: IORef Config -> Config -> IO Bool
updateConfig cache newConfig = do
oldConfig <- readIORef cache
if oldConfig == newConfig
then pure False
else do
deployConfig newConfig
writeIORef cache newConfig
pure True
이는 작동합니다. 하지만 이 글의 원칙을 배운 뒤라면, 그 타입 시그니처가 조금 의심스럽게 느껴져야 합니다. 우리 함수는 실제로 Config를 전혀 검사하지 않습니다. 논리는 독립적입니다. 캐싱 논리를 일반적으로 끌어내는 데 가치가 있을까요?
cachedUpdate :: Eq a => (a -> IO ()) -> IORef a -> a -> IO Bool
cachedUpdate action cache newVal = do
oldVal <- readIORef cache
if oldVal == newVal
then pure False
else do
action newVal
writeIORef cache newVal
pure True
updateConfig :: IORef Config -> Config -> IO Bool
updateConfig = cachedUpdate deployConfig
cachedUpdate를 재사용할 의도가 전혀 없다고 가정합시다. 그러면 전체 코드 줄 수만 늘렸습니다. 그런데 무엇을 위해서일까요? cachedUpdate는 무엇을 얻어 줄까요?
첫째, Config에 직접 맞춰 작성한 원래의 단형 updateConfig에서는 잘못될 수 있는 일이 아주 많습니다. 구성을 잘못 처리하거나 실수로 수정할 수 있습니다. 특정 필드를 고정된 값으로 설정할 수 있습니다. 직접 전달받지 않은 구성을 배포하게 될 수도 있습니다.
cachedUpdate 구현에서는 배포되는 모든 Config가 updateConfig 호출에서 직접만 온다는 것을 확신합니다. 다른 연산은 불가능합니다.
둘째, cachedUpdate의 타입 시그니처는 cachedUpdate가 의도한 논리가 무엇인지와 정확히 무엇을 지원할 수 있는지 훨씬 더 많이 알려 줍니다. 미래에 새로운 요구 사항이 생긴다고 해봅시다. deployConfig가 실패할 때마다 “기본” 구성을 배포하라는 것입니다.
이처럼 급진적인 일이 타입과 계약을 변경해야 한다는 점을 원해야 합니다. 사실 새로운 요구 사항이 생겼는데 타입을 바꾸지 않고 구현할 수 있다면, 이전에 너무 많은 잠재적으로 잘못된 프로그램의 컴파일을 허용했다는 뜻이므로 극도로 두려워해야 합니다.
그러한 변경(“기본 Config 배포”)을 추가한다면, 이를 지원하려면 어떻게 타입 시그니처가 바뀌어야 하는지 cachedUpdate의 타입 시그니처로 돌아가 살펴보게 되어야 합니다. 그런 심문의 과정은 우리가 실제로 무엇을 하고 싶은지, 데이터 흐름을 어떻게 근본적으로 바꿀지 생각하게 합니다.
“SOLID” 프로그래밍을 따른다면, 이 모든 것은 “의존성 역전”을 떠올리게 할 것입니다.
기본적으로 모든 단형 코드를 의심스럽게 대하세요. 내려놓아야 할 통제권을 더 붙들려 한다는 징후일 수 있습니다.
같은 본능의 또 다른 예시입니다. 사용자 목록에 알림을 방송하는 함수가 있다고 해봅시다.
notifyUsers :: [User] -> Text -> IO ()
이 함수는 사용자에 따라 어떻게 분기할 수 있을까요? 사실 어떤 방식으로든 가능합니다. 각 사용자를 검사하고 관리자와 일반 사용자에게 서로 다른 메시지를 만들거나, 특정 사용자를 조용히 건너뛰거나, 개인 데이터를 기록하거나, 사용자 속성에 따라 동작을 바꿀 수 있습니다.
다음과 비교해 보세요.
notifyAll :: Foldable t => t recipient -> (recipient -> IO ()) -> IO ()
notifyAll은 recipient에 대해 매개적입니다. 알림을 보내는 대상을 검사할 수 없습니다. 수신자의 속성에 따라 수신자를 건너뛰거나 관리자를 일반 사용자와 다르게 대할 수 없습니다. 그런 모든 “정책” 논리는 호출자에 강제됩니다. 방송 논리 자체는 반드시 균일해야 합니다. 원소의 고정된 부분집합에 무차별적으로 send를 호출합니다. 보낼 부분집합을 고를 수는 있지만, 수신자의 성질에 따라 그 부분집합에 무엇을 포함할지는 고를 수 없습니다.
자신의 감옥에 자신을 가두는 방식으로, 삶에서 어떤 통제권을 붙잡으려 하나요?
코드를 더 다형적으로 만들고 정보를 포기하는 일이 추상화뿐 아니라 보편적 성질을 활용하기 위해서도 가치 있다는 사실은 제게 놀라운 것이었습니다. 하지만 Haskell을 작성하기 시작한 이후로, 이는 제가 매일 활용하는 사실입니다. 그러니 다음에 기회가 보이면, 그 매개적인 forall이 여러분을 위해 무엇을 할 수 있을지 생각해 보세요. 우리 우주가 존재하기도 전에 결정된 논리의 성질에서 도래하는 Haskell 예정의 교리를 활용하세요.
전적 타락과 무조건적 선택을 받아들이면 코드와의 관계를 재정의해야 합니다. 하지만 모든 코드가 효과를 격리할 수 있는 멋진 순수 세계에 존재하는 것은 아닙니다. 결국 forall a. [a] -> [a]는 forall a. [a] -> IO [a]와 매우 다릅니다.
이 경계를 유용한 코드까지 확장하려면, 순수한 세계와 실제로 일이 일어나는 세계 사이의 경계를 다뤄야 합니다. Five-Point Haskell의 다음 장인 제한적 속죄에서 이 경계의 미묘한 차이를 살펴보겠습니다.
이 글을 연구하고 작성하는 데 시간을 바칠 수 있도록 해 주는 놀라운 공동체의 지원을 받는 것을 매우 영광스럽게 생각합니다. patreon에서 “Amazing” 등급으로 후원해 주시는 Josh Vera께 특별히 깊은 감사를 드립니다! :)