编写高质量代码:改善Java程序的151个建议(txt+pdf+epub+mobi电子书下载)


发布时间:2020-09-22 03:41:51

点击下载

作者:秦小波

出版社:机械工业出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

编写高质量代码:改善Java程序的151个建议

编写高质量代码:改善Java程序的151个建议试读:

前言

从决定撰写本书到完稿历时9个月,期间曾经遇到过种种困难和挫折,但这个过程让我明白了坚持的意义,明白了“行百里者半九十”的寓意——坚持下去,终于到了写前言的时刻。

为什么写这本书

从第一次敲出"Hello World"到现在已经有15年时间了,在这15年里,我当过程序员和架构师,也担任过项目经理和技术顾问——基本上与技术沾边的事情都做过。从第一次接触Java到现在,已经有11年4个月了,在这些年里,我对Java可谓是情有独钟,对其编程思想、开源产品、商业产品、趣闻轶事、风流人物等都有所了解和研究。对于Java,我非常感激,从物质上来说,它给了我工作,帮助我养家糊口;从精神上来说,它带给我无数的喜悦、困惑、痛苦和无奈——一如我们的生活。

我不是技术高手,只是技术领域的一个拓荒者,我希望能把自己的知识和经验贡献出来,以飨读者。在写作的过程中,我也反复地思考:我为谁而写这本书?为什么要写?

希望本书能帮您少走弯路

您是否曾经为了提供一个"One Line"的解决方案而彻夜地查看源代码?现在您不用了。

您是否曾经为了理解某个算法而冥思苦想、阅览群书?现在您不用了。

您是否曾经为了提升0.1秒的性能而对N种实现方案进行严格测试和对比?现在您不用了。

您是否曾经为了避免多线程死锁问题而遍寻高手共同诊治?现在您不用了。

……

在学习和使用Java的过程中您是否在原本可以很快掌握或解决的问题上耗费了大量的时间和精力?也许您现在不用了,本书的很多内容都是我用曾经付出的代价换来的,希望它能帮助您少走弯路!

希望本书能帮您打牢基础

那些所谓的架构师、设计师、项目经理、分析师们,已经有多长时间没有写过代码了?代码是一切的基石,我不太信任连"Hello World"都没有写过的架构师。看看我们软件界的先辈们吧,Dennis M.Ritchie决定创造一门“看上去很好”的语言时,如果只是站在高处呐喊,这门语言是划时代的,它有多么优秀,但不去实现,又有何用呢?没有Dennis M.Ritchie的亲自编码实现,C语言不可能诞生,UNIX操作系统也不可能诞生。Linux在聚拢成千上万的开源狂热者对它进行开发和扩展之前,如果没有Linus的编码实现,仅凭他高声呐喊“我要创造一个划时代的操作系统”,有用吗?一切的一切都是以编码实现为前提的,代码是我们前进的基石。

这是一个英雄辈出的年代,我们每个人都希望自己被顶礼膜拜,可是这需要资本和实力,而我们的实力体现了我们处理技术问题的能力:

你能写出简单、清晰、高效的代码?——Show it!

你能架构一个稳定、健壮、快捷的系统?——Do it!

你能回答一个困扰N多人的问题?——Answer it!

你能修复一个系统Bug?——Fix it!

你非常熟悉某个开源产品?——Broadcast it!

你能提升系统性能?——Tune it!

……

但是,“工欲善其事,必先利其器”,在“善其事”之前,先看看我们的“器”是否已经磨得足够锋利了,是否能够在我们前进的路上披荆斩棘。无论您将来的职业发展方向是架构师、设计师、分析师、管理者,还是其他职位,只要您还与软件打交道,您就有必要打好技术基础。本书对核心的Java编程技术进行了凝练,如果能全部理解并付诸实践,您的基础一定会更加牢固。

希望本书能帮您打造一支技术战斗力强的团队

在您的团队中是否出现过以下现象:

没有人愿意听一场关于编码奥秘的讲座,他们觉得这是浪费时间;

没有人愿意去思考和探究一个算法,他们觉得这实在是多余,Google完全可以解决;

没有人愿意主动重构一段代码,他们觉得新任务已经堆积成山了,“没有坏,就不要去修它”;

没有人愿意格式化一下代码,即便只需要按一下【Ctrl+Shift+F】快捷键,他们觉得代码写完就完了,何必再去温习;

没有人愿意花时间去深究一下开源框架,他们觉得够用就好;

……

一支有实力的软件研发团队是建立在技术的基础之上的,团队成员之间需要经常地互相交流和切磋,尤其是基于可辨别、可理解的编码问题。不可否认,概念和思想也很重要,但我更看重基于代码的交流,因为代码不会说谎,比如SOA,10个人至少会有5个答案,但代码就不同了,同样的代码,结果只有一个,要么是错的,要么是对的,这才是一个技术团队应该有的氛围。本书中提出的这些问题绝大部分可能都是您的团队成员在日常的开发中会遇到的,我针对这些问题给出的建议不是唯一的解决方案,也许您的团队在讨论这一个个问题的时候能有更好的解决办法。希望通过对本书中的这些问题的争辩、讨论和实践能全面提升每一位团队成员的技术实力,从而增强整个团队的战斗力!

本书特色

深。本书不是一本语法书,它不会教您怎么编写Java代码,但是它会告诉您,为什么StringBuilder会比String类效率高,HashMap的自增是如何实现的,为什么并行计算一般都是从Executors开始的……不仅仅告诉您How(怎么做),而且还告诉您Why(为什么要这样做)。

广。涉及面广,从编码规则到编程思想,从基本语法到系统框架,从JDK API到开源产品,全部都有涉猎,而且所有的建议都不是纸上谈兵,都与真实的场景相结合。

点。讲解一个知识点,而不是一个知识面,比如多线程,这里不提供多线程的解决方案,而是告诉您如何安全地停止一个线程,如何设置多线程关卡,什么时候该用lock,什么时候该用synchronize,等等。

精。简明扼要,直捣黄龙,一个建议就是对一个问题的解释和说明,以及提出相关的解决方案,不拖泥带水,只针对一个知识点进行讲解。

畅。本书延续了我一贯的写作风格,行云流水,娓娓道来,每次想好了一个主题后,都会先打一个腹稿,思考如何讲才能更流畅。本书不是一本很无趣的书,我一直想把它写得生动和优雅,但Code就是Code,很多时候容不得深加工,最直接也就是最简洁的。

这是一本建议书,想想看,在您写代码的时候,有这样一本书籍在您的手边,告诉您如何才能编写出优雅而高效的代码,那将是一件多么惬意的事情啊!

本书面向的读者

寻找"One Line"(一行)解决方案的编码人员。

希望提升自己编码能力的程序员。

期望能够在开源世界仗剑而行的有志之士。

对编码痴情的人。

总之,只要还在Java圈子里混就有必要阅读本书,不管是程序员、测试人员、分析师、架构师,还是项目经理,都有必要。

如何阅读本书

首先声明,本书不是面向初级Java程序员的,在阅读本书之前至少要对基本的Java语法有初步了解,最好是参与过几个项目,写过一些代码,具备了这些条件,阅读本书才会有更大的收获,才会觉得是一种享受。

本书的各个章节和各个建议都是相对独立的,所以,您可以从任何章节的任何建议开始阅读。强烈建议您将它放在办公桌旁,遇到问题时随手翻阅。

本书附带有大量的源码(下载地址见华章网站www.hzbook.com),建议大家在阅读本书时拷贝书中的示例代码,放到自己的收藏夹中,以备需要时使用。

勘误与支持

首先,我要为书中可能出现的错别字、多意句、歧义句、代码缺陷等错误向您真诚地道歉。虽然杨福川、杨绣国两位编辑和我都为此书付出了非常大的努力,但可能还是会有一些瑕疵,如果你在阅读本书时发现错误或有问题想一起讨论,请发邮件(cbf4life@126.com)给我,我会尽快给您回复。

本书的所有勘误,我都会发表在我的个人博客(http://cbf4life.iteye.com/)上。

致谢

首先,感谢杨福川和杨绣国两位编辑,在他们的编审下,本书才有了一个质的飞跃,没有他们的计划和安排,本书不可能出版。

其次,感谢家人的支持,为了写这本书,用尽了全部的休息时间,很少有时间陪伴父母和妻儿,甚至连吃一顿团圆饭都成了奢望,他们的大力支持让我信心满怀、干劲十足。儿子已经6岁了,明白骑在爸爸身上是对爸爸的折磨,也知道玩具是可以从网上买到的,“爸爸,给我买一个变形金刚……你在网上查呀……今天一定要买……”儿子在不知不觉中长大了。

再次,感谢交通银行“531”工程的所有领导和同事,是他们让我在这样超大规模的工程中学习和成长,使自己的技术和技能有了长足的进步;感谢我的领导李海宁总经理和周云康高级经理,他们时时迸发出的闪光智慧让我受益匪浅;感谢软件开发中心所有同仁对我的帮助和鼓励!

最后,感谢我的朋友王骢,他无偿地把钥匙给我,让我有一个安静的地方思考和写作,有这样的朋友,人生无憾!

当然,还要感谢您,感谢您对本书的关注。

再次对本书中可能出现的错误表示歉意,真诚地接受大家的“轰炸”!秦小波2011年8月于上海第1章 Java开发中通用的方法和准则

The reasonable man adapts himself to the world;the unreasonable one persists in trying to adapt the world to himself.

明白事理的人使自己适应世界;不明事理的人想让世界适应自己。——萧伯纳

Java的世界丰富又多彩,但同时也布满了荆棘陷阱,大家一不小心就可能跌入黑暗深渊,只有在了解了其通行规则后才能使自己在技术的海洋里遨游飞翔,恣意驰骋。“千里之行始于足下”,本章主要讲述与Java语言基础有关的问题及建议的解决方案,例如常量和变量的注意事项、如何更安全地序列化、断言到底该如何使用等。建议1:不要在常量和变量中出现易混淆的字母

包名全小写,类名首字母全大写,常量全部大写并用下划线分隔,变量采用驼峰命名法(Camel Case)命名等,这些都是最基本的Java编码规范,是每个Javaer都应熟知的规则,但是在变量的声明中要注意不要引入容易混淆的字母。尝试阅读如下代码,思考一下打印出的i等于多少:

public class Client { public static void main(String[] args) { long i = 1l; System.out.println("i的两倍是:" + (i+i)); } }

肯定有人会说:这么简单的例子还能出错?运行结果肯定是22!实践是检验真理的唯一标准,将其拷贝到Eclipse中,然后Run一下看看,或许你会很奇怪,结果是2,而不是22,难道是Eclipse的显示有问题,少了个“2”?

因为赋给变量i的数字就是“1”,只是后面加了长整型变量的标示字母"l"而已。别说是我挖坑让你跳,如果有类似程序出现在项目中,当你试图通过阅读代码来理解作者的思想时,此情此景就有可能会出现。所以,为了让您的程序更容易理解,字母"l"(还包括大写字母"O")尽量不要和数字混用,以免使阅读者的理解与程序意图产生偏差。如果字母和数字必须混合使用,字母"l"务必大写,字母"O"则增加注释。

注意 字母"l"作为长整型标志时务必大写。建议2:莫让常量蜕变成变量

常量蜕变成变量?你胡扯吧,加了final和static的常量怎么可能会变呢?不可能二次赋值的呀。真的不可能吗?看我们神奇的魔术,代码如下:

public class Client { public static void main(String[] args) { System.out.println("常量会变哦:" + Const.RAND_CONST); } } /*接口常量*/ interface Const{ //这还是常量吗? public static final int RAND_CONST = new Random().nextInt(); }

RAND_CONST是常量吗?它的值会变吗?绝对会变!这种常量的定义方式是极不可取的,常量就是常量,在编译期就必须确定其值,不应该在运行期更改,否则程序的可读性会非常差,甚至连作者自己都不能确定在运行期发生了何种神奇的事情。

甭想着使用常量会变的这个功能来实现序列号算法、随机种子生成,除非这真的是项目中的唯一方案,否则就放弃吧,常量还是当常量使用。

注意 务必让常量的值在运行期保持不变。建议3:三元操作符的类型务必一致

三元操作符是if-else的简化写法,在项目中使用它的地方很多,也非常好用,但是好用又简单的东西并不表示就可以随便用,我们来看看下面这段代码:

public class Client { public static void main(String[] args) { int i = 80; String s = String.valueOf(i<100?90:100); String s1 = String.valueOf(i<100?90:100.0); System.out.println("两者是否相等:"+s.equals(s1)); } }

分析一下这段程序:i是80,那它当然小于100,两者的返回值肯定都是90,再转成String类型,其值也绝对相等,毋庸置疑的。恩,分析得有点道理,但是变量s中三元操作符的第二个操作数是100,而s1的第二个操作数是100.0,难道没有影响吗?不可能有影响吧,三元操作符的条件都为真了,只返回第一个值嘛,与第二个值有一毛钱的关系吗?貌似有道理。

果真如此吗?我们通过结果来验证一下,运行结果是:“两者是否相等:false”,什么?不相等,Why?

问题就出在了100和100.0这两个数字上,在变量s中,三元操作符中的第一个操作数(90)和第二个操作数(100)都是int类型,类型相同,返回的结果也就是int类型的90,而变量s1的情况就有点不同了,第一个操作数是90(int类型),第二个操作数却是100.0,而这是个浮点数,也就是说两个操作数的类型不一致,可三元操作符必须要返回一个数据,而且类型要确定,不可能条件为真时返回int类型,条件为假时返回float类型,编译器是不允许如此的,所以它就会进行类型转换了,int型转换为浮点数90.0,也就是说三元操作符的返回值是浮点数90.0,那这当然与整型的90不相等了。这里可能有读者疑惑了:为什么是整型转为浮点,而不是浮点转为整型呢?这就涉及三元操作符类型的转换规则:

若两个操作数不可转换,则不做转换,返回值为Object类型。

若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换,int类型转换为long类型,long类型转换为float类型等。

若两个操作数中有一个是数字S,另外一个是表达式,且其类型标示为T,那么,若数字S在T的范围内,则转换为T类型;若S超出了T类型的范围,则T转换为S类型(可以参考“建议22”,会对该问题进行展开描述)。[1]

若两个操作数都是直接量数字(Literal),则返回值类型为范围较大者。

知道是什么原因了,相应的解决办法也就有了:保证三元操作符中的两个操作数类型一致,即可减少可能错误的发生。

[1] “ Literal”也译作“字面量”。建议4:避免带有变长参数的方法重载

在项目和系统的开发中,为了提高方法的灵活度和可复用性,我们经常要传递不确定数量的参数到方法中,在Java 5之前常用的设计技巧就是把形参定义成Collection类型或其子类类型,或者是数组类型,这种方法的缺点就是需要对空参数进行判断和筛选,比如实参为null值和长度为0的Collection或数组。而Java 5引入变长参数(varags)就是为了更好地提高方法的复用性,让方法的调用者可以“随心所欲”地传递实参数量,当然变长参数也是要遵循一定规则的,比如变长参数必须是方法中的最后一个参数;一个方法不能定义多个变长参数等,这些基本规则需要牢记,但是即使记住了这些规则,仍然有可能出现错误,我们来看如下代码:

public class Client { //简单折扣计算 public void calPrice(int price,int discount){ float knockdownPrice =price * discount / 100.0F; System.out.println("简单折扣后的价格是:"+formateCurrency(knockdownPrice)); } //复杂多折扣计算 public void calPrice(int price,int... discounts){ float knockdownPrice = price; for(int discount:discounts){ knockdownPrice = knockdownPrice * discount / 100; } System.out.println("复杂折扣后的价格是:" +formateCurrency(knockdownPrice)); } //格式化成本的货币形式 private String formateCurrency(float price){ return NumberFormat.getCurrencyInstance().format(price/100); } public static void main(String[] args) { Client client = new Client(); //499元的货物,打75折 client.calPrice(49900, 75); } }

这是一个计算商品价格折扣的模拟类,带有两个参数的calPrice方法(该方法的业务逻辑是:提供商品的原价和折扣率,即可获得商品的折扣价)是一个简单的折扣计算方法,该方法在实际项目中经常会用到,这是单一的打折方法。而带有变长参数的calPrice方法则是较复杂的折扣计算方式,多种折扣的叠加运算(模拟类是一种比较简单的实现)在实际生活中也是经常见到的,比如在大甩卖期间对VIP会员再度进行打折;或者当天是你的生日,再给你打个9折,也就是俗话说的“折上折”。

业务逻辑清楚了,我们来仔细看看这两个方法,它们是重载吗?当然是了,重载的定义是“方法名相同,参数类型或数量不同”,很明显这两个方法是重载。但是再仔细瞧瞧,这个重载有点特殊:calPrice(int price, int...discounts)的参数范畴覆盖了calPrice(int price, int discount)的参数范畴。那问题就出来了:对于calPrice(49900,75)这样的计算,到底该调用哪个方法来处理呢?

我们知道Java编译器是很聪明的,它在编译时会根据方法签名(Method Signature)来确定调用哪个方法,比如calPrice(499900,75,95)这个调用,很明显75和95会被转成一个包含两个元素的数组,并传递到calPrice(int price, in..discounts)中,因为只有这一个方法签名符合该实参类型,这很容易理解。但是我们现在面对的是calPrice(49900,75)调用,这个“75”既可以被编译成int类型的“75”,也可以被编译成int数组“{75}”,即只包含一个元素的数组。那到底该调用哪一个方法呢?

我们先运行一下看看结果,运行结果是:

简单折扣后的价格是:¥374.25。

看来是调用了第一个方法,为什么会调用第一个方法,而不是第二个变长参数方法呢?因为Java在编译时,首先会根据实参的数量和类型(这里是2个实参,都为int类型,注意没有转成int数组)来进行处理,也就是查找到calPrice(int price, int discount)方法,而且确认它是否符合方法签名条件。现在的问题是编译器为什么会首先根据2个int类型的实参而不是1个int类型、1个int数组类型的实参来查找方法呢?这是个好问题,也非常好回答:因为int是一个原生数据类型,而数组本身是一个对象,编译器想要“偷懒”,于是它会从最简单的开始“猜想”,只要符合编译条件的即可通过,于是就出现了此问题。

问题是阐述清楚了,为了让我们的程序能被“人类”看懂,还是慎重考虑变长参数的方法重载吧,否则让人伤脑筋不说,说不定哪天就陷入这类小陷阱里了。建议5:别让null值和空值威胁到变长方法

上一建议讲解了变长参数的重载问题,本建议还会继续讨论变长参数的重载问题。上一建议的例子是变长参数的范围覆盖了非变长参数的范围,这次我们从两个都是变长参数的方法说起,代码如下:

public class Client { public void methodA(String str,Integer... is){ } public void methodA(String str,String... strs){ } public static void main(String[] args) { Client client = new Client(); client.methodA("China", 0); client.methodA("China", "People"); client.methodA("China"); client.methodA("China",null); } }

两个methodA都进行了重载,现在的问题是:上面的代码编译通不过,问题出在什么地方?看似很简单哦。

有两处编译通不过:client.methodA("China")和client.methodA("China",null),估计你已经猜到了,两处的提示是相同的:方法模糊不清,编译器不知道调用哪一个方法,但这两处代码反映的代码味道可是不同的。

对于methodA("China")方法,根据实参"China"(String类型),两个方法都符合形参格式,编译器不知道该调用哪个方法,于是报错。我们来思考这个问题:Client类是一个复杂的商业逻辑,提供了两个重载方法,从其他模块调用(系统内本地调用或系统外远程调用)时,调用者根据变长参数的规范调用,传入变长参数的实参数量可以是N个(N>=0),那当然可以写成client.methodA("china")方法啊!完全符合规范,但是这却让编译器和调用者都很郁闷,程序符合规则却不能运行,如此问题,谁之责任呢?是Client类的设计者,他违反了KISS原则(Keep It Simple, Stupid,即懒人原则),按照此规则设计的方法应该很容易调用,可是现在在遵循规范的情况下,程序竟然出错了,这对设计者和开发者而言都是应该严禁出现的。

对于client.methodA("china",null)方法,直接量null是没有类型的,虽然两个methodA方法都符合调用请求,但不知道调用哪一个,于是报错了。我们来体会一下它的坏味道:除了不符合上面的懒人原则外,这里还有一个非常不好的编码习惯,即调用者隐藏了实参类型,这是非常危险的,不仅仅调用者需要“猜测”该调用哪个方法,而且被调用者也可能产生内部逻辑混乱的情况。对于本例来说应该做如下修改:

public static void main(String[] args) { Client client = new Client(); String[] strs = null; client.methodA("China",strs); }

也就是说让编译器知道这个null值是String类型的,编译即可顺利通过,也就减少了错误的发生。建议6:覆写变长方法也循规蹈矩

在Java中,子类覆写父类中的方法很常见,这样做既可以修正Bug也可以提供扩展的业务功能支持,同时还符合开闭原则(Open-Closed Principle),我们来看一下覆写必须满足的条件:

重写方法不能缩小访问权限。

参数列表必须与被重写方法相同。

返回类型必须与被重写方法的相同或是其子类。

重写方法不能抛出新的异常,或者超出父类范围的异常,但是可以抛出更少、更有限的异常,或者不抛出异常。

估计你已经猜测出下面要讲的内容了,为什么“参数列表必须与被重写方法的相同”采用不同的字体,这其中是不是有什么玄机?是的,还真有那么一点点小玄机。参数列表相同包括三层意思:参数数量相同、类型相同、顺序相同,看上去好像没什么问题,那我们来看一个例子,业务场景与上一个建议相同,商品打折,代码如下:

public class Client { public static void main(String[] args) { //向上转型 Base base = new Sub(); base.fun(100, 50); //不转型 Sub sub = new Sub(); sub.fun(100, 50); } } //基类 class Base{ void fun(int price,int... discounts){ System.out.println("Base......fun"); } } //子类,覆写父类方法 class Sub extends Base{ @Override void fun(int price,int[] discounts){ System.out.println("Sub......fun"); } }

请问:该程序有问题吗?——编译通不过。那问题出在什么地方呢?

@Override注解吗?非也,覆写是正确的,因为父类的calPrice编译成字节码后的形参是一个int类型的形参加上一个int数组类型的形参,子类的参数列表也与此相同,那覆写是理所当然的了,所以加上@Override注解没有问题,只是Eclipse会提示这不是一种很好的编码风格。

难道是"sub.fun(100,50)"这条语句?正解,确实是这条语句报错,提示找不到fun(int,int)方法。这太奇怪了:子类继承了父类的所有属性和方法,甭管是私有的还是公开的访问权限,同样的参数、同样的方法名,通过父类调用没有任何问题,通过子类调用却编译通不过,为啥?难道是没继承下来?或者子类缩小了父类方法的前置条件?那如果是这样,就不应该覆写,@Override就应该报错,真是奇妙的事情!

事实上,base对象是把子类对象Sub做了向上转型,形参列表是由父类决定的,由于是变长参数,在编译时,"base.fun(100,50)"中的“50”这个实参会被编译器“猜测”而编译成“{50}”数组,再由子类Sub执行。我们再来看看直接调用子类的情况,这时编译器并不会把“50”做类型转换,因为数组本身也是一个对象,编译器还没有聪明到要在两个没有继承关系的类之间做转换,要知道Java是要求严格的类型匹配的,类型不匹配编译器自然就会拒绝执行,并给予错误提示。

这是个特例,覆写的方法参数列表竟然与父类不相同,这违背了覆写的定义,并且会引发莫名其妙的错误。所以读者在对变长参数进行覆写时,如果要使用此类似的方法,请找个小黑屋仔细想想是不是一定要如此。

注意 覆写的方法参数与父类相同,不仅仅是类型、数量,还包括显示形式。建议7:警惕自增的陷阱

记得大学刚开始学C语言时,老师就说:自增有两种形式,分别是i++和++i, i++表示的是先赋值后加1,++i是先加1后赋值,这样理解了很多年也没出现问题,直到遇到如下代码,我才怀疑我的理解是不是错了:

public class Client { public static void main(String[] args) { int count =0; for(int i=0;i<10;i++){ count=count++; } System.out.println("count="+count); } }

这个程序输出的count等于几?是count自加10次吗?答案等于10?可以非常肯定地告诉你,答案错误!运行结果是count等于0。为什么呢?

count++是一个表达式,是有返回值的,它的返回值就是count自加前的值,Java对自加是这样处理的:首先把count的值(注意是值,不是引用)拷贝到一个临时变量区,然后对count变量加1,最后返回临时变量区的值。程序第一次循环时的详细处理步骤如下:

步骤1 JVM把count值(其值是0)拷贝到临时变量区。

步骤2 count值加1,这时候count的值是1。

步骤3返回临时变量区的值,注意这个值是0,没修改过。

步骤4返回值赋值给count,此时count值被重置成0。

"count=count++"这条语句可以按照如下代码来理解:

public static int mockAdd(int count){ //先保存初始值 int temp =count; //做自增操作 count = count+1; //返回原始值 return temp; }

于是第一次循环后count的值还是0,其他9次的循环也是一样的,最终你会发现count的值始终没有改变,仍然保持着最初的状态。

此例中代码作者的本意是希望count自增,所以想当然地认为赋值给自身就成了,不曾想掉到Java自增的陷阱中了。解决方法很简单,只要把"count=count++"修改为"count++"即可。该问题在不同的语言环境有不同的实现:C++中"count=count++"与"count++"是等效的,而在PHP中则保持着与Java相同的处理方式。每种语言对自增的实现方式各不同,读者有兴趣可以多找几种语言测试一下,思考一下原理。

下次如果看到某人T恤上印着"i=i++",千万不要鄙视他,记住,能够以不同的语言解释清楚这句话的人绝对不简单,应该表现出“如滔滔江水”般的敬仰,心理默念着“高人,绝世高人哪”。建议8:不要让旧语法困扰你

N多年前接手了一个除了源码以外什么都没有的项目,没需求、没文档、没设计,原创者也已鸟兽散了,我们只能通过阅读源码来进行维护。期间,同事看到一段很“奇妙”的代码,让大家帮忙分析,代码片段如下:

public class Client { public static void main(String[] args) { //数据定义及初始化 int fee=200; //其他业务处理 saveDefault:save(fee); //其他业务处理 } static void saveDefault(){ } static void save(int fee){ } }

该代码的业务含义是计算交易的手续费,最低手续费是2元,其业务逻辑大致看懂了,但是此代码非常神奇,"saveDefault:save(fee)"这句代码在此处出现后,后续就再也没有与此有关的代码了,这做何解释呢?更神奇的是,编译竟然还没有错,运行也很正常。Java中竟然有冒号操作符,一般情况下,它除了在唯一一个三元操作符中存在外就没有其他地方可用了呀。当时连项目组里的高手也是一愣一愣的,翻语法书,也没有介绍冒号操作符的内容,而且,也不可能出现连括号都可以省掉的方法调用、方法级联啊!这也太牛了吧!

隔壁做C项目的同事过来串门,看我们在讨论这个问题,很惊奇地说“耶,Java中还有标号呀,我以为Java这么高级的语言已经抛弃goto语句了……”,一语点醒梦中人:项目的原创者是C语言转过来的开发人员,所以他把C语言的goto习惯也带到项目中了,后来由于经过N手交接,重构了多次,到我们这里goto语句已经被重构掉了,但是跳转标号还保留着,估计上一届的重构者也是稀里糊涂的,不敢贸然修改,所以把这个重任留给了我们。

goto语句中有着"double face"作用的关键字,它可以让程序从多层的循环中跳出,不用一层一层地退出,类似高楼着火了,来不及一楼一楼的下,goto语句就可以让你"biu~"的一声从十层楼跳到地面上。这点确实很好,但同时也带来了代码结构混乱的问题,而且程序跳来跳去让人看着就头晕,还怎么调试?!这样做甚至会隐祸连连,比如标号前后对象构造或变量初始化,一旦跳到这个标号,程序就不可想象了,所以Java中抛弃了goto语法,但还是保留了该关键字,只是不进行语义处理而已,与此类似的还有const关键字。

Java中虽然没有了goto关键字,但是扩展了break和continue关键字,它们的后面都可以加上标号做跳转,完全实现了goto功能,同时也把goto的诟病带了进来,所以我们在阅读大牛的开源程序时,根本就看不到break或continue后跟标号的情况,甚至是break和continue都很少看到,这是提高代码可读性的一剂良药,旧语法就让它随风而去吧!建议9:少用静态导入

从Java 5开始引入了静态导入语法(import static),其目是为了减少字符输入量,提高代码的可阅读性,以便更好地理解程序。我们先来看一个不使用静态导入的例子,也就是一般导入:

public class MathUtils{ //计算圆面积 public static double calCircleArea(double r){ return Math.PI * r * r; } //计算球面积 public static double calBallArea(double r){ return 4* Math.PI * r * r; } }

这是很简单的数学工具类,我们在这两个计算面积的方法中都引入了java.lang.Math类(该类是默认导入的)中的PI(圆周率)常量,而Math这个类写在这里有点多余,特别是如果MathUtils中的方法比较多时,如果每次都要敲入Math这个类,繁琐且多余,静态导入可解决此类问题,使用静态导入后的程序如下:

import static java.lang.Math.PI; public class MathUtils{ //计算圆面积 public static double calCircleArea(double r){ return PI * r * r; } //计算球面积 public static double calBallArea(double r){ return 4 * PI * r * r; } }

静态导入的作用是把Math类中的PI常量引入到本类中,这会使程序更简单,更容易阅读,只要看到PI就知道这是圆周率,不用每次都要把类名写全了。但是,滥用静态导入会使程序更难阅读,更难维护。静态导入后,代码中就不用再写类名了,但是我们知道类是“一类事物的描述”,缺少了类名的修饰,静态属性和静态方法的表象意义可以被无限放大,这会让阅读者很难弄清楚其属性或方法代表何意,甚至是哪一个类的属性(方法)都要思考一番(当然,IDE友好提示功能是另说),特别是在一个类中有多个静态导入语句时,若还使用了*(星号)通配符,把一个类的所有静态元素都导入进来了,那简直就是恶梦。我们来看一段例子:

import static java.lang.Double.*; import static java.lang.Math.*; import static java.lang.Integer.*; import static java.text.NumberFormat.*; public class Client { //输入半径和精度要求,计算面积 public static void main(String[] args) { double s = PI * parseDouble(args[0]); NumberFormat nf = getInstance(); nf.setMaximumFractionDigits(parseInt(args[1])); formatMessage(nf.format(s)); } //格式化消息输出 public static void formatMessage(String s){ System.out.println("圆面积是:"+s); } }

就这么一段程序,看着就让人火大:常量PI,这知道,是圆周率;parseDouble方法可能是Double类的一个转换方法,这看名称也能猜测到。那紧接着的getInstance方法是哪个类的?是Client本地类?不对呀,没有这个方法,哦,原来是NumberFormate类的方法,这和formateMessage本地方法没有任何区别了——这代码也太难阅读了,非机器不可阅读。

所以,对于静态导入,一定要遵循两个规则:

不使用*(星号)通配符,除非是导入静态常量类(只包含常量的类或接口)。

方法名是具有明确、清晰表象意义的工具类。

何为具有明确、清晰表象意义的工具类?我们来看看JUnit 4中使用的静态导入的例子,代码如下:

import static org.junit.Assert.*; public class DaoTest { @Test public void testInsert(){ //断言 assertEquals("foo", "foo"); assertFalse(Boolean.FALSE); } }

我们从程序中很容易判断出assertEquals方法是用来断言两个值是否相等的,assertFalse方法则是断言表达式为假,如此确实减少了代码量,而且代码的可读性也提高了,这也是静态导入用到正确地方所带来的好处。建议10:不要在本类中覆盖静态导入的变量和方法

如果一个类中的方法及属性与静态导入的方法及属性重名会出现什么问题呢?我们先来看一个正常的静态导入,代码如下:

import static java.lang.Math.PI; import static java.lang.Math.abs; public class Client { public static void main(String[] args) { System.out.println("PI="+PI); System.out.println("abs(100)=" +abs(-100)); } }

很简单的例子,打印出静态常量PI值,计算-100的绝对值。现在的问题是:如果我们在Client类中也定义了PI常量和abs方法,会出现什么问题?代码如下:

import static java.lang.Math.PI; import static java.lang.Math.abs; public class Client { //常量名与静态导入的PI相同 public final static String PI="祖冲之"; //方法名与静态导入的相同 public static int abs(int abs){ return 0; } public static void main(String[] args) { System.out.println("PI="+PI); System.out.println("abs(100)=" +abs(-100)); } }

以上代码中,定义了一个PI字符串类型的常量,又定义了一个abs方法,与静态导入的相同。首先说好消息:编译器没有报错,接下来是不好的消息了:我们不知道哪个属性和哪个方法被调用了,因为常量名和方法名相同,到底调用了哪一个方法呢?我们运行一下看看结果:

PI = 祖冲之 abs(100) = 0

很明显是本地的属性和方法被引用了,为什么不是Math类中的属性和方法呢?那是因为编译器有一个“最短路径”原则:如果能够在本类中查找到的变量、常量、方法,就不会到其他包或父类、接口中查找,以确保本类中的属性、方法优先。

因此,如果要变更一个被静态导入的方法,最好的办法是在原始类中重构,而不是在本类中覆盖。建议11:养成良好习惯,显式声明UID

我们编写一个实现了Serializable接口(序列化标志接口)的类,Eclipse马上就会给一个黄色警告:需要增加一个Serial Version ID。为什么要增加?它是怎么计算出来的?有什么用?本章就来解释该问题。

类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储,为系统的分布和异构部署提供先决支持条件。若没有序列化,现在我们熟悉的远程调用、对象数据库都不可能存在,我们来看一个简单的序列化类:

public class Person implements Serializable{ private String name; /*name属性的getter/setter方法省略*/ }

这是一个简单JavaBean,实现了Serializable接口,可以在网络上传输,也可以本地存储然后读取。这里我们以Java消息服务(Java Message Service)方式传递该对象(即通过网络传递一个对象),定义在消息队列中的数据类型为ObjectMessage,首先定义一个消息的生产者(Producer),代码如下:

public class Producer { public static void main(String[] args) throws Exception { Person person = new Person(); person.setName("混世魔王"); //序列化,保存到磁盘上 SerializationUtils.writeObject(person); } }

这里引入了一个工具类SerializationUtils,其作用是对一个类进行序列化和反序列化,并存储到硬盘上(模拟网络传输),其代码如下:

public class SerializationUtils { private static String FILE_NAME = "c:/obj.bin"; // 序列化 public static void writeObject(Serializable s) { try { ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_NAME)); oos.writeObject(s); oos.close(); } catch (Exception e) { e.printStackTrace(); } } public static Object readObject(){ Object obj=null; // 反序列化 try { ObjectInput input = new ObjectInputStream(new FileInputStream(FILE_NAME)); obj = input.readObject(); input.close(); } catch (Exception e) { e.printStackTrace(); } return obj; } }

通过对象序列化过程,把一个对象从内存块转化为可传输的数据流,然后通过网络发送到消息消费者(Consumer)那里,并进行反序列化,生成实例对象,代码如下:

public class Consumer { public static void main(String[] args) throws Exception { // 反序列化 Person p = (Person) SerializationUtils.readObject(); System.out.println("name="+p.getName()); } }

这是一个反序列化过程,也就是对象数据流转换为一个实例对象的过程,其运行后的输出结果为:混世魔王。这太easy了,是的,这就是序列化和反序列化典型的demo。但此处隐藏着一个问题:如果消息的生产者和消息的消费者所参考的类(Person类)有差异,会出现何种神奇事件?比如:消息生产者中的Person类增加了一个年龄属性,而消费者没有增加该属性。为啥没有增加?!因为这是个分布式部署的应用,你甚至都不知道这个应用部署在何处,特别是通过广播(broadcast)方式发送消息的情况,漏掉一两个订阅者也是很正常的。

在这种序列化和反序列化的类不一致的情形下,反序列化时会报一个InvalidClassException异常,原因是序列化和反序列化所对应的类版本发生了变化,JVM不能把数据流转换为实例对象。接着刨根问底:JVM是根据什么来判断一个类版本的呢?

好问题,通过SerialVersionUID,也叫做流标识符(Stream Unique Identifier),即类的版本定义的,它可以显式声明也可以隐式声明。显式声明格式如下:

private static final long serialVersionUID = XXXXXL;

而隐式声明则是我不声明,你编译器在编译的时候帮我生成。生成的依据是通过包名、类名、继承关系、非私有的方法和属性,以及参数、返回值等诸多因子计算得出的,极度复杂,基本上计算出来的这个值是唯一的。

serialVersionUID如何生成已经说明了,我们再来看看serialVersionUID的作用。JVM在反序列化时,会比较数据流中的serialVersionUID与类的serialVersionUID是否相同,如果相同,则认为类没有发生改变,可以把数据流load为实例对象;如果不相同,对不起,我JVM不干了,抛个异常InvalidClassException给你瞧瞧。这是一个非常好的校验机制,可以保证一个对象即使在网络或磁盘中“滚过”一次,仍能做到“出淤泥而不染”,完美地实现类的一致性。

但是,有时候我们需要一点特例场景,例如:我的类改变不大,JVM是否可以把我以前的对象反序列化过来?就是依靠显式声明serialVersionUID,向JVM撒谎说“我的类版本没有变更”,如此,我们编写的类就实现了向上兼容。我们修改一下上面的Person类,代码如下:

public class Person implements Serializable{ private static final long serialVersionUID = 55799L; /*其他保持不变*/ }

刚开始生产者和消费者持有的Person类版本一致,都是V1.0,某天生产者的Person类版本变更了,增加了一个“年龄”属性,升级为V2.0,而由于种种原因(比如程序员疏忽、升级时间窗口不同等)消费端的Person还保持为V1.0版本,代码如下:

public class Person implements Serializable{ private static final long serialVersionUID = 5799L; private int age; /*age、name的getter/setter方法省略*/ }

此时虽然生产者和消费者对应的类版本不同,但是显式声明的serialVersionUID相同,反序列化也是可以运行的,所带来的业务问题就是消费端不能读取到新增的业务属性(age属性)而已。

通过此例,我们的反序列化实现了版本向上兼容的功能,使用V1.0版本的应用访问了一个V2.0版本的对象,这无疑提高了代码的健壮性。我们在编写序列化类代码时,随手加上serialVersionUID字段,也不会给我们带来太多的工作量,但它却可以在关键时候发挥异乎寻常的作用。

注意 显式声明serialVersionUID可以避免对象不一致,但尽量不要以这种方式向JVM“撒谎”。建议12:避免用序列化类在构造函数中为不变量赋值

我们知道带有final标识的属性是不变量,也就是说只能赋值一次,不能重复赋值,但是在序列化类中就有点复杂了,比如有这样一个类:

public class Person implements Serializable{ private static final long serialVersionUID = 71282334L; //不变量 public final String name="混世魔王"; }

这个Person类(此时V1.0版本)被序列化,然后存储在磁盘上,在反序列化时name属性会重新计算其值(这与static变量不同,static变量压根就没有保存到数据流中),比如name属性修改成了“德天使”(版本升级为V2.0),那么反序列化对象的name值就是“德天使”。保持新旧对象的final变量相同,有利于代码业务逻辑统一,这是序列化的基本规则之一,也就是说,如果final属性是一个直接量,在反序列化时就会重新计算。对这基本规则不多说,我们要说的是final变量另外一种赋值方式:通过构造函数赋值。代码如下:

public class Person implements Serializable{ private static final long serialVersionUID = 91282334L; //不变量初始不赋值 public final String name; //构造函数为不变量赋值 public Person(){ name="混世魔王"; } }

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载