0. Typeclass와 Class
Typeclass는 Haskell의 인터페이스 정의로, 일련의 동작을 선언하는 데 사용됩니다.
OOP에서의 Class는 현실의 사물을 묘사하고 내부 상태를 캡슐화하는 객체 템플릿입니다. FP에는 내부 상태라는 개념이 없으므로, 함수형 컨텍스트에서 Class는 인터페이스를 의미합니다. 특정 클래스에서 파생되는 것(deriving (SomeTypeclass))은 해당 클래스에 정의된 동작을 갖는다는 뜻이며, 이는 OOP에서 특정 인터페이스를 구현하여 해당 인터페이스에 정의된 동작을 갖는 것과 같습니다.
1. 선언
class 키워드는 새로운 typeclass를 정의하는 데 사용됩니다.
class Eq a where
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
x == y = not (x /= y)
x /= y = not (x == y)
여기서 a는 타입 변수이며, instance를 정의할 때 구체적인 타입을 지정합니다. 처음 두 줄의 타입 선언은 인터페이스가 정의하는 동작입니다 (함수 타입을 정의하여 묘사함). 뒤의 두 함수 구현은 선택 사항이며, 간접 재귀 정의를 통해 두 함수의 관계를 묘사하므로 하나의 함수 구현만 제공해도 충분합니다 (이 방식을 minimal complete definition, 최소 완전 정의라고 합니다).
P.S. GHCi 환경에서는 :info <typeclass> 명령을 통해 해당 클래스에 정의된 함수와 해당 클래스에 속하는 타입들을 확인할 수 있습니다.
2. 구현
instance 키워드는 특정 typeclass의 instance를 정의하는 데 사용됩니다.
instance Eq TrafficLight where
Red == Red = True
Green == Green = True
Yellow == Yellow = True
_ == _ = False
여기서는 class Eq a의 타입 변수 a를 구체적인 TrafficLight 타입으로 바꾸고 == 함수를 구현했습니다 (Eq 클래스에서 두 함수의 관계를 선언했으므로 /=를 동시에 구현할 필요는 없습니다).
사용자 정의 타입을 Show 클래스의 멤버로 만들어 봅시다.
data Answer = Yes | No | NoExcuse
instance Show Answer where
show Yes = "Yes, sir."
show No = "No, sir."
show NoExcuse = "No excuse, sir."
한번 실행해 봅시다.
> Yes
Yes, sir.
P.S. GHCi 환경에서는 :info <type> 명령을 통해 해당 타입이 어떤 typeclass에 속하는지 확인할 수 있습니다.
서브클래스
마찬가지로 서브클래스의 개념도 있습니다. B 클래스의 멤버가 되려면 먼저 A 클래스의 멤버여야 한다는 제약을 의미합니다.
class (Eq a) => Num a where
-- ...
Num 클래스의 멤버는 반드시 먼저 Eq 클래스의 멤버여야 함을 요구하며, 문법적으로는 단지 타입 제약이 추가된 형태입니다. 이와 유사한 또 다른 예시입니다.
instance (Eq m) => Eq (Maybe m) where
Just x == Just y = x == y
Nothing == Nothing = True
_ == _ = False
여기서는 Maybe a의 타입 변수 a가 반드시 Eq 클래스의 멤버여야만 Maybe a가 Eq 클래스의 멤버가 될 수 있음을 요구합니다.
3. Functor
펑터(Functor) (이름이 꽤 멋지네요) 또한 하나의 typeclass이며, 매핑할 수 있는(map over 가능한) 대상을 나타냅니다.
class Functor f where
fmap :: (a -> b) -> f a -> f b
fmap은 map a to b 함수와 f a 타입의 인자를 받아 f b 타입의 값을 반환합니다.
조금 헷갈릴 수 있는데, f a 타입은 Maybe, List와 같이 타입 매개변수를 가지는 타입을 말합니다. 예를 들어:
mapMaybe :: Eq t => (t -> a) -> Maybe t -> Maybe a
mapMaybe f m
| m == Nothing = Nothing
| otherwise = Just (f x)
where (Just x) = m
여기서 Maybe t -> Maybe a가 바로 f a -> f b의 예입니다. 실행해 봅시다.
> mapMaybe (> 0) (Just 3)
Just True
여기서 map a to b는 Maybe Num을 Maybe Bool로 바꾸는 것을 의미합니다.
Just 3 :: Num a => Maybe a
Just True :: Maybe Bool
따라서 Functor가 정의하는 동작은 큰 타입(f a에서 f, 여기서 a는 타입 변수)은 유지하면서, 매핑(fmap 함수)을 통해 작은 타입(f a에서 f b로, 여기서 a와 b는 구체적인 타입)을 변경할 수 있게 하는 것입니다.
List 컨텍스트에 적용하면, List 내용에 대해 매핑을 수행하여 다른 List를 얻을 수 있으며, 새 List의 내용 타입은 변할 수 있음을 의미합니다. 하지만 어떤 경우에도 fmap의 결과는 List a 형태입니다 (여기서 a는 타입 변수).
List는 원래 Functor 클래스에 속하므로 이는 매우 자연스럽습니다.
map :: (a -> b) -> [a] -> [b]
이것은 fmap :: (a -> b) -> f a -> f b 타입 정의의 구체적인 구현이며, 실제로 이 map이 바로 그 fmap입니다.
instance Functor [] where
fmap = map
Maybe와 List는 모두 Functor 클래스에 속합니다. 이들의 공통점은 무엇일까요?
둘 다 컨테이너와 비슷합니다. fmap이 정의하는 동작은 바로 컨테이너 안의 내용(값)에 대해 매핑을 수행한 뒤 다시 컨테이너에 담는 것입니다.
Either와 같은 특수한 시나리오도 있습니다.
data Either a b = Left a | Right b -- Defined in ‘Data.Either’
Either 타입 생성자는 두 개의 타입 매개변수를 가지지만, fmap :: (a -> b) -> f a -> f b의 f는 하나의 매개변수만 받습니다. 따라서 Either의 fmap은 왼쪽 타입을 고정할 것을 요구합니다.
mapEither :: (t -> b) -> Either a t -> Either a b
mapEither f (Right b) = Right (f b)
mapEither f (Left a) = Left a
매핑이 타입을 바꿀 수 있으므로 왼쪽은 매핑하지 않습니다. Either a(fmap :: (a -> b) -> f a -> f b에서의 f)는 변할 수 없기 때문에 Nothing과 동일하게 처리합니다. 예를 들어:
> mapEither show (Right 3)
Right "3"
> mapEither show (Left 3)
Left 3
이와 유사한 또 다른 사례는 Map입니다.
-- Data.Map에 별칭 Map을 부여함
data Map.Map k a -- ...
Map k v 매핑 시 k는 변해서는 안 되므로 값에 대해서만 매핑을 수행합니다.
mapMap :: Ord k => (t -> a) -> Map.Map k t -> Map.Map k a
mapMap f m = Map.fromList (map (\(k ,v) -> (k, f v)) xs)
where xs = Map.toList m
예를 들어:
> mapMap (+1) (Map.insert 'a' 2 Map.empty)
fromList [('a',3)]
> mapMap (+1) Map.empty
fromList []
P.S. 이러한 간단한 구현들은 표준 라이브러리 구현과 비교하여 정확성을 검증할 수 있습니다. 예를 들어:
> fmap (+1) (Map.insert 'a' 2 Map.empty )
fromList [('a',3)]
P.S. 또한 Functor를 구현할 때는 몇 가지 규칙을 준수해야 합니다. 예를 들어 List 원소의 순서가 바뀌지 않아야 한다거나, 이진 탐색 트리의 구조적 성질이 유지되어야 한다는 등의 규칙입니다.
4. Kind
연산에 참여하는 것은 값(함수 포함)이며, 타입은 값의 속성이므로 값은 타입에 따라 분류될 수 있습니다. 값이 가진 이 속성을 통해 해당 값의 성질을 추론할 수 있습니다. 이와 유사하게, kind는 타입의 타입이며 타입을 분류하는 개념입니다.
GHCi 환경에서는 :kind 명령을 통해 타입의 타입을 확인할 수 있습니다. 예를 들어:
> :k Int
Int :: *
> :k Maybe
Maybe :: * -> *
> :k Maybe Int
Maybe Int :: *
> :k Either
Either :: * -> * -> *
> :k Either Bool
Either Bool :: * -> *
> :k Either Bool Int
Either Bool Int :: *
Int :: *는 Int가 구체적인 타입임을 나타냅니다. Maybe :: * -> *는 Maybe가 하나의 구체적인 타입을 매개변수로 받아 하나의 구체적인 타입을 반환함을 나타냅니다. Either :: * -> * -> *는 Either가 두 개의 구체적인 타입을 매개변수로 받아 하나의 구체적인 타입을 반환함을 나타내며, 이는 함수 호출과 유사하고 커링(Currying) 특성이 있어 부분 적용(partially apply)이 가능합니다.
더 특이한 형태의 kind도 있습니다. 예를 들어:
data Frank a b = Frank {frankField :: b a} deriving (Show)
값 생성자 Frank의 매개변수인 frankField의 타입을 b a로 한정했습니다. 따라서 b는 * -> *이고 a는 구체적인 타입 *입니다. 그러면 Frank 타입 생성자의 kind는 다음과 같습니다.
Frank :: * -> (* -> *) -> *
여기서 첫 번째 *는 매개변수 a, 중간의 * -> *는 매개변수 b, 마지막 *는 구체적인 타입을 반환한다는 의미입니다. 다음과 같이 채울 수 있습니다:
> :t Frank {frankField = Just True}
Frank {frankField = Just True} :: Frank Bool Maybe
> :t Frank {frankField = "hoho"}
Frank {frankField = "hoho"} :: Frank Char []
다시 Either의 Functor 구현을 살펴봅시다:
> :k Either
Either :: * -> * -> *
> :t fmap
fmap :: Functor f => (a -> b) -> f a -> f b
Either의 kind는 * -> * -> *(두 개의 구체적인 타입 매개변수 필요)인 반면, fmap이 원하는 f는 * -> *(하나의 구체적인 타입 매개변수 필요)입니다. 따라서 Either에 부분 적용을 수행하여 하나의 매개변수를 채워 * -> *로 만들어야 합니다. mapEither의 구현은 다음과 같습니다.
mapEither :: (t -> b) -> Either a t -> Either a b
mapEither f (Right b) = Right (f b)
mapEither f (Left a) = Left a
Either a는 표준적인 * -> *입니다. 예를 들어:
> :k Either Int
Either Int :: * -> *
P.S. typeclass에 대해서도 확인해 볼 수 있습니다. 예를 들어:
> :k Functor
Functor :: (* -> *) -> Constraint
> :k Eq
Eq :: * -> Constraint
여기서 Constraint 또한 하나의 kind이며, 특정 클래스의 인스턴스여야 함을 나타냅니다 (즉, 함수 시그니처의 => 왼쪽에 자주 등장하는 타입 제약입니다). 예를 들어 Num이 있으며, 자세한 내용은 What does has kind 'Constraint' mean in Haskell을 참조하세요.
아직 댓글이 없습니다