Функциональный C#. Часть 2. Одержимость примитивами

Мы продолжаем цикл статей о функциональном программировании на языке C#:

  1. Функциональный C#: Неизменяемые объекты
  2. Функциональный C#: Одержимость примитивами
  3. Функциональный C#: Ненулевые ссылочные типы
  4. Функциональный C#: Обработка исключений

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

Если вы тоже так думаете, то вашим результатом будет подобный код:

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; }
}

Согласитесь, такой подход, мягко говоря, не совсем корректный. А как же принцип DRY и единый источник истины? В приведенном выше примере по меньшей мере 3 таких источника, что совсем не оправдано.

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

Как избавиться от одержимости примитивами?

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

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<Email> и Result<CustomerName> явно говорят нам, что метод 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;
    }
}

Практически все проверки были перемещены в классы Email и CustomerName. Остались только условия с проверками на null, но их мы рассмотрим в следующей статье.

Так какие преимущества мы получили, избавившись от одержимости примитивами?

  1. Мы создали единый авторитетный источник знаний для каждого объекта и избавились от дублирования кода.
  2. Теперь невозможно по ошибке присвоить объекту Email или CustomerName такое значение, которое привело бы к ошибке компилятора.
  3. Нет необходимости в дополнительной проверке корректности электронной почты или имени покупателя. Если объекты класса Email или CustomerName существуют, то мы точно знаем, что данные в них хранятся абсолютно верные.

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

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# реализовано не так безупречно, как в функциональных языках: F#, например. Возможно, ситуацию исправит новая версия языка: C# 7.0.

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

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

Вывод

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

Исходные коды

  1. Код с одержимостью примитивами
  2. Код без одержимости примитивами

Перевод статьи «Functional C#: Primitive obsession»