А чё, так можно было? Недокументированные возможности 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
А тем, кто только начинает обучение программированию на C#, полезно сначала освоить типы, ссылки, исключения, коллекции и асинхронность. Понимание фундаментальных правил языка позволит воспринимать такие эксперименты не как магию, а как наглядную демонстрацию границ, за которыми управляемая среда перестаёт гарантировать безопасность.

