effective java 读书笔记

创建和销毁对象

  1. 用静态工厂方法代替构造器。这里的静态工厂方法不是factory mode,而是一种构造器的替代方法。本条款鼓励设计者在构建一个类时,优先考虑使用static factory method。后者有多个优势:更清晰,更简洁,在参数相同时不需要使用boolean或者enum来进行区分,可以通过register——get方法提供工厂特性(子类)。不过,这种方法和普通的静态方法没有任何语法形式的区别,且静态方法不能被继承(C++中virtualstatic不能共存);
  2. 当构造参数非常多时,使用构建器(builder)。我们通常会使用默认构造函数,然后用set方法一一设置属性,如果让set返回this,就可以连续使用set,这就是builder。对于具有“具名参数”语法的语言(如python,C#)这种模式就不是很必要了,对于有default value(如C++)语法的语言,这种模式的使用频率也要低一些。
  3. 用私有构造器或枚举强化单例属性。这里给出了一个创建单例的建议:创建一个仅包含单元素的枚举类型,由于java语言特性,枚举直接免费提供了序列化、防止多次实例化等功能,因此可以简洁的解决很多问题。
  4. 如果一个类不可实例化,应该将其构造器私有化,这种类一般只是提供静态函数(类似C++中的函数)
  5. 尽量少创建新对象,而应该多复用已有对象。这条尤其对immuable的对象很重要,例如String。每次使用new时,对应该仔细考虑是否需要创建一个新的对象,而不是复用以前的对象。当然,对于小对象,这个不需要考虑。正如C++中的传值和传址的差别,小对象使用传值反而更好一些,可以避免许多不必要的麻烦。
    另外,由于autoboxing的存在,基本类型与装箱基本类型之间会相互转化。但是需要记住,要优先使用基本类型。
  6. 消除过期的对象引用。这条主要针对“程序员自己管理内存/池化”的情景。在C++中,我们习惯在free or delete之后,将其置0(nullptr),这样可以避免后续一些莫名其妙的Bug。java是GC机制的,因此一般不需要这么做。但是,如果自己池化了对象管理机制(例如创建某些数据结构时),就要注意这些问题了。
    本条款给出的建议中,要注意缓存(比如建了一个全局list/map)中过期的东西记得删除。可以使用WeakHashMap自动管理(当内存中没有其引用对象时自动清除),或者LinkedHashMapremoveEldestEntry手动管理(每次添加新元素时)。对于更加复杂的场景,必须使用java.lang.ref^1.
    此外,注册一个回调方法,但是没有显式取消它,会导致其不断被累积。因此在使用addListener创建函数对象时,请记得removeListener.
  7. 避免使用终结器(finalizer)。java使用GC来回收内存,因此终结器的用处不大,更重要的是,终结器的优先级很低,不能保证被及时执行,很容易造成性能bug。比较妥当的是提供显示的终结方法(如close),然后在finally中执行。注意终结方法不应该再抛出异常,而是应该捕获并打印日志。
    但是用户可能忘记调用显示的终结方法,因此终结器还是可以充当最后的安全保证,当然,这其实是程序不得已使用的方法,应该在日志中使用警告。
    另外需要注意的是,子类终结器必须显式调用父类的,这点和C++中的自动析构并不一样,请注意。可以考虑使用finalize guard来确保这一点。

object通用方法

  1. equals方法。C++中,重载==操作符限定在同类之间, 其他的要通过类型转换来进行比较,因此存在非常复杂的显式/隐式类型转换。java中,equals方法的参数必须是Object类型,这意味着所有的对象之间都可以判断是否相等。equals方法的设计必须非常谨慎,要严格遵守自反性、对称性、一致性和传递性这几个特征。
  • 自反性,意思是x=x恒成立;
  • 对称性,意味着a=b,必须也要b=a. 但是由于参数是Object,很容易无意识的违反这点。一般判断步骤中,首先需要知道这个Object是不是属于这个类(使用instanceof),严格认为不属于这个类的对象不可能相等(即使是value意义上的相等)。如果需要value意义上的相等,就写一个显式的类型转换方法。
  • 传递性,意味着a=b, b=c则a=c成立。这条在设计类层次结构时很容易被违反。在多层类均可被实例化时,判断这些类之间的相等性往往会出现混乱。
  • 一致性。即相等的永远都相等,不等的永远都不等。这里注意的是判断时不要依赖不可靠的资源;
  • 非空性。这是额外的一个特性,任何对象在任何情况下都不应该与null相等——这是显然的。但是这一步不需要特别写出来,instanceof会替我们做这些的。
    除了上面的款项以外,还有一些使用技巧:
  • 如果比较流程复杂,可以先用==判断是否是同一个对象;
  • 在比较时注意npt异常;
  • 短路求值与比较顺序;
  • 覆盖hashcode;
  1. hashcode方法。如果覆盖了equals必须同时覆盖hashcode,这是因为相等的对象必须有相同的hash code. 这意味着我们必须为这个类提供一种hash算法,这个活并不简单,不过一般情况下可以这么做:
    1. 选一个非0常数记为result,比如17;
    2. 对于equals中涉及的每一个域f(成员变量),计算其hashcode:
      • boolean -> 1 or 0
      • byte, short, int -> int(f)
      • long -> int(f^(f>>32))
      • float -> Float.floatToIntBits(f)
      • double -> Double.doubleToLongBits(f) -> 按着long计算
      • 对象引用:null -> 0,其他:递归调用hashcode方法
      • 数组:把每个对象当作一个单独的域计算hashcode
    3. result=$31 * result + \Sigma_{i=1}^{\propto}f_i$
      如果一个域的结果可以通过其他域计算出来,可以不必参与上面的计算过程。
      如果hash计算非常复杂,可以考虑使用延迟初始化技术。定义一个volatile int hashcode,在对应的method里,先if(hashcode==0),否则直接返回缓存的结果。
  2. toString方法。同C#一样,一般推荐覆盖这个方法,这样print时,会方便很多。
  3. clone方法。很遗憾,java的clone不好用——很不好用,它的约定太弱。即使类implement了Cloneable接口,你也不能指望什么。最好的方法是别管这个东西,自己实现一个拷贝构造器,或者拷贝工厂。
  4. 考虑实现Comparable接口。这个接口唯一声明了compareTo方法,这是一个泛型方法。如果你需要排序,最好事先这个方法——正如C++中的重载<操作符一样,这是泛型容器排序的基础。显然compareTo需要和equals在判断相等时保持一致。
    equals简单的是,compareTo的参数只能和自身类型一样,因为在implements泛型类时,填入的具体参数显然就是这个类自身。

类和接口

  1. 使类和成员的可访问性最小化。本条和语言无关,所有面向对象设计中,信息隐藏的重要性都是一致的。切记尽量少暴露实现细节,保持对外开放性最小。这可以有效减少软件工程的复杂度。
    有个细节的技巧:数组不可能是public final的, 因为数组本身是可变的。C++中有个蛋疼的const int* const p的问题,指的就是指针的可变性和内容的可变性问题。
    此外,java默认访问类型是package private的,这和其他语言有区别。

  2. 使用getter,setter代替公有成员变量。这条其实并不是那么严格,至少C++中经常可以看到反例。因为写起来实在太麻烦,所以C#引入了属性这种语法糖。

  3. 设计不可变类。省事起见,如果对性能没有特别大的需求尽量设计不可变类,这种类的所有方法都会返回一个new对象,而不是直接修改对象本身。这种类的设计遵从如下原则:

    1. 不提供mutator修改对象本身;
    2. 保证类不会被继承;
    3. 所有域都是不可变的(final);
    4. 所有域都是私有的;
    5. 确保对于任何可变组件的互斥访问;
  4. 复合优先于继承。这条是面向对象的泛用条款,从略;

  5. 要么为继承而设计,要么禁止继承。这条要求在设计一个类时,要明确它是否会被继承。这里强调了一个细节:构造器不可调用可被覆盖的方法。这和C++中构造函数和析构函数不应该调用虚函数本质上是一直的,因为对象是从基类到派生类逐级构造的,如果调用虚函数,动态绑定可能不会生效,从而产生undefined的后果;

  6. 接口优先于抽象类。显然,也是泛用条款。对于C++而言,没那么明显(因为没有接口),不过全是纯虚函数的类就是接口…

  7. 接口只用于定义类型。是的,只应该有public的method,而不要塞进去一堆常量。后者最好用枚举代替。

  8. 类层次优先于标签类。标签类就是在构造函数中传入flag,在方法中switch...case,在C语言中,这是常见的设计。但是在面向对象中,显然更适合使用继承来合理安排类结构。

  9. 用函数对象表示策略。由于C++11有了std::function<>lambda,所以函数对象这种累赘的东西一般是用不上了,但是垂垂老矣的java中,还没有这些东西(java8引入lambda了,谢天谢地),一般是写一个接口,然后用匿名函数实现它。

  10. 优先考虑静态成员类。nested class最好设计成静态的,这是为了减少对象的数量——非静态类都必须与一个外围实例关联。nested class可以摆脱友元这种东西的困扰,访问所有的成员方法|变量(某种形式的闭包)。但是静态成员类和对象本身无关,所以就只能访问静态方法和静态成员了。

泛型

在C++中,template和oo完全是两种不同的范式,因为C++并没有all is object这种思想,因此没人会觉得vector<basic_string>不是vector<string>的父类有啥问题。java其实也一样,list<string>list<object>之间也不是继承的关系。泛型使java复杂化,并且失去了优雅。

  1. 用泛型,不用原生态类型。java从1.5版本引入泛型,c#从1.2开始,C++则一直都有模板这种东西。没有泛型的语言一般都会引入一些很丑陋的类型转换,比如C中的void *。尽量使用泛型而非原生态的类型,如果需要指代任意类型而不关心具体类型,可以用?比如set<?>;此外,instanceof操作符后面必须跟着原生态类型;
  2. 消除unchecked warning,对于编译器的抱怨,要好好检查,如果确定没有问题,就使用@SuppressWarnings("unchecked")关闭这个提醒,受检警告仅存在在语言层面,并非是jvm虚拟机的特性;
  3. 优先使用List而不是Array。在C++中,array本质上是一个指针,因此效率比std::vector<>要高上不少;java中Array也比list<>要快一些,但是却更推荐使用后者。
    java的泛型在运行时其元素类型是被擦除(erase^2)的,这点和C++完全不同(为了历史兼容性做出的妥协)。因此数组是运行时安全但编译时不安全的,泛型则相反。最后,最好不要同时使用泛型和数组,这会让你蛋疼无比——如果真的需要泛型数组,必须同时使用强制类型转换和SuppressWarnings技术。
  4. 优先考虑泛型和泛型方法。
  5. 使用set<? extends Object> or set<? super String>这种技巧来完成某些动作。助记符是__PECS__,就是说,把这个对象当生产者使用时,用extends,反之,用super
    在返回的时候,仍然使用普通类型,而不是通配符。
    本条对于类库编写者比较重要。
  6. 优先使用类型安全的异构容器。本条通过一些奇技淫巧完成一个容器里面同时存放多种类型(异构,通过class<?>实现),但是又能保证类型安全(通过type.cast实现)这一目标。

枚举与注解

java的枚举有点难用,不如C#那么强大,也不如C/C++那么简洁。简单来说,每一个枚举值都是该类(枚举)的一个实例,相当于一种工厂方法,因此枚举对象可以直接调用枚举类的方法。

  1. 枚举的所有域都应该是私有final的(需要的时候提供公有的访问函数);可以通过values方法访问所有的枚举值(依照声明顺序)。对于每一个枚举值在后面加上{},里面是特定于常量的方法实现,可以通过在枚举类中声明一个抽象方法,在常量中再进行覆盖来实现对不同枚举值的特殊操作(switch(this)这种方法更适用于控制外部传入的不可控的变量的方法中)。
    如果在枚举值后面加上初始化表达式,调用该常量时会自动使用该表达式调用构造函数(但是显然我们不能直接调用构造器),比如MONDAY("mon")这种.
    PS: 这里给了一个有趣的技巧:使用%n保证换行符的跨平台性,有些类似python中的os.linesep.
  2. 如果不给枚举赋值,默认使用序列(0,1,2…),可以使用ordinal方法取得该序列的int值。当然,最好别依赖于这种自动生成的东西,而是明确赋值…
  3. EnumSet代替位域,主要使用EnumSet.of创建枚举set,用来取代依靠位运算得出的集合特性。不过,这种方法只是可读性好一些,如果需要做存储或者与提供API,还是希望使用int。
  4. EnumMap代替序数索引。可以将一个enum直接传入EnumMap<enum,Object>中,enum的所有常量值都会转化为EnumMapkey,这其实就是普通的map,但是做了优化。
  5. 枚举不是可扩展的,但是我们可以使用接口来变相实现这种可扩展性。简单来说,就是枚举实现接口,然后在需要的时候,使用接口来声明枚举而不是相反。
  6. 注解优先于命名模式。注解的声明需要导入java.lang.annotation.*,然后使用元注解来标明该注解的属性。例如@Retention, @Target,注解类的必须是@interface类型。如:
    1
    2
    3
    public @interface Test{
    Class<? extend Exception>[] value();
    }
    使用时:
    1
    2
    3
    4
    @Test({IndexOutOfBoundException.class, NullPointerException.class})
    public static void example(){
    //...
    }
    测试时:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    if(m.isAnnotationPresent(Test.class){
    test++;
    try{
    m.invoke(null);
    }catch(Throwable wrappedExc){
    Throwable exc = wrappedExc.getCause();
    Class<? extends Excetption>[] excTypes=
    m.getAnnotation(Test.class).value();
    }
    ...
    注解并不会改变代码原本的含义(和python的装饰器完全不同),但是可以使对象经过某些工具的特殊处理(用于反射)。
    显然一般程序员是不需要自定义注解类型的,除了某些设计工具平台的人以外。
  7. 坚持使用Override注解。这是一种良好的变成习惯,不再赘述。
  8. 用标记接口定义类型。标记接口很罕见,最常见的是Serializable,它只是一个接口,并没有规定任意方法。换言之,这只是一个空接口。

方法

  1. 检查参数的有效性。即防御性编程,对于公有方法,应该在Javadoc中使用@throws标明在违反约定时会抛出什么异常;对于非导出方法,设计者自己知道会传入什么参数,因此应该使用assert来进行错误检测。
  2. 必要时使用保护性拷贝。java的对象本质上都是c++中的指针或引用,因此如果对象本身是可变的,即使参数是不可变的,也可能因为某些意外导致对象被修改。比较简单的方法是使用深拷贝(值拷贝)摆脱这种引用的关系,这在C++中是比较明显的:传值还是传址的问题,但是java中如果不留意很容易忘掉这一点。
  3. 谨慎设计方法签名。本条款说了一些设计方法的技巧和忌讳,包括:
    • 命名要谨慎
    • 尽量保持接口最小,而不是提供很多快捷方式
    • 避免过长的参数列表(4个以下)
    • 如果上述条款不可避免,使用builder(参见前文)
    • 参数类型优先使用接口
    • flag尽量使用enum而不是boolean
  4. 慎用重载。有个原则是:永远不要导出两个具有相同参数数目的重载方法。如果方法使用可变参数,那么尽量不要重载它。如果违反上述两条规定,干脆给方法起不同的名字(如同在C语言中那样)。
  5. 慎用可变参数。和其他语言一样,可变参数为printf而生,但是实际上自己需要写的并不多,大部分情况下传入一个列表或者使用泛型可以解决应用问题。可变参数本身有一定的性能约束:传入可变参数隐式创建并初始化了一个数组。
  6. 返回0长度的数组或集合,而不是null。这是一条设计上的经验,在非C语言环境下,返回null往往得不偿失——需要单独写语句进行分析,而不能直接迭代。如果担心性能问题,可以使用Collections.emptyList()等方法返回不可变的空集,如果是数组,可以自己创建一个不可变的static final成员变量。
  7. 为所有的导出API写文档注释。尤其是在替别人写类库的时候,这条非常重要。必须明确标注哪些是可能会改变的,哪些是兼容的。

通用程序设计

  1. 使局部变量的作用域最小化。这点和C++、C#一致,C语言则是习惯把所有变量声明放在最前面(因为C只有基本数据和结构,且一般更习惯使用指针),python则不需要声明。
  2. for-each优先于for。同所有语言一致,C++11中引入类似语法,C#和python用in关键字. 但是for-each是不能修改容器本身的,因此在必须要的时候还是要使用for。
  3. 了解和使用类库。应该熟悉java.langjava.util中的内容。
  4. 不要使用floatdouble进行精确计算。如果需要精确的小数计算,应该使用BigDecimal,或者自己处理小数点,用intlong
  5. 基本类型优先于装箱类型。由于装箱类型表示对象,因此用==判断两者之间的相等性总是错误的。当混合两者运算时,装箱类型就会自动拆箱。尽可能使用基本类型以避免不停的装箱、拆箱造成的性能损失。在如下场合必须使用装箱类型:
    • 集合的key和value;
    • 泛型的参数;
    • 进行反射方法调用时;
  6. 如果其他类型更适合,不要使用字符串。例如,不要用”True”来代替true这种。
  7. 字符串连接问题。使用StringBuilder代替String,提高性能。
  8. 通过接口引用对象。这样更加灵活,但是必要的时候你可能需要进行类型转换。如果是基于类的框架,则使用基类更加合适。
  9. 接口优先于反射。反射的性能实际上是很差的,但是在必要的时候会非常有用。其中Class a=Class.forname("xxx"),然后使用a.newInstance()这种方法比较常见。
  10. 谨慎的使用native method. JNI允许java调用C/C++来访问特定的基于平台的sdk接口。但是,如果仅仅为了提高性能,并不提倡非要使用这种技术。
  11. 谨慎优化。写出好的程序,如果存在性能问题,则使用性能分析工具去分析它,再针对瓶颈进行优化。
  12. 遵守命名习惯。java的命名习惯和C#基本一致(而python和C++基本一致),java不喜欢下划线,而C++不喜欢驼峰。当然,一般常量还是都用全大写中间用下划线的表示方式。

异常

java的异常分为checkedunchecked两类,后者是RuntimeExcception或者Error的子类,如果程序抛出这种异常,可以不加以捕获而编译通过。
通常情况下,应该使用标准异常。且,只在真正的异常情况下使用异常。

并发

  1. synchronizedvolatile关键字的使用。前者类似C#中的lock,后者意思同C。C语言中虽然没有线程,但是有中断和信号。
  2. 避免过度同步。如果需要并发集合,那就使用语言内置的并发集合,而不要自己使用synchronized关键字加锁。CopyOnWriteArrayList是一种写时复制的并发容器,类似ConcurrentArrayList。如果类库使用者可以外部同步,那么设计类的时候就不要设计成内部同步的(这是C++的设计原则之一——效率最高)。如果修改了静态域,由于外部用户无法自己加锁,因此类的内部必须加锁。尽量不要从同步区域内部调用外来方法。
  3. executortask优先于线程。这点类似于C#,尽量不要使用抽象程度较低的线程,而是更加漂亮简洁的其他高级类。在java.lang.concurrent里包含了已经封装好的比较通用的线程模型,尽量使用它们而不是自己去写。
  4. 并发工具优先于waitnotify。类似上一条,优先使用高级工具。
  5. 慎用延迟初始化。本条目给出了几个好的建议:
    1. 正常初始化优先于延迟初始化;
    2. 使用同步方法;
    3. 如果需要性能优化,静态域延迟初始化使用一个static class作为holder,这样在第一次访问这个静态类时,所需要的域才会被初始化;
    4. 如果需要性能优化,实例域延迟初始化最好使用双重检查模式。这时候域必须被声明为volatile的,而且习惯上使用一个局部变量来优化对域的检查。
  6. 不要使用Thread.yield,直接用Thread.sleep(1)就好。java的线程调度器不可靠,不要使用线程优先级来调度。
  7. 不要使用java线程组,这个技术已经过时了…

序列化

所谓序列化,指的是将一个对象编码成字节流,一般用来持久化。

  1. 谨慎实现Serializable接口。