Pull to refresh

Книга «Элегантные объекты. Java Edition»

Reading time21 min
Views32K
imageПривет, Хаброжители! Эта книга всерьез пересматривает суть и принципы объектно-ориентированного программирования (ООП) и может быть метафорически названа «ООП Лобачевского». Егор Бугаенко, разработчик с 20-летним стажем, критически анализирует догмы ООП и предлагает взглянуть на эту парадигму совершенно по-новому. Так, он клеймит статические методы, геттеры, сеттеры, изменяемые методы, считая, что это — зло. Для начинающего программиста этот томик может стать просветлением или шоком, а для опытного является обязательным чтением.

Отрывок «Не используйте статические методы»


Ах, статические методы… Одна из моих любимых тем. Мне понадобилось несколько лет, чтобы осознать, насколько важна эта проблема. Теперь я сожалею обо всем том времени, которое потратил на написание процедурного, а не объектно-ориентированного программного обеспечения. Я был слеп, но теперь прозрел. Статические методы — настолько же большая, если не еще большая проблема в ООП, чем наличие константы NULL. Статических методов в принципе не должно было быть в Java, да и в других объектно-ориентированных языках, но, увы, они там есть. Мы не должны знать о таких вещах, как ключевое слово static в Java, но, увы, вынуждены.. Я не знаю, кто именно привнес их в Java, но они — чистейшее зло.. Статические методы, а не авторы этой возможности. Я надеюсь.

Посмотрим, что такое статические методы и почему мы до сих пор создаем их. Скажем, мне нужна функциональность загрузки веб-страницы посредством HTTP-запросов. Я создаю такой «класс»:

class WebPage {
  public static String read(String uri) {
     // выполнить HTTP-запрос
     // и конвертировать ответ в UTF8-строку
  }
}

Пользоваться им очень удобно:

String html = WebPage.read("http://www.java.com");

Метод read() относится к тому классу методов, против которого я выступаю. Предлагаю вместо этого использовать объект (также я поменял имя метода в соответствии с рекомендациями из раздела 2.4):

class WebPage {
  private final String uri;
  public String content() {
     // выполнить HTTP-запрос
     // и конвертировать ответ в UTF8-строку
  }
}

Вот как им пользоваться:

String html = new WebPage("http://www.java.com")
   .content();

Вы можете сказать, что между ними нет особой разницы. Статические методы работают даже быстрее, потому что нам нет необходимости создавать новый объект каждый раз, когда нужно скачать веб-страницу. Просто вызовите статический метод, он сделает дело, вы получите результат и будете работать дальше.. Нет необходимости возиться с объектами и сборщиком мусора. Кроме того, мы можем сгруппировать несколько статических методов в класс-утилиту и назвать его, скажем, WebUtils.

Эти методы помогут загружать веб-страницы, получать статистическую информацию, определять время отклика и т. п. В них будет много методов, а использовать их просто и интуитивно понятно. Кроме того, как применять статические методы, тоже интуитивно понятно. Все понимают, как они работают. Просто напишите WebPage.read(), и — вы догадались! — будет прочитана страница. Мы дали компьютеру инструкцию, и он ее выполняет.. Просто и понятно, так ведь? А вот и нет!

Статические методы в любом контексте — безошибочный индикатор плохого программиста, понятия не имеющего об ООП. Для применения статических методов нет ни единого оправдания ни в одной ситуации. Забота о производительности не считается. Статические методы — издевательство над объектно-ориентированной парадигмой. Они существуют в Java, Ruby, C++, PHP и других языках. К несчастью. Мы не можем их оттуда выбросить, не можем переписать все библиотеки с открытым исходным кодом, полные статических методов, но можем прекратить использовать их в своем коде.

Мы должны прекратить применять статические методы.

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

Объектное мышление против компьютерного


Изначально я назвал этот подраздел «Объектное мышление против процедурного», но потом переименовал. «Процедурное мышление» означает почти то же самое, но словосочетание «мыслить как компьютер» лучше описывает проблему.. Мы унаследовали этот образ мышления из ранних языков программирования, таких как Assembly, C, COBOL, Basic, Pascal, и многих других. Основа парадигмы в том, что компьютер работает на нас, а мы указываем ему, что делать, давая ему явные инструкции, например:

   CMP AX, BX
   JNAE greater
   MOV CX, BX
   RET
greater:
   MOV CX, AX
   RET

Это ассемблерная «подпрограмма» для процессора Intel 8086.. Она находит и возвращает большее из двух чисел. Мы помещаем их в регистры AX и BX соответственно, а результат попадает в регистр CX. Вот точно такой же код на языке С:

int max(int a, int b) {
   if (a > b) {
     return a;
   }
   return b;
}

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

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

Взглянем на тот же код, записанный на функциональном языке программирования Lisp:

(defun max (a b)
     (if (> a b) a b))

Можете ли вы сказать, где начинается и заканчивается исполнение этого кода? Нет. Мы не знаем, ни каким образом процессор получит результат, ни то, как конкретно будет работать функция if. Мы очень отстранены от процессора. Мы мыслим как функция, а не как компьютер. Когда нам нужна новая вещь, мы определяем ее:

(def x (max 5 9))

Мы определяем, а не даем инструкции процессору. Этой строчкой мы привязываем x к (max 5 9). Мы не просим компьютер вычислить большее из двух чисел. Мы просто говорим, что х есть большее из двух чисел. Мы не управляем тем, как и когда это будет вычислено. Обратите внимание, это важно: x есть большее из чисел. Отношение «есть» («быть», «являться») — то, чем отличается функциональная, логическая и объектно-ориентированная парадигма программирования от процедурной.

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

class Max implements Number {
  private final Number a;
  private final Number b;
  public Max(Number left, Number right) {
      this.a = left;
      this.b = right;
  }
}

А так я буду его использовать:

Number x = new Max(5, 9);

Смотрите, я не вычисляю большее из двух чисел. Я определяю, что х есть большее из двух чисел. Меня не особо беспокоит, что находится внутри объекта класса Max и как именно он реализует интерфейс Number. Я не даю процессору инструкции относительно этого вычисления. Я просто инстанцирую объект. Это очень похоже на def в Lisp.. В этом смысле ООП очень похоже на функциональное программирование.

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

int x = Math.max(5, 9);

Это совершенно неправильно и не должно использоваться в настоящем объектно-ориентированном проектировании.

Декларативный стиль против императивного


Императивное программирование «описывает вычисления в терминах операторов, изменяющих состояние программы».. Декларативное программирование, с другой стороны, «выражает логику вычисления, не описывая поток его выполнения» (я цитирую «Википедию»). Об этом мы, по сути, говорили на протяжении нескольких предыдущих страниц. Императивное программирование похоже на то, что делают компьютеры, — последовательное выполнение инструкций. Декларативное программирование ближе к естественному образу мышления, в котором у нас есть сущности и отношения между ними. Очевидно, что декларативное программирование — более мощный подход, но императивный подход понятнее процедурным программистам. Почему декларативный подход более мощный? Не переключайтесь, и через несколько страниц мы доберемся до сути.

Какое отношение все это имеет к статическим методам? Неважно, статический это метод или объект, мы все еще должны где-то написать if (a > b), так ведь? Да, именно так. Как статический метод, так и объект — всего лишь обертка над оператором if, который выполняет задачу сравнения a с b. Разница в том, как эта функциональность используется другими классами, объектами и методами. И это существенная разница. Рассмотрим ее на примере.
Скажем, у меня есть интервал, ограниченный двумя целыми числами, и целое число, которое должно в него попадать.. Я должен убедиться, что это так. Вот что мне придется сделать, если метод max() — статический:

public static int between(int l, int r, int x) {
    return Math.min(Math.max(l, x), r);
}

Нужно создать еще один статический метод, between(), который использует два имеющихся статических метода, Math.min() и Math.max(). Есть только один способ это сделать — императивный подход, поскольку значение вычисляется сразу же. Когда я делаю вызов, я немедленно получаю результат:

int y = Math.between(5, 9, 13); // возвращает 9

Я получаю число 9 сразу же после вызова between(). Когда будет сделан вызов, мой процессор тут же начнет работать над этим вычислением. Это императивный подход. А как тогда выглядит декларативный подход?

Вот, взгляните:

class Between implements Number {
  private final Number num;
  Between(Number left, Number right, Number x) {
      this.num = new Min(new Max(left, x), right);
  }
  @Override
  public int intValue() {
      return this.num.intValue();
   }
}

Вот как я его буду использовать:

Number y = new Between(5, 9, 13); // еще не вычисляется!

Чувствуете разницу? Она чрезвычайно важна. Такой стиль будет декларативным, поскольку я не указываю процессору, что вычисления нужно выполнить сразу. Я просто определил, что это такое, и оставил на усмотрение пользователя решение о том, когда (и нужно ли вообще) вычислять переменную y методом intValue(). Может, она никогда не будет вычислена и мой процессор никогда не узнает, что это число 9.. Все, что я сделал, — объявил, что такое y. Просто объявил. Я еще не дал никакой работы процессору. Как указано в определении, выразил логику, не описывая процесс.

Я уже слышу: «О’кей, понял вас. Есть два подхода — декларативный и процедурный, но почему первый лучше второго?» Ранее я упомянул, что очевидно, что декларативный подход более мощный, но не объяснил почему. Теперь, когда мы рассмотрели оба подхода на примерах, обсудим преимущества декларативного подхода.

Во-первых, он быстрее. На первый взгляд он может показаться более медленным. Но если присмотреться внимательнее, станет видно, что на деле он быстрее, поскольку оптимизация производительности полностью в наших руках. Действительно, на создание экземпляра класса Between потребуется больше времени, чем на вызов статического метода between(), по крайней мере в большинстве языков программирования, доступных на момент написания этой книги.. Я очень надеюсь на то, что в ближайшем будущем у нас появится язык, в котором инстанцирование объекта будет столь же быстрым, как и вызов метода. Но мы еще не пришли к нему. Вот почему декларативный подход медленнее… когда путь исполнения прост и прямолинеен.

Если речь идет о простом вызове статического метода, то он, безусловно, будет быстрее, нежели создание экземпляра объекта и вызов его методов. Но если у нас много статических методов, они будут последовательно вызываться при решении задачи, а не только для того, чтобы работать над действительно нужными нам результатами. Как насчет этого:

public void doIt() {
    int x = Math.between(5, 9, 13);
    if (/* Надо ли? */) {
      System.out.println("x=" + x);
    }
}

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

public void doIt() {
    Integer x = new Between(5, 9, 13);
    if (/* Надо ли? */) {
      System.out.println("x=" + x);
    }
}

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

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

Второй аргумент — полиморфизм. Если говорить просто, то полиморфизм — это возможность разрывать зависимости между блоками кода. Допустим, я хочу поменять алгоритм определения того, попадает ли число в определенный интервал. Он довольно примитивен сам по себе, но я хочу его изменить. Я не хочу использовать классы Max и Min. А хочу, чтобы он выполнял сравнение с применением операторов if-then-else.. Вот как сделать это декларативно:

class Between implements Number {
  private final Number num;
  Between(int left, int right, int x) {
      this(new Min(new Max(left, x), right));
  }
  Between(Number number) {
      this.num = number;
  }
}

Это тот же класс Between, что и в предыдущем примере, но с дополнительным конструктором. Теперь я могу использовать его с другим алгоритмом:

Integer x = new Between(
   new IntegerWithMyOwnAlgorithm(5, 9, 13)
);

Это, наверное, не лучший пример, поскольку класс Between очень примитивен, но, надеюсь, вы понимаете, о чем я. Класс Between очень просто отделить от классов Max и Min, поскольку они являются классами. В объектно-ориентированном программировании объект является полноправным гражданином, а статический метод — нет. Мы можем передать объект в качестве аргумента конструктору, но не можем сделать то же самое со статическим методом. В ООП объекты связаны с объектами, общаются с объектами, обмениваются с ними данными. Чтобы полностью отвязать объект от остальных объектов, мы должны убедиться, что он не использует оператор new ни в одном из своих методов (см. раздел 3.6), а также в главном конструкторе.

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

Можете ли вы проделать такую же отвязку и рефакторинг с императивным фрагментом кода?

int y = Math.between(5, 9, 13);

Нет, не можете. Статический метод between() использует два статических метода, min() и max(), и вы ничего не сможете сделать, пока не перепишете его полностью. А как вы сможете его переписать? Передадите четвертым параметром новый статический метод?

Насколько уродливо это будет выглядеть? Думаю, весьма.

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

Третий довод в пользу превосходства декларативного подхода над императивным — декларативный подход говорит о результатах, а императивный объясняет единственный способ их получения. Второй подход намного менее интуитивно понятен, чем первый. Я должен сперва «выполнить» код в голове, чтобы понять, какого результата ожидать. Вот императивный подход:

Collection<Integer> evens = new LinkedList<>();
for (int number : numbers) {
   if (number % 2 == 0) {
     evens.add(number);
   }
}

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

Collection<Integer> evens = new Filtered(
    numbers,
    new Predicate<Integer>() {
       @Override
       public boolean suitable(Integer number) {
           return number % 2 == 0;
       }
     }
);

Этот фрагмент кода намного ближе к английскому языку, чем предыдущий. Он читается следующим образом: «evens — это фильтрованная коллекция, включающая только те элементы, которые являются четными». Я не знаю, как именно класс Filtered создает коллекцию — использует ли он оператор for или что-то еще. Все, что я должен знать, читая этот код, — то, что коллекция была отфильтрована. Детали реализации скрыты, а поведение выражено.

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

Если вы считаете этот код уродливым, попробуйте, например, Groovy:

def evens = new Filtered(
   numbers,
   { Integer number -> number % 2 == 0 }
);

Четвертый довод — цельность кода. Взгляните еще раз на предыдущие два фрагмента. Обратите внимание на то, что во втором фрагменте мы объявляем evens одним оператором — evens = Filtered(…). Это значит, что все строки кода, ответственные за вычисление данной коллекции, находятся рядом друг с другом и не могут быть по ошибке разделены. Напротив, в первом фрагменте нет очевидной «склейки» строк. Можно с легкостью поменять их порядок по ошибке, и алгоритм сломается.

В таком простом фрагменте кода это небольшая проблема, поскольку алгоритм очевиден. Но если фрагмент императивного кода более крупный — скажем, 50 строк, может оказаться трудно понять, какие строки кода связаны друг с другом.. Мы обсудили проблему темпорального сцепления чуть раньше — во время обсуждения неизменяемых объектов.. Декларативный стиль программирования также помогает устранить это сцепление, благодаря чему улучшается сопровождаемость.

Вероятно, есть еще доводы, но я привел самые важные, с моей точки зрения, из относящихся к ООП. Надеюсь, я смог убедить вас в том, что декларативный стиль — это то, что надо. Некоторые из вас могут сказать: «Да, я понимаю, о чем вы. Я буду совмещать декларативный и императивный подходы там, где это уместно. Я буду использовать объекты там, где это имеет смысл, а статические методы — тогда, когда мне надо быстро сделать что-то несложное вроде вычисления большего из двух чисел».. «Нет, вы неправы!» — отвечу вам я. Вы не должны их совмещать.. Никогда не применяйте императивный стиль. Это не догма.. У этого есть вполне прагматичное объяснение.

Императивный стиль нельзя совместить с декларативным чисто технически. Когда вы начинаете использовать императивный подход, вы обречены — постепенно весь ваш код станет императивным.

Допустим, у нас есть два статических метода — max() и min(). Они выполняют небольшие быстрые вычисления, поэтому мы делаем их статическими. Теперь нам нужно создать больший алгоритм, чтобы определить, принадлежит ли число интервалу.. На сей раз мы хотим пойти декларативным путем — создать класс Between, а не статический метод between(). Можем ли мы так сделать? Наверное, да, но суррогатным способом, а не так, как положено. Мы не можем использовать конструкторы и инкапсуляцию. И вынуждены делать непосредственные, явные вызовы статических методов прямо внутри класса Between. Иными словами, мы не сможем написать чисто объектно-ориентированный код, если повторно применяемые компоненты представляют собой статические методы.

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

«Но они у меня повсюду! — воскликнете вы. — Что же делать?» Что я могу сказать… у вас проблемы, как и у всех нас. Существуют тысячи объектно-ориентированных библиотек, практически полностью состоящих из классов-утилит (мы обсудим их в следующем разделе). Здесь, как и с опухолью, лучшее средство — нож. Не используйте такие программы, если можете это себе позволить.. Однако в большинстве случаев вы не сможете позволить себе воспользоваться ножом, поскольку эти библиотеки весьма популярны и предоставляют полезную функциональность. В данном случае лучшее, что вы можете сделать, — изолировать опухоль, создав собственные классы, которые оборачивают статические методы так, чтобы ваш код работал исключительно с объектами. К примеру, в библиотеке Apache Commons есть статический метод FileUtils.readLines(), который считывает все строки из текстового файла. Вот как мы можем превратить его в объект:

class FileLines implements Iterable<String> {
  private final File file;
  public Iterator<String> iterator() {
      return Arrays.asList(
         FileUtils.readLines(this.file)
      ).iterator();
  }
}

Теперь, чтобы прочесть все строки из текстового файла, наше приложение должно будет сделать следующее:

Iterable<String> lines = new FileLines(f);

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

Классы-утилиты


Так называемые классы-утилиты на самом деле являются не классами, а лишь набором статических методов, используемых другими классами для удобства (они известны также как методы-помощники).. К примеру, класс java.lang.Math — классический образец класса-утилиты. Такие порождения очень популярны в Java, Ruby и, к сожалению, почти во всех современных языках программирования. Почему они не являются классами? Потому что из них нельзя инстанцировать объекты. В разделе 1.1 мы обсудили разницу между объектом и классом и пришли к тому, что класс — это фабрика объектов. Класс-утилита не является фабрикой, например:

class Math {
  private Math() {
     // намеренно пустой
  }

  public static int max(int a, int b) {
      if (a < b) {
        return b;
      }
        return a;
  }
}

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

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

Паттерн «Синглтон»


Паттерн «Синглтон» — популярный прием, претендующий на то, чтобы стать заменой статических методов. Действительно, в классе будет только один статический метод, а синглтон при этом будет выглядеть почти как настоящий объект. Однако он им не является:

class Math {
  private static Math INSTANCE = new Math();
  private Math() {}
  public static Math getInstance() {
      return Math.INSTANCE;
  }
  public int max(int a, int b) {
      if (a < b) {
        return b;
      }
      return a;
  }
}

Выше приведен типичный пример синглтона. Существует единственный экземпляр класса Math, который называется INSTANCE.. Каждый может получить к нему доступ, просто вызвав getInstance(). Конструктор сделан приватным, чтобы предотвратить прямое инстанцирование объектов данного класса. Единственный способ получить доступ к INSTANCE — вызвать getInstance().

«Синглтон» известен как паттерн проектирования, но в действительности это ужасный антипаттерн. Есть масса причин того, почему это плохой прием программирования. Я приведу лишь некоторые из них, касающиеся статических методов. Было бы, конечно, проще, если бы мы сначала обсудили то, чем синглтон отличается от класса-утилиты, о котором мы только что говорили. Вот как выглядел бы класс-утилита Math, который делает то же, что и приведенный ранее синглтон:

class Math {
  private Math() {}
  public static int max(int a, int b) {
      if (a < b) {
        return b;
      }
      return a;
  }
}

Вот так будет использоваться метод max():

Math.max(5, 9); // класс-утилита
Math.getInstance().max(5, 9); // синглтон

В чем разница? Выглядит, будто вторая строка просто длиннее, а делает то же самое. Зачем было изобретать синглтон, если у нас уже были статические методы и классы-утилиты? Я часто задаю этот вопрос на собеседованиях с Java-программистами. Первое, что я обычно слышу в ответ: «Синглтон позволяет инкапсулировать состояние». Например:

class User {
  private static User INSTANCE = new User();
  private String name;
  private User() {}
  public static User getInstance() {
      return User.INSTANCE;
  }
  public String getName() {
      return this.name;
  }
  public String setName(String txt) {
      this.name = txt;
  }
}

Это ужасный фрагмент кода, но я вынужден привести его в качестве иллюстрации к своим доводам. Этот синглтон значит буквально «пользователь, в данный момент применяющий систему». Этот подход очень популярен во многих веб-фреймворках, где существуют синглтоны пользователей, веб-сессий и т. п.. Итак, типичный ответ на мой вопрос о разнице между синглтоном и классом-утилитой: «Синглтон инкапсулирует состояние».. Но это неверный ответ. Цель синглтона не в хранении состояния.. Вот класс-утилита, который делает то же, что и упомянутый ранее синглтон:

class User {
  private static String name;
  private User() {}
  public static String getName() {
      return User.name;
  }
  public static String setName(String txt) {
      User.name = txt;
  }
}

Этот класс-утилита хранит состояние, и между ним и упомянутым синглтоном нет никакой разницы. Итак, в чем же проблема? И каков же правильный ответ? Единственно верный ответ состоит в том, что синглтон — это зависимость, которую можно разорвать, а класс-утилита — жестко запрограммированная тесная связь, которую разорвать невозможно. Иными словами, преимущество синглтонов в том, что в них можно добавить метод setInstance() наряду с getInstance(). Этот ответ верен, хотя я слышу его нечасто. Допустим, я использую синглтон следующим образом:

Math.getInstance().max(5, 9);

Мой код сцеплен с классом Math. Иными словами, класс Math — зависимость, на которую я полагаюсь. Без этого класса код не будет работать, и для его тестирования мне придется оставлять класс Math доступным, чтобы иметь возможность выполнять запросы. В случае с данным конкретным классом эта проблема невелика, поскольку он весьма примитивен. Однако если синглтон большой, то мне, возможно, придется применять мокинг или заменять его чем-то, что лучше подходит для тестирования. Проще говоря, я не хочу, чтобы метод Math.max() выполнялся во время работы юнит-теста. Как мне это сделать? А вот как:

Math math = new FakeMath();
Math.setInstance(math);

Паттерн «Синглтон» обеспечивает возможность заменить инкапсулированный статический объект, что позволяет тестировать объект. Правда в следующем: синглтон намного лучше класса-утилиты только потому, что позволяет заменить инкапсулируемый объект. В классе-утилите нет объекта — мы не можем ничего изменить. Класс-утилита — неразрывная жестко запрограммированная зависимость — чистейшее зло в ООП.

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

#include <stdio>
int line = 0;
void echo(char* text) {
   printf("[%d] %s\n", ++line, text);
}

Всякий раз когда мы вызываем echo(), инкрементируется глобальная переменная line. Чисто технически переменная line видна из каждой функции и каждой строки кода в *.с-файле.. Она видна глобально. Хвала разработчикам Java за то, что они не скопировали эту возможность из языка С. В Java, как и в Ruby и во многих других недо-ООП-языках, глобальные переменные запрещены. Почему? Потому что они не имеют никакого отношения к ООП. Это чисто процедурная возможность. Глобальные переменные однозначно нарушают принцип инкапсуляции. Они просто ужасны. Надеюсь, мне больше не придется объяснять это в данной книге. Мне кажется очевидным, что глобальные переменные настолько же плохи, насколько плох оператор GOTO.

Однако, несмотря на все доводы против глобальных переменных, кто-то нашел способ привнести их в Java, создав тем самым паттерн «Синглтон».. Это попросту издевательство над принципами объектно-ориентированного проектирования, ставшее возможным благодаря наличию статических методов. Эти методы технически позволяют такое жульничество.
Никогда не используйте синглтоны. Даже не думайте.

«Чем их заменить? — спросите вы. — Если нам нужно, чтобы нечто было доступно многим классам в рамках всего программного продукта, что мы можем сделать?» Скажем, нам очень надо, чтобы большинство классов знало о том, какой пользователь в данный момент вошел в систему. У нас нет классов-утилит и синглтонов. Что у нас есть? Инкапсуляция!

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

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

Но никогда не применяйте синглтон. Для этого правила нет исключений.

Функциональное программирование


Я часто слышу такой довод: если объекты небольшие и неизменяемые и при этом не задействуются статические методы, то почему бы не использовать функциональное программирование (ФП)? Действительно, если объекты элегантны настолько, насколько рекомендуется в данной книге, то они весьма похожи на функции.. Итак, зачем нам нужны объекты? Почему бы просто не использовать Lisp, Clojure или Haskell вместо Java или C++?

Вот класс, представляющий алгоритм определения большего из двух чисел:

class Max implements Number {
  private final int a;
  private final int b;
  public Max(int left, int right) {
      this.a = left;
      this.b = right;
  }
  @Override
  public int intValue() {
      return this.a > this.b ? this.a : this.b;
  }
}

Вот как мы должны его применять:

Number x = new Max(5, 9);

А вот как мы задали бы в Lisp функцию, которая делала бы то же самое:

(defn max
     (a b)
     (if (> a b) a b))

Итак, зачем же использовать объекты? Код на Lisp намного короче.

ООП более выразительно и имеет большие возможности, поскольку оперирует объектами и методами, тогда как ФП — лишь функциями. В некоторых ФП-языках тоже есть объекты, но я бы назвал их ООП-языками с ФП-возможностями, а не наоборот. Я также считаю, что лямбда-выражения в Java, будучи подвижкой в сторону ФП, делают Java более рыхлым, сбивая нас с истинного ООП-пути. ФП — отличная парадигма, но ООП лучше. Особенно при правильном применении.

Мне кажется, в идеальном ООП-языке у нас были бы классы с функциями внутри. Не методы-микропроцедуры, как сейчас в Java, а настоящие (в смысле функциональной парадигмы) функции, имеющие единственную точку выхода. Это было бы идеально.

Компонуемые декораторы


Кажется, этот термин я придумал. Компонуемые декораторы — просто объекты-обертки над другими объектами. Они являются декораторами — известным паттерном объектно-ориентированного проектирования, — но становятся компонуемыми, когда мы объединяем их в многослойные структуры, к примеру:

names = new Sorted(
    new Unique(
        new Capitalized(
            new Replaced(
                new FileNames(
                    new Directory(
                        "/var/users/*.xml"
                    )
                 ),
                 "([^.]+)\\.xml",
                 "$1"
             )
         )
     )
);

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

Считаете ли вы этот код чистым и простым для понимания? Надеюсь, что да, с учетом всего того, о чем мы с вами говорили ранее.

Это то, что я называю компонуемыми декораторами. Классы Directory, FileNames, Replaced, Capitalized, Unique и Sorted — декораторы, поскольку их поведение полностью обусловлено инкапсулируемыми ими объектами. Они добавляют некоторое поведение инкапсулированным объектам. Их состояние совпадает с состоянием инкапсулированных объектов.

Иногда они предоставляют тот же интерфейс, что и инкапсулируемые ими объекты (но это не обязательно). К примеру, Unique — это Iterable, также инкапсулирующий итератор по строкам. Однако FileNames — это итератор по строкам, инкапсулирующий итератор по файлам.
Большая часть кода в чистом объектно-ориентированном ПО должна быть похожа на приведенный ранее. Мы должны композировать декораторы друг в друга, и даже чуть более того.. В какой-то момент мы вызываем app.run(), и вся пирамида объектов начинает реагировать. В коде совсем не должно быть процедурных операторов вроде if, for, switch и while. Звучит как утопия, но это не утопия.

Оператор if предоставляется языком Java и используется нами в процедурном ключе, оператор за оператором. Почему бы не создать на замену Java язык, в котором был бы класс If? Тогда вместо следующего процедурного кода:

float rate;
if (client.age() > 65){
  rate = 2.5;
}
else {
   rate = 3.0;
}

мы бы писали такой объектно-ориентированный код:

float rate = new If(
  client.age() > 65,
  2.5, 3.0
);

А как насчет такого?

float rate = new If(
  new Greater(client.age(), 65),
  2.5, 3.0
);

И наконец, последнее улучшение:

float rate = new If(
  new GreaterThan(
     new AgeOf(client),
     65
  ),
  2.5, 3.0
);

Так выглядит чистый объектно-ориентированный и декларативный код. Он не делает ничего — просто объявляет, чем является rate.

С моей точки зрения, в чистом ООП не нужны операторы, унаследованные из процедурных языков вроде С. Не нужны if, for, switch и while. Нам нужны классы If, For, Switch и While. Чувствуете разницу?

Мы еще не дошли до таких языков, но рано или поздно обязательно дойдем. Я в этом уверен. А пока что старайтесь держаться подальше от длинных методов и сложных процедур. Проектируйте микроклассы так, чтобы они были компонуемыми.. Убедитесь, что они могут повторно использоваться как элементы композиции в более крупных объектах.
Я бы сказал, что объектно-ориентированное программирование — это сборка крупных объектов из более мелких.

Какое отношение это имеет к статическим методам? Я уверен, вы уже поняли: статические методы не могут быть скомпонованы никоим образом. Они делают невозможным все то, о чем я говорил и что показывал ранее. Мы не можем собирать крупные объекты из более мелких с применением статических методов. Эти методы противоречат идее компоновки. Вот вам еще одна причина того, что статические методы — чистое зло.

В заключение: нигде и никогда не задействуйте в своем коде ключевое слово static — этим вы окажете себе и тем, кто будет использовать ваш код, большую услугу.

» Более подробно с книгой можно ознакомиться на сайте издательства

Для Хаброжителей скидка 20% по купону — Java
Tags:
Hubs:
+3
Comments118

Articles

Information

Website
piter.com
Registered
Founded
Employees
201–500 employees
Location
Россия