从返回空值到过度使用getter和setter,我们就像Java开发人员习惯做的那样,即使在没有根据的情况下也是如此。虽然它们在某些情况下可能是合适的,但它们通常是我们为使系统运行而做出的习惯或后退力量。在本文中,我们将介绍Java开发人员常见的三件事,包括新手和高级,并探讨它们如何让我们陷入困境。应该指出的是 ,无论情况如何,这些都不是应该始终遵守的严格规则。有时,使用这些模式来解决问题可能是一个很好的理由,但总的来说,它们的使用量应该比现在少得多。首先,我们将从Java中最多产但是双刃的关键词之一开始:Null。
1.返回Null
几十年来,Null一直是开发人员最好的朋友和最大的敌人,Java中的null也不例外。在高性能应用程序中,null可以是减少对象数量和发出方法没有返回值的可靠方法。与抛出异常(与创建时必须捕获整个堆栈跟踪)相反,null是一种快速且低开销的方式,用于向客户端发送无法获取值的信号。
在高性能系统的上下文之外,null可以通过为空返回值创建更繁琐的检查并NullPointerException
在解除引用空对象时导致s而在应用程序中造成严重破坏 。在大多数应用程序中,返回空值主要有三个原因:(1)表示没有找到列表的元素,(2)表示没有找到有效值,即使没有发生错误,或者(3) )表示特殊情况的返回值。
除了任何性能原因,这些案例中的每一个都有一个更好的解决方案,它不使用null并强制开发人员处理null情况。更重要的是,这些方法的客户不会在脑子里摸不着头脑,想知道该方法是否会在某些边缘情况下返回null。在每种情况下,我们都会设计一种更简洁的方法,不涉及返回空值。
没有元素
返回列表或其他集合时,通常会看到返回的空集合,以便发出无法找到该集合的元素的信号。例如,我们可以创建一个服务来管理数据库中类似于以下内容的用户(为简洁起见,省略了一些方法和类定义):
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()); } }
由于我们选择在没有用户的情况下返回空值,因此我们强制客户端在迭代用户列表之前处理这种情况。相反,我们返回一个空列表来表示没有找到用户,客户端可以完全删除空检查并正常循环用户。如果没有用户,则将隐式跳过循环而不必手动处理该情况; 本质上,循环遍历用户列表的函数,因为我们打算使用空的和填充的列表,而不必手动处理一个或另一个案例:
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); } }
一般而言,当发出没有找到任何要素的信号时,应遵守以下规则:
返回一个空集合(或列表,集合,队列等)以表示不能找到任何元素
这样做不仅减少了客户端必须执行的特殊情况处理,而且还减少了界面中的不一致(即我们有时返回列表对象而不是其他对象)。
可选价值
很多时候,当我们希望通知客户端没有可选值但没有发生错误时,会返回空值。例如,从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
简单的空检查相比,该类提供了更多处理缺失参数的机制。例如,我们可以使用提供的查询方法(状态测试方法)来检查参数是否存在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(); }
这与null-check情况几乎相同,但我们已经使参数的可选性显式化(即客户端无法在不调用的情况下访问参数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
包含值(如果找到一个)的值来处理这种情况, 如果找不到值则为空
特殊情况价值
最后一个常见用例是特殊情况,即无法获得正常值,客户端应处理与其他情况不同的角点情况。例如,假设我们有一个命令工厂,客户端会定期从中请求完成命令。如果没有准备好完成命令,客户端应等待1秒再重新询问。我们可以通过返回客户端必须处理的null命令来完成此操作,如下面的示例所示(为简洁起见,未显示某些方法):
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
can可以返回null命令,因此客户端有义务检查收到的命令是否为空,如果是,则休眠1秒钟。这会创建一组客户端必须自己处理的条件逻辑。我们可以通过创建一个null对象 (有时称为特殊情况对象)来减少这种开销。空对象将在空方案中执行的逻辑(即,休眠1秒)封装到以空案例返回的对象中。对于我们的命令示例,这意味着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对象处理null情况或允许客户端提供默认值
2.默认为功能编程
由于在Java开发工具包(JDK)8中引入了流和lambda,因此推动向函数式编程迁移,这是正确的。在lambdas和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);
这个陈述不仅比它的迭代替代方案更简洁,它还告诉我们它正在尝试做什么。如果我们命名谓词并将其传递给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)); });
虽然阅读并不复杂,但这并不是我们可以设计的最直接的实现。很明显,我们正试图强制流进入传统迭代更有利的领域。如果我们使用传统的迭代,我们可以简化套装的交叉产品并排名如下:
for (Suit suit: suits) { for (Rank rank: ranks) { System.out.println("Suit: " + suit + ", rank: " + rank); } }
这种风格虽然不那么华丽,但却更直接。我们可以很快看到我们正在尝试迭代每个套装并对每个套装进行排名和配对。随着流表达变得越大,函数式编程的繁琐变得更加尖锐。以Joshua Bloch在Effective Java,3rd Edition(pp.205,Item 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); } } }
即使是经验最丰富的流媒体拥护者也可能会对这种实施感到不满。目前尚不清楚代码的用意,并且需要花费大量的思考来揭示上述流操作正在尝试完成的内容。这并不意味着流是复杂的或者它们太冗长,但它们并不 总是 最佳选择。正如我们上面所看到的,将 removeIf
一组复杂的语句简化为一个易于理解的语句。因此,我们不应该尝试 用流甚至lambda 替换 传统迭代的每个实例。相反,在决定是使用函数式编程还是使用传统路径时,我们应该遵守以下规则:
函数式编程和传统迭代都有其优点和缺点:使用最简单,最易读的代码中的任何一个
虽然在每种可能的场景中使用最闪亮,最新的Java特性可能很诱人,但这并不总是最好的路径。有时,老派的功能最好。
3.创建不分青红皂白的吸气剂和二传手
新手程序员学习的第一件事就是将与类关联的数据封装在私有字段中,并通过公共方法公开它们。实际上,这会导致创建getter来访问类的私有数据,并设置setter来修改类的私有数据:
public class Foo { private int value; public void setValue(int value) { this.value = value; } public int getValue() { return value; } }
虽然这对于较新的程序员来说是一种很好的实践,但是这种做法不应该被重新定义为中级或高级编程。在实践中通常发生的是每个私有字段都被赋予一对getter和setter,将类的内部暴露给外部实体。这可能会导致一些严重的问题,特别是如果私有字段是可变的。这不仅是设置者的问题,而且即使只有吸气剂存在。以下面的类为例,它使用getter公开其唯一的字段:
public class Bar { private Foo foo; public Bar(Foo foo) { this.foo = foo; } public Foo getFoo() { return foo; } }
这种暴露可能看起来无害,因为我们已明智地限制了移除了一个setter方法,但它远非如此。假设另一个类访问类型的对象 Bar
并在Foo
不Bar
知道对象的情况下 更改基础值 :
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();
执行此代码会导致a NullPointerException
因为与ArrayReader
对象关联的数组 在尝试迭代数组时为null。令人不安的 NullPointerException
是,它可以在更改之后很久发生, ArrayReader
甚至可能在完全不同的上下文中(例如在代码的不同部分或甚至在不同的线程中),从而完成跟踪任务倒下问题非常困难。
精明的读者也可能注意到我们可以创建私有ArrayReader
字段,final
因为在通过构造函数设置之后我们没有公开重新分配它的方法。虽然看起来这会使ArrayReader
常量ArrayReader
变为常量,但确保我们返回的对象不能改变,但事实并非如此。相反,添加 final
到字段只能确保不重新分配字段本身(即我们无法为该字段创建设置器)。它不会阻止对象本身的状态被更改。如果我们尝试添加 final
到getter方法,这也是徒劳的,因为 final
方法上的修饰符只意味着子类不能覆盖该方法。
We can even go one step further and defensively copy the ArrayReader
object in the constructor of Reader
, ensuring that the object that was passed into the object cannot be tampered with after it has been supplied to the Reader
object. For example, the following cannot happen:
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
字段上的final
修饰符,getter上的 修饰符,以及ArrayReader
提供给构造函数的防御副本 ),我们仍然没有解决问题。问题是 我们如何暴露我们班级的基础数据,但事实上我们首先要做的。为了解决这个问题,我们必须停止公开我们类的内部数据,而是提供一种方法来更改底层数据,同时仍然遵循类不变量。下面的代码解决了这个问题,同时引入了所提供的防御副本 ArrayReader
并标记了 ArrayReader
字段final,因为没有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
通过删除在创建后修改其状态的任何方法来完全封装类:
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。
结论
我们创建的许多应用程序最终都能正常运行,但是在其中很多应用程序中,我们引入了在最糟糕的时候可能会出现问题的偷偷摸摸的问题。在某些情况下,我们出于方便,甚至出于习惯而做事,并且在我们使用这些习语的背景下,对这些习语是否实用(或安全)不太关心。在本文中,我们深入研究了这些实践中最常见的三种,例如空返回值,函数式编程的亲和性,粗心的getter和setter,以及一些实用的替代方法。虽然本文中的规则不应被视为绝对规则,但它们确实提供了对常见实践的罕见危险的一些见解,并可能有助于抵御未来的繁重错误。
另外本人从事在线教育多年,将自己的资料整合建了一个公众号,对于有兴趣一起交流学习java的开发者可以微信搜索:“程序员文明”,里面有大神会给予解答,也会有许多的资源可以供大家学习分享,欢迎大家前来一起学习进步!