51 通过生成器模式编写不可变类
当一个类(不可变或可变)有太多字段时,它需要一个具有许多参数的构造器。当其中一些字段是必需的,而其他字段是可选的时,这个类将需要几个构造器来覆盖所有可能的组合。这对于开发人员和类的用户来说都是很麻烦的。这就是构建器模式的用武之地。
根据四人帮(GoF),构建器模式将复杂对象的构造与其表示分离,以便相同的构造过程可以创建不同的表示。
生成器模式可以作为一个单独的类或内部的static类来实现。让我们关注第二个案例。User类有三个必填字段(nickname、password、created)和三个可选字段(email、firstname、lastname)。
现在,依赖于构建器模式的不可变的User类将显示如下:
public final class User { private final String nickname; private final String password; private final String firstname; private final String lastname; private final String email; private final Date created; private User(UserBuilder builder) { this.nickname = builder.nickname; this.password = builder.password; this.created = builder.created; this.firstname = builder.firstname; this.lastname = builder.lastname; this.email = builder.email; } public static UserBuilder getBuilder( String nickname, String password) { return new User.UserBuilder(nickname, password); } public static final class UserBuilder { private final String nickname; private final String password; private final Date created; private String email; private String firstname; private String lastname; public UserBuilder(String nickname, String password) { this.nickname = nickname; this.password = password; this.created = new Date(); } public UserBuilder firstName(String firstname) { this.firstname = firstname; return this; } public UserBuilder lastName(String lastname) { this.lastname = lastname; return this; } public UserBuilder email(String email) { this.email = email; return this; } public User build() { return new User(this); } } public String getNickname() { return nickname; } public String getPassword() { return password; } public String getFirstname() { return firstname; } public String getLastname() { return lastname; } public String getEmail() { return email; } public Date getCreated() { return new Date(created.getTime()); } }
以下是一些用法示例:
import static modern.challenge.User.getBuilder; ... // user with nickname and password User user1 = getBuilder("marin21", "hjju9887h").build(); // user with nickname, password and email User user2 = getBuilder("ionk", "44fef22") .email("ion@gmail.com") .build(); // user with nickname, password, email, firstname and lastname User user3 = getBuilder("monika", "klooi0988") .email("monika@gmail.com") .firstName("Monika") .lastName("Ghuenter") .build();
52 避免不可变对象中的坏数据
坏数据是任何对不可变对象有负面影响的数据(例如,损坏的数据)。最有可能的是,这些数据来自用户输入或不受我们直接控制的外部数据源。在这种情况下,坏数据可能会击中不可变的对象,最糟糕的是没有修复它的方法。不可变的对象在创建后不能更改;因此,只要对象存在,坏数据就会快乐地存在。
这个问题的解决方案是根据一组全面的约束来验证输入到不可变对象中的所有数据。
执行验证有不同的方法,从自定义验证到内置解决方案。验证可以在不可变对象类的外部或内部执行,具体取决于应用设计。例如,如果不可变对象是通过构建器模式构建的,那么可以在 Builder 类中执行验证。
JSR380 是用于 bean 验证的 Java API(JavaSE/EE)规范,可用于通过注解进行验证。Hibernate 验证器是验证 API 的参考实现,它可以很容易地作为 Maven 依赖项在pom.xml文件中提供(请查看本书附带的源代码)。
此外,我们依赖于专用注解来提供所需的约束(例如,@NotNull、@Min、@Max、@Size和@Email)。在以下示例中,将约束添加到生成器类中,如下所示:
... public static final class UserBuilder { @NotNull(message = "cannot be null") @Size(min = 3, max = 20, message = "must be between 3 and 20 characters") private final String nickname; @NotNull(message = "cannot be null") @Size(min = 6, max = 50, message = "must be between 6 and 50 characters") private final String password; @Size(min = 3, max = 20, message = "must be between 3 and 20 characters") private String firstname; @Size(min = 3, max = 20, message = "must be between 3 and 20 characters") private String lastname; @Email(message = "must be valid") private String email; private final Date created; public UserBuilder(String nickname, String password) { this.nickname = nickname; this.password = password; this.created = new Date(); } ...
最后,验证过程通过ValidatorAPI 从代码中触发(这仅在 JavaSE 中需要)。如果进入生成器类的数据无效,则不创建不可变对象(不要调用build()方法):
User user; Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); User.UserBuilder userBuilder = new User.UserBuilder("monika", "klooi0988") .email("monika@gmail.com") .firstName("Monika").lastName("Gunther"); final Set<ConstraintViolation<User.UserBuilder>> violations = validator.validate(userBuilder); if (violations.isEmpty()) { user = userBuilder.build(); System.out.println("User successfully created on: " + user.getCreated()); } else { printConstraintViolations("UserBuilder Violations: ", violations); }
这样,坏数据就不能触及不可变的对象。如果没有生成器类,则可以直接在不可变对象的字段级别添加约束。前面的解决方案只是在控制台上显示潜在的冲突,但是根据情况,该解决方案可能执行不同的操作(例如,抛出特定的异常)。
53 克隆对象
克隆对象不是一项日常任务,但正确地克隆对象很重要。克隆对象主要是指创建对象的副本。拷贝主要有两种类型:浅拷贝(尽可能少拷贝)和深拷贝(复制所有内容)。
假设下面的类:
public class Point { private double x; private double y; public Point() {} public Point(double x, double y) { this.x = x; this.y = y; } // getters and setters }
所以,我们在一个类中映射了一个类型点(x, y)。现在,让我们进行一些克隆。
手动克隆
快速方法包括添加一个手动将当前Point复制到新Point的方法(这是一个浅复制):
public Point clonePoint() { Point point = new Point(); point.setX(this.x); point.setY(this.y); return point; }
这里的代码非常简单。只需创建一个新的Point实例,并用当前Point的字段填充其字段。返回的Point是当前Point的浅拷贝(因为Point不依赖其他对象,所以深拷贝是完全相同的):
Point point = new Point(...); Point clone = point.clonePoint();
通过clone()克隆
Object类包含一个名为clone()的方法。此方法对于创建浅拷贝非常有用(也可以用于深拷贝)。为了使用它,类应该遵循给定的步骤:
实现Cloneable接口(如果该接口没有实现,则抛出CloneNotSupportedException。
覆盖clone()方法(Object.clone()为protected)。
调用super.clone()。
Cloneable接口不包含任何方法。这只是 JVM 可以克隆这个对象的一个信号。一旦实现了这个接口,代码就需要覆盖Object.clone()方法。这是需要的,因为Object.clone()是protected,为了通过super调用它,代码需要覆盖这个方法。如果将clone()添加到子类中,这可能是一个严重的缺点,因为所有超类都应该定义一个clone()方法,以避免super.clone()链调用失败。
此外,Object.clone()不依赖构造器调用,因此开发人员无法控制对象构造:
public class Point implements Cloneable { private double x; private double y; public Point() {} public Point(double x, double y) { this.x = x; this.y = y; } @Override public Point clone() throws CloneNotSupportedException { return (Point) super.clone(); } // getters and setters }
创建克隆的步骤如下:
Point point = new Point(...); Point clone = point.clone();
通过构造器克隆
此克隆技术要求您使用构造器来丰富类,该构造器接受表示将用于创建克隆的类实例的单个参数。
让我们看看代码:
public class Point { private double x; private double y; public Point() {} public Point(double x, double y) { this.x = x; this.y = y; } public Point(Point another) { this.x = another.x; this.y = another.y; } // getters and setters }
创建克隆的步骤如下:
Point point = new Point(...); Point clone = new Point(point);
通过克隆库进行克隆
当一个对象依赖于另一个对象时,需要一个深度副本。执行深度复制意味着复制对象,包括其依赖链。例如,假设Point有一个Radius类型的字段:
public class Radius { private int start; private int end; // getters and setters } public class Point { private double x; private double y; private Radius radius; public Point(double x, double y, Radius radius) { this.x = x; this.y = y; this.radius = radius; } // getters and setters }
执行Point的浅拷贝将创建x和y的拷贝,但不会创建radius对象的拷贝。这意味着影响radius对象的修改也将反映在克隆中。是时候进行深度复制了。
一个麻烦的解决方案将涉及到调整以前提出的浅拷贝技术以支持深拷贝。幸运的是,有一些现成的解决方案可以应用,其中之一就是克隆库:
import com.rits.cloning.Cloner; ... Point point = new Point(...); Cloner cloner = new Cloner(); Point clone = cloner.deepClone(point);
代码是不言自明的。请注意,克隆库还附带了其他一些好处,如下面的屏幕截图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EXjwIwfB-1657077359612)(img/c75891ec-3902-49da-8290-62d5f7b09c70.png)]
通过序列化克隆
这种技术需要可序列化的对象(实现java.io.Serializable。基本上,对象在新对象中被序列化(writeObject())和反序列化(readObject())。可以实现这一点的助手方法如下所示:
private static <T> T cloneThroughSerialization(T t) { try { ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); oos.writeObject(t); ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()); ObjectInputStream ois = new ObjectInputStream(bais); return (T) ois.readObject(); } catch (IOException | ClassNotFoundException ex) { // log exception return t; } }
因此,对象在ObjectOutputStream中序列化,在ObjectInputStream中反序列化。通过此方法克隆对象的步骤如下:
Point point = new Point(...); Point clone = cloneThroughSerialization(point);
ApacheCommonsLang 通过SerializationUtils提供了一个基于序列化的内置解决方案。在它的方法中,这个类提供了一个名为clone()的方法,可以如下使用:
Point point = new Point(...); Point clone = SerializationUtils.clone(point);
通过 JSON 克隆
几乎所有 Java 中的 JSON 库都可以序列化任何普通的旧 Java 对象(POJO),而不需要任何额外的配置/映射。在项目中有一个 JSON 库(很多项目都有)可以避免我们添加额外的库来提供深度克隆。主要来说,该解决方案可以利用现有的 JSON 库来获得相同的效果。
以下是使用Gson库的示例:
private static <T> T cloneThroughJson(T t) { Gson gson = new Gson(); String json = gson.toJson(t); return (T) gson.fromJson(json, t.getClass()); } Point point = new Point(...); Point clone = cloneThroughJson(point);
除此之外,您还可以选择编写专用于克隆对象的库。
54 覆盖toString()
toString()方法在java.lang.Object中定义,JDK 附带了它的默认实现。此默认实现自动用于print()、println()、printf()、开发期间调试、日志记录、异常中的信息消息等的所有对象。
不幸的是,默认实现返回的对象的字符串表示形式信息量不大。例如,让我们考虑下面的User类:
public class User { private final String nickname; private final String password; private final String firstname; private final String lastname; private final String email; private final Date created; // constructor and getters skipped for brevity }
现在,让我们创建这个类的一个实例,并在控制台上打印它:
User user = new User("sparg21", "kkd454ffc", "Leopold", "Mark", "markl@yahoo.com"); System.out.println(user);
这个println()方法的输出如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-39Eby1Uc-1657077359613)(img/d41b9942-344f-444f-b3a4-dbd2513c92cf.png)]
在前面的屏幕截图中,避免输出的解决方案包括覆盖toString()方法。例如,让我们覆盖它以公开用户详细信息,如下所示:
@Override public String toString() { return "User{" + "nickname=" + nickname + ", password=" + password + ", firstname=" + firstname + ", lastname=" + lastname + ", email=" + email + ", created=" + created + '}'; }
这次,println()将显示以下输出:
User { nickname = sparg21, password = kkd454ffc, firstname = Leopold, lastname = Mark, email = markl@yahoo.com, created = Fri Feb 22 10: 49: 32 EET 2019 }
这比以前的输出信息更丰富。
但是,请记住,toString()是为不同的目的自动调用的。例如,日志记录可以如下所示:
logger.log(Level.INFO, "This user rocks: {0}", user);
在这里,用户密码将命中日志,这可能表示有问题。在应用中公开日志敏感数据(如密码、帐户和秘密 IP)绝对是一种不好的做法。
因此,请特别注意仔细选择进入toString()的信息,因为这些信息最终可能会被恶意利用。在我们的例子中,密码不应该是toString()的一部分:
@Override public String toString() { return "User{" + "nickname=" + nickname + ", firstname=" + firstname + ", lastname=" + lastname + ", email=" + email + ", created=" + created + '}'; }
通常,toString()是通过 IDE 生成的方法。因此,在 IDE 为您生成代码之前,请注意您选择了哪些字段。
55 switch表达式
在简要概述 JDK12 中引入的switch表达式之前,让我们先来看一个典型的老式方法示例:
private static Player createPlayer(PlayerTypes playerType) { switch (playerType) { case TENNIS: return new TennisPlayer(); case FOOTBALL: return new FootballPlayer(); case SNOOKER: return new SnookerPlayer(); case UNKNOWN: throw new UnknownPlayerException("Player type is unknown"); default: throw new IllegalArgumentException( "Invalid player type: " + playerType); } }
如果我们忘记了default,那么代码将无法编译。
显然,前面的例子是可以接受的。在最坏的情况下,我们可以添加一个伪变量(例如,player),一些杂乱的break语句,如果default丢失,就不会收到投诉。所以,下面的代码是一个老派,非常难看的switch:
private static Player createPlayerSwitch(PlayerTypes playerType) { Player player = null; switch (playerType) { case TENNIS: player = new TennisPlayer(); break; case FOOTBALL: player = new FootballPlayer(); break; case SNOOKER: player = new SnookerPlayer(); break; case UNKNOWN: throw new UnknownPlayerException( "Player type is unknown"); default: throw new IllegalArgumentException( "Invalid player type: " + playerType); } return player; }
如果我们忘记了default,那么编译器方面就不会有任何抱怨了。在这种情况下,丢失的default案例可能导致null播放器。
然而,自从 JDK12 以来,我们已经能够依赖于switch表达式。在 JDK12 之前,switch是一个语句,一个用来控制流的构造(例如,if语句),而不表示结果。另一方面,表达式的求值结果。因此,switch表达可产生结果。
前面的switch表达式可以用 JDK12 的样式写成如下:
private static Player createPlayer(PlayerTypes playerType) { return switch (playerType) { case TENNIS -> new TennisPlayer(); case FOOTBALL -> new FootballPlayer(); case SNOOKER -> new SnookerPlayer(); case UNKNOWN -> throw new UnknownPlayerException( "Player type is unknown"); // default is not mandatory default -> throw new IllegalArgumentException( "Invalid player type: " + playerType); }; }
这次,default不是强制性的。我们可以跳过它。
JDK12switch足够聪明,可以在switch没有覆盖所有可能的输入值时发出信号。这在 Java enum值的情况下非常有用。JDK12switch可以检测所有enum值是否都被覆盖,如果没有被覆盖,则不会强制一个无用的default。例如,如果我们删除default并向PlayerTypes enum添加一个新条目(例如GOLF),那么编译器将通过一条消息向它发送信号,如下面的屏幕截图(这是来自 NetBeans 的):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CdkcxPb3-1657077359613)(img/fd6ef71d-5ca7-4757-a37c-3e111bb83418.png)]
注意,在标签和执行之间,我们将冒号替换为箭头(Lambda 样式的语法)。此箭头的主要作用是防止跳转,这意味着只执行其右侧的代码块。不需要使用break。
不要断定箭头将switch语句转换为switch表达式。switch表达可用于结肠和break,如下所示:
private static Player createPlayer(PlayerTypes playerType) { return switch (playerType) { case TENNIS: break new TennisPlayer(); case FOOTBALL: break new FootballPlayer(); case SNOOKER: break new SnookerPlayer(); case UNKNOWN: throw new UnknownPlayerException( "Player type is unknown"); // default is not mandatory default: throw new IllegalArgumentException( "Invalid player type: " + playerType); }; }
我们的示例在enum上发布了switch,但是 JDK12switch也可以在int、Integer、short、Short、byte、Byte、char、Character和String上使用。
注意,JDK12 带来了switch表达式作为预览特性。这意味着它很容易在接下来的几个版本中发生更改,需要在编译和运行时通过--enable-preview命令行选项来解锁它。