Перейти к основному содержимому

Введение в AutoValue

· 12 мин. чтения

1. Обзор

AutoValue — это генератор исходного кода для Java, а точнее — библиотека для создания исходного кода для объектов-значений или объектов с типизированными значениями .

Чтобы сгенерировать объект типа значения, все, что вам нужно сделать, это аннотировать абстрактный класс аннотацией @AutoValue и скомпилировать ваш класс. Создается объект значения с методами доступа, параметризованным конструктором, правильно переопределенными методами toString(), equals(Object) и hashCode() .

Следующий фрагмент кода является быстрым примером абстрактного класса, который при компиляции приведет к объекту значения с именем AutoValue_Person .

@AutoValue
abstract class Person {
static Person create(String name, int age) {
return new AutoValue_Person(name, age);
}

abstract String name();
abstract int age();
}

Давайте продолжим и узнаем больше об объектах-значениях, зачем они нам нужны и как AutoValue может помочь значительно сократить время создания и рефакторинга кода.

2. Настройка Мавена

Чтобы использовать AutoValue в проектах Maven, вам необходимо включить в pom.xml следующую зависимость :

<dependency>
<groupId>com.google.auto.value</groupId>
<artifactId>auto-value</artifactId>
<version>1.2</version>
</dependency>

Последнюю версию можно найти по этой ссылке .

3. Объекты с типизированным значением

Типы значений — это конечный продукт библиотеки, поэтому, чтобы оценить ее место в наших задачах разработки, мы должны хорошо понимать типы значений, что они собой представляют, чем они не являются и зачем они нам нужны.

3.1. Что такое типы значений?

Объекты типа значения — это объекты, равенство которых друг другу определяется не идентичностью, а скорее их внутренним состоянием. Это означает, что два экземпляра объекта с типизированным значением считаются равными, если они имеют одинаковые значения полей.

Как правило, типы значений являются неизменяемыми . Их поля должны быть окончательными , и у них не должно быть методов установки , поскольку это сделает их изменяемыми после создания экземпляра.

Они должны потреблять все значения полей через конструктор или фабричный метод.

Типы значений не являются JavaBeans, потому что у них нет конструктора по умолчанию или нулевого аргумента, а также у них нет методов установки, аналогично, они не являются объектами передачи данных или обычными старыми объектами Java .

Кроме того, класс с типизированным значением должен быть окончательным, чтобы его нельзя было расширить, по крайней мере, если кто-то переопределит методы. JavaBeans, DTO и POJO не обязательно должны быть окончательными.

3.2. Создание типа значения

Предположим, мы хотим создать тип значения с именем Foo с полями с именами text и number. Как бы мы это сделали?

Мы бы сделали окончательный класс и пометили все его поля как окончательные. Затем мы использовали бы IDE для создания конструктора, метода hashCode() , метода equals(Object) , геттеров в качестве обязательных методов и метода toString() , и у нас был бы такой класс:

public final class Foo {
private final String text;
private final int number;

public Foo(String text, int number) {
this.text = text;
this.number = number;
}

// standard getters

@Override
public int hashCode() {
return Objects.hash(text, number);
}
@Override
public String toString() {
return "Foo [text=" + text + ", number=" + number + "]";
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
if (getClass() != obj.getClass()) return false;
Foo other = (Foo) obj;
if (number != other.number) return false;
if (text == null) {
if (other.text != null) return false;
} else if (!text.equals(other.text)) {
return false;
}
return true;
}
}

После создания экземпляра Foo мы ожидаем, что его внутреннее состояние останется неизменным на протяжении всего его жизненного цикла.

Как мы увидим в следующем подразделе , хэш - код объекта должен меняться от экземпляра к экземпляру `` , но для типов значений мы должны привязать его к полям, которые определяют внутреннее состояние объекта значения.

Следовательно, даже изменение поля того же объекта изменит значение hashCode .

3.3. Как работают типы значений

Причина, по которой типы значений должны быть неизменяемыми, заключается в том, чтобы предотвратить любое изменение их внутреннего состояния приложением после того, как они были созданы.

Всякий раз, когда мы хотим сравнить любые два объекта со значениями, мы должны использовать метод equals (Object) класса Object .

Это означает, что мы всегда должны переопределять этот метод в наших собственных типах значений и возвращать true только в том случае, если поля сравниваемых объектов значений имеют одинаковые значения.

Более того, чтобы мы могли использовать наши объекты-значения в коллекциях на основе хэшей, таких как HashSet и HashMap , без нарушения, мы должны правильно реализовать метод hashCode () .

3.4. Зачем нам нужны типы значений

Потребность в типах значений возникает довольно часто. Это случаи, когда мы хотели бы переопределить поведение по умолчанию исходного класса Object .

Как мы уже знаем, реализация класса Object по умолчанию считает два объекта равными, когда они имеют одинаковую идентичность, однако для наших целей мы считаем два объекта равными, когда они имеют одинаковое внутреннее состояние .

Предположим, мы хотим создать денежный объект следующим образом:

public class MutableMoney {
private long amount;
private String currency;

public MutableMoney(long amount, String currency) {
this.amount = amount;
this.currency = currency;
}

// standard getters and setters

}

Мы можем запустить на нем следующий тест, чтобы проверить его равенство:

@Test
public void givenTwoSameValueMoneyObjects_whenEqualityTestFails_thenCorrect() {
MutableMoney m1 = new MutableMoney(10000, "USD");
MutableMoney m2 = new MutableMoney(10000, "USD");
assertFalse(m1.equals(m2));
}

Обратите внимание на семантику теста.

Мы считаем, что это прошло, когда два денежных объекта не равны. Это связано с тем, что мы не переопределили метод equals , поэтому равенство измеряется путем сравнения ссылок на память объектов, которые, конечно же, не будут отличаться, поскольку это разные объекты, занимающие разные ячейки памяти.

Каждый объект представляет собой 10 000 долларов США, но Java говорит нам, что наши денежные объекты не равны . Мы хотим, чтобы два объекта проверялись на неравенство только тогда, когда различаются суммы в валюте или разные типы валют.

Теперь давайте создадим эквивалентный объект значения, и на этот раз мы позволим IDE сгенерировать большую часть кода:

public final class ImmutableMoney {
private final long amount;
private final String currency;

public ImmutableMoney(long amount, String currency) {
this.amount = amount;
this.currency = currency;
}

@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (int) (amount ^ (amount >>> 32));
result = prime * result + ((currency == null) ? 0 : currency.hashCode());
return result;
}

@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
if (getClass() != obj.getClass()) return false;
ImmutableMoney other = (ImmutableMoney) obj;
if (amount != other.amount) return false;
if (currency == null) {
if (other.currency != null) return false;
} else if (!currency.equals(other.currency))
return false;
return true;
}
}

Единственное отличие состоит в том, что мы переопределили методы equals(Object) и hashCode() , теперь у нас есть контроль над тем, как мы хотим, чтобы Java сравнивал наши денежные объекты. Давайте запустим его эквивалентный тест:

@Test
public void givenTwoSameValueMoneyValueObjects_whenEqualityTestPasses_thenCorrect() {
ImmutableMoney m1 = new ImmutableMoney(10000, "USD");
ImmutableMoney m2 = new ImmutableMoney(10000, "USD");
assertTrue(m1.equals(m2));
}

Обратите внимание на семантику этого теста: мы ожидаем, что он будет пройден, когда оба денежных объекта проверяются на равенство с помощью метода equals .

4. Почему AutoValue?

Теперь, когда мы полностью понимаем типы значений и зачем они нам нужны, мы можем взглянуть на AutoValue и то, как оно входит в уравнение.

4.1. Проблемы с ручным кодированием

Когда мы создаем типы значений, как мы это делали в предыдущем разделе, мы сталкиваемся с рядом проблем, связанных с плохим дизайном и большим количеством шаблонного кода .

Класс с двумя полями будет иметь 9 строк кода: одну для объявления пакета, две для подписи класса и закрывающей скобки, две для объявлений полей, две для конструкторов и закрывающей скобки и две для инициализации полей, но тогда нам нужны геттеры. для полей, каждое из которых занимает еще три строки кода, что дает шесть дополнительных строк.

Переопределение методов hashCode() и equalTo(Object) требует около 9 строк и 18 строк соответственно, а переопределение метода toString() добавляет еще пять строк.

Это означает, что хорошо отформатированная база кода для нашего класса с двумя полями заняла бы около 50 строк кода .

4.2. IDE спешат на помощь?

Это легко сделать с помощью таких IDE, как Eclipse или IntilliJ, и создать только один или два класса со значениями. Подумайте о множестве таких классов, которые нужно создать, будет ли это так же просто, даже если нам поможет IDE?

Перенесемся вперед, через несколько месяцев, предположим, что нам нужно пересмотреть наш код и внести поправки в наши классы Money и, возможно, преобразовать поле валюты из типа String в другой тип значения, называемый Currency.

4.3. IDE не так уж и полезны

IDE, такая как Eclipse, не может просто отредактировать для нас наши методы доступа или методы toString() , hashCode() или equals(Object) .

Этот рефакторинг должен быть сделан вручную . Редактирование кода увеличивает вероятность ошибок, и с каждым новым полем, которое мы добавляем в класс Money , количество строк увеличивается экспоненциально.

Признание того факта, что этот сценарий происходит, что это происходит часто и в больших объемах, заставит нас по-настоящему оценить роль AutoValue.

5. Пример автозначения

Проблема, которую решает AutoValue, состоит в том, чтобы убрать весь шаблонный код, о котором мы говорили в предыдущем разделе, так, чтобы нам никогда не приходилось его писать, редактировать или даже читать.

Мы рассмотрим тот же пример Money , но на этот раз с AutoValue. Мы назовем этот класс AutoValueMoney для согласованности:

@AutoValue
public abstract class AutoValueMoney {
public abstract String getCurrency();
public abstract long getAmount();

public static AutoValueMoney create(String currency, long amount) {
return new AutoValue_AutoValueMoney(currency, amount);
}
}

Что произошло, так это то, что мы пишем абстрактный класс, определяем для него абстрактные методы доступа, но не поля, мы аннотируем класс с помощью @AutoValue , всего всего 8 строк кода, и javac генерирует для нас конкретный подкласс, который выглядит следующим образом:

public final class AutoValue_AutoValueMoney extends AutoValueMoney {
private final String currency;
private final long amount;

AutoValue_AutoValueMoney(String currency, long amount) {
if (currency == null) throw new NullPointerException(currency);
this.currency = currency;
this.amount = amount;
}

// standard getters

@Override
public int hashCode() {
int h = 1;
h *= 1000003;
h ^= currency.hashCode();
h *= 1000003;
h ^= amount;
return h;
}

@Override
public boolean equals(Object o) {
if (o == this) {
return true;
}
if (o instanceof AutoValueMoney) {
AutoValueMoney that = (AutoValueMoney) o;
return (this.currency.equals(that.getCurrency()))
&& (this.amount == that.getAmount());
}
return false;
}
}

Нам вообще никогда не приходится иметь дело с этим классом напрямую, и нам не нужно его редактировать, когда нам нужно добавить дополнительные поля или внести изменения в наши поля, как в случае с валютой в предыдущем разделе.

Javac всегда будет перегенерировать для нас обновленный код .

При использовании этого нового типа значения все вызывающие объекты видят только родительский тип, как мы увидим в следующих модульных тестах.

Вот тест, который проверяет правильность установки наших полей:

@Test
public void givenValueTypeWithAutoValue_whenFieldsCorrectlySet_thenCorrect() {
AutoValueMoney m = AutoValueMoney.create("USD", 10000);
assertEquals(m.getAmount(), 10000);
assertEquals(m.getCurrency(), "USD");
}

Далее следует тест для проверки того, что два объекта AutoValueMoney с одинаковой валютой и одинаковой суммой равны:

@Test
public void given2EqualValueTypesWithAutoValue_whenEqual_thenCorrect() {
AutoValueMoney m1 = AutoValueMoney.create("USD", 5000);
AutoValueMoney m2 = AutoValueMoney.create("USD", 5000);
assertTrue(m1.equals(m2));
}

Когда мы меняем тип валюты одного денежного объекта на GBP, тест: 5000 GBP == 5000 USD больше не соответствует действительности:

@Test
public void given2DifferentValueTypesWithAutoValue_whenNotEqual_thenCorrect() {
AutoValueMoney m1 = AutoValueMoney.create("GBP", 5000);
AutoValueMoney m2 = AutoValueMoney.create("USD", 5000);
assertFalse(m1.equals(m2));
}

6. AutoValue с помощью строителей

Первоначальный пример, который мы рассмотрели, охватывает базовое использование AutoValue с использованием статического фабричного метода в качестве нашего общедоступного API создания.

Обратите внимание, что если бы все наши поля были строками , их было бы легко поменять местами, поскольку мы передавали их в статический фабричный метод, например, помещая сумму вместо валюты и наоборот.

Это особенно вероятно, если у нас много полей, и все они имеют тип String . Эта проблема усугубляется тем, что с AutoValue все поля инициализируются через конструктор .

Чтобы решить эту проблему, мы должны использовать шаблон строителя . К счастью. это может быть сгенерировано AutoValue.

Наш класс AutoValue практически не меняется, за исключением того, что статический фабричный метод заменяется построителем:

@AutoValue
public abstract class AutoValueMoneyWithBuilder {
public abstract String getCurrency();
public abstract long getAmount();
static Builder builder() {
return new AutoValue_AutoValueMoneyWithBuilder.Builder();
}

@AutoValue.Builder
abstract static class Builder {
abstract Builder setCurrency(String currency);
abstract Builder setAmount(long amount);
abstract AutoValueMoneyWithBuilder build();
}
}

Сгенерированный класс точно такой же, как и первый, но создается конкретный внутренний класс для построителя, а также реализуются абстрактные методы в построителе:

static final class Builder extends AutoValueMoneyWithBuilder.Builder {
private String currency;
private long amount;
Builder() {
}
Builder(AutoValueMoneyWithBuilder source) {
this.currency = source.getCurrency();
this.amount = source.getAmount();
}

@Override
public AutoValueMoneyWithBuilder.Builder setCurrency(String currency) {
this.currency = currency;
return this;
}

@Override
public AutoValueMoneyWithBuilder.Builder setAmount(long amount) {
this.amount = amount;
return this;
}

@Override
public AutoValueMoneyWithBuilder build() {
String missing = "";
if (currency == null) {
missing += " currency";
}
if (amount == 0) {
missing += " amount";
}
if (!missing.isEmpty()) {
throw new IllegalStateException("Missing required properties:" + missing);
}
return new AutoValue_AutoValueMoneyWithBuilder(this.currency,this.amount);
}
}

Обратите также внимание на то, что результаты теста не меняются.

Если мы хотим знать, что значения полей на самом деле правильно установлены через построитель, мы можем выполнить этот тест:

@Test
public void givenValueTypeWithBuilder_whenFieldsCorrectlySet_thenCorrect() {
AutoValueMoneyWithBuilder m = AutoValueMoneyWithBuilder.builder().
setAmount(5000).setCurrency("USD").build();
assertEquals(m.getAmount(), 5000);
assertEquals(m.getCurrency(), "USD");
}

Чтобы проверить, что равенство зависит от внутреннего состояния:

@Test
public void given2EqualValueTypesWithBuilder_whenEqual_thenCorrect() {
AutoValueMoneyWithBuilder m1 = AutoValueMoneyWithBuilder.builder()
.setAmount(5000).setCurrency("USD").build();
AutoValueMoneyWithBuilder m2 = AutoValueMoneyWithBuilder.builder()
.setAmount(5000).setCurrency("USD").build();
assertTrue(m1.equals(m2));
}

И когда значения полей разные:

@Test
public void given2DifferentValueTypesBuilder_whenNotEqual_thenCorrect() {
AutoValueMoneyWithBuilder m1 = AutoValueMoneyWithBuilder.builder()
.setAmount(5000).setCurrency("USD").build();
AutoValueMoneyWithBuilder m2 = AutoValueMoneyWithBuilder.builder()
.setAmount(5000).setCurrency("GBP").build();
assertFalse(m1.equals(m2));
}

7. Заключение

В этом руководстве мы познакомились с большинством основ библиотеки Google AutoValue и рассказали, как ее использовать для создания типов значений с очень небольшим количеством кода с нашей стороны.

Альтернативой Google AutoValue является проект Lombok — ознакомительную статью об использовании Lombok можно посмотреть здесь .

Полную реализацию всех этих примеров и фрагментов кода можно найти в проекте AutoValue GitHub .