타입과 함수 스펙
타입과 함수 스펙 (Types and Function Specifications)
Erlang은 동적 타입 언어지만, Erlang 항(term)들의 집합을 특정 타입으로 선언하는 표기법이 갖춰져 있어요. 이 표기법은 Erlang 전체 항 집합의 구체적인 하위 타입들을 만들어 냅니다. 그리고 이 타입들을 이용해 레코드 필드의 타입, 함수의 인자와 반환 타입까지 지정할 수 있어요. 이번 장에서는 Erlang 타입 언어와 함수 스펙(specification)을 어떻게 쓰는지 살펴볼게요.
Erlang 타입 언어
Erlang은 동적 타입 언어예요. 그럼에도 Erlang 항들의 집합을 선언해 특정 타입을 이루게 하는 표기법이 함께 제공됩니다. 이는 효과적으로 모든 Erlang 항 집합의 특정 하위 타입을 형성해요.
그리고 이 타입들은 레코드 필드의 타입과 함수의 인자 · 반환 타입을 지정하는 데 사용될 수 있습니다.
타입 정보는 다음과 같은 데 쓸 수 있어요:
이 절에서 설명하는 타입 언어가 EDoc이 쓰는 순수 주석 기반의 @type와 @spec 선언을 대체하고 교체할 것으로 기대됩니다.
타입과 그 문법
타입은 Erlang 항들의 집합을 설명해요. 타입은 t:integer/0, t:atom/0, t:pid/0 같은 미리 정의된 타입들의 집합으로 구성되고, 그로부터 만들어집니다. 미리 정의된 타입은 보통 이 타입에 속하는 Erlang 항들의 무한 집합을 나타내요. 예를 들어 타입 t:atom/0은 모든 Erlang 원자의 집합을 나타냅니다.
정수와 원자의 경우 싱글턴 타입(singleton type)이 허용돼요. 예를 들어 정수 -1, 42 또는 원자 'foo', 'bar'처럼요. 다른 모든 타입은 미리 정의된 타입이나 싱글턴 타입의 합집합(union)을 사용해 만들어집니다. 어떤 타입과 그 하위 타입 사이의 타입 합집합에서는 하위 타입이 상위 타입에 흡수돼요. 따라서 합집합은 마치 하위 타입이 합집합의 구성 요소가 아니었던 것처럼 취급됩니다. 예를 들어 타입 합집합:
atom() | 'bar' | integer() | 42
은 다음 타입 합집합과 같은 항들의 집합을 설명해요:
atom() | integer()
타입들 사이에 존재하는 하위 타입 관계 때문에 t:dynamic/0을 제외한 모든 타입은 격자(lattice)를 이루는데, 가장 위의 요소 t:any/0는 모든 Erlang 항의 집합을, 가장 아래 요소 t:none/0은 빈 항 집합을 나타냅니다.
Erlang의 점진적 타이핑(gradual typing)을 지원하기 위해 타입 t:dynamic/0이 제공돼요. 타입 t:dynamic/0은 정적으로 알 수 없는 타입을 나타냅니다. 이는 Python의 Any, TypeScript의 any, Hack의 dynamic과 비슷해요. t:any/0과 t:dynamic/0은 success typing과 똑같이 상호작용하므로, Dialyzer는 이 둘을 구분하지 않습니다.
미리 정의된 타입들의 집합과 타입 문법은 다음과 같아요:
Type :: any() %% The top type, the set of all Erlang terms
| none() %% The bottom type, contains no terms
| dynamic()
| pid()
| port()
| reference()
| [] %% nil
| Atom
| Bitstring
| float()
| Fun
| Integer
| List
| Map
| Tuple
| Union
| UserDefined %% described in Type Declarations of User-Defined Types
Atom :: atom()
| Erlang_Atom %% 'foo', 'bar', ...
Bitstring :: <<>>
| <<_:M>> %% M is an Integer_Value that evaluates to a positive integer
| <<_:_*N>> %% N is an Integer_Value that evaluates to a positive integer
| <<_:M, _:_*N>>
Fun :: fun() %% any function
| fun((...) -> Type) %% any arity, returning Type
| fun(() -> Type)
| fun((TList) -> Type)
Integer :: integer()
| Integer_Value
| Integer_Value..Integer_Value %% specifies an integer range
Integer_Value :: Erlang_Integer %% ..., -1, 0, 1, ... 42 ...
| Erlang_Character %% $a, $b ...
| Integer_Value BinaryOp Integer_Value
| UnaryOp Integer_Value
BinaryOp :: '*' | 'div' | 'rem' | 'band' | '+' | '-' | 'bor' | 'bxor' | 'bsl' | 'bsr'
UnaryOp :: '+' | '-' | 'bnot'
List :: list(Type) %% Proper list ([]-terminated)
| maybe_improper_list(Type1, Type2) %% Type1=contents, Type2=termination
| nonempty_improper_list(Type1, Type2) %% Type1 and Type2 as above
| nonempty_list(Type) %% Proper non-empty list
Map :: #{} %% denotes the empty map
| #{AssociationList}
Tuple :: tuple() %% denotes a tuple of any size
| {}
| {TList}
AssociationList :: Association
| Association, AssociationList
Association :: Type := Type %% denotes a mandatory association
| Type => Type %% denotes an optional association
TList :: Type
| Type, TList
Union :: Type1 | Type2
정수 값은 정수 또는 문자 리터럴이거나, 정수로 평가되는 (아마 중첩된) 단항 또는 이항 연산으로 이루어진 표현식이에요. 이런 표현식은 비트 문자열과 범위에서도 쓸 수 있어요.
비트 문자열의 일반적인 형태는 <<_:M, _:_*N>>이고, 여기서 M과 N은 양의 정수로 평가되어야 해요. 이는 길이가 M + (k*N) 비트인 비트 문자열(즉 M비트로 시작하고 이어서 각각 N비트인 k개의 세그먼트로 이어지며, k도 양의 정수)을 나타냅니다. 표기 <<_:_*N>>, <<_:M>>, <<>>는 M이나 N(또는 둘 다)이 0인 경우의 편리한 약어예요.
목록은 흔히 쓰이기 때문에 약어 타입 표기가 있어요. 타입 list(T)과 nonempty_list(T)은 각각 약어 [T]와 [T,...]를 가져요. 두 약어의 유일한 차이는 [T]는 빈 목록일 수 있지만 [T,...]는 빈 목록일 수 없다는 점입니다.
t:list/0(즉 타입이 알려지지 않은 요소들의 목록)의 약어가 [_](또는 [any()])이지 []가 아니라는 점을 주목하세요. 표기 []는 빈 목록에 대한 싱글턴 타입을 지정해요.
맵 타입의 일반적인 형태는 #{AssociationList}예요. AssociationList에서 키 타입은 겹칠 수 있는데, 겹치면 가장 왼쪽 연관(association)이 우선합니다. 맵 연관은 그 타입에 속하면 AssociationList의 키를 가져요. AssociationList는 필수 (:=) 연관 타입과 선택 (=>) 연관 타입을 모두 포함할 수 있습니다. 연관 타입이 필수라면, 그 타입을 가진 연관이 존재해야 해요. 선택 연관 타입의 경우 키 타입이 존재할 필요가 없습니다.
표기 #{}는 빈 맵에 대한 싱글턴 타입을 지정해요. 이 표기가 t:map/0 타입의 약어가 아니라는 점을 기억해 두세요.
편의를 위해 다음 타입들도 내장되어 있어요. 이 타입들은 표에 보이는 타입 합집합에 대한 미리 정의된 별칭으로 생각할 수 있습니다.
| 내장 타입 | 다음과 같이 정의됨 |
|---|---|
t:term/0 |
t:any/0 |
t:binary/0 |
<<_:_*8>> |
t:nonempty_binary/0 |
<<_:8, _:_*8>> |
t:bitstring/0 |
<<_:_*1>> |
t:nonempty_bitstring/0 |
<<_:1, _:_*1>> |
t:boolean/0 |
'false' | 'true' |
t:byte/0 |
0..255 |
t:char/0 |
0..16#10ffff |
t:nil/0 |
[] |
t:number/0 |
t:integer/0 | t:float/0 |
t:list/0 |
[any()] |
t:maybe_improper_list/0 |
maybe_improper_list(any(), any()) |
t:nonempty_list/0 |
nonempty_list(any()) |
t:string/0 |
[char()] |
t:nonempty_string/0 |
[char(), ...] |
t:iodata/0 |
iolist() | binary() |
t:iolist/0 |
maybe_improper_list(byte() | binary() | iolist(), binary() | []) |
t:map/0 |
#{any() => any()} |
t:function/0 |
fun() |
t:module/0 |
t:atom/0 |
t:mfa/0 |
{module(),atom(),arity()} |
t:arity/0 |
0..255 |
t:identifier/0 |
pid() | port() | reference() |
node/0 |
t:atom/0 |
t:timeout/0 |
'infinity' | non_neg_integer() |
t:no_return/0 |
t:none/0 |
표: 내장 타입, 미리 정의된 별칭
게다가 다음 세 가지 내장 타입도 존재하고, 아래처럼 정의된 것으로 생각할 수 있어요. 다만 엄밀히 말하면 위에서 정의한 타입 언어 문법에 따르면 그 "타입 정의"는 유효한 문법이 아닙니다.
| 내장 타입 | 문법으로 정의된 것으로 생각할 수 있음 |
|---|---|
t:non_neg_integer/0 |
0.. |
t:pos_integer/0 |
1.. |
t:neg_integer/0 |
..-1 |
표: 추가 내장 타입
참고 {: .info }
다음 내장 목록 타입들도 존재하지만, 드물게 쓰일 것으로 기대돼요. 그래서 이름이 깁니다:
nonempty_maybe_improper_list() :: nonempty_maybe_improper_list(any(), any()) nonempty_improper_list(Type1, Type2) nonempty_maybe_improper_list(Type1, Type2)여기서 마지막 두 타입은 기대할 만한 Erlang 항들의 집합을 정의해요.
또한 편의를 위해 레코드 표기도 허용됩니다. 레코드는 대응하는 튜플의 약어예요:
Record :: #Erlang_Atom{}
| #Erlang_Atom{Fields}
레코드는 확장되어 타입 정보를 포함할 수 있어요. 이는 Record Declarations의 Type Information에서 설명합니다.
내장 타입 재정의하기
변경 {: .info }
Erlang/OTP 26부터 내장 타입과 같은 이름의 타입을 정의하는 것이 허용돼요.
의도적으로 내장 이름을 재사용하는 것은 혼란을 줄 수 있으므로 피하는 걸 권장해요. 그러나 Erlang/OTP 릴리스가 새 타입을 도입할 때, 우연히 같은 이름의 타입을 정의한 코드는 계속 동작할 거예요.
예를 들어 Erlang/OTP 42 릴리스가 다음과 같이 정의된 새 타입 gadget()을 도입한다고 상상해 보세요:
-type gadget() :: {'gadget', reference()}.
그리고 어떤 코드가 gadget()의 (다른) 자체 정의를 가지고 있다고 상상해 보세요. 예를 들어:
-type gadget() :: #{}.
재정의가 허용되므로 코드는 여전히 컴파일됩니다(경고와 함께요). Dialyzer도 추가 경고를 내지 않아요.
사용자 정의 타입의 타입 선언
앞서 보았듯이 타입의 기본 문법은 닫힌 괄호가 뒤따르는 원자예요. 새 타입은 다음 예시처럼 -type, -opaque, -nominal 속성으로 선언됩니다:
-type my_struct_type() :: Type.
-opaque my_opaq_type() :: Type.
-nominal my_nominal_type() :: Type.
타입 이름은 괄호가 뒤따르는 원자 my_struct_type이에요. Type은 이전 절에서 정의한 타입입니다. 현재의 제약은 Type이 미리 정의된 타입이거나, 다음 중 하나인 사용자 정의 타입만을 포함할 수 있다는 거예요:
- 모듈 로컬 타입, 즉 정의가 해당 모듈의 코드에 존재하는 타입
- 원격 타입, 즉 다른 모듈에서 정의되고 export된 타입 (자세한 내용은 곧 다룰게요)
모듈 로컬 타입의 경우 그 정의가 모듈에 존재해야 한다는 제약을 컴파일러가 강제하며, 위반하면 컴파일 오류가 나요. (비슷한 제약이 현재 레코드에도 존재합니다.)
타입 선언은 괄호 사이에 타입 변수를 포함시켜 파라미터화할 수도 있어요. 타입 변수의 문법은 Erlang 변수와 같아요, 즉 대문자로 시작하죠. 이런 변수들은 정의의 RHS에 나타나야 합니다. 구체적인 예시를 보면:
-type orddict(Key, Val) :: [{Key, Val}].
모듈은 일부 타입을 export해서 다른 모듈이 그것들을 원격 타입 으로 참조하도록 허용할 수 있어요. 이 선언은 다음과 같은 형태를 가져요:
-export_type([T1/A1, ..., Tk/Ak]).
여기서 Ti들은 원자(타입의 이름)이고, Ai들은 그 아리티예요.
예시:
-export_type([my_struct_type/0, orddict/2]).
이 타입들이 모듈 'mod'에서 export된다고 가정하면, 다른 모듈에서 다음처럼 원격 타입 표현식을 사용해 참조할 수 있어요:
mod:my_struct_type()
mod:orddict(atom(), term())
export로 선언되지 않은 타입을 참조하는 것은 허용되지 않아요.
opaque로 선언된 타입은 그 구조가 정의 모듈 밖에서 보이지 않아야 하는 항들의 집합을 나타냅니다. 즉 정의한 모듈만이 그 항 구조에 의존할 수 있어요. 결과적으로 그런 타입은 모듈 로컬로는 별 의미가 없고(모듈 로컬 타입은 어차피 다른 모듈이 접근할 수 없으니까요) 항상 export되어야 합니다.
변경 {: .info }
명목(nominal) 타입은 Erlang/OTP 28에서 도입됐어요.
nominal로 선언된 타입은 그 구조 대신 사용자 정의 이름에 따라 타입 검사됩니다. 즉 -nominal feet() :: integer()와 -nominal meter() :: integer()는 같은 타입이 아니지만, -type을 쓴 경우라면 같은 타입이 될 거예요.
Opaques와 Nominals에 대해 더 읽어보세요.
레코드 선언의 타입 정보
레코드 필드의 타입은 레코드 선언에서 지정할 수 있어요. 문법은 다음과 같습니다:
-record(rec, {field1 :: Type1, field2, field3 :: Type3}).
네이티브 레코드의 문법은 다음과 같아요:
-record #rec{field1 :: Type1, field2, field3 :: Type3}.
타입 주석이 없는 필드의 타입은 기본적으로 any()가 돼요. 즉 이전 예시는 다음의 약어입니다:
-record(rec, {field1 :: Type1, field2 :: any(), field3 :: Type3}).
필드에 초기값이 있는 경우, 타입은 초기화 뒤에 선언되어야 해요. 다음과 같이요:
-record(rec, {field1 = [] :: Type1, field2, field3 = 42 :: Type3}).
필드의 초기값은 해당 타입과 호환되어야(즉 그 타입의 멤버여야) 합니다. 이는 컴파일러가 검사하고, 위반하면 컴파일 오류가 나요.
변경 {: .info }
Erlang/OTP 19 이전에는 초기값이 없는 필드의 경우 싱글턴 타입
'undefined'가 모든 선언된 타입에 추가됐어요. 다시 말해 다음 두 레코드 선언은 동일한 효과를 가졌습니다:-record(rec, {f1 = 42 :: integer(), f2 :: float(), f3 :: 'a' | 'b'}). -record(rec, {f1 = 42 :: integer(), f2 :: 'undefined' | float(), f3 :: 'undefined' | 'a' | 'b'}).이제는 그렇지 않아요. 레코드 필드 타입에
'undefined'가 필요하다면 두 번째 예시처럼 타입스펙에 명시적으로 추가해야 합니다.
타입 정보를 포함했든 안 했든, 일단 정의된 레코드는 다음 문법으로 타입으로 사용할 수 있어요:
#rec{}
게다가 레코드 타입을 사용할 때 필드에 대한 타입 정보를 추가해서 레코드 필드를 더 구체화할 수 있습니다. 다음과 같이요:
#rec{some_field :: Type}
이 문법은 튜플 기반 레코드에서만 지원되고, 네이티브 레코드에서는 지원되지 않아요.
지정되지 않은 필드는 원래 레코드 선언에 있는 타입을 갖는다고 가정합니다.
참고 {: .info }
튜플 기반 레코드를 ETS와 Mnesia 매치 함수의 패턴을 만드는 데 사용할 때, Dialyzer가 잘못된 경고를 내지 않도록 도움이 필요할 수 있어요. 예를 들어:
-type height() :: pos_integer(). -record(person, {name :: string(), height :: height()}). lookup(Name, Tab) -> ets:match_object(Tab, #person{name = Name, _ = '_'}).
'_'가 레코드 필드height의 타입에 없으므로 Dialyzer가 경고를 내요.이를 다루는 권장 방법은 필요를 모두 수용할 수 있도록 가장 작은 레코드 필드 타입들을 선언한 다음, 필요에 따라 정제(refinement)를 만드는 것이에요. 수정된 예시:
-record(person, {name :: string(), height :: height() | '_'}). -type person() :: #person{height :: height()}.스펙과 타입 선언에서는
#person{}보다 타입person()을 쓰는 것이 선호돼요.
함수에 대한 스펙
함수에 대한 스펙(또는 계약)은 -spec 속성으로 주어져요. 일반적인 형식은 다음과 같습니다:
-spec Function(ArgType1, ..., ArgTypeN) -> ReturnType.
같은 이름 Function을 가진 함수의 구현이 현재 모듈에 존재해야 하고, 함수의 아리티가 인자의 수와 일치해야 하며, 그렇지 않으면 컴파일에 실패해요.
Module이 현재 모듈의 이름이라면 모듈 이름을 포함한 다음의 더 긴 형식도 유효해요. 이는 문서화 목적으로 유용할 수 있어요.
-spec Module:Function(ArgType1, ..., ArgTypeN) -> ReturnType.
또한 문서화 목적으로 인자 이름을 줄 수 있어요:
-spec Function(ArgName1 :: Type1, ..., ArgNameN :: TypeN) -> RT.
함수 스펙은 오버로드될 수 있어요. 즉 세미콜론(;)으로 구분된 여러 타입을 가질 수 있죠. 예를 들어:
-spec foo(T1, T2) -> T3;
(T4, T5) -> T6.
현재 제약은, 현재 Dialyzer에서 경고를 일으키는 제약인데, 인자 타입의 도메인이 겹칠 수 없다는 거예요. 예를 들어 다음 스펙은 경고를 발생시킵니다:
-spec foo(pos_integer()) -> pos_integer();
(integer()) -> integer().
타입 변수는 스펙에서 함수의 입력과 출력 인자 사이의 관계를 지정하는 데 사용될 수 있어요. 예를 들어 다음 스펙은 다형적 항등 함수의 타입을 정의합니다:
-spec id(X) -> X.
위 스펙이 입력과 출력 타입을 어떤 식으로도 제한하지 않는다는 점을 주목하세요. 이 타입들은 가드 같은 하위 타입 제약으로 제한할 수 있고, 유계 양화(bounded quantification)를 제공합니다:
-spec id(X) -> X when X :: tuple().
현재 :: 제약("하위 타입이다"라고 읽음)은 -spec 속성의 when 부분에서 쓸 수 있는 유일한 가드 제약이에요.
참고 {: .info }
위 함수 스펙은 같은 타입 변수를 여러 번 사용해요. 이는 타입 변수가 없는 다음 함수 스펙보다 더 많은 타입 정보를 제공합니다:
-spec id(tuple()) -> tuple().후자의 스펙은 함수가 어떤 튜플을 받아 어떤 튜플을 반환한다고 말해요.
X타입 변수를 가진 스펙은 함수가 튜플을 받아 그 같은 튜플을 반환한다고 지정합니다.다만 이 추가 정보를 고려할지 말지는 스펙을 처리하는 도구들이 선택할 몫이에요.
:: 제약의 스코프는 그것이 나타나는 뒤의 (...) -> RetType 스펙이에요. 혼란을 피하기 위해 오버로드된 계약의 서로 다른 구성 요소에서 서로 다른 변수를 사용하는 것이 좋습니다. 다음 예시처럼요:
-spec foo({X, integer()}) -> X when X :: atom();
([Y]) -> Y when Y :: number().
Erlang의 일부 함수는 반환하지 않도록 설계됐어요. 서버를 정의하거나 예외를 던지는 데 쓰이기 때문이죠. 다음 함수처럼요:
my_error(Err) -> throw({error, Err}).
그런 함수에는 "반환"에 특별한 t:no_return/0 타입을 쓰는 것을 권장합니다. 다음 형태의 계약을 통해서요:
-spec my_error(term()) -> no_return().
참고 {: .info }
Erlang은
t:term/0이나t:any/0에 해당하는 익명 타입 변수로 약어_를 사용해요. 예를 들어 다음 함수:-spec Function(string(), _) -> string().은 다음과 동등합니다:
-spec Function(string(), any()) -> string().