Se você agora é obrigado a otimizar o código Java que você escreve, o que você faria? Neste artigo, o autor apresenta quatro métodos que podem melhorar o desempenho do sistema e a legibilidade do código. Se você estiver interessado nisso, vamos dar uma olhada.
Nossas tarefas habituais de programação nada mais são do que aplicar o mesmo conjunto técnico a diferentes projetos. Na maioria dos casos, essas tecnologias podem atingir os objetivos. No entanto, alguns projetos podem exigir técnicas especiais; portanto, os engenheiros precisam estudar em profundidade para encontrar os métodos mais fáceis, mas mais eficazes. Em um artigo anterior, discutimos quatro tecnologias especiais que podem ser usadas quando necessário para criar melhores software Java; Enquanto neste artigo, introduziremos algumas estratégias de design comuns e técnicas de implementação de metas que ajudam a resolver problemas comuns, a saber:
Apenas otimização proposital
Use enums o máximo possível para constantes
Redefinir o método iguals () na classe
Use o máximo de polimorfismo possível
Vale ressaltar que as técnicas descritas neste artigo não são aplicáveis a todos os casos. Além disso, quando e onde essas tecnologias devem ser usadas, elas exigem que os usuários considerem cuidadosamente.
1. Somente otimização proposital
Os grandes sistemas de software devem estar muito preocupados com os problemas de desempenho. Embora esperamos poder escrever o código mais eficiente, muitas vezes, se quisermos otimizar o código, não temos idéia de como começar. Por exemplo, o seguinte código afetará o desempenho?
public void ProcessIntegers (List <Integer> Inteiros) {for (Inteiro valor: inteiros) {for (int i = integers.size ()-1; i> = 0; i-) {value += integers.get.get (i); }}}Depende da situação. No código acima, podemos ver que seu algoritmo de processamento é O (n³) (usando símbolos O Grandes), onde n é o tamanho do conjunto da lista. Se N for apenas 5, não haverá problema, apenas 25 iterações serão realizadas. Mas se n for 100.000, pode afetar o desempenho. Observe que, mesmo assim, não podemos determinar que haverá problemas. Embora esse método exija 1 bilhão de iterações lógicas, ainda que terá um impacto no desempenho.
Por exemplo, suponha que o cliente execute esse código em seu próprio thread e esteja aguardando assíncrono para que o cálculo seja concluído, então seu tempo de execução pode ser aceitável. Da mesma forma, se o sistema for implantado em um ambiente de produção, mas nenhum cliente o chamar, não há necessidade de otimizarmos esse código, porque não consumirá o desempenho geral do sistema. De fato, o sistema se tornará mais complexo após otimizar o desempenho, mas o trágico é que o desempenho do sistema não melhora como resultado.
O mais importante é que não há almoço grátis no mundo; portanto, para reduzir o custo, geralmente usamos tecnologias como cache, expansão de loop ou valores pré-calculados para obter otimização, o que, por sua vez, aumenta a complexidade do sistema e reduz a legibilidade do código. Se essa otimização puder melhorar o desempenho do sistema, vale a pena, mesmo que se torne complicado, mas antes de tomar uma decisão, você deve primeiro saber estas duas informações:
Quais são os requisitos de desempenho
Onde está o gargalo de desempenho
Primeiro de tudo, precisamos saber claramente quais são os requisitos de desempenho. Se, em última análise, estiver dentro dos requisitos e o usuário final não tiver aumentado nenhuma objeção, não há necessidade de executar a otimização de desempenho. No entanto, quando novas funções são adicionadas ou o volume de dados do sistema atinge uma certa escala, ele deve ser otimizado; caso contrário, os problemas poderão surgir.
Nesse caso, não deve se basear na intuição ou inspeção. Porque mesmo desenvolvedores experientes como Martin Fowler são propensos a fazer algumas otimizações erradas, conforme explicado no artigo de refatoração (página 70):
Se você analisar programas suficientes, encontrará a coisa interessante sobre o desempenho que a maior parte do seu tempo é desperdiçada em uma pequena parte do código no sistema. Se todos os códigos forem otimizados da mesma forma, o resultado final é que 90% da otimização é desperdiçada, porque o código após a otimização não executa muita frequência. O tempo gasto na otimização sem objetivos é uma perda de tempo.
Como desenvolvedor endurecido por batalha, devemos levar essa visão a sério. O primeiro palpite não é apenas que o desempenho do sistema não foi melhorado, mas 90% do tempo de desenvolvimento é completamente desperdiçado. Em vez disso, devemos executar casos de uso comuns na produção (ou pré-produção) e descobrir qual parte do sistema está consumindo recursos do sistema durante a execução e depois configurar o sistema. Por exemplo, apenas 10% do código que consome a maioria dos recursos e otimizando os 90% restantes do código é uma perda de tempo.
De acordo com os resultados da análise, se quisermos usar esse conhecimento, devemos começar com as situações mais comuns. Porque isso garantirá que o esforço real melhore o desempenho do sistema. Após cada otimização, as etapas de análise devem ser repetidas. Como isso não apenas garante que o desempenho do sistema seja realmente melhorado, também pode ser visto qual parte do gargalo de desempenho é depois de otimizar o sistema (porque depois de resolver um gargalo, outros gargalos podem consumir mais recursos gerais do sistema). Deve -se notar que a porcentagem de tempo gasto nos gargalos existentes provavelmente aumentará, pois os gargalos restantes são temporariamente inalterados e o tempo geral de execução deve ser reduzido à medida que o gargalo -alvo é eliminado.
Embora seja preciso muita capacidade para verificar completamente os perfis nos sistemas Java, existem algumas ferramentas muito comuns que podem ajudar a descobrir pontos de desempenho de desempenho do sistema, incluindo JMeter, AppDynamics e YourKit. Além disso, você também pode se referir ao Guia de Monitoramento de Desempenho da DZone para obter mais informações sobre a otimização de desempenho do programa Java.
Embora o desempenho seja um componente muito importante de muitos grandes sistemas de software e faça parte do conjunto de testes automatizado no pipeline de entrega de produtos, ele não pode ser otimizado cegamente e sem propósito. Em vez disso, otimizações específicas devem ser feitas nos gargalos de desempenho que foram dominados. Isso não apenas nos ajuda a evitar aumentar a complexidade do sistema, mas também nos permite evitar desvios e evitar otimizações de desperdício de tempo.
2. Tente usar enums para constantes
Existem muitos cenários em que os usuários precisam listar um conjunto de valores predefinidos ou constantes, como códigos de resposta HTTP que podem ser encontrados em aplicativos da Web. Uma das técnicas de implementação mais comuns é criar uma nova classe, que contém muitos valores estáticos do tipo final. Cada valor deve ter um comentário descrevendo o que o valor significa:
classe pública httproponseCodes {public static final int ok = 200; public static final int não_found = 404; public static final int proibidden = 403;} if (gethttProPSOnsion ().Já é muito bom ter essa ideia, mas ainda existem algumas desvantagens:
Nenhuma verificação estrita de valores inteiros recebidos
Como é um tipo de dados básico, o método no código de status não pode ser chamado
No primeiro caso, uma constante específica é simplesmente criada para representar um valor inteiro especial, mas não há restrição ao método ou variável; portanto, o valor usado pode estar além do escopo da definição. Por exemplo:
classe pública httpResponseHandler {public static void printMessage (int statusCode) {System.out.println ("Status recuperado de" + statusCode); }} HttproponseHandler.printMessage (15000);Embora 15000 não seja um código de resposta HTTP válido, não há restrição no lado do servidor de que o cliente deve fornecer números inteiros válidos. No segundo caso, não temos como definir um método para o código de status. Por exemplo, se você deseja verificar se um determinado código de status é um código de sucesso, deve definir uma função separada:
classe pública httproponseCodes {public static final int ok = 200; public static final int não_found = 404; public static final int proibido = 403; public static boolean issuccess (int statusCode) {retornar statusCode> = 200 && statusCode <300; }} if (httproSponsecodes.issuccess (gethttpResponse (). getStatuscode ())) {// Faça algo se o código de resposta for um código de sucesso}Para resolver esses problemas, precisamos alterar o tipo constante do tipo de dados de base para um tipo personalizado e permitir apenas objetos específicos da classe personalizada. É exatamente para isso que os enumes Java. Usando enum, podemos resolver esses dois problemas de uma só vez:
Public Enum HttProSponsecodes {OK (200), proibido (403), não_found (404); Código INT final privado; HttproSponseCodes (int code) {this.code = code; } public int getCode () {Return Code; } public boolean ISSUCCESS () {Return Code> = 200 && Code <300; }} if (gethttpResponse (). getStatuscode (). ISSUCCESS ()) {// Faça algo se o código de resposta for um código de sucesso}Da mesma forma, agora é possível exigir que o código de status que deve ser válido ao chamar o método:
classe pública httproponseHandler {public static void printMessage (httproSponsecode statusCode) {System.out.println ("Status recuperado de" + statusCode.getCode ()); }} HttproponseHandler.printMessage (httproSponsecode.ok);Vale a pena notar que este exemplo mostra que, se for uma constante, você deve tentar usar enums, mas isso não significa que você deve usar enums em todas as circunstâncias. Em alguns casos, pode ser desejável usar uma constante para representar um valor específico, mas outros valores também são permitidos. Por exemplo, todos podem saber sobre Pi, e podemos usar uma constante para capturar esse valor (e reutilizá -lo):
classe pública numericconstants {public static final duplo Pi = 3,14; public static final Double Unit_circle_area = pi * pi;} Public Class Rug {private final Double Area; classe pública Run (área dupla) {this.area = área; } public Double getCost () {Return Area * 2; }} // Crie um tapete com 4 pés de diâmetro (raio de 2 pés) tapete quatrofootrug = novo tapete (2 * numericconstants.unit_circle_area);Portanto, as regras para o uso de enumes podem ser resumidas como:
Quando todos os valores discretos possíveis são conhecidos com antecedência, você pode usar a enumeração
Pegue o código de resposta HTTP mencionado acima como um exemplo. Podemos conhecer todos os valores do código de status HTTP (pode ser encontrado no RFC 7231, que define o protocolo HTTP 1.1). Portanto, a enumeração é usada. Ao calcular o PI, não sabemos todos os valores possíveis sobre Pi (qualquer duplo possível é válido), mas ao mesmo tempo queremos criar uma constante para os tapetes circulares facilitarem o cálculo (mais fácil de ler); Portanto, uma série de constantes é definida.
Se você não puder conhecer todos os valores possíveis com antecedência, mas deseja incluir campos ou métodos para cada valor, a maneira mais fácil é criar uma nova classe para representar os dados. Embora eu nunca tenha dito que não deve haver enumeração em nenhum cenário, a chave para saber onde e quando não usar a enumeração é estar ciente de todos os valores antecipadamente e proibir o uso de qualquer outro valor.
3. Redefine o método iguals () na classe
O reconhecimento de objetos pode ser um problema difícil de resolver: se dois objetos ocuparem a mesma posição na memória, eles são iguais? Se seus IDs são iguais, eles são iguais? Ou e se todos os campos forem iguais? Embora cada classe tenha sua própria lógica de identificação, existem muitos países ocidentais no sistema que precisam julgar se são iguais. Por exemplo, há uma classe abaixo que indica a compra do pedido ...
classe pública compra {private longo id; public Long getId () {return id; } public void setId (longo id) {this.id = id; }}... Como escrito abaixo, deve haver muitos lugares no código que são semelhantes:
Compra originalpurchase = new compra (); compra updatedpurchase = new compra (); if (originalpurchase.getId () == updatedpurchase.getId ()) {// execute alguma lógica para compras iguais}Quanto mais essas chamadas lógicas (por sua vez, viola o princípio seco), compra
As informações de identidade da classe também se tornarão cada vez mais. Se por algum motivo, a compra foi alterada
A lógica de identidade de uma classe (por exemplo, o tipo de identificador foi alterado); portanto, deve haver muitos lugares em que a lógica de identidade é atualizada.
Devemos inicializar essa lógica dentro da classe, em vez de espalhar a lógica de identidade da classe de compra demais através do sistema. À primeira vista, podemos criar um novo método, como ISSame, cujo parâmetro de inclusão é um objeto de compra e comparar os IDs de cada objeto para ver se são iguais:
classe pública compra {private longo id; public boolean Issame (compra outros) {return getId () == outros.gerid (); }}Embora essa seja uma solução eficaz, a funcionalidade interna do Java é ignorada: usando o método igual. Cada classe em Java herda a classe de objeto, embora esteja implícita, por isso também herda o método igual. Por padrão, este método verifica a identidade do objeto (o mesmo objeto na memória), como mostrado no snippet de código a seguir na definição da classe de objeto (versão 1.8.0_131) no JDK:
public boolean é igual (objeto obj) {return (this == obj);}Esse método é igual a um local natural para injetar a lógica de identidade (implementada substituindo o padrão é igual):
classe pública compra {private longo id; public Long getId () {return id; } public void setId (longo id) {this.id = id; } @Override public boolean é igual (objeto outro) {if (this == outros) {return true; } else if (! (outra instância de compra)) {return false; } else {return ((compra) outro) .getId () == getId (); }}}Embora esse método é igual a parecer complicado, como o método igual a aceita apenas parâmetros de objetos de tipo, precisamos considerar apenas três casos:
Outro objeto é o objeto atual (isto é, original compra.equals (original compra)), por definição, eles são o mesmo objeto; portanto, retorne verdadeiro
O outro objeto não é um objeto de compra; neste caso, não podemos comparar o ID da compra, então os dois objetos não são iguais
Outros objetos não são o mesmo objeto, mas são instâncias de compra. Portanto, se o igual depende se o ID de compra atual e outra compra são iguais. Agora podemos refatorar nossas condições anteriores, como segue:
Compra originalpurchase = new compra (); compra updatedpurchase = new compra (); if (originalpurchase.equals (updatedpurchase)) {// execute alguma lógica para compras iguais}Além de reduzir a replicação no sistema, a refatoração do método Equals Equals tem algumas outras vantagens. Por exemplo, se construirmos uma lista de objetos de compra e verifique se a lista contiver outro objeto de compra com o mesmo ID (objetos diferentes na memória), obtemos um valor verdadeiro porque os dois valores são considerados iguais:
List <Purchle> compras = new ArrayList <> (); comprass.add (OriginalPurchase); compras.Contains (UpdatedPurchase); // Verdadeiro
Geralmente, não importa onde você esteja, se você precisar determinar se as duas classes são iguais, você só precisa usar o método REWRITTEN EQUILAIS. Se queremos usar o método igual a iguais devido a herdar o objeto para julgar a igualdade, também podemos usar o operador ==, como segue:
if (originalpurchase == updatedpurchase) {// Os dois objetos são os mesmos objetos na memória}Deve -se notar também que, depois que o método igual é reescrito, o método HashCode também deve ser reescrito. Mais informações sobre a relação entre esses dois métodos e como definir corretamente o HashCode
Método, consulte este tópico.
Como vimos, substituindo o método igual não apenas inicializa a lógica de identidade dentro da classe, mas também reduz a propagação dessa lógica em todo o sistema, mas também permite que o idioma Java tome decisões bem informadas sobre a classe.
4. Use polimorfismos o máximo possível
Para qualquer linguagem de programação, as sentenças condicionais são uma estrutura muito comum e existem certas razões para sua existência. Como combinações diferentes podem permitir que o usuário altere o comportamento do sistema com base no valor fornecido ou no estado instantâneo do objeto. Supondo que o usuário precise calcular o saldo de cada conta bancária, o seguinte código pode ser desenvolvido:
public Enum BankAccountType {verificação, economia, certificado_of_deposit;} classe pública BankAccount {private final BankAccountType Type; public BankAccount (tipo BankAccountType) {this.type = type; } public duplo getInterestrate () {switch (type) {verificação do caso: return 0.03; // 3% de economia de casos: retornar 0,04; // 4% Case Certificate_of_deposit: retornar 0,05; // 5% padrão: jogue novo UnsupportEdOperationException (); }} public boolean supportsDeposits () {switch (type) {verificação do caso: retornar true; Economia de casos: retornar verdadeiro; Caso certificado_of_deposit: return false; Padrão: jogue novo UnsupportEdOperationException (); }}}Embora o código acima atenda aos requisitos básicos, há uma falha óbvia: o usuário determina apenas o comportamento do sistema com base no tipo de conta especificada. Isso não apenas exige que os usuários verifiquem o tipo de conta antes de tomar uma decisão, mas também precisam repetir essa lógica ao tomar uma decisão. Por exemplo, no design acima, o usuário deve verificar os dois métodos. Isso pode levar ao controle, especialmente ao receber a necessidade de adicionar um novo tipo de conta.
Podemos usar o polimorfismo para tomar decisões implicitamente, em vez de usar os tipos de conta para distingui -los. Para fazer isso, convertemos as classes de concreto do BankAccount em uma interface e passamos o processo de decisão em uma série de classes de concreto que representam cada tipo de conta bancária:
/*** Java Aprendizagem e Comunicação QQ Grupo: 589809992 Vamos aprender Java juntos! */interface pública BankAccount {public duplo getInterestrate (); depósitos públicos de suporte booleano ();} public classe checkingAccount implementa o BankAccount {@Override public duplo getInteStrate () {return 0.03; } @Override Public Boolean Support Deposits () {return true; }} classe pública SavingsCOUND implementa o BankAccount {@Override public Double getIntestrate () {return 0.04; } @Override public boolean SupportsDeposis () {return true; }} classe pública CertateOfDepositAccount implementa o BankAccount {@Override public duplo getIntestrate () {return 0.05; } @Override public boolean supportDeposisis () {return false; }}Isso não apenas encapsula informações específicas para cada conta em sua própria classe, mas também apoia os usuários a alterar seus projetos de duas maneiras importantes. Primeiro, se você deseja adicionar um novo tipo de conta bancária, basta criar uma nova classe específica, implementar a interface BankAccount e fornecer a implementação específica dos dois métodos. No projeto da estrutura condicional, precisamos adicionar um novo valor à enumeração, adicionar uma nova instrução de caso nos dois métodos e inserir a lógica da nova conta na declaração de cada caso.
Segundo, se queremos adicionar um novo método na interface BankAccount, precisamos apenas adicionar um novo método em cada classe de concreto. No design condicional, temos que copiar a instrução Switch existente e adicioná -la ao nosso novo método. Além disso, precisamos adicionar lógica para cada tipo de conta em cada instrução de caso.
Matematicamente, quando criamos um novo método ou adicionamos um novo tipo, precisamos fazer o mesmo número de alterações lógicas no design polimórfico e condicional. Por exemplo, se adicionarmos um novo método em um design polimórfico, precisamos adicionar o novo método às classes de concreto de todas as contas bancárias N e, em um design condicional, precisamos adicionar n novas declarações de casos em nosso novo método. Se adicionarmos um novo tipo de conta no design polimórfico, devemos implementar todos os números M na interface BankAccount e, no design condicional, devemos adicionar uma nova instrução de caso a cada método ex existente.
Embora o número de alterações que precisamos fazer seja igual, a natureza das mudanças é completamente diferente. No design polimórfico, se adicionarmos um novo tipo de conta e esquecemos de incluir um método, o compilador lança um erro porque não implementamos todos os métodos em nossa interface BankAccount. No design condicional, não existe essa verificação para garantir que cada tipo tenha uma instrução de caso. Se um novo tipo for adicionado, podemos simplesmente esquecer de atualizar cada instrução Switch. Quanto mais sério esse problema é, mais repetimos nossa instrução SWITCH. Somos humanos e tendemos a cometer erros. Portanto, sempre que podemos confiar no compilador para nos lembrar de erros, devemos fazer isso.
A segunda nota importante sobre esses dois projetos é que eles são equivalentes externamente. Por exemplo, se quisermos verificar a taxa de juros de uma conta corrente, o design condicional ficará assim:
BankAccount checkingAccount = new BankAccount (BankAccountType.Checking); System.out.println (checkingAccount.getInterestrate ()); // Saída: 0,03
Em vez disso, os projetos polimórficos serão semelhantes aos seguintes:
BankAccount checkingAccount = new checkingAccount (); System.out.println (checkingAccount.getInterestrate ()); // Saída: 0,03
Do ponto de vista externo, estamos apenas chamando getIntenunk () no objeto BankAccount. Isso será ainda mais óbvio se abstraímos o processo de criação em uma aula de fábrica:
classe pública condicionalAccountFactory {public static BankAccount CreateCheckingAccount () {return New BankAccount (BankAccountType.Checking); }} classe pública PolymorphicAccountFactory {public static BankAccount CreateCheckingAccount () {return New checkingAccount (); }} // Em ambos os casos, criamos as contas usando um FactoryBankAccount condicionalCheckingAccount = condicionalAccountFactory.CreateCheckingAccount (); BankAccount PolymorphicCheckingAccount = PolymorphicaccountFactory.Createchecking (); samesystem.out.println (condicionalCheckingAccount.getInterestrate ()); // saída: 0.03system.out.println (polymorphicCheckingAccount.getInterestrate ()); // Saída: 0,03É muito comum substituir a lógica condicional por classes polimórficas; portanto, os métodos foram publicados para reconstruir declarações condicionais em classes polimórficas. Aqui está um exemplo simples. Além disso, a refatoração de Martin Fowler (p. 255) também descreve o processo detalhado de realizar essa reconstrução.
Como outras técnicas neste artigo, não há regra rígida e rápida sobre quando realizar uma transição da lógica condicional para as classes polimórficas. De fato, não recomendamos usá -lo em nenhuma situação. Em um design orientado a testes: por exemplo, Kent Beck projetou um sistema de moeda simples com o objetivo de usar classes polimórficas, mas descobriu que isso tornou o design muito complicado e redesenhou seu design em um estilo não polimórfico. A experiência e o julgamento razoável determinarão quando o tempo certo para converter o código condicional em código polimórfico.
Conclusão
Como programadores, embora as técnicas convencionais usadas em tempos normais possam resolver a maioria dos problemas, às vezes devemos quebrar essa rotina e exigir ativamente alguma inovação. Afinal, como desenvolvedor, expandir a amplitude e a profundidade de seu conhecimento não apenas nos permite tomar decisões mais inteligentes, mas também nos torna mais inteligentes.