인스턴스instance의 의미

Posted on July 21, 2020

하스켈 언어에 있는 인스턴스(주로 레졸루션)에 관한 얘기입니다. 다른 언어의 인스턴스로 오해하시며 읽는 분들이 계셔 남겨 놓습니다.

“인자로 받은 타입에 따라, 다른 작업을 하는 같은 이름의 함수method를 만든다.”
추상적으로 표현하면
“클래스는 성격을 선언해 놓고, 클래스의 성격을 인스턴스로 구현한다.”

객체지향OOP에서의 인스턴스와는 다른 의미입니다. OOP에서는 객체 설계도에 해당하는 클래스 선언을 하고, 이 설계도에 따라 만든 구현체를 인스턴스로 표현하는데, 하스켈에서 클래스는 몇 개 함수의 선언을 묶어놓은 셋set이고, 이 함수 선언들을 특정 데이터 타입에 맞게 구현한 것을 인스턴스로 부릅니다. 이렇게 얘기하니 꼭 다른 의미라고 보지 않아도 될 것 같긴 합니다. 무언가 틀에 해당하는 걸 먼저 정해놓고, 틀에 따라 실제 쓸 물건을 만드는 건 똑같습니다. 대부분의 문서에서 다름을 강조하는데, 클래스라는 단어 뜻에만 기대서 본다면 굳이 달라 보이지 않기도 합니다.

클래스 정의를 어떤 식으로 읽어야 좋을까요?

class HasName a where    
  getName :: a -> String
  getPower :: a -> Int

instance HasName Player where    
  getName x = "Player : " ++ name x    
  ...

“a type a is an instance of the class HasName”
aHasName 클래스의 인스턴스”

PlayerHasName의 인스턴스다.”
HasName Player 인스턴스가 있다.”
Player 타입을 만나면 getName 메소드를 가져올 HasName 인스턴스가 있다.”

대체로 위와 같은 해석이 많이 쓰이는데, 해석만 봐서는 마치 PlayerHasName의 구현체처럼 느껴지지 않나요?

클래스는 메소드 셋set의 이름입니다.

class Convertible a b | a -> b where
  convert :: a -> b
  union :: a -> b -> a


instance Convertible Int Char where
  convert = ... 
  union = ...

instance Convertible Char Int where
  convert = ...
  union = ...

HasName Player처럼 헤드에 매개 변수가 하나만 있을 때는 “PlayerHasName의 인스턴스다”란 해석이 그럭 저럭 넘어갈만 한데, “Int CharConvertible의 인스턴스다”란 해석은 직관적으로 잘 와닿지 않습니다. 저와 비슷한 느낌이 오는 분들은 다음처럼 해석하면 도움이 됩니다.

인스턴스를 정의하는 건 메소드 셋set을 준비하는 겁니다. Convertibleconvert, union으로 이루어진 메소드 셋set의 이름같은 겁니다. Typeclass는 이 셋set을 가진(성격을 가진) Type들의 부류입니다. 인스턴스 헤드에 쓰여 있는 CharInt는 여러 메소드 셋 중에 하나를 선택할 때 쓰이는 키key또는 태그tag 같은겁니다. CharInt는 구현체 자체를 뜻하는게 아니라, 여러 구현체중 하나를 고를 때, 다른 구현체와 구별하기 위한 key로 쓰이게 됩니다. 개념적으로 말을 할 땐, CharInt의 조합은 Convertible 인스턴스다 또는 인스턴스를 가졌다고 말합니다.

instance Convertible Int Char 정의는 Convertible의 새로운 메소드 셋(convert,union) 한 벌을 추가하고, 이 메소드 셋의 키는 Int Char 로 지정한다.”

Player는 HasName의 구현체가 아니라, HasName 구현체 중 하나를 골라낼 때 쓰는 키입니다.
Player는 HasName 성질을 가지고 있다. Player는 HasName 인스턴스다.

타입 테이블은 아무것도 바뀌지 않습니다.

아마도 논리적인 설계는 다음과 비슷할 겁니다. (주의: 실제 테이블 모양이 이렇다는 얘기는 아닙니다. 정확한 자료를 찾으면 추가하도록 하겠습니다)

인스턴스로 지정되는 타입은 사실 “타입” 자체는 아무것도 바뀌지 않습니다. 코드 조립을 위해 GHC가 관리하는 타입 테이블은 아무것도 바뀌지 않습니다. GHC가 코드 조립 중 메소드를 맞닥뜨렸을 때 메소드 테이블에서 “타입”을 키로 메소드를 선택합니다. 이 키 역할을 해주는 타입을 인스턴스 헤드에 써줍니다. 아마도 다음과 비슷하게 설계되어 있을 겁니다.

getName 메소드 테이블 추정

Key      구현체
======== ==================
Player   Player용 getName
Villain  Villain용 getName
MyType   MyType용 getName
...

위 소스에서는 getName 메소드가 여러 개 있고, 이 중 하나를 선택하는 키로 쓰이는게 Player, Villain 타입입니다. 메소드가 인자로 받는 타입에 따라 구현체를 골라냅니다.

그럼 이렇게 constraint가 있는 경우는 어떻게 읽을까요?

instance (Eq a) => Eq (Tree a) where
  (==) ...

(Tree a)타입을 Eq 클래스의 인스턴스로 만드는데, Tree가 감싸고 있는게 Eq의 인스턴스여야 합니다. 다시 말해, 아무 (Tree a)타입을 만났다고 모두 이 인스턴스로 코드 조립을 할 수 있는게 아닙니다. a타입을 다루는 (==)가 있을 경우만, 이 인스턴스의 메소드로 해결할 수 있습니다. 최종 코드 조립을 할 때는 구체 타입을 알아야 하는데 a는 무슨 타입일까요?

코드 조립할 때, 항상 구체 타입까지 알아야 하는 건 아닙니다.
여기선 구체 타입까지 고정해 놓지 않았습니다.

구체 타입까지 알 필요는 없고, (==)의 매개 변수가 Eq a => Tree a 타입이면 모두 이 인스턴스의 (==)메소드를 선택합니다.

Tree Int, Tree Float, … 뭐든 Eq의 인스턴스가 있는 타입이면 모두 위 인스턴스의 메소드를 선택합니다.

(==)메소드 테이블 추정

Key                 구현체
=================== ==========================
Int                 Int용 (==)
Float               Float용 (==)
(Eq a) => Tree a    (Eq a) => Tree a용 (==)
...

그럼 이렇게 키에 “(Eq a)”란 constraint가 Key로 쓰일 것 같은데, 그렇지 않습니다. 인스턴스를 고를 때 constraint는 전혀 고려하지 않습니다. 그리고, 사실 Tree이기만 하면 Tree(==)를 선택합니다. FlexibleInstances 확장을 켜지 않으면, 가장 바깥쪽 생성자(여기선 Tree)만 키로 사용하므로 Tree a에서 a타입도 인스턴스를 찾는 조건이 아닙니다. 아마도 다음과 같을 겁니다. - 왜 기본 동작을 이렇게 설계했는지는 잘 모르겠습니다. 퍼포먼스 문제가 아니였을까요?

Key                 구현체
=================== ==========================
Tree                Tree용 (==)

그럼 constraint는 언제 영향을 줄까요? 다음 테스트로 짐작할 수 있습니다.
만일 Eq 인스턴스가 아닌 타입 a를 가진 Tree를 인자로 (==)를 부르면 어떤 에러가 날까요? Eq (Tree a)가 없다는 에러가 날까요? 아니면 Eq a가 없다는 에러가 날까요?

import Data.Tree                       
    
data NoEq = NoEq    
                                           
treeA :: Tree NoEq                             
treeA = Node NoEq []             
                                 
treeB :: Tree NoEq               
treeB = Node NoEq [] 

> treeA == treeB

<interactive>:21:1: error:
No instance for (Eq NoEq) arising from a use of==

위 에러로 Tree 인스턴스는 찾았지만 (==)의 constraint인 Eq a => 를 풀지 못해 발생한 에러임을 알 수 있습니다.
다른 방향에서 접근해서 설명하면, constraint만 다른 인스턴스는 정의할 수 없다는 것과 같은 말입니다.

instance (Eq a) => SomeClass a where ...
instance (Show a) => SomeClass a where ...

error:
  Duplicate instance declaration: ...

이렇게 정의할 수 없다는 말입니다.

아래와 같이 정의하면

import Data.Tree   

data YesEq = YesEq              

instance Eq YesEq where 
  YesEq == YesEq = True          

instance Eq (Tree YesEq) where
  Node x [] == Node y [] = x == y

instance.hs:9:10: error:
Illegal instance declaration for ‘Eq (Tree YesEq)’
        (All instance types must be of the form (T a1 ... an)

Tree a 처럼 써야지 Tree YesEq 처럼 구체 타입을 써 줄수 없다는 에러입니다. (어차피 Tree까지만 키로 쓰일테니 YesEq까지 구체 타입을 적어서 혼란스럽게 만들면 안된다는 취지의 에러 아닐까 추측합니다.) FlexibleInstances 확장을 켜면

{-# LANGUAGE FlexibleInstances #-}
...
...
instance.hs:10:10: error:
Overlapping instances for Eq (Tree YesEq)
        arising from a use ofGHC.Classes.$dm/=
      Matching instances:
        instance Eq a => Eq (Tree a) -- Defined in ‘Data.Tree’
        instance Eq (Tree YesEq) -- Defined at instance.hs:10:10

Data.Tree 모듈에 TreeEq 인스턴스를 가질 때 매칭하는 인스턴스가 이미 정의되어 있기 때문에 중복overlapping이라는 에러입니다. “왜 FlexibleInstances를 켰는데도 Tree YesEq가 중복이라는 에러가 나지?” 라고 오해할 수 있습니다.
FlexibleInstances 확장을 켜줬으니 구체 타입까지 써줘도 될 것 같은데, 이미 Data.Tree에 정의되어 있는 instance폴리모픽 Tree a로 정의되어 있기 때문에 둘 다 매칭되기 때문에 나는 오류입니다. FlexibleInstances 포스트를 참고하세요.

참고
https://mgsloan.com/posts/inspecting-haskell-instance-resolution/

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