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

Обратите внимание: ключевые концепции в этой статье применимы в основном к классическим объектно-ориентированным языкам, которые имеют наследование и возможность, по крайней мере, вводить параметры функции подсказки. Так, например, такие языки, как PHP, Java, Kotlin, Dart и D (и, вероятно, многие другие: C ++, Rust и т. Д. Я не использовал их, поэтому не могу быть уверен).

О уверенности и удобочитаемости

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

«Вы заслуживаете доверия к своему коду»

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

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

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

Концепция 1.Имена переменных имеют значение

Вы, вероятно, знаете, что в отношении типов языков программирования используются два разных подхода: «строгая типизация» и «динамическая типизация».

Когда вы пишете программу, используя строго типизированный язык, вы объявляете переменные с определенным типом данных. Эти типы обычно включают строки, целые числа, числа с плавающей запятой и логические значения, и на самом деле некоторые языки имеют очень много типов. C например их больше 27!

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

function registerUser(
   $n,
   $e,
   $c,
   $h) {
   ...
}

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

Если бы вместо этого функция выглядела примерно так:

function registerUser(
   $name,
   $email,
   $cat,
   $human) {
}

тогда дела обстоят несколько лучше. Вы можете сделать обоснованное предположение о том, какими должны быть типы данных для «name» и «email» (обе строки). Однако «кошек» и «людей» угадать сложнее. «Кошка» - это имя? Название породы? Число?

Все снова становится яснее, когда типы данных явно подразумеваются в именах параметров.

function registerUser(
   $name,
   $email,
   $numberOfCats,
   $isHuman) {
}

Теперь вы можете сделать вывод, что $ numberOfCats является целым числом, а isHuman, скорее всего, является логическим значением.

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

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

function registerUser(
   $sName,
   $sEmail,
   $iNumberOfCats,
   $bIsHuman) {
}

ключ: «s» = строка, «i» = целое число, «b» = логическое значение

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

Концепция 2 - Надежный ввод

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

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

function registerUser(
    string $name,
    string $email,
    int $numCats,
    bool $isHuman) {
}

Несколько наблюдений:

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

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

Для иллюстрации, если в PHP мы вызываем нашу функцию registerUser с неправильным типом данных, используя строковое значение «fish» для numCats, например

registerUser (‘Ada’, ‘[email protected]’, ‘fish’, true);

тогда среда выполнения PHP увидит ошибку и сообщит:

«‹B› Неустранимая ошибка ‹/b›: Uncaught TypeError: registerUser (): Аргумент № 3 ($ numCats) должен иметь тип int, указана строка»

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

Но подождите, это еще не конец истории.

Рассмотрим все эти варианты использования метода registerUser:

// Ошибка: имя не указано
registerUser (‘’, ‘[email protected]’, 3, true);

// Ошибка: указан неверный адрес электронной почты
registerUser (‘Ada’, ‘invalid.email’, 3, true);

// Ошибка: -1 Кошки… интересно
registerUser (‘Ada’, ‘[email protected]’, -1, true);

// Ошибка: слишком длинное значение для вашей базы данных
registerUser (‘AdaAdaAdaAdaAdaAdaAdaAdaAdaAdaAdaAdaAdaAda’, ‘[email protected]’, 3, true);

// Ошибка: очень внушительное и невероятное количество кошек
registerUser (‘Ada,‘ [email protected] ’, 99999999, true);

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

Для устранения этих проблем обычно используются следующие решения:

  • Проверка входящих данных из HTTP-запросов с использованием проверочных библиотек Framework;
  • Проведение обширных проверок достоверности внутри наших методов, чтобы убедиться, что значения разумны;

e.g.

function registerUser(
    string $name,
    string $email,
    int $numCats,
    bool $isHuman) {
    // Ensure name is not empty and not too long
    // Ensure email is not empty and actually an email
    // Ensure numCats is not < 0 and not > 20
    // Now actual business logic follows
    …
}

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

Проблема 1. Проверочные проверки скрывают бизнес-логику.

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

Проблема 2. Защитное кодирование означает дублирование проверки

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

Контроллер → Сервис → Репозиторий

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

Так, например, наша кажущаяся невинная переменная «name» в нашей функции registerUser может быть передана через 3 или более различных уровней и функций.

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

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

Чтобы натереть рану соленым песком, если вы решите, что на самом деле ваша переменная «name» теперь должна быть ограничена 75 символами, а не 50, вам, возможно, придется отслеживать все попытки проверки «name» через ваше приложение и обновлять их соответствующим образом. Возможно, вы объединили такие правила проверки в одном месте, но в зависимости от вашего языка и структуры или вашего мышления в то время, возможно, вы этого не сделали. И это действительно болезненно и чревато ошибками.

Концепция 3: Пользовательский ввод

Теперь представьте, что наша функция registerUser выглядит так:

function registerUser(
    Name $name,
    EmailAddress $email,
    WholeNumber $numCats,
    bool $isHuman) {
    // The business logic is right here — all the the validation has gone.
}

Вы сразу заметите следующее:

  • примитивные типы, такие как string и int, были заменены настраиваемыми типами, такими как «Name», «EmailAddress» и «WholeNumber»; и
  • Логика проверки удалена из нашей функции;

Основная идея такова:

Мы создаем серию настраиваемых объектов, которые становятся типами. В приведенном выше примере у нас будет класс «Имя». Этот класс будет содержать строковое значение «name», но также:

  • Убедитесь, что значение является строкой;
  • Убедитесь, что значение не может быть пустым / пустым;
  • Убедитесь, что значение не может быть длиннее, скажем, 40 символов (предотвращение переполнения базы данных);

Если какое-либо из вышеперечисленных правил было нарушено, конструктор класса выбросил бы исключение.

Итак, чтобы проиллюстрировать:

$name = new Name(“”);
$name = new Name(45);
$name = new Name(‘THIS_VALUE_IS_TOO_LONG_AND_WE_WONT_ALLOW_IT’);

все потерпят неудачу и вызовут исключение.

Аналогичным образом объект EmailAddress будет гарантировать, что значение, которое он хранит, действительно является адресом электронной почты, а объект WholeNumber гарантирует, что значение, которое он содержит, является значением ›= 0. Больше никаких -1 котов!

Создав собственную систему типов и переместив проверку значений в выделенные классы типов, мы:

  • Подтвердите значения только один раз;
  • Полностью удалите валидацию из наших ключевых методов бизнес-логики, оставив их чистыми и содержательными, насколько это возможно;
  • Сделать невозможным появление ошибок типа «имя имеет пустое значение» или «имя слишком длинное» на наших уровнях службы или репозитория;
  • Обеспечить единообразное применение и обработку типов (например, «Имя» в одном методе не может трактоваться отдельно от другого);
  • Позвольте нам писать комплексные модульные тесты для наших типов, делая нас чрезвычайно уверенными в том, что они обрабатываются последовательно и полностью;

Когда вы создаете свои собственные пользовательские типы, вы, вероятно, обнаружите, что в конечном итоге вам придется снова и снова писать одну и ту же логику при проверке типов. Например, такие типы, как «FirstName» и «LastName», будут иметь очень похожий код проверки.

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

Вот пример на PHP:

abstract class ConstrainedStringImmutable
{
   /** @var string */
   private $value;
   public function __construct(
      string $value,
      int $minLength = 0,
      int $maxLength = 0
   ) {
         $stringLen = mb_strlen($value, 'UTF-8');
         if (($minLength > 0) && ($stringLen < $minLength)) {
            throw new ConstraintException(sprintf('Invalid %s, value must be at least %d characters', static::class, $minLength));
         }
         if (($maxLength > 0) && ($stringLen > $maxLength)) {
            throw new ConstraintException(sprintf('Invalid %s, value must be no longer than %d characters', static::class, $maxLength));
         }
         $this->value = $value;
   } 
   public function __toString(): string
   {
      return $this->value;
   }
}

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

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

class FirstName extends ConstrainedStringImmutable
{
   public function __construct(string $value)
   {
      parent::__construct($value, 1, 30);
   }
}
class LastName extends ConstrainedStringImmutable
{
   public function __construct(string $value)
   {
      parent::__construct($value, 1, 30);
   }
}

Теперь у нас есть два пользовательских типа, FirstName и LastName, которые немного отличаются. Имя должно быть от 1 до 30 символов. Фамилия должна быть от 2 до 40 символов. Оба используют одну и ту же основную логику проверки, поэтому они пишутся только один раз.

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

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

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

Обратные стороны

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

Плита котла

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

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

$userModel = User::findById(1)

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

E.g.

// This is a primitive string
$userModel->name;
// This is a primitive int.
$userModel->numCats;

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

e.g.

$user = $userAdapter->fromModel($userModel);

Метод адаптера, вероятно, будет выглядеть примерно так:

public function fromModel($userModel): User
{
   return new User(
      new UserId($userModel→id),
      new FirstName($userModel→firstName),
      new LastName($userModel→lastName),
      new EmailAddress($userModel→emailAddress),
      …
   );
}

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

Использование памяти и производительность

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

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

Иногда проверки бывает слишком много

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

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

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

В вашей локальной базе данных разработки нет случаев, когда это так, поэтому вы этого не замечаете, однако в тестовой или производственной среде на самом деле есть пользователи, у которых есть пустое или нулевое название компании. К сожалению, когда такой пользователь пытается войти в систему, благодаря вашим настраиваемым типам теперь выдается исключение при попытке создать объект пользователя, потому что настраиваемый тип CompanyName говорит: «Эй, у вас не может быть пустого названия компании!». Ой, теперь вы запретили платному пользователю входить в систему!

Советы, как этого избежать:

  • Всегда проверяйте схему базы данных. Если название компании может быть пустым, схема базы данных сообщит вам об этом;
  • Если у вас есть доступ к производственной / тестовой среде, проверьте там свои предположения с помощью простого SQL-запроса.
  • Также проверьте свои предположения у менеджеров по продуктам и других разработчиков, которые работают в компании в течение длительного времени - они, скорее всего, будут знать эти крайние случаи и не позволят вам сделать ошибку в суждении;
  • Сопоставьте ограничения размера с вашей базой данных. Если ваша база данных допускает 50 символов для имени, то сравните это с классом вашего типа, чтобы получилась корреляция 1: 1 с вашими бизнес-правилами и хранилищем базы данных.

Вот и все, ребята. Позвольте мне кратко резюмировать основные моменты.

Использование настраиваемых типов повышает надежность и удобочитаемость кода за счет:

  • Перенос правил проверки в объекты типа, прошедшие тщательную модульную проверку;
  • Удаление проверки там, где она не обязательна, позволяя функциям попасть прямо в бизнес-логику;
  • Обеспечение четкого смысла и устранение двусмысленности, что позволяет быстрее проверять код, а также позволяет другим разработчикам легче понять, как использовать ваши функции;
  • Позволяет разрабатывать общие типы, соответствующие предметной области вашей деятельности и используемые во всем бизнесе в различных репозиториях;
  • Снижение когнитивной нагрузки на программистов, зная, что, когда они используют эти типы в своих собственных методах, им не нужно беспокоиться о таких вещах, как «что, если это пусто?» Или «что, если это неправильный тип».

Минусами были:

  • Вероятность того, что вам потребуется написать дополнительный код, например классы адаптера;
  • Влияние ваших объектов на память и производительность;
  • Проверка вызывает проблемы из-за ложных предположений;

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