Привет, Хабр!
Сегодня мы хотим затронуть тему иммутабельности и примериться, заслуживает ли эта проблема более серьезного рассмотрения.
Иммутабельные объекты – неизмеримо мощный феномен в программировании. Иммутабельность помогает избежать всевозможных проблем с конкурентностью и не допустить кучу разнообразных багов, но понять иммутабельные конструкции бывает непросто. Давайте рассмотрим, что они из себя представляют, и как ими пользоваться.
Во-первых, взгляните на простой объект:
Как видите, объект
Просто, правда? В любой момент читать или изменять данные как нам угодно. Но с этим способом есть пара проблем. Первая и важнейшая из них – мы используем в нашем классе переменную
В некоторых языках (например, в C#) предоставляется возможность вставлять функцию-геттер, чтобы обходить эту проблему, но в большинстве объектно-ориентированных языков приходится действовать явно:
Пока все хорошо. Если бы вы теперь захотели изменить внутреннее хранилище имени, скажем, на имя и фамилию, то могли бы сделать так:
Если не углубляться в серьезнейшие проблемы, сопряженные с таким представлением имен, то очевидно, что внешне API
Что же насчет установки имен? Что нужно добавить, чтобы не только получать имя, но и устанавливать его вот так?
На первый взгляд выглядит отлично, ведь теперь мы снова можем менять имя. Но в таком способе изменения данных есть фундаментальный изъян. У него две стороны: философская и практическая.
Для начала рассмотрим философскую проблему. Объект
Вторая причина связана с практикой: изменяемое состояние (сохраненные данные, которые могут меняться) чревато возникновением багов. Возьмем этот объект
Обратите внимание: этот
Сколько персон сейчас содержится в хранилище person? Одна или две? Кроме того, если сейчас применить метод
Как видите, здесь возможны два варианта: либо
Хуже того, сохраненные данные можно изменить, даже не вызывая функцию
Итак, в сущности, баги закрадываются в нашу программу именно потому, что мы имеем дело с изменяемым состоянием. Можно не сомневаться, что эту проблему удастся обойти, если явно прописать работу по созданию копии в хранилище, но есть и гораздо более простой способ: работа с неизменяемыми объектами. Рассмотрим пример:
Как видите, вместо метода
Помните:
Можно возразить, что уровень долговременного хранения, где содержится ссылка на действующий объект – это поломанный уровень долговременного хранения, но такой сценарий реалистичен. Неисправный код действительно существует, и иммутабельность – ценный инструмент, помогающий не допускать таких поломок.
В более сложных сценариях, когда объекты передаются сквозь несколько уровней приложения, баги легко наводняют код, и иммутабельность не допускает возникновения багов, связанных с состоянием. К примерам такого рода относится, например, кэширование в оперативной памяти или внеочередные вызовы функций.
Как иммутабельность помогает при параллельной обработке
Еще одна важная сфера, где нам пригодится иммутабельность – это параллельная обработка. Точнее, многопоточность. В многопоточных приложениях параллельно выполняется сразу несколько линий кода, которые, при этом, обращаются к одной и той же области памяти. Рассмотрим очень простой листинг:
Сам по себе этот код не содержит багов, но при параллельном запуске он начинает работать с вытеснением, и может возникнуть беспорядок. Посмотрите, как выглядит вышеприведенный фрагмент кода с комментарием:
Это состояние гонки. Первый поток проверяет, равно ли имя
Разумеется, можно было бы применить блокировку, чтобы гарантировать, что в любой момент времени в критичную часть кода будет входить только один поток, однако, здесь может возникнуть узкое место. Однако, если объекты иммутабельны, то такой сценарий сложиться не может, так как в p всегда сохранен один и тот же объект. Если другой поток хочет повлиять на изменение, то создает новую копию, которой не будет в первом потоке.
В принципе, я бы посоветовал всегда следить за тем, чтобы изменяемое состояние в вашем приложении встречалось в минимальном объеме. Если же вы и будете к нему прибегать, плотно ограничивайте его качественно спроектированными API, не позволяйте ему протечь в другие области приложения. Чем меньше у вас фрагментов кода, в которых содержится состояние, тем маловероятнее, что проклюнутся ошибки, связанные с состоянием.
Разумеется, большинство задач из области программирования не решаемы, если вообще не прибегать к состоянию. Но, если считать все структуры данных по умолчанию иммутабельными, то в коде будет возникать гораздо меньше случайных багов. Если вы действительно будете вынуждены ввести в код изменяемость – то придется делать это осторожно и продумывать последствия, а не начинять ею весь код.
Сегодня мы хотим затронуть тему иммутабельности и примериться, заслуживает ли эта проблема более серьезного рассмотрения.
Иммутабельные объекты – неизмеримо мощный феномен в программировании. Иммутабельность помогает избежать всевозможных проблем с конкурентностью и не допустить кучу разнообразных багов, но понять иммутабельные конструкции бывает непросто. Давайте рассмотрим, что они из себя представляют, и как ими пользоваться.
Во-первых, взгляните на простой объект:
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, не позволяйте ему протечь в другие области приложения. Чем меньше у вас фрагментов кода, в которых содержится состояние, тем маловероятнее, что проклюнутся ошибки, связанные с состоянием.
Разумеется, большинство задач из области программирования не решаемы, если вообще не прибегать к состоянию. Но, если считать все структуры данных по умолчанию иммутабельными, то в коде будет возникать гораздо меньше случайных багов. Если вы действительно будете вынуждены ввести в код изменяемость – то придется делать это осторожно и продумывать последствия, а не начинять ею весь код.
