Вообщем тривиальная слоган, его можно прочитать даже в достаточно старой Joshua Bloch: Effective Java, Item 15: Minimize mutability.
Под катом чутка банальности, так как сегодня доделал и наболело.
Сейчас занимался рефакторингом проекта, в частности улучшением/уменьшением кода синхронизации. И как одно из решений, несколько классов, которые должны были быть immutable - сделал таковыми. В большинстве случаев было просто, но есть один классик, который используется почти во всех частях системы. После удаления из него mutator'ов, получил 600 ошибок компиляции, после их разрешения, получил более полусотни падающих тестов. И вот тут самое интересное, что меня заставило написать пост. А именно, обнаружились тесты, которые были совершенно неправильные, они ничего не тестировали и скорее всего бы не упали при присутствие проблемы. Вот упрощённый примерчик, кое-чего подобного:
- public static enum Status {
- APPROVE, PROCESSED
- }
- public static class Bean {
- private int id;
- private Status status = Status.APPROVE;
- public Bean(int id) {
- this.id = id;
- }
- public void markProcessed() {
- status = Status.PROCESSED;
- }
- public int getId() {
- return id;
- }
- public Status getStatus() {
- return status;
- }
- ....
- }
- public static class BeanQueue {
- private Map
new TreeMapqueue = (); - public void enqueue(Bean bean) {
- if (bean == null) {
- return;
- }
- queue.put(bean.getId(), bean);
- }
- public void commit(List
ids) { - for (Integer id : ids) {
- if (id != null && queue.containsKey(id)) {
- queue.get(id).markProcessed();
- }
- }
- }
- public Bean get(Integer id) {
- if (id == null || !queue.containsKey(id)) {
- return null;
- }
- return queue.get(id);
- }
- ....
- }
Основной смысл: есть какой-то объект, который переходит из разных статусов и очередь этих объектов.
Вроде даже с большего выглядит не страшно. И его тестирует такой тест
- @Test
- public void testCommit() {
- BeanQueue queue = new BeanQueue();
- Bean bean1 = new Bean(1);
- queue.enqueue(bean1);
- Bean bean2 = new Bean(2);
- queue.enqueue(bean2);
- queue.commit(asList(1));
- assertEquals(Status.PROCESSED, bean1.getStatus());
- assertEquals(Status.APPROVE, bean2.getStatus());
- }
Тест конечно зелёненький, но если задуматься над его смыслом, это какой-то Ад кромешный.
А если мы вот так изменим метод commit
- public void commit(List
ids) { - for (Integer id : ids) {
- if (id != null && queue.containsKey(id)) {
- Bean bean = queue.get(id);
- queue.remove(id);
- bean.markProcessed();
- }
- }
- }
Тест продолжает гореть ярко зелёным цветом, и мы по прежнему уверены что у нас всё гуд.
Правда, надо отдать должное, как только я сделал immutable Bean, тест покраснел :)
- public static class Bean {
- private final int id;
- private final Status status;
- public Bean(final int id) {
- this(id, Status.APPROVE);
- }
- public Bean(final int id, final Status status) {
- this.id = id;
- this.status = status;
- }
- public Bean createProcessedBean() {
- return new Bean(id, Status.PROCESSED);
- }
- public int getId() {
- return id;
- }
- public Status getStatus() {
- return status;
- }
- }
Конечно же теперь тест падает
- java.lang.AssertionError: expected:
but was: - at org.junit.Assert.fail(Assert.java:91)
- at org.junit.Assert.failNotEquals(Assert.java:645)
- at org.junit.Assert.assertEquals(Assert.java:126)
- at org.junit.Assert.assertEquals(Assert.java:145)
- at Test.testCommit(Test.java:108)
Но он сигнализирует только о том, что он написан совершенно не правильно. Вообщем поинтов тут несколько. Главный: Item 15: Minimize mutability! Кроме того стоит отметить, что к unit тестам надо относиться серъёзно, их код должен быть ничуть не хуже, чем код приложения, иначе это впустую потраченное время и силы. Ну и по тестам конечно стоит в обязательном порядке прочитать Test Driven Development by Kent Beck, потому как это больше книга не по unit тестированию, а по архитектуре приложений. Если unit тесты допиливать как попало, когда уже код готов, это мало чем поможет, лучше чем ничего конечно, но...
Да, и так между прочим, паттер Builder, просто не заменим при работе с immutable объектами, он очень помог при наследовании. Если посмотреть на immutable версию Bean класса, то видна проблема, которая возникнет при наследовании. Если мы у потомка вызовем метод createProcessedBean, то он нам вернёт не копию потомка с измененным статусом, а папочку, т.о. мы потеряем не только тип, но и данные хранившиеся в потомке. Конечно, можно запретить final'ом наследование для immutable классов, но это не по джедайски. Так что был создан внутренний builder класс для Bean.
- protected static abstract class AbstractBeanBuilder<T extends Bean> {
- // mandatory
- protected final int id;
- // optional
- protected Status status;
- public AbstractBeanBuilder(T bean) {
- this.id = bean.id;
- this.status = bean.status;
- }
- public AbstractBeanBuilder<T> status(final Status status) {
- this.status = status;
- return this;
- }
- public abstract T build();
- }
Можем по цепочки менять все поля, а потом методом build сгенерировать immutable Bean. Просто прекрасно, ну и с учётом этого внесём поправки в наш bean class
- public Bean createProcessedBean() {
- return builder().status(Status.PROCESSED).build();
- }
- protected AbstractBeanBuilder<? extends Bean> builder() {
- return new BeanBuilder(this);
- }
- private static final class BeanBuilder extends AbstractBeanBuilder<Bean> {
- public BeanBuilder(Bean bean) {
- super(bean);
- }
- @Override
- public Bean build() {
- return new Bean(id, status);
- }
- }
Во-первых, когда например добавится ещё одно поле, придётся поменять лишь слегка Builder класс, куча методов, которые создают изменённые копии останутся в том же виде.
Во-вторых, самое главное, для чего всё это и делалось, если мы создадим наследника, и переопределим у него метод builder, который будет возвращать правильный Builder класс, то теперь наш метод createProcessedBean при вызове на потомке, будет возвращать именно копию потомка.
Примерчик:
- public class MyBean extends Bean {
- private final String info;
- public MyBean(final int id, final String info) {
- super(id);
- this.info = info;
- }
- public MyBean(final int id, final Status status, final String info) {
- super(id, status);
- this.info = info;
- }
- public MyBean createMyBeanWithInfo(final String info) {
- return builder().info(info).build();
- }
- public String getInfo() {
- return info;
- }
- @Override
- protected MyBeanBuilder builder() {
- return new MyBeanBuilder(this);
- }
- private static final class MyBeanBuilder extends AbstractBeanBuilder<MyBean> {
- private String info;
- public MyBeanBuilder(MyBean bean) {
- super(bean);
- this.info = bean.info;
- }
- public MyBeanBuilder info(final String info) {
- this.info = info;
- return this;
- }
- @Override
- public MyBean build() {
- return new MyBean(id, status, info);
- }
- }
- }
Всё достаточно просто, делаем свой билдер, потомок абстрактного и переопределяем метод builder, чтобы возвращать наш. Т.о., теперь когда мы вызовем у нашего потомка метод createProcessedBean, он нам вернёт копию нашего потомка с новым статусом. Вот пример кода и его результат.
- public static void main(String[] args) {
- MyBean m = new MyBean(1, "test");
- System.out.println(m);
- m = (MyBean) m.createProcessedBean();
- System.out.println(m);
- }
- output:
- MyBean [info=test, {Bean [id=1, status=APPROVE]}]
- MyBean [info=test, {Bean [id=1, status=PROCESSED]}]
А можно обойтись без билдера? К примеру, использовать generics в базовом Bean?
ОтветитьУдалитьПросто если для одного изменения нужно менять два класса, то кто-то может по итогу не заметить.
Без билдера можно обойтись, но
ОтветитьУдалить1. Тогда в наследнике придётся override'ить все методы, которые возвращают Bean, чтобы они возвращали ChildBean
2. К тому же билдер решает не только вопрос наследования, но и более удобного создания immutabile объекта.
MyBeanBuilder builder = new MyBeanBuilder(id);
// делаем что-нибудь что-бы определить статус
builder.status(status)
// делаем что-нибудь что-бы определить информационное поле
builder.info(info)
return builder.build();
При этом какие-то шаги и инициализация опциональных полей может быть пропущена.
С двумя полями не страшно, а вот если их как у нас было 7-9, то билдер более удобен и методы типа createProcessedBean лучше выглядят.
Чтобы минимизировать проблемы, я сделал две вещи:
1. Builder это static class Bean, т.е. находятся в одном месте.
2. У bean все поля final, поэтому если добавить ещё одно поле, то надо менять конструктор. и следовательно компиляция метода build так же упадёт. Что должно навести на мысли.
3. Добавил комментарий с примером, как необходимо наследоваться и использовать Bean класс.
Но, конечно, дублирование немного смущало по началу, но примерно такой пример видел в Effective Java, поэтому решил, что не так уж это страшно.
Без билдера можно обойтись, но
ОтветитьУдалить1. Тогда в наследнике придётся override'ить все методы, которые возвращают Bean, чтобы они возвращали ChildBean
2. К тому же билдер решает не только вопрос наследования, но и более удобного создания immutabile объекта.
MyBeanBuilder builder = new MyBeanBuilder(id);
// делаем что-нибудь что-бы определить статус
builder.status(status)
// делаем что-нибудь что-бы определить информационное поле
builder.info(info)
return builder.build();
При этом какие-то шаги и инициализация опциональных полей может быть пропущена.
С двумя полями не страшно, а вот если их как у нас было 7-9, то билдер более удобен и методы типа createProcessedBean лучше выглядят.
Чтобы минимизировать проблемы, я сделал две вещи:
1. Builder это static class Bean, т.е. находятся в одном месте.
2. У bean все поля final, поэтому если добавить ещё одно поле, то надо менять конструктор. и следовательно компиляция метода build так же упадёт. Что должно навести на мысли.
3. Добавил комментарий с примером, как необходимо наследоваться и использовать Bean класс.
Но, конечно, дублирование немного смущало по началу, но примерно такой пример видел в Effective Java, поэтому решил, что не так уж это страшно.
А можно обойтись без билдера? К примеру, использовать generics в базовом Bean?
ОтветитьУдалитьПросто если для одного изменения нужно менять два класса, то кто-то может по итогу не заметить.