Java 開發者應該改掉的 3 種不良習慣!
前言:想改掉一些壞習慣嗎?讓我們從 null、函數語言程式設計以及 getter 和 setter 著手,看看如何改善程式碼。
作為 Java 開發人員,我們會使用一些習慣用法,典型的例子,如:返回 null 值、濫用 getter 和 setter,即使在沒有必要的情況下也是如此。雖然在某些情況下,這些用法可能是適當的,但通常是習慣使然,或者是我們為了讓系統正常工作的權宜之計。在本文中,我們將討論在 Java 初學者甚至高階開發人員中都常見的三種情況,並探究它們是如何給我們帶來麻煩的。應該指出的是,文中總結的規則並不是無論何時都應該始終遵守的硬性要求。有時候,可能有一個很好的理由來使用這些模式解決問題,但是總的來說,還是應該相對的減少這些用法。首先,我們將從 Null 這個關鍵字開始討論,它也是 Java 中使用最頻繁、但也是最具兩面性的關鍵字之一。
1. Returning Null(返回 Null)
null 一直是開發者最好的朋友,也是最大的敵人,這在 Java 中也不例外。在高效能應用中,使用 null 是一種減少物件數量的可靠方法,它表明方法沒有要返回的值。與丟擲異常不同,如果要通知客戶端不能獲取任何值,使用 null 是一種快速且低開銷的方法,它不需要捕獲整個堆疊跟蹤。
在高效能系統的環境之外,null 的存在會導致建立更繁瑣的 null 返回值檢查,從而破壞應用程式,並在解引用空物件時導致 NullPointerExceptions。在大多數應用程式中,返回 null 有三個主要原因:
-
表示列表中找不到元素;
-
表示即使沒有發生錯誤,也找不到有效值;
-
表示特殊情況下的返回值。
除非有任何效能方面的原因,否則以上每一種情況都有更好的解決方案,它們不使用 null,並且強制開發人員處理出現 null 的情況。更重要的是,這些方法的客戶端不會為該方法是否會在某些邊緣情況下返回 null 而傷腦筋。在每種情況下,我們將設計一種不返回 null 值的簡潔方法。
No Elements(集合中沒有元素的情況)
在返回列表或其他集合時,通常會看到返回空集合,以表明無法找到該集合的元素。例如,我們可以建立一個服務來管理資料庫中的使用者,該服務類似於以下內容(為了簡潔起見,省略了一些方法和類定義):
public class UserService { public List<User> getUsers() { User[] usersFromDb = getUsersFromDatabase(); if (usersFromDb == null) { // No users found in database return null; } else { return Arrays.asList(usersFromDb); } } } UserServer service = new UserService(); List<Users> users = service.getUsers(); if (users != null) { for (User user: users) { System.out.println("User found: " + user.getName()); } }

因為我們選擇在沒有使用者的情況下返回 null 值,所以我們迫使客戶端在遍歷使用者列表之前先處理這種情況。如果我們返回一個空列表來表示沒有找到使用者,那麼客戶端可以完全刪除空檢查並像往常一樣遍歷使用者。如果沒有使用者,則隱式跳過迴圈,而不必手動處理這種情況;從本質上說,迴圈遍歷使用者列表的功能就像我們為空列表和填充列表所做的那樣,而不需要手動處理任何一種情況:
public class UserService { public List<User> getUsers() { User[] usersFromDb = getUsersFromDatabase(); if (usersFromDb == null) { // No users found in database return Collections.emptyList(); } else { return Arrays.asList(usersFromDb); } } } UserServer service = new UserService(); List<Users> users = service.getUsers(); for (User user: users) { System.out.println("User found: " + user.getName()); }

在上面的例子中,我們返回的是一個不可變的空列表。這是一個可接受的解決方案,只要我們記錄該列表是不可變的並且不應該被修改(這樣做可能會丟擲異常)。如果列表必須是可變的,我們可以返回一個空的可變列表,如下例所示:
public List<User> getUsers() { User[] usersFromDb = getUsersFromDatabase(); if (usersFromDb == null) { // No users found in database return new ArrayList<>();// A mutable list } else { return Arrays.asList(usersFromDb); } }

一般來說,當沒有發現任何元素的時候,應遵守以下規則:
-
返回一個空集合(或 list、set、queue 等等)表明找不到元素。
-
這樣做不僅減少了客戶端必須執行的特殊情況處理,而且還減少了介面中的不一致性(例如,我們常常返回一個 list 物件,而不是其他物件)。
Optional Value(可選值)
很多時候,我們希望在沒有發生錯誤時通知客戶端不存在可選值,此時返回 null。例如,從 web 地址獲取引數。在某些情況下,引數可能存在,但在其他情況下,它可能不存在。缺少此引數並不一定表示錯誤,而是表示使用者不需要提供該引數時包含的功能(例如排序)。如果沒有引數,則返回 null;如果提供了引數,則返回引數值(為了簡潔起見,刪除了一些方法):
public class UserListUrl { private final String url; public UserListUrl(String url) { this.url = url; } public String getSortingValue() { if (urlContainsSortParameter(url)) { return extractSortParameter(url); } else { return null; } } } UserService userService = new UserService(); UserListUrl url = new UserListUrl("http://localhost/api/v2/users"); String sortingParam = url.getSortingValue(); if (sortingParam != null) { UserSorter sorter = UserSorter.fromParameter(sortingParam); return userService.getUsers(sorter); } else { return userService.getUsers(); }

當沒有提供引數時,返回 null,客戶端必須處理這種情況,但是在 getSortingValue 方法的簽名中,沒有任何地方宣告排序值是可選的。如果方法的引數是可選的,並且在沒有引數時,可能返回 null,要知道這個事實,我們必須閱讀與該方法相關的文件(如果提供了文件)。
相反,我們可以使可選性顯式地返回一個 Optional 物件。正如我們將看到的,當沒有引數存在時,客戶端仍然需要處理這種情況,但是現在這個需求已經明確了。更重要的是,Optional 類提供了比簡單的 null 檢查更多的機制來處理丟失的引數。例如,我們可以使用 Optional 類提供的查詢方法(一種狀態測試方法)簡單地檢查引數是否存在:
public class UserListUrl { private final String url; public UserListUrl(String url) { this.url = url; } public Optional<String> getSortingValue() { if (urlContainsSortParameter(url)) { return Optional.of(extractSortParameter(url)); } else { return Optional.empty(); } } } UserService userService = new UserService(); UserListUrl url = new UserListUrl("http://localhost/api/v2/users"); Optional<String> sortingParam = url.getSortingValue(); if (sortingParam.isPresent()) { UserSorter sorter = UserSorter.fromParameter(sortingParam.get()); return userService.getUsers(sorter); } else { return userService.getUsers(); }

這與「空檢查」的情況幾乎相同,但是我們已經明確了引數的可選性(即客戶機在不呼叫 get() 的情況下無法訪問引數,如果可選引數為空,則會丟擲NoSuchElementException)。如果我們不希望根據 web 地址中的可選引數返回使用者列表,而是以某種方式使用該引數,我們可以使用ifPresentOrElse 方法來這樣做:
sortingParam.ifPresentOrElse( param -> System.out.println("Parameter is :" + param), () -> System.out.println("No parameter supplied.") );

這極大降低了「空檢查」的影響。如果我們希望在沒有提供引數時忽略引數,可以使用 ifPresent 方法:
sortingParam.ifPresent(param -> System.out.println("Parameter is :" + param));

在這兩種情況下,使用 Optional 物件要優於返回 null 以及顯式地強制客戶端處理返回值可能不存在的情況,為處理這個可選值提供了更多的途徑。考慮到這一點,我們可以制定以下規則:
如果返回值是可選的,則通過返回一個 Optional 來確保客戶端處理這種情況,該可選的值在找到值時包含一個值,在找不到值時為空
Special-Case Value(特殊情況值)
最後一個常見用例是特殊用例,在這種情況下無法獲得正常值,客戶端應該處理與其他用例不同的極端情況。例如,假設我們有一個命令工廠,客戶端定期從命令工廠請求命令。如果沒有命令可以獲得,客戶端應該等待 1 秒鐘再請求。我們可以通過返回一個空命令來實現這一點,客戶端必須處理這個空命令,如下面的例子所示(為了簡潔起見,沒有顯示一些方法):
public interface Command { public void execute(); } public class ReadCommand implements Command { @Override public void execute() { System.out.println("Read"); } } public class WriteCommand implements Command { @Override public void execute() { System.out.println("Write"); } } public class CommandFactory { public Command getCommand() { if (shouldRead()) { return new ReadCommand(); } else if (shouldWrite()) { return new WriteCommand(); } else { return null; } } } CommandFactory factory = new CommandFactory(); while (true) { Command command = factory.getCommand(); if (command != null) { command.execute(); } else { Thread.sleep(1000); } }

由於 CommandFactory 可以返回空命令,客戶端有義務檢查接收到的命令是否為空,如果為空,則休眠1秒。這將建立一組必須由客戶端自行處理的條件邏輯。我們可以通過建立一個「空物件」(有時稱為特殊情況物件)來減少這種開銷。「空物件」將在 null 場景中執行的邏輯(休眠 1 秒)封裝到 null 情況下返回的物件中。對於我們的命令示例,這意味著建立一個在執行時休眠的SleepCommand:
public class SleepCommand implements Command { @Override public void execute() { Thread.sleep(1000); } } public class CommandFactory { public Command getCommand() { if (shouldRead()) { return new ReadCommand(); } else if (shouldWrite()) { return new WriteCommand(); } else { return new SleepCommand(); } } } CommandFactory factory = new CommandFactory(); while (true) { Command command = factory.getCommand(); command.execute(); }

與返回空集合的情況一樣,建立「空物件」允許客戶端隱式處理特殊情況,就像它們是正常情況一樣。但這並不總是可行的;在某些情況下,處理特殊情況的決定必須由客戶做出。這可以通過允許客戶端提供預設值來處理,就像使用 Optional 類一樣。在 Optional 的情況下,客戶端可以使用 orElse 方法獲取包含的值或預設值:
UserListUrl url = new UserListUrl("http://localhost/api/v2/users"); Optional<String> sortingParam = url.getSortingValue(); String sort = sortingParam.orElse("ASC");

如果有一個提供的排序引數(例如,如果 Optional 包含一個值),這個值將被返回。如果不存在值,預設情況下將返回「ASC」。Optional 類還允許客戶端在需要時建立預設值,以防預設建立過程開銷較大(即只在需要時建立預設值):
UserListUrl url = new UserListUrl("http://localhost/api/v2/users"); Optional<String> sortingParam = url.getSortingValue(); String sort = sortingParam.orElseGet(() -> { // Expensive computation });

結合「空物件」和預設值的用法,我們可以設計以下規則:
如果可能,使用「空物件」處理使用 null 關鍵字的情況,或者允許客戶端提供預設值
2. Defaulting to Functional Programming(預設使用函數語言程式設計)
自從在 JDK 8 中引入了 stream 和 lambda 表示式之後,就出現了向函數語言程式設計遷移的趨勢,這理當如此。在 lambda 表示式和 stream 出現之前,執行函式式任務是非常麻煩的,並且會導致程式碼可讀性的嚴重下降。例如,如下程式碼用傳統方式過濾一個集合:
public class Foo { private final int value; public Foo(int value) { this.value = value; } public int getValue() { return value; } } Iterator<Foo> iterator = foos.iterator(); while(iterator.hasNext()) { if (iterator.next().getValue() > 10) { iterator.remove(); } }

雖然這段程式碼很緊湊,但它並沒有以一種明顯的方式告訴我們,當滿足某個條件時,我們將嘗試刪除集合的元素。相反,它告訴我們,當集合中有更多的元素時將遍歷集合,並將刪除值大於 10 的元素(我們可以假設正在進行篩選,但是刪除元素的部分被程式碼的冗長所掩蓋)。我們可以使用函數語言程式設計將這個邏輯壓縮為一條語句:
foos.removeIf(foo -> foo.getValue() > 10);

image.gif
這個語句不僅比迭代方式更簡潔,而且準確的告訴我們它的行為。如果我們為 predicate 命名並將其傳遞給 removeIf 方法,甚至可以使其更具可讀性:
Predicate<Foo> valueGreaterThan10 = foo -> foo.getValue() > 10; foos.removeIf(valueGreaterThan10);

這段程式碼的最後一行讀起來像一個英語句子,準確地告訴我們語句在做什麼。對於看起來如此緊湊和極具可讀性的程式碼,在任何需要迭代的情況下嘗試使用函數語言程式設計是很讓人嚮往的,但這是一種天真的想法。並不是每種情況都適合函數語言程式設計。例如,如果我們嘗試在一副牌中列印一組花色和牌面大小的排列組合(花色和牌面大小的每一種組合),我們可以建立以下內容(參見《Effective Java, 3rd Edition》獲得這個示例的詳細內容):
public static enum Suit { CLUB, DIAMOND, HEART, SPADE; } public static enum Rank { ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING; } Collection<Suit> suits = EnumSet.allOf(Suit.class); Collection<Rank> ranks = EnumSet.allOf(Rank.class); suits.stream() .forEach(suit -> { ranks.stream().forEach(rank -> System.out.println("Suit: " + suit + ", rank: " + rank)); });

雖然讀起來並不複雜,但這種實現並不是最簡單的。很明顯,我們正試圖強行使用 stream,而此時使用傳統迭代明顯更有利。如果我們使用傳統的迭代方法,我們可以將 花色和等級的排列組合簡化為:
for (Suit suit: suits) { for (Rank rank: ranks) { System.out.println("Suit: " + suit + ", rank: " + rank); } }

這種風格雖然不那麼浮華,但卻直截了當得多。我們可以很快地理解,我們試圖遍歷每個花色和等級,並將每個等級與每個花色配對。stream 表示式越大,函數語言程式設計的乏味性就越明顯。以Joshua Bloch 在《Effective Java, 3rd Edition》第 205 頁,第 45 項中建立的以下程式碼片段為例,在使用者提供的路徑上查詢字典中包含的指定長度內的所有片語:
public class Anagrams { public static void main(String[] args) throws IOException { Path dictionary = Paths.get(args[0]); int minGroupSize = Integer.parseInt(args[1]); try (Stream<String> words = Files.lines(dictionary)) { words.collect( groupingBy(word -> word.chars().sorted() .collect(StringBuilder::new, (sb, c) -> sb.append((char) c), StringBuilder::append).toString())) .values().stream() .filter(group -> group.size() >= minGroupSize) .map(group -> group.size() + ": " + group) .forEach(System.out::println); } } }

即使是經驗最豐富的 stream 使用者也可能會對這個實現感到迷茫。短時間內很難理解程式碼的意圖,需要大量的思考才能發現上面的 stream 操作試圖實現什麼。這並不意味著 stream 一定很複雜或太冗長,只是因為它們不總是最好的選擇。正如我們在上面看到的,使用 removeIf 可以將一組複雜的語句簡化為一個易於理解的語句。因此,我們不應該試圖用 stream 甚至 lambda 表示式替換傳統迭代的每個使用場景。相反,在決定是使用函數語言程式設計還是使用傳統方式時,我們應該遵循以下規則:
函數語言程式設計和傳統的迭代都有其優點和缺點:應該以簡易性和可讀性為準來選擇
儘管在每個可能的場景中使用 Java 最炫、最新的特性可能很讓人嚮往,但這並不總是最好的方法。有時候,老式的功能效果反而最好。
3. Creating Indiscriminate Getters and Setters(濫用 getter 和 setter)
新手程式設計師學到的第一件事是將與類相關的資料封裝在私有欄位中,並通過公共方法暴露它們。在實際使用時,通過建立 getter 來訪問類的私有資料,建立 setter 來修改類的私有資料:
public class Foo { private int value; public void setValue(int value) { this.value = value; } public int getValue() { return value; } }

雖然這對於新程式設計師來說是一個很好的學習實踐,但這種做法不能未經思索就應用在中級或高階程式設計。在實際中通常發生的情況是,每個私有欄位都有一對 getter 和 setter 將類的內部內容暴露給外部實體。這會導致一些嚴重的問題,特別是在私有欄位是可變的情況下。這不僅是 setter 的問題,甚至在只有 getter 時也是如此。以下面的類為例,該類使用 getter 公開其唯一的欄位:
public class Bar { private Foo foo; public Bar(Foo foo) { this.foo = foo; } public Foo getFoo() { return foo; } }

由於我們刪除了 setter 方法,這麼做可能看起來明智且無害,但並非如此。假設另一個類訪問 Bar 型別的物件,並在 Bar 物件不知道的情況下更改 Foo 的底層值:
Foo foo = new Foo(); Bar bar = new Bar(foo); // Another place in the code bar.getFoo().setValue(-1);

在本例中,我們更改了 Foo 物件的底層值,而沒有通知 Bar 物件。如果我們提供的 Foo 物件的值破壞了 Bar 物件的一個不變數,這可能會導致一些嚴重的問題。舉個例子,如果我們有一個不變數,它表示 Foo 的值不可能是負的,那麼上面的程式碼片段將在不通知 Bar 物件的情況下靜默修改這個不變數。當 Bar 物件使用它的 Foo 物件值時,事情可能會迅速向不好的方向發展,尤其是如果 Bar 物件假設這是不變的,因為它沒有暴露 setter 直接重新分配它所儲存的 Foo 物件。如果資料被嚴重更改,這甚至會導致系統失敗,如下面例子所示,陣列的底層資料在無意中暴露:
public class ArrayReader { private String[] array; public String[] getArray() { return array; } public void setArray(String[] array) { this.array = array; } public void read() { for (String e: array) { System.out.println(e); } } } public class Reader { private ArrayReader arrayReader; public Reader(ArrayReader arrayReader) { this.arrayReader = arrayReader; } public ArrayReader getArrayReader() { return arrayReader; } public void read() { arrayReader.read(); } } ArrayReader arrayReader = new ArrayReader(); arrayReader.setArray(new String[] {"hello", "world"}); Reader reader = new Reader(arrayReader); reader.getArrayReader().setArray(null); reader.read();

執行此程式碼將導致 NullPointerException,因為當 ArrayReader 的例項物件試圖遍歷陣列時,與該物件關聯的陣列為 null。這個 NullPointerException 的令人不安之處在於,它可能在對ArrayReader 進行更改很久之後才發生,甚至可能發生在完全不同的場景中(例如在程式碼的不同部分中,甚至在不同的執行緒中),這使得除錯變得非常困難。
讀者如果仔細考慮,可能還會注意到,我們可以將私有的 ArrayReader 欄位設定為 final,因為我們在通過建構函式賦值之後,沒有對它重新賦值的方法。雖然這看起來會使 ArrayReader 成為常量,確保我們返回的 ArrayReader 物件不會被更改,但事實並非如此。如果將 final 新增到欄位中只能確保欄位本身沒有重新賦值(即,不能為該欄位建立 setter)而不會阻止物件本身的狀態被更改。或者我們試圖將 final 新增到 getter 方法中,這也是徒勞的,因為方法上的 final 修飾符只意味著該方法不能被子類重寫。
我們甚至可以更進一步考慮,在 Reader 的建構函式中防禦性地複製 ArrayReader 物件,確保在將物件提供給 Reader 物件之後,傳入該物件的物件不會被篡改。例如,應避免以下情況發生:
ArrayReader arrayReader = new ArrayReader(); arrayReader.setArray(new String[] {"hello", "world"}); Reader reader = new Reader(arrayReader); arrayReader.setArray(null);// Change arrayReader after supplying it to Reader reader.read();// NullPointerException thrown

即使有了這三個更改(欄位上增加 final 修飾符、getter 上增加 final 修飾符以及提供給建構函式的 ArrayReader 的防禦性副本),我們仍然沒有解決問題。問題不在於我們暴露底層資料的方式,而是因為我們是在一開始就是錯的。要解決這個問題,我們必須停止公開類的內部資料,而是提供一種方法來更改底層資料,同時仍然遵循類不變數。下面的程式碼解決了這個問題,同時引入了提供的 ArrayReader 的防禦性副本,並將 ArrayReader 欄位標記為 final,因為沒有 setter,所以應該是這樣:
譯註:原文的如下程式碼有一處錯誤,Reader 類中的 setArrayReaderArray 方法返回值型別應為 void,該方法是為了取代 setter,不應產生返回值。
public class ArrayReader { public static ArrayReader copy(ArrayReader other) { ArrayReader copy = new ArrayReader(); String[] originalArray = other.getArray(); copy.setArray(Arrays.copyOf(originalArray, originalArray.length)); return copy; } // ... Existing class ... } public class Reader { private final ArrayReader arrayReader; public Reader(ArrayReader arrayReader) { this.arrayReader = ArrayReader.copy(arrayReader); } public ArrayReader setArrayReaderArray(String[] array) { arrayReader.setArray(Objects.requireNonNull(array)); } public void read() { arrayReader.read(); } } ArrayReader arrayReader = new ArrayReader(); arrayReader.setArray(new String[] {"hello", "world"}); Reader reader = new Reader(arrayReader); reader.read(); Reader flawedReader = new Reader(arrayReader); flawedReader.setArrayReaderArray(null);// NullPointerException thrown

如果我們檢視這個有缺陷的讀取器,它仍然會丟擲 NullPointerException,但在不變數(讀取時使用非空陣列)被破壞時,會立即丟擲該異常,而不是在稍後的某個時間。這確保了不變數的快速失效,這使得除錯和找到問題的根源變得容易得多。
我們可以進一步利用這一原則。如果不迫切需要更改類的狀態,那麼讓類的欄位完全不可訪問是一個好主意。例如,我們可以刪除所有能夠修改 Reader 類例項物件狀態的方法,實現 Reader 類的完全封裝:
public class Reader { private final ArrayReader arrayReader; public Reader(ArrayReader arrayReader) { this.arrayReader = ArrayReader.copy(arrayReader); } public void read() { arrayReader.read(); } } ArrayReader arrayReader = new ArrayReader(); arrayReader.setArray(new String[] {"hello", "world"}); Reader reader = new Reader(arrayReader); // No changes can be made to the Reader after instantiation reader.read();

從邏輯上總結這個概念,如果可能的話,讓類不可變是一個好主意。因此,在例項化物件之後,物件的狀態永遠不會改變。例如,我們可以建立一個不可變的 Car 物件如下:
public class Car { private final String make; private final String model; public Car(String make, String model) { this.make = make; this.model = model; } public String getMake() { return make; } public String getModel() { return model; } }

需要注意的是,如果類的欄位不是基本資料型別,客戶端可以如前所述那樣修改底層物件。因此,不可變物件應該返回這些物件的防禦性副本,不允許客戶端修改不可變物件的內部狀態。但是請注意,防禦性複製會降低效能,因為每次呼叫 getter 時都會建立一個新物件。對於這個缺陷,不應該過早地進行優化(忽視不可變性,以保證可能的效能提高),但是應該注意到這一點。下面的程式碼片段提供了一個方法返回值的防禦性複製示例:
public class Transmission { private String type; public static Transmission copy(Transmission other) { Transmission copy = new Transmission(); copy.setType(other.getType); return copy; } public String setType(String type) { this.type = type; } public String getType() { return type; } } public class Car { private final String make; private final String model; private final Transmission transmission; public Car(String make, String model, Transmission transmission) { this.make = make; this.model = model; this.transmission = Transmission.copy(transmission); } public String getMake() { return make; } public String getModel() { return model; } public Transmission getTransmission() { return Transmission.copy(transmission); } }

這給我們提示了以下原則:
使類不可變,除非迫切需要更改類的狀態。不可變類的所有欄位都應該標記為 private 和 final,以確保不會對欄位執行重新賦值,也不會對欄位的內部狀態提供間接訪問
不變性還帶來了一些非常重要的優點,例如類能夠在多執行緒上下文中輕鬆使用(即兩個執行緒可以共享物件,而不用擔心一個執行緒會在另一個執行緒訪問該狀態時更改該物件的狀態)。總的來說,在很多實際情況下我們可以建立不可變的類,要比我們意識到的要多很多,只是我們習慣了添加了 getter 或 setter。
Conclusion(結論)
我們建立的許多應用程式最終都能正常工作,但是在大量應用程式中,我們無意引入的一些問題可能只會在最極端的情況下出現。在某些情況下,我們做事情是出於方便,甚至是出於習慣,而很少注意這些習慣在我們使用的場景中是否實用(或安全)。在本文中,我們深入研究了在實際應用中最常見的三種問題,如:空返回值、函數語言程式設計的魅力、草率的 getter 和 setter,以及一些實用的替代方法。雖然本文中的規則不是絕對的,但是它們確實為一些在實際應用中遇到的罕見問題提供了見解,並可能有助於在今後避開一些費勁的問題。