Рубрики
Без рубрики

О тонкостях ООП

Почему речь идет не только о синтаксисе. Помечено ооп, хэш-кодом, равно, java.

Не будет преувеличением сказать, что я энтузиаст, когда дело доходит до объектной ориентации. Хорошо продуманная объектно-ориентированная система – это произведение искусства. Однако следование всем принципам хорошего объектно-ориентированного дизайна может оказаться непростой задачей. Эта статья должна показать, почему мы никогда не можем ослабить бдительность при программировании объектно-ориентированных систем. Это оказалось намного дольше, чем я изначально предполагал, но, пожалуйста, потерпите меня в этом вопросе – это будет стоить вашего времени.

В одном из моих проектов мне нужно было выполнить некоторое математическое кодирование, в частности, мне нужно было работать с парами. Несмотря на то, что org.apache.commons.lang3 имеет отличные классы для кортежей (и я бы настоятельно рекомендовал их использовать!) Мне нужны были дополнительные колокольчики и свистки, и в итоге я написал их самостоятельно.

Вот как примерно выглядел мой парный класс (без дополнительных вещей, которые мне были нужны):

public class Pair {

  private final A first;
  private final B second;

  public Pair(A first, B second){
    Objects.requireNonNull(first); 
    Objects.requireNonNull(second);
    this.first = first;
    this.second = second;
  }

  // getters for first and second

  public int hashCode() {
    return this.first.hashCode()*31 + this.second.hashCode();
  }

  public boolean equals(Obj other){
    if(other == null) { return false; }
    if(other == this) { return true; }
    if(other instanceof Pair == false) { return false; }
    Pair p = (Pair)other;
    return this.first.equals(p.getFirst()) 
        && this.second.equals(p.getSecond());
  }

}

Вышеупомянутый класс прекрасно работает сам по себе. Позже в моем проекте мне также пришлось работать с тройками. Следуя принципу “Не повторяйся” (СУХО), я реализовал его следующим образом:

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 не будет жаловаться. Но вот в чем проблема. Давайте предположим, что у нас есть два кортежа:

Pair pair = 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”