Si ahora está obligado a optimizar el código Java que escribe, ¿qué haría? En este artículo, el autor presenta cuatro métodos que pueden mejorar el rendimiento del sistema y la legibilidad del código. Si está interesado en esto, echemos un vistazo.
Nuestras tareas de programación habituales no son más que aplicar el mismo conjunto técnico a diferentes proyectos. En la mayoría de los casos, estas tecnologías pueden cumplir con los objetivos. Sin embargo, algunos proyectos pueden requerir técnicas especiales, por lo que los ingenieros tienen que estudiar en profundidad para encontrar los métodos más fáciles pero más efectivos. En un artículo anterior, discutimos cuatro tecnologías especiales que pueden usarse cuando sea necesario para crear un mejor software Java; Mientras que en este artículo presentaremos algunas estrategias de diseño comunes y técnicas de implementación de objetivos que ayudan a resolver problemas comunes, a saber:
Solo optimización intencional
Use enums tanto como sea posible para las constantes
Redefinir el método igual () en la clase
Use la mayor cantidad de polimorfismo posible
Vale la pena señalar que las técnicas descritas en este artículo no son aplicables a todos los casos. Además, cuándo y dónde se deben utilizar estas tecnologías, requieren que los usuarios consideren cuidadosamente.
1. Solo haga una optimización con propósito
Los grandes sistemas de software deben estar muy preocupados por los problemas de rendimiento. Aunque esperamos poder escribir el código más eficiente, muchas veces, si queremos optimizar el código, no tenemos idea de cómo comenzar. Por ejemplo, ¿afectará el siguiente código el rendimiento?
public void ProcessIntregers (List <integer> Integers) {for (valor entero: enteros) {for (int i = integrers.size ()-1; i> = 0; i--) {valor += integrers.get (i); }}}Depende de la situación. En el código anterior, podemos ver que su algoritmo de procesamiento es O (n³) (usando símbolos O grandes), donde n es el tamaño del conjunto de listas. Si N es solo 5, entonces no habrá problema, solo se realizarán 25 iteraciones. Pero si N es 100,000, puede afectar el rendimiento. Tenga en cuenta que aun así, no podemos determinar que habrá problemas. Aunque este método requiere mil millones de iteraciones lógicas, aún no se ha discutido si tendrá un impacto en el rendimiento.
Por ejemplo, suponga que el cliente ejecuta este código en su propio hilo y está esperando asíncronamente para que el cálculo se complete, entonces su tiempo de ejecución puede ser aceptable. Del mismo modo, si el sistema se implementa en un entorno de producción pero ningún cliente lo llama, entonces no es necesario que optimicemos este código, ya que no consumirá el rendimiento general del sistema. De hecho, el sistema se volverá más complejo después de optimizar el rendimiento, pero lo trágico es que el rendimiento del sistema no mejora como resultado.
Lo más importante es que no hay un almuerzo gratuito en el mundo, por lo que para reducir el costo, generalmente usamos tecnologías como caché, expansión de bucle o valores precalculados para lograr la optimización, lo que a su vez aumenta la complejidad del sistema y reduce la preparación del código. Si esta optimización puede mejorar el rendimiento del sistema, vale la pena incluso si se complica, pero antes de tomar una decisión, primero debe conocer estas dos piezas de información:
¿Cuáles son los requisitos de rendimiento?
¿Dónde está el cuello de botella de rendimiento?
En primer lugar, necesitamos saber claramente cuáles son los requisitos de rendimiento. Si finalmente está dentro de los requisitos y el usuario final no ha planteado ninguna objeción, entonces no es necesario realizar la optimización del rendimiento. Sin embargo, cuando se agregan nuevas funciones o el volumen de datos del sistema alcanza una determinada escala, debe optimizarse, de lo contrario pueden surgir problemas.
En este caso, no debe basarse en la intuición o la inspección. Porque incluso desarrolladores experimentados como Martin Fowler son propensos a hacer algunas optimizaciones incorrectas, como se explica en el artículo Refactoring (página 70):
Si analiza suficientes programas, encontrará lo interesante sobre el rendimiento de que la mayor parte de su tiempo se desperdicia en una pequeña parte del código en el sistema. Si todos los códigos están optimizados de la misma manera, el resultado final es que el 90% de la optimización se desperdicia, porque el código después de la optimización no ejecuta mucha frecuencia. El tiempo dedicado a optimizar sin objetivos es una pérdida de tiempo.
Como desarrollador endurecido por la batalla, debemos tomar este punto de vista en serio. La primera suposición no solo es que el rendimiento del sistema no se haya mejorado, sino que el 90% del tiempo de desarrollo es completamente desperdiciado. En cambio, debemos ejecutar casos de uso comunes en la producción (o preproducción) y descubrir qué parte del sistema consume recursos del sistema durante la ejecución, y luego configurar el sistema. Por ejemplo, solo el 10% del código que consume la mayoría de los recursos, y luego optimizar el 90% restante del código es una pérdida de tiempo.
Según los resultados del análisis, si queremos usar este conocimiento, debemos comenzar con las situaciones más comunes. Porque esto asegurará que el esfuerzo real mejore en última instancia el rendimiento del sistema. Después de cada optimización, los pasos de análisis deben repetirse. Debido a que esto no solo garantiza que el rendimiento del sistema realmente mejore, también se puede ver qué parte del cuello de botella de rendimiento es después de optimizar el sistema (porque después de resolver un cuello de botella, otros cuellos de botella pueden consumir más recursos generales del sistema). Cabe señalar que es probable que el porcentaje de tiempo dedicado a los cuellos de botella existentes aumente, ya que los cuellos de botella restantes no cambian temporalmente, y el tiempo de ejecución general debe reducirse a medida que el cuello de botella objetivo se elimina.
Aunque se necesita mucha capacidad para verificar completamente los perfiles en los sistemas Java, hay algunas herramientas muy comunes que pueden ayudar a descubrir puntos de acceso de rendimiento del sistema, incluidos JMeter, AppDynamics y YourKit. Además, también puede consultar la Guía de monitoreo de rendimiento de Dzone para obtener más información sobre la optimización del rendimiento del programa Java.
Aunque el rendimiento es un componente muy importante de muchos sistemas de software grandes y es parte del conjunto de pruebas automatizadas en la tubería de entrega del producto, no puede optimizarse a ciegas y sin propósito. En cambio, se deben hacer optimizaciones específicas a los cuellos de botella de rendimiento que se han dominado. Esto no solo nos ayuda a evitar aumentar la complejidad del sistema, sino que también nos permite evitar los desvíos y evitar hacer optimizaciones de desastre en el tiempo.
2. Intenta usar enums para constantes
Hay muchos escenarios en los que los usuarios deben enumerar un conjunto de valores predefinidos o constantes, como los códigos de respuesta HTTP que se pueden encontrar en aplicaciones web. Una de las técnicas de implementación más comunes es crear una nueva clase, que contiene muchos valores de tipo final estático. Cada valor debe tener un comentario que describa lo que significa el valor:
clase pública httPresponseCodes {public static final int ok = 200; Public estática final int no_found = 404; public static final int forbidden = 403;} if (gethttpResponse (). getStatUscode () == httpponseCodes.ok) {// Haga algo si el código de respuesta está bien}Ya es muy bueno tener esta idea, pero todavía hay algunas desventajas:
No hay verificación estricta de los valores enteros entrantes
Dado que es un tipo de datos básico, no se puede llamar al método en el código de estado
En el primer caso, una constante específica simplemente se crea para representar un valor entero especial, pero no hay restricción en el método o variable, por lo que el valor utilizado puede estar más allá del alcance de la definición. Por ejemplo:
clase pública httpResponseHandler {public static void printMessage (int statuscode) {System.out.println ("Estado recivado de" + statuscode); }} HttpresponseHandler.printMessage (15000);Aunque 15000 no es un código de respuesta HTTP válido, no hay restricción en el lado del servidor que el cliente debe proporcionar enteros válidos. En el segundo caso, no tenemos forma de definir un método para el código de estado. Por ejemplo, si desea verificar si un código de estado dado es un código exitoso, debe definir una función separada:
clase pública httPresponseCodes {public static final int ok = 200; Public estática final int no_found = 404; Public estática final intbidden = 403; public static boolean issuccess (int statuscode) {return statuscode> = 200 && statuscode <300; }} if (httpponseCodes.issuccess (gethttpresponse (). getStatUscode ())) {// Haga algo si el código de respuesta es un código de éxito}Para resolver estos problemas, necesitamos cambiar el tipo constante del tipo de datos base a un tipo personalizado y permitir solo objetos específicos de la clase personalizada. Esto es exactamente para lo que son Java Enums. Usando enum, podemos resolver estos dos problemas a la vez:
public enum httPresponseCodes {OK (200), prohibido (403), no_found (404); Código INT final privado; HttPresponseCodes (int código) {this.code = code; } public int getCode () {código de retorno; } public boolean isSuccess () {código de retorno> = 200 && code <300; }} if (gethttpResponse (). getStatUscode (). isSuccess ()) {// Haga algo si el código de respuesta es un código de éxito}Del mismo modo, ahora es posible exigir que el código de estado que debe ser válido al llamar al método:
clase pública httpResponseHandler {public static void printMessage (httpesponseCode statusCode) {System.out.println ("Estado de recuperación de" + statuscode.getCode ()); }} HttpresponseHandler.PrintMessage (httpponseCode.ok);Vale la pena señalar que este ejemplo muestra que si es una constante, debe intentar usar enums, pero no significa que deba usar enums en todas las circunstancias. En algunos casos, puede ser deseable usar una constante para representar un valor particular, pero también se permiten otros valores. Por ejemplo, todos pueden saber sobre PI, y podemos usar una constante para capturar este valor (y reutilizarlo):
clase pública NumericConstants {public static final Double PI = 3.14; Public static final Double unit_circle_area = pi * pi;} public class Rug {área doble final privada; Public Class Run (área doble) {this.area = área; } public Double GetCost () {Área de retorno * 2; }} // Crear una alfombra que tenga 4 pies de diámetro (radio de 2 pies) alfombra cuatrofootrug = nueva alfombra (2 * numericconstants.unit_circle_area);Por lo tanto, las reglas para usar enums se pueden resumir como:
Cuando todos los valores discretos posibles se hayan conocido de antemano, entonces puede usar enumeración
Tome el código de respuesta HTTP mencionado anteriormente como ejemplo. Podemos conocer todos los valores del código de estado HTTP (se puede encontrar en RFC 7231, que define el protocolo HTTP 1.1). Por lo tanto, se usa la enumeración. Al calcular PI, no conocemos todos los valores posibles sobre Pi (cualquier doble posible es válido), pero al mismo tiempo queremos crear una constante para las alfombras circulares para que el cálculo sea más fácil (más fácil de leer); Por lo tanto, se definen una serie de constantes.
Si no puede conocer todos los valores posibles por adelantado, pero desea incluir campos o métodos para cada valor, entonces la forma más fácil es crear una nueva clase para representar los datos. Aunque nunca he dicho que no debería haber enumeración en ningún escenario, la clave para saber dónde y cuándo no usar la enumeración es ser consciente de todos los valores por adelantado y prohibir el uso de cualquier otro valor.
3. Redefine el método igual () en la clase
El reconocimiento de objetos puede ser un problema difícil de resolver: si dos objetos ocupan la misma posición en la memoria, ¿son iguales? Si sus ID son las mismas, ¿son iguales? ¿O qué pasa si todos los campos son iguales? Aunque cada clase tiene su propia lógica de identificación, hay muchos países occidentales en el sistema que necesitan juzgar si son iguales. Por ejemplo, hay una clase a continuación que indica la compra de pedidos ...
Compra de clase pública {ID de largo privado; public Long getId () {return id; } public void setid (ID long) {this.id = id; }}... Como se escribe a continuación, debe haber muchos lugares en el código que son similares:
Compra originalPurchase = new compra (); compra actualizatedPurchase = new compra (); if (originalPurchase.getID () == UpdateDpurchase.getId ()) {// Ejecutar alguna lógica para compras iguales}Cuanto más llamen a estas lógicas (a su vez, viola el principio seco), compra
La información de identidad de la clase también se volverá cada vez más. Si por alguna razón, la compra ha cambiado
La lógica de identidad de una clase (por ejemplo, el tipo de identificador se ha cambiado), por lo que debe haber muchos lugares donde se actualice la lógica de identidad.
Deberíamos inicializar esta lógica dentro de la clase, en lugar de difundir demasiado la lógica de identidad de la clase de compra a través del sistema. A primera vista, podemos crear un nuevo método, como Issame, cuyo parámetro de inclusión es un objeto de compra, y comparar las ID de cada objeto para ver si son los mismos:
Compra de clase pública {ID de largo privado; public boolean Issame (compra otro) {return getId () == OTRO.GERID (); }}Aunque esta es una solución efectiva, se ignora la funcionalidad incorporada de Java: utilizando el método igual. Cada clase en Java hereda la clase de objeto, aunque está implícita, por lo que también hereda el método igual. Por defecto, este método verifica la identidad del objeto (el mismo objeto en la memoria), como se muestra en el siguiente fragmento de código en la definición de clase de objeto (versión 1.8.0_131) en jdk:
Public Boolean iguales (object obj) {return (this == obj);}Esto es igual al método actúa como una ubicación natural para inyectar la lógica de identidad (implementada al anular los iguales predeterminados):
Compra de clase pública {ID de largo privado; public Long getId () {return id; } public void setid (ID long) {this.id = id; } @Override public boolean iguales (objeto otro) {if (this == otro) {return true; } else if (! (otra instancia de compra)) {return false; } else {return ((compra) otro) .getId () == getId (); }}}Aunque esto es igual al método parece complicado, ya que el método igual solo acepta parámetros de objetos de tipo, solo necesitamos considerar tres casos:
Otro objeto es el objeto actual (es decir, originalPurchase.equals (original de la compra)), por definición, son el mismo objeto, por lo que regresa verdadero
El otro objeto no es un objeto de compra, en este caso no podemos comparar la ID de compra, por lo que los dos objetos no son iguales
Otros objetos no son el mismo objeto, sino que son instancias de compra. Por lo tanto, si igual depende de si la ID de compra actual y otra compra son iguales. Ahora podemos refactorizar nuestras condiciones anteriores, de la siguiente manera:
Compre originalPurchase = new compra (); compra actualizatedPurchase = new compra (); if (originalPurchase.equals (actualizatedPurchase)) {// Ejecutar alguna lógica para compras iguales}Además de reducir la replicación en el sistema, la refactorización del método iguales predeterminado tiene algunas otras ventajas. Por ejemplo, si construimos una lista de objetos de compra y verificamos si la lista contiene otro objeto de compra con la misma ID (diferentes objetos en la memoria), entonces obtenemos un valor verdadero porque los dos valores se consideran iguales:
Lista <S compra> compra = new ArrayList <> (); CompreS.Add (originalPurchase); Compates.Contains (ActualatedPurchase); // Verdadero
Por lo general, no importa dónde se encuentre, si necesita determinar si las dos clases son iguales, solo necesita usar el método Rewritten Equals. Si queremos usar el método igual implícitamente debido a heredar el objeto objeto para juzgar la igualdad, también podemos usar el operador ==, como sigue:
if (originalPurchase == UpdateTpurchase) {// Los dos objetos son los mismos objetos en la memoria}También se debe tener en cuenta que después de reescribir el método igual, el método hashcode también debe reescribirse. Más información sobre la relación entre estos dos métodos y cómo definir correctamente el hashcode
Método, vea este hilo.
Como hemos visto, la sobrescribencia del método igual no solo inicializa la lógica de identidad dentro de la clase, sino que también reduce la propagación de esta lógica en todo el sistema, sino que también permite que el lenguaje Java tome decisiones bien informadas sobre la clase.
4. Use polimorfismos tanto como sea posible
Para cualquier lenguaje de programación, las oraciones condicionales son una estructura muy común, y hay ciertas razones para su existencia. Porque diferentes combinaciones pueden permitir al usuario cambiar el comportamiento del sistema en función del valor dado o el estado instantáneo del objeto. Suponiendo que el usuario necesita calcular el saldo de cada cuenta bancaria, se puede desarrollar el siguiente código:
public enum bankAccountType {comprobación, ahorro, certificado_of_deposit;} public class BankAccount {private final BankAccountType Type; public bankAccount (bankAccountType type) {this.type = type; } public doble getinterestrate () {switch (type) {CASE CHECKING: return 0.03; // 3% de ahorro de casos: retorno 0.04; // 4% de caso Certificado_of_deposit: return 0.05; // 5% predeterminado: arroje nuevo UnpportedOperationException (); }} public boolean SupportSDeposits () {switch (type) {CASE CHECKING: return true; ahorros de casos: return verdadero; Case certificado_of_deposit: return false; predeterminado: tire una nueva UnpportedOperationException (); }}}Aunque el código anterior cumple con los requisitos básicos, existe una falla obvia: el usuario solo determina el comportamiento del sistema en función del tipo de cuenta dada. Esto no solo requiere que los usuarios verifiquen el tipo de cuenta antes de tomar una decisión, sino que también necesita repetir esta lógica al tomar una decisión. Por ejemplo, en el diseño anterior, el usuario debe verificar ambos métodos. Esto puede conducir a fuera de control, especialmente al recibir la necesidad de agregar un nuevo tipo de cuenta.
Podemos usar el polimorfismo para tomar decisiones implícitamente, en lugar de usar tipos de cuentas para distinguirlos. Para hacer esto, convertimos las clases concretas de BankAccount en una interfaz y pasamos el proceso de decisión en una serie de clases concretas que representan cada tipo de cuenta bancaria:
/*** Java Learning and Communication QQ Group: 589809992 ¡Aprendamos Java juntos! */Public Interface BankAccount {public Double GetInterestrate (); Public Boolean Support Deposits ();} public class ComprobandoCount implementa BankAccount {@Override public Double GetIntestrate () {return 0.03; } @Override Public Boolean Support Deposits () {return true; }} La clase pública SavingsAccount implementa BankAccount {@Override public Double GetIntestrate () {return 0.04; } @Override public boolean SupportSdeeposis () {return true; }} public class CertateOfDepositAccount implementa BankAccount {@Override public Double GetIntestrate () {return 0.05; } @Override public Boolean SupportDeposis () {return false; }}Esto no solo encapsula información específica de cada cuenta en su propia clase, sino que también admite a los usuarios para cambiar sus diseños de dos maneras importantes. Primero, si desea agregar un nuevo tipo de cuenta bancaria, solo necesita crear una nueva clase específica, implementar la interfaz BankAccount y dar la implementación específica de los dos métodos. En el diseño de la estructura condicional, tenemos que agregar un nuevo valor a la enumación, agregar una nueva declaración de caso en ambos métodos e insertar la lógica de la nueva cuenta en cada estado de estado de caso.
En segundo lugar, si queremos agregar un nuevo método en la interfaz BankAccount, solo necesitamos agregar un nuevo método en cada clase de concreto. En diseño condicional, tenemos que copiar la instrucción Switch existente y agregarla a nuestro nuevo método. Además, tenemos que agregar lógica para cada tipo de cuenta en cada estado de estado de caso.
Matemáticamente, cuando creamos un nuevo método o agregamos un nuevo tipo, tenemos que hacer el mismo número de cambios lógicos en el diseño polimórfico y condicional. Por ejemplo, si agregamos un nuevo método en un diseño polimórfico, tenemos que agregar el nuevo método a las clases concretas de todas las cuentas bancarias n, y en un diseño condicional, tenemos que agregar n nuevos estados de casos en nuestro nuevo método. Si agregamos un nuevo tipo de cuenta en el diseño polimórfico, debemos implementar todos los números M en la interfaz BankAccount, y en el diseño condicional, debemos agregar una nueva declaración de caso a cada método existente.
Aunque el número de cambios que tenemos que hacer es igual, la naturaleza de los cambios es completamente diferente. En el diseño polimórfico, si agregamos un nuevo tipo de cuenta y olvidamos incluir un método, el compilador arroja un error porque no implementamos todos los métodos en nuestra interfaz BankAccount. En el diseño condicional, no existe tal verificación para garantizar que cada tipo tenga una declaración de caso. Si se agrega un nuevo tipo, simplemente podemos olvidar actualizar cada instrucción Switch. Cuanto más grave es este problema, más repetimos nuestra instrucción Switch. Somos humanos y tendemos a cometer errores. Entonces, cada vez que podamos confiar en el compilador para recordarnos errores, debemos hacer esto.
La segunda nota importante sobre estos dos diseños es que son equivalentes externamente. Por ejemplo, si queremos verificar la tasa de interés para una cuenta corriente, el diseño condicional se verá así:
BankAccount checkingCount = new BankAccount (BankAccountType.CHECKING); System.out.println (cheChingAccount.getInterestrate ()); // Salida: 0.03
En cambio, los diseños polimórficos serán similares a los siguientes:
BankAcCount cheCHINGACCOUNT = new CheckingAcCount (); System.out.println (cheCkingAccount.getInterestrate ()); // Salida: 0.03
Desde un punto de vista externo, solo estamos llamando a getintereunk () en el objeto BankAccount. Esto será aún más obvio si abstrae el proceso de creación en una clase de fábrica:
clase pública condicionalAcCountFactory {public static bankAccount createCheckingAccount () {return new BankAccount (BankAccountType.CHECKING); }} clase pública PolyMorphicAcCountFactory {public static bankaccount createCheckingCount () {return new CheckingAcCount (); }} // En ambos casos, creamos las cuentas que usan un factorybankaccount condicionalChecketCkingAcCount = condicionalAcCountFactory.CreateChingCingCount (); BankAcCount PolyMorphicChechingCount = PolyMorphicAcCountFactory.CreateChechAckingCount () samesystem.out.println (condicionalCheckingAccount.getInterestrate ()); // Salida: 0.03system.out.println (PolyMorphicCheckingAcCount.getInterestrate ()); // Salida: 0.03Es muy común reemplazar la lógica condicional con clases polimórficas, por lo que se han publicado métodos para reconstruir las declaraciones condicionales en clases polimórficas. Aquí hay un ejemplo simple. Además, la refactorización de Martin Fowler (p. 255) también describe el proceso detallado de realizar esta reconstrucción.
Al igual que otras técnicas en este artículo, no hay una regla dura y rápida sobre cuándo realizar una transición de la lógica condicional a las clases polimórficas. De hecho, no recomendamos usarlo en ninguna situación. En un diseño impulsado por la prueba: por ejemplo, Kent Beck diseñó un sistema de divisas simple con el objetivo de usar clases polimórficas, pero descubrió que esto hizo que el diseño fuera demasiado complicado y rediseñó su diseño en un estilo no polimórfico. La experiencia y el juicio razonable determinarán cuándo el momento adecuado para convertir el código condicional en código polimórfico.
Conclusión
Como programadores, aunque las técnicas convencionales utilizadas en los tiempos normales pueden resolver la mayoría de los problemas, a veces deberíamos romper esta rutina y exigir activamente cierta innovación. Después de todo, como desarrollador, ampliar la amplitud y profundidad de su conocimiento no solo nos permite tomar decisiones más inteligentes, sino que también nos hace más inteligentes.