带你快速看完9.8分神作《Effective Java》—— 通用编程篇

本文阅读 36 分钟
首页 代码,Java 正文

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

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

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

img

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

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

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

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

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

57 最小化局部变量的作用域

要使局部变量的作用域最小化,最好的方法是在首次使用的地方声明它。过早地声明局部变量可能导致其作用域不仅过早开始而且结束太晚。

每个局部变量声明都应该包含一个初始化表达式。这个规则的一个例外是try-catch语句。如果该值必须在try块之外使用,那么它必须在try块之前声明,此时它还不能被「合理地初始化」。

循环允许声明循环变量,将其作用域限制在需要它们的确切区域。如果循环终止后不需要循环变量的内容,那么优先选择for循环而不是while循环。

for (Element e : c) { 
    ... // Do Something with e
}

如果需要访问迭代器,也许是为了调用它的remove方法,首选的习惯用法,使用传统的for循环代替for-each循环:

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

要了解为什么这些for循环优于while循环,考虑以下代码片段:

Iterator<Element> i = c.iterator();
    while (i.hasNext()) { 
        doSomething(i.next());
    }
    ...
    Iterator<Element> i2 = c2.iterator();
    while (i.hasNext()) {  // BUG!
        doSomethingElse(i2.next());
}

第二个循环包含一个复制粘贴错误:它初始化一个新的循环变量i2,但是使用旧的变量i,不幸的是,它仍在作用域范围内。生成的代码编译时没有错误,并且在不抛出异常的情况下运行,但是它的逻辑已经错了。

如果将类似的复制粘贴错误与for循环(for-each循环或传统循环)结合使用,则生成的代码就无法编译。

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

// Compile-time error - cannot find symbol i
for (Iterator<Element> i2 = c2.iterator(); i.hasNext(); ) { 
    Element e2 = i2.next();
    ... // Do something with e2 and i2
}

for循环比while循环还有一个优点:它更短,增强了可读性。

下面是另一种对局部变量的作用域最小化的循环做法:

for (int i = 0, n = expensiveComputation(); i < n; i++) { 
    ... // Do something with i;
}

它有两个循环变量,i和n,它们都具有完全相同的作用域。第二个变量n用于存储第一个变量的限定值,从而避免了每次迭代中冗余计算的代价。

最后一种“最小化局部变量作用域”的最终技术是保持方法小而集中。如果把两个操作(activities)合并到同一个方法中,与其中一个操作相关的局部变量就有可能会出现在执行另一个操作的代码范围之内。为了防止这种情况发生,只需将方法分为两个:每个操作用一个方法完成。

58 for-each循环优先于传统的for循环

下面是一个传统的for循环来遍历一个集合:

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

下面是数组的传统for循环的实例:

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

它们并不完美。迭代器和索引变量都很混乱,好在for-each循环解决了所有这些问题。它通过隐藏迭代器或索引变量来消除 混乱和出错的机会:

for (Element e : elements) { 
    ... // Do something with e
}

此外,使用for-each循环也不会降低性能

当涉及到嵌套迭代时,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(); )
        deck.add(new Card(i.next(), j.next()));

问题是,对于外部集合(suit),i.next会被调用很多次

下面的代码本意是要打印一对骰子的所有可能的组合:

enum Face {  ONE, TWO, THREE, FOUR, FIVE, SIX }
...
Collection<Face> faces = EnumSet.allOf(Face.class);

for (Iterator<Face> i = faces.iterator(); i.hasNext(); )
    for (Iterator<Face> j = faces.iterator(); j.hasNext(); )
        System.out.println(i.next() + " " + j.next());

该程序不会抛出异常,但它只打印6个重复的组合(从“ONE ONE”到“SIX SIX”),而不是预期的36个组合。

要修复例子中的错误,必须在外部循环的作用域内添加一个变量来保存外部元素:

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方法,来避免显式遍历。

- 转换 如果需要遍历一个列表或数组并替换其元素的部分或全部值,那么需要迭代器或数组索引来替换元素的值。

- 并行迭代 如果需要并行地遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变量都可以同步进行

for-each循环还允许遍历实现Iterable接口的任何对象

public interface Iterable<E> { 
    // Returns an iterator over the elements in this iterable
    Iterator<E> iterator();
}

59 了解并使用类库

假设你想要生成0到某个上界之间的随机整数。许多程序员会编写一个类似这样的小方法:

static Random rnd = new Random();
static int random(int n) { 
    return Math.abs(rnd.nextInt()) % n;
}

这个方法有三个缺点: 1. 如果n是较小的2的平方数,随机数序列会在短的时间内重复

2. 如果n不是2的幂,平均而言,一些数字将比其他数字更高概率返回

public static void main(String[] args) { 
    int n = 2 * (Integer.MAX_VALUE / 3);
    int low = 0;
    for (int i = 0; i < 1000000; i++)
        if (random(n) < n/2)
            low++;
    System.out.println(low);
}

如果运行它,你将发现它输出一个接近666666的数字。随机方法生成的数字中有三分之二落在其范围的前半部分

3. 在极少数情况下会返回超出指定范围的数字,这是灾难性的结果

如果nextInt()返回Integer.MIN_VALUE、Math.abs也会因为越界而返回Integer.MIN_VALUE。假设n不是2的幂,那么求模运算符 (%) 将返回一个负数。

幸运的是,已经有现成的成果可以直接使用:Random.nextInt(int)

通过使用标准库,你可以利用编写它的专家的知识和以前使用它的人的经验。

从Java 7开始,就不应该再使用Random,而是用ThreadLocalRandom,原因有以下几点:

  1. 它能产生更高质量的随机数,而且速度非常快
  2. 不必浪费时间为那些与你的工作无关的问题编写专⻔的解决方案
  3. 随着时间的推移,它们的性能会不断提高
  4. 随着时间的推移,它们往往会获得新功能
  5. 可以让自己的代码融入主流。这样的代码更容易被开发人员阅读、维护和重用

在每个主要版本中,都会向库中添加许多特性,了解这些新增特性是值得的

每个程序员都应该熟悉java.lang、java.util和java.io的基础知识及其子包

如果你在Java平台库中找不到你需要的东西,你的下一个选择应该是寻找高质量的第三方库,比如谷歌的优秀的开源Guava库

60 若需要精确答案就应避免使用float 和double 类型

float和double类型特别不适合进行货币计算,因为不可能将0.1(或10的任意负次幂)精确地表示为float或double。

例如,假设你口袋里有1.03美元,你消费了42美分。你还剩下多少钱?

System.out.println(1.03 - 0.42);

你可能认为,只需在打印之前将结果四舍五入就可以解决这个问题,但不幸的是,这种方法并不总是有效。例如,假设你口袋里有一美元,你看到一个架子上有一排好吃的糖果,它们的价格仅仅是10美分,20美分,30美分,以此类推,直到1美元。你每买一颗糖,从10美分的那颗开始,直到你买不起货架上的下一颗糖。

public static void main(String[] args) { 
    double funds = 1.00;
    int itemsBought = 0;
    for (double price = 0.10; funds >= price; price += 0.10) { 
        funds -= price;
        itemsBought++;
    }
    System.out.println(itemsBought +"items bought.");
    System.out.println("Change: $" + funds);
}

如果你运行这个程序,你会发现你可以买得起三块糖,你还有0.399999999999999999美元。这是错误的 答案。解决这个问题的正确方法是使用BigDecimal、int或long进行货币计算。

这里是前一个程序的一个简单改版,使用BigDecimal类型代替double。注意,使用BigDecimal的String构造函数而不是它的double构造函数。这是为了避免在计算中引入不准确的值

public static void main(String[] args) { 
    final BigDecimal TEN_CENTS = new BigDecimal(".10");
    int itemsBought = 0;
    BigDecimal funds = new BigDecimal("1.00");
    for (BigDecimal price = TEN_CENTS;funds.compareTo(price) >= 0;price = price.add(TEN_CENTS)) { 
        funds = funds.subtract(price);
        itemsBought++;
    }
    System.out.println(itemsBought +"items bought.");
    System.out.println("Money left over: $" + funds);
}

使用BigDecimal有两个缺点:

  1. 与原始算术类型相比很不方便
  2. 速度慢得多

除了使用BigDecimal,另一种方法是使用int或long,在这个例子中,最明显的方法是用美分而不是美元来计算

public static void main(String[] args) { 
int itemsBought = 0;
int funds = 100;
for (int price = 10; funds >= price; price += 10) { 
funds -= price;
itemsBought++;
}
System.out.println(itemsBought +"items bought.");
System.out.println("Cash left over: " + funds + " cents");
}

使用BigDecimal的另一个好处是,它可以完全控制舍入,当执行需要舍入的操作时,可以从8种舍入模式中进行选择。

61 基本类型优先于包装基本类型

自动装箱减少了使用包装类型的繁琐性,但没有减少它的风险。

Java 每个基本类型都有一个对应的引用类型,称为包装类型。与int、double和boolean对应的包装类是Integer、Double和Boolean。

基本类型和包装类型之间有三个主要区别:

  1. 两个包装类型实例可以具有相同的值,但这两个实例却是不一样的
  2. 包装类型可以是null
  3. 基本类型比包装类型更节省时间和空间

考虑下面的比较器,它的设计目的是表示Integer值上的升序数字排序。

Comparator<Integer> naturalOrder =(i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);

这个比较器存在严重缺陷,对于

naturalOrder.compare(new Integer(42), new Integer(42))

两个Integer实例都表示相同的值(42),所以这个表达式的值应该是0,但它是1,这表明第一个Integer值大于第二个。

i==j表达式对两个对象引用执行比较。如果i和j引用表示相同int值的不同Integer实例,这个比较将返回false,所以将==操作符应用于包装类型几乎都是错误的。

可以通过添加两个局部变量来存储基本类型int值,并对这些变量执行所有的比较,从而修复比较器中的问题:

Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> { 
    int i = iBoxed, j = jBoxed; // Auto-unboxing
    return i < j ? -1 : (i == j ? 0 : 1);
};

接下来考虑另外一段代码:

public class Unbelievable { 

    static Integer i;

    public static void main(String[] args) { 
        if (i == 42)
            System.out.println("Unbelievable");
    }
}

它在计算表达式 i==42 时抛出NullPointerException。原因是在操作中混合使用基本类型和包装类型时,包装类型就会自动拆箱,如果一个空对象引用自动拆箱,那么你将得到一个NullPointerException。

修复这个问题非常简单,只需将i声明为int:

最后在考虑第6条里曾经出现过的代码:

public static void main(String[] args) { 
    Long sum = 0L;
    for (long i = 0; i < Integer.MAX_VALUE; i++) { 
        sum += i;
    }
    System.out.println(sum);
}

这个程序比它预期的速度慢得多,因为它意外地声明了一个局部变量(sum),它是包装类型Long,变量被反复装箱和拆箱,导致产生明显的性能下降。

什么时候应该使用包装类型呢?

  1. 作为集合中的元素、键和值

不能将基本类型放在集合中,因此必须使用包装类型

  1. 在参数化类型和方法中,必须使用包装类型作为类型参数

例如,不能将变量声明为ThreadLocal<int>类型,因此必须使用ThreadLocal<Integer>

  1. 在进行反射方法调用时,必须使用包装类型

62 如果其他类型更合适,尽量避免使用字符串

本条目讨论了一些不应该使用字符串的场景:

1. 字符串不适合替代其他值类型 当一段数据从文件、网络或键盘输入到程序时,它通常是字符串形式的。但是这种倾向只有在数据本质上是文本的情况下才合理。

2. 字符串不适合替代枚举类型 如第34条,枚举类型比字符串更适合表示枚举类型的常量。

3. 字符串不适合替代聚合类型 如果一个实体有多个组件,将其表示为单个字符串通常是很不好的。例如:

String compoundKey = className + "#" + i.next();

这种方法有很多缺点。如果用于分隔字段的字符出现在其中一个字段中,可能会导致混乱。要访问各个字段,必须解析字符串,这是缓慢的、冗⻓的、容易出错的过程。

更好的方法是编写一个类来表示聚合,通常是一个私有静态成员类。

4. 字符串不能很好地替代capabilities 例如,考虑线程局部变量机制的设计。这样的机制提供的变量在每个线程中都有自己的值。许多年前,当面临设计这样一个机制的任务时,有人提出了相同的设计,其中客户端提供的字符串键,用于标识每个线程本地变量:

public class ThreadLocal { 
    private ThreadLocal() {  } // Noninstantiable
    
    // Sets the current thread's value for the named variable.
    public static void set(String key, Object value);
    
    // Returns the current thread's value for the named variable.
    public static Object get(String key);
}

这种方法的问题在于,为了使这种方法有效,客户端提供的字符串键必须是惟一的:如果两个客户端各自决定为它们的线程本地变量使用相同的名称,它们无意中就会共享一个变量。

这个API可以通过用一个不可伪造的键(有时称为capability)替换字符串来修复:

public class ThreadLocal { 
    private ThreadLocal() {  } // Noninstantiable
    
    public static class Key {  // (Capability)
        Key() {  }
    }
    
    // Generates a unique, unforgeable key
    public static Key getKey() { 
        return new Key();
    }
    
    public static void set(Key key, Object value);
    
    public static Object get(Key key);
}

虽然这解决了API中基于字符串的两个问题,但是你可以做得更好。

public final class ThreadLocal { 
    public ThreadLocal();
    public void set(Object value);
    public Object get();
}

通过将ThreadLocal类泛型化,使这个API变成类型安全的:

public final class ThreadLocal<T> { 
    public ThreadLocal();
    public void set(T value);
    public T get();
}

63 当心字符串连接的性能问题

不要使用字符串连接操作符合并多个字符串

字符串连接操作符(+) 是将几个字符串组合成一个字符串的简便方法。

为了连接n个字符而重复地使用字符串连接操作符,需要O(n^2)的时间。因为字符串是不可变的,当两个字符串被连接在一起时,它们的内容都要被拷贝。

例如,下面代码将每个账单项目重复连接到一行来构造账单语句的字符串表示:

public String statement() { 
    String result = "";
    for (int i = 0; i < numItems(); i++)
        result += lineForItem(i); // String concatenation
    return result;
}

如果项的数量很大,则该方法的执行时间就难以估算。要获得更好的性能,可以使用StringBuilder代替String

public String statement() { 
    StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH);
    for (int i = 0; i < numItems(); i++)
        b.append(lineForItem(i));
    return b.toString();
}

第二个方法预先分配了一个足够大的StringBuilder来保存整个结果,从而消除了自动增⻓的需要。即使使用默认大小的StringBuilder,它仍然比第一个方法快很多。

64 通过接口引用对象

如果存在合适的接口类型,那么应该使用接口类型声明参数、返回值、变量和字段。惟一真正需要引用对象的类的时候是使用构造函数创建它的时候。考虑LinkedHashSet的情况,它是Set接口的一个实现:

Set<Son> sonSet = new LinkedHashSet<>();

而不是这样:

LinkedHashSet<Son> sonSet = new LinkedHashSet<>();

如果你养成了使用接口作为类型的习惯,那么你的程序将更加灵活。如果你决定要切换实现,只需在构造函数中更改类名(或使用不同的静态工厂)。例如,第一个声明可以改为:

Set<Son> sonSet = new HashSet<>();

所有的代码都会继续工作。周围的代码不知道旧的实现类型,所以它不会在意更改。

有一点值得注意:如果原实现提供了接口的通用约定不需要的一些特殊功能,并且代码依赖于该功能,那么新实现提供相同的功能就非常重要。例如,如果围绕第一个声明的代码依赖于LinkedHashSet的排序策略,那么在声明中将HashSet替换为LinkedHashSet将是不正确的,因为HashSet不保证迭代顺序。

为什么要更改实现类型呢? 因为第二个实现比原来的实现提供了更好的性能,或者因为它提供了原来的实现所缺乏的理想功能。例如,假设一个字段包含一个HashMap实例。将其更改为EnumMap将为迭代提供更好的性能和与键的自然顺序。

将HashMap更改为LinkedHashMap将提供可预测的迭代顺序,性能与HashMap相当,而不需要对键类型作出任何特殊要求。

如果没有合适的接口存在,那么用类引用对象也可以。如String和BigInteger。

值类很少在编写时考虑到多个实现。它们通常是final的,很少有相应的接口。使用这样的值类作为参数、变量、字段或返回类型非常合适。

没有合适接口类型的第二种情况是属于框架的对象,框架的基本类型是类而不是接口。如果一个对象属于这样一个基于类的框架,那么最好使用相关的基类来引用它,这通常是抽象的,而不是使用它的实现类。在java.io类中许多诸OutputStream之类的就属于这种情况。

没有合适接口类型的最后一种情况是,实现接口但同时提供接口中不存在的额外方法的类,例如,PriorityQueue有一个在Queue接口上不存在的comparator方法。

如果没有合适的接口,就使用类层次结构中提供必要功能的最小具体类来引用对象~~

65 接口优于反射

核心反射机制java.lang.reflect提供对任意类的编程访问。给定一个Class对象,你可以获得Constructor、Method和Field实例,分别代表了该Class实例所表示的类的构造器、方法和字段。

通过调用Constructor、Method和Field实例上的方法,可以构造底层类的实例、调用底层类的方法,并访问底层类中的字段。

然而,这种能力是有代价的:

1. 失去了编译时类型检查的优势 如果一个程序试图反射性地调用一个不存在的或不可访问的方法,它将在运行时失败

2. 执行反射访问所需的代码既笨拙又冗⻓

3. 性能降低 反射方法调用比普通方法调用慢得多

如果是非常有限的形式使用反射,则可以获得反射的许多好处,同时花费的代价很少。如果代码必须用到在编译时无法获取到的类,却在编译时存在一个适当的接口或超类来引用该类,就可以用反射方式创建实例,并通过它们的接口或超类正常地访问它们。

例如,这是一个创建Set<String>实例的程序,类由第一个命令行参数指定。程序将剩余的命令行参数插入到集合中并打印出来。注意,打印这些参数的顺序取决于第一个参数中指定的类。如果你指定java.util.HashSet,它们显然是随机排列的;如果指定了java.util.TreeSet,则是按字⺟顺序打印的

public static void main(String[] args) { 

    // Translate the class name into a Class object
    Class<? extends Set<String>> cl = null;
    try { 
        cl = (Class<? extends Set<String>>) // Unchecked cast!
        Class.forName(args[0]);
    } catch (ClassNotFoundException e) { 
        fatalError("Class not found.");
    }
    
    // Get the constructor
    Constructor<? extends Set<String>> cons = null;
    try { 
        cons = cl.getDeclaredConstructor();
    } catch (NoSuchMethodException e) { 
        fatalError("No parameterless constructor");
    }
    
    // Instantiate the set
    Set<String> s = null;
    try { 
        s = cons.newInstance();
    } catch (IllegalAccessException e) { 
        fatalError("Constructor not accessible");
    } catch (InstantiationException e) { 
        fatalError("Class not instantiable.");
    } catch (InvocationTargetException e) { 
        fatalError("Constructor threw " + e.getCause());
    } catch (ClassCastException e) { 
    fatalError("Class doesn't implement Set");
}

// Exercise the set
s.addAll(Arrays.asList(args).subList(1, args.length));
System.out.println(s);
}

private static void fatalError(String msg) { 
    System.err.println(msg);
    System.exit(1);
}

这个例子反映了反射的两个缺点: 1. 该示例可以在运行时生成6个不同的异常,如果不使用反射的方式实例化,所有这些异常都将是编译时错误

2. 根据类的名称生成类的实例需要25行冗⻓的代码,而构造函数调用只需要一行

通过捕获ReflectiveOperationException(Java 7中引入的各种反射异常的超类),可以减少代码的⻓度

如果要变写一个包,它运行时必须依赖其他某个包的多个版本,这种做法可能非常有用。具体做法就是,在支持包所需要最老版本下对它进行编译,然后以反射的方式访问任何更加新的类或方法。

66 谨慎使用本地方法

Java本地接口(JNI)允许Java程序调用本地方法,它们提供了“访问特定于平台的机制”,比如注册表。本地方法还可以通过本地语言,编写应用程序中注重性能的部分,以提高性能。

使用本地方法访问特定于平台的机制是合法的,但是很少有必要:随着Java平台的成熟,它提供了对许多以前只能在宿主平台中上找到的特性。

使用本地方法来提高性能的做法也不值得提倡。原因是JVM变得越来越快了。对于大多数任务,现在可以在Java中获得类似本地方法的性能。

在Java 1.1中添加了java.math,里面的BigInteger是在一个用C编写的快速多精度运算库的基础上实现的。在当时,为了获得足够的性能这样做是必要的。在Java 3中,BigInteger则完全用Java重写了,并且进行了性能调优,新的版本比原来的版本更快。

使用本地方法有严重的缺点:

1. 本地语言是不安全的。所以使用本地方法的应用程序可能会受到内存毁坏的影响

2. 本地语言比Java更依赖于平台,因此使用本地方法的程序的可移植性较差

3. 使用本地方法的代码更难调试

4. 本地方法可能会降低性能, 因为垃圾收集器无法自动跟踪本地内存使用情况

5. 进出本地代码时会产生额外的开销

6. 本地方法需要「粘合代码」,很难阅读,并且编写起来很乏味

67 谨慎地进行优化

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

比起其他任何单一的原因(包括盲目的愚蠢),很多计算上的过失都被归昝于效率(不一定能实现)。
—William A. Wulf

不要去计较效率上的一些小小的得失,在97%的情况下,不成熟的优化才是一切问题的根源。
—Donald E. Knuth

在优化方面,我们应该遵守两条规则:
规则1:不要进行优化。
规则2(仅针对专家):还是不要进行优化,也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。
—M. A. Jackson

它们告诉我们关于优化的一个深刻的事实:很容易弊大于利,尤其是不成熟的优化。

我们应该努力编写好的程序,而不是快速的程序,如果一个好的程序不够快,它的架构将允许它被优化。好的程序体现了信息隐藏的原则:只要有可能,它们就会把设计决策集中在单个模块中,因此可以在不影响系统其余部分的情况下更改单个决策。

这并不意味着在程序完成之前可以忽略性能问题,而是必须在设计过程中考虑性能。

尽量避免限制性能的设计决策:设计中最难以更改的组件是那些指定组件之间以及与外部世界的交互的组件。最主要的是API、交互层协议和永久数据格式。

要考虑API设计决策的性能结果:

  1. 使public类成为可变的,可能需要大量不必要的保护性拷贝
    <li>在适合复合模式的public类里使用继承,会把该类永远绑定到它的超类,这会人为地限制子类的性能</li>
  2. 在API中使用实现类而不是接口,会将你束缚在一个具体的实现上,即使将来可能会编写更快的实现也无法使用

以java.awt.Component中的getSize方法为例,这个注重性能的方法将返回Dimension实例,Dimension实例是可变的,这就使得该方法的任何实现在每次调用时分配一个新的Dimension实例。

存在几种API设计的替代方案:

  1. Dimension应该是不可变的
    <li>用两个方法替换getSize,他们分别返回Dimension对象的单个基本组件</li>

为了获得良好的性能而改变API是一个非常糟糕的想法,因为导致你改变API的性能问题,可能在平台或其他底层软件的未来版本中消失,但是改变的API和随之而来的问题将永远伴随着你。

在每次尝试优化前后都要测试性能。性能分析工具可以帮助你决定将优化工作的重点放在哪里。这些工具可以提供运行时信息;另一类工具是jmh,它是一个微基准测试框架,提供了对Java代码性能详情的预测性。

68 遵守被广泛认可的命名约定

Java平台有一组完善的命名约定,其中许多约定包含在了《The Java Language Specification》中,这些命名大致分为两类:字面的和语法的。

1. 包名和模块名应该是分层的,组件之间用句点分隔 每个部分都包括小写字⺟,很少使用数字。包名就是公司或组织的域名颠倒,例如:edu.nju、com.google、org.eff。

包名的其余部分应该由描述包的一个或多个组件组成,通常不超过8个字符。鼓励使用有意义的缩写,例如util(utilities)。缩写词也行,例如awt。

2. 类、接口、枚举、注释名称,应该由一个或多个单词组成,每个单词的首字⺟大写 例如List或FutureTask。对于首字母缩写,到底应该全部大写还是只有首字母大写,没有统一的说法。但还是强烈推荐只有首字母大写,比如HttpUrl就比HTTPURL清晰。

3. 方法和字段名的第一个字⺟应该小写 例如remove或ensureCapacity。如果首字母缩写组成的单词是一个方法或字段名的第一个单词,它应该是小写的。

4. 常量字段的名称应该由一个或多个大写单词组成,由下划线分隔 例如VALUES或NEGATIVE_INFINITY。

5. 局部变量名允许使用缩写,也允许使用单个字符和短字符序列,它们的含义取决于它们出现的上下文 例如i、denom、houseNum

6. 类型参数名通常由单个字⺟组成 最常⻅的是以下五种类型之一:T表示任意类型,E表示集合的元素类型,K和V表示Map的键和值类型,X表示异常。函数的返回类型通常为R。任意类型的序列可以是T、U、V或T1、T2、T3。

上面所有的讨论可以总结如下表: img

语法命名约定比排版约定更灵活,也更有争议。

1. 可实例化的类,包括枚举类型,通常使用一个或多个名词短语来命名 例如Thread、PriorityQueue或ChessPiece

2. 不可实例化的类通常使用复数名词来命名 例如collector或Collections

3. 接口的名称类似于类 例如集合或比较器,或者以able或ible结尾的形容词,例如Runnable、Iterable或Accessible

4. 注解类型有很多的用途,所以名词、动词、介词和形容词都很常⻅ 例如BindingAnnotation、Inject、ImplementedBy或Singleton

5. 执行某些操作的方法通常用动词或动词短语(包括对象)命名 例如append或drawImage

6. 返回布尔值的方法的名称通常以单词is或has(通常很少用)开头,后面跟一个名词、一个名词短语,或者任何用作形容词的单词或短语 例如isDigit、isProbablePrime、isEmpty、isEnabled或hasSiblings

7. 返回被调用对象的非布尔函数或属性的方法通常使用名词短语、以get开头的名词或动词短语来命名 例如size、hashCode或getTime

8. 转换对象类型的实例方法通常称为toType 例如toString或toArray

9. 返回与接收对象类型不同的视图的方法通常称为asType 例如asList

10. 返回与调用它们的对象具有相同值的基本类型的方法通常称为类型值 例如intValue

11. 静态工厂的常⻅名称 from、of、valueOf、instance、getInstance、newInstance、getType和newType

字段名的语法约定没有类、接口和方法名的语法约定建立得好,也不那么重要,因为设计良好的API包含很少的公开字段。类型为boolean的字段的名称通常类似于boolean访问器方法,省略了初始值「is」,例如initialized、composite。其他类型的字段通常用名词或名词短语来命名,如height、digits和bodyStyle。

本文为互联网自动采集或经作者授权后发布,本文观点不代表立场,若侵权下架请联系我们删帖处理!文章出自:https://wangjiawei.blog.csdn.net/article/details/122101111
-- 展开阅读全文 --
安全面试之XSS(跨站脚本攻击)
« 上一篇 07-24

发表评论

成为第一个评论的人

热门文章

标签TAG

最近回复