agorlov.github.io

My homepage

View My GitHub Profile

Перевод поста Егора Бугаенко Seven Virtues of a Good Object от 20.11.2014

Оригинал: https://www.yegor256.com/2014/11/20/seven-virtues-of-good-object.html

Семь качеств хорошего объекта

Мартин Фаулер сказал:

Библиотека — это набор функций, которые вы можете вызывать; теперь, обычно оформляется в виде класса.

Функции, организованные в классы? При всем уважении, но это не правильно. Это типичное заблуждение о классе в объектно-ориентированном программировании. Классы это не органайзеры для функций. И объекты это не структуры данных.

Так каков же “правильный” объект? А какой неправильный? И в чем разница?

Не смотря на то, что данная тема является предметом дискуссий, она очень важна. Пока мы не понимаем, что такое объект, как мы можем разрабатывать объектно-ориентированные программы? Хотя, благодаря Java, Ruby и другим, мы можем. Но насколько хороши они будут? К сожалению, нет точной науки, и существует большое количество мнений. Я приведу мой список качеств хорошего объекта.

Класс vs. Объект

Перед тем, как начать разговор об объектах, давайте определим что такое класс. Это место, где рождаются объекты (создаются экземпляры). Основное предназначение класса – создавать новые объекты и уничтожать их, когда они больше не используются. Класс знает какими должны быть его дети и как они должны себя вести. Другими словами, он знает какие контракты они должны соблюдать.

Why Getters-and-Setters Is An Anti-Pattern? (webinar #4); 1 July 2015.

Иногда я слышу, классы это “шаблоны объектов” (например, в Википедии так говорится). Данное определение не корректно, потому что оно делает класс пассивным. Предполагается, что кто-то возьмет шаблон и построит объект используя его. Говоря технический, это может быть и так, но концептуально не верно. Только класс и его дети должны участвовать: объект просит класс создать другой объект и класс создает его; и только. На Ruby эта концепция выражена гораздо лучше чем в Java и C++:

photo = File.new('/tmp/photo.png')

Объект photo создается классом File (new это входная точка класса). Как только создали, объект начинает действовать самостоятельно. Ему не нужно знать, кем он создан и сколько в его классе братьев и сестер. Да, я считаю, что рефлексия это ужасная идея, я напишу подробнее об этом в одном из следующих постов :) Теперь, давайте поговорим об объектах и их лучших и худших сторонах.

1. Он существует в реальной жизни

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

Сотрудник, отдел, HTTP-запрос, таблица в MySQL, строка в файле или файл сам по себе правильные объекты, потому что они существуют в реальной жизни, даже когда наша программа не работает. Чтобы быть точнее, объект это представитель реального существа. Это прокси этого реального существа (за ним могут стоять другие объекты). Без этого существа, очевидно, нет и объекта.

photo = File.new('/tmp/photo.png')
puts photo.width()

В этом примере, я прошу File создать новый объект photo, который будет представлять реальный файл на диске. Вы можете сказать, что файл это что-то не настоящее и существует только когда компьютер включен. Я соглашусь и уточню определение “реальной жизни”: Это все, что существует за рамками программы в которой живет объект. Файл на диске находится за рамками нашей программы; поэтому совершенно правильно создать его представителя в программе.

Контроллер, парсер, фильтр, валидатор, сервис локатор, синглтон или фабрика – примеры плохих объектов (да, большинство GoF шаблонов являются анти-патернами!). Они не существуют в реальной жизни, за рамками вашей программы. Они придуманы только для того чтобы связать вместе другие объекты. Они искуственные и поддельные существа. Они ни кого не представляют. Серьезно, кого представляет XML-парсер? Никого.

Некоторые из них могут стать хорошими если их переименовать; другие никогда не смогут оправдать свое существование. Например, этот XML parser, может быть переименован в “parseable XML” и начать представлять XML-документ, который существует за пределами нашей области.

Всегда задавайте себе вопрос, “Какая реальная сущность стоит за моим объектом?” Если вы не можете ответить, начинайте думать о рефакторинге.

2. Он работает по контракту

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

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

Но постойте, важно ли для меня откуда будет взят контент, будет ли это файл на диске или HTTP-запрос или может быть документ из Dropbox? Мне нет. Все что мне нужно, это чтобы какой-то объект дал мне массив байт PNG-файла. Мой контракт будет выглядеть так:

interface Binary {
  byte[] read();
}

Теперь, любой объект любого класса (не только DataFile) может работать на меня. Все что он должен делать, это следовать контракту — реализуя интерфейс Binary.

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

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

3. Он уникален

Хороший объект должен всегда инкапсулировать что-то, чтобы быть уникальным. Если инкапсулировать нечего, объект может иметь идентичных колонов, что плохо. Вот пример плохого объекта, у которого могут быть клоны:

class HTTPStatus implements Status {
  private URL page = new URL("http://www.google.com");
  @Override
  public int read() throws IOException {
    return HttpURLConnection.class.cast(
      this.page.openConnection()
    ).getResponseCode();
  }
}

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

first = new HTTPStatus();
second = new HTTPStatus();
assert first.equals(second);

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

4. Он неизменяемый (Immutable)

Хороший объект никогда не меняет свое внутреннее состояние. Помните, объект это представитель реальной сущности, и эта сущность должна оставаться неизменной весь свой жизненный цикл объекта. Другими словами объект не должен изменять тому, кого представляет. Он не должен менять владельцев, никогда. :)

Имейте в виду, что неизменяемость не означает, что все методы всегда возвращают одинаковые значения.

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

@Immutable
final class HTTPStatus implements Status {
  private URL page;
  public HTTPStatus(URL url) {
    this.page = url;
  }
  @Override
  public int read() throws IOException {
    return HttpURLConnection.class.cast(
      this.page.openConnection()
    ).getResponseCode();
  }
}

Несмотря на то, что метод read() может возвращать различные значения, объект является неизменяемым. Он указывает на определенную веб-страницу и никогда не будет указывать куда-то еще. Он не изменит свое внутреннее состояние, и никогда не предаст URL который он представляет.

Почему неизменность это благо? Эта статья объясняет в деталях: Объекты должны быть неизменными.

В двух словах, неизменяемые объекты лучше, потому что:

И конечно, хороший объект не имеет сеттеров, которые могут изменить его состояние и спровоцировать его поменять URL. Другими словами, добавлять метод setURL() в HTTPStatus будет грубой ошибкой.

Помимо прочего, неизменяемые объекты повысят качество дизайна, добавят такие качества: стыкуемость (cohesive), единство (solid) и добавят легкость восприятия кода, об этом почитайте статью: How Immutability Helps.

5. Он не содержит ничего статического

Статический метод реализует поведение класса, а не объекта. Предположим у нас есть класс File, и метод size() у его экземпляров:

final class File implements Measurable {
  @Override
  public int size() {
    // calculate the size of the file and return
  }
}

Пока все хорошо; метод size() тут потому, что есть контракт Measurable и каждый объект класса File может измерить свой размер. Ошибкой будет спроектировать данный класс со статическим методом (такой дизайн известен как класс-утилита и очень популярен в Java, Ruby и почти всех ООП языках):

// УЖАСНЫЙ ДИЗАЙН, НЕ ДЕЛАЙТЕ ТАК!!!
class File {
  public static int size(String file) {
    // calculate the size of the file and return
  }
}

Такой дизайн идет вразрез объектной парадигме. Почему? Потому, что статические методы превращают объектно-ориентированное программирование в “классо-ориентированное” программирование. Этот метод size(), выставляет поведение класса, а не своего объекта. Ну и что в этом плохого, вы спросите? Почему не использовать и объекты и классы как основу нашего кода? И почему и те и другие не могут иметь методы и свойства?

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

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

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

Превое, их невозможно подменить (mock) (Ну, вы можете использовать PowerMock, но это будет самым ужасным решением, которое вы могли бы сделать в проекте Java… Я сделал это однажды, несколько лет назад).

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

Всякий раз, когда вы видите public static метод, сразу же переписывайте. Я даже не хочу упоминать, насколько ужасны статические (или глобальные) переменные. Я думаю это очевидно.

6. Его имя это не должность

Имя объекта должно говорить нам, что это за объект, а не то что он делает, как мы называем объекты в реальной жизни: книга вместо “page agreagator”, чашка вместо “water holder”, футболка вместо “одеватель тела”. Конечно есть исключения, такие как printer (печатальщик) или computer (вычислитель), но они были изобретены недавно и теми, кто не читал эту статью. :)

Например, эти имена говорят кому они принадлежат: яблоко (an apple), файл, серия HTTP-запросов, сокет, XML-документ, список пользователей, регулярное выражение, целое число, PostgreSQL таблица или Джеффри Лебовски. Правильно названные объекты всегда можно нарисовать как небольшую картинку. Даже регулярное выражение.

Напротив, вот пример имен, которые говорят что их носители делают: a file reader (читатель файла), a text parser (парсер текста), a URL validator (проверяль URL-а), an XML printer, a service locator (обнаружитель услуги), a singleton (одиночка), a script runner (запускатель скрипта), или Java-прогаммист. Вы можете нарисовать кого нибудь из них? Нет, не можете. Эти имена не подходят для хороших объектов. Плохие имена всегда ведут к плохому дизайну.

В общем, избегайте имен которые заканчиваются на “-er” большинство из них плохие.

Вы спросите, - “Какова же альтернатива для FileReader?”. Какое имя будет лучше? Давайте посмотрим. У нас уже есть File который представляет реальный файл на диске. Этот представитель не достаточно функционален для нас, потому что он не знает, как считывать содержимое файла. Нам нужен представитель, обладающий данной возможностью. Как же мы его назовем? Помните, имя должно говорить кто он, а не что он делает. Кто он? Он, это файл у кторого есть данные; не просто файл, как File, а более сложный, с данными. Итак, как насчет: FileWithData или просто DataFile?

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

Подробнее об этом в Don’t Create Objects That End With -ER.

7. Его класс либо Final, либо Abstract

Хороший объект происходит от final или от абстрактного класса. Final это такой класс, который нельзя расширить через наследование. Абстрактный это такой класс, который не имеет экземпляров. Проще говоря, класс должен сказать, “Вы никогда не сломаете меня; я черный ящик для вас”, либо “Я уже сломан; сначала поправьте меня и потом пользуйтесь.”

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

Предположим у меня есть HTTPStatus (см. выше), и он мне не нравится. Ну ладно, нравится, но он не достаточно функционален для меня. Я хочу, чтобы он бросал исключение если HTTP-статус больше 400. Я хочу, чтобы его метод, read(), делал больше чем делает сейчас. Традиционный способ был бы расширить класс и переписать метод:

class OnlyValidStatus extends HTTPStatus {
  public OnlyValidStatus(URL url) {
    super(url);
  }
  @Override
  public int read() throws IOException {
    int code = super.read();
    if (code >= 400) {
      throw new RuntimeException("Unsuccessful HTTP code");
    }
    return code;
  }
}

В чем тут ошибка? Это совсем неправильно, потому что мы рискуем нарушить логику всего родительского класса, переопределив один из его методов. Помните, единожды переопределив метод read() в дочернем классе, все методы из родителя начинают использовать его новую версию. Мы буквально вводим новый “кусок реализации” прямо в класс. С философской точки зрения, это вмешательство (нарушение).

С другой стороны, для расширения final-класса, вы должны принимать его как черный ящик и декорировать его вашей собственной реализацией (паттерн Декоратор):

final class OnlyValidStatus implements Status {
  private final Status origin;
  public OnlyValidStatus(Status status) {
    this.origin = status;
  }
  @Override
  public int read() throws IOException {
    int code = this.origin.read();
    if (code >= 400) {
      throw new RuntimeException("Unsuccessful HTTP code");
    }
    return code;
  }
}

Убедитесь, что этот класс реализует тотже интерфейс, что и основной: Status. Экземпляр HTTPStatus передается в него через конструктор и инкапсулируется. Затем, каждый вызов будет перехватываться и обрабатываться другим способом, если это требуется. При таком дизайне, мы рассматриваем оригинальный объект как черный ящик и никогда не вмешиваемся в его внутренние дела.

Если вы не используете ключевое слово final, любой (включая вас) будет иметь возможность расширить класс и .. нарушить его :( Итак класс без final - плохой дизайн.

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

abstract class ValidatedHTTPStatus implements Status {
  @Override
  public final int read() throws IOException {
    int code = this.origin.read();
    if (!this.isValid()) {
      throw new RuntimeException("Unsuccessful HTTP code");
    }
    return code;
  }
  protected abstract boolean isValid();
}

Как вы видите, класс не знает как именно проверять HTTP-код, и он ожидает, что мы дополним эту логику через наследование и через переопределение метода isValid(). Мы не сможем вмешиваться в него наследованием, так как он защитил все остальные методы через final (обратите внимание на модификаторы его методов). Таким образом, класс готов к нашему нападению и защищен от него.

Подводя итог, ваш класс должен быть либо final, либо abstract (и никаким другим).

Дополнение (Апрель 2017): Если вы также согласны, что наследование реализации (implementation inheritance) это зло, все ваши классы должны быть final.