Структуры с процедурами или объекты?

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

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

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

Итак, поехали. Каждый язык программирования своими возможностями и ограничениями определяет способ написания кода. С этого и начнём.

Возможности и ограничения языка

Если вы программируете на Java, где из конструкций есть только class, interface и enum, то вам приходится весь код записывать в классах в виде динамического или статического метода. Просто потому, что там никак нельзя написать отдельную неанонимную функцию без класса.

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

В JavaScript только недавно появилась конструкция class, удобная для программистов, привыкших к классам из других языков. Остальные JS-программисты спокойно создавали объекты на лету без классов или для создания объектов использовали функции.

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

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

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

Для этого стоит разобраться с глобальными способами мышления при написания программ. То есть с парадигмами программирования. Начнём с процедурного программирования и сравним постепенно с функциональным и объектно-ориентированным.

Состояние и процедуры

В программировании для нас важны пользовательские данные (которые мы принимаем, преобразовываем и сохраняем) и операции над ними (те самые алгоритмы и бизнес-логика).

Данные составляют состояние системы, а алгоритмы определяют её поведение.

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

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

Например, в PHP мы может сделать профиль с пользовательскими данными в виде ассоциативного массива:

$profile = [
    'id' => 42,
    'name' => 'Vasya',
    'emails' => [
        'vasya@examlpe.com'
    ]
];

Но вместо его ручного создания мы можем сделать вспомогательню функцию-фабрику и создавать новые экземпляры через неё:

function newProfile(int $id, string $name, string $email): array
{
    return [
       'id' => $id,
       'name' => $name,
       'emails' => [$email]
    ];
}
 
$profile = newProfile(42, 'Vasya', 'vasya@examlpe.com');

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

addEmail(&$profile, 'new@site.com');

И она может принять наш $profile и поменять его поле:

function addEmail(array &$profile, string $email): void
{
    if (!in_array($email, $profile['emails'])) {
        $profile['emails'][] = $email;
    }
}

Мы везде передаём профиль по ссылке как &$profile, чтобы во все процедуры попадал оригинал и они меняли именно его. Иначе при простой передаче по значению массив скопируется и изменения произойдут только в его копии внутри процедуры.

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

Состояние и функции

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

Рассмотрение примеров функционального программирования на PHP может выглядеть весьма странно, но раз наша статья про него мы всё-таки это сделаем.

У нас может быть тот же изначальный профиль, сделанный вручную:

$profile = [
   'id' => 42,
   'name' => 'Vasya',
   'emails' => [
      'vasya@examlpe.com'
   ]
]

или функцией-фабрикой:

$profile = newProfile(42, 'Vasya', 'vasya@examlpe.com');

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

$newProfile = addEmail($oldProfile, 'new@site.com');

Внутри её можем реализовать так:

function addEmail(array $profile, string $email): array
{
    return
        !in_array($email, $profile['emails'])
            ? [
                'id' => $profile['id'],
                'name' => $profile['name'],
                'emails' => [ ...$profile['emails'],  $email ]
            ]
            :  $profile;
}

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

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

Что это нам даёт? Если функция не ходит в базу данных за дополнительной информацией или куда-то ещё за случайными значениями или текущей датой, а использует только переданные в неё значения, то она всегда на одинаковые вызовы будет возвращать одинаковый результат.

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

Вроде всё работает, но пока мы в PHP записываем $profile в виде ассоциативного массива. Но массивы не очень удобны из-за отсутствия автоподстановки и проверки типа в редакторе и из-за наличия возможности допустить опечатку в имени поля. Поэтому для составления структур удобнее бы было перейти… на структуры.

Структуры

В языках C/C++/C# программистам повезло с тем, что для структур данных есть отдельная конструкция struct:

struct ProfileData
{
    int id;
    string name;
    string[] emails;
}

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

В PHP же struct нет. Есть только ассоциативные массивы и классы. Поэтому и приходится всё делать на классах. Например, мы можем сделать класс Profile с публичными полями:

class Profile
{
    public int $id = 0;
    public string $name = '';
    public array $emails = [];
}

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

class Profile
{
    public int $id;
    public string $name;
    public array $emails;
 
    public function __construct(int $id, string $name, array $emails)
    {
        $this->id = $id;
        $this->name = $name;
        $this->emails = $emails;
    }
}

Получилось монструозно. Но в PHP 8.0 по примеру Data-классов (классов для хранения данных) из других языков добавили сокращённый синтаксис, делающий то же самое:

class Profile
{
    public function __construct(
        public string $id,
        public string $name,
        public array $emails
    ) {}
}

Просто прямо в параметрах конструктора объявляем поля, и они автоматически создаются и присваиваются.

В итоге от ассоциативных массивов можем перейти к таким структурам:

$profile = new Profile(
   id: 42,
   name: 'Vasya',
   emails: ['vasya@examlpe.com']
)

Здесь можно порадоваться в PHP 8.0 ещё и за то, что мы можем при вызове new заполнять поля по ключам через именованные аргументы. Без них был риск случайно перепутать значения местами.

Теперь можем переписать остальной код:

function addEmail(Profile $profile, string $email): Profile
{
    return
        !in_array($email, $profile->emails)
            ? new Profile(
                id: $profile->id,
                name: $profile->name,
                emails: [...$profile->emails, $email]
            )
            : $profile
}
 
$newProfile = addEmail($oldProfile, 'new@site.com');

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

Но что же можно сказать теперь про объектно-ориентированную парадигму?

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

Объекты и инвариант

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

Микробиолог-программист Алан Кей когда-то сказал:

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

А потом эту идею многократно дополняли и меняли. Но от Алана осталось:

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

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

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

Основная идея – разбивать сложную программу на отдельные компоненты-объекты, обменивающиеся сообщениями. То есть объектно-ориентированное программирование придумано про объекты и сообщения, а не про классы.

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

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

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

Если рассматривать C++-подобные языки, то там для классификации объектов можно использовать конструкцию class. А как способ обмена сообщениями с некой натяжкой можно рассматривать вызовы методов… Это будет далеко от исходных идей отправки сообщений в Smalltalk, но мы это переживём.

И давайте применим это ООП у себя. Поместим состояние и поведение внутри умного самодостаточного объекта профиля. Чтобы описать то, что будет иметь и уметь каждый профиль, определим класс Profile. Например, мы хотим, чтобы создание и работа объекта выглядели так:

$profile = new Profile(42, 'Vasya', 'vasya@examlpe.com');
 
$profile->addEmail('new@site.com');
$profile->removeEmail('new@site.com');

У нас теперь нет отдельно структуры с общедоступными полями и отдельных процедур или функций. Здесь всё находится внутри объекта. И нам теперь даже не важно, в каком виде всё там внутри хранится.

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

То есть в более полном виде можем реализовать класс примерно так:

use Webmozart\Assert\Assert;
 
class Profile
{
    private int $id;
    private string $name;
    private array $emails;
 
    public function __construct(int $id, string $name, string $email)
    {
        Assert::notEmpty($name);
        Assert::email($email);
 
        $this->id = $id;
        $this->name = $name;
        $this->emails = [$email];
    }
 
    public function addEmail(string $email): void
    {
        Assert::email($email);
 
        if (in_array($email, $this->emails)) {
            throw new DomainException('Email already exists in the profile.');
        }
 
        $this->emails[] = $email;
    }
 
    public function removeEmail(string $email): void
    {
        Assert::email($email);
 
        if (!in_array($email, $this->emails)) {
            throw new DomainException('Email does not exist in the profile.');
        }
 
        if (count($this->emails) === 1) {
            throw new DomainException('Unable to remove the last email.');
        }
 
        unset($this->emails[array_search($email, $this->emails)]);
    }
}

Простые проверки значений мы можем делать удобной библиотекой webmozart/assert, правила которой в случае передачи некорректных значений кидают InvalidArgumentException. Более сложные логические проверки с киданием наших исключений мы можем прописывать вручную.

Теперь профиль – это полноценный «умный» объект. Он позволяет создать себя только с корректными значениями и после создания живёт своей жизнью.

Его можно попросить что-то сделать, вызвав его метод. А метод внутри уже производит всю работу.

Например, метод addEmail меняет email, проверяет формат и следит за уникальностью. Также при желании он может записать историю изменений значения и генерировать события изменения адреса, которые потом можно будет извлечь и опубликовать после сохранения этого профиля в БД.

Аналогично метод removeEmail следит, чтобы у профиля всегда был хоть один электронный адрес.

То есть наш объект хранит своё внутреннее состояние и полностью его контролирует и защищает. Не позволяет создать себя в некорректном состоянии и после создания не даёт себя сломать. То есть он у нас всегда корректен.

Такое постоянство корректности объекта называют инвариантом.

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

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

И именно такую самодостаточность подразумевают в ООП при построением ОО-систем из объектов-клеток, обменивающихся сообщениями. Если мы попробуем в нашем коде как-то организовать такую работу, то получим «то самое ООП».

Непонимание концепции

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

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

Например, с некоторыми ORM для работы с БД они часто пишут код вроде такого для создания записи:

$profile = new Profile();
 
$profile->id = 42;
$profile->name = 'Vasya';
$profile->emails = ['vasya@examlpe.com'];
 
$profile->save();

и вроде такого для её изменения:

$profile = Profile::finfById(42);
 
if (!in_array($email, $profile->emails)) {
    $profile->emails[] = $email;
}
 
$profile->save();

То есть из-за неиспользования конструктора они после вызова new Profile() получают пустой недозаполненный объект. И после этого заполняют все поля поштучно. При этом все манипуляции и проверки производят снаружи.

То есть буквально используют объект просто как примитивную структуру с публичными полями без методов. В конце они вызывают метод save. Но этот метод чисто технический для записи данных в БД. К пользовательскому коду он никак не относится.

Это полностью противоречит сокрытию и инкапсуляции и делает состояние абсолютно беззащитным. Кто угодно снаружи либо может забыть присвоить имя, либо может в $profile->emails присвоить любой мусор.

Именно эта разница определяет отличие паттернов Anemic Model и Rich Model от Мартина Фаулера. В первом случае мы создаём анемичные структуры с публичными полями или геттерами с сеттерами, помещая всю логику в отдельные процедуры (либо пишем прямо в контроллере). А во втором случае как раз пишем «богатые» доменные объекты с логикой внутри.

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

Поведение без состояния

В любом проекте мы для хэширования паролей вдруг можем сделать класс PasswordHasher с методом:

class Hasher
{
    public function hash(string $password): string
    {
        return password_hash($password, PASSWORD_ARGON2I)
    }
}
 
$hasher = new Hasher();
echo $hasher->hash('password');

Здесь у нас просто есть метод, производящий какое-то действие, но нет никаких полей для хранения данных.

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

class Hasher
{
    public static function hash(string $password): string
    {
        return password_hash($password, PASSWORD_ARGON2I)
    }
}
 
echo Hasher::hash('password');

Или даже записать это же как простую функцию:

function hash(string $password): string {
    return password_hash($password, PASSWORD_ARGON2I)
}
 
echo hash('password');

Более того, мы можем оставить её в виде класса, но позволить объект использовать как функцию, заменив метод на магический __invoke:

class Hasher
{
    public function __invoke(string $password): string
    {
        return password_hash($password, PASSWORD_ARGON2I)
    }
}
 
$hash = new Hasher();
echo $hash('password');

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

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

class Hasher
{
    private intost;
 
    public function __construct(intost)
    {
        $this->сost = $сost;
    }
 
    public function hash(string $password): string
    {
        return password_hash($password, PASSWORD_ARGON2I, [
            'memory_cost' => $this->cost,
        ]);
    }
}
 
$hasher1 = new Hasher(4);
echo $hasher1->hash('password');
 
$hasher2 = new Hasher(16);
echo $hasher2->hash('password');

И это вроде получилось объектом с состоянием? И вроде теперь его нельзя заменить функцией?

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

function createHash(int $cost)
{
    return function (string $password) use ($cost): string
    {
        return password_hash($password, PASSWORD_ARGON2I, [
            'memory_cost' => $cost,
        ]);
    }
}

Здесь вызов функции-фабрики createHash возвращает нам анонимную функцию с контекстом, хранящим значение $cost. И потом эту настроенную функцию мы спокойно вызываем:

$hash1 = createHash(4);
echo $hash1('password');
 
$hash2 = createHash(16);
echo $hash2('password');

То есть даже с настройкой $cost наш код остался той же самой функцией.

Является ли наш $hasher объектом? Вроде является, если мы его спрограммировали в виде класса и создаём экземпляры. Но до полноценного объекта как $profile он не дотягивает, так как не имеет своего личного состояния.

На этом можно сделать музыкальную паузу:

и после неё подвести итог.

Что в итоге?

Как мы уже поняли, если мы всё в своём коде описываем классами, то это совсем не значит, что у нас везде получаются объекты.

Эта конструкция:

class ProfileData
{
    public int $id;
    public string $name;
    public string $email;
}

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

Такой хэшер:

class Hasher
{
    public function __construct(int $cost) { ... }
    public function hash(string $password): string { ... }
}

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

Аналогично другой класс Mailer, через который мы можем отправлять письма:

class Mailer
{
    public function __construct(Config $config) { ... }
    public function send(Mail $mail): void { ... }
}

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

И даже если мы сделаем в Hasher несколько методов или даже передадим туда HTTP-клиент для хэширования через сторонний сервис с API:

class Hasher
{
    public function __construct(int $cost, HttpCLient $client) { ... }
    public function hash(string $password): string { ... }
    public function validate(string $password, string $hash): bool { ... }
}

то всё равно это у нас будет хоть и продвинутой, но всё равно функцией (точнее уже набором функций) без сохраняемого изменяемого состояния.

И лишь такой профиль:

class Profile
{
    public function __construct(string $id, string $name, string $email) { ... }
    public function addEmail(string $email) { ... }
    public function removeEmail(string $email) { ... }
}

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

Помимо Profile у нас могут быть другие вспомогательные объекты. Например, может быть коллекция, в которую мы можем добавлять, доставать и удалять профили:

$profiles = new Profiles();
 
$profiles->add(new Profile(...));
 
$profile = $profiles->get($id);
 
$profiles->remove($profile);

И коллекция может хранить профили в приватном массиве. Но может притвориться простой коллекцией, а в реальности сохранять профили в БД через подключение:

$db = new PDO('...');
$profiles = new Profiles($db);

и это часто встречается.

Ситуацию, когда у нас есть хранилище доменных сущностей, замаскированное под коллекцию, как раз описывает паттерн Repository

В этом коде $db является компонентом, открывающим и сохраняющим в себе соединение с БД. И вызывая методы execute, query или beginTransaction мы выполняем запросы к БД. Его мы можем рассматривать как набор процедур, работающих с подключением.

Можно подвести промежуточный итог, что чем у нас примерно является:

  • Изменяемое состояние + Поведение = Полноценный объект
  • Состояние без Поведения = Структура данных
  • Поведение без Состояния = Процедура или Функция

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

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

И при разработке проекта мы используем это для соответствующих целей.

Примеры из практики

Объекты мы часто используем для агрегатов и сущностей предметной области вроде Profile, Post или Customer. В их состояние сохраняем пользовательские данные и всевозможные текущие статусы и потом их записываем в БД.

Также если нам неудобно хранить все данные в отдельных полях:

$profile = new Profile(
    $id,
    $firstName,
    $lastName,
    $city,
    $street,
    $house
);

то их можем группировать во вспомогательные конструкции:

new Profile(
    $id,
    new Name(
        $first,
        $last
    ),
    new Address(
        $city,
        $street,
        $house
    )
);

Эти Name и Address тоже могут быть хоть структурами без поведения, хоть объектами с поведением. То есть могут быть и объектом-значением вместо простого значения в виде строки или числа.

Структурами у нас также являются всевозможные DTO, которыми мы передаём куда-то пачки данных. В разных фреймворках в качестве DTO можно рассматривать классы вроде FormModel. Например, кастомный Request в Laravel, который мы заполняем данными из запроса и валидируем.

Именно DTO часто сериализуют в JSON или XML и обратно для передачи данных по сети в другие программы. То есть для того самого Data Transfer между процессами.

И хоть аббревиатура DTO и раскрывается как Data Transfer Object, мы теперь понимаем, что это не полноценный Object, а простая структура.

Например, в нашем любимом примере регистрации у нас есть команда, которую мы передаём в обработчик:

class Command
{
    public string $email = '';
    public string $password = '';
}
 
class Handler
{
    public function __construct(
        private UserRepository $users;
        private PasswordHasher $hasher;
    ) {}
 
    public function __invoke(Command $command): void
    {
        if ($this->users->hasByEmail($command->email)) {
            throw new DomainException('User already exists.');
        }
 
        $user = new User(
            Id::generate(),
            new DateTimeImmutable(),
            $command->email,
            $this->hasher->hash($command->password)
        );
 
        $this->users->add($user);
    }
}

и аналогично поступаем в сценарии подтверждения регистрации по токену:

class Command
{
    public string $token = '';
    public string $date = '';
}
 
class Handler
{
    public function __construct(
        private UserRepository $users;
    ) {}
 
    public function __invoke(Command $command): void
    {
        $user = $this->users->getByToken($command->token);
 
        $user->confirmSignUp(
            new DateTimeImmutable($command->date)
        );
    }
}

Мы теперь можем сказать, что хоть всё записано в виде классов, но здесь:

  • Command является структурой и DTO;
  • User является полноценным доменным объектом с данными и поведением;
  • Id и DateTime являются вспомогательными объектами-значениями;
  • UserRepository является объектом-коллекцией;
  • Handler является процедурой;
  • PasswordHasher является функцией.

Как видим, из шести классов целиком объектно-ориентированы здесь только User с Id и DateTime и немного UserRepository. А остальной код у нас обычный процедурный, хоть и записан на классах.

Так что «настоящие» сущности-объекты вроде User мы обычно создаём и модифицируем сами, передавая в них пользовательские данные.

А остальные вспомогательные вещи вроде того же PasswordHasher мы просто вызываем как процедуры и функции. В них нет никаких данных, кроме настроек, помещаемых туда из конфигурации проекта.

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

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

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

$hasher = new PasswordHasher(16);
echo $hasher->hash($password1);
 
$hasher = new PasswordHasher(16);
echo $hasher->hash($password2);
 
$hasher = new PasswordHasher(16);
echo $hasher->hash($password3);

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

$hasher = new PasswordHasher(16);
 
echo $hasher->hash($password1);
echo $hasher->hash($password2);
echo $hasher->hash($password3);

А чтобы не создавать их вручную мы создание и хранение списка всех таких сервисов со всеми параметрами из конфигурации можем доверить сервис-локатору или сервис-контейнеру, который будет нам по требованию их создавать и возвращать:

$hasher = $container->get('password-hasher');
 
echo $hasher->hash($password1);
echo $hasher->hash($password2);
echo $hasher->hash($password3);

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

Как с этим жить

В итоге мы поняли, что реальное программирование, которым мы привыкли заниматься и которое называем объектно-ориентированным – это обычно большая смесь ПП, ФП и ООП.

Функцию или процедуру можно спрограммировать хоть классической функцией, хоть классом с динамическим методом, хоть статическим методом класса.

Если программист пишет классы, то это не значит, что у него ООП.

И, что интересно, одно и то же можно спрограммировать любым подходом.

Если выбранный нами язык программирования поддерживает классы, то мы можем создать объект через new и вызывать его методы:

$profile = new Profile(42, 'Vasya', 'vasya@examlpe.com');
 
$profile->addEmail('new@site.com');
$profile->removeEmail('new@site.com');

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

$profile = newProfile(42, 'Vasya', 'vasya@examlpe.com');
 
$profile = addEmail($profile, 'new@site.com');
$profile = removeEmail($profile, 'new@site.com');

А если нам не хочется тратить оперативную память, создавая новый $profile на каждую операцию, то можем сделать всё процедурно со ссылками:

$profile = newProfile(42, 'Vasya', 'vasya@examlpe.com');
 
addEmail(&$profile, 'new@site.com');
removeEmail(&$profile, 'new@site.com');

Примерно так программисты на C без классов троллят программистов C++, у которых классы есть. Что мы тоже умеем в ООП, но немного по-своему.

Помимо этого некоторые языки делают разную вроде бы "дичь". Например, в новом языке Go без классов можно объявить структуру, отдельно вписать к ней рядом процедуру и потом вызвать её из структуры как метод:

type Vertex struct {
    X, Y float64
}

func (v Vertex) Abs() float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

v := Vertex{3, 4}
a := v.Abs()

На что у программистов, не знакомых с вдохновившим Go языком Object Pascal, падает челюсть :)

Так что здесь как раз взаимная ситуация, что кто-то в процедурном языке эмулирует ООП, а кто-то на классах специально или случайно пишет код по ПП. Кто-то пишет процедурно, но называет свой код функциональным.

В любом случае надо смотреть на смысл написанного, а не просто на код.

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

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

А как более удобно работать с сервисами в контейнере - это уже совсем другая история для следующей статьи про практики внедрения зависимостей.

Материалы, упоминаемые в статье:

Чтобы не пропустить следующие части можете подождать их в Телеграме или рассылке:

Комментарии

 

Артём

Спасибо за материал, очень полезно!

Ответить

 

almasmurad

В первом абзаце ошибка: "программиты" вместо "программисты"... Но слово получилось тоже интересным :)

Ответить

 

Дмитрий Елисеев

Исправил. Спасибо!

Ответить

 

вова

Отличная статья. Ждем следующую.

Ответить

 

Евгений Жданов

Спасибо за статью! очень интересно. Как у laravel разработчика после прочтения появилась мысль что для работы с сервисами можно использовать подход как с фасадами в Laravel :)

Ответить

 

Павел

Простите, но что-то тут не так.
Если у вас есть класс и вы создаёте его инстанс через new, к примеру, то это объект. Называть его структурой, вы, конечно, можете, но это исключительно ваше представление и и именование. Будь то dto/vo/model, кто как называет, инстанс этого в первую очередь - объект.

Ответить

 

Александр Михайлов

Я так понимаю, что автор как раз пытался донести мысль о том что:
Формально, да - это Объект,
Но если брать парадигму ООП о которой говорил Алан Кей, это не является объектно-ориентированным программированием.

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

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

Ответить

 

Павел

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

Мой комментарий был исключительно про:

Изменяемое состояние + Поведение = Объект
Состояние без Поведения = Структура данных

Ответить

 

Дмитрий Елисеев

Дописал до "Полноценный объект"

Ответить

 

Sergei

А может DTO иметь методы вообще? Ну например isEmailValid().

Ответить

 

Дмитрий Елисеев

Добавить-то можно. Но зачем это делать именно в нём если можно проверить валидаторами?

Ответить

 

Александр

Отличная статья, спасибо! Я бы даже предложил узаконить такую классификацию. Сложно донести что не все что new это в чистом виде объект. Скорее это издержки недостатка инструментария в языке. На каждом форуме, команде найдётся «Павел» искренне не понимающий отличия набора функций, модели-сущности, объекта-значения, структуры. Ну написано же class, значит после new все объекты. Даже завидую, так проще очевидно жить 😄

Ответить

 

Дмитрий Елисеев

Да, так проще 😄

Ответить

 

Sergei

Все мы иногда немножко Павел.

Ответить

 

Владислав

Дякую за матеріали, корисна інформація!

Ответить

 

Андрей

Дмитрий спасибо за полезную статью.

Ответить

 

Анатолий – wpcraft.ru

В целом по статье согласен. Но мне кажется слона то тут и нет.
Все что описано в статье это разновидности класс ориентированного программирования. Один из типов ООП.
Если открыть статью в википедии про ООП то там идет речь еще о 2х типах и самый интересный на мой взгляд это КОП - компонентно ориентированное программирование.
А если взять коммент Алана 3х лет давности тут http://disq.us/p/1rpawxo - то выяснится что он имел ввиду как раз модули системы. Которые общаются друг с другом через сообщения.

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

Примеры: WordPress, Django, FreeScout, Redmine, WHMCS ... во всех этих системах ты пишешь модули и обеспечиваешь общение через сообщения. При этом иногда можешь вообще не использовать классы.

На мой взгляд это именно то ООП о котором говорил Алан Кей. Это именно то что описано в вики как Компонентно-ориентированное программирование. Это разновидность ООП. В котором вообще может не быть классов.

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

Опять же это не значит что надо без классов. Иногда эффективне описать часть логики классом, иногда функцией.

Основной тезис тут в том что Алан Кей никак не связывал его идею ООП и классы. И его идея ООП все еще жива, просто развивается и мутирует в разные технические решения. Примеры выше.

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

Ответить

 

Дмитрий Елисеев

> Просто пишешь модули системы которые общаются друг с другом через сообщения.

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

> Важно еще понимать что ты можешь писать ООП не используя классы вообще :)

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

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

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

Ответить

 

Анатолий – github.com

Спасибо за коммент :) Оч ценю.

Однако тут есть большая путаница.

ООП на классах - это если надо наследовать на базе классов.

ООП на компонентах - можно наследовать ровно также если диспетчер сообщений позволяет.

в WP - он позволяет.

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

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

а потом пришел к простому выводу: оба ООП ок, и каждый для своего.

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

но внутри компонентов что то мне проще решить через функции, и наследуюсь через сообщения.

а что то проще решить черезе классы и наследуюсь через классы.

оба подхода ок.

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

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

Ответить

 

xfg

Дмитрий, мы это обсуждаем уже не первый год. Тот кто читал красную книгу Вон Вернона, тот всё понимает, что к чему. Я хотел чтобы вы и мы вместе с вами двигались дальше, в сторону распределенных систем. Например, если у нас под проектом лежит шардированная база данных, то всё немного становится сложнее. Как правильно выбирать ключи шардирования, как обеспечивать ту же уникальность email-адреса в базе данных, как жить без транзакций, как не делать broadcast на кучу шардов при записи/чтении и так далее и всё это положить под слоистую архитектуру. Об этом крайне мало информации, особенно в русскоязычном сегменте.

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

Ответить

 

Иван

Гениальное обьяснение

Ответить

 

xfg

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

Также смотрю ваши репозитории и там вы всё же используете геттеры.

Ответить

 

Дмитрий Елисеев

Да, приходится использовать геттеры только для тестов. Можно тестировать с рефлексией, но это сделает тесты хрупкими.

Ответить

 

xfg

Благодарю за ответ.

Ответить

 

Антон Минаков

Дмитрий, спасибо за отличную статью!

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

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

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

Ответить

 

Дмитрий Елисеев

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

Конструктор состояние объекта не меняет, а создаёт. Изменяемое состояние он не привносит.

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

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

При этом в процесе последующей работы никакое дополнительное состояние внутри функции не сохраняется.

Ответить

 

Антон Минаков

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

function shouldBePermanent(int $cost)
{
    return function (int $newCost = null) use (&$cost): int
    {
        if (!is_null($newCost)) {
            $cost = $newCost;
        }
        return $cost;
    };
}

$ten = shouldBePermanent(10);
$ten(20);
echo $ten();
Ответить

 

Дмитрий Елисеев

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

Ответить

 

Алексей

Супер, очень вовремя и очень понятно осознал сервисы репозитории и дто! Тысяча благодарностей, как будет время приду к вам на курс по аукциону, успехов вам

Ответить

 

Vladimir Perepechenko

Познавательно, благодарю!!

> А как более удобно работать с сервисами в контейнере - это уже совсем другая история. История для >следующей статьи.

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

Ответить

 

Дмитрий Елисеев

Да, DI-контейнер и сервис-контейнер - это одно и то же.

Ответить

 

Николай – deriglazov.com

Интересная пища для размышления.

Но повсюду в сети идёт один и тот же холивар. Rich Domain Model или анемичная сущность в обвязке фабрик (сервисов)?

Возьмём всё тот же избитый пример с сохранением пароля сущности User. Перед тем, как сделать $user->setPassword($password) - пароль должен быть захэширован. Тогда и метод должен называться вовсе не $user->setPassword($password), а $user->setPasswordHash($hash). И геттер соответственно getPasswordHash().

Или например, есть сущность

class RadiologistReport()
{
    public function __construct(
        private int $id;
        private string $imageUri
        private string $description
    ) {}
// далее стандартные геттеры и сеттеры этих свойств.
}

Теперь, если мы хотим создать новый репорт, вначале, какой-то сервис уже должен был загрузить картинку и вернуть её uri. Если мы вкорячим метод $report->saveImage(resource $image) - разве это не нарушает SRP?

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

Ответить

 

Дмитрий Елисеев

Именно. Сущность должна хранить и контролировать свои готовые данные о загруженной картинке, а не сама её загружать.

Варианты взаимодействия с сервисами описаны в следующей статье.

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>