46 优先考虑流中无副作用的函数
我们知道Stream流是在源数据的基础上生成独立的元素流,操作流不会影响源集合。因此我们在传入函数对象操作流时也应该传入无副作用的函数,看一个反例,统计文件中单词次数:
Map<String, Long> freq = new HashMap<>(); try (Stream<String> words = new Scanner(file).tokens()) { words.forEach(word -> { freq.merge(word.toLowerCase(), 1L, Long::sum); }); } //上面的做法是在终止操作foreach里使用merge
merge(k,v,function)的作用是,来一个元素 ,则通过k方法生成键,通过v方法生成value,k键重复则使用function操作两个value生成新value;上述的做法是将元素转为小写,再拿 1 作为value表示这个单词出现了1次,冲突时将两个值相加作为新值。
例如:现在有单词 sq,第一次时则生成 sq-1 键值对,又来一个sq则再试图生成 sq-1 键值对但是冲突了,于是拿新值:1,旧值:1,通过Long::sum相加生成2作为sq的新value,即生成sq-2键值对。
以上解释好像说明代码没有错,能完成单词统计功能。但是这里的缺点在于lambda里修改了外部状态即单词频率表,这是属于副作用,foreach作为一个终止操作应该仅仅是报告结果而不是去执行计算影响外部,如将流的元素一个个打印、将这些元素添加到某个集合。
我们应该这样做:
Map<String, Long> freq; try (Stream<String> words = new Scanner(file).tokens()) { freq = words .collect(groupingBy(String::toLowerCase, counting())); }
将操作放到collect里,最后收集成一个新Map对象,将运算执行这一步作用与收集的新元素。
注意:操作流元素不影响源数据是指操作集合对象,比如向集合添加、删除元素是没影响的;但是修改集合里的元素是会影响源集合的元素的内容的,这也是不要使用副作用函数的原因。 就像给函数传递引用,函数体里修改引用本身不会影响原引用内容(传的副本),但是根据这个引用找到对象去修改对象的内容,那么原引用指向的对象毫无疑问被改了。lambda可以看作拷贝了一个集合,但这个新集合装的引用指向的对象还是之前的那些。
总之,不要在终结操作里执行运算而应该只是展示结果,多用收集器完成运算不会有副作用。
47 优先使用Collection而不是Stream来作为方法的返回类型
前面说了:流不能完全取代迭代,迭代有它的用武之地。那么,我们编写一个方法时是应该返回一个流,还是应该返回一个可迭代的集合呢?书中给出了答案:返回一个Collection是最好的,因为Collection是Iterable的子类型,代表返回的对象可以迭代,而且Collection有stream方法,可以支持流操作。用Collection作为返回类型,无论调用者想做什么操作都可以。
当然,如果返回的元素个数很小,那么最好返回一个标准的集合,如ArrayList。如果返回的集合较大还可以自定义集合,例如输入一个集合{a,b},返回它的幂集:{{},{a},{b},{a,b}}。可以发现,n个元素集合的幂集个数是2^n次方个,于是我们可以利用位向量来存储这个幂集,用索引的第n位是否为1表示有没有第n个元素。例如:get(3),3 = 11(2进制),第一个元素有第二个元素也有,说明要返回{a,b}。不用真的生成2^n个元素的集合。
总之,方法返回的元素很少就把返回值设为标准集合,很大时看看有没有其他处理办法自定义集合。如果都不行,优先设置返回类型为Collection。
48 谨慎使用流并行
在流处理中,有一个方法parallel,表示并行的去处理元素。但是这个方法一定不能滥用,很多情况使用它并行的处理元素,不会提高处理速度反而会降低,严重的甚至长时间无响应。通常,ArrayList、HashMap、HashSet和ConcurrentHashMap实例、 数组、int类型范围和long类型的范围的元素使用流并行效果最好,因为这些类型的元素方便拆分可以很轻易的划分到多个线程,而且它们的引用存放密集,线程可以快速的读取。
不要使用流并行,一定要用的话要有充足的理由正确性并严格测试,才能作为生产代码。(流并行危险的详细原因书中也没有多说)
49 检查参数有效性
参数类型检查是很有必要的,可以在运行时检查参数的有效性,使参数符合常识逻辑、业务逻辑。例如非null、越界、非法参数等。没有参数有效性检查则出错时没有有用的信息可查,会抛出一些我们无法定位的异常,或者产生错误结果,最坏的是它正常返回但是将某些对象置为受损状态,为后续的程序执行埋下隐患。目前,Obejcts类已经有很多参数检查的方法,Objects.requireNonNUll(..)。
如果一个方法被设计为私有,只是自己使用那必就不比进行检查,使用断言即可,参数非法直接抛出AssertError。
当然,不是所有时候都要进行参数检查,如果检查成本过高可以再运算中隐形检查,即发现参数非法会直接抛异常。例如 Collections.sort(list){...},就不应该一个一个检查元素类型正确性,而是一旦有两个元素类型不一致就抛出ClassCastException。使用隐性检查会抛出一些不在文档记录中的异常,因此还需要进行异常翻译。
但是也不要认为对参数的限制越多越好,相反应该尽量使方法变的同用,能够处理更多的参数。总之,编写方法一定要考虑方法参数的业务有哪些限制、符合常识,这些工作是非常值得的。
50 进行防御性拷贝
想建一个不可变的类,首先把类设为final,然后把所有属性设为private,然后不对这些属性提供公共的set方法。以上看来好像是万无一失了(不考虑反射)。但是还要注意类的构造方法,通过构造方法接收外部对象赋给属性生成的对象,是会跟外部对象一起改变的。
所以我们需要根据需要设置防御性拷贝。例如:String类是不可变的,所以构造方法源码是:
String(char [] value){ this.value = Array.copyOf(value,value.length); //在接收外部对象时根据其内容拷贝重新生成新对象赋给自己的属性 }
如果构造方法没有防御性拷贝,像这样直接将传入的对象赋给自己的属性:
String(char [] value){ this.value = value; //直接赋值,不进行防御性拷贝 }
那么,看下面语句:
char[] ch = {'s','q','6'}; String s = new String (ch); //传入数组对象生成字符串 ch[0] = 'm'; //再执行修改数组对象的操作,那么字符串s的值也会改变; s 的内容就变成了 "mq6"
所以,平时使用某些数据结构需要传入外部对象,那么就要想清楚能否接收此结构或对象受外部对象的改变影响,如果不能接受,就进行防御性拷贝,生成新对象赋给属性。
51 仔细设计方法
仔细的设计方法。这一条主要是对与编写方法时的一些建议。
方法名应该通俗易懂、不宜过长;对一个完整的方法、且这个功能频繁使用时才考虑设计成一个方法,不能设计过多的简短方法; 参数列表应该不多于4个(尤其类型相同时还出很大,调用时顺序穿错也正常编译),多于4个可以考虑拆分方法、包装成类等待;参数类型优先使用接口而不是类,更灵活。
52 明智审慎的使用重载
重写是规范,重载是特里。重载是编译时多态,编译完成即确定了要调用哪个方法。重写则是运行时多态,跑起来时根据对象的类型选择哪个类的方法来执行。编译时确定调用方法是完全根据参数列表来的,所以有时会出现一些与我们直觉不一样的情况。如:
public class CollectionClassifier { 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 ArrayList<BigInteger>(), new HashMap<String, String>().values()}; for (Collection<?> c : collections) System.out.println(classify(c)); } } //打印结果不是:"List" "Unknown Collection",而是"Unknown Collection" "Unknown Collection"。
这是因为collections数组的每个元素,编译时类型都是Collection,所以选择方法时选择调用classify(Collection<?> c) 。此外,有了基本类型的包装类型后,也会因为重载产生混淆:HashSet<Integer> set = new HashSet<>(); set.remove(1);到底是删除索引位置1的元素还是删除元素1?(实际会选择删除元素1)。
所以,只有参数类型不同时应该避免重载(起个新名),通过参数个数倒是可以使用重载。另外构造方法名字必须相同,但也可以使用起新名的静态方法代替。
53 明智审慎地使用可变参数
可变参数表示接收0个或多个某类型的参数,底层是数组实现极具灵活性。但是也有缺点,比如没有对输入参数的编译时限制例如某方法参数个数不能为0个,这就需要再方法体内部判断数组长度是否为0,运行时抛出异常。有一个技巧就是使用一个固定参数,加一个可变参数。func(int first,int ...args){...}
还有一个缺点是每次调用需要初始化数组,算是一个性能浪费。对此,需要分析大部分情况下会传几个参数,例如95%情况传一个参数,那么我可以写一个单个参数的方法 func (int a) {},剩下5%调用再重载一个方法 func(int a,int ... args){...}。
总之,可变参数很有用,但是不能乱用,用的时候还要分析情况优化。
54 返回空数组或集合,而不是null
编写方法时,如果返回的集合里没有任何元素,那也应该返回一个集合对象,而不是返回null。返回null会带来很多不必要的麻烦,让方法变的难以使用,如果调用者没有注意处理null还会在运行时引起空指针异常。不要担心那多出来的一点点开销。
55 明智审慎的返回Optional
前面说数组和集合最好返回空而不是null,那么对于返回单个对象的方法有什么可以代替返回null呢?那就是Optional,这个类可以看成是只装一个对象的容器,使用Optional.of(value)返回包装好的对象,使用get()方法获取原对象。不要使用Optional.of(null),这样会抛出空指针异常。如果没有结果返回,调用Optional.empty()即可。
使用注意:Optional只适合在方法返回值经常没有的情况下但调用方法还必须对这种情况处理时使用; 集合、数组毫无疑问不适合用Optianal,多此一举; 此外,包装一层毕竟会有一些性能影响,因此对于性能要求高的地方也不应该使用它; 而返回Optional<Integer>更是经过了两层装箱,因此代价十分高,为此设计人员专门设计了OptinalInt()、OptionalDouble()为int等提供服务,所以要Optional(10)而不是Optional<Integer>(10);
56 为所有已公开的api元素编写文档注释
这一条主要是强调文档注释的重要性,以及几个注意点,我这里不详细总结了。主要是要为代码每个元素都要加注释,类、方法、构造方法(不要用默认构造,没法写注释)、接口等,注释可以用html的标签,javadoc会生成html版的注释文档。此外,对于使用泛型的元素要为每个类型参数写注释,对于枚举类型应该为每个常量写注释。
注释可以随意使用html,但对于html的元字符需要转义。
57 最小化局部变量的作用域
最小化局部变量的作用域无疑可以很好的降低出错概率,增强代码可读性和可维护性。我们应该仅仅在首次使用某个局部变量之前定义它,这样代码简洁易读。变量在声明时往往应该对其进行初始化,除非赋值的是一个表达式而这个表达式计算可能会有异常(需要放到try中),这时可以将声明和初始化分开。
在使用循环时要优先使用for,而不是while,因为for可以声明仅仅在循环快内的有效的变量,十分安全。为了局部变量的正确安全使用,最应该注意的是保证方法的小而几种,一个方法只专注于一种行为这样出错概率能降低许多。
58 foreach循环优于for
虽然作用一样,但是foreach是把一个集合中的元素逐个拿出来操作,元素的赋值在开始时就自动进行了。这意味着我们不会得到IndexOutOfBounds异常、且代码也不会被条件判断混乱,而且相比于for还没有性能损失。所以一般推荐使用foreach。
但是也有几种情况只能用for,例如有损过滤删除集合中某元素、替换集合中某元素、或并行迭代需要显示控制迭代器/索引。总之就是需要那到集合本身引用做一些事,foreach办不到,它只能获取每个元素。
除了数组、集合,foreach可以作用于实现了Iterable接口的类,因为实现了这个接口的类就有获取迭代器的方法,foreach就是通过迭代器实现的。
59 了解并使用库
这一条主要是告诉我们尽量使用库来完成一些功能。一般来说,库函数久经考验和优化,稳定性和性能不是一个人短时间内可以做到的,所以使用库函数是一个非常号的选择。一般而言,编程时遇到一个比较通用的问题就应该意识到是否有现成的库函数可用,如果自己不知道就去查阅一下文档。对于java.lang,java.util,java.io这几个包及子包应该是要重点熟练掌握。
总之,不要白费力气重复造轮子,这并不能体现程序员的能力。
60 若需要精确答案就应避免使用float和double类型
毫无疑问,机器运算浮点数精度是有限的,对于一些要求严格的场景如货币运算,不应该用flot、double,四舍五入也不能完美的解决问题。推荐使用整型,BigDecimal进行货币运算。
如果小数位数要求不超过9为可以乘10^n转化为int运算,如果小于18位可以转化为long。也可以使用BigDecimal,这个类的构造方法入参是String,不会丢失精度,BigDecimal还可以在执行舍入操作时自己选择八种模式中的一种。但是BigDecimal运算较为麻烦,需要转换来转换去,当然,BigDecimal涉及对象的创建与转换所以运算的性能相比基本类型要差不少。
总之,浮点数精确运算性能要求高且小数位小于18可以转换位整型运算,如果小数位数太大或者对于运算性能要求不高可以选择BigDecimal。