Функциональный C#. Часть 1. Неизменяемые объекты

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

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

Почему мы рассматриваем неизменяемые объекты?

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

Давайте рассмотрим такой пример:

// Создадим критерии поиска
var queryObject = new QueryObject<Customer>(name, page: 0, pageSize: 10);
 
// Ищем клиентов
IReadOnlyCollection<Customer> customers = Search(queryObject);
 
// Отрегулируем критерии, если никто не найден
if (customers.Count == 0)
{
    AdjustSearchCriteria(queryObject, name);
    // Изменится ли здесь queryObject?
    Search(queryObject);
}

Будет ли изменен объект запроса к тому моменту, когда мы выполняем второй поиск? Может быть, да. А, может быть, нет. Это зависит от того, найдем ли мы что-нибудь, осуществив первый поиск. А еще и от того, изменятся ли критерии поиска после выполнения метода AdjustSearchCriteria. Проще говоря, мы не можем знать заранее, изменится ли объект запроса во втором поиске.

А теперь рассмотрим следующий код:

// Создадим критерии поиска
var queryObject = new QueryObject<Customer>(name, page: 0, pageSize: 10);
 
// Ищем клиентов
IReadOnlyCollection<Customer> customers = Search(queryObject);
 
if (customers.Count == 0)
{
    // Отрегулируем критерии, если никто не найден
    QueryObject<Customer> newQueryObject = AdjustSearchCriteria(queryObject, name);
    Search(newQueryObject);
}

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

Итак, какие существуют проблемы в работе с подвергающимися изменениям структурами данных?

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

Как описать неизменяемые объекты?

Если у вас есть относительно простой класс, то вы всегда должны рассматривать вопрос о том, чтобы сделать его неизменяемым. Это правило коррелирует с понятием Value Objects — они просты и их легко сделать неизменяемыми.

Так как же нам описать неизменяемые объекты? Давайте рассмотрим пример: у нас есть класс ProductPile, представляющий некоторые продукты, которые мы выставили на продажу:

public class ProductPile
{
    public string ProductName { get; set; }
    public int Amount { get; set; }
    public decimal Price { get; set; }
}

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

public class ProductPile
{
    public string ProductName { get; private set; }
    public int Amount { get; private set; }
    public decimal Price { get; private set; }
 
    public ProductPile(string productName, int amount, decimal price)
    {
        Contracts.Require(!string.IsNullOrWhiteSpace(productName));
        Contracts.Require(amount >= 0);
        Contracts.Require(price > 0);
 
        ProductName = productName;
        Amount = amount;
        Price = price;
    }
 
    public ProductPile SubtractOne()
    {
        return new ProductPile(ProductName, Amount – 1, Price);
    }
}

Итак, чего же мы добились такой организацией класса?

  1. Теперь мы можем не волноваться о корректности данных — проверять значение мы будем только один раз в конструкторе.
  2. Мы абсолютно уверены в том, что значения объектов корректны всегда.
  3. Объекты автоматически становятся потокобезопасными.
  4. Увеличилась читаемость кода, поскольку теперь нет необходимости проверять, не изменились ли значения объектов.

Ограничения в использовании

Конечно, все имеет свою цену. Мы можем применить нашу идею в маленьких и простых классах, однако она совсем не применима к большим.

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

Хорошим примером здесь являются неизменяемые коллекции (immutable collections). Их авторы учли проблемы с производительностью и добавили класс Builder, который позволяет “мутрировать”, изменять коллекцию:

var builder = ImmutableList.CreateBuilder<string>();
builder.Add("1");                                   // Добавление строки в существующий объект
ImmutableList<string> list = builder.ToImmutable();
ImmutableList<string> list2 = list.Add("2");        // Создание объекта с 2 строками

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

Вывод

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

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