学习如何在 Java 中使用不可变和可变的对象

在编写复杂的项目时,培养良好的代码文化至关重要。使用不可变和一致的对象是最重要的对象之一。

您可以忽略这一点并将复杂对象编写为标准对象,但当项目足够大时,它将成为错误的重要来源。

在我之前的文章中,我展示了如何提高标准对象的一致性和可靠性。简单来说:

  • 设置值时添加验证
  • 用于java.util.Optional每个可为空的字段
  • 将复杂的突变放置在适当的位置 – 到负责的类本身。

但这些行动不足以制造完全可靠的物体。在本文中,我将展示如何使对象成为不可变对象,并使它们雄辩而有效。

问题

如果我们使用默认构造函数/getters/setters 创建一个简单的可序列化对象,则可以将其设为标准方式。但是让我们假设我们写了一些更复杂的东西。最有可能的是,它在无数地方使用。

例如,它用于 HashMaps 并且可能用于多线程环境。所以,这似乎不再是一个好主意——以默认方式编写它。打破HashMap没什么大不了的,线程之间的不一致状态不会让等待很长时间。

在考虑制作不可变对象时首先想到的是:

  1. 不要制作setter方法
  2. 使所有字段最终
  3. 不要将实例共享给可变对象
  4. 不允许子类覆盖方法(本文将省略)

但是如何与那种物体相处呢?当我们需要更改它时,我们需要进行复制;如果不每次都复制粘贴代码和逻辑,我们怎么能做好呢?

关于我们的示例类的几句话

假设我们有帐户。每个帐户都有一个idstatusemail。可以通过电子邮件验证帐户。当状态为CREATED时,我们不希望电子邮件被填写。但是当它是VERIFIED或时INACTIVE,必须填写电子邮件。

帐户状态

public enum AccountStatus {
    CREATED,
    VERIFIED,
    INACTIVE
}

佳能Account.java实现:

public class Account {

    private final String id;
    private final AccountStatus status;
    private final String email;

    public Account(String id, AccountStatus status, String email) {
        this.id = id;
        this.status = status;
        this.email = email;
    }

    // equals / hashCode / getters

}

假设我们已经创建了一个帐户。然后,在业务逻辑的某个地方,我们需要更改电子邮件。

var account = new Account("some-id", CREATED, null);

我们怎么能做到这一点?默认方式行不通,我们不能使用不可变类的 setter。

account.setEmail("example@example.com");// we can not do that, we have no setters

做到这一点的唯一方法是创建一个新实例并将之前的值放置到构造函数中:

var withEmail = new Account(account.getId(), CREATED, "example@example.com");

但这不是更改字段值的最佳方法,它会产生大量复制/粘贴,并且 Account 类不负责其一致性。

解决方案

建议的解决方案是提供来自 Account 类的变异方法,并在负责的类中实现复制逻辑。此外,必须为电子邮件字段添加必需的验证和 Optional 用法,这样我们就不会遇到 NPE 或一致性问题。

为了构建一个对象,我使用了“Builder”模式。它非常有名,并且有很多插件可以让您的 IDE 自动生成它。

public class Account {

    private final String id;
    private final AccountStatus status;
    private final Optional<String> email;

    public Account(Builder builder) {
        this.id = notEmpty(builder.id);
        this.status = notNull(builder.status);
        this.email = checkEmail(builder.email);
    }

    public Account verify(String email) {
        return copy()
                .status(VERIFIED)
                .email(of(email))
                .build();
    }

    public Account changeEmail(String email) {
        return copy()
                .email(of(email))
                .build();
    }

    public Account deactivate() {
        return copy()
                .status(INACTIVE)
                .build();
    }

    private Optional<String> checkEmail(Optional<String> email) {
        isTrue(
                notNull(email).map(StringUtils::isNotBlank).orElse(false) || this.status.equals(CREATED),
                "Email must be filled when status %s",
                this.status
        );
        return email;
    }


    public static final class Builder {

        private String id;
        private AccountStatus status;
        private Optional<String> email = empty();

        private Builder() {
        }

        public static Builder account() {
            return new Builder();
        }

        public Builder id(String id) {
            this.id = id;
            return this;
        }

        public Builder status(AccountStatus status) {
            this.status = status;
            return this;
        }

        public Builder email(Optional<String> email) {
            this.email = email;
            return this;
        }

        public Account build() {
            return new Account(this);
        }
    }

    // equals / hashCode / getters

}

如您所见,copy我们的类中有一个私有方法,它返回 Builder一个精确的副本。这消除了所有字段的复制粘贴,但是,此方法不能从外部访问至关重要,Account.java因为在外部使用该 Builder 时,我们将失去对状态和一致性的控制。

以新方式更改帐户

现在,让我们创建一个帐户:

var account = account()
        .id("example-id")
        .status(CREATED)
        .email((empty())
        .build();

当我们需要更改电子邮件时,我们不需要负责创建副本,我们只需从Account自身调用一个方法:

var withNewEmail = account.changeEmail("new@new.com");

在单元测试中对此进行演示:

@Test
void should_successfully_change_email() {
    // given
    var account = account()
            .id("example-id")
            .status(VERIFIED)
            .email(of("old@old.com"))
            .build();
    var newEmail = "new@new.com";

    // when
    var withNewEmail = account.changeEmail(newEmail);

    // then
    assertThat(withNewEmail.getId()).isEqualTo(account.getId());
    assertThat(withNewEmail.getStatus()).isEqualTo(account.getStatus());
    assertThat(withNewEmail.getEmail()).isEqualTo(of(newEmail));
}

为了验证帐户,我们不会创建包含状态VERIFIED和新电子邮件的副本。我们简单地调用方法verify,它不仅会为我们创建一个副本,还会检查电子邮件的有效性。

@Test
void should_successfully_verify_account() {
    // given
    var created = account()
            .id("example-id")
            .status(CREATED)
            .build();
    var email = "example@example.com";

    // when
    var verified = created.verify(email);

    // then
    assertThat(verified.getId()).isEqualTo(created.getId());
    assertThat(verified.getStatus()).isEqualTo(VERIFIED);
    assertThat(verified.getEmail().get()).isEqualTo(email);
}

结论

与不可变、一致和可靠的对象一起生活是很苛刻的,但如果以适当的方式,它会变得容易得多。

实施时,不要忘记:

  1. 使所有字段最终
  2. 不提供二传手
  3. 不共享指向可变对象的链接
  4. 不允许子类覆盖方法
  5. 提供您班级中的变异方法
  6. 实施私人的 copy负责的类中返回 a 的方法Builder,并使用它在您的类中创建新实例。
  7. 通过对值使用验证来保持字段的一致性
  8. Optional与可空字段一起使用

您可以在GitHub上找到带有更多单元测试的完整工作示例。