Menu

Рефлексия и атрибуты в C#: typeof, GetType и собственные атрибуты

Как работает рефлексия в C#: объекты Type из typeof и GetType, чтение и запись свойств, вызов методов по имени, создание экземпляров, а также атрибуты: встроенные вроде Obsolete, объявление собственных и их чтение во время выполнения. Плюс цена рефлексии.

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

Рефлексия позволяет программе исследовать типы во время работы: какие свойства есть у класса, каковы их значения, какие существуют методы, какие атрибуты прикреплены. Атрибуты это вторая половина: декларативные метки в квадратных скобках вроде [Obsolete] или [JsonPropertyName("id")], которые сами по себе ничего не значат, пока их не прочитает компилятор или какой-то код через рефлексию. Так работают сериализаторы, ORM, библиотеки проверки, фреймворки тестирования и маршрутизация ASP.NET.

Большинству прикладного кода рефлексия напрямую нужна редко, но понимание её работы объясняет многое в поведении фреймворков.

Объекты Type: typeof и GetType

Всё начинается с System.Type. Получить его можно двумя способами:

Вывод:

Employee
Manager
Employee
False
True
True
System.Int32
False
Name

Разница, которую нужно запомнить: typeof(X) называет тип, известный при написании кода, а obj.GetType() спрашивает объект, чем он является на самом деле. Сравнение GetType() == typeof(Employee) это точное совпадение, которое не срабатывает для производных классов, а обычно нужно не это; is и IsAssignableFrom учитывают наследование. nameof выглядит похоже, но это вообще не рефлексия: компилятор заменяет его строковой константой.

GetType() у ссылки null выбрасывает NullReferenceException, поскольку спрашивать некого. У упакованного типа значения, допускающего null, он возвращает базовый тип: ((int?)5).GetType() это System.Int32.

Чтение и запись свойств

GetProperties() перечисляет открытые свойства типа в виде объектов PropertyInfo, каждый из которых может читать и записывать значение у заданного экземпляра:

Вывод:

Name   String   = Mug
Price  Decimal  = 8.50
Stock  Int32    = 12
7.90
True
SUP-77

Здесь видны три вещи:

  • GetValue возвращает object, поэтому типы значений возвращаются упакованными, и для использования их нужно привести.
  • GetProperty с несуществующим именем возвращает null, и следующий вызов на нём выбрасывает NullReferenceException. Проверяйте перед использованием.
  • BindingFlags.NonPublic | BindingFlags.Instance добирается до закрытых членов. Это законно в инструментах и тестах, но обходит инкапсуляцию и молча ломается при рефакторинге класса.

Этот цикл по сути и есть то, как работает экспорт в CSV или сериализатор объектов в JSON: пройти по свойствам, прочитать каждое значение, отформатировать его.

Вызов методов и создание объектов по имени

GetMethod находит метод, а Invoke вызывает его с массивом аргументов. Activator.CreateInstance создаёт объект по Type, и так системы плагинов и контейнеры внедрения зависимостей строят типы, выбранные во время выполнения:

Вывод:

60.00
Decimal WithTax(1 parameters)
String Describe(0 parameters)
True

DeclaredOnly ограничивает список членами, объявленными в самом классе; без него GetMethods возвращает также ToString, Equals, GetHashCode и GetType из object. Type.GetType("Name") требует имени с пространством имён, а для типов из других сборок ещё и имени сборки ("MyApp.Plugins.Csv, MyApp.Plugins").

Если вызванный метод выбрасывает исключение, Invoke оборачивает его в TargetInvocationException; исходное находится в его InnerException.

Атрибуты: метки, которые читают компилятор и фреймворки

Атрибут записывается в квадратных скобках перед тем, что он описывает. Фреймворк определяет множество атрибутов; вот несколько, которые меняют поведение компилятора:

public class OrderService
{
    [Obsolete("Use PlaceOrderAsync instead.")]
    public void PlaceOrder(Order order) { }
    // Every call site: warning CS0618: 'OrderService.PlaceOrder(Order)' is obsolete: 'Use PlaceOrderAsync instead.'
    // [Obsolete("...", true)] makes it error CS0619 instead.

    [Conditional("DEBUG")]
    public void Trace(string message) => Console.WriteLine(message);
    // Calls to Trace are removed entirely from builds without the DEBUG symbol.
}

[Flags] enum Channels { None = 0, Email = 1, Sms = 2 }   // changes how ToString formats combinations
[Serializable] class Snapshot { }                     // marks a type for legacy binary serialization

Другие читают библиотеки во время выполнения: [JsonPropertyName] и [JsonIgnore] читает System.Text.Json, [Required] и [MaxLength] читают проверка моделей ASP.NET Core и Entity Framework, [HttpGet("orders/{id}")] читает маршрутизация ASP.NET, [Fact] и [Test] читают средства запуска тестов. Сам атрибут ничего не делает; делает код, который его ищет.

Имя ObsoleteAttribute при применении сокращается до [Obsolete]: по соглашению имя каждого класса атрибута оканчивается на Attribute, и C# позволяет опускать этот суффикс.

Объявление и чтение собственного атрибута

Собственный атрибут это класс, наследующийся от Attribute. [AttributeUsage] говорит, к чему его можно применять. Параметры конструктора становятся позиционными аргументами, а открытые задаваемые свойства именованными аргументами:

Вывод:

Username must be at most 20 characters
Email is required
Keep the city code short
0

Это миниатюрная версия того, что проверка моделей ASP.NET Core делает с System.ComponentModel.DataAnnotations. Аргументы атрибутов должны быть константами времени компиляции (числа, строки, typeof(...), значения перечислений или массивы из них), потому что они хранятся в метаданных сборки. GetCustomAttribute<T>() это метод расширения из System.Reflection; есть также IsDefined(typeof(T)), когда нужно лишь узнать, присутствует ли атрибут.

Цена рефлексии

Рефлексия обменивает скорость и безопасность на гибкость:

  • Скорость. Поиск члена по имени и вызов через Invoke или GetValue намного медленнее прямого вызова и упаковывает типы значений. Для повторного использования найдите PropertyInfo или MethodInfo один раз и сохраните его или превратите в делегат через Delegate.CreateDelegate или MethodInfo.CreateDelegate и вызывайте его.
  • Безопасность. Опечатка в имени или изменённая сигнатура нормально компилируются и падают во время выполнения. Везде, где можно, предпочитайте nameof(Product.Price) строке "Price", чтобы переименования ловились.
  • Обрезка и AOT. Обрезанные приложения и приложения Native AOT удаляют члены, которые, как кажется, никто не использует, а рефлексия скрывает использование от этого анализа. Современные библиотеки (System.Text.Json, GeneratedRegex, логирование) переходят на генераторы исходного кода, которые делают ту же работу при компиляции.

Используйте рефлексию для тех частей программы, которые действительно заранее не знают своих типов: плагины, универсальные инструменты, сериализаторы, вспомогательные средства тестов. Когда типы известны, обычный код, обобщения или интерфейсы быстрее и проверяются компилятором.

Частые ошибки

  • GetType() == typeof(Base) для проверки базового класса. Не срабатывает для производных типов. Используйте is или IsAssignableFrom.
  • Нет проверки на null. GetProperty, GetMethod и Type.GetType возвращают null, когда ничего не найдено.
  • Рефлексия в горячем цикле без кэширования. Кэшируйте MemberInfo или скомпилируйте делегат.
  • Перехват не того исключения из Invoke. Настоящее исключение это InnerException у TargetInvocationException.
  • Магические строки для имён членов. Используйте nameof.

Часто задаваемые вопросы

Что такое рефлексия в C#?

Рефлексия это способность программы исследовать типы во время выполнения: перечислять свойства и методы класса, читать и задавать значения по имени, вызывать методы, создавать экземпляры и читать атрибуты. Она находится в System.Reflection и начинается с объекта Type. На ней построены сериализаторы, ORM, контейнеры внедрения зависимостей и фреймворки тестирования.

Чем typeof отличается от GetType в C#?

typeof(Customer) разрешается при компиляции по имени типа, и объект ему не нужен. obj.GetType() вызывается у экземпляра во время выполнения и возвращает фактический тип объекта, который может быть более производным, чем объявленный тип переменной: для Animal a = new Dog(); вызов a.GetType() даёт Dog. GetType() у ссылки null выбрасывает NullReferenceException.

Как получить значение свойства по имени в C#?

obj.GetType().GetProperty("Price") возвращает PropertyInfo (или null, если такого открытого свойства нет), а .GetValue(obj) читает его как object. .SetValue(obj, value) записывает значение. Если делаете это в цикле, кэшируйте PropertyInfo, потому что дорогая часть это поиск.

Как создать собственный атрибут в C#?

Объявите класс, наследующийся от System.Attribute, назовите его с суффиксом Attribute и укажите, где его можно применять, через [AttributeUsage]: [AttributeUsage(AttributeTargets.Property)] class MaxLengthAttribute : Attribute { public int Length { get; } public MaxLengthAttribute(int length) { Length = length; } }. Применяйте его как [MaxLength(50)] и читайте через property.GetCustomAttribute<MaxLengthAttribute>().

Что делает атрибут Obsolete в C#?

[Obsolete("Use PlaceOrderAsync instead")] на члене заставляет компилятор выдавать предупреждение CS0618 с вашим сообщением в каждом месте вызова. [Obsolete("...", true)] превращает предупреждение в ошибку CS0619. Так библиотеки выводят API из употребления, не ломая вызывающий код в одночасье.

Медленная ли рефлексия в C#?

По сравнению с прямым вызовом да: поиск члена по имени и вызов через MethodInfo.Invoke или PropertyInfo.GetValue обычно в десятки или сотни раз медленнее и упаковывает типы значений. Для запуска, конфигурации и редкого использования это нормально. Для горячих путей кэшируйте MemberInfo, один раз постройте делегат или используйте вместо этого обобщения или генератор исходного кода.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ