타입클래스

타입클래스

지난 챕터에서 몇가지 하스켈의 표준 타입클래스들을 배우고, 어떤 타입들이 속하는지 보았습니다. 또한 사용자 정의 타입을 표준 타입클래스로부터 파생시키는 방법을 살펴보았습니다. 이번 섹션에서는 사용자 정의 타입클래스를 만들고, 그것으로부터 파생되는 타입을 만들어 보겠습니다.

그동안 배운 타입클래스에 대한 내용을 요약하면 타입클래스는 인터페이스와 같다는 것입니다. 타입클래스는 동일한지 비교하거나, 순서를 비교하는 등의 몇가지 동작을 정의합니다. 그리고 타입은 타입클래스의 인스턴스로 만듬으로써 해당 동작을 할 수 있습니다. 타입클래스들의 동작은 함수들을 정의하거나 구현한 선언을 입력하여 얻을 수 있습니다. 어떤 타입이 타입클래스의 인스턴스라는 것은 타입이 타입클래스의 동작을 할 수 있다는 것을 의미합니다.

타입클래스는 자바나 파이썬의 클래스와는 전혀 관계가 없습니다. 이 부분이 헷갈리수 있으므로 지금부터는 명령형 언어의 클래스들에 대해서는 완전히 잊어야 합니다!!

예를들어, Eq 타입클래스는 동일한지를 확인할 수 있는데 ==/= 함수를 정의하고 있습니다. 만약 Car라는 타입이 Eq 타입클래스의 인스턴스라면, 두개의 차가 동일한지 여부를 ==함수로 확인할 수 있습니다.

Eq 타입클래스는 표준 prelude 모듈에 아래와 같이 정의되어 있습니다.

class Eq a where
    (==) :: a -> a -> Bool
    (/=) :: a -> a -> Bool
    x == y = not (x /= y)
    x /= y = not (x == y)

여기서 class Eq a whereEq라는 새로운 타입클래스를 정의하는 것을 의미합니다. 여기서 a는 타입 변수이고, Eq의 인스턴스가 될 타입의 역할을 수행합니다. a 다른 이름으로 쓰일 수 있고, 한개의 문자가 아니어도 되지만 소문자로 구성되어야 합니다. 그 다음에는 여러가지 함수들이 정의되었습니다. 여기서 함수의 바디를 구현하는 것은 필수가 아닙니다. 따라서 그냥 함수의 선언만 명시해도 됩니다.

어쨋든 여기는 Eq가 정의한 함수에 대해서 함수의 바디를 구현했지만, 상호 재귀 관점에서만 정의했습니다. Eq의 인스턴스 두개가 다르지 않다면 같은 것이고, 같지않다면 다를 것입니다. 이런 구현은 반드시 하지않아도 되지만 어떤 도움을 줄 수 있는지 보게 될 것 입니다.

data TrafficLight = Red | Yellow | Green

TrafficLight는 신호등의 상태를 정의합니다. TrafficLight는 클래스 인스턴스를 파생하지 않았습니다. EqShow로 파생시킬수도 있지만, 여기서는 직접 어떤 인스턴스를 작성해보도록 하겠습니다.

instance Eq TrafficLight where
    Red == Red = True
    Green == Green = True
    Yellow == Yellow = True
    _ == _ = False

instance 키워드를 사용해서 인스턴스로 작성하였습니다. class는 새로운 타입클래스를 정의하고, instance는 타입클래스의 타입 인스턴스를 만듭니다. Eq를 정의할때 class Eq a where로 작성하고 여기서 a는 나중에 인스턴스가 만들어질때 타입의 역할을 한다고 했습니다. 여기서는 instance Eq TrafficLight where로 작성하여 바로 인스턴스로 만들었기 때문에 좀 더 명확합니다. a를 실제 타입으로 변경하였습니다.

class 선언안에서 ==\=와 그 반대의 관점에서 정의되었기 때문에, 인스턴스 선언안에 둘 중 하나를 겹쳐서 사용했습니다. 이것을 타입클래스의 최소한의 완전한 선언이라고 부릅니다(타입이 클래스처럼 동작할 수 있도록 하기위해 구현해야하는 최소한의 함수). Eq가 최소한의 완전한 선언을 수행하려면 == 또는 \=중 하나를 overwrite해야 합니다. Eq를 간단히 정의하면 아래와 같습니다.

class Eq a where
    (==) :: a -> a -> Bool
    (\=) :: a -> a -> Bool

이렇게 선언된 타입클래스의 인스턴스 타입을 만들때는 두 함수 모두 구현해야 합니다. 왜냐하면 하스켈은 두 함수가 어떻게 관련되어 있는지 알지못하기 때문입니다. 최소한의 완전한 선언은 ==\= 모두가 될 것입니다.

이번에는 TrafficLight를 Show의 인스턴스로 만들어 보겠습니다. Show에 대한 최소한의 완전한 정의를 만족하기 위해서, 값을 문자열로 변환하는 show 함수를 구현해야 합니다.

instance Show TrafficLight where
    show Red = "Red light"
    show Yellow = "Yellow light"
    show Green = "Green light"
ghci> Red == Red
True
ghci> Red == Yellow
False
ghci> Red `elem` [Red, Yellow, Green]
True
ghci> [Red, Yellow, Green]
[Red light,Yellow light,Green light]]

간단하게 Eq는 파생시키는 방법으로도 동일한 효과를 얻을수 있습니다.(여기서는 교육을 목적으로 직접 작성) 하지만 Show의 경우는 그냥 파생시키면 직접 문자열을 "Red light"와 같이 지정하여 출력할 수 없습니다. 이 경우는 반드시 instance 선언으로 직접 작성해야 합니다.

또한 타입클래스의 서브클래스인 타입클래스를 만들수도 있습니다. Num 타입클래스에 대한 class 선언은 매우 길지만, 첫줄은 아래와 같습니다.

class (Eq a) => Num a where
  ...

이전에도 언급했듯이 클래스 제약안에는 여러개를 넣을 수 있습니다. 따라서 위의 선언은 class Num a where에서 aEq의 인스턴스여야 한다는 제약만 추가한 것입니다. 이 선언을 통해서 숫자를 고려한 어떤 타입을 고려하기전에, 이 타입의 값이 같은지 여부를 판단할 수 있는 값인지 확인해야 합니다. 단지 class 선언에 대한 클래스 제약을 만드는 것이 전부입니다. classinstance 선언안에 함수의 바디를 정의할때, a의 타입은 Eq의 인스턴스이고 ==를 사용할 수 있다는 것을 가정할 수 있습니다.

Maybe나 리스트 타입은 어떻게 타입클래스의 인스턴스로 만들 수 있을까요? 여기서 MaybeTrafficLight의 차이점은 Maybe는 그 자체적으로 구체적인 타입(concrete type)이 아니고 한개의 파라메터를 받아서 구체적인 타입을 만드는 타입 생성자(type constructor)라는 점입니다.(예를들면, Maybe Char 처럼..)

여기서 다시한번 Eq 타입클래스를 살펴보겠습니다.

class Eq a where
    (==) :: a -> a -> Bool
    (/=) :: a -> a -> Bool
    x == y = not (x /= y)
    x /= y = not (x == y)

이 타입 선언에서 a함수안의 모든 타입들은 이미 구체화(concrete)되어 있어야 하기때문에, 구체적인 타입(concrete type)으로 사용된다는 것을 알 수 있습니다(예를들면, 함수의 타입이 a -> Maybe일수는 없지만, a -> Maybe aMaybe Int -> Maybe String일수는 있습니다.). 따라서 아래와 같이 선언할수는 없습니다.

instance Eq Maybe where
   ...

왜냐하면 a는 구체적인 타입이어야 하는데 Maybe는 타입 생성자이기 때문입니다. 이것을 모든 타입에 대해서 instance Eq (Maybe Int) where, instance Eq (Maybe Char) where와 같이 작성하는 것은 매우 번거로운 일입니다. 그래서 아래와 같이 작성합니다.

instance Eq (Maybe m) where
    Just x == Just y = x == y
    Nothing == Nothing = True
    _ == _ = False

이렇게 작성하면 모든 타입에 대해서 Maybe something 형태의 Eq 인스턴스를 만들 수 있습니다. class Eq a wherea와 마찬가지로 (Maybe m)은 구체적인 타입입니다(여기서 m은 소문자여야함). m이 어떤 타입이든 (Maybe m)의 형태로 Eq의 인스턴스가 될 수 있습니다.

여기에는 한가지 문제가 있습니다. Maybe 자체는 ==을 사용할 수 있다는 것은 알지만, Maybe가 가지고 있는 mEq의 인스턴스인지는 알 수 없습니다. 따라서 아래와 같이 instance 선언을 수정해야 합니다.

instance (Eq m) => Eq (Maybe m) where
    Just x == Just y = x == y
    Nothing == Nothing = True
    _ == _ = False

여기서는 instance 선언에 클래스 한정을 추가해 주었습니다. 이렇게하면 Maybe m뿐만 아니라 mEq의 인스턴스 입니다. 이것은 실제로 하스켈이 인스턴스를 파생시키는 방법입니다.

대부분의 경우 class 선언내에서 클래스 한정은 타입클래스의 서브클래스인 타입클래스를 만들기 위해서 사용됩니다. 그리고 instance 선언에서는 어떤 타입의 내용물(contents)에 대한 요구사항을 표현하기 위해서 사용됩니다. 예를들어, Maybe에 포함된 내용물(contents)도 Eq 타입클래스의 인스턴스라는 것입니다.

인스턴스를 만들때 타입 선언안에서 타입이 구체적인 타입으로 사용된다면(a -> a -> Bool에서 a처럼), 타입 파라메터들을 제공하고 괄호를 추가하여 구체적인 타입으로 끝나야 합니다.

인스턴스를 만들려는 타입이 class 선언의 파라메터를 대체한다는 점을 고려해야 합니다. class Eq a wherea는 인스턴스로 만들어질때 실제 타입으로 대체될 것 입니다. 따라서 타입을 함수 타입 선언안에도 넣도록 해야합니다. (==) :: Maybe -> Maybe -> Bool는 안되지만 (==) :: (Eq m) => Maybe m -> Maybe m -> Bool는 가능합니다. 이렇게되면 어떤 경우에 관계없이 ==이 항상 (==) :: (Eq a) => a -> a -> Bool의 타입을 갖는 것을 고려해야 합니다.

만약 타입클래스의 인스턴스가 무엇인지 알려면, GHCI에서 :info YourTypeClass라고 치면 됩니다. 따라서 :info Num이라고 치면 타입클래스가 정의하는 함수와 타입클래스에 있는 타입 목록을 보여줍니다. 또한 :info는 타입과 타입 생성자에 대해서도 동작합니다. 만약 :info Maybe라고 하면, Maybe가 인스턴스인 모든 타입클래스들을 보여줄 것 입니다. 또한 info는 함수의 타입 선언을 보여줄 수도 있습니다.

yes-no 타입클래스 예제

자바스크립트와 같은 약한 타입 언어에서는 if 표현식안에 거의 모든 것을 넣을 수 있습니다. 예를들어, if (0) alert("YEAH!") else alert("NO!"), if ("") alert("YEAH!") else alert("NO!"), if (false) alert("YEAH!") else alert("NO!") 등의 같은 표현이 가능합니다. 그리고 결과는 모두 "NO!"를 출력합니다. 만약 if ("WHAT") alert("YEAH!") else alert("NO!")"YEAH!"를 출력합니다. 왜냐하면 자바스크립트에서 비어져있지 않은 문자열은 true이기 때문입니다.

하스켈에서는 boolean을 엄격하게 Bool로 사용하는 것이 좋지만, 여기서는 자바스크립트처럼 동작하도록 구현해 보겠습니다. 먼저 class 선언으로 시작합니다.

class YesNo a where
    yesno :: a -> Bool

YesNo 타입클래스는 하나의 함수로 정의되었습니다. 이 함수는 참과 거짓의 의미를 가질 수 있는 타입의 값을 받아서 참인지 여부를 알려줍니다. 여기서 a는 구체적인 타입이 되어야 합니다.

다음으로 숫자에 대한 인스턴스를 정의하겠습니다. 여기서는 자바스크립트처럼 0이면 거짓, 1이면 참으로 하겠습니다.

instance YesNo Int where
    yesno 0 = False
    yesno _ = True

리스트에 대한 인스턴스는 빈리스트면 거짓이고, 그렇지 않으면 참입니다.(문자열도 리스트로 정의됩니다.)

instance YesNo [a] where
    yesno [] = False
    yesno _ = True

Bool은 이미 참과 거짓의 분명하기 때문에 아래와 같이 정의합니다.

instance YesNo Bool where
    yesno = id

여기서 id는 어떤 파라메터를 받아서 동일한 것을 리턴해주는 표준 라이브러리 함수 입니다.

Maybe a에 대한 인스턴스도 정의하겠습니다.

instance YesNo (Maybe a) where
    yesno (Just _) = True
    yesno Nothing = False

여기서도 Maybe의 내용물에 대한 어떠한 가정도 없기 때문에 클래스 한정자는 불필요합니다. 그냥 Nothing이면 거짓을 Just이면 참이 됩니다. Maybe만으로는 구체적인 타입이 될 수 없기때문에(Maybe -> Bool이라는 함수가 존재할 수 없듯이) (Maybe a)를 넣어주어야 합니다(Maybe a -> Bool은 가능하므로). 이렇게하면 Maybe somethingsomething이 무엇이든지 YesNo의 일부가 됩니다.

이전 챕터에서 정의한 Tree a 타입도 만들 수 있습니다. 비어있는 트리는 거짓이고 그렇지 않으면 참입니다.

instance YesNo (Tree a) where
    yesno EmptyTree = False
    yesno _ = True

TrafficLight는 빨간불인 경우에만 거짓으로 하고, 아래와 같이 정의하겠습니다.

instance YesNo TrafficLight where
    yesno Red = False
    yesno _ = True

이제 정의한 인스턴스들을 확인해보면 아래와 같습니다.

ghci> yesno $ length []
False
ghci> yesno "haha"
True
ghci> yesno ""
False
ghci> yesno $ Just 0
True
ghci> yesno True
True
ghci> yesno EmptyTree
False
ghci> yesno []
False
ghci> yesno [0,0,0]
True
ghci> :t yesno
yesno :: (YesNo a) => a -> Bool

이제 YesNo의 값들로 동작하는 가짜 if문을 만들어보겠습니다.

yesnoIf :: (YesNo y) => y -> a -> a -> a
yesnoIf yesnoVal yesResult noResult = if yesno yesnoVal then yesResult else noResult

yesnoIf 함수는 참거짓을 판단할 값과, 참일때 리턴할 값과 거짓일때 리턴할 값을 받습니다. 실제로 실행해보면 아래와 같습니다.

ghci> yesnoIf [] "YEAH!" "NO!"
"NO!" 
ghci> yesnoIf [2,3,4] "YEAH!" "NO!"
"YEAH!"
ghci> yesnoIf True "YEAH!" "NO!"
"YEAH!"
ghci> yesnoIf (Just 500) "YEAH!" "NO!"
"YEAH!"
ghci> yesnoIf Nothing "YEAH!" "NO!"
"NO!"

Functor 타입클래스

지금까지 Ord, Eq, Show, Read 등 표준 라이브러리에 정의된 많은 타입클래스를 보았습니다. 여기서는 맵핑할 수 있는 것(can be mapped over)이라는 기능을 가진 Functor 타입클래스에 대해서 알아보겠습니다. 리스트를 다룰때 맵핑이 매우 자주 쓰이는데, 바로 이 리스트가 Functor 타입클래스에 포함됩니다.

Functor 타입클래스는 아래와 같이 구현됩니다.

class Functor f where
    fmap :: (a -> b) -> f a -> f b

여기에는 fmap이라는 한개의 함수가 정의되어 있고, 기본 구현체는 없습니다. 지금까지의 타입클래스 정의에서는 (==) :: (Eq a) => a -> a -> Boola처럼 타입클래스내의 타입의 역할을 수행하는 타입 변수가 구체적인 타입이었습니다. 그러나 Functorf는 구체적인 타입(Int, Bool, Maybe String과 같이 값을 가질 수 있는 타입)은 아니지만 한개의 타입 파라메터를 받는 타입 생성자(type constructor)입니다. 잠깐 관련 내용을 복습해보면 Maybe Int는 구체적인 타입이지만, Maybe는 파라메터로 하나의 타입을 받는 타입 생성자입니다. fmapa 타입을 받아서 b 타입을 리턴하는 함수와 a 타입이 적용된 펑터를 받아서 b 타입이 적용된 펑터를 리턴합니다.

fmap을 타입 선언을 보면 map의 타입 시그니쳐인 map :: (a -> b) -> [a] -> [b]를 떠올려 볼 수 있습니다. mapa 타입을 받아서 b 타입을 리턴하는 함수와 a 타입을 가진 리스트를 받아서 b 타입을 가진 리스트를 리턴합니다. 사실은 map은 리스트에서만 동작하는 fmap일 뿐입니다. 리스트는 아래와 같은 방법으로 Functor 타입클래스의 인스턴스로 만들 수 있습니다.

instance Functor [] where
    fmap = map

fmap :: (a -> b) -> f a -> f b를 통해서 f가 하나의 타입을 받는 타입 생성자가 되어야 하는 것을 알 수 있기때문에, instance Functor [a] where와 같이 작성하지 않았습니다.

[a]는 이미 구체화된 타입(리스트가 어떤 타입을 포함하든)입니다. 반면에 []는 하나의 타입을 받아서 [Int], [String], [[String]]과 같은 타입을 생성하는 타입 생성자 입니다.

리스트로 만들었을때 fmapmap과 동일하기 때문에, 아래와 같이 리스트에 대해서 동일한 결과를 리턴합니다.

map :: (a -> b) -> [a] -> [b]
ghci> fmap (*2) [1..3]
[2,4,6]
ghci> map (*2) [1..3]
[2,4,6]

빈리스트인 경우에는 mapfmap은 모두 빈리스트를 리턴합니다. 이 경우는 그냥 [a] 타입의 목록을 [b] 타입의 목록으로 바꿉니다.

Functor는 마치 무엇인가를 담을 수 있는 컨테이너 같습니다. 리스트는 무한대의 작은 칸이있는 컨테이너에 비유할 수 있습니다. 리스트에는 아무것도 없이 비어있거나, 하나는 가득차고 나머지는 비어있거나 또는 많은 수가 채워져 있을수도 있습니다. 또한 Maybe a 타입도 컨테이너와 같은 속성을 가지고 있습니다. 값이 Nothing일때 처럼 아무것도 가지고 있지 않거나, 값이 Just "HAHA"와 같은 어떤 아이템을 가질 수도 있습니다. 아래는 Maybe를 펑터로 만드는 예제입니다.

instance Functor Maybe where
    fmap f (Just x) = Just (f x)
    fmap f Nothing = Nothing

여기서도 MaybeYesNo를 다룰때 처럼, instance Functor (Maybe m) where 대신에 instance Functor Maybe where로 작성되었습니다. Functor는 구체화된 타입이 아닌 타입 한개를 받는 타입 생성자를 요구합니다. 만약 f들을 Maybe로 교체하면, fmapMaybe 타입에 대해서 (a -> b) -> Maybe a -> Maybe b와 같이 동작합니다. 하지만, 만약 f들을 (Maybe m)으로 교체하면, (a -> b) -> Maybe m a -> Maybe m b와 같이되서 Maybe가 하나의 타입 파라메터만 받는다는 점에서 위배됩니다.

어쨋든 fmap 구현 부분은 상당히 간단합니다. 만약 Nothing으로 비워져있다면, Nothing을 리턴합니다. 만약 비어있지 않고 Just안에 포장된 값이 있으면, Just가 가진 아이템에 입력받은 함수를 적용합니다.

ghci> fmap (++ " HEY GUYS IM INSIDE THE JUST") (Just "Something serious.")
Just "Something serious. HEY GUYS IM INSIDE THE JUST"
ghci> fmap (++ " HEY GUYS IM INSIDE THE JUST") Nothing
Nothing
ghci> fmap (*2) (Just 200)
Just 400
ghci> fmap (*2) Nothing
Nothing

이전 챕터에서 만들었던 Tree aFunctor로 만들어질 수 있습니다. Tree도 비어있거나 다양한 타입이 들어갈 수 있는 컨테이너가 될 수 있습니다. 그리고 Tree의 타입 생성자는 정확히 하나의 타입 파라메터를 받습니다. 만약 fmapTree만 적용이 가능한 함수로 만들면, (a -> b) -> Tree a -> Tree b가 됩니다.

이번에는 펑터 인스턴스를 정의하기 위해서 재귀를 사용할 것입니다. 빈트리로 맵핑되면 빈트리를 리턴하고, 비어있지 않으면 트리의 루트, 왼쪽 서브트리, 오른쪽 서브트리까지 모두 해당 타입으로 맵핑된 트리가 될 것 입니다.

instance Functor Tree where
    fmap f EmptyTree = EmptyTree
    fmap f (Node x leftsub rightsub) = Node (f x) (fmap f leftsub) (fmap f rightsub)
ghci> fmap (*2) EmptyTree
EmptyTree
ghci> fmap (*4) (foldr treeInsert EmptyTree [5,7,3,2,1,7])
Node 28 (Node 4 EmptyTree (Node 8 EmptyTree (Node 12 EmptyTree (Node 20 EmptyTree EmptyTree)))) EmptyTree

이번에는 Either a b를 펑터로 만들어 보겠습니다. Functor 타입클래스는 하나의 타입 파라메터를 받는 타입 생성자를 받습니다. 그런데 Either의 경우 두개의 타입 파라메터를 받습니다. 이때는 Either를 부분 적용하여 한개의 파라메터만 제공하고, 하나의 자유 파라메터(free parameter)를 갖도록 할 수 있습니다. 아래는 표준 라이브러리에 정의된 Either a 입니다.

instance Functor (Either a) where
    fmap f (Right x) = Right (f x)
    fmap f (Left x) = Left x

Either a는 한개의 파라메터만 받는 타입 생성자고, Either는 두개의 파라메터가 필요하기 때문에 Either a를 사용하였습니다. 이것을 fmap에 적용해보면 (b -> c) -> (Either a) b -> (Either a) c이고, (b -> c) -> Either a b -> Either a c와 같습니다.

구현부를 살펴보면, Right 값 생성자의 경우는 (f x)로 함수에 맵핑하였지만 Left는 그렇게 하지 않았습니다. 이렇게된 이유는 아래 Either a b 타입의 정의 때문입니다.

data Either a b = Left a | Right b

LeftRight를 하나의 함수에 맵핑하려면 ab가 같은 타입이어야 합니다. 예를들어 만약 맵핑 함수가 문자열을 받아서 문자열을 리턴하고 b는 문자열인데, a는 숫자 타입이라면 동작되지 않습니다.

fmap의 타입이 Either 값에서만 동작하는 것을 보면, 첫번째 파라메터는 동일하게 유지되어야하는 반면에 두번째 파라메터는 변경될 수 있습니다. 그리고 첫번째 파라메터의 타입은 Left 값 생성자에 의해서 정해집니다.

이것을 컨테이너로 비유하면, Left 왜 비어있는지를 알려주는 메시지가 측면에 적여있는 빈 컨테이너로 생각할 수 있습니다.

Data.Map의 맵들도 어떤 값들을 가지고 있을 수 있기때문에 Functor로 만들 수 있습니다. Map k v의 경우, fmapMap k v 타입의 맵에 v -> v' 맵핑 함수를 적용하여 Map k v'를 리턴합니다.

'는 특별한 의미를 가진 것은 아니고, 하스켈에서 변수명에 사용할 수 있는 문자입니다. 대개 유사한데 약간 바꼈을때 사용합니다.

여기서 Map k에 대한 Functor는 직접 만들어 보시기 바랍니다!

Functor 타입클래스를 통해 타입클래스들이 higher-order 컨셉을 어떻게 나타낼 수 있는지 알았습니다. 또한 부분 적용된 타입으로 인스턴스를 만드는 방법도 연습하였습니다. 다음 챕터에서는 Functor를 사용하기 위한 몇가지 법칙에 대해서 살펴볼 것 입니다.

타입의 종류 Kinds

타입 생성자는 다른 타입들을 파라메터로 받아서 구체화된 타입들을 생성합니다. 이것은 파라메터로 값들을 받아서 값들을 생성하는 함수와 유사합니다. 타입 생성자들은 함수처럼 부분 적용이 가능합니다.(예를들어, Either String은 한개의 타입을 받아서 Either String Int와같은 구체적인 타입을 생성하는 타입입니다.)

이번 섹션에서는 타입 선언을 사용해서 함수에 값을 적용하는 방법을 정의한 것처럼 타입 생성자에 타입을 적용하는 방법을 정의할 것 입니다. 여기서 다루는 내용을 이해하지 못한다해도 앞으로 하스켈을 공부하는데 지장은 없습니다. 하지만 이 절에서 설명하는 것을 이해하면 타입 시스템에 대한 깊은 이해를 얻을 수 있습니다.

3, "YEAH", takeWhile과 같은 값들은 타입을 가집니다.(함수도 값으로 넘길 수 있기때문에 값입니다.) 타입들은 값들에 대한 설명을 해주는 값들이 담은 라벨입니다. 하지만 타입은 kinds라고 불리는 그들 자신에 대한 라벨을 가지고 있습니다. kind는 타입의 타입입니다. 좀 이상하고 혼란 스럽게 들리지만 실제로는 굉장한 개념입니다. 그렇다면 kinds는 무엇이고 무엇이 좋을까요?

GHCI에서 :k 명령을 사용하면 타입의 kind를 알 수 있습니다.

ghci> :k Int
Int :: *

여기서 *는 구체적인 타입을 의미합니다. 구체적인 타입은 타입 파라메터를 받지않는 타입이고, 값은 이 타입만 결정되어 다른 구체적인 타입이 될 수 없습니다.

Maybe의 kind는 아래와 같습니다.

ghci> :k Maybe
Maybe :: * -> *

Maybe 타입 생성자는 한개의 Int와 같은 구체적인 타입을 받아서 Maybe Int와 같은 구체적인 타입을 리턴합니다. Maybe에 타입 파라메터를 적용해보면 kind는 아래와 같습니다.

ghci> :k Maybe Int
Maybe Int :: *

예상대로 Maybe에 타입 파라메터를 넣으면 구체적인 타입이 리턴됩니다. :t isUpper:t isUpper 'A'을 실행해보면 isUpper의 타입은 Char -> Bool이고, isUpper 'A'은 값이 항상 True이기때문에 타입은 Bool입니다. 하지만 Char -> BoolTrue의 kind는 모두 *입니다. 따라서 타입과 kind는 완전히 다른 개념입니다. 타입은 값의 라벨이고, kind는 타입의 라벨이며 이 둘은 수렴하지 않습니다.

Either의 kind를 살펴보겠습니다.

ghci> :k Either
Either :: * -> * -> *

Either는 구체적인 타입을 만들기 위한 타입 파라메터로 두개의 구체적인 타입을 받습니다. Either도 두개의 값을 받아서 어떤 것을 리턴하는 함수의 타입 선언과 유사합니다. 타입 생성자도 함수처럼 커링이되서 부분 적용이 가능합니다.

ghci> :k Either String
Either String :: * -> *
ghci> :k Either String Int
Either String Int :: *

EitherFunctor 타입클래스에 포함되도록할때, Either의 부분 적용을 사용했었습니다. 이렇게 했던 이유는 Either는 두개의 파라메터가 필요한데 Functor는 한개의 파라메터만 받기 때문입니다. 다시말해서 Functor의 kind는 * -> *인데 Either의 kind인 * -> * -> *에 적용하기 위해서 * -> *를 부분 적용한 것 입니다. 다시한번 Functor의 클래스 선언을 보면 아래와 같습니다.

class Functor f where   
    fmap :: (a -> b) -> f a -> f b

여기서 Functor의 구체적인 타입을 만들기 위해서 f 구체적인 타입변수 하나만 받는 것을 확인할 수 있습니다. 따라서 Functor의 kind는 * -> *가 됩니다.

이제 Tofo라는 타입클래스를 예로들어 보겠습니다.

class Tofu t where  
    tofu :: j a -> t a j

j atofu 함수가 매개변수로 받는 값의 타입으로 사용되기 때문에 j a의 kind는 *(구체적인 타입)이어야 합니다. j a에서 a*로 가정하면 j의 kind는 * -> *입니다. t는 두개의 타입을 받아서 구체적인 타입을 생성해야 합니다. t a j에서 a의 kind는 *j의 kind는 * -> *이므로 t의 kind는 * -> (* -> *) -> *입니다. 따라서 구체적인 타입 a와 구체적인 타입 j를 받는 타입 생성자를 받아서 구체적인 타입을 생성합니다.

kind가 * -> (* -> *) -> *인 타입을 하나 만들어보면 아래와 같습니다.

data Frank a b  = Frank {frankField :: b a} deriving (Show)

a*(구체적인 타입)으로 가정하면, b는 하나의 타입 파라메터 받으므로 kind는 * -> *입니다. 여기서 abFrank의 두개의 파라메터이므로 kind가 * -> (* -> *) -> *가 됩니다. 첫번째 *a이고 (* -> *)b입니다. 이제 Frank의 값을 만들어 타입을 확인해 보겠습니다.

ghci> :t Frank {frankField = Just "HAHA"}
Frank {frankField = Just "HAHA"} :: Frank [Char] Maybe
ghci> :t Frank {frankField = Node 'a' EmptyTree EmptyTree}
Frank {frankField = Node 'a' EmptyTree EmptyTree} :: Frank Char Tree
ghci> :t Frank {frankField = "YES"}
Frank {frankField = "YES"} :: Frank Char []

frankFielda b 현태의 타입을 가지고 있기 때문에, 값들도 유사한 형태의 타입을 가져야만 합니다. 따라서 Maybe [Char]을 타입으로 가지는 Just "HAHA"가 될 수 있습니다. 또한 [Char](=List Char) 타입인 ['Y','E','S']도 가능합니다. 여기서 Frank의 값들의 타입은 Frank의 kind와 상응한다는 것을 알 수 있습니다. [Char]의 kind는 *이고 Maybe의 kind는 * -> *입니다. 값을 가지려면 구체적인 타입어야하고 그래서 완전히 적용되어하기 때문에 Frank blah blaah의 모든 값의 kind는 *입니다.

FrankTofu 타입클래스의 인스턴스로 만들면 아래와 같습니다. tofuj a(예를들어 Maybe Int)를 받아서 t a j 리턴합니다. 여기서 Frankj와 바꾸면, 결과는 Frank Int Maybe가 됩니다.

instance Tofu Frank where  
    tofu x = Frank x
ghci> tofu (Just 'a') :: Frank Char Maybe
Frank {frankField = Just 'a'}
ghci> tofu ["HELLO"] :: Frank [Char] []
Frank {frankField = ["HELLO"]}

한가지 예를 더 살펴보겠습니다.

data Barry t k p = Barry { yabba :: p, dabba :: t k }

Functor의 kind는 * -> *인데 Barry는 3개의 파라메터를 받아서 something -> something -> something -> * 형태가 될 것 입니다. Barry 예에서 p의 kind는 *입니다. k*로 가정하면 t의 kind는 * -> *입니다. 이제 something을 kind로 바꾸어 보면 (* -> *) -> * -> * -> *가 됩니다. GHCI에서 확인해 보면 아래와 같습니다.

ghci> :k Barry
Barry :: (* -> *) -> * -> * -> *

이제 Functor의 인스턴스로 만드려면 앞의 두개의 타입 파라메터를 부분 적용해서 왼쪽을 * -> *로 만들어야 합니다. 이말은 인스턴스 선언의 시작이 instance Functor (Barry a b) where된다는 것을 의미합니다. 이것을 fmap에 적용해서 FunctorfBarry c d로 바꾸어 보면, fmap :: (a -> b) -> Barry c d a -> Barry c d b가 됩니다. Barry의 세번째 타입 파라메터가 자체 필드로 변경되어 들어갔습니다.

instance Functor (Barry a b) where  
    fmap f (Barry {yabba = x, dabba = y}) = Barry {yabba = f x, dabba = y}

이번 섹션에서는 타입 파라메터가 동작하는 방식을 보고, 타입 선언으로 함수 파라메터를 형식화한 것 처럼, 타입을 형식화한 kind를 살펴 보았습니다. 여기서 함수와 타입 생성자는 유사하지만 두가지 차이점을 발견할 수 있습니다. 실제 하스켈이 동작할때, 직접 kind를 다룰 필요는 없습니다. 보통 표준 타입클래스의 인스턴스를 만들때, 자체 정의한 타입을 * -> *, 또는*으로 부분 적용할 뿐입니다. 이번 섹션에서 다룬 내용을 이해할 필요는 없지만, kind가 어떻게 동작하는지 이해하면 하스켈의 타입시스템을 좀 더 깊이 파악할 수 있습니다.

results matching ""

    No results matching ""