Как стать автором
Обновить
1297.59
OTUS
Цифровые навыки от ведущих экспертов

Иммутабельность в C#

Время на прочтение13 мин
Количество просмотров21K
Автор оригинала: John V. Petersen

В разработке программного обеспечения иммутабельным (immutable — неизменяемым) называется объект, который после своего создания не может быть изменен. Зачем вам может понадобиться такой объект? Чтобы ответить на этот вопрос, давайте проведем анализ проблем, которые могут возникнуть в результате мутации (изменения) объектов. Вернемся к основам того, что делает каждое приложение: создает, извлекает, обновляет и удаляет данные (CRUD-операции). Ядро любого приложения манипулирует объектами. Ответ на вопрос о том, работает ли приложение в соответствии со своей спецификацией, в первую очередь определяется правильностью обработки данных. Вам необходимо быть уверенными, что код работает правильно, каждый раз, когда он затрагивает какой-либо объект. 

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

Если ваше приложение является многопоточным, иммутабельность должна быть фундаментальной частью вашей архитектуры, поскольку неизменяемые объекты по своей природе являются потокобезопасными и невосприимчивыми к состояниям гонки. Если ваше приложение использует data transport objects (DTO — объекты передачи данных), вам также следует серьезно подойти к иммутабельности ваших объектов. Предметом дискуссий остается лишь то, как наиболее эффективно реализовать и работать с иммутабельными объектами, поскольку C# не предлагает встроенной поддержки таких объектов. Эта статья предлагает ответ на этот вопрос.

Весь код, приведенный в статье, можно найти в это GitHub-репозитории.

Аргументы в пользу иммутабельности

Представьте себе любой произвольный объект. Это может быть пользователь, продукт или что-то совсем другое, что пришло вам в голову. Рассмотрим жизненный цикл этого объекта. Он создается, живет какое-то время и в какой-то момент прекращает свое существование. В основном вас интересует середина этого жизненного цикла, когда он живет в течение какого-то периода времени. Что, если в течении всего своего периода жизни (или даже его части) объект не должен изменится? Учитывая, что C# изначально не поддерживает абсолютную иммутабельность, как бы вы реализовали требование, согласно которому однажды созданный объект никогда не должен изменяться?

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

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

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

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

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

Пример использования иммутабельности: DTO

DTO (Data Transport Object) — это классический пример, где мы хотели бы видеть иммутабельность. У DTO всего одно предназначение. Это предназначение, как следует из названия, заключается в передаче данных. Представьте, что вам приходится иметь дело с различными границами приложения (application boundaries), которые нужно пересекать вашим данным. Зачастую данные необходимо сериализовать, отправить в сервис на принимающей границе, а затем десериализовать, где затем происходит их гидратация в какой-нибудь объект (предположительно, этот объект также будет иммутабельным). В этом случае вы хотите гарантировать, что после создания объекта в течении всего его жизненного цикла ни при каких обстоятельствах никакие из его характеристик не будут изменены. По крайней мере, вы хотите быть уверены, что после установки свойств и блокировки объекта дальнейшие изменения будут невозможны.

Сложности c реализацией иммутабельности в C# 

С реализацией иммутабельности в C# есть несколько сложностей. Чтобы проиллюстрировать их, в листинге 1 продемонстрирован самый прямой и простой способ добиться иммутабельности. Код работает, но есть две ощутимые проблемы. Во-первых, вы должны инициализировать значения свойств в конструкторе. Во-вторых, поскольку в деле замешаны приватные сеттеры, вы не можете использовать инициализатор свойств, который позволяет игнорировать свойства, которые вы не хотите устанавливать.

Листинг 1: Наименее масштабируемый, но самый простой способ добиться иммутабельности: через аргументы конструктора с неявными приватными сеттерами

public class SimpleImmutableClass
{
    public SimpleImmutableClass(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }
    //Неявные приватные сеттеры
    public string LastName { get; }
    public string FirstName { get; }
}

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

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

public class SimpleImmutableClass
{
    public SimpleImmutableClass(
       string firstName, string lastName, List<string> items
    )
    {
        FirstName = firstName;
        LastName = lastName;
        Items = items;
    }
    //Неявные приватные сеттеры
    public string LastName { get; }
    public string FirstName { get; }
    public List<string> Items { get; }
}

var sut = new SimpleImmutableClass(
    "John",
    "Petesen",
    new List<string>()
);
sut.Items.Add("BS");
sut.Items.Add("MBA");
sut.Items.Add("JD");

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

Как насчет структур — они же ведь иммутабельны, да?

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

Листинг 3: По умолчанию, несмотря на популярное заблуждение, структуры изменяемы, как и классы.

public struct Person
{
    public string FirstName;
    public string LastName;
    public List<string> Items;
}


var sut = new Person() {
    FirstName = "John",
    LastName = "Petersen"
};

sut.Items = new List<string>();
sut.FirstName = "john";
sut.Items.Add("Structs are not immutable!");

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

Реализация иммутабельности в C# (почти без боли!)

Наши ожидания: во-первых, давайте согласимся с тем, что не все в C# можно сделать иммутабельным. Во фреймворке тысячи классов. Не все должно быть иммутабельным. Не каждый юзкейс требует иммутабельности. Во-вторых, от нас потребуется некоторое количество пользовательского кода. Полная иммутабельность, увы, не дана нам прямо из коробки. Поэтому нам придется немного пописать код. Хорошей новостью является то, что написать нужно действительно немного! В данном случае мы определим абстрактный класс, на основе которого мы будем создавать наши конкретные (concrete) классы. Эти конкретные классы должны будут следовать всего нескольким правилам.

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

Шаг 1: Использование JSON.NET и System.Immutable.Collections

На рисунке 1 показаны два пакета Nuget, необходимые для этого решения: NewtonSoft.Json (JSON.Net) и System.Immutable.Collections.

Рис. 1. Два пакета Nuget, необходимые для реализации иммутабельности.
Рис. 1. Два пакета Nuget, необходимые для реализации иммутабельности.

JSON.NET требуется для упрощения сериализации и десериализации объектов. Несмотря на то, что иммутабельные объекты не изменяются, нам все-таки необходимо внести быстрые изменения в рамках создания нового объекта. JSON.NET — это средство, с помощью которого мы можем быстро создавать и управлять иммутабельными объектами.

System.Collections.Immutable — это пакет Nuget от Microsoft, который содержит иммутабельные версии списков, словарей, массивов, хешей и очередей. Это как раз те виды объектов, которые демонстрируют проблему с приватными сеттерами и свойствами коллекциями и массива. Если вы уже успели забыть, приватный сеттер не запрещает вам добавление или манипуляции с элементами во вложенной коллекции.

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

Шаг 2: Создание пользовательских исключений

Самое главное различие между обычным и “корпоративным” программным обеспечением заключается вовсе не в ключевом функционале. Разница заключается в том, как они обрабатывает ситуации, когда что-то идет не так, и, в частности, в том, как они сообщают о таких ситуациях. В нашем случае нам нужно отловить две недопустимые ситуации. Первая - когда делается попытка изменить иммутабельное свойство. Вторая — это попытка добавить к инстансу иммутабельного класса свойство, которое не поддерживает иммутабельность. В листинге 4 показано исключение ImmutableObjectEditAttempt.

Листинг 4: Исключение ImmutableObjectEditAttempt генерируется всякий раз, когда предпринимается попытка изменить иммутабельное свойство после того, как оно было установлено.

public class ImmutableObjectEditException : Exception
    {
        public ImmutableObjectEditException() : base("An immutable object cannot be changed after it has been created.")
    {

    }
}

В листинге 5 показано исключение InvalidDataType.

Листинг 5: Исключение InvalidDataTypeException генерируется при попытке определить свойство с типом, не содержащимся в ValidImmutableClassTypes.

public class InvalidDataTypeException : Exception
{
        public static ImmutableHashSet<string>
    ValidImmutableClassTypes =
        ImmutableHashSet.Create<string>(
        "Boolean",
        "Byte",
        "SByte",
        "Char",
        "Decimal",
        "Double",
        "Single",
        "Int32",
        "UInt32",
        "Int64",
        "UInt64",
        "Int16",
        "UInt16",
        "String",
        "ImmutableArray",
        "ImmutableDictionary",
        "ImmutableList",
        "ImmutableHashSet",
        "ImmutableSortedDictionary",
        "ImmutableSortedSet",
        "ImmutableStack",
        "ImmutableQueue"
    );

    public InvalidDataTypeException(
        ImmutableHashSet<string> invalidProperties) : base(
        $"Properties of an instance of " +
        "ImmutableClass may only " +
        "contain the following types: Boolean, Byte, " +
        "SByte, Char, Decimal, Double, Single, " +
        "Int32, UInt32, Int64, " +
        "UInt64, Int16, UInt16, String, ImmutableArray, " +
        "ImmutableDictionary, ImmutableList, ImmutableQueue, " +
        "ImmutableSortedSet, ImmutableStack or ImmutableClass. " +

        $"Invalid property types: " +
        $" {string.Join(",", invalidProperties.ToArray())}")
    {
        Data.Add("InvalidPropertyTypes",
            invalidProperties.ToArray());
    }
}

Шаг 3: Создание иммутабельного абстрактного класса

В этом разделе мы сконцентрируемся на абстрактном классе ImmutableClass. В листинге 6 показан его конструктор.

Листинг 6: Конструктор ImmutableClass гарантирует, что в свойствах объекта присутствуют только допустимые типы данных. Если мы пытаемся установить туда недопустимый тип, генерируется исключение, что предотвращает создание объекта.

public abstract class ImmutableClass
{
    private bool _lock;

    protected ImmutableClass()
    {
        var properties = GetType()
           .GetProperties()
           .Where(x => x.PropertyType.BaseType.Name != "ImmutableClass")
           .Select(x =>
               x.PropertyType.Name.Substring(0,
              (x.PropertyType.Name.Contains("`")
                  ? x.PropertyType.Name.IndexOf("`", StringComparison.Ordinal)
                  : x.PropertyType.Name.Length)))
            .ToImmutableHashSet();

        var invalidProperties = properties
            .Except(InvalidDataTypeException
            .ValidImmutableClassTypes);

        if (invalidProperties.Count > 0)

            throw new InvalidDataTypeException(invalidProperties);
    }

Когда создается инстанс иммутабельного объекта, в приватное поле _lock устанавливается значение true. Одно из требований — сделать инициализацию свойств доступной. Если вы хотите использовать конструктор для инициализации свойств инстанса вашего иммутабельного класса, у вас есть такая возможность. Использование инициализаторов предполагает, что сеттеры свойств должны быть публичными. Раз я заговорил о сеттерах свойств, давайте посмотрим на листинг 7, где показан стандартный код сеттера для свойства. Если объект залочен (_lock), генерируется исключение ImmutableOjbectEditException (ранее показанное в листинге 4).

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

protected void Setter<T>(string name, T value, ref T variable)
{
    if (_lock)
        throw new ImmutableObjectEditException();
    variable = value;
}

Как вы уже могли догадаться, задумка заключается в том, что когда вы создаете инстанс класса на основе ImmutableClass и не установили какое-либо из его свойств при создании, это должно означать, что вы не собирались устанавливать это свойство. В листинге 8 показан общий статический метод Create для создания иммутабельного объекта.

Листинг 8: Create() принимает json-строку в качестве аргумента, чтобы создать залоченный инстанс ImmutableClass.

public static T Create<T>() where T : ImmutableClass
        {
            ImmutableClass retVal = Activator.CreateInstance<T>();
            retVal._lock = true;
            return (T)retVal;
        }

В следующем разделе будут приведены примеры, иллюстрирующие работу с ImmutableClass. Метод Create<T>() автоматически лочит возвращаемый инстанс.

Шаг 4: Реализация ImmutableClass

В листинге 9 показаны два подхода к реализации инстанса ImmutableClass. Здесь представлен инстанс класса Person, наследующего абстрактный класс ImmutableClass. В первом подходе происходит гидратация иммутабельного объекта JSON-строкой. Как создавать JSON оставлю решать вам. Если имена полей подходят, JSON.NET с помощью функции десериализации правильно гидратирует объект. Второй подход заключается в создании класса Person напрямую, вне метода Create. Примечание: когда вы создаете инстанс напрямую, он НЕ ЯВЛЯЕТСЯ иммутабельным, поскольку для поля _lock не установлено значение true.

Листинг 9: Иммутабельный инстанс можно создать либо с помощью JSON-строки, либо с помощью изменяемого инстанса иммутабельного класса.

public class Person : ImmutableClass
    {
        private string _firstName;
        public string FirstName
        {
            get => _firstName;
            set => Setter(
                MethodBase
                    .GetCurrentMethod()
                    .Name
                    .Substring(4),
                value,
                ref _firstName);
        }
        private string _lastName;

        public string LastName
        {
            get => _lastName;
            set => Setter(
                MethodBase
                    .GetCurrentMethod()
                    .Name
                    .Substring(4),
                value,
                ref _lastName);
        }
    }


var immutablePerson = ImmutableClass.Create<Person>
                (
                "{\"FirstName\":\"John\"," +
                "\"LastName\":\"Petersen\"}"
                );
    //Или

    immutablePerson = ImmutableClass.Create
                 (
                 new Person()
                 { FirstName = "John",
                 LastName = "Petersen"}
                 );

    //Следующая строка сгенерирует исключение
    immutablePerson.FirstName = "john";

}

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

Рисунок 2: При попытке изменить неизменяемое свойство инстанса класса возникает исключение ImmutableObjectEditException.
Рисунок 2: При попытке изменить неизменяемое свойство инстанса класса возникает исключение ImmutableObjectEditException.

Свойства класса, основанного на ImmutableClass, могут быть следующих типов:

  • Boolean

  • Byte

  • SByte

  • Char

  • Decimal

  • Double

  • Single

  • Int32

  • UInt32

  • Int64

  • UInt64

  • Int16

  • UInt16

  • String

  • ImmutableArray

  • ImmutableDictionary

  • ImmutableList

  • ImmutableHashSet

  • ImmutableSortedDictionary

  • ImmutableQueue

  • ImmutableSortedSet

  • ImmutableStack

  • ImmutableClass

Первые 14 являются стандартными примитивными типами .NET. Следующие шесть, ImmutableArray, ImmutableDictionary, ImmutableList, ImmutableHashSet, ImmutableSortedDictionary и ImmutableQueue, реализуются пакетом System.Collections.Immutable Nuget. Этот Nuget пакет также предоставляет методы расширения для их базовых типов .NET, что упрощают приведение к иммутабельным аналогам. Наконец, классы на основе ImmutableClass могут содержать в себе другие классы на основе ImmutableClass. Больше информации о System.Collections.Immutable можно найти здесь.

В листинге 10 показана немного более сложная версия класса Person.

Листинг 10: Иммутабельный класс может содержать свойства с инстансами других иммутабельных классов.

public class Person : ImmutableClass
    {
        private string _firstName;
        public string FirstName
        {
            get => _firstName;
            set => Setter(
                MethodBase
                   .GetCurrentMethod()
                   .Name
                   .Substring(4),
                value,
                ref _firstName);
        }
        private string _lastName;

        public string LastName
        {
            get => _lastName;
            set => Setter(
                MethodBase
                    .GetCurrentMethod()
                    .Name
                    .Substring(4),
                value,
                ref _lastName);
        }

        private ImmutableArray<string> _schools;

        public ImmutableArray<string> Schools
        {
            get => _schools;
            set => Setter(
                MethodBase
                    .GetCurrentMethod()
                    .Name
                    .Substring(4),
                value,
                ref _schools);
        }
    }

Свойство ImmutableArray решает проблему со свойствами на основе коллекций и массивов. System.Collections.Immutable содержит иммутабельные аналоги всех базовых типов коллекций в .NET. Если вы используете .NET Core или Standard, System.Collections.Immutable также поддерживает эти платформы.

Заключение

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

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


Сегодня вечером состоится бесплатное занятие «Базы данных: реляционные базы и работа с ними», на котором мы рассмотрим несколько способов работы с реляционными базами данных в приложениях .NET на примере PostgreSQL + ADO.NET и ORM (Dapper, Entity Framework). Все желающие могут зарегистрироваться по ссылке.

Теги:
Хабы:
Всего голосов 7: ↑3 и ↓4+1
Комментарии10

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS