타입 제한

타입 제한 (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)

앞의 예시에서 selfPerson이라고 쓰는 것과 같아요. 하지만 일반적으로 self는 그 메서드를 최종적으로 소유하게 될 타입을 의미하는데, 모듈이 개입되면 훨씬 유용해집니다.

참고로 PersonReference를 상속하므로 ==의 두 번째 정의는 사실 필요 없어요. 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)