Как стать автором
Обновить

eb#0: Квалификаторы и машины состояний, или Высокотипизированная магия в .NET

Уровень сложностиСложный
Время на прочтение51 мин
Количество просмотров1.6K
Идёшь по Хабру, видишь двух парней в шляпах волшебников. Вдруг, резко - белый свет, какая-то синева... О, EmptyBox!
Идёшь по Хабру, видишь двух парней в шляпах волшебников. Вдруг, резко - белый свет, какая-то синева... О, EmptyBox!

Введение

Привет, Хабр! За окном весна, или даже почти лето, а значит - настало время велосипедов наступил сезон Open Source, конечно же. И у меня есть на этот счёт. В этой статье я приоткрою дверцу в собственную разработку под названием EmptyBox.

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

Фреймворк написан на языке C#, предназначен для платформы .NET версии 9 и выше, находится в состоянии POC, и обладает известными и неизвестными достоинствами и недостатками. Код фреймворка будет публиковаться частями в репозитории, почти одновременно со статьями, описывающими эти части.

Центральной темой фреймворка является шаблон квалифицированного доступа, но обо всём по порядку.

Оглавление

О наполнении

Запасайтесь ко́феем, уютно обустройтесь, сделайте всё, что будет способствовать комфортному погружению в материал, тут много букв.

Под спойлерами содержится комплементарная информация, чаще всего - некоторое количество кода. Если вам интересно лишь ознакомится с концепциями, можете в них не заглядывать, они предназначены для раскрытия и понимания деталей, для тех, кто любит следить за руками (да, фокусы). Названия спойлеров носят развлекательный характер; спойлеры, помеченные атрибутом [EB] обозначают наличие кода из фреймворка внутри.

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

Предлагаю сразу настроить IDE

Фреймворк находится в состоянии POC и попадать в ленту реестра NuGet ему рановато, поэтому есть два пути: быстрый и аутентичный.

Быстрый путь

  1. Склонировать репозиторий EmptyBox;

  2. Открыть решение EmptyBox.slnx в редакторе;

  3. Открыть файл Program.cs в проекте EmptyBox.Sandbox.

Аутентичный путь

Этот способ подходит только для владельцев аккаунта на GitHub, и вот почему. Внезапно, ни на одном из хостингов кода, заточенных под Open Source, нет доступа к общедоступному реестру пакетов без предварительной авторизации по токену. Ни GitHub, ни GitLab, ни GitVerse, ни GitFlic, ни даже Gitea не отдадут вам пакеты просто так.

Поэтому список действий следующий:

  1. Авторизация на GitHub;

  2. Генерация Personal Access Token по инструкции, необходимо указать в процессе разрешение read:packages (можно только его). Токен будет показан лишь единожды, не закрывайте страницу до выполнения 4 пункта;

  3. Создать в редакторе проект C#, нацеленный на .NET 9;

  4. Создать в проекте файл с названием NuGet.config и следующим содержимым (соответственно заменяя YOUR_GITHUB_USERNAME и YOUR_GITHUB_PAT на ваше имя и сгенерированный в пункте 2 токен):

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <packageSources>
        <clear/> <!--Удаляет все реестры пакетов из проекта-->
        <add key="NuGet" value="https://api.nuget.org/v3/index.json"/> <!--Возвращает в проект реестр NuGet-->
        <add key="EmptyBox" value="https://nuget.pkg.github.com/eb-f/index.json"/> <!--Добавляет в проект реестр EmptyBox-->
    </packageSources>
    <packageSourceCredentials>
        <EmptyBox>
            <add key="Username" value="YOUR_GITHUB_USERNAME" />
            <add key="ClearTextPassword" value="YOUR_GITHUB_PAT"/>
        </EmptyBox>
    </packageSourceCredentials>
</configuration>
  1. Модифицировать файл проекта, добавив в него ссылку на пакет EmptyBox.SDK. Пример файла:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net9.0</TargetFramework>
        <LangVersion>preview</LangVersion>
        <Nullable>enable</Nullable>
        <ImplicitUsings>true</ImplicitUsings>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="EmptyBox.SDK" Version="0.0.1-concept" /> <!--Достаточно добавить данную строку-->
    </ItemGroup>
    
</Project>
  1. Выполнить команду dotnet restore в папке с проектом (потребуется командная строка). Это позволит без перезапуска Visual Studio подобрать параметры из файла NuGet.config.

Инструменты

Дабы во время прочтения текста возникало меньше тривиальных вопросов, ниже описаны основные инструменты языка и платформы .NET, применяемые далее. Большинство из них они просты, но если по ходу прочтения возникнут вопросы - вам сюда.

Обобщённые типы и методы, ковариантность и контрвариантность

Кратко об изящном

Обобщённые типы и методы - сущности, имеющие в своей сигнатуре параметры типа.

  • Параметр типа - T в объявлении шаблонного типа public interface IEnumerable<out T> или шаблонного метода public void Process<T>(T value). Название параметра типа может быть любым;

  • Аргумент типа - int в объявлении переменной IEnumerable<int> list = ...; или вызове метода Process<int>(42);. Так же аргументом типа может являться другой параметр типа;

  • Инсталляция типа или метода - обобщённые тип или метод с указанными аргументами типа, например, Func<int, string>, IEnumerable<object> или Process<Enum>(value);

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

На параметры типа в месте их объявления могут быть наложены некоторые ограничения или разрешения, что позволяет в месте использования значения типа параметра знать о возможностях экземпляра несколько больше.

class Program
{
    static void Print<T>(T enumerable)
        where T : IEnumerable // Ограничиваем возможный набор аргументов типа реализациями интерфейса IEnumerable
    {
        // Благодаря ограничению гарантируется, что у enumerable есть метод GetEnumerator()
        // и его можно использовать в цикле foreach
        foreach (object item in enumerable)
        {
            Console.Write(item);
        }

        Console.WriteLine();
    }

    public static void Main(string[] args)
    {
        // Исходя из типа аргумента метода, а он есть string, компилятор C# сам укажет правильный аргумент типа.
        Print("Выводить строки в консоль посимвольно - неэффективно!");
        // В некоторых ситуациях указывать тип аргумента необходимо
        Print<char[]>(['О', 'с', 'о', 'б', 'е', 'н', 'н', 'о', ' ', 'е', 'с', 'л', 'и', ' ', 'и', 'х', ' ', 'м', 'н', 'о', 'г', 'о']);
    }
}

Параметры типа, объявленные с модификаторам out и in в объявлениях интерфейсов и делегатов, являются ковариантными и контрвариантными соответственно, что разрешает типу участвовать в расширенных операциях приведения типа относительно параметра:

  • Определение T (без модификатора) называется инвариантным, IExample<T> есть IExample<T> и ничего больше;

  • Определение out T допускает приведение к верху иерархии типа, от IExample<T> вплоть до IExample<object>;

  • Определение in T - приведение к низу иерархии типа, то есть к типу, реализующему или наследующему тип T, от IExample<object> вплоть до IExample<T>.

Одновременное использование модификаторов над одним параметром недопустимо.

Структуры взаимодействуют с иерархией типов через операции упаковки и распаковки, операция приведения для них недоступна. Следовательно, указание структуры в качестве аргумента типа делает инсталляцию инвариантной относительно параметра.

// Ковариантные параметры типа могут быть использованы для возвращаемых значений методов, в том числе метода get свойств, но не могут быть использованы как параметры метода. 
interface ICovariant<out T>
{
    public T Value { get; }
}

// Ситуация с контрвариантными параметрами строго обратна ковариантным.
interface IContravariant<in T>
{
    public T Value { set; }
}

// Классы всегда инвариантны
class Example<T> : ICovariant<T>, IContravariant<T>
{
    public T Value { get; set; }
}

class Program
{
    public static void Main(string[] args)
    {
        // Тип string можно интерпретировать как тип IEnumerable<char> при присвоении значения свойству Value
        Example<IEnumerable<char>> ex = new() { Value = "Hello World!" };

        // IEnumerable<char> реализует IEnumerable, поэтому ковариантное преобразование доступно
        ICovariant<IEnumerable> enumerable = ex;
        // Любой интерфейс может быть представлен типом object, поэтому ковариантное преобразование доступно
        ICovariant<object> @object = enumerable;

        Console.WriteLine($"Объект тот же самый, интерпретация другая: {ReferenceEquals(ex, enumerable)} {ReferenceEquals(ex, @object)}");

        // IReadOnlyList<char> реализует интерфейс IEnumerable<char>, поэтому контравариантное преобразование доступно
        IContravariant<IReadOnlyList<char>> list = ex;

        // Записанное в ex значение не может быть представлено как IReadOnlyList<char>.
        // Но ошибкой это не будет, ведь получить его невозможно. Зато его можно заменить:
        list.Value = ['П', 'р', 'и', 'в', 'е', 'т', ' ', 'м', 'и', 'р', '!'];

        // И так как это всё тот же объект, в консоли можно увидеть заветное

        foreach (var item in enumerable.Value)
        {
            Console.Write(item);
        }
    }
}

Данное поведение параметров типа позволяет коду стать более обобщённым, и это будет активно использоваться.

Методы расширения

Функционально о примитивном

Одна из базовых конструкций языка C# - методы расширения. Они позволяют использовать функционал, описанный отдельно от типа, в стиле использования функционала, описываемого самим типом.

static class Extension
{
    public static int QueryCount(this IEnumerable<Delegate> enumerable) => enumerable.Count();
}

class Program
{
    public static void Main(string[] args)
    {
        IEnumerable<Delegate> delegates = [new Action<string[]>(Main)];
        // Вызов метода QueryCount выглядит так, будто он был определён внутри IEnumerable<T>
        int count = delegates.QueryCount();
        Console.WriteLine(count);
    }
}

Метод QueryCount будет доступен для вызова над любым значением, соответствующем типу IEnumerable<Delegate>.

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

Рефлексия

Осознанно о самоосознанном

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

В коде использован синтаксис запросов LINQ – часть языка C#, очень похожая на SQL. Его можно использовать с любыми коллекциями (типами, реализующими интерфейс IEnumerable<out T>).

class Program
{
    static void Main(string[] args)
    {
        var methodsWhereAnyParamterIsArrayOfByte = from assembly in AppDomain.CurrentDomain.GetAssemblies()
                                                   select assembly.GetTypes() into types
                                                   from type in types
                                                   select type.GetMethods() into methods
                                                   from method in methods
                                                   where method.GetParameters().Any(x => x.ParameterType == typeof(byte[]))
                                                   select method;

        Console.WriteLine("Все методы в приложении, принимающие в качестве аргумента значение типа byte[]:");

        foreach (var method in methodsWhereAnyParamterIsArrayOfByte)
        {
            Console.WriteLine($"{method.DeclaringType}.{method.Name}({new StringBuilder().AppendJoin(", ", method.GetParameters())})");
        }
    }
}

Сколь бы мощным инструментом не была рефлексия, по сути своей её использование приводит к появлению сложного, трудно проверяемого кода. Поэтому в фреймворке EmptyBox она используется в строго необходимых местах, где иного пути мне найти не довелось.

Интерфейс IDynamicInterfaceCastable

Наглядно о волшебном

Данный интерфейс - концентрированная чёрная магия инструмент, позволяющий реализующему его классу определять, какой интерфейс он реализует в любой момент времени.

Более формально – при проведении проверки типа или попытке его преобразования (операторы is и as как пример подобных операций), в случае невозможности сопоставить проверяемое/преобразовываемое значение с целевым интерфейсом (продолжая пример - правый аргумент операторов is и as), производится дополнительная проверка на реализацию интерфейса IDynamicInterfaceCastable. Если тип проверяемого значения реализует данный интерфейс - вызывается определённый в IDynamicInterfaceCastable метод IsInterfaceImplemented/GetInterfaceImplementation соответственно проводимой операции, определяющий её конечный результат.

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

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

interface IWantToBeImplemented
{
    public string Value { get; }
    public string Description { get; }
}

file interface IContract
{
    protected string GetString()
        => throw new NotSupportedException();
}

[DynamicInterfaceCastableImplementation]
file interface IImplementer : IWantToBeImplemented, IContract
{
    string IWantToBeImplemented.Value => "Оно работает";
    string IWantToBeImplemented.Description => GetString();
}

class Example(string description) : IDynamicInterfaceCastable, IContract
{
    private readonly string Description = description;

    RuntimeTypeHandle IDynamicInterfaceCastable.GetInterfaceImplementation(RuntimeTypeHandle interfaceType)
    {
        return interfaceType.Equals(typeof(IWantToBeImplemented).TypeHandle)
             ? typeof(IImplementer).TypeHandle
             : throw new InvalidCastException();
    }

    string IContract.GetString()
    {
        return Description;
    }

    bool IDynamicInterfaceCastable.IsInterfaceImplemented(RuntimeTypeHandle interfaceType, bool throwIfNotImplemented)
    {
        if (interfaceType.Equals(typeof(IWantToBeImplemented).TypeHandle))
        {
            return true;
        }
        else if (throwIfNotImplemented)
        {
            throw new InvalidCastException();
        }
        else
        {
            return false;
        }
    }
}

class Program
{
    public static void Main(string[] args)
    {
        Example ex = new("Значение по контракту");
        IWantToBeImplemented @interface = (IWantToBeImplemented)ex;
        Console.WriteLine(@interface.Value);
        Console.WriteLine(@interface.Description);
    }
}

Следует отметить ограничение, связанное с данной механикой. Реализации методов интерфейсов кэшируются для конкретного класса с учётом аргументов типа (не его экземпляра), поэтому при динамическом приведении к различным интерфейсам с разными реализациями одного и того же метода (по иерархии типов, например, оба интерфейса IExample1 и IExample2 реализуют интерфейс IExample0 и его метод abstract void Test();) вас ждёт мир удивительного неопределённого поведения - среда исполнения не гарантирует, какая из реализаций метода будет вызвана. По этой причине следует осуществлять контроль интерфейсов, возможных к динамической реализации.

Это, пожалуй, самый важный инструмент для реализации описываемой машины состояний.

Расширения для компилятора Roslyn

Невербально о мощном
Ну вот, мне захотелось такую КДПВ в бумажном формате
Ну вот, мне захотелось такую КДПВ в бумажном формате

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

Описание генерации кода выходит за рамки и так малой статьи, поэтому сейчас это можно воспринимать как чёрный ящик, который просто работает. Впрочем, если хочется углубится в устройство генератора - исходный код, что малость печален, можно посмотреть в репозитории, проект EmptyBox.Generation.


Квалификаторы, или шаблон квалифицированного доступа

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

Вполне типичный подход к решению этой задачи можно наблюдать в платформе .NET - в ней определены типы List<T> и ReadOnlyCollection<T>, где первый может рассматриваться как построитель, а второй - как конечная цель. Однако, такое решение приводит к дублированию кода и сопутствующим проблемам.

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

Это что за .....?

Для упрощения, здесь и далее, методы для чтения информации из списка считаются доступными без определённой квалификации и не будут описываться.

/// <summary>
///     Квалификатор, обозначающий возможность записи чего-либо.
/// </summary>
interface IAW : IQualifier;

class List<Q, T>
    where Q : IQualifier
{
    internal T[] values;
}

static class ListExtensions
{
    public static void Add<T>(this List<IAW, T> list, T value)
    {
        // Делаем всякие дела с внутренним состоянием объекта
    }
}

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

Чтобы избежать подобной ситуации, реализацию методов, работающих с внутренним состоянием, вернём обратно в сущность, изменив модификатор доступа на protected internal, а методы расширения оставим как прослойку, позволяющую вызвать метод только для сущности с указанным квалификатором.

Следующая итерация выглядит несколько лучше
/// <summary>
///     Квалификатор, обозначаемый возможность записи чего-либо.
/// </summary>
interface IAW : IQualifier;

class List<Q, T>
    where Q : IQualifier
{
    private T[] values;

    protected internal virtual void Add(T value) { }
}

static class ListExtensions
{
    public static void Add<T>(this List<IAW, T> list, T value)
    {
        list.Add(value);
    }
}

Остаётся проблема доступности методов экземпляра внутри сборки - модификатор internal нужен для возможности вызвать метод в методе-расширении, а редактировать неизменяемый объект даже внутри одной сборки просто непозволительно.

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

При написании подобного кода можно стереть надписи с клавиатуры
/// <summary>
///     Квалификатор, обозначаемый возможность записи чего-либо.
/// </summary>
interface IAW : IQualifier;

class List<Q, T>
    where Q : IQualifier
{
    internal interface ListProxy
    {
        internal static void Add(List<IAW, T> list, T value)
        {
            list.Add(value);
        }
    }

    private T[] values;

    protected virtual void Add(T value) { }
}

static class ListExtensions
{
    public static void Add<T>(this List<IAW, T> list, T value)
    {
        List<IAW, T>.ListProxy.Add(list, value);
    }
}

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

Основы квалификации

Тут то мы и подкрались к первым определениям в EmptyBox. Чтобы сохранить клавиатуру целой, а ментальность здоровой, в фреймворке определены:

  • Интерфейс IQualifier - для обозначения квалификатора;

  • Интерфейс IQualified<out Q> - для идентификации объекта, поддерживающего квалифицированный доступ, а так же параметра типа, являющегося квалификатором;

  • Атрибут QualifiedAttribute<Q> и его не шаблонная версия - для указания квалификации метода.

[EB] Кусочек коробки, исписанный кодом
/// <summary>
///     Контракт квалификатора, или иначе - специализации.
/// </summary>
public interface IQualifier
{
    /// <summary>
    ///     Проверяет наличие квалификатора <typeparamref name="QFlag"/> в определении квалификатора <typeparamref name="QSet"/>.
    /// </summary>
    /// <typeparam name="QSet">
    ///     Набор квалификаторов.
    /// </typeparam>
    /// <typeparam name="QFlag">
    ///     Квалификатор, присутствие которого необходимо проверить.
    /// </typeparam>
    public static bool HasFlag<QSet, QFlag>()
        where QSet : IQualifier
        where QFlag : IQualifier
    {
        return typeof(QSet).IsAssignableTo(typeof(QFlag));
    }

    /// <summary>
    ///     Проверяет на равенство квалификаторы <typeparamref name="QLeft"/> и <typeparamref name="QRight"/>
    /// </summary>
    /// <typeparam name="QLeft">
    ///     Левый квалификатор.
    /// </typeparam>
    /// <typeparam name="QRight">
    ///     Правый квалификатор.
    /// </typeparam>
    public static bool Equals<QLeft, QRight>()
        where QLeft : IQualifier
        where QRight : IQualifier
    {
        return typeof(QLeft) == typeof(QRight);
    }
}

/// <summary>
///     Контракт объекта с квалификацией.
/// </summary>
/// <typeparam name="Q">
///     Тип квалификации.
/// </typeparam>
public interface IQualified<out Q>
    where Q : IQualifier
{
    /// <summary>
    ///     Квалификация экземпляра объекта.
    /// </summary>
    protected Type Qualification => typeof(Q);
}


/// <summary>
///     Атрибут, указывающий тип квалификатора, для которого вызов метода, отмеченного данным атрибутом, разрешён.
/// </summary>
/// <remarks>
///     Приводит к генерации метода-расширения, позволяющего вызывать отмеченный метод только для объекта с соответствующим квалификатором.
/// </remarks>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class QualifiedAttribute(Type qualifier, params string[] typeParameterNames) : Attribute
{
    /// <summary>
    ///     Аргументы типа квалификатора.
    /// </summary>
    public ImmutableArray<string> TypeParameterNames { get; } = [.. typeParameterNames];
    /// <summary>
    ///     Квалификатор доступа к методу.
    /// </summary>
    public Type? QualifierType { get; } = qualifier;

    public QualifiedAttribute(string typeParameterName) : this(null!, [typeParameterName]) { }
}

/// <summary>
///     Атрибут, указывающий тип квалификатора, для которого вызов метода, отмеченного данным атрибутом, разрешён.
/// </summary>
/// <typeparam name="Q">
///     Квалификатор доступа к методу.
/// </typeparam>
/// <remarks>
///     Приводит к генерации метода-расширения, позволяющего вызывать отмеченный метод только для объекта с соответствующим квалификатором.
/// </remarks>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public class QualifiedAttribute<Q>() : QualifiedAttribute(typeof(Q))
    where Q : class, IQualifier;

Встречая эти три компонента в коде, кодогенератор, включённый в состав фреймворка, обеспечивает генерацию шаблона квалифицированного доступа для любого метода экземпляра, помеченного атрибутом QualifiedAttribute<T>, типа, реализующего интерфейс IQualified<Q> и помеченного модификатором partial.

Пример со списком теперь выглядит слишком просто и знакомо
/// <summary>
///     Квалификатор, обозначаемый возможность записи чего-либо.
/// </summary>
internal interface IAW : IQualifier;

internal partial class List<Q, T> : IQualified<Q>
    where Q : class, IQualifier
{
    private T[] values;

    [Qualified<IAW>]
    protected virtual void Add(T value) { }
}
Тем временем кодогенератор проделывает всю самую интересную работу
/// <auto-generated/>

partial class List<Q, T>
{
    /// <auto-generated/>
    [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
    internal partial interface ListProxy
    {
        /// <summary>
        ///     Автоматически сгенерированный метод-прослойка для метода <see cref="global::EmptyBox.Sandbox.List{Q, T}.Add(T)"/>.
        /// </summary>
        [global::System.Diagnostics.StackTraceHidden]
        [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
        internal static void Add(global::EmptyBox.Sandbox.List<global::EmptyBox.Sandbox.IAW, T> qualified, T value)
        {
            if (qualified.Qualification.IsAssignableTo(typeof(global::EmptyBox.Sandbox.IAW)))
            {
                qualified.Add(value);
            }
            else
            {
                global::EmptyBox.Execution.IException.Throw<global::EmptyBox.Presentation.Permissions.InvalidQualificationException>();
            }
        }
    }
}

/// <auto-generated/>
internal static partial class ListProxyExtensions
{
    /// <inheritdoc cref="global::EmptyBox.Sandbox.List{Q, T}.Add(T)"/>
    [global::System.Diagnostics.DebuggerHidden, global::System.Diagnostics.StackTraceHidden]
    [global::System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
    public static void Add<T>(this global::EmptyBox.Sandbox.List<global::EmptyBox.Sandbox.IAW, T> qualified, T value)
        => global::EmptyBox.Sandbox.List<global::EmptyBox.Sandbox.IAW, T>.ListProxy.Add(qualified, value);
}

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

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

Квалификатор в роли флага

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

Решение данной проблемы лежит на поверхности - ковариантность параметра типа, представляющего квалификатор. Это значит, что атрибуты Qualified<Q> следует определять в контрактах - интерфейсах, которые будут реализованы целевыми классами.

Продолжая пример со списком - интерфейс IList<out Q, T> должен содержать определение метода protected void Add(T item); с объявленным над ним атрибутом Qualified<IAW>, а класс List<Q, T> - явную реализацию метода Add, дополнительные атрибуты над реализацией не требуются.

Образец только для пощупывания
/// <summary>
///     Квалификатор, обозначаемый возможность записи чего-либо.
/// </summary>
internal interface IAW : IQualifier;
/// <summary>
///     Квалификатор, обозначаемый возможность специальных действий.
/// </summary>
internal interface IAS : IQualifier;

internal partial interface IList<out Q, T> : IQualified<Q>
    where Q : class, IQualifier
{
    public nuint Count { get; }

    /// <summary>
    ///     Добавляет значение <paramref name="item"/> в список.
    /// </summary>
    [Qualified<IAW>]
    protected void Add(T item);

    /// <summary>
    ///     Удаляет все элементы из списка.
    /// </summary>
    /// <remarks>
    ///     Дополнительная квалификация S была введена для демонстрации возможностей.
    /// </remarks>
    [Qualified<IAS>]
    protected void Clear();
}

internal class List<Q, T> : IList<Q, T>
    where Q : class, IQualifier
{
    public nuint Count { get; protected set; }

    /// <remarks>
    ///     Для упрощения демонстрации квалифицированного подхода делаем простейшее изменение во внутреннем состоянии.
    /// </remarks>
    void IList<Q, T>.Add(T item)
    {
        Count++;
    }

    void IList<Q, T>.Clear()
    {
        Count = 0;
    }
}

internal class Program
{
    private static void Main(string[] args)
    {
        List<IASW, string> writableCollection = new();
        writableCollection.Add("Привет мир!");

        IList<IAW, string> collectionInterface = writableCollection;
        collectionInterface.Add("Очевидно, так тоже работает");

        IList<IQualifier, string> nonWritableInterface = collectionInterface;
        Console.WriteLine($"Элементов в коллекции: {nonWritableInterface.Count}");
        //nonWritableInterface.Add("Этот вызов метода даже не скомпилируется");

        //Без квалификации S отчистить такой список не получится
        //collectionInterface.Clear();

        //А вот с квалификацией S вполне возможно
        writableCollection.Clear();
        Console.WriteLine($"Элементов в коллекции после отчистки: {collectionInterface.Count}");

        List<IQualifier, string> readonlyCollection = new();
        //readonlyCollection.Add("Эта коллекция тоже не обладает методом Add");
        Console.WriteLine($"Элементов в коллекции только для чтения: {readonlyCollection.Count}");
    }
}

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

Архивация раздела

Преимущества использования шаблона квалифицированного доступа:

  • Уменьшает количество кода, связанного с представлением данных, не подлежащих изменению;

  • Декларативный подход к описанию возможностей сущности;

  • Гарантирует доступность вызова методов в зависимости от квалификатора благодаря проверкам компилятора - вместо выброса исключения во время исполнения, некорректный вызов метода не скомпилируется;

  • Квалификаторы могут использоваться как флаги, управляя доступностью множества групп операций над объектом;

  • Естественная интеграция в среду разработки - контекстные предложения будут учитывать квалификацию экземпляра и тому подобное.

Недостатки применения шаблона:

  • Нестандартный подход к проектированию сущностей (может потребоваться усилие для полного понимания концепции);

  • Возможно, не полностью совместимо с какими-либо инструментами, доступными для платформы .NET;

  • Квалификатор невозможно применить к свойству, поскольку в языке C# отсутствует возможность объявления свойства-расширения (однако, работы в данном направлении обозначены почти сделаны, за время написания статьи это появилось в предварительной .NET 10);

  • Оставляет открытым вопрос об изменении квалификации сущности (на самом деле - не баг, а фича, но об этом далее и ещё дальше);

  • Инструменты рефлексии способны обходить эти ограничения, могут потребоваться дополнительные проверки на квалификацию типа.

Кстати, касательно списка. Полагаю, не составит труда представить способ превратить редактируемый список в неизменяемый - достаточно скопировать внутреннее состояние списка в новый объект без квалификации IAW, например, используя конструктор - ровно так в .NET List<T> можно превратить в ReadOnlyCollection<T>. Или же придумать метод AsReadOnly(), или какой другой способ, тьма их.

О подобных коллекциях, с использованием квалификаторов, будет написано в одной из следующих статей, когда они действительно потребуются, а пока что по всем параметрам предостаточно обычных коллекций в поставке .NET.


Машина состояний

Что такое машина состояний - в общих чертах знает каждый программист, однако, если вы ещё не - ознакомьтесь с автоматом Мили, именно этот математический объект я постараюсь описать далее.

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

Как пример, моделирование задачи, используя UML-диаграммы или прочие аналоги, нередко приводит к появлению структуры, напоминающей, а то и явно представляющей абстрактный автомат. Такие структуры относительно легко переносятся в код, описывающий машину состояний.

Абстрактные автоматы довольно часто встречаются в программировании, хотя зачастую являются узкоспециализированными. Примерами из языка C# могут служить интерфейс IEnumerator<T> и асинхронная машина состояний, генерируемая компилятором при использовании ключевых слов async и await. Менее функционально обособленные машины состояний можно увидеть во фреймворках, предоставляющих инструменты для реализации подхода Saga, например, MassTransit. Существует так же тысяча различных реализаций в виде народного творчества, и вот перед вами ещё одна.

Описываемая машина состояний... Абстрактна. У неё нет узкой специализации, заинтересовавшимся конкретным применением придётся самим натягивать сову на глобус деловые процессы на предоставляемые автоматом инструменты.

Состояния

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

Состояния машины довольно, естественно и непринуждённо образуют три группы:

  • Начальное состояние - в данной группе всего один, очевидный, элемент;

  • Конечные состояния - переходя к состояниям данной группы, машина состояний считается выполнившей свою работу, то есть не может перейти к следующему этапу. Можно предусмотреть переход из этой группы к начальному состоянию в рамках программирования, но формально это будет считаться новой машиной состояний;

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

[EB] Угол коробки - переходим из плоскости квалификаторов в плоскость состояний
/// <summary>
///     Контракт состояния.
/// </summary>
public interface IState : IQualifier
{
    /// <summary>
    ///     При допустимости, пополняет состояние <paramref name="state"/> данными из текущего состояния.
    /// </summary>
    /// <typeparam name="S">
    ///     Представление пополняемого данными состояния.
    /// </typeparam>
    /// <param name="state">
    ///     Пополняемое данными состояние.
    /// </param>
    public void Map<S>(S state)
        where S : class, IState;
}

/// <summary>
///     Представляет изначальное состояние службы.
/// </summary>
public sealed class SI : IState, ISingleton<SI>
{
    public static SI Instance { get; } = new();

    private SI() { }

    public void Map<N>(N state)
        where N : class, IState
    {

    }
}

/// <summary>
///     Атрибут, позволяющий сгенерировать класс состояния на основе интерфейса, к которому данный атрибут применён.
/// </summary>
[AttributeUsage(AttributeTargets.Interface)]
public class StateAttribute : Attribute;

Контракт состояния

Для идентификации состояния в EmptyBox определён интерфейс IState с единственным объявленным членом внутри - методом void Map<N>(N state) where N : class, IState.

Метод Map предназначен для пополнения переданного в метод состояния данными из текущего состояния. Формально, такое действие должно определятся и производиться машиной состояний, то есть определёнными в ней правилами перехода из одного состояния в другое. Однако, при практическом применении, скорее всего, окажется, что определённые группы состояний обладают подмножеством характеристик, неизменных в рамках переходов состояний внутри группы. Такой характеристикой может быть, например, наличие средства ведения журнала, экземпляр которого явно не будет меняться от смены состояний. Тем не менее, вызов метода Map будет осуществляется машиной состояний.

Начальное состояние

Стандартное, начальное состояние SI (State Initialized) обозначает что машина состояний прошла этап инициализации. Аббревиатурное наименование выбрано не с проста - являясь стандартным, оно будет часто встречаться при разработке, и это будет визуально отличать его от определённых разработчиком. Такой стиль наименования будет и дальше применятся к предопределённым состояниям абстрактных автоматов, но в целом, их не так уж и много.

Класс SI не содержит каких-либо свойств, потому реализация метода Map никак не изменяет обрабатываемого состояния.

Генерация состояний на основе контрактов

Атрибут StateAtribute, определённый в EmptyBox, предназначен для указания над определением интерфейса состояния и является указанием кодогенератору создать класс, реализующий отмеченный интерфейс, и сгенерировать содержание метода Map.

Поведение генератора, обнаруживающего атрибут StateAtribute, следующее:

  • Название создаваемого класса сходно названию интерфейса - генератор убирает из имени интерфейса первый символ;

  • Каждое абстрактное свойство в иерархии реализуемых интерфейсов определяется в классе соответствующе;

  • Метод Map наполняется проверками переданного в метод состояния на реализацию интерфейсов, реализуемых сгенерированным классом, и, при успешном преобразовании, копированием значений свойств в переданное состояние (учитывая наличие такой возможности, конечно);

  • Все сгенерированные классы отмечены модификатором partial, что позволяет их дополнить.

Пример применения атрибута

Состоятельные состояния с подогревом
/// <summary>
///     Контракт состояния "Запущено".
/// </summary>
/// <remarks>
///     Указание атрибута <see cref="StateAttribute"/> приводит к генерации класса <see cref="Launched"/> - реализации данного контракта.
/// </remarks>
[State]
public interface ILaunched : ISL<Configuration>
{
    public double Temperature { get; set; }
}
/// <summary>
///     Состояние "Нагревание".
/// </summary>
/// <remarks>
///     Пример определения состояния без использования кодогенерации.
/// </remarks>
public class Heating : ILaunched, INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    
    public double Temperature
    {
        get;
        set
        {
            if (value == field)
            {
                field = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Temperature)));
            }
        }
    }
    public Configuration Configuration { get; set; }
    void IState.Map<S>(S state)
    {
        if (state is ISC<Configuration> configurable)
        {
            configurable.Configuration = Configuration;
        }    
        if (state is ILaunched launched)
        {
            launched.Temperature = Temperature;
        }
    }
}
/// <summary>
///     Контракт состояния "Требует обслуживания".
/// </summary>
/// <remarks>
///     Указание атрибута <see cref="StateAttribute"/> приводит к генерации класса <see cref="RequireMaintenance"/> - реализации данного контракта.
/// </remarks>
[State]
public interface IRequireMaintenance : ILaunched;
Руки так и чешутся написать это самому, но кодогенератор слишком заботлив

Стоит отметить, что среда разработки автоматически наследует документацию для реализуемых и наследуемых членов типа, поэтому явное наследование не требуется.

public partial class Launched : global::EmptyBox.Application.Services.Shared.ITeapot.ILaunched
{
    public global::System.Double Temperature { get; set; }
    public global::EmptyBox.Application.Services.Shared.ITeapot.Configuration Configuration { get; set; }
    
    void EmptyBox.Construction.Machines.IState.Map<S>(S state)
    {
        {
            if (state is global::EmptyBox.Application.Services.Shared.ITeapot.ILaunched variant)
            {
                global::EmptyBox.Application.Services.Shared.ITeapot.ILaunched @this = this;
                variant.Temperature = @this.Temperature;
                
            }
        }
        {
            if (state is global::EmptyBox.Application.Services.Operation.ISC<global::EmptyBox.Application.Services.Shared.ITeapot.Configuration> variant)
            {
                global::EmptyBox.Application.Services.Operation.ISC<global::EmptyBox.Application.Services.Shared.ITeapot.Configuration> @this = this;
                variant.Configuration = @this.Configuration;
                
            }
        }
        
    }
}

public partial class RequireMaintenance : global::EmptyBox.Application.Services.Shared.ITeapot.IRequireMaintenance
{
    public global::System.Double Temperature { get; set; }
    public global::EmptyBox.Application.Services.Shared.ITeapot.Configuration Configuration { get; set; }
    
    void EmptyBox.Construction.Machines.IState.Map<S>(S state)
    {
        {
            if (state is global::EmptyBox.Application.Services.Shared.ITeapot.ILaunched variant)
            {
                global::EmptyBox.Application.Services.Shared.ITeapot.ILaunched @this = this;
                variant.Temperature = @this.Temperature;
                
            }
        }
        {
            if (state is global::EmptyBox.Application.Services.Operation.ISC<global::EmptyBox.Application.Services.Shared.ITeapot.Configuration> variant)
            {
                global::EmptyBox.Application.Services.Operation.ISC<global::EmptyBox.Application.Services.Shared.ITeapot.Configuration> @this = this;
                variant.Configuration = @this.Configuration;
                
            }
        }
        
    }
}

Повторение - основа рекурсии. Фреймворк находится в состоянии POC, если у вас есть дельные мысли по данному поводу - увидимся на GitHub Discussions.

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

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

Инфраструктура машины состояний

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

Ровно как при определении понятия абстрактного автомата используется некое устройство, процесс работы которого формализуется абстракцией, так и для претворения алгоритма, описываемого в терминах машин состояний, потребуется ввести инфраструктурный объект, выполняющий описанную работу. Для упрощения дальнейшего описания, введём два термина:

  • Контейнером далее будет называться инфраструктурный объект, обеспечивающий жизненный цикл описываемого разработчиком алгоритма на основе модели абстрактного автомата;

  • Службой далее будет именоваться определение абстрактного автомата, то есть описываемый разработчиком алгоритм - набор состояний и правила их перехода.

Собственно, служба, запущенная в контейнере - и есть работающая машина состояний.

Чтобы ворочать сервисы, в EmptyBox имеются:

  • Интерфейс IStateMachineContract, позволяющий службе взаимодействовать с контейнером;

  • Интерфейс IStateMachine, описывающий основной функционал контейнера, реализует IStateMachineContract;

  • Интерфейс IService<out SQ>, идентифицирующий службу и аргумент типа, представляющий её состояние. Этот интерфейс реализует IStateMachineContract, но не содержит в себе объявлений;

  • Класс QualifiedStateMachine<S>, представляющий обычную квалифицированную машину состояний, реализует интерфейсы IStateMachine и IDynamicInterfaceCastable;

  • Атрибут SwitchToAttribute<SQ>, указывающий в каком состоянии должна находится служба по выполнении метода. Обычно, применяется в паре с QualifiedAttribute<Q>;

  • Исключение ContractViolationException, возникающее при неисполнении трансформации состояния машины, описанной в контракте службы атрибутом SwitchToAttribute<SQ>;

  • Прочие вспомогательные элементы, суть которых ясна из названия - IStateMachineFactory, QualifiedStateMachineFactory и другие.

[EB] Корпускулярно-волновое полотно коробки
/// <summary>
///     Контракт между машиной состояний и запущенной в ней службой.
/// </summary>
public interface IStateMachineContract
{
    /// <summary>
    ///     Текущее состояние машины.
    /// </summary>
    protected IState State => throw new NotSupportedException();

    /// <summary>
    ///     Изменяет состояние машины. 
    /// </summary>
    /// <typeparam name="SQ">
    ///     Тип нового состояния.
    /// </typeparam>
    /// <returns>
    ///     Обновлённое состояние машины.
    /// </returns>
    protected sealed SQ Switch<SQ>()
        where SQ : class, IState, new()
    {
        return Switch(new SQ());
    }

    /// <summary>
    ///     Изменяет состояние машины. 
    /// </summary>
    /// <typeparam name="SQ">
    ///     Тип нового состояния.
    /// </typeparam>
    /// <param name="newState">
    ///     Новое состояние машины.
    /// </param>
    /// <returns>
    ///     Обновлённое состояние машины.
    /// </returns>
    protected SQ Switch<SQ>(SQ newState)
        where SQ : class, IState
    {
        throw new NotSupportedException();
    }
}

/// <summary>
///     Контракт машины состояний.
/// </summary>
public interface IStateMachine : IDynamicInterfaceCastable, IStateMachineContract
{
    /// <summary>
    ///     Событие, возникающее при смене состояния службой.
    /// </summary>
    public event StateChangedEventHandler? StateChanged;

    /// <summary>
    ///     Текущий контракт службы, исполняемый машиной состояний.
    /// </summary>
    public Type Contract { get; }
}

/// <summary>
///     Представление службы.
/// </summary>
/// <typeparam name="SQ">
///     Представление состояния службы.
/// </typeparam>
public interface IService<out SQ> : IQualified<SQ>, IStateMachineContract
    where SQ : class, IState
{
    Type IQualified<SQ>.Qualification => State.GetType();
}

/// <summary>
///     Машина состояний.
/// </summary>
/// <typeparam name="S">
///     Контракт службы.
/// </typeparam>
[RequiresDynamicCode("Конструирование машины состояний.")]
public class QualifiedStateMachine<[DynamicallyAccessedMembers(DynamicallyAccessedMembers)] S> : IStateMachine, IDynamicInterfaceCastable
    where S : class, IService<SI>
{
    /// <summary>
    ///     Перечисление членов контракта, доступ к которым осуществляется при помощи рефлексии.
    /// </summary>
    /// <remarks>
    ///     Вспомогательное значение для AOT-компиляции.
    /// </remarks>
    public const DynamicallyAccessedMemberTypes DynamicallyAccessedMembers = DynamicallyAccessedMemberTypes.Interfaces;

    /// <summary>
    ///     Слабая ссылка на кэш вариаций контракта <typeparamref name="S"/> для различных состояний.
    /// </summary>
    private static readonly WeakRef<ConcurrentDictionary<Type, Type>> ContractVariationStorageWeakReference = new();

    private static readonly Type RepresentationBase;

    /// <summary>
    ///     Ленивый конструктор кэша вариаций контракта.
    /// </summary>
    private static ConcurrentDictionary<Type, Type> ContractVariationStorage => LazyInitializer.EnsureInitialized(ref ContractVariationStorageWeakReference.Value, static () =>
    {
        ConcurrentDictionary<Type, Type> storage = new();
        storage[typeof(SI)] = typeof(S);

        return storage;
    });

    /// <summary>
    ///     Определяет базовую корректность контракта <typeparamref name="S"/>.
    /// </summary>
    protected static bool IsContractValid { get; }

    static QualifiedStateMachine()
    {
        IsContractValid = typeof(S).IsInterface
                       && typeof(S).IsGenericType
                       && typeof(S).GetCustomAttribute<DynamicInterfaceCastableImplementationAttribute>() != null;

        RepresentationBase = IsContractValid
                           ? AdoptContract(typeof(IState), typeof(S))
                           : typeof(void);
    }

    /// <summary>
    ///     Адаптирует тип контракта к иному состоянию.
    /// </summary>
    /// <param name="stateType">
    ///     Тип состояния, в котором будет находится адаптированный контракт.
    /// </param>
    /// <param name="contractType">
    ///     Тип контракта, для которого будет проводится адаптация.
    /// </param>
    /// <returns>
    ///     Адаптированный контракт.
    /// </returns>
    /// <remarks>
    ///     Сигнатура метода соответствует параметру <see langword="valueFactory"/> метода <see cref="ConcurrentDictionary{TKey, TValue}.GetOrAdd{TArg}(TKey, Func{TKey, TArg, TValue}, TArg)"/>.
    /// </remarks>
    
    private static Type AdoptContract(Type stateType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type contractType)
    {
        return contractType.MakeConstructedGenericTypeLike(typeof(IQualified<>).MakeGenericType(stateType));
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static R DynamicInterfaceCastThrow<R>([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type interfaceType)
    {
        if (interfaceType.IsAssignableTo(RepresentationBase))
        {
            return IException.Throw<InvalidQualificationException, R>();
        }
        else if (interfaceType.IsGenericType && interfaceType.IsAssignableTo(typeof(IQualified<IState>)))
        {
            try
            {
                Type upcastedInterface = AdoptContract(typeof(IState), interfaceType);

                if (upcastedInterface.IsAssignableFrom(RepresentationBase))
                {
                    goto INVALID_QUALIFICATION_EXCEPTION;
                }
            }
            catch { }

        }

        return IException.Throw<InvalidOperationException, R>();

    INVALID_QUALIFICATION_EXCEPTION:
        return IException.Throw<InvalidQualificationException, R>();
    }

    /// <summary>
    ///     Кэш вариаций контракта <typeparamref name="S"/> для различных состояний.
    /// </summary>
    private readonly ConcurrentDictionary<Type, Type> ContractVariations = ContractVariationStorage;

    /// <remarks>
    ///     Требуется для инициализации без выполнения логики в методе <see langword="set"/> свойства <see cref="State"/>.
    /// </remarks>
    private IState _State = SI.Instance;

    public event StateSwitchedEventHandler? StateSwitched;

    [SuppressMessage("Trimming", "IL2111:Method with parameters or return value with `DynamicallyAccessedMembersAttribute` is accessed via reflection. Trimmer can't guarantee availability of the requirements of the method.", Justification = "Соответствующий параметр типа отмечен атрибутом.")]
    public IState State
    {
        get => _State;
        protected set
        {
            if (value.GetType() != _State.GetType())
            {
                Contract = ContractVariations.GetOrAdd(value.GetType(), AdoptContract, Contract);
            }

            _State = value;
        }
    }
    public Type Contract { get; private set; } = typeof(S);

    public QualifiedStateMachine()
    {
        if (!IsContractValid)
        {
            throw new InvalidContractException();
        }
    }

    SQ IStateMachineContract.Switch<SQ>(SQ newState)
    {
        IState oldState = State;
        oldState.Map(newState);
        State = newState;
        OnStateSwitch(oldState);

        if (oldState is IDisposable disposableState)
        {
            disposableState.Dispose();
        }

        return newState;
    }

    RuntimeTypeHandle IDynamicInterfaceCastable.GetInterfaceImplementation(RuntimeTypeHandle interfaceTypeHandle)
    {
        Type interfaceType = Type.GetTypeFromHandle(interfaceTypeHandle)!;

        if (interfaceType == Contract || Contract.IsAssignableTo(interfaceType))
        {
            return Contract.TypeHandle;
        }

        return DynamicInterfaceCastThrow<RuntimeTypeHandle>(interfaceType);
    }

    bool IDynamicInterfaceCastable.IsInterfaceImplemented(RuntimeTypeHandle interfaceTypeHandle, bool throwIfNotImplemented)
    {
        Type interfaceType = Type.GetTypeFromHandle(interfaceTypeHandle)!;

        if (interfaceType == Contract || Contract.IsAssignableTo(interfaceType))
        {
            return true;
        }
        else if (throwIfNotImplemented)
        {
            return DynamicInterfaceCastThrow<bool>(interfaceType);
        }
        else
        {
            return false;
        }
    }

    /// <summary>
    ///     Вызывает событие <see cref="StateSwitched"/>.
    /// </summary>
    /// <param name="previousState">
    ///     Предшествующее состояние.
    /// </param>
    protected virtual void OnStateSwitch(IState previousState)
    {
        if (!ReferenceEquals(previousState, State))
        {
            StateSwitched?.Invoke(this, previousState, State);
        }
    }
}

/// <summary>
///     Атрибут, указывающий на состояние службы после выполнения метода, результат которого помечен данным атрибутом.
/// </summary>
/// <remarks>
///     Данный атрибут можно использовать лишь единожды. Для возможности перехода в различные состояния в качестве параметра укажите базовый тип для всех состояний, в которые может перейти служба по завершению исполнения данного метода.
/// </remarks>
[AttributeUsage(AttributeTargets.ReturnValue, AllowMultiple = false, Inherited = true)]
public class SwitchToAttribute(Type qualifier, params string[] typeParameterNames) : Attribute
{
    public Type? QualifierType { get; } = qualifier;
    public ImmutableArray<string> TypeParameterNames { get; } = [.. typeParameterNames];

    public SwitchToAttribute(string typeParameterName) : this(null!, [typeParameterName]) { }
}

/// <summary>
///     Атрибут, указывающий на состояние службы после выполнения метода, результат которого помечен данным атрибутом.
/// </summary>
/// <typeparam name="SQ">
///     Новое состояние службы.
/// </typeparam>
/// <remarks>
///     Данный атрибут можно использовать лишь единожды. Для возможности перехода в различные состояния в качестве <typeparamref name="SQ"/> укажите базовый тип для всех состояний, в которые может перейти служба по завершению исполнения данного метода.
/// </remarks>
[AttributeUsage(AttributeTargets.ReturnValue, AllowMultiple = false, Inherited = true)]
public class SwitchToAttribute<SQ>() : SwitchToAttribute(typeof(SQ))
    where SQ : class, IState;

public class ContractViolationException(string? message = default, Exception? innerException = null) : Exception(message, innerException)
{
    public ContractViolationException() : this(default, null) { }
}

/// <summary>
///     Контракт фабрики служб.
/// </summary>
public interface IServiceFactory<out M>
    where M : class, IStateMachine
{
    /// <summary>
    ///     Создаёт машину состояний и инициализирует в ней службу <typeparamref name="S"/>.
    /// </summary>
    /// <typeparam name="S">
    ///     Контракт службы.
    /// </typeparam>
    /// <returns>
    ///     Экземпляр службы в состоянии <see cref="SI"/>.
    /// </returns>
    [RequiresDynamicCode("Конструирование машины состояний.")]
    public S Initialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] S>()
        where S : class, IService<SI>;

    /// <summary>
    ///     Создаёт машину состояний и инициализирует в ней службу <typeparamref name="S"/>.
    /// </summary>
    /// <typeparam name="S">
    ///     Контракт службы.
    /// </typeparam>
    /// <param name="service">
    ///     Экземпляр службы в состоянии <see cref="SI"/>.
    /// </param>
    /// <returns>
    ///     Экземпляр машины состояний.
    /// </returns>
    [RequiresDynamicCode("Конструирование машины состояний.")]
    public M Initialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] S>(out S service)
        where S : class, IService<SI>;
}

public sealed class QualifiedStateMachineFactory : IServiceFactory<IStateMachine>, ISingleton<QualifiedStateMachineFactory>
{
    public static QualifiedStateMachineFactory Instance { get; } = new();

    private QualifiedStateMachineFactory() { }

    [RequiresDynamicCode("Конструирование машины состояний.")]
    IStateMachine IServiceFactory<IStateMachine>.Initialize<[DynamicallyAccessedMembers(QualifiedStateMachine<S>.DynamicallyAccessedMembers)] S>(out S service)
    {
        return Initialize(out service);
    }

    [RequiresDynamicCode("Конструирование машины состояний.")]
    public S Initialize<[DynamicallyAccessedMembers(QualifiedStateMachine<S>.DynamicallyAccessedMembers)] S>()
        where S : class, IService<SI>
    {
        return (S)(object)new QualifiedStateMachine<S>();
    }

    [RequiresDynamicCode("Конструирование машины состояний.")]
    public QualifiedStateMachine<S> Initialize<[DynamicallyAccessedMembers(QualifiedStateMachine<S>.DynamicallyAccessedMembers)] S>(out S service)
        where S : class, IService<SI>
    {
        QualifiedStateMachine<S> machine = new();
        service = (S)(object)machine;

        return machine;
    }
}

Контракт между контейнером и службой

Интерфейс IStateMachineContract содержит в себе не так уж и много элементов:

  • Свойство State - позволяет получить службе её текущее состояние от контейнера;

  • Два метода Switch<SQ> - сообщают контейнеру о необходимости перехода в новое состояние и возвращают его (состояние). Один из методов требует от SQ (State Qualifier) конструктор без параметров, второй - требует в качестве аргумента созданный экземпляр состояния.

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

Квалифицированная машина состояний

IStateMachine, то бишь - любой контейнер, определяемый с использованием EmptyBox, обладает 2 характеристиками:

  • Состояние, представленное свойством State;

  • Контракт службы, представленный свойством Contract.

А так же способами взаимодействия, описанными в IStateMachineContract, и событием StateSwitched.

На данный момент, единственным классом в EmptyBox, напрямую реализующим интерфейс IStateMachine, является тип QualifiedStateMachine<S>, где S - контракт службы в состоянии SI, что означает возможность приведения аргумента параметра типа S к инсталляции IService<SI>.

Состояние машины, отражаемое свойством State, инициализировано значением SI.Instance. Смена состояния производится методом Switch<SQ>, однако, данный метод не доступен для вызова извне машины состояний. Если предыдущее состояние реализует интерфейс IDisposable, для него будет вызван метод Dispose после обработки события StateSwitched.

Свойство Contract, изначально заданное значением typeof(S), обновляется со сменой типа, представляющего состояние. Созданием нового типа контракта службы (подменой квалификатора) занимается метод расширения MakeConstructedGenericTypeLike, но поля этой статьи слишком узки для него. Значение свойства используется в реализации методов интерфейса IDynamicInterfaceCastable для определения нижней границы приведения типа при помощи метода IsAssignableTo.

Об атрибуте SwitchTo

Данный атрибут применяется только к возвращаемому параметру метода, представленного типом void, Task или ValueTask, а метод должен содержаться в интерфейсе, реализующем IService<SQ>. Объявить этот атрибут можно лишь единожды.

Обнаруживая данный атрибут, использованный совместно с QualifiedAttribute, генератор вносит изменения в возвращаемый параметр методов-прослоек:

  • void заменяется на службу в новом состоянии, например, IService<Launched>;

  • Task или ValueTask - её же, но обёрнутую в задачу, например, ValueTask<IService<Launched>>.

Помимо этого, генерируемый метод-прослойка дополняется отловом исключений и проверкой выполнения контракта - соответствия типа состояния описанному атрибутом. Существует 4 варианта развития событий:

  1. Было отловлено исключение, но контракт был выполнен - исключение передаётся дальше по стеку вызовов;

  2. Было поймано исключение и контракт не выполнен - продуцируется исключение ContractViolationException с заданным свойством InnerException;

  3. Вызов целевого метода прошёл без исключений, но контракт не выполнен - выбрасывается исключение ContractViolationException;

  4. Успешное выполнение - возврат службы в указанном состоянии.

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

Генератор проводит работу над каждым интерфейсом, где присутствуют методы, отмеченные данным атрибутом. Это означает, что при реализации интерфейса IRoot<SQ> интерфейсом ILeaf<SQ>, каждый отмеченный метод из IRoot<SQ> будет повторно снабжён прослойкой, но уже для интерфейса ILeaf<SQ>, что позволит избежать потери типизации. Да, для экземпляра типа ILeaf<SQ> будут доступны два метода расширения, различающихся лишь типом возвращаемого значения, но это не приводит к неоднозначности. Правила разрешения перегрузок вызовов компилятора C# определяют приоритет перегрузок таким образом, что будет вызван более подходящий по переданным аргументам метод.

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

Важно, что применение данного атрибута не является обязательным. Это может быть полезно при необходимости иметь возвращаемое значение метода, отличное от void, Task и ValueTask, хотя оставит разработчика без информации о типе нового состояния, и код после вызова подобного метода станет местом потенциальной ошибки. Вероятно, не будет ошибкой переход в состояние, производное (наследующееся) от текущего состояния - все доступные для прошлого состояния методы останутся доступными и для нового состояния.

Кроме того, наличие атрибута не обязывает переключать состояние лишь единожды за время исполнения метода, он указывает только на конечное (в рамках метода) состояние. Эта особенность позволяет определять промежуточные состояния, например, реализующие INotifyPropertyChanged, удобные для отслеживания прогресса длительной операции.

Структура машины состояний

Рассмотрим же пример определения службы.

Вариант сервировки службы
/// <summary>
///     Содержит сопутствующие службе <see cref="Teapot{SQ}"/> состояния и конфигурацию. 
/// </summary>
public partial interface ITeapot
{
    /// <summary>
    ///     Конфигурация службы <see cref="Teapot{SQ}"/>
    /// </summary>
    public readonly struct Configuration
    {
        public required double HeatingRate { get; init; }
        public double? BaseTemperature { get; init; }
    }

    /// <summary>
    ///     Контракт состояния "Запущено".
    /// </summary>
    /// <remarks>
    ///     Указание атрибута <see cref="StateAttribute"/> приводит к генерации класса <see cref="Launched"/> - реализации данного контракта.
    /// </remarks>
    [State]
    public interface ILaunched : ISL<Configuration>
    {
        public double Temperature { get; set; }
    }

    /// <summary>
    ///     Состояние "Нагревание".
    /// </summary>
    /// <remarks>
    ///     Пример определения состояния без использования кодогенерации.
    /// </remarks>
    public class Heating : ILaunched, INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler? PropertyChanged;
        
        public double Temperature
        {
            get;
            set
            {
                if (value == field)
                {
                    field = value;
                    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Temperature)));
                }
            }
        }
        public Configuration Configuration { get; set; }

        void IState.Map<S>(S state)
        {
            if (state is ISC<Configuration> configurable)
            {
                configurable.Configuration = Configuration;
            }    

            if (state is ILaunched launched)
            {
                launched.Temperature = Temperature;
            }
        }
    }

    /// <summary>
    ///     Контракт состояния "Требует обслуживания".
    /// </summary>
    /// <remarks>
    ///     Указание атрибута <see cref="StateAttribute"/> приводит к генерации класса <see cref="RequireMaintenance"/> - реализации данного контракта.
    /// </remarks>
    [State]
    public interface IRequireMaintenance : ILaunched;
}

/// <summary>
///     Служба выставочного автоматизированного самовара.
/// </summary>
/// <typeparam name="SQ">
///     Представление состояния службы.
/// </typeparam>
/// <remarks>
///     Чаю?
/// </remarks>
[DynamicInterfaceCastableImplementation]
public partial interface Teapot<out SQ> : ITeapot, IManageableService<SQ, ITeapot.Launched>, IConfigurableService<SQ, ITeapot.Configuration>
    where SQ : class, IState
{
    // Реализация метода расширения контракта машины состояний
    ValueTask IManageableService<SQ>.Launch(CancellationToken cancellationToken)
    {
        // Свойство State представлено типом IState, поэтому требуется преобразование
        // Однако, машина состояний гарантирует, что свойство State содержит значения,
        // представимые типами, перечисленными в атрибутах Qualified
        if (State is SC<Configuration> configured)
        {
            // Переключаемся в состояние "Запущено"
            // Значение свойства Configuration состояния SC<Configuration> переносится в состояние Launched автоматически
            // машиной состояний при помощи вызова метода Map
            _ = Switch(new Launched()
            {
                Temperature = configured.Configuration.BaseTemperature ?? 26,
            });
        }

        return ValueTask.CompletedTask;
    }

    ValueTask IManageableService<SQ>.Stop(CancellationToken cancellationToken)
    {
        _ = Switch<SC<Configuration>>();

        return ValueTask.CompletedTask;
    }

    /// <summary>
    ///     Нагревает содержимое.
    /// </summary>
    /// <param name="cancellationToken"></param>
    /// <remarks>
    ///     Тратит некоторое время на поднятие температуры.
    /// </remarks>
    [Qualified<Launched>]
    [return: SwitchTo<Launched>]
    protected async ValueTask Heat(CancellationToken cancellationToken = default)
    {
        // Свойство State представлено типом IState, поэтому требуется преобразование
        // Однако, машина состояний гарантирует, что свойство State содержит значения,
        // представимые типами, перечисленными в атрибутах Qualified
        if (State is Launched launched)
        {
            // Закомментированная, или отсутствующая, проверка на неравенство 0 значения HeatingRate
            // является бессознательным нарушением контракта, ведь в методе присутствует деление на данное значение
            // и последующее преобразование результата к типу Int32
            if (double.IsFinite(launched.Configuration.HeatingRate)/* && launched.Configuration.HeatingRate > 0*/)
            {
                // Переключаемся в состояние "Нагревание"
                // Значение свойств Configuration и Temperature состояния Launched переносится в состояние Heating автоматически
                // машиной состояний при помощи вызова метода Map
                // Не является нарушением контракта, если после этого вызова будет выполнен вызов Switch<Launched>()
                Heating heatingState = Switch<Heating>();

                // При делении на 0 и последующем преобразовании double.PositiveInfinity к типу Int32
                // будет создано исключение System.OverflowException, что прервёт исполнение метода
                // и приведёт к нарушению контракта
                int intervals = checked((int)((100 - double.Clamp(heatingState.Temperature, 0, 100)) / heatingState.Configuration.HeatingRate));

                for (int count = 0; count < intervals; count++)
                {
                    await Task.Delay(100, cancellationToken);
                    heatingState.Temperature += heatingState.Configuration.HeatingRate;
                }

                // Переключаемся в состояние "Запущено"
                // Значение свойств Configuration и Temperature состояния Heating переносится в состояние Launched автоматически
                // машиной состояний при помощи вызова метода Map
                _ = Switch<Launched>();
            }
            else
            {
                // Сознательное нарушение - состояние по завершению метода Heat не представимо указанным типом в атрибуте SwitchTo
                // По завершению исполнения метода Heat будет сгенерировано исключение ContractViolationException
                // Отловив данное исключение можно проверить состояния службы и произвести необходимые действия
                // Например, вызвать метод Maintenance
                _ = Switch<RequireMaintenance>();
            }
        }
    }

    /// <summary>
    ///     Обслуживание самовара.
    /// </summary>
    /// <param name="cancellationToken">
    ///     Токен отмены действия.
    /// </param>
    /// <remarks>
    ///     А вы пробовали перезагрузить устройство?
    /// </remarks>
    [Qualified<RequireMaintenance>]
    [return: SwitchTo<Launched>]
    protected async ValueTask Maintenance(CancellationToken cancellationToken = default)
    {
        // В отличии от метода Switch контракта IStateMachineContract
        // методы, определённые в интерфейсах, наследующихся от IService<SQ> зависят от состояния.
        // Поэтому, после смены состояния методом Switch, вызов таких методов будет являться ошибкой -
        // машина состояний создаст исключение ContractViolationException.
        // Для правильной типизации используем внутреннюю прослойку из интерфейса TeapotProxy
        var configured = await TeapotProxy.Stop((Teapot<ISL>)this, cancellationToken);
        await TeapotProxy.Launch(configured, cancellationToken);
    }
}

На оформление состояний в виде вложенных типов есть и объективная причина. Может, состояния в рамках одной службы и уникальны, но вполне возможно совпадение по имени состояний, принадлежащих разным службами. Ситуацию, происходящую в среде разработки при её попытке подсказать используемый тип, хорошо описал Д. И. Фонвизин:

Я, когда в гостях обедаю, принужден обыкновенно вставать голодный. Часто подле меня стоит такое кушанье, которого есть не хочу, а попросить с другого края не могу, потому что слеп и чего просить — не вижу.

Ну и в чём же он был не прав?
Ну и в чём же он был не прав?

И так, определение службы состоит из:

  • Набора состояний, в которых служба может находится;

  • Интерфейса - представления службы, реализующего ISevice<out SQ>;

    • Ковариантного параметра интерфейса out SQ - текущего состояния службы, являющегося аргументом в реализации интерфейса IService<SQ>;

    • Атрибута DynamicInterfaceCastableImplementation, применённого к интерфейсу;

  • Методов с реализациями, помеченных атрибутами QualifiedAttribute<Q> и SwitchToAttribute<SQ> для контроля доступности и смены состояния соответственно;

    • Методы, изменяющие состояние машины, содержат вызов метода Switch;

Взаимосвязь контейнера и службы

Указывая службу в качестве аргумента типа S класса QualifiedStateMachine<S> при создании экземпляра контейнера, свойство Contract будет содержать значение typeof(S), а свойство State будет содержать экземпляр состояния SI.Instance.

Таким образом, с момента создания экземпляра контейнера, этот экземпляр можно привести к интерфейсу, представляющему службу, то есть фактически превратить его в службу. Да, изъясняясь на языке C#, ReferenceEquals(machine, service) вернёт true.

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

Некоторые практические вопросы

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

Обращение к состоянию службы изнутри

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

Однако, тип свойства State определён как IState, поскольку, со стороны контейнера, тип поля, в котором хранится состояние, неизменен; а данный интерфейс выступает в роли общего знаменателя для всех состояний.

В связи с этой особенностью, во время написания кода в методе внутри службы перед использованием данных из состояния, значение состояния требуется преобразовать к необходимому типу, обычно - указанному в атрибуте Qualified<Q>. К великому сожалению, даже средствами кодогенерации, обеспечить правильное представление состояние путём считывания информации из атрибута невозможно.

По этой же причине методы Switch возвращают новое состояние службы - чтобы не производить лишних приведений типа.

Обращение к состоянию службы снаружи

Для проверки состояния службы по типу достаточно выполнить операцию is или switch.

Встречают по одёжке, провожают по уму
IService<IState> service = ...;

if (service is IService<SI> initialized)
{
    ...
}
else
{
    switch (service)
    {
        case IService<SC> configured: break;
        case IService<Launched> launched: break;
        default: throw new NotSupportedException();
    }
}

Чтобы получить экземпляр состояния, службу необходимо преобразовать обратно к контейнеру - интерфейсу IStateMachine, и воспользоваться свойством State. Такое устройство машины состояний подчёркивает, что в общем случае доступ к внутреннему состоянию нежелателен. Как говорится: "Работает - не трогай". Однако, если потребуется, поступить сообразно интерфейсу IStateMachine (см. определение свойства State) препятствий нет.

Так же можно подписаться на событие StateChanged и получать предыдущие и новые состояния в формате уведомлений.

Хранение экземпляра службы

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

Попытка использовать неправильную в моменте ссылку на службу приведёт к созданию исключения типа InvalidQualificationException.

Если не требуется сохранять информацию о контракте службы, вместо этого можно хранить ссылку на контейнер в виде значения типа IStateMachine. Иначе следует хранить ссылку на службу в обобщённом варианте, например как IService<IState>, что, по правилам ковариантности, доступно для любого состояния службы.

Асинхронный доступ к службе

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

Многопоточный доступ

А вот многопоточный доступ к машине состояний является нетривиальной задачей, по крайней мере в той части, где происходит смена состояния.

Одним из способов решения такой задачи является создание внутреннего диспетчера операций, например как в WPF, выполняющего смену и/или доступ к состоянию в одном потоке. Однако, на данный момент, инструментария во фреймворке по этому поводу нет.

В остальном, многопоточный доступ в рамках одного состояния ничем не отличается от типичных задач синхронизации исполнения алгоритма.

Шаблоны контрактов для служб

Объявление машины состояний в описанном виде позволяет не только получать квалифицированный по состоянию доступ, но и обобщать его естественным образом - созданием интерфейсов.

Фреймворк определяет несколько контрактов, позволяющих удобно управлять службами их реализующими. Начнём с самого простого - конфигурации.

Конфигурируемые службы

Для представления службы как конфигурируемой в EmptyBox имеется:

  • Интерфейс ISC и его сгенерированный собрат SC - контракт и непосредственно само состояние сконфигурированной службы, не обладающее характеристиками;

  • Интерфейс ISC<C> и его сконструированный товарищ SC<C> - такие же, как и предыдущие, но со свойством Configuration типа C;

  • Интерфейс IConfigurableService<out SQ, in BC> - шаблонный контракт, где BC есть базовое представление конфигурации.

[EB] Оригами? Ан нет, ещё один кусочек коробки
/// <summary>
///     Контракт состояния сконфигурированной службы.
/// </summary>
[State]
public interface ISC : IState;

/// <summary>
///     Контракт состояния сконфигурированной службы.
/// </summary>
/// <typeparam name="C">
///     Представление конфигурации службы.
/// </typeparam>
[State]
public interface ISC<C> : ISC
{
    /// <summary>
    ///     Конфигурация службы.
    /// </summary>
    public C Configuration { get; set; }
}

/// <summary>
///     Состояние сконфигурированной службы.
/// </summary>
/// <typeparam name="C">
///     Представление конфигурации службы.
/// </typeparam>
public partial class SC<C> : SC;

/// <summary>
///     Контракт конфигурируемой службы.
/// </summary>
/// <typeparam name="SQ">
///     Представление состояния службы.
/// </typeparam>
/// <typeparam name="CB">
///     Базовое представление конфигурации службы.
/// </typeparam>
public partial interface IConfigurableService<out SQ, in CB> : IService<SQ>
    where SQ : class, IState
{
    /// <summary>
    ///     Конфигурирует управляемую службу.
    /// </summary>
    /// <returns>
    ///     Служба в сконфигурированном состоянии.
    /// </returns>
    [Qualified<SI>, Qualified<SC>]
    [return: SwitchTo(typeof(SC<>), nameof(C))]
    protected void Configure<C>(C configuration)
        where C : CB
    {
        _ = Switch(new SC<C>() { Configuration = configuration });
    }
}

Отдельно отмечу вариацию использования атрибута SwitchTo. Его декларация в таком виде позволяет генератору более точно отражать конечное состояние службы - оно может зависеть от параметра типа C.

Реализация интерфейса IConfigurableService<out SQ, in CB> службой определяет что она является конфигурируемой, и предоставляет особую ручку взаимодействия с ней - метод Configure<C>, где C - CB (Configuration Base) или его наследник.

Configure<C> реализован контрактом, службе, без пущей необходимости, изменять реализацию не требуется. Вызов данного метода у службы с квалификацией SI или SC приводит к переключению её состояния на SC<C>.

Если другие состояния службы реализуют интерфейс ISC<CB>, то автогенерируемый метод Map будет перемещать конфигурацию из состояния в состояние. При обращении к таким состояниям, в том числе SC<C>, через свойство Configuration можно получить доступ к конфигурации.

Таким образом, служба, указавшая реализацию данного интерфейса, автоматически получает:

  • Новое состояние - SC<C>;

  • Способ конфигурации - метод Configure<C>;

  • Возможность сохранения конфигурации между состояниями.

Управляемые службы

Помимо конфигурации, типовой потребностью является управление службой в рамках состояний "запущено" и "остановлено", для чего в EmptyBox созданы:

  • Интерфейс ISL - контракт состояния, интерпретируемого как "запущено", реализации по умолчанию нет;

  • Интерфейс IManageableService<out SQ> - шаблонный контракт, декларирующий способы запуска и остановки службы;

  • Интерфейс IManageableService<out SQ, out SL, in BC> - расширенная версия шаблона, позволяющая более точно указывать конечное состояние по выполнению операций запуска и, в будущем, остановки.

[EB] Материя коробки, лишь отдалённо напоминающая ленту Мёбиуса
/// <summary>
///     Контракт состояния запущенной службы.
/// </summary>
public interface ISL : ISC;

/// <summary>
///     Контракт управляемой службы.
/// </summary>
/// <typeparam name="SQ">
///     Представление состояния управляемой службы.
/// </typeparam>
public partial interface IManageableService<out SQ> : IService<SQ>
    where SQ : class, IState
{
    /// <summary>
    ///     Запускает управляемую службу.
    /// </summary>
    /// <returns>
    ///     Служба в запущенном состоянии.
    /// </returns>
    [Qualified<SC>]
    [return: SwitchTo<ISL>]
    protected ValueTask Launch(CancellationToken cancellationToken = default);

    /// <summary>
    ///     Останавливает управляемую службу.
    /// </summary>
    /// <returns>
    ///     Служба в сконфигурированном состоянии.
    /// </returns>
    [Qualified<ISL>]
    [return: SwitchTo<SC>]
    protected ValueTask Stop(CancellationToken cancellationToken = default);
}

/// <summary>
///     Расширенный контракт управляемой службы, позволяющий более точно описать состояние службы по завершению выполнения метода <see cref="IManageableService{SQ}.Launch(CancellationToken)"/>.
/// </summary>
/// <typeparam name="SQ">
///     Представление состояния управляемой службы.
/// </typeparam>
/// <typeparam name="SL">
///     Представление состояния запущенной службы.
/// </typeparam>
public partial interface IManageableService<out SQ, out SL> : IManageableService<SQ>
    where SQ : class, IState
    where SL : class, ISL
{
    [Qualified<SC>]
    [return: SwitchTo(nameof(SL))]
    abstract ValueTask IManageableService<SQ>.Launch(CancellationToken cancellationToken);
}

Обращаю внимание читателя на занимательную возможность объявлений членов в интерфейсах - реабстракция. Используя этот механизм, можно дополнить член типа необходимыми атрибутами. В нашем случае, в IManageableService<out SQ, out SL>, это позволяет более точно указать конечное состояние службы.

Базовый шаблон IManageableService<out SQ> объявляет два метода без реализации - Launch и Stop, доступных из состояний SC (State Configured) и ISL (Interface of State Launched) соответственно, и переводящих службу в состояния ISL и SC соответственно. Реализация этих методов должна предоставляться самой службой. Благодаря ковариантности квалификатора, фактические состояния не ограничены SC и ISL - они должны их наследовать или реализовывать.

Методы контракта Launch и Stop псевдоасинхронны - они возвращают значение типа ValueTask. Если отсутствует необходимость в асинхронном поведении алгоритма, эти методы могут просто вернуть значение ValueTask.CompletedTask. Генерируемые методы-прослойки будут возвращать значения типа ValueTask<ExampleService<ISL>> и ValueTask<ExampleService<SC>> соответственно.

Однако, переход в одно из группы состояний ISL - понятие достаточно размытое. Интерфейс ISL будет реализован каким-то конкретным классом, что будет использоваться в методе Switch контрактного метода Launch, скажем, этим классом будет состояние Launched. При потребности определить квалификацию какого-либо метода как Launched, возникнет ситуация, в которой, после запуска службы, данный метод будет невозможно вызвать, так как квалификацией службы будет ISL.

Для решения данной проблемы предназначен расширенный шаблон управляемой службы - IManageableService<out SQ, out SL, in CB>, где SL - состояние "запущено", реализующее интерфейс ISL. Он одновременно реализует интерфейсы IManageableService<out SQ> и IConfigurableService<out SQ, in CB>, и не объявляет новых членов, однако влияет на генерацию методов-прослоек.

При реализации расширенного шаблона службой добавляется новый метод-прослойка Launch для квалификации SC<CB> с возвращаемым значением, представляющим службу в состоянии SL.

Возобновляемые службы

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

Для обеспечения возможности возобновления службы, в EmptyBox обозначен шаблон контракта службы IResumableService<out SQ, RSQB>.

[EB] Кусочек коробки, отмеченный знаком ♻️
/// <summary>
///     Контракт возобновляемой службы.
/// </summary>
/// <typeparam name="SQ">
///     Представление состояния службы.
/// </typeparam>
/// <typeparam name="RSQB">
///     Базовое представление состояния, из которого возможно возобновление.
/// </typeparam>
public partial interface IResumableService<out SQ, RSQB> : IService<SQ>
    where SQ : class, IState
    where RSQB : class, IState
{
    /// <summary>
    ///     Получает текущее состояние машины.
    /// </summary>
    /// <returns>
    ///     Текущее возобновляемое состояние машины.
    /// </returns>
    /// <remarks>
    ///     Этот метод должен быть <see langword="get"/> частью свойства <see langword="State"/>, однако на данный момент, квалифицированный доступ к свойствам не поддерживается.
    /// </remarks>
    [Qualified(nameof(RSQB))]
    protected RSQB get_State() => (RSQB)State;

    /// <summary>
    ///     Переводит службу в состояние <typeparamref name="RSQ"/>.
    /// </summary>
    /// <typeparam name="RSQ">
    ///     Представление состояния для возобновления.
    /// </typeparam>
    /// <param name="resumedState">
    ///     Состояние для возобновления.
    /// </param>
    [Qualified<SI>]
    [return: SwitchTo(nameof(RSQ))]
    protected void Resume<RSQ>(RSQ resumedState)
        where RSQ : class, RSQB
    {
        _ = Switch(resumedState);
    }

    /// <summary>
    ///     Перезапускает службу в состоянии <see cref="SI"/>.
    /// </summary>
    [Qualified(nameof(RSQB))]
    [return: SwitchTo<SI>]
    protected void Reset()
    {
        _ = Switch(SI.Instance);
    }
}

Параметр типа RSQB (Resumable State Qualifier Base) необходим для определения группы возобновляемых состояний. Однако, на время проектирования, или же когда оно действительно способно быть любым, в инсталляции типа при реализации расширения можно указать IState в качестве аргумента данного параметра типа, что не будет приводить к ограничениям.

Шаблон предоставляет 3 метода взаимодействия с возобновляемой службой:

  • Resume<RSQ> - переводит службу в состоянии SI в состояние RSQ, где RSQ - RSQB, его наследник или реализация;

  • get_State - при нахождении службы в возобновляемом состоянии позволяет получить его. Является типичным представлением проблемы неприменимости квалификаторов к свойствам;

  • Reset - переводит службу из возобновляемого состояния в состояние SI.

Пополнение сусеков рациональными зёрнами

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

  • Набор состояний автомата может обладать характеристиками и легко расширяем даже за пределами сборки .NET;

  • Для описания машины не требуется построитель, всё выражается типичными языковыми конструкциями;

  • Абстрактный автомат абстрактен - машина состояний максимально общего назначения;

  • Все преимущества квалифицированного подхода;

  • Является инструментом изменения квалификации сущности;

  • Дихотомия машины состояний - служба и контейнер могут разрабатываться раздельно посредством контракта, выполняемого обоими;

  • В любом из состояний служба и контейнер являются одним объектом;

  • Широкие возможности по определению шаблонных контрактов и наследованию служб;

  • Естественная поддержка асинхронных методов;

  • Автоматическая генерация высокотипизированного Fluent API к службе (за исключением асинхронных методов и методов без атрибута SwitchTo; первый случай, при желании, решаем; второй - без надобности).

Из негативных моментов в данном подходе отмечу:

  • Использование рефлексии сопряжено с трудностями;

  • Возможно хранение ссылки на службу в неправильном представлении, что может стать источником ошибок;

  • Ненормальный подход к проектированию сущностей (норма, в данном контексте, - это не плохое и не хорошее, это среднее по палате), может потребоваться усердие для понимания концепции;

  • Не полностью совместимо с какими-либо инструментами, доступными для платформы .NET;

  • Возникают сложности при реализации многопоточного доступа к службе;

  • Накладные расходы на проверки квалификации не сильно большие, но в конкретном случае может и стрельнуть. Проконсультируйтесь с бенчмарками перед возникновением сомнений.


Завершение, но не конец

Выводы, итоги? Решительное нет, не пришло их время. Однако, попытаюсь вас порадовать классическим вайб-кодингом, ну тот, что с музыкой и без ИИ. Будем, значится, самовар из примера в статье программировать.

Осторожно, дико. Не забудьте включить звук.

Как можно понять из названия статьи - подразумевается цикл статей. eb - сокращение от EmptyBox, #0 - номер статьи.

Двигаться будем дискретно, во всех направлениях. А это значит, что чуть ниже можно проголосовать за тему статьи под номером #-1, и когда-нибудь она напишется и опубликуется.

А вот тема статьи eb#1 останется секретом до её выхода, впереди ещё много интересного.

И ещё пару слов

Всех, кому интересно участие в развитии подобного подхода к разработку, жду в репозитории EmptyBox. Пробуйте, сообщайте об ошибках, предлагайте и реализуйте идеи - мне будет приятно.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
О чём будет статья `eb#-1`?
0% Как дела у функции `MakeConstructedGenericTypeLike`?0
75% Как там поживает кодогенератор?3
25% Для чего был придуман `IDynamicInterfaceCastable`?1
Проголосовали 4 пользователя. Воздержались 4 пользователя.
Теги:
Хабы:
+6
Комментарии7

Публикации

Работа

Ближайшие события