带你快速看完9.8分神作《Effective Java》—— 方法篇

Posted 小王曾是少年

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了带你快速看完9.8分神作《Effective Java》—— 方法篇相关的知识,希望对你有一定的参考价值。

🍊 Java学习:Java从入门到精通总结

🍊 Spring系列推荐:Spring源码解析

📆 最近更新:2021年12月16日

🍊 个人简介:通信工程本硕💪、阿里新晋猿同学🌕。我的故事充满机遇、挑战与翻盘,欢迎关注作者来共饮一杯鸡汤

🍊 点赞 👍 收藏 ⭐留言 📝 都是我最大的动力!

豆瓣评分9.8的图书《Effective Java》,是当今世界顶尖高手Josh Bloch的著作,在我之前的文章里我也提到过,编程就像练武,既需要外在的武功招式(编程语言、工具、中间件等等),也需要修炼心法(设计模式、源码等等)学霸、学神OR开挂

我也始终有一个观点:看视频跟着敲代码永远只是入门,从书籍里学到了多少东西才决定了你的上限。

我个人在Java领域也已经学习了近5年,在修炼“内功”的方面也通过各种途径接触到了一些编程规约,例如阿里巴巴的泰山版规约,在此基础下读这本书的时候仍是让我受到了很大的冲激,学习到了很多约定背后的细节问题,还有一些让我欣赏此书的点是,书中对于编程规约的解释让我感到十分受用,并愿意将他们应用在我的工作中,也提醒了我要把阅读JDK源码的任务提上日程。

最后想分享一下我个人目前的看法,内功修炼不像学习一个新的工具那么简单,其主旨在于踏实,深入探索底层原理的过程很缓慢并且是艰辛的,但一旦开悟,修为一定会突破瓶颈,达到更高的境界,这远远不是我通过一两篇博客就能学到的东西。

接下来就针对此书列举一下我的收获与思考。

不过还是要吐槽一下的是翻译版属实让人一言难尽,有些地方会有误导的效果,你比如java语言里extends是继承的关键字,书本中全部翻译成了扩展 就完全不是原来的意思了。所以建议有问题的地方对照英文原版进行语义上的理解。

没有时间读原作的同学可以参考我这篇文章。


49 检查参数的有效性

当编写方法或构造方法时,都应该考虑其参数应该有哪些限制。应该把这些限制写到文档里,并在方法体的开头显式检查


大多数方法和构造方法对于传递给他们的参数有一些限制。例如,索引值必须是非负数,对象引用必须为非null。我们应该在文档里清楚地指明这些限制,并且在方法的最开始进行检查。

如果没有验证参数的有效性,可能会导致违背失败原子性

  1. 该方法可能在处理过程中失败,该方法可能会出现费解的异常
  2. 该方法可以正常返回,会默默地计算出错误的结果
  3. 该方法可以正常返回,但是使得某个对象处于受损状态,在将来某个时间点会报错

对于publicprotected方法,要用Java文档的@throws注解来说明会抛出哪些异常,通常为:IllegalArgumentExceptionIndexOutOfBoundsExceptionNullPointerException,例如:

/**
 * Returns a BigInteger whose value is (this mod m). This method
 * differs from the remainder method in that it always returns a
 * non-negative BigInteger.
 *
 * @param m the modulus, which must be positive
 * @return this mod m
 * @throws ArithmeticException if m is less than or equal to 0
*/
public BigInteger mod(BigInteger m) 
	if (m.signum() <= 0)
		throw new ArithmeticException("Modulus <= 0: " + m);
	
	... // Do the computation


在Java 7中添加的 Objects.requireNonNull 方法灵活方便,因此没有理由再手动执行null检查。该方法返回其输入的值,因此可以在使用值的同时执行null检查:

this.strategy = Objects.requireNonNull(strategy, "strategy");

对于不是public的方法,通常应该使用断言来检查参数:

private static void sort(long a[], int offset, int length) 
	assert a != null;
	assert offset >= 0 && offset <= a.length;
	assert length >= 0 && length <= a.length - offset;
	... // Do the computation

不同于一般的有效性检查,如果它们没有起到作用,本质上也没有成本开销。


在某些场景下,有效性检查的成本很高,且在计算过程里也已经完成了有效性检查,例如对象列表排序的方法Collections.sort(List)

如果List里的对象不能互相比较,就会抛ClassCastException异常,这正是sort方法该做的事情,所以提前检查列表中的元素是否可以互相比较并没有很大意义。


有些计算会隐式执行必要的有效性检查,如果检查失败则会抛异常,这个异常可能和文档里标明的不同,此时就应该使用异常转换将其转换成正确的异常。


50 必要时进行保护性拷贝

Java是一门安全的语言,它对于缓存区溢出、数组越界、非法指针以及其他内存损坏错误都自动免疫。


但仅管如此,我们也必须保护性地编写程序,因为代码随时可能会遭受攻击

如果没有对象的帮助,另一个类是不可能修改对象的内部状态的,但对象可能会在无意的情况下提供这样的帮助。例如,下面的代码表示一个不可变的时间周期:

public final class Period 
	private final Date start;
	private final Date end;
	
	/**
	* @param start the beginning of the period
	* @param end the end of the period; must not precede start
	* @throws IllegalArgumentException if start is after end
	* @throws NullPointerException if start or end is null
	*/
	public Period(Date start, Date end) 
		if (start.compareTo(end) > 0)
			throw new IllegalArgumentException(start + " after " + end);
		
		this.start = start;
		this.end = end;
	
	
	public Date start() 
		return start;
	
	
	public Date end() 
		return end;
	
	... // Remainder omitted

上面代码虽然强制令period 实例的开始时间小于结束时间。然而,Date 类是可变的,很容易违反这个约束:

Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // Modifies internals of p!

从Java 8 开始,解决此问题的显而易⻅的方法是使用 Instant(或LocalDateTimeZonedDateTime)代替Date,因为他们是不可变的。但Date在老代码里仍有使用的地方,为了保护 Period 实例的内部不受这种攻击,可以使用拷⻉来做 Period 实例的组件:

public Period(Date start, Date end) 
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());

    if (this.start.compareTo(this.end) > 0)
        throw new IllegalArgumentException(this.start + " after " + this.end);

有了新的构造方法后,前面的攻击将不会对Period 实例产生影响。注意:保护性拷⻉是在检查参数的有效性之前进行的,且有效性检查是在拷贝实例上进行的

这样做可以避免从检查参数开始到拷贝参数之间的时间段内,其他的线程改变类的参数

也被称作 Time-Of-Check / Time-Of-Use 或 TOCTOU攻击


看了之前章节的同学可能有疑问了,这里为什么没用clone方法来进行保护性拷贝?

答案是:Date不是final的,所以clone方法不能保证返回类确实是 java.util.Date 的对象,也可能返回一个恶意的子类实例。


但是普通方法就不一样了,它们在进行保护性拷贝是允许使用clone方法,原因是我们知道Period内部的Date对象类型确实是java.util.Date

对于参数类型可能被恶意子类化的参数,不要使用 clone 方法进行防御性拷⻉


其实,改变Period实例仍是有可能的:

Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
p.end().setYear(78); // Modifies internals of p!

修改方法也很简单:

public Date start() 
	return new Date(start.getTime());


public Date end() 
	return new Date(end.getTime());


上面的分析带来的启发是:应该尽量使用不可变对象作为对象内部的组件,这样就不必担心保护性拷⻉。在 Period 示例中,使用Instant(或LocalDateTimeZonedDateTime)。另一个选项是存储Date.getTime() 返回的long类型来代替Date引用。


最后,如果拷贝成本较大的话,并且我们新人使用它的客户端不会恶意修改组件,则可以在文档中指明客户端不得修改受到影响的组件,以此来代替保护性拷贝。


51 谨慎设计方法

这一条介绍了若干经验:

1. 谨慎给方法起名

  • 方法名应该选易于理解的,并且与同一个包里其他名称的风格一致
  • 选择大众认可的名称

2. 不要过于追求提供便利的方法
方法太多会使类难以学习、使用、文档化、维护。只有当一项操作被经常用到时,才考虑为它提供快捷方式(shorthand)

3. 避免过长的参数列表,相同类型的长参数序列格外有害

参数个数不超过4个

有三种技巧可以缩短过长的参数列表:

  1. 把一个方法分解成多个方法,每个方法只需要这些参数的一个子集。例如:java.util.List接口里没有提供在子列表中查找元素的第一个索引和最后一个索引的方法。相反,它提供了 subList 方法,返回子列表。此方法可以与 indexOflastIndexOf 方法结合使用来达到所需的功能。

  2. 创建辅助类用来保存参数的分组。例如:编写一个表示纸牌游戏的类,发现需要两个参数来表示纸牌的点数和花色,这时就可以创建一个类来表示卡片。

  3. 从对象构建到方法调用全都采用Builder模式


4. 优先使用接口作为入参类型
只要有适当的接口可用来定义参数,就优先使用这个接口,而不是使用实现该接口的类。例如:在编写方法时使用Map接口作为参数


5. 对于boolean型参数,优先使用有两个元素的枚举
例如,有一个 Thermometer 类型的静态工厂方法,这个方法的签名需要以下这个枚举的值:

public enum TemperatureScale  FAHRENHEIT, CELSIUS 

Thermometer.newInstance(TemperatureScale.CELSIUS) 不仅比Thermometer.newInstance(true) 更有意义,而且可以在将来的版本中将新的枚举值添加到 TemperatureScale 中,而无需向 Thermometer 添加新的静态工厂。


52 慎用重载

下面这个程序试图将一个集合进行分类:

public class CollectionClassifier 

    public static String classify(Set<?> s) 
        return "Set";
    

    public static String classify(List<?> lst) 
        return "List";
    

    public static String classify(Collection<?> c) 
        return "Unknown Collection";
    

    public static void main(String[] args) 
        Collection<?>[] collections = 
                new HashSet<String>(),
                new ArrayList<BigInteger>(),
                new HashMap<String, String>().values()
        ;

        for (Collection<?> c : collections)
            System.out.println(classify(c));
    

运行结果是打印了三次Unknown Collection为什么会这样呢?

原因就是classify方法被重载了,要调用哪个重载方法是在编译时做出决定的。for循环里参数的编译时类型一直是Collection<?>,所以唯一适合的重载方法是classify(Collection<?> c)


有一个很有意思的事实:重载(overloaded)方法的选择是静态的,重写(overridden)方法的选择是动态的

重写方法的选择是在运行时进行的,依据是被调用的方法所在的对象的运行时类型。


以下面这个例子具体说明:

class Wine 
    String name() 
        return "wine";
    


class SparklingWine extends Wine 
    @Override
    String name() 
        return "sparkling wine";
    


class Champagne extends SparklingWine 
    @Override
    String name() 
        return "champagne";
    


public class Overriding 
    public static void main(String[] args) 
        List<Wine> wineList = Arrays.asList(
                new Wine(), new SparklingWine(), new Champagne());

        for (Wine wine : wineList)
            System.out.println(wine.name());
    

这段代码打印出wine,sparkling wine和champagne,尽管在每次迭代里,实例的编译类型都是Wine,但总是会执行最具体(most specific)的重写方法,也就是在子类上调用的就执行被子类覆盖的方法。


CollectionClassifier示例中,程序的目的是根据参数的运行时类型自动执行适当的方法重载来辨别参数的类型。但方法重载完全没有提供这样的功能,这段代码最佳修改方案是:用单个方法来替换这三个重载的classify方法,代码逻辑里用instanceof判断:

public static String classify(Collection<?> c) 
	return c instanceof Set ? "Set" : c instanceof List ? "List" : "Unknown Collection";


如果API的普通用户根本不知道哪个重载会被调用,使用这样的API就会报错。所以,应该避免混淆使用重载

安全保守的策略是:一个安全和保守的策略是永远不要编写两个具有相同参数数量的重载

因为我们始终可以给方法起不同的名字,避免使用重载


例如,考虑ObjectOutputStream类。对于每个类型,它的write方法都有一种变体,例如writeBoolean(boolean)writeInt(int)writeLong(long)。这种命名模式的另一个好处是,可以为read方法提供相应的名称,例如readBoolean()readInt()readLong()


一个类的多个构造器总是重载的,可以选择导出静态工厂。


对于每一对重载方法,至少要有一个形参在这两个重载中具有「完全不同的」类型。这时主要的混淆根源就没有了。例如ArrayList有接受int的构造方法和接受Collection的构造方法。


Java有一个自动装箱的概念,他们的出现也引入了一些麻烦:

public class SetList 
    public static void main(String[] args) 
        Set<Integer> set = new TreeSet<>();
        List<Integer> list = new ArrayList<>();

        for (int i = -3; i < 3; i++) 
            set.add(i);
            list.add(i);
        

        for (int i = 0; i < 3; i++) 
            set.remove(i);
            list.remove(i);
        

        System.out.println(set + " " + list);
    

实际上,程序从Set中删除非负值,从List中删除奇数值,并打印 [-3, -2, -1] 和 [-2, 0, 2]。


  • set.remove(i)选择重载了remove(E)方法,执行结果正确
  • list.remove(i)的调用选择重载remove(int i)方法,它将删除列表中指定位置的元素,所以最终打印 [-2, 0, 2]

有两种手段可以解决这个问题:

  1. 强制转换list.remove的参数为Integer
  2. 调用Integer.valueOf(i),将结果传递list.remove方法
for (int i = 0; i < 3; i++) 
	set.remove(i);
	list.remove((Integer) i); // or remove(Integer.valueOf(i))


在Java 8中添加Lambda表达式和方法引用以后,进一步增加了重载混淆的可能性。

new Thread(System.out::println).start();

ExecutorService exec = Executors.newCachedThreadPool();
exec.submit(System.out::println);

Thread 构造方法调用和submit方法调用看起来很相似,但是前者编译而后者不编译。参数是相同的(System.out::println)。因为sumbit方法有一个带有Callable <T>参数的重载,而Thread构造方法却没有。在submit这里不知道应该调用哪个方法。


在更新现有类时,可能会违反这一条目中的指导原则。例如,从Java 4开始就有一个contentEquals(StringBuffer)方法。在Java 5中,添加了contentEquals(CharSequence)接口。但只要这两个方法返回相同的结果就可以,例如下面的代码:

public boolean contentEquals(StringBuffer sb) 
	return contentEquals((CharSequence) sb);

原因是这两个重载互相调用。


Java类库在很大程度上遵循了这一条中的建议,但是有一些类违反了它。例如,String导出两个重载的静态工厂方法valueOf(char[])valueOf(Object),这应该被看成是一种反常行为。


53 慎用可变参数

可变参数方法接受0个或多个指定类型的参数,首先创建一个数组,其大小是在调用位置传递的参数数量,然后将参数值放入数组中,最后将数组传递给方法。


例如,这里有一个可变参数方法,返回入参的总和:

static int sum(int... args) 
int sum = 0;
for (int arg : args)
	sum += arg;
return sum;

有时,编写一个需要某种类型的一个或多个参数的方法是合适的,而不是0个或者多个。可以在运行时检查数组⻓
度:

static int min(int... args) 
	if (args.length == 0)
		throw new IllegalArgumentException("Too few arguments");
	int min = args[0];
	for (int i = 1; i < args.length; i++)
		if (args[i] < min)
			min = args[i];
	return min;

最严重的是,如果客户端在没有参数的情况下调用此方法,则它在运行时而不是在编译时失败。

有一种更好的方法可以达到预期的效果。声明方法采用两个参数,一个指定类型的普通参数,另一个此类型的可变参数。

static int min(int firstArg, int... remainingArgs) 
	int min = firstArg;
	for (int arg : remainingArgs)
		if (arg < min)
			min = arg;
	return min;

在性能关键的情况下使用可变参数时要小心。每次调用可变参数方法都会导致数组分配和初始化。

还有一种模式可以让你如愿以偿:

public void foo()  

public void foo(int a1)  

public void foo(int a1, int a2)  

public void foo(int a1, int a2, int a3)  

public void foo(int a1, int a2, int a3, int... rest)  

当参数数目超过3个时需要创建数组。

EnumSet类的静态工厂使用这种方法,将创建枚举集合的成本降到最低。


54 返回空的数组或集合,不要返回null

像如下的方法并不罕⻅:

private final List<Cheese> cheesesInStock = ...;

/**
* @return a list containing all of the cheeses in the shop,
* or null if no cheeses are available for purchase.
*/
public List<Cheese> getCheeses() 
	return cheesesInStock.isEmpty() ? null : new ArrayList<>(cheesesInStock);

把没有奶酪(Cheese)可买的情况当做一种特例,这是不合常理的。这样需要在客户端中必须有额外的代码来处理null的返回值:

List<Cheese> cheeses = shop.getCheeses();
if (cheeses != null && cheeses.contains(Cheese.STILTON))
	System.out.println("Jolly good, just the thing.");

这样做很容易出错,因为编写客户端的程序员可能忘记编写特殊情况代码来处理null返回。


下面是返回可能为空的集合的典型代码。一般情况下,这些都是必须的:

public List<Cheese> getCheeses() 
	return new ArrayList<>(cheesesInStock);

如果有证据表明分配空集合会损害性能,可以通过重复返回相同的不可变空集合来避免多次分配

// Optimization - avoids allocating empty collections
public List<Cheese> getCheeses() 
	return cheesesInStock.isEmpty() ? Collections.emptyList() : new ArrayList<>(cheesesInStock);

数组的情况与集合的情况相同。永远不要返回null,而是返回⻓度为零的数组。

// Optimization - avoids allocating empty arrays
private static final 带你快速看完9.8分神作《Effective Java》—— 序列化篇(所有RPC框架的基石)

带你快速看完9.8分神作《Effective Java》—— 并发篇(工作里的这些坑你都遇到过吗?)

带你快速浏览Xcode 9新特性

Django1.9.8 + Xadmin + Windows 快速搭建网站

50k大牛告诉你Python怎么学,10个特性带你快速了解python

Effective Java 第三版——20. 接口优于抽象类