Функциональное мышление: Неизменяемость

Сделайте Java-код более функциональным, отказавшись от изменений

Неизменяемость (immutability) – это один из краеугольных камней функционального программирования. В этой статье из серии "Функциональное мышление" обсуждаются аспекты применения неизменяемости в языке Java™ и показывается, как создавать неизменяемые Java-классы c помощью различных подходов: традиционного и нового. Также представлены два способа создания неизменяемых классов в Groovy, избавленные от многих недостатков, присущих Java-реализации. В конце будут описаны сценарии, в которых рекомендуется применение этой абстракции.

Нил Форд, Архитектор приложений, ThoughtWorks

Нил Форд (Neal Ford) работает архитектором приложений в ThoughtWorks, революционной компании, предоставляющей профессиональные IT-услуги и помогающей талантливым людям по всему миру эффективнее использовать программное обеспечение. Он также является проектировщиком и разработчиком приложений, учебных материалов, журнальных статей, учебных курсов, видео/DVD-презентаций и автором книг "Разработка с Delphi: Объектно-ориентированный подход", "JBuilder 3 Unleashed" и "Искусство разработки Web-приложений на Java". Он специализируется на консультациях по построению широкомасштабных корпоративных приложений. Он также является общепризнанным докладчиком и выступал на многочисленных конференциях разработчиков по всему миру. С ним можно связаться по адресу: nford@thoughtworks.com.



05.05.2012

Объектно-ориентированное программирование затрудняет понимание кода за счет инкапсуляции "движущихся частей". Функциональное программирование облегчает понимание кода за счет сокращения числа "движущихся частей" – цитата из твиттера Майкла Фиверса (Michael Feathers), автора книги Working with Legacy Code.

Об этой серии статей

Эта серия статей призвана переориентировать читателя в «функциональном» направлении и помочь ему взглянуть на проблемы под новым углом, чтобы улучшить повседневное написание кода. В ней изучаются принципы функционального программирования и инфраструктуры, позволяющие использовать функциональное программирование в языке Java. Также в статьях рассматриваются функциональные языки программирования, работающие на виртуальной Java-машине, и некоторые направления будущего развития языков программирования. Эта серия статей предназначена для программистов, знающих язык Java и работу его абстракций, но не имеющих опыта в использовании функциональных языков.

В этой статье обсуждается один из строительных блоков функционального программирования – неизменяемость (immutability). Состояние неизменяемого объекта не может изменяться после создания. Другими словами, состояние объекта можно изменить только с помощью конструктора. Если же состояние подобного объекта требуется изменить, то менять существующий объект нельзя, а следует создать новый объект с измененными значениями и направить ссылку на него. (Классический пример неизменяемого класса – класс String, встроенный в "ядро" языка Java). Неизменяемость – это один из ключевых аспектов функционального программирования, помогающий минимизировать количество изменяемых фрагментов, облегчив их использование.

Реализация неизменяемых классов в Java

Современные объектно-ориентированные языки, такие как Java, Perl, Groovy и C#, обладают удобными встроенными механизмами для управления процессом изменения объектов. Однако состояние объектов настолько интенсивно используется при исполнении бизнес-логики, что невозможно предсказать, где может произойти его "утечка". Например, процесс создания высокопроизводительного многопоточного кода в объектно-ориентированных языках значительно усложняется из-за существования множества возможностей изменить состояние объектов. Так как язык Java изначально оптимизирован для манипулирования состоянием, то необходимо научиться "обходить" некоторые их этих механизмов, чтобы в результате получить объекты с неизменяемым состоянием. Но после того как вы научитесь избегать подобных "ловушек", вы сможете легко создавать неизменяемые классы.

Определение неизменяемых классов

Для создания неизменяемого Java-класса следует:

  • Объявить все его поля как final.

    Когда вы в Java определяете final-поле, его необходимо инициализировать либо в момент объявления, либо в конструкторе. Не обращайте внимания, если IDE предупреждает о том, что поле не было инициализировано прямо в месте объявления. Среда разработки поймет, что все в порядке, когда соответствующий код будет добавлен в конструктор.

  • Объявить класс как final, чтобы его нельзя было переопределить.

    Если класс можно переопределить, то поведение его методов также может быть переопределено, так что для безопасности стоит запретить наследование. Замечу, что именно такой подход используется в Java-классе String.

  • Не предоставлять в классе конструктор без аргументов (по умолчанию).

    Если у вас имеется неизменяемый объект, его состояние необходимо устанавливать в конструкторе. Если нет состояния, которое необходимо хранить, то зачем тогда объект? В подобном случае вполне можно ограничиться классом со статическими (static) методами без поддержки состояния. Поэтому в неизменяемом классе не должно быть конструктора по умолчанию (без аргументов). Если вы используете инфраструктуру, в которой наличие конструктора по умолчанию – это обязательное требование, то стоит проверить, нельзя ли обойти его, предоставив private-конструктор без аргументов, который будет виден через Reflections API.

    Стоит отметить, что отсутствие конструктора по умолчанию также противоречит стандарту JavaBeans, в котором он является обязательным. Но JavaBeans-объекты не могут быть неизменяемыми в любом случае, так как этот стандарт основан на использовании методов setXXX.

  • Предоставить в классе хотя бы один конструктор.

    Если в классе отсутствует конструктор по умолчанию, то это последний шанс установить состояние для объекта.

  • Не предоставлять других методов для изменения состояния, за исключением конструктора.

    Следует не только избегать стандартных для JavaBeans методов setXXX, но и не возвращать ссылки на изменяемые объекты. Тот факт, что ссылка на объект является final, не означает, что нельзя изменить то, на что она указывает. Поэтому необходимо убедиться, что методы getXXX возвращают защищенные копии объектов, на которые ссылается класс.

Стандартные неизменяемые классы

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

Листинг 1. Неизменяемый Java-класс Address
public final class Address {
    private final String name;
    private final List<String> streets;
    private final String city;
    private final String state;
    private final String zip;

    public Address(String name, List<String> streets, 
                   String city, String state, String zip) {
        this.name = name;
        this.streets = streets;
        this.city = city;
        this.state = state;
        this.zip = zip;
    }

    public String getName() {
        return name;
    }

    public List<String> getStreets() {
        return Collections.unmodifiableList(streets);
    }

    public String getCity() {
        return city;
    }

    public String getState() {
        return state;
    }

    public String getZip() {
        return zip;
    }
}

Обратите внимание на использование в листинге 1 метода Collections.unmodifiableList(), который используется для создания защищенной копии списка streets. Желательно всегда использовать коллекции, а для создания неизменяемых списков всегда следует использовать коллекции, а не массивы. Конечно, можно создать и защищенную копию массива, но это может привести к нежелательным побочным эффектам. Рассмотрим код, представленный в листинге 2.

Листинг 2. Класс Customer, в котором вместо коллекций используются массивы
public class Customer {
    public final String name;
    private final Address[] address;

    public Customer(String name, Address[] address) {
        this.name = name;
        this.address = address;
    }

    public Address[] getAddress() {
        return address.clone();
    }
}

Проблема с кодом, представленным в листинге 2, возникнет, когда вы попытаетесь что-нибудь сделать с клонированным массивом, возвращенным из метода getAddress(), как показано в листинге 3.

Листинг 3. Тест, демонстрирующий правильный, хотя и не очевидный, результат
public static List<String> streets(String... streets) {
    return asList(streets);
}

public static Address address(List<String> streets, 
                              String city, String state, String zip) {
    return new Address(streets, city, state, zip);
}

@Test public void immutability_of_array_references_issue() {
    Address [] addresses = new Address[] {
        address(streets("201 E Washington Ave", "Ste 600"), "Chicago", "IL", "60601")};
    Customer c = new Customer("ACME", addresses);
    assertEquals(c.getAddress()[0].city, addresses[0].city);
    Address newAddress = new Address(
        streets("HackerzRulz Ln"), "Hackerville", "LA", "00000");
    // эта инструкция не выполнится, хотя сбой и останется незамеченным
    c.getAddress()[0] = newAddress;

    // пояснение, почему выше не удалось изменить поле address объекта Customer
    assertNotSame(c.getAddress()[0].city, newAddress.city);
    assertSame(c.getAddress()[0].city, addresses[0].city);
    assertEquals(c.getAddress()[0].city, addresses[0].city);
}

Когда метод возвращает клонированный массив, исходный массив оказывается защищенным, но работать с полученным массивом можно точно так же, как и с обычным, а это значит, что можно изменять его содержимое. Даже если переменная, указывающая на массив, объявлена как final, этот модификатор применяется только к самой ссылке на массив, а не к его содержимому. Используя же метод Collections.unmodifiableList() (или другой из методов подобного рода, имеющихся в классе Collections), вы получаете объектную ссылку, у которой нет методов для изменения состояния.

Идеальные неизменяемые классы

Часто можно услышать, что неизменяемые поля также следует объявлять как private. Я не могу согласиться с этим утверждением, учитывая и противоположное мнение, основанное на четко изложенных предпосылках. Рич Хики (Rich Hickey), создатель Clojure, в своём интервью, которое он дал Майклу Фогусу (Michael Fogus), говорил о недостаточной инкапсуляции и скрытии данных, которые наблюдаются во многих базовых компонентах Clojure. Этот аспект Clojure всегда беспокоил меня, так как я рассуждал с позиции, которая подразумевала необходимость существования состояния. Но затем я понял, что не следует беспокоиться о раскрытии полей, если они являются неизменяемыми. Большая часть защитных мер, используемых для инкапсуляции, на самом деле призвана предотвратить изменения. Когда мы разделяем эти две концепции, становится возможно создавать более аккуратные Java-реализации неизменяемых классов.

Рассмотрим новую версию класса Address в листинге 4.

Листинг 4. Класс Address с открытыми (public) неизменяемыми полями
public final class Address {
    private final List<String> streets;
    public final String city;
    public final String state;
    public final String zip;

    public Address(List<String> streets, String city, String state, String zip) {
        this.streets = streets;
        this.city = city;
        this.state = state;
        this.zip = zip;
    }

    public final List<String> getStreets() {
        return Collections.unmodifiableList(streets);
    }
}

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

Если вы не планируете изменять коллекцию внутри самого класса, то объявленный список можно сразу в конструкторе преобразовать в неизменяемый список. Это позволит объявить поле streets как public и устранит потребность в методе getStreets(). Как будет показано в следующем примере, Groovy позволяет создавать защищенные методы для доступа к полю (такие как getStreets()) и одновременно обеспечивать их представление в виде поля.

Если вы начнете использовать неизменяемые public-поля, то поначалу можете почувствовать себя немного неудобно, особенно если вспомнить известную историю о сложившихся традициях; но необычность этого подхода и есть его главное преимущество. Изначально мы не привыкли использовать неизменяемые типы в Java, и это выглядит как создание сущностей нового типа, как показано в листинге 5.

Листинг 5. Unit-тест для класса Address
@Test (expected = UnsupportedOperationException.class)
public void address_access_to_fields_but_enforces_immutability() {
    Address a = new Address(
        streets("201 E Randolph St", "Ste 25"), "Chicago", "IL", "60601");
    assertEquals("Chicago", a.city);
    assertEquals("IL", a.state);
    assertEquals("60601", a.zip);
    assertEquals("201 E Randolph St", a.getStreets().get(0));
    assertEquals("Ste 25", a.getStreets().get(1));
    // следующая строка вызовет ошибку во время компиляции
    //a.city = "New York";
    a.getStreets().clear();
}

История о сложившихся традициях, или злобные обезьяны

Я впервые услышал эту историю от Дейва Томаса (Dave Thomas), а впоследствии включил ее в свою книгу Productive Programmer (см. раздел "Ресурсы"). Не знаю, правда это или вымысел (хотя и я пытался выяснить), но это и неважно. Эта история отлично описывает ситуацию.

В 60-х годах ученые, изучавшие поведение, провели эксперимент, в ходе которого они поместили пять обезьян в комнату со стремянкой и связкой бананов, подвешенной к потолку. Обезьяны быстро сообразили, что они могут подняться по лестнице и съесть бананы. Затем каждый раз, когда одна из обезьян приближалась к лестнице, ученые обливали всех обезьян ледяной водой. Вскоре обезьяны перестали приближаться к лестнице. Потом ученые заменили одну из «облитых» обезьян новой, которая еще не участвовала в эксперименте. Когда она попыталась воспользоваться лестницей, другие обезьяны напали на неё. Обезьяна не знала, почему именно на неё напали, но быстро поняла, что к лестнице лучше не подходить. Постепенно ученые заменили всех обезьян новыми, пока не образовалась группа из обезьян, которых никогда не обливали холодной водой, но они все равно нападали на любую обезьяну, пытающуюся приблизиться к лестнице.

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

Доступ к неизменяемым public-полям устраняет накладные расходы, связанные с вызовами методов getXXX(). Стоит также отметить, что компилятор не позволит присвоить значения примитивным переменным, а если попытаться вызвать метод, чтобы изменить состояние коллекции streets, то возникнет исключительная ситуация UnsupportedOperationException (которая перехватывается вверху теста). Использование такого стиля программирования служит четким указателем, что был создан именно неизменяемый класс.

Недостатки подхода

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

  • Как мне указал Гленн Вандербург (Glenn Vanderburg), основным недостатком подобного подхода является то, что он нарушает принцип унифицированного доступа (Uniform Access Principle – термин, предложенный Бертраном Мейером (Bertrand Meyer), создателем языка программирования Eiffel). Этот принцип гласит: "все сервисы, предоставляемые модулем, должны быть доступны через унифицированную нотацию, которая скрывала бы подробности их реализации: через хранимые или вычисляемые данные". Другими словами, при доступе к полю не должно раскрываться действительно ли это поле или это метод, возвращающий значение. Метод getStreets() класса Address не унифицирован с другими полями. Эту проблему невозможно полностью решить в Java, но она решается в некоторых других языках на основе JVM с помощью различных способов реализации неизменяемости.
  • Некоторые инфраструктуры чрезмерно полагаются на Reflections API, который не работает с данным подходом, так как требует наличия конструктора по умолчанию.
  • Поскольку мы создаем новые объекты, а не изменяем существующие, в приложениях с большим количеством обновлений это может привести к неэффективной "сборке мусора". Языки, подобные Clojure, обладают встроенными возможностями, позволяющими повысить эффективность этого процесса за счет неизменяемых ссылок, которые применяются в этих языках по умолчанию.

Неизменяемость в Groovy

При создании Groovy-версии класса Address с неизменяемыми public полями получается довольно аккуратная реализация, показанная в листинге 6.

Листинг 6. Неизменяемый класс Address в Groovy
class Address {
    def public final List<String> streets;
    def public final city;
    def public final state;
    def public final zip;

    def Address(streets, city, state, zip) {
        this.streets = streets;
        this.city = city;
        this.state = state;
        this.zip = zip;
    }

    def getStreets() {
        Collections.unmodifiableList(streets);
    }
}

Как обычно, в Groovy требуется меньше связующего кода, чем в Java, но присутствуют и другие преимущества. Так как Groovy позволяет создавать свойства, используя знакомый get/set-синтаксис, можно создать по настоящему защищенное свойство для объектной ссылки. Рассмотрим unit-тесты, показанные в листинге 7.

Листинг 7. Unit-тесты, демонстрирующие унифицированный доступ в Groovy
class AddressTest {
    @Test (expected = ReadOnlyPropertyException.class)
    void address_primitives_immutability() {
        Address a = new Address(
            ["201 E Randolph St", "25th Floor"], "Chicago", "IL", "60601")
        assertEquals "Chicago", a.city
        a.city = "New York"
    }

    @Test (expected=UnsupportedOperationException.class)
    void address_list_references() {
        Address a = new Address(
            ["201 E Randolph St", "25th Floor"], "Chicago", "IL", "60601")
        assertEquals "201 E Randolph St", a.streets[0]
        assertEquals "25th Floor", a.streets[1]
        a.streets[0] = "404 W Randoph St"
    }
}

Стоит отметить, что в обоих случаях тест завершается, когда из-за нарушения контракта неизменяемости возникает исключительная ситуация. Однако в листинге 7 поле streets выглядит так же, как и поля с примитивными значениями, хотя на самом деле оно защищено методом getStreets().

Groovy-аннотация @Immutable

Одним из базовых принципов, декларируемых в этой серии статей, является утверждение, что функциональные языки могут "взять на себя" низкоуровневые аспекты реализации. Хорошим примером этого служит аннотация @Immutable, добавленная в Groovy начиная с версии 1.7, которая делает весь код из листинга 6 ненужным. В листинге 8 показан класс Client, использующий эту аннотацию.

Листинг 8. Неизменяемый класс Client
@Immutable
class Client {
    String name, city, state, zip
    String[] streets
}

Благодаря аннотации @Immutable класс получает следующие свойства:

  • он становится final, т.е. запрещается его наследование;
  • свойства класса автоматически получают зарезервированные private-поля со сгенерированными методами getXXX();
  • любая попытка изменить свойство приводит к возникновению ReadOnlyPropertyException;
  • Groovy создает два конструктора: принимающий набор отдельных параметров и принимающий коллекцию типа Map;
  • классы коллекций помещаются в соответствующие оболочки, а массивы (или другие клонируемые объекты) клонируются;
  • автоматически генерируются реализации по умолчанию для методов equals, hashcode и toString.

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

Листинг 9. Аннотация @Immutable обеспечивает корректную обработку предполагаемых сценариев
@Test (expected = ReadOnlyPropertyException)
void client_object_references_protected() {
    def c = new Client([streets: ["201 E Randolph St", "Ste 25"]])
    c.streets = new ArrayList();
}

@Test (expected = UnsupportedOperationException)
void client_reference_contents_protected() {
    def c = new Client ([streets: ["201 E Randolph St", "Ste 25"]])
    c.streets[0] = "525 Broadway St"
}

@Test
void equality() {
    def d = new Client(
        [name: "ACME", city:"Chicago", state:"IL",
         zip:"60601",
         streets: ["201 E Randolph St", "Ste 25"]])
    def c = new Client(
            [name: "ACME", city:"Chicago", state:"IL",
             zip:"60601",
             streets: ["201 E Randolph St", "Ste 25"]])
    assertEquals(c, d)
    assertEquals(c.hashCode(), d.hashCode())
    assertFalse(c.is(d))
}

Попытка заменить объектную ссылку приводит к возникновению ReadOnlyPropertyException. А попытка изменить объект, на который указывает одна из инкапсулированных объектных ссылок, приводит к возникновению UnsupportedOperationException. Данная аннотация также создает корректные реализации методов equals() и hashcode(), как показано в последнем тесте: содержимое объектов совпадает, но они находятся по разным ссылкам.

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


Преимущества неизменяемости

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

Неизменяемые классы избавляют от множества проблем, существующих в Java. Одно из преимуществ перехода на функциональное мышление состоит в осознании факта, что тесты существуют для того, чтобы проверить, успешно ли было выполнено изменение в коде. Другими словами, настоящая цель тестирования – это проверка изменений, и чем больше у вас изменений, тем больше тестов вам требуется, чтобы проверить, что изменения выполняются правильно. Если изолировать места, в которых происходят изменения, существенно ограничив их количество, то останется не так уж и много мест, в которых могут возникнуть ошибки, а значит, и тестов также потребуется меньше. Поскольку изменения происходят только в момент создания, неизменяемые классы делают написание unit-тестов тривиальной задачей. Не требуется создавать копирующие конструкторы или вдаваться в подробности реализации метода clone(). Неизменяемые объекты хорошо подходят для использования в качестве ключей в коллекциях типа Map или Set. Значение ключа в коллекциях-словарях в Java не может изменяться, пока он находится в коллекции, поэтому из неизменяемых объектов получаются отличные ключи.

Неизменяемые объекты также автоматически защищены при параллельном исполнении и не имеют проблем с синхронизацией. Они не могут находиться в неизвестном или нежелательном состоянии, так как это приведет к возникновению исключительной ситуации. Поскольку инициализация происходит в момент создания, а эта операция в Java является атомарной, любое исключение возникнет до того, как вы получите экземпляр объекта. Джошуа Блох (Joshua Bloch) назвал такую ситуацию атомарностью ошибки (failure atomicity): в процессе создания объекта всегда точно известно, прошло ли его создание успешно или возникла ошибка, связанная с изменчивостью (см. раздел "Ресурсы").

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

Ресурсы

  • Functional thinking: Immutability: оригинал статьи (EN).
  • The Productive Programmer (Neal Ford, O'Reilly Media, 2008 г.): новая книга Нила Форда, в которой более подробно рассматриваются некоторые вопросы из данной серии статей.
  • Clojure: современная функциональная реализация Lisp, работающая на JVM.
  • Rich Hickey Q&A: интервью с Ричем Хики, создателем Clojure.
  • Подкаст: Стюарт Хэллоуэй о Clojure: узнайте больше о Clojure и о двух основных причинах, способствующих его широкому распространению и росту его популярности.
  • Scala: современный функциональный язык программирования на основе JVM.
  • The busy Java developer's guide to Scala: узнайте больше о Scala из серии статей Теда Ньюарда, также опубликованной на developerWorks.
  • Effective Java, 2d ed. (Joshua Bloch, Addison Wesley, 2008 г.): узнайте больше об атомарности ошибок.
  • Посетите магазин книг, посвященных ИТ и различным аспектам программирования.
  • Раздел Java-технологий на портале developerWorks содержит сотни статей обо всех аспектах Java-программирования.
  • Следите за публикациями developerWorks в Твиттере.

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=813077
ArticleTitle=Функциональное мышление: Неизменяемость
publish-date=05052012