Pontos Principais
- O Java SE 10 introduziu em março de 2018 a inferência de tipos para variáveis locais, um dos recursos mais solicitados no Java;
- A inferência de tipos é uma técnica usada por linguagens tipadas estaticamente, na qual os tipos de variáveis podem ser inferidos de acordo com o contexto pelo compilador;
- A inferência de tipo no Java é local. O escopo onde as restrições são reunidas e resolvidas é restrito a uma fatia do programa, como uma única expressão ou instrução;
- Stuart Marks, da equipe do Java Libraries, compilou um excelente guia além de um FAQ para ajudar a entender as vantagens e desvantagens em torno da inferência de tipos para os locais;
- Usada corretamente, a inferência de tipos pode tornar o código mais conciso e legível.
Na trilha Java Futures, no QCon Nova York, o arquiteto de linguagem Java Brian Goetz nos levou a um tour por alguns recursos da linguagem Java. Neste artigo, Goetz mergulha na inferência de tipos para variável local.
O Java SE 10 introduziu em Março de 2018, a inferência de tipos para variáveis locais. Anteriormente, declarar uma variável local requer uma declaração de tipo manifesto, ou seja, explicita. Agora, a inferência de tipo permite que o compilador escolha o tipo estático da variável, com base no tipo do inicializador:
var names = new ArrayList<String>();
Neste exemplo simples, a variável names
terá o tipo ArrayList<String>
.
Apesar da semelhança sintática com a mesma funcionalidade fornecida pelo JavaScript, essa não é uma digitação dinâmica, pois todas as variáveis no Java ainda possuem um tipo estático. A inferência de tipo de variável local apenas nos permite solicitar ao compilador que descubra esse tipo, ao invés de termos que fornecê-lo explicitamente.
Inferência de tipos no Java
A inferência de tipos é uma técnica usada por linguagens tipadas estaticamente, em que os tipos de variáveis podem ser inferidos do contexto pelo compilador. As linguagens variam no uso e interpretação da inferência de tipos, que geralmente fornece ao programador uma opção, não uma obrigação. Somos livres para escolher entre tipos manifestos e tipos inferidos, e devemos fazer essa escolha com responsabilidade, usando a inferência de tipos nas situações que melhora a legibilidade e evitá-la onde possa criar confusão.
Os nomes dos tipos no Java podem ser longos, porque o próprio nome da classe é longo ou possui parâmetros de tipos genéricos que são complexos. É um fato das linguagens de programação que quanto mais interessantes são os tipos, mais trabalhosos são de escrever, e é por isso que as linguagens com sistemas de tipos mais sofisticados tendem a se apoiar na inferência de tipos.
O Java começou com uma forma limitada de inferência de tipos no Java 5 e o escopo se expandiu constantemente ao longo dos anos. No Java 5, quando os métodos genéricos foram introduzidos, também introduzimos a capacidade de inferir os parâmetros de tipo genérico, normalmente codificamos assim:
List<String> list = Collection.emptyList();
Em vez de fornecer tipos explícitos:
List<String> list = Collection.<String>emptyList();
De fato, a forma inferida é tão comum que alguns desenvolvedores de Java nunca viram a forma explícita!
No Java 7, estendemos o escopo da inferência de tipos para inferir parâmetros de tipos de invocações genéricas de construtores (conhecidas também como "diamante"). Podemos escrever o código da seguinte maneira:
List<String> list = new ArrayList<>();
Para quem não gosta da versão abreviada:
List<String> list = new ArrayList<String>();
No Java 8, quando introduzimos expressões lambda, também introduzimos a capacidade de inferir os tipos de parâmetros formais nelas. Então, poderíamos escrever:
list.forEach(s -> System.out.println(s))
Para quem não gosta da versão abreviada:
list.forEach((String s) -> System.out.println(s))
E, no Java 10, estendemos ainda mais a inferência de tipos para a declaração de variáveis locais.
Alguns desenvolvedores podem pensar que o uso rotineiro de tipos inferidos é melhor, porque resulta em um programa mais conciso, do outro lado existem aqueles que pensam que é algo ruim porque remove informações potencialmente úteis dos olhos dos desenvolvedores. Mas, ambas as visões são muito simplistas. Às vezes, as informações que seriam inferidas são meramente desorganizadas que, de outra forma, atrapalham (ninguém reclama que usamos a inferência de tipos para parâmetros de tipo genérico) e, nesses casos, a inferência de tipos torna o código mais legível, já em outros, as informações de tipos fornecem informações vitais sobre o que está acontecendo ou refletem as escolhas criativas do desenvolvedor, que nesses casos, é melhor ficar com tipos declarados.
Embora tenhamos expandido o escopo da inferência de tipos ao longo dos anos, um princípio de design que seguimos é o uso de inferência de tipos apenas para detalhes de implementação, não para declaração de elementos na API. Os tipos de campos, parâmetros e retornos de métodos sempre devem ser sempre informados, porque não queremos que os contratos de API sejam sutilmente alterados com base nas alterações durante a implementação, entretanto, dentro da implementação dos corpos dos métodos, é prudente ter mais margem para fazer escolhas com base no que é mais legível.
Como funciona a inferência de tipos?
A inferência de tipos é frequentemente mal interpretada como sendo uma habilidade mágica, na qual os desenvolvedores frequentemente humanizam o compilador e perguntam "por que o compilador não conseguiu descobrir o que eu queria". Na realidade, a inferência de tipos é algo muito mais simples, nada mais que uma solução de restrições.
Linguagens diferentes usam a inferência de tipos de maneiras diferentes, mas o conceito básico é o mesmo em todos elas: reunir restrições em tipos desconhecidos e, em algum momento, resolvê-los. Os designers da linguagem têm margem onde a inferência de tipos pode ser usada, quais restrições são reunidas e quais escopos as restrições são resolvidas.
A inferência de tipos no Java é local, o escopo sobre o qual reunimos restrições e quando as solucionamos fica restrito a uma fatia do programa, como uma única expressão ou declaração. Por exemplo, variáveis locais, o escopo sobre o qual reunimos restrições e as resolvemos é a declaração do próprio local, independentemente de outras atribuições. Outras linguagens buscam uma abordagem mais global para a inferência de tipos, considerando todos os usos da variável antes de tentar resolvê-lo. Embora a princípio isso possa parecer melhor porque é mais preciso, normalmente dificulta o seu uso. Se os tipos de variáveis poderem ser influenciados por todos os usos, quando algo der errado, como o tipo que está com excesso de restrição devido a um erro na programação, as mensagens de erro geralmente não ajudam e podem aparecer longe da declaração da variável cujo tipo está sendo inferido ou do local onde o uso foi incorreto. Essas opções ilustram uma das compensações fundamentais que os designers de linguagem enfrentam ao usar a inferência de tipo, estamos sempre trocando precisão e poder preditivo por complexidade e previsibilidade. Podemos ajustar o algoritmo para aumentar a força do compilador "deixando tudo certo", como reunindo mais restrições ou resolvendo um escopo maior, mas a consequência é quase sempre mais desagradável quando isso falha.
Como um exemplo simples, considere a invocação diamante:
List<String> list = new ArrayList<>();
Sabemos que o tipo list
é List<String>
, porque possui seu tipo declarado. A seguir estamos tentando inferir o tipo de parâmetro no ArrayList
, que escreveremos com x
. Portanto, o tipo no lado direito é ArrayList<x>
. Como estamos atribuindo da direita para a esquerda, o tipo da direita deve ser um subtipo da esquerda, portanto, temos a seguinte restrição:
ArrayList<x> <: List<String>
O <:
significa "subtipo de". (Reunimos o limite trivial Object
, do fato de que x
é uma variável com tipo genérico, cujo limite implícito é o Object
.) Também sabemos pela declaração do ArrayList
que List <x>
é um supertipo do ArrayList <x >
. A partir disso, podemos derivar a restrição vinculada a x <: String
(JLS 18.2.3) e, como essa é a nossa única restrição em x
, podemos concluir que x = String
.
Aqui está um exemplo um pouco mais complicado:
List<String> list = ...
Set<String> set = ...
var v = List.of(list, set);
Neste caso, o lado direito é uma chamada de método genérico, portanto, deduzimos o parâmetro de tipo genérico a partir do método posterior na list
:
public static <X> List<X> of(X... values)
Aqui, temos mais informações para trabalhar do que no exemplo anterior, os tipos de parâmetros, que são List<String>
e Set<String>
. Então, podemos reunir as restrições:
List<String> <: x
Set<String> <: x
Dado esse conjunto de restrições, resolvemos x
calculando o menor limite superior (JLS 4.10.4), ou seja, o tipo mais preciso que é um supertipo de ambos que no caso, é Collection <String>
. Portanto, o tipo do v
é List<Collection<String>>
.
Quais restrições reunimos?
Ao projetar um algoritmo de inferência de tipos, uma das principais escolhas é como reunimos as restrições do programa. Para alguns construtores no programa, como atribuição, o tipo no lado direito deve ser compatível com o tipo da esquerda, assim conseguimos obter corretamente as restrições com base nisso. Da mesma forma, com os parâmetros de métodos genéricos, podemos reunir restrições dos tipos. Mas existem outras fontes de informação que podemos optar por ignorar em determinadas circunstâncias.
A princípio, isso parece surpreendente. Não seria melhor reunir mais restrições, porque leva a uma resposta mais precisa? Novamente, a precisão nem sempre é o objetivo mais importante, pois reunir mais restrições também pode aumentar a probabilidade de uma solução com excesso de restrições, podendo causar uma falha na inferência ou escolher uma resposta de fallback como Object
, além de levar a uma maior instabilidade no programa já que pequenas alterações na implementação podem levar a mudanças surpreendentes na tipificação ou sobrecarregar a resolução em outro local. Assim como no escopo que discutimos, estamos trocando a precisão e poder preditivo por mais complexidade e previsibilidade, o que é uma tarefa subjetiva.
Como exemplo concreto de quando faz sentido ignorar uma possível fonte de restrições, considere o próximo exemplo: sobrecarga de métodos que recebem lambdas como parâmetros. Poderíamos usar as exceções dos corpos do lambda para restringir o conjunto de métodos aplicáveis (tendo maior precisão), mas isso também possibilita que pequenas alterações na implementação do corpo do lambda alterasse o resultado da escolha da sobrecarga do método, que seria uma surpresa (redução da previsibilidade). Nesse caso, o aumento da precisão não vale a pena devido ao custo da previsibilidade, portanto, a restrição não foi considerada para escolher a sobrecarga do método.
Os detalhes
Agora que entendemos como a inferência de tipos funciona de modo geral, vamos mergulhar em alguns detalhes de como ela se aplica às declarações de variáveis locais. Para uma variável local declarada com var
, primeiro calculamos o tipo autônomo do inicializador. O tipo autônomo é o tipo que obtemos ao computar o tipo de uma expressão "de baixo para cima", ignorando o destino da atribuição. Algumas expressões, como lambdas e referências de método, não possuem um tipo autônomo e, portanto, não podem ser o inicializador para um local cujo tipo é inferido.
Para a maioria das expressões, usamos apenas o tipo autônomo do inicializador como o tipo do local. No entanto, em diversos casos, especificamente quando o tipo autônomo não é denotável, podemos refinar ou rejeitá-lo.
Um tipo não denotável é aquele que não podemos escrever na sintaxe da linguagem. Tipos não denotáveis no Java incluem tipos de interseção (Runnable e Serializable
), tipos de captura (aqueles que derivam da conversão de wildcards), tipos de classe anônima (o tipo de uma expressão de criação de classe anônima) e o tipo Null (o tipo literal null
). Inicialmente, consideramos rejeitar a inferência em todos os tipos não denotáveis, sob a teoria de que o var
deveria ser apenas uma abreviação para um tipo declarado. Porém, os tipos não denotáveis eram tão difundidos em programas que essa restrição diminuiria a utilidade do recurso deixando-o mais frustrante. Isso significa que os programas que usam var
não são necessariamente apenas uma abreviação para um programa que usa tipos explícitos, existem alguns programas que são expressados com var
e que não são expressados diretamente.
Exemplo de um programa desse tipo, considere a declaração da classe anônima:
var v = new Runnable() {
void run() { … }
void runTwice() { run(); run(); }
};
v.runTwice();
Se fornecessem um tipo declarado, a opção óbvia sendo Runnable
, o método runTwice( )
não seria acessível por meio da variável v
porque não é um membro do Runnable. Porém, com um tipo inferido, podemos inferir o tipo mais nítido da expressão de criação da classe anônima e, portanto, podemos acessar o método.
Cada categoria do tipo não denotável tem sua própria história. Para o tipo Null (que é o que reduziríamos do var x = null
), simplesmente rejeitamos a declaração. Isso ocorre porque o único valor possível para o tipo Null
é null
, e é pouco provável que o que foi planejado seja uma variável que possa apenas manter null
. Como não queremos "adivinhar" a intenção inferindo o Object
ou outro tipo, rejeitamos esse caso para que o desenvolvedor possa fornecer o tipo correto.
Para tipos de classes anônimas e tipos de interseção, simplesmente usamos o tipo inferido, que são estranhos e novos, mas fundamentalmente inofensivos. Isso significa que agora estamos mais expostos a alguns tipos "estranhos" que anteriormente permaneciam abaixo do radar. Exemplo, suponha que tenhamos:
var list = List.of(1, 3.14d);
Parecido com o exemplo anterior, então sabemos o que irá acontecer, pegamos o limite superior mínimo do Integer
e Double
. Esse é um tipo feio Number / Comparable <? extends Number / Comparable <? >>
. Portanto, o tipo da list
é List <Number / Comparable <? extends Number / Comparable <? >>>
.
Como podemos ver, mesmo um exemplo simples pode dar origem a alguns tipos bem complicados, incluindo alguns que não podem ser escritos explicitamente.
O caso mais complicado é o que fazemos com os tipos de captura com wildcards (curinga). Os tipos de captura vêm do submundo dos tipos genéricos, que decorrem do fato de que cada uso de ?
em um programa corresponde a um tipo diferente. Vejamos à seguir:
void m(List<?> a, List<?> b)
Embora os tipos de a
e b
sejam textualmente idênticos, na verdade não são do mesmo tipo, pois não há certeza que ambas as listas são do mesmo tipo de elemento. (Se esse for o objetivo, mudamos o m()
para um método genérico em T
e usamos List <T>
para ambos). Portanto, o compilador inventa um placeholder, chamado "captura" para cada uso do ?
no programa, para que possamos manter distintos os usos dos curingas. Até agora, os tipos de captura ficavam no submundo, mas se permitirmos que escapem para a superfície, podem causar muita confusão.
Por exemplo, vamos supor que tenhamos esse código na classe MyClass
:
var c = getClass();
Podemos esperar que o tipo de c
seja Class <?>
, mas o tipo da expressão no lado direito é na verdade Class<capture <?>>
. Definir um tipo amplo no programa não ajuda ninguém.
A proibição de inferência de tipos de captura parece atraente no início, mas novamente, houve muitos casos em que esses tipos apareceram. Em vez disso, optamos por higienizá-los, usando uma transformação conhecida como projeção para cima (JLS 4.10.5), que pega um tipo que pode incluir tipos de captura e produz um supertipo desse tipo sem tipos de captura. No caso do exemplo anterior, a projeção para cima higieniza o tipo do c
para a Class <?>
, que é um tipo mais comportado.
A higienização de tipos é uma solução pragmática, mas não é isenta de problemas. Ao inferir um tipo diferente do tipo natural da expressão, significa que se refatorarmos uma expressão complexa f (e)
em var x = e; f (x)
usando uma refatoração de "extração de variável", isso pode alterar as decisões de inferência ou sobrecarga do tipo downstream. Na maioria das vezes, isso não é um problema, mas é um risco que corremos quando mexemos no tipo "natural" de uma expressão. No caso de tipos de captura, os efeitos colaterais da alteração foram melhores que os problemas causados.
Opiniões divergentes
Comparado a algo como os lambdas, ou os genéricos, a inferência de tipos para variáveis locais é um recurso que não agrega muita coisa (embora, como vimos, os detalhes sejam mais complicados do que a maioria das pessoas acham). Mas, a controvérsia em relação a este recurso foi algo pequeno.
Por vários anos, esse foi um dos recursos mais solicitados no Java. Os desenvolvedores se acostumaram com esse recurso no C#, ou Scala, ou no Kotlin posteriormente, e sentiram muita falta dele ao voltar para o Java, e foram bastante expressivos sobre a falta deste recurso. Decidimos avançar com base em sua popularidade, que havia provado funcionar bem em outras linguagens similares ao Java e que tinha um escopo relativamente pequeno para interação com outros recursos da linguagem.
Surpreendentemente, assim que anunciamos que estávamos desenvolvendo o recurso, outro grupo começou a falar, aqueles que pensavam claramente que essa era a idéia mais idiota que já haviam visto. Descreveram como "caindo na modinha" ou "encorajando a preguiça" (e outras coisas mais), e previsões terríveis foram feitas sobre um futuro distópico sobre o código ilegível. E tanto os defensores quanto os antagonistas justificaram suas posições apelando para o mesmo valor central: A legibilidade.
Depois de entregar o recurso, a realidade não foi muito tranquila. Embora exista uma curva de aprendizado inicial em que os desenvolvedores precisam encontrar o caminho certo para usar o novo recurso, assim como todos os outros, na maioria dos casos os desenvolvedores podem facilmente internalizar algumas diretrizes razoáveis sobre quando o recurso agrega ou não valor, e assim, usar quando necessário.
Conselhos
Stuart Marks, da equipe Java Libraries da Oracle, compilou um guia muito útil para ajudar a entender as vantagens e desvantagens sobre o uso da inferência de tipos para as variáveis locais.
Como nos guias mais sensatos, este guia foca em esclarecer os trade-offs envolvidos. Tornar tudo explícito é um trade-off, de um lado, o tipo explícito fornece uma declaração precisa e inequívoca do tipo de uma variável, mas por outro lado, às vezes o tipo é óbvio ou sem importância, e o tipo explícito pode ajudar a embaralhar as informações mais importantes, que precisam ter maior atenção do leitor.
Os princípios gerais descritos no guia incluem:
- Escolher bons nomes de variáveis. Se escolhermos nomes expressivos para variáveis locais, é mais provável que a declaração do tipo seja desnecessária ou mesmo indiferente. Por outro lado, se escolhermos nomes de variáveis como
x
ea3
, retirar as informações de tipo pode complicar muito o código; - Minimizar o escopo das variáveis locais. Quanto maior a distância entre a declaração de uma variável e o seu uso, maior a probabilidade de raciocínio impreciso sobre ela. O uso do
var
para declarar variáveis locais cujo escopo abrange diversas linhas tem mais chances de serem negligenciadas do que aquelas com escopos menores ou tipos mais explícitos; - Considerar o
var
quando o inicializador fornecer informações suficientes para o leitor. Para muitas declarações de variáveis locais, a expressão do inicializador é óbvia quando lemos o que está acontecendo (comovar names = new ArrayList <String> ()
) e, portanto, a necessidade de um tipo explícito é menor; - Não se preocupar muito com "programação para interface". Uma preocupação comum entre os desenvolvedores é que há muito tempo somos incentivados a usar tipos abstratos (como
List
) para variáveis, em vez de tipos de implementação mais específicos (comoArrayList
), mas se permitirmos que o compilador infira o tipo, ele irá inferir o tipo mais específico. Mas não devemos nos preocupar muito com isso, porque esse conselho é muito mais importante para APIs (como tipos de retorno de método) do que para variáveis locais na implementação, especialmente se seguirmos o conselho anterior sobre como manter os escopos pequenos; - Cuidado com as interações entre
var
e inferência diamante. Tantovar
como "diamante" pedem ao compilador para inferir tipos, e não há problema em usá-los juntos, se houver informações de tipos suficientes para inferir o tipo desejado, como nos tipos dos argumentos do construtor; - Cuidado com a combinação do
var
com literais numéricos. Literais numéricos são poli expressões, significando que o tipo pode depender do tipo ao qual estão sendo atribuídos. (Por exemplo, podemos escrevershort x = 0
, e o literal0
é compatível comint
,long
,short
ebyte
). Mas, sem um tipo de destino, o tipo autônomo de um literal numérico éint
, portanto se alterarmos o shorts = 0
paravar s = 0
resultará na alteração do tipo dos
; - Use
var
para separar expressões encadeadas ou aninhadas. Quando declarar um novo local para uma subexpressão é oneroso, aumenta a tentação de criar expressões complexas com encadeamento e/ou aninhamento, às vezes à custa da legibilidade. Ao reduzir o custo de declarar uma subexpressão, a inferência do tipo de variável local torna menos tentador fazer a coisa errada, aumentando assim a legibilidade.
O último item ilustra um ponto importante que muitas vezes é esquecido no debate sobre os recursos da linguagem de programação. Ao avaliar as consequências de um novo recurso, geralmente consideramos apenas as maneiras mais superficiais nas quais podem ser usadas, no caso de inferência de tipo de variável local, isso substituiria tipos declarados em programas existentes por var
. Mas, o estilo de programação que adotamos é influenciado por muitas coisas, incluindo o custo relativo de várias construções. Se reduzirmos o custo de declarar variáveis locais, é lógico que podemos equilibrar um local onde usamos mais variáveis locais, e isso tem o potencial de tornar os programas mais legíveis. Porém, esses efeitos de segunda ordem raramente são considerados nas discussões sobre se um recurso irá ajudar ou prejudicar.
De qualquer maneira, muitas dessas diretrizes são apenas conselhos de estilo, escolher bons nomes para as variáveis é uma das maneiras mais eficazes de tornar o código mais legível, com ou sem inferência.
Resumo
Como vimos, a inferência de tipo para variáveis locais não é um recurso tão simples quanto sugere sua sintaxe; embora algumas pessoas queiram ignorar os tipos, na verdade, é necessário que tenhamos uma melhor compreensão do sistema de tipos do Java. Porém, se você entender como ele funciona e seguir algumas diretrizes de estilo razoáveis, isso poderá ajudar a tornar seu código mais conciso e mais legível.
Sobre o autor
Brian Goetz é o arquiteto de linguagem Java da Oracle e foi o líder de especificação do JSR-335 (Expressões lambda para a linguagem de programação Java). É o autor do best-seller Java Concurrency in Practice e é fascinado por programação desde quando Jimmy Carter era presidente dos Estados Unidos.