Мои Конспекты
Главная | Обратная связь


Автомобили
Астрономия
Биология
География
Дом и сад
Другие языки
Другое
Информатика
История
Культура
Литература
Логика
Математика
Медицина
Металлургия
Механика
Образование
Охрана труда
Педагогика
Политика
Право
Психология
Религия
Риторика
Социология
Спорт
Строительство
Технология
Туризм
Физика
Философия
Финансы
Химия
Черчение
Экология
Экономика
Электроника

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



 

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

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

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

publlic boolean remove(Object о)

 

 

 

Удаляет из данной коллекции один экземпляр указанного элемента, если таковой имеется (необязательная операция). Или более формально: удаляет элемент е, такой, что (о == 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м. пер.). При переопределении этого метода, дающем доступ к деталям реализации списка, можно значительно повысить производительность операции очистки как для списка, так и для его подсписков.

 

 

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

Параметры:

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

 

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

 

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

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

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

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

 

 

 

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

 

 

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

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

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

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

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

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

 

 

 

 

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

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

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

 

 

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

 

 

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

 

 

 

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

Все, что для этого нужно,- добавить в класс необходимые методы, если их еще нет, и внести в декларацию класса пункт о реализации. Например, когда платформа 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(); }

 

 

 

 

Такой уровень гибкости нужен не всегда. Если же он необходим, интерфейсы становятся спасительным средством. Альтернативой им является раздутая иерархия классов, которая содержит отдельный класс для каждой поддерживаемой ею комбинации атрибутов. Если в системе имеется п атрибутов, то существует 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);

}

};

}

 

 

 

Если принять во внимание все, что делает реализация интерфейса 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();

 

 

 

 

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

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. для краткости в предыдущем примере опущены комментарии к документации, однако качественное документирование для скелетных реализаций абсолютно необходимо.

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

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

 

 

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

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

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

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