Как я могу создать тип с семантикой равенства, аналогичной строкам?

Вопрос или проблема

Исходя из этого закрытого вопроса (который в настоящее время вряд ли будет повторно открыт), задача состоит в том, чтобы создать неизменяемый тип, который

  • использует механизм, аналогичный string.Intern, для совместного использования экземпляров со значением.
  • сравнение на равенство сначала выполняется на основе ReferenceEquals, а затем сводится к семантике значений, т.е. равенству полей.

Таким образом, что-то похожее на string.Equals:

public override bool Equals([NotNullWhen(true)] object? obj) {
    if (ReferenceEquals(this, obj))
        return true;

    if (obj is not string str)
        return false;

    if (this.Length != str.Length)
        return false;

    return EqualsHelper(this, str);
}

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

Что касается части неизменяемости (и некоторой помощи от компилятора для шаблонного кода, который мы бы написали сами), мы могли бы использовать тип record, в котором мы также определяем

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

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

Хороший встроенный тип, который мы могли бы использовать, это ConditionalWeakTable<OurType,OurType>:

… ключ, хранящийся в таблице
ConditionalWeakTable<TKey,TValue> не сохраняется после уничтожения
ссылок на него вне таблицы.

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

Основная проблема в нашем случае в том, что это не «нормальный» словарь, так как он не использует GetHashCode и Equals для поиска совпадений, а использует вместо этого ReferenceEquals. Это означает, что для получения кэшированного экземпляра нам уже нужно иметь кэшированный экземпляр доступным…

Но я думаю, что мы можем создать Dictionary, который будет демонстрировать подобное поведение, если мы определим его как Dictionary<OurType,WeakReference<OurType>>.

Таким образом, чтобы получить наш кэшированный экземпляр, мы создадим новый временный экземпляр в нашем фабричном методе. Затем мы используем его в качестве ключа (компилятор уже позаботился о переопределении GetHashCode для нашего record), чтобы найти WeakReference<OurType> и затем извлечь его целевой объект.

Это позволит нам позже выполнить очистку экземпляров, на которые больше нет ссылок, из словаря. Для этого нам нужно будет ввести небольшую накладную нагрузку – два дублирующих экземпляра с одинаковым значением/GetHashCode, но с разными управляемыми ссылками – один для ключа в словаре и другой для WeakReference, чтобы ключ не удерживал целевой объект WeakReference в памяти.

Чтобы выполнить очистку, мы можем иметь ручной статический метод ClearCache() или немного магии finalizer, чтобы сделать это автоматически. В последнем случае мы будем проверять в финализаторе экземпляра, находится ли он в словаре и является ли его управляемая ссылка такой же, как и в WeakReference – тогда мы удалим запись из словаря. Я оставил эту версию закомментированной в коде, поскольку еще не протестировал ее тщательно (но она выглядит многообещающе)

sealed record RecordWithIntern(int field) {

    // ключ и значение - разные экземпляры
    // с теми же членами
    // используются для дифференциации и автоматической очистки
    // имеют одинаковый GetHashCode и Equals
    private static Dictionary<RecordWithIntern, WeakReference<RecordWithIntern>>
        s_dictionaryInterns = new();
    public static int CacheCount => s_dictionaryInterns.Count;
    static object lockObject = new Object();

    // та же сигнатура, что и у конструктора record
    public static RecordWithIntern CreateOrGetIntern(int field) {
        var newInst = new RecordWithIntern(field);

        lock (lockObject) {
            if (s_dictionaryInterns.TryGetValue(newInst, out var weakReference)) {
                if (weakReference.TryGetTarget(out var actualInstance)) {
                    return actualInstance;
                }
                // если реализация финализатора, мы не должны
                // иметь null в любом weakReference
                // поэтому этот путь должен быть невозможен
                // с ручной очисткой кэша мы бы
                weakReference.SetTarget(newInst);
                return newInst;
            }

            // ключ должен быть другим объектом
            // чем значение, хотя значения одинаковы
            // альтернативно, тот же вызов конструктора
            var key = newInst with {};

            s_dictionaryInterns.Add(key,
                new WeakReference<RecordWithIntern>(newInst,
                trackResurrection: true)); // для автоматической очистки в финализаторе
            return newInst;

        }
    }
    public static void ClearCache() {
        lock (lockObject) {
            var deadObjects = s_dictionaryInterns
                .Where(kvp => kvp.Value.TryGetTarget(out _) == false)
                .Select(x => x.Key)
                .ToList();

            foreach (var dead in deadObjects) {
                s_dictionaryInterns.Remove(dead);
            }
        }
    }

    // Мы получаем GetHashCode бесплатно от компилятора
    // подавляем предупреждение здесь
    // или определяем свой собственный get HashCode
    // с той же логикой
    public bool Equals(RecordWithIntern other) {
        if (object.ReferenceEquals(other, null)) {
            return false;
        }

        if (object.ReferenceEquals(other, this)) {
            return true;
        }

        // нужно реализовать равенство по значению
        // самостоятельно
        return this.field.Equals(other.field);
    }


    // альтернатива ClearCache -> финализатор - самочистка
    //~RecordWithIntern() {
    //    // нам нужно заблокировать, потому что
    //    // если мы не сделаем этого, мы получим ссылку
    //    // в фабрике создания на что-то, что собирается быть удалено
    //    // так что в следующий раз мы пересоздадим запись
    //    // и у нас будут разные ссылки для одного и того же значения
    //    lock (lockObject) {
    //        if (s_dictionaryInterns.TryGetValue(this, out var weakReference)
    //        && weakReference.TryGetTarget(out var actualInstance)
    //        && object.ReferenceEquals(this, actualInstance)) {
    //            // требуется отслеживание воскрешения для слабой ссылки
    //            s_dictionaryInterns.Remove(this);
    //        }
    //    }
    //}

}

И несколько тестов:

// ТЕСТ в режиме Release / TieredCompilation выключен на .NET Core3+

Console.WriteLine("--- Простые тесты ---");
var c1 = RecordWithIntern.CreateOrGetIntern(3);
var c2 = RecordWithIntern.CreateOrGetIntern(3);

Console.WriteLine((c1 == c2)); // true
Console.WriteLine(object.ReferenceEquals(c1, c2)); // true
Console.WriteLine(object.ReferenceEquals(c1,
    RecordWithIntern.CreateOrGetIntern(3))); // true
Console.WriteLine("----");

Console.WriteLine(object.ReferenceEquals(c1,
       new RecordWithIntern(3))); // false - то же, что и строка
Console.WriteLine(c1 == new RecordWithIntern(3)); // true - то же, что и строка
Console.WriteLine("----");

RecordWithIntern.ClearCache(); // не очистится, поскольку c1 сохраняется
Console.WriteLine(RecordWithIntern.CacheCount); // 1
GC.KeepAlive(c1);

RecordWithIntern.ClearCache(); // все еще не очистится, так как не было сборки мусора
Console.WriteLine(RecordWithIntern.CacheCount); // 1
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
RecordWithIntern.ClearCache(); // наконец очистится
Console.WriteLine(RecordWithIntern.CacheCount); // 0

Console.WriteLine("--- Тесты массивов ---");
// Тесты массивов
TestArray(i => RecordWithIntern.CreateOrGetIntern(i));
// Новая память: 803048
// Количество кэша до сборки мусора: 42
// Количество кэша после сборки мусора: 0
Console.WriteLine("---");
TestArray(i => new RecordWithIntern(i));
// Новая память: 3200032
// Количество кэша до сборки мусора: 0
// Количество кэша после сборки мусора: 0
Console.WriteLine("---");

const int ARR_SIZE = 100_000;

void TestArray(Func<int, RecordWithIntern> factory) {
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();

    var bytes = GC.GetTotalMemory(false);
    var arr = new RecordWithIntern[ARR_SIZE];
    for (int i = 0; i < ARR_SIZE; i++) {
        arr[i] = factory(i % 42);
    }
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
    var newBytes = GC.GetTotalMemory(false);
    Console.WriteLine("Новая память: " + (newBytes - bytes));

    GC.KeepAlive(arr);
    RecordWithIntern.ClearCache(); // не очистится до сборки мусора
    Console.WriteLine("Количество кэша до сборки мусора: " + RecordWithIntern.CacheCount);
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
    RecordWithIntern.ClearCache();

    Console.WriteLine("Количество кэша после сборки мусора: " + RecordWithIntern.CacheCount);
}

Результаты тестов (могут незначительно варьироваться в зависимости от точного количества байт памяти):

--- Простые тесты ---
True
True
True
----
False
True
----
1
1
0
--- Тесты массивов ---
Новая память: 803048
Количество кэша до сборки мусора: 42
Количество кэша после сборки мусора: 0
---
Новая память: 3200024
Количество кэша до сборки мусора: 0
Количество кэша после сборки мусора: 0
---

Я все еще не на 100% уверен, как реализовать потокобезопасность более оптимальным способом (особенно в части финализатора), на данный момент простой объект блокировки используется каждый раз, когда мы собираемся использовать словарь. Любые комментарии приветствуются.

Ответ или решение

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

  1. Неизменяемость типа – экземпляры должны быть неизменяемыми после создания.
  2. Общий референс для одинаковых значений – экземпляры с одинаковыми значениями должны ссылаться на один и тот же объект в памяти.
  3. Сравнение по ссылке и по значению – равенство должно проверяться сначала по ссылке, а затем по значению.

Решение

Шаг 1: Определение типа

Для реализации такого поведения можно использовать record в C#. Это обеспечит уже встроенные механизмы для реализации неизменяемости и автоматическую генерируемую реализацию вещей, таких как GetHashCode и Equals.

sealed record RecordWithIntern(int field)
{
    private static readonly Dictionary<RecordWithIntern, WeakReference<RecordWithIntern>> s_dictionaryInterns = new();
    private static readonly object lockObject = new();

    public static RecordWithIntern CreateOrGetIntern(int field)
    {
        var newInst = new RecordWithIntern(field);
        lock (lockObject)
        {
            if (s_dictionaryInterns.TryGetValue(newInst, out var weakReference))
            {
                if (weakReference.TryGetTarget(out var actualInstance))
                {
                    return actualInstance;
                }

                weakReference.SetTarget(newInst);
                return newInst;
            }

            var key = newInst with { };
            s_dictionaryInterns.Add(key, new WeakReference<RecordWithIntern>(newInst, trackResurrection: true));
            return newInst;
        }
    }

    public static void ClearCache()
    {
        lock (lockObject)
        {
            var deadObjects = s_dictionaryInterns
                .Where(kvp => !kvp.Value.TryGetTarget(out _))
                .Select(x => x.Key)
                .ToList();

            foreach (var dead in deadObjects)
            {
                s_dictionaryInterns.Remove(dead);
            }
        }
    }

    public bool Equals(RecordWithIntern? other)
    {
        if (ReferenceEquals(other, null)) return false;
        if (ReferenceEquals(other, this)) return true;
        return this.field.Equals(other.field);
    }

    public override int GetHashCode() => field.GetHashCode();
}

Шаг 2: Кэширование и управление памятью

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

Шаг 3: Тестирование

После реализации типа важно провести тесты, чтобы убедиться в правильности его работы:

public static void Main()
{
    var c1 = RecordWithIntern.CreateOrGetIntern(3);
    var c2 = RecordWithIntern.CreateOrGetIntern(3);

    Console.WriteLine((c1 == c2)); // true
    Console.WriteLine(object.ReferenceEquals(c1, c2)); // true

    Console.WriteLine(object.ReferenceEquals(c1, new RecordWithIntern(3))); // false
    Console.WriteLine(c1 == new RecordWithIntern(3)); // true

    RecordWithIntern.ClearCache();
    Console.WriteLine("Cache count after GC: " + RecordWithIntern.CacheCount);
}

Заключение

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

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

Капча загружается...