Der Inhalt dieses Artikels ist eine Zusammenfassung von vier Techniken zum Schreiben von Java-Code. Ich hoffe, dass er für Freunde hilfreich ist.
Unsere üblichen Programmieraufgaben sind nichts anderes als die Anwendung derselben Technologiesuite auf verschiedene Projekte. In den meisten Fällen können diese Technologien die Ziele erreichen. Einige Projekte erfordern jedoch möglicherweise den Einsatz spezieller Technologien, sodass Ingenieure tiefer in die Materie eintauchen müssen, um die einfachsten, aber effektivsten Methoden zu finden. In diesem Artikel stellen wir einige allgemeine Designstrategien und Zielimplementierungstechniken vor, die zur Lösung häufiger Probleme beitragen können, nämlich:
Führen Sie nur eine gezielte Optimierung durch
Verwenden Sie so oft Aufzählungen wie möglich für Konstanten
Definieren Sie die Methode equal() in der Klasse neu
Verwenden Sie so viel Polymorphismus wie möglich
Es ist zu beachten, dass die in diesem Artikel beschriebenen Techniken möglicherweise nicht in allen Situationen funktionieren. Darüber hinaus muss der Anwender sorgfältig abwägen, wann und wo diese Technologien eingesetzt werden sollen.
Große Softwaresysteme müssen große Bedenken hinsichtlich Leistungsproblemen haben. Obwohl wir hoffen, den effizientesten Code zu schreiben, haben wir oft keine Möglichkeit, anzufangen, wenn wir den Code optimieren möchten. Wird sich beispielsweise der folgende Code auf die Leistung auswirken?
public void processIntegers(List<Integer> integers) { for (Integer value: integers) { for (int i = integers.size() - 1; i >= 0; i--) { value += integers.get(i); } } }
Das hängt von der Situation ab. Aus dem obigen Code ist ersichtlich, dass sein Verarbeitungsalgorithmus O(n³) ist (unter Verwendung der Big-O-Notation), wobei n die Größe der Listensammlung ist. Wenn n nur 5 wäre, gäbe es kein Problem und es würden nur 25 Iterationen durchgeführt. Wenn n jedoch 100.000 beträgt, kann dies Auswirkungen auf die Leistung haben. Bitte beachten Sie, dass wir auch dann nicht sicher sein können, dass ein Problem vorliegt. Obwohl diese Methode die Durchführung von 1 Milliarde logischen Iterationen erfordert, ist es noch umstritten, ob sie Auswirkungen auf die Leistung hat.
Angenommen, der Client führt diesen Code in seinem eigenen Thread aus und wartet asynchron auf den Abschluss der Berechnung, dann kann seine Ausführungszeit akzeptabel sein. Wenn das System in einer Produktionsumgebung bereitgestellt wird, aber kein Client zum Aufrufen vorhanden ist, müssen wir diesen Code überhaupt nicht optimieren, da er die Gesamtleistung des Systems überhaupt nicht beeinträchtigt. Tatsächlich wird das System nach der Optimierung der Leistung komplexer, aber die Tragödie besteht darin, dass sich die Leistung des Systems dadurch nicht verbessert.
Das Wichtigste ist, dass es kein kostenloses Mittagessen auf der Welt gibt. Um die Kosten zu senken, implementieren wir daher normalerweise Optimierungen durch Technologien wie Caching, Loop-Unrolling oder vorberechnete Werte, was die Komplexität tatsächlich erhöht des Systems verringert auch die Lesbarkeit des Codes. Wenn diese Optimierung die Leistung des Systems verbessern kann, lohnt es sich, auch wenn sie kompliziert wird. Bevor Sie jedoch eine Entscheidung treffen, müssen Sie zunächst diese beiden Informationen kennen:
Was sind die Leistungsanforderungen?
Wo liegen die Leistungsengpässe?
Zuerst müssen wir die Leistungsanforderungen genau kennen. Liegt es letztlich im Rahmen der Anforderungen und erheben die Endanwender keine Einwände, besteht keine Notwendigkeit zur Leistungsoptimierung. Wenn jedoch neue Funktionen hinzugefügt werden oder das Datenvolumen des Systems eine bestimmte Größenordnung erreicht, muss eine Optimierung durchgeführt werden, da es sonst zu Problemen kommen kann.
In diesem Fall sollte man sich weder auf die Intuition noch auf die Inspektion verlassen. Denn selbst erfahrene Entwickler wie Martin Fowler neigen dazu, einige falsche Optimierungen vorzunehmen, wie im Artikel Refactoring (Seite 70) erläutert:
Wenn Sie genügend Programme analysieren, werden Sie an der Leistung die meisten davon interessant finden Die Zeit wird in einem kleinen Teil des Systems verschwendet. Wenn die gleiche Optimierung für den gesamten Code durchgeführt wird, ist das Endergebnis, dass 90 % der Optimierung verschwendet werden, weil der optimierte Code nicht so häufig ausgeführt wird. Zeitaufwand für die Optimierung ohne Ziele ist Zeitverschwendung.
Als erfahrener Entwickler sollten wir diese Perspektive ernst nehmen. Die erste Vermutung verbessert nicht nur die Systemleistung nicht, sondern 90 % der Entwicklungszeit werden auch völlig verschwendet. Stattdessen sollten wir häufige Anwendungsfälle in einer Produktionsumgebung (oder einer Vorproduktionsumgebung) ausführen, herausfinden, welche Teile des Systems während der Ausführung Systemressourcen verbrauchen, und dann das System konfigurieren. Wenn beispielsweise nur 10 % des Codes die meisten Ressourcen verbrauchen, ist die Optimierung der restlichen 90 % des Codes Zeitverschwendung.
Um dieses Wissen nutzen zu können, sollten wir den Analyseergebnissen zufolge mit den häufigsten Situationen beginnen. Denn dadurch wird sichergestellt, dass der tatsächliche Aufwand letztendlich die Leistung des Systems verbessert. Nach jeder Optimierung sollten die Analyseschritte wiederholt werden. Denn dies stellt nicht nur sicher, dass die Leistung des Systems wirklich verbessert wird, sondern zeigt auch, wo der Leistungsengpass nach der Optimierung des Systems liegt (denn nachdem ein Engpass behoben wurde, können andere Engpässe mehr Gesamtressourcen des Systems verbrauchen). Beachten Sie, dass sich der Prozentsatz der in bestehenden Engpässen verbrachten Zeit wahrscheinlich erhöht, da die verbleibenden Engpässe vorübergehend unverändert bleiben, und dass die Gesamtausführungszeit abnehmen sollte, wenn der Zielengpass beseitigt wird.
尽管在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三者的区别
Das obige ist der detaillierte Inhalt vonFassen Sie vier Tipps zum Schreiben von Java-Code zusammen. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!