에러가 읽기 어려울 땐, :type 을 써서 단계마다 타입을 추적하면 도움이 됩니다. 일부러 간단한 구문을 에러가 나도록 해서 메시지를 따라가 봤습니다.
> fmap ((+1) . fst) (Just 1, 100)
<interactive>:5:1: error:
• Non type-variable argument in the constraint: Num (b1, b2)
...fst 함수를 튜플 (Just 1, 100)에 적용해서 나온 Just 1 에 fmap (+1)을 적용해서 Just 2를 결과로 예상했지만, 위와 같은 에러를 만났습니다.
타입 제약에 타입 변수가 아닌 인자가 있다?
> :t fmap ((+1) . fst) (Just 1, 100)
fmap ((+1) . fst) (Just 1, 100)
:: (Num b1, Num a, Num (b1, b2)) => (Maybe a, b1)=> 왼쪽의 타입 제약constraint을 보면 Num (b1, b2)가 보입니다. GHC는 왜 이런 제약이 있다고 추론했을까요?
fmap은 어떤 인스턴스의 fmap을 선택했을까요?
fmap의 두 번째 인자 (,)를 보고, 튜플의 fmap 을 선택했습니다.
fmap :: (a0 -> b) -> (a, a0) -> (a,b)
fmap 첫 번째 매개 변수의 타입은 a0 -> b
> :t (+1) . fst
(+1) . fst :: Num c => (c, b) -> c
a0는 (c,b)가 되고, b는 c라고 추론합니다.
fmap 두 번째 매개 변수의 타입은 (a, a0)
(Just 1, 100) 인자가 들어왔으니, a는 Just 1, a0는 100
위에서 a0는 튜플로 추론됐고, 여기서는 100을 보고 Num 클래스 소속이라고 추론 됐습니다.
100은 Int나 Double이 아니고 Num a => a 입니다.
Num (,)이라고 추론 됐습니다. (,) 인스턴스가 없다는 에러가 날 것 같기도 한데, 그 이전에 Num a => a 에서 a에 튜플이 들어갑니다. 제약에는 타입 클래스만 올 수 있습니다. a, b 같은 타입 변수가 아니라 생성자 (,)가 들어왔기 때문에 non type-variable argument 란 에러가 납니다.
에러는 괄호를 잘 못 씌워서 원하는 대로 함수 적용이 이루어지지 않았는데, 에러 메시지는 “제약에 타입 변수가 아닌게 들어왔다” 입니다. 꽤 오랫동안 봐야 통찰이 생길 것 같은 연결 고리입니다.
아래처럼 바꿨더니 이 번에 다른 에러가 납니다.
> fmap (+1) . fst (Just 1, 100)
<interactive>:13:13: error:
• Couldn't match expected type ‘a -> f b’
with actual type ‘Maybe Integer’ ---- [2]
• Possible cause: ‘fst’ is applied to too many arguments
In the second argument of ‘(.)’, namely ‘fst (Just 1, 100)’
In the expression: fmap (+ 1) . fst (Just 1, 100)
In an equation for ‘it’: it = fmap (+ 1) . fst (Just 1, 100)
• Relevant bindings include
it :: a -> f b (bound at <interactive>:53:1):t로 보려고 해도 같은 에러가 나서, 이 방법으로 힌트를 얻을 수 없습니다.it :: a -> f b가 보입니다. 전체의 표현식이 a -> f b라고 추론했다는데 왜 그렇게 했을까요? fmap (+1) (. fst (Just 1, 100)) -- 이렇게 나눠지지 않습니다.
(fmap (+1)) . (fst (Just 1, 100)) -- 이렇게 나눌 수 있습니다. (.) 의 우선순위가 높습니다. ---- [1]
(.) :: (b -> c) -> (a -> b) -> a -> c
fmap :: Functor f => (a -> b) -> f a -> f b
(b -> c) 가 fmap (+1) :: (functor f, Num b) => f b -> f bb 자리에 f b, c 자리에 f b 로 추론하고
그럼 두 번째 함수인 a -> b는 a -> f b라 추론합니다.
그런데, 두 번째 함수는 (fst (Just 1, 100)) 에서 Num a => Maybe a로 추론됩니다.
그래서 a -> f b와 Maybe a가 매칭이 안된다는 에러가 납니다.
에러는 괄호를 잘 못 씌워서 원하는 대로 함수 적용이 이루어지지 않았는데, 에러 메시지는 “타입 매칭 실패” 입니다. 에러에 대한 경험을 키우는 것 말고, 별다른 이해 방법이 없어 보입니다. GHC 입장에서 생각하고, 타입 추론을 생각하며 에러를 따라 가는 훈련이 필요합니다.
[1] 괄호 없는 표현식의 실행 순서
:info 명령어로 우선 순위를 볼 수 있습니다. prefix 적용은 따로 우선 순위 표현이 없는데, 중위 연산자와 섞어 쓸 때면 우선순위가 가장 높은 10이라고 보면 되겠습니다. infix 만 우선 순위가 있습니다. 그리고 또 한가지, 중위 연산자는 함수를 넘기는 곳에 인자로 쓰일 수 없습니다. 괄호로 감싸서 prefix 로 만들어 넘겨야 합니다.
-- . 은 infixr 9
-- $ 는 infixr 0
-- 아래처럼 괄호 없이 써도 연산자 우선 순위가 있어 제대로 계산됩니다.
> add x y = x + y
> add 1 . add 2 $ 3
-- 중위 연산자 . 보다 앞에 add가 우선 실행되고, 중위 연산자 . 이 동작하기 전 뒤에 add를 먼저 실행합니다.
-- 그리고 나서 . 연산자를 실행합니다.
-- $ 의 우선 순위는 infixr 0 입니다. 가장 낮은 우선 순위입니다.
6
> 1 + add 1 1
3
> fmap (+1) . fmap (+2) $ [1,2]
[4,5][2] 실제 타입으로 Maybe a가 아니라 Maybe Integer가 나온건 Num 클래스의 디폴트 타입이 Integer라서 그렇습니다. 숫자 리터럴은 별다른 추론 단서가 없다면 Integer로 추론됩니다.
https://kseo.github.io/posts/2017-01-04-type-defaulting-in-haskell.html