47489

Java TM. Эффективное программирование

Книга

Информатика, кибернетика и программирование

Создание и уничтожение объектов Рассмотрите возможность замены конструкторов статическими методами генерации.16 Остерегайтесь методов finlize. Методы общие для всех объектов Переопределяя метод euls соблюдайте общие соглашения.24 Переопределяя метод equls Всегда переопределяйте hshCode.

Русский

2013-11-29

2.01 MB

16 чел.

Effective Java TM

Programming Language Guide

Joshua Bloch

ADDISON- WESLEY

   Java TM

             Эффективное программирование

         Джошуа Блох

                                       Издательство «Лори»

Благодарности

 

я благодарю Патрика Чана (Patrick Chan) за то, что он посоветовал мне написать эту книгу и подбросил идею Лайзе Френдли (Lisa Friendly), главному редактору серии, а также Тима Линдхолма (Tim Lindholm), технического редактора серии, и Майка Хендриксона (Mike Hendrickson), исполнительного редактора издательства Addison- Wesley Professional. Спасибо Лайзе, Тиму и Майку за их поддержку при реализации проекта, за сверхчеловеческое терпение и несгибаемую веру в то, что когда-нибудь я напишу эту книгу.

Я благодарю Джеймса Гослинга (James Gosling) и его незаурядную команду" за то, что они предоставили мне нечто значительное, о чем можно написать, а также многих разработчиков платформы Java, последователей ДжеЙмса. В особенности я благодарен моим коллегам по работе в компании Sun из Java Platform Тools and Libraries Group за понимание, одобрение и поддержку. В эту группу входят Эндрю Беннетт (Andrew Benriett), Джо Дарси Оое Darcy), Нил Гафтер (Neal Gafter), Айрис Гарсиа (Iris Garcia), Константин Кладко (Konstantin Кladko), Йена Литтл (Ian Little), Майк Маклоски (Mike McCloskey) и Марк Рейнхольд (Mark Reinhold). Среди бывших членов группы: Дзенгуа Ли (Zhenghua Li), Билл Мэддокс (Bill Maddox) и Нейвин Санджива (Naveen Sanjeeva).

Выражаю благодарность моему руководителю Эндрю Беннетту (Andrew Bennett) и директору Ларри Абрахамсу (Larry Abrahams) за полную и страстную поддержку этого проекта. Спасибо Ричу Грину (Rich Green), вице-президенту компании Java Software, за создание условий, при которых разработчики имеют возможность творить и публиковать свои труды.

Мне чрезвычайно повезло с самой лучшей, какую только можно вообразить, группой рецензентов, и я выражаю мои самые искренние благодарности каждому из них: Эндрю Беннетту (Andrew Bennett), Синди Блох (Cindy Вloch), Дэну Блох (Dan Вloch), Бет Ботос (Beth Bottos), Джо Баубиеру Оое Bowbeer), Джиладу Браче (Gilad Bracha), Мэри Кампьон (Mary Campione), Джо Дарси Оое Darcy), Дэвиду Экхардту (David Eckhardt), Джо Фьалли Оое Fialli), Лайзе Френдли (Lisa Friendly), Джеймсу Гослингу (James Gosling), Питеру Хаггеру (Peter Haggar), Брайену КеРl:lигану (Brian Kernighan), Константину Кладко (Konstantin Кladko), Дагу Ли (Doug Lea), Дзенгуа Ли (Zhenghua Li), Тиму Линдхолму (Tim Lindholm), Майку Маклоски (Mike McCloskey), Тиму Пейерлсу (Tim Peierls), Марку Рейнхолду (Mark Reinhold), Кену Расселу (Ken Russell), Биллу Шэннону (ВШ· Shannon), Питеру Стауту (Peter Stout), Филу Уодлеру (Phil Wadler), Давиду Холмсу (David Holmes) и двум анонимным рецензентам. Они внесли множество предложений, которые позволили существенно улучшить книгу и избавили меня от многих затруднений. Все оставшиеся недочеты полностью лежат на моей совести.

Многие мои коллеги, работающие в компании Sun и вне ее, участвовали в технических дискуссиях, которые улучшили качество этой книги. Среди прочих: Бен Гомес (Ben Gomes), Стефен Грерап (Steffen Grarup), Питер Кесслер (Peter Kessler), Ричард Рода (Richard Roda), Джон Роуз (John Rose) и Дэвид Стаутэмайер (David Stoutamire). Особая благодарность Дагу Ли (Doug Lea), озвучившему многие идеи этой книги. Даг неизменно щедро делился своим временем и знаниями.

Я благодарен Джули Дайникола (Julie Dinicola), Джекки Дусетт (Jacqui Doucette), Майку Хендриксону (Mike Hendrickson), Хизер Ольщик (Heather Olszyk), Трейси Расс (Tracy Russ) и всем сотрудникам Addison-Wesley за их поддержку и Профессионализм. Даже будучи занятыми до предела, они всегда были дружелюбны и учтивы.

Я благодарю Гая Стила (Сиу Steele), написавшего предисловие. Его участие в этом проекте - большая честь для меня.

Наконец, спасибо моей жене Синди Блох (Cindy Вloch), которая своим ободрением, а подчас и угрозами помогла мне написать эту книгу. Благодарю за чтение каждой статьи в необработанном виде, за помощь при работе с программой Framemaker, за написание предметного указателя и за то, что терпела меня, пока я корпел над этой книгой.

XlV

Содержание

Предисловие

Предисловие автора

1. Введение

2. Создание и уничтожение объектов

 

  1.  Рассмотрите возможность замены конструкторов статическими методами генерации…………………………………………………………......5
  2.  Свойство синглтона обеспечивайте закрытым конструктором……………9
  3.  Отсутствие экземпляров обеспечивает закрытый конструктор………….11
  4.  Не создавайте дублирующих объектов……………………………………...12
  5.  Уничтожайте утаревшие ссылки ( на объекты)……………………………..16
  6.  Остерегайтесь методов finalize………………………………………………..19

3. Методы, общие для всех объектов

  1.  Переопределяя метод euals, соблюдайте общие соглашения………….24
  2.  Переопределяя метод equals? Всегда переопределяйте hashCode…...................................................................................................35
  3.  Всегда переопределяйте метод toString……………………………………..40
  4.  Соблюдайте осторожность при переопределении метода clone………..43
  5.  Подумайте над реализацией интерфейса Comparable…………………..........................................................................51

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

  1.  Сводите к минимуму доступность классов и членов………………………57
  2.  Предпочтитайте постоянство………………………………………………….61
  3.  Предпочитайте компановку наследованию…………………………………69
  4.  Проектируйте и документируйте наследование либо запрещайте его…………………………………………………………………………………..75
  5.  Предпочитайте интерфейсы абстрактным классам……………………….80
  6.  Используйте интерфейсы только для определения типов……………….85
  7.  Предпочитайте статистические классы-члены нестатическим………….87

5. Замена конструкций на языке C

  1.  Заменяйте структуру классом………...……………………………………….92
  2.  Заменяйте объединение иерархией классов……………………………….94
  3.  Заменяйте конструкцию enum классом………………………………………98
  4.  Указатель на функцию заменяйте классом и интерфейсом…………….109

6. Методы

  1.  Проверяйте достоверность параметров……………………………………112
  2.  При необходимости создавайте резервные копии……………………….114
  3.  Тщательно проектируйте сигнатуру…………………………………………118
  4.  Перезагружая методы, соблюдайте осторожность……………………….120
  5.  Возвращайте массив нулевой длины, а не null……………………………125
  6.  Для всех открытых элементов АРI пишите dос-комментарии………….127

7. Общие вопросы программирования

  1.  Сводите к минимуму область видимости локальных переменных…….132
  2.  Изучите библиотеки и пользуйтесь ими…………………………………….135
  3.  Если требуются точные ответы, избегайте использования типов float и doubIe……………………………………………………………………………..139
  4.  Не используйте строку там, где более уместен иной тип……………….141
  5.  При конкатенации строк опасайтесь потери производительности…………144
  6.  Для ссылки на объект используйте его интерфейс…………………………...145
  7.  Предпочитайте интерфейс отражению класса………………………………...147
  8.  Соблюдайте осторожность при использовании машинно-зависимых методов……………………………………………………………………………….150
  9.  Соблюдайте осторожность при оптимизации………………………………….151
  10.  выборе имен придерживайтесь общепринятых соглашений………………..154

8. Исключения

  1.  Используйте исключения лишь в исключительных ситуациях……………158
  2.  Применяйте обрабатываемые исключения для восстановления, для программных ошибок используйте исключения времени выполнения…………………………………………………………………………161
  3.  Избегайте ненужных обрабатываемых исключений…………………………163
  4.  Предпочитайте стандартные исключения……………………………………..165
  5.  Инициируйте исключения, соответствующие абстракции…………………..167
  6.  Для каждого метода документируйте все инициируемые исключения…………………………………………………………………………..170
  7.  В описание исключения добавляйте информацию о сбое…………………..171
  8.  Добивайтесь атомарности методов по отношению к сбоям…………………173
  9.  Не игнорируйте исключений………………………………………………………175

9. Потоки

  1.  Синхронизируйте доступ потоков к совместно используемым изменяемым данным………………………………………………………………………………..177
  2.  Избегайте избыточной синхронизации………………………………………….183
  3.  Никогда не вызывайте метод wait вне цикла…………………………………..188
  4.  Не попадайте в зависимость от планировщика потоков……………………..191
  5.  При работе с потоками документируйте уровень безопасности……………194
  6.  Избегайте группировки потоков…………………………………………………..197

10. Сериализация

  1.  Соблюдайте осторожность при реализации интерфейса SerializabIe…………………………………………………………………………...199
  2.  Рассмотрите возможность использования специализированной сериализованной формы…………………………………………………………..204
  3.  Метод readObject должен создаваться с защитой…………………………….210
  4.  При необходимости создавайте метод readResolve………………………….217

11. Литература

 Предисловие

Если бы сослуживец сказал вам: "Моя супруга сегодня вечером готовит дома нечто необычное. Придешь?" (Spouse of me this night today manufactures the unusual meal in а home. You will join?), вам в голову, вероятно, пришли бы сразу три мысли: вас уже пригласили на обед; английский язык не является родным для вашего сослуживца; ну и прежде всего это слишком большое беспокойство.

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

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

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

Есть и другой аспект. Программы, в отличие от произнесенных фраз и большинства изданных книг и журналов, имеют возможность меняться со временем. Недостаточно создать программный код, который эффективно работает и без труда может быть понят другими людьми. Нужно еще организовать этот код таким образом, чтобы его можно было легко модифицировать для не которой задачи А существует десяток вариантов написания программного кода. Из этих десяти семь оказываются неуклюжими, неэффективными или запутывающими читателя. Какой же из оставшихся трех вариантов будет представлять собой программный код, который потребуется в следующем году для новой версии программы, ,решающей задачу А'?

Существует много книг, по которым можно изучать грамматику языка программирования Jаvа, в том числе книги "The Java Prograттiпg Laпguage" авторов Arnold, Gosling и Holmes [ArnoldOO] и "The Java Laпguage Specificatioп" авторов Gosling, Jоу, Bracha и вашего покорного слуги [JLS]. Немало книг посвящено библиотекам и прикладным интерфейсам, связанным с Jаvа.

Эта книга посвящена третьей теме: общепринятым и эффективным приемам работы с языком Jаvа. На протяжении нескольких лет Джошуа Блох (Joshua Blосk) трудился в компании Sun Microsystems, работая с языком программирования Jаvа, занимаясь расширением и реализацией программного кода. Он изучил большое количество программ, написанных многими людьми, в том числе и мною. 8 настоящей книге он дает дельные советы о том, каким образом структурировать код, чтобы он работал хорошо, чтобы его могли понять другие люди, чтобы последующие модификации и усовершенствования доставляли меньше головной боли и чтобы ваши программы были приятными, элегантными и красивыми.

Гай А. Стuл-младшuй (Сиу L. Steele Jr.) Берлингтон, шт. Массачусетс Апрель 2001

            Предисловие автора

в 1996г. я направился на запад, в компанию JavaSoft, как она тогда называлась, поскольку было очевидно, что именно там происходят главные события. На протяжении пяти лет я работал архитектором библиотек для платформы Java. Я занимался проектированием, разработкой и обслуживанием этих библиотек, а также давал консультации по многим другим библиотекам. Контроль над библиотеками в ходе становления платформы языка Java - такая возможность предоставляется раз в жизни. Не будет преувеличением сказать, что я имел честь трудиться бок о бок с великими разработчиками нашего времени. Я многое узнал о языке программирования Java: что в нем работает, а что нет, как пользоваться языком и его библиотеками для получения наилучшего результата.

Эта книга является попыткой поделиться с вами моим опытом, чтобы вы смогли повторить мои успехи и избежать моих неудач. Оформление книги я позаимствовал из руководства Скотта Мейерса (Scott Meyers) "Effective С++" [Meyers98]; оно состоит из пятидесяти статей, каждая из которых посвящена одному конкретному правилу, направленному на улучшение программ и проектов. Я нашел такое оформление необычайно эффективным, и надеюсь, вы тоже его оцените.

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

Эта книга предназначена не только для тех, кто занимается разработкой повторно используемых компонентов, тем не менее она неизбежно отражает мой опыт в написании таковых, накопленный за последние два десятилетия. Я привык думать в терминах прикладных интерфейсов (API) и предлагаю вам делать то же. Даже если вы не занимаетесь разработкой повторно используемых компонентов, применение этих терминов поможет вам повысить качество ваших программ. Более того, нередко случается писать многократно используемые компоненты, не подозревая об этом: вы создали нечто полезное, поделились своим результатом с приятелем, и вскоре у вас будет уже с полдюжины пользователей. С этого момента вы лишаетесь возможности свободно менять этот АР! и получаете благодарности за все те усилия, которые потратили на его разработку, когда писали программу в первый раз.

Мое особое внимание к разработке АР! может показаться несколько противоестественным для ярых приверженцев новых облегченных методик создания программного обеспечения, таких как "Экстремальное программирование" [Beck99]. В этих методиках особое значение придается написанию самой простой программы, какая только сможет работать. Если вы пользуетесь одной из этих методик, то обнаружите, что внимание к АРI сослужит вам добрую службу в процессе последующей перестройки программы (refactoring). Основной задачей перестроения является усовершенствование структуры системы, а также исключение дублирующего программного кода. Этой цели невозможно достичь, если у компонентов системы нет хорошо спроектированного API.

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

Джошуа Блох

Купертино, шт. Калифорния

Апрель 2001

                           Глава 1

Введение

Эта книга писалась с той целью, чтобы помочь вам наиболее эффективно использовать язык программирования Jаvа ТМ и его основные библиотеки jаvа.lang, java.util и java.io. В книге рассматриваются и другие библиотеки, но мы не касаемся графического интерфейса пользователя и специализированных API.

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

Большинство статей сопровождается примерами программ. Главной особенностью этой книги является наличие в ней примеров программного кода, иллюстрирующих многие шаблоны (design pattern) и идиомы. Некоторые из них, такие как Singleton (статья 2), известны давно, другие появились недавно, например Finalizer Guardian (статья б) и Defensive readResolve (статья 57). Где это необходимо, шаблоны и идиомы имеют ссылки на основные работы в данной области [Саmmа95].

Многие статьи содержат при меры программ, иллюстрирующие приемы, которых следует избегать. Подобные примеры, иногда называемые "антишаблонами", четко обозначены комментарием "// никогда не делайте так!" В каждом таком случае в статье дается объяснение, почему пример плох, и предлагается альтернатива.

Эта книга не предназначена для начинающих: предполагается, что вы уже хорошо владеете языком программирования Java. В противном случае обратитесь к одному из множества прекрасных изданий для начинающих [ArnoldOO, CampioneOO]. Книга построена так, чтобы быть доступной для любого, кто работает с этим языком, тем не менее она дает пищу для размышлений даже опытным программистам.

1

В основе большинства правил этой книги лежит несколько фундаментальных принципов. Ясность и простота имеют первостепенное значение. Функционирование модуля не должно вызывать удивление у него пользователя. Модули Должны быть настолько компактны, насколько это возможно, но не более того. (В этой книге термин "модуль" относится к любому программному компоненту, который используется много раз: от отдельного метода до сложной системы, состоящей из нескольких пакетов.) Программный код следует использовать повторно, а не копировать. Взаимозависимость между модулями должна быть сведена к минимуму. Ошибку нужно выявлять как можно раньше, в идеале - уже на стадии компиляции.

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

Большая часть этой книги посвящена отнюдь не производительности программ.

Речь идет о написании понятных, прав ильных,  полезных, надежных, гибких программ, которые удобно сопровождать. Если вы сможете сделать это, то добиться необходимой производительности будет несложно (статья 37). В некоторых статьях обсуждаются вопросы производительности, в ряде случаев приводятся показатели производительности. Эти данные, предваряемые выражением "на моей машине", следует рассматривать как приблизительные.

Для справки, моя машина - это старый компьютер домашней сборки с процессором 400 МГц Pentium® II и 128 Мбайт оперативной памяти под управлением Мiсrosоft Windows NT® 4.0, на котором установлен Java 2 Standard Edition Software Development Kit (SDK) компании Sun. В состав этого SDK входит Java HotSpot тм Client УМ компании Sun - финальная реализация виртуальной машины Java, предназначенной для клиентов.

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

В некоторых статьях обсуждаются возможности, появившиеся в версии 1.4, однако в примерах программ, за редким 'исключением, я воздерживался от того, чтобы пользоваться ими. Эти примеры были проверены в версии 1.3. Большинство из них, если не все, без всякой переделки должны работать также с версией 1.2.

 

Официальное название версии

Рабочий номер версии

JDK 1.1.x / JRE 1.1.x

1.1

Java 2 Platform, Standard Edition, v1.2

1.2

Java 2 Platform, Standard Edition, v1.3

1.3

Java 2 Platform, Standard Edition, v1.4

1.4

2

Примеры по возможности являются полными, однако предпочтение отдается не завершенности, а удобству чтения. В примерах широко используются классы пакеТ08 java.util и java. 10. Чтобы скомпилировать пример, вам потребуется добавить один или оба оператора import:

Import java.ut11.*;

Import java.io.*;

В примерах опущены детали. Полные версии всех примеров содержатся на web-сайте этой книги (http://java.sun.com/docs/books/effective). При желании любой из них можно скомпилировать и запустить.

Технические термины в этой книге большей частью используются в том виде, как они определены в "The Jаvа Laпguage Specificatioп, Secoпd Editioп" [JLS]. Однако некоторые термины заслуживают отдельного упоминания. Язык Java поддерживает четыре группы типов: интерфейсы (interface), классы (class), массивы rrау) и простые типы (primitive). Первые три группы называются ссылочными типами (reference type). Экземпляры классов и массивов '- это объекты, значения простых типов таковыми не являются. К членам класса (members) относятся его поля (fields), методы (methods), классы-члены (member classes) и интерфейсы-члены (mernber interfaces). Сигнатура метода (signature) состоит из его названия и типов, которые имеют его формальные параметры. Т ил значения, возвращаемого методом, в сигнатуру не входит.

Некоторые термины в этой книге используются в ином значении, чем в "The Jаvа .

Laпguage Speci/icatioп". Так, "наследование" (inheritance) применяется как синоним "образования подклассов" (subclassing). Вместо использования для интерфейсов термина "наследование" в книге констатируется, что некий класс реализует (implement) интерфейс или что один интерфейс является расширением другого (extend) для описания' уровня доступа, который применяется, когда ничего больше не указано, в книге используется описательный термин "доступ только в пределах пакета" (package-private) вместо формально правильного термина "доступ по умолчанию" (default access) [JLS, 6.6.1].

В этой книге встречается несколько технических терминов, которых нет в "The Jаvа Language Specification". Термин "внешний АР!" (exported API), или просто АРI”  относится к классам, интерфейсам, конструкторам, членам и сериализованным формам, с помощью которых программист получает доступ к классу, интерфейсу или пакету. (Термин API, являющийся сокращением от application programming interface - программный интерфейс приложения, используется вместо термина "интерфейс" (interface). Это позволяет избежать путаницы с одноименной конструкцией языка Java.) Программист, который пишет программу, применяющую некий API, называется пользователем (user) указанного API. Класс, в реализации которого используется некий API, называется клиентом (client) этого API.

 

3

Классы, интерфейсы, конструкторы, члены и сериализованные формы называются элементами АР/ (API element). Внешний API образуется из элементов API, которые доступны за пределами пакета, где этот  API был определен. Указанные элементы может использовать любой клиент, автор АР! берет на себя их поддержку. Неслучайно документацию именно к этим элементам генерирует утилита Javadoc при запуске в режиме по умолчанию. В общих чертах, внешний АР! пакета состоит из открытых (pubIic) и защищенных (protected) членов, а также из конструкторов всех открытых классов и интерфейсов в пакете.

4

 

Глава 2

Создание и уничтожение объектов

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

Рассмотрите возможность замены конструкторов статическими методами генерации.

Обычно для того, чтобы клиент мог получать экземпляр класса, ему предоставляется открытый (pubIic) конструктор. Есть и другой, менее известный прием, который должен быть в арсенале любого программиста. Класс может иметь открытый статический метод генерации (static factory method), который является статическим методом, возвращающим экземпляр класса. Пример такого метода возьмем из класса Boolean (являющего оболочкой для простого типа boolean). Приведенный ниже статический метод генерации, который был добавлен в версию 1.4, преобразует значение boolean в ссылку на объект Boolean:

public static Boolean valueOf(boolean Ь) {

Return (Ь? Boolean. TRUE: Boolean. FALSE);

}

Статические методы генерации могут быть предоставлены клиентам класса не только вместо конструкторов, но и в дополнение к ним. Замена открытого конструктора статическим методом генерации имеет свои достоинства и недостатки.

5

Первое преимущество статического метода генерации состоит в том, что, в отличие от конструкторов, он имеет название. В то время как параметры конструктора сами по себе не дают описания возвращаемого объекта, статический метод генерации с хорошо подобранным названием может упростить работу с классом и, как следствие, сделать соответствующий программный код клиента более понятным. Например, конструктор Biglnteger(int, int, Random), который возвращает Biglnteger, являющийся, вероятно, простым числом (prime), лучше было бы представить как статический метод генерации с названием Biglnteger.ргоЬаЫеРrime. (В конечном счете этот статический метод был добавлен в версию 1.4.)

Класс может иметь только один конструктор с заданной сигнатурой. Известно, что программисты обходят данное ограничение, создавая конструкторы, чьи списки параметров отличаются лишь порядком следования типов. Это плохая идея. Человек, использующий подобный API, не сможет запомнить, для чего нужен один конструктор, а для чего другой, и, в конце концов, по ошибке вызовет не тот конструктор. Т е, кто читает' программный код, в котором при меняются такие конструкторы, не смогут понять, что же он делает, если не будут сверяться с сопроводительной документацией

на этот класс.

Поскольку статические методы генерации имеют имена, к ним не относится

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

Второе преимущество статических методов генерации заключается в том, что, в отличие от конструкторов, они не обязаны при каждом вызове создавать новый объект. Это позволяет использовать для неизменяемого класса (статья 13) предварительно созданный экземпляр либо кэшировать экземпляры класса по мере их создания, а затем раздавать их повторно, избегая создания ненужных дублирующих объектов. Подобный прием иллюстрирует метод Boolean. valueOf(boolean): он не создает объектов. Эта методика способна значительно повысить производительность программы, если часто возникает необходимость в создании одинаковых объектов, особенно в тех случаях, когда создание объектов требует больших затрат.

Способность статических методов генерации возвращать при повторных вызовах тот же самый объект можно использовать и для того, чтобы в любой момент времени; четко контролировать, какие экземпляры объекта еще существуют. На это есть две причины. Во-первых, это позволяет гарантировать, что некий класс является синглтоном (статья 2). Во-вторых, это дает возможность убедиться в том, что у неизменяемого класса не появилось двух одинаковых экземпляров: a.equals(b) тогда и только тогда, когда а==Ь. Если класс предоставляет' такую гарантию, его клиенты могут использовать оператор == вместо метода equals(Object), что приводит к существенному повышению производительности программы. Подобную оптимизацию реализует шаблон перечисления типов, описанный в статье 21, частично ее реализует также метод String.intern.

6

Третье преимущество статического метода генерации заключается в том, что, в отличие от конструктора, он может вернуть объект, который соответствует не только заявленному типу возвращаемого значения, но и любому его подтипу. Это предоставляет вам значительную гибкость в выборе класса для возвращаемого объекта. Например, интерфейс АР! может возвращать объект, не декларируя его класс как pubIic. Сокрытие реализации классов может привести к созданию очень компактного API. Этот прием идеально подходит для конструкций, построенных на интерфейсах, где интерфейсы для статических методов генерации задают собственный тип возвращаемого значения.

Например, архитектура Collections Framework имеет двадцать полезных реализаций интерфейсов коллекции: неизменяемые коллекции, синхронизированные коллекции и т. д. С помощью статических методов генерации большинство этих реализаций сводится в единственный класс jауа.util.Collections, для которого невозможно создать экземпляр. Все классы, соответствующие возвращаемым объектами, не, являются открытыми.

АРI Collections Framework имеет гораздо меньшие размеры, чем это было бы, если бы в нем были представлены двадцать отдельных открытых классов для всех возможных реализаций. Сокращен не только объем этого API, но и его "концептуальная нагрузка". Пользователь знает, что возвращаемый объект имеет в точности тот API, который указан в соответствующем интерфейсе, и ему нет нужды читать дополнительные документы по этому классу. Более того, применение статического метода генерации дает клиенту право обращаться к возвращаемому объекту, используя его собственный интерфейс, а не интерфейс класса реализации, что обычно является хорошим приемом (статья 34).

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

В момент написания класса, содержащего статический метод генерации, класс, соответствующий возвращаемому объекту, может даже не существовать. Подобные гибкие статические методы генерации лежат в основе систем с предоставлением услуг (service provider framework), например ]ауа Cryptography Extension ОСЕ). Система с предоставлением услуг - это такая система, в которой поставщик может создавать различные реализации интерфейса API, доступные пользователям этой системы. Чтобы сделать эти реализации доступными для применения, предусмотрен механизм регистрации (register). Клиенты могут пользоваться указанным API, не беспокоясь о том, с какой из его реализаций они имеют дело.

В Упомянутой системе ]CE системный администратор регистрирует класс реализации, редактируя хорошо известный файл Properties: делает в нем запись, которая с13лзыветT некий ключ-строку с именем соответствующего класса. Клиенты же используют статический метод генерации, который получает этот ключ в качестве параметра.

7

По схеме, восстановленной из файла Properties, статический метод генерации находит объект Class, а затем создает экземпляр соответствующего класса с помощью метода Class. newlnstance.' Этот прием демонстрируется в следующем фрагменте:

import java.util.*;

// Эскиз системы с предоставлением услуг

public abstract class Foo {

   // Ставит ключ типа String в соответствие объекту Class

   private static Map implementations = null;

// При первом вызове инициализирует карту соответствия

   private static synchronized void initMapIfNecessary() {

       if (implementations == null) {

           implementations = new HashMap();

            // 3агружает названия классов и ключи из файла Properties,

            // транслирует названия в объекты Class, используя

           // Class. forName, и сохраняет их соответствие ключам

           // ...

       }

   }

    public static Foo getInstance(String key) {

       initMapIfNecessary();

       Class c = (Class) implementations.get(key);

       if (c == null)

           return new DefaultFoo();

 try {

           return (Foo) c.newInstance();

       } catch (Exception e) {

           return new DefaultFoo();

       }    }

  public static void main(String[] args) {

  System.out.println(getInstance("NonexistentFoo"));

   }   }

class DefaultFoo extends Foo {

}

            Основной недостаток статических методов генерации заключается в том, что классы, не имеющие открытых или защищенных конструкторов, не могут иметь подклассов. Это же касается классов, которые возвращаются открытыми статическими методами генерации, но сами открытыми не являются. Например, в архитектуре Collections Framework невозможно создать подкласс ни для одного из классов реализации. Сомнительно, что в такой маскировке может быть благо, поскольку поощряет программистов использовать не наследование, а композицию (статья 14).

 Второй недостаток статических методов генерации состоит в том, что их трудно отличить от других статических методов. В документации АРI они не выделяются так, как это делается для конструкторов. Более того, статические методы генерации представляют собой отклонение от нормы. Поэтому иногда из документации к классу сложно понять, как создать экземпляр класса, в котором вместо конструкторов

8

клиенту предоставлены статические· методы генерации. Указанный недостаток может быть смягчен, если придерживаться стандартных соглашений, касающихся именования. Эти соглашения продолжают совершенствоваться, но два названия статических методов генерации стали уже общепринятыми:

valueOf' - возвращает экземпляр, который имеет то же значение,

что и его параметры. Статические методы генерации с таким названием фактически являются операторами преобразования типов.

getlnstance - возвращает экземпляр, который описан параметрами, однако говорить о том, что он будет иметь то же значение, нельзя.

В случае с синглтоном этот метод возвращает единственный экземпляр данного класса. Это название является общепринятым в системах

с предоставлением услуг.

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

Свойство синглтона обеспечивайте закрытым конструктором

Сuнглтон (singleton) - это класс, для которого экземпляр создается только один раз [Саmmа95, стр.127]. Синглтоны обычно представляют некоторые компоненты системы, которые действительно являются уникальными, например видеодисплей или файловая система.

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

// Синглтон с полем типа final

public class Elvis {

   public static final Elvis INSTANCE = new Elvis();

  private Elvis() {

       // ...

   }

   // ...  // Остальное опущено

   public static void main(String[] args) {

       System.out.println(Elvis.INSTANCE);

   }

}

9

Закрытый конструктор вызывается только один раз для инициализации поля Elvis.INSTANCE. Отсутствие открытых или защищенных конструкторов гарантирует "вселенную с одним Elvis": после инициализации класса Elvis будет существовать ровно один экземпляр Elvis - не больше и не меньше. И клиент не может ничего с этим поделать.

Во втором варианте вместо открытого статического поля типа final создается открытый статический метод генерации:

// Синглтон со статическим методом генерации

import java.io.*;

public class Elvis {

   public static final Elvis INSTANCE = new Elvis();

   private Elvis() {

       // ...

   }

 // ...  // Остальное опущено

}

Все вызовы статического метода Elvis. getInstance возвращают ссылку на один и тот же объект, и никакие другие экземпляры Elvis никогда не будут созданы.

Основное преимущество первого подхода заключается в том, что из декларации членов, составляющих класс, понятно, что этот класс является синглтоном: открытое статическое поле имеет тип fina!, а потому оно всегда будет содержать ссылку на один и тот же объект. Первый вариант, по сравнению со вторым, может также иметь некоторое преимущество в производительности. Однако хорошая реализация JVM должна свести это преимущество к минимуму благодаря встраиванию (in!ining) вызовов для статического метода генерации во втором варианте.

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

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

Если требуется сделать класс синглтона сериализуемым (см. главу 10), недостаточно добавить к его декларации implements Serializable. Чтобы дать синглтону нужные гарантии, необходимо также создать метод readResolve (статья 57). В противном случае каждая десериализация сериализованного экземпляра будет приводить к созданию нового экземпляра, что в нашем примере станет причиной

10

обнаружения ложных Elvis. Во избежание этого добавьте в класс Elvis следующий метод readResolve:

// Метод readResolve, сохраняющий свойство синглтона

   private Object readResolve() throws ObjectStreamException {

       /*

* Возвращает единственный истинный Elvis и позволяет

 * сборщику мусора разобраться с Еlvis-самозванцем

        */

       return INSTANCE;

   }

   public static void main(String[] args) {

       System.out.println(Elvis.INSTANCE);

   }

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

Отсутствие экземпляров обеспечивает закрытый конструктор

Время от времени приходится писать класс, который является всего лишь собранием статических методов и статических полей. Такие классы приобрели дурную репутацию, поскольку отдельные личности неправильно пользуются ими с целью написания процедурных программ с помощью объектно-ориентированных языков. Подобные классы требуют правильного применения. Их можно использ~ать для того, чтобы собирать вместе связанные друг с другом методы обработки простых значений или массивов, как это сделано в библиотеках java.lang.Math и java.util.Arrays, либо чтобы собирать вместе статические методы объектов, которые реализуют определенный интерфейс, как это сделано в j ауа.util.Collections. Можно также собрать Методы в некоем окончательном (fina!) классе вместо того, чтобы заниматься расширением Класса.

Подобные классы утилит (uti!ity c!ass) разрабатываются не для того, чтобы СОздавать для них экземпляры - такой экземпляр был бы абсурдом. Однако если у Класса нет явных конструкторов, компилятор по умолчанию сам создает для него ОТКРытый конструктор (defau!t constructor), не имеющий параметров. Для пользователя этот Конструктор ничем не будет отличаться от любого другого. В опубликованных АРI нередко можно встретить классы, непреднамеренно наделенные способностью порождать экземпляры.

11

Попытки запретить классу создавать экземпляры, объявив его абстрактным, не работают. Такой класс может иметь подкласс, для которого можно' создавать экземпляры. Более того, это вводит пользователя в заблуждение, заставляя думать, что данный класс был разработан именно для наследования (статья 15). Существует, однако, простая идиома, гарантирующая отсутствие экземпляров. Конструктор по умолчанию создается только тогда, когда у класса нет явных конструкторов, и потому запретить создание экземпляров можно, поместив в класс единственный явный закрытый конструктор:

// Класс утилит, не имеющий экэемпляров

public class UtilityClass {

// Подавляет появление конструктора по умолчанию, а заодно и создание экземпляров класса

   private UtilityClass() {

    // Этот конструктор никогда не будет вызван

   }

  // ...  // Остальное опущено

}

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

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

Не создавайте дублирующих объектов

Вместо того чтобы создавать новый функционально эквивалентный объект всякий раз, когда в нем возникает необходимость, можно, как правило, еще раз использовать тот же объект. Применять что-либо снова - и изящнее, и быстрее. Если объект является неизменяемым (immutable), его всегда можно использовать повторно (статья 13).

Рассмотрим оператор, демонстрирующий, как делать не надо:

String s = new String("silly");                                              / / Никогда не делайте так!

 

12

При каждом проходе этот оператор создает новый экземпляр String, но ни одна из процедур создания объектов не является необходимой. Аргумент конструктора String - "silly" - сам является экземпляром класса String и функционально равнозначен всем объектам, создаваемым конструктором. Если этот оператор попадает в цикл или в часто вызываемый метод, без всякой надобности могут создаваться миллионы экземпляров String.

Исправленная версия выглядит просто:

String s = "No longer silly";

В этом варианте используется единственный экземпляр String вместо создания новых при каждом проходе. Более того, гарантируется, что этот объект будет повторно использоваться любым другим программным кодом, выполняемым на той же виртуальной машине, где содержится эта строка-константа [JLS, 3.10.5]. Создания дублирующих объектов часто можно избежать, если неизменяемом классе, имеющем и конструкторы, и статические методы генерации (статья 1), предпочесть вторые первым. Например, статический метод генерации Boolean.val'ueOf(String) почти всегда предпочтительнее конструктора Boolean(String). При каждом вызове конструктор создает новый объект, тогда как от статического метода генерации этого не требуется. Вы можете повторно использовать не только неизменяемые объекты, но и изменяемые, если знаете, что последние уже не будут меняться. Рассмотрим более тонкий и более распространенный пример того, как не надо поступать, в том Числе с изменяемыми объектами, которые, будучи получены один раз, впоследствии остаются без Изменений:

import java.util.*;

public class Person {

   private final Date birthDate;

 

 // Прочие поля опущены

 

 public Person(Date birthDate) {

       this.birthDate = birthDate;

   }

// Никогда не делайте так!

  

public boolean isBabyBoomer() {

       Calendar gmtCal =

           Calendar.getInstance(TimeZone.getTimeZone("GMT"));

       gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);

       Date boomStart = gmtCal.getTime();

       gmtCal.set(1965, Calendar.JANUARY, 1, 0, 0, 0);

       Date boomEnd = gmtCal.getTime();

       return birthDate.compareTo(boomStart) >= 0 &&

              birthDate.compareTo(boomEnd)   <  0;

   }

   public static void main(String[] args) {

       Person p = new Person(new Date());

       long startTime = System.currentTimeMillis();

       for (int i=0; i<1000000; i++)

           p.isBabyBoomer();

       long endTime = System.currentTimeMillis();

       long time = endTime - startTime;

       System.out.println(time+" ms.");

   }

}

13

Метод isBabyBoomer при каждом вызове создает без всякой надобности новые экземпляры Calendar, TimeZone и два экземпляра Date. В следующей версии подобная расточительность пресекается с помощью статического инициализатора:

import java.util.*;

class Person {

   private final Date birthDate;

  public Person(Date birthDate) {

       this.birthDate = birthDate;

}

   /**

   * Даты начала и конца демографического взрыва

    */

   private static final Date BOOM_START;

   private static final Date BOOM_END;

static {

       Calendar gmtCal =

           Calendar.getInstance(TimeZone.getTimeZone("GMT"));

       gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);

       BOOM_START = gmtCal.getTime();

       gmtCal.set(1965, Calendar.JANUARY, 1, 0, 0, 0);

       BOOM_END = gmtCal.getTime();

   }

     public boolean isBabyBoomer() {

       return birthDate.compareTo(BOOM_START) >= 0 &&

              birthDate.compareTo(BOOM_END)   <  0;

   }

     public static void main(String[] args) {

       Person p = new Person(new Date());

       long startTime = System.currentTimeMillis();

       for (int i=0; i<1000000; i++)

           p.isBabyBoomer();

       long endTime = System.currentTimeMillis();

       long time = endTime - startTime;

       System.out.println(time+" ms.");

   }     }

В исправленной версии класса Person экземпляры Calendar, ТimeZone и Date создаются только один раз в ходе инициализации, а не при каждом вызове метода isBabyBoomer. Если данный метод вызывается часто, это приводит к значительному выигрышу в производительности. На моей машине исходная версия программы тратит на миллион вызовов 36000 мс, улучшенная - 370 мс, т. е. она работает в сто раз быстрее. Причем повышается не только производительность программы, но и наглядность. Замена локальных переменных boomStart и boomEnd статическими полями типа final показывает, что эти даты рассматриваются как константы, и программный код становится более понятным. Для полной ясности заметим, что экономия от подобной оптимизации не всегда будет столь впечатляющей, просто здесь много ресурсов требует создание экземпляров Calendar.

Если метод isBabyBoomer вызываться не будет, инициализация полей BOOM_START и BOOM_END в улучшенной версии класса Person окажется напрасной. Ненужных действий можно избежать, использовав для этих полей отложенную инициализацию (lazily initializing) (статья 48), которая бы выполнялась при первом вызове метода      

     14

isBabyBoomer, однако делать это не рекомендуется. Как часто бывает в случаях с отложенной инициализацией, это усложнит реализацию и вряд ли приведет к заметному повышению производительности (статья 37).

Во всех примерах, приведенных в этой статье, было очевидным то, что рассматриваемые объекты можно использовать повторно, поскольку они неизменяемые. Однако в ряде ситуаций это не столь очевидно. Рассмотрим случай с адаптерами (adapter) [Сатта95, стр. 139], известными также как представления (view). Адаптер - это объект, который делегирован нижележащим объектом и который создает для него альтернативный интерфейс. Адаптер не имеет иных состояний, помимо состояния нижележащего объекта, поэтому для адаптера, представляющего данный объект, не нужно создавать более одного экземпляра.

Например, в интерфейсе Мар метод keySet возвращает для объекта Мар представление Set, которое содержит все ключи данной схемы. По незнанию можно подумать, что каждый вызов метода keySet должен создавать новый экземпляр Set. Однако в действительности для некоего объекта Мар любые вызовы keySet могут возвращать один и тот же экземпляр Set. И хотя обычно возвращаемый экземпляр Set является изменяемым, все' возвращаемые объекты функционально идентичны: когда меняется один из них, то же самое происходит со всеми остальными экземплярами Set, поскольку за всеми ними стоит один и тот же экземпляр Map.

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

И наоборот, отказ от создания объектов и поддержка собственного пула объектов (object pool) - плохая идея, если только объекты в этом пуле не будут крайне ресурсоемкими. Основной пример объекта, для которого оправданно создание пула, - соединение с базой данных (database connection). Затраты на установление такого соединения высоки, и потому лучше обеспечить многократное использование этого объекта. Однако в общем случае создание собственного пула объектов загромождает.  Современные реализации JVM имеют хорошо оптимизированные сборщики мусора, которые при работе с небольшими объектами с легкостью превосходят подобные пулы объектов.

В противовес этой статье можно привести статью 24, посвященную резервному копированию (defensive copying). Если в настоящей статье говориться: ”Не создавайте новый объект, если вы обязаны исполнять имеющийся еще раз”, то статья 24 гласит: ” Не надо использовать имеющийся объект еще раз, если вы обязаны создать новый”. Заметим, что ущерб от повторного применения объекта, когда требуется резервное копирование, значительно превосходит ущерб от бесполезного создания дублирующего объекта. Отсутствие резервных копий там, где они необходимы, может привести к коварным ошибкам и дырам в системе безопасности, создание же ненужных объектов всего лишь влияет на стиль и производительность программы.

15

Уничтожайте устаревшие ссыпки (на объекты)

При переходе с языка программирования с ручным управлением памятью, такого как С или С++, на язык с автоматической очисткой памяти (garbage-collect - "сбор!<а мусора") ваша работа как программиста существенно упрощается благодаря тому обстоятельству, что ваши объекты автоматически утилизируются, как только вы перестаете их использовать. Когда вы впервые сталкиваетесь с этой особенностью, то воспринимаете ее как волшебство. Легко может создаться впечатление, что вам больше не нужно думать об управлении' памятью, но это не совсем так.

Рассмотрим следующую реализацию простого стека:

import java.util.*;

//Можете ли вы заметить "утечку памяти"?

public class Stack {

   private Object[] elements;

   private int size = 0;

  public Stack(int initialCapacity) {

       this.elements = new Object[initialCapacity];

   }

 public void push(Object e) {

       ensureCapacity();

       elements[size++] = e;

   }

 public Object pop() {

       if (size==0)

           throw new EmptyStackException();

       Object result = elements[--size];

       elements[size] = null; // Eliminate obsolete reference

       return result;

   }

    /**

* Убедимся в том, что в стеке есть место хотя бы еще

* для одного элемента. Каждый раз, когда

* нужно увеличить массив, удваиваем его емкость.

    */

private void ensureCapacity() {

       if (elements.length == size) {

           Object[] oldElements = elements;

           elements = new Object[2 * elements.length + 1];

           System.arraycopy(oldElements, 0, elements, 0, size);

       }     }

  public static void main(String[] args) {

       Stack s = new Stack(0);

       for (int i=0; i<args.length; i++)

           s.push(args[i]);

       for (int i=0; i<args.length; i++)

           System.out.println(s.pop());

   }        }

16

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

Где же происходит утечка? Если стек растет, а затем уменьшается, то объекты, которые были вытолкнуты из стека, не могут быть удалены, даже если программа, пользующаяся этим стеком, уже не имеет ссылок на них. Все дело в том, что стек сохраняет устаревшие ссылки (obsolete reference) на объекты. Устаревшая ссылка это такая ссылка, которая уже никогда не будет разыменована. В данном случае устаревшими являются любые ссылки, оказавшиеся за пределами активной части массива элементов. Активная же часть стека включает в себя элементы, чей индекс меньше значения переменной size.

Утечка памяти в языках с автоматической сборкой мусора (или точнее, непреднамеренное сохранение объектов - unintentional object retention) весьма коварна. Если ссылка на объект была непреднамеренно сохранена, сборщик мусора не сможет удалить не только этот объект, но и все объекты, на которые он ссылается, и т. д. Если даже непреднамеренно было сохранено всего несколько объектов, многие и многие объекты могут стать недоступными сборщику мусора, а это может оказать большое влияние на производительность программы.

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

public Object рор()

 if (size == О)

throw new EmptyStackException(); Object result = elements[--size];

elements[size] = null; // Убираем устаревшую ссылку 

геtuгп result;

}

Обнуление устаревших ссылок дает и другое преимущество: если впоследствии кто-то по ошибке попытается разыменовать какую-либо из этих ссылок, программа незамедлительно завершится с диагностикой NullРоiпtегЕхсерtiоп вместо того, чтобы спокойно выполнять неправильную работу. Всегда выгодно обнаруживать ошибки fIрограммирования настолько быстро, насколько это возможно.

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

17

производительность. Обнуление ссылок на объект должно быть не нормой, а исключением. Лучший способ избавиться от устаревшей ссылки - вновь использовать переменную, в которой она находилась, либо выйти из области видимости переменной. Это происходит естественным образом, если для каждой переменной вы задаете самую ограниченную область видимости (статья 29). Следует, заметить, что в современных реализациях JVM  недостаточно просто выйти из блока, в котором определена переменная. Чтобы переменная пропала, необходимо выйти из соответствующего метода-контейнера.

Так когда же следует обнулять ссылку? Какая особенность класса Stack сделала его восприимчивым к утечке памяти? Класс Stack управляет своей памятью. Пул хранения состоит из массива элементов (причем его ячейками являются ссылки на объекты, а не сами объекты). Как указывалось выше, элементы в активной части массива считаются занятыми, в остальной - свободными. Сборщик мусора этого знать никак не может, и для него все ссылки на объекты, хранящиеся в массиве, в равной' степени действительны. Только программисту известно, что неактивная часть массива не нужна. Сообщить об этом сборщику мусора программист может, лишь вручную обнуляя элементы массива по мере того, как они переходят в не активную часть массива.  

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

Другим распространенным источником утечки памяти являются КЭШи. Поместив однажды в кэш ссылку на некий объект, легко можно забыть о том, что она там есть, и держать ссылку в КЭШе еще долгое время после того, как она стала недействительной. Возможны два решения этой проблемы. Если вам посчастливилось создать кэш, в котором запись остается значимой ровно до тех пор, пока за пределами КЭШа остаются ссылки на ее ключ, представьте этот кэш как WeakHashMap: когда записи устареют, они будут удалены автоматически. В общем случае время, на протяжении которого запись в КЭШе остается значимой. четко не оговаривается. Записи теряют свою значимость с течением времени. В таких обстоятельствах кэш следует время от времени очищать от записей, которыми уже никто не пользуется. Подобную чистку может выполнять фоновый поток (например, через АРI java.util. Тiтeг), либо это может быть побочным эффектом от добавления в кэш новых записей. При реализации второго подхода применяется метод removeEldestEntry из класса java.util.LinkedHashMap, включенного в версию 1.4.

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

18

Остерегайтесь методов flnalize

Методы finalize непредсказуемы, часто опасны и, как правило, не нужны. Их использование может привести к странному поведению программы, низкой производительности и проблемам с переносимостью. Метод finalize имеет лишь несколько областей применения (см. ниже), а главное правило таково: следует избегать методов finalize.

Программистов, пишущих на С++, следует предостеречь в том, что нельзя рассматривать методы finalize как аналог деструкторов в С++. В С++ деструктор _ это обычный способ утилизации ресурсов, связанных с объектом, обязательное дополнение к конструктору. В языке программирования java, когда объект становится недоступен, очистку связанной с ним памяти осуществляет сборщик мусора. Со стороны же программиста никаких специальных действий не требуется. В С++ деструкторы используются для освобождения не только памяти, но и других ресурсов системы. В языке программирования java для этого обычно применяется блок  try-finally.

Нет гарантии, что метод finalize будут вызван немедленно [JLS, 12.6]. С момента, когда объект становится недосТупен, и до момента выполнения метода finalize может пройти сколь угодно длительное время. Это означает, что с помощью метода finalize нельзя выполнять никаких операций, критичных по времени. Например, будет серьезной ошибкой ставить процедуру закрытия открытых файлов в зависимость от метода finalize, поскольку дескрипторы открытых файлов - ресурс ограниченный. Если из-за того, что jVM медлит с запуском методов finalize, открытыми будут оставаться много файлов, программа может завершиться с ошибкой, поскольку ей не удастся открыть новые файлы. 

Частота запуска методов finalize в первую очередь определяется алгоритмом сборки мусора, который существенно меняется от одной реализации jVM к другой. Точно так же может меняться и поведение программы, работа которой зависит от частоты вызова методов finalize. Вполне возможно, что программа будет превосходно работать с jVM, на которой проводится ее тестирование, а затем позорно даст сбой на JVM, которую предпочитает ваш самый важный заказчик.

Запоздалый вызов методов finalize - не только теоретическая проблема. Создав ДЛЯ какого-либо класса метод finalize, в ряде случаев можно спровоцировать произвольную задержку при удалении его экземпляров. Один мой коллега недавно отлаживал приложение СИI, которое было рассчитано на длительное функционирование, но таинственно умирало с ошибкой OutOfMemoryError. Анализ показал, что в момент смерти  приложение в очереди на удаление стояли тысячи графических объектов, ждавших лишь вызова метода finalize и утилизации. К несчастью, поток утилизации выполнялся с меньшим приоритетом, чем другой поток того же приложения, а потому удаление объектов не могло осуществиться в том же темпе, в каком они становились доступны для удаления. Спецификация языка Java не определяет, в каком из потоков

19

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

Спецификация языка Java не только не дает поручительства, что методы finalize будут вызваны быстро, она не гарантирует, что они вообще будут вызваны. Вполне возможно, что программа завершится, так и не вызвав метод finalize для некоторых объектов, ставших недоступными. Следовательно, обновление критического фиксируемого (persistent) состояния не должно зависеть от метода finalize. Например, ставить освобождение фиксируемой блокировки разделяемого ресурса, такого как база данных, в зависимость от метода finalize - верный способ привести всю вашу распределенную систему к сокрушительному краху.

Не соблазняйтесь методами System.gc и System.runFinalization. Они могут повысить вероятность запуска утилизации, но не гарантируют этого. Единственные методы, требующие гарантированного удаления,- это System.runFinalizersOnExit и его вредный близнец Runtime.runFinalizersOnExit. Эти методы некорректны и признаны устаревшими.

Стоит обратить внимание еще на один момент: если в ходе утилизации возникает необработанная исключительная ситуация (exception), она игнорируется, а утилизация этого объекта прекращается [JLS, 12.6]. Необработанная исключительная ситуация может оставить объект в испорченном состоянии. И если другой поток попытается воспользоваться испорченным объектом, результат в определенной мере может быть непредсказуем. Обычно необработанная исключительная ситуация завершает поток и выдает распечатку стека, однако в методе finalize этого не происходит; он даже не выводит предупреждений.

Так чем же заменить метод finalize для класса, чьи объекты инкапсулируют ресурсы, требующие завершения, такие как файлы или потоки? Создайте метод для прямого завершения и потребуйте, чтобы клиенты класса вызывали этот метод для каждого экземпляра, ,когда он им больше не нужен. Стоит упомянуть об одной детали: экземпляр сам должен следить за тем, был ли он завершен. Метод прямо го завершения должен делать запись в некое м закрытом поле о том, что объект более не является действительным. Остальные методы класса должны проверять это поле и инициировать исключительную ситуацию IllegalStateException, если их вызывают после того, как данный объект был завершен.

Типичный пример метода прямого завершения - метод close в InputStгеаm и OutputStгеаm. Еще один пример - метод саnсеl из jауа.util.Timer, который нужным образом меняет состояние объекта, заставляя поток (thread), связанный с экземпляром Timer, аккуратно завершить свою работу. Среди примеров из пакета jауа.awt - Graphics.dispose и Window.dispose. На эти методы редко обращают внимание, что сказывается на производительности программы. То же самое касается метода Image. flush, который освобождает все ресурсы, связанные с экземпляром Image, но оставляет последний в таком состоянии, что его еще можно использовать, выделив вновь необходимые ресурсы.

20

Методы прямого завершения часто используются в сочетании с конструкцией try-finally для обеспечения гарантированного завершения. Вызов метода прямого завершения из оператора finally гарантирует, что он будет выполнен, если даже при работе с объектом возникнет исключительная ситуация:

// Блок try-finally гарантирует вызов метода завершения

Foo foo = new Foo( ... );

tгу {

// Делаем то, что необходимо сделать с foo

}finally {

foo.terminate(); // Метод прямого Завершения

} 

Зачем же тогда вообще нужны методы finalize? У них есть два приемлемых применения. Первое - они выступают в роли "страховочной сетки" в том случае, если владелец объекта забывает вызвать метод прямо го завершения. Нет гарантии, что метод finalize будет вызван своевременно, однако в тех случаях (будем надеяться, редких), когда клиент не выполняет свою часть соглашения, т. е. не вызывает метод прямого завершения, критический ресурс лучше 'освободить поздно, чем никогда. Три класса, представленных как пример использования метода прямого завершения (InputStream, OutputStream и Тiтeг), тоже имеют методы finalize, которые применяется в качестве страховочной сетки, если соответствующие методы завершения не были вызваны.

Другое приемлемое применение методов finalize связано с объектами, имеющими "местных партнеров" (native peers). Местный партнер - это местный объект (native object), к которому обычный объект обращается через машинно-зависимые методы. Поскольку местный партнер не является обычным объектом, сборщик мусора о нем не знает, и когда утилизируется обычный партнер, утилизировать местного партнера он не может. Метод finalize является приемлемым посредником для решения этой задачи при условии, что местный партнер не содержит критических ресурсов. Если же местный партнер содержит ресурсы, которые необходимо освободить немедленно, данный класс должен иметь метод прямого завершения. Этот метод завершения обязан делать все, что необходимо для освобождения соответствующего критического ресурса. Метод завершения может быть машинно-зависимым методом либо вызывать таковой.

Важно отметить, что здесь нет автоматического связывания методов finalize ("finalizer chaining"). Если в классе (за исключениемОЬjесt) есть метод finalize, но в подклассе он был переопределен, то метод finalize в подклассе должен вызывать. Метод finalize, из суперкласса. Вы должны завершить подкласс в блоке try, а затем в Соответствующем блоке finally вызвать метод finalize суперкласса. Тем самым

21

гарантируется, что метод finalize суперкласса будет вызван, даже если при завершении подкласса инициируется исключительная ситуация, и наоборот:

// Ручное связывание метода finalize

protected void finalize() throws Throwable try {

//'Ликвидируем состояние подкласса 

finally {

super.f1nal1ze();

}

}

Если разработчик подкласса переопределяет метод finalize суперкласса, но забывает вызвать его "вручную" (или не делает этого из вредности), метод finalize суперкласса так и не будет вызван. Защититься от такого беспечного или вредного подкласса можно ценой создания некоего дополнительного объекта для каждого объекта, подлежащего утилизации. Вместо того чтобы размещать метод finalize в классе, требующем утилизации, поместите его в анонимный класс (статья 18), единственным назначением которого будет утилизация соответствующего экземпляра. для каждого экземпляра контролируемого класса создается единственный экземпляр анонимного класса, называемый хранителем утилизации (fjnalizer guardian). Контролируемый экземпляр содержит в закрытом экземпляре поля единственную в системе ссылку на хранителя утилизации. Таким образом, хранитель утилизации становится доступен для удаления в момент утилизации контролируемого им экземпляра. Когда хранитель утилизируется, он выполняет процедуры, необходимые для ликвидации контролируемого им экземпляра, как если бы его метод finalize был методом контролируемого класса:

// Идиома хранителя утилизации (Finalizer Guardian)

public class Foo {

// Единственная задача этого объекта - утилизировать

// внешний объект Foo

private final Object finalizerGuardian = new Object() protected void finalize() throws Throwable

// Утилизирует внешний объект Foo

} ;

// Остальное опущено

}

Заметим, что у открытого класса Foo нет метода finalize (за исключением·тривиального, унаследованного от класса Object), а потому не важно, был ли в методе f1nalize подкласса вызов метода super.finalize или нет. Возможность использования этой

22

методики следует рассмотреть для каждого открытого расширяемого класса, имеющего метод finalize.

Подведем итоги. Не применяйте методы finalize, кроме как в качестве страховочной сетки или для освобождения некритических местных ресурсов. В тех редких случаях, когда вы должны использовать метод finalize, не забывайте делать вызов Super.finalize. И последнее: если вам необходимо связать метод finalize с открытым классом без модификатора finaI, подумайте о применении хранителя утилизации, чтобы быть уверенным в том, что утилизация будет выполнена, даже если в подклассе в методе finalize не будет вызова super.finalize.

23

Глава 3

Методы, общие для всех объектов

Хотя класс Object может иметь экземпляры, прежде всего он предназначен для расширения. Поскольку все его методы без модификатора fina! - equals, hashCode, toString, clone и finalize - служат для переопределения, для них есть общие соlлашения (genera! contracts). Любой класс, в котором эти методы переопределяются, обязан подчиняться соответствующим соглашениям. В противном случае он будет препятствовать правильному функционированию других взаимодействующих с ним классов, работа которых зависит от выполнения указанных соглашений.

В этой главе рассказывается о том, как и когда следует переопределять методы класса Object, не имеющие модификатора fina!. Метод finalize в этой главе не рассматривается, речь о нем шла в статье 6. В этой главе обсуждается также метод Соmparable.compareТо, который не принадлежит классу Object, однако имеет схожие свойства.

Переопределяя метод equals, соблюдайте общие соглашения

Переопределение метода equals кажется простой операцией, однако есть множество способов неправильного ее выполнения, и последствия этого могут быть ужасны. Простейший способ избежать проблем: вообще не переопределять метод equals. В этом случае каждый экземпляр класса будет равен только самому себе. Это решение будет правильным, если выполняется какое-либо из следующих условий:

 

  •  Каждый экземпляр класса внутренне уникален. Это утверждение справедливо для таких классов как Thread, которые представляют не величины, а активные сущности. Реализации метода equals, предлагаемая классом Object, для этих классов работает совершенно правильно.

24

  •  Вас не интересует, предусмотрена ли в классе проверка "логического равенства". Например, в классе java.util.Random можно было бы переопределить метод equals с тем, чтобы проверять, будут ли два экземпляра Random генерировать одну и ту же последовательность случайных чисел, однако разработчики посчитали, что клиенты не должны знать о такой возможности и она им не понадобится. В таком случае тот вариант метода equals, который наследуется от класса Object, вполне приемлем.
  •  Метод equals уже переопределен в суперклассе, и функционал, унаследованный от суперкласса, вполне приемлем дли данного класса. Например, большинство реализаций интерфейса Set наследует реализацию метода equals от Класса AbstractSet, List наследует реализацию от AbsctractList, а Мар - от AbstractMap.
  •  Класс является закрытым или доступен только в пределах пакета, и вы уверены, что его метод eQuals никогда не будет вызван. Сомнительно, что в такой ситуации метод eQuals  следует переопределять, разве что на тот случай, если его однажды случайно вызовут:

Public Boolean equals (Object о) {

Throw new UnsupportedOperationException ();

}

Так когда же имеет смысл переопределять Object. equals? Тогда, когда для класса определено понятие логической эквивалентности (Jogica! equality), которая не совпадает с тождественностью объектов, а метод equals в суперклассе не был переопределен с тем, чтобы реализовать требуемый функционал. Обычно это случается с классами значении, такими как Integer или Date. Программист, сравнивающий ссылки на объекты значений с помощью метода equals, желает, скорее всего, выяснить, являются ли они логически эквивалентными, а не просто узнать, указывают ли эти ссылки на один и тот же объект. Переопределение метода equals необходимо не только для того, чтобы удовлетворить ожидания программистов, оно позволяет использовать экземпляры класса в качестве ключей в некоей схеме или элементов в некоем наборе, Имеющих необходимое и предсказуемое поведение.

Существует один вид классов значений, которым не нужно переопределение метода equals,- перечисление типов (статья 21). Поскольку для классов этого типа гарантируется, что каждому значению соответствует не больше одного объекта, метод equals из Object для этих классов будет равнозначен методу логического сравнения.

25

Переопределяя метод equals, вы должны твердо придерживаться принятых для него общих соглашений. Воспроизведем эти соглашения по тексту спецификации java,lang.Object:

Метод equals реализует отношение эквивалентности:

Рефлективность, для любой ссылки на значение х выражение х.equals(x) должно возвращать true.

Симметричность, для любых ссылок на значения х и у

выражение х. equals(y) должно возвращать t гue тогда и только тогда, когда y.equals(x) возвращает true.

Транзитивность, для любых ссылок на значения х, у и z,

если x.equals(y) возвращает true и y.equals(z) возвращает true, то и выражение х. equals(z) должно возвращать true.

Непротиворечивость. Для любых ссылок на значения х и у,

если несколько раз вызвать х. equals(y), постоянно будет возвращаться значение true либо постоянно будет возвращаться значение false при условии, что никакая информация, используемая при сравнении объектов, не поменялась.

Для любой ненулевой ссылки на значение х выражение х. equals(null) должно возвращать false.

Если у вас нет склонности к математике, все это может показаться ужасным, однако игнорировать это нельзя! Если вы нарушите условия, то рискуете получить программу, которая работает неустойчиво или заканчивается с ошибкой, а установить источник ошибок крайне сложно. Перефразируя Джона Донна (John Dоппе), можно сказать: ни один класс - не остров. ("Нет человека, что был бы сам по себе, как остров ... " - Джон Донн, "Взывая на краю".- Прим. пер.) Экземпляры одного класса часто передаются другому классу. Работа многих классов, в том числе всех классов коллекции, зависит от того, соблюдают ли передаваемые им объекты соглашения для метода equals.  

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

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

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

26

непреднамеренное нарушение этого требования несложно. Например, рассмотрим следующий класс:

 /**

* Строка без учета регистра. Регистр исходной строки сохраняется

* методом toString, однако, при сравнениях игнорируется.

*/

public final class CaseInsensitiveString {

   private String s;

   public CaseInsensitiveString(String s) {

       if (s == null)

           throw new NullPointerException();

       this.s = s;

   }

// Ошибка: нарушение симметрии! 

public boolean equals(Object o) {

       if (o instanceof CaseInsensitiveString)

           return s.equalsIgnoreCase(

               ((CaseInsensitiveString)o).s);

       if (o instanceof String)  // One-way interoperability!

           return s.equalsIgnoreCase((String)o);

       return false;

   }

 /*  Одностороннее взаимодействие!

   // Fixed

   public boolean equals(Object o) {

       return o instanceof CaseInsensitiveString &&

           ((CaseInsensitiveString)o).s.equalsIgnoreCase(s);

   }

*/

   // ...  // Остальное опущено

 

  static void main(String[] args) {

       CaseInsensitiveString cis = new CaseInsensitiveString("Polish");

       String s = "polish";

       System.out.println(cis.equals(s));

       System.out.println(s.equals(cis));

   }

}

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

Caselnsensiti veString cis = new Caselnsensiti veString ("Polish");

String s = "polish";

Как и предполагалось, выражение cis,equals(s) возвращает tгue. Проблема заключается в том, что хотя метод equals в классе CaselnsensitiveStгing знает о существовании обычных строк, метод equals в классе String не догадывается о строках, нечувствительных к регистру. Поэтому выражение s.equals(cis) возвращает false, явно нарушая симметрию. Предположим, вы помещаете в коллекцию строку, нечувствительную к регистру:

List list =new ArrayList();

list.add(cis);

27

Какое значение возвратит выражение list.contains(s)? Кто знает. В текущей версии JOK от компании Sun выяснилось, что оно возвращает false, но это всего лишь особенность реализации. В другой реализации может быть возвращено true или во время выполнения будет инициирована исключительная ситуация. Нарушив соглашение для equals, вы не можете знать, как поведут себя другие объекты, столкнувшись с вашим объектом.

Для устранения этой проблемы удалите из метода equals попытку взаимодействия с классом String.  Сделав это, вы сможете перестроить метод так, чтобы он содержал один оператор возврата:

Public Boolean equals (Object о) {

Return о instanceof CaseInsensitiveString&&

((CaseInsensitiveString) o), s.equalsIgnoreCase(s);

Транзитивность. Третье требование в соглашениях для метода equals гласит: если один объект равен второму, а второй объект равен третьему, то и первый объект должен быть равен третьему объекту. И вновь несложно представить непреднамеренное нарушение этого требования. Допустим, что программист создает подкласс, придающий своему суперклассу новый аспект. иными словами, подкласс привносит некую информацию, окаэываl0~УЮ влияние на процедуру сравнения. Начнем с простого неизменяемого класса, соответствующего точке в двухмерном пространстве:

public class Point {

private final int x;

private final int y;

public Point(int x, int y) {

       this.x = x;

       this.y = y;

}

public boolean equals(Object o) {

       if (!(o instanceof Point))

           return false;

       Point p = (Point)o;

       return p.x == x && p.y == y;

   }

 // ...  // Остальное опущено

Предположим, что вы хотите расширить этот класс, добавив понятие цвета:

public elass ColorPoint  extends Point

private Color color;

28

public colorPoint(int х, int у, Color color) {

super(x, у);

this.color = color;

// Остальное опущено

Как должен выглядеть метод equals? Если вы оставите его как есть, реализация метода будет наследоваться от класса Point, и при сравнении с помощью методов equals информация о цвете будет игнорироваться. Хотя такое решение и не нарушает общих соглашений для метода equals, очевидно, что оно неприемлемо. Допустим, вы пишите метод equals, который возвращает значение t rue, только если его аргументом является цветная точка, имеющая то же положение и тот же цвет:

// Ошибка: нарушение симметрии!

publie boolean equals (Objeet о) {

if (1(0 instaneeof ColorPoint))

return false;

ColorPoint ср = (ColorPoint) o;

return super.equals(o) && cp.color == color;

}

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

Point р = new Point (1, 2);

ColorPoint ер = new ColorPoint (1, 2, Color.RED);

Выражение р.equals(cp) возвратит true, а cр.equals(p) возвратит false. Вы можете попытаться решить эту проблему, заставив метод ColorPoint. equals игнорировать цвет при выполнении "смешанных сравнений"

// Ошибка: нарушение транзитивности!

public boolean equals (Object о) {

If (!(o instanceof Point))

return false;

// Если о - обычный Point, выполнить сравнение

// без проверки цвета 

if (! (о instanceof ColorPoint))

return o.equals(this);

// Если о - ColorPoint, выполнить полное сравнение 

ColorPoint ер = (ColorPoint) o;  

return super.equals(o) && cp.color == color;

}

29

Такой подход обеспечивает симметрию, но за счет транзитивности:

ColorPoint р1 = new ColorPoint(1, 2, Color.RED);

Point р2 = new Point(1, 2);

ColorPoint р3 = new ColorPoint(1, 2, Color.BLUE);

В этом случае выражения р1.equals(p2) и р2.equals(p3) возвращают значение true, а р1.equals(р3) возвращает false - прямое нарушение транзитивности. Первые два сравнения игнорируют цвет, в третьем цвет учитывается.

Так где же решение? Оказывается, это фундаментальная проблема эквивалентных отношений в объектно-ориентированных языках. Не существует способа расширить класс, порождающий экземпляры, и добавить к нему новый аспект, сохранив при этом соглашения для метода equals. Зато есть изящный обходной путь. Следуйте совету из статьи 14: "Предпочитайте композиции наследование". Вместо того чтобы заставлять ColorPoint расширять класс Point, поместите в ColorPoint закрытое поле Point и открытый метод представления (статья 4), который в том же месте, где находится цветная точка, показывает обычную точку:

// Добавляет новый аспект, не нарушая соглашений для equals 

public class ColorPoint {

private Point point;

private Color color;

public ColorPoint(int х, int у, Color color) {

point = new Point(x, у);

this.color = color;

/**

* Для данной цветной точки возвращает точку-представление

*/

public Point asPoint()

return point;

public boolean equals(Object о) {

if (!(o instanceof ColorPoint» return false;

ColorPoint ср = (ColorPoint)o;

return cp.point.equals(point) && cp.color.equals(color);

}

// Остальное опущено 

}

30

В библиотеках для платформы Java содержатся классы, которые являются подклассами для класса, создающего экземпляры, и при этом придают ему новый аспект. Например, java.sql.Тimestamp является подклассом класса java.util,Date и добавляет поле для наносекунд. Реализация метода equals в Тimestamp нарушает правило симметрии, и это может привести к странному поведению программы, если объекты Timestamp и Date использовать в одной коллекции или смешивать как-нибудь иначе. В документации к классу Тimestamp есть предупреждение, предостерегающее программиста от смешивания объектов Date и Timestamp. Пока вы не смешиваете их, у вас проблем не будет, но если вы сделаете это, устранение возникших в результате ошибок может быть непростым. Класс Timestamp не является правильным, и подражать ему не надо.

Заметим, что вы можете добавить аспект в подкласс абстрактного класса, не нарушая при этом соглашений для метода equals. Это важно для тех разновидностей иерархии классов, которые вы получите, следуя совету из статьи 20: "Заменяйте объединение иерархией классов". Например, вы можете иметь простой абстрактный класс Shape, а также подклассы Ci,rcle, добавляющий поле радиуса, и Rectangle, добавляющий поля длины и ширины. Только что продемонстрированные проблемы не будут возникать до тех пор, пока нет возможности создавать экземпляры суперкласса.

Непротиворечивость. Четвертое требование в соглашениях для метода equals гласит: если два объекта равны, они должны быть равны все время, пока один из них (или оба) не будет изменен. Это не столько настоящее требование, сколько напоминание о том, что изменяемые объекты в разное время могут быть равны разным объектам, а неизменяемые объекты - не могут. Когда вы пишите класс, подумайте, не следует ли его сделать неизменяемым (статья 13). Если вы решите, что это необходимо, позаботьтесь о том, чтобы ваш метод equals выполнял это ограничение: равные объекты должны оставаться все время равными, а неравные объекты - соответственно, неравными.

Отличие от null (non-nullity). Последнее требование гласит, что все объекты должны отличаться от нуля (null). Трудно себе представить, что в ответ на вызов o.equals(null) будет случайно возвращено значение true, однако вполне вероятно случайное инициирование исключительной ситуации NullPointerException. Общие соглашения этого не допускают. Во многих классах методы equals имеют защиту в виде явной про верки аргумента на null:

public boolean equals(Object о) {

if (о == null)

return false;

}

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

31

к его полям. Перед приведением типа метод equals должен воспользоваться оператором instanceof для проверки того, что аргумент имеет правильный тип:

public boolean equals(Object о){

if (I(o instanceof МуТуре))

return false;

}

Если бы эта проверка типа отсутствовала, а метод equals получил бы аргумент неправильного типа, то он бы инициировал исключительную ситуацию ClassCastException, что нарушает соглашения для метода equals. Однако здесь есть оператор instanseof, и если его первый операнд равен null, то независимо от типа второго операнда он возвратит false (JLS,15.19.2]. Поэтому при передаче null проверка типа вернет false, и, следовательно, нет необходимости делать отдельную проверку для null: Собрав все это вместе, получаем рецепт для создания высококачественного метода equals:

  1.  Используйте оператор == для проверки, является ли аргумент ссылкой на указанный объект, Если является, возвращайте true. Это всего лишь способ повьшreния производительности программы, которая будет низкой, если процедура сравнения оказывается трудоемкой.
  2.  Используйте оператор instanceof для проверки, имеет ли аргумент правильный тип. Если не имеет, возвращайте false. Обычно

прав ильный тип - это тип того класса, которому принадлежит данный метод. В некоторых случаях это может быть какой-либо интерфейс, реализуемый данным классом. Если класс реализует интерфейс, который уточняет соглашения для метода equals, то в качестве типа указывайте этот интерфейс, что позволит выполнять сравнение классов, реализующих интерфейс. Подобным свойством обладают интерфейсы коллекций Set, List, Мар и Мар. Entry.

  1.  Приводите аргумент к правильному типу. Поскольку эта операция следует за проверкой i.nstanceof, она гарантированно будет выполнена.
  2.  Пройдитесь по всем "значимым" полям класса и убедитесь в том, что значение поля в аргументе и значение того же поля в объекте соответствуют друг другу. Если проверки для всех полей прошли успешно, возвращайте результат true, в противном случае - false.

Если на шаге 2 тип был определен как интерфейс, вы должны получать доступ к значимым полям аргумента, используя методы самого интерфейса. Если же тип аргумента определен как класс, то в зависимости от условий вам, возможно, удастся получить прямой доступ к полям аргумента.  Для простых полей, за исключением типов float и double, для сравнения применяйте оператор ==. Для полей со ссылкой на объекты рекурсивно

32

вызывайте метод equals. Для поля float преобразуйте его значение в int с помощью метода Float. floatTolntBi ts,

а затем сравнивайте полученные значения, используя оператор -- для полей double преобразуйте их значения в long с помощью метода Double. doubleToLongBi ts, а затем сравнивайте полученные значения long, используя оператор ==. (Особая процедура обработки полей float и double нужна потому, что существуют особые значения Float. NaN, -о. Of, а также аналогичные значения для типа double. См. документацию по Float. equals.) При работе с полями массивов применяйте перечисленные правила для каждого элемента отдельно. Некоторые поля, предназначенные для ссылки на объекты, вполне оправданно могут иметь значение null. Чтобы не допустить возникновения исключительной ситуации NullPointerException,

для сравнения подобных полей используйте следующую идиому:

(field == null ? о. field == null : field. equals( о. Field))

Если field и о. field часто ссылаются на один и тот же объект, 'следующий альтернативный вариант может оказаться быстрее:

(field == о. field 11 (field ! = null && field. equals( о, field)))

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

В результате метод equals сможет выполнять' простое и точное сравнение канонических форм вместо того, чтобы пользоваться более трудоемким и неточным вариантом сравнения. Описанный прием более подходит для неизменяемых классов (статья 13), поскольку, когда объект меняется, приходится приводить его каноническую форму в соответствие последним изменениям.

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

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

33

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

  1.  Закончив написание собственного метода equals, задайте себе вопрос: является ли он симметричным, транзитивным и непротиворечивым? (Оставшиеся два свойства обычно получаются сами собой.) Если ответ отрицательный, разберитесь, почему не удалось реализовать эти свойства, и подправьте метод соответствующим образом.

в качестве конкретного примера метода equals, который был выстроен по приведенному выше рецепту, можно посмотреть PhoneNumbeг.equals из статьи 8. Несколько заключительных предостережений:

Переопределяя метод equals, всегда переопределяйте метод hashCode (статья 8).

Не старайтесь быть слишком умным. Если вы проверяете лишь равенство полей, соблюдать условия соглашений для метода equals совсем не трудно. Если же в поисках равенства вы излишне агрессивны, можно легко нарваться на неприятности. Так, использование синонимов в каком бы то ни было обличии обычно оказывается плохим решением. Например, класс File не должен пытаться считать равными символьные связи (в системе UNIX), относящиеся к одному и тому же файлу. К счастью, он этого и не делает.

Не надо писать метод equals, использующий ненадежные ресурсы. Если вы делаете это, то соблюсти требование непротиворечивости будет крайне трудно. Например, метод equals в классе java.net.URL использует IP-aдpeca хостов, соответствующих сравниваемым адресам URL. Процедура преобразования имени хоста в IР-адрес может потребовать выхода в компьютерную сеть, и нет гарантии, что это всегда будет давать один и тот же результат. Это может привести к нарушению соглашений для метода equals, сравнивающего адреса URL, и на практике уже, создавало проблемы. сожалению, описанную схему сравнения уже нельзя поменять из-за требований обратной совместимости.) За некоторыми исключениями, 'методы equals обязаны выполнять детерминированные операции с объектами, находящимися в памяти.

Декларируя метод equals, не нужно указывать вместо Object другие типы объектов. Нередко программисты пишут метод equals следующим образом, а потом часами ломают голову над тем, почему он не работает правильно:

public boolean equals(MyClass о) {

}

34

Проблема заключается в том, что этот метод не переопределяет (override) метод Object. equals, чей аргумент имеет тип Object, а перегружает его (overload) (статья 26). Подобный "строго типизированный" метод equals можно создать в дополнение к обычному методу equals, однако поскольку оба метода возвращают один и тот результат, нет никакой причины делать это. При определенных условиях это может дать минимальный выигрыш в производительности, но не оправдывает дополнительного усложнения программы (статья 37).

Переопределяя метод equals, всегда переопределяйте hashCode

Распространенным источником ошибок является отсутствие переопределения метода hashCode. Вы должны переопределять метод hashCode в каждом классе, где переопределен метод equals. Невыполнение этого условия приведет к нарушению общих соглашений для метода Object.hashCode, а это не позволит вашему классу правильно работать в сочетании с любыми коллекциями, построенными на использовании хэш-таблиц, в том числе с HashMap, HashSet и HashTable.

Приведем текст соглашений, представленных в спецификации jауа.lang.Object:

Если во время работы приложения несколько раз обратиться к одному и тому же объекту, метод hashCode должен постоянно возвращать одно и то же целое число, показывая тем самым, что информация, которая используется при сравнении этого объекта с другими (метод equals), не поменялась. Однако если приложение остановить и запустить снова, это число может стать другим.

Если метод equals(Object) показывает, что два объекта равны друг другу, то вызвав для каждого из них метод hashCode, вы должны получить в обоих случаях одно и то же целое число.

Если метод equals(Object) показывает, что два объекта не равны

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

Главным является второе условие: равные объекты должны иметь одиноко - вый хаш·код. Если вы не переопределите метод hashCode, оно будет нарушено: два различных экземпляра с точки зрения метода equals могут быть логически равны, Однако для метода hashCode из класса Object это всего лишь два объекта, не имеющих между собой ничего общего. Поэтому метод hashCode скорее всего возвратит для этих объектов два случайных числа. а не одинаковых, как того требует соглашение.

35

В качестве примера рассмотрим следующий упрощенный класс PhoneNumber, в котором метод equals построен по рецепту из статьи 7:

import java.util.*;

public final class PhoneNumber {

   private final short areaCode;

   private final short exchange;

   private final short extension;

public PhoneNumber(int areaCode, int exchange,

                      int extension) {

       rangeCheck(areaCode,   999, "area code");

       rangeCheck(exchange,   999, "exchange");

       rangeCheck(extension, 9999, "extension");

       this.areaCode  = (short) areaCode;

       this.exchange  = (short) exchange;

       this.extension = (short) extension;

   }

private static void rangeCheck(int arg, int max,

                                  String name) {

       if (arg < 0 || arg > max)

          throw new IllegalArgumentException(name +": " + arg);

   }

public boolean equals(Object o) {

       if (o == this)

           return true;

       if (!(o instanceof PhoneNumber))

           return false;

       PhoneNumber pn = (PhoneNumber)o;

       return pn.extension == extension &&

              pn.exchange  == exchange  &&

              pn.areaCode  == areaCode;

   }

 // Нет метода hashCode!

 // Остальное опущено

 }

Предположим, что вы попытались использовать этот класс с HashMap:

Мар m = new HashMap();

m. put (new PhbneNumber( 408, 867, 5309), "Jenny");

Вы вправе ожидать, что m.get(new PhoneNumber(408, 867, 5309)) возвратит строку "Jenny", однако он выдает null. Заметим, что здесь задействованы два экземпляра класса PhoneNumber: один используется для вставки в таблицу HashMap, а другой,

36

равный ему экземпляр,- для поиска. Отсутствие в классе PhoneNumber переопределенного метода hashCode приводит к тому, что двум равным экземплярам соответствует разный хэш-код, т. е. имеем нарушение соглашений для этого метода. Как следствие, метод get ищет указанный телефонный номер в другом сегменте хэш-таблицы, а не там, где была сделана запись с помощью метода put. Разрешить эту проблему можно, поместив в класс PhoneNumber правильный метод hashCode.

Как же должен выглядеть метод hashCode? Написать действующий, но не слишком хороший метод нетрудно. Например, следующий метод всегда приемлем, но пользоваться им не надо никогда:

// Самая плохая из допуотимых хэш-функций – никогда

 // не пользуйтеоь еюl

public int hashCode() { return 42; }

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

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

  1.  Присвойте переменной result (тип int) некоторое ненулевое число, скажем, 17.
  2.  Для каждого значимого поля f в вашем объекте (т. е. поля, значение которого принимается в расчет методом equals), выполните следующее:

а. Вычислите для поля хэш-код с (тип int):а

  1.  Если поле имеет тип boolean, вычислите (f ? О : 1).
  2.  Если поле имеет тип byte, char, short или int, вычислите (int)f.
  3.  Если поле имеет тип long, вычислите (int)(f - (f >>> 32)).
  4.  Если поле имеет тип float, вычислите Float. floatтoIntBits(f).
  5.  Если - тип double, вычислите Double. doubleToLongBits(f), а затем преобразуйте полученное значение, как указано в п. 2.a.3.

37

  1.  Если поле является ссылкой на объект, а метод equals

данного класса сравнивает это поле, рекурсивно вызывая другие методы equals, так же рекурсивно вызывайте для этого поля метод hashCode. Если требуется более сложное сравнение, вычислите для данного поля каноническое представление (canonical representation), а затем вызовите для него

метод hashCode. Если значение поля равно null, возвращайте О (можно любую другую константу, но традиционно используется О).

  1.  Если поле является массивом, обрабатываете его так, как если бы каждый его элемент был отдельным полем. Иными словами, вычислите хэш-код для каждого значимого элемента, рекурсивно применяя данные правила, а затем объедините полученные значения так, как описано в п. 2.Ь.

b. Объедините хэш-код с, вычисленный на этапе а, с текущим значением поля resul t следующим образом:

result = 37*result + с;

  1.  Верните значение resul t.
  2.  Закончив писать метод hashCode, спросите себя, имеют ли равные экземпляры одинаковый хэш-код. Если нет, выясните, в чем причина, и устраните проблему.

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

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

Умножение на шаге 2.Ь создает зависимость значения хэш-кода от очередности обработки полей, а это обеспечивает гораздо лучшую хэш-функцию в случае, когда в классе много одинаковых полей. Например, если из хэш-функции для класса String, построенной по этому рецепту, исключить умножение, то все анаграммы (слова, полученные от некоего исходного слова путем перестановки букв) будут иметь один и тот же хэш-код. Множитель 37 выбран потому, что является простым нечетным числом. Если бы это было четное число и при умножении произошло переполнение,

38

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

Используем описанный рецепт для класса PhoneNumber. В нем есть три значимых поля, все имеют тип short. Прямое применение рецепта дает следующую хэш-функцию

public int hashCode() {

int result = 17;

result = 37*result + areaCode;

result = 37*result + exchange;

result = 37*result + extension;  \

return result;

}

Поскольку этот метод возвращает результат простого детерминированного вычисления, исходными данными для которого являются три значащих поля в экземпляре PhoneNumber, очевидно, что равные экземпляры PhoneNumber будут иметь равный хэш-код. Фактически этот метод является абсолютно правильной реализацией hashCode для класса PhoneNumber наряду с методами из библиотек Java версии 1.4. Он прост, довольно быстр и правильно разносит неравные телефонные номера по разным сегментам хэш-таблицы.

Если класс является неизменным и при этом важны затраты на вычисление хэш-кода, вы можете сохранять хэш-код в самом объекте вместо того, чтобы вычислять его всякий раз заново, как только в нем появится необходимость. Если вы полагаете, что большинство объектов данного типа будут использоваться как ключи в хэш-таблице, вам следует вычислять соответствующий хэш-код уже в момент создания соответствующего экземпляра. С другой стороны, вы можете выбрать инициализацию, отложенную до первого обращения к методу hashCode (статья 48). Хотя достоинства подобного режима для нашего класса PhoneNumbers не очевидны, покажем, как это делается:

// Отложенная инициализация, кэшируемый hashCode

private volatile int hashCode = о;  // (см. статью 48)

public int hashCode() {

if (hashCode == о) {

int result = 17;

result = 37*result + areaCode;

result = 37*result + exchange;

result = 37*result + extension;

hashCode = result;

}

return hashCode;

}

39

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

Повышение производительности не стоит того, чтобы при вычислении хэш-кода игнорировать значимые части объекта. Хотя хэш-функция способна работать быстрее, ее качество может ухудшиться до такой степени, что обработка хэш-таБлицы будет производиться слишком медленно. В частности, не исключено, что фэш-функция столкнется с большим количеством экземпляров, которые существенно разнятся как раз в тех частях, которые вы решили игнорировать. В этом случае хэш-функция сопоставит всем этим экземплярам всего лишь несколько значений хэш-кода. Соответственно, коллекция, основанная на хэш-функциях, будет вызывать падение производительности в квадратичной зависимости от числа элементов. Это не просто теоретическая проблема. Хэш-функция класса String, реализованная во всех версиях платформы Java до номера 1,2, проверялась самое большее для строк с 16 символами и равномерным распределением пробелов по всей строке, начиная с первого символа. для больших коллекций иерархических имен, таких как URL, эта хэшфую(ция демонстрировала то патологическое поведение, о котором здесь говорилось.

у многих классов в библиотеках для платформы Java, таких как Str1ng, Integer и Date, конкретное значение, возвращаемое методом hashCode, определяется как функция от значения экземпляра. Вообще говоря, это не слишком хорошая идея, поскольку она серьезно ограничивает возможности по улучшению хэш-функций в будущих версиях. Если бы вы оставили детали реализации фэш-йункции не конкретизированными и в ней обнаружился бы изъян, вы бы могли исправить эту хэш-функцию в следующей версии, не опасаясь утратить совместимость с теми клиентами, работа которых зависит от того, какое конкретное значение возвращает хэш-функция.

Всегда переопределяйте метод toStrlng

в классе java.lang.Object предусмотрена реализация метода toString, однако возвращаемая им строка, как правило, совсем не та, которую желает видеть пользователь вашего класса. Она состоит из названия класса, за которым следуют символ "коммерческого at" (@) и его хэш-код в виде беззнакового шестнадцатеричного числа, например "PhoneNumbeг@16ЗЬ91". Общее соглашение для метода toString: возвращаемая строка должна быть "лаконичным, но информативным, легко читаемым

40

представлением объекта". Хотя можно поспорить, является ли лаконичной и легко читаемой строка "РhопеNumЬег@16ЗЬ91", она не столь информативна, как, например, такая ,строка: "( 408) 867 -5З09'~. Далее в соглашении для метода toSt ring говорится:

"Рекомендуется во всех подклассах переопределять этот метод". Хороший совет, ничего не скажешь.

Эти соглашения не столь строги, как соглашения для методов equals и hashCode (статьи 7 и 8), однако, качественно реализовав метод toString, вы сделаете свой класс более приятным в использовании. Метод toSt ring вызывается автоматически, когда ваш объект передается методу println, оператору сцепления строк (+) или assert (в версии 1.4). Если вы создали хороший метод toString, получить удобное диагностическое сообщение можно простым способом:

System.out.println("Failed to connect: "+ phoneNumber);

Программисты все равно будут строить такие диагностические сообщения, переопределите вы метод toString или нет, но сообщения не станут понятней, если не сделать этого. Преимущества от реализации удачного метода toString передаются не только экземплярам этого класса, но и объектам, которые содержат ссылки на эти экземпляры, особенно это касается коллекций. Что бы вы хотели увидеть: "{Jеппу=РhопеNumЬег@16ЗЬ91}" или же "{Jenny=(408) 867-5З09}"?

Будучи переопределен, метод toString должен передавать всю полезную информацию, которая содержится в объекте, как это было показано в примере с телефонными номерами. Однако это не годится для больших объектов или объектов, состояние которых трудно представить в виде строки. В подобных случаях метод toString возвращает такое резюме, как "Manhattan white pages (1487536 listings)" или "Thread [main, 5, main]". В идеале полученная строка не должна требовать разъяснений. (Последний пример с Th read не отвечает этому требованию.)

При реализации метода toString вы должны принять одно важное решение: будете ли вы описывать в документации формат возвращаемого значения. Это желательно делать для классов-значении (value class), таких как телефонные номера и таблицы. Задав определенный формат, вы получите то преимущество, что он будет стандартным, однозначным и удобным для чтения представлением соответствующего объекта. Это представление можно использовать для ввода, вывода, а также для создания удобных для прочтения записей в фиксируемых объектах, например в документах XML. При задании определенного формата, как правило, полезно бывает создать соответствующий конструктор объектов типа String (или статический метод генерации, см. статью 1), что позволит программистам с легкостью осуществлять преобразование между объектом и его строковым представлением. Такой подход используется в 'библиотеках платформы Java для многих классов-значений, включая Biglnteger, BigDecimal и большинство примитивных классов-оболочек (wrapper).

Неудобство от конкретизации формата значения, возвращаемого методом toString, заключается в том, что если ваш класс используется широко, то, задав формат, вы оказываетесь привязаны к нему навсегда. Другие программисты будут писать код, который анализирует данное представление, генерирует и использует его при записи

41

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

версиях.

Будете вы объявлять формат или нет, вы должны четко обозначить ваши

намерения. Если вы описываете формат, то обязаны сделать это пунктуально. В качестве примера представим метод toString, который должен сопровождать класс PhoneNumber (статья 8):

/**

* Возвращает представление данного телефонного номера в виде строки.

* Строка состоит из четырнадцати символов, имеющих формат

* "(ХХХ) YYY-ZZZZ" , где ХХХ - код зоны, УУУ - номер АТС,

* ZZZZ - номер абонента в АТС. (Каждая прописная буква представляет * одну десятичную цифру.)

*

* Если какая-либо из трех частей телефонного номера мала и

* не заполняет свое поле, последнее дополняется ведущими нулями. * Например, если значение номера абонента в АТС равно 12З, то

* последними четырьмя символами в строковом представлении будут "0123".

*

* Заметим, что закрывающую скобку, следующую за кодом зоны, и первую * цифру номера АТС разделяет один пробел.

*/

public Stгiпg tоStгiпg() {

геtuгп "(" + tоРаddеdStгiпg(агеаСоdе, З) + ") " +

tоРаddеdStгiпg(ехсhапgе, 3) + "-" +

toPaddedString(extension, 4);

/**

* Преобразует значение типа iпt в строку указанной длины, дополненную

* ведущими нулями. Предполагается. что i >= О,

* 1 <= 1епgth <= 10, а Integer.toString(i) <= 1ength.

*/

private static String toPaddedString(int i, int  length)

String s = Integer.toString(i);

return ZEROS[length - s.length()] + s;

}

private static String[] ZEROS =

{ “”, "0", "00", "000", "0000",

"00000". "000000", "0000000". "00000000", "ООООООООО"};

42

Если вы решили не конкретизировать формат, соответствующий комментарий к документации должен выглядеть примерно так:

/**

* Возвращает краткое описание зелья. Точные детали представления

* не конкретизированы и могут меняться, однако следующее представление . * может рассматриваться в качестве типичного:

*

* "[Зелье #9: тип=любовный, аромат=скипидар, вид=тушь]"

*/

public Striпg toString () { ... }

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

Вне зависимости от того, описываете вы формат или нет, всегда полезно предоставлять альтернативный программный доступ ко всей информации, которая содержится в значении, возвращаемом методом toString. Например, класс PhoneNumber должен включать в себя методы доступа к коду зоны, номеру А те и номеру абонента в А те. В противном случае программистам, которым нужна эта информация, придется делать разбор данной строки. Помимо того, что вы снижаете производительность приложения и заставляете программистов выполнять ненужную работу, это чревато ошибками и приводит к созданию ненадежной системы, которая перестает работать, как только вы меняете формат. Не предоставив альтернативных методов доступа, вы де-факто превращаете формат строки в элемент API, даже если в документации и указали, что он может быть изменен.

Соблюдайте осторожность при переопределении метода Сlоnе

Интерфейс Сlonеаblе проектировался в качестве дополнительного интерфейса (mixin) (статья 16), позволяющего объектам объявлять о том, что они могут быть клонированы. К сожалению, он не может использоваться для этой цели. Его основной недостаток - отсутствие метода clone; в самом же классе Object метод clone является закрытым. Вы не можете, не обращаясь к механизму отражения свойств (reflection) (статья 35), вызывать для объекта метод clone лишь на том основании, что он реализует интерфейс Сlоnеаblе. Даже отражение может завершиться неудачей, поскольку нет гарантии, что у данного объекта есть доступный метод clone. Несмотря на этот и другие. недочеты, данный механизм используется настолько широко, что Имеет смысл с ним разобраться. В этой статье рассказывается о том, каким образом создать хороший метод clone, обсуждается, когда имеет смысл это делать, а также кратко описываются альтернативные подходы.

43

Что же делает интерфейс Cloneable, который, как оказалось, не имеет методов?

Он определяет поведение закрытого метода clone в классе Object: если какой-либо класс реализует интерфейс Cloneable, то метод clone, реализованный в классе Object, возвратит его копию с воспроизведением всех полей, в противном случае будет инициирована исключительная ситуация CloneNotSupportedException. Это совершенно нетипичный способ использования интерфейсов, и ему не следует подражать. Обычно факт реализации некоего интерфейса говорит кое-что о том, что этот класс может делать для своих клиентов. Интерфейс же Cloneable лишь меняет поведение некоего защищенного метода в суперклассе.

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

Общие соглашения для метода clone довольно свободны. Они описаны в спецификации класса java.lang.Object:

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

x.clone() ! = х 

и 

x.clone().getClass() == x.getClass()

возвращали true, однако эти требования не являются безусловными. Обычно условие состоит в том, чтобы выражение

x.clone().equals(x)

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

Такое соглашение создает множество проблем. Условие "никакие конструкторы не вызываются" является слишком строгим. Правильно работающий метод clone может воспользоваться конструкторами для создания внутренних объектов клона. Если же класс является окончательным (final), метод clone может просто вернуть объект, СОЗДанный конструктором.

Условие, что x.clone().getClaSs() должно быть тождественно x.getClass(), является слишком слабым. Как правило, программисты полагают, что если они расширяют класс и вызывают в полученном подклассе метод super. clone, то получаемый в результате объект будет экземпляром этого подкласса. Реализовать такую схему суперкласс может только одним способом - вернуть объект, полученный в результате вызова метода supeг.clone. Если метод clone возвращает объект, созданный конструктором, это будет экземпляр другого класса. Поэтому, если в расширяемом

44

классе вы переопределяете метод clone, то возвращаемый объект вы должны получать вызовом super. clone. Если все суперклассы данного класса выполняют это условие, рекурсивный вызов метода supe г. clone в конечном счете приведет к вызову метода clone из класса Object и к созданию экземпляра именно того класса, который нужен. Этот механизм отдаленно напоминает автоматическое сцепление конструкторов, за исключением того, что оно не является принудительным.

В версии 1.3 интерфейс Cloneable не раскрывает, какие обязанности берет на себя класс, реализуя этот интерфейс. В спецификации не говорится ничего, помимо того, что реализация данного интерфейса влияет на реализацию метода clone в классе Object. На практике же требования сводятся к тому, что в классе, реализующем интерфейс Cloneable, должен быть представлен правильно работающий открытый метод clone. Вообще же, выполнить это условие невозможно, если только все суперклассы этого класса не будут иметь правильную реализацию метода clone, открытую или защищенную.

Предположим, что вы хотите реализовать интерфейс Cloneable с помощью класса, чьи суперклассы имеют правильно работающие методы clone. В зависимости от природы этого класса объект, который вы получите после вызова super.clone(), может быть, а может и не быть похож на тот, что вы будете иметь в итоге. С точки зрения любого суперкласса этот объект будет полнофункциональным клоном исходного объекта. Поля, объявленные в вашем классе (если таковые имеются), будут иметь те же значения, что и поля в клонируемом объекте. Если все поля объекта содержат значения простого типа или ссылки на неизменяемые объекты, то возвращаться будет именно тот объект, который вам нужен, и дальнейшая обработка в этом случае не требуется. Такой вариант демонстрирует, например, класс PhoneNumbe r из статьи 8. Все, что здесь нужно,- это обеспечить в классе Object открытый доступ к защищенному методу clone:

public Object clone() {

try {

return super,clone();

catch(CloneNotSupportedException е) {

throw new Error("Assertion failure");        // Этого не может быть 

}

}

Однако если ваш объект содержит поля, имеющие ссылки на изменяемые объекты, такая реализация метода clone может иметь катастрофические последствия. Рассмотрим класс Stack' из статьи 5:

public class Stack {

private Object[] elements;

private int size = о;

public Stack(int initialCapacity) {

this.elements = new Object[initialCapacity];

}

45

public void push(Object е) {

ensureCapacity();

elements[size ++] = е;

}

public Object рор() {

if (size == О)

throw new EmptyStackException();

Object result = elements[- size];

elements[size] = null;  // Убираем устаревшую ссылку 

return result;

}

// Убедимся в том, что в стеке есть место хотя бы

// еще для одного элемента

private void ensureCapacity() {

if (elements.length == size) {

Object oldElements[] = elements;

elements = new Object[2 * elements.length + 1]; System.arraycopy(oldElements, о, elements, О, size);

}

}

}

Предположим, что вы хотите сделать этот класс клонируемым. Если его метод clone просто вернет результат вызова super.clone(), полученные экземпляр Stack будет иметь правильное значение в поле size, однако его поле elements будет ссылаться на тот же самый массив, что и исходный экземпляр Stack. Следовательно, изменение в оригинале будет нарушать инварианты клона, и наоборот. Вы быстро обнаружите, что ваша программа выдает бессмысленные результаты либо инициирует исключительную ситуацию NullPointerException.

Подобная ситуация не могла бы возникнуть, если бы использовался основной конструктор класса Stack. Метод clone фактически работает как еще один конструктор, и вам необходимо убедиться в том, что он не вредит оригинальному объекту и правильно устанавливает инварианты клона. Чтобы метод clone в классе Stack работал правильно, он должен копировать содержимое стека. Проще всего это сделать путем рекурсивного вызова метода clone для массива elements:

public Object clone() throws CloneNotSupportedException {

Stack result = (Stack) super.clone();

result.elements = (Object[]) elements.clone();

return result;

}

46

Заметим, что такое решение не будет работать, если поле elements имеет модификатор final, поскольку тогда методу clone запрещено помещать туда новое значение. Это фундаментальная проблема: архитектура клона не совместима с обычным применением полей final, содержащих ссылки на изменяемые объекты. Исключение составляют случаи, когда изменяемые объекты могут безопасно использовать сразу и объект, и его клон. Чтобы сделать класс клонируемым, возможно, потребуется убрать' у некоторых полей модификатор final.

Не всегда бывает достаточно рекурсивного вызова метода clone. Предположим, что вы пишите метод clone для хэш-таблицы, состоящей из набора сегментов (buckets), каждый из которых содержит либо ссылку на пер выи элемент в связном списке, имеющем несколько пар ключ/значение, либо null, если сегмент пуст. для лучшей производительности в этом классе вместо java.util.LinkedList используется собственный упрощенный связный список:

public class HashTable implements Cloneable{

private'Entry[] buckets = ... ;

private static class Entry {

Object key;

Object value;

Entry next;

Entry(Object key, Object value, Entry next) {

this.key  = key;

this.value = value;

this.next = next;

}

}

// Остальное опущено

}

Предположим, что вы рекурсивно клонируете массив buckets, как это делалось для класса Stack:

// Ошибка: объекты будут иметь общее внутреннее состояние

public Object clone() throws CloneNotSupportedException {

HashTable result = (HashTable) super.clone();

result.buckets = (Entry[]) buckets.clone();

return result;

}

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

47

копировать связный список для каждого сегмента. Представим один из распространенных приемов:

public class HashTable implements Cloneable {

private Entry[] buckets = ….;

private static class Entry {

Object key;

Object value;

Entry next;

Entry(Object key, Object value, Entry next) {

this.key  = key;

this.value = value;

this.next = next;

}

// Рекурсивно копирует связный список. начинающийся

// с указанной записи

Entry deepCopy() {

return new Entry(key, value,

next == null ? null : next.deepCopy());

public Object clone() throws CloneNotSupportedException

HashTable result = (HashTable) super.clone();

result.buckets = new Entry[buckets.length];

for (int i = о; i < buckets.lenght; i ++)

if (buckets[i] != null)

result.buckets[i] = (Entry)

buckets[i]:deepCopy();

return result;

}

// Остальное опущено 

}

Закрытый класс HashTable. Entry был привнесен для реализации метода "глубокого копирования" (deep сору). Метод clone в классе HashTable размещает в памяти новый массив buckets нужного размера, а затем в цикле просматривает исходный набор buckets, выполняя глубокое копирование каждого непустого сегмента. Чтобы скопировать связный список, начинающийся с указанной записи, метод глубокого копирования (deepCopy) из класса Ent гу рекурсивно вызывает самого себя. Этот прием выг/\ядит изящно и прекрасно работает для не слишком длинных сегментов, однако он не совсем подходит для клонирования связных списков, поскольку для каждого элемента в списке он делает в стеке новую запись. И если список buckets

48

окажется большим, может возникнуть переполнение стека. Во избежание этого можно заменить в методе deepCopy рекурсию итерацией:

// Копирование в цикле связного списка.

// начинающегося с указанной записи

Entгy deepCopy() {

Entry result = new Entгy(key, value, next);

for (Entry р = result; p.next ! = null; р = p.next)

p.next = new Entгy(p.next.key, p.next.value, p.next.next);

return result;

}

Окончательный вариант клонирования сложных объектов заключается в вызове метода supeг.clone, в установке всех полей в первоначальное состояние и в вызове методов более высокого уровня, окончательно определяющих состояние объекта. В случае с классом HashTable поле buckets должно получить при инициализации новый массив сегментов, а затем для каждой пары ключ/значение в клонируемой хэш-таблице следует вызвать метод put(key, value) (не показан в распечатке). При таком подходе обычно получается простой, довольно элегантный метод clone, пусть даже и не работающий столь же быстро, как при прямо м манипулировании содержимым объекта и его клона.

Как и конструктор, метод clone не должен вызывать каких-либо переопределяемых методов создаваемого клона (статья 15). Если метод clone вызывает переопределенный метод, этот метод будет выполняться до того, как подкласс, в котором он был определен, установит для клона нужно"е состояние. Это может привести к разрушению и клона, и самого оригинала. Поэтому метод put (key, val ue) должен быть либо не переопределяемым (final), либо закрытым. (Если это закрытый метод, то, по-видимому, он является вспомогательным (helper method) для другого, открытого и переопределяемого метода.)

Метод clone в классе Object декларируется как способный инициировать исключительную ситуацию CloneNotSupportedException, однако в пере определенных методах clone эта декларация может быть опущена. Метод clone в окончательном классе не должен иметь такой декларации, поскольку работать с методами, не инициирующими обрабатываемых исключений, приятнее, чем с теми, которые их инициируют (статья 41). Если же метод clone пере определяется в расширяемом классе, особенно в классе, предназначенном для наследования (статья 15), новый метод clone должен иметь декларацию для исключительной ситуации CloneNotSuppoгtedException. Это дает возможность изящно отказаться в подклассе от клонирования, реализовав следующий метод clone:

,

// Метод клонирования, гарантирующий невозможность

// клонирования экземпляров 

public final Object clone() throws CloneNotSuppoгtedException{

thгow new CloneNotSuppoгtedException();

}

49

Следовать указанному совету необязательно, поскольку если для переопределяемого метода clone не было заявлено, что он может инициировать CloneNotSupportedException, новый метод clone в подклассе, не подлежащем клонированию, всегда может инициировать необрабатываемое исключение, например UnsupportedOperationException. Однако установившаяся практика говорит, что в этих условиях прав ильным будет исключение CloneNotSupportedExceptrion.

Подведем итоги. Все классы, реализующие интерфейс Cloneable, должны переопределять метод clone как открытый. Этот метод должен сначала вызвать метод supe г. clone, а затем привести в порядок все поля, подлежащие восстановлению. Обычно это означает копирование всех изменяемых объектов, составляющих внутреннюю "глубинную структуру" клонируемого объекта, и замену всех ссылок на эти объекты ссылками на соответствующие копии. Хотя обычно внутренние копии можно получить рекурсивным вызовом метода clone, такой подход не всегда является самым лучшим. Если класс содержит только поля простого типа и ссылки на неизменяемые объекты, 'ГО, по-видимому, нет полей, нуждающихся в восстановлении. Из этого правила есть исключения. Например, поле, предоставляющее серийный номер или иной уникальный идентификатор, а также поле, показывающее время создания объекта, нуждаются в восстановлении, даже если они имеют простой тип или являются неизменяемыми.

Нужны ли все эти сложности? Не всегда. Если вы расширяете класс, реализующий интерфейс Cloneable, у вас практически не остается иного выбора, кроме как реализовать правильно работающий метод clone. В противном случае вам, по-видимому, лучше отказаться от некоторых альтернативных способов копирования объектов либо от самой этой возможности. Например, для неизменяемых классов нет смысла поддерживать копирование объектов, поскольку копии будут фактически неотличимы от оригинала.

Изящный подход к копированию объектов - создание конструктора копий.

Конструктор копии - это всего лишь конструктор, единственный аргумент которого имеет тип, соответствующий классу, где находится этот конструктор, например:

public Yum(Yum yum);

Небольшое изменение - и вместо конструктора имеем статический метод генерации:

public static Yum newlnstance(Yum yum);

Использование конструктора копий (или, как его вариант, статического метода генерации) имеет множество преимуществ перед механизмом Cloneableclone: оно не связано с рискованным, выходящим за рамки языка Java механизмом создания объектов; не требует следования расплывчатым, плохо документированным соглашениям; не конфликтует с обычной схемой использования полей final не требует от клиента перехвата ненужных исключений; наконец, клиент получает объект строго определенного типа. Конструктор копий или статический метод генерации невозможно поместить в интерфейс, Cloneable не может выполнять функции интерфейса, поскольку не имеет открытого метода clone. Поэтому нельзя утверждать, что, используя конструктор копий вместо метода clone, вы отказываетесь от возможностей интерфейса.

50

Более того, конструктор копий (или статический метод генерации) может иметь аргумент, тип которого соответствует интерфейсу, реализуемому этим классом. Например, все реализации коллекций общего назначения, по соглашению, имеют конструктор копий с аргументом типа Collection или Мар. Конструкторы копий, использующие интерфейсы, позволяют клиенту выбирать для копии вариант реализации вместо того, чтобы принуждать его принимать реализацию исходного класса. Допустим, что у вас есть объект LinkedList 1 и вы хотите скопировать его как экземпляр ArrayList. Метод с!опе не предоставляет такой возможности, хотя это легко делается с помощью конструктора копий new ArrayList(l).

Рассмотрев все проблемы, связанные с интерфейсом Cloneable, можно с уверенностью сказать, что остальные интерфейсы не должны становиться его расширением, а классы, предназначенные для наследования (статья 15), не должны его реализовывать. Из-за множества недостатков этого интерфейса некоторые высококвалифицированные программисты предпочитают никогда не переопределять метод clone и никогда им не пользоваться за исключением, быть может, случая простого копирования массивов. Учтите. что, если в классе, предназначенном для наследования, вы не создадите, по меньшей мере, правильно работающий защищенный метод clone, реализация интерфейса Cloneable в подклассах станет невозможной.

Подумайте над реализацией интерфейса ComparabIe

в отличие от других обсуждавшихся в этой главе методов, метод соmрагеТо в классе Object не декларируется. Пожалуй, это единственный такой метод в интерфейсе java.lang.СоmрагаЫе. По своим свойствам он похож на метод equals из класса Object, за исключением того, что, помимо простой проверки равенства, он позволяет выполнять упорядочивающее сравнение. Реализуя интерфейс СоmрагаЫе, класс показывает, что его экземпляры обладают естественным своиством упорядочения (natиra! ordering). Сортировка массива объектов, реализующих интерфейс СоmрагаЫе, выполняется просто:

Arrays.sort(a);

Для объектов СоmрагаЫе так же просто выполняется поиск, вычисляются предельные значения и обеспечивается поддержка автоматически сортируемых коллекций. Например, следующая программа, использующая тот факт, что класс String реализует I1нтерфейс СоmрагаЫе, печатает в алфавитном порядке список аргументов, указанных в командной строке, удаляя при этом дубликаты:

public class WordList {

public static void main(String[] args) {

Set 5 = new TreeSet();

s,addAll(Arrays.asList(args));

System.out.println(s);

}

}

51

Реализуя интерфейс СотрагаЫе, вы разрешаете вашему классу взаимодействовать со всем обширным набором общих алгоритмов и реализаций коллекций, которые связаны с этим интерфейсом. Приложив немного усилий, вы получаете огромные возможности. Практически все классы значений в библиотеках платформы Java реализуют интерфейс СотрагаЫе. И если вы пишите класс значений с очевидным свойством естественного упорядочения - алфавитным, числовым либо хронологическим,- вы должны хорошо продумать реализацию этого интерфейса. В этой статье рассказывается о том, как к этому приступить.

Общее соглашение для метода соmрагеТо имеет тот же характер, что и соглашение

Для метода equals. Приведем его текст по спецификации интерфейса СотрагаЫе:

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

В следующем описании запись sgп(выражение) обозначает математическую функцию signuт, которая, по определению, возвращает -1, О или 1 в зависимости от того, является ли

значение выражения отрицательным, равным нулю или положительным.

Разработчик должен гарантировать тождество sgn(x.compareTo(y)) == -sgп(у.соmрагеТо(х)) для всех х и у. (Это подразумевает, что выражение х. сотрагеТо(у) должно инициировать исключительную ситуацию тогда и только тогда, когда у. сотрагеТо(х) инициирует исключение.)

Разработчик должен также гарантировать транзитивность отношения (х.сотрагеТо(у»О && y.compareTo(z»O) подразумевает x.compareTo(z»O.

Наконец, разработчик должен гарантировать,

что из тождества х. соmрагеТо(у) == О вытекает тождество sgn(x.compareTo(z)) == sgn(y.compareTo(z)) для всех z.

Настоятельно рекомендуется выполнять условие

(х. сотрагеТо(у) == О) == (х. equalS(y)). Вообще говоря, для любого класса, который реализует интерфейс СотрагаЫе,  но нарушает это условие, сей факт должен быть четко оговорен (в сопроводительной документации). Рекомендуется использовать следующую формулировку: "Примечание: данный класс имеет естественное упорядочение, не согласующееся с условием равенства".

52

Как и соглашения для метода equals (статья 7), соглашения для соmрагеТо не так сложны, как это кажется. Для одного класса любое разумное отношение упорядочения будет соответствовать соглашениям для соmрагеТо. Для сравнения разных классов метод соmрагеТо, в отличие от метода equals, использоваться не должен: если сравниваются две ссылки на объекты различных классов, можно инициировать исключительную ситуацию ClassCastException. Метод compareTo обычно так и делает. И хотя представленное соглашение не исключает сравнения между классами, в библиотеках для платформы Java, в частности в версии 1.4, нет классов, которые поддерживали бы такую возможность.

Точно так же, как класс, нарушающий соглашения для метода 'hashCode, может испортить другие классы, работа которых зависит от хэширования, класс, не соблюдающий соглашений для метода соmрагеТо, способен нарушить работу других классов, использующих сравнение. К классам, связанным со сравнением, относятся упорядоченные коллекции, TreeSet и ТгееМар, а также вспомогательные классы Collections и Arrays, содержащие алгоритмы поиска и сортировки.

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

Из этих трех условий следует, что проверка равенства, осуществляемая с помощью метода соmрагеТо, должна подчиняться тем же самым ограничениям, которые продиктованы соглашениями для метода equals: рефлективность, симметрия, транзитивность и отличие от null. Следовательно, здесь справедливо то же самое предупреждение: невозможно расширить порождающий экземпляры класс, вводя новый аспект и не нарушая при этом соглашения для метода сотрагеТо (статья 7). Возможен обходной Путь. Если вы хотите добавить важное свойство к классу, реализующему интерфейс СотрагаЫе, не расширяйте его, а напишите новый независимый класс, в котором для исходного класса выделено отдельное поле. Затем добавьте метод представления, возвращающий значение этого поля. Это позволит вам реализовать во втором классе любой метод сотрагеТо, который вам нравится. При необходимости клиент может рассматривать экземпляр второго класса как экземпляр первого класса.

Последний пункт соглашений для соmрагеТо, являющийся скорее предположением, чем настоящим условием, постулирует, что проверка равенства, осуществляемая с помощью метода соmрагеТо, обычно должна давать те же самые результаты, что и метод equals. Если это условие выполняется, считается, что упорядочение, задаваемое методом соmрагеТо, согласуется с проверкой равенства (consistent with equals). Если же оно нарушается, то упорядочение называется несогласующимся с проверкой равенства (inconsistent with eqиals).

53

Класс, чей метод соmрагеТо устанавливает порядок, несогласующийся с условием равенства, будет работоспособен, однако отсортированные коллекции, содержащие элементы этого класса, могут не соответствовать общим соглашениям для соответствующих интерфейсов коллекций (Collection, Set или Мар). Дело в том, что общие соглашения для этих интерфейсов определяются в терминах метода equals, тогда как в отсортированных коллекциях ИСПОЛl>зуется проверка равенства, которая реализуется методом соmрагеТо, а не equals. Если это произойдет, катастрофы не будет, но иногда это следует учитывать.

Например, рассмотрим класс BigDecimal, чей метод соmрагеТо не согласуется с проверкой равенства. Если вы создадите HashSet и добавите в него новую запись BigDecimal("1.0"), а затем BigDecimal("1.00"), этот набор будет содержать два элемента, поскольку два добавленных в него экземпляра класса BigDecimal не будут равны, если их сравнивать с помощью' метода equals. Однако если вы выполняете ту же самую процедуру с TreeSet, а не HashSet, полученный набор будет содержать только один элемент, поскольку два представленных экземпляра BigDecimal оказываются равны при их сравнении с помощью метода соmаргеТо. (См. документацию на BigDecimal.)

Процедура написания метода соmрагеТо похожа на процедуру для метода equals, но есть несколько ключевых различий. Перед преобразованием типа нет необходимости проверять тип аргумента. Если аргумент имеет неправильный тип, метод соmрагеТо обязан инициировать исключительную ситуацию ClassCastException. Если аргумент имеет значение null, метод compare Т о должен инициировать исключительную ситуацию NullPointerException. Т о же самое вы получите, если приведете аргумент к правильному типу, а затем попытаетесь обратиться к его членам.

Сравнение полей само по себе является упорядочивающим сравнением, а не сравнением с проверкой равенства. Сравнение полей, имеющих ссылки на объекты, осуществляйте путем рекурсивного вызова метода сотрагеТо. Если поле не реализует интерфейс Сотра rable или вам необходимо не стандартное упорядочение, вы можете использовать явную реализацию интерфейса Comparator. Либо напишите ваш собственный метод, либо воспользуйтесь уже имеющимся, как это было в случае с методом сотра геТо в классе CaselnsensitiveString (статья 7):

public int compareTo(Object о) {

CaselnsensitiveString cis = (CaselnsensitiveString)o;

return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);

}

Поля простого типа нужно сравнивать с помощью операторов < и >, массивы применяя эти инструкции для каждого элемента. Если у класса есть несколько значимых полей, порядок их сравнения критически важен. Вы должны начать с самого значимого поля и затем следовать в порядке убывания значимости. Если сравнение дает что-либо помимо нуля (означающего равенство), все, что вам нужно сделать,-  

54

возвратить этот результат. Если самые значимые поля равны, продолжайте сравнивать следующие по значимости поля и т. д. Если все поля равны, равны и объекты, поэтому возвращайте нуль. Такой прием демонстрирует метод соmрагеТо для класса PhoneNumber из статьи 8:

public int compareTo(Object о) {

PhoneNumber рn = (PhoneNumber)o;

// Сравниваем коды зон 

if (areaCode < pn.areaCode)

return -1;

if (areaCode > pn.areaCode)

return 1;

// Коды зон равны, сравниваем номера АТС

if (exchange < pn.exchange)

retuгn -1;

if (exchange > pn.exchange)

retuгn 1;

// Коды зон и номера АТС равны, сравниваем номера абонентов

if (extension < pn.extension)

return -1;

if (extension > pn.extension)

return 1;

return о; // Все поля равны 

}

Этот метод работает прекрасно, но его можно улучшить. Напомним, что в соглашениях для метода соmрагеТо величина возвращаемого значения не конкретизируется, только знак. Вы можете извлечь из этого пользу, упростив программу и, возможно, заставив ее работать немного быстрее:

public int compareTo(Object о) {

PhoneNumber рп = (PhoneNumber)o;

// Сравниваем коды зон 

int areaCodeDiff = areaCode - pn.areaCode;

if (areaCodeDiff 1= О)

return areaCodeDiff;

// Коды зон равны, сравниваем номера АТС 

int exchangeDiff = exchange - pn.exchange;

if (exchangeDiff != 0)

return exchangeDiff;

// Коды зон и номера АТС равны, сравниваем номера абонентов 

return extension - pn.extension;

}

55

Такая уловка работает прекрасно, но применять ее следует крайне осторожно.

Не пользуйтесь ею, если у вас нет уверенности в том, что рассматриваемое поле не может иметь отрицательное значение или, что бывает чаще, разность между наименьшим и наибольшим возможными значениями поля меньше или равна значению INTEGER. MAX_VALUE (231 -1). Причина, по которой этот прием не работает, обычно заключается в том, что 32-битовое целое число со знаком является недостаточно большим, чтобы показать разность двух 32-битовых целых чисел с произвольным знаком. Если i - большое положительное целое число, а j - большое отрицательное целое число, то при вычислении разности (i-j) произойдет переполнение и будет возвращено отрицательное значение. Следовательно, полученный нами метод соmрагеТо работать не будет: для некоторых аргументов будет выдаваться бессмысленный результат, тем самым будут нарушены первое и второе условия соглашения для метода соmрагеТо. И эта проблема не является чисто теоретической, она уже вызывала сбои в реальных системах. Выявить причину подобных отказов бывает крайне трудно, поскольку не· правильный метод соmрагеТо работает правильно со многими входными значениями.

Глава 4

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

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

Сводите к минимуму доступность классов и членов

Единственный чрезвычайно важный фактор, отличающий хорошо спроектированный модуль от неудачного,- степень сокрытия его внутренних данных и иных деталей реализации от других модулей. Хорошо спроектированный модуль скрывает все детали реализации, четко разделяя свой АРI и реализацию. Модули взаимодействуют друг с другом только через свои API, и ни один из них не знает, какая обработка происходит внутри другого модуля. Эта концепция, называемая сокрытием информации (information hiding) или инкапсуляцией (encapsulatiori), представляет собой один из фундаментальных принципов разработки программного обеспечения [Parnas72].

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

57

хорошей производительности, но оно создает условия для эффективного управления производительностью. Когда разработка системы завершена и процедура ее профилирования показала, работа каких модулей вызывает падение производительности (статья 37), можно заняться их оптимизацией, не нарушая функционирования остальных модулей. Сокрытие информации повышает возможность повторного использования программ, поскольку каждый отдельно взятый модуль независим от остальных модулей и часто оказывается полезен в иных контекстах, чем тот, для которого он разрабатывался. Наконец, сокрытие информации уменьшает риски при построении больших систем: удачными могут оказаться отдельные модули, даже если в целом система не будет пользоваться успехом.

Язык программирования java имеет множество возможностей для сокрытия информации. Одна из них - механизм управления доступом (access control) [JLS, 6.6], задающий степень доступности (accessibility) для интерфейсов, классов и членов классов. Доступность любой сущности определяется тем, в каком месте она была декларирована и какие модификаторы доступа, если таковые есть, присутствуют в ее декларации (pr1vate, protected или public). Правильное использование этих модификаторов имеет большое значение для сокрытия информации.

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

Для классов и интерфейсов верхнего уровня (не являющихся вложенными) существуют лишь два возможных уровня доступа: доступный только в пределах пакета (package-private) и открытый (public). Если вы объявляете класс или интерфейс верхнего уровня с модификатором public, он будет открытым, в противном случае он будет доступен только в пределах пакета. Если класс или интерфейс верхнего уровня можно сделать доступным только в пакете, так и нужно поступать. При этом класс или интерфейс становится частью реализации этого пакета, а не частью его внешнего API. Вы можете модифицировать его, заменить или исключить из пакета, не опасаясь нанести вред клиентам. Если же вы делаете класс или интерфейс открытым, на вас возлагается обязанность всегда поддерживать его с целью сохранения совместимости.

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

Для членов класса (полей, методов, вложенных классов и вложенных интерфейсов) существуют четыре возможных уровня доступа, которые перечислены здесь в порядке увеличения доступности:

Закрытый (private) - данный член доступен лишь в пределах того класса верхнего уровня, где он был объявлен.

58

Доступный лишь в пределах пакета (package-private) - член доступен из любого класса в пределах того пакета, где он был объявлен. Формально этот уровень называется доступом по умолчанию

(default access), и именно этот уровень доступа вы получаете,

если не -были указаны модификаторы доступа.

Защищенный (protected) - член доступен для подклассов того класса, "где этот член был объявлен (с небольшими ограничениями [jLS, 6.6.2]); доступ к члену можно получить из любого класса в пакете, где этот член был объявлен.

Открытый (public) - член доступен отовсюду.

После того как для вашего класса тщательно спроектирован открытый API, вам следует сделать все остальные члены класса закрытыми. И только если другому классу из того же пакета действительно необходим доступ к какому-то члену, вы можете убрать модификатор private и сделать этот член доступным в пределах всего пакета. Если вы обнаружите, что таких членов слишком много, еще раз проверьте модель вашей системы и попытайтесь найти другой вариант разбиения на классы, при котором они были бы лучше изолированы друг от друга. Как было сказано, и закрытый член, и член, доступный только в пределах пакета, являются частью реализации класса и обычно не оказывают воздействия на его внешний API. Однако они могут "просочиться" во внешний API, если класс реализует интерфейс Serializable (статьи 54 и 55).

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

Существует одно правило, ограничивающее ваши возможности по уменьшению доступности методов. Если какой-либо метод переопределяет метод суп ер класса, то методу в подклассе не разрешается иметь более низкий уровень доступа, чем был у метода в суперклассе [JLS, 8.4.6.3]. Это необходимо для гарантии того, что экземпляр подкласса можно будет использовать повсюду, где можно было использовать экземпляр суперкласса. Если вы нарушите это правило, то когда попытаетесь скомпилировать этот подкласс, компилятор сгенерирует сообщение об ошибке. Частный случай правила: если класс реализует некий интерфейс, то все методы класса, представленные в этом интерфейсе, должны быть объявлены как открытые (public). Это объясняется тем, что в интерфейсе все методы неявно подразумеваются открытыми.

Открытые поля (в отличие от открытых методов) в открытых классах должны быть редким явлением (если вообще должны ПОЯВЛЯТЬСЯ). Если поле не имеет модификатора final или имеет модификатор и ссылается на изменяемый объект, то, делая его открытым, вы упускаете возможность наложения ограничений на значения,  

59

которые могут быть записаны в этом поле. Вы также лишаетесь возможности предпринимать какие-либо действия в ответ на изменение этого поля. Отсюда простой вывод: классы с открытыми изменяемыми полями небезопасны в системе с несколькими потоками (not thread-safe). Даже если поле имеет модификатор final и не ссылается на изменяемый объект, объявляя его открытым, вы отказываетесь от возможности гибкого перехода на новое представление внутренних данных, в котором это поле будет отсутствовать.

Из правила, запрещающего открытым классам иметь открытые поля, есть одно исключение. С помощью полей public static final классы могут предоставлять вовне константы. Согласно договоренности, названия таких полей состоят из прописных букв, слова в названии разделяются символом подчеркивания (статья 38). Крайне важно, чтобы эти поля содержали либо простые значения, либо ссылки на неизменяемые объекты (статья 13). Поле с модификатором final, содержащее ссылку на изменяемый объект, обладает всеми недостатками поля без модификатора final: хотя саму ссылку изменить нельзя, объект, на который она указывает, может быть изменен - с роковыми последствиями.

Заметим, что массив ненулевой длины всегда является изменяемым. Поэтому практически никогда нельзя декларировать поле массива как public static final. Если в классе будет такое поле, клиенты получат возможность менять содержимое этого массива. Часто это является причиной появления дыр в системе безопасности.

// Потенциальная дыра в системе безопасности

public static final Туре[] VALUES = { ... };

Открытый массив следует заменить закрытым массивом и открытым неизменяемым списком:

private static final Туре[] PRIVATE_VALUES = { ... };

public static final List VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));

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

private static final Туре[] PRIVATE_VALUES = { ... };

 

private static final Туре[] values() {

return ( Туре[] ) PRIVATE_VALUES.clone();

}

Подведем итоги. Всегда следует снижать уровень доступа, насколько это возможно. Тщательно разработав наименьший открытый API, вы должны не дать возможности каким-либо случайным классам, интерфейсам и членам стать частью этого API. За исключением полей типа public static final, других открытых полей в открытом классе быть не должно. Убедитесь в том, что объекты, на которые есть ссылки в полях типа public static final, не являются изменяемыми.

60

Предпочитайте постоянство

Неизменяемый класс - это такой класс, экземпляры которого нельзя поменять.

Вся информация, содержащаяся в любом его экземпляре, записывается в момент его создания и остается неизменной в течение всего времени существования этого объекта. В библиотеках для платформы ]ауа имеется целый ряд неизменяемых классов; в том числе String, простые классы-оболочки, Biglnteger и BigDecimal. На это есть много веских причин: по сравнению с изменяемыми классами, их проще проектировать, разрабатывать и и<;пользовать. Они менее подвержены ошибкам и более надежны.

Делая класс неизменяемым, выполняйте следующие пять правил:

  1.  Не создавайте каких-либо методов, которые модифицируют представленный объект (эти методы называются мутаторами  (mutator)).
  2.  Убедитесь в том, что ни один метод класса не может быть переопределен. Это предотвратит потерю свойства неизменяемости данного класса в небрежном или умышленно плохо написанном подклассе. Защита методов от переопределения обычно осуществляется путем объявления класса в качестве окончательного, однако есть

и другие способы (см. ниже).

  1.  Сделайте все поля окончательными (final). Это ясно выразит

ваши намерения, причем в некоторой степени их будет поддерживать сама система. Это может понадобиться и для обеспечения правильного поведения программы в том случае, когда ссылка на вновь созданный экземпляр передается из одного потока в другой без выполнения синхронизации [Pugh01a] (как результат ведущихся работ

по ис~равлению модели памяти в ]ауа).

  1.  Сделайте все поля закрытыми (private). Это не позволит клиентам непосредственно менять значение полей. Хотя формально неизменяемые классы и могут иметь открытые поля с модификатором final, которые содержат либо значения простого типа, либо ссылки на неизменяемые объекты, делать это не рекомендуется, поскольку они будут препятствовать изменению в последующих версиях внутреннего представления класса (статья 12).

  1.  Убедитесь в монопольном доступе ко всем изменяемым компонентам. Если в вашем классе есть какие-либо поля, содержащие ссылки на изменяемые объекты, удостоверьтесь в том, что клиенты этого класса не смогут получить ссылок на эти объекты. Никогда не инициализируйте такое поле ссылкой на объект, полученной от клиента, метод доступа не должен возвращать хранящейся в этом поле ссылки на объект. При использовании конструкторов, методов доступа к полям и методов readObject (статья 56) создавайте резервные копии (defensive copies) (статья 24).

61

В при мерах из предыдущих статей многие классы были неизменяемыми. Так, класс PhoneNumber (статья 8) имеет метод доступа для каждого атрибута, но не имеет соответствующего мутатора. ГIредставим более сложный пример:

public final class Complex {

private final float rе;

pгivate final float im;

public Complex(float ге, float im) {

     this. ге = ге;

this.im = im;

}

// Методы доступа без соответствующих мутаторо

 public float realPart() { return ге; }

public float imaginaryPart() { return im; }

public Complex add(Complex с) {

return new Complex(re + С.ге, im + c,im);

}

public Complex subtract(Complex с) {

return new Complex(re - С.ге, im - c.im);

}

public Complex multiply(Complex с) {

return new Complex(re*c.гe - im*c.im, re*c.im + im*c. ге);

}

public Complex divide(Complex с) {

float tmp = с. ге*с. ге + c.im*c.im;

return new Complex((re*c. ге + im*c.im)/tmp, (im*c.re - re*c.im)/tmp);

}

public boolean equals(Object о) {

if (о == this)

return true;

if (!(о instanceof Complex))

return false;

с = (Complex)o;

return (Float.floatTolntBits(re) ==Float.floatTolntBits(c.re)) && (Float.floatTolntBits(im) == Float.floatTolntBits(c.im));

}

// Чтобы понять,

// почему используется

// метод floatTolntBits

// см. статью 7.

public int hashCode() {

int result = 17 + Float.floatTolntBits(re);

result = 37*result + Float.floatTolntBits(im);

return result;

}

public String toString() {

return "(" + ге + " + " + im + "i)";

}

}

62

Данный класс представляет комплексное число (число с действительной и мнимой частями). Помимо обычных методов класса Object, он реализует методы доступа к действительной и мнимой частям числа, а также четыре основные арифметические операции: сложение, вычитание, умножение и деление. Обратите внимание на то, что представленные арифметические операции вместо того, чтобы менять данный экземпляр, генерируют и передают новый экземпляр класса Complex. Такой подход используется для большинства сложных неизменяемых классов. Называется это функциональным подходом (functiona! approach), поскольку рассматриваемые методы возвращают результат применения некоей функции к своему операнду, не изменяя при этом сам операнд. Альтернативой является более распространенный процедурный подход (procedura! approach), при котором метод выполняет для своего операнда некую процедуру, которая меняет его состояние.

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

63

Неизменяемые объекты по своей сути безопасны при работе с потоками (thread-safe): им не нужна синхронизация. Они не могут быть разрушены только из-за того, что одновременно к ним обращается несколько потоков. Несомненно, это самый простой способ добиться безопасности при работе с потоками. Действительно, ни один поток никогда не сможет обнаружить какого-либо воздействия со стороны другого потока через неизменяемый объект. По этой причине неизменяемые объекты можно свободно использовать для совместного доступа. Неизменяемые классы должны задействовать это преимущество, заставляя клиентов везде, где возможно, применять yжe существующие экземпляры. Один из простых приемов, позволяющих достичь этого: для часто используемых значений создавать константы типа publiC static final. Например, в классе Соmрех можно представить следующие константы:

publiC static final Complex ZERO = new Complex(0, 0);

public static final Complex ONE = new Complex(1, 0);

public static final Complex I = new Complex(0, 1);

Mожно сделать еще один шаг в этом направлении. В неизменяемом классе MO~HO предусмотреть статические методы генерации, которые кэшируют часто запрашиваемые экземпляры вместо того, чтобы при каждом запросе создавать новые экземпляры, дублирующие уже имеющиеся. Подобные статические методы генерации есть в классах 8iglnteger и 8oo1ean. Применение статических методов генерации заставляет клиентов совместно использовать уже имеющиеся экземпляры, а не создавать новые. Это снижает расход памяти и сокращает работу по ее освобождению.

Благодаря тому, что неизменяемые объекты можно свободно предоставлять для 'совместного доступа, не требуется создавать для них резервные копии (defensive copies) (статья 24). В действительности вам вообще не нужно делать никаких копий, поскольку они всегда будут идентичны оригиналу. Соответственно, для неизменяемого класса не надо, да и не следует создавать метод clone и конструктор копии (сору constructor) (статья 10). Когда платформа Java только появилась, еще не было четкого понимания этого обстоятельства, и потому класс String имеет конструктор копий. Лучше им не пользоваться (статья 4).

Можно совместно использовать не только неизменяемый объект, но и его содержимое. Например, класс 8iglnteger применяет внутреннее представление знак/модуль (sign/magnitude). Знак числа задается полем типа int, его модуль массивом int. Метод инвертирования negate создает новый экземпляр 8iglnteger с тем же модулем и с противоположным знаком. При этом нет необходимости копировать массив, поскольку вновь созданный экземпляр 8iglnteger имеет внутри ссылку на тот же самый массив, что и исходный экземпляр.

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

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

74

Последняя группа вопросов, которые вы должны рассмотреть, прежде чем решиться использовать наследование вместо композиции: есть ли в АР! того Класса, который вы намереваетесь расширять, какие-либо изъяны? если есть, то не волнует ли вас то обстоятельство, что эти изъяны перейдут в АР! вашего класса? Наследование копиру~т любые дефекты в АР! суперкласса, тогда как композиция ПОзволяет разработать новый АР!, который скрывает эти недостатки.

Подведем итоги. Наследование является мощным инструментом, но оно же создает проблемы, поскольку нарушает принцип инкапсуляции. Пользоваться им МОжно лишь в том случае, когда между суперклассом и подклассом существует реальная связь "тип/подтип". Но даже в этом случае применение наследования может сделать программу ненадежной, особенно если подкласс и суперкласс принадлежат к разным пакетам, а сам суперкласс не предназначен для расширения. Для устранения Этой не надежности вместо наследования используйте композицию и переадресацию, особенно когда для' реализации класса-оболочки имеется подходящий интерфейс. Классы-оболочки не только надежней подклассов, но и имеют большую мощность.

Проектируйте и документируйте наследование  либо запрещайте его.

Статья предупреждает вас об опасностях создания подклассов для "чужого" класса, наследование которого не предполагалось и не было документировано. Что же означает "класс, спроектированный и документированный для наследования"?

Во-первых, требуется четко документировать последствия переопределения каждого метода в этом классе. Иными словами, для класса должно быть документировано, какие из переопределяемых методов он использует сам (self-use): для каждого открытого или защищенного метода, каждого конструктора в документации должно быть указано, какие переопределяемые методы он вызывает, в какой последовательности, а также каким образом результаты их вызова влияют на дальнейшую обработку. (Под переопределяемостью (overridable) метода здесь подразумевается То, что он является неокончательным, а также что он либо открытый, либо защищенный.) В общем, в документации должны быть отражены все условия, при которых класс может вызвать переопределяемый метод. Например, вызов может поступать из фонового потока или от статического метода-инициализатора.

По соглашению, метод, который сам вызывает пере определяемые методы, должен содержать описание этих обращений в конце своего dос-комментария. Такое Описание начинается с фразы "This implementation". Эту фразу не следует использовать лишь для того, чтобы показать, что поведение метода может меняться от версии к версии. Она подразумевает, что следующее описание будет касаться внутренней работы данного метода. Приведем пример, взятый из спецификации класса java.util.AbstractCollection:

publlic boolean remove(Object о)

75

Удаляет из данной коллекции один экземпляр указанного элемента, если таковой имеется (необязательная операция). Или более формально: удаляет элемент е, такой, что (о ==  null ? е == null : о.equals(e)), при условии, что в коллекции содержится один или несколько таких элементов. Возвращает значение true, если в коллекции присутствовал указанный элемент (или, что то жe самое, если в результате этого вызова произошло изменение коллекции).

В данной реализации организуется цикл по коллекции с поиском заданного элемента. Если элемент найден, он удаляется из коллекции с помощью метода remove, взятого у итератора. Метод iterato r коллекции возвращает объект итератора. Заметим, что если

у итератора не реализован метод гетоуе, то данная реализация инициирует исключительную ситуацию Unsuppo rtedOpe rationException.

Приведенное описание не оставляет сомнений в том, что переопределение метода iterator повлияет на работу метода гетоуе. Более того, в ней точно указано, каким образом работа экземпляра Iterator, возвращаемого методом iterator, будет влиять ii работу метода гетоуе. Сравните это с ситуацией, рассмотренной в статье 14, когда программист, создающий подкласс для HashSet, просто не мог знать, повлияет  переопределение метода add на работу метода addAll.

Но разве это не нарушает авторитетное мнение, что хорошая документация API  должна описывать, что делает данный метод, а не то, как он это делает? Конечно, нарушает! Это печальное следствие того обстоятельства, что наследование нарушает принцип инкапсуляции. Для того чтобы в документации к классу показать, что его можно наследовать безопасно, вы должны описать детали реализации, которые в других случаях можно было бы оставить без уточнения.

Проектирование наследования не исчерпывается описанием того, как класс использует сам себя, для того чтобы программисты могли писать полезные подклассы, не прилагая чрезмерных усилий, от класса может потребоваться создание механизма для диагностирования своей собственной внутренней деятельности в виде правильно выбранных защищенных методов или, в редких случаях, защищенных полей. Например, рассмотрим метод removeRange из класса j ava. util. Abst ractList:

protected void removeRange(int fromlndex, int tolndex)

Удаляет из указанного списка все элементы, чей индекс попадает в интервал от fromlndex (включительно) до tolndex (исключая). Все последующие элементы сдвигаются влево (уменьшается их индекс). Данный вызов укорачивает список ArrayList на (tolndex - fromlndex) элементов. (Если tolndex == fromlndex, процедура ни на что не влияет.)

Этот метод используется процедурой clea r как в самом списке, так и в его подсписках (subList - подмножество из нескольких идущих подряд элементов.- Прuм. пер.). При переопределении этого метода,  дающем доступ к деталям реализации списка, можно значительно повысить производительность операции очистки как для списка, так и для его подсписков.

76

В данной реализации итератор списка ставится перед fromlndex, а затем в цикле делается вызов Listrterator. next, за которым следует Listlterator. remove. И так до тех пор, пока полностью не будет удален указанный диапазон. Примечание: если время выполнения операции Listlterator. гетоуе зависит от числа элементов в списке линейным образом, то в данной реализации зависимость является квадратичной.

Параметры:

Fromlndex                   индекс первого удаляемого элемента индекс

tolndex                        последнего удаляемого элемента

Описанный метод не представляет интереса для конечных пользователей реализации List. Он служит только для того, чтобы облегчить реализацию в подклассе быстрого метода очистки подсписков. Если бы метод removeRange отсутствовал, в подклассе пришлось бы довольствоваться квадратичной зависимостью для метода clear, вызываемого для подсписка, либо полностью переписывать весь механизм subList - задача не из легких!

Как же решить; какие из защищенных методов и полей мощно раскрывать при построении класса, предназначенного для наследования? К сожалению, чудодейственного рецепта здесь не существует. Лучшее, что можно сделать,- это выбрать самую приемлемую гипотезу и проверить ее на практике, написав несколько подклассов. Вы должны предоставить клиентам минимально возможное число защищенных методов и полей, поскольку каждый из них связан с деталями реализации. С другой стороны, их количество не должно быть слишком малым, поскольку отсутствие защищенного метода может сделать класс практически негодным для наследования.

Готовя к наследованию класс, который, по-видимому, получит широкое распространение, учтите, что вы навсегда задаете схему использования классом самого себя, а также реализацию, неявно представленную защищенными методами и полями. Такие обязательства могут усложнять или дaжe делать невозможным дальнейшее улучшение Производительности и функциональных возможностей в будущих версиях класса.

Заметим также, что специальные описания, обязательные для организации наследования, усложняют обычную документацию, которая предназначена для программистов, создающих экземпляры вашего класса и использующих их методы. Что же касается собственно документации, то лишь немногие инструменты и правила комментирования способны отделить документацию обычного АРI от той информации, которая представляет интерес только для программистов, создающих подклассы.

77

Есть лишь несколько ограничений, которым обязан соответствовать класс, чтобы его наследование стало возможным. Конструкторы класса не должны вызывать переопределяемые методы, непосредственно или опосредованно. Нарушение этого правила может привести к аварийному завершению программы. Конструктор суперкласса выполняется прежде конструктора подкласса, а потому переопределяющий метод в подклассе будет вызываться перед запуском конструктора этого подкласса. И если переопределенный метод зависит от инициализации, которую осуществляет конструктор подкласса, то этот метод будет работать совсем не так, как ожидалось. для пояснения приведем пример класса, нарушающеГ9 это правило:

public class Supeг {

// Ошибка: конструктор вызывает переопределяемый метод

public Supeг() {

me();}

public void m() {

}

}

Представим подкласс, в котором переопределяется метод т, неправомерно вызываемый единственным конструктором класса Supeг:

final class Sub extends Supeг {

pгivate final Date date;

// Пустое поле final заполняется конструктором

Sub() {

date = new Date();

}

// Переопределяет метод Supeг.m, используемый конструктором 

Super() publiC void m() {

System.out.println(date); }

public static void main(Stгing[] aгgs) {

Sub s = new Sub();

s.m();

}

}

Предполагается, что эта про грамма напечатает текущую дату дважды, однако в первый раз она выводит null, поскольку метод т вызывается конструктором Supeг() прежде, чем конструктор Sub() получает возможность инициализировать поле даты. Отметим, что данная программа видит поле final в двух разных состояниях.

78

Реализация интерфейсов Cloneable и Se гializable при проектировании наследования создает особые трудности. Вообще говоря, реализовывать какой-либо из этих интерфейсов в классах, предназначенных для наследования, не очень хорошо уже потому, что они создают большие сложности для программистов, расширяющих этот класс. Есть, однако, специальныe приемы, которые можно использовать с тем, чтобы обеспечить передачу реализации этих интерфейсов в подкласс, а не заставлять его реализовывать их заново. Эти приемы описаны в статьях 10 и 54.

Если вы решите реа-лизовать интерфейс Cloneable или Seгializable в классе, предназначенном для наследования, то учтите, что, поскольку методы clone и гeadObj edt в значительной степени работают как конструкторы, к ним применимо то же самое ограничение: ни методу clone, ни методу readObject не разрешается вызы. вать переопределяемый метод, непосредственно или опосредованно. В случае с методом readObj ect переопределенный метод будет выполняться перед десериализацией состояния подкласса. Что же касается метода clone, то переопределенный метод будет выполняться прежде, чем метод сlопе в подклассе получит возможность установить состояние клона. В обоих случаях, по-видимому, последует сбой программы. При работе с методом clone такой сбой может нанести ущерб и клонируемому объекту,  и клону.

И, наконец, если вы решили реализовать интерфейс Serializable в классе, предназначенном для наследования, а у этого класса есть метод readResolve или wri teReplace, то вы должны делать этот метод не закрытым, а защищенным. Если эти методы будут закрытыми, то подклассы будут молча игнорировать их. Это еще один случай, когда для обеспечения наследования детали реализации класса становятся частью его АРI.

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

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

Наилучшим решением этой проблемы является запрет на создание под·классов для тех классов, которые не были специально разработаны и не имеют требуемого описания для безопасного выполнения данной операции. Запретить создание подклассов можно двумя способами. Более простой заключается в объявлении класса как окончательного (final). Другой подход состоит в том, чтобы сделать все Конструкторы класса закрытыми или доступными лишь в пределах пакета, а вместо них создать открытые статические методы генерации. Такая альтернатива, дающая возможность гибко использовать класс внутри подкласса, обсуждалась в статье 13. Приемлем любой из указанных подходов.

79

Возможно, этот совет несколько сомнителен, поскольку так много программистов выросло с привычкой создавать для обычного неабстрактного класса подклассы лишь для того, чтобы добавить новые возможности, например средства контроля, оповещения и синхронизации, либо наоборот, чтобы ограничить его функциональные возможности. Если класс реализует некий интерфейс, в котором отражена его сущность, например Set, List или Мар, то у вас не должно быть сомнений по поводу запрета подклассов. Шаблон класса-оболочки (wrapper class), описанный в статье 14, создает превосходную альтернативу наследованию, используемому всего лишь для изменения функциональности.

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

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

Предпочитайте интерфейсы абстрактным классам.

В языке программирования Java предоставлены два механизма определения типов, которые допускают множественность реализаций: интерфейсы и абстрактные классы. Самое очевидное различие между этими механизмами заключается в том, что в абстрактные классы можно включать реализацию некоторых методов, для интерфейсов это запрещено. Более важное отличие связано с тем, что для реализации типа, определенного неким. Абстрактным классом, класс должен стать подклассом этого абстрактного класса. С другой стороны, реализовать интерфейс может любой класс, независимо от его места в иерархии классов, если только он отвечает общепринятым соглашениям и в нем есть все необходимые для этого методы. Поскольку в языке Java не допускается множественное наследование, указанное требование для абстрактных классов серьезно ограничивает их использование при определении типов.  

80

Имеющийся класс несложно подогнать под реализацию нового интерфейса.

Все, что для этого нужно,- добавить в класс необходимые методы, если их еще нет, и внести в декларацию класса пункт о реализации. Например, когда платформа Java была дополнена интерфейсом Compaгable, многие существовавшие классы были перестроены под его реализацию. С другой стороны, уже имеющиеся классы, вообще говоря, нельзя перестраивать для расширения нового абстрактного класса. Если вы хотите, чтобы два класса расширяли один и тот же абстрактный класс, вам придется поднять этот абстрактный класс в иерархии типов настолько высоко, чтобы прародитель обоих этих классов стал его Подклассом. К сожалению, это вызывает значительное нарушение иерархии типов, заставляя всех потомков общего предка расширять новый абстрактный класс независимо от того, целесообразно это или нет.

Интерфейсы идеально подходят для создания дополнений (mixin). Помимо своего "первоначального типа", класс может реализовать некий дополнительный тип (mixin), объявив о том, что в нем реализован дополнительный функционал. Например, Compaгable является дополнительным интерфейсом, который дает классу возможность декларировать, что его экземпляры упорядочены по отношению к другим, сравнимым с ними объектам. Такой интерфейс называется mixiп, поскольку позволяет к первоначальным функциям некоего типа примешивать (mixed in) дополнительные функциональные возможности. Абстрактные классы нельзя использовать для создания дополнений по той же причине, по которой их невозможно встроить в уже имеющиеся классы: класс не может иметь более одного родителя, и в иерархии классов нет подходящего места, куда можно поместить mixin.

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

public interface Singer {

     AudioClip Sing(Song s);  }

 publlic interface Songwriter {

      Song compose(boolean hit); }

В жизни некоторые певцы являются и авторами песен. Поскольку для определения этих типов мы использовали не абстрактные классы, а интерфейсы, то одному классу никак не запрещается реализовывать оба интерфейса: Singer и Songwriter. В действительности мы можем определить третий интерфейс, который расширяет оба Интерфейса и добавляет новые методы, соответствующие сочетанию:

public interface SingerSongwriter extends Singer, Songwriter {

AudioClip strum();

void actSensitive(); }

81

Такой уровень гибкости нужен не всегда. Если же он необходим, интерфейсы становятся спасительным средством. Альтернативой им является раздутая иерархия классов, которая содержит отдельный класс для каждой поддерживаемой ею комбинации атрибутов. Если в системе имеется п атрибутов, то существует 2 в степени n сочетаний, которые, возможно, придется поддерживать. Это называется комбинаторным взрывом (combinatorial explosion). Раздутые иерархии классов могут привести к созданию раздутых классов, содержащих массу методов, отличающихся друг от друга лишь типом аргументов, поскольку в такой иерархии классов не будет типов, отражающих общий

функционал.

Интерфейсы позволяют безопасно и мощно наращивать функциональность,

используя идиому клacca-оболочки, описанную в статье 14. Если же для определения типов вы при меняете абстрактный класс, то вы не оставляете программисту, желающему добавить новые функциональные возможности, иного выбора, кроме как использовать наследование. Получаемые в результате классы будут не такими мощными и не такими надежными, как классы-оболочки.

Хотя в интерфейсе нельзя хранить реализацию методов, определение типов с помощью интерфейсов не мешает оказывать программистам помощь в реализации класса. Вы можете объединить преимущества интерфейсов и абстрактных классов, сопроводив каждый предоставляемый вами нетривиальный интерфейс. абстрактным классом с наброском (скелетом) реализации (skeletal implementation class). Интерфейс по-прежнему будет определять тип, а вся работа по его воплощению ляжет на скелетную реализацию.

По соглашению, скелетные реализации носят названия вида Abstract/пterface,

где iпterface - это имя реализуемого ими интерфейса. Например, в архитектуре Collections Framework представлены скелетные реализации для всех основных интерфейсов коллекций: AbstractCollection, AbstractSet, AbstractList и AbstractMap.

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

// Адаптер интерфейса List для массива целых чисел (int)

static List intArrayAsList(final int[] а)

if (а == null)

throw new NullPointerException();

return new AbstractList() {

public Object get(int i) {

return new Integer(a[i]); }

public int size() {

return a.length; }

public Object set(int i, Object о) {

int oldVal = a[i];

a[i] = «Integer)o).intValue();

return new Integer(oldVal);

}  

};

}

82

Если принять во внимание все, что делает реализация интерфейса List, то этот пример демонстрирует всю мощь скелетных реализаций. Кстати, пример является адаптером (Adapter) [Сатта95, стр. 139], который позволяет представить массив int в виде списка экземпляров Integer. Из-за всех этих преобразований из значений int в экземпляры Integer и обратно производительность метода не очень высока. Отметим, что здесь приведен лишь статический метод генерации, сам же класс является недоступным анонимным lUIассом (статья 18), спрятанным внутри статического метода генерации.

Достоинство скелетных реализаций заключается в том, что они оказывают помощь в реализации абстрактного класса, не налагая при этом строгих ограничений, как это имело бы место, если бы для определения типов использовались абстрактные классы. для большинства программистов, реализующих интерфейс, расширение скелетной реализации - это очевидный, хотя и необязательный выбор. Если имеющийся класс нельзя заставить расширять скелетную реализацию, он всегда может реализовать представленный интерфейс сам. Более того, скелетная реализация помогает в решении стоящей перед разработчиком задачи. Класс, который реализует данный интерфейс, может переадресовывать вызов метода, указанного в интерфейсе, содержащемуся внутри его экземпляру закрытого класса, расширяющего скелетную реализацию. Такой прием, известный как искусственное множественное наследование (simulated multiple inheritance), тесно связан с идиомой класса-оболочки (статья 14). Он обладает большинством преимуществ множественного наследования и при этом избегает его подводных камней.

Написание скелетной реализации - занятие относительно простое, хотя иногда и скучное. Во-первых, вы должны изучить интерфейс и решить, какие из методов Являются примитивами (primitive) в терминах, в которых можно было бы реализовать остальные методы интерфейса. Эти примитивы и будут абстрактными методами в вашей скелетной реализации. После этого вы должны предоставить конкретную реализацию всех остальных методов данного интерфейса. В качестве примера при в едем скелетную реализацию интерфейса Мар. Entry. В том виде, как это показано здесь, Класс не включен в библиотеки для платформы Java, хотя, вероятно, это следовало бы сделать.

// Скелетная реализация 

public abstract class AbstractMapEntry implements Мар. Entry {

// Примитивы 

public abstract Object getKey();

public abstract Object getValue();

83

// Элементы в изменяемых схемах должны переопределять этот метод 

publiC Object setValue(Object value) {

throw пеw UnsupportedOperationException();

}

// Реализует основные соглашения для метода Мар.Entry.equals 

public boolean equals(Object о) {

if (о == this)

return true;

if (!(o iпstапсеоf Map,Entry))

return false;

Map.Entry arg = (Мар. Entry)o;

return eq(getKey(), arg.getKey()) && eq(getValue(), arg.getValue());

private static boolean eq(Object 01, Object 02) {

return (01 == null ? 02 == null : 01.equals(02));

}

// Реализует основные соглашения для метода Мар. Entry.hashCode

public int hashCode() {

return

(getKey() == пull ? 0 : getKey().hashCode())

(getValue() == null ? 0 : getValue().hashCode());

}

}

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

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

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

84

новый метод добавить одновременно и· в скелетную реализацию, и в интерфейс, однако по-настоящему это не решит проблемы. Любая реализация интерфейса, не наследующая скелетную реализацию, все равно работать не будет.

Следовательно, открытые интерфейсы необходимо проектировать аккуратно. Как толрко интерфейс создан и повсюду реализован, поменять его почти невозможно. В действительности его нужно правильно строить с первого же раза. Если в Интерфейсе есть незначительньый изъян, он уже всегда будет раздражать и вас, и пользователей. Если же интерфейс имеет серьезные дефекты, он способен погубить АРI. Самое лучшее, что можно предпринять при создании нового интерфейса,- заставить как можно больше программистов реализовать этот интерфейс самыми разнообразными способами, прежде чем он будет "заморожен". Это позволит вам найти все

ошибки, пока у вас еще есть возможность их исправить.  

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

Используйте интерфейсы только для определения типов

Если класс реализует интерфейс, то этот интерфейс может служить как некий тип, который можно использовать для ссылки на экземпляры этого класса. То, что класс реализует некий интерфейс, должно говорить нечто о том, что именно клиент может делать с экземплярами этого класса. Создавать интерфейс для каких-либо иных целей неправомерно.

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

// Шаблон интерфейса констант - не использоваты!

public interface РhуsiсаlСопstапts

// Число Авогадро (1/моль)

static final double AVOGADROS NUMBER = 6.02214199е23;

85

// Постоянная Больцмана (Дж/К)

static final double BOLTZMANN_CONSTANT=1.3806503е-23;

// Масса электрона (кг)

static final double ELECTRON MASS=9.10938188е-31;

Шаблон интерфейса констант представляет собой неудачный вариант использования интерфейсов. Появление внутри класса каких-либо констант является деталью реализации. Реализация интерфейса констант приводит к утечке таких деталей во внешний АРI данного класса. То, что класс реализует интерфейс констант, для пользователей класса не представляет никакого интереса. На практике это может даже сбить их с толку. Хуже того, это является неким обязательством: если в будущих версиях класс поменяется так, что ему уже не будет нужды использовать данные константы, он все равно должен будет реализовывать этот интерфейс для обеспечения совместимости на уровне двоичных кодов (binary compatibility). Если же интерфейс констант реализует неокончательный класс; константами из этого интерфейса будет засорено пространство имен всех его подклассов.

В библиотеках для платформы Java есть несколько интерфейсов с константами, например jауа.io.ObjectSt reamConstants. Подобные интерфейсы нужно воспринимать как отклонение от нормы, и подражать им не следует.

Для передачи констант существует несколько разумных способов. Если константы сильно связаны с имеющимся классом или интерфейсом, вы должны добавить их непосредственно в этот класс или интерфейс. Например, все классы-оболочки в библиотеках платформы Java, связанные с числами, такие как Integer и Float, предоставляют константы МIN_VALUE и MAX_VALUE. Если же константы лучше рассматривать как члены перечисления, то передавать их нужно с помощью класса перечисления (статья 21). В остальных случаях вы должны передавать константы с помощью вспомогательнoго класса (utility c!ass), не имеющего экземпляров (статья 3). Представим вариант вспомогательного класса для предыдущего примера PhysicalConstants:

// Вспомогательный класс для констант

public class PhysicalConstants {

private PhysicalConstants() { }

// Предотвращает появление экэемпляра 

public static final double AVOGADROS_NUMBER  =6.02214199е23;

public static final doubleBOLTZMANN_CONSTANT  =1.3806503е-23;

     public static final double ELECTRON_MASS  =9.10938188е-31;  

}

 

Хотя представление PhysicalConstants в виде вспомогательного класса требует, чтобы клиенты связывали названия констант с именем класса, это не большая цена за получение осмысленного API. Возможно, что со временем язык Java позволит

86

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

рrivate static final double РI = Math. PI;

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

Предпочитайте статические классы-члены нестатическим

Класс называется вложенным (nested), если он определен внутри другого класса.

Вложенный класс должен создаваться только для того, чтобы обслуживать окружающий его класс. Если вложенный класс оказывается полезен в каком-либо ином контексте, он должен стать классом верхнего уровня. Существуют четыре категории вложенных классов: статический класс-член (static member class), нестатический класс-член (nonstatic member class), анонимный класс (anonymoиs class) и локальный класс (local class). За исключением первого, остальные категории классов называются внутренними (inner class). В этой статье рассказывается о том, когда и какую категорию вложенного класса нужно использовать и почему.

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

В одном из распространенных вариантов статический класс-член используется как открытый вспомогательный класс, который пригоден для применения, только когда есть внешний класс. Например, рассмотрим перечисление, описывающее операции, которые может выполнять калькулятор (статья 21). Класс Operation должен быть открытым статическим классом-членом класса Calculator. Клиенты класса Calculator могут ссылаться на операции, выполняемые калькулятором, используя такие имена, как Calculator.Ореration.PLUS или Calculator.Ореration.MINUS. Этот вариант приводится ниже.

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