본문 바로가기

Haskell

커스텀 타입을 정의하는 법

data 키워드는 새로운 데이터 타입을 정의할 때 사용된다. 새로운 타입을 정의하는 방법은 크게 두 가지가 있다.

  1. 열거형으로 정의하기
  2. 조합형으로 정의하기

열거형은 대수적 타입을 정의할 때 흔히 사용할 수 있다. data 타입명을 좌변으로 하고 등호의 우변에는 타입의 개별 값을 |로 구분지어 넣는다. 이 때 열거형의 개별 값은 인자를 받지 않는 constructor로 간주되므로 대문자로 작성한다. 예를 들어 Bool 타입은 다음과 같이 정의될 수 있다.

data Bool = True | False

여기서 Bool 자체는 데이터 타입의 이름이며, True, False는 각 열거케이스에 해당하는데 이 각각은 하나의 값을 생성하기 위해 호출되는 constructor이다.

다른 데이터 타입 정의 방식으로는 조합형이 있다. 조합형은 여러 개의 기존 데이터 타입 값을 조합하여 새로운 데이터 타입으로 만드는 일이다. 다른 언어들의 구조체와 비슷하다고 보면 된다. 다음은 2차원 좌표계상의 특정한 점 하나를 표현하기 위한 Point를 정의하는 방법이다.

data Point = Point Float Float

자 그럼 두 점 사이의 거리를 구하는 함수를 만들어보면서 새로운 타입을 어떻게 사용하는지 살펴보자.

-- 먼저 거리계산에 필요한 hypot 함수를 정의한다. 
hypot :: Float -> Float -> Float
hypot x y = sqrt $ x*x + y*y

distance :: Point -> Point -> Float
distance (Point x1 y1) (Point x2 y2) = hypot (x1-x2) (y1-y2)

타입 Point는 1개의 구축자를 가지고 있으며, 이 구축자는 두 개의 인자를 받는다. 그리고 이 모양이 그대로 해당 타입의 값이 된다. 구축자의 모양은 다시 함수에서 패턴으로 인식될 수 있다. distance 함수는 두 개의 Point 값을 인자로 받는데, 여기서 구축자의 모양 그대로가 패턴으로 사용되는 것을 볼 수 있다. 이제 아래와 같이 해당 함수를 사용해보자.

> let a = Point 1.0 1.0
> let b = Point 4.0 5.0
> distance a b
5.0

두 개의 Point 타입 값을 정의하고 distance 함수에 넣어본다. a라는 이름은 Point 1.0 1.0으로 그대로 치환되면서 패턴매칭이 적용된다. b 역시 마찬가지이다. 따라서 distance 함수에서는 두 Point 값의 개별 필드값을 알게 되므로 해당 값들을 계산하여 두 점의 위치를 계산해주고 있다.

그렇다면 점 하나의 각 좌표값에는 어떻게 접근해야 할까? 하스켈은 객체지향형 언어가 아니므로 특정한 타입 내부에 메소드가 내장되지 않는다. 필요한 모든 API는 함수의 형태로 따로 정의해야 한다. 하지만, 패턴매칭을 사용하면 어려운 일이 아니지 않은가?

getX :: Point -> Float
getX (Point x _) = x

getY :: Point -> Float
getY (Point _ y) = y

하지만 필드 값이 아주 많은 데이터 타입이라면 이렇게 일일이 필드 접근 함수를 만들어주는 것이 매우 귀찮은 일이 된다. 그래서 하스켈에서는 레코드 문법이라는 별도의 표기법을 제공한다. 이는 { ... } 속에 필드이름과 타입을 차례로 나열하는 것인데, 이 때 필드 이름은 필드 접근 함수의 이름이 된다.

data Point = Point { getX :: Float, getY :: Float } deriving (Show)

여기서 추가한 deriving (Show)는 기본적으로 해당 타입이 Show 클래스에 포함되도록 해주는 표기이다. 모든 필드가 Show를 따른다면 해당 값을 표현하는 show 함수가 자동으로 정의된다. 일종의 타입클래스 상속이라 이해하면 된다. 참고로 앞서 deriving (Show)를 사용하지 않고 Point를 정의했다면 GHCI 상에서 해당 값이 제대로 표시되지 않고 에러가 날 것이다.

다른 예를 들어보자. 주소록에 사용할 사람을 표현할 타입을 정의하고자 한다면 다음과 같이 쓸 수 있다.

data Person = Person { firstName :: String
                     , lastName :: String
                     , age :: Int
                     , height :: Float
                     , phoneNumber :: String
                     , flavor :: String
                     } deriving (Show)

이걸 예시로 사용하기에는 너무 크니까, 좀 짧게 차량의 모델을 표현하는 타입을 다시 정의해보자.

data Car = Car { company::String, model::String, year::Int } deriving (Show)

레코드 표기법은 타입 정의 외에도 값 생성시에도 사용될 수 있다. Car의 경우 Car "Ford" "Mustang" 1967과 같이 생성하는 것이 보통이겠으나, 가독성을 높이기 위해서 레코드 표기를 사용할 수 있다.

다중 구축자

데이터 타입을 만들때 열거식과 조합식을 병행하여 사용할 수 있다. 열거식 자체가 constructor 들을 | 로 구분한거라고 했으니 말이다. 바코드를 예로 들어보자. 바코드는 EAN이라고 하는 숫자를 표현하는 형태가 있고, 널리 알려진 QR 코드 포맷이 있다. (QR코드는 문자열을 인코딩한다.)

data BarCode = EAN {value :: Integer} | QRCode {message::String} deriving (Show)

위는 바코드정보 타입을 정의한 것이다. 타입 BarCode의 값을 만드는 방법은 두 가지 이다. 하나는 EAN이라는 구축자를 사용하는 것이고 다른 하나는 QRCode라는 구축자를 사용하는 것이다. 그리고 두 구축자의 파라미터는 다르다. 하지만 이 두 구축자가 생성하는 값은 각각 EAN, QRCode 라는 타입이 아니라, BarCode라는 동일한 타입의 값을 만들어내게 된다.

그러면 거꾸로 어떤 BarCode 타입의 값이 있을 때, 이게 EAN인지 QR코드인지는 어떻게 알 수 있을까? 이 질문에 대한 답은 '관심의 대상이 아니다'이다. 왜? 값은 함수에 전달될 때 그 의미가 필요해지고, 함수에 전달되는 시점에는 패턴매칭으로 구분하면 되기 때문이다.

위 예에서 EAN은 value 라는 레코드 액세스 함수가 자동으로 만들어지게 되어 있다. 여기에 QRCode로 생성한 값을 넣으면 당연히 예외가 발생한다. 하지만 다음과 같이 value 함수에 패턴 매칭 구문을 추가해주면 안전하게 실행될 수 있다.

value (QRCode _) = 0

여기서 EANInt 값 1개만을 인자로 받기 때문에 Int와 똑같은 거 아니냐고 생각할 수 있지만, 실제로는 그렇지 않다. 완전히 새로운 타입의 값인 것이다. 실제로 정수값으로 구분되는 고객번호, 상품번호, 주문번호 등의 일련번호들은 모두 Int 값이다. 예를 들어 상품번호를 인자로 받는 함수에 고객번호를 전달하는 실수를 한다고 해보자. 이 두 정보의 타입이 모두 Int라면 컴파일 시점에는 해당 실수를 잡아낼 수 없다. (나중에 가서야 뭔가 데이터가 맞지 않는 것을 깨달은 다음에 대참사가 벌어졌다는 것을 알 게 될 것이다.) 하지만 이렇게 타입을 구분지어 놓으면 컴파일 시점에 이런 실수는 바로 잡아낼 수 있다.

이러한 특징은 '단위'에 의한 실수를 방지하는데에도 도움을 줄 수 있다. NASA에서 화성 탐사선을 발사하면서 미터와 피트를 혼용하는 바람에 탐사선이 추락해버린 유명한 일화도 있는데, 만약 이런식으로 처리했다면 미리 문제를 알아 낼 수 있지 않았을까?

data Length = MM Float | Inch Float deriving (Show)

getMMValue :: Length
getInch (MM x) = x
getInch (Inch x) = x * 2.54

Length 타입은 임의의 길이를 표현하고자 하는 데이터 타입이다. 이 때 길이는 mm 단위나 인치단위로 생성할 수 있다. 그리고 getMMValue라는 함수를 통해서 어떤 단위로 만들었든지 간에 mm 단위로 환산한 값을 안전하게 구할 수 있다는 장점이 있다.

타입 파라미터

커스텀 타입 선언의 우변에는 구축자와 인자값들이 결합하는 형태로 해당하는 새 타입의 값을 구성하는 방법을 정의하게 된다. 그렇다면 좌변인 타입명 부분에도 파라미터가 오는 경우는 어떤 것일까? 리스트를 보자. 하스켈의 리스트는 동일한 타입의 원소들의(homogeneous) 집합이다. 즉 리스트라는 어떤 타입 템플릿이 있고, 여기에 그 원소 타입에 따라 구체적인 타입이 결정된다. 문자열, 정수의 리스트, 불리언 값의 리스트는 모두 리스트라는 공통된 타입이지만, 그 구체적인 타입은 결국 원소의 타입에 의해 결정된다.

이렇게 원소의 타입에 의존하는 타입을 선언하기 위해서는 타입 파라미터를 사용한다. 일례로 Maybe 타입은 다음과 같은 식으로 정의할 수 있을 것이다.

data Maybe a = Nothing | Just a deriving (Show)

Maybe 다음에 a가 바로 타입 파라미터이다. Nothing은 타입을 불문하고 값이 없는 상태를 가리키는 용도의 값으로 사용될 것이고, Just a 부분에서 a는 임의의 타입이 될 수 있다. 값 구축자의 인자의 타입이 결정되지 않는 경우, 전체 타입은 그 결정되지 않은 인자의 타입에 의존하게 된다. 일종의 제네릭 타입이라고 볼 수 있는데, Maybe의 경우에도 Maybe 그 자체는 구체적인 타입이 아니다.

이 경우 Maybe 자체를 타입으로 보는 것은 가능하다. 하지만 그 세부에 다시 Maybe Int, Maybe String 등의 세부 타입이 나올 수 있다. Maybe Int와 같이 모든 타입 파라미터가 결정되었을 때,이를 zero - kinded라 부른다. 그리고 타입 파라미터가 하나씩 빠질 때마다 kind가 올라간다.

GHCI에서 :kind 명령을 통해서 특정한 타입의 카인드를 알아볼 수 있다. 예를 들어 :kind Maybe Int*를 표시하며, :kind Maybe* -> *를 표시한다. 즉 하나의 타입 파라미터가 결정되어야 구체적인 타입이 된다는 뜻이다.

앞서 정의해보았던 Car는 별도의 타입 파라미터가 없으므로 그 자체로 구체적인 타입이 될 수 있다. 제조사나 모델이 문자열이 아닌 임의의 타입으로 올 수 있다면 어떨까? 그 경우에는 타입 파라미터를 써서 정의할 수 있으며, 이 때에는 하스켈이 값 구축자에서 사용된 대응되는 인자의 타입을 통해서 최종 타입을 추론하게 된다.

data Car a b c = Car { company::a, model::b, year::c } deriving (Show)

차량 값을 간단한 문자열로 표현하는 함수를 작성한다고 생각해보자. 원래의 Car 타입에서는 다음과 같은 함수를 생각할 수 있다.

tellCar :: Car -> String
tellCar (Car {company=c,model=m,year=y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y

하지만 새로 작성한 Car a b c를 다루는 함수는 이런식으로 작성해야 한다.

tellCar :: (Show a) => Car String String a -> String -- #1
tellCar (Car {company=c, model=m, year=y}) = "This " ++ c ++ " " ++ m ++ " was made in " ++ show y

자, 이 함수는 제한적으로 Car a b c 타입에 사용될 수 있다. 왜냐하면 패턴 매칭에서 company, model 필드가 문자열인 경우, 그리고 year 필드는 Show의 인스턴스인 타입인 경우에만 사용된다. 따라서 company 필드가 정수값이라거나 하는 Car a b c타입의 값은 만들 수 있지만 tellCar 함수에는 사용할 수 없게 된다.

조금 더 모호한 케이스

이차원 좌표인 Point를 만들 때 우리는 각 좌표의 값이 실수(Float) 타입일 것이라 정의했다. 이번에는 3차원 벡터 타입을 하나 정의해보자. 대신에 각 좌표 레코드의 타입은 결정되지 않은 임의의 타입이라 가정한다.

data Vector t = Vector t t t deriving (Show)

이제 다음과 같이 확인해보자. 과연 0 1 0을 인자로 생성한 Vector값의 타입은 뭘까? Vector Int? 아니면 Vector Float?

> let v = Vector 0 1 0
> :t v
(Num t) => Vector t

여전히 t 타입은 확정되지 않았다. 왜냐하면 0 1 0 각각의 값은 지금 상황에서는 정수일수도 실수일수도 있기 때문이다. 그렇다면 두 벡터를 더하는 함수를 작성해보자.

vplus (Vector x y z) (Vector a b c) = Vector (x+a) (y+b) (z+c)

자 이 정의는 합당할까? 뭐 큰 문제는 없어보이는데, 구멍이 있다. 바로 함수의 본체에서 + 연산이 사용된 것이다. 문자열이나 Bool 타입 값은 + 연산을 적용할 수 없다. 따라서 우리는 vplus 함수의 타입 시그니처를 정의해서 보다 분명하게 해당 타입이 적용될 수 있는 범위를 결정해야 한다.

vplus :: (Num t) => Vector t -> Vector t -> Vector t
vplus (Vector x y z) (Vector a b c) = Vector (x+a) (y+b) (z+c)

정리

data 키워드를 통해서 새로운 타입을 정의하는 방법에 대해 살펴보았다. 마지막으로 원과 삼각형, 직사각형을 표현하는 Shape라는 타입을 만들고 그 넓이를 구하는 surface 함수를 작성하는 예제를 소개하고 마무리하도록 하겠다.

data Shape t = Circle {radius::t} 
               | Triangle {base::t, height::t} 
               | Rectangle {width::t, height::t}
               deriving (Show)

surface :: (Num t) => Shape t -> t
surface (Circle r) = r * r * 3.1415927
surface (Triangle b h) = b * h / 2
surface (Rectangle w h) = w * h