经验越丰富的 Java 程序员,越注重细节,不然只会堆积更多的屎山代码
1: 静态工厂方法代替构造方法
1)可定义工厂方法名字
当构造函数的参数本身无法明确描述函数返回的对象类型时,一个巧妙命名的静态方法能够帮助客户端代码更好地理解其功能。
举个例子,考虑一个构造函数 BigInteger(int, int, Random)
,它返回一个可能为素数的 BigInteger 对象。然而,如果我们改用静态工厂方法 BigInteger.probablePrime
,代码表达会更加明确和清晰。
此外,我们都了解一个类通常只能有一个特定参数数量和类型的构造函数。但有时程序员为了绕开这一限制,可能会创建两个构造函数,它们的参数顺序不同。这种做法并不推荐,因为使用者很容易混淆应该使用哪个构造函数,从而引发错误。除非他们仔细研究文档才能弄清楚。
然而,静态工厂方法的命名方式解决了上述问题。只需为这两个方法选择清晰而不同的名称即可,消除了歧义。
2) 只需要创建一次新对象
例子 :
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
这种方法完全不会创建新的对象,类似于设计模式中的享元模式(Flyweight Pattern)。当经常需要相同类型的对象时,它能显著提升性能,尤其是对象创建成本很高的情况下。
静态工厂方法保证在多次调用时返回相同的对象,这种能力确保了对类实例的严格控制,被称为"实例控制(instance-controlled)"。
有几个理由来编写实例控制的类:
- 确保类是单例或者不可实例化的。
- 对于不可变值类型的类,可以确保它们是相等的。
- 这是享元模式的基础。
3)静态工厂方法可以返回任何子类型的对象。
这个功能允许用户更灵活地选择要返回的对象类型。
这种灵活性的一种应用是API可以返回对象,而无需公开返回对象的类。只需返回对象的类是静态工厂方法定义的返回类型的子类即可。这种技术适用于基于接口的框架,其中接口提供了原生返回类型的对象。
按照惯例,一个名为"Type"的接口通常将其静态工厂方法放在一个名为"Types"的不可实例化的伴随类中。例如,Java集合框架有45个实用程序实现接口,提供不可变集合、同步集合等。几乎所有这些实现都是通过静态工厂方法在一个不可实例化的类(java.util.Collections)中导出的。返回对象的类都是非公开的。
使用这种技术,Collections类变得更简洁。这不仅减少了API的大小,还减少了概念上的复杂性,因为程序员只需了解返回的对象具有接口所规定的API,而无需额外阅读实现类的文档。
此外,使用静态工厂方法,客户端通常通过接口引用返回的对象,这通常是一个好实践。
从Java 8开始,接口不再受限于不能包含静态方法的限制,因此通常不需要提供不可实例化的伴随类。许多公共静态成员应该放在接口本身中。但请注意,可能仍需要将大量实现代码放在这些静态方法后面的包私有类中。这是因为Java 8要求接口的所有静态成员都是公共的。Java 9允许私有静态方法,但静态字段和静态成员类仍然需要公开。
4)静态工厂方法可以根据输入参数改变返回对象的类。
返回对象的类型只需是声明类型的子类型即可。
EnumSet类没有公共的构造方法,只有静态工厂方法。在OpenJDk的实现中,它可以返回两种子类型之一:如果枚举类型的数量小于等于64,静态工厂将返回RegularEnumSet,否则将返回JumboEnumSet。
这两种实现的子类对调用者是不可见的。因此,如果以后出于性能原因删除了其中一个类,对用户也没有影响。同样,添加新的子类也不会影响调用者。
5)在编写静态工厂方法时,返回对象的类不需要存在。
这种灵活的静态工厂方法构成了服务提供者框架的基础,如Java数据库连接API(JDBC)。服务提供者框架是一种系统,其中提供者负责实现服务,使实现可用于客户端并将客户端与实现分离。
服务提供者框架有三个基本组件:
- 服务接口,代表具体实现。
- 提供者注册API,提供者用于注册实现。
- 服务访问API,客户端用于获取服务的实例。
服务访问API允许客户端指定选择实现的标准,如果没有这样的标准,API将返回默认实现的实例,或允许客户端循环遍历所有可用的实现。服务访问API是灵活的静态工厂,它构成了服务提供者框架的基础。
另一个可选组件是服务提供者接口,用于描述生产服务接口实例的工厂对象。在没有服务提供者接口的情况下,必须使用反射实例化实现。在JDBC中,Connection作为服务接口,DriverManager.registerDriver作为提供者注册API,DriverManager.getConnection作为服务访问API,Driver是服务提供者接口。
服务提供者框架模式有许多变体。例如,服务访问API可以返回比提供者提供的服务接口更丰富的服务接口,这就是桥接模式(Bridge Pattern)。依赖注入框架也被视为强大的服务提供者。Java 6引入了通用的服务提供者框架:java.util.ServiceLoader,因此无需自己实现。
缺点:
1)没有 public 或 protected 构造函数的类不能被子类化
例如,我们不可能在Collections Framework中继承任何便捷的实现类。
2)静态工厂方法不能容易的被使用者找到
构造方法,我们不看 API 文档也知道,不像构造方法,我们可以根据命名约定来使用。因此,最好约定一些 如下:
- from: 一种类型转换方法,它接受一个参数并返回一个相应的这种类型的实例。
Date d = Date.from(instant);
- of:一种聚合方法,它接受多个参数并返回实例包含它们的这种类型
Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
- valueOf:一个更详细的替代 from 和 of
BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
- instance or getInstance:返回由其参数(如果有)描述的实例,但不能说具有相同的值
StackWalker luke = StackWalker.getInstance(options);
- create or newInstance:像instance或getInstance,但是该方法保证每个调用返回一个新实例
Object newArray = Array.newInstance(classObject, arrayLen);
- getType:与getInstance类似,但如果工厂方法位于不同的类中,则使用它。 Type是工厂方法返回的对象类型
FileStore fs = Files.getFileStore(path);
- newType:与newInstance类似,但如果工厂方法在不同的类中,则使用。 Type是工厂方法返回的对象类型
BufferedReader br = Files.newBufferedReader(path);
- type:getType和newType的简明替代方案
List<Complaint> litany = Collections.list(legacyLitany);
2:当多参构造函数时,使用虑构建器(Builder)
静态工厂方法和构造函数都共享一个限制:当存在众多可选参数时,它们难以有效扩展。
在构造函数中,即使我们不需要这些参数,也必须为它们传递值。特别是当面对许多可选参数时,这种方法会导致代码显得笨重。
虽然最初可以采用伸缩构造模式(函数重载)来一定程度上解决这个问题,但随着参数数量的增加,代码会变得臃肿,难以维护和理解。
另一种方法是JavaBeans模式,使用get和set方法。在这种模式下,我们首先调用无参数构造函数创建对象,然后使用setter方法设置每个必需参数和每个感兴趣的可选参数。这种方法避免了前一种方法的问题,容易创建实例,并生成的代码也更易阅读。
不幸的是,JavaBeans模式本身也存在严重的缺点。构建一个完整的对象需要多次调用,而在多线程环境下,这可能导致状态不一致。尽管可以使用锁来解决这些问题,但会增加程序的复杂性。
幸运的是,还有第三种方式,即生成器模式(Builder Pattern)。它首先使用必需参数构建一个生成器对象,然后设置可选参数(类似于使用setter函数),最后通过调用生成器方法生成最终的对象。这种方法克服了前两种方法的问题,提供了更灵活、易于维护和理解的对象构建方式。
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder {
// Required parameters
private final int servingSize;
private final int servings;
// Optional parameters - initialized to default values
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val)
{ calories = val; return this; }
public Builder fat(int val)
{ fat = val; return this; }
public Builder sodium(int val)
{ sodium = val; return this; }
public Builder carbohydrate(int val)
{ carbohydrate = val; return this; }
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
调用 :
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();
Builder模式在模拟Python和Scala中的命名可选参数方面发挥了重要作用。
此外,重要的是在builder函数中及早检查参数的有效性。如果参数不满足要求,应立即抛出IllegalArgumentException异常,并明确指出无效的参数。
Builder模式非常适合用于类层次结构。可以创建并行的构建器层次结构,其中每个构建器嵌套在相应的类中。抽象类拥有抽象构建器,而具体类拥有具体的构建者。
相对于构造函数,构建器的一个小优点是构建器可以接受多个varargs参数,因为每个参数都在自己的方法中指定。此外,构建器可以将多次方法调用传递的参数聚合到单个字段中。
Builder模式非常灵活。单个构建器可以重复使用以构建多个对象。可以在构建方法调用之间灵活调整构建器的参数,以改变所创建的对象。构建器还可以在创建对象时自动填充某些字段,例如每次创建对象时增加的序列号。
然而,Builder模式也存在一些缺点,其中之一是必须首先创建构建器才能创建对象。虽然在实际应用中,创建构建器的成本可能不太明显,但在性能关键的情况下可能会产生一些开销。
此外,与伸缩构造函数模式相比,Builder模式可能需要更多的代码,因此只有当有足够的参数使其变得合理(例如四个或更多参数)时才应使用它。但需要牢记,参数的数量可能会在将来增加。
如果一开始使用构造函数或静态工厂创建了类,并且随着需求的变化,参数数量变得难以管理,那么切换到Builder模式可能是一个明智的选择。这将减少旧的构造函数或静态工厂的冗余性。
总之,在设计具有多个参数的类的构造函数或静态工厂时,Builder模式是一个出色的选择,特别是当许多参数是可选的或类型相似的情况下。相对于伸缩构造函数,客户端代码更容易阅读和编写,相对于JavaBeans,也更安全。
3: 单例或枚举
// Singleton with public final field
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}
// Singleton with static factory
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public static Elvis getInstance() { return INSTANCE; }
public void leaveTheBuilding() { ... }
}
静态工厂方法的一个优点是,它能在你不更改API的情况下,灵活地控制类是否为单例。工厂方法返回唯一的实例,但可以修改它,例如,为每个调用它的线程返回一个单独的实例。
第二个优点是,如果您的应用需要,您可以编写通用的单例工厂。
使用静态工厂的最后一个优点是方法引用可以用作供应商,例如Elvis :: instance是Supplier 。
除非是为了其中一个优点,否则第一种方法更可取。
序列化
要注意对一个拥有单例属性的类来讲,仅仅实现 Serializable 接口是不够的。而是要将单例属性前加上 transient 关键字,否则每一次的反序列化,都会创建出一个的新的对象。在反序列化后,如果需要获取单例属性,需要添加 readResolve 方法。
4:使用依赖注入取代硬连接资源
1) 静态实用工具类
// Inappropriate use of static utility - inflexible & untestable!
public class SpellChecker {
private static final Lexicon dictionary = ...;
private SpellChecker() {} // Noninstantiable
public static boolean isValid(String word) { ... }
public static List<String> suggestions(String typo) { ... }
}
2)单例:
// Inappropriate use of singleton - inflexible & untestable!
public class SpellChecker {
private final Lexicon dictionary = ...;
private SpellChecker(...) {}
public static INSTANCE = new SpellChecker(...);
public boolean isValid(String word) { ... }
public List<String> suggestions(String typo) { ... }
}
然而,这两种方法都不令人满意,因为它们都假设只有一本字典值得使用。实际上,每种语言都拥有自己的特殊字典,专门用于特定的词汇表。此外,使用专门的字典进行测试也是一种可行的方法。想当然地认为只需一个字典就足够了,这是一种过于乐观的假设。
可以通过将dictionary
属性设置为非final
,并添加一种方法来更改现有拼写检查器中的字典,从而使拼写检查器支持多个字典。然而,在并发环境中,这种方法既笨拙又容易出错,因此不切实际。对于那些需要底层资源参数化的类,静态实用类和单例模式都不合适。
所需的是能够支持类的多个实例(在我们的示例中即SpellChecker
),每个实例都使用客户端所期望的资源(在我们的例子中是dictionary
)。满足这一需求的简单模式是在创建新实例时将资源传递到构造方法中。这就是依赖注入(dependency injection)的一种形式:字典是拼写检查器的一个依赖,当创建拼写检查器时,将字典注入到拼写检查器中。
// Dependency injection provides flexibility and testability
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) {
this.dictionary = Objects.requireNonNull(dictionary);
}
public boolean isValid(String word) { ... }
public List<String> suggestions(String typo) { ... }
}
依赖注入模式非常简单。虽然我们的拼写检查器的例子只有一个资源(这里是字典),但是依赖项注入可以使用任意数量的资源和任意依赖图。它保持了不变性,因此多个客户端可以共享依赖对象(假设客户需要相同的底层资源)。 依赖注入同样适用于构造方法,静态工厂和 builder模式。
该模式的一个有用的变体是将资源工厂传递给构造方法。工厂是可以重复调用以创建类型实例的对象。 这种工厂体现了工厂方法模式(Factory Method pattern )。 Java 8中引入的*Supplier*接口非常适合代表工厂。 在输入上采用Supplier的方法通常应该使用有界的通配符类型( bounded wildcard type)约束工厂的类型参数,以允许客户端传入工厂,创建指定类型的任何子类型。
例如,下面是一个使用客户端提供的工厂生成tile的方法:‘
Mosaic create(Supplier<? extends Tile> tileFactory) { ... }
尽管依赖注入极大地提高了灵活性和可测试性,但它可能使大型项目变得混乱,这些项目通常包含数千个依赖项。使用依赖注入框架(如Dagger[Dagger]、Guice[Guice]或Spring[Spring])可以消除这些混乱。这些框架的使用超出了本书的范围,但是请注意,为手动依赖注入而设计的API非常适合使用这些框架。
总之,当类依赖于一个或多个底层资源,不要使用单例或静态的实用类来实现一个类,这些资源的行为会影响类的行为,并且不要让类直接创建这些资源。相反,将资源或工厂传递给构造方法(或静态工厂或builder模式)。这种称为依赖注入的实践将极大地增强类的灵活性、可重用性和可测试性。
5: 它创建了几个对象?
通常重用单个对象,比起每次需要时创建一个新的功能等效对象,要更加合适。 重复使用可以更快,更优雅。如果一个对象是不可变的,那么它总是可以被重用。
以下的这个例子就是不合适的。
String s = new String("bikini"); // DON'T DO THIS!
优化:
String s = "bikini";
这保证了在同一个虚拟机中,只有一个相同内容的 String 实例。
通过使用静态工厂方法,可以避免创建不需要的对象。例如,工厂方法Boolean.valueOf(String)比构造方法Boolean(String)更可取,后者在Java 9中被弃用。构造方法每次调用时都必须创建一个新对象,而工厂方法永远不需要这样做,在实践中也不需要。除了重用不可变对象,如果知道它们不会被修改,还可以重用可变对象。
一些对象的创建会比其他的昂贵的多。如果你需要重复使用这些创建昂贵的对象,把它缓存并复用它,将是个明智的选择。不幸的是,创建这种昂贵对象的动作,并不是总是明显可见的。
比如你想写一个正则来判断一个String是否为罗马数字。
// Performance can be greatly improved!
static boolean isRomanNumeral(String s) {
return s.matches("^(?=.)M*(C[MD]|D?C{0,3})"
+ "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}
上面的这个实现,主要问题在于它依赖于 String.matches 方法。虽然 String.matches 方法是对一个 string 进行正则匹配的最简单的方式,但是它不适合在高性能要求的情况下重复使用。问题是它在内部为正则表达式创建一个Pattern实例,并且只使用它一次,之后它就有资格进行垃圾收集。而创建Pattern实例是昂贵的,因为它需要将正则表达式编译成有限状态机(finite state machine)。
为了改善性能,可以将正则表达式显式编译为一个不可变的Pattern实例,作为类初始化的一部分,来缓存它,并在isRomanNumeral方法的每个调用中重复使用相同的实例:
// Reusing expensive object for improved performance
public class RomanNumerals {
private static final Pattern ROMAN = Pattern.compile(
"^(?=.)M*(C[MD]|D?C{0,3})"
+ "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
static boolean isRomanNumeral(String s) {
return ROMAN.matcher(s).matches();
}
}
6:回收过期对象
如果你是从 C++ 之类的语言过渡到 Java 来的,你一定会觉得编程简单了许多,因为 Java 自带垃圾回收机制。这个过程看起来有些很神奇,而且很容易给你造成一个错觉,那就是不需要再关心内存的使用情况了。
// Can you spot the "memory leak"?
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}
/**
* Ensure space for at least one more element, roughly
* doubling the capacity each time the array needs to grow.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
上面这个程序,乍看没有问题,但是当你仔细观察,就会发现,它有一个潜在的问题,那就是 pop 方法,stack pop 出一个对象后,elements 仍然持有该对象的引用。也就是说这些pop的对象不会被垃圾回收,因为stack维护了对这些对象的过期引用(obsolete references)。
垃圾收集语言中的内存泄漏(更适当地称为无意的对象保留 unintentional object retentions)是隐蔽的。如果无意中保留了对象引用,那么不仅这个对象排除在垃圾回收之外,而且该对象引用的任何对象也是如此。即使只有少数对象引用被无意地保留下来,也可以阻止垃圾回收机制对许多对象的回收,这对性能产生很大的影响。
优化:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
7:不要使用 finalizers 和 cleaners
Finalizers 不可预见它的行为,经常也是危险的,同时也是没必要的。它们的使用会导致不稳定的行为、低下的性能和移植性的问题。虽然它有一些合适的场景,但是总而言之,还是应该避免使用它。
8: 不要使用try-finally
try-with-resources代替try-finally
Java类库中包含许多必须通过调用close方法手动关闭的资源。 比如InputStream,OutputStream和java.sql.Connection。 客户经常忽视关闭资源,其性能结果可想而知。 尽管这些资源中有很多使用finalizer机制作为安全网,但finalizer机制却不能很好地工作。
// try-finally - No longer the best way to close resources!
static String firstLineOfFile(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
br.close();
}
}
// try-finally is ugly when used with more than one resource!
static void copy(String src, String dst) throws IOException {
InputStream in = new FileInputStream(src);
try {
OutputStream out = new FileOutputStream(dst);
try {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) >= 0)
out.write(buf, 0, n);
} finally {
out.close();
}
} finally {
in.close();
}
}
这可能很难相信,但即使是优秀的程序员,大多数时候也会犯错误。事实上,2007年Java类库中使用close方法的三分之二都是错误的。
即使是用try-finally语句关闭资源的正确代码,如前面两个代码示例所示,也有一个微妙的缺陷。 try-with-resources块和finally块中的代码都可以抛出异常。例如,在firstLineOfFile方法中,由于底层物理设备发生故障,对readLine方法的调用可能会引发异常,并且由于相同的原因,调用close方法可能会失败。 在这种情况下,第二个异常完全冲掉了第一个异常。在异常堆栈跟踪中没有第一个异常的记录,这可能使实际系统中的调试非常复杂——通常这是你想要诊断问题的第一个异常。 虽然可以编写代码来抑制第二个异常,但是实际上没有人这样做,因为它太冗长了。
当Java 7引入了try-with-resources语句时,所有这些问题一下子都得到了解决。要使用这个构造,资源必须实现 AutoCloseable接口,该接口由一个返回为void的close组成。Java类库和第三方类库中的许多类和接口现在都实现或继承了AutoCloseable接口。如果你编写的类表示必须关闭的资源,那么这个类也应该实现AutoCloseable接口。
以下是我们的第一个使用try-with-resources的示例:
// try-with-resources - the the best way to close resources!
static String firstLineOfFile(String path) throws IOException {
try (BufferedReader br = new BufferedReader(
new FileReader(path))) {
return br.readLine();
}
}
// try-with-resources on multiple resources - short and sweet
static void copy(String src, String dst) throws IOException {
try (InputStream in = new FileInputStream(src);
OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) >= 0)
out.write(buf, 0, n);
}
}
不仅 try-with-resources版本比原始版本更精简,更好的可读性,而且它们提供了更好的诊断。
考虑firstLineOfFile方法。 如果调用readLine和close方法(不可见)都抛出异常,则后一个异常将被抑制(suppressed),而不是前者。事实上,为了保留你真正想看到的异常,可能会抑制多个异常。 这些抑制的异常没有被抛弃,而是打印在堆栈跟踪中,并标注为被抑制了。 你也可以使用getSuppressed方法以编程方式访问它们,该方法在Java 7中已添加到的Throwable中。
可以在 try-with-resources语句中添加catch子句,就像在常规的try-finally语句中一样。这允许你处理异常,而不会在另一层嵌套中污染代码。作为一个稍微有些做作的例子,这里有一个版本的firstLineOfFile方法,它不会抛出异常,但是如果它不能打开或读取文件,则返回默认值:
// try-with-resources with a catch clause
static String firstLineOfFile(String path, String defaultVal) {
try (BufferedReader br = new BufferedReader(
new FileReader(path))) {
return br.readLine();
} catch (IOException e) {
return defaultVal;
}
}
结论明确:在处理必须关闭的资源时,使用try-with-resources语句替代try-finally语句。 生成的代码更简洁,更清晰,并且生成的异常更有用。try-with-resources语句在编写必须关闭资源的代码时会更容易,也不会出错,而使用try-finally语句实际上是不可能的。
未完待续。。。