前言
看大神推薦的書單中入門有這么一本書,所以決定把這本書的精華(自認為很有用的點),或許是我自己現在能用到的點都提煉出來,供大家參考學習。
以下內容均出自《編寫高質量代碼 改善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),其目的是為了減少字符輸入量,提高代碼的可閱讀性,以便更好的理解程序。先來看一個例子:
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學習資料