Eu costumo dizer que as pessoas que participaram das discussões sobre como deveria ser a linguagem Java fumaram um baseado antes de fazê-lo.
Na época, a linguagem mais comum era C++, que já existia há 10 anos e que, por sua vez, surgiu mais de 10 anos após a linguagem C, inspirada pela última, introduzindo conceitos mais modernos de orientação a objeto, entre outros. Tanto C e C++ foram projetadas por uma única pessoa, diferentemente da linguagem Java, a qual foi projetada por um grupo de pessoas, que na minha opinião iniciaram a reunião com um baseado, que é o que justificaria as decisões erradas que relato a seguir.
O maior problema com C e C++ é que o desenvolvedor precisava preocupar-se com o gerenciamento de memória, sendo forçado a escrever instruções para alocar e desalocar memória. Como todo ser humano, às vezes os desenvolvedores esqueciam-se de desalocar alguma memória e, ao longo do tempo, o programa que sofria de fuga (leak) de memória terminaria por consumir toda a memória disponível até que fosse terminado pelo sistema operacional. Este era um dos sintomas, mas havia outros.
O fato é que na época em que as linguagens C e C++ foram projetadas, os recursos de memória eram escassos e os desenvolvedores realmente precisavam ter controle sobre o gerenciamento de memória em muitos casos. Além disso, gerenciar memória automaticamente não é uma tarefa trivial.
Outro problema de programação enfrentado na época estava relacionado com a portabilidade do código. Escrever código portável não era trivial. Para escrever programas portáveis, foram criadas ferramentas como autotools e bibliotecas multi-plataforma, com várias condicionais para utilizar código diferente para abrir uma janela gráfica, por exemplo, dependendo do sistema operacional alvo da compilação. Esta técnica ainda é utilizada em programas como KDE, Opera, Google Chrome, Mozilla Firefox e Thunderbird entre vários outros. No entanto, não é fácil garantir que todos os recursos estarão presentes em todos os sistemas operacionais com esta técnica, nem que o resultado visual será exatamente o mesmo. Alguns não consideram isso um problema, mas outros sim. Não vou discutir essa questão aqui, mas os defensores das duas abordagens (abordarei a outra logo a seguir) têm argumentos válidos e não há uma solução certa para esse problema.
Quando a linguagem Java foi idealizada, seu principal objetivo era facilitar o desenvolvimento de programas portáveis, que se comportassem exatamente da mesma forma em qualquer sistema operacional. Isto significava que o desenvolvedor não teria que se preocupar tanto com gerenciamento de memória nem com questões específicas de sistema operacional. O objetivo é completamente válido e algumas decisões foram acertadas, como o uso de uma máquina virtual. A idéia de utilizar uma máquina virtual também foi adotada pelo framework mais novo Microsoft .NET, recentemente. E de fato, a linguagem recém criada pela Microsoft C# é mais parecida com Java que com C ou C++. Talvez devesse chamar-se Java# ou Cb…
Uma máquina virtual é uma especificação de um formato único de armazenamento de instruções pré-compiladas que serão interpretadas de modo ligeiramente diferentes dependendo da arquitetura do processador e do sistema operacional. Desta forma, a distribuição binária de programas é extremamente facilitada porque os mesmos arquivos binários funcionarão da mesma forma em todos os sistemas em que houver uma máquina virtual disponível. A distribuição binária de programas compilados em C ou C++ sempre foi um pânico e resolver este problema era outro objetivo do projeto da linguagem Java.
Para minimizar o problema de fugas de memória, Java delegou a função de desalocar a memória para a máquina virtual, implementando coletores de lixo (garbage collector). O problema de fuga de memória continua existindo, mas é minimizado consideravelmente, uma vez que, ao menos as regiões de memória que não são mais referenciadas podem ser liberadas pelo coletor de memória sem intervenção do desenvolvedor.
Naquela época, boa parte do desenvolvimento era concentrado em aplicações gráficas, enquanto hoje a maior parte está concentrada em aplicações web. Por isso, Java provia uma biblioteca gráfica junto com a máquina virtual, que tinha como objetivo manter a apresentação visual exatamente da mesma forma em todos os sistemas operacionais. Isto significava que Java não reutilizaria as bibliotecas gráficas que acompanhavam cada sistema operacional e utilizariam apenas uma pequena API que lhe permitisse desenhar pixels. Todo o resto seria implementado do zero. Esta decisão lhe custou uma máquina virtual muito grande para distribuir. No entanto, esta decisão incomodava muitos usuários pelo desconforto visual, já que suas aplicações Java eram visualmente diferentes das janelas dos outros programas que usavam. Mais tarde, outras implementações para solucionar esse problema surgiram, mas o tamanho da máquina virtual só foi aumentando.
Toda linguagem é projetada de acordo com sua intenção. Java não estava disposta a abrir mão de desempenho mais que o necessário e, portanto, não poderia ser tão dinâmica quanto outras linguagens contemporâneas como Ruby ou Python. No entanto, os motivos que inspiraram a criação da linguagem Java não justificam seu projeto ruim. Seguem algumas deficiências da linguagem, que não encontro explicação razoável.
Em C++, era possível declarar um método ou função desta forma:
1 | void Metodo(int argumentoObrigatorio, bool opcional1 = false, int opcional2 = 0); |
Em Java, houve uma regressão. Para obter o mesmo efeito, é necessário implementar em uma classe algo como:
1 | void Metodo(int argumentoObrigatorio, boolean opcional1, int opcional2) { |
2 | // implementação real |
3 | } |
4 | |
5 | void Metodo(int argumentoObrigatorio, boolean opcional1) { |
6 | Metodo(argumentoObrigatorio, opcional1, 0); |
7 | } |
8 | |
9 | void Metodo(int argumentoObrigatorio) { |
10 | Metodo(argumentoObrigatorio, false); |
11 | } |
Esta regressão simplesmente não faz o menor sentido!
A seguinte interface não pode ser definida em Java:
1 | interface UmaInterface { |
2 | static void metodoEstatico(); |
3 | } |
Simplesmente não existe lógica para essa proibição. Isto é consequência, na verdade, de outro problema:
Uma vez que todos os métodos de uma interface são implicitamente abstratos, como não é permitido definir um método estático como abstrato, não é possível definir um método estático em uma interface. Mas não faz sentido impedir que métodos estáticos sejam abstratos.
Em C++, é possível sobrescrever diversos operadores para qualquer classe e isto realmente facilita a programação de uma série de casos de uso.
Este problema está relacionado ao anterior. Seria muito melhor comparar Strings com a == “string”, em vez de “string”.equals(a). O fato do resultado ser diferente é mais perigoso, porque a == “string” é permitido entre Strings, mas o resultado é diferente visto que o conteúdo da string não é verificado. Raramente alguém iria querer comparar se as referências para a string são a mesma…
Trabalhar com datas em Java é um pânico. E este é apenas um exemplo. A quantidade de métodos depreciados é também muito alta e permanecem depreciados por muito tempo. Outro exemplo de API mal pensada é a ausência de um método join na classe String, enquanto existe um método split. Simplesmente não faz sentido.
Com relação à API de datas, imagine como se escreveria um código para verificar se dois objetos tipo Date equivalem ao mesmo dia, descartando a informação de horas, ou o que seria necessário fazer para obter um objeto que contenha apenas a porção da data, ajustando a parte das horas para 0. Em várias linguagens, existem tipos separados para representar data e hora, data ou somente hora, e normalmente existem operadores de soma e subtração que funcionam como esperado entre esses tipos. Lidar com datas no Java é um pânico devido à API mal projetada e devido a ausência de flexibilidade para sobrescrita de operadores, como já foi comentado.
Closures permitem construção de código muito mais expressivo, facilitando a manutenção. A utilização de construções tipo closure não impacta em si no desempenho do resultado produzido, mas permite simplificar bastante a escrita e manutenção de código e dariam nova vida à linguagem Java.
Em C++, é possível definir tipos assim:
1 | class Classe { |
2 | typedef Resultado map<string, void *>; |
3 | Resultado Metodo() { |
4 | return new Resultado(); |
5 | } |
6 | |
7 | vector<Resultado> OutroMetodo() { |
8 | return new vector<Resultado>(); |
9 | } |
10 | } |
Normalmente, haveria uma série de outros métodos utilizando Resultado. Se quisermos alterar a definição de Resultado para map
Em Ruby e Object Pascal, por exemplo, há o conceito de propriedades de objeto embutido na linguagem:
Ruby:
1 | class Classe |
2 | attr_accessor a, b |
3 | attr_reader r |
4 | attr_writer w |
5 | |
6 | def b=(valor) |
7 | puts 'Valor b atribuído' |
8 | @b = valor |
9 | end |
10 | end |
Equivalente em Java:
1 | class Classe { |
2 | private int a, b, r, w; |
3 | |
4 | void setA(int a) { |
5 | this.a = a; |
6 | } |
7 | |
8 | int getA() { |
9 | return a; |
10 | } |
11 | |
12 | void setB(int b) { |
13 | System.out.println("Valor b atribuído"); |
14 | this.b = b; |
15 | } |
16 | |
17 | int getB() { |
18 | return b; |
19 | } |
20 | |
21 | int getR() { |
22 | return r; |
23 | } |
24 | |
25 | void setW(int w) { |
26 | this.w = w; |
27 | } |
28 | } |
Seria muito melhor se fosse possível utilizar expressões para construir listas, mapas e expressões regulares de modo mais direto. Veja esses exemplos em Ruby 1.9:
1 | if chama_metodo([elementos, de, uma, lista, recem, criada], |
2 | {usando: 'mapa', diretamente: true}) =~ /resultado(.*)esperado/ |
3 | puts $~[1] |
4 | end |
Os próximos tópicos são realmente questão de gosto pessoal e não é possível culpar os projetistas da linguagem Java pelo projeto. São opiniões pessoais minhas e que reconheço que outros possam discordar com argumentos válidos.
Em muitos casos, diretivas de pré-processamento na etapa de compilação podem ser úteis. As macros do C++ podem permitir estilos de código ruins, mas também podem facilitar bastante alguns trechos de código. Veja um exemplo:
1 | #define verifica(parametro) if (parametro == null || parametro->invalido()) return 0 |
2 | |
3 | int funcao(Classe *p1, Classe *p2, Classe *p3) { |
4 | verifica(p1); |
5 | verifica(p2); |
6 | verifica(p3); |
7 | // Resto da função aqui... |
8 | } |
9 |
Esse é um exemplo simplista, mas certamente há casos em que o uso de macros é ainda mais interessante.
Eu prefiro muito mais o estilo Ruby (e a maioria das linguagens) do que o estilo Java para este caso:
Ruby, Perl, Javascript, etc:
1 | if (obj && obj.metodo()) facaAlgo(); |
Java:
1 | if (obj != null && obj.metodo()) facaAlgo(); |
Nem só de erros consiste a linguagem Java. Conceitos de máquina virtual e gerenciamento automático de memória com coleta de lixo, aplicados na arquitetura Java, facilitam a distribuição e minimizam certos tipos de erros, como fugas de memória. De fato, a máquina virtual Java tem-se tornado bem sólida e eficiente ao longo dos anos, desde que foi criada. Por isso, muitas linguagens dinâmicas estão disponíveis para a máquina virtual Java (JVM), como JRuby, Jython e outras linguagens surgiram especificamente para integrar com a JVM, com Groovy e Scala.
Isto significa que é possível evoluir um sistema atual que executa na JVM com uma linguagem mais dinâmica ou mais bem projetada, sem precisar desfazer-se da base de código atual. A JVM é bastante sólida e tem bom desempenho, além de ser bem testada e é um excelente ambiente para execução de programas escritos em linguagens mais interessantes como JRuby, Groovy, Jython ou Scala, por exemplo, dependendo de muitos fatores, os quais não tratarei nesse artigo, mas incluem desempenho, disponibilidade de bibliotecas e frameworks e finalidade da aplicação.
Até mesmo a linguagem Java não seria um caso perdido, caso ela passasse por uma reformulação, envolvendo adição dos recursos citados neste artigo e uma alteração em sua API.