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

简介: 49 检查参数的有效性50 必要时进行保护性拷贝51 谨慎设计方法52 慎用重载53 慎用可变参数54 返回空的数组或集合,不要返回null55 谨慎返回optional56 为所有已公开的API 元素编写文档注释

49 检查参数的有效性


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



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


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


该方法可能在处理过程中失败,该方法可能会出现费解的异常

该方法可以正常返回,会默默地计算出错误的结果

该方法可以正常返回,但是使得某个对象处于受损状态,在将来某个时间点会报错


对于public和protected方法,要用Java文档的@throws注解来说明会抛出哪些异常,通常为:IllegalArgumentException,IndexOutOfBoundsException 或 NullPointerException,例如:


/**
 * 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(或LocalDateTime 或 ZonedDateTime)代替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(或LocalDateTime或ZonedDateTime)。另一个选项是存储Date.getTime() 返回的long类型来代替Date引用。



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


51 谨慎设计方法


这一条介绍了若干经验:


1. 谨慎给方法起名


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

选择大众认可的名称


2. 不要过于追求提供便利的方法


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


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


参数个数不超过4个


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


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


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


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



4. 优先使用接口作为入参类型


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


5. 对于boolean型参数,优先使用有两个元素的枚举


例如,有一个 Thermometer 类型的静态工厂方法,这个方法的签名需要以下这个枚举的值:


public enum TemperatureScale { FAHRENHEIT, CELSIUS }

1

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]

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


强制转换list.remove的参数为Integer

调用Integer.valueOf(i),将结果传递list.remove方法


for (int i = 0; i < 3; i++) {
  set.remove(i);
  list.remove((Integer) i); // or remove(Integer.valueOf(i))
}


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类的静态工厂使用这种方法,将创建枚举集合的成本降到最低。

相关文章
|
13天前
|
Java 数据处理 数据安全/隐私保护
Java处理数据接口方法
Java处理数据接口方法
20 1
|
2月前
|
Java API
Java 对象释放与 finalize 方法
关于 Java 对象释放的疑惑解答,以及 finalize 方法的相关知识。
50 17
|
1月前
|
存储 Java 程序员
Java基础的灵魂——Object类方法详解(社招面试不踩坑)
本文介绍了Java中`Object`类的几个重要方法,包括`toString`、`equals`、`hashCode`、`finalize`、`clone`、`getClass`、`notify`和`wait`。这些方法是面试中的常考点,掌握它们有助于理解Java对象的行为和实现多线程编程。作者通过具体示例和应用场景,详细解析了每个方法的作用和重写技巧,帮助读者更好地应对面试和技术开发。
86 4
|
1月前
|
Java 测试技术 Maven
Java一分钟之-PowerMock:静态方法与私有方法测试
通过本文的详细介绍,您可以使用PowerMock轻松地测试Java代码中的静态方法和私有方法。PowerMock通过扩展Mockito,提供了强大的功能,帮助开发者在复杂的测试场景中保持高效和准确的单元测试。希望本文对您的Java单元测试有所帮助。
98 2
|
2月前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
23 3
|
2月前
|
Java 大数据 API
别死脑筋,赶紧学起来!Java之Steam() API 常用方法使用,让开发简单起来!
分享Java Stream API的常用方法,让开发更简单。涵盖filter、map、sorted等操作,提高代码效率与可读性。关注公众号,了解更多技术内容。
|
2月前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
20 2
|
2月前
|
安全 Java
Java多线程通信新解:本文通过生产者-消费者模型案例,深入解析wait()、notify()、notifyAll()方法的实用技巧
【10月更文挑战第20天】Java多线程通信新解:本文通过生产者-消费者模型案例,深入解析wait()、notify()、notifyAll()方法的实用技巧,包括避免在循环外调用wait()、优先使用notifyAll()、确保线程安全及处理InterruptedException等,帮助读者更好地掌握这些方法的应用。
22 1
|
2月前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
37 1
|
2月前
|
Java
在Java多线程编程中,`wait()`和`notify()`方法的相遇如同一场奇妙的邂逅
在Java多线程编程中,`wait()`和`notify()`方法的相遇如同一场奇妙的邂逅。它们用于线程间通信,使线程能够协作完成任务。通过这些方法,生产者和消费者线程可以高效地管理共享资源,确保程序的有序运行。正确使用这些方法需要遵循同步规则,避免虚假唤醒等问题。示例代码展示了如何在生产者-消费者模型中使用`wait()`和`notify()`。
31 1