Статьи

Недостатки неизменности

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

Что такое неизменный класс? Это просто класс, экземпляры которого нельзя изменить. Каждое значение для атрибутов класса устанавливается в их объявлении или в конструкторе, и они сохраняют эти значения до конца жизненного цикла объекта. В Java довольно много неизменных классов, таких как String , все коробочные примитивы ( Double , Integer , Float и т. Д.), BigInteger и BigDecimal и другие. Для этого есть веская причина: неизменяемые классы легче проектировать, реализовывать и использовать, чем изменяемые классы. После создания экземпляра они могут находиться только в одном состоянии, поэтому они менее подвержены ошибкам и, как мы увидим далее в этом посте, они более безопасны.

Как вы гарантируете, что класс неизменен? Просто выполните следующие 5 простых шагов:

  1. Не предоставляйте публичные методы, которые изменяют состояние объекта , также известные как мутаторы (такие как сеттеры).
  2. Предотвратите расширение класса . Это не позволяет любому злонамеренному или неосторожному классу расширить наш класс и поставить под угрозу его неизменное поведение. Обычный и более простой способ сделать это — пометить класс как окончательный , но есть еще один способ, который я упомяну в этом посте.
  3. Сделайте все поля окончательными . Это способ позволить компилятору принудительно использовать пункт номер 1 для вас. Кроме того, он явно позволяет любому, кто видит ваш код, знать, что вы не хотите, чтобы эти поля меняли свои значения после их установки.
  4. Сделайте все поля приватными . Это должно быть довольно очевидно, и вы должны следовать ему независимо от того, принимаете ли вы неизменность или нет, но я упоминаю об этом на всякий случай.
  5. Никогда не предоставляйте доступ к любому изменяемому атрибуту . Если ваш класс имеет изменяемый объект в качестве одного из своих свойств (например, List , Map или любой другой изменяемый объект из вашей проблемы домена), убедитесь, что клиенты вашего класса никогда не смогут получить ссылку на этот объект. Это означает, что вы никогда не должны напрямую возвращать ссылку на них из средства доступа (например, получателя) и никогда не должны инициализировать их в своем конструкторе ссылкой, передаваемой в качестве параметра от клиента. В этом случае вы всегда должны делать защитные копии.

Это много теории и никакого кода, поэтому давайте посмотрим, как выглядит простой неизменяемый класс и как он работает с 5 шагами, которые я упоминал ранее:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Book {
    private final String isbn;
    private final int publicationYear;
    private final List reviews;
    private Book(BookBuilder builder) {
        this.isbn = builder.isbn;
        this.publicationYear = builder.publicationYear;
        this.reviews = Lists.newArrayList(builder.reviews);
    }
    public String getIsbn() {
        return isbn;
    }
    public int getPublicationYear() {
        return publicationYear;
    }
    public List getReviews() {
        return Lists.newArrayList(reviews);
    }
    public static class BookBuilder {
        private String isbn;
        private int publicationYear;
        private List reviews;
        public BookBuilder isbn(String isbn) {
            this.isbn = isbn;
            return this;
        }
        public BookBuilder publicationYear(int year) {
            this.publicationYear = year;
            return this;
        }
        public BookBuilder reviews(List reviews) {
            this.reviews = reviews == null ? new ArrayList() : reviews;
            return this;
        }
        public Book build() {
            return new Book(this);
        }
    }
}

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

    • Не предоставляйте никаких открытых методов, которые изменяют состояние объекта . Обратите внимание, что единственными методами в классе являются его закрытый конструктор и методы получения его свойств, но нет метода для изменения состояния объекта.
    • Предотвратите расширение класса . Это довольно сложно. Я упомянул, что самый простой способ обеспечить это — сделать урок окончательным, но урок Книги явно не окончательный. Однако обратите внимание, что единственный доступный конструктор является закрытым . Компилятор гарантирует, что класс без открытых или защищенных конструкторов не может быть разделен на подклассы. Так что в этом случае последнее ключевое слово в объявлении класса не является необходимым, но было бы неплохо включить его в любом случае, просто чтобы прояснить свое намерение для любого, кто видит ваш код.
    • Сделайте все поля окончательными . Довольно просто, все атрибуты в классе объявлены как окончательные .
    • Никогда не предоставляйте доступ к любому изменяемому атрибуту . Это на самом деле довольно интересно. Обратите внимание, как класс Book имеет атрибут List <String>, который объявлен как final и значение которого установлено в конструкторе класса. Тем не менее, этот список является изменяемым объектом. Таким образом, хотя ссылка на отзывы не может измениться после ее установки, содержимое списка может измениться. Клиент со ссылкой на тот же список может добавить или удалить элемент и, как следствие, изменить состояние объекта Book после его создания. По этой причине обратите внимание, что в конструкторе Book мы не назначаем ссылку напрямую. Вместо этого мы используем библиотеку Guava для создания копии списка, вызывая « this.reviews = Lists.newArrayList(builder.reviews); «. Такая же ситуация наблюдается в методе getReviews , где мы возвращаем копию списка вместо прямой ссылки. Стоит отметить, что этот пример может быть несколько упрощен, поскольку список рецензий может содержать только строки, которые являются неизменяемыми. Если тип списка является изменяемым классом, то вам также нужно будет сделать копию каждого объекта в списке, а не только самого списка.

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

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

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

Первый вариант — использовать технику интерфейса Fluent в классе Book и иметь методы, подобные сеттерам, которые фактически создают объект с одинаковыми значениями для всех его атрибутов, кроме того, который вы хотите изменить. В нашем примере мы должны добавить следующее к классу Book :

01
02
03
04
05
06
07
08
09
10
11
private Book(BookBuilder builder) {
    this(builder.isbn, builder.publicationYear, builder.reviews);
}
private Book(String isbn, int publicationYear, List reviews) {
    this.isbn = isbn;
    this.publicationYear = publicationYear;
    this.reviews = Lists.newArrayList(reviews);
}
public Book withIsbn(String isbn) {
    return new Book(isbn,this.publicationYear, this.reviews);
}

Обратите внимание, что мы добавили новый частный конструктор, в котором мы можем указать значение каждого атрибута, и изменили старый конструктор, чтобы использовать новый. Кроме того, мы добавили новый метод, который возвращает новый объект Book со значением, которое мы хотели для атрибута isbn . То же самое относится и к остальным атрибутам класса. Это известно как функциональный подход, потому что методы возвращают результат работы с их параметрами без их изменения. Это должно противопоставить это процессуальному или императивному подходу, где методы применяют процедуру к своим операндам, таким образом изменяя их состояние.

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

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

1
2
3
4
5
public BookBuilder(Book book) {
    this.isbn = book.getIsbn();
    this.publicationYear = book.getPublicationYear();
    this.reviews = book.getReviews();
}

Тогда на наших клиентов мы можем иметь:

1
2
3
Book originalBook = getRandomBook();
 
Book modifiedBook = new BookBuilder(originalBook).isbn('123456').publicationYear(2011).build();

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

Я упомянул, что тот факт, что мы должны создавать новый объект для каждого изменения состояния, может привести к снижению производительности, и это единственный реальный недостаток неизменяемых классов. Тем не менее, создание объектов является одним из аспектов JVM, который постоянно совершенствуется. На самом деле, за исключением исключительных случаев, создание объектов намного эффективнее, чем вы думаете. В любом случае, обычно хорошей идеей является создание простой и понятной конструкции, а затем, только после измерения, рефакторинг для повышения производительности. Девять из десяти раз, когда вы попытаетесь угадать, где ваш код занимает столько времени, вы обнаружите, что ошиблись. Кроме того, тот факт, что неизменяемые объекты могут использоваться совместно, не беспокоясь о последствиях, дает вам возможность побуждать клиентов повторно использовать существующие экземпляры везде, где это возможно, что значительно сокращает количество создаваемых объектов. Обычный способ сделать это — предоставить открытые статические конечные константы для наиболее распространенных значений. Эта техника интенсивно используется в JDK, например, в Boolean.FALSE или BigDecimal.ZERO .

В заключение, если вы хотите извлечь что-то из этого, пусть это будет так: классы должны быть неизменяемыми, если только нет очень веской причины сделать их изменяемыми . Не добавляйте автоматически сеттер для каждого атрибута класса. Если по какой-то причине вы абсолютно не можете сделать свой класс неизменным, то ограничьте его изменчивость в максимально возможной степени. Чем меньше состояний, в которых может находиться объект, тем легче думать об объекте и его инвариантах. И не беспокойтесь о производительности из-за неизменности, скорее всего, вам не придется об этом беспокоиться.

Ссылка: все тонкости неизменности от нашего партнера JCG Хосе Луиса в разработке так, как это должно быть в блоге.