Вопрос или проблема
Исходя из этого закрытого вопроса (который в настоящее время вряд ли будет повторно открыт), задача состоит в том, чтобы создать неизменяемый тип, который
- использует механизм, аналогичный
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: Определение типа
Для реализации такого поведения можно использовать 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
, но это потребует дополнительных шагов по адаптации кода.