Реализация словаря на двух статических методах и делегате в C#

В статье представлена реализация структуры данных “Словарь” в “функциональном стиле” на двух статических методах и делегате в C#.

Написано под впечатлением от главы “Total and Partial Maps” из Software Foundation. Volume 1: Logical Foundation.

Всегда интересно посмотреть на некоторые знакомые и привычные вещи немного под другим углом. Указанная выше глава из Software Foundation как раз способствовала такому “виденью”.

Речь пойдет о структуре данных “словарь” (отображение или ассоциативный массив). В программировании мы чаще всего пользуемся шаблоном мышления, который предполагает, что есть данные и функции, которые над этими данными выполняют различные операции. При этом данные могут быть представлены как некоторые одиночные элементы – объекты простых типов (числа, bool’ы, символы и т.п.) и объекты сложных типов, которые мы проектируем сами (Student, Employer, Car, Factory и т.п.), так и коллекции, состоящие из этих одиночных элементов: множества, словари, списки, массивы, стеки и т.п. Коллекции могу существовать в виде обобщенных вариантов, в этом случае предполагается, что тип объектов, которые будут храниться в ней, задается (заранее известен) до компиляции (для статически типизированных языков), это способствует большей производительности и лучшей безопасности.

Если немного помедитировать над лямбда-исчислением Черча, то в какой-то момент становится понятно, что описанное выше восприятие данных — это всего лишь удобная абстракция, которую мы перетащили из нашего “физического” мира. Например, у нас есть яблоко (это объект данных), и мы над ним с помощью определенной функции (соковыжималка) производим работу, преобразуя его в сок (объект другого типа). А вот само яблоко, мы, как правило, в качестве функции не рассматриваем. В лямба-исчислении всё есть функция – то, над чем выполняются операции и то, что эти операции выполняет. Фон-неймановская архитектура, по дизайну которой построены почти все современные вычислительные системы, также нас толкает к тому, что функции и данные — это одно и тоже: байты в памяти мы можем интерпретировать как данные и как операции для работы над данными, тут другая идея: всё есть данные (биты), а уж как мы их интерпретируем — это наша ответственность.

Вернемся к структурам данных. В C# связный список, состоящий из элементов типа int, объявляется вот так:

LinkedList<int> someList = new();

Обобщенный вариант имеет вид: LinkedList<T>, в этом случае о LinkedList можно думать как о “функции”, которая переводит T -> LinkedList<T>. Внутренняя механика C# так явно это не реализует, но думать о LinkedList как о функции над типами нам никто не запрещает.

Теперь “функционально” подумаем о словаре. Сделаем это с немного другого угла, нежели чем выше представленный LinkedList, то была просто разминка. Вариант объявления словаря в C#:

Dictionary<string, int> someDict = new() { ["a"] = 1 };  

Когда мы обращаемся к элементу словаря через ключ, то делаем следующее:

var value = someDict["a"];

Если мысленно квадратные скобки заменить на круглые, то объект типа Dictionary<string, int> можно рассматривать как функцию со следующим прототипом:

int someDict(string key);

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

Построим такой “функциональный” словарь в C#. Создадим два вида словарей: TotalMap и PartialMap. Особенность TotalMap заключается в том, что если в нем нет записи для заданного ключа, то он вернет предварительно установленное значение по умолчанию. PartialMap работает с Option значением, т. е. если значения по заданному ключу нет, то он вернет None, если есть, то значение, упакованное в Some (более подробно про Option далее).

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

public delegate V TotalMap<K, V>(K key)
        where K: IEquatable<K>;

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

Следующий шаг — это построить функцию, которая конструирует словарь с заданным значением по умолчанию (напоминаю, что словарь у нас — это ФУНКЦИЯ!):

static TotalMap<K, V> TEmpty<K, V>(V defaultValue) 
    where K : IEquatable<K> =>
    (K _) => defaultValue;

TEmpty принимает на вход значение по умолчанию и возвращает функцию, которая для любого значения типа K выдает defaultValue.

И последнее, что нужно сделать — это создать функцию, которая “добавляет” в наш словарь элементы (пары ключ-значение):

public static TotalMap<K, V> TUpdate<K, V>(TotalMap<K, V> totalMap, K key, V value)
    where K : IEquatable<K> =>
    (K k) => k.Equals(key) switch
    {
        true => value,
        false => totalMap(k)
    };

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

Объединим все наработки в один статический класс:

public static class TotalMapFn
{
    public delegate V TotalMap<K, V>(K key)
        where K: IEquatable<K>;
    public static TotalMap<K, V> TEmpty<K, V>(V defaultValue) 
        where K : IEquatable<K> =>
        (K _) => defaultValue;

    public static TotalMap<K, V> TUpdate<K, V>(
        TotalMap<K, V> totalMap, 
        K key, 
        V value)
        where K : IEquatable<K> =>
        (K k) => k.Equals(key) switch
        {
            true => value,
            false => totalMap(k)
        };
}

Проверим работу нашего “функционального” словаря:

var tm1 = TUpdate(TUpdate(TEmpty<string, int>(-1), "orange", 1), "apple", 2);
Console.WriteLine($"default value: {tm1("test")}");
Console.WriteLine($"at \"orange\": {tm1("orange")}");
Console.WriteLine($"at \"apple\": {tm1("apple")}");

Если вы запустите этот код, то увидите примерно следующее:

default value: -1
at "orange": 1
at "apple": 2

Разберем более подробно то, что мы написали:

В примере, tm1 – это функция (если быть точнее, то делегат конкретной структуры). Т.е. мы, используя определенный набор функций (TEmpty и TUpdate), построили другую функцию, поведение которой аналогично тому, как ведет себя структура данных “словарь”.

Для того чтобы не указывать явно типы ключа и значения можно создать псевдонимы для функций TEmpty и TUpdate:

Func<TotalMap<string, int>, string, int, TotalMap<string, int>> t_update = TUpdate<string, int>;
Func<int, TotalMap<string, int>> t_empty = TEmpty<string, int>;

В этом случае код, создающий словарь, как в предыдущем примере, будет выглядеть так:

var tm2 = t_update(t_update(t_empty(-1), "orange", 1), "apple", 2);

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

Теперь перейдем к PartialMap. Для начала разберемся с Option. Довольно часто бывают ситуации, когда функция должна вернуть результат, но значения, которое бы его, по факту, представляло нет. Например, нет значения по заданному ключу в словаре или нет данных по указанному запросу в базе и т.п. Для обработки таких ситуаций существуют несколько решений:

  • Вернут null, но с ним существует огромное количество проблем. Хотелось бы чтобы функция возвращала значение, с которым можно работать, а не что-то при обращении с чем может возникнуть Null Reference Exception.
  • Выбросить исключение. Если мы пишем какую-то функцию, то хотелось бы по ее прототипу понимать, что она принимает, и что возвращает. Во многих языках программирования в прототипе функции (метода) типы возможных выбрасываемых исключений не прописываются (Python, C#), т. е. мы имеем некоторое недокументированное поведение.
  • Вернуть объект типа MayBe (или Option), Такой подход применяется в функциональных языках программирования для обработки подобных ситуаций. Суть в том, что мы либо возвращаем None, что соответствует “отсутствию” “нормального” результата, либо Some x, где x – возвращаемое значение, а Some – это конструктор для упаковки x в элемент типа MayBe. В этом случае None – это значение, с которым можно работать, например, вызвать у него ToString().

К сожалению, в C# отсутствуют алгебраические типы данных (в частности, нам тут нужен тип-сумма), и мы не можем сконструировать нормальный Option, поэтому построим его следующим образом:

public abstract record Option<T>
{
    public static Some<T> Some(T value) => new(value);
    public static None<T> None() => new();
}
public record Some<T>(T Value) : Option<T> { }
public record None<T>() : Option<T> { }

По аналогии с TotalMap, построим PartialMap:

public static class PartialMapFn
{
    public delegate Option<V> PartialMap<K, V>(K key)
        where K : IEquatable<K>;

    public static PartialMap<K, V> PEmpty<K, V>()
        where K : IEquatable<K> =>
         (_) => Option<V>.None();

    public static PartialMap<K, V> PUpdate<K, V>(PartialMap<K, V> partialMap, K key, V value)
       where K : IEquatable<K> =>
       (k) => k.Equals(key) switch
       {
           true => Option<V>.Some(value),
           false => partialMap(k)
       };
}

PartialMap, в случае, если нет значения по указанному ключу, возвращает None, если есть, то Some. Пример, демонстрирующий работу с PartialMap:

var pm1 = PUpdate(PUpdate(PEmpty<string, int>(), "car", 1), "bike", 2);
Console.WriteLine($"Default value of pm1: {pm1("test")}");
Console.WriteLine($"at \"car\": {pm1("car")}");            
Console.WriteLine($"at \"bike\": {pm1("bike")}");

По аналогии с TotalMap создадим псевдонимы для конкретных типов:

Func<PartialMap<string, int>, string, int, PartialMap<string, int>> p_update = PUpdate<string, int>;
Func<PartialMap<string, int>> p_empty = PEmpty<string, int>;

Построим словарь с использованием указанных выше делегатов:

var pm2 = p_update(p_update(p_empty(), "car", 1), "bike", 2);

Если мы пытаемся получить значение по ключу для pm2, то результатом будет либо запакованное значение, либо None. Это можно использовать в pattern matching’е:

var value = pm2("car") switch
    {
        None<int> => "No value in dict.",
        Some<int> v => $"Value: {v.Value}",
        _ => "Something wrong!"
    };

Единственное дополнение, которое приходится делать из-за того, что Option не является классическим типом суммой, это строка:

_ => "Something wrong!"

Возникает вопрос: зачем всё это нужно? Зачем строить “словарь” функционально? Если ответить на него в контексте “зачем это делать на C#?”, то: для развлечения; какой-то особой практической пользы, наверное, от такого решения нет. Если же не привязываться к C#, то это вопрос аналогичный, например такому: почему для типа данных unsigned long нужно резервировать 8 байт? Ответ в обоих случаях: потому что так удобно! Здесь следует сразу же уточнить: а для чего удобно? Натуральное число мы можем представить как совокупность из 64 бит, которые последовательно расположены в памяти компьютера, такое представление удобно, когда мы пишем программы практически для любой предметной области (драйверы, мобильные приложения, сервисы) и на любом языке (почти любом). В этом случае удобство связано с разработкой и компиляцией, в итоге мы можем получить работающее приложение с приемлемой производительностью. Но если мы пытаемся рассуждать о свойствах программы и доказывать некоторые утверждения относительно кода, который мы написали — это становится неудобным.

Такие задачи возникаю в области формальной верификации ПО, доказательств теорем и т.п. В этом случае, представление чисел как набора из 64 бит уже не будет удобным. Для данных задач используются специальные языки, в которых тип “Натуральное число” можно построить индуктивным способом (например, так делают в Coq). Такая же история со словарем в “функциональной форме”, с ним удобно строить и доказывать различные утверждения относительно кода в системах типа proof assistant (Coq и прочие), а в том виде как мы привыкли видеть словарь, они хорошо подходят для “классических” задач, с которыми разработчик сталкивается в повседневной работе.

Исходный код всего представленного в статье лежит тут.

Про язык C# можете прочитать в нашем “цикле уроков“.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *