타입 제한
타입 제한 (Type restrictions)
타입 제한(Type restriction)은 메서드 매개변수에 적용되어 그 메서드가 받아들이는 타입을 제한합니다.
def add(x : Number, y : Number)
x + y
end
# Ok
add 1, 2
# Error: no overload matches 'add' with types Bool, Bool
add true, false
참고로 add를 타입 제한 없이 정의했어도 컴파일 타임 에러가 났을 겁니다:
def add(x, y)
x + y
end
add true, false
위 코드는 이런 컴파일 에러를 냅니다:
Error in foo.cr:6: instantiating 'add(Bool, Bool)'
add true, false
^~~
in foo.cr:2: undefined method '+' for Bool
x + y
^
이유는 add를 호출하면 인자의 타입으로 그 메서드가 인스턴스화(instantiate)되기 때문입니다. 즉 서로 다른 타입 조합으로 호출할 때마다 서로 다른 메서드 인스턴스가 생기죠.
차이점은 첫 번째 에러 메시지가 조금 더 명확하다는 것뿐이고, 어느 쪽이든 컴파일 타임 에러가 나기 때문에 안전합니다. 그래서 일반적으로 타입 제한을 지정하지 않는 편이 낫고, 거의 서로 다른 메서드 오버로드를 정의할 때만 사용해요. 이렇게 하면 더 제네릭하고 재사용 가능한 코드가 됩니다. 예를 들어 + 메서드를 가지지만 Number가 아닌 클래스를 정의했다면, 타입 제한이 없는 add 메서드는 쓸 수 있지만 타입 제한이 있는 add 메서드는 쓸 수 없어요.
# A class that has a + method but isn't a Number
class Six
def +(other)
6 + other
end
end
# add method without type restrictions
def add(x, y)
x + y
end
# OK
add Six.new, 10
# add method with type restrictions
def restricted_add(x : Number, y : Number)
x + y
end
# Error: no overload matches 'restricted_add' with types Six, Int32
restricted_add Six.new, 10
타입 제한에 쓰이는 표기는 타입 문법(type grammar)을 참고하세요.
타입 제한은 실제 메서드 안의 변수에는 적용되지 않는다는 점에 유의하세요.
def handle_path(path : String)
path = Path.new(path) # *path* is now of the type Path
# Do something with *path*
end
인스턴스 변수로부터의 제한 (Restrictions from instance variables)
어떤 경우에는 그 값이 어떻게 쓰이는지에 따라 메서드 매개변수의 타입을 제한할 수 있어요. 다음 예시를 봅시다:
class Foo
@x : Int64
def initialize(x)
@x = x
end
end
이 경우 초기화 함수의 매개변수 x는 반드시 Int64여야 한다는 걸 알 수 있고, 제한을 걸어 두는 편이 낫죠.
컴파일러는 메서드 매개변수가 인스턴스 변수에 할당되는 모습을 발견하면 그런 제한을 넣어요. 위 예시에서 Foo.new "hi"를 호출하면 (타입 제한에 주목하세요) 이렇게 실패합니다:
Error: no overload matches 'Foo.new' with type String
Overloads are:
- Foo.new(x : ::Int64)
self 제한 (self restriction)
특별한 타입 제한이 하나 있는데 바로 self입니다:
class Person
def ==(other : self)
other.name == name
end
def ==(other)
false
end
end
john = Person.new "John"
another_john = Person.new "John"
peter = Person.new "Peter"
john == another_john # => true
john == peter # => false (names differ)
john == 1 # => false (because 1 is not a Person)
앞의 예시에서 self는 Person이라고 쓰는 것과 같아요. 하지만 일반적으로 self는 그 메서드를 최종적으로 소유하게 될 타입을 의미하는데, 모듈이 개입되면 훨씬 유용해집니다.
참고로 Person이 Reference를 상속하므로 ==의 두 번째 정의는 사실 필요 없어요. Reference에 이미 정의되어 있거든요.
self는 클래스 메서드 안에서도 항상 인스턴스 타입과의 일치를 나타낸다는 점을 기억하세요:
class Person
getter name : String
def initialize(@name)
end
def self.compare(p1 : self, p2 : self)
p1.name == p2.name
end
end
john = Person.new "John"
peter = Person.new "Peter"
Person.compare(john, peter) # OK
self.class를 쓰면 Person 타입으로 제한할 수 있어요. 다음 절에서 타입 제한의 .class 접미사에 대해 이야기합니다.
클래스를 제한으로 (Classes as restrictions)
예를 들어 타입 제한으로 Int32를 쓰면, 그 메서드는 Int32의 인스턴스만 받아들입니다:
def foo(x : Int32)
end
foo 1 # OK
foo "hello" # Error
메서드가 Int32 타입 자체(인스턴스가 아닌)만 받게 하려면 .class를 씁니다:
def foo(x : Int32.class)
end
foo Int32 # OK
foo String # Error
이 방식은 인스턴스가 아니라 타입을 기준으로 오버로드를 제공할 때 유용해요:
def foo(x : Int32.class)
puts "Got Int32"
end
def foo(x : String.class)
puts "Got String"
end
foo Int32 # prints "Got Int32"
foo String # prints "Got String"
스플랫에서의 타입 제한 (Type restrictions in splats)
스플랫에도 타입 제한을 지정할 수 있어요:
def foo(*args : Int32)
end
def foo(*args : String)
end
foo 1, 2, 3 # OK, invokes first overload
foo "a", "b", "c" # OK, invokes second overload
foo 1, 2, "hello" # Error
foo() # Error
타입을 지정하면 튜플의 모든 요소가 그 타입과 일치해야 합니다. 게다가 빈 튜플은 위 어느 경우에도 일치하지 않아요. 빈 튜플 케이스를 지원하려면 오버로드를 하나 더 추가합니다:
def foo
# This is the empty-tuple case
end
어느 타입이든 하나 이상의 요소와 일치시키는 간단한 방법은 제한으로 _를 쓰는 것입니다:
def foo(*args : _)
end
foo() # Error
foo(1) # OK
foo(1, "x") # OK
자유 변수 (Free variables)
forall을 쓰면 타입 제한이 인자의 타입, 또는 인자 타입의 일부를 받아들이게 할 수 있어요:
def foo(x : T) forall T
T
end
foo(1) # => Int32
foo("hello") # => String
즉 T는 메서드를 인스턴스화하는 데 실제로 쓰인 타입이 됩니다.
자유 변수를 쓰면 타입 제한 안의 제네릭 타입의 타입 인자를 뽑아낼 수도 있어요:
def foo(x : Array(T)) forall T
T
end
foo([1, 2]) # => Int32
foo([1, "a"]) # => (Int32 | String)
타입의 인스턴스가 아니라 타입 이름을 받는 메서드를 만들려면, 타입 제한의 자유 변수에 .class를 붙이세요:
def foo(x : T.class) forall T
Array(T)
end
foo(Int32) # => Array(Int32)
foo(String) # => Array(String)
여러 인자의 타입을 일치시키기 위해 자유 변수를 여러 개 지정할 수도 있어요:
def push(element : T, array : Array(T)) forall T
array << element
end
push(4, [1, 2, 3]) # OK
push("oops", [1, 2, 3]) # Error
스플랫 타입 제한 (Splat type restrictions)
스플랫 매개변수의 제한에도 스플랫이 있으면, 그 제한은 반드시 Tuple 타입을 가리켜야 하고, 그 매개변수에 해당하는 인자는 스플랫 제한의 요소와 일치해야 합니다:
def foo(*x : *{Int32, String})
end
foo(1, "") # OK
foo("", 1) # Error
foo(1) # Error
스플랫 제한에 튜플 타입을 직접 지정하는 경우는 극히 드물어요. 위 내용은 스플랫을 쓰지 않는 것(def foo(x : Int32, y : String))으로도 표현할 수 있기 때문이죠. 하지만 제한이 자유 변수면, 그 변수는 해당하는 모든 인자의 타입을 포함하는 Tuple로 추론됩니다:
def foo(*x : *T) forall T
T
end
foo(1, 2) # => Tuple(Int32, Int32)
foo(1, "") # => Tuple(Int32, String)
foo(1) # => Tuple(Int32)
foo() # => Tuple()
마지막 줄에서 T는 빈 튜플로 추론되는데, 이건 스플랫이 아닌 제한을 가진 스플랫 매개변수로는 불가능한 일입니다.
더블 스플랫 매개변수도 마찬가지로 더블 스플랫 타입 제한을 지원합니다:
def foo(**x : **T) forall T
T
end
foo(x: 1, y: 2) # => NamedTuple(x: Int32, y: Int32)
foo(x: 1, y: "") # => NamedTuple(x: Int32, y: String)
foo(x: 1) # => NamedTuple(x: Int32)
foo() # => NamedTuple()
추가로, 단일 스플랫 제한은 제네릭 타입 안에서도 쓸 수 있어 여러 타입 인자를 한 번에 뽑아낼 수 있습니다:
def foo(x : Proc(*T, Int32)) forall T
T
end
foo(->(x : Int32, y : Int32) { x + y }) # => Tuple(Int32, Int32)
foo(->(x : Bool) { x ? 1 : 0 }) # => Tuple(Bool)
foo(-> { 1 }) # => Tuple()
출처: Crystal 공식 문서
더 알아보기 (Learn more)
- 타입 문법 (Type grammar) — 타입 표기법
- 오버로딩 (Overloading) — 인자 타입에 따른 메서드 다중 정의
- 제네릭 (Generics) — 타입 매개변수화