타입, 한 걸음 더

타입, 한 걸음 더

기본 타입 선언을 넘어, 하스켈 타입 시스템의 고급 기능들을 만나 볼게요. 표현이 똑같지만 타입 시스템에서는 별개의 정체성을 갖는 newtype, 데이터의 구성요소를 이름으로 접근하는 필드 레이블, 그리고 지연 평가를 선택적으로 끄는 엄격 생성자까지요. 타입을 다루는 표현력이 부쩍 늘어날 거예요.

출처: https://www.haskell.org/tutorial/moretypes.html

본문

newtype 선언

프로그래밍에서 기존 타입과 표현(representation)은 같지만 타입 시스템에서는 별개의 정체성을 갖는 타입을 만들고 싶을 때가 많아요. 하스켈에서는 newtype 선언으로 기존 타입에서 새 타입을 만들어요. 예를 들어 자연수를 Integer로 표현할 수 있지만, 별개의 타입으로 만들고 싶다면 이렇게 해요.

newtype Natural = MakeNatural Integer

이렇게 하면 생성자가 Integer 하나만 담는 완전히 새로운 타입 Natural이 생겨요. 생성자 MakeNaturalNaturalInteger 사이를 변환해 줘요.

toNatural                :: Integer -> Natural
toNatural x | x < 0      = error "Can't create negative naturals!"
            | otherwise  = MakeNatural x
fromNatural              :: Natural -> Integer
fromNatural (MakeNatural i) = i

이 타입을 Num 클래스에 가입시키는 instance 선언은 다음과 같아요.

instance Num Natural where
    fromInteger             = toNatural
    x + y                   = toNatural (fromNatural x + fromNatural y)
    x - y                   = let r = fromNatural x - fromNatural y in
                                  if r < 0 then error "Unnatural subtraction"
                                           else toNatural r
    x * y                   = toNatural (fromNatural x * fromNatural y)

이 선언이 없으면 NaturalNum에 속하지 못해요. 기존 타입에 선언된 instance는 새 타입으로 이어지지 않아요. 사실 이 타입의 목적 자체가 다른 Num instance를 도입하는 거라서, NaturalInteger의 타입 동의어였다면 불가능했을 거예요.

이 모든 건 data 선언으로도 할 수 있어요. 다만 dataNatural 값의 표현에 추가 오버헤드가 생겨요. newtype을 쓰면 data가 도입하는 (지연성으로 인한) 추가 간접 참조 레벨을 피할 수 있어요. newtype 선언은 생성자가 하나이고 필드가 하나인 data 선언과 같은 문법을 써요.

필드 레이블

하스켈 data 타입의 필드는 위치로나 이름으로 접근할 수 있어요. 2차원 점 타입을 볼게요.

data Point = Pt Float Float

Point의 두 구성요소는 생성자 Pt의 첫·둘째 인자예요. 구성요소를 좀 더 설명적으로 접근하고 싶다면 이런 함수를 직접 만들 수도 있어요.

pointx                    :: Point -> Float
pointx (Pt x _)           =  x

하지만 구조가 커지면 이런 함수를 일일이 만드는 게 번거로워져요. data 선언의 생성자에는 중괄호로 묶인 필드 이름을 붙일 수 있어요. 필드를 위치 대신 이름으로 식별해 주죠. Point를 이렇게도 정의할 수 있어요.

data Point = Pt {pointx, pointy :: Float}

이 타입은 앞선 Point 정의와 동일해요. 다만 이 선언은 두 필드 이름 pointx, pointy를 추가로 정의하는데, 이들이 곧 구성요소를 뽑아내는 선택자(selector) 함수가 돼요.

pointx                    ::   Point -> Float
pointy                    ::   Point -> Float

선택자를 쓰는 함수 예시예요.

absPoint                 :: Point -> Float
absPoint p               =  sqrt (pointx p * pointx p +
                                       pointy p * pointy p)

필드 레이블로 새 값을 만들 수도 있어요. Pt {pointx=1, pointy=2}Pt 1 2와 동일해요. 필드 이름으로 만들 때 일부 필드를 생략할 수 있는데, 생략된 필드는 정의되지 않은 값이 돼요. 필드 이름을 이용한 패턴 매칭도 가능해요.

absPoint (Pt {pointx = x, pointy = y}) = sqrt (x*x + y*y)

갱신(update) 함수도 있어요. pPoint일 때 p {pointx=2}ppointy는 같고 pointx만 2로 바뀐 점이에요. 파괴적 갱신이 아니라, 지정된 필드에 새 값을 채운 새 복사본을 만드는 거예요.

필드 이름은 한 타입 안에서 여러 생성자에 같은 이름을 쓸 수 있지만, 같은 타입 안에서 타입이 같아야 해요. 예를 들어,

data T = C1 {f :: Int, g :: Float}
       | C2 {f :: Int, h :: Bool}

에서 필드 fT의 두 생성자 모두에 적용돼요. 필드 레이블은 대수적 데이터 타입의 본질을 바꾸지 않아요. 구성요소를 위치가 아니라 이름으로 접근하게 해 주는 편리한 문법일 뿐이에요. 필드가 많은 생성자를 다루기 훨씬 수월하게 만들어 줘요.

엄격 데이터 생성자

하스켈의 자료구조는 일반적으로 **지연(lazy)**돼요. 구성요소는 필요할 때까지 평가되지 않아요. 덕분에 평가하면 오류가 나거나 종료하지 않을 원소를 담은 구조도 만들 수 있어요. 내부적으로 지연 데이터 객체의 각 필드는 필드 값을 계산하는 정의를 담은 thunk라는 구조에 싸여 있어요. thunk는 값이 필요해질 때까지 접근되지 않고, 오류(_|_)를 담은 thunk는 다른 원소에 영향을 주지 않아요. 예를 들어 튜플 ('a', _|_)는 하스켈에서 완벽히 유효한 값이에요. 'a'는 다른 성분을 건드리지 않고 쓸 수 있어요.

대부분의 언어는 지연 대신 **엄격(strict)**해서, 자료구조의 모든 구성요소가 구조에 들어가기 전에 값으로 환원돼요. 그런데 thunk에는 오버헤드가 따라와요. 만들고 평가하는 데 시간이 걸리고, 힙 공간을 차지하며, 가비지 컬렉터가 thunk 평가에 필요한 다른 구조를 붙잡아 두게 만들기도 해요. 이런 오버헤드를 피하고자 data 선언의 엄격성 플래그로 특정 필드를 즉시 평가하게 할 수 있어요. data 선언에서 !로 표시한 필드는 thunk에 지연되지 않고 구조가 만들어질 때 평가돼요.

엄격성 플래그가 적절한 경우는 몇 가지예요. 실행 중 어느 시점에 반드시 평가될 구성요소, 평가가 쉽고 오류를 일으키지 않는 구성요소, 부분 정의 값이 의미 없는 타입 등이에요. 예를 들어 복소수 라이브러리의 Complex 타입을 보면,

data RealFloat a => Complex a = !a :+ !a

이 정의는 복소수의 실수부와 허수부를 엄격하게 표시해요. 더 컴팩트한 복소수 표현이 되지만, 구성요소가 정의되지 않은 1 :+ _|_ 같은 복소수는 완전히 _|_가 되는 대가를 치러요. 부분적으로 정의된 복소수가 필요 없으니 더 효율적인 표현을 위해 엄격성 플래그를 쓰는 게 합리적인 거예요.

엄격성 플래그는 메모리 누수(가비지 컬렉터가 잡고 있지만 더 이상 계산에는 필요 없는 구조)를 다루는 데도 쓸 수 있어요. ! 플래그는 data 선언에만 쓸 수 있고, 다른 타입 시그니처나 정의에는 못 써요. 함수 인자를 엄격하게 표시하는 대응 방법은 없지만, seq!$ 함수로 같은 효과를 얻을 수 있어요.

엄격성 플래그 사용에는 정확한 지침을 주기 어려워요. 조심해서 써야 해요. 지연성은 하스켈의 근본 성질 중 하나라서, 엄격성 플래그를 추가하면 찾기 어려운 무한 루프나 다른 예상치 못한 결과를 낳을 수 있거든요.

더 알아보기