Придерживаясь великой цитаты "правила созданы для того, чтобы их нарушать", давайте нарушим какие-то основополагающие правила CLR. Как на счет того, чтобы послать GC с его работой в отставку и самим заняться размещением в памяти экземпляров классов? Заодно разберемся, как все это работает где-то там под капотом CLR.
Начнем с того, что мы сразу откинем правило "все экземепляры должны создаваться через ключевое слово new в управляемой куче благодаря GC". Сначала разберемся, что вообще такое есть экземпляр класса и ссылка.
Когда мы создаем экземпляр через ключевое слово new, мы аллоцируем где-то в куче sizeof(class) + 16 и возвращаем pointer + 8. Теперь остановимся подробнее. То, чем мы оперируем в контексте работы с классом - это всего лишь указатель на некую область памяти. То есть любую ссылку можно представить как IntPtr или void* и смысла она не поменяет. А если любая ссылка это просто указатель, значит мы может откинуть оковы безопасного кода и поработать напрямую с указателями... Насколько это позволяет C#.
Но для этого, сначала, надо понять, как работает память у классов. Помимо размещения полей класса, CLR так же добавляет для себя еще 16 байт (или 8 для 32bit систем), это так называемый object header и указатель на virtualMethodTable. Первое содержит себе разные данные для правильной работы GC и CLR, а указатель помогает определить, с каким типом мы работаем. Для простоты понимания можно сказать, что это поле отвечает за тип класса, а соответственно, и за его переопределенные методы, и другие ООП вещи.
Размещается он в памяти как...
public unsafe struct UnmanagedClass { public ulong objectHeader; -8 public void* methodTable; 0 < сюда будет указывать наш указатель // Далее уже идут наши поля public int value0; 8 public ulong value1; 12 }
...аналогичная структура. Почему objectHeader находится по адресу -8? Зачем так сделано? Хз. Даже майкрософт говорили, что просто так исторически сложилось и никакого скрытого смысла здесь нет.
Значит теперь мы можем представлять классы в виде структур и работать с указателями? Верно.
Попробуем...
public class TestClass { public int value0; public ulong value1; public string value2; } // Будет равно public struct UnmanagedTestClass { public void* methodTable; public int value0; public ulong value1; public void* value2; // Все managed поля должны быть unmanaged. // Либо создаем свой вариант строки в виде структуры с таким же расположением полей. // Либо забиваем и используем void* или IntPtr. Я выбрал этот вариант <3 }
... и теперь если мы сделаем...
public static void Test() { var instance = new TestClass(); var pInstance = *(UnmanagedTestClass**)Unsafe.AsPointer(ref instance); }
...мы получим указатель на наш класс. Почему *(UnmanagedTestClass**) - объясняю, мы взяли указатель на переменную на стеке, которая, как я говорил, является указателем на экземпляр класса. Соответственно, это указатель на указатель, поэтому его надо разыменовать.
Поздравляю. Уже на этом этапе мы послали на 3 буквы очень много правил безопасности C# и CLR, продолжаем. Теперь нам надо избавится от ключевого слова new. Если мы загуглим, как это сделать, то получим ответ в духе "нельзя так делать, нужно только new и бла-бла-бла", но мы сделаем.
Мы уже разобрались, как в памяти располагается экземпляр, теперь мы можем его воссоздать.
Для этого нам надо где-то аллоцировать память. Для этого можно использовать 3 варианта:
1. Использовать malloc();
2. Использовать stackalloc;
3. Использовать кастомный аллокатор.
Первый и второй вариант самые простые, которое готовы прямо из коробки, поэтому будем использовать их. Первый и третий вариант схожи по своей сути и я настоятельно рекомендую использовать 3й.
Что же нам надо сделать? Аллоцировать память sizeof(class) + 16. Заполнить первые 8 байт нулями, вторые 8 байт заполнить указателем на virtualMethodTable, записать указатель на эту область память в переменную на стеке... И все.
Теперь попробуем это сделать и аллоцируем экземпляр на стеке...
public static void Test() { var memory = stackalloc ulong[8]; // Аллоцируем память на стеке 64 байта. Взято с запасом Unsafe.InitBlock(memory, 0, 8 * 8); // Очищаем память, ибо она грязная TestClass res = null; // Создаем на стеке переменную под ссылку на экземпляр var pRes = (ulong**)Unsafe.AsPointer(ref res); // Получаем указатель на переменную на стеке *memory = 0x0; // Заполняем первые 8 байт нулями, ибо это заголовок, а он нам не нужен *++memory = (ulong)typeof(TestClass).TypeHandle.Value.ToPointer(); // Заполняем вторые 8 байт указателем на метод таблицу // В вашем случае вместо TestClass должен быть указан ваш класс *pRes = memory; // Передаем указатель на память в переменную // Теперь переменную res можно использовать в этой функции. // Как толкьо мы выйдем из функции, ссылка станет невалидной и ее использование недопустимо }
...и мы получим работоспособный экземпляр. Для получения точного размера класса нам нужно немного напрячься и написать структуру VirtualMethodTable, выглядит она так...
[StructLayout(LayoutKind.Explicit)] public struct VirtualMethodTable { [FieldOffset(0)] public uint flags; [FieldOffset(4)] public uint size; // Отсюда нам надо только это поле, остальные можно игнорировать [FieldOffset(8)] public uint flags2; [FieldOffset(12)] public ushort numVirtuals; [FieldOffset(14)] public ushort numInterfaces; [FieldOffset(16)] public void* pParentMethodTable; [FieldOffset(24)] public void* pModule; [FieldOffset(32)] public void* pAuxiliaryData; // Эти два поля находятся по одному и тому же адресу [FieldOffset(40)] public void* pPerInstInfo; [FieldOffset(40)] public void* pElementTypeHnd; [FieldOffset(48)] public void* pInterfaceMap; }
...в поле size будет выравненный размер класса в байтах.
В примере указан вариант со аллокацией на стеке, но его можно заменить на malloc() или Marshal.AllocHGlobal(). В таком случае надо не забыть потом вызвать Free() для указателя. Однако для глобальной аллокации намного лучше использовать кастомные аллокаторы с кэшем памяти по причинам, как я и говорил выше.
Если напрячься и сделать для всего этого API, то выйдет нечто такое:
public static void Main() { var size = (int)((VirtualMethodTable*)typeof(TestClass).TypeHandle.Value)->size + 16; // Вычисляем размер класса var buffer = stackalloc byte[size]; // Аллоцируем память для класса на стеке Unsafe.InitBlock(buffer, 0, (uint)size); // Очищаем память, ибо она грязная TestClass res = null; // Резервуем на стеке переменную под ссылку UnsafeInitializeInstance<TestClass>((ulong*)buffer, (void**)Unsafe.AsPointer(ref res)); // Передаем указатель на память и указатель на переменную Console.WriteLine(res.GetType()); var res2 = UnsafeAllocateInstance<TestClass>(); Console.WriteLine(res2.GetType()); } public static T UnsafeAllocateInstance<T>() { var size = (int)((VirtualMethodTable*)typeof(TestClass).TypeHandle.Value)->size + 16; // Вычисляем размер класса var buffer = Marshal.AllocHGlobal(size ).ToPointer(); // Аллоцируем память для класса. Взято с запасом Unsafe.InitBlock(buffer, 0, (uint)size ); // Очищаем память, ибо она грязная T res = default; // Резервуем на стеке переменную под ссылку UnsafeInitializeInstance<T>((ulong*)buffer, (void**)Unsafe.AsPointer(ref res)); // Передаем указатель на память и указатель на переменную return res; // Возвращаем переменную } public static void UnsafeInitializeInstance<T>(ulong* ptr, void** stackPointer) { *ptr = 0x0; // Заполняем первые 8 байт нулями, ибо это заголовок *++ptr = (ulong)typeof(T).TypeHandle.Value.ToPointer(); // Заполняем вторые 8 байт указателем на метод таблицу *stackPointer = ptr; // Передаем указатель на память в переменную }
Поздравляю, вы нарушили так много правил безопасности в C#, что заслужили пинок под зад от Хайльсберга. Этот код ходит по грани UB и строго не рекомендуется к использовании без веских причин, ибо малейшая ошибка или изменение могут поломать всю логику и вызвать "Segmentation fault", а так же понос и выпадение волос.
Однако теперь вы знаете, что происходит за невинным словом `new` (спойлер: это даже не половина того, что происходит, в CLR эта часть написана на ассемблере и там еще много логики для выравнивания и оптимизации) и почему структуры намного предпочтительнее классов в случаях, когда их использование возможно. Спасибо за внимание.
P.S. Если правильно организовать управление памятью, такой способ аллокации экземпляров может быть на ~20% быстрее, чем через new :D. Не говоря уж о том, что это снимает нагрузку с GC и дает на контроль над тем, сколько будет жить объект и возможность его переиспользовать под те же, или даже другие цели.
