Сегодня мы хотим затронуть тему иммутабельности и примериться, заслуживает ли эта проблема более серьезного рассмотрения.
Иммутабельные объекты – неизмеримо мощный феномен в программировании. Иммутабельность помогает избежать всевозможных проблем с конкурентностью и не допустить кучу разнообразных багов, но понять иммутабельные конструкции бывает непросто. Давайте рассмотрим, что они из себя представляют, и как ими пользоваться.
Во-первых, взгляните на простой объект:
class Person {
public String name;
public Person(
String name
) {
this.name = name;
}
}
Как видите, объект
Person
в своем конструкторе принимает один параметр, а затем ставит его в публичную переменную name
. Соответственно, мы можем делать такие вещи:Person p = new Person("John");
p.name = "Jane";
Просто, правда? В любой момент читать или изменять данные как нам угодно. Но с этим способом есть пара проблем. Первая и важнейшая из них – мы используем в нашем классе переменную
name
, и таким образом, бесповоротно вводим внутреннее хранилище класса в состав публичного API. Иными словами, мы никак не сможем изменить способ хранения имени внутри класса, если только не перепишем значительной части нашего приложения.В некоторых языках (например, в C#) предоставляется возможность вставлять функцию-геттер, чтобы обходить эту проблему, но в большинстве объектно-ориентированных языков приходится действовать явно:
class Person {
private String name;
public Person(
String name
) {
this.name = name;
}
public String getName() {
return name;
}
}
Пока все хорошо. Если бы вы теперь захотели изменить внутреннее хранилище имени, скажем, на имя и фамилию, то могли бы сделать так:
class Person {
private String firstName;
private String lastName;
public Person(
String firstName,
String lastName
) {
this.firstName = firstName;
this.lastName = lastName;
}
public String getName() {
return firstName + " " + lastName;
}
}
Если не углубляться в серьезнейшие проблемы, сопряженные с таким представлением имен, то очевидно, что внешне API
getName()
не изменился.Что же насчет установки имен? Что нужно добавить, чтобы не только получать имя, но и устанавливать его вот так?
class Person {
private String name;
//...
public void setName(String name) {
this.name = name;
}
//...
}
На первый взгляд выглядит отлично, ведь теперь мы снова можем менять имя. Но в таком способе изменения данных есть фундаментальный изъян. У него две стороны: философская и практическая.
Для начала рассмотрим философскую проблему. Объект
Person
предназначен для представления человека. Действительно, фамилия у человека может меняться, но функцию для этой цели лучше было бы назвать changeName
, поскольку такое название подразумевает, что мы меняем фамилию все того же человека. Также она должна включать бизнес-логику для изменения фамилии человека, а не просто действовать как сеттер. Название setName
подводит к вполне логичному выводу, что мы можем в добровольно-принудительном порядке изменить имя, сохраненное в объекте person, и нам за это ничего не будет.Вторая причина связана с практикой: изменяемое состояние (сохраненные данные, которые могут меняться) чревато возникновением багов. Возьмем этот объект
Person
и определим интерфейс PersonStorage
:interface PersonStorage {
public void store(Person person);
public Person getByName(String name);
}
Обратите внимание: этот
PersonStorage
не указывает, где именно хранится объект: в памяти, на диске или в базе данных. Интерфейс также не требует от реализации создавать копию хранимого в ней объекта. Поэтому может возникнуть интересный баг:Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
myPersonStorage.store(p);
Сколько персон сейчас содержится в хранилище person? Одна или две? Кроме того, если сейчас применить метод
getByName
, то какую из персон он вернет? Как видите, здесь возможны два варианта: либо
PersonStorage
скопирует объект Person
, и в таком случае будут сохранены две записи Person
, либо не станет этого делать, и сохранит лишь ссылку на переданный объект; во втором случае будет сохранен всего один объект с именем “Jane”
. Реализация второго варианта может выглядеть так:class InMemoryPersonStorage implements PersonStorage {
private Set<Person> persons = new HashSet<>();
public void store(Person person) {
this.persons.add(person);
}
}
Хуже того, сохраненные данные можно изменить, даже не вызывая функцию
store
. Поскольку в хранилище находится только ссылка на оригинал объекта, при изменении имени также изменится и сохраненная версия:Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
Итак, в сущности, баги закрадываются в нашу программу именно потому, что мы имеем дело с изменяемым состоянием. Можно не сомневаться, что эту проблему удастся обойти, если явно прописать работу по созданию копии в хранилище, но есть и гораздо более простой способ: работа с неизменяемыми объектами. Рассмотрим пример:
class Person {
private String name;
public Person(
String name
) {
this.name = name;
}
public String getName() {
return name;
}
public Person withName(String name) {
return new Person(name);
}
}
Как видите, вместо метода
setName
теперь используется метод withName
, создающий новую копию объекта Person
. Если всякий раз создавать новую копию, то мы обходимся без изменяемого состояния и без соответствующих проблем. Конечно, это приводит к некоторым издержкам, но современные компиляторы могут с ними справляться и, если у вас возникнут проблемы с производительностью, то их можно будет исправить позже.Помните:
Преждевременная оптимизация – корень всех зол (Дональд Кнут)
Можно возразить, что уровень долговременного хранения, где содержится ссылка на действующий объект – это поломанный уровень долговременного хранения, но такой сценарий реалистичен. Неисправный код действительно существует, и иммутабельность – ценный инструмент, помогающий не допускать таких поломок.
В более сложных сценариях, когда объекты передаются сквозь несколько уровней приложения, баги легко наводняют код, и иммутабельность не допускает возникновения багов, связанных с состоянием. К примерам такого рода относится, например, кэширование в оперативной памяти или внеочередные вызовы функций.
Как иммутабельность помогает при параллельной обработке
Еще одна важная сфера, где нам пригодится иммутабельность – это параллельная обработка. Точнее, многопоточность. В многопоточных приложениях параллельно выполняется сразу несколько линий кода, которые, при этом, обращаются к одной и той же области памяти. Рассмотрим очень простой листинг:
if (p.getName().equals("John")) {
p.setName(p.getName() + "Doe");
}
Сам по себе этот код не содержит багов, но при параллельном запуске он начинает работать с вытеснением, и может возникнуть беспорядок. Посмотрите, как выглядит вышеприведенный фрагмент кода с комментарием:
if (p.getName().equals("John")) {
// здесь другой поток изменяет имя, которое более не равно John
p.setName(p.getName() + "Doe");
}
Это состояние гонки. Первый поток проверяет, равно ли имя
“John”
, но затем второй поток изменяет это имя. Первый поток при этом продолжает работу, по-прежнему полагая, что имя равно John
.Разумеется, можно было бы применить блокировку, чтобы гарантировать, что в любой момент времени в критичную часть кода будет входить только один поток, однако, здесь может возникнуть узкое место. Однако, если объекты иммутабельны, то такой сценарий сложиться не может, так как в p всегда сохранен один и тот же объект. Если другой поток хочет повлиять на изменение, то создает новую копию, которой не будет в первом потоке.
Итоги
В принципе, я бы посоветовал всегда следить за тем, чтобы изменяемое состояние в вашем приложении встречалось в минимальном объеме. Если же вы и будете к нему прибегать, плотно ограничивайте его качественно спроектированными API, не позволяйте ему протечь в другие области приложения. Чем меньше у вас фрагментов кода, в которых содержится состояние, тем маловероятнее, что проклюнутся ошибки, связанные с состоянием.
Разумеется, большинство задач из области программирования не решаемы, если вообще не прибегать к состоянию. Но, если считать все структуры данных по умолчанию иммутабельными, то в коде будет возникать гораздо меньше случайных багов. Если вы действительно будете вынуждены ввести в код изменяемость – то придется делать это осторожно и продумывать последствия, а не начинять ею весь код.
Dotarev
public IEnumerableOfPerson getByName(String Name)
Кроме того, Вы допускаете изменение признака Name в экземпляре класса с одной стороны, и передаете через интерфейс экземпляр неограниченному кругу пользователей без уведомления об изменении — с другой стороны. Это второй источник багов.