Это позволяет избежать хрупкой проблемы базового класса . Каждый класс поставляется с набором неявных или явных гарантий и инвариантов. Принцип подстановки Лискова требует, чтобы все подтипы этого класса также предоставляли все эти гарантии. Тем не менее, это действительно легко нарушить это, если мы не используем final
. Например, давайте проверим пароль:
public class PasswordChecker {
public boolean passwordIsOk(String password) {
return password == "s3cret";
}
}
Если мы допустим переопределение этого класса, одна реализация может заблокировать всех, другая может предоставить всем доступ:
public class OpenDoor extends PasswordChecker {
public boolean passwordIsOk(String password) {
return true;
}
}
Обычно это не так, поскольку подклассы теперь имеют поведение, очень несовместимое с оригиналом. Если мы действительно намерены расширить класс другим поведением, лучше использовать Chain of Responsibility:
PasswordChecker passwordChecker =
new DefaultPasswordChecker(null);
// or:
PasswordChecker passwordChecker =
new OpenDoor(null);
// or:
PasswordChecker passwordChecker =
new DefaultPasswordChecker(
new OpenDoor(null)
);
public interface PasswordChecker {
boolean passwordIsOk(String password);
}
public final class DefaultPasswordChecker implements PasswordChecker {
private PasswordChecker next;
public DefaultPasswordChecker(PasswordChecker next) {
this.next = next;
}
@Override
public boolean passwordIsOk(String password) {
if ("s3cret".equals(password)) return true;
if (next != null) return next.passwordIsOk(password);
return false;
}
}
public final class OpenDoor implements PasswordChecker {
private PasswordChecker next;
public OpenDoor(PasswordChecker next) {
this.next = next;
}
@Override
public boolean passwordIsOk(String password) {
return true;
}
}
Проблема становится более очевидной, когда более сложный класс вызывает свои собственные методы, и эти методы могут быть переопределены. Я иногда сталкиваюсь с этим, когда красиво печатаю структуру данных или пишу HTML. Каждый метод отвечает за некоторый виджет.
public class Page {
...;
@Override
public String toString() {
PrintWriter out = ...;
out.print("<!DOCTYPE html>");
out.print("<html>");
out.print("<head>");
out.print("</head>");
out.print("<body>");
writeHeader(out);
writeMainContent(out);
writeMainFooter(out);
out.print("</body>");
out.print("</html>");
...
}
void writeMainContent(PrintWriter out) {
out.print("<div class='article'>");
out.print(htmlEscapedContent);
out.print("</div>");
}
...
}
Теперь я создаю подкласс, который добавляет немного больше стиля:
class SpiffyPage extends Page {
...;
@Override
void writeMainContent(PrintWriter out) {
out.print("<div class='row'>");
out.print("<div class='col-md-8'>");
super.writeMainContent(out);
out.print("</div>");
out.print("<div class='col-md-4'>");
out.print("<h4>About the Author</h4>");
out.print(htmlEscapedAuthorInfo);
out.print("</div>");
out.print("</div>");
}
}
Теперь на мгновение игнорируем, что это не очень хороший способ создания HTML-страниц, что произойдет, если я захочу еще раз изменить макет? Я должен был бы создать SpiffyPage
подкласс, который каким-то образом оборачивает этот контент. Здесь мы видим случайное применение шаблона метода шаблона. Методы шаблона - это четко определенные точки расширения в базовом классе, которые предназначены для переопределения.
А что произойдет, если базовый класс изменится? Если содержимое HTML изменяется слишком сильно, это может нарушить компоновку, предоставляемую подклассами. Поэтому не очень безопасно менять базовый класс впоследствии. Это не очевидно, если все ваши классы находятся в одном проекте, но очень заметно, если базовый класс является частью какого-либо опубликованного программного обеспечения, на котором строят другие люди.
Если бы эта стратегия расширения была предназначена, мы могли бы позволить пользователю поменять способ создания каждой части. Либо, для каждого блока может быть стратегия, которая может быть предоставлена извне. Или мы могли бы вкладывать декораторы. Это будет эквивалентно приведенному выше коду, но будет гораздо более явным и более гибким:
Page page = ...;
page.decorateLayout(current -> new SpiffyPageDecorator(current));
print(page.toString());
public interface PageLayout {
void writePage(PrintWriter out, PageLayout top);
void writeMainContent(PrintWriter out, PageLayout top);
...
}
public final class Page {
private PageLayout layout = new DefaultPageLayout();
public void decorateLayout(Function<PageLayout, PageLayout> wrapper) {
layout = wrapper.apply(layout);
}
...
@Override public String toString() {
PrintWriter out = ...;
layout.writePage(out, layout);
...
}
}
public final class DefaultPageLayout implements PageLayout {
@Override public void writeLayout(PrintWriter out, PageLayout top) {
out.print("<!DOCTYPE html>");
out.print("<html>");
out.print("<head>");
out.print("</head>");
out.print("<body>");
top.writeHeader(out, top);
top.writeMainContent(out, top);
top.writeMainFooter(out, top);
out.print("</body>");
out.print("</html>");
}
@Override public void writeMainContent(PrintWriter out, PageLayout top) {
... /* as above*/
}
}
public final class SpiffyPageDecorator implements PageLayout {
private PageLayout inner;
public SpiffyPageDecorator(PageLayout inner) {
this.inner = inner;
}
@Override
void writePage(PrintWriter out, PageLayout top) {
inner.writePage(out, top);
}
@Override
void writeMainContent(PrintWriter out, PageLayout top) {
...
inner.writeMainContent(out, top);
...
}
}
(Дополнительный top
параметр необходим, чтобы убедиться, что вызовы writeMainContent
проходят через вершину цепочки декораторов. Это эмулирует функцию подклассов, называемую открытой рекурсией .)
Если у нас есть несколько декораторов, мы можем теперь смешивать их более свободно.
Гораздо чаще, чем желание слегка адаптировать существующие функциональные возможности, возникает желание повторно использовать некоторую часть существующего класса. Я видел случай, когда кто-то хотел класс, где вы могли бы добавлять элементы и перебирать их все. Правильным решением было бы:
final class Thingies implements Iterable<Thing> {
private ArrayList<Thing> thingList = new ArrayList<>();
@Override public Iterator<Thing> iterator() {
return thingList.iterator();
}
public void add(Thing thing) {
thingList.add(thing);
}
... // custom methods
}
Вместо этого они создали подкласс:
class Thingies extends ArrayList<Thing> {
... // custom methods
}
Это внезапно означает, что весь интерфейс ArrayList
стал частью нашего интерфейса. Пользователи могут remove()
вещи или get()
вещи по определенным показателям. Это было задумано таким образом? ХОРОШО. Но часто мы не продумываем все последствия.
Поэтому желательно
- никогда не
extend
урок без тщательной мысли.
- всегда помечайте свои классы как
final
исключая, если вы намереваетесь переопределить любой метод.
- создавать интерфейсы, где вы хотите поменять реализацию, например, для модульного тестирования.
Есть много примеров, когда это «правило» нужно нарушать, но оно обычно ведет вас к хорошему, гибкому дизайну и позволяет избежать ошибок из-за непреднамеренных изменений в базовых классах (или непреднамеренного использования подкласса в качестве экземпляра базового класса. ).
Некоторые языки имеют более строгие механизмы обеспечения соблюдения:
- Все методы являются окончательными по умолчанию и должны быть явно помечены как
virtual
- Они обеспечивают частное наследование, которое не наследует интерфейс, а только реализацию.
- Они требуют, чтобы методы базового класса были помечены как виртуальные, и также требуется пометить все переопределения. Это позволяет избежать проблем, когда подкласс определяет новый метод, но метод с той же сигнатурой был позже добавлен в базовый класс, но не предназначен для использования в качестве виртуального.
final
? Многие люди (включая меня) считают, что это хороший дизайн - создавать каждый неабстрактный классfinal
.