Maison >Java >javaDidacticiel >Résumez quatre conseils pour écrire du code Java
Le contenu de cet article consiste à résumer quatre techniques d'écriture de code Java. Il a une certaine valeur de référence. Les amis dans le besoin peuvent s'y référer.
Nos tâches de programmation habituelles ne sont rien d'autre que l'application de la même suite technologique à différents projets. Dans la plupart des cas, ces technologies peuvent atteindre les objectifs. Cependant, certains projets peuvent nécessiter l’utilisation de technologies spéciales, les ingénieurs doivent donc approfondir leurs recherches pour trouver les méthodes les plus simples mais les plus efficaces. Dans cet article, nous présenterons quelques stratégies de conception générales et techniques de mise en œuvre d'objectifs qui peuvent aider à résoudre des problèmes courants, à savoir :
Ne faites qu'une optimisation ciblée
Utilisez autant que possible les énumérations pour les constantes
Redéfinissez la méthode equals() dans la classe
Utilisez le polymorphisme autant que possible
Il est à noter que les techniques décrites dans cet article peuvent ne pas fonctionner dans toutes les situations. De plus, le moment et l’endroit où ces technologies doivent être utilisées nécessitent une réflexion approfondie de la part de l’utilisateur.
Les systèmes logiciels à grande échelle doivent être très préoccupés par les problèmes de performances. Même si nous espérons écrire le code le plus efficace, souvent, si nous voulons optimiser le code, nous n'avons aucun moyen de commencer. Par exemple, le code suivant affectera-t-il les performances
public void processIntegers(List<Integer> integers) { for (Integer value: integers) { for (int i = integers.size() - 1; i >= 0; i--) { value += integers.get(i); } } }
Cela dépend de la situation ? À partir du code ci-dessus, nous pouvons voir que son algorithme de traitement est O(n³) (en utilisant la notation grand O), où n est la taille de la collection de listes. Si n n’était que 5, alors il n’y aurait aucun problème et seulement 25 itérations seraient effectuées. Mais si n vaut 100 000, cela peut affecter les performances. Veuillez noter que même dans ce cas, nous ne pouvons pas être sûrs qu'il y aura un problème. Bien que cette méthode nécessite d’effectuer 1 milliard d’itérations logiques, la question de savoir si elle aura un impact sur les performances reste encore à discuter.
Par exemple, en supposant que le client exécute ce code dans son propre thread et attend de manière asynchrone la fin du calcul, 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'il n'y a pas de client pour passer des appels, nous n'avons pas du tout besoin d'optimiser ce code, car il ne consommera pas du tout les performances globales du système. En fait, après avoir optimisé les performances, le système deviendra plus complexe, mais le drame 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 repas gratuit dans le monde, donc afin de réduire les coûts, nous mettons généralement en œuvre une optimisation via des technologies telles que la mise en cache, le déroulement de boucles ou les valeurs précalculées, ce qui augmente en réalité la complexité du système, réduit également la lisibilité du code. Si cette optimisation peut améliorer les performances du système, alors même si cela devient compliqué, cela en vaut la peine, mais avant de prendre une décision, il faut d'abord connaître ces deux informations :
Quelles sont les exigences de performance ?
Où sont les goulots d'étranglement en matière de performances ?
Nous devons d'abord savoir clairement quelles sont les exigences de performance ? Si cela répond finalement aux exigences et que les utilisateurs finaux ne soulèvent aucune objection, il n’est alors 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, une optimisation doit être effectuée, sinon des problèmes peuvent survenir.
Dans ce cas, il ne faut pas se fier à l'intuition, ni à l'inspection. Parce que même les développeurs expérimentés comme Martin Fowler ont tendance à faire de mauvaises optimisations, comme expliqué dans l'article Refactoring (page 70) :
Si suffisamment de programmes sont analysés, ce que vous trouverez intéressant à propos des performances, c'est que la plupart des le temps est perdu dans une petite partie du système. Si la même optimisation est effectuée sur tout le code, le résultat final est que 90 % de l’optimisation est gaspillée car le code optimisé ne s’exécute pas aussi fréquemment. Tout temps passé à optimiser sans objectif est une perte de temps.
En tant que développeur chevronné, nous devrions prendre cette perspective au sérieux. Non seulement la première hypothèse n’améliore pas les performances du système, mais 90 % du temps de développement est complètement gaspillé. Au lieu de cela, nous devrions exécuter des cas d'utilisation courants dans un environnement de production (ou un environnement de pré-production), découvrir quelles parties du système consomment des ressources système pendant l'exécution, puis configurer le système. Par exemple, si seulement 10 % du code consomme la plupart des ressources, optimiser les 90 % restants du code est une perte de temps.
Selon les résultats de l'analyse, pour utiliser ces connaissances, il faut commencer par les situations les plus courantes. Parce que cela garantira que l’effort réel améliore 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 réellement améliorées, mais montre également où se situe le goulot d'étranglement des performances après l'optimisation du système (car une fois qu'un goulot d'étranglement est résolu, d'autres goulots d'étranglement peuvent consommer plus de ressources globales du système). Notez que le pourcentage de temps passé dans les goulots d'étranglement existants est susceptible d'augmenter car les goulots d'étranglement restants sont temporairement inchangés, et le temps d'exécution global devrait diminuer à mesure que le goulot d'étranglement cible est éliminé.
尽管在Java系统中想要对概要文件进行全面检查需要很大的容量,但是还是有一些很常见的工具可以帮助发现系统的性能热点,这些工具包括JMeter、AppDynamics和YourKit。另外,还可以参见DZone的性能监测指南,获取更多关于Java程序性能优化的信息。
虽然性能是许多大型软件系统一个非常重要的组成部分,也成为产品交付管道中自动化测试套件的一部分,但是还是不能够盲目的且没有目的的进行优化。相反,应该对已经掌握的性能瓶颈进行特定的优化。这不仅可以帮助我们避免增加了系统的复杂性,而且还让我们少走弯路,不去做那些浪费时间的优化。
需要用户列出一组预定义或常量值的场景有很多,例如在web应用程序中可能遇到的HTTP响应代码。最常见的实现技术之一是新建类,该类里面有很多静态的final类型的值,每个值都应该有一句注释,描述该值的含义是什么:
public class HttpResponseCodes { public static final int OK = 200; public static final int NOT_FOUND = 404; public static final int FORBIDDEN = 403; } if (getHttpResponse().getStatusCode() == HttpResponseCodes.OK) { // Do something if the response code is OK }
能够有这种思路就已经非常好了,但这还是有一些缺点:
没有对传入的整数值进行严格的校验
由于是基本数据类型,因此不能调用状态代码上的方法
在第一种情况下只是简单的创建了一个特定的常量来表示特殊的整数值,但并没有对方法或变量进行限制,因此使用的值可能会超出定义的范围。例如:
public class HttpResponseHandler { public static void printMessage(int statusCode) { System.out.println("Recieved status of " + statusCode); } }
HttpResponseHandler.printMessage(15000);
尽管15000并不是有效的HTTP响应代码,但是由于服务器端也没有限制客户端必须提供有效的整数。在第二种情况下,我们没有办法为状态代码定义方法。例如,如果想要检查给定的状态代码是否是一个成功的代码,那就必须定义一个单独的函数:
public class HttpResponseCodes { public static final int OK = 200; public static final int NOT_FOUND = 404; public static final int FORBIDDEN = 403; public static boolean isSuccess(int statusCode) { return statusCode >= 200 && statusCode < 300; } } if (HttpResponseCodes.isSuccess(getHttpResponse().getStatusCode())) { // Do something if the response code is a success code }
为了解决这些问题,我们需要将常量类型从基本数据类型改为自定义类型,并只允许自定义类的特定对象。这正是Java枚举(enum)的用途。使用enum,我们可以一次性解决这两个问题:
public enum HttpResponseCodes { OK(200), FORBIDDEN(403), NOT_FOUND(404); private final int code; HttpResponseCodes(int code) { this.code = code; } public int getCode() { return code; } public boolean isSuccess() { return code >= 200 && code < 300; } } if (getHttpResponse().getStatusCode().isSuccess()) { // Do something if the response code is a success code }
同样,现在还可以要求在调用方法的时候提供必须有效的状态代码:
public class HttpResponseHandler { public static void printMessage(HttpResponseCode statusCode) { System.out.println("Recieved status of " + statusCode.getCode()); } }
HttpResponseHandler.printMessage(HttpResponseCode.OK);
值得注意的是,举这个例子事项说明如果是常量,则应该尽量使用枚举,但并不是说什么情况下都应该使用枚举。在某些情况下,可能希望使用一个常量来表示某个特殊值,但是也允许提供其它的值。例如,大家可能都知道圆周率,我们可以用一个常量来捕获这个值(并重用它):
public class NumericConstants { public static final double PI = 3.14; public static final double UNIT_CIRCLE_AREA = PI * PI; } public class Rug { private final double area; public class Run(double area) { this.area = area; } public double getCost() { return area * 2; } } // Create a carpet that is 4 feet in diameter (radius of 2 feet) Rug fourFootRug = new Rug(2 * NumericConstants.UNIT_CIRCLE_AREA);
因此,使用枚举的规则可以归纳为:
当所有可能的离散值都已经提前知道了,那么就可以使用枚举
再拿上文中所提到的HTTP响应代码为例,我们可能知道HTTP状态代码的所有值(可以在RFC 7231中找的到,它定义了HTTP 1.1协议)。因此使用了枚举。在计算圆周率的情况下,我们不知道关于圆周率的所有可能值(任何可能的double都是有效的),但同时又希望为圆形的rugs创建一个常量,使计算更容易(更容易阅读);因此定义了一系列常量。
如果不能提前知道所有可能的值,但是又希望包含每个值的字段或方法,那么最简单的方法就是可以新建一个类来表示数据。尽管没有说过什么场景应该绝对不用枚举,但要想知道在什么地方、什么时间不使用枚举的关键是提前意识到所有的值,并且禁止使用其他任何值。
对象识别可能是一个很难解决的问题:如果两个对象在内存中占据相同的位置,那么它们是相同的吗?如果它们的id相同,它们是相同的吗?或者如果所有的字段都相等呢?虽然每个类都有自己的标识逻辑,但是在系统中有很多西方都需要去判断是否相等。例如,有如下的一个类,表示订单购买…
public class Purchase { private long id; public long getId() { return id; } public void setId(long id) { this.id = id; } }
……就像下面写的这样,代码中肯定有很多地方都是类似于的:
Purchase originalPurchase = new Purchase(); Purchase updatedPurchase = new Purchase(); if (originalPurchase.getId() == updatedPurchase.getId()) { // Execute some logic for equal purchases }
这些逻辑调用的越多(反过来,违背了DRY原则),Purchase类的身份信息也会变得越来越多。如果出于某种原因,更改了Purchase类的身份逻辑(例如,更改了标识符的类型),则需要更新标识逻辑所在的位置肯定也非常多。
我们应该在类的内部初始化这个逻辑,而不是通过系统将Purchase类的身份逻辑进行过多的传播。乍一看,我们可以创建一个新的方法,比如isSame,这个方法的入参是一个Purchase对象,并对每个对象的id进行比较,看看它们是否相同:
public class Purchase { private long id; public boolean isSame(Purchase other) { return getId() == other.gerId(); } }
虽然这是一个有效的解决方案,但是忽略了Java的内置功能:使用equals方法。Java中的每个类都是继承了Object类,虽然是隐式的,因此同样也就继承了equals方法。默认情况下,此方法将检查对象标识(内存中相同的对象),如JDK中的对象类定义(version 1.8.0_131)中的以下代码片段所示:
public boolean equals(Object obj) { return (this == obj); }
这个equals方法充当了注入身份逻辑的自然位置(通过覆盖默认的equals实现):
public class Purchase { private long id; public long getId() { return id; } public void setId(long id) { this.id = id; } @Override public boolean equals(Object other) { if (this == other) { return true; } else if (!(other instanceof Purchase)) { return false; } else { return ((Purchase) other).getId() == getId(); } } }
虽然这个equals方法看起来很复杂,但由于equals方法只接受类型对象的参数,所以我们只需要考虑三个案例:
另一个对象是当前对象(即originalPurchase.equals(originalPurchase)),根据定义,它们是同一个对象,因此返回true
另一个对象不是Purchase对象,在这种情况下,我们无法比较Purchase的id,因此,这两个对象不相等
其他对象不是同一个对象,但却是Purchase的实例,因此,是否相等取决于当前Purchase的id和其他Purchase是否相等
现在可以重构我们之前的条件,如下:
Purchase originalPurchase = new Purchase(); Purchase updatedPurchase = new Purchase(); if (originalPurchase.equals(updatedPurchase)) { // Execute some logic for equal purchases }
除了可以在系统中减少复制,重构默认的equals方法还有一些其它的优势。例如,如果构造一个Purchase对象列表,并检查列表是否包含具有相同ID(内存中不同对象)的另一个Purchase对象,那么我们就会得到true值,因为这两个值被认为是相等的:
List<Purchase> purchases = new ArrayList<>(); purchases.add(originalPurchase); purchases.contains(updatedPurchase); // True
通常,无论在什么地方,如果需要判断两个类是否相等,则只需要使用重写过的equals方法就可以了。如果希望使用由于继承了Object对象而隐式具有的equals方法去判断相等性,我们还可以使用= =操作符,如下:
if (originalPurchase == updatedPurchase) { // The two objects are the same objects in memory }
还需要注意的是,当equals方法被重写以后,hashCode方法也应该被重写。有关这两种方法之间关系的更多信息,以及如何正确定义hashCode方法,请参见此线程。
正如我们所看到的,重写equals方法不仅可以将身份逻辑在类的内部进行初始化,并在整个系统中减少了这种逻辑的扩散,它还允许Java语言对类做出有根据的决定。
对于任何一门编程语言来说,条件句都是一种很常见的结构,而且它的存在也是有一定原因的。因为不同的组合可以允许用户根据给定值或对象的瞬时状态改变系统的行为。假设用户需要计算各银行账户的余额,那么就可以开发出以下的代码:
public enum BankAccountType { CHECKING, SAVINGS, CERTIFICATE_OF_DEPOSIT; } public class BankAccount { private final BankAccountType type; public BankAccount(BankAccountType type) { this.type = type; } public double getInterestRate() { switch(type) { case CHECKING: return 0.03; // 3% case SAVINGS: return 0.04; // 4% case CERTIFICATE_OF_DEPOSIT: return 0.05; // 5% default: throw new UnsupportedOperationException(); } } public boolean supportsDeposits() { switch(type) { case CHECKING: return true; case SAVINGS: return true; case CERTIFICATE_OF_DEPOSIT: return false; default: throw new UnsupportedOperationException(); } } }
虽然上面这段代码满足了基本的要求,但是有个很明显的缺陷:用户只是根据给定帐户的类型决定系统的行为。这不仅要求用户每次要做决定之前都需要检查账户类型,还需要在做出决定时重复这个逻辑。例如,在上面的设计中,用户必须在两种方法都进行检查才可以。这就可能会出现失控的情况,特别是接收到添加新帐户类型的需求时。
我们可以使用多态来隐式地做出决策,而不是使用账户类型用来区分。为了做到这一点,我们将BankAccount的具体类转换成一个接口,并将决策过程传入一系列具体的类,这些类代表了每种类型的银行帐户:
public interface BankAccount { public double getInterestRate(); public boolean supportsDeposits(); } public class CheckingAccount implements BankAccount { @Override public double getIntestRate() { return 0.03; } @Override public boolean supportsDeposits() { return true; } } public class SavingsAccount implements BankAccount { @Override public double getIntestRate() { return 0.04; } @Override public boolean supportsDeposits() { return true; } } public class CertificateOfDepositAccount implements BankAccount { @Override public double getIntestRate() { return 0.05; } @Override public boolean supportsDeposits() { return false; } }
这不仅将每个帐户特有的信息封装到了到自己的类中,而且还支持用户可以在两种重要的方式中对设计进行变化。首先,如果想要添加一个新的银行帐户类型,只需创建一个新的具体类,实现了BankAccount的接口,给出两个方法的具体实现就可以了。在条件结构设计中,我们必须在枚举中添加一个新值,在两个方法中添加新的case语句,并在每个case语句下插入新帐户的逻辑。
其次,如果我们希望在BankAccount接口中添加一个新方法,我们只需在每个具体类中添加新方法。在条件设计中,我们必须复制现有的switch语句并将其添加到我们的新方法中。此外,我们还必须在每个case语句中添加每个帐户类型的逻辑。
在数学上,当我们创建一个新方法或添加一个新类型时,我们必须在多态和条件设计中做出相同数量的逻辑更改。例如,如果我们在多态设计中添加一个新方法,我们必须将新方法添加到所有n个银行帐户的具体类中,而在条件设计中,我们必须在我们的新方法中添加n个新的case语句。如果我们在多态设计中添加一个新的account类型,我们必须在BankAccount接口中实现所有的m数,而在条件设计中,我们必须向每个m现有方法添加一个新的case语句。
虽然我们必须做的改变的数量是相等的,但变化的性质却是完全不同的。在多态设计中,如果我们添加一个新的帐户类型并且忘记包含一个方法,编译器会抛出一个错误,因为我们没有在我们的BankAccount接口中实现所有的方法。在条件设计中,没有这样的检查,以确保每个类型都有一个case语句。如果添加了新类型,我们可以简单地忘记更新每个switch语句。这个问题越严重,我们就越重复我们的switch语句。我们是人类,我们倾向于犯错误。因此,任何时候,只要我们可以依赖编译器来提醒我们错误,我们就应该这么做。
关于这两种设计的第二个重要注意事项是它们在外部是等同的。例如,如果我们想要检查一个支票帐户的利率,条件设计就会类似如下:
BankAccount checkingAccount = new BankAccount(BankAccountType.CHECKING); System.out.println(checkingAccount.getInterestRate()); // Output: 0.03
相反,多态设计将类似如下:
BankAccount checkingAccount = new CheckingAccount(); System.out.println(checkingAccount.getInterestRate()); // Output: 0.03
从外部的角度来看,我们只是在BankAccount对象上调用getintereUNK()。如果我们将创建过程抽象为一个工厂类的话,这将更加明显:
public class ConditionalAccountFactory { public static BankAccount createCheckingAccount() { return new BankAccount(BankAccountType.CHECKING); } } public class PolymorphicAccountFactory { public static BankAccount createCheckingAccount() { return new CheckingAccount(); } } // In both cases, we create the accounts using a factory BankAccount conditionalCheckingAccount = ConditionalAccountFactory.createCheckingAccount(); BankAccount polymorphicCheckingAccount = PolymorphicAccountFactory.createCheckingAccount(); // In both cases, the call to obtain the interest rate is the same System.out.println(conditionalCheckingAccount.getInterestRate()); // Output: 0.03 System.out.println(polymorphicCheckingAccount.getInterestRate()); // Output: 0.03
将条件逻辑替换成多态类是非常常见的,因此已经发布了将条件语句重构为多态类的方法。这里就有一个简单的例子。此外,马丁·福勒(Martin Fowler)的《重构》(p . 255)也描述了执行这个重构的详细过程。
就像本文中的其他技术一样,对于何时执行从条件逻辑转换到多态类,没有硬性规定。事实上,如论在何种情况下我们都是不建议使用。在测试驱动的设计中:例如,Kent Beck设计了一个简单的货币系统,目的是使用多态类,但发现这使设计过于复杂,于是便将他的设计重新设计成一个非多态风格。经验和合理的判断将决定何时是将条件代码转换为多态代码的合适时间。
作为程序员,尽管平常所使用的常规技术可以解决大部分的问题,但有时我们应该打破这种常规,主动需求一些创新。毕竟作为一名开发人员,扩展自己知识面的的广度和深度,不仅能让我们做出更明智的决定,也能让我们变得越来越聪明。
相关推荐:
总结Java SE、Java EE、Java ME三者的区别
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!