Це дозволяє уникнути нестабільної проблеми базового класу . Кожен клас має набір неявних або явних гарантій та інваріантів. Принцип заміщення Ліскова передбачає, що всі підтипи цього класу також повинні забезпечувати всі ці гарантії. Однак порушити це дійсно легко, якщо ми не використовуємо final
. Наприклад, давайте перевіримо пароль:
public class PasswordChecker {
public boolean passwordIsOk(String password) {
return password == "s3cret";
}
}
Якщо ми дозволимо скасувати цей клас, одна реалізація може заблокувати всіх, а інша може дати доступ усім:
public class OpenDoor extends PasswordChecker {
public boolean passwordIsOk(String password) {
return true;
}
}
Зазвичай це не нормально, оскільки зараз у підкласах є поведінка, яка дуже несумісна з оригіналом. Якщо ми дійсно маємо намір розширити клас іншою поведінкою, краще буде ланцюжок відповідальності:
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
.