Crystal permite que una variable conste de varios tipos. Esto se conoce como tipo unión. En Crystal, es común que el compilador infiera un tipo unión.
Un tipo unión sigue siendo un solo tipo en tiempo de ejecución, aunque conste de varios tipos.
Esto significa que si un tipo unión está formado por String e Int32, no será ambos al mismo tiempo.
En cambio, será un String o un Int32.
Un tipo unión se declara separando los tipos con una barra vertical (|).
A menudo se colocan entre paréntesis, pero no es obligatorio.
El tipo unión más común es (T | Nil), donde T es un tipo, y puede aparecer en métodos que pueden devolver Nil.
Esto también se conoce como que una variable es nilable.
foo : String = "Hello"
foo = nil # Error: type must be String, not (String | Nil)
foo : (String | Nil) = "Hello"
foo = nil
No está limitado a solo dos tipos, sino que puede tener tantos como quieras.
foo : (String | Int32 | Nil | Float64) = "Hello"
foo = 1
foo = nil
foo = 1.0
typeof vs Object#class
Hay dos formas de obtener el tipo de una variable.
Ya sea usando typeof o usando Object#class.
La diferencia es que typeof devuelve el tipo de una variable en tiempo de compilación, mientras que Object#class devuelve el tipo en tiempo de ejecución.
Esto significa que si quieres ver, por ejemplo, si una variable es un tipo unión, Object#class no podrá decírtelo, ya que solo devuelve el tipo en tiempo de ejecución, que es un solo tipo.
foo = 0 == 0 ? "a" : 1
typeof(foo) # => (String | Int32)
foo.class # => String
Como un tipo unión es un solo tipo en tiempo de ejecución, todas las operaciones estándar funcionan con él. Pero al compilar el código, el compilador necesita saber de qué tipo se trata. Por eso, el código debe estar estructurado de tal forma que solo pueda ser uno de los tipos cuando quieras usar las operaciones específicas de ese tipo.
foo : (String | Int32) = "Hello"
foo.downcase # Error: undefined method 'downcase' for (String | Int32)
Crystal sí tiene un método particular para los tipos unión: el método is_a?, que recibe un tipo como argumento y devuelve un Boolean.
El método nil? es un atajo para is_a?(Nil).
Poner el método is_a? en una expresión de control le dice al compilador de qué tipo se trata y, con eso, garantiza que sea ese tipo.
Y en una rama else, se garantiza que no es ese tipo.
foo : (String | Int32) = "Hello"
if foo.is_a?(String)
typeof(foo) # => String
foo.downcase # => "hello"
end
Este is_a? no está limitado a tener un solo tipo como argumento, sino que también puede tener un tipo unión.
También se puede combinar con && para varios tipos.
El método is_a?, cuando se usa junto con una expresión de control, no puede ser una variable de instancia ni una variable de clase.
En su lugar, hay que asignarlas primero a una variable local.
Una forma de convertir un tipo unión en un solo tipo es hacer que solo se pueda entrar a una rama cuando el tipo es un tipo específico.
Otra forma es usar el método as.
Este convierte un tipo unión en un solo tipo mediante una comprobación en tiempo de ejecución.
Se lanzará una excepción si el tipo no es el esperado.
foo : String | Int32 = "Hello"
foo.as(String).downcase # => "hello"
foo.as(Int32) # Error: can't cast String to Int32
Este enfoque solo está pensado para cuando tienes la certeza de que el tipo es el esperado, o para cuando quieres lanzar una excepción si no lo es.
Usar este enfoque con una configuración inadecuada puede provocar un comportamiento inesperado.
as? funciona de forma muy similar a as, pero devolverá 'nil' en lugar de lanzar una excepción si el tipo no es el esperado.
Esto significa que devolverá un tipo unión del tipo esperado y Nil.
foo : (String | Int32) = "Hello"
foo.as?(String).downcase # => "hello"
foo.as?(Int32) # => nil
Nilable significa que una variable puede ser un tipo o Nil.
Esto se puede escribir como (T | Nil).
Pero como los tipos nilable son relativamente comunes, existe una forma abreviada: T?.
# This:
foo : (String | Nil) = "Hello"
foo = nil
# Is the same as:
foo : String? = "Hello"
foo = nil
El compilador infiere un tipo unión si no está seguro de qué tipo es.
Por ejemplo, en el siguiente código el compilador no sabrá de qué tipo es foo, ya que puede ser un String o un Int32.
if true
foo = 1
else
foo = "Hello"
end
typeof(foo) # => (Int32 | String)
Esta inferencia ocurre de forma automática, y hay otros escenarios en los que, por ejemplo, el compilador infiere que un método devuelve un tipo unión.
character = "Hello world"[10]?
typeof(character) # => (Char | Nil)