Здравствуйте, уважаемые читатели.

На русском языке выходит не так много универсальной неустаревающей литературы о принципах ООП. Пользуясь случаем, предлагаем скачать "Объектно-ориентированное мышление" Мэтта Вайсфельда, которой практически не осталось в бумаге. Однако подобные книги время от времени появляются, причем есть и такие, которые написаны в новаторском и прикладном стиле, а не просто перемалывают известные истины. Одна из них называется "Elegant Objects", мы серьезно задумываемся издать ее на русском языке.



Предлагаем оценить стиль и философию автора по переводу одной из последних статей из его блога.



Отладка – это «процесс интерактивного запуска программы/метода, причем поток выполнения после каждой инструкции приостанавливается, и отображается результат…». В сущности, это очень дельная техника… для плохого программиста. Или для олдскульного программиста, который все еще пишет процедурный код на C. Специалисты по ООП не отлаживают код — они пишут модульные тесты. Берусь утверждать, что модульное тестирование полностью снимет отладку с повестки дня. Если требуется отладка, значит программа спроектирована плохо.



Допустим, я плохой программист, пишу в императивном процедурном стиле и выдаю такой код на Java:

class FileUtils {
  public static Iterable<String> readWords(File f) {
    String text = new String(
      Files.readAllBytes(Paths.get(f)),
      "UTF-8"
    );
    Set<String> words = new HashSet<>();
    for (String word : text.split(" ")) {
      words.add(word);
    }
    return words;
  }
}


Этот статический вспомогательный метод считывает содержимое файла, а затем находит в нем все уникальные слова. Все просто. Однако, если он откажется работать, что мы будем делать? Допустим, вот файл:

We know what we are,
but know not what we may be.


Из него извлекаем следующий список слов:

"We"
"know"
"what"
"we"
"are,\n"
"but"
"not"
"may"
"be\n"


В данном случае мне не нравится… как раз следующий шаг — какой? Либо файл считывается с ошибкой, либо ошибка в разрыве строк. Давайте его отлаживать, так? Прогоняем файл через ввод и шаг за шагом прорабатываем его, отслеживая все переменные и наблюдая за ними. Находим баг, фиксим. Но если подобная проблема возникнет повторно, ее придется снова отлаживать! Именно такие случаи следует предотвращать при помощи модульных тестов.

Нам нужно один раз написать модульный тест и тем самым воспроизвести проблему. Затем мы исправляем проблему и убеждаемся, что тест пройден. Таким образом, проблемы решаются рационально. Нам не придется ничего фиксить заново, так как ошибка больше не произойдет. Наш тест это гарантирует.

Однако, все это работает, лишь если написать модульный тест легко. Если написать его будет сложно — я заленюсь и не буду писать. Просто займусь отладку и пофиксю проблему. В данном конкретном случае написать тест достаточно непросто. То есть, сам уровень сложности теста довольно высок. Требуется создать временный файл, наполнить его данными, запустить метод и проверить результат. Чтобы понять, что происходит, и где тут баг, понадобится не один тест. Чтобы избежать дублирования кода, мне также потребуется написать несколько утилит, которые помогут мне создавать временный файл и заполнять его данными. Много работы. Может быть, «не слишком много», но с отладкой я бы управился за несколько минут.

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

class Words implements Iterable<String> {
  private final File file;
  Words(File src) {
    this.file = src;
  }
  @Override
  public Iterator<String> iterator() {
    String text = new String(
      Files.readAllBytes(Paths.get(this.file)),
      "UTF-8"
    );
    Set<String> words = new HashSet<>();
    for (String word : words.split(" ")) {
      words.add(word);
    }
    return words.iterator();
  }
}


Уже гораздо лучше, но по-прежнему сложно. Далее разобью его на более мелкие классы:

class Text {
  private final File file;
  Text(File src) {
    this.file = src;
  }
  @Override
  public String toString() {
    return new String(
      Files.readAllBytes(Paths.get(this.file)),
      "UTF-8"
    );
  }
}
class Words implements Iterable<String> {
  private final String text;
  Words(String txt) {
    this.text = txt;
  }
  @Override
  public Iterator<String> iterator() {
    Set<String> words = new HashSet<>();
    for (String word : this.text.split(" ")) {
      words.add(word);
    }
    return words.iterator();
  }
}


Ну как? Написать тест для класса Words
– совершенно тривиальная задача:

import org.junit.Test;
import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.Matchers.*;
public class WordsTest {
  @Test
  public void parsesSimpleText() {
    assertThat(
      new Words("How are you?"),
      hasItems("How", "are", "you")
    );
  }
}


Сколько времени я на это потратил? Меньше минуты. Не приходится создавать временный файл, наполнять его данными, поскольку класс Words не работает с файлами. Он просто разбирает входящую строку и находит в ней уникальные слова. Теперь пофиксить ошибку легко, так как тест маленький, и нам не составит труда написать и другие тесты, например:

import org.junit.Test;
import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.Matchers.*;
public class WordsTest {
  @Test
  public void parsesSimpleText() {
    assertThat(
      new Words("How are you?"),
      hasItems("How", "are", "you")
    );
  }
  @Test
  public void parsesMultipleLines() {
    assertThat(
      new Words("first line\nsecond line\n"),
      hasItems("first", "second", "line")
    );
  }
}


Я считаю, что отладка необходима, когда на написание модульного теста тратится существенно больше времени, чем на нажатие кнопок Trace-In/Trace-Out. Это логично. Мы все ленивы, любим простые и быстрые решения. Но отладка – это пустая трата времени и сил. Она помогает лишь отыскивать проблемы, но не застраховаться от их повторного появления.

Отладка нужна в процедурном и алгоритмическом коде, когда код описывает, как достигается цель, а не какова эта цель. Еще раз пересмотрите вышеприведенные примеры. Весь первый статический метод рассказывает о том, как считывать файл, делать его синтаксический анализ и находить слова. Он даже называется readWords() («read» это глагол). Напротив, во втором примере указано, какая цель должна быть достигнута. Речь либо о тексте Text файла или о словах Words в тексте (это существительные).

Я считаю, что в правильном ООП отладка неуместна. Только модульное тестирование!
Опрос

Проголосовало 132 человека. Воздержалось 36 человек.

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

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


  1. ANtlord
    04.03.2016 14:09
    +1

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


  1. palebluedot
    04.03.2016 15:58
    +2

    Мотивацию автор описывает абсолютно правильно, но пример использует плохой. В его предлагаемом решении класс Text является бесполезным, а классу Words стоило бы принимать параметром Reader, а не строку, что дало бы ему сразу возможность работать с файлами/строками и т.д. Плюс к этому, это позволило бы написать более оптимальную реализацию итератора для больших объемов данных.


  1. scifix
    04.03.2016 16:20
    +7

    Почему вы преподносите отладку, как что-то плохое и недопустимое в программировании? В данном конкретном случае — вполне возможно.
    "Специалисты по ООП не отлаживают код" — совсем что ли не отлаживают, как им это удается?
    Лично я считаю что отладка — это неизбежный пока процесс, особенно в сложных приложениях и на начальной стадии разработки.
    Отладка необходима как ответ на проблему "человеческого фактора" — если программист забыл включить некий элемент из модели которую он программирует.


  1. r_ii
    04.03.2016 17:02
    +3

    "Любая проблема проектирования может быть решена введением дополнительного абстрактного слоя, за исключением проблемы слишком большого количества дополнительных абстрактных слоев" в действии.


  1. Throwable
    06.03.2016 15:30
    +1

    В идеальном сферически-конно-вакуумном мире это все смотрится отлично. В более-менее реальных задачах и проектах идеальная теория начинает буксовать. Начнем с того, что сам ООП патерн не есть венец творенья сего мира, а скорей некий отросток сбоку. Никто еще не доказал, что ООП лучше процедурного стиля, более того, имеются мнения, что все как раз наоборот.
    Во-вторых, подход «тестировать каждый пук», в реальной задаче приводит к сильной атомизации кода. Простой и понятный метод дробится на кучу вспомогательных сущностей, которым бывает даже трудно дать вразумительное название и определить четко их назначение.
    Кроме того, контекстно-зависимые задачи сложно бывает даже разбить на независимые куски, поскольку они постоянно обращаются к общему контексту и их выполнение зависит от его текущего состояния. Кто реализовывал задачу разбора синтаксиса, меня поймет.
    Ну и, наконец, смысл? Есть дебаггер для отлова ошибочных ситуаций. Есть комплексный тест, который покрывает различные ситуации. Зачем дробить, тестировать по отдельности каждое действие, а затем их комбинацию, а затем и все вместе, если можно сразу протестировать все вместе? Общий тест точно также свалится. Ошибку можно откопать долбаггером.


    1. knagaev
      09.03.2016 15:29
      +1

      Это Вы ещё про функциональное программирование не сказали.
      И в этой статье очень сильный уклон туда (пассаж про существительные против глаголов).


    1. YuriPanchul
      10.03.2016 22:43

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

      Во! Только хотел сказать.

      В начале 1990-х я был большим поклонником ООП, но потом стал писать некий специфический компилятор и обнаружил вот что:

      Если делать всякие деревья и графы из узлов сотни типов, после чего ползать по ним и менять поддеревья, то гораздо удобнее просто сделать функцию walk с аргументов-указателем на другую функцию с switch внутри, где манипулировать с поддеревьями в зависимости от типа узла — чем делать "объектно-ориентированным" образом — т.е. наплодить сто классов и в каждом несколько десятков виртуальных функций.


      1. Colwin
        11.03.2016 07:09

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

        В части написания компиляторов множество классов — это более простой путь в части поддержки, т.к. это позволяет не забыть добавить обработку для всех возможных типов узлов.
        switch просто писать, но очень легко пропустить обработку нового узла, и компилятор тебе не поможет. Это можно нивелировать другими практиками (наличие default-ветки с Exception'ом + полное покрытие тестами), но не все это делают, сами понимаете. :-)

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


  1. Colwin
    11.03.2016 07:01

    IMHO, автор не понимает всей сущности автотестов.
    Что даст автотест на класс Words?
    Тестирование маленькой функции с вполне определенной семантикой.
    Это всегда просто, обычно как раз такие тесты и пишутся.

    Проблема в том, что в реальном ПО чаще всего нужно покрывать тестами более сложные вещи, иначе это теряет смысл.

    Например, возьмем простой проект — калькулятор (имитирующий реальный калькулятор с памятью, это важно!). Написать тесты на сами функции — тривиальная задача. А в чем здесь наиболее вероятны ошибки? Например, в таких последовательностях нажатий кнопок: "5", "+", "=", "=" (для тех, кто не знает — попробуйте на калькуляторе Windows или на реальном). И тестировать здесь нужно не отдельные операции, а их связки.
    Если посмотреть на любой объект со стороны теории, то он выглядит как конечный автомат. И вызовы его методов меняют внутреннее состояние, после чего вызовы методов могут поменять принцип своей работы. Следовательно, нужно тестировать не отдельные вызовы, а сценарии.

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

    Вот так-то.


  1. Colwin
    11.03.2016 07:16
    +1

    А теперь небольшое слово про отладку.
    Отладка нужна тогда, когда по коду кажется, что все должно работать, но оно почему-то не работает.
    Забыли какой-то сценарий, и не можем быстро понять, какой именно. Как правило, ошибка при этом содержится в данных, а не в логике кода, а причина находится довольно далеко от неверно работающего кода. И в этом случае stack trace позволяет однозначно выцепить ветку, в которой происходит ошибка, выкинув все побочные варианты. Анализировать все варианты в голове просто дольше.
    Т.е. дебаггер — это просто средство экономии времени для таких сложных случаев. И как показывает практика, чаще ошибку проще найти путем анализа кода, ведь для дебага нужно поднять соответствующее окружение, а это часто дороже, чем анализ всех веток.