Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Джош Блох

.pdf
Скачиваний:
57
Добавлен:
08.03.2016
Размер:
27.13 Mб
Скачать

Глава 4 Классы и интерфейсы

public class Complex { private final double re; private final double im;

private Complex(double re, double im) { this, re = re;

this.im = im;

}

public static Complex valueOf(double re, double im) { return new Complex(re, im);

}

... // Остальное не изменилось

}

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

Как показано в статье 1, статические методы генерации объектов имеют много других преимуществ по сравнению с конструкторами. Предположим, что вы хотите создать механизм генерации комплекс­ ного числа, отталкиваясь от его полярных координат. Использовать здесь конструкторы плохо, поскольку окажется, что собственный конструктор класса Complex будет иметь ту же самую сигнатуру, ко­ торую мы только что применяли: Complex(flo a t, float). Со стати­ ческими методами генерации все проще: достаточно просто добавить второй статический метод генерации с таким названием, которое чет­ ко обозначит его функцию:

110

С татья 15

public static Complex valueOfPolar(double r, double theta) { return new Complex(r * Math.cos(theta)),

(r * Math.sin(theta));

}

Когда писались классы Biglnteger и BigDecimal, не было оконча­ тельного согласия в том, что неизменяемые классы должны быть фак­ тически окончательными. Поэтому любой метод этих классов можно переопределить. К сожалению, исправить что-либо впоследствии уже было нельзя, не потеряв при этом совместимость версий снизу вверх. Поэтому, если вы пишете класс, безопасность которого зависит от не­ изменяемости аргумента с типом Biglnteger или BigDecimal, получен­ ного от ненадежного клиента, вы должны выполнить проверку и убе­ диться в том, что этот аргумент действительно является «настоящим» классом Biglnteger или BigDecimal, а не экземпляром какого-либо ненадежного подкласса. Если имеет место последнее, вам необходи­ мо создать резервную копию этого экземпляра, поскольку придется исходить из того, что он может оказаться изменяемым (статья 39):

public static Biglnteger safeInstance(BigInteger val) { if (val.getClass() != Biglnteger.class)

return new Biglnteger(val.toByteArrayO); return val;

}

Список правил для неизменяемых классов, представленный в начале статьи, гласит, что ни один метод не может модифициро­ вать объект и все поля должны быть окончательными. Эти правила несколько строже, чем это необходимо, и их можно ослабить с целью повышения производительности программы. Действительно, ни один метод не может произвести такие изменение состояния объекта, ко­ торое можно было бы увидеть извне. Вместе с тем многие неиз­ меняемые классы имеют одно или несколько избыточных полей без модификатора final, в которых они сохраняют однажды полученные результаты трудоемких вычислений. Если в дальнейшем потребует­ ся произвести те же самые вычисления, то будет возвращено ранее

111

read-

Глава 4 Классы и интерфейсы

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

Например, метод hashCode из класса PhoneNumber (статья 9) вы­ числяет хэш-код. Получив код в первый раз, метод сохраняет его на тот случай, если хэш-код потребуется вычислять снова. Такая методика, представляющая собой классический пример отложенной инициализации (статья 71), используется также и в классе String.

Следует добавить одно предостережение, касающееся сериализуемости объектов. Если вы решили, чтобы ваш неизменяемый класс должен реализовывать интерфейс Serializable и при этом у него есть одно или несколько полей, которые ссылаются на изменяемые объ­ екты, то вы обязаны предоставить явный метод readObject или

Resolve или использовать методы ObjectOutputStrem.writellnshared и ObjectlnputStrem. readllnshared, даже если для этого класса можно

использовать сериализуемую форму, предоставляемую по умолчанию. В противном случае может быть создан изменяемый экземпляр вашего неизменяемого класса. Эта тема детально раскрывается в статье 76.

Подведем итоги. Не стоит для каждого метода get писать метод set. Классы должны оставаться неизменяемыми, если нет уж со­ всем веской причины сделать их изменяемыми. Неизменяемые классы имеют массу преимуществ, единственный же их недоста­ ток — возможные проблемы с производительностью при определен­ ных условиях. Небольшие объекты значений, такие как PhoneNumber или Complex, всегда следует делать неизменяемыми. (В библиотеках для платформы Java есть несколько классов, например java. util. Date и java. awt. Point, которые должны были бы стать неизменяе­ мыми, но таковыми не являются.) Вместе с тем вам следует серьезно подумать, прежде чем делать неизменяемыми более крупные объек­ ты значений, такие как String или Biglnteger. Создавать для вашего неизменяемого класса открытый изменяемый класс-компаньон сле­

U2

С тать я 15

дует только тогда, когда вы убедитесь в том, что это необходимо для получения приемлемой производительности (статья 55).

Есть классы, которым неизменяемость не нужна, например классы-процессы Thread и TimerTask. Если класс невозможно сде­ лать неизменяемым, вы должны ограничить его изменяемость, насколько это возможно. Чем меньше число состояний, в которых может находиться объект, тем проще рассматривать этот объект, тем меньше вероятность ошибки. По этой причине конструктор такого класса должен создавать полностью инициализированный объ­ ект, у которого все инварианты уже установлены.

Конструктор не должен передавать другим методам класса объект, сформированный частично. Не создавайте открытый метод инициализации отдельно от конструктора, если только для этого нет чрезвычайно веской причины. Точно так же не следует создавать метод «повторной инициализации», который позволил бы использо­ вать объект повторно, как если бы он был создан с другим исходным состоянием. Метод повторной инициализации обычно дает (если во­ обще дает) лишь небольшой выигрыш в производительности за счет увеличения сложности приложения.

Перечисленные правила иллюстрирует класс TimeTask. Он яв­ ляется изменяемым, однако пространство его состояний намеренно оставлено небольшим. Вы создаете экземпляр, задаете порядок его выполнения и, возможно, отменяете это решение. Как только задача, контролируемая таймером, была запушена на исполнение или отме­ нена, повторно использовать его вы уже не можете.

Последнее замечание, которое нужно сделать в этой статье, ка­ сается класса Complex. Этот пример предназначался лишь для того, чтобы проиллюстрировать свойство неизменяемости. Он не облада­ ет достоинствами промышленной реализации класса комплексных чисел. Для умножения и деления комплексных чисел он использует обычные формулы, для которых нет правильного округления, кото­ рые имеют скудную семантику для комплексных значений NaN и бес­ конечности [Kahan91, Smith62, Thomas94],

113

Глава 4 Классы и интерфейсы

Предпочитайте компоновку наследованию

Наследование ( inheritance) — это мощный способ добиться мно­ гократного использования кода, но не всегда лучший инструмент для работы. При неправильном применении наследование приводит к появлению ненадежных программ. Наследование можно безопасно использовать внутри пакета, где реализация и подкласса, и суперк­ ласса находится под контролем одних и тех же программистов. Столь же безопасно пользоваться наследованием, когда расширяемые классы специально созданы и документированы для последующего расширения (статья 17). Однако наследование обыкновенных не аб­ страктных классов за пределами пакета сопряжено с риском. Н а­ помним, что в этой книге слово «наследование» (inheritance) исполь­ зуется для обозначения наследование реализации (implementation inheritance), когда один класс расширяет другой. Проблемы, об­ суждаемые в этой статье, не касаются наследования интерфейса (interface inheritance), когда класс реализует интерфейс или же один интерфейс расширяет другой.

В отличие от вызова метода наследование нарушает инкапсу­ ляцию [Snyder86]. Иными словами, правильное функционирование подкласса зависит от деталей реализации его суперкласса. Реали­ зация суперкласса может меняться от версии к версии, и, если это происходит, подкласс может сломаться, даже если его код и остался в неприкосновенности. Как следствие, подкласс должен развивать­ ся вместе со своим суперклассом, если только авторы суперкласса не спроектировали и не документировали его специально для после­ дующего расширения.

Предположим, что у нас есть программа, использующая класс HashSet. Для повышения производительности нам необходимо запра­ шивать у HashSet, сколько элементов было добавлено с момента его создания (не путать с его текущим размером, который при удале­ нии элемента уменьшается). Чтобы обеспечить такую возможность,

114

С татья 16

мы пишем вариант класса HashSet, который содержит счетчик коли­ чества попыток добавления элемента и предоставляет метод доступа к этому счетчику. В классе HashSet есть два метода, с помощью ко­ торых можно добавлять элементы: add и addАН.Переопределим оба метода:

// Ошибка: неправильное использование наследования! public class InstrumentedHashSet<E> extends HashSet<E> {

// Число попыток вставить элемент private int addCount = 0;

public InstrumentedHashSet() {

}

public InstrumentedHashSet(Collection c) { super(c);

}

public InstrumentedHashSet(int initCap, float loadFactor) { super(initCap, loadFactor);

}

@0verride public boolean add(E e) { addCount ++;

return super.add(e);

}

(©Override public boolean addAll(Collection <? Extends E> c) { addCount += c.size();

return super.addAll(c);

}

public int getAddCount() { return addCount;

}

}

Представленный класс кажется правильным, но не работает. Предположим, что мы создали один экземпляр и с помощью метода addAll поместили в него три элемента:

InstrumentedHashSet <String> s = new InstrumentedHashSet(); s.addAll(Arrays.asList(“Snap”, "Crackle”, "Pop”});

115

Глава 4 Классы и интерфейсы

Мы могли предположить, что после этого метод getAddCount должен возвратить число 3, но он возвращает 6. Что же не так? Внутри класса HashSet метод addAll реализован поверх его метода add, хотя в документации эта деталь реализации не отражена, что вполне оправданно. Метод addAll в классе InstrumentedHashSet до­ бавил к значению поля addCount число 3. Затем с помощью super. addAll была вызвана реализация addAll в классе HashSet. В свою оче­ редь это влечет вызов метода add, переопределенного в классе In­ strumentedHashSet — по одному разу для каждого элемента. Каждый из этих трех вызовов добавлял к значению addCount еще единицу, так что и итоге общий прирост составляет шесть: добавление каждого элемента с помощью метода addAll засчитывалось дважды.

Мы могли бы «исправить» подкласс, отказавшись от переопре­ деления метода addAll. Полученный класс и будет работать, правиль­ ность его работы зависит от того обстоятельства, что метод addAll в классе HashSet реализуется поверх метода add. Такое «использова­ ние самого себя» является деталью реализации, и нет гарантии, что она будет сохранена во всех реализациях платформы Java, не поменя­ ется при переходе от одной версии к другой. Соответственно, полу­ ченный класс InstrumentedHashSet может быть ненадежен.

Ненамного лучшим решением будет переопределение addAll в ка­ честве метода, который в цикле просматривает представленный на­ бор и для каждого элемента один раз вызывает метод add. Это может гарантировать правильный результат независимо от того, реализо­ ван ли метод addAll в классе HashSet поверх метода add, поскольку реализация addAll в классе HashSet больше не применяется. Однако и такой прием не решает всех наших проблем. Он подразумевает по­ вторную реализацию методов суперкласса, которые могут проводить, а могут не приводить к использованию классом самого себя. Этот вариант сложен, трудоемок и подвержен ошибкам. К тому же это не всегда возможно, поскольку некоторые методы нельзя реализо­ вать, не имея доступа к закрытым полям, которые недоступны для подкласса.

116

С тать я 16

Еще одна причина ненадежности подклассов связана с тем, что в новых версиях суперкласс может обзавестись новыми методами. Предположим, безопасность программы зависит от того, чтобы все элементы, помещенные в некую коллекцию, соответствовали некое­ му утверждению. Выполнение этого условия можно гарантировать, создав для этой коллекции подкласс, переопределив в нем все мето­ ды, добавляющие элемент, таким образом, чтобы перед добавлением элемента проверялось его соответствие рассматриваемому утвержде­ нию. Такая схема работает замечательно до тех пор, пока в следую­ щей версии суперкласса не появится новый метод, который также может добавлять элемент в коллекцию. Как только это произойдет, станет возможным добавление «незаконных» элементов в экземпляр подкласса простым вызовом нового метода, который не был перео­ пределен в подклассе. Указанная проблема не является чисто теоре­ тической. Когда производился пересмотр классов Hashtable и Vector для включения в архитектуру Collections Framework, пришлось за­ крывать несколько дыр такой природы, возникших в системе безо­ пасности.

Обе описанные проблемы возникают из-за переопределения методов. Вы можете решить, что расширение класса окажется без­ опасным, если при добавлении в класс новых методов воздержитесь от переопределения уже имеющихся. Хотя расширение такого рода гораздо безопаснее, оно также не исключает риска. Если в очередной версии суперкласс получит новый метод, но окажется, что вы, к со­ жалению, уже имеете в подклассе метод с той же сигнатурой, но дру­ гим типом возвращаемого значения, то ваш подкласс перестанет ком­ пилироваться [JLS, 8.4.8.3]. Если же вы создали в подклассе метод с точно такой же сигнатурой, как и у нового метода в суперклассе, то переопределите последний и опять столкнетесь с обеими описан­ ными выше проблемами. Более того, вряд ли ваш метод будет отве­ чать требованиям, предъявляемым к новому методу в суперклассе, так как, когда вы писали этот метод в подклассе, они еще не были сформулированы.

117

Глава 4 • Классы и интерфейсы

К счастью, есть способ устранить все описанные проблемы. Вме­ сто того чтобы расширять имеющийся класс, создайте в вашем новом классе закрытое поле, которое будет содержать ссылку на экземпляр прежнего класса. Такая схема называется композицией (composition), поскольку имеющийся класс становится частью нового класса. Каж­ дый экземпляр метода в новом классе вызывает соответствующий метод содержащегося здесь же экземпляра прежнего класса, а затем возвращает полученный результат. Это называется передачей вызова (.forwarding), а соответствующие методы нового класса носят назва­ ние методов переадресации (forwarding methods). Полученный класс будет прочен, как скала: он не будет зависеть от деталей реализации прежнего класса. Даже если к имевшемуся прежде классу будут до­ бавлены новые методы, на новый класс это не повлияет. В качестве конкретного примера использования метода компоновки/переадре­ сации представим класс, который заменяет InstrumentedHashSet. О б­ ратите внимание, что реализация разделена на две части: сам класс и многократно используемый класс переадресации, который содер­ жит все методы переадресации и больше ничего:

// Класс-оболочка - вместо наследования используется композиция public class InstrumentedSet<E> extends ForwardingSet<E> {

private int addCount = 0;

public InstrumentedSet(Set<E> s) { super(s);

}

@0verride public boolean add(E e) { addCount++;

return super.add(e);

}

@0verride public boolean addAll(Collection<? extends E> c) { addCount += c.size();

return super.addAll(c);

}

public int getAddCount() { return addCount;

118

С тать я 16

)

}

// Многократно используемый класс переадресации public class ForwardingSet<E> implements Set<E> {

private final Set<E> s;

public ForwardingSet(Set<E> s) { this.s = s; } public void clear() { s.clear(); }

public boolean contains(Object o) { return s.contains(o); } public boolean isEmptyO { return s.isEmptyO; }

public int size() { return s.size(); }

public Iterator<E> iterator() { return s.iterator(); } public boolean add(E e) { return s.add(e); }

public boolean remove(Object o) { return s.remove(o); } public boolean containsAll(Collection<?> c)

{ return s.containsAll(c); }

public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }

public boolean removeAll(Collection<?> c) { return s.removeAll(c); }

public boolean retainAll(Collection<?> c) { return s.retainAll(c); }

public Object[] toArrayO { return s.toArrayO; } public <T> T[] toArray(T[] a) { return s.toArray(a); } @0verride public boolean equals(Object o)

{ return s.equals(o); }

@0verride public int hashCode() { return s.hashCode(); }

Создание класса InstrumentedSet стало возможным благодаря наличию интерфейса Set, в котором собраны функциональные воз­ можности класса Hash Set. Данная реализация не только устойчива, но и чрезвычайно гибка. Класс Inst rumentedSet реализует интерфейс Set и имеет единственный конструктор, аргумент которого также имеет тип Set. В сущности, представленный класс преобразует один интерфейс Set в другой, добавляя возможность выполнять измере­ ния. В отличие от подхода, использующего наследование, который работает только для одного конкретного класса и требует отдель­

119

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]