O Crystal permite que uma variável tenha múltiplos tipos. Isso é chamado de união de tipos. No Crystal, é comum o compilador inferir uma união de tipos.
Uma união de tipos ainda é um único tipo em tempo de execução, mesmo que seja composta de vários tipos.
Isso significa que, se uma união de tipos for formada por String e Int32, ela não será os dois tipos ao mesmo tempo.
Em vez disso, ela será ou uma String ou um Int32.
Uma união de tipos é declarada separando os tipos com um pipe (|).
Elas costumam vir entre parênteses, mas isso não é obrigatório.
A união de tipos mais comum é (T | Nil), em que T é um tipo, e pode aparecer em métodos que podem retornar Nil.
Isso também é chamado de uma variável nilable.
foo : String = "Hello"
foo = nil # Error: type must be String, not (String | Nil)
foo : (String | Nil) = "Hello"
foo = nil
Ela não se limita a apenas dois tipos, mas pode ter quantos você quiser.
foo : (String | Int32 | Nil | Float64) = "Hello"
foo = 1
foo = nil
foo = 1.0
typeof vs Object#class
Há duas formas de obter o tipo de uma variável.
Seja usando typeof, seja usando Object#class.
A diferença é que typeof retorna o tipo de uma variável em tempo de compilação, enquanto Object#class retorna o tipo em tempo de execução.
Isso significa que, se você quiser verificar se uma variável é uma união de tipos, por exemplo, Object#class não conseguirá te dizer isso, pois só retorna o tipo em tempo de execução, que é um único tipo.
foo = 0 == 0 ? "a" : 1
typeof(foo) # => (String | Int32)
foo.class # => String
Como uma união de tipos é um único tipo em tempo de execução, todas as operações padrão funcionam nela. Mas, ao compilar o código, o compilador precisa saber de qual tipo se trata. Por isso, o código precisa ser estruturado de forma que só possa ser de um dos tipos quando você quiser usar as operações específicas de cada tipo.
foo : (String | Int32) = "Hello"
foo.downcase # Error: undefined method 'downcase' for (String | Int32)
O Crystal tem um método específico para uniões de tipos: o método is_a?, que recebe um tipo como argumento e retorna um Boolean.
O método nil? é um atalho para is_a?(Nil).
Colocar o método is_a? em uma expressão de controle dirá ao compilador qual é o tipo e, assim, garantirá que ele seja esse tipo.
E, para uma cláusula else, ficará garantido que não é esse tipo.
foo : (String | Int32) = "Hello"
if foo.is_a?(String)
typeof(foo) # => String
foo.downcase # => "hello"
end
Esse is_a? não se limita a receber um único tipo como argumento, ele também pode receber uma união de tipos.
Ele também pode ser combinado com && para vários tipos.
O método is_a?, quando usado em conjunto com uma expressão de controle, não pode ser uma variável de instância nem uma variável de classe.
Nesse caso, elas precisam primeiro ser atribuídas a uma variável local.
Uma forma de transformar uma união de tipos em um único tipo é fazer com que um bloco só possa ser executado se o tipo for um tipo específico.
Outra abordagem é usar o método as.
Isso transforma uma união de tipos em um único tipo fazendo uma verificação em tempo de execução.
Uma exceção será lançada se o tipo não for o tipo esperado.
foo : String | Int32 = "Hello"
foo.as(String).downcase # => "hello"
foo.as(Int32) # Error: can't cast String to Int32
Essa abordagem serve apenas para quando você tem certeza de que o tipo é o esperado, ou para quando você quer lançar uma exceção caso não seja.
Usar essa abordagem com uma configuração inadequada pode levar a um comportamento inesperado.
as? funciona de forma muito parecida com as, mas retorna nil em vez de lançar uma exceção se o tipo não for o esperado.
Isso significa que ele retorna uma união de tipos do tipo esperado e Nil.
foo : (String | Int32) = "Hello"
foo.as?(String).downcase # => "hello"
foo.as?(Int32) # => nil
Nilable significa que uma variável pode ser um tipo ou Nil.
Isso pode ser escrito como (T | Nil).
Mas, como tipos nilable são relativamente comuns, existe um atalho para isso: T?.
# This:
foo : (String | Nil) = "Hello"
foo = nil
# Is the same as:
foo : String? = "Hello"
foo = nil
O compilador infere uma união de tipos quando não tem certeza de qual tipo se trata.
Por exemplo, no código a seguir o compilador não saberá qual é o tipo de foo, já que ele pode ser uma String ou um Int32.
if true
foo = 1
else
foo = "Hello"
end
typeof(foo) # => (Int32 | String)
Essa inferência acontece automaticamente, e há outros cenários em que, por exemplo, o compilador vai inferir que um método retorna uma união de tipos.
character = "Hello world"[10]?
typeof(character) # => (Char | Nil)