Не будет преувеличением сказать, что я энтузиаст, когда дело доходит до объектной ориентации. Хорошо продуманная объектно-ориентированная система – это произведение искусства. Однако следование всем принципам хорошего объектно-ориентированного дизайна может оказаться непростой задачей. Эта статья должна показать, почему мы никогда не можем ослабить бдительность при программировании объектно-ориентированных систем. Это оказалось намного дольше, чем я изначально предполагал, но, пожалуйста, потерпите меня в этом вопросе – это будет стоить вашего времени.
В одном из моих проектов мне нужно было выполнить некоторое математическое кодирование, в частности, мне нужно было работать с парами. Несмотря на то, что org.apache.commons.lang3 имеет отличные классы для кортежей (и я бы настоятельно рекомендовал их использовать!) Мне нужны были дополнительные колокольчики и свистки, и в итоге я написал их самостоятельно.
Вот как примерно выглядел мой парный класс (без дополнительных вещей, которые мне были нужны):
Вышеупомянутый класс прекрасно работает сам по себе. Позже в моем проекте мне также пришлось работать с тройками. Следуя принципу “Не повторяйся” (СУХО), я реализовал его следующим образом:
public class Triple extends Pair { private final C third; public Triple(A first, B second, C third) { super(first, second); Objects.requireNonNull(third); this.third = third; } // getter for third public int hashCode() { return super.hashCode()*47 + this.third.hashCode(); } public boolean equals(Obj other){ if(other == null) { return false; } if(other == this) { return true; } if(other instanceof Triple == false) { return false; } Triple,?,?> t = (Triple,?,?>)other; return this.first.equals(t.getFirst()) && this.second.equals(t.getSecond()) && this.third.equals(t.getThird()); } }
Мне удалось повторно использовать два поля и кучу других вещей, здорово! … по крайней мере, я так думал. Предупреждение о спойлере: приведенный выше код имеет огромный недостаток. Снимаю перед вами шляпу, если вы сможете сразу это заметить. Я не.
Вот в чем дело: Приведенный выше код нарушает основной контракт программирования на Java.
Долгое время после написания этих классов я искал ошибку в кодовой базе примерно из 100 000 строк кода, некоторые из которых были довольно сложными. В некоторых случаях код вел себя беспорядочно, мои тесты JUnit жаловались. Но я не мог найти проблему.
В конце концов, после нескольких часов отладки, я пришел к следующему утверждению:
// complex logic here ... Set> pairs = calculate(); Pair > pairToSearchFor = ...; if(pairs.contains(pairToSearchFor)){ // ... path A } else { // ... path B }
Интенсивная отладка показала, что в этом методе путь A никогда не вызывался. И я был озадачен, почему это так, очевидно, что пара, которую я искал в своем сценарии отладки, должна была быть в наборе. Я проверил записи набора в отладчике, и тут меня внезапно осенило.
Набор содержал только Тройной с.
A Тройной может использоваться вместо Пара в нашей реализации компилятор Java не будет жаловаться. Но вот в чем проблема. Давайте предположим, что у нас есть два кортежа:
Pairpair = new Pair<>("Hello", 123L); Triple triple = new Triple<>("Hello", 123L, 144L); pair.equals(triple); // returns true triple.equals(pair); // returns false
Мы нарушили контракт Объект#равен(...) . Вот почему запись не была найдена в хеш-наборе. Объект#равен(...) (как и любое другое отношение равенства) должно быть симметричным , рефлексивный и транзитивный . В нашем примере это не симметрично. Мы синтаксически выполнили контракт (т.е. мы реализовали метод таким образом, чтобы сделать javac счастливым), но мы семантически нарушили его. Это может показаться совершенно очевидным в изоляции этого сообщения в блоге, но удачи в обнаружении этой ошибки в большой кодовой базе.
Моим первым побуждением было побежать за исправлением и настроить равно метод Пара . Ясно, что тройка никогда не может быть равна паре. Но Тройной является подклассом Пара , так что экземпляр пары не помог бы различить эти два. другое.getClass().равно(Pair.class) мне тоже было не по себе. Я понял, что у меня не было возможности заставить этот метод equals(...) работать, не сообщив ему о его подклассах. И в соответствии с правилами проектирования ООП класс должен никогда не знать о своих подклассах (если только он не абстрактный ).
Так какого черта? Разве мы не следовали всем лучшим практикам, которые только существуют? Нашли ли мы изъян в самих принципах объектной ориентации? Мы сломали матрицу?
Когда я применил СУХОЙ принцип, чтобы поделиться первым и вторые поля, я сосредоточился исключительно на синтаксисе . Я следовал этому принципу, потому что это было легко . Не повторять эти поля казалось хорошей идеей. Однако я забыл об одном принципе объектной ориентации, который превосходит все остальное, когда дело доходит до наследования. Используйте наследование только в том случае, если подкласс на самом деле является семантической специализацией базового класса. Наследование может быть инструментом для достижения СУХОГО дизайна. Но это не единственное, и оно не всегда применимо.
Делать Тройной и Соединять совместно использовать два поля? Ну да, они это делают! Но это всего лишь синтаксис !
Является ли каждый Тройной также Пара в каждом конкретном случае? Конечно, нет!
Второй вывод должен быть верным , чтобы правильно использовать наследование. Однако для проверки этого требуется гораздо больше умственных усилий, чем для первого. Речь идет о семантике вашего класса. javac не может вам здесь помочь. Существенным недостатком в моем мышлении было то, что я сосредоточился на написании как можно меньшего количества строк кода, но в итоге я нарушил не только контракт Объект#равен(...) но также и одно из основных правил объектной ориентации – даже не замечая этого поначалу. Правило, которое здесь нарушается, известно как Принцип подстановки Лискова .
Сообщения на вынос из этой статьи являются:
- Синтаксис – это средство достижения цели в ООП. Сосредоточьтесь на семантике своих решений.
- Правила, принципы и лучшие практики иногда могут противоречить друг другу. Убедитесь, что вы осознаете, какой из них важнее другого.
- Никогда не используйте наследование просто ради синтаксического обмена общими полями или методами.
- Никогда просто не внедряйте интерфейс вслепую. Прочтите это чертово руководство.
- Никогда не относитесь к реализации
Объекта#хэш-код()илиОбъект#равен(...)легкомысленно. Оно вернется , чтобы преследовать тебя.
Спасибо за чтение, я надеюсь, что этот пост однажды сэкономит кому-нибудь много часов, потраченных впустую в отладчике.
Оригинал: “https://dev.to/martinhaeusler/on-the-subtleties-of-oop-ah4”