Elm의 타입은 제네릭 타입을 허용하는데, 이는 보통 인터페이스에 유연성을 더해 줘요.
예를 들어 Maybe a 타입은 어떤 타입의 값이든 담을 수 있어요.
type Maybe a = Nothing | Just a
위 예시에서 타입 매개변수 a는 등호 =의 양쪽에서 모두 사용됐어요.
이때 a는 타입 정의 안의 어떤 데이터에 묶여 있다고 말해요.
그런데 어떤 경우에는 타입 매개변수가 등식의 왼쪽에만 나타나요.
type Distance unit = Distance Float
위 Distance 정의에서 unit은 자유 타입 매개변수로, 타입 안의 어떤 데이터에도 묶여 있지 않아요.
이런 타입을 팬텀 타입이라고도 불러요.
팬텀 타입은 컴파일 타임에 제약을 강제하고 싶을 때 놀랄 만큼 유용해요. 예를 들어 같은 단위의 거리끼리만 더하도록 보장하고 싶다고 해봐요.
-- Distance is an opaque type, since the module does not expose its variants.
-- This means that users may only use the functions meter, foot and add to manipulate distances.
module Distance exposing (Distance, Meter, Foot, meter, foot, add)
-- The Distance type has a phantom type 'unit'.
type Distance unit = Distance Float
-- We define two types that will be used in place
-- of the phantom type 'unit' in our constructor functions.
type Meter = Meter
type Foot = Foot
-- Constructor for Meter
meter : Distance Meter
meter = Distance 1.0
-- Constructor for Foot
foot : Distance Foot
foot = Distance 0.3048
-- The add function cannot take two parameters of different types.
-- So we cannot add meters and feet by mistake.
add : Distance unit -> Distance unit -> Distance unit
add (Distance d1) (Distance d2) = Distance (d1 + d2)
그 모듈 바깥의 코드는 Distance 타입의 내부에 접근할 수 없어요.
사용자는 두 개의 Distance Meter는 더할 수 있지만, Distance Meter와 Distance Foot은 더할 수 없어요.
import Distance exposing (Distance)
-- Compiles
twoMeters = Distance.add Distance.meter Distance.meter
-- Does not compile
errDist = Distance.add Distance.meter Distance.foot
어떤 타입이 팬텀 타입이 될 수 있는지에 제한은 없고, 레코드 타입도 사용할 수 있어요. 레코드를 팬텀 타입으로 사용하면 함수를 거치면서 변환되는 복잡한 제약을 표현할 수 있어요.
이제 Distance 모듈 안에 LegoBlock이라는 새로운 거리 단위를 추가해 봐요.
실제 블록은 규격이 정해진 물건이라 언제나 두 가지 속성을 가져요.
바로 거리가 분수가 아니고 음수가 아니라는 점이에요.
type LegoBlock = LegoBlock
fourStuds : Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
fourStuds = Distance 4.0
nonFractional과 nonNegative는 타입 인자 밖에서는 존재하지 않는 레코드의 필드예요.
그래서 이 필드들에는 () 타입을 주는 게 어울리는데, 이 타입을 __유닛 타입__이라고 하고, 존재한다는 사실 외에는 아무 정보도 담지 않아요.
물론 임의의 LegoBlock 거리를 얻는 것도 가능해요.
예를 들어 거리의 차이나 비율을 계산한 뒤에는 그렇죠.
negativeStud : Distance { properties | unit: LegoBlock, nonFractional : () }
negativeStud = Distance -1.0
threeFiddyStud : Distance { properties | unit: LegoBlock, nonNegative : () }
threeFiddyStud = Distance 3.50
crazyStud : Distance { properties | unit: LegoBlock }
crazyStud = Distance -13.37
위의 모든 값은 유효하고 컴파일도 되지만, nonFractional과 nonNegative 속성을 모두 가진 fourStuds 같은 값에 특히 관심이 있어요.
이런 값은 서로 결합할 수 있는 실제 블록을 나타내기 때문이에요.
combineLegoBlocks
: Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
-> Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
-> Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
combineLegoBlocks = add
이제 사용자가 LegoBlock 거리를 만들고 다듬을 수 있도록 몇 가지 함수를 추가해 봐요.
newLegoBlock : Float -> Distance { properties | unit: LegoBlock }
newLegoBlock dist = Distance dist
floorDistance : Distance properties -> Distance { properties | nonFractional : () }
floorDistance (Distance dist) = Distance (toFloat (floor dist))
ceilingDistance : Distance properties -> Distance { properties | nonFractional : () }
ceilingDistance (Distance dist) = Distance (toFloat (ceiling dist))
absDistance : Distance properties -> Distance { properties | nonNegative : () }
absDistance (Distance dist) = Distance (abs dist)
floorDistance, ceilingDistance, absDistance는 LegoBlock이 아닌 단위도 다룰 수 있어요.
그리고 일반적으로 입력 속성에 대해 아무 가정도 하지 않고, 출력이 nonFractional이나 nonNegative 중 특정 속성을 가지도록 보장할 뿐이에요.
몇 가지 결과를 살펴봐요.
import Distance exposing (Distance)
-- Compiles
distance1 = combineLegoBlocks fourStuds fourStuds
-- Does not compile
distance2 = combineLegoBlocks fourStuds threeFiddyStud
-- Compiles
distance3 = combineLegoBlocks fourStuds (floorDistance threeFiddyStud)
-- Does not compile
distance4 = combineLegoBlocks fourStuds negativeStud
-- Compiles
distance5 = combineLegoBlocks fourStuds (absDistance negativeStud)
-- Does not compile
distance6 = combineLegoBlocks fourStuds crazyStud
-- Compiles
distance7 = combineLegoBlocks fourStuds (floorDistance (absDistance crazyStud))
-- Compiles
distance8 = combineLegoBlocks fourStuds (ceilingDistance (absDistance crazyStud))
일반적으로 floorDistance와 ceilingDistance는 서로 다른 결과를 내지만 보장은 같아요.
이것이 팬텀 타입 기법의 강점이에요.
사용자에게 유연한 선택지를 주면서도 강력한 보장을 유지할 수 있거든요.
악의 지배자로서, 전 세계 악의 던전에 있는 TreasureChest의 품질에 큰 자부심을 느껴요.
품질을 유지하는 건 쉽지 않아요. 던전 관리자들이 자꾸 보물 제작을 엉망으로 만들거든요. 그래서 관리자들을 뜻대로 움직이게 할 Elm API를 제공하기로 해요.
양보할 수 없는 두 가지 조건이 있어요:
TreasureChest는 최소 8자 이상의 안전한 비밀번호로 보호되어야 해요TreasureChest는 고유한 보물 하나만 담아야 해요던전 관리자들이 비밀번호와 보물 조합의 목록을 제안하면, 조건에 맞는 TreasureChest만 만들어져요.
이 기준에 따르면, 제안 목록에서 안전한 상자를 만드는 방법은 두 가지가 있어요:
두 방법의 결과가 다를 수도 있어요(예를 들어 [("strong_password", GoldStatue), ("1234", GoldStatue)]의 경우). 하지만 어느 쪽이든 상관없으니, 선택은 던전 관리자들에게 맡기고 싶어요.
사용자에게 어느 정도 선택의 여지를 주면서도, 최종 결과의 속성을 보장하는 API라고요? 팬텀 타입 기법에 딱 맞을 것 같아요!
TreasureChest 타입과 그 동반 함수인 getTreasure는 이미 주어져 있어요. 하지만 상자 제안을 나타낼 타입은 직접 만들어야 해요.
Chest 타입과 makeChest를 구현하고, secureChest와 uniqueTreasures의 타입 시그니처를 고쳐요.
makeChest와 makeTreasureChest의 타입 시그니처는 요구 사항의 일부로 이미 제공되어 있으니, 수정하면 안 돼요.
Chest는 TreasureChest와 같은 데이터를 담아야 하고, 두 개의 타입 인자를 가져야 해요. 하나는 treasure를 위한 것이고, 다른 하나는 conditions를 위한 레코드 팬텀 타입이에요.
Chest는 팬텀 타입을 사용하므로, TreasureFactory 모듈 밖에서는 사용되지 않도록 불투명해야 한다는 점에 유의해요.
이 경우에는 아예 노출되지도 않아요.
확장 가능한 레코드를 팬텀 타입으로 사용해 제약을 추가하도록, secureChest와 uniqueTreasures의 타입 시그니처를 고쳐요.
secureChest는 특정 조건이 없는 Chest를 받아서 Maybe Chest를 반환해야 하며, 팬텀 레코드에 securePassword : ()라는 추가 조건이 있어야 해요.
uniqueTreasures는 특정 조건이 없는 List Chest를 받아서 List Chest를 반환해야 하며, 팬텀 레코드에 uniqueTreasure : ()라는 추가 조건이 더해져야 해요.
Elm 테스트는 노출된 함수에는 접근할 수 있지만 타입 시그니처에는 접근할 수 없어요. 따라서 올바른 시그니처를 사용하고 있는지 테스트로 검증하기는 불가능해요. 물론 올바른 시그니처를 사용했는지 확인하는 가장 확실한 방법은 모듈과 테스트를 컴파일하고 실행하는 데 성공하는 것이에요. 그래도 해답을 제출하면 타입 시그니처를 검사해 주는 분석기 규칙을 만들어 두었어요.
상자가 준비되면, 그중 안전한 상자를 골라내야 해요.
secureChest를 구현해서, 비밀번호가 8자 이상인 상자에만 Just를 반환하도록 해요.
그 조건을 만족하는 Chest는 팬텀 타입에 securePassword : () 조건이 추가되어야 해요.
던전에는 가장 희귀한 보물만 들어갈 수 있어요. 복제본이 하나라도 있으면 보물이 싸 보이거든요.
uniqueTreasures를 구현해서, Chest 목록을 받아 입력 목록에서 보물이 고유한 Chest들의 목록을 반환하도록 해요.
어떤 보물이 입력에 두 번 나타나면, 출력에 포함되면 안 돼요.
입력으로 들어오는 Chest에는 특정 조건이 없어야 하고, 출력되는 Chest에는 uniqueTreasure : () 조건이 추가되어야 해요.
모험가들을 꿀에 모여드는 파리처럼 끌어들일 최고의 TreasureChest를 마음껏 즐겨요.
makeTreasureChest를 구현해서, 안전하면서 고유한 Chest를 받아 TreasureChest를 만들어요.
TreasureChest는 불투명 타입이라 이것이 유일한 생성 방법이에요. 형편없는 던전 관리자라도 이걸 망칠 수 없어요.
Exercism에 가입하고 Elm 트랙을 개념 28개연습 문제 110개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.