《Effective Java》笔记45-56:通用程序设计

第45条:将局部变量的作用域最小化

将局部变量的作用域最小化,可以增强代码的可读性和可维护性,并降低出错的可能性。

要使用局部变量的作用域最小化,最有力的方法就是在第一次使用它的地方才声明,不要过早的声明。

局部变量的作用域从它被声明的点开始扩展,一直到外围块的结束外。如果变量是在“使用它的块”之外被声明有,当程序退出该块之后,该变量仍是可见的,如果它在目标使用区之前或之后意外使用,将可能引发意外错误。

几乎每个局部变量的声明都应该包含一个初始化表达式,如果你还没有足够信息来对象一个变量进行有意义的初始化,就应该推迟这个声明,直到可初始化为止。

循环中提供了特殊的机会来将变量的作用域最小化。如果在循环终止之后不再需要使用循环变量的内容,for 循环就优先于 while 循环。for 循环也增强了可读性。

Iterator<Element> i = c.iterator();
while (i.hasNext()) {
    doSomething(i.next());
}

for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {
    doSomething(i.next());
}

最后一种“将局部变量的作用域最小化”的方法是使方法小而集中。如果两个操作合并在一个方法中,与其中的一个操作相关的局部变量就有可能出现在另一个操作的代码范围之内。为了防止这种情况发生,需将这两个操作分成两个方法。

第46条:for-each 循环优先于传统的 for 循环

传统的 for 循环

for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {
    Element e = i.next();
    ... // Do something with e
}

for (int i = 0; i < a.length; i++) {
    ... // Do something with a[i]
}

这些习惯用法比 while 循环更好,但是它们并不完美。迭代器和索引变量都很混乱——你只需要元素而已。此外,它们也代表了出错的机会。迭代器在每个循环中出现三次,索引变量出现四次,这使你有很多机会使用错误的变量。如果这样做,就不能保证编译器会发现到问题。

for-each 循环,完全隐藏迭代器或者索引变量,避免了混乱和出错的可能,下面这种模式同样适合于集合与数组:

for(Element e : elements){
    doSomething(e);
}

当涉及到嵌套迭代时,for-each 循环相对于传统 for 循环的优势甚至更大。下面是人们在进行嵌套迭代时经常犯的一个错误:

enum Suit { CLUB, DIAMOND, HEART, SPADE }
enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT,
            NINE, TEN, JACK, QUEEN, KING }
...
static Collection<Suit> suits = Arrays.asList(Suit.values());
static Collection<Rank> ranks = Arrays.asList(Rank.values());

List<Card> deck = new ArrayList<>();
for (Iterator<Suit> i = suits.iterator(); i.hasNext(); )
    for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); )
        // next 方法,多次被调用,在 suit 用完之后,循环抛出 NoSuchElementException 异常
        deck.add(new Card(i.next(), j.next()));

// 要修复例子中的错误,必须在外部循环的作用域内添加一个变量来保存外部元素:
for (Iterator<Suit> i = suits.iterator(); i.hasNext(); ) {
    Suit suit = i.next();
    for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); )
        deck.add(new Card(suit, j.next()));
}

// 如果使用嵌套 for-each 循环,问题就会消失。生成的代码也尽可能地简洁:
for (Suit suit : suits)
    for (Rank rank : ranks)
        deck.add(new Card(suit, rank));

三种常见的情况不能使用 for-each 循环

  • 有损过滤:如果需要遍历集合,并删除指定选元素,则需要使用显式迭代器,以便可以调用其 remove 方法。通常可以使用在 Java 8 中添加的 Collection 类中的 removeIf 方法,来避免显式遍历。
  • 转换:如果需要遍历一个列表或数组并替换其元素的部分或全部值(不可赋值,可以对元素进行操作 setter),那么需要列表迭代器或数组索引来替换元素的值。
  • 并行迭代:如果需要并行地遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变量都可以同步进行 (正如上面错误的示例中无意中演示的那样)。

第47条:了解和使用类库

通过使用标准类库,可以充分利用这些编写标准类库的专家的知识,以及在你之前的其他人的使用经验。

使用标准类库中的第二个好处是,不必关心底层细节上,把时间应花在应用程序上。

使用标准类库中的第三个好处是,它们的性能往往会随着时间的推移而不断提高,无需你做任何努力。

总之,不要重新发明轮子,已存在的我们就直接使用,只有不能满足我们需求时,才需自己开发,总的来说,多了解类库是有好处的,特别是为库中的工具包。

第48条:如果需要精确的答案,请避免使用 float 和 double

Java大数操作(BigInteger,BigDecimal)

float 和 double 类型主要是用来为科学计算和工程计算而设计的。它们执行二进制浮点运算,这是为了在广泛的数值满园上提供较为精确的快速近似计算而精心设计的,然而,它们并没有提供完全精确的结果,所以不应该被用于需要精确结果的场合。float 和 double 类型尤其不适合用于货币的计算,因为要让一个 float 和 double 精确地表示0.1(或者 10 的任何其他负数次方值)是不可能的。

System.out.println(1.0-.9);// 0.09999999999999998

请使用BigDecimal、int 或 long(int 与 long 以货币最小单位计算)进行货币计算。

使用 BigDecimal 时还请使用 BigDecimal(String),而不要使用 BigDecimal(float 或 double),因为后者在传递的过程中会丢失精度:

new BigDecimal(0.1)// 0.1000000000000000055511151231257827021181583404541015625

new BigDecimal("0.1")//0.1

使用 BigDecimal 有两个缺点:与使用基本运算类型相比,这样做很不方便,而且很慢。

如果性能非常关键,请使用 int 和 long,如果数值范围没有超过 9 位十进制数字,就可以使用 int;如果不超过 18 位数字,就可以使用 long,如果数字可能超过 18 位数字,就必须使用 BigDecimal。

第49条:基本类型优先于装箱基本类型

主要区别:

  1. 基本类型只有值,在进行比较时可以直接基于值进行比较,而装箱类型在进行同一性比较时和基本类型相比有着不同的逻辑,毕竟他们是对象,是 Object 的子类,它们需要遵守 Java 中类对象比较的默认规则。
  2. 基本类型只有功能完备的值,而每个装箱类型除了它对应基本类型的所有功能之外,还有一个非功能值:null。记住,它毕竟是对象。
  3. 基本类型通常比装箱类型更节省时间和空间。
public class MyTest {
    private static int compare(Integer first,Integer second) {
        return first < second ? -1 : (first == second ? 0 : 1);
    }
    public static void main(String[] args) {
        Integer first = new Integer(42);
        Integer second = new Integer(42);
        System.out.println("Result of compare first and second is " + compare(first,second));
    }
}
// Result of compare first and second is 1

这段代码看起来非常简单,它的运行结果也非常容易得出,然而当我们真正运行它的时候却发现,实际输出的结果和我们的期望是完全不同的,这是为什么呢?见如下分析:

  1. compare 方法中的第一次比较(first < second,自动拆箱)将能够正常工作并得到正确的结果,即 first < second 为 false;
  2. 在进行相等性比较的时候问题出现了,如前所述,Integer 毕竟是对象,在进行对象之间的同一性比较时它将遵守对象的同一性比较规则,由于这两个参数对象的地址是不同的,因为我们是通过两次不同的 new 方法构建出的这两个参数对象。结果可想而知,first == second 返回 false;
  3. 现在最后的输出结果已经很清楚了:Result of compare first and second is 1

对于装箱基本类型运用 == 操作符几乎总是错的。

// 修正问题
private static int compare(Integer first,Integer second) {
    int f = first;
    int s = second;
    return f < s ? -1 : (f == s ? 0 : 1);
}
// 错误示例1:
public class Unbelievable {
    static Integer i;
    public static void main(String[] args) {
        if (i == 42)  // NullPointerException,需声明 i 为 int
            System.out.println("Unbelievable");
    }
}

// 错误示例2:
public static void main(String[] args) {
    Long sum = 0L;
    for (long i = 0; i < Integer.MAX_VALUE; ++i) {
        sum += i;
    }
    System.out.println(sum);
}
// 没有错误,但反复的装箱拆箱导致性能下降

在以下三种情况下我们将需要使用装箱基本类型:

  1. 作为集合中的元素、建和值。
  2. 泛型
  3. 在使用反射进行方法调用时。

总之,当可以选择的时候,基本类型要优先于包装类型,基本类型更加简单,也更加快速。自动装箱减少了使用包装基本类型的繁琐,但是并没有减少它的风险。另外,对于装箱基本类型运用 == 操作符几乎总是错的。

第50条:如果其他类型更适合,则尽量避免使用字符串

字符串不适合代替其他的值类型。数组经过文件、网络,或键盘输出设置进入到程序中之后,它通常是以字符形式存在,但我们应该尽量将他们转换为确切的类型。

如果可以使用更加合适的数据类型,或者可以编写更加适当的数据类型,就应该避免用字符串来表示对象。若使用不当,字符串会比其他类型更加笨拙、更不灵活、速度慢,也更容易出错。经常被错误地用字符串来代替的类型包括基本类型、枚举类型和聚集类型。

第51条:当心字符串连接的性能

循环中不要使用字符串连接操作符 + 来合并多个字符串,除非性能无关紧要。相反,应该使用 StringBuilder 的 append 方法。

第52条:通过接口引用对象

一般来讲,在函数参数、返回值、域变量等声明中,应该尽量使用接口而不是类作为它们的类型。只有当你利用构造器创建某个对象的时候,才真正需要引用这个对象的类,如:

List<Subscriber> subscribers = new Vector<Subscriber>();
// 而不是像下面这样的声明:
Vector<Subscriber> subscribers = new Vector<Subscriber>();

如果你养成了用接口作为类型的习惯,你的程序将更加灵活。对于上面的例子,在今后的改进中,如果不想使用 Vector 作为实例化对象,我们只需在如下一出进行修改即可:

List<Subscriber> subscribers = new ArrayList<Subscriber>();

如果之前该变量的类型不是接口类型,而是它实际类型的本身,那么在做如此修改之前,则需要确认在所有使用该变量的代码行是否用到了 Vector 的特性(同步),从而导致不行直接进行替换。如果该变量的接口为接口,我们将不受此问题的限制。

那么在哪些情况下不是使用接口而是使用实际类呢?见如下情况:

  1. 没有合适的接口存在,如 String 和 BigInteger 等值对象,通常它们都是 final 的,也没有提供任何接口。
  2. 对象属于一个框架,而框架的基本类型是类,不是接口。如果对象属于这种基于类的框架,就应使用基类来引用该对象,如 TimerTask。
  3. 类实现了接口,但是它提供了接口中不存在的额外方法。如果程序此时依赖于这些额外的方法,这种类就应该只被用来引用他的实例。例如依赖 Vector 的同步(只是假设)。

简而言之,如果类实现了接口,就应该尽量使用其接口引用该类的引用对象,这样可以使程序更加灵活,如果不是,则使用类层次结构中提供了必要功能的最基础的类。

第53条:接口优先于反射机制

Java 中提供了反射的机制,如给定一个 Class 实例,你可以获取 Constructor、Method 和 Field 等实例,分别代表了该 Class 实例所表示的类的 Constructor(构造器)、Method(方法) 和 Field(域)。与此同时,这些实例可以使你通过反射机制操作它们的底层对等体。然后这种灵活是需要付出一定代价的,如下:

  1. 丧失了编译时类型检查的好处,包括异常检查和类型检查等。
  2. 执行反射访问所需要的代码往往非常笨拙和冗长,阅读起来也非常困难,通常而言,一个基于普通方式的函数调用大约 1,2 行,而基于反射方式,则可能需要十几行。
  3. 性能损失,反射方法的调用比普通方法调用慢了许多。

核心反射机制最初是为了基于组件的应用创建工具而设计的。它们通常需要动态装载类,并且用反射功能找出它们支持哪些方法和构造器,如类浏览器、对象监视器、代码分析工具、解释性的嵌入式系统等。

在通常情况下,如果只是以非常有限的形式使用反射机制,虽然也要付出少许代价,但是可以获得许多好处。对于有些程序,它们必须用到编译时无法获取的类,但是在编译时却存在适当的接口或超类,通过它们可以引用这个类。如果是这样,可以先通过反射创建实例,然后再通过它们的接口或超类,以正常的方式访问这些实例。见如下代码片段:

public static void main(String[] args) {
    Class<?> cl = null;
    try {
        c1 = Class.forName(args[0]);
    } catch (ClassNotFoundException e) {
        System.err.println("Class not found.");
        System.exit(1);
    }
    Set<String> s = null;
    try {
        s = (Set<String>)c1.newInstance();
    } catch (IllegalAccessException e) {
        System.err.println("Class not accessible");
        System.exit(1);
    } catch (InstantiationException e) {
        System.err.println("Class not instantiation.");
        System.exit(1);
    }
    s.addAll(Arrays.asList(args).subList(1,args.length));
    System.out.println(s);
}

上面的代码中体现出了反射的两个缺点:

  1. 这个例子有 3 个运行时异常的错误,如果不使用反射方式实例化,这 3 个错误都会成为编译时错误。
  2. 根据类名生成它的实例需要 20 行冗长的代码,而调用构造器可以非常简洁的只使用一行代码。

简而言之,反射机制是一种功能强大的机制,对于特定的复杂系统编程任务,它是非常必要的。如果你编写的程序必须要与编译时未知的类一起工作,如有可能,就应该仅仅使用反射机制来实例化对象,而访问对象时则使用编译时已知的某个接口或者超类。

第54条:谨慎地使用本地方法

Java Native Interface(JNI)允许 Java 应用程序可以调用本地方法,所谓本地方法是指用本地程序设计的语言(如 C 或者 C++)来编写的特殊的方法。它可以在本地语言中执行任意的计算任务后返回到 Java 语言。

本地方法主要有三种用途。它们提供了“访问特定于平台的机制”的能力,比如访问注册表和文件锁。它们还提供了访问遗留代码库的能力,从而可以访问遗留数据。最后,本地方法可以通过本地语言,编写应用程序中注重性能的部分,以提高系统的性能。

使用本地方法来访问特定于平台的机制与访问遗留代码是合法的。但使用本地方法来提高性能的做法不值得提倡,因为 VM 在逐渐的更优更快了,如 1.1 中 BigInteger 是在一个用 C 编写的快速多精度运行库的基础上实现的,但在 1.3 中,则完全用 Java 重写了,并进行了精心的性能调优,比原来的版本甚至更快一些。

使用本地方法有一些严重的缺点。因为本地语言不是安全的、不可移植、难调试,而且在进行本地代码时,需要相关的固定开销,所以如果本地代码只是做少量的工作,本地方法就可能降低性能。

总之,本地方法极少数情况下会需要使用本地方法来提高性能。如果你必须要使用本地方法访问底层的资源,或者遗留代码,也要尽可能少的使用本地代码。

第55条:谨慎地进行优化

有三条与优化有关的格言是每个人都应该知道的:

  1. 很多计算上的过失都被归咎于效率(没有必要达到的效率),而不是任何其他原因——甚至包括盲目地做傻事。
  2. 不要去计较效率上的一些小小的得失,在97%的情况下,不成熟的优化才是一切问题的根源。
  3. 在优化方面,我们应该两条规则:规则1:不要进行优化。规则2(仅针对专家):还是不要进行优化——也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。

所有这些格言都比Java程序设计语言的出现早了20年,它们讲述了一个关于优化的深刻真理:优化的弊小于利,特别是不成熟的优化。在优化过程中,产生软件可能既不快速,也不正确,而且还不容易修正。

总之,不要费力去编写快速的程序——应该努力编写好的程序,速度自然会随之而来。在设计系统的时候,特别是在设计 API、线路层协议和永久数据库格式的时候(模块之间的交互与模块与外界的交互一旦定下来后是不可能更改的),一定要考虑性能的因素。当构建完系统之后,要测量它的性能。如果它足够快,你的任务就完了。如果不够快,则可以在性能剖析器的帮助下,找到问题的根源,然后设法优化系统中相关的部分。第一个步骤是检查所选择的算法:再多的低层优化也无法弥补算法的选择不当。

第56条:遵守普遍接受的命名惯例


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 bin07280@qq.com

文章标题:《Effective Java》笔记45-56:通用程序设计

文章字数:4.9k

本文作者:Bin

发布时间:2019-06-19, 21:15:22

最后更新:2019-08-06, 00:43:27

原始链接:http://coolview.github.io/2019/06/19/Effective-Java/%E3%80%8AEffective%20Java%E3%80%8B%E7%AC%94%E8%AE%B045-56%EF%BC%9A%E9%80%9A%E7%94%A8%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录