Привет, Хабр!

Сегодня мы хотим затронуть тему иммутабельности и примериться, заслуживает ли эта проблема более серьезного рассмотрения.

Иммутабельные объекты – неизмеримо мощный феномен в программировании. Иммутабельность помогает избежать всевозможных проблем с конкурентностью и не допустить кучу разнообразных багов, но понять иммутабельные конструкции бывает непросто. Давайте рассмотрим, что они из себя представляют, и как ими пользоваться.

Во-первых, взгляните на простой объект:

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, не позволяйте ему протечь в другие области приложения. Чем меньше у вас фрагментов кода, в которых содержится состояние, тем маловероятнее, что проклюнутся ошибки, связанные с состоянием.

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