INDIES
[Learn You a Haskell For Great Good!] 8. 자신만의 타입과 타입 클래스 만들기 (1) 본문
[Learn You a Haskell For Great Good!] 8. 자신만의 타입과 타입 클래스 만들기 (1)
jwvg0425 2015. 6. 7. 20:23Learn You a Haskell For Great Good!
이 게시글은 http://learnyouahaskell.com/chapters 사이트에 올라와있는 글을 한글로 번역한 것입니다.의역이 굉장히 많으니 주의...
8. 자신만의 타입과 타입 클래스 만들기
이전 챕터에서, 우린 Haskell에 존재하는 몇 가지 타입들과 타입 클래스들을 살펴봤어. 이번 챕터에서는 우리만의 타입, 타입클래스들을 만드는 방법과 그것을 사용하는 방법에 대해 알아볼거야!
대수적(Algebraic) 데이터 타입 소개
지금까지, 우리는 Bool, Int, Char, Maybe, 기타 등등의 많은 데이터 타입을 겪어봤어. 하지만 우리만의 타입을 만들려면 어떻게 해야할까? 그 방법중 하나는 데이터 타입을 정의하기 위해 data 키워드를 사용하는 거야. 표준 라이브러리에서 Bool 타입이 어떻게 정의되어 있는지 살펴보자.
- data Bool = False | True
data는 우리가 새로운 데이터 타입을 정의할 거라는 걸 의미해. = 이전 부분이 타입을 의미하고(여기서는 Bool이지), 그 이후 부분이 값 생성자(value constructors)를 의미해. 값 생성자는 해당 타입이 가질 수 있는 서로 다른 값들을 명시해주지. |는 또는(or)이라고 읽어. 따라서 우리는 위 문장을 이렇게 읽을 수 있지. "Bool 타입은 False 또는 True라는 값을 가질 수 있다." 타입의 이름과 값 생성자의 이름은 모두 대문자로 시작해야해.
비슷한 방식으로, 우린 Int가 다음과 같이 정의되어 있을 거라고 생각할 수 있어.
- data Int = -2147483648 | -2147483647 | ... | -1 | 0 | 1 | 2 | ... | 2147483647
첫번째와 마지막 값 생성자가 Int 타입이 가질 수 있는 가능한 한 가장 작은 값과 가장 큰 값이지. 실제로는 위처럼 정의되어있진 않아. 생략부호로 표시한 부분들에 실제로 Int 타입이 가질 수 있는 값들이 들어가야하니까. 이건 그냥 설명하기 위한 목적으로 쓴 거야.
이제, Haskell에서 어떻게 하면 도형을 나타낼 수 있을 지 생각해보자. 한 가지 방법은 튜플을 사용하는 거야. 원은 (43.1, 55.0, 10.4)와 같이 표기되고 여기서 첫번째, 두 번째 원소는 각각 원의 중점, 세 번째 원소는 원의 반지름을 나타내는 거지. 괜찮은 방법 같아보이긴 하지만, 이건 3D 공간 상의 벡터나 혹은 다른 어떤 것을 나타내는 게 될 수도 있어. 더 나은 방법은 도형을 나타내는 우리만의 타입을 정의하는 거지. 다음과 같이 도형이 원이나 사각형이 될 수 있다고 하자.
- data Shape = Circle Float Float Float | Rectangle Float Float Float Float
위 코드는 이렇게 생각할 수 있어. Circle 값 생성자는 3개의 필드(field)를 갖고 있는데, 그 세 개는 모두 float이야. 값 생성자를 작성할 때 추가적으로 뒤에 몇 개의 타입을 덧붙일 수 있고 이렇게 덧붙여진 타입은 곧 해당 값이 포함하는 값들이 돼. 여기서, 처음 두 필드는 원의 중점, 세 번째는 원의 반지름을 나타내지. Rectangle 값 생성자는 float을 받아들이는 4개의 필드를 갖고 있어. 처음 두 개의 필드는 사각형의 왼쪽 위 점의 좌표를, 나머지 두 개의 필드는 사각형의 오른쪽 아래 점의 좌표를 나타내지.
지금까지 내가 필드라고 말하긴 했는데, 사실 나는 이걸 매개변수(parameter)의 의미로 말한 거야. 값 생성자는 실제론 결국 해당 타입의 값을 반환하는 함수거든. 이 두가지 값 생성자의 타입 서명을 한 번 살펴보자.
- ghci> :t Circle
- Circle :: Float -> Float -> Float -> Shape
- ghci> :t Rectangle
- Rectangle :: Float -> Float -> Float -> Float -> Shape
좋아, 그러니까 결국 값 생성자는 다른 모든것들처럼 함수야. 누가 이럴거라고 생각이나 했겠어? 이제 도형을 인자로 받아서 그 표면적을 리턴하는 함수를 한 번 만들어보자.
- surface :: Shape -> Float
- surface (Circle _ _ r) = pi * r ^ 2
- surface (Rectangle x1 y1 x2 y2) = (abs $ x2 - x1) * (abs $ y2 - y1)
여기서 첫번째로 주의해야할 점은 바로 이 함수의 타입 선언이야. 지금 이 함수는 Shape를 인자로 받아서 float을 리턴한다고 되어있지. 우리는 이 함수의 타입 선언을 Circle->float이라고 쓸 수 없어. 왜냐하면 Circle이 타입인게 아니라, Shape가 타입이거든. 그냥 뭐 타입 선언을 True -> Int라고 쓸 수는 없는 거랑 똑같아. 우리가 여기서 알아차려야 할 다음 부분은 바로 우리가 생성자에 대해 패턴 매칭을 할 수 있다는 거야. 실제론 지금까지 항상 우리는 생성자에 대해 패턴매칭을 해왔어. [], False, 혹은 5와 같은 값들 말이야. 다만 이런 값 생성자들은 어떤 필드도 갖고 있지 않았을 뿐이지. 위 코드에서 우린 그냥 생성자를 쓰고 해당 생성자의 필드에 이름을 바인딩했어. 왜냐하면 우린 반지름에만 관심이 있고 첫 두 개의 필드(원이 있는 위치)엔 관심이 없었거든.
- ghci> surface $ Circle 10 20 10
- 314.15927
- ghci> surface $ Rectangle 0 0 100 100
- 10000.0
좋아, 굉장히 잘 동작해. 하지만 프롬프트 창에서 Circle 10 20 5를 그냥 출력하려고 한다면, 우린 에러를 맞닥뜨리게 될 거야. 왜냐하면 Haskell은 아직 해당 타입을 어떻게 해야 문자열의 형태로 화면에 출력할 수 있는지 모르거든. 기억해, 우리가 프롬프트 창에서 값을 출력하려고 시도하면 Haskell은 맨 먼저 해당 값을 나타내는 문자열 표현을 얻기 위해 show 함수를 수행하고 해당 문자열을 터미널에 출력해. Shape 타입을 Show 타입 클래스의 일부로 만들고 싶다면, 위 코드를 다음과 같이 수정해야 돼.
- data Shape = Circle Float Float Float | Rectangle Float Float Float Float deriving (Show)
지금은 상속(deriving)에 대해선 별로 신경쓰지 않아도 괜찮아. 그냥 data 선언의 맨 끝에 deriving (show)를 붙여주면 Haskell이 자동으로 해당 타입을 Show 타입 클래스의 일부로 만들어준다고 생각하면 돼. 그래서, 우린 이제 이렇게 할 수 있어.
- ghci> Circle 10 20 5
- Circle 10.0 20.0 5.0
- ghci> Rectangle 50 230 60 90
- Rectangle 50.0 230.0 60.0 90.0
값 생성자들이 함수기 때문에, 우린 이걸 매핑할 수도 있고 부분 적용시킬 수도 있고, 그 외의 모든 것들도 다 할 수 있어. 만약 서로 다른 반지름을 가진 동심원들의 리스트를 원한다면, 이렇게 하면 돼.
- ghci> map (Circle 10 20) [4,5,6,6]
- [Circle 10.0 20.0 4.0,Circle 10.0 20.0 5.0,Circle 10.0 20.0 6.0,Circle 10.0 20.0 6.0]
우리의 데이터 타입은 꽤 괜찮지만, 여기서 더 나아질 수 있어. 2차원 공간의 한 점을 나타내는 중간 타입을 정의해보자. 그럼 우리 타입을 좀 더 이해하기 쉽게 정의할 수 있어.
- data Point = Point Float Float deriving (Show)
- data Shape = Circle Point Float | Rectangle Point Point deriving (Show)
여기서 2차원 공간상의 점을 정의할 때, 데이터 타입과 값 생성자에 모두 같은 이름을 썼어. 특별한 의미는 없지만, 타입이 단 하나의 값 생성자만을 가질 땐 값 생성자의 이름과 타입의 이름이 같은게 일반적이야. 어쨌든 그래서 이제 Circle은 Point와 Float이라는 두 개의 필드를 갖게 됐고, 이게 이 값 생성자의 의미가 뭔지 이해하기 더 편하지. Rectangle 역시 마찬가지고. 아까 작성한 surface 함수도 이 변화에 맞춰 수정할 필요가 있어.
- surface :: Shape -> Float
- surface (Circle _ r) = pi * r ^ 2
- surface (Rectangle (Point x1 y1) (Point x2 y2)) = (abs $ x2 - x1) * (abs $ y2 - y1)
우리가 바꿔야할 건 패턴밖에 없어. Circle 패턴에서는 Point에 대해 완전히 무시해도 돼. Rectangle 패턴에서는, 점의 필드를 얻기 위해 패턴 매칭을 중첩해서 이용하기만 하면 되고. Point 자체를 가리키고 싶다면 패턴을 사용하듯이 쓸 수 있어.
- ghci> surface (Rectangle (Point 0 0) (Point 100 100))
- 10000.0
- ghci> surface (Circle (Point 0 0) 24)
- 1809.5574
Shape를 평행이동 시키는 함수는 어떨까? 이 함수는 Shape와 해당 Shape를 x,y축 방향으로 각각 얼만큼 이동시킬 지를 인자로 받아서 같은 크기를 가지되 위치만 다른 Shape를 리턴하는거지.
- nudge :: Shape -> Float -> Float -> Shape
- nudge (Circle (Point x y) r) a b = Circle (Point (x+a) (y+b)) r
- nudge (Rectangle (Point x1 y1) (Point x2 y2)) a b = Rectangle (Point (x1+a) (y1+b)) (Point (x2+a) (y2+b))
굉장히 직관적이야. 평행이동시킬 크기를 해당 x,y좌표에 더하기만 하면 되지.
- ghci> nudge (Circle (Point 34 34) 10) 5 10
- Circle (Point 39.0 44.0) 10.0
만약 Point들을 직접적으로 다루길 원하지 않는다면, 특정 크기의 원을 0,0 위치에 생성하는 보조 함수를 만들어서 그걸 평행이동시킬 수도 있어.
- baseCircle :: Float -> Shape
- baseCircle r = Circle (Point 0 0) r
- baseRect :: Float -> Float -> Shape
- baseRect width height = Rectangle (Point 0 0) (Point width height)
- ghci> nudge (baseRect 40 100) 60 23
- Rectangle (Point 60.0 23.0) (Point 100.0 123.0)
당연한 이야기지만, 네가 만든 데이터 타입도 모듈로 내보낼 수 있어. 그렇게 하고 싶다면 그냥 네가 내보낼 함수들을 따라 네가 작성한 타입을 쓴 뒤 거기에 몇 개의 소괄호를 더하고 네가 내보내길 원하는 값 생성자들을 ,를 이용해 구분해서 기록하면 돼. 만약 주어진 타입에 대한 모든 값 생성자를 내보내기 원한다면, 그냥 ..라고 써.
예를 들어 여기서 이 챕터에서 지금까지 예제로 작성한 타입들과 함수들을 모두 모듈로 내보내고 싶다면, 다음과 같이 쓰면 돼.
- module Shapes
- ( Point(..)
- , Shape(..)
- , surface
- , nudge
- , baseCircle
- , baseRect
- ) where
Shape(..)라고 쓰면 Shape의 모든 값 생성자들을 내보내게 되지. 무슨 뜻이냐면 이 모듈을 사용하는 쪽에선 누구나 Rectangle 또는 Circle를 이용해서 Shape를 생성할 수 있다는 거야. 이건 Shape(Rectangle, Circle)라고 쓰는거랑 똑같아.
혹은 export 구문에 단지 Shape만 써넣음으로써 Shape에 대한 값 생성자를 어느 것도 내보내지 않기로 결정할 수도 있어. 이렇게 되면, 모듈을 사용하는 쪽에서는 baseCircle, baseRect와 같은 보조 함수를 이용해서만 shape를 만들 수 있게 되지. Data.Map이 이러한 접근법을 사용하고 있어. 그래서 너는 Map.Map [ (1,2), (3,4) ]와 같은 방법으로 Map을 생성할 수 없지. 왜냐하면 이 모듈은 값 생성자를 외부로 내보내지 않거든. 대신에 Map.fromList와 같은 보조 함수중 하나를 이용해 매핑해서 맵을 만들 수 있지. 잘 기억해둬. 값 생성자는 필드를 매개변수처럼 받아서 그 결과로 어떤 타입의 값을 반환하는(Shape 처럼) 그냥 함수일 뿐이야. 따라서 모듈에서 어떤 걸 내보내고 어떤걸 내보내지 않을지 결정할 때, 이 함수들을 모듈을 쓰는 사람이 사용하지 못하게끔 막을 수 있어. 다만 내보내기로 결정한 다른 함수들이 이 타입의 값을 리턴한다면 그걸 이용해서 커스텀 데이터 타입을 쓸 수 있겠지.
데이터 타입의 값 생성자를 내보내지 않는 건 그들의 구현을 숨김으로써 해당 데이터 타입을 어떤 면에서 더 추상적으로 만들어줘. 또, 우리 모듈을 쓰는 사람들이 값 생성자에 대한 패턴매칭을 쓰지 못하게 되지.
레코드 구문(Record Syntax)
좋아, 이번엔 사람을 표현하는 데이터 타입을 만들어보자. 우리가 사람이라는 데이터타입에 저장하고 싶은 정보는 다음과 같아. "성, 이름, 나이, 키, 휴대폰 번호, 그리고 좋아하는 아이스크림". 난 너에 대해선 잘 모르지만, 내가 어떤 사람에 대해 알고 싶은 정보는 이게 다야. 자, 한 번 해보자!
- data Person = Person String String Int Float String String deriving (Show)
좋아. 이렇게 하면 첫번째 필드가 성, 두번째가 이름, 세번째가 나이 그리고 기타 등등이 되겠지. 이제 사람을 만들어 보자.
- ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
- ghci> guy
- Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
이것도 뭐 괜찮지만, 문제는 좀 가독성이 떨어져. 사람에 대한 정보를 분리해내는 함수를 만들고 싶다면 어떻게 해야할까? 사람의 이름을 얻는 함수, 사람의 성을 얻는 함수, 뭐 기타등등 이런 종류의 함수들 말야. 음, 그런 경우엔 코드를 아래와 같이 짜면 될거야.
- firstName :: Person -> String
- firstName (Person firstname _ _ _ _ _) = firstname
- lastName :: Person -> String
- lastName (Person _ lastname _ _ _ _) = lastname
- age :: Person -> Int
- age (Person _ _ age _ _ _) = age
- height :: Person -> Float
- height (Person _ _ _ height _ _) = height
- phoneNumber :: Person -> String
- phoneNumber (Person _ _ _ _ number _) = number
- flavor :: Person -> String
- flavor (Person _ _ _ _ _ flavor) = flavor
휴! 이 긴 코드를 일일히 다 쓰는 게 별로 재밌는 일은 아닐거야. 이 코드가 길고 복잡하고 쓰기 재미없긴 하지만 이 방법이 제대로 동작하는 방법이긴 해.
- ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
- ghci> firstName guy
- "Buddy"
- ghci> height guy
- 184.2
- ghci> flavor guy
- "Chocolate"
분명 이것보다 더 나은 방법이 있을거야, 네가 지금 생각한 것처럼! 미안하지만, 안타깝게도 그런 방법따윈 없어.
사실 농담이야. 하하하! Haskell을 만든 사람들은 굉장히 똑똑하고 이런 시나리오를 불쾌하게 생각했어. 그래서 데이터 타입을 쓰기 위한 다른 방법을 포함시켰지. 여기 이게 바로 레코드 구문을 이용해서 위의 기능을 구현하는 방법이야.
- data Person = Person { firstName :: String
- , lastName :: String
- , age :: Int
- , height :: Float
- , phoneNumber :: String
- , flavor :: String
- } deriving (Show)
타입의 이름을 정하고 해당 타입이 쓸 필드의 타입을 연달아 정하는 대신에, 여기선 중괄호를 사용했어. 먼저 각 필드의 이름을 적고(예를 들어 firstName 같이), 더블 콜론(::)을 쓴 후 해당 필드의 타입을 명시하면 돼. 그 결과로 나온 데이터 타입은 우리가 방금 만든 데이터 타입과 완전히 똑같아. 이렇게 만들었을 때의 주된 장점은 바로 각 데이터 타입의 필드를 찾는 함수가 생성된다는 거지. 데이터 타입을 만들기 위해 레코드 구문을 사용하면, Haskell은 자동으로 firstName, lastName, age, height, phoneNumber, flavor 같은 함수를 만들어줘.
- ghci> :t flavor
- flavor :: Person -> String
- ghci> :t firstName
- firstName :: Person -> String
레코드 구문을 사용했을 때의 또다른 장점이 있어. 타입에 대해 Show를 상속받으려고 할 때, 레코드 구문을 이용해서 타입을 정의하고 인스턴스화했을 때에는 화면에 나타나는 문자열이 달라. 자동차를 나타내는 타입이 있다고 하자. 이 타입은 자동차를 만든 회사와, 모델 이름, 그리고 생산된 연도를 저장해. 봐봐.
- data Car = Car String String Int deriving (Show)
- ghci> Car "Ford" "Mustang" 1967
- Car "Ford" "Mustang" 1967
이걸 레코드 구문을 이용해 정의하면, 우린 새로운 자동차를 이렇게 만들 수 있어.
- data Car = Car {company :: String, model :: String, year :: Int} deriving (Show)
- ghci> Car {company="Ford", model="Mustang", year=1967}
- Car {company = "Ford", model = "Mustang", year = 1967}
새로운 자동차를 만들 때, 각 필드의 값을 모두 명시하기만 하면 필드들을 꼭 적절한 순서에 맞춰 작성할 필요가 없어. 하지만 레코드 구문을 사용하지 않는다면 반드시 각 필드들을 선언된 순서에 맞춰서 작성해야만 하지.
생성자가 여러 개의 필드를 갖고 있고 어떤 필드가 뭘 뜻하는지가 명확하지 않을 때만 레코드 구문을 써. 3차원 벡터 데이터 타입 같은 걸 만들려고 한다면 그냥 data Vector = Vector Int Int Int 라고 쓰는 편이 더 명확해. 반면에, Person 타입이나 Car 타입처럼 각각의 필드가 의미하는 바가 명확하지 않을 때 레코드 구문을 쓰면 많은 이득을 볼 수 있지.
타입 매개변수(Type parameters)
값 생성자는 몇 개의 값 매개변수를 받아서 새로운 값을 만들어낼 수 있어. 예를 들어서, Car 생성자는 3개의 값을 받아서 하나의 Car 값을 만들어내지. 비슷한 방법으로, 타입 생성자는 타입을 매개변수로 받아서 새로운 타입을 만들어내. 처음엔 너무 형이상학적으로 들릴 수 있지만, 알고보면 그렇게 복잡한 개념은 아니야. C++의 템플릿 개념에 익숙하다면 그거랑 거의 비슷하다고 생각하면 돼. 타입 매개변수가 실제로 어떻게 동작하는지 명확히 알기 위해, 우리가 이미 경험해본 타입이 실제로 어떻게 구현되는지 살펴보자.
- data Maybe a = Nothing | Just a
여기서 a가 바로 타입 매개변수야. 그리고 타입 변수를 포함하기 때문에, Maybe는 타입 생성자라고 부를 수 있어. 이 데이터 타입이 들고 있는 값이 Nothing이 아니라면, 이 타입 생성자가 Maybe Int 타입, Maybe Car 타입, Maybe String 타입 등등의 타입을 만들어내지. 어떤 값도 그냥 Maybe라는 타입을 가질 수는 없어. 왜냐하면 Maybe는 그 자체로는 타입이 아니라 타입 생성자기 때문이야. 이게 실제로 값을 생성할 수 있는 타입이 되려면 반드시 모든 타입 매개변수를 채워넣어야 해.
그래서 만약 Maybe 타입에 타입 매개변수로 Char을 넘긴다면, Maybe Char 타입을 얻게 될거야. Just 'a'라는 값이 Maybe Char 타입의 값이 되겠지.
아마 넌 몰랐겠지만, 우린 Maybe 타입을 배우기 전에 이미 타입 매개변수를 사용한 적이 있어. 그게 뭐냐면 바로 리스트 타입이야. 단순하게 표현하자면, 리스트 타입은 실제 타입을 생성하기 위해 매개변수를 취해. [Int] 타입, [Char] 타입, [[String]] 타입 등등의 값은 존재할 수 있지만 [] 타입의 값이라는 건 존재할 수 없지.
Maybe 타입을 한 번 가지고 놀아보자.
- ghci> Just "Haha"
- Just "Haha"
- ghci> Just 84
- Just 84
- ghci> :t Just "Haha"
- Just "Haha" :: Maybe [Char]
- ghci> :t Just 84
- Just 84 :: (Num t) => Maybe t
- ghci> :t Nothing
- Nothing :: Maybe a
- ghci> Just 10 :: Maybe Double
- Just 10.0
타입 매개변수는 우리의 데이터 타입에 포함시키고 싶은 타입의 종류가 무엇인지에 따라 서로 다른 타입들을 만들어주기 때문에 굉장히 유용해. 만약 :t Just "Haha" 라는 명령어를 친다면, 타입 추론 엔진은 이게 Maybe [Char] 타입이라는 걸 알아낼거야. 왜냐하면 Just a에서 a가 String이고, 그렇다면 Maybe a에서의 a역시 String일테니까.
Nothing의 타입 역시 Maybe a라는 점을 알아둬야해. 이녀석의 타입은 다형적(polymorphic)이야. 만약 어떤 함수가 Maybe Int 타입을 매개변수로 받는다면, 거기에 Nothing을 인자로 넘겨줄 수 있어. 왜냐하면 Nothing은 어쨌든 어떤 값도 담고 있지 않고 그래서 타입에 상관없이 아무런 문제가 발생하지 않기 때문이지. Maybe a 타입은 필요하다면 Maybe Int 타입처럼 동작할 수 있어. 5가 Int나 Double처럼 동작할 수 있는 것과 마찬가지로 말야. 비슷하게, 텅 빈 리스트의 타입은 [a]야. 텅 빈 리스트는 리스트 타입의 종류에 상관없이 사용할 수 있지. 그래서 [1,2,3] ++[]이나 ["ha", "ha", "ha"]++[]같은 코드를 쓸 수 있어.
타입 매개변수를 사용하는 건 굉장히 이득이 많지만, 그게 합리적일 때만 사용해야돼. 보통 타입 매개변수는 만들 데이터 타입이 해당 값이 내부적으로 들고 있는 타입이 무엇인지에 상관없이 동작해야할 필요가 있을 때만 써. Maybe a 타입처럼 말야. 만들 타입이 box의 일종처럼 동작하는 상황이 타입 매개변수를 쓰기 적절한 상황이지. 아래의 Car 타입을
- data Car = Car { company :: String
- , model :: String
- , year :: Int
- } deriving (Show)
다음처럼 바꿔보자.
- data Car a b c = Car { company :: a
- , model :: b
- , year :: c
- } deriving (Show)
이게 과연 좋은걸까? 아마 그렇지 않다는게 답일거야. 왜냐하면 Car 타입은 그냥 Car String String Int 타입인 경우에만 제대로 동작하는 함수를 만들면 되거든. 예를 들어, 원래의 Car 타입에 대해 해당 Car의 특징을 간결하게 출력해주는 함수를 만든다고 생각해보자.
- tellCar :: Car -> String
- tellCar (Car {company = c, model = m, year = y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y
- ghci> let stang = Car {company="Ford", model="Mustang", year=1967}
- ghci> tellCar stang
- "This Ford Mustang was made in 1967"
아주 간단한 함수지! 타입 선언도 간단하고 동작도 깔끔해. 반면에 Car 타입이 Car a b c인 경우는 어떨까?
- tellCar :: (Show a) => Car String String a -> String
- tellCar (Car {company = c, model = m, year = y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y
일단 이 함수가 Car 타입을 (Show a) => Car String String a를 받도록 강제할 필요가 있어. 이 때문에 타입 서명이 길고 복잡해지지. 그 댓가로 얻을 수 있는 장점이라곤 c 타입을 Show 타입 클래스의 어느 타입이든 사용할 수 있다는 것 뿐이야.
- ghci> tellCar (Car "Ford" "Mustang" 1967)
- "This Ford Mustang was made in 1967"
- ghci> tellCar (Car "Ford" "Mustang" "nineteen sixty seven")
- "This Ford Mustang was made in \"nineteen sixty seven\""
- ghci> :t Car "Ford" "Mustang" 1967
- Car "Ford" "Mustang" 1967 :: (Num t) => Car [Char] [Char] t
- ghci> :t Car "Ford" "Mustang" "nineteen sixty seven"
- Car "Ford" "Mustang" "nineteen sixty seven" :: Car [Char] [Char] [Char]
하지만 실세계에서, 결국 쓰는 타입은 거의 대부분 Car String String Int 하나뿐일거야. 그래서 Car 타입이 타입 매개변수를 받는 건 별로 큰 효과를 볼 수가 없지. 타입 매개변수는 만들고자 하는 타입의 다양한 값 생성자가 내부적으로 가지는 타입들이 정말로 어떤 타입이든 상관없이 동작하게 만들기 위해 중요할 때만 써. 리스트는 말 그대로 리스트고 해당 리스트가 갖고 있는 타입이 뭐든지 상관없이 잘 동작해야만 해. 만약 숫자로 된 리스트에서 해당 리스트의 모든 원소들의 합을 구하고 싶다면, 나중에 숫자의 리스트를 인자로 받음을 명확하게 표기한 함수를 작성함으로써 구현할 수 있어. Maybe의 경우도 마찬가지야. Maybe는 아무것도 가지고 있지 않은지, 아니면 어떤 걸 하나만 갖고 있는지에 대한 옵션을 나타내. 이건 그 '어떤 것'이 무슨 타입인지 전혀 상관없이 동작해야하지.
일반화된 타입(parameterized type - 타입 매개변수를 가지는 타입)에 대한 또다른 예제는 이미 이전에 우리가 다뤄본 적이 있는 Data.Map의 Map k v야. k는 map의 키들의 타입을 말하고, v는 맵의 값들의 타입을 말하지. 이건 타입 매개변수가 매우 유용하게 쓰인 좋은 예시야. 맵은 타입 매개변수를 받음으로써 우리가 타입에 상관없이 두 개의 타입을 서로 연관시킬 수 있게 해주지(맵 자료구조의 특성상 키의 타입은 Ord 타입 클래스에 속하는 타입이어야하지만 말야). 맵 타입의 이런 타입 클래스 제약도 data 선언에 타입 클래스 제약을 추가함으로써 구현할 수 있어.
- data (Ord k) => Map k v = ...
하지만, data 선언에서 절대 타입 클래스 제약을 추가하지 말라 라는 Haskell의 아주 강력한 코딩 규약이 있어. 왜? 음, 왜냐하면 그렇게 해서 얻을 수 있는 이득이 별로 없는 반면에, 별 필요없는 클래스 제약까지 더 적어야하는 상황과 맞닥뜨려야하기 때문이야. Map k v에 대해 Ord k라는 제약을 넣든 안 넣든, 맵의 키가 정렬될 수 있다는 가정이 필요한 함수에 대해선 동일한 제약을 적어줘야돼. 하지만 data 선언에 제약을 집어넣지 않는다면, 키가 정렬될 수 있든 아니든 상관없는 함수에 대해선 (Ord k) => 라는 제약을 적어주지 않아도 되지. 예를 들어 toList와 같은 함수는 그냥 맵을 인자로 받아서 그걸 연관 리스트의 형태로 바꿔줘. 이 함수의 타입 서명은 toList :: Map k a -> [(k, a)]야. 만약 Map k v가 data 선언에서 타입 제약조건을 갖고 있었다면, toList의 타입 서명은 toList :: (Ord k) => Map k a -> [(k, a)]가 되어야했을 거야. 이 함수는 키값이 정렬되어있든 아니든 아무런 상관없음에도 불구하고 말이지.
그래서 타입 제약조건을 data 선언에 넣는건 그게 합리적인 것 같아 보인다 하더라도 안하는게 좋아. 그 제약을 넣든 안 넣든 그 제약 조건이 필요하다면 해당 함수의 타입 서명에서 똑같은 제약조건을 적게될테니까.
이제 3D 벡터 타입을 정의하고 해당 타입을 위한 몇 가지 연산을 정의해보자. 이 타입 역시 일반화된 타입이야. 거의 숫자 타입만 포함하게 될 거긴 하지만, 어쨌든 숫자 타입에도 여러 가지 종류가 있으니 그걸 다 지원하려면 타입 매개변수를 써야하지.
- data Vector a = Vector a a a deriving (Show)
- vplus :: (Num t) => Vector t -> Vector t -> Vector t
- (Vector i j k) `vplus` (Vector l m n) = Vector (i+l) (j+m) (k+n)
- vectMult :: (Num t) => Vector t -> t -> Vector t
- (Vector i j k) `vectMult` m = Vector (i*m) (j*m) (k*m)
- scalarMult :: (Num t) => Vector t -> Vector t -> t
- (Vector i j k) `scalarMult` (Vector l m n) = i*l + j*m + k*n
vplus는 두 개의 벡터를 서로 더해줘. 덧셈은 각각 자신과 짝이 맞는 원소끼리 덧셈을 함으로써 이루어지지. scalarMult는 두개의 벡터에 대한 스칼라 곱이고 vectMult는 벡터와 스칼라간의 곱을 위한 거야. 이 함수들은 Vector Int, Vector Integer, Vector Float, 등등 Vector a의 a 타입이 Num 타입 클래스에 속하기만 하면 뭐든지간에 제대로 동작하지. 이 함수들의 타입 선언들이 올바른지 검사하고 싶다면, 이 연산들이 동일한 타입의 벡터에 대해서만 이루어지며 벡터가 포함하는 타입에 반드시 숫자가 포함되어야한다는 걸 살펴보면 돼. data 선언에서는 Num 타입클래스 제약조건을 넣지 않았어. 앞에서 이야기한 것처럼 어차피 함수에서도 똑같은 제약조건을 적어야하기 때문이지.
다시 한 번 말하지만, 타입 생성자와 값 생성자를 구분하는 건 굉장히 중요해. 데이터 타입을 선언할 때, = 이전 부분은 타입 생성자를 의미하고 = 이후 부분은 |를 이용해 구분되는 값 생성자들이야. Vector t t t -> Vector t t t -> t라는 타입 서명을 가진 함수가 있다면 그건 잘못된 거야. 왜냐하면 타입 선언엔 해당 타입 생성자가 사용하는 타입을 집어넣어야하는데, 값 생성자가 3개의 값을 받는 반면에 Vector 타입 생성자의 경우는 단 하나의 타입 매개변수만을 받기 때문이지(올바른 서명은 Vector t -> Vector t -> t가 될 거야). 이제 우리가 만든 Vector를 갖고 놀아보자.
- ghci> Vector 3 5 8 `vplus` Vector 9 2 8
- Vector 12 7 16
- ghci> Vector 3 5 8 `vplus` Vector 9 2 8 `vplus` Vector 0 2 3
- Vector 12 9 19
- ghci> Vector 3 9 7 `vectMult` 10
- Vector 30 90 70
- ghci> Vector 4 9 5 `scalarMult` Vector 9.0 2.0 4.0
- 74.0
- ghci> Vector 2 9 3 `vectMult` (Vector 4 9 5 `scalarMult` Vector 9 2 4)
- Vector 148 666 222
상속된 인스턴스(Derived instances)
Typeclass 101 섹션에서, 우린 타입 클래스의 기초에 대해 배웠어. 그 때 타입 클래스란 특정 동작을 정의해놓은 인터페이스의 집합이라고 설명했지. 타입은 해당 타입 클래스의 동작을 지원할 때 그 타입 클래스의 인스턴스로 만들어질 수 있어. 예를 들어, Int 타입은 Eq 타입 클래스의 인스턴스야. Eq 타입 클래스는 서로 동일한 지 비교될 수 있는가 하는 행동을 정의하기 때문이고, 또 정소는 서로 동일한지 비교할 수 있기 때문에 Int는 Eq 타입 클래스의 일부가 되는 거야. Eq의 인터페이스로 동작하는 함수들인 ==과 /=을 쓸 때 타입클래스의 실제 유용성이 나타나. Eq 타입클래스에 속하는 타입이라면, 해당 타입의 값에 대해 == 함수를 쓸 수 있어. 그게 바로 4 == 4나 "foo" /= "bar" 같은 표현식이 있는 이유지.
또 이전에 타입 클래스가 Java, Python, C++ 그리고 그 유사한 언어의 클래스들과 헷갈릴 수 있을 거라고 언급한 적이 있어. 이런 언어들에서 클래스는 생성할 객체에 대한 설계도고 그래서 상태를 지니고 있으며 여러가지 동작을 할 수가 있지. 타입 클래스는 이런 언어들의 개념 중에선 클래스보다는 인터페이스와 더 유사해. 타입 클래스로부터 데이터를 만들어낼 수는 없어. 대신에, 먼저 데이터 타입을 만든 다음 그 데이터 타입이 어떻게 동작할 지 생각해야하지. 만약 이 타입의 값이 서로 동등한지 비교될 수 있다면, 이 타입을 Eq 타입클래스의 인스턴스로 만들어야 할거야. 또 정렬까지 가능하다면 Ord 타입 클래스의 인스턴스도 되도록 만들어야겠지.
다음 섹션에서, 새로 정의하는 타입이 타입 클래스에 의해 정의된 함수들을 구현함으로써 해당 타입클래스의 타입이 되도록 만드는 일반적인 방법을 살펴볼거야. 하지만 지금 당장은, Haskell이 Eq, Ord, Enum, Bounded, Show, Read 등의 타입 클래스들에 대해 새로운 타입이 어떻게 자동으로 해당 타입의 인스턴스가 되도록 만들어주는지 살펴볼거야. Haskell은 새로운 타입이 deriving 키워드를 이용해 이런 타입 클래스들을 상속받을 때 해당 타입 클래스들의 행동을 자동으로 구현해주지.
이런 데이터 타입을 생각해보자.
- data Person = Person { firstName :: String
- , lastName :: String
- , age :: Int
- }
이건 사람을 나타내.이 때, 같은 나이, 같은 성, 같은 이름을 가진 두 사람이 절대 존재하지 않는다고 가정하자. 어떤 두 사람에 대한 기록이 있을 때 그 두 사람이 같은 사람인지를 알아내는게 가능할까? 물론 당연히 가능하겠지. 단순히 그 둘의 기록을 비교한다음 모든 요소가 같은지 아닌지만 살펴보면 돼. 그래서 이 타입은 Eq 타입클래스의 일부가 될 수 있어. Eq 타입클래스를 상속받아보자.
- data Person = Person { firstName :: String
- , lastName :: String
- , age :: Int
- } deriving (Eq)
이 타입은 이제 Eq 타입 클래스를 상속받았기 때문에 ==과 /=을 이용해 두 개의 값을 비교할 수 있어. 이제 이 타입의 값을 비교할 때, Haskell은 우선 두 값의 생성자가 일치하는지 살펴보고(위 예제에서는 생성자가 하나 뿐이지), 해당 값이 포함하고 있는 필드들 각각에 대해 == 연산자를 이용해 일치하는지 테스트를 진행해. 여기서 알아두어야할 점은, 각 필드에 대해 == 연산자를 이용하기 때문에 해당 타입의 모든 필드들 역시 Eq 타입클래스에 속하는 타입이어야한다는 거지. 여기선 String과 Int를 포함하고 있기 때문에 괜찮아. 이제 Eq 타입클래스의 인스턴스를 테스트해보자.
- ghci> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}
- ghci> let adRock = Person {firstName = "Adam", lastName = "Horovitz", age = 41}
- ghci> let mca = Person {firstName = "Adam", lastName = "Yauch", age = 44}
- ghci> mca == adRock
- False
- ghci> mikeD == adRock
- False
- ghci> mikeD == mikeD
- True
- ghci> mikeD == Person {firstName = "Michael", lastName = "Diamond", age = 43}
- True
Preson이 이제 Eq 타입클래스에 속하기 때문에, elem 함수와 같이 타입 서명에 Eq a라는 타입 클래스 제약이 붙는 모든 함수에서도 Person 타입을 사용할 수 있게 됐어.
Show와 Read 타입클래스는 각각 문자열로 변환될 수 있게, 그리고 문자열로부터 값으로 변환이 될 수 있게 해주는 타입클래스들이야. Eq와 유사하게, 이 타입클래스를 상속받으려면 이 타입이 지닌 필드들이 모두 Show나 Read 타입 클래스에 속해있어야해. Person 타입이 Show와 Read 타입클래스에도 속하도록 만들어보자.
- data Person = Person { firstName :: String
- , lastName :: String
- , age :: Int
- } deriving (Eq, Show, Read)
이제 Person 타입의 값을 화면에 출력할 수 있어.
- ghci> let mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}
- ghci> mikeD
- Person {firstName = "Michael", lastName = "Diamond", age = 43}
- ghci> "mikeD is: " ++ show mikeD
- "mikeD is: Person {firstName = \"Michael\", lastName = \"Diamond\", age = 43}"
만약 Person 타입을 Show 타입 클래스를 상속받기 전에 화면에 출력하려고 시도한다면, Haskell은 "Person을 문자열로 어떻게 표현해야할 지 모르겠는걸."이라고 불평을 토할거야. 하지만 이제 Show 타입클래스의 인스턴스를 상속받았으므로 Haskell은 이 타입을 문자열로 어떻게 표현해야할 지 알게 되지.
Read는 Show의 거의 반대에 해당하는 타입 클래스야. Show가 해당 타입의 값을 문자열로 바꿔준다면, Read는 특정 문자열을 해당 타입의 값으로 바꿔주지. 기억해둬야할 건, read 함수를 쓸 때 Haskell에게 우리가 변환 결과로 얻고자 하는 타입이 뭔지 명확하게 타입을 표기해주어야한다는 거야. 그걸 명시해주지 않으면 Haskell은 우리가 어떤 타입을 원하는지 알 수 없어.
- ghci> read "Person {firstName =\"Michael\", lastName =\"Diamond\", age = 43}" :: Person
- Person {firstName = "Michael", lastName = "Diamond", age = 43}
만약 read 함수의 결과를 나중에 어디선가 이용한다면, Haskell은 그걸 어떤 타입으로 읽어야할 지 코드로부터 추론해내기 때문에 딱히 타입 표기를 해주지 않아도 돼.
물론 일반화된 타입에 대해서도 read함수를 쓸 수 있어. 하지만 반드시 타입 매개변수를 채워넣어줘야하지. 그래서, read "Just 't'" :: Maybe a 같은 건 안되지만 read "Just 't'" :: Maybe Char 같은 건 돼.
각 값이 정렬될 수 있는 타입에 대해서 Ord 타입 클래스의 인스턴스를 상속받을 수 있어. 같은 타입의 값이지만 서로 다른 생성자로 만들어진 두 값을 비교할 땐, 더 먼저 정의된 생성자가 더 작은 값으로 여겨져. 예를 들어, False 값 또는 True 값을 갖는 Bool 타입에 대해, 이게 비교될 때 어떻게 동작하는지 보려면 다음과 같이 정의할 수 있지.
- data Bool = False | True deriving (Ord)
False 값 생성자가 먼저 정의되고 True 생성자가 나중에 정의되었기 때문에, True가 Fales보다 더 큰 값이 돼.
- ghci> True `compare` False
- GT
- ghci> True > False
- True
- ghci> True < False
- False
Maybe a 데이터 타입에서, Nothing 값은 Just 값 생성자보다 더 앞에 있기 때문에, Nothing 값이 항상 Just something값보다 더 작은 값이 돼. something값이 마이너스 몇천억쯤되는 값이라도 말야. 하지만 두 개의 Just 값을 비교할 때는, 해당 Just 값이 내부적으로 포함하고 있는 값에 따라 순서가 결정되지.
- ghci> Nothing < Just 100
- True
- ghci> Nothing > Just (-49999)
- False
- ghci> Just 3 `compare` Just 2
- GT
- ghci> Just 100 > Just 50
- True
하지만 Just (*3) > Just (*2) 같은 비교는 할 수 없어. 왜냐하면 (*3)과 (*2)는 함수고, 함수는 Ord 타입클래스에 속하지 않거든.
Enum과 Bounded 타입 클래스가 도와준다면 대수적 데이터 타입을 이용해 손쉽게 열거형(enumeration)을 만들 수 있어. 다음의 데이터 타입을 보자.
- data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday
이 데이터 타입에 속하는 모든 값 생성자들은 어떤 필드도 갖고 있지 않기 때문에, 우리는 이걸 Enum 타입 클래스에 속하게 만들 수 있어. Enum 타입클래스는 어떤 값에 대해 그 값의 전 값(predecessor)와 다음 값(successor)을 지니는 타입들을 위한거야. Bounded 타입클래스는 해당 타입이 가질 수 있는 값중 가장 작은 값과 가장 큰 값이 존재하는 타입을 위한 타입클래스지. 그리고, 여기서 이 타입이 상속 가능한 모든 타입 클래스들을 상속받게 한 다음 그게 어떻게 동작하는지 살펴볼거야.
- data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday
- deriving (Eq, Ord, Show, Read, Bounded, Enum)
이 타입이 Show와 Read 타입클래스에 속하기 때문에, 이 타입의 값은 문자열로 변환가능하고 또 문자열로부터 이 타입의 값을 얻을 수도 있어.
- ghci> Wednesday
- Wednesday
- ghci> show Wednesday
- "Wednesday"
- ghci> read "Saturday" :: Day
- Saturday
이 타입이 Eq와 Ord 타입클래스에 속하기 때문에, 이 타입의 값은 서로 비교할 수 있지.
- ghci> Saturday == Sunday
- False
- ghci> Saturday == Saturday
- True
- ghci> Saturday > Friday
- True
- ghci> Monday `compare` Wednesday
- LT
또 이 타입이 Bounded 타입클래스에 속하기 때문에, 가장 작은 날짜와 가장 큰 날짜도 구할 수 있어.
- ghci> minBound :: Day
- Monday
- ghci> maxBound :: Day
- Sunday
이 타입이 Enum 타입클래스에도 속하기 때문에, 특정 날짜의 전 날짜와 다음 날짜도 구할 수 있고 날짜 범위로부터 리스트를 구할 수도 있어!
- ghci> succ Monday
- Tuesday
- ghci> pred Saturday
- Friday
- ghci> [Thursday .. Sunday]
- [Thursday,Friday,Saturday,Sunday]
- ghci> [minBound .. maxBound] :: [Day]
- [Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday]
이 건 굉장히 멋져.
타입 동의어(Type synonyms)
이전에, [Char]과 [String] 타입이 상호변환가능한 동일한 타입이라고 말한 적이 있어. 이건 타입 동의어(type synonyms)를 통해 구현돼. 타입 동의어는 정말 그 자체로는 하는 일이 아무 것도 없고, 단지 코드와 문서의 가독성을 높이기 위해 특정 타입에 대해 다른 이름을 붙여주는 역할 밖에 안 해. 여기 [Char]에 대해 표준 라이브러리에서 String을 어떻게 동의어로 정의했나를 나타낸 코드가 있어.
- type String = [Char]
이제 type 키워드를 소개할 차례야. 이 키워드는 오해의 소지가 있는데, 왜냐하면 이 키워드를 써서 실제로 새로운 어떤 것을 만드는게 아니라(그건 data 키워드를 이용하지), 그저 이미 존재하는 타입에 대해 동의어를 만들어줄 뿐이기 때문이야.
만약 문자열의 모든 문자를 대문자로 바꿔주는 함수를 만들고 그걸 toUpperString 혹은 다른 이름으로 부른다면, 여기에 toUpperString :: [Char] -> [Char] 또는 toUpperString :: String -> String이라는 타입 선언을 붙여줄 수 있을거야. 이 두가지는 본질적으로 똑같고, 후자가 좀 더 가독성이 좋지.
Data.Map 모듈을 다뤘을 때, 전화번호부를 연관 리스트로 표현하는 걸 맵으로 변환하기 전에 먼저 설명했었어. 이미 알고 있듯이 연관 리스트는 키-값 쌍의 리스트지. 다음과 같은 전화번호부가 있다고 생각해보자.
- phoneBook :: [(String,String)]
- phoneBook =
- [("betty","555-2938")
- ,("bonnie","452-2928")
- ,("patsy","493-2928")
- ,("lucille","205-2928")
- ,("wendy","939-8282")
- ,("penny","853-2492")
- ]
여기서 phoneBook의 타입은 [(String, String)]이야. 이건 String과 String을 매핑하는 연관 리스트라는 것 딱 하나만 말해주지. 여기 타입 선언이 좀 더 많은 정보를 담을 수 있도록 타입 동의어를 만들어보자.
- type PhoneBook = [(String,String)]
이제 phoneBook은 phoneBook :: PhoneBook이란 타입 선언을 가질 수 있지. String에 대한 타입 동의어도 마찬가지로 만들어보자.
- type PhoneNumber = String
- type Name = String
- type PhoneBook = [(Name,PhoneNumber)]
String에 타입 동의어를 주는 건 Haskell 프로그래머가 해당 String이 함수 내에서 어떻게 쓰이고 또 어떤 걸 나타내는지에 대한 더 많은 정보를 담고 싶을 때 주로 하는 일이야.
이제 이름과 전화번호를 인자로 받아서 해당 조합이 전화번호부 내에 존재하는지 판별하는 함수를 구현할 차례야. 이제 여기에 굉장히 멋지고 가독성이 뛰어난 타입 선언을 붙일 수 있지.
- inPhoneBook :: Name -> PhoneNumber -> PhoneBook -> Bool
- inPhoneBook name pnumber pbook = (name,pnumber) `elem` pbook
만약 타입 동의어를 사용하지 않는다면, 이 함수는 String -> String -> [(String, String)] -> Bool이란 타입을 갖게 돼. 이 경우에, 타입 선언에 타입 동의어를 사용하는 편이 훨씬 이해하기 쉬워. 하지만, 언제나 과유불급인 법이지. 타입 동의어는 함수 내에서 이미 존재하는 타입이 나타내는 역할이 뭔지 묘사하거나(그리고 해당 타입 선언이 더 나은 가독성을 가질 때), 혹은 여러번 반복되는 긴 타입([String, String]같은)이지만 함수 내 문맥에서 명확한 의미를 지닌 어떤 것을 나타낼 때 쓰는거야.
타입 동의어 역시 일반화(parameterized)될 수 있어. 연관 리스트 타입을 나타내고 싶지만 여전히 일반적이어서 어떤 키,값 타입에 대해서도 해당 타입을 쓸 수 있게 하고 싶을 때, 다음과 같이 할 수 있어.
- type AssocList k v = [(k,v)]
이제, 연관리스트의 키로부터 값을 가져오는 함수는 (Eq k) => k -> AssocList k v -> Maybe v라는 타입을 가질 수 있겠지. AssocList는 두 개의 타입을 받아서 하나의 구체적 타입(예를 들면 AssocList Int String 같은)을 만들어내는 타입 생성자야.
Fonzie 曰 : 아아! 내가 말한 구체적 타입(concrete types)이 어떤 의미냐면, Map Int String같이 완전히 적용된 타입, 혹은 [a]나 (ord a) => Maybe a 같은 다형적인 함수의 구체화된 함수중 하나를 뜻하는 거야. 그리고, 가끔 나나 몇몇 녀석들이 Maybe를 타입이라고 이야기하지만, 그건 타입이 아냐. 어떤 멍청이라도 알고 있겠지만 Maybe는 타입 생성자기 때문이지. 내가 Maybe에 추가적인 타입을 적용해서 Maybe String같은 걸 만들면, 그게 바로 구체적 타입이 되는거야. 너도 알다시피 특정 값은 '구체적 타입'인 타입의 값이어야만 해.
새로운 함수를 만들기 위해 부분 적용된 함수를 쓸 수 있는 것처럼, 타입 매개변수를 부분 적용해서 새로운 타입 생성자를 만들어낼 수 있어. 함수를 너무 적은 개수의 인자로 호출하면 새로운 함수를 얻을 수 있듯이, 타입 생성자에 너무 적은 개수의 타입 매개변수를 적용하면 부분 적용된 타입 생성자를 얻을 수 있지. 만약 Data.Map에서 정수로부터의 어떤 것을 의미하는 맵 타입을 만들고 싶다면, 이런 식으로 쓸 수 있어.
- type IntMap v = Map Int v
혹은 이렇게도 되겠지.
- type IntMap = Map Int
어떤 방법이든, IntMap 타입 생성자는 하나의 매개변수를 취해서 정수를 key값으로 하는 맵을 만들어낼거야.
오 예. 위에 적힌 걸 실제로 구현하려고 한다면, 아마 Data.map을 명시적으로 포함(qualified import)해야할 거야. 명시적으로 포함했다면, 타입 생성자 역시 모듈 이름을 앞에 붙여주어야 제대로 동작하겠지. 그래서 type IntMap = Map.Map Int와 같이 적어줘야하지.
타입 생성자와 값 생성자간의 차이에 대해 정말로 명확하게 이해했는지 확신할 수 있어야 돼. IntMap이나 AssocList같은 타입 동의어를 만들었다는게 우리가 AssocList [(1,2), (4,5), (7,9)]같은 일을 할 수 있게 되었다는 의미가 아냐. 이건 단지 우리가 그 타입을 다른 이름을 이용해서 부를 수 있게 되었음을 의미할 뿐이야. 우린 [(1,2), (3,5), (8,9)] :: AssocList Int Int라고 쓸 수 있고 이건 Int 타입의 숫자들을 포함하는 리스트를 만들어내겠지. 하지만, 그렇다 하더라도 우린 이 리스트를 그냥 Int형의 페어를 저장하고 있는 어떤 일반적인 리스트로 사용할 수 있어(AssocList의 의미가 아닌). 타입 동의어는(그리고 일반적으로 타입들은) Haskell의 타입 부분에서만 쓰여. 새로운 타입을 정의하거나(data나 type 선언같은) 혹은 :: 뒷부분이 Haskell의 타입 부분이지. 여기서 ::는 타입 선언(type declarations) 혹은 타입 주석(type annotations)이라고 불려.
또다른 멋진 데이터 타입은 인자로 두 개의 타입을 받는 Either a b 타입이야. 이건 대략적으로 다음과 같이 정의되어 있어.
- data Either a b = Left a | Right b deriving (Eq, Ord, Read, Show)
이건 두 개의 값 생성자를 갖고 있지. Left가 사용된다면, 이건 a 타입의 값을 갖게 되고 만약 Right가 사용된다면 이건 b 타입의 값을 갖게 돼. 그래서 이 타입을 특정 타입 혹은 다른 타입의 값을 캡슐화하기 위해 사용할 수 있고 Either a b 타입의 값을 얻기 위해 보통 Left Right 값 생성자 패턴 매칭을 이용하지. 그리고 이걸 이용해 어떤 것이 쓰였나에 따라 다른 동작을 수행할 수 있어.
- ghci> Right 20
- Right 20
- ghci> Left "w00t"
- Left "w00t"
- ghci> :t Right 'a'
- Right 'a' :: Either a Char
- ghci> :t Left True
- Left True :: Either Bool b
지금까지, Maybe a 타입은 대부분 어떤 계산의 결과가 실패했냐 아니냐를 나타낼 때 쓰였어. 하지만 때때로 Maybe a 는 뭔가 실패했을 때 Nothing이 아무런 정보도 담을 수 없기 때문에 별로 좋지 않을 수가 있어. Maybe는 단 한 가지 방법으로만 실패가 일어나거나, 왜 실패했는지, 어떻게 실패했는지에 대해 전혀 관심이 없을 때 쓰기 좋지. Data.Map에서의 검색은 키값이 맵에 존재하지 않을 때에만 실패해. 그래서 실패했을 때 왜 실패했는지 아무런 정보 없이도 명확히 알 수 있지. 하지만, 어떤 함수에 대해 그 함수가 어떻게, 왜 실패했는지 관심이 있다면 그 결과 타입으로 종종 Either a b를 써. a는 실패한 경우에 대한 어떤 정보를 담게 되고 b는 계산을 성공했을 때의 결과를 담게 되지. 그래서, error는 Left 값 생성자를 쓰고 결과값은 Right 값 생성자를 써.
예를 들어, 사물함이 있는 어떤 고등학교가 있고 그래서 그 학교의 학생들은 자신들의 Guns'n'Roses 포스터를 놓을 장소를 갖고 있어. 각각의 사물함은 비밀번호가 걸려있지. 학생들은 새로운 사물함이 필요할 때, 사물함 관리자에게 자신이 원하는 사물함 번호를 말하고, 사물함 관리자는 해당 사물함의 비밀번호를 알려줘. 하지만, 누군가가 이미 해당 사물함을 쓰고 있다면, 사물함 관리자는 해당 사물함의 비밀번호를 말해주지 않아. 대신 다른 사물함을 선택해야하지. 사물함을 나타내기 위해 Data.Map을 쓸거야. 이 맵은 사물함의 번호와 해당 사물함이 사용중인지 아닌지 여부, 그리고 사물함의 비밀번호를 저장할 수 있겠지.
- import qualified Data.Map as Map
- data LockerState = Taken | Free deriving (Show, Eq)
- type Code = String
- type LockerMap = Map.Map Int (LockerState, Code)
간단하지. 여기서 사물함이 사용중인지 아닌지를 나타내는 새로운 타입을 만들었고 또 비밀번호에 대한 타입 동의어도 만들었어. 거기에 사물함 정보를 저장하는 맵에 대한 타입 동의어도 만들었고. 이제, 이 사물함 맵의 비밀번호를 검색하는 함수를 만들 차례야. 여기서 함수의 수행 결과를 위해 Either String Code 타입을 쓸 거야. 왜냐하면 검색 함수는 실패하는 경우가 두 가지 존재하거든. 하나는 해당 사물함을 이미 쓰고 있어서 비밀번호를 말해줄 수 없는 경우고, 나머지 하나는 해당 번호의 사물함이 아예 존재하지 않는 경우지. 만약 검색이 실패한다면, String을 이용해 어떤 일이 일어났는지 알려줄거야.
- lockerLookup :: Int -> LockerMap -> Either String Code
- lockerLookup lockerNumber map =
- case Map.lookup lockerNumber map of
- Nothing -> Left $ "Locker number " ++ show lockerNumber ++ " doesn't exist!"
- Just (state, code) -> if state /= Taken
- then Right code
- else Left $ "Locker " ++ show lockerNumber ++ " is already taken!"
우선 그냥 맵에 대한 검색을 수행해. 만약 Nothing을 얻으면 해당 사물함이 존재하지 않는다는 Left String을 돌려주고, 찾았을 경우엔 해당 사물함이 이미 쓰는 중인지 아닌지를 추가적으로 판별해. 그리고 이미 쓰고 있는 경우엔 Left를 이용해 이미 쓰고 있음을, 그렇지 않은 경우엔 올바른 결과 값인 Right Code 타입의 값을 리턴하지. 이건 실제로 Right String 타입이지만 위에서 말한 것처럼 타입 동의어를 이용해 이 타입 선언에 추가적인 정보(비밀번호를 저장한다는)를 담기 위해 Right Code로 썼어. 여기 예제 맵이 있어.
- lockers :: LockerMap
- lockers = Map.fromList
- [(100,(Taken,"ZD39I"))
- ,(101,(Free,"JAH3I"))
- ,(103,(Free,"IQSA9"))
- ,(105,(Free,"QOTSA"))
- ,(109,(Taken,"893JJ"))
- ,(110,(Taken,"99292"))
- ]
이제 한 번 사물함을 검색해보자.
- ghci> lockerLookup 101 lockers
- Right "JAH3I"
- ghci> lockerLookup 100 lockers
- Left "Locker 100 is already taken!"
- ghci> lockerLookup 102 lockers
- Left "Locker number 102 doesn't exist!"
- ghci> lockerLookup 110 lockers
- Left "Locker 110 is already taken!"
- ghci> lockerLookup 105 lockers
- Right "QOTSA"
물론 여기에 Maybe a 타입을 이용할 수도 있겠지만, 그렇게 하면 비밀번호를 얻지 못했을 때 왜 못 얻었는 지 그 이유를 알 수 없게 돼. 하지만, Either를 이용하면 실패했을 경우에 왜 실패했는지를 결과 값으로부터 얻어낼 수 있지.
'Haskell > LYAH' 카테고리의 다른 글
[Learn You a Haskell For Great Good!] 7. 모듈 (1) | 2015.02.10 |
---|---|
[Learn You a Haskell For Great Good!] 6. 고차 함수 (1) | 2015.02.01 |
[Learn You a Haskell For Great Good!] 5. 재귀 (0) | 2015.01.28 |
[Learn You a Haskell For Great Good!] 4. 함수에서의 구문 (0) | 2015.01.21 |
[Learn You a Haskell For Great Good!] 3. 타입과 타입 클래스 (0) | 2015.01.18 |