image Привет, Хаброжители!

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

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

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


3.2. ЗАПРАШИВАЙТЕ ТОЛЬКО ДАННЫЕ, КОТОРЫЕ ИМЕЮТ СМЫСЛ


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

image

Всегда следите, чтобы клиент предоставлял только осмысленные значения. Все, что считается бессмысленным, теоретически можно также назвать инвариантом предметной области. Но в данном случае инвариантом будет то, что «широта— это значение между –90 и 90 включительно, а долгота — значение между –180 и 180 включительно».

При проектировании объектов ориентируйтесь на инварианты предметной области. Собирайте больше информации об инвариантах и используйте их при создании модульных тестов. Например, ниже представлен листинг, в котором используется утилита expectException(), описанная в разделе 1.10.

image

Чтобы тесты проходили успешно, выдавайте исключение в конструкторе, как только любой аргумент будет неверным, см. листинг 3.5.

Листинг 3.5. Выдача исключений при недопустимых аргументах конструктора

final class Coordinates
{
    // ... 

    public function __construct(float latitude, float longitude)
    {

        if (latitude > 90 || latitude < -90) {
            throw new InvalidArgumentException(
                'Широта должна быть в пределах от -90 до 90'
            );
        }
        this.latitude = latitude;

        if (longitude > 180 || longitude < -180) {
            throw new InvalidArgumentException(
                'Долгота должна быть в пределах от -180 до 180'
            );
        }
        this.longitude = longitude;
    }
}

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

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

Листинг 3.6. Класс ReservationRequest

final class ReservationRequest
{

    public function __construct(
        int numberOfRooms,
        int numberOfAdults,
        int numberOfChildren
    ) {
        // ...
    }
}

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

Поэтому получается, что numberOfRooms (число номеров) и numberOfAdults (число взрослых) связаны и должны проверяться на допустимость вместе. Нужно убедиться, что конструктор принимает оба значения и применяет соответствующие правила, как в листинге ниже.

Листинг 3.7. Проверка аргументов конструктора на допустимость

final class ReservationRequest
{

    public function __construct(
        int numberOfRooms,
        int numberOfAdults,
        int numberOfChildren
    ) {

        if (numberOfRooms > numberOfAdults + numberOfChildren) {
            throw new InvalidArgumentException(
                'Количество номеров не должно превышать количества гостей'
            );
        } 

        if (numberOfAdults < 1) {
            throw new InvalidArgumentException(
                'numberOfAdults должен иметь значение 1 или больше'
            );
        } 

        if (numberOfChildren < 0) {
            throw new InvalidArgumentException(
                'numberOfChildren должен иметь значение 0 или больше'
            );
        }
    }
}

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

Листинг 3.8. Класс Deal

final class Deal
{

    public function __construct(
        int totalAmount,
        int amountToFirstParty,
        int amountToSecondParty
    ) {
        // ...
    }
}

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

Листинг 3.9. Deal проверяет сумму средств сторон

final class Deal
{

    public function __construct(
        int totalAmount,
        int amountToFirstParty,
        int amountToSecondParty
    ) {
        // ... 

        if (amountToFirstParty + amountToSecondParty
            != totalAmount) {
            throw new InvalidArgumentException(/* ... */);
        }
    }
}

Как вы, возможно, заметили, это правило можно реализовать намного эффективнее. Общую сумму саму по себе можно и не сообщать, если клиент предоставляет положительные числа для amountToFirstParty и amountToSecondParty. Объект Deal может сам узнать общую сумму сделки, складывая эти значения. Консолидированной проверки аргументов конструктора не требуется.

Листинг 3.10. Удаление лишних аргументов конструктора

final class Deal
{

    private int amountToFirstParty;
    private int amountToSecondParty; 

    public function __construct(
        int amountToFirstParty,
        int amountToSecondParty
    ) {

        if (amountToFirstParty <= 0) {
            throw new InvalidArgumentException(/* ... */);
        }
        this.amountToFirstParty = amountToFirstParty;        

        if (amountToSecondParty <= 0) {
            throw new InvalidArgumentException(/* ... */);
        }
        this.amountToSecondParty = amountToSecondParty;
    } 

    public function totalAmount(): int
    {
        return this.amountToFirstParty
            + this.amountToSecondParty;
    }
}

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

image

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

image

Эти статические методы называют именованными конструкторами, и мы рассмотрим их подробнее в разделе 3.9.

УПРАЖНЕНИЕ

2 PriceRange представляет минимальную и максимальную цены в центах, которые покупатель может заплатить за некий объект:

final class PriceRange
{

    public function __construct(int minimumPrice, int maximumPrice)
    {
        this.minimumPrice = minimumPrice;
        this.maximumPrice = maximumPrice;
    }
}

Конструктор здесь принимает любое значение int для обоих аргументов. Улучшите конструктор и сделайте так, чтобы он выдавал ошибку, если эти значения не имеют смысла.

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

3.3. НЕ ИСПОЛЬЗУЙТЕ СОБСТВЕННЫЕ КЛАССЫ ИСКЛЮЧЕНИЙ ПРИ ПРОВЕРКЕ НЕДОПУСТИМЫХ АРГУМЕНТОВ


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

Листинг 3.13. SpecificEsception можно получать и обрабатывать

final class SpecificException extends InvalidArgumentException
{
} 

try {
    // попытка создать объект
} catch (SpecificException exception) {
    // обработка специфичной задачи определенным образом
}

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

В то же время в случае с RuntimeExceptions зачастую имеет смысл использовать собственные исключения, так как после них есть возможность восстановиться или преобразовать их в сообщения об ошибках, понятные пользователю. Мы подробнее рассмотрим исключения времени исполнения и то, как их создавать, в разделе 5.2.

3.4. ПРОВЕРЯЙТЕ СПЕЦИФИЧЕСКИЕ ИСКЛЮЧЕНИЯ ДЛЯ НЕДОПУСТИМЫХ АРГУМЕНТОВ, АНАЛИЗИРУЯ СООБЩЕНИЯ ИСКЛЮЧЕНИЙ


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

Листинг 3.14. Класс Coordinates

final class Coordinates
{
    // ... 

    public function __construct(float latitude, float longitude)
    {
        if (latitude > 90 || latitude < -90) {
            throw new InvalidArgumentException(
                'Широта должна иметь значение от -90 до 90'
            );
        }
        this.latitude = latitude; 

        if (longitude > 180 || longitude < -180) {
            throw new InvalidArgumentException(
                'Долгота должна иметь значение от -180 до 180'
            );
        }
        this.longitude = longitude;
    }
}

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

Листинг 3.15. Тестирование инвариантов предметной области в классе Coordinates

// Широта не может быть больше 90.0
expectException(
    InvalidArgumentException.className,
    function() {
        new Coordinates(90.1, 0.0);
    }
); 

// Широта не может быть меньше -90.0
expectException(
    InvalidArgumentException.className,
    function() {
        new Coordinates(-90.1, 0.0);
    }
);

// Долгота не может быть больше 180.0
expectException(
    InvalidArgumentException.className,
    function() {
        new Coordinates(-90.1, 180.1);
    }
);

В последнем тесте из конструктора выдается исключение InvalidArgumentException, но это не то, что мы ожидали. Так как в этом случае используется недопустимое значение для широты из предыдущего теста, при попытке создания объекта Coordinates будет выдаваться исключение, которое сообщит, что «широта должна иметь значение от –90.0 до 90.0». Но тест должен проверять, что код отклонит недопустимые значения долготы. Это значит, что долгота не будет проверяться в этом тестовом сценарии, даже если все тесты пройдут успешно.

Чтобы предотвратить такого рода ошибки, старайтесь проверять, что выдаваемые в модульном тесте исключения соответствуют ожидаемым. Удобнее всего проверять, что сообщение об исключении содержит определенные слова.

image

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

3.5. СОЗДАВАЙТЕ НОВЫЕ ОБЪЕКТЫ, ЧТОБЫ ИЗБЕЖАТЬ МНОГОКРАТНОЙ ПРОВЕРКИ ИНВАРИАНТОВ ПРЕДМЕТНОЙ ОБЛАСТИ


На практике вы обнаружите, что одна и та же логика проверки повторяется в одном классе или даже в разных классах. Например, взгляните на следующий класс User, в котором при помощи функции из стандартной библиотеки многократно проверяется email-адрес.

image

И хотя можно просто передать логику проверки email-адреса в отдельный метод, более грамотное решение — создать отдельный класс объектов, представляющий собой допустимый email-адрес. Так как мы ожидаем, что все объекты создаются допустимыми, уберем часть «valid» из имени класса и реализуем решение следующим образом.

Листинг 3.18. Класс EmailAddress

final class EmailAddress
{

    private string emailAddress; 

    public function __construct(string emailAddress)
    {
        if (!is_valid_email_address(emailAddress)) {
            throw new InvalidArgumentException(
                'Недопустимый email'
            );
        }
        this.emailAddress = emailAddress;
    }
}

Когда вам встретится объект EmailAddress, вы всегда будете знать, что значение email-адреса для него уже проверено:

final class User
{

    private EmailAddress emailAddress;

     public function __construct(EmailAddress emailAddress)
    {
        this.emailAddress = emailAddress;
    } 

    // ... 

    public function changeEmailAddress(EmailAddress emailAddress): void
    {
        this.emailAddress = emailAddress;
    }
}

Оборачивание значений в новые объекты под названием объекты-значения полезно не только, чтобы избегать повторения логических конструкций. Как только вы заметите, что метод принимает примитивные значения (string, int и т. д.), стоит задуматься о том, чтобы создать для них класс. Чтобы принять решение, ответьте на вопрос: будут ли здесь приемлемы значения string, int, и т. д.? Если ответ — нет, создавайте отдельный класс.

Объекты-значения сами по себе стоит рассматривать как типы наравне со string, int и т. п. Создавая новые объекты для представления понятий, вы расширяете систему типов. Компилятор языка или среда исполнения могут проводить проверку соответствия типов, чтобы только правильные типы использовались при передаче значений через аргументы методов и в возвращаемых значениях.

УПРАЖНЕНИЕ

3 Код страны может быть представлен строкой из двух символов, но не каждая такая строка может быть кодом страны. Создайте класс объекта-значения, который будет представлять код страны. Будем считать, что список известных кодов страны состоит из NL и GB.
Об авторе
Маттиас Нобак — профессиональный веб-разработчик (с 2003 года). Он живет в городе Зейст в Нидерландах со своей второй половинкой и детьми: сыном и дочерью.

Маттиас — основатель собственной компании в области веб-разработки, обучения и консультирования под названием Noback’s Office. Он вплотную занимается бэкенд-разработкой и программной архитектурой и всегда ищет возможности для улучшения методов проектирования ПО.

С 2011 года ведет блог о программировании на matthiasnoback.nl. Маттиас — автор и других книг: «A Year with Symfony» (Leanpub, 2013), «Microservices for Everyone» (Leanpub, 2017) и «Principles of Package Design»1 (Apress, 2018). Связаться с Маттиасом можно по электронной почте (info@matthiasnoback.nl) или в Twitter (@matthiasnoback).

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

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Для Хаброжителей скидка 25% по купону — Объекты

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


  1. Anarchist
    00.00.0000 00:00

    Не используйте исключения, если не хотите, чтобы исполнение кода не закончилось совсем. Если используете try/catch, вы добавляете неявное значение, возвращаемое из функции. По сути сигнатура становится не то, что бессмысленной, но не описанной до конца. Есть масса вариантов возвращения: Option/Optional, Result/Try, Either. Думайте в разных парадигмах, а не только в ООП. ООП дивно хороша, но текущие реализации далеки от идеала. Мощь ООП хорошо раскрывается в комбинации с ФП.


    1. Filex
      00.00.0000 00:00

      А есть примеры best practice? Может ссылка на статью?


      1. rudinandrey
        00.00.0000 00:00

        мне очень нравится вариант, который используется в Golang'е, т.е. value, err := someFunc(); дальше проверяем если err != nil то что-то делаем.

        но для того же PHP например приходится либо в массиве это возвращать return [$result, $err];

        и потом проверять if ($result[1] != null) {...} либо в класс какой то оборачивать, можно ассоциативным массивом, самый простой вариант. Но по сути это просто делаем одно и тоже разными способами. Кому то нравится так, кому то так. Нет ничего в этой жизни не правильного, все правильное и так и эдак!


    1. kacetal
      00.00.0000 00:00

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

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


    1. odisseylm
      00.00.0000 00:00

      Да будет ХолиВар!
      1) Книга относится к ООП, и рекомендации тоже.
      2) >> Не используйте исключения, если не хотите, чтобы исполнение кода не закончилось совсем
      В тех примерах которые были приведены, это будет хорошей практикой для императивного кода ООП (когда исключение словится где-то наверху и просто залогируется, а запрос/транзакция сама отменится/зафейлится)
      3) >> Option/Optional, Result/Try, Either
      Эти ФП подходы хороши в случае организации кода в виде цепочек. Использование их для классического императивного кода только усложняет код, особенно если базовые библиотеки кидают исключения. В случае Java: streams (как часть ФП) нормально перепрокинут их наверх, а React frameworks (RxJava, so on) нормально обернут Exceptions в реактивные аналоги Try (с реактами иногда проще использовать их Try сразу без Exception, но это уже по ситуации)
      4) >> Мощь ООП хорошо раскрывается в комбинации с ФП.
      В тех местах где язык/framework это предполагает/допускает (иначе имеем спагети-код с дикой смесью различных подходов)

      Хочется ФП - используйте ФП язык (иначе имеем что-то типа Scala - у нас тут и ФП, и ООП, но ты ("туда не ходи") ООП не юзай, а то по рукам получишь :-) )