data
키워드는 새로운 데이터 타입을 정의할 때 사용된다. 새로운 타입을 정의하는 방법은 크게 두 가지가 있다.
- 열거형으로 정의하기
- 조합형으로 정의하기
열거형은 대수적 타입을 정의할 때 흔히 사용할 수 있다. 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
여기서 EAN
은 Int
값 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