Graphics Programming

newtype 선언 본문

Season 1/하스켈

newtype 선언

minseoklee 2014. 9. 29. 11:40

하스켈에서 타입을 선언하는 키워드는 data, type, newtype 이렇게 세 개가 있습니다. 그런데 위키책에서 newtype을 나중에 설명한다고 했지만 Beginner's Track이 끝날 때까지 설명을 안 해서 HaskellWiki에 있는 newtype 항목 을 여기에 따로 번역합니다.

Newtype

Newtype 선언은 data와 매우 비슷한 방식으로 새로운 타입을 생성한다. newtype의 문법과 용법은 data 선언과 거의 같다. 사실은 newtype 키워드를 data로 교체해도 컴파일이 되고 심지어 프로그램이 잘 돌아갈 가능성이 높다. 하지만 그 반대는 아니다. data를 newtype으로 교체할 수 있는 것은 생성자가 오직 하나고 그 생성자 내의 필드가 오직 하나일 때 뿐이다.

몇 가지 예시:

newtype Fd = Fd CInt
-- data Fd = Fd CInt도 타당하다

-- newtype도 일반 타입처럼 파생절을 가질 수 있다
newtype Identity a = Identity a
  deriving (Eq, Ord, Read, Show)

-- 레코드 문법도 가능하지만 하나의 필드만 허용된다
newtype State s a = State { runState :: s -> (s, a) }

-- 이것은 허용되지 *않는다*
-- newtype Pair a b = Pair { pairFst :: a, pairSnd :: b }
-- 하지만 이건 허용된다
data Pair a b = Pair { pairFst :: a, pairSnd :: b}
-- 이것도 된다
newtype Pair' a b = Pair' (a, b)​

이렇게 제한적이라면 newtype을 어디에 써야 할까?

목차

1 단축 버전
2 골치아픈 부분
3 strict 타입은 어떻게?
4 예시
5 더 보기

1 단축 버전

필드 하나, 생성자 하나라는 제약은 이 새로운 타입과 그 필드의 타입이 직접적으로 대응되는 관계임을 뜻한다.

State :: (s -> (s, a)) -> State s a
runState :: State s a -> (s -> (s, a))

수학적으로 말하자면 이것들은 동형(isomorphic)이다. 컴파일 시간에 타입을 검사하고 나면 런타임에는 두 타입을 동일하게 취급할 수 있어서 통상적으로 데이터 생성자에 딸려오는 오버헤드나 간접 참조(indirection)가 없다. 따라서 특정 타입에 대해 여러 타입 클래스를 선언하거나 어떤 타입을 추상 타입으로 만들고자 한다면, 그 타입을 newtype으로 감싸면 타입 검사기는 이를 별개의 것으로 취급하지만 런타임에는 동일한 것이 된다. 그러면 GHC가 바이트 덩어리들을 이리저리 움직이는 것을 걱정할 필요 없이 phantom type이나 recursive type 같은 온갖 기교를 부릴 수 있다.

2 골치아픈 부분

그럼 왜 모두가 newtype을 쓰지 않는 걸까? 음, 자주들 사용한다. 하지만 미묘하지만 문법상 중요한 차이점이 있다. 다음과 같이 Bool과 동형인 데이터 타입을 만들려 한다면

data Any = Any { getAny :: Bool }

이 동형성이라는 것이 정확하지 않다는 것을 알게 된다.

Any . getAny $ Any True  = Any True  -- 괜찮다
Any . getAny $ Any False = Any False -- 이것도 괜찮다
Any . getAny $ Any ⊥     = Any ⊥    -- 아직까진 좋다
Any . getAny $ ⊥         = Any ⊥    -- 잠깐만...

(이 뒤집힌 T는 뭘까?)

여기서 문제는 data 키워드를 통해 선언한 타입들이 전이(lift)된다는 것이다. 그런 타입들은 다른 모든 타입과 구분되는 자신만의 ⊥ 값을 포함한다. 이 예시에서 Any ⊥ :: Any⊥ :: Any와 다르다. 이것이 뜻하는 바는 다음의 패턴 비교가

case x of
  Any _ -> ()

그 인자를 반드시 평가해봐야 한다는 것이다. 패턴 비교가 실패할 리 없어보여도 말이다. 우리는 x가 ⊥인지 어떤 y에 대해 Any y인지 검사해야 한다.

이것이 하스켈의 게으르고 모든 것을 처리하지 않는 시맨틱의 본질이다. 문제는 이러면 어떤 값이 생성자에 의해 감싸지는지 아닌지 추적해야 하고, 이것이 뜻하는 바는 우리가 원하지도 않은 여분의 bottom value를 구별하기 위해서라도 런타임에 여분의 생성자를 추적해야 한다는 것이다. 그러니 일관되게 하면서도 정확한 동형성을 보존하기 위해 하스켈은 lift되지 않는 타입의 생성을 위해 newtype 키워드를 제공하는 것이다. newtype 생성자에 대한 패턴 매칭은 먹히지 않는데, 별개의 ⊥이 없어서 타입 내의 모든 값이 생성자에 의해 감싸지기 때문이다.

3 strict 타입은 어떻게?

여러분은 알아챘을지도 모르겠다. 다음과 같은 타입에서

data Identity' a = Identity' !a

Identity' ⊥ = ⊥ 이고 따라서 그 동형성이란 걸 얻은 게 아닌지 생각할 수도 있다. 하지만 적극성 표기(strictness annotation)의 요지는 Identity' ⊥ 가 실제로는 Identity' $! ⊥ 을 뜻한다는 것이다. 타입의 시맨틱들은 근본적으로 동일하고, 특히 case 표현식은 여전히 값 평가를 강제한다.

4 예제

module Foo where

data Foo1 = Foo1 Int    -- Int를 느슨하게 참조하는 생성자 Foo1을 정의한다
data Foo2 = Foo2 !Int   -- Int를 적극적으로 참조하는 생성자 Foo2를 정의한다
newtype Foo3 = Foo3 Int -- Int와 같은 뜻을 갖는 Foo3 생성자를 정의한다

-- 인수는 게으르고 무시된다. 따라서
-- undefined는 실패를 일으키지 않는다.
-- 생성자 패턴 비교가 성공하기 때문이다.
x1 = case Foo1 undefined of
     Foo1 _ -> 1        -- 1

-- 인자는 적극적이고(! 때문) 따라서
-- undefined는 실패를 유발한다.
x2 = case Foo2 undefined of
     Foo2 _ -> 1        -- undefined

-- newtype은 Int처럼 행동한다. 밑의 yInt를 보자.
x3 = case Foo3 undefined of
     Foo3 _ -> 1        -- 1

-- 생성자 패턴 비교가 실패한다
y1 = case undefined of
     Foo1 _ -> 1        -- undefined

-- 생성자 패턴 비교가 실패한다
y2 = case undefined of
     Foo2 _ -> 1        -- undefined

-- newtype은 Int처럼 행동한다. 런타임에는
-- 아무 생성자도 없다.
y3 = case undefined of
     Foo3 _ -> 1        -- 1

-- Int의 동작을 보여준다
int :: Int
int = undefined

yInt = case int of
       _ -> 1                   -- 1

조금 더 읽기 쉽게 표로 정리했다.

newtype Foo = Foo () data Foo = Foo () data Foo = Foo !()
case Foo undefined of Foo _ -> () () () undefined
case undefined of Foo _ -> () () undefined undefined
Foo undefined seq () undefined () undefined
(undefined :: Foo) seq () undefined undefined undefined

5 더 보기

Haskell 98 Report는 4.2.3절에서 newtype을 정의한다.

Comments