컴파일 에러 읽기 - Non type-variable argument in the constraint

Posted on June 20, 2020

에러가 읽기 어려울 땐, :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 1fmap (+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는 왜 이런 제약이 있다고 추론했을까요?

  1. fmap은 어떤 인스턴스의 fmap을 선택했을까요?
    fmap의 두 번째 인자 (,)를 보고, 튜플의 fmap 을 선택했습니다.
    fmap :: (a0 -> b) -> (a, a0) -> (a,b)

  2. fmap 첫 번째 매개 변수의 타입은 a0 -> b
    > :t (+1) . fst
    (+1) . fst :: Num c => (c, b) -> c
    a0(c,b)가 되고, bc라고 추론합니다.

  3. fmap 두 번째 매개 변수의 타입은 (a, a0)
    (Just 1, 100) 인자가 들어왔으니, aJust 1, a0100
    위에서 a0는 튜플로 추론됐고, 여기서는 100을 보고 Num 클래스 소속이라고 추론 됐습니다.
    100IntDouble이 아니고 Num a => a 입니다.

  4. 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 typeMaybe 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)
  1. 이 번엔 :t로 보려고 해도 같은 에러가 나서, 이 방법으로 힌트를 얻을 수 없습니다.
  2. 구문이 실행되는 순서로 쪼개어 봅시다.
  3. 에러 중에 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 b

b 자리에 f b, c 자리에 f b 로 추론하고 그럼 두 번째 함수인 a -> ba -> f b라 추론합니다. 그런데, 두 번째 함수는 (fst (Just 1, 100)) 에서 Num a => Maybe a로 추론됩니다.

그래서 a -> f bMaybe 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

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com