Мы продолжаем цикл статей о функциональном программировании на языке C#:
- Функциональный C#: Неизменяемые объекты
- Функциональный C#: Одержимость примитивами
- Функциональный C#: Ненулевые ссылочные типы
- Функциональный 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
, но их мы рассмотрим в следующей статье.
Так какие преимущества мы получили, избавившись от одержимости примитивами?
- Мы создали единый авторитетный источник знаний для каждого объекта и избавились от дублирования кода.
- Теперь невозможно по ошибке присвоить объекту
Email
илиCustomerName
такое значение, которое привело бы к ошибке компилятора. - Нет необходимости в дополнительной проверке корректности электронной почты или имени покупателя. Если объекты класса
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
.
Исходные коды
Перевод статьи «Functional C#: Primitive obsession»