Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

А чё, так можно было? Три недооценённых атрибута.NET

Дата публикации: 14-09-2026 18:53:10

Три атрибута, которые меняют результат компиляции. Один запускает метод раньше Main. Второй передаёт текст выражения вместо результата. Третий ускоряет stackalloc в 50 раз.Все три описаны в документации и применяются внутри .NET. А в рабочем коде я их ни разу не видел. Читать далее

Основное содержимое страницы с новостью.

Три атрибута, которые меняют результат компиляции. Один запускает метод раньше Main. Второй передаёт текст выражения вместо результата. Третий ускоряет stackalloc в 50 раз.

Все три описаны в документации и применяются внутри.NET. За его пределами их практически не найти. 

Процессор

Система

Intel Core i9-10900KF 3.70GHz, 10 ядер

Windows 10 22H2

AMD Ryzen 9 5950X 3.39GHz, 16 ядер

Windows 10 1809

Intel Xeon W-2255 3.70GHz, 10 ядер

Windows Server 2022

Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра

Windows Server 2022

Все машины x64

Рантаймы.NET 8, 9 и 10 — все три в одном запуске BenchmarkDotNet 0.15.8.

1. Код, который выполняется до MainИсходник

Метод помечен атрибутом и больше нигде не встречается:

internal static class Startup
{
    [ModuleInitializer]
    internal static void Init()
    {
        Order.Add("инициализатор модуля");
    }
}

В Main его нет. Там только своя отметка первой строкой:

private static int Main(string[] args)
{
    Startup.Order.Add("точка входа");
    // ...
}
Что выводит отчёт
Порядок событий:
 
  1. инициализатор модуля
  2. точка входа

Одинаково на четырёх машинах и трёх рантаймах.

Причина

Компилятор находит все методы с этим атрибутом и вызывает их из инициализатора модуля. До этого момента ни одна строка кода сборки не выполняется.

К методу есть требования: статический, без параметров, возвращает void, не обобщённый и не внутри обобщённого типа, доступность internal или public. Любое нарушение — ошибка сборки.

Где встречается

В библиотеке классов такой метод один, в EventSource. Основное применение — генераторы исходного кода: настройка выполняется до Main.

Где легко ошибиться

Рассчитывать на порядок, если инициализаторов несколько. Три метода в разных классах вызвались в порядке объявления и от прогона к прогону не менялись, но в спецификации этот порядок не закреплён.

Бросить исключение. Программа падает до точки входа, и Main не выполняется:

Unhandled exception. System.TypeInitializationException:
  The type initializer for '<Module>' threw an exception.
 ---> System.InvalidOperationException: из инициализатора

Применять в библиотеке. Начиная с.NET 10 включено правило CA2255: библиотека меняет порядок запуска приложения и мешает выбросить неиспользуемый код при публикации.

2. Текст выражения вместо значенияИсходник

Второй параметр помечен атрибутом и получает значение по умолчанию:

static void Check(bool condition,
    [CallerArgumentExpression(nameof(condition))] string? text = null)
{
    Console.WriteLine(text + " = " + condition);
}
Что выводит отчёт
Что попадает в text при разных вызовах:
 
  a + b > 10                            ложь
  a * b == 6                            истина
  empty is null                         истина
  !string.IsNullOrEmpty(empty)          ложь

В параметр приходит не результат, а то, что написано в месте вызова.

Причина

Подстановка происходит при сборке: компилятор берёт исходный текст аргумента и передаёт его строковой константой. Программа получает готовую строку и ничего с ней не делает.

Замер это подтверждает. Библиотечная проверка и такая же, написанная явно, исключений в замере нет: 

Способ

i9-10900KF

Ryzen 9 5950X

Xeon W-2255

Xeon Silver 4314

из библиотеки

0,3944

0,2656

0,7058

0,6679

вручную

0,3969

0,2646

0,6427

0,7231

Наносекунды,.NET 10

Где встречается

В 21-м месте библиотеки классов. Например, ArgumentNullException.ThrowIfNull:

public static void ThrowIfNull([NotNull] object? argument,
    [CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
    if (argument is null)
    {
        Throw(paramName);
    }
}

Поэтому исключение показывает имя переменной из места вызова, а не имя параметра argument. Отчёт это подтверждает:

  ArgumentNullException             имя аргумента: empty
  ArgumentOutOfRangeException       имя аргумента: a - 5

Во втором случае именем аргумента стало целое выражение — то, что передали.

Где легко ошибиться

Передать текст вторым аргументом. Тогда компилятор ничего не подставляет и берёт то, что написано:

Check(a + b > 10);                       // текст: a + b > 10
Check(a + b > 10, "передано вручную");   // текст: передано вручную

Забыть про размер сборки. Текст каждого аргумента строкой попадает в метаданные: после шести вызовов сборка увеличилась с 4608 байт до 5120.

3. Отказ от обнуления памятиИсходник

Два одинаковых метода, различаются одной строкой:

[MethodImpl(MethodImplOptions.NoInlining)]
internal static int StackWithInit(int size)
{
    Span<byte> buffer = stackalloc byte[size];
    buffer[0] = 1;
    buffer[^1] = 2;
 
    return buffer[0] + buffer[^1];
}
 
[SkipLocalsInit]
[MethodImpl(MethodImplOptions.NoInlining)]
internal static int StackWithoutInit(int size)
{
    // тело то же самое
}
Что показывает замер

Буфер

i9-10900KF

Ryzen 9 5950X

Xeon W-2255

Xeon Silver 4314

64 байта

2,452 / 1,765

2,082 / 4,695

2,793 / 2,419

5,409 / 4,512

256 байт

6,475 / 1,729

5,741 / 4,691

8,530 / 2,208

13,015 / 4,501

1024 байта

25,505 / 1,769

17,712 / 4,703

31,281 / 2,574

48,282 / 4,566

4096 байт

103,474 / 2,043

71,811 / 4,939

126,066 / 2,685

189,264 / 4,827

Наносекунды, с обнулением и без,.NET 10 

На четырёх килобайтах разница от 14,5 до 50,6 раза. С обнулением время растёт вместе с буфером, без обнуления — не меняется.

Причина

У метода есть флаг от компилятора, и по нему джит заполняет нулями всю память под локальные переменные, включая ту, что выделена через stackalloc. Атрибут его убирает.

Отсюда и числа в таблице: чем больше буфер, тем дольше заполнение, а без него размер не важен.

Где встречается

Атрибут прописан не в коде, а в общем файле сборки: свойство SkipLocalsInit включено для каждого проекта, входящего в.NET.

Где легко ошибиться

Поставить атрибут ради небольшого буфера. На 64 байтах выигрыш в лучшем случае 1,39 раза, а на Ryzen 9 5950X версия без обнуления медленнее: 4,695 против 2,082.

На этой машине результат отличается от остальных: без заполнения нулями с ростом буфера время не меняется и составляет около 4,7 наносекунды, а на других процессорах оно в пределах 1,7–2,7.

Прочитать буфер раньше, чем в него что‑то записали. Атрибут не заполняет память нулями и не требует этого от рантайма — в буфере будут данные от предыдущих вызовов.

Забыть про файл проекта. Без небезопасного контекста атрибут не работает: сборка упадёт с ошибкой CS0227.

Границы замеров

Все замеры сняты на x64 под Windows, на.NET 8, 9 и 10.

У всех измеряемых методов запрещено встраивание. Иначе компилятор перенесёт код метода в замер, а вместе с ним заполнение памяти нулями.

SkipLocalsInit проставлен на отдельных методах, а не на классе: на классе он подействует и на тот вариант, который заполняет память нулями.

В замере на вход всегда передаётся непустая строка, поэтому проверка на null ни разу не срабатывает. Так в замер попадает только сама проверка, без обработки исключения.

Код из статьи
  • AttributeProof — замеры, отчёты и выгрузки с четырёх машин

Ссылки

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

15.38%Все знают4

19.23%Знал, но не пробовал5

65.38%А чё, так можно было что ли!17

Проголосовали 26 пользователей. Воздержались 3 пользователя.

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1А чё, так можно было? Три места в.NET, которые не развидеть08.4915-09-2026
2А чё, так можно было? Опечатка в исходниках .NET, которой больше 20 лет014.7315-09-2026
3Бенчмаркая Sum: ускорил циклом — замедлил в ×4,707.8118-07-2026
4Бенчмаркая ключи Dictionary: забыл IEquatable — получил ×5 и 96 байт на каждый поиск19.1325-07-2026
5Как скормить нейросети большой проект и не скормить лишнего: что я узнал, пока агенты читали мой код06.4509-10-2026
6Отбор акций на .NET: 14,5 млн баров в SQLite, фильтр, который мерил не в тех единицах, и перестановочный тест07.9411-09-2026
7Обзор нововведений в C# 1508.5521-10-2026
8Native AOT заставил меня переписать весь .NET проект08.3622-09-2026
9Бенчмаркая foreach и энумераторы: аллокации, которые прятались 10 лет09.4511-07-2026
10Я ненавижу AutoMapper07.702-07-2026

Классификация: Мнения. Схожих патентов: 0. Схожих новостей: 10. Тональность: 0. Информативность: 9.91. Источник: habr.com.