제네릭

제네릭 (Generics)

제네릭을 쓰면 다른 타입을 바탕으로 타입을 매개변수화(parameterize)할 수 있어요. 제네릭은 타입 다형성(type-polymorphism)을 제공하죠. Box 타입을 생각해 봅시다:

class MyBox(T)
  def initialize(@value : T)
  end

  def value
    @value
  end
end

int_box = MyBox(Int32).new(1)
int_box.value # => 1 (Int32)

string_box = MyBox(String).new("hello")
string_box.value # => "hello" (String)

another_box = MyBox(String).new(1) # Error, Int32 doesn't match String

제네릭은 컬렉션 타입을 구현할 때 특히 유용해요. Array, Hash, Set이 제네릭 타입이고, Pointer도 마찬가지입니다.

타입 매개변수는 여러 개 쓸 수 있어요:

class MyDictionary(K, V)
end

타입 매개변수의 이름은 아무거나 쓸 수 있습니다:

class MyDictionary(KeyType, ValueType)
end

제네릭 클래스 메서드

제네릭 타입의 클래스 메서드에서 타입 제한(Type restriction)은, 수신자(receiver)의 타입 인자를 지정하지 않았을 때 자유 변수(free variable)가 됩니다. 그러면 그 자유 변수는 호출의 인자로부터 추론됩니다. 예를 들어 이렇게도 쓸 수 있어요:

int_box = MyBox.new(1)          # : MyBox(Int32)
string_box = MyBox.new("hello") # : MyBox(String)

위 코드에서 MyBox의 타입 인자를 일일이 지정하지 않았죠. 컴파일러가 다음 과정을 거쳐 추론했습니다:

  • 컴파일러는 MyBox#initialize(@value : T)로부터 자유 변수를 명시적으로 정의하지 않은 MyBox.new(value : T) 메서드를 생성한다
  • MyBox.new(value : T)T는 아직 타입에 묶이지 않았고, TMyBox의 타입 매개변수이므로, 컴파일러는 T를 주어진 인자의 타입에 묶는다
  • 컴파일러가 생성한 MyBox.new(value : T)MyBox(T)#initialize(@value : T)를 호출하는데, 이때 T는 이미 묶여 있다

이렇게 해서 제네릭 타입을 다루는 일이 훨씬 수월해집니다. 참고로 이 방식이 동작하도록 #initialize 메서드 자체가 자유 변수를 지정할 필요는 없어요.

.new가 아닌 다른 클래스 메서드에도 같은 타입 추론이 적용됩니다:

class MyBox(T)
  def self.nilable(x : T)
    MyBox(T?).new(x)
  end
end

MyBox.nilable(1)     # : MyBox(Int32 | Nil)
MyBox.nilable("foo") # : MyBox(String | Nil)

이 예시들에서 T는 자유 변수로만 추론되므로, 수신자 자체의 T는 묶이지 않은 채 남아요. 그래서 T를 추론할 수 없는 다른 클래스 메서드를 호출하면 에러가 나죠:

module Foo(T)
  def self.foo
    T
  end

  def self.foo(x : T)
    foo
  end
end

Foo.foo(1)        # Error: can't infer the type parameter T for the generic module Foo(T). Please provide it explicitly
Foo(Int32).foo(1) # OK

제네릭 구조체와 모듈

구조체와 모듈도 제네릭이 될 수 있습니다. 모듈이 제네릭일 때는 이렇게 include 해요:

module Moo(T)
  def t
    T
  end
end

class Foo(U)
  include Moo(U)

  def initialize(@value : U)
  end
end

foo = Foo.new(1)
foo.t # Int32

위 예시에서 Foo.new(1) 때문에 UInt32가 되고, 그 덕분에 제네릭 모듈의 include를 통해 TInt32가 되는 점을 눈여겨보세요.

제네릭 타입 상속

제네릭 클래스와 구조체는 상속될 수 있어요. 상속할 때 제네릭 타입의 특정 인스턴스를 지정하거나, 타입 변수를 위임할 수 있습니다:

class Parent(T)
end

class Int32Child < Parent(Int32)
end

class GenericChild(T) < Parent(T)
end

가변 인자를 가진 제네릭

스플랫 연산자(splat operator)를 쓰면 가변 개수의 인자를 가진 제네릭 클래스를 정의할 수 있어요.

Foo라는 제네릭 클래스를 정의하고, 서로 다른 개수의 타입 변수로 사용해 보는 예시를 볼게요:

class Foo(*T)
  getter content

  def initialize(*@content : *T)
  end
end

# 2 type variables:
# (explicitly specifying type variables)
foo = Foo(Int32, String).new(42, "Life, the Universe, and Everything")

p typeof(foo) # => Foo(Int32, String)
p foo.content # => {42, "Life, the Universe, and Everything"}

# 3 type variables:
# (type variables inferred by the compiler)
bar = Foo.new("Hello", ["Crystal", "!"], 140)
p typeof(bar) # => Foo(String, Array(String), Int32)

다음 예시에서는 제네릭 타입에 인스턴스를 지정하면서 상속으로 클래스를 정의합니다:

class Parent(*T)
end

# We define `StringChild` inheriting from `Parent` class
# using `String` for generic type argument:
class StringChild < Parent(String)
end

# We define `Int32StringChild` inheriting from `Parent` class
# using `Int32` and `String` for generic type arguments:
class Int32StringChild < Parent(Int32, String)
end

그럼 인자가 0개인 class를 인스턴스화해야 한다면요? 그럴 때는 이렇게 합니다:

class Parent(*T)
end

foo = Parent().new
p typeof(foo) # => Parent()

하지만 인자 0개는 제네릭 타입 변수를 지정하지 않는 것과 혼동하면 안 돼요. 다음 예시들은 에러를 발생시킵니다:

class Parent(*T)
end

foo = Parent.new # Error: can't infer the type parameter T for the generic class Parent(*T). Please provide it explicitly

class Foo < Parent # Error: generic type arguments must be specified when inheriting Parent(*T)
end

출처: Crystal 공식 문서

더 알아보기 (Learn more)