Crystal에서는 변수가 여러 타입으로 이루어질 수 있어요. 이것을 유니언 타입이라고 해요. Crystal에서는 컴파일러가 유니언 타입을 추론하는 일이 흔해요.
유니언 타입은 여러 타입으로 이루어져 있더라도 런타임에서는 여전히 하나의 타입이에요.
즉, 유니언 타입이 String과 Int32로 이루어져 있다면, 동시에 둘 다인 것은 아니에요.
대신 String이거나 Int32 둘 중 하나예요.
유니언 타입은 타입들을 파이프(|)로 구분해서 선언해요.
보통 괄호로 감싸지만, 꼭 그럴 필요는 없어요.
가장 흔한 유니언 타입은 (T | Nil)인데, 여기서 T는 타입이고, Nil을 반환할 수 있는 메서드에서 나타날 수 있어요.
이런 변수를 nilable하다고도 해요.
foo : String = "Hello"
foo = nil # Error: type must be String, not (String | Nil)
foo : (String | Nil) = "Hello"
foo = nil
타입이 꼭 두 개로 제한되는 것은 아니고, 원하는 만큼 여러 개일 수 있어요.
foo : (String | Int32 | Nil | Float64) = "Hello"
foo = 1
foo = nil
foo = 1.0
typeof vs Object#class
변수의 타입을 알아내는 방법에는 두 가지가 있어요.
typeof를 사용하거나 Object#class를 사용하는 거예요.
차이점은 typeof는 컴파일 타임에 변수의 타입을 반환하고, Object#class는 런타임에 타입을 반환한다는 거예요.
즉, 예를 들어 변수가 유니언 타입인지 확인하고 싶다면, Object#class는 그것을 알려줄 수 없어요. 런타임의 타입, 그러니까 하나의 타입만 반환하기 때문이에요.
foo = 0 == 0 ? "a" : 1
typeof(foo) # => (String | Int32)
foo.class # => String
유니언 타입은 런타임에서는 하나의 타입이기 때문에, 모든 표준 연산이 동작해요. 하지만 코드를 컴파일할 때 컴파일러는 그것이 어떤 타입인지 알아야 해요. 따라서 타입에 특화된 연산을 사용하려면, 코드가 그 타입 중 하나일 수밖에 없도록 구성되어야 해요.
foo : (String | Int32) = "Hello"
foo.downcase # Error: undefined method 'downcase' for (String | Int32)
Crystal에는 유니언 타입을 위한 특별한 메서드가 있는데, 바로 is_a? 메서드예요. 이 메서드는 타입을 인자로 받아서 불리언을 반환해요.
nil? 메서드는 is_a?(Nil)의 축약형이에요.
is_a? 메서드를 제어 표현식에 넣으면 컴파일러에게 어떤 타입인지 알려주고, 따라서 그것이 그 타입임을 보장해요.
그리고 else 분기에서는 그 타입이 아님을 보장해요.
foo : (String | Int32) = "Hello"
if foo.is_a?(String)
typeof(foo) # => String
foo.downcase # => "hello"
end
이 is_a?는 인자로 하나의 타입만 받는 것으로 제한되지 않고, 유니언 타입도 받을 수 있어요.
또한 여러 타입을 위해 &&와 함께 사용할 수도 있어요.
is_a? 메서드는 제어 표현식과 함께 사용할 때 인스턴스 변수나 클래스 변수가 될 수 없어요.
대신 먼저 지역 변수에 할당해야 해요.
유니언 타입을 하나의 타입으로 만드는 한 가지 방법은, 특정 타입일 때만 분기에 들어갈 수 있도록 만드는 거예요.
또 다른 방법은 as 메서드를 사용하는 거예요.
이 메서드는 런타임 검사를 통해 유니언 타입을 하나의 타입으로 만들어요.
타입이 예상한 타입이 아니면 예외가 발생해요.
foo : String | Int32 = "Hello"
foo.as(String).downcase # => "hello"
foo.as(Int32) # Error: can't cast String to Int32
이 방법은 타입이 예상한 타입이라고 확신할 때, 또는 그렇지 않을 때 예외를 발생시키고 싶을 때만 사용해야 해요.
잘못된 구성으로 이 방법을 사용하면 예상치 못한 동작이 발생할 수 있어요.
as?는 as와 매우 비슷하게 동작하지만, 타입이 예상한 타입이 아니면 예외를 발생시키는 대신 nil을 반환해요.
즉, 예상한 타입과 Nil의 유니언 타입을 반환해요.
foo : (String | Int32) = "Hello"
foo.as?(String).downcase # => "hello"
foo.as?(Int32) # => nil
Nilable은 변수가 어떤 타입이거나 Nil일 수 있다는 뜻이에요.
이것은 (T | Nil)로 쓸 수 있어요.
그런데 Nilable 타입은 비교적 흔하기 때문에, 축약형이 있어요: T?.
# This:
foo : (String | Nil) = "Hello"
foo = nil
# Is the same as:
foo : String? = "Hello"
foo = nil
컴파일러는 어떤 타입인지 확실하지 않으면 유니언 타입을 추론해요.
예를 들어, 다음 코드에서 컴파일러는 foo가 어떤 타입인지 알 수 없어요. String일 수도 있고 Int32일 수도 있기 때문이에요.
if true
foo = 1
else
foo = "Hello"
end
typeof(foo) # => (Int32 | String)
이 추론은 자동으로 이루어지고, 예를 들어 컴파일러가 메서드가 유니언 타입을 반환한다고 추론하는 다른 상황들도 있어요.
character = "Hello world"[10]?
typeof(character) # => (Char | Nil)