В C# есть четыре ключевых слова, которых нет в документации. Компилятор их принимает.
Одно из них собирается на любой машине, а выполняется только на Windows. На Linux та же сборка падает. Дальше — как объект меняет свой тип на ходу и что на самом деле происходит при вызове метода у пустой ссылки.
Проверено на четырёх машинах x64 под Windows, на .NET 8, 9 и 10, сборки от 8.0.11 до 10.0.12.
1. __makeref, __reftype, __refvalue
Исходник
__makeref создаёт TypedReference — пару из указателя на данные и указателя на тип. __reftype возвращает тип из этой пары, __refvalue читает и пишет значение.
int number = 42; string text = "Секрет"; TypedReference toNumber = __makeref(number); TypedReference toText = __makeref(text); Console.WriteLine(__reftype(toNumber).Name); // Int32 Console.WriteLine(__reftype(toText).Name); // String __refvalue(toNumber, int) = 100; Console.WriteLine(number); // 100
Тип TypedReference в документации есть. __makeref, __reftype и __refvalue — нет.
Что выводит отчёт
Тип переменной без вызова GetType int __reftype даёт Int32 string __reftype даёт String DateTime __reftype даёт DateTime Разница с GetType число до записи: 42 после __refvalue: 100 TypedReference.ToObject: Секрет
Одинаково на .NET 8, 9 и 10 и на всех четырёх машинах.
Причина
GetType работает с объектом. Для значимого типа это означает упаковку: значение сначала попадает в кучу и только потом возвращает свой тип.
__reftype работает с переменной. Тип берётся из второй половины TypedReference, куда его записал __makeref, и в кучу ничего не попадает.
Так же устроена и запись через __refvalue: в паре хранится указатель на саму переменную, а не копия значения. В строке __refvalue(toNumber, int) = 100; переменная number не названа, а меняется именно она.
Где легко ошибиться
TypedReference — это ref struct, и отсюда все запреты: его нельзя сохранить в поле, положить в массив или передать в асинхронный метод. Он существует только на стеке и только в пределах вызова, потому что внутри хранит прямой указатель на переменную.
Где встречается
В рефлексии. Методы FieldInfo.SetValueDirect и GetValueDirect принимают TypedReference: через них можно записать поле структуры, не упаковывая её в объект. Обычный SetValue принимает object, то есть упакованную копию, и запись уходит в неё, а не в исходную структуру.
__reftype вызывается внутри TypedReference.GetHashCode: иначе тип оттуда не получить.
2. Собирается везде, работает только на Windows
Исходник
__arglist объявляет метод с переменным числом аргументов без params и без массива. Аргументы перечисляются через ArgIterator:
private static string Join(__arglist) { ArgIterator iterator = new(__arglist); string result = string.Empty; while (iterator.GetRemainingCount() > 0) { TypedReference argument = iterator.GetNextArg(); result += __reftype(argument).Name + " = " + TypedReference.ToObject(argument) + "; "; } return result; } // вызов Join(__arglist(1, "два", 3.5));
Сборка проходит без единого предупреждения.
Что выводит отчёт
Система: Microsoft Windows 10.0.17763, X64 вызов прошёл: Int32 = 1; String = два; Double = 3,5;
Та же сборка на Linux:
Система: Ubuntu 24.04.4 LTS, X64 вызов не прошёл тип исключения: System.InvalidProgramException сообщение: Vararg calling convention not supported.
Причина
Всё решает одна проверка в джите:
// Native Varargs are not supported on Unix (all architectures) and Windows ARM inline bool compFeatureVarArg() { return TargetOS::IsWindows && !TargetArchitecture::IsArm32; }
Когда она не выполняется, джит отказывается компилировать вызов:
if (!compFeatureVarArg() && ((sig->callConv & CORINFO_CALLCONV_MASK) == CORINFO_CALLCONV_VARARG || (sig->callConv & CORINFO_CALLCONV_MASK) == CORINFO_CALLCONV_NATIVEVARARG)) { BADCODE("Varargs not supported."); }
Джит проверяет соглашение вызова не тогда, когда компилирует метод с __arglist, а тогда, когда компилирует метод, откуда его вызывают. Поэтому падает вызывающий код, а не сам метод.
Соглашение вызова с переменным числом аргументов пришло из C, и на Unix его в .NET не реализовали.
Где легко ошибиться
Вызов нужно вынести в отдельный метод и поставить над ним [MethodImpl(MethodImplOptions.NoInlining)]. Джит компилирует метод целиком, поэтому при встроенном вызове он откажется от всего метода, куда тот попал, и программа упадёт до первой выведенной строки — даже если вызов обёрнут в try.
Метод с __arglist нельзя объявить в обобщённом типе: среда выполнения такое не собирает.
Где встречается
Поиск по исходникам .NET не находит ни одного метода с __arglist. Оно существует ради соглашения вызова из стандарта CLI: другие компиляторы под эту платформу умеют выдавать такой код, и рантайм должен его выполнять. На Unix этого не сделали.
3. Массив уверен, что он строка
Исходник
У объекта в куче перед данными идут два служебных поля по 8 байт: заголовок для блока синхронизации и указатель на таблицу методов. Сама таблица описана на C# структурой с явными смещениями. Если записать в это место указатель на таблицу другого типа, рантайм начнёт считать объект этим типом:
nint original = MethodTable(numbers); SetMethodTable(numbers, MethodTable(sample)); Console.WriteLine(numbers.GetType()); SetMethodTable(numbers, original);
Что выводит отчёт
До замены массив отвечает: System.Int32[] строка отвечает: System.String После замены массив отвечает: System.String содержимое как строка: ..o (длина 3) После возврата массив отвечает: System.Int32[] первый элемент: 111 сборка мусора прошла, куча цела
Причина
Массив создан из трёх чисел: 111, 222, 333. Сразу за указателем на таблицу методов у строки записана длина в четырёх байтах, а у массива под длину отведено восемь. Строка прочитала первые четыре байта и получила длину 3.
Символы строка читает сразу за своими четырьмя байтами длины. Там у массива ещё не данные, а оставшиеся четыре байта его длины, и в них нули: отсюда два первых символа, которые отчёт заменил точками. Третий символ приходится уже на первое число массива: 111, а код 111 в Юникоде — латинская o.
Где легко ошибиться
Объект с заменённой таблицей существует ровно две строки. Замена и возврат идут в одном методе, между ними нет ни выхода, ни вызова сборки мусора. Если оставить объект в таком виде, сборщик мусора прочитает его поля по описанию чужого типа и уронит процесс.
Отчёт после возврата вызывает сборку мусора и убеждается, что процесс не упал. Тот же отчёт снят и на серверном сборщике: он размещает объекты иначе.
Где встречается
В библиотеке .NET такой замены нет, и польза от неё только одна: понять, из чего собран объект. Похожим приёмом пользуются отладчики и профилировщики, которым нужно прочитать чужой объект, не вызывая его методов.
4. Объект вместо условия
Исходник
Операторы true и false перегружаются как обычные операторы, но только парой:
public static bool operator true(Flag flag) => flag._value; public static bool operator false(Flag flag) => !flag._value; public static Flag operator &(Flag left, Flag right) => new(left._value && right._value);
После этого объект можно написать в условии вместо bool, хотя приведения к bool у типа нет.
Что выводит отчёт
if (yes) сработал if (no) не сработал while с объектом оборотов 3 тернарный оператор ветка да yes && no нет yes || no да
Причина
Пара true и false нужна ради коротких && и || для своих типов. Компилятор собирает короткий && из трёх операторов: & считает результат, а true и false решают, нужно ли вообще вычислять правый операнд.
Сделали это для типов вроде SqlBoolean, где кроме да и нет есть третье значение — Null, то есть «неизвестно». Для такого значения оба оператора возвращают false: оно не проходит ни if (x), ни if (!x).
Где легко ошибиться
Объявить только true компилятор не разрешит: нужны оба оператора. При этом присвоить объект переменной типа bool или сравнить его с true по-прежнему нельзя — приведения к bool у типа нет.
Где встречается
В SqlBoolean: там объявлены оба оператора и оператор &, ровно как в примере. Это тип для колонки базы данных, где кроме да и нет бывает пустое значение.
5. Метод у пустой ссылки
Исходник
Есть утверждение, что метод класса вызовется без ошибки, если внутри он не обращается к полям: разыменовывать в таком методе нечего. Проверка на трёх случаях:
Sample empty = null; empty.WithField(); // возвращает поле empty.WithoutField(); // возвращает строку, к полям не обращается empty.Extension(); // метод расширения
Что выводит отчёт
ссылка пустая: True метод обращается к полю NullReferenceException метод к полям не обращается NullReferenceException метод расширения выполнился, аргумент пустой
Причина
Компилятор C# на вызов метода у ссылочного типа выдаёт инструкцию callvirt даже для невиртуального метода — ради проверки на пустую ссылку. Обращение к полям тут ни при чём.
Метод расширения работает, потому что это не вызов на объекте: компилятор превращает его в статический вызов с аргументом. Внутри такого метода null приходит как значение параметра, и его можно проверить как любой другой аргумент.
Где легко ошибиться
В методе расширения проверка is null работает и нужна. В обычном методе она недостижима: до неё дело не дойдёт, исключение прилетит раньше.
Границы
Все прогоны на четырёх машинах сняты на Windows x64. Вывод с Linux в разделе про __arglist получен отдельно, на одной машине с Ubuntu 24.04, и подтверждён кодом джита.
Замена указателя на таблицу методов держится на том, что он записан в начале объекта. Microsoft этого нигде не обещала: реализацию могут изменить, и тогда приём перестанет работать. Сверка это заметит и остановит прогон.
__makeref, __reftype, __refvalue и __arglist в документации не описаны. Их могут убрать в любой версии компилятора без предупреждения — поэтому отчёты и запускаются на трёх рантаймах.
Код из статьи
UndocumentedProof — отчёты и выгрузки с четырёх машин
Ссылки
Всем удачи и до новых встреч!

