Функциональный C#: «одержимость» примитивами | OTUS

Функциональный C#: «одержимость» примитивами

Представьте, что вам надо описать некий класс Customer, который содержит, к примеру, name и e-mail. Скорее всего, вы придёте к мысли, что использовать для имени и электронной почты поля элементарных типов данных намного проще, чем писать базовый класс. В итоге вы получите следующий код:

public class Customer
{
    public string Name { get; private set; }
    public string Email { get; private set; }

    public Customer(string name, string email)
    {
        Name = name;
        Email = email;
    }
}

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

public class Customer
{
    public string Name { get; private set; }
    public string Email { get; private set; }

    public Customer(string name, string email)
    {
        // Проверяем корректность имени
        if (string.IsNullOrWhiteSpace(name) || name.Length > 50)
            throw new ArgumentException("Name is invalid");

        // Проверяем корректность электронной почты
        if (string.IsNullOrWhiteSpace(email) || email.Length > 100)
            throw new ArgumentException("E-mail is invalid");
        if (!Regex.IsMatch(email, @"^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$"))
            throw new ArgumentException("E-mail is invalid");

        Name = name;
        Email = email;
    }

    public void ChangeName(string name)
    {
        // Проверяем корректность имени
        if (string.IsNullOrWhiteSpace(name) || name.Length > 50)
            throw new ArgumentException("Name is invalid");

        Name = name;
    }

    public void ChangeEmail(string email)
    {
        // Проверяем корректность электронной почты
        if (string.IsNullOrWhiteSpace(email) || email.Length > 100)
            throw new ArgumentException("E-mail is invalid");
        if (!Regex.IsMatch(email, @"^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$"))
            throw new ArgumentException("E-mail is invalid");

        Email = email;
    }
}

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

[HttpPost]
public ActionResult CreateCustomer(CustomerInfo customerInfo)
{
    if (!ModelState.IsValid)
        return View(customerInfo);

    Customer customer = new Customer(customerInfo.Name, customerInfo.Email);
    // Остаток метода
}

public class CustomerInfo
{
    [Required(ErrorMessage = "Name is required")]
    [StringLength(50, ErrorMessage = "Name is too long")]
    public string Name { get; set; }

    [Required(ErrorMessage = "E-mail is required")]
    [RegularExpression(@"^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$",
        ErrorMessage = "Invalid e-mail address")]
    [StringLength(100, ErrorMessage = "E-mail is too long")]
    public string Email { get; set; }
}

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

Избавляемся от «одержимости» примитивами

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

public class Email
{
    private readonly string _value;

    private Email(string value)
    {
        _value = value;
    }

    public static Result<Email> Create(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
            return Result.Fail<Email>("E-mail can’t be empty");

        if (email.Length > 100)
            return Result.Fail<Email>("E-mail is too long");

        if (!Regex.IsMatch(email, @"^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$"))
            return Result.Fail<Email>("E-mail is invalid");

        return Result.Ok(new Email(email));
    }

    public static implicit operator string(Email email)
    {
        return email._value;
    }

    public override bool Equals(object obj)
    {
        Email email = obj as Email;

        if (ReferenceEquals(email, null))
            return false;

        return _value == email._value;
    }

    public override int GetHashCode()
    {
        return _value.GetHashCode();
    }
}

public class CustomerName
{
    public static Result<CustomerName> Create(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
            return Result.Fail<CustomerName>("Name can’t be empty");

        if (name.Length > 50)
            return Result.Fail<CustomerName>("Name is too long");

        return Result.Ok(new CustomerName(name));
    }

    // Дальше следует то же самое, что и в классе Email
}

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

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

Рассмотрим пример использования таких классов:

[HttpPost]
public ActionResult CreateCustomer(CustomerInfo customerInfo)
{
    Result<Email> emailResult = Email.Create(customerInfo.Email);
    Result<CustomerName> nameResult = CustomerName.Create(customerInfo.Name);

    if (emailResult.Failure)
        ModelState.AddModelError("Email", emailResult.Error);
    if (nameResult.Failure)
        ModelState.AddModelError("Name", nameResult.Error);

    if (!ModelState.IsValid)
        return View(customerInfo);

    Customer customer = new Customer(nameResult.Value, emailResult.Value);
    // Остаток метода
}

Посмотрите, что экземпляры Result<CustomerName> и Result<Email> явно говорят, что метод Create способен вызвать ошибку. Если это произойдет, информацию об ошибке можно будет узнать из свойства Error.

Но давайте глянем и на класс Customer после введения нами двух маленьких побочных классов:

public class Customer
{
    public CustomerName Name { get; private set; }
    public Email Email { get; private set; }

    public Customer(CustomerName name, Email email)
    {
        if (name == null)
            throw new ArgumentNullException("name");
        if (email == null)
            throw new ArgumentNullException("email");

        Name = name;
        Email = email;
    }

    public void ChangeName(CustomerName name)
    {
        if (name == null)
            throw new ArgumentNullException("name");

        Name = name;
    }

    public void ChangeEmail(Email email)
    {
        if (email == null)
            throw new ArgumentNullException("email");

        Email = email;
    }
}

Обратите внимание, что почти все проверки переместились в классы CustomerName и Email. Остались лишь условия с проверками на null.

Что же мы получили, когда избавились от «одержимости» примитивами: 1. Создали единый источник знаний для каждого объекта. 2. Избежали дублирования кода. 3. Стало невозможным ошибочно присвоить объекту CustomerName или Email такое значение, которое стало бы причиной ошибки компиляции. 4. Пропала необходимость в дополнительной проверке корректности e-mail либо name покупателя. Если объекты класса CustomerName или Email существуют, то мы точно уверены, что в них хранятся абсолютно верные данные.

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

public void Process(string oldEmail, string newEmail)
{
    Result<Email> oldEmailResult = Email.Create(oldEmail);
    Result<Email> newEmailResult = Email.Create(newEmail);

    if (oldEmailResult.Failure || newEmailResult.Failure)
        return;

    string oldEmailValue = oldEmailResult.Value;
    Customer customer = GetCustomerByEmail(oldEmailValue);
    customer.Email = newEmailResult.Value;
}

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

public void Process(Email oldEmail, Email newEmail)
{
    Customer customer = GetCustomerByEmail(oldEmail);
    customer.Email = newEmail;
}

Ограничения

Увы, создание пользовательских типов данных в языке программирования C# реализовано не столь безупречно, как в функциональных языках. Именно поэтому иногда применение примитивов всё же лучше, чем создание простого класса-обертки. К примеру, если речь идёт о представлении денег, т. к. их можно выразить посредством элементарного типа данных и лишь с одной проверкой знака числа. Да, придётся продублировать это условие, однако такое решение проще.

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

По материалам статьи «Functional C#: Primitive obsession».

Не пропустите новые полезные статьи!

Спасибо за подписку!

Мы отправили вам письмо для подтверждения вашего email.
С уважением, OTUS!

Автор
0 комментариев
Для комментирования необходимо авторизоваться
Популярное
Сегодня тут пусто