Российские технологии и цифровая экономика
ПоискRSS

Недокументированные возможности C#: typedreference, __arglist и эксперименты с Clr

5 минут чтения

А чё, так можно было? Недокументированные возможности C#

В языке C# есть возможности, которые компилятор исправно принимает, хотя в официальной документации они практически не описаны. Речь идёт не о случайных трюках, а о конструкциях, связанных с внутренним устройством CLR, соглашениями вызова и представлением объектов в памяти.

Некоторые из них работают одинаково в .NET 8, 9 и 10. Другие зависят от операционной системы: код может успешно собраться на любой машине, но завершиться с ошибкой при запуске под Linux. Ниже разберём скрытые ключевые слова C#, особенности `TypedReference`, переменное число аргументов и эксперименты с внутренним представлением объектов.

Три ключевых слова для работы со ссылками

Первая группа состоит из конструкций `__makeref`, `__reftype` и `__refvalue`. Тип `TypedReference` присутствует в документации .NET, но сами ключевые слова обычно остаются за рамками стандартных руководств.

`__makeref` создаёт экземпляр `TypedReference`. Внутри него находятся два указателя: один ведёт к данным, второй - к информации о типе. `__reftype` извлекает из этой пары тип, а `__refvalue` позволяет получить или изменить значение.

```csharp
int number = 42;
TypedReference reference = __makeref(number);

Console.WriteLine(__reftype(reference));
__refvalue(reference, int) = 100;

Console.WriteLine(number); // 100
```

Здесь меняется именно переменная `number`, хотя в присваивании она явно не указана. `__refvalue` работает не с копией, а с адресом исходной переменной.

Это принципиально отличается от `GetType()`. Для значимого типа вызов `GetType()` требует упаковки значения: объект сначала помещается в управляемую кучу, и только затем у него определяется тип. `__reftype` обращается непосредственно к данным `TypedReference`, поэтому дополнительного объекта в куче не создаёт.

У такого подхода есть строгие ограничения. `TypedReference` является `ref struct`: его нельзя сохранить в поле класса, поместить в массив или использовать внутри асинхронного метода. Причина очевидна - внутри хранится прямой указатель на переменную, существующую только в пределах текущего вызова.

Похожий механизм используется в рефлексии. Методы `FieldInfo.GetValueDirect` и `SetValueDirect` принимают `TypedReference` и позволяют читать или записывать поле структуры без упаковки всей структуры в объект. Обычный `SetValue` получает параметр типа `object`, поэтому работает с упакованной копией, а не с оригиналом.

Интересно, что `__reftype` применяется внутри `TypedReference.GetHashCode()`: это один из внутренних способов извлечь сведения о типе из структуры ссылки. Поведение проверялось на нескольких x64-машинах под Windows и на версиях .NET от 8.0.11 до 10.0.12.

`__arglist`: сборка везде, выполнение не везде

Ключевое слово `__arglist` создаёт метод с переменным числом аргументов без использования `params` и массива:

```csharp
static void Print(__arglist)
{
ArgIterator iterator = new ArgIterator(__arglist);
// обработка аргументов
}
```

Такой код собирается без предупреждений на Windows и Linux. Однако запуск на Unix-подобной системе завершается ошибкой. Причина кроется в соглашении вызова, пришедшем из мира C. В .NET для Unix это соглашение не реализовано.

Особенность проявляется на этапе JIT-компиляции. Среда выполнения проверяет соглашение вызова не тогда, когда компилируется сам метод с `__arglist`, а в момент компиляции вызывающего метода. Поэтому ошибка возникает в месте вызова, а не обязательно внутри объявления функции.

Чтобы изолировать проблемный участок на Windows, вызов можно вынести в отдельный метод и запретить его встраивание:

```csharp
[MethodImpl(MethodImplOptions.NoInlining)]
static void RunVarArgs()
{
Print(__arglist(1, 2, 3));
}
```

Это важно, потому что JIT компилирует метод целиком. Если вызов будет встроен, ошибка способна возникнуть ещё до выполнения первой строки, даже если участок обёрнут в `try/catch`.

Методы с `__arglist` нельзя объявлять в обобщённых типах: среда выполнения не принимает подобную комбинацию. При поиске по исходному коду .NET такие методы практически не встречаются. Конструкция существует главным образом для поддержки спецификации CLI и совместимости с другими компиляторами, способными генерировать соответствующий промежуточный код.

Как массив может "стать" строкой

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

Если заменить указатель на таблицу методов массива указателем на таблицу строки, CLR начнёт обращаться с массивом как со строкой. Например, можно создать массив чисел:

```csharp
int[] values = { 111, 222, 333 };
```

Затем подменить его служебную информацию. После этого строковые методы начнут читать память массива по правилам, предназначенным для `System.String`.

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

Далее строка ожидает символы сразу после этого поля. Но в памяти массива там находятся оставшиеся четыре байта его длины - обычно нулевые. Поэтому первые символы окажутся нулевыми и при выводе могут отображаться как точки. Затем чтение доберётся до первого элемента массива: число `111` совпадает с кодом Unicode латинской буквы `o`.

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

Подобные исследования полезны не для повседневной разработки, а для понимания того, как устроены CLR и JIT. На практике C# разработка программного обеспечения должна опираться на документированные API, безопасные ссылки и предсказуемое управление временем жизни объектов. Низкоуровневые эксперименты уместны при изучении рантайма, диагностике ошибок или создании специализированных инструментов.

Тем, кто хочет глубже разобраться в подобных механизмах, стоит изучить устройство стека и кучи, boxing/unboxing, структуру метаданных типов и работу JIT-компилятора. Материалы о таких экспериментах, включая дополнительные примеры и ограничения, собраны в статье о недокументированных возможностях C#.

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

Для обычных задач лучше выбирать безопасные средства языка: `ref`, `Span`, `Unsafe` там, где это действительно необходимо, генераторы исходного кода и стандартную рефлексию. Если требуется полноценное приложение или библиотека, можно заказать разработку на C# с учётом требований к производительности, переносимости и сопровождению.

А тем, кто только начинает обучение программированию на C#, полезно сначала освоить типы, ссылки, исключения, коллекции и асинхронность. Понимание фундаментальных правил языка позволит воспринимать такие эксперименты не как магию, а как наглядную демонстрацию границ, за которыми управляемая среда перестаёт гарантировать безопасность.

Прокрутить вверх