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

Джошуа Блох - Эффективное программирование

.pdf
Скачиваний:
1622
Добавлен:
05.02.2016
Размер:
1.1 Mб
Скачать

64

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

Единственный настоящий недостаток неизменяемых классов заключается в том, что для каждого уникального значения им нужен отдельный объект. Создание таких объектов может потребовать больших ресурсов, особенно если они имеют значительные размеры. Предположим, что у вас есть объект Biglnteger размером в миллион битов и вы хотите логически дополнить его младший бит:

Biglnteger mоbу = ...; Mоbу = moby.flipBit(0);

Метод flip8it создает новый экземпляр класса 8iglnteger длиной также в миллион битов, который отличается от своего оригинала только одним битом. Этой операции требуются время и место, пропорциональные размеру экземпляра 8iglnteger. Противоположный подход использует java.util.BitSet. Как и Biglnteger, BitSet представляет последовательность битов произвольной длины, однако, в отличие от BigInteger, BitSet является изменяемым классом. В классе BitSet предусмотрен метод, позволяющий в экземпляре, содержащем миллионы битов, менять значение отдельного бита в течение фиксированного времени.

Проблема производительности усугубляется, когда вы выполняете многошаговую операцию, генерируя на каждом этапе новый объект, а в конце отбрасываете все эти объекты, оставляя только окончательный результат. Справиться с этой проблемой можно двумя способами. Во-первых, можно догадаться, какие многошаговые операции будут требоваться чаще всего, и представить их в качестве элементарных. Если многошаговая операция реализована как элементарная (primitive), неизменяемый класс уже не обязан на каждом шаге создавать отдельный объект. Изнутри неизменяемый класс может быть сколь угодно хитроумным. Например, у класса Biglnteger есть изменяемый "класс-компаньон", который доступен только в пределах пакета и применяется для ускорения многошаговых операций, таких как возведение в степень по модулю. По всем перечисленным выше причинам использовать изменяемый класс-компаньон гораздо сложнее. Однако делать этого вам, к счастью, не надо. Разработчики класса Biglnteger уже выполнили за вас всю тяжелую работу.

Описанный прием будет работать превосходно, если вам удастся точно предсказать, какие именно сложные многошаговые операции с вашим неизменяемым классом будут нужны клиентам. Если сделать это невозможно, самый лучший вариант создание открытого изменяемого классакомпаньона. В библиотеках для платформы Java такой подход демонстрирует класс String, для которого изменяемым классом Компаньоном является StringBuffer. В силу ряда причин BitSet вряд ли играет роль Изменяемого компаньона для Biglnteger.

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

65

Помимо возможности сделать класс окончательным (final), есть еще два способа решения этой проблемы. Первый способ: вместо того, чтобы делать окончательным сам класс, сделать окончательными все его методы, пометив как final. Единственное преимущество данного подхода заключается в том, что он позволяет программистам расширять класс, добавляя к нему новые методы, выстроенные поверх старых. По эффективности это то же самое, что вводить ,новые методы как статические в отдельном вспомогательном классе, не создающем экземпляров (статья 3), а потому использовать такой подход не рекомендуется.

Второй прием заключается в том, чтобы сделать все конструкторы неизменяемого класса закрытыми либо доступными только в пакете и вместо открытых конструкторов использовать открытые статические методы генерации (статья 1). Для пояснения представим, как бы выглядел класс Complex, если бы применялся такой подход:

//Неизменяемый класс со статическими методами генерации

//вместо конструкторов

publiC class Complex { private final float ге; private final float im;

private Complex(float ге, float im) { this. ге = ге;

this.im = im;

public static Соmрlех valueOf(float ге, float im) {

return new Сотрlех(ге, im);}

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

}

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

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

66

Со статическими методами генерации все проще - достаточно добавить второй статический метод генерации с таким названием, которое четко обозначит его функцию:

public static Complex valueOfPolar(float г, float theta) {

return new Complex((float) (r * Math.cos(theta)), (float) (r * Math,sin(theta»);

}

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

public void foo(BigInteger b) {

if (b.getClass() != BigInteger.class)

b = new BigInteger(b.toByteArray());

}

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

Например, метод hashCode из класса PhoneNumbeT (статья 8) вычисляет хэш-код.

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

67

Приведем общую идиому для кэширующей функции с отложенной инициализацией для неизменяемого объекта:

//Кэширующая функция с отложенной инициализацией

//для неизменяемого объекта

.private volatile Foo cachedFooVal = UNLIKELY_FOO_VALUE;

publlic Foo foo() {

Foo result = cachedFooVal;

if (result == UNLIKELY_FOO_VALUE) result = cachedFooVal = fooValue(); return result;

}

//Закрытая вспомогательная функция, вычисляющая

//значение нашего объекта foo

private Foo fooVal() { ... }

Следует добавить одно предостережение, касающееся сериализуемости объектов. Если вы решили, что ваш неизменяемый класс должен реализовывать интерфейс Sеrializable, но при этом у него есть одно или несколько полей, которые ссылаются на изменяемые объекты, то вы обязаны предоставить явный метод readObject или readResolve, даже если для этого класса можно использовать сериализуемую форму, предоставляемую по умолчанию. Метод readObject, применяемый по умолчанию, позволил бы пользователю создать изменяемый экземпляр вашего во всех остальных ситуациях неизменяемого класса. Эта тема детально раскрывается в статье 56.

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

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

68

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

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

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

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

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

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

69

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

// Ошибка: неправильное использование наследования!

public class InstrumentedHashSet extends HashSet { // Число попыток вставить элемент

private int addCount = 0;

public InstrumentedHashSet() {

}

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

}

public InstrumentedHashSet(int initCap, float loadFactor) {

super(initCap, loadFactor);

}

public boolean add(Object о) {

addCount ++;

return super.add(o);

}

public boolean addAll(Collection с) {

addCount += c.size(); return super.addAll(c);

}

publlic int getAddCount()

return addCount;

}

}

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

InstrumentedHashSet s = new InstrumentedHashSet(); s.addAll(Arrays.asList(new String[] {"Snap", "Crackle", "Рор"}));

Мы предполагаем, что метод getAddCount должен возвратить число 3, но он возвращает 6. Что же

не так? Внутри класса HashSet метод addAll реализован прверх его метода add, хотя в документации эта деталь реализации не отражена, что вполне оправданно.

70

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

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

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

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

Обе описанные проблемы возникают из-за переопределения методов. Вы можете решить, что расширение класса окажется безопасным, если при добавлении в класс новых методов воздержаться от переопределения уже имеющихся. Хотя расширение такого рода гораздо безопаснее, оно также не исключает риска.

71

Если в очередной версии суперкласс получит новый метод, но окажется, что вы, к сожалению, уже

имеете в подклассе метод с той же сигнатурой, но с другим типом возвращаемого значения, то ваш подкласс перестанет компилироваться [ILS,8.4.6.3]. Если же вы создали в подклассе метод с

точно такой же сигнатурой, как и у нового метода в суперклассе, то переопределите последний и опять столкнетесь с обеими описанными выше проблемами. Более того, вряд ли ваш метод будет отвечать требованиям, предъявляемым к новому методу в суперклассе, так как, когда вы писали этот метод в подклассе, они еще не были сформулированы.

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

// Класс-оболочка: вместо наследования используется композиция

public class InstrumentedSet implements Set { private final Set s;

private int addCount = 0; public InstrumentedSet(Set s) {

this.s = s; }

public boolean add(Object о){

addCount ++;

return s. add(o); }

public boolean addAll(Collections с) {

addCount += c.size(); return s.addAll(c); }

public int getAddCount(){

return addCount; }

//Методы переадресации public void clear() { s.clear(); }

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

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

public Iterator iterator() { return s.iterator(); }

public boolean remove(Object о) { return s. геmоуе(о); }

public boolean containsAll(Collection с) { return s.containsAll(c); public boolean removeAll(Collection с) { return s.removeAll(c); } public boolean retainAll(Collection с) { return s. retainAll(c); } public Object[] toArray() { return s.toArray(); }

pUblic Object[] toArray(Object[] а) { return s.toArray(a); } public boolean equals(Object о) { return s.equals(o); } public 1nt hashCode() { return s.hashCode(); }

public Str1ng toStr1ng() { return s.toStr1ng(); }

72

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

Set s1 = new InstrumentedSet(new TreeSet(list));

Set s2 = new InstrumentedSet(new HashSet(capacity, loadFactor));

Класс Inst rumentedSet можно применять даже для временного оснащения экземпляра Set, который до сих пор не пользовался этими функциями:

static void f(Set s) {

InstrumentedSet sInst = new InstrumentedSet(s);

// Внутри этого метода вместо s используем sInst

}

Класс InstrumentedSet называется классом-оболочкои (wrapper), поскольку Каждый экземпляр InstrumentedSet является оболочкой для другого экземпляра Set. Он также известен как шаблон Decorator (декоратор) [Саmmа95, стр. 175], класс InstrumentedSet "украшает" Set, добавляя ему новые функции. Иногда сочетание композиции и переадресации ошибочно называют делегuрованuем (delegation). Однако формально назвать это делегированием нельзя, если только объект-оболочка не передает себя "обернутому" объекту [Саmmа95, стр.20].

73

Недостатков у классов-оболочек немного. Первый связан с тем, что классы-оболочки не приспособлены для использования в схемах с обратным вызовом (callback framework), где один объект передает другому объекту ссылку на самого себя для последующего вызова (callback - обратный вызов). Поскольку обернутый объект не знает о своей оболочке, он передает ссылку на самого себя (this), и, как следствие, обратные вызовы минуют оболочку. Это называется проблемои самоидентификации (SELF рroblеm) [Lieberman86]. Некоторых разработчиков беспокоит влияние методов переадресации на производительность системы, а также влияние объектов-оболочек на расход памяти. На практике же ни один из этих факторов не оказывает существенного влияния. Писать методы переадресации несколько утомительно, однако это частично компенсируется тем, что вам нужно создавать лишь один конструктор.

Наследование уместно только в тех случаях, когда подкласс действительно является подтипом (subtype) суперкласса. Иными словами, класс В должен расширять класс А только тогда, когда между двумя этими классами существует отношение типа "является". Если вы хотите сделать класс В расширением класса А, задайте себе вопрос: "Действительно ли каждый В является А?" Если вы не можете с уверенностью ответить на этот вопрос утвердительно, то В не должен расширять А. Если же ответ отрицательный, часто это оказывается, что В должен иметь закрытый от всех экземпляр А и предоставлять при этом меньший по объему и более простой АР!: А не является необходимой частью В, это лишь деталь его реализации.

В библиотеках для платформы Java имеется множество очевидных нарушений этого принципа. Например, стек не является вектором, соответственно класс Stack не должен быть расширением класса Vector. Точно так же список свойств не является хэш-таблицей, а потому класс Properties не должен расширять Hashtable. В обоих случаях более уместной была бы композиция.

Используя наследование там, где подошла бы композиция, вы безо всякой необходимости раскрываете детали реализации. Получающийся при этом АРI привязывает вас к первоначальной реализации, навсегда ограничивая производительность вашего класса. Более серьезно то, что: демонстрируя внутренние элементы класса, вы позволяете клиенту обращаться к ним напрямую. Самое меньшее это может привести к запутанной семантике. Например, если р ссылается на экземпляр класса Properties, то р.getProperty(key) может давать совсем другие результаты, чем р. get(key): старый метод учитывает значения по умолчанию, тогда как второй метод, унаследованный от класса Hashtable, этого не делает. И самое серьезное: напрямую модифицируя. суперкласс, клиент получает возможность разрушать инварианты подкласса. В случае с классом Properties разработчики рассчитывали, что в качестве ключей и значений можно будет применять только строки, однако прямой доступ к базовому классу H.ashtable позволяет обходить это условие. Как только указанный инвариант нарушается, пользоваться другими элементами АРI для класса Properties (методами load и store) становится невозможно. Когда эта проблема была обнаружена, исправлять что-либо было слишком поздно, поскольку появились клиенты, работа которых Зависит от возможности применения ключей и значений, не являющихся строками.