В 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 в документации не описаны. Их могут убрать в любой версии компилятора без предупреждения — поэтому отчёты и запускаются на трёх рантаймах.

Код из статьи

Ссылки

Всем удачи и до новых встреч!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Знали про __makeref и __arglist?
0%Все знают0
0%Слышал, но не пробовал0
100%А чё, так можно было что ли!6
Проголосовали 6 пользователей. Воздержались 2 пользователя.