ООП — это замечательно. За несоблюдение этой парадигмы принято ругать, а знание паттернов зачастую является обязательным. Но даже правильный подход не страхует полностью от ошибок. О том, как сломать программу при помощи обычного шаблонного метода, мы сегодня и узнаем.

Предисловие

Это ещё одна статья, родившаяся из проверки 24-й версии DBeaver статическим анализатором PVS-Studio. В её ходе я нашёл несколько подозрительных мест, которые зацепили меня достаточно для того, чтобы я посвятил им по отдельной статье. Обновляемый список статей из этого цикла:

ООП? Шаблонный метод?

ООП!

Не так давно я писал статью о том, как можно применять ООП в своей ежедневной работе. Статья теоретическо-практическая и заглядывает в вопрос шире, чем мы это сделаем сегодня. Интересующихся темой приглашаю в ту статью, но весь необходимый материал мы повторим здесь — об этом можно не беспокоиться. В рамках же этой статьи необходимость использования ООП и следования SOLID предлагаю взять за постулат.

Шаблонный метод!

Собственно, один из самых простых паттернов — шаблонный метод. Но если кто-то вдруг забыл его реализацию, или вы ещё новичок, то давайте повторим его содержание.

В этом разделе будет базовая информация, так что если вы с ней знакомы, то можете переходить сразу к следующему. Остальных же приветствую в теоретической части.

Сразу отдаём дань GoF и берём классическую схему этого паттерна из их книги:

Вот настолько просто. Если вы умеете читать классовые диаграммы, конечно. В противном случае расшифруем через пример в коде, который хочет казаться реальным.

Допустим, нам надо выводить в консоль (хотя это может быть и файл, и что угодно ещё) содержимое класса Person, который содержит в себе только имя и фамилию.

Внутренности класса
public class Person {
    private final String name; 
    private final String surname;

    public String getName() {
        return name;
    }
    public String getSurname() {
        return surname;
    }

    public Person(String name, String surname) {
        this.name = name;
        this.surname = surname;
    }
}

Если вам показалось, что я не хочу переусложнять, вам не показалось :)

Просто так содержимое класса мы вывести не можем, нам надо его сериализовать. Для интереса представим, что формата сериализации два: json и xml. Мы бы, конечно, могли сделать всё в одном классе, а нужный тип сериализации выбирать посредством какого-нибудь перечисления, но тогда мы бы нарушили два принципа SOLID:

  • SRP: совместив логику разных сериализаторов в одном, мы нарушим принцип единственной ответственности;

  • OCP: добавляя новые типы сериализации в будущем, мы нарушим принцип открытости-закрытости, так как придётся менять уже существующий класс.

Вспомнив об этом, разумеется, сразу же понимаем, что так делать не надо. Вместо этого определим абстрактный метод-сериализатор в классе нашего принтера. Реализация класса тривиальна:

public abstract class AbstractPersonPrinter {
    protected abstract String serialize(Person person);
    public void print(Person person) {
        System.out.println(serialize(person));
    }
}

Всё, что нам осталось, это создать наследников, которые будут реализовывать каждый свою логику. Для json:

public class JsonPersonPrinter extends AbstractPersonPrinter {
    @Override
    public String serialize(Person person) {
        var sb = new StringBuilder();
        var s = System.getProperty("line.separator");
        sb.append("{").append(s);
        sb.append("  name: \"").append(person.getName())
                               .append("\"")
                               .append(s);
        sb.append("  surname: \"").append(person.getSurname())
                                  .append("\"")
                                  .append(s);
        sb.append("}");
        return sb.toString();
    }
}

И для xml:

public class XmlPersonPrinter extends AbstractPersonPrinter {
    @Override
    public String serialize(Person person) {
        var sb = new StringBuilder();
        var s = System.getProperty("line.separator");
        sb.append("<root>").append(s);
        sb.append("  <name>").append(s);
        sb.append("    ").append(person.getName())
                         .append(s);
        sb.append("  </name>").append(s);
        sb.append("  <surname>").append(s);
        sb.append("    ").append(person.getSurname())
                         .append(s);
        sb.append("  </surname>").append(s);
        sb.append("</root>");
        return sb.toString();
    }
}

Вуаля. Теперь мы можем сконфигурировать какой-нибудь логгер и получать вывод в желаемом формате. Если вам когда-то вообще хотелось получать вывод в json или xml.

Код какого-нибудь логгера
public class ConsoleLogger {
    private final AbstractPersonPrinter printer;
    public ConsoleLogger(AbstractPersonPrinter printer) {
        this.printer = printer;
    }

    public void logPerson(Person person) {
        printer.print(person);
    }
    ....
}

Вывод какого-нибудь логгера

json:

{
  name: "John"
  surname: "Doe"
}

xml:

<root>
  <name>
    John
  </name>
  <surname>
    Doe
  </surname>
</root>

Вернёмся к диаграмме класса. Её можно перерисовать под наш пример, чтобы было проще понять предыдущую:

Хорошо, затянутое объяснение паттерна закончено. Почти. Только упомяну, что если у вас были мысли, что вместо наследования тут может быть лучше использовать композицию (то есть стратегию) — то вы в своём праве. Я же просто хотел продемонстрировать конкретный паттерн, так что условился использовать именно его.

Реальная практика

Задача и решение

О шаблонном методе можно сказать как о базовом во многих смыслах, но в нём есть и неочевидные места.

Вот, например, была следующая задача: на веб-странице есть форма с данными, большую часть из которых можно менять. Причём нужно запоминать их версию до и после изменений, чтобы при закрытии страницы уведомлять пользователя о том, что он их не сохранил. Таких форм на разных страницах много, поэтому нужно общее решение.

Пробуем решить задачу:

  1. Делаем абстрактный обобщённый класс ParameterBase, который заключает в себе вышеописанную логику. Копирование из DTO и других объектов происходит через рефлексию, а логика сохранения и обновления состояния реализована с помощью паттерна хранитель (memento);

  2. Такой подход довольно грубый, поэтому пока не останавливаемся. Мы не учли как сложные вещи вроде маппинга полей с разными типами данных (опустим для простоты), так и простые вроде простого игнорирования полей исходного объекта для автоматического копирования.

Проблему во втором пункте надо как-то решать. Для этого вводим переопределяемый метод, в котором можно указывать ненужные поля. Вот немного упрощённое решение, которое я тогда написал:

public abstract class ParameterBase<TEntity>
    : ObservableValidator where TEntity : class
{
    protected virtual List<string> GetIgnoredProperties()
        => new List<string>();

    public ParameterBase(TEntity entity)
    {
        var ignore = GetIgnoredProperties();
        var sourceProp = GetType().GetProperties();

        var colNumber = sourceProp.Length - ignore.Length;
        var colCounter = 0;

        foreach (var prop in sourceProp)
            if (!(ignore.Contains(prop.Name)))
            {
                prop.SetValue(this, typeof(TEntity)
                    .GetProperty(prop.Name, Consts.PropertyBindingFlags)
                    .GetValue(entity, null), null);
                colCounter++;
            }

        if (colNumber != colCounter)
            throw new InvalidOperationException(
                "Not every parameter field got its value");

        this.PropertyChanged += Change;
    }

    ....
}
Код выше какой-то странный

Это не у меня Java сломалась, это С# код :) Прошу воспринимать это как стилистическое отступление, код я постарался упростить.

На резонный вопрос о том, почему во фронтэнд задаче используется C#, отвечу, что это фреймворк Blazor, который активно используется в нашей компании.

К слову, раз мы заговорили о других языках программирования, то у нас есть статья на схожую тему и для C++.

Алгоритм довольно простой: копируем при помощи рефлексии все свойства из обобщённой модели в потомка ParameterBase, игнорируя указанные в самом потомке. Если что-то не сошлось по количеству, то выбрасываем исключение. Собственно, метод GetIgnoredMappingProperties и является частным случаем шаблонного метода, который называется зацепка (hook). Задача решена. Всё хорошо, так ведь?

Внезапное предупреждение

Всё бы хорошо, но после сборки срабатывает инкрементальный анализ, и PVS-Studio выдаёт следующее сообщение на код выше:

V3068 Calling overrideable class member 'GetIgnoredProperties' from constructor is dangerous. ParameterBase.cs(34)

И вот тут надо сказать, что я не очень люблю анализ на code smell-ы. В большинстве случаев хочется встать в позу, пожурить инструмент и с умным видом подавить предупреждение. И не буду нагнетать ненужной интриги — в данном случае анализатор не прав, никакой проблемы в моём решении нет. И именно так я в своё время и поступил, занеся предупреждение в suppress файл проекта, откуда я его сейчас и достал.

Конечно, можно пофантазировать о возможной проблеме и её исправлении:

  1. Если добавить в потомка конструктор, принимающий дополнительные данные, и в зависимости от этих данных менять поведение GetIgnoredProperties, то мы получим именно ту проблему, о которой говорится в описании диагностики;

  2. Для исправления можно было бы сделать конструктор приватным, инициализацию вынести в отдельный метод, а созданием объектов управлять через фабрику.

Но зачем заниматься этой эквилибристикой только ради того, чтобы анализатор от тебя отстал, если проще подавить предупреждение? В общем, неинтересно.

У нас проблемы

Опасность близко

Об этом случае я и вспомнил, когда, листая DBeaver, наткнулся на аналогичное для Java сообщение PVS-Studio:

V6052 Calling overridden 'isBinaryContents' method in 'TextWithOpen' parent-class constructor may lead to use of uninitialized data. Inspect field: binary. TextWithOpenFile.java(77), TextWithOpen.java(59)

Вспоминая прошлый опыт, я намеревался пройти дальше, но всё же решил изучить код. Итак, метод isBinaryContents находится в классе TextWithOpenFIle:

public class TextWithOpenFile extends TextWithOpen {
    private final boolean binary;
    ....

    @Override
    protected boolean isBinaryContents() {
        return binary;
    }
    ....
}

Не выглядит захватывающе. Но давайте посмотрим на единственное место, где он используется:

public TextWithOpen(Composite parent, boolean multiFS, boolean secured) {
    super(parent, SWT.NONE);

    ....
    if (!useTextEditor && !isBinaryContents()) {
        ....
        
        editItem.setEnabled(false);
    }

    ....
}

Анализатор указал на огромный конструктор, который я для удобства сократил. Вышеупомянутый isBinaryContents используется в условии, в теле которого я вырезал около 40 строк. Имейте в виду, что сейчас мы находимся в классе-родителе TextWithOpen. Теперь бы посмотреть, что находится внутри родительского isBinaryContents:

protected boolean isBinaryContents() {
    return false;
}

О, та самая зацепка, о которой мы говорили ранее. Получается, разработчики хотели, чтобы в родительском классе второе условие всегда равнялось true (не забываем про отрицание в нём). Так, а о чём говорит документация к диагностике?

Выявлен случай использования метода в родительском конструкторе, который, в свою очередь, переопределяется в дочернем классе. Такое использование может привести к тому, что переопределённый метод будут использовать неинициализированные поля класса.

Выходит, надо проверить конструктор класса TextWithOpenFIle:

public TextWithOpenFile(
    Composite parent,
    String title,
    String[] filterExt,
    int style,
    boolean binary,
    boolean multiFS,
    boolean secured
) {
    super(parent, multiFS, secured);   // <=
    this.title = title;
    this.filterExt = filterExt;
    this.style = style;
    this.binary = binary;              // <=
}

Ого. Ошибка. Сначала мы вызываем конструктор TextWithOpenFile, тот в свою очередь вызывает конструктор TextWithOpen, где вызывается isBinaryContents, читающий значение binary, которое по умолчанию является false. И только после этого поле binary инициализируется в конструкторе TextWithOpenFile.

Самое неприятное, что сходу непонятно, как это исправить. Просто взять и переместить вниз вызов super, к сожалению, не выйдет :)

Проще всего будет вынести инициализацию в отдельный метод и отдельно звать его во всех конструкторах. Просто и сердито, но не оптимально: если переусложнить инициализацию, то вероятность в ней ошибиться при создании нового наследника будет выше.

Хорошей альтернативой было бы использовать порождающие паттерны:

  • Любая из фабрик, как я уже упоминал, поместила бы процесс создания объектов в отдельное место. Тогда рядом хотя бы будут напоминания о необходимости инициализации и её способе;

  • Для данного случая также поможет строитель — классическое решение для ситуаций, когда инициализация становится слишком сложной. С помощью этого паттерна можно разбить её на несколько более простых шагов, тем самым упростив дальнейшее расширение.

Мораль

Кажется, где-то здесь надо признать, что мой скептицизм был посрамлён, а предупреждения анализатора игнорировать нельзя. Но я всё же останусь при своём мнении. В конце концов, даже в нашей документации написано:

В случае, если вы точно уверены, что вам нужно описанное поведение при инициализации объекта и вы хотите скрыть предупреждение от анализатора, пометьте сообщение как ложнопозитивное.

В моём случае я оценивал и оцениваю вероятность возможной ошибки как невероятно малую, хотя и без гарантий. Здесь же мы имеем значительно более сложную инициализацию с большим количеством параметров, и в этих обстоятельствах опасный приём закономерно привёл к ошибке. В общем, здравая оценка рисков — главная вещь при принятии решений. Тоже не звучит захватывающе, я знаю :)

Послесловие

И на этом хочется перейти к заключению. Надеюсь, вам было интересно узнать, как следование правильному подходу может привести к обидной ошибке. И если вы будете лишний раз всё перепроверять, используя переопределяемые методы в конструкторах, то я своё дело сделал :)

Если вам хочется поискать эту или другие ошибки в своём проекте, то вы можете бесплатно попробовать PVS-Studio, перейдя по этой ссылке.

А ещё я напомню, что это не все интересные вещи, которые были найдены в DBeaver, и к выходу планируется ещё одна статья. После публикации я добавлю ссылки сюда, а пока предлагаю подписаться на мой Х-твиттер, чтобы не пропустить её выход.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Konstantin Volohovsky. How template method can ruin your Java code.

Комментарии (19)


  1. rukhi7
    17.06.2024 16:45
    +3

    Делаем абстрактный обобщённый класс ParameterBase, который заключает в себе вышеописанную логику.

    если вы действительно хотели реализовать:

    один из самых простых паттернов — шаблонный метод.

    (который я бы постеснялся назвать простым честно говоря), вы бы логику реализовали в методе, а не в конструкторе.

    Конструктор вроде как должен только объект создать, вы уже тут нарушили:

    SRP: совместив логику разных ...

    совместив некоторую логику и создание объекта (это вроде как тоже логика, хоть и примитивная) который нужен для реализации этой логики, в конструкторе, или нет?


    1. Volokhovskii Автор
      17.06.2024 16:45

      который я бы постеснялся назвать простым честно говоря

      Это всё, конечно, моё имхо, но я с радостью послушаю других претендентов на это звание

      вы бы логику реализовали в методе, а не в конструкторе.

      А что, конструктор это не метод? :)
      Плюс вольную трактовку я действительно мог где-то себе позволить, но тут сошлюсь на вводный параграф той же книжки GoF по паттернам. Там всё-таки сказано, что это не какой-то строгий свод правил :)

      Конструктор вроде как должен только объект создать, вы уже тут нарушили SRP

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


      1. rukhi7
        17.06.2024 16:45

        Хотя даже если я что-то упустил - нет, не нарушил.

        А если у вас объект фабрикой классов должен создаваться или не дай бог через Dependency Injection, что тогда получится? Фабрика класов должна создать объект и отдать его тому кто его будет использовать по назначению, а у вас получилось что объект все закончил уже внутри фабрики классов, и по назначению его уже нельзя использовать тому кому это нужно, как же так-то? Если для вас шаблонный метод - один из самых простых паттернов, то такая логика тоже не должна вроде казаться сложной? Паттерны проектирования обычно используются в комбинации друг с другом, иначе в них мало смысла.


        1. Volokhovskii Автор
          17.06.2024 16:45

          Много мыслей, а центральную я не уловил. Давайте по отдельности.

          А если у вас объект фабрикой классов должен создаваться, что тогда получится?

          Не понял. Если будет - тогда и посмотрим. Тут фабрика не рассматривается.

          или не дай бог через Dependency Injection

          DI никакие объекты не создаёт, оно их передаёт (не могу подобрать лучшую замену inject). Создаются они где-то в другом месте.

          Фабрика класов должна создать объект и отдать его тому кто его будет использовать по назначению, а у вас получилось что объект все закончил уже внутри фабрики классов, и по назначению его уже нельзя использовать тому кому это нужно, как же так-то?

          Не совсем уловил, что именно тут понимается под "фабрикой классов". Класс сам себя инициализирует, что в простых случаях является нормальной практикой. Использование какой-либо фабрики в приведённых случаях будет оверинжинирингом. К слову, фабрикам не надо заниматься передачей объекта куда бы то ни было, это уже не их задача (если мы говорим про IoC, то этим занимаются отдельные контейнеры). Про "по назначению его уже нельзя использовать тому кому это нужно" совсем не понял.

          Шаблоны обычно используются в комбинации друг с другом, иначе в них мало смысла.

          Согласен, что очень часто они идут рука об руку. Даже в моём примере с логгером я ненароком использовал стратегию, а в коде с ParameterBase упоминался хранитель. Но вот невозможность их независимого использования тезис очень спорный. Тут я сторонник KISS. Ну и

          Если для вас шаблонный метод - один из самых простых паттернов, то такая логика тоже не должна вроде казаться сложной

          Отдаю себе отчёт, что в контексте моего недопонимания по многим пунктам звучит забавно, но имеем что имеем :)


          1. rukhi7
            17.06.2024 16:45

            Создаются они где-то в другом месте.

            вот именно на это я пытался обратить ваше внимание, что мы даже можем не знать в каком другом месте они создаются, поэтому вопрос:

            вы уверены что в этом другом месте надо (или даже просто можно) выполнять логику которую вы поместили в конструктор? Вы уверены что это надежное решение?

            Поэтому мне кажется это решение засунуть логику в конструктор очень надуманным, мягко говоря.


            1. Volokhovskii Автор
              17.06.2024 16:45

              Я всё ещё немного теряюсь в теме обсуждения, но если мы говорим о ParameterBase и о том, не является ли плохой идеей оставить в конструкторе инициализацию - нет, не кажется. Как архитектору модуля нам должно быть известно, как и где объект должен создаваться (в данном случае объект-клиент создаёт конкретную реализацию абстрактного класса, которая ему нужна). Мы же это пишем для конкретного таска, а не в вакууме.
              Если логика станет достаточно сложной/понадобится нужда в разных типах объектов в рамках одних и тех же объектов-клиентов то да, порождающий паттерн станет необходимым. Собственно, это я и предлагаю как один из вариантов решения найденного бага.


              1. rukhi7
                17.06.2024 16:45

                я честно говоря не очень понимаю как эта логика связана с описанной вами задачей:

                Причём нужно запоминать их версию до и после изменений, чтобы при закрытии страницы уведомлять пользователя о том, что он их не сохранил.

                то есть где у вас запоминание до, а где запоминание после, и только потом, вроде как, должно произойти сравнение, а после еще и уведомление. Я что-то не вижу как ваш код в конструкторе мапится на эту задачу. У вас даже возвращаемого значения нет, как же понять где сформированное уведомление?

                Если бы вы это разъяснили я бы наверно смог вам предоставить альтернативный вариант, который, возможно, был бы более функциональным и в то же время более простым.


                1. Volokhovskii Автор
                  17.06.2024 16:45

                  А, мы об этом. Конечно, задачу сравнения конструктор не выполняет и выполнять не может (впрочем, там было начальное создание мементо, но я его не включил во фрагмент из-за небольшой мудрёности), этим занимается класс в целом.
                  В пример был приведён только конструктор c инициализацией объекта как конкретный случай использования опасной практики, что и является предметом статьи.


      1. ris58h
        17.06.2024 16:45

        А что, конструктор это не метод? :)

        https://docs.oracle.com/javase/specs/jls/se21/html/jls-8.html#jls-8.8

        Можете прочитать спецификацию и попробовать указать нам, в каком именно месте указано, что конструктор является методом. Ну или, например, посмотреть что getDeclaredMethods и getDeclaredConstructors - это два разных метода класса java.lang.Class.


        1. Volokhovskii Автор
          17.06.2024 16:45

          Разговор ведётся в плоскости проектирования, а не типов данных в рефлексии. В этом контексте определение метода это процедура, связанная с объектом, чему конструктор соответствует.


          1. ris58h
            17.06.2024 16:45

            Вы можете любые определения придумывать, но в спецификации языка Java, в плоскости которого ведётся разговор, этого нет.

            Ответ на ваш изначальный вопрос - нет, конструктор - это не метод.


            1. Volokhovskii Автор
              17.06.2024 16:45

              Ну а вы можете сколько угодно придумывать контекст разговора, будто обобщённого программирования не существует. К слову, в статье помимо Java есть и C#, давайте посмотрим определение из его документации:

              A constructor is a method whose name is the same as the name of its type

              Если уж докопались до определения, то по вашей ссылке его вообще нет, она ведёт на спецификацию по рефлексии :)


              1. ris58h
                17.06.2024 16:45

                Если уж докопались до определения, то по вашей ссылке его вообще нет, она ведёт на спецификацию по рефлексии :)

                По моей ссылке - спецификация языка. Прямого определения метода там нет, но определения конструктора и метода там не пересекаются и разделены.

                В C# же любой блок кода - метод, что весьма расплывчато, как по мне.

                https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/methods

                A method is a code block that contains a series of statements.


                1. Volokhovskii Автор
                  17.06.2024 16:45

                  Ну, я этим буквоедством занялся шутки ради. Меня вполне устраивает определение, вычитанное мною ещё в университетском курсе по программированию. В целом, оно же продублировано в википедии если такой источник данных, конечно, устроит.

                  Какой-то спор тут разводить бесцельно, ибо использование термина я пояснил.


  1. SergeyEgorov
    17.06.2024 16:45

    А что мешает свести ООП (с шаблонами GoF) к максимально необходимому минимуму и перестать сталкиваться с такими проблемами?


    1. Volokhovskii Автор
      17.06.2024 16:45

      И ведь даже в этом минимуме можно ошибиться, если мы полностью не отказываемся от ООП. Не говоря о том, идея шаблонного метода достаточно интуитивна. Из-за этого такую ошибку можно совершить, даже если не знаешь с чем имеешь дело.


      1. SergeyEgorov
        17.06.2024 16:45

        :-D даже если мы полностью откажемся от ООП (в Java это вообще возможно?) мы не избавимся от возможностей совершать ошибки.


        1. Volokhovskii Автор
          17.06.2024 16:45

          в Java это вообще возможно?

          Если использовать только статические классы, то мы можем опасно к этому приблизиться :)

          мы не избавимся от возможностей совершать ошибки.

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


  1. ris58h
    17.06.2024 16:45

    Надо было на уровне языка запретить вызов переопределяемых методов в конструкторе, но уже поздно.