Si vous êtes maintenant tenu d'optimiser le code Java que vous écrivez, que feriez-vous? Dans cet article, l'auteur introduit quatre méthodes qui peuvent améliorer les performances du système et la lisibilité du code. Si cela vous intéresse, jetons un coup d'œil.
Nos tâches de programmation habituelles ne sont rien de plus que d'appliquer la même suite technique à différents projets. Dans la plupart des cas, ces technologies peuvent atteindre les objectifs. Cependant, certains projets peuvent nécessiter des techniques spéciales, de sorte que les ingénieurs doivent étudier en profondeur pour trouver les méthodes les plus faciles mais les plus efficaces. Dans un article précédent, nous avons discuté de quatre technologies spéciales qui peuvent être utilisées lorsque cela est nécessaire pour créer un meilleur logiciel Java; Alors que dans cet article, nous présenterons certaines stratégies de conception courantes et des techniques de mise en œuvre d'objectifs qui aident à résoudre des problèmes courants, à savoir:
Seule l'optimisation déterminée
Utilisez autant que possible pour les constantes
Redéfinir la méthode equals () dans la classe
Utilisez autant de polymorphisme que possible
Il convient de noter que les techniques décrites dans cet article ne sont pas applicables à tous les cas. De plus, quand et où ces technologies doivent être utilisées, ils obligent les utilisateurs à considérer soigneusement.
1. Effectuer une optimisation déterminée
Les grands systèmes logiciels doivent être très préoccupés par les problèmes de performances. Bien que nous espérons pouvoir écrire le code le plus efficace, plusieurs fois, si nous voulons optimiser le code, nous ne savons pas comment commencer. Par exemple, le code suivant affectera-t-il les performances?
public void processIntegers (list <Integer> entiers) {for (Integer Value: Integers) {for (int i = entegers.size () - 1; i> = 0; i--) {value + = integers.get (i); }}}Cela dépend de la situation. Dans le code ci-dessus, nous pouvons voir que son algorithme de traitement est O (n³) (en utilisant de grands symboles O), où n est la taille de l'ensemble de liste. Si N n'est que 5, il n'y aura pas de problème, seulement 25 itérations seront effectuées. Mais si N est de 100 000, cela peut affecter les performances. Veuillez noter que même ainsi, nous ne pouvons pas déterminer qu'il y aura des problèmes. Bien que cette méthode nécessite 1 milliard d'itérations logiques, si elle aura un impact sur les performances reste à discuter.
Par exemple, supposons que le client exécute ce code dans son propre thread et attend de manière asynchrone que le calcul se termine, alors son temps d'exécution peut être acceptable. De même, si le système est déployé dans un environnement de production mais qu'aucun client ne l'appelle, nous n'avons pas besoin d'optimiser ce code, car il ne consommera pas du tout les performances globales du système. En fait, le système deviendra plus complexe après avoir optimisé les performances, mais la chose tragique est que les performances du système ne s'améliorent pas en conséquence.
La chose la plus importante est qu'il n'y a pas de déjeuner gratuit dans le monde, donc pour réduire le coût, nous utilisons généralement des technologies telles que le cache, l'expansion de boucle ou les valeurs pré-calculées pour atteindre l'optimisation, ce qui augmente à son tour la complexité du système et réduit la lisibilité du code. Si cette optimisation peut améliorer les performances du système, cela en vaut la peine même si elle devient compliquée, mais avant de prendre une décision, vous devez d'abord connaître ces deux informations:
Quelles sont les exigences de performance
Où est le goulot d'étranglement des performances
Tout d'abord, nous devons savoir clairement quelles sont les exigences de performance. Si c'est finalement dans les exigences et que l'utilisateur final n'a pas soulevé aucune objection, il n'est pas nécessaire d'effectuer une optimisation des performances. Cependant, lorsque de nouvelles fonctions sont ajoutées ou que le volume de données du système atteint une certaine échelle, il doit être optimisé, sinon des problèmes peuvent survenir.
Dans ce cas, il ne devrait pas être basé sur l'intuition ou l'inspection. Parce que même des développeurs expérimentés comme Martin Fowler sont enclins à faire de mauvaises optimisations, comme expliqué dans l'article Refactoring (page 70):
Si vous analysez suffisamment de programmes, vous trouverez la chose intéressante à propos des performances que la plupart de votre temps est gaspillée dans une petite partie du code du système. Si tous les codes sont optimisés de la même manière, le résultat final est que 90% de l'optimisation est gaspillée, car le code après optimisation n'a pas beaucoup de fréquence. Le temps consacré à l'optimisation sans objectifs est une perte de temps.
En tant que développeur endurcis au combat, nous devons prendre ce point de vue au sérieux. La première supposition n'est pas seulement que les performances du système n'ont pas été améliorées, mais 90% du temps de développement est complètement gaspillé. Au lieu de cela, nous devons exécuter des cas d'utilisation courants dans la production (ou la pré-production) et découvrir quelle partie du système consomme des ressources système pendant l'exécution, puis configurer le système. Par exemple, seulement 10% du code qui consomme la plupart des ressources, puis l'optimisation des 90% restants du code est une perte de temps.
Selon les résultats de l'analyse, si nous voulons utiliser ces connaissances, nous devons commencer par les situations les plus courantes. Parce que cela garantira que l'effort réel améliorera finalement les performances du système. Après chaque optimisation, les étapes d'analyse doivent être répétées. Parce que cela garantit non seulement que les performances du système sont vraiment améliorées, il peut également être vu dans quelle partie du goulot d'étranglement des performances est après avoir optimisé le système (car après avoir résolu un goulot d'étranglement, d'autres goulots d'étranglement peuvent consommer plus de ressources globales du système). Il convient de noter que le pourcentage de temps passé dans les goulots d'étranglement existants devrait augmenter, car les goulots d'étranglement restants sont temporairement inchangés, et le temps d'exécution global devrait être réduit à mesure que le goulot d'étranglement cible est éliminé.
Bien qu'il fasse beaucoup de capacité pour vérifier entièrement les profils dans les systèmes Java, il existe des outils très courants qui peuvent aider à découvrir les hotspots de performances du système, notamment JMeter, AppDynamics et YourKit. De plus, vous pouvez également vous référer au guide de surveillance des performances de Dzone pour plus d'informations sur l'optimisation des performances du programme Java.
Bien que les performances soient un composant très important de nombreux grands systèmes logiciels et font partie de la suite de tests automatisé dans le pipeline de livraison de produit, il ne peut pas être optimisé aveuglément et sans but. Au lieu de cela, des optimisations spécifiques doivent être faites aux goulots d'étranglement de performance qui ont été maîtrisés. Cela nous aide non seulement à éviter d'augmenter la complexité du système, mais nous permet également d'éviter les détours et d'éviter de faire des optimisations de temps.
2. Essayez d'utiliser des énumérations pour les constantes
Il existe de nombreux scénarios où les utilisateurs doivent énumérer un ensemble de valeurs prédéfinies ou constantes, telles que les codes de réponse HTTP qui peuvent être rencontrés dans les applications Web. L'une des techniques d'implémentation les plus courantes consiste à créer une nouvelle classe, qui contient de nombreuses valeurs de type final statique. Chaque valeur doit avoir un commentaire décrivant ce que signifie la valeur:
classe publique httpResponSecodes {public static final int ok = 200; public static final int not_found = 404; public static final int forbidden = 403;} if (gethttpResponse (). getStaturcode () == httpResponcodes.ok) {// faire quelque chose si le code de réponse est ok}Il est déjà très bon d'avoir cette idée, mais il y a encore des inconvénients:
Aucune vérification stricte des valeurs entières entrantes
Puisqu'il s'agit d'un type de données de base, la méthode sur le code d'état ne peut pas être appelé
Dans le premier cas, une constante spécifique est simplement créée pour représenter une valeur entière spéciale, mais il n'y a aucune restriction sur la méthode ou la variable, de sorte que la valeur utilisée peut être au-delà de la portée de la définition. Par exemple:
classe publique httpResponseHandler {public static void PrintMessage (int statuscode) {System.out.println ("Statut recevant de" + StatusCode); }} HttpResponseHandler.printMessage (15000);Bien que 15000 ne soit pas un code de réponse HTTP valide, il n'y a aucune restriction du côté serveur que le client doit fournir des entiers valides. Dans le deuxième cas, nous n'avons aucun moyen de définir une méthode pour le code d'état. Par exemple, si vous souhaitez vérifier si un code d'état donné est un code réussi, vous devez définir une fonction distincte:
classe publique httpResponSecodes {public static final int ok = 200; public static final int not_found = 404; public static final int interbidden = 403; public static booléen issucess (int statuscode) {return statuscode> = 200 && statuscode <300; }} if (httpResponSEcodes.issucCess (GethttpResponse (). getStaturcode ())) {// faire quelque chose si le code de réponse est un code de réussite}Pour résoudre ces problèmes, nous devons modifier le type constant du type de données de base à un type personnalisé et ne permettre que des objets spécifiques de la classe personnalisée. C'est exactement à cela que servent Java Enus. En utilisant Enum, nous pouvons résoudre ces deux problèmes à la fois:
Public Enum httpResponseCodes {ok (200), Forbidden (403), Not_Found (404); code int fin final; HttpResponSEcodes (int code) {this.code = code; } public int getcode () {return code; } public boolean issuCcess () {return code> = 200 && code <300; }} if (gethttpResponse (). getStaturScode (). IssuCess ()) {// faire quelque chose si le code de réponse est un code de réussite}De même, il est désormais possible d'exiger que le code d'état doit être valide lors de l'appel de la méthode:
classe publique httpResponseHandler {public static void PrintMessage (httpResponSecODODSCODE) {System.out.println ("Statut récolté de" + statuscode.getcode ()); }} HttpResponseHandler.printMessage (httpResponseCode.ok);Il convient de noter que cet exemple montre que s'il s'agit d'une constante, vous devriez essayer d'utiliser des énumérations, mais cela ne signifie pas que vous devez utiliser des énumérations en toutes circonstances. Dans certains cas, il peut être souhaitable d'utiliser une constante pour représenter une valeur particulière, mais d'autres valeurs sont également autorisées. Par exemple, tout le monde peut connaître PI, et nous pouvons utiliser une constante pour capturer cette valeur (et la réutiliser):
classe publique NumericConstants {public static final double pi = 3,14; public static final double unit_Circle_area = pi * pi;} class public tapis {private final Double zone; Classe publique Run (Double Area) {this.Area = Area; } public double getCost () {zone de retour * 2; }} // Créez un tapis de 4 pieds de diamètre (rayon de 2 pieds) Fourfootrug = nouveau tapis (2 * NumericConstants.Unit_Circle_Area);Par conséquent, les règles d'utilisation des énuméraires peuvent être résumées comme suit:
Lorsque toutes les valeurs discrètes possibles ont été connues à l'avance, vous pouvez utiliser l'énumération
Prenez le code de réponse HTTP mentionné ci-dessus comme exemple. Nous pouvons connaître toutes les valeurs du code d'état HTTP (peut être trouvée dans RFC 7231, qui définit le protocole HTTP 1.1). Par conséquent, l'énumération est utilisée. Dans le calcul de PI, nous ne connaissons pas toutes les valeurs possibles sur PI (tout double possible est valide), mais en même temps, nous voulons créer une constante pour les tapis circulaires pour faciliter le calcul (plus facile à lire); Par conséquent, une série de constantes sont définies.
Si vous ne pouvez pas connaître toutes les valeurs possibles à l'avance, mais que vous souhaitez inclure des champs ou des méthodes pour chaque valeur, le moyen le plus simple est de créer une nouvelle classe pour représenter les données. Bien que je n'ai jamais dit qu'il ne devrait y avoir aucune énumération dans aucun scénario, la clé pour savoir où et quand ne pas utiliser l'énumération est d'être conscient de toutes les valeurs à l'avance et d'interdire l'utilisation d'une autre valeur.
3. Redéfinir la méthode equals () dans la classe
La reconnaissance des objets peut être un problème difficile à résoudre: si deux objets occupent la même position en mémoire, sont-ils les mêmes? Si leurs identifiants sont les mêmes, sont-ils les mêmes? Ou que se passe-t-il si tous les champs sont égaux? Bien que chaque classe ait sa propre logique d'identification, il existe de nombreux pays occidentaux du système qui doivent juger s'ils sont égaux. Par exemple, il y a une classe ci-dessous qui indique l'achat de commande ...
Achat de classe publique {ID long privé; public long getID () {return id; } public void setid (long id) {this.id = id; }}... Comme écrit ci-dessous, il doit y avoir de nombreux endroits dans le code qui sont similaires:
Acheter originalPurchase = new achat (); achat updatedPurchase = new achat (); if (originalPurchase.getId () == updatedPurchase.getid ()) {// exécuter une logique pour les achats égaux}Plus ces appels logiques (à leur tour, il viole le principe sec), achat
Les informations d'identité de la classe deviendront également de plus en plus. Si pour une raison quelconque, l'achat a été modifié
La logique d'identité d'une classe (par exemple, le type d'identifiant a été modifié), il doit donc y avoir de nombreux endroits où la logique d'identité est mise à jour.
Nous devons initialiser cette logique à l'intérieur de la classe, plutôt que d'étendre trop la logique d'identité de la classe d'achat via le système. À première vue, nous pouvons créer une nouvelle méthode, telle que ISSAME, dont le paramètre d'inclusion est un objet d'achat, et comparer les ID de chaque objet pour voir s'ils sont les mêmes:
Achat de classe publique {ID long privé; public boolean issame (acheter autre) {return getID () == autre.gerid (); }}Bien qu'il s'agisse d'une solution efficace, la fonctionnalité intégrée de Java est ignorée: en utilisant la méthode equals. Chaque classe de Java hérite de la classe d'objets, bien qu'elle soit implicite, donc elle hérite également de la méthode égale. Par défaut, cette méthode vérifie l'identité d'objet (même objet en mémoire), comme indiqué dans l'extrait de code suivant dans la définition de la classe d'objets (version 1.8.0_131) dans JDK:
public booléen égaux (objet obj) {return (this == obj);}Cette méthode égale agit comme un emplacement naturel pour l'injection de la logique d'identité (implémentée en remplaçant les égaux par défaut):
Achat de classe publique {ID long privé; public long getID () {return id; } public void setid (long id) {this.id = id; } @Override public booléen equals (objet autre) {if (this == autre) {return true; } else if (! (Autre instance d'achat)) {return false; } else {return ((acheter) autre) .getId () == getID (); }}}Bien que cette méthode égale semble compliquée, car la méthode égale accepte uniquement les paramètres des objets de type, nous n'avons qu'à considérer trois cas:
Un autre objet est l'objet actuel (c'est-à-dire OriginalPurchase.Equals (originalPurchase)), par définition, ils sont le même objet, alors renvoyez True
L'autre objet n'est pas un objet d'achat, dans ce cas, nous ne pouvons pas comparer l'ID de l'achat, donc les deux objets ne sont pas égaux
Les autres objets ne sont pas le même objet, mais sont des cas d'achat. Par conséquent, la question de savoir si l'égalité dépend de la question de savoir si l'identifiant d'achat actuel et d'autres achats sont égaux. Maintenant, nous pouvons refactor nos conditions précédentes, comme suit:
Achat originalPurchase = new achat (); achat updatedPurchase = new achat (); if (originalPurchase.equals (updatedPurchase)) {// exécuter une logique pour les achats égaux}En plus de réduire la réplication dans le système, la refactorisation de la méthode égale par défaut a d'autres avantages. Par exemple, si nous construisons une liste d'objets d'achat et vérifions si la liste contient un autre objet d'achat avec le même ID (différents objets en mémoire), nous obtenons une vraie valeur car les deux valeurs sont considérées comme égales:
List <achat> achats = new ArrayList <> (); achats.add (originalPurchase); achats.contains (mise à jour updated); // Vrai
Habituellement, peu importe où vous vous trouvez, si vous devez déterminer si les deux classes sont égales, vous n'avez qu'à utiliser la méthode égale à réécriture. Si nous voulons utiliser la méthode égaux implicitement en raison de l'héritage de l'objet objet pour juger l'égalité, nous pouvons également utiliser l'opérateur ==, comme suit:
if (originalPurchase == updatedPurchase) {// Les deux objets sont les mêmes objets en mémoire}Il convient également de noter qu'après la réécriture de la méthode Equals, la méthode HashCode doit également être réécrite. Plus d'informations sur la relation entre ces deux méthodes et comment définir correctement HashCode
Méthode, voir ce fil.
Comme nous l'avons vu, l'écrasement de la méthode égaux initialise non seulement la logique d'identité à l'intérieur de la classe, mais réduit également la propagation de cette logique dans tout le système, il permet également au langage Java de prendre des décisions bien informées concernant la classe.
4. Utilisez autant que possible les polymorphismes
Pour tout langage de programmation, les phrases conditionnelles sont une structure très courante et il y a certaines raisons de leur existence. Parce que différentes combinaisons peuvent permettre à l'utilisateur de modifier le comportement du système en fonction de la valeur donnée ou de l'état instantané de l'objet. En supposant que l'utilisateur doit calculer le solde de chaque compte bancaire, le code suivant peut être développé:
public Enum BankAccountType {vérification, épargne, certificate_of_deposit;} classe publique BankAccount {Type privé BankAccountType; Public BankAccount (type BankAccountType) {this.type = type; } public double getInterrestrate () {switch (type) {Case vérification: return 0.03; // Économies de cas de 3%: rendement 0,04; // 4% de cas Certificate_of_Deposit: Retour 0,05; // 5% par défaut: lancez un nouveau Uns-SupportOperationException (); }} public boolean supportsDeposits () {switch (type) {Case vérification: return true; Économies de cas: Retour True; Cas Certificate_of_deposit: return false; Par défaut: lancez un nouveau UnportEdOperationException (); }}}Bien que le code ci-dessus réponde aux exigences de base, il existe un défaut évident: l'utilisateur détermine uniquement le comportement du système en fonction du type du compte donné. Cela oblige non seulement les utilisateurs à vérifier le type de compte avant de prendre une décision, mais doivent également répéter cette logique lors de la prise de décision. Par exemple, dans la conception ci-dessus, l'utilisateur doit vérifier les deux méthodes. Cela peut conduire à hors contrôle, en particulier lors de la réception d'un besoin d'ajouter un nouveau type de compte.
Nous pouvons utiliser le polymorphisme pour prendre des décisions implicitement, plutôt que d'utiliser des types de compte pour les distinguer. Pour ce faire, nous convertissons les classes concrètes de BankAccount en interface et passons le processus de décision en une série de classes concrètes qui représentent chaque type de compte bancaire:
/ ** * Java Learning and Communication QQ Group: 589809992 apprenons Java ensemble! * / interface publique BankAccount {public double getInterrestrate (); Public Boolean Support Deposits ();} Classe publique CheckingAccount implémente BankAccount {@Override public double getIntestrate () {return 0.03; } @Override public Boolean Support Deposits () {return true; }} public class SavingsAccount implémente BankAccount {@Override public double getIntestrate () {return 0.04; } @Override public boolean supportSdeposis () {return true; }} classe publique CertificateOfDepositAccount implémente BankAccount {@Override public double getIntestrestrate () {return 0.05; } @Override public boolean supportDeposis () {return false; }}Cela résume non seulement les informations spécifiques à chaque compte dans sa propre classe, mais aide également les utilisateurs à modifier leurs conceptions de deux manières importantes. Tout d'abord, si vous souhaitez ajouter un nouveau type de compte bancaire, il vous suffit de créer une nouvelle classe spécifique, d'implémenter l'interface BankAccount et de donner l'implémentation spécifique des deux méthodes. Dans la conception de la structure conditionnelle, nous devons ajouter une nouvelle valeur à l'énumération, ajouter une nouvelle instruction de cas dans les deux méthodes et insérer la logique du nouveau compte dans chaque instruction de cas.
Deuxièmement, si nous voulons ajouter une nouvelle méthode dans l'interface BankAccount, nous avons juste besoin d'ajouter une nouvelle méthode dans chaque classe de béton. Dans la conception conditionnelle, nous devons copier l'instruction Switch existante et l'ajouter à notre nouvelle méthode. De plus, nous devons ajouter une logique pour chaque type de compte dans chaque instruction de cas.
Mathématiquement, lorsque nous créons une nouvelle méthode ou ajoutons un nouveau type, nous devons apporter le même nombre de changements logiques dans la conception polymorphe et conditionnelle. Par exemple, si nous ajoutons une nouvelle méthode dans une conception polymorphe, nous devons ajouter la nouvelle méthode aux classes en béton de tous les n comptes bancaires, et dans une conception conditionnelle, nous devons ajouter n nouvelles instructions de cas dans notre nouvelle méthode. Si nous ajoutons un nouveau type de compte dans la conception polymorphe, nous devons implémenter tous les nombres M dans l'interface BankAccount et dans la conception conditionnelle, nous devons ajouter une nouvelle instruction de cas à chaque méthode existante.
Bien que le nombre de changements que nous devons apporter soit égal, la nature des changements est complètement différente. Dans la conception polymorphe, si nous ajoutons un nouveau type de compte et oublions d'inclure une méthode, le compilateur lance une erreur car nous n'implémentez pas toutes les méthodes dans notre interface BankAccount. Dans la conception conditionnelle, il n'y a pas un tel chèque pour s'assurer que chaque type a une déclaration de cas. Si un nouveau type est ajouté, nous pouvons simplement oublier de mettre à jour chaque instruction Switch. Plus ce problème est sérieux, plus nous répétons notre déclaration de Switch. Nous sommes des humains et nous avons tendance à faire des erreurs. Ainsi, chaque fois que nous pouvons compter sur le compilateur pour nous rappeler les erreurs, nous devons le faire.
La deuxième note importante sur ces deux conceptions est qu'elles sont équivalentes à l'extérieur. Par exemple, si nous voulons vérifier le taux d'intérêt pour un compte courant, la conception conditionnelle ressemblera à ceci:
BankAccount CheckingAccount = new BankAccount (BankAccountType.Checking); System.out.println (CheckingAccount.getInterrestrate ()); // Sortie: 0,03
Au lieu de cela, les conceptions polymorphes seront similaires à ce qui suit:
BankAccount CheckingAccount = new CheckingAccount (); System.out.println (CheckingAccount.getIterestrate ()); // Sortie: 0,03
D'un point de vue externe, nous appelons simplement getIntereunk () sur l'objet BankAccount. Ce sera encore plus évident si nous abstraction du processus de création en une classe d'usine:
classe publique ConditionalAccountFactory {public static BankAccount CreateCheCkingAccount () {return new BankAccount (BankAccountType.Checking); }} classe publique PolymorphicAccountFactory {public static bancaccount CreateChareccount () {return new CheckingAccount (); }} // Dans les deux cas, nous créons les comptes à l'aide d'un factorybankAccount conditionalCheckingAccount = ConditionalAccountFactory.CreateCcingAccount (); BankAccount PolymorphicCcheckingAccount = PolymorPhicAccountFactory.CreateCheCkingAccount (); // dans les deux cas, l'appel pour obtenir le taux d'intérêt est le taux d'intérêt est le taux d'intérêt est la samesystem.out.println (conditionalCheckingAccount.getInterrestrate ()); // Sortie: 0.03System.out.println (polymorphicCheckingAccount.getIterestrate ()); // Sortie: 0,03Il est très courant de remplacer la logique conditionnelle par des classes polymorphes, de sorte que des méthodes ont été publiées pour reconstruire les instructions conditionnelles en classes polymorphes. Voici un exemple simple. De plus, la refactorisation de Martin Fowler (p. 255) décrit également le processus détaillé de réalisation de cette reconstruction.
Comme les autres techniques de cet article, il n'y a pas de règle stricte et rapide sur le moment où effectuer une transition de la logique conditionnelle aux classes polymorphes. En fait, nous ne recommandons pas de l'utiliser dans une situation. Dans une conception axée sur les tests: par exemple, Kent Beck a conçu un système de devises simple dans le but d'utiliser des classes polymorphes, mais a constaté que cela rendait le design trop compliqué et redessiné son design en un style non polymorphe. L'expérience et un jugement raisonnable détermineront le bon moment pour convertir le code conditionnel en code polymorphe.
Conclusion
En tant que programmeurs, bien que les techniques conventionnelles utilisées dans les temps normales puissent résoudre la plupart des problèmes, nous devons parfois briser cette routine et exiger activement une certaine innovation. Après tout, en tant que développeur, élargir l'étendue et la profondeur de ses connaissances nous permet non seulement de prendre des décisions plus intelligentes, mais nous rend également plus intelligents.