第31條:用實例域代替序數
枚舉類型有一個ordinal方法,它范圍該常量的序數從0開始,不建議使用這個方法,因為這不能很好地對枚舉進行維護,正確應該是利用實例域,例如:
1 /**
2 * 枚舉類型錯誤碼
3 * Created by yulinfeng on 8/20/17.
4 */
5 public enum ErrorCode {
6 FAILURE(0), 7 SUCCESS(1); 8 9 private final int code; //上一條講到枚舉天生不可變,所有域都應該是final的。 10 11 ErrorCode(int code) { 12 this.code = code; 13 } 14 15 public int getCode() { 16 return code; 17 } 18 }
第32條:用EnumSet代替位域
前面說到枚舉類型並“不常用”,那么這個EnumSet可能就更不常用了,首先來介紹寫EnumSet是什么類型,它存在的意義是什么。
我們都知道HashSet不包含重復元素,同樣EnumSet也和HashSet一樣實現自AbstractSet,它也不包含重復元素,可以說它就是為Enum枚舉類型而生,在《Thinking in Java》中這樣描述“Java SE5引入EnumSet,是為了通過enum創建一種替代品,以替代傳統的基於int的“位標識””。本書中也是提到用EnumSet來代替位域。
關於EnumSet中的元素必須來自同一個Enum,並且構造一個EnumSet實例是通過靜態工廠方法——noneOf,用法如下:
1 /**
2 * 加減乘除枚舉
3 * Created by yulinfeng on 8/20/17.
4 */
5 public enum Operation {
6 PLUS, MINUS, TIMES, DEVIDE; 7 }
1 import java.util.EnumSet;
2
3 /**
4 * Created by yulinfeng on 8/17/17.
5 */
6 public class Main { 7 8 public static void main(String[] args) throws InterruptedException { 9 EnumSet<Operation> enumSet = EnumSet.noneOf(Operation.class); 10 enumSet.add(Operation.DEVIDE); 11 System.out.println(enumSet); 12 enumSet.remove(Operation.DEVIDE); 13 System.out.println(enumSet); 14 } 15 }
書中提到的位域,實際上就是OR位運算,換句話說就是“並集”也就是Set所代表的就是並集,在使用int型枚舉模式的時候可能會用到類似“1 || 2”,這個時候不如用Enum枚舉加以EnumSet來實現。
第33條:用EnumMap代替序數索引
有了上一條EnumSet的經驗,實際上EnumMap和HashMap也類同,不同的是它是為Enum為生的,同樣它的鍵也只允許來自同一個Enum枚舉,我們舉例《Thinking in Java》中的例子(命令設計模式):
1 /**
2 * 報警枚舉
3 * Created by yulinfeng on 8/20/17.
4 */
5 public enum AlamPoints {
6 KITCHEN, BATHROOM; 7 }
1 /**
2 * 命令接口
3 * Created by yulinfeng on 8/20/17.
4 */
5 public interface Command {
6 void action(); 7 }
1 import java.util.EnumMap;
2 import java.util.Map; 3 4 /** 5 * EnumMap 6 * Created by yulinfeng on 8/17/17. 7 */ 8 public class Main { 9 10 public static void main(String[] args) throws InterruptedException { 11 EnumMap<AlamPoints, Command> em = new EnumMap<AlamPoints, Command>(AlamPoints.class); 12 em.put(AlamPoints.KITCHEN, new Command() { 13 @Override 14 public void action() { 15 System.out.println("Kitchen fire"); 16 } 17 }); 18 em.put(AlamPoints.BATHROOM, new Command(){ 19 @Override 20 public void action() { 21 System.out.println("Bathroom alert!"); 22 } 23 }); 24 for (Map.Entry<AlamPoints, Command> e : em.entrySet()) { 25 System.out.print(e.getKey() + ":"); 26 e.getValue().action(); 27 } 28 } 29 }
這個例子說明了EnumMap的基本用法,和HashMap除了在構造方法上的不同外,基本無異。至於本條目所說的不要使用序數,實際上看完這條目過后得出的結論就是利用Map而不是是使用數組這個意思。
第34條:用接口模擬可伸縮的枚舉
在第30條里我們舉了這么一個例子:
1 /**
2 * 加減乘除枚舉
3 * Created by yulinfeng on 8/20/17.
4 */
5 public enum Operation {
6 PLUS { 7 double apply(double x, double y) { 8 return x + y; 9 } 10 }, 11 MIUS { 12 double apply(double x, double y) { 13 return x - y; 14 } 15 }, 16 TIMES { 17 double apply(double x, double y) { 18 return x * y; 19 } 20 }, 21 DEVIDE { 22 double apply(double x, double y) { 23 return x / y; 24 } 25 }; 26 27 abstract double apply(double x, double y); 28 }
同時提到當需要新增一個操作符時直接在源代碼中新增即可,且不會忘記重寫apply方法,但從軟件開發的可擴展性來說這並不是一個好的方法,軟件可擴展性並不是在原有代碼上做修改,如果這段代碼是在jar中的呢?這個時候就需要接口出場了,我們修改上述例子:
1 /**
2 * 操作符接口
3 * Created by yulinfeng on 8/20/17.
4 */
5 public interface Operation {
6 double apply(double x, double y); 7 }
1 /**
2 * 基本操作符,實現自Operation接口
3 * Created by yulinfeng on 8/20/17.
4 */
5 public enum BasicOperation implements Operation{
6 PLUS("+") { 7 public double apply(double x, double y) { 8 return x + y; 9 } 10 }, 11 MIUS("-") { 12 public double apply(double x, double y) { 13 return x - y; 14 } 15 }, 16 TIMES ("*") { 17 public double apply(double x, double y) { 18 return x * y; 19 } 20 }, 21 DEVIDE ("/") { 22 public double apply(double x, double y) { 23 return x / y; 24 } 25 }; 26 private final String symbol; 27 28 BasicOperation(String symbol) { 29 this.symbol = symbol; 30 } 31 }
當我們需要擴展操作符枚舉的時候只需要重新實現Operation接口即可:
1 /**
2 * 擴展操作符
3 * Created by yulinfeng on 8/20/17.
4 */
5 public enum ExtendedOperation implements Operation {
6 EXP ("^") { 7 public double apply(double x, double y) { 8 return Math.pow(x, y); 9 } 10 }, 11 REMAINDER ("%") { 12 public double apply(double x, double y) { 13 return x % y; 14 } 15 }; 16 private final String symbol; 17 18 ExtendedOperation(String symbol) { 19 this.symbol = symbol; 20 } 21 }
這樣就達到代碼的可擴展性,這樣做的有一個小小的不足就是無法從一個枚舉類型繼承到另外一個枚舉類型。
第35條:注解優先於命名模式
關於注解,我想很多人並沒有親自自定義過一個注解包括我,僅僅是在使用Spring框架時有使用過。確實對於注解來講,個人認為一般很難接觸自定義注解,除非是在公司的“自由框架組”或者“平台組”,這條我們就來學習如何編寫自定義的注解。
首先來回顧Spring MVC中很常用的一條注解:
1 import org.springframework.stereotype.Controller;
2 import org.springframework.web.bind.annotation.RequestMapping; 3 import static org.springframework.web.bind.annotation.RequestMethod.GET; 4 5 /** 6 * Created by 余林豐 on 2017/4/12/0012. 7 */ 8 @Controller 9 @RequestMapping("/") 10 public class HomeController { 11 12 @RequestMapping(value="/home", method = GET) 13 public String home() { 14 return "home"; 15 } 16 }
選擇@RequestMapping查看其源碼,可以發現@RequestMapping上還有注解:
1 @Target({ElementType.METHOD, ElementType.TYPE}) //注解可使用在方法聲明和類、接口或enum聲明
2 @Retention(RetentionPolicy.RUNTIME) //注解應該在運行時保留
3 @Documented //注解應該被 javadoc工具記錄
4 @Mapping //表明是一個web映射注解
5 public @interface RequestMapping {
6 String name() default ""; 7 @AliasFor("path") //別名 8 String[] value() default {}; 9 @AliasFor("value") 10 String[] path() default {}; 11 RequestMethod[] method() default {}; 12 String[] params() default {}; 13 String[] headers() default {}; 14 String[] consumes() default {}; 15 String[] produces() default {}; 16 }
定義注解時發現注解的語法有點奇怪,所有注解都只包含方法聲明,但是不能為這些方法提供方法體,這些方法的行為更像是域變量,對於沒有定義方法的注解稱之為標記注解。方法名后通過default來聲明參數的默認值,該注解的適用方法就是開頭的HomeController代碼示例。這是注解的基本使用和定義,一定注意:注解永遠不會改變被注解代碼的語義,它們只負責提供信息供相關的程序使用。
對於注解運行原理,以及如何正確使用自定義的注解在這里不做過多講解,此條的目的在於對待“特定程序員”,注解是他們編寫“工具類”、“框架類”的利器。
第36條:堅持使用Override注解
一句話,如果你要重寫父類的方法,一定要使用Override注解,防止對方法進行了重載。
第37條:用標記接口定義類型
標記接口是沒有包含方法聲明的接口,而只是指明一個類實現了具有某種屬性的接口,例如實現了Serializable接口表示它可被實例化。在有的情況下使用標記注解比標記接口可能更好,但書中提到了兩點標記接口勝過標記注解:
1) 標記接口定義的類型是由被標記類的實例實現的;標記注解則沒有定義這樣的類型。 //這條有點不好理解,希望有大神有更加通俗地解釋
2) 盡管標記注解可以鎖定類、接口、方法,但它是針對所有,標記接口則可以被更加精確地鎖定。
另外書中也提到標記注解優於標記接口的地方:那就是能標記程序元素而非類和接口,且在未來能給標記添加更多的信息。
第38條:檢查參數的有效性
對於這一條,最常見的莫過於檢查參數是否為null。
有時出現調用方未檢查傳入的參數是否為空,同時被調用方也沒有檢查參數是否為空,結果這就導致兩邊都沒檢查以至於出現null的值程序出錯,通常情況下會規定調用方或者被調用方來檢查參數的合法性,或者干脆規定都必須檢查。null值的檢查相當有必要,很多情況下沒有檢查值是否為空,結果導致拋出NullPointerException異常。
第39條:必要時進行保護性拷貝
書中所舉的例子也相當有借鑒參考意義,不妨來看看:
1 import java.util.Date;
2
3 /**
4 * 開始時間不能大於結束時間
5 * Created by yulinfeng on 2017/8/21/0021.
6 */
7 public class Period { 8 private final Date start; //定義為不可變的引用 9 private final Date end; 10 11 public Period(Date start, Date end) { 12 if (start.compareTo(end) > 0) { 13 throw new IllegalArgumentException(start + "after" + end); 14 15 } 16 this.start = start; 17 this.end = end; 18 } 19 20 public Date start() { 21 return start; 22 } 23 24 public Date end() { 25 return end; 26 } 27 }
這段代碼看起來沒有問題,代碼中也明確了start和end是不變的。但是!Date類本身確實可變的,一定注意Date類本身是可變的。所以當出現以下代碼時就會違背代碼本身的意願:
1 import java.util.Date;
2
3 /**
4 *
5 * Created by yulinfeng on 2017/8/17
6 */
7 public class Main { 8 public static void main(String[] args) throws Exception{ 9 Date start = new Date(); 10 Date end = new Date(); 11 Period p = new Period(start, end); //傳遞的是引用的拷貝 12 end.setYear(78); //對end變量的修改是會影響到Period中的end對象,因為它們指向的是同一個實例對象 13 } 14 }
注釋中提到傳遞的是引用的拷貝,那么我們在Period中重新創建一個Date實例就不再指向同一個對象實例了:
1 import java.util.Date;
2
3 /**
4 * 開始時間不能大於結束時間
5 * Created by yulinfeng on 2017/8/21/0021.
6 */
7 public class Period { 8 private final Date start; //定義為不可變的引用 9 private final Date end; 10 11 public Period(Date start, Date end) { 12 /*實例的創建應在有效性檢查之前進行,避免在“從檢查參數開始直到拷貝參數之間的時間段期間”從另一個線程改變類的參數*/ 13 14 this.start = new Date(start.getTime()); 15 this.end = new Date(start.getTime()); 16 17 if (this.start.compareTo(this.end) > 0) { 18 throw new IllegalArgumentException(start + "after" + end); 19 20 } 21 } 22 23 public Date start() { 24 return start; 25 } 26 27 public Date end() { 28 return end; 29 } 30 }
不過如果我們將客戶端測試代碼改為以下,則還是會帶來新的問題,它同樣是修改了Period實例,原因就是Date類是可改變的:
1 import java.util.Date;
2
3 /**
4 *
5 * Created by yulinfeng on 2017/8/17
6 */
7 public class Main { 8 public static void main(String[] args) throws Exception{ 9 Date start = new Date(); 10 Date end = new Date(); 11 Period p = new Period(start, end); //傳遞的是引用的拷貝 12 p.end().setYear(78); //對end變量的修改是會影響到Period中的end對象,因為它們指向的是同一個實例對象 13 } 14 }
再次利用上述思路,在調用start()和end()方法時不直接返回成員變量,而是范圍一個新的實例:
1 import java.util.Date;
2
3 /**
4 * 開始時間不能大於結束時間
5 * Created by yulinfeng on 2017/8/21/0021.
6 */
7 public class Period { 8 private final Date start; //定義為不可變的引用 9 private final Date end; 10 11 public Period(Date start, Date end) { 12 /*實例的創建應在有效性檢查之前進行,避免在“從檢查參數開始直到拷貝參數之間的時間段期間”從另一個線程改變類的參數*/ 13 14 this.start = new Date(start.getTime()); 15 this.end = new Date(start.getTime()); 16 17 if (this.start.compareTo(this.end) > 0) { 18 throw new IllegalArgumentException(start + "after" + end); 19 20 } 21 } 22 23 public Date start() { 24 return new Date(start.getTime()); 25 } 26 27 public Date end() { 28 return new Date(end.getTime()); 29 } 30 }
至此,Period就是真正的不可變了,當然有經驗的程序員通常不會Date作為參宿類型,而是直接使用long。這個例子對類的不可變性很有參考意義。
第40條:謹慎設計方法簽名
方法簽名不僅僅是指方法命名,還包括方法所包含的參數。
方法命名要遵循一定的規則和規律,可參考JDK的命名;方法所包含的參數最好不應超過4個,如果超過4個則應考慮拆分成多個方法或者創建輔助類用來保存參數的分組。
2017-08-21
第41條:慎用重載
面向對象有三個特性:繼承、封裝、多態。重載所體現的就是多態。
重載和重寫有區別,重寫是子類的方法重新實現父類的方法,包括方法名和參數都要相同;重載則不用要求是要繼承,只要求擁有相同的方法名,參數類型不同個數不同都可以稱之為重載。
此條目下書中建議慎用重載,在舉幾個例子過后就能很清除地明白為什么需要慎用。
1 import java.util.*;
2
3 /**
4 * 令人疑惑的重載示例
5 * Created by yulinfeng on 8/22/17.
6 */
7 public class CollectionClassifier { 8 public static String classify(Set<?> set) { 9 return "Set"; 10 } 11 12 public static String classify(List<?> list) { 13 return "List"; 14 } 15 16 public static String classify(Collection<?> collection) { 17 return "Unknown Collection"; 18 } 19 20 public static void main(String[] args) { 21 Collection<?>[] collections = {new HashSet<String>(), new ArrayList<String>(), new HashMap<String, String>().values()}; 22 for (Collection<?> c : collections) { 23 System.out.println(classify(c)); 24 } 25 } 26 }
運行結果可能有點讓人疑惑:
這引出了一個問題,先說結論:重載方法的選擇是靜態的,而對於重寫方法的選擇則是動態的。在《深入理解Java虛擬機》第8.3.2章節中有講解,這涉及到重載與重寫在Java虛擬機中是如何實現的。
再來看一個關於重載的例子:
1 /**
2 * 靜態分派——重載
3 * Created by yulinfeng on 8/22/17.
4 */
5 public class StaticDispatch {
6 static abstract class Human{ 7 } 8 9 static class Man extends Human { 10 } 11 12 static class Woman extends Human { 13 } 14 15 public void sayHello(Human guy) { 16 System.out.println("hello, guy!"); 17 } 18 19 public void sayHello(Man guy) { 20 System.out.println("hello, gentleman!"); 21 } 22 23 public void sayHello(Woman guy) { 24 System.out.println("hello, lady!"); 25 } 26 27 public static void main(String[] args) { 28 Human man = new Man(); 29 Human woman = new Woman(); 30 StaticDispatch staticDispatch = new StaticDispatch(); 31 staticDispatch.sayHello(man); 32 staticDispatch.sayHello(woman); 33 } 34 }
思考上面例子的執行結果,實際上有了第一個例子,這個例子可能也能“猜”得到。
這兩個例子實際上有異曲同工之妙,相似點在:
第一個例子:for (Collection<?> c : collections)。第二個例子:Human man = new Man();Human woman = new Woman();。
實際上“Human”稱為變量的靜態類型(外觀類型),而“Man”和“Woman”則稱為變量的實際類型。本例中實際上就是定義了兩個靜態類型但實際類型不同的變量,但是虛擬機在重載時是通過參數的靜態類型而不是實際類型作為判定一句的,靜態類型又是在編譯器可知的,所以在編譯時就能確定使用哪個重載版本。這樣看來上面兩個例子的執行結果就能解釋了,因為它們的靜態類型確定為了Collection和Human,故在編譯時就選用了這兩個參數類型的重載方法。所有依賴靜態類型來定位方法執行版本的分派動作稱為靜態分派。
下面我們順便說下“重寫”。
1 /**
2 * 動態分派——重寫
3 * Created by yulinfeng on 8/22/17.
4 */
5 public class DynamicDispatch {
6 static abstract class Human { 7 protected abstract void sayHello(); 8 } 9 10 static class Man extends Human { 11 @Override 12 protected void sayHello() { 13 System.out.println("man say hello"); 14 } 15 } 16 17 static class Woman extends Human { 18 @Override 19 protected void sayHello() { 20 System.out.println("woman say hello"); 21 } 22 } 23 24 public static void main(String[] args) { 25 Human man = new Man(); 26 Human woman = new Woman(); 27 man.sayHello(); 28 woman.sayHello(); 29 man = new Woman(); 30 man.sayHello(); 31 } 32 }
這個例子的執行結果應該不會感到疑惑了:
顯然這並不是根據靜態類型來選擇的重寫方法,簡單推理一下可得只是按照實際類型來選擇確定的重寫方法,而實際類型又是在編譯時不可知只有在運行時才確定的,所以這有個較為專業的叫法——動態分配。也就是說在運行時根據實際類型確定方法執行版本的分派過程稱為動態分配。
清楚了重載和重寫的虛擬機實現過后,回到此條目的主題——慎用重載。
同樣給出書中的例子就能說明,重載一定要慎重使用:
1 import java.util.ArrayList;
2 import java.util.List; 3 import java.util.Set; 4 import java.util.TreeSet; 5 6 /** 7 * 慎用重載 8 * Created by yulinfeng on 8/22/17. 9 */ 10 public class Main { 11 12 public static void main(String[] args) throws InterruptedException { 13 Set<Integer> set = new TreeSet<Integer>(); 14 List<Integer> list = new ArrayList<Integer>(); 15 for (int i = -3; i < 3; i++) { 16 set.add(i); 17 list.add(i); 18 } 19 for (int i = 0; i < 3; i++) { 20 set.remove(i); 21 list.remove(i); 22 } 23 System.out.println(set + " " + list); 24 } 25 }
這個例子本身想要的打印結果是[-3, -2, -1] [-3, -2, -1],結果運行確是:
原因就在於對於List的remove方法有兩個,這兩個在這里產生了歧義,其中一個是刪除下標索引元素,另一個是刪除集合中的元素。如果將第21行
list.remove(i);
修改為
list.remove((Integer)i);
則避免得到了我們想要的結果。
這就是重載帶來的“危害”,稍不留神就出現了致命問題。比較好的解決辦法學習ObjectOutputStream類中做法:writeBoolean(boolean),writeInt(int),writeLong(long)。如果兩個重載方法的參數類型很相似,那一定得考慮這樣做是否容易造成“程序的誤解”。
第42條:慎用可變參數
具有可變參數的方法可以傳入0個或者多個參數,這種方法我相信自己寫的可能在少數,用得最多的可能要屬反射中的getDeclaredMethod方法:
public Method getDeclaredMethod(String name, Class<?>... parameterTypes)
為什么要慎用,其中有一個原因就是很有可能在沒有傳入參數的時候程序沒有做任何保護而導致程序錯誤。另外有一個原因就是它會帶來一定的性能問題,EnumSet類在傳入少量參數的時候是直接調用具體的方法,只有在傳入大量參數時才會調用可變參數的方法,這也是它在性能方面有優勢的原因,因為可變參數的每次調用都會導致進行一次數組分配和初始化。
總之,“在定義參數數目不定的方法時,可變參數是一種很方便的方式,但是它們不應該被過度濫用。如果使用不當,會產生混亂的結果”。
第43條:返回零長度的數組或者集合,而不是null
我在閱讀《Google Guava官方教程》時所讀到最開頭就是——使用和避免null:null是模棱兩可的,會引起令人困惑的錯誤,有些時候它讓人很不舒服。
例如對於一個Map,調用其get(key)方法,此時若返回null,可能表示這個key所對應的值本身就是null,或者表示這個Map中沒有這個key值。這是兩種截然不同的語義。
書中僅是說明對於零長度的數組或者集合不應該返回null,實際上對於所有的情況,都不要輕易返回null,特別是在語義不清的情況,更別說返回null時有的客戶端程序並沒有處理null的這種情況。
如果一定要用到null,更好的辦法是單獨維護它。
第44條:為所有導出的API元素編寫文檔注釋
說實話,別說文檔注釋,連普通的方法注釋也不是人人都會去編寫,連參數和返回值,以及方法用途都不會注明。如今越來越強大的IDE,只要輕輕敲幾個快捷鍵就能方便的生成文檔注釋模板,不要覺得麻煩,起碼的參數、返回值、用途的注釋一定要寫。
關於文檔注釋這里不再做介紹。
2017-08-22
第45條:將局部變量的作用域最小化
之前在第13條談到過成員變量的可訪問性最小化,此處指的在一個方法中定義的局部變量。
簡單一句話,用到的時候在定義,不要提前定義,當然在某種特殊情況下例外。想到之前看的《重構:改善既有代碼的設計》書中有關成員變量的看法是不建議在程序中使用成員變量而是使用查詢來代替。
第46條:for-each循環優先於傳統的for循環
對於for-each的語法格式:
for (Element e : elements) {
doSomething(e);
}
當對集合、數組中的元素只做遍歷時應首選for-each,而不是通過for循環手動移動數組下標。
第47條:了解和使用類庫
JDK中內置了大量的工具類庫,但很多“不為人知”。這實際上考研的是編程人員對Java基礎的掌握程度,例如輸出數組的方法:Arrays.toString等等,再比如判斷是否字符串為空是實際上有isEmpty方法的。書中建議每個程序員都應該熟悉java.lang、java.util。
在進行工程項目類的開發時,不應重復造輪子,利用現有的已成熟的技術能避免很多bug和其他問題。除非自己業余愛好研究,重復造輪子我認為就很能提高編程水平了。
第48條:如果需要精確的答案,請避免使用float和double
float和double表示浮點類型數據,對於要精確到小數點的數值運算,通常下意識的會選擇float或者double類型,但實際上這兩種類型對於精確的計算是存在一定隱患的。
對於精確的數值計算,首推BigDecimal,或者可以使用int、long型將單位縮小不再有小數點。書中舉了詳細例子來說明。
第49條:基本類型優先於裝箱基本類型
簡單回顧下Java中的數據類型分為:基本類型和引用類型。基本類型包括:byte、short、int、long、float、double、char、boolean。引用類型則是String、List等。
對於int型的數據我們對它的數值大小判斷直接上就是“==”,但對於其裝箱類型Integer呢?
1 /**
2 * 裝箱基本類型Integer的數值比較
3 * Created by 余林豐 on 2017/8/23
4 */
5 public class Main {
6 public static void main(String[] args) throws Exception{ 7 Integer a = 10; 8 Integer b = 10; 9 Integer c = 1000; 10 Integer d = 1000; 11 System.out.println(a == b); 12 System.out.println(c == d); 13 } 14 }
它的執行結果有點令人疑惑:
這是由於在Integer中會緩存-128~127的小數值,在自動裝箱的時候對這些小數值能直接比較。再來看下面例子:
1 /**
2 * 裝箱基本類型Integer的數值比較
3 * Created by 余林豐 on 2017/8/23
4 */
5 public class Main {
6 public static void main(String[] args) throws Exception{ 7 Integer a = new Integer(10); 8 Integer b = new Integer(10); 9 Integer c = new Integer(1000); 10 Integer d = new Integer(1000); 11 System.out.println(a == b); 12 System.out.println(c == d); 13 } 14 }
它的執行結果為:
這里不再涉及自動裝箱,有點類似String的比較,你把它當做引用類型,引用類型的“==”比較的都是其引用是否相等就能理解了。看到這里估計就差不多明白了,基本類型在有的情況優先於裝箱基本類型,況且裝箱基本類型還會帶來性能問題。
而對於有的特定條件,例如集合的類型參數,這個時候就必須使用裝箱類型,沒得選。
綜上,在有的選的情況下,有限考慮基本類型。
第50條:如果其他類型更合適,則盡量避免使用字符串
關於字符串是個萬年不變的“考題”。Java中字符串不是一種基本類型,它實際上還是有char數組實現,在C中也並沒有字符串而只有char類型。String字符串從誕生開始就注定不一般。
書中提到幾種不應該使用字符串的情況,我認為在編碼過程往往為了圖省事將什么類型都定義為字符串,例如手機號,甚至是boolean類型定義為”true”或者”false”字符串。不應為了省事輕易使用字符串,而要選擇更為恰當的類型。
第51條:當心字符串連接的性能
我們都知道String字符串是不可變的,每次對一個字符串變量的賦值實際上都在內存中開辟了新的空間。如果要經常對字符串做修改應該使用StringBuilder(線程不安全)或者StringgBuffer(線程安全),其中StringBuilder由於不考慮線程安全,它的速度更快。
第52條:通過接口引用對象
考慮程序代碼的靈活性應該優先使用接口而不是類來引用對象,例如:
List<String> list = new ArrayList<String>();
這樣帶來的好處就是可以更換list的具體實現只需一行代碼,之前有談到將接口作為參數的類型,這兩者配合使用就能最大限度實現程序的靈活性。
但如果是類實現了接口,但是它提供了接口中不存在的額外方法,且程序依賴這些額外方法,這個時候用接口來代替類引用對象就不合適了。
2017-08-23
第53條:接口優先於反射機制
其實在有關接口的建議中所推崇的都是面向接口編程,此條也不例外。
首先是反射的使用一定要慎重,它能在運行時訪問對象,但它也有以下負面影響:
喪失了編譯時類型檢查
執行反射訪問所需要的代碼非常笨拙和冗長(這需要一定的編碼能力)
性能損失
在使用反射時利用接口指的是,在編譯時無法獲取相關的類,但在編譯時有合適的接口就可以引用這個類,當在運行時以反射方式創建實例后,就可以通過接口以正常的方式訪問這些實例。
第54條:謹慎地使用本地方法
所謂的本地方法就是在JDK源碼中你所看到在有的方法中會有“native”關鍵字的方法,這種方法表示用C或者C++等本地程序設計語言編寫的特殊方法。之所以會存在本地方法的原因主要有:訪問特定平台的接口、提高性能。
實際上估計很少很少在代碼中使用本地方法,就算是在設計比較底層的庫時也不會使用到,除非要訪問很底層的資源。當使用到本地方法時唯一的要求就是全面再全面地測試,以確保萬無一失。
第55條:謹慎地進行優化
我在實際編碼過程中,常常聽到別人說,這么實現性能可能會好一點,少了個什么什么性能會好一點,甚至是少了個局部變量也會提到這么性能要好一點,能提高一點是一點。
書中引用了三句話提出了截然不同的觀點:
很多計算上的過失都被歸咎於效率(沒有必要達到的效率),而不是任何其他的原因——甚至包括盲目地做傻事。
——William A.Wulf
不要去計較效率上的一些小小的得失,在97%的情況下,不成熟的優化才是一切問題的根源。
——Donald E.Knuth
在優化方面,我們應該遵守兩條規則:
規則1:不要進行優化
規則2:(僅針對專家):還是不要進行優化——也就是說,在你還沒有絕對清晰的未優化方案之前,請不要進行優化。
——M.A.Jackson
上面幾句話看似讓你不要做優化,這當然不可能。實際上是在編碼中如果你沒有考慮清楚就冒然想當然的去做優化,常常可能是得不償失,就像我開頭提到的那樣,甚至為了優化性能而去減少一個局部變量。
正確的做法應該是,寫出結構優美、設計良好的代碼,不是寫出快的程序。
性能的問題應該有數據做支撐,也就是有性能測試軟件對程序測試來評判出性能問題出現在哪個地方,從而做針對性的修改。
第56條:遵守普遍接受的命名慣例
阿里巴巴針對Java已經出了一份《阿里巴巴Java開發手冊》,這本手冊就是很好的參考。就說書里沒有的一點,不要使用拼音!
第57條:只針對異常的情況才使用異常
接着這幾條建議是針對異常,關於異常我個人感覺就是人人都會用,但並不是人人都能用得好。有公司自研框架就規定了如何處理異常的方法,以供程序員統一異常處理。
此條建議在書中所給出的例子我相信實際上也沒有幾個會寫出來,異常就是超出意料之外的錯誤,也就是說正常的控制流邏輯是不會走到異常的,所以不要再正常的控制流中出現異常來對程序做正常邏輯處理。
2017-08-27
第58條:對可恢復的情況使用受檢異常,對程序錯誤使用運行時異常
這里會涉及幾個異常相關的概念,先對異常分類做一個簡單的梳理。
所有的異常、錯誤都繼承自Throwable,它直接包含了兩個子類Error和Exception。
Error用來表示編譯時和系統錯誤,Exception是可以被拋出的基本類型,通常情況下我們只關心Exception異常,而Error錯誤是程序員控制不了的系統錯誤。
對於Exception異常又分為兩種:受檢查的異常和不受檢查的異常,受檢查的異常直接繼承自Exception,不受檢查的異常則繼承自RuntimeException(RuntimeException也繼承自Exception)。
受檢查的異常在編碼中就是需要被try-catch捕獲或者通過throws拋出的異常,例如在進行I/O操作時候常常都會明確要求對文件的操作需要對異常進行處理。
而對於不受檢查的異常就是不需要在代碼中體現但也可以對這種異常出現時做處理,例如經常遇到的就是空指針異常(NullPointerException)。因為不受檢查的異常全部繼承自RuntimeException,此條目中所說的“運行時異常”也就是不受檢查的異常。
什么時候使用受檢查的異常(throws Exception),什么時候使用不受檢查的異常(throws RuntimeException),本書中給出原則是:如果期望調用者能夠適當地恢復,對於這種情況就應該使用受檢的異常。對於程序錯誤,則使用運行時異常。例如在數據庫的事務上通常就會對異常做處理,防止出現數據不一致等情況的發生。
第59條:避免不必要地使用受檢的異常
關於這條建議書中實際上是建議如何更好的“設計”異常處理。
對於異常本身初衷是使程序具有更高的可靠性,特別是受檢查的異常。但濫用受檢查的異常可能就會使得程序變得負責,給程序員帶來負擔。
兩種情況同時成立的情況下就可以使用受檢查的異常:1、正確地使用API並不能阻止這種異常條件的產生;2、如果一旦產生異常,使用API的程序員可以立即采取有用的動作。以上兩種情況成立時,就可以使用受檢查的異常,否則可能就是徒增煩惱。
另外在一個方法拋出受檢查異常時,也需要仔細考量,因為對於調用者來講就必須處理做相應處理,或捕獲或繼續向上拋出。如果是一個方法只拋出一個異常那么實際上可以將拋出異常的方法重構為boolean返回值來代替。
第60條:優先使用標准的異常
對異常的時候都知道可以自定義異常,但往往在尚不標准的開發工程中都是不管三七二十一統統捕獲或者拋出Exception異常。實際上更好的是使用內置的標准的異常而不是這么籠統。例如參數值不合法就拋出llegalArgumentException等。
借用書中的話:專家級程序員與缺乏經驗的程序員一個最主要的區別在於,專家追求並且通常也能夠實現高度的代碼重用。
第61條:拋出與抽象相對應的異常
看題目會覺得不知所雲,這實際上講的是“異常轉譯”,所謂異常轉譯簡單來說指的就是將一個異常轉換成另外一個異常,例如AbstractSequentialList#get方法。
首先查看對於列表List類 get方法的規范要求:
//List /** * Returns the element at the specified position in this list. * * @param index index of the element to return * @return the element at the specified position in this list * @throws IndexOutOfBoundsException if the index is out of range * (<tt>index < 0 || index >= size()</tt>) */ E get(int index);
可以看到關於List的get方法,其子類需要拋出未受檢的異常(RuntimeException)——IndexOutOfBoundsException。
//AbstractSequentialList#get /** * Returns the element at the specified position in this list. * * <p>This implementation first gets a list iterator pointing to the * indexed element (with <tt>listIterator(index)</tt>). Then, it gets * the element using <tt>ListIterator.next</tt> and returns it. * * @throws IndexOutOfBoundsException {@inheritDoc} */ public E get(int index) { try { return listIterator(index).next(); } catch (NoSuchElementException exc) { throw new IndexOutOfBoundsException("Index: "+index); //異常轉譯,底層拋出NoSuchElementException,轉譯為IndexOutOfBoundsException。 } }
可以看到AbstractSequentialList#get方法的異常拋出就做了異常轉譯,這么做的原因有幾點:符合List對get方法的規范;便於理解。
另外還有一點就是“異常鏈”:底層的異常被傳到高層的異常,高層的異常提供訪問方法來獲得底層的異常。這樣做的好處在於高層能查看低層異常的原因,其壞處就是逐層上拋會消耗大量資源。
總之,就是當低層拋出異常時,此時要考慮是否做異常轉譯,使得上層方法的調用者易於理解。
第62條:每個方法拋出的異常都要有文檔
這里書中主要提到要為拋出的異常建立Java的文檔注釋即Javadoc的@throws標簽。
對於一些未受檢的異常同樣也應該在文檔注釋中說明,例如上面提到的List#get。
第63條:在細節信息中包含能捕獲失敗的信息
關於此條目的建議可以歸結於如何編寫好的日志。
在剛工作的時候關於日志的打印對我來說基本是不知道怎么來寫的,不知道在哪里寫,不知道怎么寫,需要包含什么內容。
對於異常的捕獲,在平時只在IDE中的控制顯示並且沒有日志記錄他通常都會這么寫:
try { doSomething(); } catch (Exception e) { e.printStackTrace(); //打印出異常的堆棧信息 }
在生產環境中當然不能這么寫而需要打印到日志中,除了堆棧信息外還需要一個可跟蹤的信息,這通常是一個用戶的ID,總之就是需要可定位、可分析。
第64條:努力使失敗保持原子性
失敗的方法調用應該使對象保持在被調用之前的狀態,具有這種屬性的方法被稱為具有失敗原子性。
失敗過后,我們不希望這個對象不可用。在數據庫事務操作中拋出異常時通常都會在異常做回滾或者恢復處理,要實現對象在拋出異常過后照樣能處在一種定義良好的可用狀態之中,有以下兩個辦法:
1) 設計一個不可變的對象。不可變的對象被創建之后它就處於一致的狀態之中,以后也不會發生變化。
2) 在執行操作之前檢查參數的有效性。例如對棧進行出棧操作時提前檢查棧中的是否還有元素。
3) 在失敗過后編寫一段恢復代碼,使對象回滾到操作開始前的狀態。
在對象的一份臨時拷貝上執行操作,操作完成過后再用臨時拷貝中的結果代替對象的內容,如果操作失敗也並不影響原來的對象。
第65條:不要忽略異常
所謂的忽略就是寫出以下代碼:
try { doSomething(); } catch (Exception e) { }
當然強烈不這樣寫,如果一定要這么寫,那就需要加以注釋。
第66條:同步訪問共享的可變數據
在編程中同步是一個專業術語,所謂同步指的發出一個調用時,如果沒有得到結果就不返回,直到有結果后再返回。另外相對應的是異步,指的是發出一個調用時就立即返回而不在乎此時有沒有結果。
同步和異步關注的是“消息通信機制”,通常我們提到同步的時候實際上只理解了它一部分或者干脆理解為“互斥”,這是不全對的,例如Java中synchronized關鍵字,經常聽到教育我們說,要互斥訪問某個共享變量且需要保證它線程安全的時候就用synchronized關鍵字。
互斥表示當一個對象被一個線程修改的時候,可以阻止另一個線程觀察到對象內部不一致的狀態。同步不僅包含這層意義還包含:它可以保證進入同步方法或者同步代碼塊的每個線程,都看到由同一個鎖保護的之前所有修改效果。
對於synchronized我相信幾乎人人都知道它是線程安全的重要保證,這里不再敘述它如何保證。着重強調幾個術語:活性失敗:線程A對某變量值的修改,可能沒有立即在線程B體現出來。這是由於Java內存模型造成的原因,一個線程修改某個變量后並不會立即寫入主存而是寫到線程自身所維護的內存中,這個時候導致另一個線程從主存中取出的值並不是最新的,使用synchronized可保證這種可見性,當然還有volatile關鍵字。安全性失敗:例如i++操作並不是原子的,而是先+1再復制,這就有兩個動作,而這兩個動作的完成很有可能導致中間穿插兩個線程,這個時候就會導致程序計算結果出錯。
簡而言之,當多個線程共享可變數據的時候,每個讀或者寫數據的線程都必須執行同步,以確保線程安全,程序正確運行。
第67條:避免過度同步
上一條談到要使用同步,這一條告訴我們不要過度使用。對於在同步區域的代碼,千萬不要擅自調用其他方法,特別是會被重寫的方法,因為這會導致你無法控制這個方法會做什么,嚴重則有可能導致死鎖和異常。通常,應該在同步區域內做盡可能少的工作。獲得鎖,檢查共享數據,根據需要轉換數據,然后放掉鎖。
第68條:executor和task優先於線程
之所以推薦executor和task原因就在於這樣便於管理。
在java.util.concurrent包與Exececutor Framework相關知識點可查看《12.ThreadPoolExecutor線程池原理及其execute方法》、《13.ThreadPoolExecutor線程池之submit方法》、《14.Java中的Future模式》。
第69條:並發工具優先於wait和notify
從JDK5新增加的java.util.concurret並發包中共提供了三個方面的並發工具:Executor Framework(這在上一條中有提到)、並發集合(Concurrent Collection)以及同步器(Synchronizer)。有關並發包的相關知識我有過一個源碼解讀,不過很遺憾只包含前兩個方面,關於同步器暫未做深入了解,參考http://www.cnblogs.com/yulinfeng/category/998911.html
隨着JDK的發展,基於原始的同步操作wait和notify已不再提倡使用,因為基礎所以很多東西需要自己去保證,越來越多並發工具類的出現應該轉而學習如何使用更為高效和易用的並發工具。
第70條:線程安全性的文檔化
書中提到了很有意思的情景,有人會下意識的去查看API文檔此方法是否包含synchronized關鍵字,如不包含則認為不是線程安全,如包含則認為是線程安全。實際上線程安全不能“要么全有要么全無”,它有多種級別:
不可變的——也就是有final修飾的類,例如String、Long,它們就不用外部同步。
無條件的線程安全——這個類沒有final修飾,但其內部已經保證了線程安全,例如並發包中的並發集合類,同樣它們無需外部同步。
有條件的線程安全——這個有的方法需要外部同步,而有的方法則和“無條件的線程安全”一樣無需外部同步。
非線程安全——這就是最“普通”的類了,內部的任何方法想要保證安全性就必須要外部同步。
線程對立的——這種類就可以忽略不計了,這個類本身不是線程安全,並且就算外部同樣同樣也不是線程安全的,JDK中很少很少,幾乎不計,自身也不會寫出這樣的類,或者也不要寫出這種類。
可見隊員線程是否安全不能僅僅做安全與不安全這種籠統的概念,更不能根據synchronized關鍵字來判斷是否線程安全。你應該在文檔注釋中注明是以上哪種級別的線程安全,如果是有條件的線程安全不僅需要注明哪些方法需要外部同步,同時還需要注明需要獲取什么對象鎖。
第71條:慎用延遲初始化
延遲初始化又稱懶加載或者懶漢式,這在單例模式中很常見。
眾所周知單例模式大致分為懶漢式和餓漢式,這兩種方式各有其優缺點。對於餓漢式會在初始化類或者創建實例的時候就進行初始化操作,而對於懶漢式則相反它只有在實際用到訪問的時候才進行初始化。
至於用何種方式通常來講並沒有太大的講究,幾乎是看個人習慣。而此書卻單獨列了一條來說明延遲初始化使用不當所帶來的危害。
1) 使用延遲初始化時一定要考慮它的線程安全性,通常此時會利用synchronized進行同步。
2) 若需要對靜態域使用延遲初始化,且需要考慮性能,則使用lazy initialization holder class模式:
1 /** 2 * lazy initialization holder class 3 * Created by yulinfeng on 2017/8/30/0030. 4 */ 5 public class Singleton { 6 private static class SingletonHolder { 7 private static Singleton singleton = new Singleton(); 8 } 9 10 private Singleton() { 11 12 } 13 14 public static Singleton getInstance() { 15 return SingletonHolder.singleton; 16 } 17 }
這種模式不同於傳統的延遲加載,當調用getInstance時候第一次讀取SingletonHolder.singleton,導致SingletonHolder類得到初始化,這個類在裝載並被初始化的時候會初始化它的靜態域即Singleton實例,getInstance方法並沒有使用被synchronized同步,並且只是執行一個域的訪問這種延遲初始化的方式實際上並沒有增加任何訪問成本。
3) 若需要對實例域使用延遲初始化,且需要考慮性能,則使用雙重檢查模式,這種模式其實也是為了避免synchronized帶來的鎖定開銷:
1 /** 2 * double-check idiom 3 * Created by yulinfeng on 2017/8/30/0030. 4 */ 5 public class Singleton { 6 private volatile Singleton singleton; 7 8 private Singleton() { 9 10 } 11 public Singleton getInstance() { 12 Singleton result = singleton; 13 if (result == null) { 14 synchronized (this) { 15 result = singleton; 16 if (result == null) { 17 singleton = result = new Singleton(); 18 } 19 } 20 } 21 return result; 22 } 23 }
通常用的比較多的可能是對靜態域應用雙重檢查模式。
最后書中的建議就是正常地進行初始化,而對於延遲初始化則徐亞慎重考慮它的性能和安全性。
第72條:不要依賴於線程調度器
第69條說的是executor和task優先於線程,此處又指不要依賴,實際上這里的不要依賴指的是不要將正確性依賴於線程調度器。例如:調整線程優先級,線程的優先級是依賴於操作系統的並不可取;調用Thrad.yield是的線程獲得CPU執行機會,這也不可取。所以不要將程序的正確性依賴於線程調度器。
第73條:避免使用線程組
ThreadGroup我想大部分人別說用了,可能連聽都沒有聽過,包括我也是,記住不要使用就行了。
第74條:謹慎地實現Serializable接口
這是本書最后一個章節——序列化。將一個對象編碼成一個字節流這個過程就稱作該對象序列化,相反的過程就被稱作反序列化。
在Java中的類只需要實現Serializable接口即可被序列化,很放便也很簡單,但這並不意味着可以隨隨便便的實現Serializable接口,它會帶來以下幾個代價:
1) 實現Serializable接口后就基本等同於將這個對象如同API一樣暴露發布出去,這意味着你不可隨意更改這個類,也就是大大降低了“改變這個類的實現”的靈活性。
2) 增加了出現Bug和安全漏洞的可能性,一個類的構造器往往是用來構建一個類約束關系。序列化機制是一種語言之外的對象創建機制,反序列化可以看作是一個“隱藏的構造器”,這也就是說如果按照默認的反序列化機制很容易不按照約定的構造器建立約束關系,以及很容易使對象的約束關系遭到破壞,以及遭受到非法訪問。
3) 隨着類的版本的改變,測試的負擔增加。因為類的改變需要不斷檢查測試新版本與舊版本之間的“序列化-反序列化”是否兼容。
上面這三點是代價,在有的條件下是值得的,例如很常見的如果一個類將加入到某個框架中,並且該框架依賴序列化來實現對象的傳輸和持久化這個時候就需要這個類實現Serializable接口。
另外書中舉了JDK中為了繼承而設計的實現了Serializable接口的類,Throwable類實現了Serializable接口,所以RMI的異常可以從服務器端傳到客戶端。Component實現了Serializable接口,因此GUI可以被發送、保護和恢復。HttpServlet實現了Serializable接口,因此會話狀態可以被緩存。
盡管一個類要實現序列化很簡單,但實現前一定要想好以及設計好這個類是否需要序列化,是否值得付出上面三個代價。
第75條:考慮使用自定義的序列化形式
在這里書中談到了對象什么時候可使用默認的序列化形式,而又在什么時候需要自定義序列化形式。如果一個對象的物理表示法等同於它的邏輯內容,可能就適用於使用默認的序列化形式。邏輯內容意思是這個類所要想表達傳遞的含義是什么,而物理表示法則表示邏輯內容所要傳達含義的具體實現。
import java.io.Serializable; /** * 默認的自定義形式 * Created by yulinfeng on 2017/8/31/0031. */ public class Name implements Serializable{ /** * last name * @serial //表示把這些文檔信息放在有關序列化形式的特殊文檔頁中 */ private String lastName; private String firstName; private String middleName; }
這個例子的邏輯來看表示一個人的姓名,從物理實現來看利用三個實例域直接就精確地反映了邏輯內容就能直接使用默認的序列化形式。
書中另外舉了一個物理表示不同於邏輯內容的例子,一個表示字符串序列的類:
import java.io.Serializable; /** * * Created by yulinfeng on 2017/8/31/0031. */ public class StringList implements Serializable{ private int size = 0; private Entry head = null; private static class Entry implements Serializable { String data; Entry next; Entry previous; } }
可以看到物理表示是通過一個雙向鏈表來表示的,其實我也不是很懂什么叫做“物理表示等同於邏輯內容”這句話,如果有人看到這里了希望能請教請教。
當一個對象的物理表示法與它的邏輯數據內容有實質性的區別時,使用默認序列化形式會有以下4個缺點:
1) 內部實現被束縛,這個很好理解,比如上面的例子內部實現是鏈表,意味着以后的版本會一直使用這個數據結構,而不能更改,或者更改導致很大的成本。
2) 消耗空間,上面個例子中序列化時就會記錄所有的鏈接關系這是實現細節不值得記錄,所以會消耗過多的空間。
3) 消耗時間,同樣要遍歷的時候也要逐個遍歷,也會消耗時間
4) 棧溢出,如果遞歸遍歷過多可能會造成棧的溢出。
書中有修訂版,主要實現了writeObject和readObject方法以實現自定義的序列化形式。關於序列化的內容,我大概接下來會單獨寫一篇來詳細介紹。
2017-08-31
后面還有3條有關序列化的建議,暫時打算先研究一下序列化。
這是一個能給程序員加buff的公眾號