newtype은 왜 필드 하나만 갖는 생성자 하나만 있는 타입일까?

Posted on August 19, 2020

소스에선 다른 타입, 하지만 바이너리에선 같은 타입

어렵게 얘기하면 같은 성질의 것인데, 프로그램 로직속에서 다른 데이터로 취급하는 방법이라 할 수 있습니다. 예를 들면,

나이: 숫자,
: 숫자,
몸무게: 숫자

모두 숫자로 표현되는 성질의 데이터입니다. 하지만, 이들끼리의 연산은 의미가 없습니다. 그래서, 나이를 처리하는 곳에 키가 들어가거나, 몸무게를 처리하는 곳에 나이가 들어가는 실수를 하지 않게 안전 장치를 만들려고 합니다.

Age Int
Height Int
Weight Int

이렇게 래핑해서 각 각 다른 타입으로 만들고, 나이를 다루는 함수는 Int가 아닌 Age Int를 받도록 만들고, 키는 Height Int, 몸무게는 Weight Int를 받도록 만들면 다른 뜻을 지닌 값이 들어오는 걸 막을 수 있습니다.

하지만, 이들은 타입 안정성을 위해 래핑한 것으로 본질은 Int와 다를게 없습니다. 코드 조립(type-checker)할 때만 써먹고, 런타임엔 필요 없는 정보입니다. 컴파일 타임에만 구별되고, 효율성을 위해 런타임에는 사라지게 래핑할 방법이 필요합니다.

이럴 때 newtype을 씁니다.

성의를 봐서 컴파일 타임에만 속아줍니다.

왜 하나의 필드만 있는 하나의 생성자만 newtype으로 선언할 수 있을까요? 아마도 런타임에 생성자를 지울 때 필요한 규칙일 겁니다. (정확히 내부 동작 설명이 있는 자료를 아직 못 찾았습니다.) 런타임에 타입 생성자와 값 생성자는 지워지고, 래핑해 둔 필드만 살아남으니, 필드가 곧 타입의 의미와 같아야isomorphic 합니다. 당연히 생성자도 하나이고, 필드도 하나여야 명확하게 연결할 수 있습니다.

특정 용도로 쓰기 위해 Int를 다음과 같이 래핑하면

newtype Age = Age { getAge :: Int } deriving Show

Age 타입의 본질은 Int와 같습니다. 런타임에 생성자 Age는 사라지고 필드 Int만 남습니다.

val1 :: Age Int
val1 = Age 1

val2 :: Age Int
val2 = Age 2

valSum = getAge val1 + getAge val2

> valSum
3

런타임에는 valSum = 1 + 2 로 바뀝니다.

래핑 타입들을 위한 fmap

생일이 지나 나이를 + 1year 하거나, 많이 먹고 살쪄서 + 1kg을 한다면 그냥 (+)를 쓰지 못하는 상황입니다. 타입 안전성을 위해 래핑하면 지겹게 만나게 될 문제입니다. (+)를 새로 정의할까요? 그럼 (-)도, (*)도… 관련 함수들을 모두 다시 정의해야 합니다. 이런 문제를 해결하기 위해 딱인 이론이 바로 펑크터입니다. 그런데 펑크터로 만들어도 런타임에 생성자와 필드가 사라질 수 있을까요? 펑크터를 적용한 것과 아닌 것의 바이너리 코드가 같을까요?

> (+1) 1
> fmap (+1) val1

두 개가 같은 바이너리로 나와야 할 것 같은데, 두 번째 줄에서 GHC가 쉽게 버릴 수 있는 조각들이 안 보입니다. 런타임에 생성자가 지워진다는 말은 Age를 만나면 무조건 Int로 바뀌는게 아닙니다. 필드 접근자accessor getAge를 만나야 생성자를 지울 수 있습니다. 위 fmap 적용에서 지워지는게 아니라 fmap 안에서 생성자가 지워집니다. Age를 펑크터의 인스턴스로 만들려면 fmap을 다음처럼 정의합니다.

instance Functor Age where
  fmap f ag = Age $ f (getAge ag)

바로 getAge ag 부분이 런타임에 Int 타입으로 바뀌는 부분입니다. - 아직 확실한 자료는 못찾았는데, 결국은 fmap으로 Age에 적용하는 구문들은 단순 f Int 형태로 바뀔거라 생각합니다. (fmap까지 이렇게 최적화를 해준다고 명시적으로 쓰여있는 문서를 아직 못 찾아서 ’생각합니다’라고 끝맺었습니다.)

보통 펑크터나 모나드 등의 인스턴스로 만들려고 할 때, 바인드나 fmap을 고르는 키로 쓰일 타입을 newtype으로 정의합니다.

newtype State s a = State { runState :: s -> (a, s) }

GHC가

State s ... >>= ...

를 만나면 State s를 키로 하는 인스턴스의 바인드를 가져오게 됩니다. 런타임에는 s -> (a, s)만 남습니다. 그럼 State 타입에 runState를 적용하는 구문들이 모두 사라진다는 얘기입니다.

그런데, 원래 하스켈의 모든 타입 정보는 런타임에 사라지지 않나?

맞습니다. 그럼, 패턴 매칭할 때는 어떻게 하나요? 예를 들면 Just, Nothing을 구별해서 패턴 매칭을 해야 되는데, 타입이 사라지면 안될 것처럼 보입니다. Just, Nothing은 타입이 아닙니다. 이들의 타입은 Maybe a 타입입니다. JustNothing은 값 생성자value constructor입니다. 런타임에도 값 생성자는 사라지지 않습니다. 하나의 생성자만 있는 타입을 data로 선언하면 별도의 값 생성자를 유지하지만, newtype으로 선언한 타입은 값 생성자까지도 원래의 타입과 동일한 걸 쓰게되어 별도로 값 생성자를 만들어내지 않아도 됩니다. 본문에서 얘기한대로 보통 타입 클래스와 인스턴스를 활용하기 위해 newtype을 자주 씁니다. 매개 변수 타입을 키로 인스턴스를 골라내니,

Int -----(A)
newtype TwinInt = TwinInt { getTwinInt :: Int } -----(B)

A와 B는 다른 타입으로 취급하고 인스턴스를 고를 수 있습니다. 클래스 인스턴스 선택 작업은 런타임이 아니라, 컴파일 타임에 필요한 작업입니다. 컴파일 타임만 통과하고 나면 런타임에서 TwinIntInt는 완전히 동일한 타입입니다. 코드 조각을 골라 내기 위해 TwinInt를 쓰고, 조각을 고른 후에는 버립니다. 별도의 값 생성자 TwinInt가 런타임엔 없습니다. 1

문법에 한 요소를 추가할 정도로 퍼포먼스에 영향이 있는지는 모르겠습니다. 아주 빈번하게 newtype이 사용되긴 하니, 영향이 있지 않을까요?

하스켈의 모든 함수는 리턴 값이 반드시 있어야 합니다. 그럼 무한히 루프를 도는 함수는 “리턴 값”이 뭘까요? 이럴 때 리턴 값을 “Bottom”이라 표시합니다. 모든 타입들은 무한 루프의 결과 타입이 될 수 있으니, 모든 타입들에 Bottom값을 정의합니다. 예를 들어 하스켈 내부에 정의된 진짜 Bool 타입은 3가지 값을 가질 수 있다고 보면 됩니다. (명확하게 아래처럼 코딩되어 있다는 자료는 못찾았습니다. 이해하기 위한 의사 코드로 봐주세요.)

data Bool = True | False | Bottom -- 의사코드. 실제로는 Bottom을 명시적으로 적어주지 않습니다.

어떤 함수의 리턴 타입을 Bool이라 써주면, 이 함수의 리턴 값은 True, False, Bottom이 될수 있다는 말입니다. Any 타입은 눈에 보이지 않지만, 내부적으론 다음과 같이 정의됩니다. 위 하스켈 위키 사이트에서 가져온 소스에 Bottom울 명확히 표시하면

data Any = Any {... Bool ...} | Bottom 

Any 타입의 Bottom값이 새로 생겼습니다.

case x of
  Any _ -> ()

xAny 타입의 값이 들어갈 경우 xAny True, Any False, Any Bottom, Bottom 4가지 형태의 값이 들어갈 수 있습니다. 아예 Any 값 생성자와 매칭이 안되는 Bottom이란 경우의 수가 존재합니다. _ (hole)로 버릴지라도 x값을 평가해 봐야 합니다. 하지만 newtype으로 선언하면 Any True, Any False, Any Bottom 3가지 경우만 보면 됩니다. 그럼 위와 같은 패턴 매칭은 모두 Any에 걸려들테니 _ (hole)부분을 평가할 필요가 없어집니다. 패턴 매칭에 있는 newtype에서 정의한 값 생성자는 하는 일이 없습니다. 그냥 떼어내고 읽어도 됩니다.

내부를 들여다 보면 복잡하긴 한데, newtype으로 선언한 타입은 런타임에 감싸고 있는 타입과 완전히 같게 처리한다고 생각하면 됩니다.


  1. Bottom값에 대해 이해했다면 다음 사이트를 보세요.
    https://wiki.haskell.org/Newtype↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com