effective java 读书笔记
创建和销毁对象
- 用静态工厂方法代替构造器。这里的静态工厂方法不是factory mode,而是一种构造器的替代方法。本条款鼓励设计者在构建一个类时,优先考虑使用static factory method。后者有多个优势:更清晰,更简洁,在参数相同时不需要使用boolean或者enum来进行区分,可以通过register——get方法提供工厂特性(子类)。不过,这种方法和普通的静态方法没有任何语法形式的区别,且静态方法不能被继承(C++中
virtual
与static
不能共存); - 当构造参数非常多时,使用构建器(builder)。我们通常会使用默认构造函数,然后用
set
方法一一设置属性,如果让set
返回this
,就可以连续使用set
,这就是builder
。对于具有“具名参数”语法的语言(如python,C#)这种模式就不是很必要了,对于有default value
(如C++)语法的语言,这种模式的使用频率也要低一些。 - 用私有构造器或枚举强化单例属性。这里给出了一个创建单例的建议:创建一个仅包含单元素的枚举类型,由于java语言特性,枚举直接免费提供了序列化、防止多次实例化等功能,因此可以简洁的解决很多问题。
- 如果一个类不可实例化,应该将其构造器私有化,这种类一般只是提供静态函数(类似C++中的函数)
- 尽量少创建新对象,而应该多复用已有对象。这条尤其对immuable的对象很重要,例如String。每次使用
new
时,对应该仔细考虑是否需要创建一个新的对象,而不是复用以前的对象。当然,对于小对象,这个不需要考虑。正如C++中的传值和传址的差别,小对象使用传值反而更好一些,可以避免许多不必要的麻烦。
另外,由于autoboxing
的存在,基本类型与装箱基本类型之间会相互转化。但是需要记住,要优先使用基本类型。 - 消除过期的对象引用。这条主要针对“程序员自己管理内存/池化”的情景。在C++中,我们习惯在
free
ordelete
之后,将其置0(nullptr
),这样可以避免后续一些莫名其妙的Bug。java是GC机制的,因此一般不需要这么做。但是,如果自己池化了对象管理机制(例如创建某些数据结构时),就要注意这些问题了。
本条款给出的建议中,要注意缓存(比如建了一个全局list/map)中过期的东西记得删除。可以使用WeakHashMap
自动管理(当内存中没有其引用对象时自动清除),或者LinkedHashMap
的removeEldestEntry
手动管理(每次添加新元素时)。对于更加复杂的场景,必须使用java.lang.ref
^1.
此外,注册一个回调方法,但是没有显式取消它,会导致其不断被累积。因此在使用addListener
创建函数对象时,请记得removeListener
. - 避免使用终结器(
finalizer
)。java使用GC来回收内存,因此终结器的用处不大,更重要的是,终结器的优先级很低,不能保证被及时执行,很容易造成性能bug。比较妥当的是提供显示的终结方法(如close
),然后在finally
中执行。注意终结方法不应该再抛出异常,而是应该捕获并打印日志。
但是用户可能忘记调用显示的终结方法,因此终结器还是可以充当最后的安全保证,当然,这其实是程序不得已使用的方法,应该在日志中使用警告。
另外需要注意的是,子类终结器必须显式调用父类的,这点和C++中的自动析构并不一样,请注意。可以考虑使用finalize guard
来确保这一点。
object通用方法
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
;
hashcode
方法。如果覆盖了equals
,必须同时覆盖hashcode
,这是因为相等的对象必须有相同的hash code. 这意味着我们必须为这个类提供一种hash算法,这个活并不简单,不过一般情况下可以这么做:- 选一个非0常数记为result,比如17;
- 对于
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
- result=$31 * result + \Sigma_{i=1}^{\propto}f_i$
如果一个域的结果可以通过其他域计算出来,可以不必参与上面的计算过程。
如果hash计算非常复杂,可以考虑使用延迟初始化技术。定义一个volatile int hashcode
,在对应的method里,先if(hashcode==0)
,否则直接返回缓存的结果。
toString
方法。同C#一样,一般推荐覆盖这个方法,这样print
时,会方便很多。clone
方法。很遗憾,java的clone
不好用——很不好用,它的约定太弱。即使类implement了Cloneable
接口,你也不能指望什么。最好的方法是别管这个东西,自己实现一个拷贝构造器,或者拷贝工厂。- 考虑实现
Comparable
接口。这个接口唯一声明了compareTo
方法,这是一个泛型方法。如果你需要排序,最好事先这个方法——正如C++中的重载<
操作符一样,这是泛型容器排序的基础。显然compareTo
需要和equals
在判断相等时保持一致。
比equals
简单的是,compareTo
的参数只能和自身类型一样,因为在implements泛型类时,填入的具体参数显然就是这个类自身。
类和接口
使类和成员的可访问性最小化。本条和语言无关,所有面向对象设计中,信息隐藏的重要性都是一致的。切记尽量少暴露实现细节,保持对外开放性最小。这可以有效减少软件工程的复杂度。
有个细节的技巧:数组不可能是public final的, 因为数组本身是可变的。C++中有个蛋疼的const int* const p
的问题,指的就是指针的可变性和内容的可变性问题。
此外,java默认访问类型是package private
的,这和其他语言有区别。使用
getter
,setter
代替公有成员变量。这条其实并不是那么严格,至少C++中经常可以看到反例。因为写起来实在太麻烦,所以C#引入了属性这种语法糖。设计不可变类。省事起见,如果对性能没有特别大的需求尽量设计不可变类,这种类的所有方法都会返回一个
new
对象,而不是直接修改对象本身。这种类的设计遵从如下原则:- 不提供mutator修改对象本身;
- 保证类不会被继承;
- 所有域都是不可变的(final);
- 所有域都是私有的;
- 确保对于任何可变组件的互斥访问;
复合优先于继承。这条是面向对象的泛用条款,从略;
要么为继承而设计,要么禁止继承。这条要求在设计一个类时,要明确它是否会被继承。这里强调了一个细节:构造器不可调用可被覆盖的方法。这和C++中构造函数和析构函数不应该调用虚函数本质上是一直的,因为对象是从基类到派生类逐级构造的,如果调用虚函数,动态绑定可能不会生效,从而产生undefined的后果;
接口优先于抽象类。显然,也是泛用条款。对于C++而言,没那么明显(因为没有接口),不过全是纯虚函数的类就是接口…
接口只用于定义类型。是的,只应该有public的method,而不要塞进去一堆常量。后者最好用枚举代替。
类层次优先于标签类。标签类就是在构造函数中传入flag,在方法中
switch...case
,在C语言中,这是常见的设计。但是在面向对象中,显然更适合使用继承来合理安排类结构。用函数对象表示策略。由于C++11有了
std::function<>
和lambda
,所以函数对象这种累赘的东西一般是用不上了,但是垂垂老矣的java中,还没有这些东西(java8引入lambda了,谢天谢地),一般是写一个接口,然后用匿名函数实现它。优先考虑静态成员类。nested class最好设计成静态的,这是为了减少对象的数量——非静态类都必须与一个外围实例关联。nested class可以摆脱友元这种东西的困扰,访问所有的成员方法|变量(某种形式的闭包)。但是静态成员类和对象本身无关,所以就只能访问静态方法和静态成员了。
泛型
在C++中,template和oo完全是两种不同的范式,因为C++并没有all is object
这种思想,因此没人会觉得vector<basic_string>
不是vector<string>
的父类有啥问题。java其实也一样,list<string>
和list<object>
之间也不是继承的关系。泛型使java复杂化,并且失去了优雅。
- 用泛型,不用原生态类型。java从1.5版本引入泛型,c#从1.2开始,C++则一直都有模板这种东西。没有泛型的语言一般都会引入一些很丑陋的类型转换,比如C中的
void *
。尽量使用泛型而非原生态的类型,如果需要指代任意类型而不关心具体类型,可以用?
比如set<?>
;此外,instanceof
操作符后面必须跟着原生态类型; - 消除
unchecked warning
,对于编译器的抱怨,要好好检查,如果确定没有问题,就使用@SuppressWarnings("unchecked")
关闭这个提醒,受检警告仅存在在语言层面,并非是jvm虚拟机的特性; - 优先使用List而不是Array。在C++中,array本质上是一个指针,因此效率比
std::vector<>
要高上不少;java中Array
也比list<>
要快一些,但是却更推荐使用后者。
java的泛型在运行时其元素类型是被擦除(erase^2)的,这点和C++完全不同(为了历史兼容性做出的妥协)。因此数组是运行时安全但编译时不安全的,泛型则相反。最后,最好不要同时使用泛型和数组,这会让你蛋疼无比——如果真的需要泛型数组,必须同时使用强制类型转换和SuppressWarnings
技术。 - 优先考虑泛型和泛型方法。
- 使用
set<? extends Object>
orset<? super String>
这种技巧来完成某些动作。助记符是__PECS__,就是说,把这个对象当生产者使用时,用extends
,反之,用super
。
在返回的时候,仍然使用普通类型,而不是通配符。
本条对于类库编写者比较重要。 - 优先使用类型安全的异构容器。本条通过一些奇技淫巧完成一个容器里面同时存放多种类型(异构,通过
class<?>
实现),但是又能保证类型安全(通过type.cast
实现)这一目标。
枚举与注解
java的枚举有点难用,不如C#那么强大,也不如C/C++那么简洁。简单来说,每一个枚举值都是该类(枚举)的一个实例,相当于一种工厂方法,因此枚举对象可以直接调用枚举类的方法。
- 枚举的所有域都应该是私有final的(需要的时候提供公有的访问函数);可以通过
values
方法访问所有的枚举值(依照声明顺序)。对于每一个枚举值在后面加上{}
,里面是特定于常量的方法实现,可以通过在枚举类中声明一个抽象方法,在常量中再进行覆盖来实现对不同枚举值的特殊操作(switch(this)
这种方法更适用于控制外部传入的不可控的变量的方法中)。
如果在枚举值后面加上初始化表达式,调用该常量时会自动使用该表达式调用构造函数(但是显然我们不能直接调用构造器),比如MONDAY("mon")
这种.
PS: 这里给了一个有趣的技巧:使用%n
保证换行符的跨平台性,有些类似python中的os.linesep
. - 如果不给枚举赋值,默认使用序列(0,1,2…),可以使用
ordinal
方法取得该序列的int值。当然,最好别依赖于这种自动生成的东西,而是明确赋值… - 用
EnumSet
代替位域,主要使用EnumSet.of
创建枚举set,用来取代依靠位运算得出的集合特性。不过,这种方法只是可读性好一些,如果需要做存储或者与提供API,还是希望使用int。 - 用
EnumMap
代替序数索引。可以将一个enum
直接传入EnumMap<enum,Object>
中,enum
的所有常量值都会转化为EnumMap
的key
,这其实就是普通的map
,但是做了优化。 - 枚举不是可扩展的,但是我们可以使用接口来变相实现这种可扩展性。简单来说,就是枚举实现接口,然后在需要的时候,使用接口来声明枚举而不是相反。
- 注解优先于命名模式。注解的声明需要导入
java.lang.annotation.*
,然后使用元注解来标明该注解的属性。例如@Retention
,@Target
,注解类的必须是@interface
类型。如:使用时:1
2
3public @interface Test{
Class<? extend Exception>[] value();
}测试时:1
2
3
4@Test({IndexOutOfBoundException.class, NullPointerException.class})
public static void example(){
//...
}注解并不会改变代码原本的含义(和python的装饰器完全不同),但是可以使对象经过某些工具的特殊处理(用于反射)。1
2
3
4
5
6
7
8
9
10if(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();
}
...
显然一般程序员是不需要自定义注解类型的,除了某些设计工具平台的人以外。 - 坚持使用
Override
注解。这是一种良好的变成习惯,不再赘述。 - 用标记接口定义类型。标记接口很罕见,最常见的是
Serializable
,它只是一个接口,并没有规定任意方法。换言之,这只是一个空接口。
方法
- 检查参数的有效性。即防御性编程,对于公有方法,应该在Javadoc中使用
@throws
标明在违反约定时会抛出什么异常;对于非导出方法,设计者自己知道会传入什么参数,因此应该使用assert
来进行错误检测。 - 必要时使用保护性拷贝。java的对象本质上都是c++中的指针或引用,因此如果对象本身是可变的,即使参数是不可变的,也可能因为某些意外导致对象被修改。比较简单的方法是使用深拷贝(值拷贝)摆脱这种引用的关系,这在C++中是比较明显的:传值还是传址的问题,但是java中如果不留意很容易忘掉这一点。
- 谨慎设计方法签名。本条款说了一些设计方法的技巧和忌讳,包括:
- 命名要谨慎
- 尽量保持接口最小,而不是提供很多快捷方式
- 避免过长的参数列表(4个以下)
- 如果上述条款不可避免,使用builder(参见前文)
- 参数类型优先使用接口
- flag尽量使用enum而不是boolean
- 慎用重载。有个原则是:永远不要导出两个具有相同参数数目的重载方法。如果方法使用可变参数,那么尽量不要重载它。如果违反上述两条规定,干脆给方法起不同的名字(如同在C语言中那样)。
- 慎用可变参数。和其他语言一样,可变参数为
printf
而生,但是实际上自己需要写的并不多,大部分情况下传入一个列表或者使用泛型可以解决应用问题。可变参数本身有一定的性能约束:传入可变参数隐式创建并初始化了一个数组。 - 返回0长度的数组或集合,而不是null。这是一条设计上的经验,在非C语言环境下,返回null往往得不偿失——需要单独写语句进行分析,而不能直接迭代。如果担心性能问题,可以使用
Collections.emptyList()
等方法返回不可变的空集,如果是数组,可以自己创建一个不可变的static final
成员变量。 - 为所有的导出API写文档注释。尤其是在替别人写类库的时候,这条非常重要。必须明确标注哪些是可能会改变的,哪些是兼容的。
通用程序设计
- 使局部变量的作用域最小化。这点和C++、C#一致,C语言则是习惯把所有变量声明放在最前面(因为C只有基本数据和结构,且一般更习惯使用指针),python则不需要声明。
- for-each优先于for。同所有语言一致,C++11中引入类似语法,C#和python用
in
关键字. 但是for-each是不能修改容器本身的,因此在必须要的时候还是要使用for。 - 了解和使用类库。应该熟悉
java.lang
和java.util
中的内容。 - 不要使用
float
和double
进行精确计算。如果需要精确的小数计算,应该使用BigDecimal
,或者自己处理小数点,用int
或long
。 - 基本类型优先于装箱类型。由于装箱类型表示对象,因此用
==
判断两者之间的相等性总是错误的。当混合两者运算时,装箱类型就会自动拆箱。尽可能使用基本类型以避免不停的装箱、拆箱造成的性能损失。在如下场合必须使用装箱类型:- 集合的key和value;
- 泛型的参数;
- 进行反射方法调用时;
- 如果其他类型更适合,不要使用字符串。例如,不要用”True”来代替
true
这种。 - 字符串连接问题。使用
StringBuilder
代替String
,提高性能。 - 通过接口引用对象。这样更加灵活,但是必要的时候你可能需要进行类型转换。如果是基于类的框架,则使用基类更加合适。
- 接口优先于反射。反射的性能实际上是很差的,但是在必要的时候会非常有用。其中
Class a=Class.forname("xxx")
,然后使用a.newInstance()
这种方法比较常见。 - 谨慎的使用native method. JNI允许java调用C/C++来访问特定的基于平台的sdk接口。但是,如果仅仅为了提高性能,并不提倡非要使用这种技术。
- 谨慎优化。写出好的程序,如果存在性能问题,则使用性能分析工具去分析它,再针对瓶颈进行优化。
- 遵守命名习惯。java的命名习惯和C#基本一致(而python和C++基本一致),java不喜欢下划线,而C++不喜欢驼峰。当然,一般常量还是都用全大写中间用下划线的表示方式。
异常
java的异常分为checked
和unchecked
两类,后者是RuntimeExcception
或者Error
的子类,如果程序抛出这种异常,可以不加以捕获而编译通过。
通常情况下,应该使用标准异常。且,只在真正的异常情况下使用异常。
并发
synchronized
和volatile
关键字的使用。前者类似C#中的lock
,后者意思同C。C语言中虽然没有线程,但是有中断和信号。- 避免过度同步。如果需要并发集合,那就使用语言内置的并发集合,而不要自己使用
synchronized
关键字加锁。CopyOnWriteArrayList
是一种写时复制的并发容器,类似ConcurrentArrayList
。如果类库使用者可以外部同步,那么设计类的时候就不要设计成内部同步的(这是C++的设计原则之一——效率最高)。如果修改了静态域,由于外部用户无法自己加锁,因此类的内部必须加锁。尽量不要从同步区域内部调用外来方法。 executor
和task
优先于线程。这点类似于C#,尽量不要使用抽象程度较低的线程,而是更加漂亮简洁的其他高级类。在java.lang.concurrent
里包含了已经封装好的比较通用的线程模型,尽量使用它们而不是自己去写。- 并发工具优先于
wait
和notify
。类似上一条,优先使用高级工具。 - 慎用延迟初始化。本条目给出了几个好的建议:
- 正常初始化优先于延迟初始化;
- 使用同步方法;
- 如果需要性能优化,静态域延迟初始化使用一个static class作为holder,这样在第一次访问这个静态类时,所需要的域才会被初始化;
- 如果需要性能优化,实例域延迟初始化最好使用双重检查模式。这时候域必须被声明为
volatile
的,而且习惯上使用一个局部变量来优化对域的检查。
- 不要使用
Thread.yield
,直接用Thread.sleep(1)
就好。java的线程调度器不可靠,不要使用线程优先级来调度。 - 不要使用java线程组,这个技术已经过时了…
序列化
所谓序列化,指的是将一个对象编码成字节流,一般用来持久化。
- 谨慎实现
Serializable
接口。