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


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

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



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

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

 

 

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

Метод 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, пришлось закрывать несколько дыр такой природы, возникших в системе безопасности.

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

 

 

 

Если в очередной версии суперкласс получит новый метод, но окажется, что вы, к сожалению, уже имеете в подклассе метод с той же сигнатурой, но с другим типом возвращаемого значения, то ваш подкласс перестанет компилироваться [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(); }

 

Создание класса 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].

 

 

 

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

 

 

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

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