为了获得更好的阅读体验,请访问原文:传送门

一、前言


代码是什么呢?或者说作为程序员的我们,对于写代码这件事又是抱着怎样的一种态度呢?我时常都在想,如今我如愿成为了一名程序员(虽然还很菜),写代码这件事成了我的工作,我期望从工作中获得些什么?而工作又能给予我什么呢?

我在短暂的工作经历中(4 个月),犯下过不少错,少部分是因为经验,但大部分的情况下都是因为对代码没有足够的敬畏之心导致的,并且在工作中也遇到过一些很有意思的代码,所以今天就着这本《代码整洁之道》,来谈一谈对于代码的感受和一些想法。(Ps:想吐槽一下这本书挺魔怔的..)

二、什么是整洁的代码


我搜索「代码」这两个关键字,给出的官方解释都特别有意思,摘一下百度百科的好了:

代码就是程序员用开发工具所支持的语言写出来的源文件,是一组由字符、符号或信号码元以离散形式表示信息的明确的规则体系。代码设计的原则包括唯一确定性、标准化和通用性、可扩充性与稳定性、便于识别与记忆、力求短小与格式统一以及容易修改等。 源代码是代码的分支,某种意义上来说,源代码相当于代码。现代程序语言中,源代码可以书籍或磁带形式出现,但最为常用格式是文本文件,这种典型格式的目的是为了编译出计算机程序。计算机源代码最终目的是将人类可读文本翻译成为计算机可执行的二进制指令,这种过程叫编译,它由通过编译器完成。

好了,学术介绍一大堆,重点还是在最后一句:计算机源代码最终目的是将人类可读文本翻译成为计算机可执行的二进制指令。

再精简一下:「人类可读」、「计算机可执行」。

说到底,代码最终还是写给人看的,所以「可读性」就显得尤为重要,但总归我们是要先有「代码」,再有「可读的代码」,经过不断重构or重写,最终形成我们「简洁的代码」。

说几点感受比较大的吧。

方法尽量短 && 职责单一

有谁能告诉我下面这个方法究竟是在做什么吗?

/**
 * @author Administrator
 *
 */
public class GeneratePrimes {

    /**
     * @param maxValue is the generation limit.
     * */
    public static int[] generatePrimes(int maxValue) {
        if (maxValue >= 2){ //the only valid case
            //ddeclarations
            int s = maxValue +1 ;// size of array
            boolean[] f = new boolean[s];
            int i;
            //initialize array to true.
            for ( i = 0;i < s;i++) {
                f[i] = true;
            }
            f[0] = f[1] = false;
            // sieve
            int j;
            for (i = 2;i < Math.sqrt(s) + 1; i++) {
                if (f[i]) { // if i is uncrossed , cross its multiples.
                    for (j = 2 * i; j < s ;j += i) {
                        f[j] = false; //multiple is not prime
                    }
                }
            }

            // how many primes are there?
            int count = 0;
            for (i = 0;i < s; i++) {
                if (f[i]) {
                    count ++; //bump count.
                }
            }

            int[] primes = new int[count];

            //move the primes into the result
            for (i = 0,j = 0;i < s;i++) {
                if (f[i]) {
                    primes[j++] = i;
                }
            }
            return primes;
        }
        else { //maxValue < 2
            return new int[0]; // return null array if bad input.
        }
    }
}

如果你非常有耐心地看完了,你可能大概或许会了解到,这是一个返回 maxValue 范围以内的质数的方法,但是我们经过简单的重构之后,会变得更加容易理解:

public class PrimeGenerator {

    private static boolean[] crossedOut;
    private static int[] result;

    public static int[] generatePrimes(int maxValue) {
        if (maxValue < 2) {
            return new int[0];
        }
        else {
            uncrossIntegersUpTo(maxValue);
            crossOutMultiples();
            putUncrossedIntegersIntoResult();
            return result;
        }
    }

    private static void putUncrossedIntegersIntoResult() {
        result = new int[numberOfUncrossedIntegers()];
        for (int j = 0, i = 2; i < crossedOut.length; i++) {
            if (notCrossed(i)) {
                result[j++] = i;
            }
        }
    }

    private static int numberOfUncrossedIntegers() {
        int count = 0;
        for (int i = 2; i < crossedOut.length; i++) {
            if (notCrossed(i)) {
                count++;
            }
        }
        return count;
    }

    private static void crossOutMultiples() {
        int limit = determinuIterationLimit();
        for (int i = 2;i <= limit; i++) {
            if (notCrossed(i)) {
                crossOutMultiplesOf(i);
            }
        }
    }

    private static void crossOutMultiplesOf(int i) {
        for (int multiple = 2 * i; multiple < crossedOut.length; multiple +=i) {
            crossedOut[multiple] = true;
        }
    }

    private static boolean notCrossed(int i) {
        return crossedOut[i] == false;
    }

    private static int determinuIterationLimit() {
        double iterationLimit = Math.sqrt(crossedOut.length);
        return (int)iterationLimit;
    }

    private static void uncrossIntegersUpTo(int maxValue) {
        crossedOut = new boolean[maxValue+1];
        for (int i = 2; i < crossedOut.length ; i++) {
            crossedOut[i] = false;
        }
    }

}

首先我们通过私有方法隐藏掉了实现的具体细节,并且使用有意义的命名,使得我们主函数 generatePrimes 更加便于理解。

函数的第一规则就是要短小,第二条规则就是要更短小。每个函数保持职责单一,并且有意识的维持在一定行数内(JVM 就强制要求每个函数要小于 8000 行…也听过每个函数尽量维持在 15 行 or 30 行 之内这样的说法..可能有点魔怔,但要点就是函数要尽量短小),这当然是最理想的情况,而现实的情况往往要糟糕一些。

在工作中,我就遇到过一些长得可怕的方法,他们或许本来保持着单纯,职责单一,但是经过业务不断的改造,需求不断的叠加,甚至是一些临时逻辑的加入,这个方法就变得越来越臃肿不堪…并且因为业务的不断发展,越来越少的人会 care 到它,以至于改造成本越来越大,甚至被遗忘在角落..

这其实是再正常不过的事情,但在多人协作的项目中,有一点需要自己来维持清醒,那就是:「一个方法就可以返回的为什么要写两个?」,关于这一点,保持自己的思考就好了..

注释要体现代码之外的东西

有一句听起来好厉害的话叫做:「代码即注释」,不知道大家是怎么看待这样一句话的,或者说是怎么看待注释的。其实反过来想,如果你的代码需要大量的注释来解释其中的逻辑,会不会是代码本身就存在一定问题?或者换个角度思考,注释是用来解释代码逻辑的吗?

可怕的废话

我们来看下面这一段代码的注释:

/** The name. */
private String name;
/** The version. */
private String version;
/** The licenceName. */
private String licenceName;
/** The version. */
private String info;

上面这些 Javadoc 的目的是什么?答案是:无。并且仔细阅读,你甚至会发现一处剪切-粘贴导致的错误,如果作者在写(或粘贴)注释时都没有花心思,怎么能指望读者从中收益呢?

能用函数或变量时就别用注释

看看以下代码概要:

// does the module from the globale list <mod> depend on the
// subsystem we are part of?
if (smodule.getDependSubsystems().contains(subSysMod.getSubSystem())

可以修改成以下没有注释的版本:

ArrayList moduleDependes = smodule.getDependSubsystems();
String ourSubSystem = subSysMod.getSubSystem();
if  (moduleDependes.contains(ourSubSystem))

用代码来阐述

有时,代码本身不足以解释其行为。但不幸的是,许多程序员以此为由,觉得大部分时候代码都不足以解释工作。这种观点纯属错误,比如你愿意看到下面这个:

// Check to see if the employee is eligible for full benefits
if ((employee.flags & HOURLY_FLAG) && (employee.age > 65))

还是这个:

if (employee.isEligibleForFullBenefits())

只需要多思考那么几秒钟,就能用代码解释你的大部分意图。其实很多时候,简单到只需要创建一个描述与注释所言同一事物的函数即可。

小结

注释终归是要用来体现代码之外的东西..

名副其实的名字

取名字这件事,真的是程序员的一门艺术。脑海里面能浮现出同事们用翻译软件取名的画面吗?

一个好的名字再怎么夸赞都不为过,但是这个最基础的前提就是,它首先得是一个「正确的名字」。我就遇到过一次,函数名字叫做类似于 listAll 这样的东西,戳进去看实际上还基于业务规则做了过滤..(这样的牛肉不对马嘴的情况又让我联想到了注释这样的东西,可能实际的代码已经作了更改,但是注释还是是维持原样没有变化..)

并且还有一个特别有意思的点,就是关于名字的「长度」。有时候可能为了想要描述清楚一个变量 or 一个类的具体作用,我会给它起一个看起来特别长的名字..关于这个,这里有一些小经验可以分享一下:

  • 去掉 Info 和 Data 这样的后缀:这些就像是英语中的 a/ an/ the 一样,是意义含糊的废话,废话都是冗余的..
  • 不要给变量加前缀来标识:变量不需要一个 m_ or 其他什么的前缀来标识这是一个变量..
  • 思考是否有必要标识出变量的类型:我们标注出变量的类型的目的是什么?对于弱类型的语言,可能有时候还是必要的,因为我们有时候并不能从 students 这个变量中判明我应该怎样对这个变量进行操作,但是对于 Java 这样的强类型的语言,我们就需要根据实际的场景思考是否真有那么必要了。

无副作用

函数承诺只做一件事,但还是会做其他被隐藏起来的事。

public class UserValidator {
    private Cryptographer cryptographer;

    public boolean checkPassword(String userName, String password) {
        User user = UserGateway.findByName(userName);
        if (user != User.NULL) {
            String codedPhrase = user.getPhraseEncodedByPassrod();
            String phrase = cryptographer.decrypt(codedPhrase, password);
            if ("Vliad Passwordw".equals(phrase)) {
                Session.initialize();
                return true;
            }
        }
        return false;
    }
}

上面的函数副作用就在于对 Session.initialize() 的调用。checkPassword 函数,顾名思义就是用来检查密码的,该名称并未暗示它会初始化该次会话。所以,当某个误信了函数名的调用者想要检查用户有效性时,就得冒着抹除现有会话数据的风险。

所以这里可以重命名函数为 checkPasswordAndInitializeSession,虽然这还是违背了 “只做一件事” 的规则。

函数参数尽可能少

一个函数最理想的参数数量是 0,其次是 1,再次是 2.. 要避免使用三个以上参数的情况,因为参数带有太多的概念性,参数过多就会带来更多的复杂性..

我就见过一个查询接口,为了满足不同的复杂查询场景,参数大概可能有接近 10 个.. 就算不为接手的编码人员考虑,测试人员也会头疼的.. 想想看,要覆盖如此兼容如此多场景如此复杂的一个查询接口,测试用例究竟应该怎么写呢?

More..

这本书说实话看下来挺魔怔的.. 里面有许多简洁实用的观点可以让我们受益,我仅仅挑了一些最近比较感同身受的几点,来进行了说明。

代码仓库就像是一本《哈姆雷特》一样,每个人都有自己不同的见解,这无可厚非,我觉得重要的就是要保持对代码的敬畏之心,保持自身的思考,才能让我们不断向前(说话都变魔怔了..)

三、代码之外


每个人都能写出好的代码

这就是一个非常有意思的话题了,我们可以分成几个角度来思考:

  • 好的代码是写出来的吗?(这可能有点类似于好的文章是写出来的吗?)
  • 为什么我们写不出好的代码?

我记得之前在看《重构:改善既有代码的设计》这本老经典的书的时候,就提到一种观点说:「重构不是一个一蹴而就的事,需要长期的实践和经验才能够完成得很好。重构强调的是 Be Better,那在此之前我们首先需要先动起手来搭建我们的系统,而不要一味地“完美主义”。」

好的代码也是这样,需要一个循序渐进的过程,虽然大部分时候,经验可以让我们少走许多弯路,但这些都是一个过程。

当然上面所说的全部,都是理想中的状况,而现实中的情况往往不允许我们这样做。什么之前炒起来的 996,什么 ICU,都无情的揭示着大部分程序员的现状:忙。忙于各种已经堆成山的需求 && 修复各种 BUG 中。

我学到一个很正经的概念,叫做「管窥」,附带的一种概念叫做「稀缺」。(看完下面这个故事应该很容易理解,故这里不作解释..)

我记得之前看过一篇报道,说是香港某富豪在节目中要体验几天环卫工人,参加节目前,他曾说过:“我的人生其实没有很多时间坐下来,想想现在的生活不错,享受一下。我有时间就会计划下一步!”

可几天下来,让他最纠结的竟然是吃饭问题,他对着镜头说:“很奇怪,我这两天只是考虑吃东西,完全没什么盼望,什么都不想。我努力工作,就是希望吃一顿好的。”

程序员是一种很容易陷入,对于时间「稀缺」状态的物种。稀缺会俘获我们的注意力,并带来一点点好处:我们能够在应对迫切需求时,做得更好。但从长远的角度来看,我们的损失更大:我们会忽视其他需要关注的事项,在生活的其他方面变得不那么有成效。(摘自《稀缺》P17)

这听上去就像是在找借口一样,但其实有点差别。我发觉每个人其实都能够写出好的代码,只是取决于你有没有这样的意识,有没有坚持自己的思考,更重要的是,有没有「跳出需求」,甚至是「跳出工作」之外来思考,就像是要跳出「我们明明知道了很多道理,却依然过不好这一生」的怪圈一样。

结尾


这一段时间都不怎么更新了,不是我变懒了.. 前段时间就陷入了不加班就完成不了工作的状态,一方面是因为事情比较杂.. 另外一方面就是自己效率还不够高.. (悄悄说:虽然很忙,但是总是能抽得出时间玩儿手机 hhhh…)值得反思吧.. 最近也开始有一些觉得越来越难下笔了.. 想写的东西很多,但总怕写不好..

另外,程序员真的是很有意思的职位了,并且也觉得程序员都多少带着点儿自己的骄傲来得,因为每天都在自己的世界玩儿拼图,自己就是世界的造物主,久了,难免有些受影响..(主要体现在沟通上..)

摁.. 总之这是一本很好的书,建议感兴趣的童鞋可以溜一遍。


按照惯例黏一个尾巴:

欢迎转载,转载请注明出处!
独立域名博客:wmyskxz.com
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz
分享自己的学习 & 学习资料 & 生活
想要交流的朋友也可以加qq群:3382693

前言: 捧读像这一类的书对于自己来说总带着一些神圣感,感谢自己并没有被这么宏大的主题吓退,看完了这里分享输出一下自己的笔记。

一、理解重构


什么是重构?

按书中 P45 中的说法,重构这个概念被分成了动词和名词的方面被分别阐述:

  • 重构(名词): 对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
  • 重构(动词): 使用一系列重构手法,在不改变软件可观察行为的前提下,调整其结构。

在过去的几十年时间里,重构这个词似乎被用来代指任何形式的代码清理,但上面的定义所指的是一种特定的清理代码的方式。重构的关键在于运用大量微小且保持软件行为的步骤,一步一步达成大规模的修改。

每一次的重构要么很小,要么包含了若干个小步骤,即使重构没有完成,也应当可以在任何时刻停下来,所以如果有人说它们的代码在重构过程中有一两天时间不可用,基本上可以确定,他们做的事不是重构。

与性能优化的区别

重构与性能优化有很多相似的地方:两者都需要修改代码,并且两者都不会改变程序的整体功能。

两者的差别在于起目的:

  • 重构是为了让代码 “更容易理解,更容易修改”。这可能使程序运行得更快,也可能使程序运行的更慢。
  • 性能优化则只关心程序是否运行的更快。对于最终得到的代码是否容易理解和维护就不知道了。

为什么重构?

重构不是包治百病的灵丹妙药,也绝对不是所谓的“银弹”。重构只是一种工具,能够帮助你始终良好的控制代码而已。使用它,可能基于下面的几个目的。

这里有一个有意思的科普(引用自百度百科:没有银弹
):在民俗传说里,所有能让我们充满梦靥的怪物之中,没有比狼人更可怕的了,因为它们会突然地从一般人变身为恐怖的怪兽,因此人们尝试着查找能够奇迹似地将狼人一枪毙命的银弹。我们熟悉的软件项目也有类似的特质(以一个不懂技术的管理者角度来看),平常看似单纯而率直,但很可能一转眼就变成一只时程延误、预算超支、产品充满瑕疵的怪兽,所以,我们听到了绝望的呼唤,渴望有一种银弹,能够有效降低软件开发的成本,就跟电脑硬件成本能快速下降一样。

1. 改进软件的设计

当人们只为短期目的而修改代码时,他们经常没有完全理解架构的整体设计。于是代码逐渐失去了自己的结构。程序员越来越难以通过阅读代码来理解原来的设计。代码结构的流失有累积效应。越难看出代码所代表的设计企图,就越难以保护其设计,于是设计就腐败得越快。

完成同样一件事,设计欠佳的程序往往需要更多代码,这常常是因为代码在不同的地方使用完全相同的语句做同样的事情,因此改进设计的一个重要方向就是消除重复代码。消除重复代码,我就可以确定所有事物和行为在代码中只表述一次,这正是优秀设计的根本。

2. 使软件更容易理解

所谓程序设计,很大程度上就是与计算机对话:我编写代码告诉计算机做什么,而它的响应是按照我的指示精确行动。一言以蔽之,我所做的就是填补“我想要它做什么”和“我告诉它做什么”之间的缝隙。编程的核心就在于“准确说出我想要的”。

然而别忘了,除计算机之外,源码还有其他读者,并且很大概率还是几个月后的自己,如何更清晰地表达我想要做的,这可能就需要一些重构的手法。

这里我联想到了软件设计的 KISS 原则:KISS 原则,Keep It Simple and Stupid ,简单的理解这句话就是,要把一个系统做的连白痴都会用。

3. 帮助找到 BUG

对代码的理解,可以帮助找到系统中存在的一些 BUG。搞清楚程序结构的同时,也可以对自己的假设做一些验证,这样一来 BUG 想不发现都难。

Kent Beck 经常形容自己的一句话是:“我不是一个特别好的程序员,我只是一个有着一些特别好的习惯的还不错的的程序员。”重构能够帮助我们更有效地写出健壮的代码。

4. 提高编程速度

听起来可能有些反直觉,因为重构可能会花大量的时间改善设计、提高阅读性、修改 BUG,难道不是在降低开发速度嘛?

软件开发者交谈时的故事:一开始他们进展很快,但如今想要添加一个新功能需要的时间就要长得多。他们需要花越来越多的时间去考虑如何把新功能塞进现有的代码库,不断蹦出来的bug修复起来也越来越慢。代码库看起来就像补丁摞补丁,需要细致的考古工作才能弄明白整个系统是如何工作的。这份负担不断拖慢新增功能的速度,到最后程序员恨不得从头开始重写整个系统。

下面这幅图可以描绘他们经历的困境。

但有些团队的境遇则截然不同。他们添加新功能的速度越来越快,因为他们能利用已有的功能,基于已有的功能快速构建新功能。

两种团队的区别就在于软件的内部质量。需要添加新功能时,内部质量良好的软件让我可以很容易找到在哪里修改、如何修改。良好的模块划分使我只需要理解代码库的一小部分,就可以做出修改。如果代码很清晰,我引入 BUG 的可能性就会变小,即使引入了 BUG,调试也会容易得多。理想情况下,代码库会逐步演化成一个平台,在其上可以很容易地构造与其领域相关的新功能。

这种现象被作者称为“设计耐久性假说”:通过投入精力改善内部设计,我们增加了软件的耐久性,从而可以更长时间地保持开发的快速。目前还无法科学地证明这个理论,所以说它是一个“假说”。

20年前,行业的陈规认为:良好的设计必须在开始编程之前完成,因为一旦开始编写代码,设计就只会逐渐腐败。重构改变了这个图景。现在我们可以改善已有代码的设计,因此我们可以先做一个设计,然后不断改善它,哪怕程序本身的功能也在不断发生着变化。由于预先做出良好的设计非常困难,想要既体面又快速地开发功能,重构必不可少。

什么时候重构?

  • 三次法则:
    第一次做某件事时只管去做;第二次做类似的事会产生反感,但无论如何还是可以去做;第三次再做类似的事,你就应该重构。

什么时候不应该重构?

重构并不是必要,当然也有一些不那么需要重构的情况:

  • 不需要修改,那些丑陋的代码能隐藏在一个 API 之下。 只有当我需要理解其工作原理时,对其进行重构才会有价值;
  • 重写比重构容易。 这可能就需要良好的判断力和丰富的经验才能够进行抉择了。

二、重构的几种姿势


预备性重构:让添加新功能更容易

重构的最佳时机就在添加新功能之前。在动手添加新功能之前,我会看看现有的代码库,此时经常会发现:如果对代码结构做一点微调,我的工作会容易得多。也许已经有个函数提供了我需要的大部分功能,但有几个字面量的值与我的需要略有冲突。如果不做重构,我可能会把整个函数复制过来,修改这几个值,但这就会导致重复代码—如果将来我需要做修改,就必须同时修改两处(更麻烦的是,我得先找到这两处)。而且,如果将来我还需要一个类似又略有不同的功能,就只能再复制粘贴一次,这可不是个好主意。

这就好像我要往东去100公里。我不会往东一头把车开进树林,而是先往北开20公里上高速,然后再向东开100公里。后者的速度比前者要快上3倍。如果有人催着你“赶快直接去那儿”,有时你需要说:“等等,我要先看看地图,找出最快的路径。”这就是预备性重构于我的意义。

——Jessica Kerr

修复bug时的情况也是一样。在寻找问题根因时,我可能会发现:如果把3段一模一样且都会导致错误的代码合并到一处,问题修复起来会容易得多。或者,如果把某些更新数据的逻辑与查询逻辑分开,会更容易避免造成错误的逻辑纠缠。用重构改善这些情况,在同样场合再次出现同样bug的概率也会降低。

帮助理解的重构:使代码更易懂

我需要先理解代码在做什么,然后才能着手修改。这段代码可能是我写的,也可能是别人写的。一旦我需要思考“这段代码到底在做什么”,我就会自问:能不能重构这段代码,令其一目了然?我可能看见了一段结构糟糕的条件逻辑,也可能希望复用一个函数,但花费了几分钟才弄懂它到底在做什么,因为它的函数命名实在是太糟糕了。这些都是重构的机会。

看代码时,我会在脑海里形成一些理解,但我的记性不好,记不住那么多细节。正如 Ward Cunningham 所说,通过重构,我就把脑子里的理解转移到了代码本身。随后我运行这个软件,看它是否正常工作,来检查这些理解是否正确。如果把对代码的理解植入代码中,这份知识会保存得更久,并且我的同事也能看到。

重构带来的帮助不仅发生在将来——常常是立竿见影。是我会先在一些小细节上使用重构来帮助理解,给一两个变量改名,让它们更清楚地表达意图,以方便理解,或是将一个长函数拆成几个小函数。当代码变得更清晰一些时,我就会看见之前看不见的设计问题。如果不做前面的重构,我可能永远都看不见这些设计问题,因为我不够聪明,无法在脑海中推演所有这些变化。Ralph Johnson说,这些初步的重构就像扫去窗上的尘埃,使我们得以看到窗外的风景。在研读代码时,重构会引领我获得更高层面的理解,如果只是阅读代码很难有此领悟。有些人以为这些重构只是毫无意义地把玩代码,他们没有意识到,缺少了这些细微的整理,他们就无法看到隐藏在一片混乱背后的机遇。

捡垃圾式重构

帮助理解的重构还有一个变体:我已经理解代码在做什么,但发现它做得不好,例如逻辑不必要地迂回复杂,或者两个函数几乎完全相同,可以用一个参数化的函数取而代之。这里有一个取舍:我不想从眼下正要完成的任务上跑题太多,但我也不想把垃圾留在原地,给将来的修改增加麻烦。如果我发现的垃圾很容易重构,我会马上重构它;如果重构需要花一些精力,我可能会拿一张便笺纸把它记下来,完成当下的任务再回来重构它。

当然,有时这样的垃圾需要好几个小时才能解决,而我又有更紧急的事要完成。不过即便如此,稍微花一点工夫做一点儿清理,通常都是值得的。正如野营者的老话所说:至少要让营地比你到达时更干净。如果每次经过这段代码时都把它变好一点点,积少成多,垃圾总会被处理干净。重构的妙处就在于,每个小步骤都不会破坏代码——所以,有时一块垃圾在好几个月之后才终于清理干净,但即便每次清理并不完整,代码也不会被破坏。

有计划的重构和见机行事的重构

上面的例子——预备性重构、帮助理解的重构、捡垃圾式重构——都是见机行事的:我并不专门安排一段时间来重构,而是在添加功能或修复 BUG 的同时顺便重构。这是我自然的编程流的一部分。不管是要添加功能还是修复 BUG,重构对我当下的任务有帮助,而且让我未来的工作更轻松。这是一件很重要而又常被误解的事:重构不是与编程割裂的行为。你不会专门安排时间重构,正如你不会专门安排时间写 if 语句。我的项目计划上没有专门留给重构的时间,绝大多数重构都在我做其他事的过程中自然发生。

还有一种常见的误解认为,重构就是人们弥补过去的错误或者清理肮脏的代码。当然,如果遇上了肮脏的代码,你必须重构,但漂亮的代码也需要很多重构。在写代码时,我会做出很多权衡取舍:参数化需要做到什么程度?函数之间的边界应该划在哪里?对于昨天的功能完全合理的权衡,在今天要添加新功能时可能就不再合理。好在,当我需要改变这些权衡以反映现实情况的变化时,整洁的代码重构起来会更容易。

长久以来,人们认为编写软件是一个累加的过程:要添加新功能,我们就应该增加新代码。但优秀的程序员知道,添加新功能最快的方法往往是先修改现有的代码,使新功能容易被加入。所以,软件永远不应该被视为“完成”。每当需要新能力时,软件就应该做出相应的改变。越是在已有代码中,这样的改变就越显重要。

不过,说了这么多,并不表示有计划的重构总是错的。如果团队过去忽视了重构,那么常常会需要专门花一些时间来优化代码库,以便更容易添加新功能。在重构上花一个星期的时间,会在未来几个月里发挥价值。有时,即便团队做了日常的重构,还是会有问题在某个区域逐渐累积长大,最终需要专门花些时间来解决。但这种有计划的重构应该很少,大部分重构应该是不起眼的、见机行事的。

长期重构

大多数重构可以在几分钟—最多几小时—内完成。但有一些大型的重构可能要花上几个星期,例如要替换一个正在使用的库,或者将整块代码抽取到一个组件中并共享给另一支团队使用,再或者要处理一大堆混乱的依赖关系,等等。

即便在这样的情况下,我仍然不愿让一支团队专门做重构。可以让整个团队达成共识,在未来几周时间里逐步解决这个问题,这经常是一个有效的策略。每当有人靠近“重构区”的代码,就把它朝想要改进的方向推动一点。这个策略的好处在于,重构不会破坏代码—每次小改动之后,整个系统仍然照常工作。例如,如果想替换掉一个正在使用的库,可以先引入一层新的抽象,使其兼容新旧两个库的接口。一旦调用方已经完全改为使用这层抽象,替换下面的库就会容易得多。(这个策略叫作Branch By Abstraction[mf-bba]。)

复审代码时重构

至于如何在代码复审的过程中加入重构,这要取决于复审的形式。在常见的pull request模式下,复审者独自浏览代码,代码的作者不在旁边,此时进行重构效果并不好。如果代码的原作者在旁边会好很多,因为作者能提供关于代码的上下文信息,并且充分认同复审者进行修改的意图。对我个人而言,与原作者肩并肩坐在一起,一边浏览代码一边重构,体验是最佳的。这种工作方式很自然地导向结对编程:在编程的过程中持续不断地进行代码复审。

三、坏代码长什么样?


这让我想起之前在捧读《阿里巴巴 Java 开发手册》时学习的代码规范的问题(传送门
,只不过当时学习的是好的代码应该长什么样,而现在讨论的事情是:坏的代码长什么样?

其实大部分的情况应该作为程序员的我们都有一定的共识,所以我觉得简单列一下书中提到的情况就足以说明:

  • 神秘命名

  • 重复代码

  • 过长函数

  • 过长参数列表

  • 全局数据: 全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。一次又一次,全局数据造成了一些诡异的 BUG,而问题的根源却在遥远的别处。

  • 可变数据: 对数据的修改经常导致出乎意料的结果和难以发现的 BUG。我在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据。

  • 发散式变化: 模块经常因为不同的原因在不同的方向上发生变化。

  • 散弹式修改: 每遇到某种变化,你都必须在许多不同的类内做出许多小修改。

  • 依恋情结: 所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但有时你会发现,一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于在自己所处模块内部的交流。

  • 数据泥团: 你经常在很多地方看到相同的三四项数据:两个类中相同的字段、许多函数签名中相同的参数。

  • 基本类型偏执: 很多程序员不愿意创建对自己的问题域有用的基本类型,如钱、坐标、范围等。

  • 重复的 switch: 在不同的地方反复使用相同的 switch 逻辑。问题在于:每当你想增加一个选择分支时,必须找到所有的 switch,并逐一更新。

  • 循环语句: 我们发现,管道操作(如 filter 和 map)可以帮助我们更快地看清被处理的元素一级处理它们的动作。

  • 冗余的元素

  • 夸夸其谈通用性: 函数或类的唯一用户是测试用例。

  • 临时字段: 有时你会看到这样的类:其内部某个字段仅为某种特定情况而定。这样的代码让人不理解,因为你通常认为对象在所有时候都需要它的所有字段。在字段未被使用的情况下猜测当初设置它的目的,会让你发疯。

  • 过长的消息链

  • 中间人: 过度运用委托。

  • 内幕交易: 软件开发者喜欢在模块之间筑起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。

  • 过大的类

  • 异曲同工的类

  • 纯数据类: 所谓纯数据类是指:他们拥有一些字段,以及用于访问(读写)这些字段的函数,除此之外一无长物。纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据的行为从客户端搬移到纯数据类里来,就能使情况大为改观。

  • 被拒绝的遗赠: 拒绝继承超类的实现,我们不介意:但如果拒绝支持超类的接口,这就难以接受了。

  • 注释: 当你感觉需要纂写注释时,请先尝试重构,试着让所有注释都变得多余。

四、重构的一些方法


书中花了大量的章节介绍我们应该如何重构我们的程序,有几个关键的点是我自己能够提炼出来的:找出代码中不合理的地方、结构化、容易理解、测试确保正确。总之围绕这几个点,书中介绍了大量的方法,下面结合自己的一些理解来简单概述一下吧。

结构化代码

结构化的代码更加便于我们阅读和理解,例如最常使用的重构方法:提炼函数

  • 动机:把意图和实现分开
 void printOwing(double amount) {
     printBanner();
     //print details
     System.out.println ("name:" + _name);
     System.out.println ("amount" + amount);
 }

=>

 void printOwing(double amount) {
     printBanner();
     printDetails(amount);
 }
 void printDetails (double amount) {
     System.out.println ("name:" + _name);
     System.out.println ("amount" + amount);
 }

更清楚的表达用意

要保持软件的 KISS 原则是不容易的,但是也有一些方法可以借鉴,例如:引入解释性变量

动机:用一个良好命名的临时变量来解释对应条件子句的意义,使语义更加清晰。

 if ( (platform.toUpperCase().indexOf("MAC") > -1) &&
     (browser.toUpperCase().indexOf("IE") > -1) &&
      wasInitialized() && resize > 0 )
{
     // do something
}

=>

   final boolean isMacOs     = platform.toUpperCase().indexOf("MAC") > -1;
   final boolean isIEBrowser = browser.toUpperCase().indexOf("IE")  > -1;
   final boolean wasResized  = resize > 0;
   if (isMacOs && isIEBrowser && wasInitialized() && wasResized) {
       // do something
   }

另外由于 lambda 表达式的盛行,我们现在有一些更加优雅易读的方法使我们的代码保持可读:以管道取代循环就是这样一种方法。

   const names = [];
   for (const i of input) {
      if (i.job === "programer")
         names.push(i.name);
   }

=>

   const names = input
      .filter(i => i.job === "programer")
      .map(i => i.name)
   ;

合理的组织结构

例如上面介绍的提炼函数的方法,固然是一种很好的方式,但也应该避免过度的封装,如果别人使用了太多间接层,使得系统中的所有函数都似乎只是对另一个函数的简单委托(delegation),造成我在这些委托动作之间晕头转向,并且内部代码和函数名称同样清晰易读,那么就应该考虑内联函数。

动机:①去处不必要的间接性;②可以找出有用的间接层。

 int getRating() {
     return (moreThanFiveLateDeliveries()) ? 2 : 1;
 }
 boolean moreThanFiveLateDeliveries() {
     return _numberOfLateDeliveries > 5;
 }

=>

 int getRating() {
     return (_numberOfLateDeliveries > 5) ? 2 : 1;
 }

合理的封装

封装能够帮助我们隐藏细节并且,能够更好的应对变化,当我们发现我们的类太大而不容易理解的时候,可以考虑使用提炼类的方法。

动机:类太大而不容易理解。

 class Person {
     get officeAreaCode() { return this._officeAreaCode; }
     get officeNumber() { return this._officeNumber; }
 }

=>

 class Person {
     get officeAreaCode() { return this._telephoneNumber.areaCode; }
     get officeNumber() { return this._telephoneNumber.number; }
 }
 class TelephoneNumber {
     get areaCode() { return this._areaCode; }
     get number() { return this._number; }
 }

反过来,如果我们发现一个类不再承担足够责任,不再有单独存在的理由的时候,我们会进行反向重构:内敛类

 class Person {
     get officeAreaCode() { return this._telephoneNumber.areaCode; }
     get officeNumber() { return this._telephoneNumber.number; }
 }
 class TelephoneNumber {
     get areaCode() { return this._areaCode; }
     get number() { return this._number; }
 }

=>

 class Person {
     get officeAreaCode() { return this._officeAreaCode; }
     get officeNumber() { return this._officeNumber; }
 }

简化条件表达式

分解条件式: 我们能通过提炼代码,把一段 「复杂的条件逻辑」 分解成多个独立的函数,这样就能更加清楚地表达自己的意图。

     if (date.before (SUMMER_START) || date.after(SUMMER_END))
         charge = quantity * _winterRate + _winterServiceCharge;
     else charge = quantity * _summerRate;

=>

     if (notSummer(date))
         charge = winterCharge(quantity);
     else charge = summerCharge (quantity);

另外一个比较受用的一条建议就是:以卫语句取代嵌套条件式。根据经验,条件式通常有两种呈现形式。第一种形式是:所有分支都属于正常行为。第二种形式则是:条件式提供的答案中只有一种是正常行为,其他都是不常见的情况。

精髓是:给某一条分支以特别的重视。如果使用 if-then-else 结构,你对 if 分支和 else 分支的重视是同等的。 这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。卫语句(guard clauses)就不同了,它告诉阅读者:「这种情况很罕见,如果它真的发生了,请做 一些必要的整理工作,然后退出。」

「每个函数只能有一个入口和一个出口」的观念,根深蒂固于某些程序员的脑海里。 我发现,当我处理他们编写的代码时,我经常需要使用 Replace Nested Conditional with Guard Clauses。现今的编程语言都会强制保证每个函数只有一个入口, 至于「单一出口」规则,其实不是那么有用。在我看来,保持代码清晰才是最关键的:如果「单一出口」能使这个函数更清楚易读,那么就使用单一出口;否则就不必这么做。

 double getPayAmount() {
   double result;
   if (_isDead) result = deadAmount();
   else {
       if (_isSeparated) result = separatedAmount();
       else {
           if (_isRetired) result = retiredAmount();
           else result = normalPayAmount();
       };
   }
 return result;
 };

=>

 double getPayAmount() {
   if (_isDead) return deadAmount();
   if (_isSeparated) return separatedAmount();
   if (_isRetired) return retiredAmount();
   return normalPayAmount();
 };

自我测试代码

如果认真观察程序员把最多时间耗在哪里,你就会发现,编写代码其实只占非常小的一部分。有些时间用来决定下一步干什么,另一些时间花在设计上面,最多的时间则是用来调试(debug)。每个程序员都能讲出「花一整天(甚至更多)时间只找出一只小小臭虫」的故事。修复错误通常是比较快的,但找出错误却是噩梦一场。当你修好一个错误,总是会有另一个错误出现,而且肯定要很久以后才会注意到它。 彼时你又要花上大把时间去寻找它。

「频繁进行测试」是极限编程( extreme programming XP)[Beck, XP]的重要一 环。「极限编程」一词容易让人联想起那些编码飞快、自由而散漫的黑客(hackers), 实际上极限编程者都是十分专注的测试者。他们希望尽可能快速开发软件,而他们也知道「测试」可协助他们尽可能快速地前进。

在重构之前,先保证一组可靠的测试用例(有自我检验的能力),这不仅有助于我们检测 BUG,其中也有一种以终为始的思想在里面,实际上,我们可以通过编写测试用例,更加清楚我们最终的函数应该长什么样子,提供什么样的服务。

结束语


感谢您的耐心阅读,以上就是整个学习的笔记了。

重构不是一个一蹴而就的事,需要长期的实践和经验才能够完成得很好。重构强调的是 Be Better,那在此之前我们首先需要先动起手来搭建我们的系统,而不要一味地“完美主义”,近些时间接触的敏捷式开发也正是这样的一种思想。

如果有兴趣阅读,这里只找到一份第一版可以在线阅读的地方,请自行食用吧:https://www.kancloud.cn/sstd521/refactor/194190


按照惯例黏一个尾巴:

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz
分享自己的学习 & 学习资料 & 生活
想要交流的朋友也可以加qq群:3382693

偶然看到阿里巴巴居然出书了???趁着满减活动(节约节约….)我赶紧买来准备看看,刚拿到的时候掂量了好多下,总觉得商家给我少发了一本书,结果打开才知道..原来这本书这么小….

编码规范的重要性

别人都说我们是搬砖的码农,但我们知道自己是追求个性的艺术家。也许我们不会过多在意自己的外表和穿着,但在我们不羁的外表下,骨子里追求着代码的美、系统的美、设计的美,代码规范其实就是一个对程序美的定义。—— 引自 序

如果有一天在我们的项目中看到了这样的代码:

或者是这样的代码:

这样美不美呢?或许看着是还挺美的,但是如果需要修改,是不是人傻啦?

那这样的代码呢?

作为一个对自己有一定要求的程序猿,是不是第一反应就是:

  • 重写!
  • 原作者是谁?锤他!

规范不一,就会像下图中的小鸭和小鸡对话一样,语言不通,一脸囧相。鸡同鸭讲也恰恰形容了人与人之间沟通的痛点,自说自话,无法达成一致意见。再举一个生活中的例子,交通规则靠左行驶还是靠右行驶,两者孰好孰坏并不重要,重要的是必须要在统一的方向上通行,表面上限制了自由,但实际上是保障了公众的人身安全。试想,如果没有规定靠右行驶,那样的路况肯定拥堵不堪,险象环生。同样,过分自由随意、天马行空的代码会严重的伤害系统的健康,影响到可扩展性以及可维护性。

  • 总结:代码规范很重要!

关于编码规范的三大圣战

众所周知,互联网公司的优势在于效率,它是企业核心竞争力。体现在产品开发领域,就是够沟通效率和研发效率。对于沟通效率的重要性,可以从程序猿三大 “编码理念之争” 说起:

  • 缩进采用空格键,还是 Tab 键
  • if 单行语句需要大括号还是不需要大括号
  • 左大括号不换行,还是单独另起一行

在美剧《硅谷》中,有这样的一个经典镜头:

  • 程序媛:Kid? 我们似乎很久没有一起睡了。
  • 程序猿:现在?不可能!我永远不会和使用空格来缩进的人睡在一起!

  • 程序媛:(疯狂敲 space 气走了程序猿)
  • 程序猿:(甩了一句)一个 Tab 可以代替 8个 空格!
    之后程序猿就因为视图一步跨下八个阶梯而摔了….

Tab 键和空格键的争议确实存在,并且在知乎上讨论得火热:写代码时,缩进使用 tab 还是空格?

  • 总结:使用 4 个空格好,在《阿里巴巴 Java 开发手册》中也明确支持了这样的做法。下面也引用一张图来调侃一下。

if 单语句是否需要换行,也是争论不休的话题。相对来说,写过格式缩进类编程语言的开发者, 更加习惯于不加大括号。《手册》中明确 if/for 单行语句必须加大括号,因为单行语句的写法,容易在添加逻辑时引起视觉上的错误判断。此外,if 不加大括号还会有局部变量作用域的问题。

左大括号是否单独另起一行?因为 Go 语言的强制不换行,在这点上,“编程理念之争” 的硝烟味似乎没有那么浓。如果一定要给一个理由,那么换行的代码可以增加一行,对于按代码行数考核工作量的公司员工,肯定倾向于左大括号前换行。《手册》明确左大括号不换行!

  • 总结: 其实,很多编程方式客观上没有对错之分,一致性很重要,可读性很重要,团队沟通效率很重要。

第1章:编程规约

这一章是对传统意义上的代码规范,包括变量命名、代码风格、控制语句、代码注释等基本的变成习惯,以及从高并发场景中提炼出来的集合处理技巧与并发多线程的注意事项。

1.1 命名风格

第一条:【强制】代码中的命名均不能以下划线或美元符号开始,也不能以下划线或美元符号结束。

  • 反例:_name / $name / name_ / name$

尽管 $ 可以作为标识符使用,然而我们应该尽量避免对其使用。

  • 原因: $ 通常在编译器生成的标识符名称中使用,如果我们也使用这个符号,可能会有一些意想不到的错误发生….
  • 意想不到的错误示例:
package test;

public class User$VIP {
    public static void main(String[] args) {
        User user = new User();
        User.VIP vip = user.new VIP();
        vip.print();
    }
}

class User{
    class VIP{
        void print(){
            System.out.println("成员类");
        }
    }
}

仔细阅读以下,似乎并没有什么问题,代码也比较简单,但正在我们编译的时候,IDEA提示我们:

定义了重复的代码?归根到底,都是 $ 惹的祸!因为 $ 被编译器所使用,在源文件(.java 文件)编译成字节码(.class 文件)后,会称为顶层类型与嵌套类型之间的连接符。例如,如果存在一个顶层类 A,在其内声明了一个成员类 B,那么编译之后就会产生两个 class 文件,分别为 A.classA$B.class

就本程序来说,会生成 3 个 class 文件(如果可以编译的话),分别是 User$VIP.class(顶层类)、User.classUser$VIP.class(User 类的成员类,也就是类 VIP)。由于试图存在两个 User$VIP.class 所以才会报错!

第三至第六条:【强制】

  • 类名使用 UpperCamelCase 风格,方法名、参数名、成员变量、局部变量都同意使用 lowerCamelCase 风格,必须遵从驼峰形式。

  • 变量命名全部大写,单词兼用下划线隔开,力求予以表达完整清楚,不要嫌名字太长。

    正例:MAX_STOCK_COUNT / PRIZE_NUMBER_EVERYDAY
    反例:MAX_COUNT / PRIZE_NUMBER

  • 抽象类命名使用 Abstract 或 Base 开头;异常类命名使用 Exception 结尾;测试类命名以它要测试的类名开始,以 Test 结尾。

第八条:【强制】 POJO 类中布尔类型的变量都不要加 is 前缀,否则部分框架解析会引起序列化错误。

反例:定义为基本数据类型 Boolen isDeleted; 的属性,它的方法名称也是 isDeleted() ,RPC 框架在反向解析的时候,“误以为” 对应的属性名称是 deleted ,导致属性获取不到抛出异常。

第十二条:【推荐】 如果模块、类、方法使用了设计模式,应在命名时体现出具体模式

  • 说明: 将设计模式体现在名字中,有利于阅读者快速理解架构设计理念。

    正例:
    public class OrderFactory;
    public class LoginProxy;
    public class ResourceObserver;

第十三条:【推荐】 接口类中的方法和属性不要加任何修饰符号(public 也不要加),保持代码的间接性,并加上有效的 Javadoc 注释。尽量不要在接口里定义变量,如果一定要定义变量,必须是与接口方法相关的,并且是整个应用的基础变量。

正例:
接口方法签名: void commit();
接口基础变量: String COMPANY = "alibaba";
反例:
接口定义方法: public abstract void commit();

  • 说明: 如果 JDK8 中接口允许有默认实现,那么这个 default 方法,是对所有实现类都有价值的默认实现。

第十四条:接口和实现类的命名规则

  • 1):【强制】 对于 Service 和 DAO 类,基于 SOA 的理念,暴露出来的服务一定是接口,内部的实现类用 Impl 后缀与接口区别。

正例: CacheServiceImpl 实现 CacheServcie 接口

  • 2):【推荐】 如果是形容能力的接口名称,取对应的形容词为接口名(通常是 -able 的形式)。

    正例: AbstractTranslator 实现 Translatable。

1.2 常量定义

第二条:【强制】 long 或者 Long 初始赋值时,使用大写的 L,不能是小写的 l。小写的 l 容易跟数字 1 混淆,造成误解。

  • 说明: Long a = 2l; 写得是数字的 21 还是 Long 型的 2?

第三条:【推荐】 不要使用一个常量类维护所有变量,要按常量功能进行归类,分开维护。

  • 说明: 大而全的变量类,非得使用查找功能才能定位到修改的常量,不利于理解和维护。

    正例:缓存相关常量放在类 CacheConsts 下;系统配置相关常量放在 ConfigConsts 下。

1.3 代码格式

public static void main(String[] args){
    // 注释的双斜线与注释内容之间有且仅有一个空格
    // 缩进 4 个空格
    String say = "hello";
    // 运算符的左右必须有 1 个空格
    int flag = 0;
    // 关键字 if 与括号之间必须有 1 个空格,括号内的 f与左括号、
    // 0 与右括号之间不需要空格
    if (flag == 0) {
        System.out.println(say);
    }
    // 左大括号前加空格且不换行;左大括号后换行
    if (flag == 1) {
        System.out.println("world");
    // 右大括号前换行,右大括号后有 else,不用换行
    } else {
        System.out.println("ok");
    // 在右大括号后直接结束,则必须换行
    }
}

第八条:【强制】 方法参数在定义和传入时,多个参数逗号后边必须加空格。

正例:下例中实参的“one”,后边必须要有一个空格。
method("one", "two", "three");

1.4 OOP 规约

第二条:【强制】 所有的复写方法,必须加 @Override 注解。

  • 说明: getObject() 与 get0bject() 的问题。一个是字母 O,一个是数字 0,
    加 @Override 注解可以准确判断是否覆盖成功。另外,如果在抽象类中对方法签名进行修改,其实现类会马上编译报错。

打脸

第七条:【强制】 所有相同类型的包装类对象之间值得比较,全部使用 equals 方法

  • 说明: 对于 Intergre var = ? 在 -128~127 范围内的赋值, Integer 对象是在 IntegerCache.cache 中产生的,会复用已有的对象,这个区间内的 Integer 值可以直接使用 == 进行判断,但是这个区间之外的所有数据,都会在堆上产生,并不会复用已有对象。这是一个大坑,推荐使用 equals 方法进行判断。

第十二条:【强制】 POJO 类必须写 toString 方法。在使用 IDE 中的工具 source>generate toString 时,如果继承了另一个 POJO 类,注意在前面加一下 super.toString。

  • 说明: 在方法执行抛出异常时,可以直接调用 POJO 的 toString() 方法打印其属性值,便于排查问题。

1.5 集合处理

第七条:【强制】 不要在 foreach 循环里进行元素的 remove / add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。

// 正例
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String item = iterator.next();
    if (删除元素的条件) {
        iterator.remove();
    }
}
// 反例
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
for (String item : list) {
    // 如果把 1 改为 2 再试一下看看是否相同
    if ("1".equals(item)) {
        list.remove(item);
    }
}

打脸

第十一条:【推荐】 高度注意 Map 类集合 K/V 能不能存储 null 值得情况

1.6 并发处理

第三条:【强制】 线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

  • 说明: 使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源,解决资源不足的问题。如果不适用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者 “过渡切换” 的问题。

打脸

1.7 控制语句

第二条:【强制】 在 if / else / for / while / do 语句中,必须使用大括号。即使只有一行代码,也应该避免采用单行的编码方式:if (condition) statements;

第三条:【强制】 在高并发场景中,避免使用 “等于” 判断作为终端或退出的条件

  • 说明: 如果并发控制没有处理好,容易产生等值判断被 “击穿” 的情况,应使用大于或小于的区间判断条件来代替。

反例:判断剩余奖品数量等于 0 时,终止发放奖品,但因为并发处理错误导致奖品数量瞬间变成了负数,这样的话,活动无法终止。

第四条:【推荐】 在表达异常的分支时,尽量少用 if-else 方式

  • 说明: 如果不得不使用 if()…else if()…else… 方式表达逻辑,【强制】 避免后续代码维护困难,请勿超过 3 层。
// 正例:超过 3 层的 if-else 逻辑判断代码可以使用卫语句、策略模式
// 状态模式等来实现,其中卫语句实例如下:
public void today() {
    if (isBusy()) {
        System.out.println("change time,");
        return;
    }

    if (isFree()) {
        System.out.println("go to travel.");
        return;
    }
    System.out.println("stay at home to learn Java");
    return;
}

1.8 注释规约

第一条:【强制】 类、类属性、类方法的注释必须使用 Javadoc 规范,使用 /**内容*/ 格式,不得使用 //xxx 方式

第二条:【强制】 所有的抽象方法(包括接口中的方法)必须要用 Javadoc 注释,除了返回值、参数、异常说明外,还必须指出该方法做什么事情,实现什么功能。

  • 说明: 对子类的实现要求,或者调用注意事项,请一并说明。

第三条:【强制】 所有的类都必须添加创建者和创建日期。

打脸

1.9 其他

第三条:【强制】 后台输送给网页的变量必须加$!{var}——中间是感叹号

  • 说明: 如果 var=null 或者不存在,那么 ${var} 会直接显示在页面上。

第四条:【强制】 注意 Math.random() 这个方法返回的是 double 类型,取值的范围 0≤x<1(能够取到零值,注意除零异常),如果向获取整数类型的随机数,不要将 x 放大 10 的若干倍然后取整,直接使用 Random 对象的 nextInt 或者 nextLong 方法。

第六条:【推荐】 不要在视图模板中加入任何复杂的逻辑。

  • 说明: 根据 MVC 理论,视图的职责是展示,不要抢模型和控制器的工作。

第4章:安全规约

“安全生产,责任重于泰山。” 这句话同样适用于软件生产,本章主要说明编程中需要注意的比较基础的安全准则。

第一条:【强制】 隶属于用户个人的页面或者功能必须进行权限控制校验

  • 说明: 放置皆有做水平权限校验就可以随意访问、修改、删除别人的数据,比如查看他人的私信内容、修改他人的订单。

第二条:【强制】 用户敏感数据禁止直接展示,必须对展示数据进行脱敏。

  • 说明: 个人手机号码会显示为 158****9119,隐藏中间 4 位,防止个人隐私泄露。

第三条:【强制】 用户输入的 SQL 参数严格使用参数绑定或者 METADATA 字段值限定,防止 SQL 注入,禁止字符串拼接 SQL 访问数据库。

打脸

第四条:【强制】 用户请求传入的任何参数必须做有效性验证

  • 说明: 忽略参数校验可能导致如下情况。
    1)page size 过大导致内存溢出
    2)恶意 order by 导致数据库慢查询
    3)任意重定向
    4)SQL 注入
    5)反序列化注入
    6)正则输入源串拒绝服务 ReDoS
    Java 代码用正则来验证客户端的输入,有些正则写法验证普通用户输入没有问题,但是如果攻击人员使用的是特殊构造的字符串来验证,则有可能导致死循环。

打脸

第五条:【强制】 禁止向 HTML 页面输出未经安全过滤或未正确转义的用户数据。

第六条:【强制】 表单、AJAX 提交必须执行 CSRF 安全过滤

第七条:【强制】 在使用平台资源,譬如短信、邮件、电话、下单、支付,必须实现正确的防重放限制,如数量限制、疲劳度控制、验证码校验,避免被滥刷、资损。

  • 说明:如注册时发送验证码到手机,如果没有限制次数和频率,那么可以利用此功能骚扰到其它用户,并造成短信平台资源浪费。

第5章:MySQL 数据库

5.1 建表规约

第二条:【强制】 表名、字段名必须使用小写字母或数字 , 禁止出现数字开头,禁止两个下划线中间只出现数字。数据库字段名的修改代价很大,因为无法进行预发布,所以字段名称需要慎重考虑。

  • 说明: MySQL 在 Windows 下不区分大小写,但在 Linux 下默认区分大小写。因此,数据库名、表明、字段名都不允许出现任何大写字母,避免节外生枝。

    正例: getter _ admin , task _ config , level 3_ name
    反例: GetterAdmin , taskConfig , level 3 name

第四条:【强制】禁用保留字,如 desc 、 range 、 match 、 delayed 等,请参考 MySQL 官方保留字。

第五条: 【强制】主键索引名为 pk_ 字段名;唯一索引名为 uk _字段名 ; 普通索引名则为 idx _字段名。

  • 说明: pk_ 即 primary key;uk _ 即 unique key;idx _ 即 index 的简称。

第六条:【强制】小数类型为 decimal ,禁止使用 float 和 double 。

  • 说明:float 和 double 在存储的时候,存在精度损失的问题,很可能在值的比较时,得到不正确的结果。如果存储的数据范围超过 decimal 的范围,建议将数据拆成整数和小数分开存储。

第八条:【强制】 varchar 是可变长字符串,不预先分配存储空间,长度不要超过 5000,如果存储长度大于此值,定义字段类型为 text ,独立出来一张表,用主键来对应,避免影响其它字段索引效率。

第九条:【强制】表必备三字段: id , gmt _ create , gmt _ modified

  • 说明:其中 id 必为主键,类型为 unsigned bigint 、单表时自增、步长为 1。 gmt _ create ,gmt _ modified 的类型均为 date _ time 类型。

第十条: 【推荐】表的命名最好是加上“业务名称_表的作用”。

正例: tiger _ task / tiger _ reader / mpp _ config

第十五条:【参考】合适的字符存储长度,不但节约数据库表空间、节约索引存储,更重要的是提升检索速度。

正例:如下表,其中无符号值可以避免误存负数,且扩大了表示范围。

对象 年龄区间 类型 表示范围
150 岁之内 unsigned tinyint 无符号值:0 到 255
数百岁 unsigned smallint 无符号值:0 到 65535
恐龙化石 数千万年 unsigned int 无符号值:0 到约 42.9 亿
太阳 约 50 亿年 unsigned bigint 无符号值:0 到约 10 的 19 次方

5.2 索引规约

第五条: 【推荐】如果有 order by 的场景,请注意利用索引的有序性。 order by 最后的字段是组合索引的一部分,并且放在索引组合顺序的最后,避免出现 file _ sort 的情况,影响查询性能。

正例: where a =? and b =? order by c; 索引: a _ b _ c
反例:索引中有范围查找,那么索引有序性无法利用,如: WHERE a >10 ORDER BY b; 索引 a _ b 无法排序。

第九条: 【推荐】建组合索引的时候,区分度最高的在最左边。

正例:如果 where a =? and b =? , a 列的几乎接近于唯一值,那么只需要单建 idx _ a 索引即可。

  • 说明: 存在非等号和等号混合判断条件时,在建索引时,请把等号条件的列前置。如: where a >? and b = ? 那么即使 a 的区分度更高,也必须把 b 放在索引的最前列。

5.3 SQL 语句

第一条:【强制】不要使用 count( 列名 ) 或 count( 常量 ) 来替代 count( * ) , count( * ) 是 SQL 92 定义的标准统计行数的语法,跟数据库无关,跟 NULL 和非 NULL 无关。

  • 说明: count( * ) 会统计值为 NULL 的行,而 count( 列名 ) 不会统计此列为 NULL 值的行。

第六条: 【强制】不得使用外键与级联,一切外键概念必须在应用层解决。

  • 说明: ( 概念解释 ) 学生表中的 student _ id 是主键,那么成绩表中的 student _ id 则为外键。如果更新学生表中的 student _ id ,同时触发成绩表中的 student _ id 更新,则为级联更新。外键与级联更新适用于单机低并发,不适合分布式、高并发集群 ; 级联更新是强阻塞,存在数据库更新风暴的风险 ; 外键影响数据库的插入速度。

打脸

第八条: 【强制】数据订正时,删除和修改记录时,要先 select ,避免出现误删除,确认无误才能执行更新语句。

打脸

5.4 ORM 映射

整个规约对自己来说都挺有用的,因为正好涉及到这方面,幸好感觉脸不怎么疼。

第一条:【强制】在表查询中,一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明。

  • 说明: 1 ) 增加查询分析器解析成本。2 ) 增减字段容易与 resultMap 配置不一致。

第二条:【强制】 POJO 类的 布尔 属性不能加 is ,而数据库字段必须加 is _,要求在 resultMap 中进行字段与属性之间的映射。

  • 说明: 参见定义 POJO 类以及数据库字段定义规定,在 中 增加映射,是必须的。在 MyBatis Generator 生成的代码中,需要进行对应的修改。

第三条:【强制】不要用 resultClass 当返回参数,即使所有类属性名与数据库字段一一对应,也需要定义 ; 反过来,每一个表也必然有一个与之对应。

  • 说明: 配置映射关系,使字段与 DO 类解耦,方便维护。

第七条:【强制】更新数据表记录时,必须同时更新记录对应的 gmt _ modified 字段值为当前时间。

第九条:【参考】@ Transactional 事务不要滥用。事务会影响数据库的 QPS ,另外使用事务的地方需要考虑各方面的回滚方案,包括缓存回滚、搜索引擎回滚、消息补偿、统计修正等。


总结

浏览了一遍,还是学习到了很多东西吧,上面也仅仅只是总结了对我自己比较收益,现阶段我能吸收能实际感受得到的规约,如果想要 PDF 版的可以在这里下载:戳这里

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz_javaweb
分享自己的Java Web学习之路以及各种Java学习资料

建议125:优先选择线程池

在Java1.5之前,实现多线程比较麻烦,需要自己启动线程,并关注同步资源,防止出现线程死锁等问题,在1.5版本之后引入了并行计算框架,大大简化了多线程开发。我们知道一个线程有五个状态:新建状态(NEW)、可运行状态(Runnable,也叫作运行状态)、阻塞状态(Blocked)、等待状态(Waiting)、结束状态(Terminated),线程的状态只能由新建转变为了运行状态后才能被阻塞或等待,最后终结,不可能产生本末倒置的情况,比如把一个结束状态的线程转变为新建状态,则会出现异常,例如如下代码会抛出异常:

public static void main(String[] args) throws InterruptedException {
        // 创建一个线程,新建状态
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程正在运行");
            }
        });
        // 运行状态
        t.start();
        // 是否是运行状态,若不是则等待10毫秒
        while (!t.getState().equals(Thread.State.TERMINATED)) {
            TimeUnit.MICROSECONDS.sleep(10);
        }
        // 直接由结束转变为云心态
        t.start();
    }

此段程序运行时会报java.lang.IllegalThreadStateException异常,原因就是不能从结束状态直接转变为运行状态,我们知道一个线程的运行时间分为3部分:T1为线程启动时间,T2为线程的运行时间,T3为线程销毁时间,如果一个线程不能被重复使用,每次创建一个线程都需要经过启动、运行、销毁时间,这势必增大系统的响应时间,有没有更好的办法降低线程的运行时间呢?

T2是无法避免的,只有通过优化代码来实现降低运行时间。T1和T2都可以通过线程池(Thread Pool)来缩减时间,比如在容器(或系统)启动时,创建足够多的线程,当容器(或系统)需要时直接从线程池中获得线程,运算出结果,再把线程返回到线程池中___ExecutorService就是实现了线程池的执行器,我们来看一个示例代码: 

public static void main(String[] args) throws InterruptedException {
        // 2个线程的线程池
        ExecutorService es = Executors.newFixedThreadPool(2);
        // 多次执行线程体
        for (int i = 0; i < 4; i++) {
            es.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName());
                }
            });
        }
        // 关闭执行器
        es.shutdown();
    }

此段代码首先创建了一个包含两个线程的线程池,然后在线程池中多次运行线程体,输出运行时的线程名称,结果如下:
**        pool-1-thread-1
        pool-1-thread-2
        pool-1-thread-1
        pool-1-thread-2**

本次代码执行了4遍线程体,按照我们之前阐述的” 一个线程不可能从结束状态转变为可运行状态 “,那为什么此处的2个线程可以反复使用呢?这就是我们要搞清楚的重点。

线程池涉及以下几个名词:

  • 工作线程(Worker):线程池中的线程,只有两个状态:可运行状态和等待状态,没有任务时它们处于等待状态,运行时它们循环的执行任务。
  • 任务接口(Task):这是每个任务必须实现的接口,以供工作线程调度器调度,它主要规定了任务的入口、任务执行完的场景处理,任务的执行状态等。这里有两种类型的任务:具有返回值(异常)的Callable接口任务和无返回值并兼容旧版本的Runnable接口任务。
  • 任务对列(Work Quene):也叫作工作队列,用于存放等待处理的任务,一般是BlockingQuene的实现类,用来实现任务的排队处理。

我们首先从线程池的创建说起,Executors.newFixedThreadPool(2)表示创建一个具有两个线程的线程池,源代码如下:

public class Executors {
    //生成一个最大为nThreads的线程池执行器
  public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

}

这里使用了LinkedBlockingQueue作为队列任务管理器,所有等待处理的任务都会放在该对列中,需要注意的是,此队列是一个阻塞式的单端队列。线程池建立好了,那就需要线程在其中运行了,线程池中的线程是在submit第一次提交任务时建立的,代码如下:

public Future<?> submit(Runnable task) {
        //检查任务是否为null
        if (task == null) throw new NullPointerException();
        //把Runnable任务包装成具有返回值的任务对象,不过此时并没有执行,只是包装
        RunnableFuture<Object> ftask = newTaskFor(task, null);
        //执行此任务
        execute(ftask);
        //返回任务预期执行结果
        return ftask;
    }

此处的代码关键是execute方法,它实现了三个职责。

  • 创建足够多的工作线程数,数量不超过最大线程数量,并保持线程处于运行或等待状态。
  • 把等待处理的任务放到任务队列中
  • 从任务队列中取出任务来执行

其中此处的关键是工作线程的创建,它也是通过new Thread方式创建的一个线程,只是它创建的并不是我们的任务线程(虽然我们的任务实现了Runnable接口,但它只是起了一个标志性的作用),而是经过包装的Worker线程,代码如下:  

private final class Worker implements Runnable {
// 运行一次任务
    private void runTask(Runnable task) {
        /* 这里的task才是我们自定义实现Runnable接口的任务 */
        task.run();
        /* 该方法其它代码略 */
    }
    // 工作线程也是线程,必须实现run方法
    public void run() {
        try {
            Runnable task = firstTask;
            firstTask = null;
            while (task != null || (task = getTask()) != null) {
                runTask(task);
                task = null;
            }
        } finally {
            workerDone(this);
        }
    }
    // 任务队列中获得任务
    Runnable getTask() {
        /* 其它代码略 */
        for (;;) {
            return r = workQueue.take();
        }
    }
}

此处为示意代码,删除了大量的判断条件和锁资源。execute方法是通过Worker类启动的一个工作线程,执行的是我们的第一个任务,然后改线程通过getTask方法从任务队列中获取任务,之后再继续执行,但问题是任务队列是一个BlockingQuene,是阻塞式的,也就是说如果该队列的元素为0,则保持等待状态,直到有任务进入为止,我们来看LinkedBlockingQuene的take方法,代码如下:  

public E take() throws InterruptedException {
        E x;
        int c = -1;
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly();
        try {
            try {
                // 如果队列中的元素为0,则等待
                while (count.get() == 0)
                    notEmpty.await();
            } catch (InterruptedException ie) {
                notEmpty.signal(); // propagate to a non-interrupted thread
                throw ie;
            }
            // 等待状态结束,弹出头元素
            x = extract();
            c = count.getAndDecrement();
            // 如果队列数量还多于一个,唤醒其它线程
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        // 返回头元素
        return x;
    }

分析到这里,我们就明白了线程池的创建过程:创建一个阻塞队列以容纳任务,在第一次执行任务时创建做够多的线程(不超过许可线程数),并处理任务,之后每个工作线程自行从任务对列中获得任务,直到任务队列中的任务数量为0为止,此时,线程将处于等待状态,一旦有任务再加入到队列中,即召唤醒工作线程进行处理,实现线程的可复用性。

使用线程池减少的是线程的创建和销毁时间,这对于多线程应用来说非常有帮助,比如我们常用的Servlet容器,每次请求处理的都是一个线程,如果不采用线程池技术,每次请求都会重新创建一个新的线程,这会导致系统的性能符合加大,响应效率下降,降低了系统的友好性。

省略了很多东西,因为有一些东西现在对于自己来说还不是那么实用,后边还有几个章节的内容也没有整理,是因为感觉是一些更加广泛的东西。有一些东西仍然很有针对性,但是在这里就不给出了。

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz_javaweb
分享自己的Java Web学习之路以及各种Java学习资料

写着写着发现简书提醒我文章接近字数极限,建议我换一篇写了。


建议52:推荐使用String直接量赋值

一般对象都是通过new关键字生成的,但是String还有第二种生成方式,也就是我们经常使用的直接声明方式,这种方式是极力推荐的,但不建议使用new String(”A”)的方式赋值。为什么呢?我们看如下代码:

public class Client58 {
    public static void main(String[] args) {
        String str1 = "詹姆斯";
        String str2 = "詹姆斯";
        String str3 = new String("詹姆斯");
        String str4 = str3.intern();
        // 两个直接量是否相等
        System.out.println(str1 == str2);
        // 直接量和对象是否相等
        System.out.println(str1 == str3);
        // 经过intern处理后的对象与直接量是否相等
        System.out.println(str1 == str4);
    }
}

注意看上面的程序,我们使用”==”判断的是两个对象的引用地址是否相同,也就是判断是否为同一个对象,打印的结果是true,false,true。即有两个直接量是同一个对象(进过intern处理后的String与直接量是同一个对象),但直接通过new生成的对象却与之不等,原因何在?

原因是Java为了避免在一个系统中大量产生String对象(为什么会大量产生,因为String字符串是程序中最经常使用的类型),于是就设计了一个字符串池(也叫作字符串常量池,String pool或String Constant Pool或String Literal Pool),在字符串池中容纳的都是String字符串对象,它的创建机制是这样的:创建一个字符串时,首先检查池中是否有字面值相等的字符串,如果有,则不再创建,直接返回池中该对象的引用,若没有则创建之,然后放到池中,并返回新建对象的引用,这个池和我们平常说的池非常接近。对于此例子来说,就是创建第一个”詹姆斯”字符串时,先检查字符串池中有没有该对象,发现没有,于是就创建了”詹姆斯”这个字符串并放到池中,待创建str2字符串时,由于池中已经有了该字符串,于是就直接返回了该对象的引用,此时,str1和str2指向的是同一个地址,所以使用”==”来判断那当然是相等的了。

那为什么使用new String(“詹姆斯”)就不相等了呢?因为直接声明一个String对象是不检查字符串池的,也不会把对象放到字符串池中,那当然”==”为false了。

那为什么intern方法处理后即又相等了呢?因为intern会检查当前对象在对象池中是否存在字面值相同的引用对象,如果有则返回池中的对象,如果没有则放置到对象池中,并返回当前对象。

可能有人要问了,放到池中,是不是要考虑垃圾回收问题呀?不用考虑了,虽然Java的每个对象都保存在堆内存中但是字符串非常特殊,它在编译期已经决定了其存在JVM的常量池(Constant Pool),垃圾回收不会对它进行回收的。

  通过上面的介绍,我们发现Java在字符串的创建方面确实提供了非常好的机制,利用对象池不仅可以提高效率,同时减少了内存空间的占用,建议大家在开发中使用直接量赋值方式,除非必要才建立一个String对象。


建议54:正确使用String、StringBuffer、StringBuilder

CharSequence接口有三个实现类与字符串有关,String、StringBuffer、StringBuilder,虽然它们都与字符串有关,但其处理机制是不同的。

String类是不可变的量,也就是创建后就不能再修改了,比如创建了一个”abc”这样的字符串对象,那么它在内存中永远都会是”abc”这样具有固定表面值的一个对象,不能被修改,即使想通过String提供的方法来尝试修改,也是要么创建一个新的字符串对象,要么返回自己,比如:

String  str = "abc";
String str1 = str.substring(1);

其中str是一个字符串对象,其值是”abc”,通过substring方法又重新生成了一个字符串str1,它的值是”bc”,也就是说str引用的对象一但产生就永远不会变。为什么上面还说有可能不创建对象而返回自己呢?那是因为采用substring(0)就不会创建对象。JVM从字符串池中返回str的引用,也就是自身的引用。

StringBuffer是一个可变字符串,它与String一样,在内存中保存的都是一个有序的字符序列(char 类型的数组),不同点是StringBuffer对象的值是可改变的,例如:

StringBuffer sb = new StringBuffer("a");
sb.append("b");

从上面的代码可以看出sb的值在改变,初始化的时候是”a” ,经过append方法后,其值变成了”ab”。可能有人会问了,这与String类通过 “+” 连接有什么区别呢?例如:

String s = "a";
s = s + "b";

有区别,字符串变量s初始化时是 “a” 对象的引用,经过加号计算后,s变量就修改为了 “ab” 的引用,但是初始化的 “a” 对象还没有改变,只是变量s指向了新的引用地址,再看看StringBuffer的对象,它的引用地址虽不变,但值在改变。

StringBuffer和StringBuilder基本相同,都是可变字符序列,不同点是:StringBuffer是线程安全的,StringBuilder是线程不安全的,翻翻两者的源代码,就会发现在StringBuffer的方法前都有关键字syschronized,这也是StringBuffer在性能上远远低于StringBuffer的原因。

  在性能方面,由于String类的操作都是产生String的对象,而StringBuilder和StringBuffer只是一个字符数组的再扩容而已,所以String类的操作要远慢于StringBuffer 和 StringBuilder。

  弄清楚了三者之间的原理,我们就可以在不同的场景下使用不同的字符序列了:

  • 使用String类的场景:在字符串不经常变化的场景中可以使用String类,例如常量的声明、少量的变量运算等;
  • 使用StringBuffer的场景:在频繁进行字符串的运算(如拼接、替换、删除等),并且运行在多线程的环境中,则可以考虑使用StringBuffer,例如XML解析、HTTP参数解析和封装等;
  • 使用StringBuilder的场景:在频繁进行字符串的运算(如拼接、替换、删除等),并且运行在单线程的环境中,则可以考虑使用StringBuilder,如SQL语句的拼接,JSON封装等。   

*注意:在适当的场景选用字符串类型 *

事实上这个问题被多个地方研究了很多次,我自己也写了一篇专门的文章来介绍String类:
http://www.jianshu.com/p/e494552f2cf0


建议55:注意字符串的位置

看下面一段程序:

public class Client55 {
    public static void main(String[] args) {
        String str1 = 1 + 2 + "apples";
        String str2 = "apples" + 1 + 2;
        System.out.println(str1);
        System.out.println(str2);
    }
}

想想两个字符串输出的结果的苹果数量是否一致,如果一致,会是几呢?

答案是不一致,str1的值是”3apples” ,str2的值是“apples12”,这中间悬殊很大,只是把“apples” 调换了一下位置,为何会发生如此大的变化呢?

这都源于java对于加号的处理机制:在使用加号进行计算的表达式中,只要遇到String字符串,则所有的数据都会转换为String类型进行拼接,如果是原始数据,则直接拼接,如是是对象,则调用toString方法的返回值然后拼接,如:

str =  str + new ArrayList();

上面就是调用ArrayList对象的toString方法返回值进行拼接的。再回到前面的问题上,对与str1 字符串,Java的执行顺序是从左到右,先执行1+2,也就是算术加法运算,结果等于3,然后再与字符串进行拼接,结果就是 “3 apples”,其它形式类似于如下计算:

String str1 = (1 + 2 ) + "apples" ;

而对于str2字符串,由于第一个参与运算的是String类型,加1后的结果是“apples 1” ,这仍然是一个字符串,然后再与2相加,结果还是一个字符串,也就是“apples12”。这说明如果第一个参数是String,则后续的所有计算都会转变为String类型,谁让字符串是老大呢!

注意: 在“+” 表达式中,String字符串具有最高优先级。


建议57:推荐在复杂字符串操作中使用正则表达式

这是一个很自然的选择,因为正则表达式实在是太强大了。


建议58:强烈建议使用UTF编码

Java的乱码问题由来已久,有经验的开发人员肯定遇到过乱码,有时从Web接收的乱码,有时从数据库中读取的乱码,有时是在外部接口中接收的乱码文件,这些都让我们困惑不已,甚至是痛苦不堪,看如下代码:

public class Client58 {
    public static void main(String[] args) throws UnsupportedEncodingException {
        String str = "汉字";
        // 读取字节
        byte b[] = str.getBytes("UTF-8");
        // 重新生成一个新的字符串
        System.out.println(new String(b));
    }
}

Java文件是通过IDE工具默认创建的,编码格式是GBK,大家想想看上面的输出结果会是什么?可能是乱码吧?两个编码格式不同。我们暂时不说结果,先解释一下Java中的编码规则。Java程序涉及的编码包括两部分:

  • (1)、Java文件编码:如果我们使用记事本创建一个.java后缀的文件,则文件的编码格式就是操作系统默认的格式。如果是使用IDE工具创建的,如Eclipse,则依赖于IDE的设置,Eclipse默认是操作系统编码(Windows一般为GBK);

  • (2)、Class文件编码:通过javac命令生成的后缀名为.class的文件是UTF-8编码的UNICODE文件,这在任何操作系统上都是一样的,只要是.class文件就会使UNICODE格式。需要说明的是,UTF是UNICODE的存储和传输格式,它是为了解决UNICODE的高位占用冗余空间而产生的,使用UTF编码就意味着字符集使用的是UNICODE.

再回到我们的例子上,getBytes方法会根据指定的字符集取出字节数组(这里按照UNICODE格式来提取),然后程序又通过new String(byte [] bytes)重新生成一个字符串,来看看String的这个构造函数:通过操作系统默认的字符集解码指定的byte数组,构造一个新的String,结果已经很清楚了,如果操作系统是UTF-8的话,输出就是正确的,如果不是,则会是乱码。由于这里使用的是默认编码GBK,那么输出的结果也就是乱码了。我们再详细分解一下运行步骤:

  • 步骤1:创建Client58.java文件:该文件的默认编码格式GBK(如果是Eclipse,则可以在属性中查看到)。

  • 步骤2:编写代码(如上);

  • 步骤3:保存,使用javac编译,注意我们没有使用”javac -encoding GBK Client58.java” 显示声明Java的编码方式,javac会自动按照操作系统的编码(GBK)读取Client58.java文件,然后将其编译成.class文件。

  • 步骤4:生成.class文件。编译结束,生成.class文件,并保存到硬盘上,此时 .class文件使用的UTF-8格式编码的UNICODE字符集,可以通过javap 命令阅读class文件,其中” 汉字”变量也已经由GBK转变成UNICODE格式了。

  • 步骤5:运行main方法,提取”汉字”的字节数组。”汉字” 原本是按照UTF-8格式保存的,要再提取出来当然没有任何问题了。

  • 步骤6:重组字符串,读取操作系统默认的编码GBK,然后重新编码变量b的所有字节。问题就在这里产生了:因为UNICODE的存储格式是两个字节表示一个字符(注意:这里是指UCS-2标准),虽然GBK也是两个字节表示一个字符,但两者之间没有映射关系,只要做转换只能读取映射表,不能实现自动转换—-于是JVM就按照默认的编码方式(GBK)读取了UNICODE的两个字节。

  • 步骤7:输出乱码,程序运行结束,问题清楚了,解决方案也随之产生,方案有两个。

  • 步骤8:修改代码,明确指定编码即可,代码如下:
    System.out.println(new String(b,"UTF-8"));

  • 步骤9:修改操作系统的编码方式,各个操作系统的修改方式不同,不再赘述。

我们可以把字符串读取字节的过程看做是数据传输的需要(比如网络、存储),而重组字符串则是业务逻辑的需求,这样就可以是乱码重现:通过JDBC读取的字节数组是GBK的,而业务逻辑编码时采用的是UTF-8,于是乱码就产生了。对于此类问题,最好的解决办法就是使用统一的编码格式,要么都用GBK,要么都用UTF-8,各个组件、接口、逻辑层、都用UTF-8,拒绝独树一帜的情况。

问题清楚了,我们看看以下代码: 

public class Client58 {
    public static void main(String[] args) throws UnsupportedEncodingException {
        String str = "汉字";
        // 读取字节
        byte b[] = str.getBytes("GB2312");
        // 重新生成一个新的字符串
        System.out.println(new String(b));
    }
}

仅仅修改了读取字节的编码方式(修改成了GB2312),结果会怎样呢?又或者将其修改成GB18030,结果又是怎样的呢?结果都是”汉字”,不是乱码。这是因为GB2312是中文字符集的V1.0版本,GBK是V2.0版本,GB18030是V3.0版本,版本是向下兼容的,只是它们包含的汉字数量不同而已,注意UNICODE可不在这个序列之内。

注意:一个系统使用统一的编码。


建议60:性能考虑,数组是首选

数组在实际的系统开发中用的越来越少了,我们通常只有在阅读一些开源项目时才会看到它们的身影,在Java中它确实没有List、Set、Map这些集合类用起来方便,但是在基本类型处理方面,数组还是占优势的,而且集合类的底层也都是通过数组实现的,比如对一数据集求和这样的计算:

//对数组求和
    public static int sum(int datas[]) {
        int sum = 0;
        for (int i = 0; i < datas.length; i++) {
            sum += datas[i];
        }
        return sum;
    }

对一个int类型 的数组求和,取出所有数组元素并相加,此算法中如果是基本类型则使用数组效率是最高的,使用集合则效率次之。再看使用List求和:

// 对列表求和计算
    public static int sum(List<Integer> datas) {
        int sum = 0;
        for (int i = 0; i < datas.size(); i++) {
            sum += datas.get(i);
        }
        return sum;
    }

注意看sum += datas.get(i);这行代码,这里其实已经做了一个拆箱动作,Integer对象通过intValue方法自动转换成了一个int基本类型,对于性能濒于临界的系统来说该方案是比较危险的,特别是大数量的时候,首先,在初始化List数组时要进行装箱操作,把一个int类型包装成一个Integer对象,虽然有整型池在,但不在整型池范围内的都会产生一个新的Integer对象,而且众所周知,基本类型是在栈内存中操作的,而对象是堆内存中操作的,栈内存的特点是:速度快,容量小;堆内存的特点是:速度慢,容量大(从性能上讲,基本类型的处理占优势)。其次,在进行求和运算时(或者其它遍历计算)时要做拆箱动作,因此无谓的性能消耗也就产生了。在实际测试中发现:对基本类型进行求和运算时,数组的效率是集合的10倍。

注意:性能要求较高的场景中使用数组代替集合。


建议64:多种最值算法,适时选择

对一批数据进行排序,然后找出其中的最大值或最小值,这是基本的数据结构知识。在Java中我们可以通过编写算法的方式,也可以通过数组先排序再取值的方式来实现,下面以求最大值为例,解释一下多种算法:

*(1)、自行实现,快速查找最大值  *
先看看用快速查找法取最大值的算法,代码如下: 

public static int max(int[] data) {
    int max = data[0];
    for (int i : data) {
        max = max > i ? max : i;
    }
    return max;
}

这是我们经常使用的最大值算法,也是速度最快的算法。它不要求排序,只要遍历一遍数组即可找出最大值。

(2)、先排序,后取值
对于求最大值,也可以采用先排序后取值的方式,代码如下:

public static int max(int[] data) {
    Arrays.sort(data);
    return data[data.length - 1];
}

从效率上讲,当然是自己写快速查找法更快一些了,只用遍历一遍就可以计算出最大值,但在实际测试中发现,如果数组量少于10000,两个基本上没有区别,但在同一个毫秒级别里,此时就可以不用自己写算法了,直接使用数组先排序后取值的方式。

如果数组元素超过10000,就需要依据实际情况来考虑:自己实现,可以提高性能;先排序后取值,简单,通俗易懂。排除性能上的差异,两者都可以选择,甚至后者更方便一些,也更容易想到。

现在问题来了,在代码中为什么先使用data.clone拷贝再排序呢?那是因为数组也是一个对象,不拷贝就改变了原有的数组元素的顺序吗?除非数组元素的顺序无关紧要。那如果要查找仅次于最大值的元素(也就是老二),该如何处理呢?要注意,数组的元素时可以重复的,最大值可能是多个,所以单单一个排序然后取倒数第二个元素时解决不了问题的。

此时,就需要一个特殊的排序算法了,先要剔除重复数据,然后再排序,当然,自己写算法也可以实现,但是集合类已经提供了非常好的方法,要是再使用自己写算法就显得有点重复造轮子了。数组不能剔除重复数据,但Set集合却是可以的,而且Set的子类TreeSet还能自动排序,代码如下: 

public static int getSecond(Integer[] data) {
    //转换为列表
    List<Integer> dataList = Arrays.asList(data);
    //转换为TreeSet,剔除重复元素并升序排列
    TreeSet<Integer> ts = new TreeSet<Integer>(dataList);
    //取得比最大值小的最大值,也就是老二了
    return ts.lower(ts.last());
}

剔除重复元素并升序排列,这都是由TreeSet类实现的,然后可再使用lower方法寻找小于最大值的值,大家看,上面的程序非常简单吧?那如果是我们自己编写代码会怎么样呢?那至少要遍历数组两遍才能计算出老二的值,代码复杂度将大大提升。因此在实际应用中求最值,包括最大值、最小值、倒数第二小值等,使用集合是最简单的方式,当然从性能方面来考虑,数组才是最好的选择。

注意:最值计算时使用集合最简单,使用数组性能最优。


建议82:由点及面,集合大家族总结

Java中的集合类实在是太丰富了,有常用的ArrayList、HashMap,也有不常用的Stack、Queue,有线程安全的Vector、HashTable,也有线程不安全的LinkedList、TreeMap,有阻塞式的ArrayBlockingQueue,也有非阻塞式的PriorityQueue等,整个集合大家族非常庞大,可以划分以下几类:

  • (1)、List:实现List接口的集合主要有:ArrayList、LinkedList、Vector、Stack,其中ArrayList是一个动态数组,LinkedList是一个双向链表,Vector是一个线程安全的动态数组,Stack是一个对象栈,遵循先进后出的原则。 

  • (2)、Set:Set是不包含重复元素的集合,其主要实现类有:EnumSet、HashSet、TreeSet,其中EnumSet是枚举类型专用Set,所有元素都是枚举类型;HashSet是以哈希码决定其元素位置的Set,其原理与HashMap相似,它提供快速的插入和查找方法;TreeSet是一个自动排序的Set,它实现了SortedSet接口。

  • (3)、Map:Map是一个大家族,他可以分为排序Map和非排序Map,排序Map主要是TreeMap类,他根据key值进行自动排序;非排序Map主要包括:HashMap、HashTable、Properties、EnumMap等,其中Properties是HashTable的子类,它的主要用途是从Property文件中加载数据,并提供方便的操作,EnumMap则是要求其Key必须是某一个枚举类型。
    Map中还有一个WeakHashMap类需要说明,它是一个采用弱键方式实现的Map类,它的特点是:WeakHashMap对象的存在并不会阻止垃圾回收器对键值对的回收,也就是说使用WeakHashMap装载数据不用担心内存溢出的问题,GC会自动删除不用的键值对,这是好事。但也存在一个严重的问题:GC是静悄悄的回收的(何时回收,God,Knows!)我们的程序无法知晓该动作,存在着重大的隐患。

  • (4)、Queue:对列,它分为两类,一类是阻塞式队列,队列满了以后再插入元素会抛出异常,主要包括:ArrayBlockingQueue、PriorityQueue、LinkedBlockingQueue,其中ArrayBlockingQueue是一个以数组方式实现的有界阻塞队列;另一类是非阻塞队列,无边界的,只要内存允许,都可以持续追加元素,我们经常使用的是PriorityQuene类。
    还有一种队列,是双端队列,支持在头、尾两端插入和移除元素,它的主要实现类是:ArrayDeque、LinkedBlockingDeque、LinkedList。

  • (5)、数组:数组与集合的最大区别就是数组能够容纳基本类型,而集合就不行,更重要的一点就是所有的集合底层存储的都是数组。

  • (6)、工具类:数组的工具类是java.util.Arraysjava.lang.reflect.Array,集合的工具类是java.util.Collections,有了这两个工具类,操作数组和集合就会易如反掌,得心应手。

  • (7)、扩展类:集合类当然可以自行扩展了,想写一个自己的List?没问题,但最好的办法还是”拿来主义”,可以使用Apache的common-collections扩展包,也可以使用Google的google-collections扩展包,这些足以应对我们的开发需要。


建议83:推荐使用枚举定义常量

常量声明是每一个项目都不可或缺的,在Java1.5之前,我们只有两种方式的声明:类常量和接口常量,若在项目中使用的是Java1.5之前的版本,基本上都是如此定义的。不过,在1.5版本以后有了改进,即新增了一种常量声明方式:枚举声明常量,看如下代码:

enum Season {
    Spring, Summer, Autumn, Winter;
}

这是一个简单的枚举常量命名,清晰又简单。顺便提一句,JLS(Java Language Specification,Java语言规范)提倡枚举项全部大写,字母之间用下划线分割,这也是从常量的角度考虑的(当然,使用类似类名的命名方式也是比较友好的)。

那么枚举常量与我们经常使用的类常量和静态常量相比有什么优势?问得好,枚举的优点主要表现在四个方面:

1.枚举常量简单:简不简单,我们来对比一下两者的定义和使用情况就知道了。先把Season枚举翻写成接口常量,代码如下:  

interface Season {
    int SPRING = 0;
    int SUMMER = 1;
    int AUTUMN = 2;
    int WINTER = 3;
}

此处定义了春夏秋冬四个季节,类型都是int,这与Season枚举的排序值是相同的。首先对比一下两者的定义,枚举常量只需定义每个枚举项,不需要定义枚举值,而接口常量(或类常量)则必须定义值,否则编译不通过,即使我们不需要关注其值是多少也必须定义;其次,虽然两者被引用的方式相同(都是 “类名 . 属性”,如Season.SPRING),但是枚举表示的是一个枚举项,字面含义是春天,而接口常量确是一个int类型,虽然其字面含义也是春天,但在运算中我们势必要关注其int值。

2.枚举常量属于稳态型:例如我们要描述一下春夏秋冬是什么样子,使用接口常量应该是这样写。  

public void describe(int s) {
        // s变量不能超越边界,校验条件
        if (s >= 0 && s < 4) {
            switch (s) {
            case Season.SPRING:
                System.out.println("this is spring");
                break;
            case Season.SUMMER:
                System.out.println("this is summer");
                break;
                ......
            }
        }
    }

很简单,先使用switch语句判断哪一个是常量,然后输出。但问题是我们得对输入值进行检查,确定是否越界,如果常量非常庞大,校验输入就成了一件非常麻烦的事情,但这是一个不可逃避的过程,特别是如果我们的校验条件不严格,虽然编译能照样通过,但是运行期就会产生无法预知的后果。

我们再来看看枚举常量是否能够避免校验的问题,代码如下:

public void describe(Season s){
    switch(s){
    case Spring:
        System.out.println("this is "+Season.Spring);
        break;
    case Summer:
        System.out.println("this is summer"+Season.Summer);
        break;
        ......
    }
}

不用校验,已经限定了是Season枚举,所以只能是Season类的四个实例,即春夏秋冬4个枚举项,想输入一个int类型或其它类型?门都没有!这是我们最看重枚举的地方:在编译期间限定类型,不允许发生越界的情况。

3.枚举具有内置方法:有一个简单的问题:如果要列出所有的季节常量,如何实现呢?接口常量或类常量可以通过反射来实现,这没错,只是虽然能实现,但会非常繁琐,大家可以自己写一个反射类实现此功能(当然,一个一个地动手打印出输出常量,也可以算是列出)。对于此类问题可以非常简单的解决,代码如下: 

public void query() {
    for (Season s : Season.values()) {
        System.out.println(s);
    }
}

通过values方法获得所有的枚举项,然后打印出来即可。如此简单,得益于枚举内置的方法,每个枚举都是java.lang.Enum的子类,该基类提供了诸如获得排序值的ordinal方法、compareTo比较方法等,大大简化了常量的访问。

4.枚举可以自定义的方法:这一点似乎并不是枚举的优点,类常量也可以有自己的方法呀,但关键是枚举常量不仅可以定义静态方法,还可以定义非静态方法,而且还能够从根本上杜绝常量类被实例化。比如我们要在常量定义中获得最舒服季节的方法,使用常量枚举的代码如下: 

enum Season {
        Spring, Summer, Autumn, Winter;
        public static Season getComfortableSeason(){
            return Spring;
        }
}

我们知道,每个枚举项都是该枚举的一个实例,对于我们的例子来说,也就表示Spring其实是Season的一个实例,Summer也是其中一个实例,那我们在枚举中定义的静态方法既可以在类(也就是枚举Season)中引用,也可以在实例(也就是枚举项Spring、Summer、Autumn、Winter)中引用,看如下代码:

public static void main(String[] args) {
    System.out.println("The most comfortable season is "+Season.getComfortableSeason());
}

那如果使用类常量要如何实现呢?代码如下: 

class Season {
    public final static int SPRING = 0;
    public final static int SUMMER = 1;
    public final static int AUTUMN = 2;
    public final static int WINTER = 3;
    public static  int getComfortableSeason(){
        return SPRING;
    }
}

想想看,我们怎么才能打印出”The most comfortable season is Spring” 这句话呢?除了使用switch和if判断之外没有其它办法了。

虽然枚举在很多方面比接口常量和类常量好用,但是有一点它是比不上接口常量和类常量的,那就是继承,枚举类型是不能继承的,也就是说一个枚举常量定义完毕后,除非修改重构,否则无法做扩展,而接口常量和类常量则可以通过继承进行扩展。但是,一般常量在项目构建时就定义完毕了,很少会出现必须通过扩展才能实现业务逻辑的场景。

注意: 在项目中推荐使用枚举常量代替接口常量或类常量。


建议88:用枚举实现工厂方法模式更简洁

工厂方法模式(Factory Method Pattern)是” 创建对象的接口,让子类决定实例化哪一个类,并使一个类的实例化延迟到其它子类”。工厂方法模式在我们的开发中经常会用到。下面以汽车制造为例,看看一般的工厂方法模式是如何实现的,代码如下:

//抽象产品
interface Car{

}
//具体产品类
class FordCar implements Car{

}
//具体产品类
class BuickCar implements Car{

}
//工厂类
class CarFactory{
    //生产汽车
    public static Car createCar(Class<? extends Car> c){
        try {
            return c.newInstance();
        } catch (InstantiationException | IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;
    }
}

这是最原始的工厂方法模式,有两个产品:福特汽车和别克汽车,然后通过工厂方法模式来生产。有了工厂方法模式,我们就不用关心一辆车具体是怎么生成的了,只要告诉工厂” 给我生产一辆福特汽车 “就可以了,下面是产出一辆福特汽车时客户端的代码: 

    public static void main(String[] args) {
        //生产车辆
        Car car = CarFactory.createCar(FordCar.class);
    }

这就是我们经常使用的工厂方法模式,但经常使用并不代表就是最优秀、最简洁的。此处再介绍一种通过枚举实现工厂方法模式的方案,谁优谁劣你自行评价。枚举实现工厂方法模式有两种方法:

(1)、枚举非静态方法实现工厂方法模式

我们知道每个枚举项都是该枚举的实例对象,那是不是定义一个方法可以生成每个枚举项对应产品来实现此模式呢?代码如下:

enum CarFactory {
    // 定义生产类能生产汽车的类型
    FordCar, BuickCar;
    // 生产汽车
    public Car create() {
        switch (this) {
        case FordCar:
            return new FordCar();
        case BuickCar:
            return new BuickCar();
        default:
            throw new AssertionError("无效参数");
        }
    }

}

create是一个非静态方法,也就是只有通过FordCar、BuickCar枚举项才能访问。采用这种方式实现工厂方法模式时,客户端要生产一辆汽车就很简单了,代码如下:

public static void main(String[] args) {
        // 生产车辆
        Car car = CarFactory.BuickCar.create();
    }

(2)、通过抽象方法生成产品

枚举类型虽然不能继承,但是可以用abstract修饰其方法,此时就表示该枚举是一个抽象枚举,需要每个枚举项自行实现该方法,也就是说枚举项的类型是该枚举的一个子类,我们俩看代码:

enum CarFactory {
    // 定义生产类能生产汽车的类型
    FordCar{
        public Car create(){
            return new FordCar();
        }
    },
    BuickCar{
        public Car create(){
            return new BuickCar();
        }
    };
    //抽象生产方法
    public abstract Car create();
}

首先定义一个抽象制造方法create,然后每个枚举项自行实现,这种方式编译后会产生CarFactory的匿名子类,因为每个枚举项都要实现create抽象方法。客户端调用与上一个方案相同,不再赘述。

大家可能会问,为什么要使用枚举类型的工厂方法模式呢?那是因为使用枚举类型的工厂方法模式有以下三个优点:

  • 避免错误调用的发生:一般工厂方法模式中的生产方法(也就是createCar方法),可以接收三种类型的参数:类型参数(如我们的例子)、String参数(生产方法中判断String参数是需要生产什么产品)、int参数(根据int值判断需要生产什么类型的的产品),这三种参数都是宽泛的数据类型,很容易发生错误(比如边界问题、null值问题),而且出现这类错误编译器还不会报警,例如:

      public static void main(String[] args) {
          // 生产车辆
          Car car = CarFactory.createCar(Car.class);
      }

    Car是一个接口,完全合乎createCar的要求,所以它在编译时不会报任何错误,但一运行就会报出InstantiationException异常,而使用枚举类型的工厂方法模式就不存在该问题了,不需要传递任何参数,只需要选择好生产什么类型的产品即可。

  • 性能好,使用简洁:枚举类型的计算时以int类型的计算为基础的,这是最基本的操作,性能当然会快,至于使用便捷,注意看客户端的调用,代码的字面意思就是” 汽车工厂,我要一辆别克汽车,赶快生产”。

  • 降低类间耦合:不管生产方法接收的是Class、String还是int的参数,都会成为客户端类的负担,这些类并不是客户端需要的,而是因为工厂方法的限制必须输入的,例如Class参数,对客户端main方法来说,他需要传递一个FordCar.class参数才能生产一辆福特汽车,除了在create方法中传递参数外,业务类不需要改Car的实现类。这严重违背了迪米特原则(Law of Demeter 简称LoD),也就是最少知识原则:一个对象应该对其它对象有最少的了解。

而枚举类型的工厂方法就没有这种问题了,它只需要依赖工厂类就可以生产一辆符合接口的汽车,完全可以无视具体汽车类的存在。


建议93:Java的泛型是可以擦除的

Java泛型(Generic) 的引入加强了参数类型的安全性,减少了类型的转换,它与C++中的模板(Temeplates) 比较类似,但是有一点不同的是:Java的泛型在编译器有效,在运行期被删除,也就是说所有的泛型参数类型在编译后会被清除掉,我们来看一个例子,代码如下:

public class Foo {
    //arrayMethod接收数组参数,并进行重载
    public void arrayMethod(String[] intArray) {

    }

    public void arrayMethod(Integer[] intArray) {

    }
    //listMethod接收泛型List参数,并进行重载
    public void listMethod(List<String> stringList) {

    }
    public void listMethod(List<Integer> intList) {

    }
}

程序很简单,编写了4个方法,arrayMethod方法接收String数组和Integer数组,这是一个典型的重载,listMethod接收元素类型为String和Integer的list变量。现在的问题是,这段程序是否能编译?如果不能?问题出在什么地方?

事实上,这段程序时无法编译的,编译时报错信息如下:

这段错误的意思:简单的的说就是方法签名重复,其实就是说listMethod(List intList)方法在编译时擦除类型后是listMethod(List intList)与另一个方法重复。这就是Java泛型擦除引起的问题:在编译后所有的泛型类型都会做相应的转化。转换规则如下:

  • List、List、List擦除后的类型为List
  • List[] 擦除后的类型为List[].
  • List<? extends E> 、List<? super E> 擦除后的类型为List.
  • List<T extends Serializable & Cloneable >擦除后的类型为List< Serializable>.

明白了这些规则,再看如下代码:

public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        list.add("abc");
        String str = list.get(0);
    }

进过编译后的擦除处理,上面的代码和下面的程序时一致的:

public static void main(String[] args) {
        List list = new ArrayList();
        list.add("abc");
        String str = (String) list.get(0);
    }

Java编译后字节码中已经没有泛型的任何信息了,也就是说一个泛型类和一个普通类在经过编译后都指向了同一字节码,比如Foo类,经过编译后将只有一份Foo.class类,不管是Foo还是Foo引用的都是同一字节码。Java之所以如此处理,有两个原因:

  • 避免JVM的大换血。C++泛型生命期延续到了运行期,而Java是在编译期擦除掉的,我们想想,如果JVM也把泛型类型延续到运行期,那么JVM就需要进行大量的重构工作了。
  • 版本兼容:在编译期擦除可以更好的支持原生类型(Raw Type),在Java1.5或1.6…平台上,即使声明一个List这样的原生类型也是可以正常编译通过的,只是会产生警告信息而已。

明白了Java泛型是类型擦除的,我们就可以解释类似如下的问题了:

1.泛型的class对象是相同的:每个类都有一个class属性,泛型化不会改变class属性的返回值,例如:

public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        List<Integer> list2 = new ArrayList<Integer>();
        System.out.println(list.getClass()==list2.getClass());
    }

以上代码返回true,原因很简单,List和List擦除后的类型都是List,没有任何区别。

*2.泛型数组初始化时不能声明泛型,如下代码编译时通不过: *

List<String>[] listArray = new List<String>[];

原因很简单,可以声明一个带有泛型参数的数组,但不能初始化该数组,因为执行了类型擦除操作,List[]与List[] 就是同一回事了,编译器拒绝如此声明。

3.instanceof不允许存在泛型参数:以下代码不能通过编译,原因一样,泛型类型被擦除了:   

List<String> list = new ArrayList<String>();
System.out.println(list instanceof List<String>);

建议98:建议的采用顺序是List中泛型顺序依次为T、?、Object

List、List、List这三者都可以容纳所有的对象,但使用的顺序应该是首选List,次之List,最后选择List,原因如下:

(1)、List是确定的某一个类型

List表示的是List集合中的元素都为T类型,具体类型在运行期决定;List<?>表示的是任意类型,与List类似,而List则表示List集合中的所有元素为Object类型,因为Object是所有类的父类,所以List也可以容纳所有的类类型,从这一字面意义上分析,List更符合习惯:编码者知道它是某一个类型,只是在运行期才确定而已。

(2)List可以进行读写操作

List可以进行诸如add,remove等操作,因为它的类型是固定的T类型,在编码期不需要进行任何的转型操作。

List是只读类型的,不能进行增加、修改操作,因为编译器不知道List中容纳的是什么类型的元素,也就无法校验类型是否安全了,而且List<?>读取出的元素都是Object类型的,需要主动转型,所以它经常用于泛型方法的返回值。注意List<?>虽然无法增加,修改元素,但是却可以删除元素,比如执行remove、clear等方法,那是因为它的删除动作与泛型类型无关。

List 也可以读写操作,但是它执行写入操作时需要向上转型(Up cast),在读取数据的时候需要向下转型,而此时已经失去了泛型存在的意义了。

打个比方,有一个篮子用来容纳物品,比如西瓜,番茄等.List的意思是说,“嘿,我这里有一个篮子,可以容纳固定类别的东西,比如西瓜,番茄等”。List的意思是说:“嘿,我有一个篮子,我可以容纳任何东西,只要是你想得到的”。而List就更有意思了,它说” 嘿,我也有一个篮子,我可以容纳所有物质,只要你认为是物质的东西都可以容纳进来 “。

推而广之,Dao应该比Dao<?>、Dao更先采用,Desc则比Desc<?>、Desc更优先采用。


建议101:注意Class类的特殊性

Java语言是先把Java源文件编译成后缀为class的字节码文件,然后再通过ClassLoader机制把这些类文件加载到内存中,最后生成实例执行的,这是Java处理的基本机制,但是加载到内存中的数据的如何描述一个类的呢?比如在Dog.class文件中定义一个Dog类,那它在内存中是如何展现的呢?

Java使用一个元类(MetaClass)来描述加载到内存中的类数据,这就是Class类,它是一个描述类的类对象,比如Dog.class文件加载到内存中后就会有一个class的实例对象描述之。因为是Class类是“类中类”,也就有预示着它有很多特殊的地方:

  • 1.无构造函数:Java中的类一般都有构造函数,用于创建实例对象,但是Class类却没有构造函数,不能实例化,Class对象是在加载类时由Java虚拟机通过调用类加载器中的difineClass方法自动构造的。
  • 2.可以描述基本类型:虽然8个基本类型在JVM中并不是一个对象,它们一般存在于栈内存中,但是Class类仍然可以描述它们,例如可以使用int.class表示int类型的类对象。
  • 3.其对象都是单例模式:一个Class的实例对象描述一个类,并且只描述一个类,反过来也成立。一个类只有一个Class实例对象,如下代码返回的结果都为true: 
          // 类的属性class所引用的对象与实例对象的getClass返回值相同
          boolean b1=String.class.equals(new String().getClass());
          boolean b2="ABC".getClass().equals(String.class);
          // class实例对象不区分泛型
          boolean b3=ArrayList.class.equals(new ArrayList<String>().getClass());

Class类是Java的反射入口,只有在获得了一个类的描述对象后才能动态的加载、调用,一般获得一个Class对象有三种途径:

  • 类属性方式:如String.class
  • 对象的getClass方法,如new String().getClass()
  • forName方法加载:如Class.forName(“ java.lang.String”)

获得了Class对象后,就可以通过getAnnotations()获得注解,通过getMethods()获得方法,通过getConstructors()获得构造函数等,这位后续的反射代码铺平了道路。


建议106:动态代理可以使代理模式更加灵活

Java的反射框架提供了动态代理(Dynamic Proxy)机制,允许在运行期对目标类生成代理,避免重复开发。我们知道一个静态代理是通过主题角色(Proxy)和具体主题角色(Real Subject)共同实现主题角色(Subject)的逻辑的,只是代理角色把相关的执行逻辑委托给了具体角色而已,一个简单的静态代理如下所示:

interface Subject {
    // 定义一个方法
    public void request();
}

// 具体主题角色
class RealSubject implements Subject {
    // 实现方法
    @Override
    public void request() {
        // 实现具体业务逻辑
    }

}

class Proxy implements Subject {
    // 要代理那个实现类
    private Subject subject = null;

    // 默认被代理者
    public Proxy() {
        subject = new RealSubject();
    }

    // 通过构造函数传递被代理者
    public Proxy(Subject _subject) {
        subject = _subject;
    }

    @Override
    public void request() {
        before();
        subject.request();
        after();
    }

    // 预处理
    private void after() {
        // doSomething
    }

    // 善后处理
    private void before() {
        // doSomething
    }
}

这是一个简单的静态代理。Java还提供了java.lang.reflect.Proxy用于实现动态代理:只要提供一个抽象主题角色和具体主题角色,就可以动态实现其逻辑的,其实例代码如下:

interface Subject {
    // 定义一个方法
    public void request();
}

// 具体主题角色
class RealSubject implements Subject {
    // 实现方法
    @Override
    public void request() {
        // 实现具体业务逻辑
    }

}

class SubjectHandler implements InvocationHandler {
    // 被代理的对象
    private Subject subject;

    public SubjectHandler(Subject _subject) {
        subject = _subject;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args)
            throws Throwable {
        // 预处理
        System.out.println("预处理...");
        //直接调用被代理的方法
        Object obj = method.invoke(subject, args);
        // 后处理
        System.out.println("后处理...");
        return obj;
    }
}

注意这里没有代理主题角色,取而代之的是SubjectHandler 作为主要的逻辑委托处理,其中invoke方法是接口InvocationHandler定义必须实现的,它完成了对真实方法的调用。

我们来详细解释一下InvocationHandler接口,动态代理是根据被代理的接口生成的所有方法的,也就是说给定一个或多个接口,动态代理会宣称“我已经实现该接口下的所有方法了”,那大家想想看,动态代理是怎么才能实现接口中的方法呢?在默认情况下所有方法的返回值都是空的,是的,虽然代理已经实现了它,但是没有任何的逻辑含义,那怎么办?好办,通过InvocationHandler接口的实现类来实现,所有的方法都是由该Handler进行处理的,即所有被代理的方法都由InvocationHandler接管实际的处理任务。

我们开看看动态代理的场景,代码如下: 

public static void main(String[] args) {
        //具体主题角色,也就是被代理类
        Subject subject = new RealSubject();
        //代理实例的处理Handler
        InvocationHandler handler =new SubjectHandler(subject);
        //当前加载器
        ClassLoader cl = subject.getClass().getClassLoader();
        //动态代理
        Subject proxy = (Subject) Proxy.newProxyInstance(cl,subject.getClass().getInterfaces(),handler);
        //执行具体主题角色方法
        proxy.request();
    }

此时就实现了,不用显式创建代理类即实现代理的功能,例如可以在被代理的角色执行前进行权限判断,或者执行后进行数据校验。

动态代理很容易实现通用的代理类,只要在InvocationHandler的invoke方法中读取持久化的数据即可实现,而且还能实现动态切入的效果,这也是AOP(Aspect Oriented Programming)变成理念。


建议110:提倡异常封装

Java语言的异常处理机制可以去确保程序的健壮性,提高系统的可用率,但是Java API提供的异常都是比较低级的(这里的低级是指 “ 低级别的 “ 异常),只有开发人员才能看的懂,才明白发生了什么问题。而对于终端用户来说,这些异常基本上就是天书,与业务无关,是纯计算机语言的描述,那该怎么办?这就需要我们对异常进行封装了。异常封装有三方面的优点:

(1)、提高系统的友好性

例如,打开一个文件,如果文件不存在,则回报FileNotFoundException异常,如果该方法的编写者不做任何处理,直接抛到上层,则会降低系统的友好性,代码如下所示:

public static void doStuff() throws FileNotFoundException {
        InputStream is = new FileInputStream("无效文件.txt");
        /* 文件操作 */
    }

此时doStuff的友好性极差,出现异常时(如果文件不存在),该方法直接把FileNotFoundException异常抛到上层应用中(或者是最终用户),而上层应用(或用户要么自己处理),要么接着抛,最终的结果就是让用户面对着” 天书 “ 式的文字发呆,用户不知道这是什么问题,只是知道系统告诉他” 哦,我出错了,什么错误?你自己看着办吧 “。

解决办法就是封装异常,可以把异常的阅读者分为两类:开发人员和用户。开发人员查找问题,需要打印出堆栈信息,而用户则需要了解具体的业务原因,比如文件太大、不能同时编写文件等,代码如下:

public static void doStuff2() throws MyBussinessException{
        try {
            InputStream is = new FileInputStream("无效文件.txt");
        } catch (FileNotFoundException e) {
            //方便开发人员和维护人员而设置的异常信息
            e.printStackTrace();
            //抛出业务异常
            throw new MyBussinessException();
        }
        /* 文件操作 */
    }
(2)、提高系统的可维护性

看如下代码:

public  void doStuff3(){
        try{
            //doSomething
        }catch(Exception e){
            e.printStackTrace();
        }

    }

这是大家很容易犯的错误,抛出异常是吧?分类处理多麻烦,就写一个catch块来处理所有的异常吧,而且还信誓旦旦的说” JVM会打印出栈中的错误信息 “,虽然这没错,但是该信息只有开发人员自己看的懂,维护人员看到这段异常时基本上无法处理,因为需要到代码逻辑中去分析问题。

正确的做法是对异常进行分类处理,并进行封装输出,代码如下: 

public  void doStuff4(){
        try{
            //doSomething
        }catch(FileNotFoundException e){
            log.info("文件未找到,使用默认配置文件....");
            e.printStackTrace();
        }catch(SecurityException e1){
            log.info(" 无权访问,可能原因是......");
            e1.printStackTrace();
        }
    }

如此包装后,维护人员看到这样的异常就有了初步的判断,或者检查配置,或者初始化环境,不需要直接到代码层级去分析了。

(3)、解决Java异常机制自身的缺陷

Java中的异常一次只能抛出一个,比如doStuff方法有两个逻辑代码片段,如果在第一个逻辑片段中抛出异常,则第二个逻辑片段就不再执行了,也就无法抛出第二个异常了,现在的问题是:如何才能一次抛出两个(或多个)异常呢?

其实,使用自行封装的异常可以解决该问题,代码如下: 

class MyException extends Exception {
    // 容纳所有的异常
    private List<Throwable> causes = new ArrayList<Throwable>();

    // 构造函数,传递一个异常列表
    public MyException(List<? extends Throwable> _causes) {
        causes.addAll(_causes);
    }

    // 读取所有的异常
    public List<Throwable> getExceptions() {
        return causes;
    }
}

MyException异常只是一个异常容器,可以容纳多个异常,但它本身并不代表任何异常含义,它所解决的是一次抛出多个异常的问题,具体调用如下:

public void doStuff() throws MyException {
        List<Throwable> list = new ArrayList<Throwable>();
        // 第一个逻辑片段
        try {
            // Do Something
        } catch (Exception e) {
            list.add(e);
        }
        // 第二个逻辑片段
        try {
            // Do Something
        } catch (Exception e) {
            list.add(e);
        }
        // 检查是否有必要抛出异常
        if (list.size() > 0) {
            throw new MyException(list);
        }
    }

这样一来,DoStuff方法的调用者就可以一次获得多个异常了,也能够为用户提供完整的例外情况说明。可能有人会问:这种情况会出现吗?怎么回要求一个方法抛出多个异常呢?

绝对有可能出现,例如Web界面注册时,展现层依次把User对象传递到逻辑层,Register方法需要对各个Field进行校验并注册,例如用户名不能重复,密码必须符合密码策略等,不要出现用户第一次提交时系统显示” 用户名重复 “,在用户修改用户名再次提交后,系统又提示” 密码长度小于6位 “ 的情况,这种操作模式下的用户体验非常糟糕,最好的解决办法就是异常封装,建立异常容器,一次性地对User对象进行校验,然后返回所有的异常。


建议114:不要在构造函数中抛出异常

Java异常的机制有三种:

  • Error类及其子类表示的是错误,它是不需要程序员处理也不能处理的异常,比如VirtualMachineError虚拟机错误,ThreadDeath线程僵死等。
  • RunTimeException类及其子类表示的是非受检异常,是系统可能会抛出的异常,程序员可以去处理,也可以不处理,最经典的就是NullPointException空指针异常和IndexOutOfBoundsException越界异常。
  • Exception类及其子类(不包含非受检异常),表示的是受检异常,这是程序员必须处理的异常,不处理则程序不能通过编译,比如IOException表示的是I/O异常,SQLException表示的数据库访问异常。  

我们知道,一个对象的创建过程经过内存分配,静态代码初始化、构造函数执行等过程,对象生成的关键步骤是构造函数,那是不是也允许在构造函数中抛出异常呢?从Java语法上来说,完全可以在构造函数中抛出异常,三类异常都可以,但是从系统设计和开发的角度来分析,则尽量不要在构造函数中抛出异常,我们以三种不同类型的异常来说明之。

(1)、构造函数中抛出错误是程序员无法处理的

在构造函数执行时,若发生了VirtualMachineError虚拟机错误,那就没招了,只能抛出,程序员不能预知此类错误的发生,也就不能捕捉处理。

(2)、构造函数不应该抛出非受检异常

我们来看这样一个例子,代码如下:

class Person {
    public Person(int _age) {
        // 不满18岁的用户对象不能建立
        if (_age < 18) {
            throw new RuntimeException("年龄必须大于18岁.");
        }
    }

    public void doSomething() {
        System.out.println("doSomething......");
    }
}

这段代码的意图很明显,年龄不满18岁的用户不会生成一个Person实例对象,没有对象,类行为doSomething方法就不可执行,想法很好,但这会导致不可预测的结果,比如我们这样引用Person类:

public static void main(String[] args) {
        Person p =  new Person(17);
        p.doSomething();
        /*其它的业务逻辑*/
    }

很显然,p对象不能建立,因为是一个RunTimeException异常,开发人员可以捕捉也可以不捕捉,代码看上去逻辑很正确,没有任何瑕疵,但是事实上,这段程序会抛出异常,无法执行。这段代码给了我们两个警示:

  • 1.加重了上层代码编写者的负担:捕捉这个RuntimeException异常吧,那谁来告诉我有这个异常呢?只有通过文档约束了,一旦Person类的构造函数经过重构后再抛出其它非受检异常,那main方法不用修改也是可以测试通过的,但是这里就可能会产生隐藏的缺陷,而写还是很难重现的缺陷。不捕捉这个RuntimeException异常,这个是我们通常的想法,既然已经写成了非受检异常,main方法的编码者完全可以不处理这个异常嘛,大不了不执行Person的方法!这是非常危险的,一旦产生异常,整个线程都不再继续执行,或者链接没有关闭,或者数据没有写入数据库,或者产生内存异常,这些都是会对整个系统产生影响。
  • 2.后续代码不会执行:main方法的实现者原本是想把p对象的建立作为其代码逻辑的一部分,执行完doSomething方法后还需要完成其它逻辑,但是因为没有对非受检异常进行捕捉,异常最终会抛出到JVM中,这会导致整个线程执行结束后,后面所有的代码都不会继续执行了,这就对业务逻辑产生了致命的影响。
(3)、构造函数尽可能不要抛出受检异常

我们来看下面的例子,代码如下:

//父类
class Base {
    // 父类抛出IOException
    public Base() throws IOException {
        throw new IOException();
    }
}
//子类
class Sub extends Base {
    // 子类抛出Exception异常
    public Sub() throws Exception {

    }
}

就这么一段简单的代码,展示了在构造函数中抛出受检异常的三个不利方面:

  • 1.导致子类膨胀:在我们的例子中子类的无参构造函数不能省略,原因是父类的无参构造函数抛出了IOException异常,子类的无参构造函数默认调用的是父类的构造函数,所以子类无参构造函数也必须抛出IOException或其父类。
  • 2.违背了里氏替换原则:”里氏替换原则” 是说父类能出现的地方子类就可以出现,而且将父类替换为子类也不会产生任何异常。那我们回头看看Sub类是否可以替换Base类,比如我们的上层代码是这样写的:
    public static void main(String[] args) {
          try {
              Base base = new Base();
          } catch (Exception e) {    
              e.printStackTrace();
          }
      }
    然后,我们期望把new Base()替换成new Sub(),而且代码能够正常编译和运行。非常可惜,编译不通过,原因是Sub的构造函数抛出了Exception异常,它比父类的构造函数抛出更多的异常范围要宽,必须增加新的catch块才能解决。  

可能大家要问了,为什么Java的构造函数允许子类的构造函数抛出更广泛的异常类呢?这正好与类方法的异常机制相反,类方法的异常是这样要求的:

// 父类
class Base {
    // 父类方法抛出Exception
    public void testMethod() throws Exception {

    }
}

// 子类
class Sub extends Base {
    // 父类方法抛出Exception
    @Override
    public void testMethod() throws IOException {

    }
}

子类的方法可以抛出多个异常,但都必须是覆写方法的子类型,对我们的例子来说,Sub类的testMethod方法抛出的异常必须是Exception的子类或Exception类,这是Java覆写的要求。构造函数之所以于此相反,是因为构造函数没有覆写的概念,只是构造函数间的引用调用而已,所以在构造函数中抛出受检异常会违背里氏替换原则原则,使我们的程序缺乏灵活性。

  • 3.子类构造函数扩展受限:子类存在的原因就是期望实现扩展父类的逻辑,但父类构造函数抛出异常却会让子类构造函数的灵活性大大降低,例如我们期望这样的构造函数。

    // 父类
    class Base {
      public Base() throws IOException{
    
      }
    }
    // 子类
    class Sub extends Base {
      public Sub() throws Exception{
          try{
              super();
          }catch(IOException e){
              //异常处理后再抛出
              throw e;
          }finally{
              //收尾处理
          }
      }
    }

    很不幸,这段代码编译不通过,原因是构造函数Sub没有把super()放在第一句话中,想把父类的异常重新包装再抛出是不可行的(当然,这里有很多种 “曲线” 的实现手段,比如重新定义一个方法,然后父子类的构造函数都调用该方法,那么子类构造函数就可以自由处理异常了),这是Java语法机制。

将以上三种异常类型汇总起来,对于构造函数,错误只能抛出,这是程序人员无能为力的事情;非受检异常不要抛出,抛出了 “ 对己对人 “ 都是有害的;受检异常尽量不抛出,能用曲线的方式实现就用曲线方式实现,总之一句话:在构造函数中尽可能不出现异常。

注意 :在构造函数中不要抛出异常,尽量曲线实现。


建议117:多使用异常,把性能问题放一边

我们知道异常是主逻辑的例外逻辑,举个简单的例子来说,比如我在马路上走(这是主逻辑),突然开过一辆车,我要避让(这是受检异常,必须处理),继续走着,突然一架飞机从我头顶飞过(非受检异常),我们可以选在继续行走(不捕捉),也可以选择指责其噪音污染(捕捉,主逻辑的补充处理),再继续走着,突然一颗流星砸下来,这没有选择,属于错误,不能做任何处理。这样具备完整例外场景的逻辑就具备了OO的味道,任何一个事务的处理都可能产生非预期的效果,问题是需要以何种手段来处理,如果不使用异常就需要依靠返回值的不同来进行处理了,这严重失去了面向对象的风格。

我们在编写用例文档(User case Specification)时,其中有一项叫做 “ 例外事件 “,是用来描述主场景外的例外场景的,例如用户登录的用例,就会在” 例外事件 “中说明” 连续3此登录失败即锁定用户账号 “,这就是登录事件的一个异常处理,具体到我们的程序中就是:  

public void login(){
        try{
            //正常登陆
        }catch(InvalidLoginException lie){
            //    用户名无效
        }catch(InvalidPasswordException pe){
            //密码错误的异常
        }catch(TooMuchLoginException){
            //多次登陆失败的异常
        }
    }

如此设计则可以让我们的login方法更符合实际的处理逻辑,同时使主逻辑(正常登录,try代码块)更加清晰。当然了,使用异常还有很多优点,可以让正常代码和异常代码分离、能快速查找问题(栈信息快照)等,但是异常有一个缺点:性能比较慢。

Java的异常机制确实比较慢,这个”比较慢”是相对于诸如String、Integer等对象来说的,单单从对象的创建上来说,new一个IOException会比String慢5倍,这从异常的处理机制上也可以解释:因为它要执行fillInStackTrace方法,要记录当前栈的快照,而String类则是直接申请一个内存创建对象,异常类慢一筹也就在所难免了。

而且,异常类是不能缓存的,期望先建立大量的异常对象以提高异常性能也是不现实的。

难道异常的性能问题就没有任何可以提高的办法了?确实没有,但是我们不能因为性能问题而放弃使用异常,而且经过测试,在JDK1.6下,一个异常对象的创建时间只需1.4毫秒左右(注意是毫秒,通常一个交易是在100毫秒左右),难道我们的系统连如此微小的性能消耗都不予许吗?

** 注意:性能问题不是拒绝异常的借口。**


建议121:线程优先级只使用三个等级

线程的优先级(Priority)决定了线程获得CPU运行的机会,优先级越高获得的运行机会越大,优先级越低获得的机会越小。Java的线程有10个级别(准确的说是11个级别,级别为0的线程是JVM的,应用程序不能设置该级别),那是不是说级别是10的线程肯定比级别是9的线程先运行呢?我们来看如下一个多线程类:

class TestThread implements Runnable {
    public void start(int _priority) {
        Thread t = new Thread(this);
        // 设置优先级别
        t.setPriority(_priority);
        t.start();
    }
    @Override
    public void run() {
        // 消耗CPU的计算
        for (int i = 0; i < 100000; i++) {
            Math.hypot(924526789, Math.cos(i));
        }
        // 输出线程优先级
        System.out.println("Priority:" + Thread.currentThread().getPriority());
    }
}

该多线程实现了Runnable接口,实现了run方法,注意在run方法中有一个比较占用CPU的计算,该计算毫无意义,

public static void main(String[] args) {
        //启动20个不同优先级的线程
        for (int i = 0; i < 20; i++) {
            new TestThread().start(i % 10 + 1);
        }
    }

这里创建了20个线程,每个线程在运行时都耗尽了CPU的资源,因为优先级不同,线程调度应该是先处理优先级高的,然后处理优先级低的,也就是先执行2个优先级为10的线程,然后执行2个优先级为9的线程,2个优先级为8的线程……但是结果却并不是这样的。
**  Priority:5
  Priority:7
  Priority:10
  Priority:6
  Priority:9
  Priority:6
  Priority:5
  Priority:7
  Priority:10
  Priority:3
  Priority:4
  Priority:8
  Priority:8
  Priority:9
  Priority:4
  Priority:1
  Priority:3
  Priority:1
  Priority:2
  Priority:2**

println方法虽然有输出损耗,可能会影响到输出结果,但是不管运行多少次,你都会发现两个不争的事实:
(1)、并不是严格按照线程优先级来执行的
比如线程优先级为5的线程比优先级为7的线程先执行,优先级为1的线程比优先级为2的线程先执行,很少出现优先级为2的线程比优先级为10的线程先执行(注意,这里是” 很少 “,是说确实有可能出现,只是几率低,因为优先级只是表示线程获得CPU运行的机会,并不代表强制的排序号)。
(2)、优先级差别越大,运行机会差别越明显
比如优先级为10的线程通常会比优先级为2的线程先执行,但是优先级为6的线程和优先级为5的线程差别就不太明显了,执行多次,你会发现有不同的顺序。

这两个现象是线程优先级的一个重要表现,之所以会出现这种情况,是因为线程运行是需要获得CPU资源的,那谁能决定哪个线程先获得哪个线程后获得呢?这是依照操作系统设置的线程优先级来分配的,也就是说,每个线程要运行,需要操作系统分配优先级和CPU资源,对于JAVA来说,JVM调用操作系统的接口设置优先级,比如windows操作系统优先级都相同吗?

事实上,不同的操作系统线程优先级是不同的,Windows有7个优先级,Linux有140个优先级,Freebsd则由255个(此处指的优先级个数,不同操作系统有不同的分类,如中断级线程,操作系统级等,各个操作系统具体用户可用的线程数量也不相同)。Java是跨平台的系统,需要把这10个优先级映射成不同的操作系统的优先级,于是界定了Java的优先级只是代表抢占CPU的机会大小,优先级越高,抢占CPU的机会越大,被优先执行的可能性越高,优先级相差不大,则抢占CPU的机会差别也不大,这就是导致了优先级为9的线程可能比优先级为10的线程先运行。

Java的缔造者们也觉察到了线程优先问题,于是Thread类中设置了三个优先级,此意就是告诉开发者,建议使用优先级常量,而不是1到10的随机数字。常量代码如下: 

public class Thread implements Runnable {
    /**
     * The minimum priority that a thread can have. 
     */
    public final static int MIN_PRIORITY = 1;
    /**
     * The default priority that is assigned to a thread. 
     */
    public final static int NORM_PRIORITY = 5;
    /**
     * The maximum priority that a thread can have. 
     */
    public final static int MAX_PRIORITY = 10;


}

在编码时直接使用这些优先级常量,可以说在大部分情况下MAX_PRIORITY的线程回比MIN_PRIORITY的线程优先运行,但是不能认为是必然会先运行,不能把这个优先级做为核心业务的必然条件,Java无法保证优先级高肯定会先执行,只能保证高优先级有更多的执行机会。因此,建议在开发时只使用此三类优先级,没有必要使用其他7个数字,这样也可以保证在不同的操作系统上优先级的表现基本相同。

大家也许会问,如果优先级相同呢?这很好办,也是由操作系统决定的。基本上是按照FIFO原则(先入先出,First Input First Output),但也是不能完全保证。

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz_javaweb
分享自己的Java Web学习之路以及各种Java学习资料

前言

看大神推荐的书单中入门有这么一本书,所以决定把这本书的精华(自认为很有用的点),或许是我自己现在能用到的点都提炼出来,供大家参考学习。

以下内容均出自《编写高质量代码 改善Java程序的151个建议》——秦小波 著一书。


建议1:不要在常量和变量中出现易混淆的字母

包名全小写,类名首字母全大写,常量全部大写并用下划线分隔,变量采用驼峰命名法命名等,这些都是最基本的Java编码规范,是每个Javaer都应熟知的规则,但是在变量的声明中药注意不要引入容易混淆的字母。看下面的例子,请思考以下程序打印的i等于多少:

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

肯定有人会说,这么简单的例子还能出错?运行结果肯定是22!实践是检验真理的唯一标准,将这一段程序拷贝到任一编译器中,run以下,你会发现运行结果是2,而不是22,难道是编译器显示有问题?少了一个“2”?

因为赋值给i的值就是“1”,只是后面加了长整型变量的标示字母“l”(L的小写)而已。

**如果字母和数字必须混合使用,字母“l”务必大写,字母“O”则增加注释。**

***

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

```java
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方法则是断言表达式为假,如此确实减少了代码量,而且代码的可读性也提高了,这也是静态导入用到正确地方所带来的好处。


建议16:易变业务使用脚本语言编写

Java世界一直在遭受着异种语言的入侵,比如PHP,Ruby,Groovy、Javascript等,这些入侵者都有一个共同特征:全是同一类语言—–脚本语言,它们都是在运行期解释执行的。为什么Java这种强编译型语言会需要这些脚本语言呢?那是因为脚本语言的三大特征,如下所示:

  • 灵活:脚本语言一般都是动态类型,可以不用声明变量类型而直接使用,可以再运行期改变类型。  
  • 便捷:脚本语言是一种解释性语言,不需要编译成二进制代码,也不需要像Java一样生成字节码。它的执行时依靠解释器解释的,因此在运行期间变更代码很容易,而且不用停止应用;
  • 简单:只能说部分脚本语言简单,比如Groovy,对于程序员来说,没有多大的门槛。

脚本语言的这些特性是Java缺少的,引入脚本语言可以使Java更强大,于是Java6开始正式支持脚本语言。但是因为脚本语言比较多,Java的开发者也很难确定该支持哪种语言,于是JSCP(Java Community ProCess)很聪明的提出了JSR233规范,只要符合该规范的语言都可以在Java平台上运行(它对JavaScript是默认支持的)。

先来看一个简单的例子:

function formual(var1, var2){
     return var1 + var2 * factor;
}

这就是一个简单的脚本语言函数,可能你会很疑惑:factor(因子)这个变量是从那儿来的?它是从上下文来的,类似于一个运行的环境变量。该js保存在C:/model.js中,下一步需要调用JavaScript公式,代码如下:

import java.io.FileNotFoundException;
import java.io.FileReader;
import java.util.Scanner;

import javax.script.Bindings;
import javax.script.Invocable;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;

public class Client16 {
    public static void main(String[] args) throws FileNotFoundException,
            ScriptException, NoSuchMethodException {
        // 获得一个JavaScript执行引擎
        ScriptEngine engine = new ScriptEngineManager().getEngineByName("javascript");
        // 建立上下文变量
        Bindings bind = engine.createBindings();
        bind.put("factor", 1);
        // 绑定上下文,作用于是当前引擎范围
        engine.setBindings(bind, ScriptContext.ENGINE_SCOPE);
        Scanner input =new Scanner(System.in);

        while(input.hasNextInt()){
            int first = input.nextInt();
            int second = input.nextInt();
            System.out.println("输入参数是:"+first+","+second);
            // 执行Js代码
            engine.eval(new FileReader("C:/model.js"));
            // 是否可调用方法
            if (engine instanceof Invocable) {
                Invocable in = (Invocable) engine;
                // 执行Js中的函数
                Double result = (Double) in.invokeFunction("formula", first, second);
                System.out.println("运算结果是:" + result.intValue());
            }
        }

    }
}

上段代码使用Scanner类接受键盘输入的两个数字,然后调用JavaScript脚本的formula函数计算其结果,注意,除非输入了一个非int数字,否则当前JVM会一直运行,这也是模拟生成系统的在线变更情况。运行结果如下:

输入参数是;1,2 运算结果是:3

此时,保持JVM的运行状态,我们修改一下formula函数,代码如下:

function formual(var1, var2){
     return var1 + var2 - factor;
}

其中,乘号变成了减号,计算公式发生了重大改变。回到JVM中继续输入,运行结果如下:

输入参数:1,2 运行结果是:2

修改Js代码,JVM没有重启,输入参数也没有任何改变,仅仅改变脚本函数即可产生不同的效果。这就是脚本语言对系统设计最有利的地方:可以随时发布而不用部署;这也是我们javaer最喜爱它的地方—-即使进行变更,也能提供不间断的业务服务。

Java6不仅仅提供了代码级的脚本内置,还提供了jrunscript命令工具,它可以再批处理中发挥最大效能,而且不需要通过JVM解释脚本语言,可以直接通过该工具运行脚本。想想看。这是多么大的诱惑力呀!而且这个工具是可以跨操作系统的,脚本移植就更容易了。


建议17:慎用动态编译

动态编译一直是java的梦想,从Java6开始支持动态编译了,可以再运行期直接编译.java文件,执行.class,并且获得相关的输入输出,甚至还能监听相关的事件。不过,我们最期望的还是定一段代码,直接编译,然后运行,也就是空中编译执行(on-the-fly),看如下代码:

import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;

public class Client17 {
    public static void main(String[] args) throws Exception {
        // Java源代码
        String sourceStr = "public class Hello { public String sayHello (String name) {return \"Hello,\"+name+\"!\";}}";
        // 类名及文件名
        String clsName = "Hello";
        // 方法名
        String methodName = "sayHello";
        // 当前编译器
        JavaCompiler cmp = ToolProvider.getSystemJavaCompiler();
        // Java标准文件管理器
        StandardJavaFileManager fm = cmp.getStandardFileManager(null, null,
                null);
        // Java文件对象
        JavaFileObject jfo = new StringJavaObject(clsName, sourceStr);
        // 编译参数,类似于javac <options>中的options
        List<String> optionsList = new ArrayList<String>();
        // 编译文件的存放地方,注意:此处是为Eclipse工具特设的
        optionsList.addAll(Arrays.asList("-d", "./bin"));
        // 要编译的单元
        List<JavaFileObject> jfos = Arrays.asList(jfo);
        // 设置编译环境
        JavaCompiler.CompilationTask task = cmp.getTask(null, fm, null,
                optionsList, null, jfos);
        // 编译成功
        if (task.call()) {
            // 生成对象
            Object obj = Class.forName(clsName).newInstance();
            Class<? extends Object> cls = obj.getClass();
            // 调用sayHello方法
            Method m = cls.getMethod(methodName, String.class);
            String str = (String) m.invoke(obj, "Dynamic Compilation");
            System.out.println(str);
        }

    }
}

class StringJavaObject extends SimpleJavaFileObject {
    // 源代码
    private String content = "";

    // 遵循Java规范的类名及文件
    public StringJavaObject(String _javaFileName, String _content) {
        super(_createStringJavaObjectUri(_javaFileName), Kind.SOURCE);
        content = _content;
    }

    // 产生一个URL资源路径
    private static URI _createStringJavaObjectUri(String name) {
        // 注意,此处没有设置包名
        return URI.create("String:///" + name + Kind.SOURCE.extension);
    }

    // 文本文件代码
    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors)
            throws IOException {
        return content;
    }
}

上面代码较多,可以作为一个动态编译的模板程序。只要是在本地静态编译能够实现的任务,比如编译参数,输入输出,错误监控等,动态编译都能实现。

Java的动态编译对源提供了多个渠道。比如,可以是字符串,文本文件,字节码文件,还有存放在数据库中的明文代码或者字节码。**汇总一句话,只要符合Java规范的就可以在运行期动态加载,**其实现方式就是实现JavaFileObject接口,重写getCharContent、openInputStream、openOutputStream,或者实现JDK已经提供的两个SimpleJavaFileObject、ForwardingJavaFileObject,具体代码可以参考上个例子。

动态编译虽然是很好的工具,让我们可以更加自如的控制编译过程,但是在我们目前所接触的项目中还是使用较少。原因很简单,静态编译已经能够帮我们处理大部分的工作,甚至是全部的工作,即使真的需要动态编译,也有很好的替代方案,比如Jruby、Groovy等无缝的脚本语言。另外,我们在使用动态编译时,需要注意以下几点:

  • 在框架中谨慎使用:
    比如要在struts中使用动态编译,动态实现一个类,它若继承自ActionSupport就希望它成为一个Action。能做到,但是debug很困难;再比如在Spring中,写一个动态类,要让它注入到Spring容器中,这是需要花费老大功夫的。
  • 不要在要求性能高的项目中使用:
    如果你在web界面上提供了一个功能,允许上传一个java文件然后运行,那就等于说:”我的机器没有密码,大家都可以看看”,这是非常典型的注入漏洞,只要上传一个恶意Java程序就可以让你所有的安全工作毁于一旦。
  • 动态编译要考虑安全问题:
    如果你在Web界面上提供了一个功能,允许上传一个Java文件然后运行,那就等于说:“我的机器没有密码,大家都来看我的隐私吧”,这就是非常典型的注入漏洞,只要上传一个而已Java程序就可以让你所有的安全工作毁于一旦。
  • 记录动态编译过程:
    建议记录源文件,目标文件,编译过程,执行过程等日志,不仅仅是为了诊断,还是为了安全和审计,对Java项目来说,空中编译和运行时很不让人放心的,留下这些依据可以很好地优化程序。

建议21:用偶判断,不用奇判断

判断一个数是奇数还是偶数是小学里的基本知识,能够被2整除的整数是偶数,不能被2整除的数是奇数,这规则简单明了,还有什么可考虑的?好,我们来看一个例子,代码如下:

import java.util.Scanner;

public class Client21 {
    public static void main(String[] args) {
        // 接收键盘输入参数
        Scanner input = new Scanner(System.in);
        System.out.println("输入多个数字判断奇偶:");
        while (input.hasNextInt()) {
            int i = input.nextInt();
            String str = i + "-->" + (i % 2 == 1 ? "奇数" : "偶数");
            System.out.println(str);

        }
    }
}

输入多个数字,然后判断每个数字的奇偶性,不能被2整除的就是奇数,其它的都是偶数,完全是根据奇偶数的定义编写的程序,我们开看看打印的结果:

输入多个数字判断奇偶:1 2 0 -1 -2 
1-->奇数 
2-->偶数 
0-->偶数 
-1-->偶数 
-2-->偶数

前三个还很靠谱,第四个参数-1怎么可能是偶数呢,这Java也太差劲了吧。如此简单的计算也会出错!别忙着下结论,我们先来了解一下Java中的取余(%标识符)算法,模拟代码如下:

// 模拟取余计算,dividend被除数,divisor除数
public static int remainder(int dividend, int divisor) {
    return dividend - dividend / divisor * divisor;
}

看到这段程序,大家都会心的笑了,原来Java这么处理取余计算的呀,根据上面的模拟取余可知,当输入-1的时候,运算结果为-1,当然不等于1了,所以它就被判定为偶数了,也就是我们的判断失误了。问题明白了,修正也很简单,改为判断是否是偶数即可。代码如下:

i % 2 == 0 ? "偶数" : "奇数";

注意:对于基础知识,我们应该”知其然,并知其所以然”。


建议22:用整数类型处理货币

在日常生活中,最容易接触到的小数就是货币,比如,你付给售货员10元钱购买一个9.6元的零食,售货员应该找你0.4元,也就是4毛钱才对,我们来看下面的程序:

public class Client22 {
    public static void main(String[] args) {
        System.out.println(10.00-9.60);
    }
}

我们的期望结果是0.4,也应该是这个数字,但是打印出来的却是:0.40000000000000036,这是为什么呢?

这是因为在计算机中浮点数有可能(注意是有可能)是不准确的,它只能无限接近准确值,而不能完全精确。为什么会如此呢?这是由浮点数的存储规则所决定的,我们先来看看0.4这个十进制小数如何转换成二进制小数,使用”乘2取整,顺序排列”法(不懂,这就没招了,这太基础了),我们发现0.4不能使用二进制准确的表示,在二进制数世界里它是一个无限循环的小数,也就是说,”展示” 都不能 “展示”,更别说在内存中存储了(浮点数的存储包括三部分:符号位、指数位、尾数,具体不再介绍),可以这样理解,在十进制的世界里没有办法唯一准确表示1/3,那么在二进制的世界里当然也无法准确表示1/5(如果二进制也有分数的话倒是可以表示),在二进制的世界里1/5是一个无限循环的小数。

大家可能要说了,那我对结果取整不就对了吗?代码如下:

public class Client22 {
    public static void main(String[] args) {
        NumberFormat f = new DecimalFormat("#.##");
        System.out.println(f.format(10.00-9.60));
    }
}

打印出的结果是0.4,看似解决了。但是隐藏了一个很深的问题。我们来思考一下金融行业的计算方法,会计系统一般记录小数点后的4为小数,但是在汇总、展现、报表中、则只记录小数点后的2位小数,如果使用浮点数来计算货币,想想看,在大批量加减乘除后结果会有很大的差距(其中还涉及到四舍五入的问题)!会计系统要求的就是准确,但是因为计算机的缘故不准确了,那真是罪过,要解决此问题有两种方法:

  • (1)使用BigDecimal

BigDecimal是专门为弥补浮点数无法精确计算的缺憾而设计的类,并且它本身也提供了加减乘除的常用数学算法。特别是与数据库Decimal类型的字段映射时,BigDecimal是最优的解决方案。

  • (2)使用整型

把参与运算的值扩大100倍,并转为整型,然后在展现时再缩小100倍,这样处理的好处是计算简单,准确,一般在非金融行业(如零售行业)应用较多。此方法还会用于某些零售POS机,他们输入和输出的全部是整数,那运算就更简单了。


建议23:不要让类型默默转换

我们做一个小学生的题目,光速每秒30万公里,根据光线的旅行时间,计算月球和地球,太阳和地球之间的距离。代码如下: 

public class Client23 {
    // 光速是30万公里/秒,常量
    public static final int LIGHT_SPEED = 30 * 10000 * 1000;

    public static void main(String[] args) {
        System.out.println("题目1:月球照射到地球需要一秒,计算月亮和地球的距离。");
        long dis1 = LIGHT_SPEED * 1;
        System.out.println("月球与地球的距离是:" + dis1 + " 米 ");
        System.out.println("-------------------------------");
        System.out.println("题目2:太阳光照射到地球需要8分钟,计算太阳到地球的距离.");
        // 可能要超出整数范围,使用long型
        long dis2 = LIGHT_SPEED * 60 * 8;
        System.out.println("太阳与地球之间的距离是:" + dis2 + " 米");
    }
}

估计有人鄙视了,这种小学生的乘法有神么可做的,不错,就是一个乘法运算,我们运行之后的结果如下:

题目1:月球照射到地球需要一秒,计算月亮和地球的距离。
月球与地球的距离是:300000000-------------------------------
题目2:太阳光照射到地球需要8分钟,计算太阳到地球的距离.
太阳与地球之间的距离是:-2028888064

太阳和地球的距离竟然是负的,诡异。dis2不是已经考虑到int类型可能越界的问题,并使用了long型吗,怎么还会出现负值呢?

那是因为Java是先运算然后进行类型转换的,具体的说就是因为dis2的三个运算参数都是int型,三者相乘的结果虽然也是int型,但是已经超过了int的最大值,所以其值就是负值了(为什么是负值,因为过界了就会重头开始),再转换为long型,结果还是负值。

问题知道了,解决起来也很简单,只要加个小小的L即可,代码如下:

long dis2 = LIGHT_SPEED * 60L * 8;

60L是一个长整型,乘出来的结果也是一个长整型的(此乃Java的基本转换规则,向数据范围大的方向转换,也就是加宽类型),在还没有超过int类型的范围时就已经转换为long型了,彻底解决了越界问题。在实际开发中,更通用的做法是主动声明类型转化(注意,不是强制类型转换),代码如下:

long dis2 = 1L * LIGHT_SPEED * 60L * 8

既然期望的结果是long型,那就让第一个参与的参数也是Long(1L)吧,也就说明”嗨”我已经是长整型了,你们都跟着我一块转为长整型吧。

注意:基本类型转换时,使用主动声明方式减少不必要的Bug.


建议25:不要让四舍五入亏了一方

本建议还是来重温一个小学数学问题:四舍五入。四舍五入是一种近似精确的计算方法,在Java5之前,我们一般是通过Math.round来获得指定精度的整数或小数的,这种方法使用非常广泛,代码如下:

public class Client25 {
    public static void main(String[] args) {
        System.out.println("10.5近似值: "+Math.round(10.5));
        System.out.println("-10.5近似值: "+Math.round(-10.5));
    }
}

输出结果为:10.5近似值: 11 -10.5近似值: -10

这是四舍五入的经典案例,也是初级面试官很乐意选择的考题,绝对值相同的两个数字,近似值为什么就不同了呢?这是由Math.round采用的舍入规则决定的(采用的是正无穷方向舍入规则),我们知道四舍五入是有误差的:其误差值是舍入的一半。我们以舍入运用最频繁的银行利息计算为例来阐述此问题。

我们知道银行的盈利渠道主要是利息差,从储户手里收拢资金,然后房贷出去,期间的利息差额便是所获得利润,对一个银行来说,对付给储户的利息计算非常频繁,人民银行规定每个季度末月的20日为银行结息日,一年有4次的结息日。

场景介绍完毕,我们回头来看看四舍五入,小于5的数字被舍去,大于5的数字进位后舍去,由于单位上的数字都是自然计算出来的,按照利率计算可知,被舍去的数字都分布在0~9之间,下面以10笔存款利息计算作为模型,以银行家的身份来思考这个算法:

  • 四舍:舍弃的数值是:0.000、0.001、0.002、0.003、0.004因为是舍弃的,对于银行家来说就不需要付款给储户了,那每舍一个数字就会赚取相应的金额:0.000、0.001、0.002、0.003、0.004.

  • 五入:进位的数值是:0.005、0.006、0.007、0.008、0.009,因为是进位,对银行家来说,每进一位就会多付款给储户,也就是亏损了,那亏损部分就是其对应的10进制补数:0.005、.0004、0.003、0.002、0.001.

因为舍弃和进位的数字是均匀分布在0~9之间,对于银行家来说,没10笔存款的利息因采用四舍五入而获得的盈利是:
0.000 + 0.001 + 0.002 + 0.003 + 0.004 - 0.005 - 0.004 - 0.003 - 0.002 - 0.001 = - 0.005;

也就是说,每10笔利息计算中就损失0.005元,即每笔利息计算就损失0.0005元,这对一家有5千万储户的银行家来说(对国内银行来说,5千万是个小数字),每年仅仅因为四舍五入的误差而损失的金额是:5000100000.00054=100000.0;即,每年因为一个算法误差就损失了10万元,事实上以上的假设条件都是非常保守的,实际情况可能损失的更多。那各位可能要说了,银行还要放贷呀,放出去这笔计算误差不就抵消了吗?不会抵消,银行的贷款数量是非常有限的其数量级根本无法和存款相比。

这个算法误差是由美国银行家发现的(那可是私人银行,钱是自己的,白白损失了可不行),并且对此提出了一个修正算法,叫做银行家舍入(Banker’s Round)的近似算法,其规则如下:

  • 舍去位的数值小于5时,直接舍去;
  • 舍去位的数值大于等于6时,进位后舍去;
  • 当舍去位的数值等于5时,分两种情况:5后面还有其它数字(非0),则进位后舍去;若5后面是0(即5是最后一个数字),则根据5前一位数的奇偶性来判断是否需要进位,奇数进位,偶数舍去。

以上规则汇总成一句话:四舍六入五考虑,五后非零就进一,五后为零看奇偶,五前为偶应舍去,五前为奇要进一。我们举例说明,取2位精度:

round(10.5551) = 10.56 
round(10.555) = 10.56 
round(10.545) = 10.56

要在Java5以上的版本中使用银行家的舍入法则非常简单,直接使用RoundingMode类提供的Round模式即可,示例代码如下:  

import java.math.BigDecimal;
import java.math.RoundingMode;

public class Client25 {
    public static void main(String[] args) {
        // 存款
        BigDecimal d = new BigDecimal(888888);
        // 月利率,乘3计算季利率
        BigDecimal r = new BigDecimal(0.001875*3);
        //计算利息
        BigDecimal i =d.multiply(r).setScale(2,RoundingMode.HALF_EVEN);
        System.out.println("季利息是:"+i);

    }
}

在上面的例子中,我们使用了BigDecimal类,并且采用了setScale方法设置了精度,同时传递了一个RoundingMode.HALF_EVEN参数表示使用银行家法则进行近似计算,BigDecimal和RoundingMode是一个绝配,想要采用什么方式使用RoundingMode设置即可。目前Java支持以下七种舍入方式:

  • ROUND_UP:原理零方向舍入。向远离0的方向舍入,也就是说,向绝对值最大的方向舍入,只要舍弃位非0即进位。
  • ROUND_DOWN:趋向0方向舍入。向0方向靠拢,也就是说,向绝对值最小的方向输入,注意:所有的位都舍弃,不存在进位情况。
  • ROUND_CEILING:向正无穷方向舍入。向正最大方向靠拢,如果是正数,舍入行为类似于ROUND_UP;如果为负数,则舍入行为类似于ROUND_DOWN.注意:Math.round方法使用的即为此模式。
  • ROUND_FLOOR:向负无穷方向舍入。向负无穷方向靠拢,如果是正数,则舍入行为类似ROUND_DOWN,如果是负数,舍入行为类似以ROUND_UP。
  • HALF_UP:最近数字舍入(5舍),这就是我们经典的四舍五入。
  • HALF_DOWN:最近数字舍入(5舍)。在四舍五入中,5是进位的,在HALF_DOWN中却是舍弃不进位。
  • HALF_EVEN:银行家算法,在普通的项目中舍入模式不会有太多影响,可以直接使用Math.round方法,但在大量与货币数字交互的项目中,一定要选择好近似的计算模式,尽量减少因算法不同而造成的损失。

注意:根据不同的场景,慎重选择不同的舍入模式,以提高项目的精准度,减少算法损失。


建议28:优先使用整型池

首先看看如下代码:

import java.util.Scanner;

public class Client28 {
    public static void main(String[] args) {
        Scanner input = new Scanner(System.in);
        while (input.hasNextInt()) {
            int tempInt = input.nextInt();
            System.out.println("\n=====" + tempInt + " 的相等判断=====");
            // 两个通过new产生的对象
            Integer i = new Integer(tempInt);
            Integer j = new Integer(tempInt);
            System.out.println(" new 产生的对象:" + (i == j));
            // 基本类型转换为包装类型后比较
            i = tempInt;
            j = tempInt;
            System.out.println(" 基本类型转换的对象:" + (i == j));
            // 通过静态方法生成一个实例
            i = Integer.valueOf(tempInt);
            j = Integer.valueOf(tempInt);
            System.out.println(" valueOf产生的对象:" + (i == j));
        }
    }
}

输入多个数字,然后按照3中不同的方式产生Integer对象,判断其是否相等,注意这里使用了”==”,这说明判断的不是同一个对象。我们输入三个数字127、128、555,结果如下:

127
=====127 的相等判断=====
new 产生的对象:false
基本类型转换的对象:true
valueOf产生的对象:true
128
=====128 的相等判断=====
new 产生的对象:false
基本类型转换的对象:false
valueOf产生的对象:false
555
=====555 的相等判断=====
new 产生的对象:false
基本类型转换的对象:false
valueOf产生的对象:false

很不可思议呀,数字127的比较结果竟然和其它两个数字不同,它的装箱动作所产生的对象竟然是同一个对象,valueOf产生的也是同一个对象,但是大于127的数字和128和555的比较过程中产生的却不是同一个对象,这是为什么?我们来一个一个解释。

  • (1)new产生的Integer对象
    new声明的就是要生成一个新的对象,没二话,这是两个对象,地址肯定不等,比较结果为false。
  • (2)装箱生成的对象
    对于这一点,首先要说明的是装箱动作是通过valueOf方法实现的,也就是说后两个算法相同的,那结果肯定也是一样的,现在问题是:valueOf是如何生成对象的呢?我们来阅读以下Integer.valueOf的源码
    /**
     * Returns an {@code Integer} instance representing the specified
     * {@code int} value.  If a new {@code Integer} instance is not
     * required, this method should generally be used in preference to
     * the constructor {@link #Integer(int)}, as this method is likely
     * to yield significantly better space and time performance by
     * caching frequently requested values.
     *
     * This method will always cache values in the range -128 to 127,
     * inclusive, and may cache other values outside of this range.
     *
     * @param  i an {@code int} value.
     * @return an {@code Integer} instance representing {@code i}.
     * @since  1.5
     */
    public static Integer valueOf(int i) {
        assert IntegerCache.high >= 127;
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

这段代码的意思已经很明了了,如果是-128到127之间的int类型转换为Integer对象,则直接从cache数组中获得,那cache数组里是什么东西,JDK7的源代码如下:

    /**
     * Cache to support the object identity semantics of autoboxing for values between
     * -128 and 127 (inclusive) as required by JLS.
     *
     * The cache is initialized on first usage.  The size of the cache
     * may be controlled by the -XX:AutoBoxCacheMax=<size> option.
     * During VM initialization, java.lang.Integer.IntegerCache.high property
     * may be set and saved in the private system properties in the
     * sun.misc.VM class.
     */

    private static class IntegerCache {
        static final int low = -128;
        static final int high;
        static final Integer cache[];

        static {
            // high value may be configured by property
            int h = 127;
            String integerCacheHighPropValue =
                sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low));
            }
            high = h;

            cache = new Integer[(high - low) + 1];
            int j = low;
            for(int k = 0; k < cache.length; k++)
                cache[k] = new Integer(j++);
        }

        private IntegerCache() {}
    }

cache是IntegerCache内部类的一个静态数组,容纳的是-128到127之间的Integer对象。通过valueOf产生包装对象时,如果int参数在-128到127之间,则直接从整型池中获得对象,不在该范围内的int类型则通过new生成包装对象。

明白了这一点,要理解上面的输出结果就迎刃而解了,127的包装对象是直接从整型池中获得的,不管你输入多少次127这个数字,获得的对象都是同一个,那地址自然是相等的。而128、555超出了整型池范围,是通过new产生一个新的对象,地址不同,当然也就不相等了。

以上的理解也是整型池的原理,整型池的存在不仅仅提高了系统性能,同时也节约了内存空间,这也是我们使用整型池的原因,也就是在声明包装对象的时候使用valueOf生成,而不是通过构造函数来生成的原因。顺便提醒大家,在判断对象是否相等的时候,最好使用equals方法,避免使用”==”产生非预期效果。

注意:通过包装类型的valueOf生成的包装实例可以显著提高空间和时间性能。


建议29:优先选择基本类型

包装类型是一个类,它提供了诸如构造方法,类型转换,比较等非常实用的功能,而且在Java5之后又实现了与基本类型的转换,这使包装类型如虎添翼,更是应用广泛了,在开发中包装类型已经随处可见,但无论是从安全性、性能方面来说,还是从稳定性方面来说,基本类型都是首选方案。我们看一段代码:

public class Client29 {
    public static void main(String[] args) {
        Client29 c = new Client29();
        int i = 140;
        // 分别传递int类型和Integer类型
        c.testMethod(i);
        c.testMethod(new Integer(i));
    }

    public void testMethod(long a) {
        System.out.println(" 基本类型的方法被调用");
    }

    public void testMethod(Long a) {
        System.out.println(" 包装类型的方法被调用");
    }
}

在上面的程序中首先声明了一个int变量i,然后加宽转变成long型,再调用testMethod()方法,分别传递int和long的基本类型和包装类型,诸位想想该程序是否能够编译?如果能编译,输出结果又是什么呢?

首先,这段程序绝对是能够编译的。不过,说不能编译的同学还是动了一番脑筋的,你可能猜测以下这些地方不能编译:

  • (1)testMethod方法重载问题。定义的两个testMethod()方法实现了重载,一个形参是基本类型,一个形参是包装类型,这类重载很正常。虽然基本类型和包装类型有自动装箱、自动拆箱功能,但并不影响它们的重载,自动拆箱(装箱)只有在赋值时才会发生,和编译重载没有关系。

  • (2)c.testMethod(i) 报错。i 是int类型,传递到testMethod(long a)是没有任何问题的,编译器会自动把 i 的类型加宽,并将其转变为long型,这是基本类型的转换法则,也没有任何问题。

  • (3)c.testMethod(new Integer(i))报错。代码中没有testMethod(Integer i)方法,不可能接收一个Integer类型的参数,而且Integer和Long两个包装类型是兄弟关系,不是继承关系,那就是说肯定编译失败了?不,编译时成功的,稍后再解释为什么这里编译成功。

既然编译通过了,我们看一下输出:

基本类型的方法被调用
基本类型的方法被调用

c.testMethod(i)的输出是正常的,我们已经解释过了,那第二个输出就让人困惑了,为什么会调用testMethod(long a)方法呢?这是因为自动装箱有一个重要原则:基本类型可以先加宽,再转变成宽类型的包装类型,但不能直接转变成宽类型的包装类型。这句话比较拗口,简单的说就是,int可以加宽转变成long,然后再转变成Long对象,但不能直接转变成包装类型,注意这里指的都是自动转换,不是通过构造函数生成,为了解释这个原则,我们再来看一个例子:

public class Client29 {
    public static void main(String[] args) {
        Client29 c = new Client29();
        int i = 140;
        c.testMethod(i);
    }

    public void testMethod(Long a) {
        System.out.println(" 包装类型的方法被调用");
    }
}

这段程序的编译是不通过的,因为i是一个int类型,不能自动转变为Long型,但是修改成以下代码就可以通过了:
int i = 140; long a =(long)i; c.testMethod(a);
这就是int先加宽转变成为long型,然后自动转换成Long型,规则说明了,我们继续来看testMethod(Integer.valueOf(i))是如何调用的,Integer.valueOf(i)返回的是一个Integer对象,这没错,但是Integer和int是可以互相转换的。没有testMethod(Integer i)方法?没关系,编译器会尝试转换成int类型的实参调用,Ok,这次成功了,与testMethod(i)相同了,于是乎被加宽转变成long型—结果也很明显了。整个testMethod(Integer.valueOf(i))的执行过程是这样的:

  • (1)i 通过valueOf方法包装成一个Integer对象
  • (2)由于没有testMethod(Integer i)方法,编译器会”聪明”的把Integer对象转换成int。
  • (3)int自动拓宽为long,编译结束

使用包装类型确实有方便的方法,但是也引起一些不必要的困惑,比如我们这个例子,如果testMethod()的两个重载方法使用的是基本类型,而且实参也是基本类型,就不会产生以上问题,而且程序的可读性更强。自动装箱(拆箱)虽然很方便,但引起的问题也非常严重,我们甚至都不知道执行的是哪个方法。

注意:重申,基本类型优先考虑。


建议31:在接口中不要存在实现代码

看到这样的标题,大家是否感到郁闷呢?接口中有实现代码吗?这怎么可能呢?确实,接口中可以声明常量,声明抽象方法,可以继承父接口,但就是不能有具体实现,因为接口是一种契约(Contract),是一种框架性协议,这表明它的实现类都是同一种类型,或者具备相似特征的一个集合体。对于一般程序,接口确实没有任何实现,但是在那些特殊的程序中就例外了,阅读如下代码: 

public class Client31 {
    public static void main(String[] args) {
        //调用接口的实现
        B.s.doSomeThing();
    }
}

// 在接口中存在实现代码
interface B {
    public static final S s = new S() {
        public void doSomeThing() {
            System.out.println("我在接口中实现了");
        }
    };
}

// 被实现的接口
interface S {
    public void doSomeThing();
}

仔细看main方法,注意那个B接口。它调用了接口常量,在没有实现任何显示实现类的情况下,它竟然打印出了结果,那B接口中的s常量(接口是S)是在什么地方被实现的呢?答案在B接口中。

在B接口中声明了一个静态常量s,其值是一个匿名内部类(Anonymous Inner Class)的实例对象,就是该匿名内部类(当然,也可以不用匿名,直接在接口中是实现内部类也是允许的)实现了S接口。你看,在接口中也存在着实现代码吧!

这确实很好,很强大,但是在一般的项目中,此类代码是严禁出现的,原因很简单:这是一种非常不好的编码习惯,接口是用来干什么的?接口是一个契约,不仅仅约束着实现,同时也是一个保证,保证提供的服务(常量和方法)是稳定的、可靠的,如果把实现代码写到接口中,那接口就绑定了可能变化的因素,这会导致实现不再稳定和可靠,是随时都可能被抛弃、被更改、被重构的。所以,接口中虽然可以有实现,但应避免使用。

注意:接口中不能出现实现代码。


建议32:静态变量一定要先声明后赋值

这个标题是否像上一个建议的标题一样让人郁闷呢?什么叫做变量一定要先声明后赋值?Java中的变量不都是先声明后使用的吗?难道还能先使用后声明?能不能暂且不说,我们看一个例子,代码如下:

public class Client32 {
    public static int i = 1;

    static {
        i = 100;
    }
    public static void main(String[] args) {
        System.out.println(i);
    }
}

这段程序很简单,输出100嘛,对,确实是100,我们稍稍修改一下,代码如下:

public class Client32 {
    static {
        i = 100;
    }

    public static int i = 1;

    public static void main(String[] args) {
        System.out.println(i);
    }
}

注意变量 i 的声明和赋值调换了位置,现在的问题是:这段程序能否编译?如过可以编译,输出是多少?还要注意,这个变量i可是先使用(也就是赋值)后声明的。

答案是:可以编译,没有任何问题,输出结果为1。对,输出是 1 不是100.仅仅调换了位置,输出就变了,而且变量 i 还是先使用后声明的,难道颠倒了?

这要从静态变量的诞生说起,静态变量是类加载时被分配到数据区(Data Area)的,它在内存中只有一个拷贝,不会被分配多次,其后的所有赋值操作都是值改变,地址则保持不变。我们知道JVM初始化变量是先声明空间,然后再赋值,也就是说:在JVM中是分开执行的,等价于:

int  i ; //分配空间
i = 100; //赋值

静态变量是在类初始化的时候首先被加载的,JVM会去查找类中所有的静态声明,然后分配空间,注意这时候只是完成了地址空间的分配,还没有赋值,之后JVM会根据类中静态赋值(包括静态类赋值和静态块赋值)的先后顺序来执行。对于程序来说,就是先声明了int类型的地址空间,并把地址传递给了i,然后按照类的先后顺序执行赋值操作,首先执行静态块中i = 100,接着执行 i = 1,那最后的结果就是 i =1了。

哦,如此而已,如果有多个静态块对 i 继续赋值呢?i 当然还是等于1了,谁的位置最靠后谁有最终的决定权。

有些程序员喜欢把变量定义放到类最底部,如果这是实例变量还好说,没有任何问题,但如果是静态变量,而且还在静态块中赋值了,那这结果就和期望的不一样了,所以遵循Java通用的开发规范”变量先声明后赋值使用”,是一个良好的编码风格。

注意:再次重申变量要先声明后使用,这不是一句废话。


建议35:避免在构造函数中初始化其它类

构造函数是一个类初始化必须执行的代码,它决定着类初始化的效率,如果构造函数比较复杂,而且还关联了其它类,则可能产生想不到的问题,我们来看如下代码:

public class Client35 {
    public static void main(String[] args) {
        Son son = new Son();
        son.doSomething();
    }
}

// 父类
class Father {
    public Father() {
        new Other();
    }
}

// 相关类
class Other {
    public Other() {
        new Son();
    }
}

// 子类
class Son extends Father {
    public void doSomething() {
        System.out.println("Hi, show me Something!");
    }
}

这段代码并不复杂,只是在构造函数中初始化了其它类,想想看这段代码的运行结果是什么?会打印出”Hi ,show me Something!”吗?

答案是这段代码不能运行,报StatckOverflowError异常,栈(Stack)内存溢出,这是因为声明变量son时,调用了Son的无参构造函数,JVM又默认调用了父类的构造函数,接着Father又初始化了Other类,而Other类又调用了Son类,于是一个死循环就诞生了,知道内存被消耗完停止。

大家可能觉得这样的场景不会出现在开发中,我们来思考这样的场景,Father是由框架提供的,Son类是我们自己编写的扩展代码,而Other类则是框架要求的拦截类(Interceptor类或者Handle类或者Hook方法),再来看看问题,这种场景不可能出现吗?  

可能大家会觉得这样的场景不会出现,这种问题只要系统一运行就会发现,不可能对项目产生影响。

那是因为我们这里展示的代码比较简单,很容易一眼洞穿,一个项目中的构造函数可不止一两个,类之间的关系也不会这么简单,要想瞥一眼就能明白是否有缺陷这对所有人员来说都是不可能完成的任务,解决此类问题最好的办法就是:不要在构造函数中声明初始化其他类,养成良好习惯。


建议36:使用构造代码块精简程序

什么叫做代码块(Code Block)?用大括号把多行代码封装在一起,形成一个独立的数据体,实现特定算法的代码集合即为代码块,一般来说代码快不能单独运行的,必须要有运行主体。在Java中一共有四种类型的代码块:

  • 普通代码块:就是在方法后面使用”{}”括起来的代码片段,它不能单独运行,必须通过方法名调用执行;
  • 静态代码块:在类中使用static修饰,并用”{}”括起来的代码片段,用于静态变量初始化或对象创建前的环境初始化。
  • 同步代码块:使用synchronized关键字修饰,并使用”{}”括起来的代码片段,它表示同一时间只能有一个线程进入到该方法块中,是一种多线程保护机制。
  • 构造代码块:在类中没有任何前缀和后缀,并使用”{}”括起来的代码片段;

我么知道一个类中至少有一个构造函数(如果没有,编译器会无私的为其创建一个无参构造函数),构造函数是在对象生成时调用的,那现在为你来了:构造函数和代码块是什么关系,构造代码块是在什么时候执行的?在回答这个问题之前,我们先看看编译器是如何处理构造代码块的,看如下代码:

public class Client36 {

    {
        // 构造代码块
        System.out.println("执行构造代码块");
    }

    public Client36() {
        System.out.println("执行无参构造");
    }

    public Client36(String name) {
        System.out.println("执行有参构造");
    }
}

这是一段非常简单的代码,它包含了构造代码块、无参构造、有参构造,我们知道代码块不具有独立执行能力,那么编译器是如何处理构造代码块的呢?很简单,编译器会把构造代码块插入到每个构造函数的最前端,上面的代码等价于:

public class Client36 {

    public Client36() {
        System.out.println("执行构造代码块");
        System.out.println("执行无参构造");
    }

    public Client36(String name) {
        System.out.println("执行构造代码块");
        System.out.println("执行有参构造");
    }
}

每个构造函数的最前端都被插入了构造代码块,很显然,在通过new关键字生成一个实例时会先执行构造代码块,然后再执行其他代码,也就是说:构造代码块会在每个构造函数内首先执行(需要注意的是:构造代码块不是在构造函数之前运行的,它依托于构造函数的执行),明白了这一点,我们就可以把构造代码块应用到如下场景中:

  • 初始化实例变量(Instance Variable):如果每个构造函数都要初始化变量,可以通过构造代码块来实现。当然也可以通过定义一个方法,然后在每个构造函数中调用该方法来实现,没错,可以解决,但是要在每个构造函数中都调用该方法,而这就是其缺点,若采用构造代码块的方式则不用定义和调用,会直接由编译器写入到每个构造函数中,这才是解决此问题的绝佳方式。

  • 初始化实例环境:一个对象必须在适当的场景下才能存在,如果没有适当的场景,则就需要在创建该对象的时候创建次场景,例如在JEE开发中,要产生HTTP Request必须首先建立HTTP Session,在创建HTTP Request时就可以通过构造代码块来检查HTTP Session是否已经存在,不存在则创建之。

以上两个场景利用了构造代码块的两个特性:在每个构造函数中都运行和在构造函数中它会首先运行。很好的利用构造代码块的这连个特性不仅可以减少代码量,还可以让程序更容易阅读,特别是当所有的构造函数都要实现逻辑,而且这部分逻辑有很复杂时,这时就可以通过编写多个构造代码块来实现。每个代码块完成不同的业务逻辑(当然了构造函数尽量简单,这是基本原则),按照业务顺序一次存放,这样在创建实例对象时JVM就会按照顺序依次执行,实现复杂对象的模块化创建。


建议37:构造代码块会想你所想

上一建议中我们提议使用构造代码块来简化代码,并且也了解到编译器会自动把构造代码块插入到各个构造函数中,那我们接下来看看,编译器是不是足够聪明,能为我们解决真实的开发问题,有这样一个案例,统计一个类的实例变量数。你可要说了,这很简单,在每个构造函数中加入一个对象计数器补救解决了嘛?或者我们使用上一建议介绍的,使用构造代码块也可以,确实如此,我们来看如下代码是否可行:

public class Client37 {
    public static void main(String[] args) {
        new Student();
        new Student("张三");
        new Student(10);
        System.out.println("实例对象数量:"+Student.getNumOfObjects());
    }
}

class Student {
    // 对象计数器
    private static int numOfObjects = 0;

    {
        // 构造代码块,计算产生的对象数量
        numOfObjects++;
    }

    public Student() {

    }

    // 有参构造调用无参构造
    public Student(String stuName) {
        this();
    }

    // 有参构造不调用无参构造
    public Student(int stuAge) {

    }
    //返回在一个JVM中,创建了多少实例对象
    public static int getNumOfObjects(){
        return numOfObjects;
    }
}

这段代码可行吗?能计算出实例对象的数量吗?如果编译器把构造代码块插入到各个构造函数中,那带有String形参的构造函数就可能有问题,它会调用无参构造,那通过它生成的Student对象就会执行两次构造代码块:一次是无参构造函数调用构造代码块,一次是执行自身的构造代码块,这样的话计算就不准确了,main函数实际在内存中产生了3个对象,但结果确是4。不过真的是这样吗?我们运行之后,结果是:

实例对象数量:3;

实例对象的数量还是3,程序没有问题,奇怪吗?不奇怪,上一建议是说编译器会把构造代码块插入到每一个构造函数中,但是有一个例外的情况没有说明:如果遇到this关键字(也就是构造函数调用自身的其它构造函数时),则不插入构造代码块,对于我们的例子来说,编译器在编译时发现String形参的构造函数调用了无参构造,于是放弃插入构造代码块,所以只执行了一次构造代码块。

那Java编译器为何如此聪明?这还要从构造代码块的诞生说起,构造代码块是为了提取构造函数的共同量,减少各个构造函数的代码产生的,因此,Java就很聪明的认为把代码插入到this方法的构造函数中即可,而调用其它的构造函数则不插入,确保每个构造函数只执行一次构造代码块。

还有一点需要说明,大家千万不要以为this是特殊情况,那super也会类似处理了,其实不会,在构造块的处理上,super方法没有任何特殊的地方,编译器只把构造代码块插入到super方法之后执行而已。仅此不同。

注意:放心的使用构造代码块吧,Java已经想你所想了。


建议38:使用静态内部类提高封装性

Java中的嵌套类(Nested Class)分为两种:静态内部类(也叫静态嵌套类,Static Nested Class)和内部类(Inner Class)。本次主要看看静态内部类。什么是静态内部类呢?是内部类,并且是静态(static修饰)的即为静态内部类,只有在是静态内部类的情况下才能把static修饰符放在类前,其它任何时候static都是不能修饰类的。

静态内部类的形式很好理解,但是为什么需要静态内部类呢?那是因为静态内部类有两个优点:加强了类的封装和提高了代码的可读性,我们通过下面代码来解释这两个优点。 

public class Person {
    // 姓名
    private String name;
    // 家庭
    private Home home;

    public Person(String _name) {
        name = _name;
    }

    /* home、name的setter和getter方法略 */

    public static class Home {
        // 家庭地址
        private String address;
        // 家庭电话
        private String tel;

        public Home(String _address, String _tel) {
            address = _address;
            tel = _tel;
        }
        /* address、tel的setter和getter方法略 */
    }
}

其中,Person类中定义了一个静态内部类Home,它表示的意思是”人的家庭信息”,由于Home类封装了家庭信息,不用再Person中再定义homeAddr,homeTel等属性,这就使封装性提高了。同时我们仅仅通过代码就可以分析出Person和Home之间的强关联关系,也就是说语义增强了,可读性提高了。所以在使用时就会非常清楚它表达的含义。  

public static void main(String[] args) {
        // 定义张三这个人
        Person p = new Person("张三");
        // 设置张三的家庭信息
        p.setHome(new Home("北京", "010"));
}

定义张三这个人,然后通过Person.Home类设置张三的家庭信息,这是不是就和我们真是世界的情形相同了?先登记人的主要信息,然后登记人员的分类信息。可能你由要问了,这和我们一般定义的类有神么区别呢?又有什么吸引人的地方呢?如下所示:

  • 1.提高封装性:从代码的位置上来讲,静态内部类放置在外部类内,其代码层意义就是,静态内部类是外部类的子行为或子属性,两者之间保持着一定的关系,比如在我们的例子中,看到Home类就知道它是Person的home信息。
  • 2.提高代码的可读性:相关联的代码放在一起,可读性肯定提高了。
  • 3.形似内部,神似外部:静态内部类虽然存在于外部类内,而且编译后的类文件也包含外部类(格式是:外部类+$+内部类),但是它可以脱离外部类存在,也就说我们仍然可以通过new Home()声明一个home对象,只是需要导入”Person.Home”而已。  

解释了这么多,大家可能会觉得外部类和静态内部类之间是组合关系(Composition)了,这是错误的,外部类和静态内部类之间有强关联关系,这仅仅表现在”字面上”,而深层次的抽象意义则依类的设计.

那静态类内部类和普通内部类有什么区别呢?下面就来说明一下:

  • 静态内部类不持有外部类的引用:在普通内部类中,我们可以直接访问外部类的属性、方法,即使是private类型也可以访问,这是因为内部类持有一个外部类的引用,可以自由访问。而静态内部类,则只可以访问外部类的静态方法和静态属性(如果是private权限也能访问,这是由其代码位置决定的),其它的则不能访问。
  • 静态内部类不依赖外部类:普通内部类与外部类之间是相互依赖关系,内部类实例不能脱离外部类实例,也就是说它们会同生共死,一起声明,一起被垃圾回收,而静态内部类是可以独立存在的,即使外部类消亡了,静态内部类也是可以存在的。
  • 普通内部类不能声明static的方法和变量:普通内部类不能声明static的方法和变量,注意这里说的是变量,常量(也就是final static 修饰的属性)还是可以的,而静态内部类形似外部类,没有任何限制。

建议39:使用匿名类的构造函数

阅读如下代码,看上是否可以编译: 

public static void main(String[] args) {
        List list1=new ArrayList();
        List list2=new ArrayList(){};
        List list3=new ArrayList(){{}};
        System.out.println(list1.getClass() == list2.getClass());
        System.out.println(list2.getClass() == list3.getClass());
        System.out.println(list1.getClass() == list3.getClass());
}

注意ArrayList后面的不通点:list1变量后面什么都没有,list2后面有一对{},list3后面有两个嵌套的{},这段程序能否编译呢?若能编译,那输结果是什么呢?

答案是能编译,输出的是3个false。list1很容易理解,就是生命了ArrayList的实例对象,那list2和list3代表的是什么呢?

(1)、list2 = new ArrayList(){}:list2代表的是一个匿名类的声明和赋值,它定义了一个继承于ArrayList的匿名类,只是没有任何覆写的方法而已,其代码类似于: 

// 定义一个继承ArrayList的内部类
    class Sub extends ArrayList {

    }

    // 声明和赋值
    List list2 = new Sub();

(2)、list3 = new ArrayList(){ {} }:这个语句就有点奇怪了,带了两对{},我们分开解释就明白了,这也是一个匿名类的定义,它的代码类似于: 

// 定义一个继承ArrayList的内部类
    class Sub extends ArrayList {
        {
            //初始化代码块
        }
    }

    // 声明和赋值
    List list3 = new Sub();

看到了吧,就是多了一个初始化块而已,起到构造函数的功能,我们知道一个类肯定有一个构造函数,而且构造函数的名称和类名相同,那问题来了:匿名类的构造函数是什么呢?它没有名字呀!很显然,初始化块就是它的构造函数。当然,一个类中的构造函数块可以是多个,也就是说会出现如下代码:

List list4 = new ArrayList(){{} {} {} {} {}};

上面的代码是正确无误,没有任何问题的,现在清楚了,匿名类虽然没有名字,但也是可以有构造函数的,它用构造函数块来代替构造函数,那上面的3个输出就很明显了:虽然父类相同,但是类还是不同的。


建议45:覆写equals方法时不要识别不出自己

我们在写一个JavaBean时,经常会覆写equals方法,其目的是根据业务规则判断两个对象是否相等,比如我们写一个Person类,然后根据姓名判断两个实例对象是否相同时,这在DAO(Data Access Objects)层是经常用到的。具体操作时先从数据库中获得两个DTO(Data Transfer Object,数据传输对象),然后判断他们是否相等的,代码如下: 

public class Person {
    private String name;

    public Person(String _name) {
        name = _name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object obj) {
        if(obj instanceof Person){
            Person p = (Person) obj;
            return name.equalsIgnoreCase(p.getName().trim());
        }
        return false;
    }
}

覆写的equals方法做了多个校验,考虑到Web上传递过来的对象有可能输入了前后空格,所以用trim方法剪切了一下,看看代码有没有问题,我们写一个main:

public static void main(String[] args) {
        Person p1= new Person("张三");
        Person p2= new Person("张三  ");
        List<Person> list= new ArrayList<Person>();
        list.add(p1);
        list.add(p2);
        System.out.println("列表中是否包含张三:"+list.contains(p1));    
        System.out.println("列表中是否包含张三:"+list.contains(p2));
    }

上面的代码产生了两个Person对象(注意p2变量中的那个张三后面有一个空格),然后放到list中,最后判断list是否包含了这两个对象。看上去没有问题,应该打印出两个true才对,但是结果却是:

列表中是否包含张三:true
列表中是否包含张三:false  

刚刚放到list中的对象竟然说没有,这太让人失望了,原因何在呢?list类检查是否包含元素时时通过调用对象的equals方法来判断的,也就是说 contains(p2)传递进去,会依次执行p2.equals(p1),p2.equals(p2),只有一个返回true,结果都是true,可惜 的是比较结果都是false,那问题出来了:难道

p2.equals(p2)因为false不成?

还真说对了,p2.equals(p2)确实是false,看看我们的equals方法,它把第二个参数进行了剪切!也就是说比较的如下等式:

"张三 ".equalsIgnoreCase("张三");

注意前面的那个张三,是有空格的,那结果肯定是false了,错误也就此产生了,这是一个想做好事却办成了 “坏事” 的典型案例,它违背了equlas方法的自反性原则:对于任何非空引用x,x.equals(x)应该返回true,问题直到了,解决非常简单,只要把trim()去掉即可。注意解决的只是当前问题,该equals方法还存在其它问题。

欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz_javaweb
分享自己的Java Web学习之路以及各种Java学习资料