어렵게 얘기하면 같은 성질의 것인데, 프로그램 로직속에서 다른 데이터로 취급하는 방법이라 할 수 있습니다. 예를 들면,
: 숫자,
나이: 숫자,
키: 숫자 몸무게
모두 숫자로 표현되는 성질의 데이터입니다. 하지만, 이들끼리의 연산은 의미가 없습니다. 그래서, 나이를 처리하는 곳에 키가 들어가거나, 몸무게를 처리하는 곳에 나이가 들어가는 실수를 하지 않게 안전 장치를 만들려고 합니다.
Age Int
Height Int
Weight Int
이렇게 래핑해서 각 각 다른 타입으로 만들고, 나이를 다루는 함수는 Int
가 아닌 Age Int
를 받도록 만들고, 키는 Height Int
, 몸무게는 Weight Int
를 받도록 만들면 다른 뜻을 지닌 값이 들어오는 걸 막을 수 있습니다.
하지만, 이들은 타입 안정성을 위해 래핑한 것으로 본질은 Int
와 다를게 없습니다. 코드 조립(type-checker)할 때만 써먹고, 런타임엔 필요 없는 정보입니다. 컴파일 타임에만 구별되고, 효율성을 위해 런타임에는 사라지게 래핑할 방법이 필요합니다.
이럴 때 newtype
을 씁니다.
data
는 새로운 데이터 타입을 만들고,type
은 기존에 있는 타입의 별명만 만들고, (컴파일 타임에도 같은 타입으로 취급합니다. 예를 들어 type SameType = String
이라 하면, SameType
이 들어갈 자리에 String
을 넣어도 컴파일러는 불만이 없습니다. 그야말로 단순 별명일 뿐입니다.)newtype
은 컴파일 타임에는 새로운 타입인 것처럼 동작하고, 런타임에는 기존 타입과 같은 동작을 합니다.왜 하나의 필드만 있는 하나의 생성자만 newtype
으로 선언할 수 있을까요? 아마도 런타임에 생성자를 지울 때 필요한 규칙일 겁니다. (정확히 내부 동작 설명이 있는 자료를 아직 못 찾았습니다.) 런타임에 타입 생성자와 값 생성자는 지워지고, 래핑해 둔 필드만 살아남으니, 필드가 곧 타입의 의미와 같아야isomorphic 합니다. 당연히 생성자도 하나이고, 필드도 하나여야 명확하게 연결할 수 있습니다.
특정 용도로 쓰기 위해 Int
를 다음과 같이 래핑하면
newtype Age = Age { getAge :: Int } deriving Show
Age
타입의 본질은 Int
와 같습니다. 런타임에 생성자 Age
는 사라지고 필드 Int
만 남습니다.
val1 :: Age Int
= Age 1
val1
val2 :: Age Int
= Age 2
val2
= getAge val1 + getAge val2
valSum
> valSum
3
런타임에는 valSum = 1 + 2
로 바뀝니다.
생일이 지나 나이를 + 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
타입입니다. Just
와 Nothing
은 값 생성자value constructor입니다. 런타임에도 값 생성자는 사라지지 않습니다. 하나의 생성자만 있는 타입을 data
로 선언하면 별도의 값 생성자를 유지하지만, newtype
으로 선언한 타입은 값 생성자까지도 원래의 타입과 동일한 걸 쓰게되어 별도로 값 생성자를 만들어내지 않아도 됩니다. 본문에서 얘기한대로 보통 타입 클래스와 인스턴스를 활용하기 위해 newtype
을 자주 씁니다. 매개 변수 타입을 키로 인스턴스를 골라내니,
Int -----(A)
newtype TwinInt = TwinInt { getTwinInt :: Int } -----(B)
A와 B는 다른 타입으로 취급하고 인스턴스를 고를 수 있습니다. 클래스 인스턴스 선택 작업은 런타임이 아니라, 컴파일 타임에 필요한 작업입니다. 컴파일 타임만 통과하고 나면 런타임에서 TwinInt
와 Int
는 완전히 동일한 타입입니다. 코드 조각을 골라 내기 위해 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 _ -> ()
x
에 Any
타입의 값이 들어갈 경우 x
는 Any True
, Any False
, Any Bottom
, Bottom
4가지 형태의 값이 들어갈 수 있습니다. 아예 Any
값 생성자와 매칭이 안되는 Bottom
이란 경우의 수가 존재합니다. _
(hole)로 버릴지라도 x
값을 평가해 봐야 합니다. 하지만 newtype
으로 선언하면 Any True
, Any False
, Any Bottom
3가지 경우만 보면 됩니다. 그럼 위와 같은 패턴 매칭은 모두 Any
에 걸려들테니 _
(hole)부분을 평가할 필요가 없어집니다. 패턴 매칭에 있는 newtype
에서 정의한 값 생성자는 하는 일이 없습니다. 그냥 떼어내고 읽어도 됩니다.
내부를 들여다 보면 복잡하긴 한데, newtype
으로 선언한 타입은 런타임에 감싸고 있는 타입과 완전히 같게 처리한다고 생각하면 됩니다.
Bottom값에 대해 이해했다면 다음 사이트를 보세요.
https://wiki.haskell.org/Newtype↩︎