01Стандартная библиотека C# и .NET (BCL)
Коллекции и структуры данных C#/.NET
Содержание
- Обзор System.Collections.Generic
- Временная сложность операций
- Внутреннее устройство коллекций
- Параллельные коллекции
- Неизменяемые коллекции
- Span
vs List - Практические рекомендации
System.Collections.Generic — Полный обзор
Основные коллекции и их назначение
| Коллекция | Назначение | Операции |
|---|---|---|
List<T> | Динамический массив, доступ по индексу | Добавление в конец, поиск по индексу |
Dictionary<TKey, TValue> | Хеш-таблица, ключ-значение | Быстрый поиск/вставка/удаление по ключу |
HashSet<T> | Уникальные элементы, операции над множествами | Проверка Contains, объединение, пересечение |
SortedSet<T> | Отсортированные уникальные элементы | Запросы по диапазону, порядковые операции |
LinkedList<T> | Двусвязный список | Вставка/удаление в середине |
Queue<T> | Очередь FIFO | Enqueue/Dequeue |
Stack<T> | Стек LIFO | Push/Pop |
Производительность обобщённых и необобщённых коллекций
// Обобщённая — типобезопасная, без боксинга
List<int> genericList = new() { 5, 9, 1, 4 };
// Необобщённая — требует боксинга/анбоксинга, ~75x медленнее
ArrayList nonGenericList = new() { 5, 9, 1, 4 };Ключевые преимущества:
- Типобезопасность на этапе компиляции
- Отсутствие боксинга/анбоксинга для значимых типов (снижает нагрузку на GC)
- Производительность: сортировка
List<int>~75x быстрее, чемArrayList
Временная сложность операций
List
| Операция | Сложность | Примечания |
|---|---|---|
Индексатор [] | O(1) | Прямой доступ к массиву |
| Add (в конец) | O(1)* | Амортизированная, O(n) при изменении размера |
| Insert (по индексу) | O(n) | Сдвиг элементов |
| RemoveAt | O(n) | Сдвиг элементов |
| Remove (по значению) | O(n) | Линейный поиск + сдвиг |
| Contains | O(n) | Линейный поиск |
| Sort | O(n log n) | IntroSort |
| BinarySearch | O(log n) | Требует отсортированного списка |
Dictionary
| Операция | Сложность | Примечания |
|---|---|---|
Индексатор [] (get/set) | O(1)* | Хеш-таблица с цепочками |
| TryGetValue | O(1)* | Амортизированная |
| Add | O(1)* | O(n) при изменении размера |
| Remove | O(1)* | — |
| ContainsKey | O(1)* | — |
*При равномерном распределении хеш-кодов. Худший случай (все ключи в одной корзине): O(n).
HashSet
| Операция | Сложность | Примечания |
|---|---|---|
| Add | O(1)* | O(n) при изменении размера |
| Contains | O(1)* | — |
| Remove | O(1)* | — |
| SetEquals, IsSubsetOf и др. | O(n) | Перебор элементов |
SortedSet
| Операция | Сложность | Примечания |
|---|---|---|
| Add | O(log n) | Балансировка красно-чёрного дерева |
| Contains | O(log n) | — |
| Remove | O(log n) | — |
| GetViewBetween | O(log n) | Создаёт представление без копирования |
| Min / Max | O(1) | Левый/правый край дерева |
LinkedList
| Операция | Сложность | Примечания |
|---|---|---|
| AddFirst/AddLast | O(1) | — |
| InsertAfter/Before | O(1) | При наличии ссылки на узел |
| Find (по значению) | O(n) | Линейный обход |
| Remove (по узлу) | O(1) | — |
| Remove (по значению) | O(n) | Поиск + удаление |
Queue и Stack
Обе используют кольцевой массив:
| Операция | Queue | Stack |
|---|---|---|
| Enqueue / Push | O(1)* | O(1)* |
| Dequeue / Pop | O(1)* | O(1) |
| Peek | O(1) | O(1) |
*O(n) при изменении размера внутреннего массива.
Внутреннее устройство коллекций
List — динамический массив со стратегией роста x2
public class List<T>
{
private const int DefaultCapacity = 4;
private T[] _items; // Внутренний массив
private int _size; // Фактическое количество элементов
private int _version; // Версия для обнаружения изменений при итерации
public void Add(T item)
{
if (_size == _items.Length)
EnsureCapacity(_size + 1); // Изменение размера: удвоение ёмкости
_items[_size++] = item;
_version++;
}
private void EnsureCapacity(int min)
{
if (_items.Length < min)
{
int newCapacity = _items.Length == 0
? DefaultCapacity // Первый элемент -> ёмкость = 4
: _items.Length * 2; // Далее: удвоение (4->8->16->32...)
if ((uint)newCapacity > Array.MaxArrayLength)
newCapacity = Array.MaxArrayLength;
if (newCapacity < min)
newCapacity = min;
Capacity = newCapacity; // Создаёт новый массив + Array.Copy
}
}
}Ключевые моменты:
- Начальная ёмкость: 0 (пустой
_emptyArray) - Первый Add -> ёмкость становится 4
- Каждое последующее изменение размера -> ёмкость удваивается
- Изменение размера создаёт новый массив и копирует все элементы через
Array.Copy(нативный, на основе указателей) - Для 10 млн элементов требуется всего ~24 операции изменения размера
Оптимизация: если размер известен — установите ёмкость заранее:
var list = new List<int>(expectedCount); // Избегает всех операций изменения размераDictionary — хеш-таблица с цепочками (цепочки коллизий)
.NET использует раздельные цепочки, а не открытую адресацию. В .NET Core / .NET 5+ структура была оптимизирована для значительного сокращения потребления памяти и ускорения вычислений.
public class Dictionary<TKey, TValue> where TKey : notnull
{
private struct Entry
{
public uint hashCode; // Полный 32-битный хеш-код ключа (uint). В .NET Core/5+ знак не маскируется
public int next; // Индекс следующей записи в цепочке, -1 если последняя
public TKey key;
public TValue value;
}
// Оптимизация компактных (byte-sized) корзин (начиная с .NET Core 2.1):
// Вместо фиксированного int[] (4 байта на корзину) тип массива корзин выбирается динамически:
// - byte[] для емкостей < 256 (1 байт на корзину)
// - ushort[] для емкостей < 65536 (2 байта на корзину)
// - int[] для больших емкостей (4 байта на корзину)
private object? _buckets; // Хранит массив корзин (byte[], ushort[] или int[])
private Entry[] _entries; // Все записи (включая удалённые)
private int _count; // Количество активных записей
private int _freeList; // Индекс первого свободного слота в entries
private int _freeCount; // Количество свободных слотов
}Алгоритм поиска:
private ref TValue FindValue(TKey key)
{
if (_buckets != null)
{
uint hashCode = (uint)_comparer.GetHashCode(key);
// В .NET Core / 5+ деление по модулю оптимизировано через умножение (FastMod) с помощью специального множителя.
// Ниже показана логическая цепочка обхода коллизий:
int i = GetBucket(hashCode);
Entry[] entries = _entries;
while ((uint)i < (uint)entries.Length)
{
if (entries[i].hashCode == hashCode && _comparer.Equals(entries[i].key, key))
return ref entries[i].value; // Найдено!
i = entries[i].next;
}
}
throw new KeyNotFoundException();
}Алгоритм вставки:
- Вычислить 32-битный хеш-код ключа (как
uint). - Найти индекс корзины (с использованием оптимизированного деления по модулю через
FastMod). - Обход цепочки — если ключ найден, заменить значение.
- Если нет -> взять свободный слот из списка удалённых (
freeList) или расширить массив_entriesи заново пересчитать_buckets. - Вставить запись в начало цепочки корзины.
Изменение размера: когда _count == _entries.Length:
- Новый размер = ближайшее простое число > нового счётчика (
HashHelpers.ExpandPrime). - Все записи повторно хешируются в новые корзины. Тип массива
_bucketsможет повыситься (например, сbyte[]доushort[]). - Для ключей
string: после определённого порога коллизий переключается наRandomizedStringEqualityComparer(защита от DoS-атак через искусственное создание хеш-коллизий).
Почему цепочки вместо открытой адресации?
- Цепочки лучше справляются с высокими коэффициентами загрузки (>70%).
- Открытая адресация (используется в старом
Hashtable) страдает от кластеризации при плохих хешах. - Цепочки дают более стабильную и предсказуемую производительность при росте коллекции.
HashSet — та же структура, что и Dictionary
HashSet<T> внутренне использует ту же архитектуру хеш-таблицы с цепочками.
SortedSet — красно-чёрное дерево
public class SortedSet<T> : ISortedSet<T>, ISet<T>
{
private struct Node
{
public T Item; // Значение
public Node Left; // Левый потомок (меньше)
public Node Right; // Правый потомок (больше)
public Node Parent; // Родитель
internal bool Color; // true = Красный, false = Чёрный
}
}Свойства красно-чёрного дерева:
- Каждый узел красный или чёрный
- Корень всегда чёрный
- Красный узел не может иметь красный потомок (нет двух последовательных красных)
- Все пути от узла до листьев содержат одинаковое количество чёрных узлов
Гарантия: высота дерева <= 2*log2(n+1), обеспечивая O(log n) для всех операций.
LinkedList, Queue, Stack
LinkedList
private class Node
{
internal T _value;
internal Node _next;
internal Node _prev;
internal LinkedList _list;
}LinkedListNode<T>— публичная обёртка вокруг внутреннего узла- Вставка/удаление по ссылке на узел: O(1)
- Поиск по значению: O(n)
Queue
private T[] _array;
private int _head; // Индекс первого элемента
private int _tail; // Индекс следующего свободного слота
private int _size;Параллельные коллекции (System.Collections.Concurrent)
Когда использовать
| Сценарий | Коллекция |
|---|---|
| Несколько потоков производителей/потребителей | ConcurrentQueue<T>, ConcurrentBag<T> |
| Кэш с частым чтением, редкой записью | ConcurrentDictionary<TKey, TValue> |
| Потокобезопасный стек | ConcurrentStack<T> |
| Без блокировок, только добавление | ImmutableArray<T> + замена ссылок |
ConcurrentDictionary
var dict = new ConcurrentDictionary<string, int>();
// Атомарная вставка или обновление
dict.AddOrUpdate("key",
newValue: 1,
updateValueFactory: (_, oldValue) => oldValue + 1);
// Атомарное получение или добавление
var value = dict.GetOrAdd("key", k => ComputeExpensiveValue(k));
// Безопасное удаление
dict.TryRemove("key", out int removedValue);Внутреннее устройство: сегментная блокировка (lock striping) — несколько сегментов блокируются независимо, позволяя параллельные операции с разными ключами.
ConcurrentQueue и ConcurrentStack
Обе используют алгоритмы без блокировок (CompareExchange / CAS).
Неизменяемые коллекции (System.Collections.Immutable)
Когда использовать
- Функциональное программирование (без побочных эффектов)
- Многопоточное чтение без блокировок
- Структуры данных, неизменяемые после создания (DAG, AST)
- Кэширование: безопасно передавать между потоками
Основные типы
| Изменяемая | Неизменяемый эквивалент | Особенности |
|---|---|---|
List<T> | ImmutableList<T> | Персистентная структура данных |
Dictionary<K,V> | ImmutableDictionary<K,V> | Hash array mapped trie (HAMT) |
HashSet<T> | ImmutableHashSet<T> | HAMT |
SortedSet<T> | ImmutableSortedSet<T> | Персистентное красно-чёрное дерево |
Паттерн Builder для эффективности
var builder = ImmutableHashSet.CreateBuilder<string>();
foreach (var item in largeCollection)
builder.Add(item); // O(1) — изменяемый builder
var frozen = builder.ToImmutable(); // O(1) — заморозка без копированияHAMT (Hash Array Mapped Trie)
ImmutableDictionary и ImmutableHashSet используют HAMT — trie с коэффициентом ветвления 32 (5 бит хеш-кода на уровень). Это даёт:
- O(log32 n) для всех операций ~ O(1) на практике (максимум 5 уровней для миллиарда элементов)
- Структурное разделение: изменение одного элемента создаёт только новый путь от корня до листа
Span vs List — Когда что использовать
Сравнение
| Критерий | List<T> | Span<T> |
|---|---|---|
| Выделение памяти | Куча (GC) | Стек / inline (без GC) |
| Размер | Динамический (изменение размера) | Фиксированный при создании |
| Совместимость с async | Да | Нет (ref struct) |
| Хранение в поле класса | Да | Нет |
| Срез | GetRange() -> новый объект | Slice() -> представление без выделения памяти |
Когда использовать Span
// Парсинг — одноразовая обработка без выделений памяти
public int ParseInt32(ReadOnlySpan<char> value) { /* ... */ }
// Обработка буфера — работа с частью данных
Span<byte> buffer = stackalloc byte[1024];
Process(buffer.Slice(0, 512)); // Представление без выделения памяти
// Высокопроизводительные критические участки
public void Transform(Span<float> data)
{
for (int i = 0; i < data.Length; i++)
data[i] *= 2.0f;
}Когда использовать List
// Коллекция, которая растёт/уменьшается
var items = new List<string>();
items.Add("new item");
// Хранение между вызовами методов
public class Service
{
private readonly List<int> _cache = new(); // Span<T> здесь невозможен
}
// Асинхронный контекст
public async Task ProcessAsync()
{
var list = new List<byte>(); // Span<T> нельзя использовать в async/await
}GetHashCode() — Правильная реализация
Правила
- Детерминированность: один объект -> один хеш в течение жизни
- Согласованность с Equals:
a.Equals(b)->a.GetHashCode() == b.GetHashCode() - Равномерное распределение: равномерное распределение для лучшей производительности хеш-таблиц
Примеры
// Плохо — все объекты имеют одинаковый хеш
public override int GetHashCode() => 42;
// Хорошо — составной хеш через структуру HashCode
public override int GetHashCode()
=> HashCode.Combine(FirstName, LastName, BirthDate);
// Для record — генерируется автоматически
public record Person(string FirstName, string LastName, DateTime BirthDate);IEnumerable vs IReadOnlyCollection vs IReadOnlyList
| Интерфейс | Индексатор | Count | Перебор | Изменяемый? |
|---|---|---|---|---|
IEnumerable<T> | Нет | Нет | Да | Неизвестно |
IReadOnlyCollection<T> | Нет | Да | Да | Нет |
IReadOnlyList<T> | Да O(1)* | Да | Да | Нет |
ICollection<T> | Нет | Да | Да | Да (Add/Remove) |
IList<T> | Да O(1)* | Да | Да | Да (Add/Remove/Insert) |
Что возвращать
// Минимальный контракт — только перебор
public IEnumerable<Product> GetProducts() => _products;
// Нужен размер заранее (для прогресс-бара, ёмкости)
public IReadOnlyCollection<Product> GetProducts() => _products;
// Нужен доступ по индексу без раскрытия изменяемости
public IReadOnlyList<Product> GetProducts() => _products.AsReadOnly();
// Не делайте так — раскрывает внутреннюю реализацию
public List<Product> GetProducts() => _internalList;Практика
- [ ] Изучить материал
Бенчмарк: List vs ArrayPool
using System.Buffers;
using System.Diagnostics;
public class ListVsArrayPoolBenchmark
{
[Benchmark]
public int ListApproach()
{
var list = new List<int>(1000);
for (int i = 0; i < 1000; i++) list.Add(i);
int sum = 0;
foreach (var x in list) sum += x;
return sum;
}
[Benchmark]
public int ArrayPoolApproach()
{
var array = ArrayPool<int>.Shared.Rent(1000);
try
{
for (int i = 0; i < 1000; i++) array[i] = i;
int sum = 0;
for (int i = 0; i < 1000; i++) sum += array[i];
return sum;
}
finally
{
ArrayPool<int>.Shared.Return(array);
}
}
}Результаты: ArrayPool выигрывает при повторном использовании (меньше нагрузка на GC), List быстрее для одноразовых операций.
Пользовательская коллекция с Comparer
public class CaseInsensitiveStringDictionary<TValue>
: Dictionary<string, TValue>
{
public CaseInsensitiveStringDictionary()
: base(StringComparer.OrdinalIgnoreCase) { }
public CaseInsensitiveStringDictionary(int capacity)
: base(capacity, StringComparer.OrdinalIgnoreCase) { }
}Сводная таблица: Какую коллекцию выбрать
| Задача | Рекомендуемая коллекция |
|---|---|
| Последовательность с доступом по индексу | List<T> |
| Ключ -> значение (быстрый поиск) | Dictionary<TKey, TValue> |
| Уникальные элементы без порядка | HashSet<T> |
| Отсортированные уникальные элементы | SortedSet<T> |
| Обработка FIFO | Queue<T> / ConcurrentQueue<T> |
| Обработка LIFO | Stack<T> / ConcurrentStack<T> |
| Вставка в середину по узлу | LinkedList<T> |
| Многопоточный производитель/потребитель | Параллельные коллекции |
| Срез без выделения памяти | Span<T> / Memory<T> |
| Потокобезопасное чтение | Неизменяемые коллекции |
Контрольные вопросы
- Как работает GetHashCode() и почему важна правильная реализация?
- В чём разница между IEnumerable
, IReadOnlyCollection , IReadOnlyList ? - Когда следует использовать неизменяемые коллекции?
LINQ и функциональное программирование в C#/.NET
Содержание
- Ленивое выполнение vs немедленное выполнение
- yield return — внутреннее устройство state machine
- IEnumerable
vs IQueryable - Стандартные операторы запросов
- ValueTuple vs Tuple
- Пользовательские операторы LINQ
- Деревья выражений
Ленивое выполнение vs немедленное выполнение
Отложенное (ленивое) выполнение
Большинство операторов LINQ используют отложенное выполнение — запрос не выполняется при определении, а выполняется при перечислении:
var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Определение запроса — выполнения ЕЩЁ НЕТ
var query = numbers
.Where(n => n % 2 == 0)
.Select(n => n * n);
// Выполнение происходит здесь (во время перечисления)
foreach (var item in query)
Console.WriteLine(item); // 4, 16, 36, 64, 100Ключевой момент: Каждое перечисление повторно выполняет всю цепочку запросов. Если исходные данные изменяются между перечислениями, результаты будут отличаться.
Немедленное выполнение
Операторы, возвращающие одно значение или материализующие коллекцию, выполняются немедленно:
| Немедленные (возвращают одно значение) | Материализующие (возвращают коллекцию) |
|---|---|
Count(), LongCount() | ToList(), ToArray() |
Sum(), Average(), Min(), Max() | ToDictionary(), ToLookup() |
First(), FirstOrDefault() | ToHashSet() |
Single(), SingleOrDefault() | — |
Any(), All() | — |
Contains() | — |
ElementAt(), Last() | — |
var numbers = new[] { 1, 2, 3, 4, 5 };
// Эти выполняются немедленно:
int count = numbers.Count(); // 5
int sum = numbers.Sum(); // 15
bool hasEven = numbers.Any(n => n % 2 == 0); // true
// Материализация принудительно выполняет и кэширует результаты:
var list = numbers.Where(n => n > 2).ToList(); // [3, 4, 5] — выполнено один разПринудительное выполнение для избежания повторного вычисления
var db = GetDatabaseContext();
// ПЛОХО: Запрос выполняется дважды (два вызова к БД)
var query = db.Users.Where(u => u.IsActive);
Console.WriteLine(query.Count()); // Первое выполнение
foreach (var user in query) // Второе выполнение!
Console.WriteLine(user.Name);
// ХОРОШО: Материализация один раз, повторное использование результатов
var users = db.Users.Where(u => u.IsActive).ToList(); // Один вызов к БД
Console.WriteLine(users.Count());
foreach (var user in users)
Console.WriteLine(user.Name);yield return — внутреннее устройство state machine
Как это работает
yield return создаёт блок итератора, который компилятор преобразует в класс state machine, реализующий IEnumerator<T>:
public static IEnumerable<int> GetNumbers()
{
yield return 1; // Состояние 0
yield return 2; // Состояние 1
yield return 3; // Состояние 2
// Состояние -1 (завершено)
}Эквивалент IL, генерируемый компилятором:
// Что генерирует компилятор:
sealed class <GetNumbers>d__0 : IEnumerable<int>, IEnumerator<int>
{
int <>1__state; // Текущее состояние (-1 = завершено, 0..N = точки yield)
int <>2__current; // Текущее возвращаемое значение
bool <>4__this;
public bool MoveNext()
{
switch (<>1__state)
{
case 0:
<>1__state = -1;
<>2__current = 1;
<>1__state = 1;
return true;
case 1:
<>1__state = -1;
<>2__current = 2;
<>1__state = 2;
return true;
case 2:
<>1__state = -1;
<>2__current = 3;
<>1__state = 3;
return true;
default:
return false; // Конец итерации
}
}
public int Current => <>2__current;
// Реализации Dispose(), Reset()...
}Ключевые характеристики:
- Промежуточная коллекция не создаётся — значения генерируются по требованию
- Состояние сохраняется между вызовами
MoveNext() - Обработка исключений с try/finally генерирует дополнительные состояния для очистки
- Объект итератора реализует и
IEnumerable<T>, иIEnumerator<T>
Практический пример: бесконечная последовательность
public static IEnumerable<long> Fibonacci()
{
long a = 0, b = 1;
while (true)
{
yield return a;
(a, b) = (b, a + b);
}
}
// Использование — берёте только то, что нужно:
var first10 = Fibonacci().Take(10).ToList();
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]IEnumerable vs IQueryable — отложенное выполнение и деревья выражений
Ключевые отличия
| Аспект | IEnumerable<T> | IQueryable<T> |
|---|---|---|
| Место выполнения | В памяти (на клиенте) | Удалённо (база данных, сервис) |
| Представление запроса | Делегат (Func<>) скомпилирован в вызов метода | Дерево выражений (Expression<Func<>>) как структура данных |
| Фильтрация | Загружает все данные, фильтрует в памяти | Переводит в SQL/нативный запрос, фильтрует на сервере |
| Методы расширения | System.Linq.Enumerable (статический класс) | System.Linq.Queryable (статический класс) |
| Производительность для больших данных | Плохая (загружает всё) | Хорошая (переводит в оптимизированный запрос) |
Как работает IQueryable
// IEnumerable — делегаты выполняются в памяти
IEnumerable<int> enumerableQuery = numbers.Where(n => n > 5);
// Использует: Enumerable.Where(source, predicate) где predicate — Func<int, bool>
// IQueryable — дерево выражений отправляется провайдеру
IQueryable<int> queryableQuery = numbers.AsQueryable().Where(n => n > 5);
// Использует: Queryable.Where(source, predicate) где predicate — Expression<Func<int, bool>>Структура дерева выражений для n => n > 5:
Lambda: n => n > 5
├── Parameter: n (int)
└── Body: BinaryExpression (GreaterThan)
├── Left: ParameterExpression (n)
└── Right: ConstantExpression (5)Пример с Entity Framework
// ПЛОХО — загружает ВСЕХ пользователей в память, затем фильтрует в C#
var bad = db.Users.AsEnumerable() // Материализует всю таблицу!
.Where(u => u.City == "London") // Фильтр в памяти
.ToList();
// ХОРОШО — переводится в SQL: SELECT * FROM Users WHERE City = 'London'
var good = db.Users
.Where(u => u.City == "London") // Дерево выражений -> перевод в SQL
.ToList(); // Выполнение оптимизированного запросаКогда преобразование имеет значение
// Порядок цепочки важен!
var result1 = db.Products
.Where(p => p.Price > 100) // Фильтр SQL (эффективно)
.AsEnumerable() // Переключение на выполнение в памяти
.OrderBy(p => p.Name.Length); // Сортировка в памяти (SQL не может это легко перевести)
// vs. всё в SQL:
var result2 = db.Products
.Where(p => p.Price > 100)
.OrderBy(p => p.Name) // SQL ORDER BY
.ToList();Стандартные операторы запросов — полный справочник
Проекция
| Оператор | Описание | Пример |
|---|---|---|
Select | Преобразование каждого элемента | .Select(x => x.ToUpper()) |
SelectMany | Сглаживание вложенных коллекций | .SelectMany(u => u.Orders) |
OfType<T> | Фильтрация по типу | .OfType<Customer>() |
Фильтрация
| Оператор | Описание | Пример |
|---|---|---|
Where | Фильтрация по предикату | .Where(x => x > 0) |
Distinct | Удаление дубликатов | .Distinct() |
Except | Разность множеств | .Except(otherSet) |
Intersect | Пересечение множеств | .Intersect(otherSet) |
Группировка
| Оператор | Описание | Пример |
|---|---|---|
GroupBy | Группировка по ключу | .GroupBy(x => x.Category) |
ToLookup | Создание таблицы поиска | .ToLookup(x => x.Key) |
GroupJoin | Групповое соединение | .GroupJoin(...) |
Соединение
// Синтаксис методов LINQ
var joined = customers
.Join(orders,
c => c.Id, // Селектор ключа клиента
o => o.CustomerId, // Селектор ключа заказа
(c, o) => new { Customer = c, Order = o }); // Селектор результата
// Синтаксис запросов LINQ (эквивалентно)
var joined2 = from c in customers
join o in orders on c.Id equals o.CustomerId
select new { Customer = c, Order = o };Агрегация
| Оператор | Описание | Пример |
|---|---|---|
Sum | Сумма значений | .Select(x => x.Price).Sum() |
Average | Среднее значение | .Average(x => x.Score) |
Min / Max | Мин/макс значение | .Min(x => x.Age) |
Count / LongCount | Количество элементов | .Count(x => x.Active) |
Aggregate | Пользовательская аккумуляция | См. ниже |
Aggregate — мощный паттерн аккумуляции
// Базовый: сведение к одному значению
var numbers = new[] { 1, 2, 3, 4, 5 };
var sum = numbers.Aggregate((acc, x) => acc + x); // 15
// С начальным значением: аккумуляция в сложный результат
var result = words.Aggregate(
new Dictionary<string, int>(), // Начальное значение
(dict, word) => // Аккумулятор
{
dict[word] = dict.GetValueOrDefault(word) + 1;
return dict;
});
// С селектором результата: преобразование финального аккумулятора
var longest = words.Aggregate(
"", // Начальное значение (пустая строка)
(longest, word) => // Аккумулятор
word.Length > longest.Length ? word : longest,
longest => longest.ToUpper()); // Селектор результатаScan (кумулятивные операции)
.NET не имеет встроенного оператора Scan, но его можно реализовать:
public static IEnumerable<TAccumulate> Scan<TSource, TAccumulate>(
this IEnumerable<TSource> source,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> func)
{
yield return seed;
foreach (var item in source)
{
seed = func(seed, item);
yield return seed;
}
}
// Использование: кумулятивная сумма
var numbers = new[] { 1, 2, 3, 4, 5 };
var cumulative = numbers.Scan(0, (acc, x) => acc + x);
// [0, 1, 3, 6, 10, 15]Сортировка
| Оператор | Описание | Пример |
|---|---|---|
OrderBy | Сортировка по возрастанию | .OrderBy(x => x.Name) |
OrderByDescending | Сортировка по убыванию | .OrderByDescending(x => x.Age) |
ThenBy / ThenByDescending | Вторичная сортировка | .OrderBy(...).ThenBy(...) |
Reverse | Обратный порядок | .Reverse() |
Разбиение
| Оператор | Описание | Пример |
|---|---|---|
Take(n) | Первые n элементов | .Take(10) |
Skip(n) | Пропустить первые n | .Skip(10) |
TakeWhile | Брать, пока предикат истинен | .TakeWhile(x => x > 0) |
SkipWhile | Пропускать, пока предикат истинен | .SkipWhile(x => x == 0) |
Операторы элементов
| Оператор | Описание | Выбрасывает исключение при пустой? |
|---|---|---|
First() | Первый элемент, соответствующий предикату | Да (InvalidOperationException) |
FirstOrDefault() | Первый или default(T) | Нет |
Single() | Ровно один элемент | Да (если 0 или >1 элементов) |
SingleOrDefault() | Один или none, иначе исключение | Да (если >1 элементов) |
Ключевое отличие: First возвращает первое совпадение; Single утверждает, что совпадение ровно одно. Используйте Single когда ожидаете уникальность (например, поиск по первичному ключу).
ValueTuple vs Tuple — Когда что использовать
Сравнение
| Аспект | Tuple<T1,T2,...> | (T1, T2, ...) / ValueTuple |
|---|---|---|
| Тип | Ссылочный тип (class) | Значимый тип (struct) |
| Неизменяемость | Неизменяемый | Изменяемый (поля публичны и могут меняться) |
| Имена полей | Всегда Item1, Item2... | Настраиваемые имена |
| Производительность | Выделение в куче (GC) | Стек/inline (без GC для небольших кортежей) |
| Деконструкция | Нет | Да (var (a, b) = result) |
| Доступно с | .NET 4.0 | .NET Core 2.0 / .NET Standard 2.1 |
Примеры использования
// Старый Tuple — многословный, выделение в куче
Tuple<string, int> oldStyle = Tuple.Create("Alice", 30);
Console.WriteLine(oldStyle.Item1); // Alice
// ValueTuple — лаконичный, выделение на стеке
(string name, int age) newStyle = ("Bob", 25);
Console.WriteLine(newStyle.name); // Bob
// Деконструкция
var (n, a) = GetPerson();
Console.WriteLine($"{n}, {a}");
// Out кортежи (C# 7+)
if (int.TryParse("42", out int result))
Console.WriteLine(result);
// Несколько возвращаемых значений
public static (bool success, string message) Validate(string input)
{
if (string.IsNullOrEmpty(input))
return (false, "Input cannot be empty");
return (true, "Valid");
}Когда что использовать
- ValueTuple: Выбор по умолчанию для большинства сценариев — лучшая производительность, чистый синтаксис
- Tuple: Только при таргетировании .NET Framework < 4.7 или когда нужна семантика ссылочного типа (кэширование в словарях с null-значениями)
Пользовательский оператор LINQ через метод расширения
public static class CustomLinqExtensions
{
/// <summary>
/// Применяет функцию к каждому элементу, передавая результат вперёд.
/// Аналогично Scala's scan / Haskell's scanl.
/// </summary>
public static IEnumerable<TAccumulate> Scan<TSource, TAccumulate>(
this IEnumerable<TSource> source,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> accumulator)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (accumulator == null) throw new ArgumentNullException(nameof(accumulator));
return ScanIterator(source, seed, accumulator);
}
private static IEnumerable<TAccumulate> ScanIterator<TSource, TAccumulate>(
IEnumerable<TSource> source,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> accumulator)
{
TAccumulate state = seed;
yield return state;
foreach (var item in source)
{
state = accumulator(state, item);
yield return state;
}
}
/// <summary>
/// Группирует элементы в чанки указанного размера.
/// </summary>
public static IEnumerable<IList<T>> Chunk<T>(this IEnumerable<T> source, int size)
{
if (size <= 0) throw new ArgumentOutOfRangeException(nameof(size));
using var enumerator = source.GetEnumerator();
while (enumerator.MoveNext())
{
yield return TakeChunk(enumerator, size);
}
static IList<T> TakeChunk(IEnumerator<T> enumrator, int chunkSize)
{
var chunk = new List<T>(chunkSize);
chunk.Add(enumrator.Current);
while (chunk.Count < chunkSize && enumrator.MoveNext())
chunk.Add(enumrator.Current);
return chunk;
}
}
}
// Использование:
var numbers = Enumerable.Range(1, 10);
var cumulativeSums = numbers.Scan(0, (acc, x) => acc + x).ToList();
// [0, 1, 3, 6, 10, 15, 21, 28, 36, 45, 55]
var chunks = Enumerable.Range(1, 7).Chunk(3).ToList();
// [[1,2,3], [4,5,6], [7]]Бенчмарк: LINQ vs foreach для 1 млн элементов
using System.Diagnostics;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class LinqVsForeachBenchmark
{
private int[] _data;
[GlobalSetup]
public void Setup()
{
_data = Enumerable.Range(0, 1_000_000).ToArray();
}
[Benchmark]
public long ForeachSum()
{
long sum = 0;
foreach (var x in _data)
if (x % 3 == 0)
sum += x * 2;
return sum;
}
[Benchmark]
public long LinqSum()
{
return _data
.Where(x => x % 3 == 0)
.Select(x => x * 2)
.Sum();
}
[Benchmark]
public long ParallelLinqSum()
{
return _data.AsParallel()
.Where(x => x % 3 == 0)
.Select(x => x * 2)
.Sum();
}
}Типичные результаты (Release mode):
| Метод | Среднее время | Выделено памяти |
|---|---|---|
| ForeachSum | ~1мс | 0 Б |
| LinqSum | ~3-5мс | ~20 КБ (выделение делегатов) |
| ParallelLinqSum | ~2-3мс | ~100 КБ (накладные расходы многопоточности) |
Ключевой вывод: foreach быстрее для простых операций. LINQ добавляет накладные расходы на выделение делегатов для каждого оператора. PLINQ помогает с CPU-bound задачами, но имеет накладные расходы на многопоточность.
Expression — Как работает и где применяется
Лямбда как код vs лямбда как данные
// Лямбда как КОД (скомпилирована в делегат)
Func<int, bool> code = x => x > 5;
code(10); // True — выполняется напрямую
// Лямбда как ДАННЫЕ (дерево выражений)
Expression<Func<int, bool>> data = x => x > 5;
// Нельзя вызвать: data(10); // ОШИБКА КОМПИЛЯЦИИ
// Сначала нужно скомпилировать:
Func<int, bool> compiled = data.Compile();
compiled(10); // TrueСтруктура дерева выражений
Expression<Func<int, int, int>> expr = (x, y) => x + y * 2;
// Внутренняя структура дерева:
// LambdaExpression
// ├── Parameters: [ParameterExpression(x), ParameterExpression(y)]
// └── Body: BinaryExpression(Add)
// ├── Left: ParameterExpression(x)
// └── Right: BinaryExpression(Multiply)
// ├── Left: ParameterExpression(y)
// └── Right: ConstantExpression(2)Динамическая компиляция
// Построение дерева выражений программно
var param = Expression.Parameter(typeof(int), "x");
var constant = Expression.Constant(10);
var body = Expression.GreaterThan(param, constant);
var lambda = Expression.Lambda<Func<int, bool>>(body, param);
// Компиляция в делегат (использует DynamicMethod + генерацию IL)
Func<int, bool> predicate = lambda.Compile();
Console.WriteLine(predicate(15)); // TrueГде применяются деревья выражений
| Фреймворк | Применение |
|---|---|
| Entity Framework / LINQ to SQL | Перевод C# выражений в SQL-запросы |
| Dynamic LINQ | Построение запросов из строк во время выполнения |
| ORM (Dapper, NHibernate) | Перевод запросов |
| Фреймворки валидации | Компиляция правил валидации один раз, повторное использование |
| RPC/сериализация | Анализ паттернов доступа к свойствам |
Простой построитель запросов в стиле ORM
public static class ExpressionHelper
{
// Извлечение имени свойства из выражения: x => x.Name -> "Name"
public static string GetPropertyName<T, TProp>(Expression<Func<T, TProp>> expr)
{
var body = expr.Body as MemberExpression
?? (expr.Body as UnaryExpression)?.Operand as MemberExpression;
return body?.Member.Name ?? throw new ArgumentException("Not a member access");
}
// Извлечение константного значения: x => x == "test" -> "test"
public static object GetConstantValue(Expression expr)
{
return (expr as ConstantExpression)?.Value
?? (expr as UnaryExpression)?.Operand as ConstantExpression?.Value;
}
}
// Использование:
string propName = ExpressionHelper.GetPropertyName<User, string>(u => u.Name); // "Name"Практика
- [ ] Изучить материал
Контрольные вопросы
- Что такое отложенное выполнение и как оно влияет на производительность?
- Разница между First(), FirstOrDefault(), Single(), SingleOrDefault()?
- Как работает Expression
и где применяется?
Делегаты, события и выражения в C#/.NET
Содержание
- Встроенные типы делегатов: Action
, Func , Predicate , Comparison - Мультикаст-делегаты — внутреннее устройство списка вызовов
- События — паттерн издатель/подписчик, потокобезопасность
- Expression
— построение и компиляция деревьев выражений - Динамическая компиляция через Expression.Compile()
- Практические упражнения
Встроенные типы делегатов
Func — функции с возвращаемым значением
// Обобщённые определения (до 16 параметров + возвращаемый тип)
public delegate TResult Func<TResult>();
public delegate TResult Func<T, TResult>(T arg);
public delegate TResult Func<T1, T2, TResult>(T1 arg1, T2 arg2);
public delegate TResult Func<T1, T2, T3, TResult>(T1 arg1, T2 arg2, T3 arg3);
// ... до Func<T1,...,T16, TResult>
// Примеры использования
Func<string, int> parse = int.Parse; // Преобразование группы методов
Func<int, int, int> add = (a, b) => a + b; // Лямбда-выражение
Func<string, int> length = s => s.Length; // Ещё одна лямбда
// В LINQ
var names = new[] { "Alice", "Bob", "Charlie" };
var uppercased = names.Select((Func<string, string>)(s => s.ToUpper()));Action — функции без возвращаемого значения
public delegate void Action();
public delegate void Action<T>(T arg);
public delegate void Action<T1, T2>(T1 arg1, T2 arg2);
// ... до Action<T1,...,T16>
// Примеры использования
Action<string> log = Console.WriteLine;
Action<int, string> logWithLevel = (level, msg) =>
Console.WriteLine($"[{level}] {msg}");
// В LINQ
names.ForEach((Action<string>)(n => Console.WriteLine(n)));
// Распространённый паттерн: обратные вызовы
public void Retry(Action action, int maxRetries = 3)
{
for (int i = 0; i < maxRetries; i++)
{
try { action(); return; }
catch { if (i == maxRetries - 1) throw; }
}
}Predicate — булева функция
public delegate bool Predicate<T>(T obj);
// Примеры использования
Predicate<string> isNotEmpty = s => !string.IsNullOrEmpty(s);
Predicate<int> isEven = n => n % 2 == 0;
// В LINQ (хотя Where использует Func<T, bool>)
var list = new List<int> { 1, 2, 3, 4, 5 };
int firstEven = list.Find(isEven); // List.Find использует Predicate<T>
int index = list.FindIndex(isEven); // List.FindIndex использует Predicate<T>
bool exists = list.Exists(isEven); // List.Exists использует Predicate<T>
// Примечание: Predicate<T> функционально эквивалентен Func<T, bool>
// но имеет семантическое значение: «этот делегат проверяет условие»Comparison — функция сравнения
public delegate int Comparison<T>(T x, T y);
// Возвращает: < 0 если x < y, 0 если x == y, > 0 если x > y
// Примеры использования
Comparison<string> lengthCompare = (a, b) => a.Length.CompareTo(b.Length);
var words = new List<string> { "banana", "pie", "apple" };
words.Sort(lengthCompare); // ["pie", "banana", "apple"] — отсортировано по длине
// vs IComparer<T>:
public class LengthComparer : IComparer<string>
{
public int Compare(string x, string y) => x.Length.CompareTo(y.Length);
}Когда что использовать
| Делегат | Возвращаемый тип | Лучше всего подходит для |
|---|---|---|
Func<T, TResult> | TResult | Преобразования, вычисления, LINQ Select |
Action<T> | void | Побочные эффекты, обратные вызовы, обработчики событий (без аргументов) |
Predicate<T> | bool | Условия, фильтрация (List.Find, List.RemoveAll) |
Comparison<T> | int | Сортировка пользовательских коллекций |
Мультикаст-делегаты — внутреннее устройство списка вызовов
Как работает мультикастинг
Делегат может хранить ссылки на несколько методов (список вызовов):
Action<string> handlers = Message1;
handlers += Message2; // Добавить в список вызовов
handlers += Message3;
handlers -= Message1; // Удалить из списка
handlers("Hello"); // Вызывает: Message2("Hello"), Message3("Hello")
void Message1(string msg) => Console.WriteLine($"M1: {msg}");
void Message2(string msg) => Console.WriteLine($"M2: {msg}");
void Message3(string msg) => Console.WriteLine($"M3: {msg}");Внутренняя структура
// Упрощённая внутренняя структура System.Delegate
// Упрощённая внутренняя структура System.MulticastDelegate (в современном .NET)
public abstract class MulticastDelegate : Delegate
{
// Вместо связанного списка, современный .NET использует массив для хранения цепочки вызовов
private object _invocationList; // Массив делегатов Delegate[], если элементов > 1
private IntPtr _invocationCount;
}
// Мультикаст-делегат — это МАССИВ ВЫЗОВОВ (в современном .NET), а не связанный список:
// _invocationList = [Delegate1, Delegate2, Delegate3]
// При вызове делегаты обходятся циклом for по массиву.Ключевые моменты:
+=добавляет в начало списка (O(1))-=удаляет из любого места списка (O(n), поиск по равенству ссылок)- Порядок вызова: последний добавленный → первый добавленный (LIFO для цепочки, но на самом деле порядок инвертируется при вызове)
- На самом деле: делегаты вызываются в порядке их добавления (FIFO)
Возвращаемые значения и исключения
// С Func — возвращается возвращаемое значение ТОЛЬКО ПОСЛЕДНЕГО делегата!
Func<int, int> f = x => x + 1;
f += x => x * 2;
f += x => x - 3;
var result = f(10); // Возвращает 7 (только последний: 10 - 3). Результаты первых двух отбрасываются!
// С исключениями — первое исключение останавливает вызов
Func<int, int> f2 = x => x + 1;
f2 += x => throw new InvalidOperationException("Boom!");
f2 += x => x * 2; // Никогда не будет вызван, если второй выбросит исключение
try { f2(5); } catch (InvalidOperationException) { /* поймано */ }
// Чтобы получить все возвращаемые значения, используйте GetInvocationList():
foreach (Func<int, int> individual in f.GetInvocationList())
{
Console.WriteLine(individual(10)); // 11, 20, 7 — каждый результат захвачен
}Лучшие практики
- Используйте
Actionдля мультикаста (возвращаемое значение всё равно отбрасывается) - Используйте
Funcтолько когда уверены, что вызов одиночный - Для получения всех результатов: перебирайте
GetInvocationList() - Будьте осторожны с обработкой исключений — одно исключение останавливает цепочку
События — паттерн издатель/подписчик и потокобезопасность
Базовый паттерн событий
public class Publisher
{
// Объявление события (компилятор генерирует приватное поле делегата + аксессоры add/remove)
public event Action<string> MessageReceived;
public void SendMessage(string message)
{
// Потокобезопасный паттерн вызова
MessageReceived?.Invoke(message);
// Альтернатива (более потокобезопасная в старых .NET):
// var handler = MessageReceived;
// handler?.Invoke(message);
}
}
public class Subscriber
{
public void Subscribe(Publisher publisher)
{
publisher.MessageReceived += OnMessage;
}
private void OnMessage(string message)
{
Console.WriteLine($"Получено: {message}");
}
}Паттерн EventHandler и EventArgs (традиционный .NET)
// Пользовательские аргументы события
public class TemperatureChangedEventArgs : EventArgs
{
public double OldTemperature { get; }
public double NewTemperature { get; }
public TemperatureChangedEventArgs(double oldTemp, double newTemp)
{
OldTemperature = oldTemp;
NewTemperature = newTemp;
}
}
// Издатель с традиционным паттерном
public class Thermostat : IDisposable
{
// EventHandler<T> эквивалентен delegate void Handler(object sender, T e)
public event EventHandler<TemperatureChangedEventArgs> TemperatureChanged;
private double _temperature;
public double Temperature
{
get => _temperature;
set
{
if (_temperature == value) return;
var oldTemp = _temperature;
_temperature = value;
// Вызов события (virtual для тестируемости в производных классах)
OnTemperatureChanged(oldTemp, value);
}
}
protected virtual void OnTemperatureChanged(double oldTemp, double newTemp)
{
TemperatureChanged?.Invoke(this, new TemperatureChangedEventArgs(oldTemp, newTemp));
}
public void Dispose()
{
TemperatureChanged = null; // Позволяет подписчикам быть собранными GC
}
}Потокобезопасность событий
public class ThreadSafePublisher
{
private event Action<string> _messageReceived;
public event Action<string> MessageReceived
{
add { lock (_lock) _messageReceived += value; }
remove { lock (_lock) _messageReceived -= value; }
}
private readonly object _lock = new();
public void Publish(string message)
{
// Паттерн снимка: копирование ссылки в локальную переменную
// Предотвращает состояние гонки, когда обработчик становится null между проверкой и вызовом
var handlers = _messageReceived;
if (handlers != null)
handlers(message);
// В .NET Core+: null-условный оператор достаточно потокобезопасен для большинства случаев
// _messageReceived?.Invoke(message);
}
}Сценарий состояния гонки:
Поток A: Поток B:
_проверка handler (не null) handler -= отписка (теперь null!)
_вызов handler → NullReferenceException!Решение: снимок в локальную переменную (как показано выше).
Асинхронные обработчики событий
public class AsyncPublisher
{
public event Func<Task> AsyncEvent;
public async Task RaiseAsync()
{
var handlers = AsyncEvent?.GetInvocationList()
.Cast<Func<Task>>()
.Select(h => h()) // Запуск всех задач
.ToArray();
if (handlers != null)
await Task.WhenAll(handlers); // Ожидание всех
}
}Expression — построение и компиляция деревьев выражений
Лямбда как структура данных
// Обычный делегат — сразу компилируется в IL
Func<int, bool> del = x => x > 5;
// Дерево выражений — захвачено как структура данных
Expression<Func<int, bool>> expr = x => x > 5;
// Дерево выражений представляет:
// Lambda [x] -> BinaryExpression(GreaterThan)
// Left: ParameterExpression [x]
// Right: ConstantExpression [5]Программное построение деревьев выражений
using System.Linq.Expressions;
// Построение: x => x > 5
var parameter = Expression.Parameter(typeof(int), "x");
var constant = Expression.Constant(5);
var body = Expression.GreaterThan(parameter, constant);
var lambda = Expression.Lambda<Func<int, bool>>(body, parameter);
Console.WriteLine(lambda); // x => (x > 5)
// Построение: (x, y) => x + y * 2
var p1 = Expression.Parameter(typeof(int), "x");
var p2 = Expression.Parameter(typeof(int), "y");
var multiply = Expression.Multiply(p2, Expression.Constant(2));
var add = Expression.Add(p1, multiply);
var lambda2 = Expression.Lambda<Func<int, int, int>>(add, p1, p2);
Console.WriteLine(lambda2); // (x, y) => (x + (y * 2))Распространённые типы узлов выражений
| Тип | Описание | Пример | ||
|---|---|---|---|---|
ParameterExpression | Параметр метода | x в x => x > 5 | ||
ConstantExpression | Константное значение | 5 в x => x > 5 | ||
BinaryExpression | Бинарная операция | >, <, +, -, ==, &&, `\ | \ | ` |
UnaryExpression | Унарная операция | !, -, ++, --, преобразование | ||
MemberExpression | Доступ к свойству/полю | x.Name в x => x.Name | ||
MethodCallExpression | Вызов метода | x.ToString() в x => x.ToString() | ||
ConditionalExpression | Тернарный оператор | x > 0 ? "pos" : "neg" | ||
LambdaExpression | Обёртка лямбды | Всё выражение целиком | ||
NewExpression | Создание объекта | new Person() | ||
InvocationExpression | Вызов делегата | func(x) |
Паттерн Expression Visitor
public class ExpressionReplacer : ExpressionVisitor
{
private readonly Expression _from;
private readonly Expression _to;
public ExpressionReplacer(Expression from, Expression to)
{
_from = from;
_to = to;
}
protected override Expression VisitExpression(Expression node)
{
if (node == _from)
return _to;
return base.VisitExpression(node);
}
}
// Использование: замена параметра в выражении
var param = Expression.Parameter(typeof(int), "x");
var expr = Expression.Lambda<Func<int, bool>>(
Expression.GreaterThan(param, Expression.Constant(5)), param);
var newParam = Expression.Parameter(typeof(int), "y");
var replacer = new ExpressionReplacer(param, newParam);
var newExpr = (LambdaExpression)replacer.Visit(expr);
Console.WriteLine(newExpr); // y => (y > 5)Динамическая компиляция через Expression.Compile()
Как работает Compile
Expression<Func<int, bool>> expr = x => x > 5;
// Компилирует дерево выражений в делегат с использованием DynamicMethod + генерации IL
Func<int, bool> compiled = expr.Compile();
Console.WriteLine(compiled(10)); // True
Console.WriteLine(compiled(3)); // False
// Скомпилированный делегат можно кэшировать и переиспользовать:
var cache = new Dictionary<string, Func<object, bool>>();
public Func<object, bool> GetOrCreatePredicate(string fieldName, object value)
{
if (cache.TryGetValue(fieldName, out var existing))
return existing; // Переиспользование скомпилированного делегата — быстро!
var param = Expression.Parameter(typeof(object), "x");
var convert = Expression.Convert(param, GetFieldType(fieldName));
var fieldAccess = Expression.Property(convert, fieldName);
var constant = Expression.Constant(value, GetFieldType(fieldName));
var equal = Expression.Equal(fieldAccess, constant);
var lambda = Expression.Lambda<Func<object, bool>>(equal, param);
var compiled = lambda.Compile(); // Генерация IL происходит один раз
cache[fieldName] = compiled;
return compiled;
}Сравнение производительности
| Подход | Первый вызов | Последующие вызовы | Примечания |
|---|---|---|---|
Рефлексия (PropertyInfo.GetValue) | ~100нс | ~100нс | Медленно, но стабильно |
| Expression.Compile() + кэш | ~5мкс (первая компиляция) | ~5нс | Быстро после компиляции |
Delegate.CreateDelegate | ~5мкс | ~5нс | Аналогично скомпилированному выражению |
| Прямой вызов метода | ~1нс | ~1нс | Базовый уровень |
Динамический построитель предикатов для фильтрации данных
public static class PredicateBuilder
{
public static Expression<Func<T, bool>> True<T>() => _ => true;
public static Expression<Func<T, bool>> False<T>() => _ => false;
public static Expression<Func<T, bool>> Or<T>(
this Expression<Func<T, bool>> expr1,
Expression<Func<T, bool>> expr2)
{
var invoked = Expression.Invoke(expr2, expr1.Parameters);
return Expression.Lambda<Func<T, bool>>(
Expression.OrElse(expr1.Body, invoked), expr1.Parameters);
}
public static Expression<Func<T, bool>> And<T>(
this Expression<Func<T, bool>> expr1,
Expression<Func<T, bool>> expr2)
{
var invoked = Expression.Invoke(expr2, expr1.Parameters);
return Expression.Lambda<Func<T, bool>>(
Expression.AndAlso(expr1.Body, invoked), expr1.Parameters);
}
}
// Использование: динамическое построение сложных предикатов
Expression<Func<Product, bool>> predicate = PredicateBuilder.True<Product>();
if (minPrice.HasValue)
predicate = predicate.And(p => p.Price >= minPrice.Value);
if (!string.IsNullOrEmpty(category))
predicate = predicate.And(p => p.Category == category);
if (inStockOnly)
predicate = predicate.And(p => p.Stock > 0);
var results = db.Products.Where(predicate).ToList();
// Генерирует: WHERE Price >= ? AND Category = ? AND Stock > 0Практика
- [ ] Изучить материал
Упражнение 1: EventAggregator с поддержкой async
public interface IEventAggregator : IDisposable
{
void Publish<TEvent>(TEvent @event) where TEvent : notnull;
Task PublishAsync<TEvent>(TEvent @event) where TEvent : notnull;
IDisposable Subscribe<TEvent>(Action<TEvent> handler) where TEvent : notnull;
IDisposable Subscribe<TEvent>(Func<TEvent, Task> handler) where TEvent : notnull;
}
public class EventAggregator : IEventAggregator
{
private readonly Dictionary<Type, Delegate> _handlers = new();
private readonly object _lock = new();
public void Publish<TEvent>(TEvent @event) where TEvent : notnull
{
var eventType = typeof(TEvent);
lock (_lock)
{
if (!_handlers.TryGetValue(eventType, out var handler))
return;
}
foreach (Action<TEvent> h in (Delegate)handler)
h(@event);
}
public async Task PublishAsync<TEvent>(TEvent @event) where TEvent : notnull
{
var eventType = typeof(TEvent);
List<Task> tasks;
lock (_lock)
{
if (!_handlers.TryGetValue(eventType, out var handler))
return;
tasks = ((Delegate)handler).GetInvocationList()
.Cast<Func<TEvent, Task>>()
.Select(h => h(@event))
.ToList();
}
await Task.WhenAll(tasks);
}
public IDisposable Subscribe<TEvent>(Action<TEvent> handler) where TEvent : notnull
{
var eventType = typeof(TEvent);
lock (_lock)
{
_handlers[eventType] = Delegate.Combine(
_handlers.GetValueOrDefault(eventType), handler);
}
return new SubscriptionHandle(this, eventType, handler);
}
public IDisposable Subscribe<TEvent>(Func<TEvent, Task> handler) where TEvent : notnull
{
var eventType = typeof(TEvent);
lock (_lock)
{
_handlers[eventType] = Delegate.Combine(
_handlers.GetValueOrDefault(eventType), handler);
}
return new SubscriptionHandle(this, eventType, handler);
}
private void Unsubscribe<TEvent>(Delegate handler) where TEvent : notnull
{
lock (_lock)
{
var eventType = typeof(TEvent);
_handlers[eventType] = Delegate.Remove(
_handlers.GetValueOrDefault(eventType), handler);
}
}
private class SubscriptionHandle : IDisposable
{
readonly EventAggregator _aggregator;
readonly Type _eventType;
readonly Delegate _handler;
public SubscriptionHandle(EventAggregator aggregator, Type eventType, Delegate handler)
{
_aggregator = aggregator;
_eventType = eventType;
_handler = handler;
}
public void Dispose()
{
// Использование рефлексии для вызова обобщённого Unsubscribe
var method = typeof(EventAggregator)
.GetMethod(nameof(Unsubscribe), BindingFlags.NonPublic | BindingFlags.Instance)!
.MakeGenericMethod(_eventType);
method.Invoke(_aggregator, new[] { _handler });
}
}
public void Dispose()
{
_handlers.Clear();
}
}Упражнение 2: Простой построитель запросов в стиле ORM на деревьях выражений
См. раздел «Динамический построитель предикатов» выше для основы. Полный построитель запросов расширит это:
- Генерация SQL из деревьев выражений (ExpressionVisitor)
- Извлечение параметров и построение команд
- Маппинг результатов обратно в сущности
Сводная таблица
| Концепция | Ключевой вывод |
|---|---|
Func<T> | Используйте для вычислений, возвращающих значения |
Action<T> | Используйте для побочных эффектов, обратных вызовов |
Predicate<T> | Семантический эквивалент Func<T, bool>, используется методами List |
| Мультикаст-делегаты | Связанный список методов; сохраняется только возвращаемое значение последнего Func |
| События | Издатель/подписчик с потокобезопасным паттерном снимка |
| Деревья выражений | Код как данные — позволяет перевод запросов (EF Core и др.) |
| Compile() | Преобразует дерево выражений в IL-делегат — кэшируйте для производительности |
Сопоставление с образцом и современные возможности C#
Содержание
- Паттерны свойств, рекурсивные паттерны, реляционные паттерны
- switch expression vs switch statement
- Типы record — равенство по значению, неразрушающее изменение, выражения with
- ref struct, модификатор required, namespaces на уровне файла
- Source Generators — базовое понимание
- Практические упражнения
Эволюция сопоставления с образцом
Паттерны типов (C# 7)
// Базовый паттерн типа
void Process(object value)
{
if (value is int i)
Console.WriteLine($"Целое число: {i}");
if (value is string s && s.Length > 0)
Console.WriteLine($"Непустая строка: {s}");
}Паттерны констант (C# 8)
void Check(object value)
{
if (value is null) // Паттерн null (безопаснее, чем == null)
Console.WriteLine("Null");
if (value is 42) // Паттерн константы
Console.WriteLine("Ответ!");
if (value is "hello") // Строковая константа
Console.WriteLine("Приветствие");
if (value is not null) // Отрицательный паттерн (C# 9)
Console.WriteLine("Не null");
}Реляционные паттерны (C# 9)
string Classify(double value) => value switch
{
< -100.0 => "Очень низко",
< 0.0 => "Низко",
0.0 => "Ноль",
> 0.0 and <= 100.0 => "Нормально",
> 100.0 => "Высоко",
_ => "Неизвестно (NaN?)",
};
// Составные паттерны с 'and', 'or', 'not'
bool IsLetterGrade(char c) => c is >= 'A' and <= 'F';
bool IsVowel(char c) => c is 'a' or 'e' or 'i' or 'o' or 'u';
bool IsNotControlChar(char c) => c is not < ' ';Паттерны свойств (C# 8)
public record Person(string Name, int Age, string Email);
void Describe(Person person)
{
if (person is { Age: >= 18, Email: not null })
Console.WriteLine("Взрослый с email");
if (person is { Name: "Alice", Age: > 25 })
Console.WriteLine("Alice старше 25");
}
// В switch expressions:
string Categorize(Person p) => p switch
{
{ Age: < 0 } => "Некорректно",
{ Age: >= 0, Age: < 13 } => "Ребёнок",
{ Age: >= 13, Age: < 18 } => "Подросток",
{ Age: >= 18, Age: < 65 } => "Взрослый",
{ Age: >= 65 } => "Пожилой",
null => "Неизвестно",
};Рекурсивные паттерны (C# 9)
Паттерны внутри паттернов — сопоставление вложенных структур:
public record Point(int X, int Y);
public record Segment(Point Start, Point End);
public record Circle(Point Center, double Radius);
// Проверка, находится ли любая конечная отрезка на оси X
bool IsAnyEndOnXAxis(Segment seg) =>
seg is { Start: { Y: 0 } } or { End: { Y: 0 } };
// Сопоставление конкретных фигур со свойствами
string DescribeShape(object shape) => shape switch
{
Circle { Radius: > 10, Center: { X: 0, Y: 0 } } => "Большой круг в начале координат",
Circle { Radius: var r } when r > 0 => $"Круг с радиусом {r}",
Segment { Start: { X: sx }, End: { X: sx } } => "Вертикальный отрезок", // Одинаковый X = вертикальный
Segment { Start: var s, End: var e } => $"Отрезок от ({s}) до ({e})",
_ => "Неизвестная фигура",
};
// Сопоставление с образцом списков (C# 11)
int[] CheckArray(int[] arr) => arr switch
{
[0, 1, 2, ..] => new[] { 1 }, // Начинается с 0, 1, 2
[.. , 9, 8, 7] => new[] { 2 }, // Заканчивается на 9, 8, 7
[1, .., 9] => new[] { 3 }, // Начинается с 1, заканчивается на 9
[var first, ..] => new[] { first }, // Захват первого элемента
[] => Array.Empty<int>(), // Пустой массив
};Паттерн var
// Захват любого значения (всегда совпадает)
if (value is var _) // Всегда true, значение отбрасывается
Console.WriteLine("Имеет значение");
if (person is { Name: var name }) // Захват свойства
Console.WriteLine($"Имя: {name}");
// В switch — привязка к переменной для использования в результате
string Describe(object obj) => obj switch
{
int var i when i > 0 => $"Положительное: {i}",
int var i => $"Неположительное: {i}",
string var s => $"Строка длины {s.Length}",
null => "Null",
var _ => obj.GetType().Name, // Запасной вариант
};switch Expression vs switch Statement
switch Statement (традиционный)
// C# 7+ с сопоставлением с образцом в case
switch (value)
{
case null:
Console.WriteLine("Null");
break;
case int n when n > 0:
Console.WriteLine($"Положительное: {n}");
break;
case int n when n < 0:
Console.WriteLine($"Отрицательное: {n}");
break;
case int _:
Console.WriteLine("Ноль");
break;
case string s:
Console.WriteLine($"Строка: {s}");
break;
default:
Console.WriteLine("Другое");
break; // Обязательно! (нет проваливания без goto)
}switch Expression (C# 8+)
// Более лаконичный, ориентированный на выражения, проверка полноты
string result = value switch
{
null => "Null",
int n when n > 0 => $"Положительное: {n}",
int n when n < 0 => $"Отрицательное: {n}",
int _ => "Ноль",
string s => $"Строка: {s}",
_ => "Другое", // Ветка по умолчанию (discard подчёркивание)
};
// break не нужен, нет проваливания, возвращает значение напрямуюСравнение
| Аспект | switch Statement | switch Expression |
|---|---|---|
| Возвращает значение | Нет (использует операторы) | Да (выражение) |
| Требует break/goto | Да | Нет |
| Проверка полноты | Нет | Предупреждение компилятора, если паттерны не покрывают все случаи (с record) |
| Порядок паттернов | Сверху вниз | Сверху вниз |
| Безопасность null | Ручная проверка null | Паттерн null работает естественно |
| Ограничение сложности | Без ограничений | Лучше для простой/средней логики |
Когда что использовать
- switch expression: Возврат значения на основе паттернов, лаконичные преобразования
- switch statement: Сложная логика с побочными эффектами, несколько операторов на case, операции ввода/вывода
Типы record — равенство по значению и неразрушающее изменение
Что такое record?
Record обеспечивают равенство по значению для ссылочных типов:
// Record class (ссылочный тип с семантикой значений)
public record Person(string Name, int Age);
var p1 = new Person("Alice", 30);
var p2 = new Person("Alice", 30);
Console.WriteLine(p1 == p2); // True! (равенство по значению)
Console.WriteLine(p1.Equals(p2)); // True!
Console.WriteLine(p1.GetHashCode() == p2.GetHashCode()); // True!
// vs обычный class:
public class ClassicPerson
{
public string Name { get; set; }
public int Age { get; set; }
}
var c1 = new ClassicPerson { Name = "Alice", Age = 30 };
var c2 = new ClassicPerson { Name = "Alice", Age = 30 };
Console.WriteLine(c1 == c2); // False! (равенство по ссылке)Генерируемые члены
Компилятор генерирует для public record Person(string Name, int Age):
- Первичный конструктор с параметрами
- Init-only свойства для каждого параметра
Equals(),GetHashCode(),ToString()— на основе значений- Метод
<Clone>()для неразрушающего изменения (with)
Неразрушающее изменение с выражениями with (C# 9+)
public record Person(string Name, int Age, string Email);
var original = new Person("Alice", 30, "alice@example.com");
var updated = original with { Age = 31 }; // Новый экземпляр, Age изменён
var alsoUpdated = original with { Name = "Bob" }; // Ещё один новый экземпляр
Console.WriteLine(original.Age); // 30 (не изменился!)
Console.WriteLine(updated.Age); // 31
Console.WriteLine(original == updated); // False (разные значения)
// Несколько свойств:
var fullyUpdated = original with
{
Name = "Alice Smith",
Age = 31,
Email = "alice.smith@example.com"
};Record Struct vs Record Class
// Record class — ссылочный тип с равенством по значению
public record PersonClass(string Name, int Age);
// Record struct — значимый тип с равенством по значению (без GC для небольших записей)
public record struct PersonStruct(string Name, int Age);
var rc1 = new PersonClass("Alice", 30);
var rc2 = rc1 with { Age = 31 }; // Новое выделение в куче
var rs1 = new PersonStruct("Alice", 30);
var rs2 = rs1 with { Age = 31 }; // Копирование на стеке (без нагрузки на GC)| Аспект | record class | record struct |
|---|---|---|
| Тип | Ссылочный | Значимый |
| Равенство | По значению | По значению |
| Память | Выделение в куче | Стек/inline |
| Выражение with | Новый объект в куче | Копирование + изменение |
| Наследование | Поддерживает record A : B | Без наследования |
| Nullability | Может быть null | Не может быть null |
Наследование record
public record Person(string Name, int Age);
public record Employee(string Name, int Age, string Department) : Person(Name, Age);
var emp = new Employee("Alice", 30, "Engineering");
Console.WriteLine(emp.Name); // Alice (унаследованное свойство)
Console.WriteLine(emp.Department); // Engineering
// Выражение with работает через наследование:
var promoted = emp with { Department = "Management" };Позиционная деконструкция
public record Point(int X, int Y);
var point = new Point(3, 4);
var (x, y) = point; // x = 3, y = 4
// Пользовательский деконструктор:
public record Person(string FirstName, string LastName, int Age)
{
public void Deconstruct(out string Name, out int Age)
{
Name = $"{FirstName} {LastName}";
Age = Age;
}
}
var (name, age) = new Person("Alice", "Smith", 30);
// name = "Alice Smith", age = 30ref struct, модификатор required, namespaces на уровне файла
ref struct (C# 7.2+)
Структуры, которые должны находиться на стеке — не могут быть боксированы:
public ref struct BufferReader
{
private readonly Span<byte> _buffer;
private int _position;
public BufferReader(Span<byte> buffer)
{
_buffer = buffer;
_position = 0;
}
public bool TryReadInt32(out int value)
{
if (_position + sizeof(int) > _buffer.Length)
{
value = default;
return false;
}
value = MemoryMarshal.Read<int>(_buffer.Slice(_position));
_position += sizeof(int);
return true;
}
}
// Ограничения для ref struct:
// 1. Нельзя хранить в куче (нет поля класса, нет реализации интерфейсов)
// 2. Нельзя использовать как параметр обобщённого типа (если T не тоже ref struct)
// 3. Нельзя боксировать (нет преобразования в object, нет приведения к интерфейсу)
// 4. Нельзя использовать с async/await (state machine — это класс)
// 5. Нельзя реализовывать интерфейсы
// Корректное использование:
void Process(Span<byte> data)
{
var reader = new BufferReader(data); // Находится на стеке
if (reader.TryReadInt32(out int value))
Console.WriteLine(value);
} // Reader уничтожается при выходе из области видимости — GC не нужен!Когда использовать ref struct: Высокопроизводительные сценарии, где нужно гарантировать отсутствие выделения в куче и работа с Span<T>.
Модификатор required (C# 11+)
Принудительная инициализация свойства/поля на этапе компиляции:
public class Configuration
{
public required string ConnectionString { get; init; }
public required int Timeout { get; init; }
public string? OptionalSetting { get; init; } // Не обязательное — может быть null
}
// Обязательно инициализировать обязательные свойства:
var config = new Configuration
{
ConnectionString = "Server=localhost", // Обязательное
Timeout = 30 // Обязательное
};
// ОШИБКА КОМПИЛЯЦИИ: отсутствует обязательное свойство
// var badConfig = new Configuration { ConnectionString = "test" };
// Также работает с первичными конструкторами (C# 12):
public class Config2(
required string ConnectionString,
required int Timeout)
{
public string ConnStr => ConnectionString;
public int Tmout => Timeout;
}Namespaces на уровне файла (C# 10+)
Уменьшение отступов и boilerplate-кода:
// Традиционный namespace (дополнительный уровень отступа):
namespace MyCompany.MyProject.Services
{
public class UserService
{
// ... всё с дополнительным отступом
}
}
// Namespace на уровне файла (без дополнительного отступа):
namespace MyCompany.MyProject.Services;
public class UserService
{
// Без дополнительного отступа!
}
// Директивы using всё ещё работают:
using System;
using System.Collections.Generic;
namespace MyCompany.MyProject.Services; // Объявление namespace после using
public class AnotherService { }Source Generators — базовое понимание
Что такое Source Generators?
Генерация кода во время компиляции, выполняющаяся в процессе компиляции:
Исходный код → [Анализатор + Source Generator] → Дополнительный код → Компилятор → ILКлючевые характеристики:
- Выполняется во время компиляции (не во время выполнения, как Reflection.Emit)
- Генерирует исходный код C# (не IL напрямую)
- Нулевые накладные расходы во время выполнения (сгенерированный код компилируется нормально)
- Интеграция с IDE (IntelliSense видит сгенерированный код)
Базовая структура Source Generator
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
[Generator]
public record EqualsGetHashCodeGenerator : IIncrementalSourceGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Шаг 1: Сбор кандидатов (классы/структуры с [GenerateEquality])
var candidates = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (s, c) => s is ClassDeclarationSyntax { Modifiers.Text: "public" },
transform: static (ctx, ct) =>
{
var classDecl = (ClassDeclarationSyntax)ctx.Node;
var semanticModel = ctx.SemanticModel;
var symbol = semanticModel.GetDeclaredSymbol(classDecl, ct);
return symbol?.GetAttributes()
.Any(a => a.AttributeClass?.Name == "GenerateEqualityAttribute") == true
? classDecl
: null;
})
.Where(static m => m is not null);
// Шаг 2: Генерация кода для каждого кандидата
context.RegisterSourceOutput(candidates,
static (ctx, node) => Execute(node!, ctx));
}
private static void Execute(ClassDeclarationSyntax node, SourceProductionContext ctx)
{
var className = node.Identifier.Text;
var namespaceName = node.Parent is NamespaceDeclarationSyntax ns ? ns.Name.ToString() : "";
var source = $$"""
// <auto-generated/>
using System;
using System.Diagnostics.CodeAnalysis;
{{(!string.IsNullOrEmpty(namespaceName) ? $"namespace {namespaceName}" : "file")}};
public static partial class {className}Equality
{{"{"}}
public static bool Equals<{className}>({className} a, {className} b)
{{"{"}}
if (ReferenceEquals(a, b)) return true;
if (a is null || b is null) return false;
return a.Equals(b);
{"}"}
{"}"}
""";
ctx.AddSource($"{className}Equality.Generated", source);
}
}Инкрементальные vs обычные Source Generators
| Аспект | ISourceGenerator | IIncrementalSourceGenerator |
|---|---|---|
| Выполняется при | Полная компиляция | Только изменённые файлы |
| Производительность | Медленнее (перезапуск при каждой компиляции) | Быстрее (инкрементально) |
| Сложность | Проще API | Сложнее настройка |
| Рекомендация | Наследие | Используйте это для новых проектов |
Распространённые варианты использования
- Автогенерация
Equals/GetHashCode(до появления record) - Генерация мапперов DTO (как режим code-gen AutoMapper)
- Создание команд/объектов из атрибутов (как MediatR, McMaster.Extensions.CommandLineUtils)
- Контракты сериализации (как Source Generator System.Text.Json)
- Регистрация контейнера внедрения зависимостей
Практика
- [ ] Изучить материал
Упражнение 1: Паттерн Command с сопоставлением с образцом
public abstract record Command(string Id);
public record CreateOrderCommand(string Id, string ProductId, int Quantity) : Command(Id);
public record CancelOrderCommand(string Id, string OrderId) : Command(Id);
public record UpdatePriceCommand(string Id, string ProductId, decimal NewPrice) : Command(Id);
public class CommandHandler
{
public string Handle(Command command) => command switch
{
CreateOrderCommand { Quantity: < 1 }
=> throw new ArgumentException("Quantity must be at least 1"),
CreateOrderCommand c
=> $"Created order for {c.ProductId} x{c.Quantity}",
CancelOrderCommand c when string.IsNullOrEmpty(c.OrderId)
=> throw new ArgumentException("OrderId is required"),
CancelOrderCommand c
=> $"Cancelled order {c.OrderId}",
UpdatePriceCommand c when c.NewPrice < 0
=> throw new ArgumentException("Price cannot be negative"),
UpdatePriceCommand c
=> $"Updated price of {c.ProductId} to {c.NewPrice:C}",
_ => throw new NotSupportedException($"Unknown command: {command.GetType().Name}")
};
}Упражнение 2: Слой DTO с типами record
// DTO запросов API
public record CreateProductRequest(
required string Name,
required decimal Price,
required string Category,
int Stock = 0);
public record UpdateProductRequest(
required Guid Id,
string? Name = null,
decimal? Price = null,
string? Category = null,
int? Stock = null);
// DTO ответов API
public record ProductResponse(
Guid Id,
string Name,
decimal Price,
string Category,
int Stock,
DateTime CreatedAt);
// Маппинг с выражением with и сопоставлением с образцом:
public static class ProductMapper
{
public static ProductResponse ToResponse(Domain.Product product) =>
new(
Id: product.Id,
Name: product.Name,
Price: product.Price,
Category: product.Category,
Stock: product.Stock,
CreatedAt: product.CreatedAt
);
public static Domain.Product ToDomain(CreateProductRequest request) =>
new(
Id: Guid.NewGuid(),
Name: request.Name,
Price: request.Price,
Category: request.Category,
Stock: request.Stock,
CreatedAt: DateTime.UtcNow
);
// Частичное обновление с использованием паттерна with-expression:
public static Domain.Product ApplyUpdate(Domain.Product product, UpdateProductRequest request) =>
product with
{
Name = request.Name ?? product.Name,
Price = request.Price ?? product.Price,
Category = request.Category ?? product.Category,
Stock = request.Stock ?? product.Stock
};
}Сводная таблица
| Возможность | Версия C# | Назначение |
|---|---|---|
| Паттерны типов | 7 | Сопоставление и приведение типов за один шаг |
| Паттерны констант/null | 8 | Безопасное сопоставление конкретных значений |
| Паттерны свойств | 8 | Прямое сопоставление свойств объекта |
| Реляционные паттерны | 9 | Сравнение с <, >, <=, >= |
| Логические паттерны (and/or/not) | 9 | Комбинирование паттернов |
| Рекурсивные паттерны | 9 | Вложенное сопоставление с образцом |
| Паттерны списков | 11 | Сопоставление структуры массива/коллекции |
| switch expressions | 8 | Лаконичное присваивание значений на основе паттернов |
| record | 9 | Равенство по значению для ссылочных типов |
| Выражения with | 9 | Неразрушающее изменение |
| ref struct | 7.2 | Структуры только на стеке (нулевой GC) |
| Модификатор required | 11 | Принудительная инициализация свойств |
| Namespaces на уровне файла | 10 | Уменьшение отступов |
| Source Generators | 9 | Генерация кода во время компиляции |
Span, Memory и stackalloc — программирование без выделения памяти
Содержание
- Span
— срез без выделения памяти, ограничения ref struct - Memory
— альтернатива на основе кучи, MemoryPool - ArrayPool
.Shared — аренда массивов - stackalloc — выделение на стеке, ограничения
- ReadOnlySequence
— для сетевых и файловых потоков - Практические упражнения
Span — срез без выделения памяти
Что такое Span?
Span<T> — это ref struct, представляющий непрерывную область памяти любого размера без выделения в куче:
public readonly ref struct Span<T>
{
public T this[int index] { get; set; }
public int Length { get; }
public bool IsEmpty { get; }
public Span<T> Slice(int start);
public Span<T> Slice(int start, int length);
public void Clear();
public void CopyTo(Span<T> destination);
public T Min() => MinValue; // .NET 7+
public T Max() => MaxValue; // .NET 7+
}Создание Span из разных источников
// Из массива — Span просматривает существующий массив (без копирования!)
int[] array = { 1, 2, 3, 4, 5 };
Span<int> spanFromArr = array; // Неявное преобразование
Span<int> spanFromArr2 = new Span<int>(array); // Явное
// Из сегмента массива — представление части без выделения памяти
Span<int> slice = array.AsSpan(1, 3); // [2, 3, 4] — без копирования!
// Из памяти стека — действительно нулевое выделение (вообще без кучи)
Span<int> stackSpan = stackalloc int[100]; // 400 байт на стеке
for (int i = 0; i < stackSpan.Length; i++)
stackSpan[i] = i * 2;
// Из строки — доступ к символам без выделения памяти
string text = "Hello, World!";
ReadOnlySpan<char> spanFromStr = text; // Без преобразования кодировки!
// Из IntPtr/unsafe памяти (для сценариев P/Invoke)
unsafe
{
byte* ptr = ...;
Span<byte> spanFromPtr = new Span<byte>(ptr, length);
}Срез без выделения памяти
Ключевое преимущество — срез создаёт представление, а не копию:
int[] data = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// Традиционный подход — ВЫДЕЛЯЕТ новый массив для каждого среза
int[] slice1 = data.Skip(2).Take(3).ToArray(); // Выделяет int[3] в куче
int[] slice2 = data.Skip(5).Take(3).ToArray(); // Выделяет ещё один int[3]
// Подход с Span — НУЛЬ выделений, только указатель + длина
Span<int> span1 = data.AsSpan(2, 3); // Представление: [2, 3, 4]
Span<int> span2 = data.AsSpan(5, 3); // Представление: [5, 6, 7]
// Изменение span изменяет исходный массив!
span1[0] = 99;
Console.WriteLine(data[2]); // 99 — та же память!Ограничения ref struct
Поскольку Span<T> — это ref struct, у него строгие ограничения:
| Можно | Нельзя |
|---|---|
| Использовать как локальную переменную | Хранить как поле обычного класса или структуры (только в другом ref struct) |
| Использовать как параметр или возвращаемый тип метода | Использовать как параметр обобщённого типа (List<Span<int>> — ОШИБКА) |
| Использовать в синхронных методах | Использовать в async методах или yield return итераторах |
Передавать по ссылке (ref, in, out) | Реализовывать интерфейсы |
Хранить указатели (через ref поля с C# 11) | Боксировать (приводить к object, dynamic или интерфейсу) |
// КОРРЕКТНО:
void Process(Span<int> data) { /* ... */ }
int[] arr = new int[100];
Process(arr.AsSpan()); // Работает!
// НЕКОРРЕКТНО:
public class MyClass
{
private Span<int> _data; // ОШИБКА КОМПИЛЯЦИИ: ref struct не может быть полем
async Task ProcessAsync(Span<int> data) // ОШИБКА КОМПИЛЯЦИИ: нельзя использовать в async
{
await Something();
}
IEnumerable<Span<int>> GetSpans() // ОШИБКА КОМПИЛЯЦИИ: не может быть обобщённым параметром
{
yield return new Span<int>();
}
}
// ОБХОДНОЙ ПУТЬ для async: используйте Memory<T> вместо (см. следующий раздел)Практические операции со Span
// Парсинг без выделения памяти
public static bool TryParseInt32(ReadOnlySpan<char> text, out int result)
{
if (text.IsEmpty) { result = 0; return false; }
bool negative = text[0] == '-';
var digits = negative ? text.Slice(1) : text;
long value = 0;
foreach (var c in digits)
{
if (c < '0' || c > '9') { result = 0; return false; }
value = value * 10 + (c - '0');
}
result = (int)(negative ? -value : value);
return true;
}
// Бинарный поиск на Span
public static int BinarySearch(Span<int> sorted, int target)
{
int left = 0, right = sorted.Length - 1;
while (left <= right)
{
int mid = left + (right - left) / 2;
if (sorted[mid] == target) return mid;
if (sorted[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
// Строковые операции без выделения памяти
public static bool StartsWith(ReadOnlySpan<char> text, ReadOnlySpan<char> prefix)
{
if (text.Length < prefix.Length) return false;
return text.Slice(0, prefix.Length).SequenceEqual(prefix);
}
// Разбиение строки на span (парсинг CSV без выделения памяти!)
public static int Split(ReadOnlySpan<char> source, char separator, Span<ReadOnlySpan<char>> destination)
{
int count = 0;
var start = source;
while (count < destination.Length)
{
var index = start.IndexOf(separator);
if (index == -1)
{
destination[count++] = start;
break;
}
destination[count++] = start.Slice(0, index);
start = start.Slice(index + 1);
}
return count;
}Memory — альтернатива Span на основе кучи
Зачем нужен Memory?
Memory<T> — это дружественный к куче эквивалент Span<T>:
| Возможность | Span | Memory |
|---|---|---|
| Тип | ref struct | struct (содержит указатель + длину) |
| Совместимость с async | Нет | Да |
| Может быть полем класса | Нет | Да |
| Может реализовывать интерфейсы | Н/Д | Да (IMemoryOwner<T> с пулом) |
| Получить представление Span | Н/Д | Свойство .Span |
| Проверка перекрытия памяти | Исключение времени выполнения | Исключение времени выполнения |
Использование Memory
// Из массива — оборачивает ссылку на массив (без копирования)
int[] array = { 1, 2, 3, 4, 5 };
Memory<int> memory = array; // Неявное преобразование
Memory<int> memory2 = new Memory<int>(array); // Явное
// Срез — всё ещё без выделения памяти (только смещение + длина)
Memory<int> slice = memory.Slice(1, 3); // [2, 3, 4]
// Получение Span для обработки
Span<int> span = memory.Span; // Преобразование в Span для операций
span[0] *= 2; // Изменяет исходный массив
// Memory<T> в async контексте — ЭТО РАБОТАЕТ!
public async Task ProcessAsync(Memory<byte> data)
{
await SomeIoOperation();
// Обработка памяти после await
var span = data.Span;
for (int i = 0; i < span.Length; i++)
span[i] ^= 0xFF;
}
// Memory<T> как поле класса — ЭТО РАБОТАЕТ!
public class DataProcessor
{
private Memory<byte> _buffer; // Корректно!
public DataProcessor(int size)
{
_buffer = new byte[size]; // Выделяется один раз, оборачивается в Memory
}
public void Process()
{
var span = _buffer.Span; // Получение представления Span для обработки
// ... работа со span
}
}IMemoryOwner — Disposable память
// Аренда памяти из пула, освобождение по завершении
using IMemoryOwner<byte> owner = MemoryPool<byte>.Shared.Rent(1024);
Memory<byte> memory = owner.Memory; // Арендованная память
Span<byte> span = memory.Span; // Обработка
// При выходе из блока 'using' память автоматически возвращается в пулMemoryPool — эффективное управление большими буферами
// Общий пул (глобальный, потокобезопасный)
using IMemoryOwner<byte> owner1 = MemoryPool<byte>.Shared.Rent(1024);
Span<byte> buffer1 = owner1.Memory.Span;
// Аренда большего буфера (пул округляет до степени 2: 1024 -> 1024, 1500 -> 2048)
using IMemoryOwner<byte> owner2 = MemoryPool<byte>.Shared.Rent(1500);
Console.WriteLine(owner2.Memory.Length); // >= 1500 (вероятно 2048 или 4096)
// Пользовательский пул с определённым размером блока
var pool = new MemoryPool<byte>(
maxRetainedBlocks: 10,
blockSize: 4096, // Каждый блок 4KB
maxBlockSize: 65536 // Максимальный размер блока (64KB)
);
using var owner = pool.Rent(2000); // Получает один блок 4KB
// ... использование owner.Memory.Span ...
// Возврат в пул при освобождении — GC не нужен!Преимущества MemoryPool:
- Снижает нагрузку на GC за счёт повторного использования буферов
- Пре-аллоцированные блоки избегают задержек выделения памяти
- Идеально для операций ввода/вывода (сеть, обработка файлов)
ArrayPool.Shared — аренда массивов
Базовое использование
// Аренда массива из пула
int[] rented = ArrayPool<int>.Shared.Rent(100);
Console.WriteLine(rented.Length); // >= 100 (пул округляет: вероятно 128, 256 или 512)
try
{
// Использование массива обычным образом
for (int i = 0; i < 100; i++)
rented[i] = i * 2;
Process(rented.AsSpan(0, 100)); // Используйте только нужную часть
}
finally
{
// КРИТИЧНО: Вернуть массив в пул по завершении!
ArrayPool<int>.Shared.Return(rented);
// Или с флагом очистки (для конфиденциальных данных):
// ArrayPool<int>.Shared.Return(rented, clearArray: true);
}Паттерн using (предпочтительный)
// Обёртка в IDisposable для автоматического возврата
public struct ArrayPoolRent<T> : IDisposable
{
private readonly T[] _array;
private readonly ArrayPool<T> _pool;
public ArrayPoolRent(ArrayPool<T> pool, int minimumLength)
{
_pool = pool;
_array = pool.Rent(minimumLength);
}
public Span<T> Value => _array;
public void Dispose() => _pool.Return(_array);
}
// Использование:
using var rent = new ArrayPoolRent<byte>(ArrayPool<byte>.Shared, 1024);
Span<byte> buffer = rent.Value;
// ... обработка ...
// Автоматически возвращается при выходе из области видимости!Когда использовать ArrayPool vs новый массив
| Сценарий | Рекомендация | Причина |
|---|---|---|
| Одноразовое небольшое выделение (< 1KB) | new T[n] | Накладные расходы пула не оправданы |
| Повторные большие выделения (> 4KB) | ArrayPool<T> | Значительная экономия GC |
| Критический участок с частым выделением/освобождением | ArrayPool<T> | Устраняет нагрузку на GC |
| Буфер для операций ввода/вывода | ArrayPool<T> или MemoryPool<T> | Стандартный паттерн в .NET I/O |
| Данные должны быть обнулены после использования | Return(array, clearArray: true) | Конфиденциальные данные |
stackalloc — выделение на стеке
Базовое использование
// Выделение на стеке (без GC!)
Span<byte> buffer = stackalloc byte[1024]; // 1KB на стеке
Span<int> numbers = stackalloc int[100]; // 400 байт на стеке
// Инициализация значениями (C# 7.3+)
Span<int> initialized = stackalloc int[5] { 1, 2, 3, 4, 5 };
// Очистка перед использованием (память стека НЕ обнуляется!)
Span<byte> dirty = stackalloc byte[100];
dirty.Clear(); // Обязательно: содержимое стека не определено!
// Динамический размер (определяется во время выполнения)
int length = GetLength();
Span<char> dynamicBuffer = stackalloc char[length];Ограничения
| Ограничение | Детали |
|---|---|
| Размер должен быть int во время компиляции или выполнения | Нельзя использовать размеры long |
| Максимальный практический размер | ~1MB (лимит стека обычно 1-8MB) |
| Нельзя напрямую выделять ссылочные типы | Только значимые типы (byte, int, структуры) |
| Время жизни памяти = область видимости метода | Недействительно после возврата из метода |
| Нельзя возвращать из метода | Используйте параметр Span<T> или возврат Span<T> в структуре |
| Нельзя использовать с async | Фрейм стека может не существовать после await |
stackalloc с ref Returns (C# 7.2+)
// Возврат ссылки на память, выделенную на стеке — ОПАСНО!
// Безопасно только когда вызыватель использует её в том же вызове:
public ref int GetFirstElement(Span<int> data)
{
return ref data[0]; // Возвращает ссылку в Span вызывающего
}
// Безопасный паттерн: обработка в области видимости метода
public int ProcessSmallArray(int[] source, int count)
{
Span<int> temp = stackalloc int[Math.Min(count, 1024)];
for (int i = 0; i < Math.Min(count, temp.Length); i++)
temp[i] = source[i] * 2;
int sum = 0;
foreach (var x in temp)
sum += x;
return sum; // Возврат вычисленного значения, а не ссылки на память стека!
}considerations размера стека
// Размеры стека потоков по умолчанию:
// - Windows: 1 MB на поток (32-бит), 4 MB (64-бит)
// - Потоки ThreadPool .NET: настраиваются через <Thread_UseAllKindsOfCpus/>
// Безопасные паттерны использования:
Span<byte> small = stackalloc byte[4096]; // ✅ 4KB — безопасно
Span<int> medium = stackalloc int[1000]; // ✅ 4KB — безопасно
Span<double> large = stackalloc double[100000]; // ⚠️ 800KB — рискованно на стеке по умолчанию
// Для больших буферов используйте ArrayPool или MemoryPool:
using var owner = MemoryPool<byte>.Shared.Rent(1_000_000); // ✅ Безопасно для больших данныхReadOnlySequence — discontinuous буферы
Проблема, которую Span не может решить
Span<T> требует непрерывной памяти. Но сетевой и файловый ввод/вывод часто создают фрагментированные буферы:
Сетевой приём: [chunk1][gap][chunk2][gap][chunk3]
^^^^^^^^ ^^^^^^^^
Не непрерывно! Нельзя использовать один Span<T>Решение ReadOnlySequence
// Представляет последовательность блоков памяти (возможно, не непрерывных)
ReadOnlySequence<byte> sequence = ...; // Из сетевого потока, файлового читателя и т.д.
// Перебор всех данных по фрагментам:
foreach (var segment in sequence)
{
Span<byte> span = segment.Span; // Каждый сегмент — непрерывный Memory<T>
ProcessChunk(span);
}
// Получение позиции для отслеживания прогресса чтения:
SequencePosition position = sequence.Start;
while (sequence.TryGet(ref position, out Memory<byte> chunk, isEnd: false))
{
Process(chunk.Span);
}
// Проверка, являются ли данные непрерывными (один блок):
if (sequence.IsSingleSegment)
{
Span<byte> allData = sequence.First.Span; // Прямой доступ!
}
// Срез по сегментам (представление части без копирования):
ReadOnlySequence<byte> slice = sequence.Slice(10, 20); // Байты 10-29 по фрагментам
// Преобразование в непрерывный массив (ВЫДЕЛЯЕТ память — используйте редко):
byte[] contiguous = sequence.ToArray(); // Только когда действительно нужно всё сразуПрактический пример: парсер сетевого протокола
public class HttpParser
{
private ReadOnlySequence<byte> _buffer;
public ParseResult TryParseRequest(ReadOnlySequence<byte> input)
{
_buffer = input;
// Поиск терминатора строки HTTP-запроса (\r\n\r\n)
var terminator = new byte[] { (byte)'\r', (byte)'\n', (byte)'\r', (byte)'\n' };
var consumed = FindTerminator(terminator);
if (consumed == null)
return ParseResult.Incomplete; // Нужно больше данных
var headerSection = _buffer.Slice(0, *consumed);
var bodyStart = _buffer.GetPosition(*consumed, _buffer.Start);
// Парсинг заголовков из span (может охватывать несколько сегментов!)
ParseHeaders(headerSection);
return ParseResult.Complete(bodyStart);
}
private int? FindTerminator(ReadOnlySpan<byte> terminator)
{
foreach (var segment in _buffer)
{
var index = segment.Span.IndexOf(terminator);
if (index != -1)
return (int)_buffer.GetPosition(index, segment.Start).Value;
}
return null; // Терминатор не найден — нужно больше данных
}
}Когда использовать ReadOnlySequence
| Сценарий | Используйте |
|---|---|
| Обработка сетевого потока | ✅ Основной вариант использования |
| Потоковое чтение файлов (большие файлы) | ✅ Когда файл может быть фрагментирован |
| Парсинг протоколов (HTTP, gRPC и т.д.) | ✅ Стандартный паттерн |
| Непрерывные данные в памяти | ❌ Используйте Span |
| Небольшие буферы фиксированного размера | ❌ Накладные расходы не оправданы |
Практика
- [ ] Изучить материал
Упражнение 1: Парсер CSV без выделения памяти с Span
public ref struct CsvParser
{
private readonly ReadOnlySpan<char> _data;
private int _position;
public CsvParser(ReadOnlySpan<char> data)
{
_data = data;
_position = 0;
}
public bool TryReadRow(Span<ReadOnlySpan<char>> fields, int maxFields)
{
int fieldCount = 0;
while (_position < _data.Length && fieldCount < maxFields)
{
var remaining = _data.Slice(_position);
var commaIndex = remaining.IndexOf(',');
var newlineIndex = remaining.IndexOf('\n');
int end;
if (commaIndex != -1 && (newlineIndex == -1 || commaIndex < newlineIndex))
{
fields[fieldCount++] = remaining.Slice(0, commaIndex);
_position += commaIndex + 1;
}
else if (newlineIndex != -1)
{
fields[fieldCount++] = remaining.Slice(0, newlineIndex);
_position += newlineIndex + 1;
break; // Конец строки
}
else
{
fields[fieldCount++] = remaining;
_position = _data.Length;
break; // Последнее поле без новой строки
}
}
return fieldCount > 0;
}
}
// Использование — НУЛЬ выделений для всей обработки CSV:
void ProcessCsv(ReadOnlySpan<char> csvData)
{
var parser = new CsvParser(csvData);
// На практике используйте массив span фиксированного размера:
Span<ReadOnlySpan<char>> fields = stackalloc ReadOnlySpan<char>[100];
while (parser.TryReadRow(fields, 100))
{
foreach (var field in fields[..GetActualFieldCount()])
Process(field.Trim());
}
}Упражнение 2: Бенчмарк — манипуляция строками с Span и без
using System.Diagnostics;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class StringManipulationBenchmark
{
private string _input = new string('a', 10000);
[Benchmark(Baseline = true)]
public int CountCharTraditional()
{
int count = 0;
for (int i = 0; i < _input.Length; i++)
if (_input[i] == 'a') count++;
return count;
}
[Benchmark]
public int CountCharWithSpan()
{
ReadOnlySpan<char> span = _input;
int count = 0;
foreach (var c in span)
if (c == 'a') count++;
return count;
}
[Benchmark]
public string UpperTraditional() => _input.ToUpper(); // Выделяет новую строку
[Benchmark]
public int UpperWithSpan()
{
Span<char> dest = stackalloc char[_input.Length];
ReadOnlySpan<char> src = _input;
for (int i = 0; i < src.Length; i++)
dest[i] = char.ToUpper(src[i]);
return dest.Length; // Без выделения строки!
}
}Упражнение 3: Оптимизация нагрузки на GC с MemoryPool
public class OptimizedJsonSerializer
{
private static readonly MemoryPool<byte> _pool = MemoryPool<byte>.Shared;
public byte[] Serialize(object obj)
{
// Аренда буфера из пула вместо выделения нового каждый раз
using var owner = _pool.Rent(EstimateSize(obj));
Span<byte> buffer = owner.Memory.Span;
int written = WriteJson(obj, buffer); // Пользовательская сериализация
// Копирование результата (только использованную часть) в возвращаемый массив
byte[] result = new byte[written];
buffer.Slice(0, written).CopyTo(result);
return result;
}
private int WriteJson(object obj, Span<byte> buffer)
{
// ... логика сериализации, записывающая в span ...
// Возвращает количество записанных байт
throw new NotImplementedException();
}
private int EstimateSize(object obj) => 4096; // Эвристика
}Сводная таблица: Выбор правильного инструмента
| Потребность | Используйте | Почему |
|---|---|---|
| Обработка части массива без копирования | Span<T> из массива | Представление без выделения памяти |
| Небольшой фиксированный буфер, без GC | stackalloc + Span<T> | Память стека, автоочистка |
| Буфер в async методе | Memory<T> | Не ref struct |
| Буфер как поле класса | Memory<T> | Можно хранить в классе |
| Повторное использование больших буферов | ArrayPool<T> или MemoryPool<T> | Снижает нагрузку на GC |
| Обработка фрагментированных данных ввода/вывода | ReadOnlySequence<T> | Обрабатывает не непрерывную память |
| Парсинг строк без выделения памяти | ReadOnlySpan<char> из строки | Не нужно преобразование кодировки |
| Возврат буфера из метода | IMemoryOwner<T> или массив | Span нельзя вернуть напрямую |
Быстрая справка: сравнение выделений памяти
// ВЫДЕЛЯЕТ в куче (нагрузка на GC):
byte[] arr = new byte[1024]; // ~1KB выделение GC
string s = "hello".ToUpper(); // Новое выделение строки
var list = new List<int>(); // Выделение List + внутреннего массива
// НУЛЬ выделения в куче:
Span<byte> span = stackalloc byte[1024]; // Только стек
ReadOnlySpan<char> rspan = someString; // Представление существующей строки
Memory<byte> mem = ArrayPool<byte>.Shared.Rent(1024); // Пул (не GC)Рефлексия и Emit в C#/.NET
Содержание
- System.Reflection — Type, MemberInfo, MethodInfo, PropertyInfo
- Стоимость рефлексии — кэширование, MethodInfo.CreateDelegate()
- System.Reflection.Emit — динамическая генерация кода
- Практические упражнения: быстрый маппер объектов, DI-контейнер, динамический прокси
System.Reflection — основные типы
Обнаружение типов и интроспекция
// Получение объектов Type
Type t1 = typeof(int); // Тип, известный на этапе компиляции
Type t2 = typeof(List<string>); // Обобщённый тип
Type t3 = "System.String".GetType(); // Имя типа во время выполнения (не рекомендуется)
Type t4 = someObject.GetType(); // Тип экземпляра во время выполнения
// Информация о типе
Console.WriteLine(t1.Name); // "Int32"
Console.WriteLine(t1.FullName); // "System.Int32"
Console.WriteLine(t1.Namespace); // "System"
Console.WriteLine(t1.Assembly); // "System.Private.CoreLib, ..."
Console.WriteLine(t1.IsClass); // false (это структура)
Console.WriteLine(t1.IsValueType); // true
Console.WriteLine(t1.IsGenericType); // false
// Анализ обобщённого типа
Type listType = typeof(List<>); // Открытый обобщённый тип
Console.WriteLine(listType.IsGenericTypeDefinition); // true
Type constructedList = typeof(List<string>); // Построенный обобщённый тип
Console.WriteLine(constructedList.IsGenericType); // true
Console.WriteLine(constructedList.IsGenericTypeDefinition); // false
Type[] typeArgs = constructedList.GetGenericArguments(); // [System.String]MemberInfo — доступ к членам
public class SampleClass
{
public string PublicProperty { get; set; }
private int _privateField;
public void PublicMethod() { }
private void PrivateMethod() { }
}
Type type = typeof(SampleClass);
// Получение всех свойств (включая унаследованные)
PropertyInfo[] props = type.GetProperties(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
// Получение конкретного свойства
PropertyInfo prop = type.GetProperty("PublicProperty",
BindingFlags.Instance | BindingFlags.Public);
// Получение всех методов
MethodInfo[] methods = type.GetMethods(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
// Получение конкретного метода (с типами параметров для перегрузок)
MethodInfo method = type.GetMethod("PublicMethod");
MethodInfo overload = type.GetMethod("MethodName", new[] { typeof(int), typeof(string) });
// Получение всех полей
FieldInfo[] fields = type.GetFields(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
// Получение конструкторов
ConstructorInfo[] ctors = type.GetConstructors();PropertyInfo — чтение и запись свойств
public class Person
{
public string Name { get; set; }
public int Age { get; private set; }
}
var person = new Person { Name = "Alice", Age = 30 };
Type type = typeof(Person);
// Чтение значения свойства
PropertyInfo nameProp = type.GetProperty("Name");
string name = (string)nameProp.GetValue(person); // "Alice"
string name2 = (string)nameProp.GetValue(person, null); // Аргументы индексатора для индексированных свойств
// Запись значения свойства
nameProp.SetValue(person, "Bob", null); // person.Name = "Bob"
// Проверка доступности
Console.WriteLine(nameProp.CanWrite); // true
var ageProp = type.GetProperty("Age");
Console.WriteLine(ageProp.CanWrite); // false (приватный сеттер)
// Получение атрибутов свойства
object[] attrs = nameProp.GetCustomAttributes(false);MethodInfo — динамический вызов методов
public class Calculator
{
public int Add(int a, int b) => a + b;
public static string Greet(string name) => $"Hello, {name}!";
}
Type calcType = typeof(Calculator);
// Вызов экземпляра метода
var calculator = new Calculator();
MethodInfo addMethod = calcType.GetMethod("Add");
object result = addMethod.Invoke(calculator, new object[] { 3, 4 }); // 7
// Вызов статического метода (передайте null для экземпляра)
MethodInfo greetMethod = calcType.GetMethod("Greet",
BindingFlags.Static | BindingFlags.Public);
string greeting = (string)greetMethod.Invoke(null, new object[] { "World" }); // "Hello, World!"
// Вызов с необязательными параметрами
MethodInfo methodWithOptional = calcType.GetMethod("SomeMethod");
object optResult = methodWithOptional.Invoke(instance,
bindingAttr: BindingFlags.Default,
binder: null,
parameters: new object[] { "arg1", Type.Missing }, // Пропуск необязательного параметра
culture: null);
// Получение типа возвращаемого значения и параметров метода
Console.WriteLine(addMethod.ReturnType); // System.Int32
ParameterInfo[] @params = addMethod.GetParameters(); // [int a, int b]BindingFlags — управление поиском членов
// Распространённые комбинации:
BindingFlags flags = BindingFlags.Instance | BindingFlags.Public; // Публичные члены экземпляра
flags |= BindingFlags.NonPublic; // + Приватные/protected/internal
flags |= BindingFlags.Static; // + Статические члены
flags |= BindingFlags.DeclaredOnly; // Только объявленные на этом типе (не унаследованные)
// Типичное использование:
var allMembers = type.GetMembers(
BindingFlags.Instance |
BindingFlags.Static |
BindingFlags.Public |
BindingFlags.NonPublic);
var publicInstanceProps = type.GetProperties(
BindingFlags.Instance | BindingFlags.Public);
var privateFields = type.GetFields(
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);Стоимость рефлексии — кэширование и CreateDelegate
Проблема: рефлексия медленная
using System.Diagnostics;
public class ReflectionBenchmark
{
private readonly Person _person = new() { Name = "Alice", Age = 30 };
private readonly Type _type = typeof(Person);
// Прямой доступ — базовый уровень (~1нс)
public string DirectAccess() => _person.Name;
// Рефлексия без кэширования — очень медленно (~100-500нс на вызов)
public string ReflectionNoCache()
{
var prop = typeof(Person).GetProperty("Name"); // Поиск каждый раз!
return (string)prop.GetValue(_person);
}
// Кэшированный PropertyInfo — быстрее (~20-50нс)
private static readonly PropertyInfo _nameProp =
typeof(Person).GetProperty("Name")!;
public string ReflectionCached() => (string)_nameProp.GetValue(_person);
// Скомпилированный делегат — скорость, близкая к прямой (~5-10нс)
private static readonly Func<Person, string> _getName =
CreateGetter<Person, string>("Name");
public string DelegateAccess() => _getName(_person);
}Сравнение производительности
| Метод | Время на вызов | Выделено памяти | Примечания |
|---|---|---|---|
| Прямой доступ к свойству | ~1нс | 0 Б | Базовый уровень |
PropertyInfo.GetValue (кэшированный) | ~20-50нс | 0 Б | Всё ещё медленно для значимых типов (боксинг) |
Delegate.CreateDelegate | ~5-10нс | 0 Б | Скорость, близкая к прямой |
| Expression.Compile getter | ~5-10нс | 0 Б | То же, что и CreateDelegate |
| Рефлексия без кэша | ~100-500нс | варьируется | Включает накладные расходы поиска члена |
Кэширование PropertyInfo/MethodInfo
public static class ReflectionCache
{
private static readonly ConditionalWeakTable<Type, Dictionary<string, PropertyInfo>>
_propertyCache = new();
public static PropertyInfo GetCachedProperty(Type type, string propertyName)
{
var dict = _propertyCache.GetOrCreateValue(type);
lock (dict)
{
if (!dict.TryGetValue(propertyName, out var prop))
{
prop = type.GetProperty(propertyName,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
dict[propertyName] = prop;
}
return prop!;
}
}
}
// Использование:
var prop = ReflectionCache.GetCachedProperty(typeof(Person), "Name");
string value = (string)prop.GetValue(person); // Быстро после первого вызова на тип/свойствоMethodInfo.CreateDelegate() — быстрый динамический вызов
public static class DelegateFactory
{
// Создание быстрого делегата-геттера для любого свойства
public static Func<T, TResult> CreateGetter<T, TResult>(string propertyName)
{
var prop = typeof(T).GetProperty(propertyName,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (prop == null || prop.PropertyType != typeof(TResult))
throw new ArgumentException($"Property '{propertyName}' of type TResult not found on {typeof(T)}");
// Создание делегата открытого экземпляра — без выделения замыкания!
var getter = prop.GetMethod ?? throw new InvalidOperationException("No getter");
return (Func<T, TResult>)Delegate.CreateDelegate(
typeof(Func<T, TResult>), getter);
}
// Создание быстрого делегата-сеттера для любого свойства
public static Action<T, TValue> CreateSetter<T, TValue>(string propertyName)
{
var prop = typeof(T).GetProperty(propertyName,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (prop == null)
throw new ArgumentException($"Property '{propertyName}' not found");
var setter = prop.SetMethod ?? throw new InvalidOperationException("No setter");
return (Action<T, TValue>)Delegate.CreateDelegate(
typeof(Action<T, TValue>), setter);
}
// Создание делегата для статического метода
public static Func<TResult> CreateStaticGetter<TType, TResult>(string methodName)
{
var method = typeof(TType).GetMethod(methodName,
BindingFlags.Static | BindingFlags.Public);
return (Func<TResult>)Delegate.CreateDelegate(
typeof(Func<TResult>), method);
}
}
// Использование:
var getName = DelegateFactory.CreateGetter<Person, string>("Name");
var setAge = DelegateFactory.CreateSetter<Person, int>("Age");
string name = getName(person); // ~5нс — скорость, близкая к прямому доступу!
setAge(person, 31); // Быстрая динамическая установка свойстваАльтернатива Expression.Compile()
public static Func<T, TResult> CompileGetter<T, TResult>(Expression<Func<T, TResult>> expr)
{
return expr.Compile(); // Генерирует оптимизированный IL-делегат
}
// Использование:
var getter = CompileGetter<Person, string>(p => p.Name);
string name = getter(person); // ~5нс — та же скорость, что и CreateDelegateSystem.Reflection.Emit — динамическая генерация кода
Что такое Reflection.Emit?
System.Reflection.Emit позволяет генерировать новые типы и методы во время выполнения, испуская инструкции IL (Intermediate Language):
Определение сборки → Определение модуля → Определение типа → Определение методов → Генерация IL → CreateType()Базовый пример: создание динамического типа
using System.Reflection;
using System.Reflection.Emit;
using System.Collections.ObjectModel;
// Шаг 1: Создание динамической сборки
AssemblyName asmName = new("DynamicAssembly");
AssemblyBuilder asmBuilder = AssemblyBuilder.DefineDynamicAssembly(
asmName, AssemblyBuilderAccess.Run);
ModuleBuilder module = asmBuilder.DefineDynamicModule("DynamicModule");
// Шаг 2: Определение типа
TypeBuilder typeBuilder = module.DefineType(
"DynamicGreeter",
TypeAttributes.Public | TypeAttributes.Class);
// Шаг 3: Добавление поля
FieldBuilder nameField = typeBuilder.DefineField("_name", typeof(string),
FieldAttributes.Private);
// Шаг 4: Добавление конструктора
ConstructorBuilder ctor = typeBuilder.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
new[] { typeof(string) });
ILGenerator ctorIl = ctor.GetILGenerator();
ctorIl.Emit(OpCodes.Ldarg_0); // Загрузка 'this'
ctorIl.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyArray)!); // Вызов base()
ctorIl.Emit(OpCodes.Ldarg_0); // Загрузка 'this'
ctorIl.Emit(OpCodes.Ldarg_1); // Загрузка параметра 'name'
ctorIl.Emit(OpCodes.Stfld, nameField); // Сохранение в поле _name
ctorIl.Emit(OpCodes.Ret); // Возврат
// Шаг 5: Добавление метода
MethodBuilder greetMethod = typeBuilder.DefineMethod(
"Greet",
MethodAttributes.Public | MethodAttributes.Static,
typeof(string),
new[] { typeof(string) });
ILGenerator methodIl = greetMethod.GetILGenerator();
methodIl.Emit(OpCodes.Ldstr, "Hello, "); // Push строкового литерала
methodIl.Emit(OpCodes.Ldarg_0); // Загрузка параметра 'name'
methodIl.Emit(OpCodes.Call, typeof(string).GetMethod("Concat",
new[] { typeof(string), typeof(string) })!); // Вызов string.Concat
methodIl.Emit(OpCodes.Ret);
// Шаг 6: Создание типа
Type greeterType = typeBuilder.CreateType();
// Использование динамического типа!
var instance = Activator.CreateInstance(greeterType, "World");
string result = (string)greeterType.GetMethod("Greet")!.Invoke(null, new[] { "Alice" });
Console.WriteLine(result); // "Hello, Alice"Справочник распространённых IL-опкодов
| Опкод | Описание | Эффект на стеке |
|---|---|---|
Ldarg_0 — Ldarg_3 | Загрузка аргумента 0-3 | push arg |
Ldarg_S byte | Загрузка короткого аргумента | push arg |
Ldarga / Ldarga_S | Загрузка адреса аргумента | push &arg |
Ldloc_0 — Ldloc_3 | Загрузка локальной переменной 0-3 | push var |
Stloc_0 — Stloc_3 | Сохранение в локальную переменную 0-3 | pop val |
Ldc_I4 int32 | Push 32-битной целочисленной константы | push int |
Ldc_I8 int64 | Push 64-битной целочисленной константы | push long |
Ldc_R4 float32 | Push 32-битной float константы | push float |
Ldc_R8 float64 | Push 64-битной double константы | push double |
Ldstr string | Push строкового литерала | push string |
Ldfld FieldInfo | Загрузка значения поля из объекта | pop obj, push val |
Stfld FieldInfo | Сохранение значения в поле объекта | pop val, pop obj |
Ldsfld FieldInfo | Загрузка значения статического поля | push val |
Stsfld FieldInfo | Сохранение значения в статическое поле | pop val |
Call MethodInfo | Вызов метода (виртуальный/статический) | pop args, push ret |
Callvirt MethodInfo | Вызов виртуального метода (с проверкой null) | pop obj+args, push ret |
Newobj ConstructorInfo | Создание нового объекта | pop args, push obj |
Ret | Возврат из метода | — |
Br target | Безусловный переход | — |
Brfalse target | Переход, если ноль/false | pop val |
Brtrue target | Переход, если не ноль/true | pop val |
Генерация обобщённого типа с Emit
// Определение обобщённого типа: public class Cache<TKey, TValue> where TKey : notnull
TypeBuilder cacheType = module.DefineType(
"Cache`2",
TypeAttributes.Public | TypeAttributes.Class,
baseType: typeof(object),
generics: new[] { typeof(TKey).GetGenericArguments()[0], /* ... */ });
// На самом деле, для параметров обобщённого типа:
GenericTypeParameterBuilder tKey = cacheType.DefineGenericParameter(
"TKey", GenericParameterAttributes.None);
tKey.SetGenericParameterAttributes(GenericParameterAttributes.NotNullableValueTypeConstraint);
GenericTypeParameterBuilder tValue = cacheType.DefineGenericParameter(
"TValue", GenericParameterAttributes.None);
// Добавление ограничений: where TKey : notnull
tKey.SetGenericParameterAttributes(GenericParameterAttributes.NotNullableValueTypeConstraint);Практика
- [ ] Изучить материал
Упражнение 1: Быстрый маппер объектов с рефлексией + кэшированием делегатов
public class FastObjectMapper
{
private static readonly ConditionalWeakTable<Type, PropertyMap> _cache = new();
public class PropertyMap
{
public string[] PropertyNames;
public Func<object, object>[] Getters;
public Action<object, object>[] Setters;
}
public TDestination Map<TSource, TDestination>(TSource source)
{
var dest = Activator.CreateInstance<TDestination>();
var srcType = typeof(TSource);
var dstType = typeof(TDestination);
// Получение или создание карты свойств
var map = GetOrCreateMap(srcType, dstType);
for (int i = 0; i < map.PropertyNames.Length; i++)
{
object value = map.Getters[i](source);
if (value != null)
map.Setters[i](dest, value);
}
return dest;
}
private PropertyMap GetOrCreateMap(Type srcType, Type dstType)
{
var cacheKey = Tuple.Create(srcType, dstType);
// Упрощено — в продакшене используйте составной ключ
var srcMap = _cache.GetOrCreateValue(srcType);
// ... полная реализация кэшировала бы по паре (src, dst)
throw new NotImplementedException("Требуется полная реализация кэширования");
}
}
// Более простая версия с прямым созданием делегатов:
public static class SimpleMapper
{
public static TDestination Map<TSource, TDestination>(TSource source)
where TSource : class where TDestination : class, new()
{
var dest = new TDestination();
foreach (var srcProp in typeof(TSource).GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
var dstProp = typeof(TDestination).GetProperty(srcProp.Name, srcProp.PropertyType);
if (dstProp != null && dstProp.CanWrite)
{
var value = srcProp.GetValue(source);
dstProp.SetValue(dest, value);
}
}
return dest;
}
}Упражнение 2: Простой DI-контейнер с разрешением на основе рефлексии
public interface IServiceContainer : IDisposable
{
TService Resolve<TService>() where TService : notnull;
object Resolve(Type serviceType);
void Register<TService, TImplementation>()
where TImplementation : class, TService;
void RegisterInstance<TService>(TService instance) where TService : notnull;
}
public class SimpleContainer : IServiceContainer
{
private readonly Dictionary<Type, ServiceRegistration> _registrations = new();
enum ServiceLifetime { Transient, Singleton }
class ServiceRegistration
{
public Type ImplementationType;
public object Instance; // Для регистрации экземпляра
public object SingletonInstance; // Кэшированный синглтон
public ServiceLifetime Lifetime;
public Func<IServiceContainer, object> Factory;
}
public void Register<TService, TImplementation>()
where TImplementation : class, TService
{
_registrations[typeof(TService)] = new ServiceRegistration
{
ImplementationType = typeof(TImplementation),
Lifetime = ServiceLifetime.Transient
};
}
public void RegisterInstance<TService>(TService instance) where TService : notnull
{
_registrations[typeof(TService)] = new ServiceRegistration
{
Instance = instance,
Lifetime = ServiceLifetime.Singleton
};
}
public TService Resolve<TService>() where TService : notnull
{
return (TService)Resolve(typeof(TService));
}
public object Resolve(Type serviceType)
{
if (!_registrations.TryGetValue(serviceType, out var registration))
throw new InvalidOperationException($"No registration for {serviceType}");
// Регистрация экземпляра — возврат напрямую
if (registration.Instance != null)
return registration.Instance;
// Синглтон — создание один раз и кэширование
if (registration.Lifetime == ServiceLifetime.Singleton)
{
if (registration.SingletonInstance == null)
registration.SingletonInstance = CreateInstance(registration, serviceType);
return registration.SingletonInstance;
}
// Transient — создание нового каждый раз
return CreateInstance(registration, serviceType);
}
private object CreateInstance(ServiceRegistration reg, Type serviceType)
{
if (reg.Factory != null)
return reg.Factory(this);
var implType = reg.ImplementationType ?? serviceType;
// Поиск конструктора с наибольшим количеством параметров, которые можно разрешить
var constructors = implType.GetConstructors(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
ConstructorInfo bestCtor = null;
int maxParams = -1;
foreach (ConstructorInfo ctor in constructors)
{
var parameters = ctor.GetParameters();
bool canResolve = true;
foreach (var param in parameters)
{
if (!_registrations.ContainsKey(param.ParameterType))
{
canResolve = false;
break;
}
}
if (canResolve && parameters.Length > maxParams)
{
bestCtor = ctor;
maxParams = parameters.Length;
}
}
if (bestCtor == null)
throw new InvalidOperationException($"Cannot resolve constructor for {implType}");
// Рекурсивное разрешение параметров конструктора
var parameters = bestCtor.GetParameters();
object[] resolvedParams = new object[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
resolvedParams[i] = Resolve(parameters[i].ParameterType);
return bestCtor.Invoke(resolvedParams);
}
public void Dispose() { /* Очистка disposables */ }
}
// Использование:
var container = new SimpleContainer();
container.Register<IUserService, UserService>();
container.Register<IRepository, UserRepository>();
var service = container.Resolve<IUserService>(); // Создаёт UserService с внедрёнными зависимостямиУпражнение 3: Динамический прокси для AOP (паттерн интерцептора)
public class DynamicProxyGenerator
{
public static T CreateProxy<T>(IInterceptor interceptor) where T : class
{
var interfaceType = typeof(T);
if (!interfaceType.IsInterface)
throw new ArgumentException("Можно создавать прокси только для интерфейсов");
// Создание динамической сборки
var asmName = new AssemblyName($"Proxy_{interfaceType.Name}");
var assembly = AssemblyBuilder.DefineDynamicAssembly(asmName, AssemblyBuilderAccess.Run);
var module = assembly.DefineDynamicModule("ProxyModule");
// Определение типа прокси, реализующего интерфейс
var proxyType = module.DefineType(
$"Proxy_{interfaceType.Name}",
TypeAttributes.Public | TypeAttributes.Class,
typeof(object),
new[] { interfaceType });
// Добавление поля интерцептора
var interceptorField = proxyType.DefineField(
"_interceptor", typeof(IInterceptor), FieldAttributes.Private);
// Добавление конструктора
var ctor = proxyType.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
new[] { typeof(IInterceptor) });
var ctorIl = ctor.GetILGenerator();
ctorIl.Emit(OpCodes.Ldarg_0);
ctorIl.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyArray));
ctorIl.Emit(OpCodes.Ldarg_0);
ctorIl.Emit(OpCodes.Ldarg_1);
ctorIl.Emit(OpCodes.Stfld, interceptorField);
ctorIl.Emit(OpCodes.Ret);
// Генерация прокси-методов для каждого метода интерфейса
foreach (MethodInfo method in interfaceType.GetMethods())
{
if (method.IsSpecialName &&
(method.Name.StartsWith("get_") || method.Name.StartsWith("set_")))
continue; // Пропуск свойств
GenerateProxyMethod(proxyType, interceptorField, method);
}
Type createdType = proxyType.CreateType();
return (T)Activator.CreateInstance(createdType, interceptor);
}
private static void GenerateProxyMethod(
TypeBuilder typeBuilder,
FieldInfo interceptorField,
MethodInfo targetMethod)
{
var methodBuilder = typeBuilder.DefineMethod(
targetMethod.Name,
MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.Final,
targetMethod.CallingConvention,
targetMethod.ReturnType,
targetMethod.GetParameters().Select(p => p.ParameterType).ToArray());
var il = methodBuilder.GetILGenerator();
// Генерация: return _interceptor.Intercept(this, methodInfo, arguments);
// Упрощено — реальная реализация требует правильной обработки аргументов
if (targetMethod.ReturnType == typeof(void))
il.Emit(OpCodes.Ret);
else
il.Emit(OpCodes.Ldnull); // Заглушка
il.Emit(OpCodes.Ret);
}
}
public interface IInterceptor
{
object Intercept(object proxy, MethodInfo method, object[] arguments);
}
public class LoggingInterceptor : IInterceptor
{
public object Intercept(object proxy, MethodInfo method, object[] arguments)
{
Console.WriteLine($"Вызов: {method.Name}");
var startTime = Stopwatch.GetTimestamp();
try
{
var result = method.Invoke(proxy, arguments);
var elapsed = (Stopwatch.GetTimestamp() - startTime) / Stopwatch.Frequency * 1000;
Console.WriteLine($"Завершено: {method.Name} за {elapsed:F2}мс");
return result;
}
catch (TargetInvocationException ex)
{
Console.WriteLine($"Ошибка в {method.Name}: {ex.InnerException?.Message}");
throw;
}
}
}Сводная таблица: Когда что использовать
| Сценарий | Рекомендуемый подход | Производительность |
|---|---|---|
| Одноразовая инспекция типа | Type.GetProperties() и т.д. | Приемлемо (одноразовая стоимость) |
| Повторный доступ к свойствам | Кэширование PropertyInfo | ~20-50нс на доступ |
| Доступ к свойствам в критическом участке | Delegate.CreateDelegate или Expression.Compile | ~5-10нс на доступ |
| Создание динамического типа во время выполнения | Reflection.Emit | Сложно, но мощно |
| Генерация кода на этапе компиляции | Source Generators (предпочтительнее Emit) | Нулевая стоимость во время выполнения |
| Простой маппинг объектов | Деревья выражений + скомпилированные делегаты | Скорость, близкая к прямой |
| DI-контейнер | Рефлексия для создания + кэширование делегатов | Достаточно для большинства случаев |
Эмпирическое правило производительности
Прямой доступ: ~1нс ████████████████████ (быстрее всего)
Скомпилированный делегат: ~5-10нс ████████████████
Кэшированный PropertyInfo: ~20-50нс ███████████
Рефлексия без кэша: ~100-500нс ████Правило: Если вызов рефлексии находится в критическом участке (вызывается > 1000 раз/сек), скомпилируйте его в делегат. В противном случае достаточно кэширования PropertyInfo.
Атрибуты и метаданные в C#/.NET
Содержание
- Пользовательские атрибуты — наследование, AllowMultiple, AttributeUsage
- Чтение метаданных во время выполнения vs на этапе компиляции
- API Roslyn — SyntaxTree, SemanticModel
- Практические упражнения: валидация на основе атрибутов, анализатор Roslyn
Пользовательские атрибуты
Что такое атрибуты?
Атрибуты — это аннотации метаданных, прикрепляемые к элементам кода (классы, методы, свойства, параметры и т.д.), которые можно читать во время выполнения через рефлексию или на этапе компиляции через Roslyn:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class MyAttribute : Attribute
{
public string Description { get; }
public MyAttribute(string description)
{
Description = description;
}
}
// Использование:
[My("Это сущность Person")]
public class Person
{
[My("Полное имя человека")]
public string Name { get; set; }
}AttributeUsage — управление применением атрибутов
[AttributeUsage(
AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Method, // Допустимые цели
AllowMultiple = true, // Можно применять несколько раз
Inherited = false)] // Не наследуется производными классами
public class ExampleAttribute : Attribute { }
// Варианты AttributeTargets:
// - All — Любой элемент программы
// - Assembly — Уровень сборки
// - Module — Уровень модуля
// - Class — Классы
// - Struct — Структуры
// - Enum — Перечисления
// - Constructor — Конструкторы
// - Method — Методы
// - Property — Свойства
// - Field — Поля
// - Event — События
// - Parameter — Параметры методов
// - ReturnValue — Возвращаемые значения
// - GenericParameter — Параметры обобщённых типов
// Примеры:
[Example] // Однократное использование (по умолчанию)
[Example, Example] // Многократное использование (если AllowMultiple = true)
public class BaseClass
{
[Example] // При Inherited = false, производные классы не наследуют это
public void Method() { }
}
public class DerivedClass : BaseClass { } // Метод не будет иметь атрибут [Example]Чтение атрибутов во время выполнения
public class Person
{
[Obsolete("Используйте FullName вместо этого", false)]
public string Name { get; set; }
[Required]
[StringLength(100, MinimumLength = 1)]
public string FullName { get; set; }
}
// Проверка, имеет ли тип атрибут
bool hasAttribute = typeof(Person).IsDefined(typeof(ObsoleteAttribute), inherit: true);
// Получение всех атрибутов определённого типа
var obsoleteAttrs = typeof(Person).GetCustomAttributes(typeof(ObsoleteAttribute), false);
// Получение конкретного атрибута (нужна проверка на null)
var obsolete = typeof(Person).GetProperty("Name")!
.GetCustomAttribute<ObsoleteAttribute>();
if (obsolete != null)
{
Console.WriteLine(obsolete.Message); // "Используйте FullName вместо этого"
Console.WriteLine(obsolete.IsError); // false
}
// Получение всех атрибутов (любого типа)
object[] allAttrs = typeof(Person).GetCustomAttributes(false);
// Получение атрибутов с наследованием
var inheritedAttrs = typeof(DerivedClass).GetCustomAttributes(inherit: true);
// Проверка атрибутов параметров
foreach (var method in typeof(Person).GetMethods())
{
foreach (var param in method.GetParameters())
{
var notNullAttr = param.GetCustomAttribute<NotNullAttribute>();
if (notNullAttr != null)
Console.WriteLine($"Параметр '{param.Name}' не должен быть null");
}
}Справочник встроенных атрибутов
| Атрибут | Назначение | Пример |
|---|---|---|
[Obsolete] | Пометка устаревших членов | [Obsolete("Используйте NewMethod")] |
[Serializable] | Включение бинарной сериализации | [Serializable] public class Data |
[Flags] | Перечисление как битовые флаги | [Flags] enum Permissions { Read = 1, Write = 2 } |
[Conditional] | Условный вызов метода | [Conditional("DEBUG")] void DebugLog() |
[DllImport] | P/Invoke нативных методов | [DllImport("kernel32")] static extern int GetVersion() |
[JsonProperty] | Управление JSON-сериализацией | [JsonProperty("user_name")] public string Username |
[NotNull], [CanBeNull] | Аннотации nullability | [return: NotNull] string GetName() |
[Pure] | Метод не имеет побочных эффектов | [Pure] int Calculate(int a, int b) |
[MethodImpl] | Управление реализацией метода | [MethodImpl(MethodImplOptions.AggressiveInlining)] |
Пример пользовательского атрибута: основа фреймворка валидации
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter,
AllowMultiple = false, Inherited = true)]
public abstract class ValidationAttribute : Attribute
{
public string ErrorMessage { get; set; } = "Валидация не пройдена.";
public abstract bool IsValid(object? value);
}
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public class RequiredAttribute : ValidationAttribute
{
public override bool IsValid(object? value) => value != null;
}
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public class RangeAttribute : ValidationAttribute
{
public double Minimum { get; }
public double Maximum { get; }
public RangeAttribute(double minimum, double maximum)
{
Minimum = minimum;
Maximum = maximum;
}
public override bool IsValid(object? value)
{
if (value is IComparable comparable)
return comparable.CompareTo(Minimum) >= 0 &&
comparable.CompareTo(Maximum) <= 0;
return false;
}
}
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public class StringLengthAttribute : ValidationAttribute
{
public int MinimumLength { get; } = 0;
public int MaximumLength { get; }
public StringLengthAttribute(int maximumLength)
{
MaximumLength = maximumLength;
}
public override bool IsValid(object? value)
{
if (value == null) return true; // Проверка null обрабатывается [Required]
if (value is string str)
return str.Length >= MinimumLength && str.Length <= MaximumLength;
return false;
}
}
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public class RegularExpressionAttribute : ValidationAttribute
{
public string Pattern { get; }
public RegularExpressionAttribute(string pattern)
{
Pattern = pattern;
}
public override bool IsValid(object? value)
{
if (value == null) return true;
return Regex.IsMatch(value.ToString()!, Pattern);
}
}
// Использование:
public class CreateUserRequest
{
[Required]
[StringLength(50, MinimumLength = 1)]
public string Username { get; set; } = "";
[Required]
[RegularExpression(@"^[^@]+@[^@]+\.[^@]+$")]
public string Email { get; set; } = "";
[Range(0, 150)]
public int? Age { get; set; }
}Чтение метаданных: во время выполнения vs на этапе компиляции
Рефлексия во время выполнения (System.Reflection)
// Чтение атрибутов во время выполнения — гибко, но имеет стоимость производительности
public static List<ValidationResult> Validate(object obj)
{
var results = new List<ValidationResult>();
Type type = obj.GetType();
foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
var attrs = prop.GetCustomAttributes(typeof(ValidationAttribute), false)
.Cast<ValidationAttribute>()
.ToList();
object? value = prop.GetValue(obj);
foreach (var attr in attrs)
{
if (!attr.IsValid(value))
results.Add(new ValidationResult(prop.Name, attr.ErrorMessage));
}
}
return results;
}
// Производительность: ~5-20мкс на валидацию объекта (накладные расходы рефлексии)На этапе компиляции с Roslyn (Microsoft.CodeAnalysis)
Roslyn предоставляет доступ к структуре и семантике кода на этапе компиляции:
| Аспект | Рефлексия во время выполнения | Roslyn (на этапе компиляции) |
|---|---|---|
| Когда выполняется | Во время выполнения приложения | Во время компиляции |
| Влияние на производительность | Стоимость во время выполнения | Нулевая стоимость во время выполнения |
| Может изменять поведение | Да, динамические решения | Нет, код уже скомпилирован |
| Варианты использования | Валидация, DI, сериализация | Генерация кода, анализаторы, инструменты рефакторинга |
| API | System.Reflection | Microsoft.CodeAnalysis |
Обзор архитектуры Roslyn
Исходный код (.cs файлы)
↓
SyntaxTree (AST — абстрактное синтаксическое дерево)
↓
Compilation + SemanticModel (Информация о типах, поток данных)
↓
IL → Нативный код (через JIT/AOT)Ключевые компоненты:
- SyntaxTree: Дерево разбора исходного кода (только структура, без типов)
- SyntaxNode: Отдельный узел дерева (объявление класса, метод, выражение и т.д.)
- SyntaxToken: Лексические токены (идентификаторы, ключевые слова, операторы)
- SemanticModel: Информация о типах, разрешение символов, анализ потока данных
- Compilation: Представляет полную единицу компиляции со всеми ссылками
API Roslyn — SyntaxTree и SemanticModel
Парсинг исходного кода
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
string source = @"
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public void Greet()
{
Console.WriteLine(""Hello, "" + Name);
}
}";
// Парсинг в SyntaxTree (AST) — пока без информации о типах
SyntaxTree tree = CSharpSyntaxTree.ParseText(source);
CompilationUnitSyntax root = tree.GetRoot();
// Навигация по синтаксическому дереву
foreach (var member in root.DescendantNodes().OfType<ClassDeclarationSyntax>())
{
Console.WriteLine($"Класс: {member.Identifier.Text}");
foreach (var prop in member.Members.OfType<PropertyDeclarationSyntax>())
{
var type = prop.Type.ToString();
var name = prop.Identifier.Text;
Console.WriteLine($" Свойство: {type} {name}");
}
foreach (var method in member.Members.OfType<MethodDeclarationSyntax>())
{
Console.WriteLine($" Метод: {method.ReturnType} {method.Identifier.Text}()");
// Анализ тела метода
if (method.Body != null)
{
foreach (var stmt in method.Body.Statements)
Console.WriteLine($" Оператор: {stmt.GetType().Name}");
}
}
}
// Вывод:
// Класс: Person
// Свойство: string Name
// Свойство: int Age
// Метод: void Greet()
// Оператор: ExpressionStatementSyntaxSemanticModel — информация о типах и разрешение символов
// Создание компиляции для получения семантической информации
MetadataReference[] references = new[]
{
MetadataReference.CreateFromFile(typeof(object).Assembly.Location), // System.Private.CoreLib
MetadataReference.CreateFromFile(typeof(Console).Assembly.Location), // System.Console
// Добавьте другие по мере необходимости...
};
Compilation compilation = CSharpCompilation.Create(
"MyAssembly",
syntaxTrees: new[] { tree },
references: references,
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
// Получение семантической модели для дерева
SemanticModel semanticModel = compilation.GetSemanticModel(tree);
// Теперь можно разрешать типы и символы!
var classNode = root.DescendantNodes().OfType<ClassDeclarationSyntax>()
.First(c => c.Identifier.Text == "Person");
// Получение символа (информации о типе) для класса
INamedTypeSymbol personSymbol = semanticModel.GetDeclaredSymbol(classNode)!;
Console.WriteLine($"Полное имя: {personSymbol.ToDisplayString()}"); // global::Person
Console.WriteLine($"Базовый тип: {personSymbol.BaseType}"); // System.Object
// Анализ типов свойств
foreach (var prop in classNode.Members.OfType<PropertyDeclarationSyntax>())
{
IPropertySymbol propSymbol = semanticModel.GetDeclaredSymbol(prop)!;
Console.WriteLine($" {propSymbol.Type.ToDisplayString()} {propSymbol.Name}");
// Проверка nullability
Console.WriteLine($" NullableAnnotation: {propSymbol.NullableAnnotation}");
}
// Анализ вызовов методов и их целей
foreach (var invocation in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
IMethodSymbol methodSymbol = semanticModel.GetSymbolInfo(invocation).Symbol as IMethodSymbol;
if (methodSymbol != null)
Console.WriteLine($"Вызов: {methodSymbol.ToDisplayString()}");
}Типы символов в Roslyn
| Тип символа | Представляет | Пример |
|---|---|---|
ITypeSymbol | Любой тип | int, string, пользовательские классы |
INamedTypeSymbol | Именованные типы (класс, структура, enum, интерфейс) | Person, List<T> |
IArrayTypeSymbol | Типы массивов | int[], string[,] |
IMethodSymbol | Методы, аксессоры свойств, операторы | ToString(), get_Name() |
IFieldSymbol | Поля и значения enum | _cache, Color.Red |
IPropertySymbol | Свойства | Name { get; set; } |
IParameterSymbol | Параметры методов | int count в Method(int count) |
IEventSymbol | События | event Action Changed |
INamespaceSymbol | Пространства имён | System.Collections.Generic |
Анализ потока данных и управления
var method = root.DescendantNodes().OfType<MethodDeclarationSyntax>().First();
var body = method.Body!;
// Поток данных: какие переменные читаются/записываются в операторе
DataFlowAnalysis dataFlow = semanticModel.AnalyzeDataFlow(body);
Console.WriteLine("Входные (захваченные) переменные:");
foreach (var symbol in dataFlow.DataFlowsIn)
Console.WriteLine($" {symbol}");
Console.WriteLine("Выходные (используемые снаружи) переменные:");
foreach (var symbol in dataFlow.DataFlowsOut)
Console.WriteLine($" {symbol}");
// Поток управления: ветвления, циклы, обработка исключений
ControlFlowAnalysis controlFlow = semanticModel.AnalyzeControlFlow(body);
Console.WriteLine($"Точки возврата: {controlFlow.ReturnPoints.Length}");
Console.WriteLine($"Точки выброса исключений: {controlFlow.ThrowPoints.Length}");Практика
- [ ] Изучить материал
Упражнение 1: Фреймворк валидации на основе атрибутов
public class ValidationResult
{
public string PropertyName { get; }
public string ErrorMessage { get; }
public ValidationResult(string propertyName, string errorMessage)
{
PropertyName = propertyName;
ErrorMessage = errorMessage;
}
}
public static class Validator
{
// Валидация объекта с использованием рефлексии + кэшированных делегатов
private static readonly ConditionalWeakTable<Type, ValidationPlan> _cache = new();
public class ValidationPlan
{
public List<PropertyValidation> Validations = new();
public class PropertyValidation
{
public string PropertyName;
public Func<object?, object?> Getter;
public List<ValidationAttribute> Attributes;
}
}
public static IEnumerable<ValidationResult> Validate<T>(T obj) where T : class
{
var plan = _cache.GetOrCreateValue(typeof(T));
if (plan.Validations.Count == 0)
{
// Построение плана валидации с использованием рефлексии + скомпилированных делегатов
Type type = typeof(T);
foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
var attrs = prop.GetCustomAttributes(typeof(ValidationAttribute), false)
.Cast<ValidationAttribute>()
.ToList();
if (attrs.Count == 0) continue;
// Компиляция делегата-геттера для производительности
var param = Expression.Parameter(typeof(T), "x");
var propertyAccess = Expression.Property(param, prop);
var lambda = Expression.Lambda<Func<T, object?>>(
Expression.Convert(propertyAccess, typeof(object)), param);
plan.Validations.Add(new ValidationPlan.PropertyValidation
{
PropertyName = prop.Name,
Getter = obj2 => ((Func<T, object?>)lambda)((T)(object)obj2),
Attributes = attrs
});
}
}
// Выполнение валидаций
foreach (var validation in plan.Validations)
{
object? value = validation.Getter(obj);
foreach (var attr in validation.Attributes)
{
if (!attr.IsValid(value))
yield return new ValidationResult(validation.PropertyName, attr.ErrorMessage);
}
}
}
}
// Использование:
var request = new CreateUserRequest
{
Username = "", // Не проходит [Required] и [StringLength MinimumLength=1]
Email = "invalid", // Не проходит [RegularExpression]
Age = 200 // Не проходит [Range(0, 150)]
};
var errors = Validator.Validate(request).ToList();
foreach (var error in errors)
Console.WriteLine($"{error.PropertyName}: {error.ErrorMessage}");
// Вывод:
// Username: Валидация не пройдена.
// Email: Валидация не пройдена.
// Age: Валидация не пройдена.Упражнение 2: Анализатор Roslyn для обнаружения антипаттернов
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ConsoleWriteLineInLoopAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "PERF001";
private const string Title = "Console.WriteLine в цикле";
private const string MessageFormat = "Console.WriteLine внутри цикла может вызвать проблемы с производительностью. Рассмотрите буферизацию вывода.";
private const string Category = "Performance";
private static readonly DiagnosticDescriptor Rule = new(
id: DiagnosticId,
title: Title,
messageFormat: MessageFormat,
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
// Анализ операторов выражений
context.RegisterSyntaxNodeAction(AnalyzeNode,
SyntaxKind.ExpressionStatement);
}
private static void AnalyzeNode(SyntaxNodeAnalysisContext context)
{
var statement = (ExpressionStatementSyntax)context.Node;
// Проверка, является ли это вызовом Console.WriteLine
if (statement.Expression is not InvocationExpressionSyntax invocation)
return;
// Получение символа вызываемого метода
var info = context.SemanticModel.GetSymbolInfo(invocation);
var methodSymbol = info.Symbol as IMethodSymbol;
if (methodSymbol?.ContainingType.ToDisplayString() != "System.Console")
return;
if (!methodSymbol.Name.StartsWith("Write"))
return;
// Проверка, находимся ли мы внутри цикла
var parent = statement.Parent;
while (parent != null)
{
if (parent is ForStatementSyntax ||
parent is WhileStatementSyntax ||
parent is DoStatementSyntax ||
parent is ForEachStatementSyntax)
{
// Создание диагностического сообщения
var diagnostic = Diagnostic.Create(
Rule, statement.GetLocation());
context.ReportDiagnostic(diagnostic);
return;
}
// Остановка на границе метода
if (parent is MethodDeclarationSyntax ||
parent is LocalFunctionStatementSyntax)
return;
parent = parent.Parent;
}
}
}
// Дополнительные анализаторы для распространённых антипаттернов:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class StringConcatInLoopAnalyzer : DiagnosticAnalyzer
{
// Обнаруживает: string result += item; внутри циклов
// Предлагает: используйте StringBuilder вместо этого
public const string DiagnosticId = "PERF002";
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(new DiagnosticDescriptor(
DiagnosticId, "Конкатенация строк в цикле",
"Используйте StringBuilder для конкатенации строк в циклах", "Performance",
DiagnosticSeverity.Warning, true));
public override void Initialize(AnalysisContext context)
{
context.RegisterSyntaxNodeAction(ctx =>
{
var stmt = (ExpressionStatementSyntax)ctx.Node;
if (stmt.Expression is not AssignmentExpressionSyntax assignment)
return;
if (assignment.Kind() != SyntaxKind.AddAssignmentExpression)
return;
// Проверка, является ли левая часть строковой переменной
var leftType = ctx.SemanticModel.GetTypeInfo(assignment.Left).Type;
if (leftType?.ToDisplayString() != "string")
return;
// Проверка, находимся ли внутри цикла
if (IsInsideLoop(stmt))
ctx.ReportDiagnostic(Diagnostic.Create(
SupportedDiagnostics[0], stmt.GetLocation()));
}, SyntaxKind.ExpressionStatement);
}
private static bool IsInsideLoop(SyntaxNode node)
{
var parent = node.Parent;
while (parent != null)
{
if (parent is ForStatementSyntax || parent is WhileStatementSyntax ||
parent is DoStatementSyntax || parent is ForEachStatementSyntax)
return true;
if (parent is MethodDeclarationSyntax) return false;
parent = parent.Parent;
}
return false;
}
}
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class UnusedParameterAnalyzer : DiagnosticAnalyzer
{
// Обнаруживает: параметры метода, которые никогда не используются в теле
public const string DiagnosticId = "STYLE001";
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(new DiagnosticDescriptor(
DiagnosticId, "Неиспользуемый параметр",
"Параметр '{0}' объявлен, но никогда не используется. Рассмотрите его удаление или добавьте префикс подчёркивания.",
"Style", DiagnosticSeverity.Info, true));
public override void Initialize(AnalysisContext context)
{
context.RegisterSyntaxNodeAction(ctx =>
{
var method = (MethodDeclarationSyntax)ctx.Node;
foreach (var param in method.ParameterList.Parameters)
{
if (param.Identifier.Text.StartsWith("_"))
continue; // Префикс подчёркивания указывает на намеренное неиспользование
// Проверка, используется ли параметр в теле
string paramName = param.Identifier.Text;
bool isUsed = method.DescendantTokens()
.SkipWhile(t => t != param.Identifier) // Пропуск до объявления
.Skip(1) // Пропуск самого объявления
.Any(t => t.IsKind(SyntaxKind.IdentifierToken) &&
t.Text == paramName);
if (!isUsed)
ctx.ReportDiagnostic(Diagnostic.Create(
SupportedDiagnostics[0], param.Identifier.GetLocation(), paramName));
}
}, SyntaxKind.MethodDeclaration);
}
}Сводная таблица
| Концепция | Ключевой вывод |
|---|---|
| Пользовательские атрибуты | Аннотации метаданных для использования во время выполнения и на этапе компиляции |
| AttributeUsage | Управляет, где атрибут может применяться (цели, многократность, наследование) |
| Рефлексия во время выполнения | Чтение атрибутов во время выполнения — гибко, но имеет стоимость производительности |
| Скомпилированные делегаты | Кэшируйте PropertyInfo или компилируйте в делегат для критических участков |
| Roslyn SyntaxTree | Представление AST структуры исходного кода |
| Roslyn SemanticModel | Информация о типах и разрешение символов |
| DiagnosticAnalyzer | Анализ кода на этапе компиляции (предупреждения, ошибки, предложения) |
| SourceGenerator | Генерация кода на этапе компиляции (нулевые накладные расходы во время выполнения) |
Когда использовать каждый подход
| Цель | Лучший инструмент |
|---|---|
| Валидация/маппинг во время выполнения | Атрибуты + рефлексия с кэшированием делегатов |
| Обеспечение качества кода | Roslyn DiagnosticAnalyzer |
| Устранение boilerplate-кода | Roslyn Source Generator |
| Расширения IDE/инструменты рефакторинга | Roslyn Workspace API |
| Генерация документации | Атрибуты + обработка на этапе компиляции |
| AOP/интерцепция | DynamicProxy (Emit) или Source Generators |
Внутренности BCL и среда выполнения — экспертный уровень
Содержание
- Ключевые типы в Reference Source / репозитории .NET GitHub
- Внутреннее устройство ObjectPool
- Lazy
— режимы потокобезопасности, стратегии инициализации - ConditionalWeakTable
— слабые ссылки без нагрузки на GC - AsyncLocal
vs [ThreadStatic] - Внутреннее устройство параллельных коллекций
- Практические упражнения
Понимание исходного кода BCL
Где найти исходный код .NET
| Ресурс | URL | Назначение |
|---|---|---|
| .NET Runtime (GitHub) | github.com/dotnet/runtime | Основные библиотеки, среда выполнения, JIT |
| Reference Source (наследие) | referencesource.microsoft.com | Исходный код .NET Framework 4.8 |
| .NET Fiddle | dotnetfiddle.net | Быстрые онлайн-эксперименты |
| SharpLab | sharplab.io | Просмотр IL/state machine, генерируемых компилятором |
Ключевые пути в репозитории dotnet/runtime
src/
├── libraries/
│ ├── System.Private.CoreLib/ # Основная BCL (System.*, System.Collections.*)
│ │ └── src/System/Collections/Generic/
│ │ ├── Dictionary.cs # Реализация Dictionary<TKey,TValue>
│ │ ├── List.cs # Реализация List<T>
│ │ ├── HashSet.cs # Реализация HashSet<T>
│ │ └── SortedSet.cs # SortedSet<T> (красно-чёрное дерево)
│ │
│ ├── System.Collections.Concurrent/ # Потокобезопасные коллекции
│ │ ├── ConcurrentDictionary.cs
│ │ ├── ConcurrentQueue.cs
│ │ └── ConcurrentStack.cs
│ │
│ └── System.Threading/ # Примитивы многопоточности
│ ├── Objects/ObjectPool.cs
│ └── LazyInitializer.cs
│
├── src/coreclr/ # Среда выполнения CLR (наследие, перемещается в native AOT)
└── src/corefx/ # Кроссплатформенные библиотеки классов (объединены в libraries/)Чтение исходного кода Dictionary — ключевые выводы
Из src/libraries/System.Private.CoreLib/src/System/Collections/Generic/Dictionary.cs:
Ключевые внутренние структуры:
// Структура Entry — хранит одну пару ключ-значение со ссылкой на цепочку коллизий
private struct Entry
{
public int hashCode; // Младшие 31 бит хеш-кода, -1 если не используется
public int next; // Индекс следующей записи в цепочке (-1 = последняя)
public TKey key;
public TValue value;
}
// Параллельные массивы для хеш-таблицы
private int[]? _buckets; // buckets[hash % length] → индекс первой записи
private Entry[]? _entries; // Все записи (включая логически удалённые)
private int _count; // Общее количество активных записей
private int _freeList; // Голова списка свободных записей (-1 = нет)
private int _freeCount; // Количество свободных записейКлючевая оптимизация: свободный список для удалённых записей
Когда запись удаляется, она не удаляется из массива физически — вместо этого она добавляется в свободный список. Новые вставки повторно используют эти слоты перед расширением:
private void Insert(TKey key, TValue value, bool add)
{
// ... вычисление хеша ...
int index;
if (_freeCount > 0)
{
// Повторное использование освобождённого слота — избегает расширения массива!
index = _freeList;
_freeList = _entries[index].next; // Следующий свободный слот
_entries[index].next = -1;
_freeCount--;
}
else if (_count == _entries.Length)
{
// Нет свободных слотов — нужно изменить размер
Resize();
// Пересчёт корзины после изменения размера
targetBucket = hashCode % _buckets!.Length;
index = _count;
}
else
{
// Обычный случай: добавление в конец
index = _count;
}
_count++;
_entries[index].hashCode = hashCode;
_entries[index].next = _buckets![targetBucket]; // Добавление в начало цепочки
_entries[index].key = key;
_entries[index].value = value;
_buckets[targetBucket] = index;
_version++;
}Почему это важно: Этот паттерн свободного списка означает, что удаление и повторное добавление тех же ключей не вызывает повторных изменений размера — распространённый сценарий в реализациях кэша.
HashHelpers — выбор простых чисел для массивов корзин
Dictionary использует простые числа для размеров массивов корзин, чтобы минимизировать хеш-коллизии:
internal static class HashHelpers
{
// Первые 39 простых чисел от 1 до 16777619
internal static readonly int[] Primes =
{
3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197,
239, 293, 353, 431, 521, 631, 761, 919, 1103, 1327, 1597, 1931,
2333, 2801, 3371, 4051, 4861, 5839, 7009, 8419, 10103, 12143,
14591, 17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851,
75431, 90523, 108631, 130363, 156437, 187751, 225307, 270371,
324449, 389357, 467237, 560689, 672827, 807403, 968897,
1162687, 1395263, 1674319, 2009191, 2411033, 2893249, 3471899,
4166287, 4999559, 5999471, 7199369
};
// Расширение до следующего простого числа, которое может вместить как минимум 'oldSize' записей
internal static int ExpandPrime(int oldValue)
{
if (oldValue > Primes[Primes.Length - 1])
return oldValue; // Уже за пределами нашей таблицы — использовать как есть
for (int i = 0; i < Primes.Length; i++)
{
int prime = Primes[i];
if (prime >= oldValue) return prime;
}
return oldValue;
}
// Порог коэффициента загрузки: изменение размера, когда счётчик достигает ~85% ёмкости
internal const int HashCollisionThreshold = 100;
}Внутреннее устройство ObjectPool
Базовое использование
using System.Threading;
// Создание пула с функцией-фабрикой
var pool = new ObjectPool<MyResource>(
factory: () => new MyResource(), // Создание нового, когда пул пуст
maxRetained: 100); // Максимальное количество объектов в пуле
// Аренда объекта из пула
MyResource resource = pool.Rent();
try
{
// Использование ресурса...
resource.DoWork();
}
finally
{
// Возврат в пул (опционально сброс состояния)
pool.Return(resource, shouldReset: true);
}
// Политика пула: управление созданием и удержанием
var policyPool = new ObjectPool<MyResource>(new DefaultPooledObjectPolicy<MyResource>());Внутренняя структура
public class ObjectPool<T> where T : class
{
// Потокобезопасный стек для пула объектов
private readonly ConcurrentStack<T?> _pool;
// Функция-фабрика для создания новых экземпляров
private readonly Func<T> _factory;
// Максимальное количество объектов для удержания в пуле
private readonly int? _maxRetained;
public ObjectPool(Func<T> factory, int? maxRetained = null)
{
_factory = factory;
_maxRetained = maxRetained;
_pool = new ConcurrentStack<T?>();
}
public T Rent()
{
// Попытка получения из пула (pop без блокировки)
if (_pool.TryPop(out var resource))
return resource!;
// Пул пуст — создание нового экземпляра
return _factory();
}
public void Return(T? obj, bool shouldReset = true)
{
if (obj == null) return;
// Проверка ограничения ёмкости
if (_maxRetained.HasValue && _pool.Count >= _maxRetained.Value)
return; // Отбрасывание — пул заполнен
// Опциональный сброс состояния объекта перед возвратом
if (shouldReset && obj is IPooledObject pooled)
pooled.Reset();
_pool.Push(obj); // Push без блокировки
}
}
// Паттерн политики фабрики:
public interface IObjectPolicy<T>
{
T Create();
bool Return(T obj); // Возвращает false, если объект должен быть отброшен
}
public class DefaultPooledObjectPolicy<T> : IObjectPolicy<T> where T : new()
{
public T Create() => new T();
public bool Return(T obj) => true; // Всегда принимать возврат
}Когда использовать ObjectPool
| Сценарий | Рекомендация |
|---|---|
| Дорогостоящее создание объектов (подключения к БД, сетевые сокеты) | ✅ Отлично подходит |
| Частое выделение/освобождение в критических участках | ✅ Снижает нагрузку на GC |
| Объекты со сбрасываемым состоянием | ✅ Возврат после Reset() |
| Простые значимые типы | ❌ Используйте stackalloc или ArrayPool вместо этого |
| Объекты, которые нельзя безопасно переиспользовать | ❌ Пулинг вызывает ошибки |
Lazy — режимы потокобезопасности и стратегии инициализации
Базовое использование
// По умолчанию: только публикация (не полностью потокобезопасно для значения)
var lazy1 = new Lazy<MyExpensiveObject>(() => new MyExpensiveObject());
// Потокобезопасно: только один поток инициализирует, остальные ждут
var lazy2 = new Lazy<MyExpensiveObject>(
() => new MyExpensiveObject(),
LazyThreadSafetyMode.ExecutionAndPublication);
// Потокобезопасно с повторной попыткой при исключении
var lazy3 = new Lazy<MyExpensiveObject>(
() => new MyExpensiveObject(),
LazyThreadSafetyMode.PublicationOnly);
// Доступ — инициализация происходит только при первом доступе
MyExpensiveObject obj = lazy1.Value; // Запускает выполнение фабрики
MyExpensiveObject obj2 = lazy1.Value; // Возвращает кэшированный экземпляр (без повторного выполнения)
// Проверка, инициализировано ли
bool isReady = lazy1.IsValueCreated;Варианты LazyThreadSafetyMode
| Режим | Потокобезопасность | Производительность | Поведение при исключении |
|---|---|---|---|
None | Не потокобезопасно | Быстрее всего | Н/Д (только однопоточный) |
PublicationOnly | Первый побеждает | Быстро | Проигравшие потоки получают исключение или значение победителя |
ExecutionAndPublication | Подобно мьютексу | Медленнее (блокировка) | Повторная попытка при исключении |
Детали внутренней реализации
// Упрощённые внутренности Lazy<T>:
public class Lazy<T> where T : class
{
private T? _value; // Кэшированное значение
private readonly Func<T> _valueFactory; // Делегат фабрики
private readonly object _lock = new(); // Для режима ExecutionAndPublication
private volatile bool _isValueCreated; // Флаг двойной проверки блокировки
public T Value
{
get
{
if (_isValueCreated)
return _value!; // Быстрый путь: значение уже создано
return LazyInitializer.EnsureInitialized(ref _value, _lock, _valueFactory);
}
}
public bool IsValueCreated => _isValueCreated;
}
// LazyInitializer — API более низкого уровня для большего контроля:
public static class LazyInitializer
{
// Гарантирует, что поле инициализировано ровно один раз (потокобезопасно)
public static T EnsureInitialized<T>(ref T target, Func<T> valueFactory)
where T : class
// С явным объектом блокировки (избегает выделения одного на каждый Lazy<T>)
public static T EnsureInitialized<T>(ref T target, object syncLock, Func<T> valueFactory)
// Для значимых типов (использует боксинг внутренне)
public static TValue EnsureInitialized<TTarget, TValue>(ref TTarget target,
Func<TValue> valueFactory) where TValue : struct
}
// Использование LazyInitializer напрямую:
public class MyClass
{
private ExpensiveResource? _resource;
private readonly object _resourceLock = new();
public ExpensiveResource Resource
{
get => LazyInitializer.EnsureInitialized(
ref _resource,
_resourceLock,
() => new ExpensiveResource());
}
}Когда использовать Lazy
| Сценарий | Рекомендация |
|---|---|
| Дорогостоящий объект, создаваемый редко или никогда | ✅ Идеально подходит |
| DI-контейнер с ленивыми зависимостями | ✅ Стандартный паттерн |
| Потокобезопасная инициализация синглтона | ✅ Используйте ExecutionAndPublication |
| Простое кэширование вычисленного значения | ✅ Или используйте паттерн Memoize |
| Значение всегда нужно немедленно | ❌ Просто создайте его напрямую |
ConditionalWeakTable — слабые ссылки без нагрузки на GC
Проблема, которую она решает
// ПРОБЛЕМА: обычный Dictionary предотвращает сборку ключей GC!
var cache = new Dictionary<object, ComputedValue>();
object key = new object();
cache[key] = ComputeExpensiveValue(key);
key = null; // Ключ всё ещё ссылается словарём — НИКОГДА не будет собран!
// Даже после удаления всех внешних ссылок, словарь хранит сильную ссылку
// РЕШЕНИЕ: ConditionalWeakTable позволяет ключам быть собранными GC, когда нет других ссылок
var weakCache = new ConditionalWeakTable<object, ComputedValue>();Как это работает
ConditionalWeakTable хранит слабые ссылки на ключи и сильные ссылки на значения. Когда ключ собирается сборщиком мусора, связанное с ним значение также удаляется:
using System.Runtime.CompilerServices;
var table = new ConditionalWeakTable<MyObject, ComputedData>();
// Добавление записи — ключ удерживается слабо, значение удерживается сильно (пока ключ жив)
MyObject obj = new MyObject();
table.Add(obj, new ComputedData());
// Или использование GetValueOrCreate (паттерн ленивой инициализации):
ComputedData data = table.GetValueOrCreate(obj,
factory: key => ComputeExpensiveValue(key));
// Когда последняя сильная ссылка на 'obj' исчезает:
obj = null;
GC.Collect(); // Принудительная сборка для демонстрации
// Теперь и obj, И его ComputedData собраны!
// Запись автоматически удаляется из таблицы.Внутренняя структура (упрощённо)
public class ConditionalWeakTable<TKey, TValue> where TKey : class
{
// Каждая запись имеет слабую ссылку на ключ
private class Entry
{
internal WeakReference<TKey> Key; // Слабая ссылка — позволяет GC
internal TValue Value; // Сильная ссылка — живёт, пока жив ключ
internal int Generation; // Для синхронизации финализатора
}
// Финализатор удаляет записи, ключи которых были собраны
~ConditionalWeakTable()
{
// Очистка оставшихся записей во время финализации
}
}Распространённые варианты использования
| Сценарий | Паттерн |
|---|---|
| Данные расширения для объектов, которыми вы не владеете | ConditionalWeakTable<ExternalType, MyExtensionData> |
| Кэширование на экземпляр с автоматической очисткой | GetValueOrCreate(key, factory) |
| Интерцепторы AOP, хранящие состояние на проксированных объектах | Маппинг прокси → состояние интерцептора |
| Отслеживание обработчиков событий без утечек памяти | Альтернатива паттерну слабых событий |
Практический пример: паттерн данных расширения
public static class ObjectExtensions
{
// Хранение произвольных данных на любом объекте без изменения его типа
private static readonly ConditionalWeakTable<object, Dictionary<string, object>>
_extensionData = new();
public static void SetExtensionData<T>(this T obj, string key, object value) where T : class
{
var dict = _extensionData.GetValueOrCreate(obj,
_ => new Dictionary<string, object>());
dict[key] = value;
}
public static object? GetExtensionData<T>(this T obj, string key) where T : class
{
if (_extensionData.TryGetValue(obj, out var dict))
return dict.GetValueOrDefault(key);
return null;
}
}
// Использование:
var externalObject = GetSomeExternalObject(); // Тип, который мы не можем изменить
externalObject.SetExtensionData("computedValue", ExpensiveComputation());
var cached = externalObject.GetExtensionData("computedValue"); // Быстрый поиск
// Когда externalObject собирается GC, все данные расширения автоматически очищаются!AsyncLocal vs [ThreadStatic]
ThreadStatic — традиционное локальное хранилище потока
public class MyClass
{
[ThreadStatic]
private static int _threadLocalValue;
public static void Demo()
{
_threadLocalValue = Environment.CurrentManagedThreadId;
Console.WriteLine($"Поток {Environment.CurrentManagedThreadId}: {_threadLocalValue}");
}
}
// Ограничения:
// 1. Каждый поток получает свою копию (хорошо)
// 2. Значение НЕ передаётся через границы async/await (проблема!)
// 3. Потоки ThreadPool могут переиспользоваться — старые значения от предыдущего использованияAsyncLocal — хранилище с учётом ExecutionContext
public static class RequestContext
{
private static readonly AsyncLocal<Dictionary<string, object>> _local = new();
private static Dictionary<string, object> Current
{
get => _local.Value ??= new Dictionary<string, object>();
set => _local.Value = value;
}
public static void Set(string key, object value) => Current[key] = value;
public static T? Get<T>(string key) =>
Current.TryGetValue(key, out var val) ? (T?)val : default;
}
// Использование в middleware ASP.NET Core:
public class RequestContextMiddleware
{
private readonly RequestDelegate _next;
public async Task Invoke(HttpContext context)
{
// Установка значений, которые передаются через всю цепочку async-вызовов
RequestContext.Set("RequestId", context.TraceIdentifier);
RequestContext.Set("UserId", context.User.FindFirst("id")?.Value);
await _next(context); // Значения доступны во всех async-продолжениях!
}
}
// В любом downstream-сервисе (даже после множества await):
public class MyService
{
public async Task DoWork()
{
await SomeAsyncOperation();
string requestId = RequestContext.Get<string>("RequestId"); // Всё ещё доступно!
await AnotherAsyncOperation();
string userId = RequestContext.Get<string>("UserId"); // Всё ещё корректно передаётся!
}
}Ключевые отличия
| Аспект | [ThreadStatic] | AsyncLocal |
|---|---|---|
| Область хранения | На поток ОС | На контекст выполнения (передаётся через async) |
| Работает с async/await | ❌ Значение теряется после await | ✅ Значение передаётся через продолжения |
| Безопасность ThreadPool | ⚠️ Старые значения от переиспользованных потоков | ✅ Чистый на async-контекст |
| Производительность | Быстрее (прямой доступ к TLS) | Небольшие накладные расходы (копирование контекста) |
| Уведомление об изменении значения | Нет | Да (обратный вызов ValueChanged) |
| Лучше всего подходит для | Наследие синхронного кода, нативное взаимодействие | Современные async-приложения |
Обратный вызов AsyncLocal ValueChanged
private static readonly AsyncLocal<string> _operationName = new();
// Отслеживание изменений значения (полезно для логирования/диагностики)
_operationName.ValueChanged += (sender, args) =>
{
Console.WriteLine($"Операция изменилась с '{args.OldValue}' на '{args.NewValue}'");
};
// Использование с паттерном области видимости:
public async Task ScopedOperation(string name)
{
var previous = _operationName.Value;
_operationName.Value = name; // Вызывает обратный вызов ValueChanged
try
{
await DoWork(); // Все async-продолжения видят новое значение
}
finally
{
_operationName.Value = previous; // Восстановление предыдущего значения (вызывает обратный вызов)
}
}Когда что использовать
| Сценарий | Рекомендация |
|---|---|
| Современное async-приложение | AsyncLocal |
| Контекст запроса ASP.NET Core | AsyncLocal |
| Наследие синхронного кода | [ThreadStatic] приемлемо |
| Нативное взаимодействие, требующее TLS | [ThreadStatic] или ThreadLocalStorageAttribute |
| ID корреляции распределённой трассировки | AsyncLocal |
Внутреннее устройство параллельных коллекций
ConcurrentDictionary — сегментная блокировка
// Упрощённые внутренности:
public class ConcurrentDictionary<TKey, TValue>
{
// Несколько сегментов, каждый со своей блокировкой
private Tables _tables;
private struct Tables
{
internal readonly IEqualityComparer<TKey> comparisons;
internal readonly int locksCount; // Количество полос блокировки (обычно 16)
internal readonly object[] locks; // Объекты блокировок на полосу
internal volatile Node[]? buckets; // Корзины хеш-таблицы
internal volatile int[]? countPerLock; // Счётчик на полосу (для быстрого Count)
}
private struct Node
{
internal readonly TKey key;
internal readonly TValue value;
internal int next; // Ссылка цепочки коллизий
internal int hashCode; // Хеш-код для быстрого сравнения
}
// Определение, какую полосу блокировки использовать на основе хеш-кода
private int GetLockIndexFromHashCode(int hashCode) =>
(uint)hashCode % (uint)_tables.locksCount;
}Ключевой вывод: Сегментная блокировка позволяет ~16 параллельных записей (одна на полосу), значительно увеличивая пропускную способность записи по сравнению с одной глобальной блокировкой.
ConcurrentQueue — очередь Майкла-Скотта без блокировок
Использует операции CompareExchange (CAS) для enqueue/dequeue без блокировок:
// Упрощённые внутренности очереди Майкла-Скотта:
public class ConcurrentQueue<T>
{
private class Node
{
internal T item;
internal volatile Node? next; // Volatile для упорядочивания памяти
}
private volatile Node _head = new Node(); // Фиктивный головной узел
private volatile Node _tail = _head; // Указатель хвоста
public void Enqueue(T item)
{
var newNode = new Node { item = item, next = null };
// Без блокировки: атомарная привязка нового узла к хвосту
Node oldTail;
do
{
oldTail = _tail;
} while (Interlocked.CompareExchange(
ref oldTail.next, newNode, null) != null); // Цикл CAS
// Попытка продвижения указателя хвоста (лучшие усилия, не критично для корректности)
Interlocked.CompareExchange(ref _tail, newNode, oldTail);
}
public bool TryDequeue([MaybeNullWhen(false)] out T? result)
{
Node oldHead;
do
{
oldHead = _head;
Node? next = oldHead.next;
if (_head != oldHead) continue; // Голова изменилась, повтор
if (next == null)
{
// Очередь пуста
result = default;
return false;
}
result = next.item;
} while (Interlocked.CompareExchange(ref _head, next, oldHead) != oldHead);
return true;
}
}Практика
- [ ] Изучить материал
Упражнение 1: Пользовательский пул объектов с паттерном аренды ресурсов
public interface IPooledResource : IDisposable
{
void Reset(); // Сброс состояния для повторного использования
}
public class PooledObjectPool<T> where T : class, IPooledResource, new()
{
private readonly ConcurrentStack<T> _pool = new();
private readonly int _maxSize;
private int _totalCreated;
public PooledObjectPool(int maxSize = 100)
{
_maxSize = maxSize;
}
public Lease Rent()
{
T? resource;
if (_pool.TryPop(out resource))
{
resource.Reset(); // Сброс состояния перед повторным использованием
}
else
{
// Создание нового, если в пределах лимита
if (Interlocked.Increment(ref _totalCreated) > _maxSize)
throw new InvalidOperationException("Пул исчерпан");
resource = new T();
}
return new Lease(this, resource);
}
private void Return(T resource)
{
if (_pool.Count < _maxSize)
_pool.Push(resource);
// Иначе: отбрасывание (пусть GC собирает)
}
// Паттерн Lease гарантирует, что ресурс всегда возвращается
public readonly struct Lease : IDisposable
{
private readonly PooledObjectPool<T> _pool;
private T? _resource;
internal Lease(PooledObjectPool<T> pool, T resource)
{
_pool = pool;
_resource = resource;
}
public T Resource => _resource!;
public void Dispose()
{
if (_resource != null)
{
_resource.Dispose();
_pool.Return(_resource);
_resource = null;
}
}
}
}
// Использование:
var pool = new PooledObjectPool<DbConnectionWrapper>(maxSize: 50);
using var lease = pool.Rent();
lease.Resource.ExecuteQuery("SELECT ...");
// Автоматически возвращается в пул, когда lease выходит из области видимости!Упражнение 2: Диагностический инструмент для паттернов выделения памяти
public static class AllocationDiagnostics
{
// Измерение выделений памяти в блоке кода (современный точный метод)
public static long MeasureAllocatedBytes(Action action)
{
long before = GC.GetAllocatedBytesForCurrentThread();
action();
long after = GC.GetAllocatedBytesForCurrentThread();
return after - before;
}
// Альтернатива со сборкой мусора (для изоляции от других процессов)
public static long MeasureAllocatedBytesAccurate(Action action)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
long before = GC.GetAllocatedBytesForCurrentThread();
action();
long after = GC.GetAllocatedBytesForCurrentThread();
return Math.Max(0, after - before);
}
// Сравнение паттернов выделения между двумя реализациями
public static void CompareAllocations(
string name1, Action impl1,
string name2, Action impl2,
int iterations = 100)
{
long total1 = 0, total2 = 0;
for (int i = 0; i < iterations; i++)
{
total1 += MeasureAllocatedBytes(impl1);
total2 += MeasureAllocatedBytes(impl2);
}
Console.WriteLine($"{name1}: {total1 / iterations:N0} байт/итерация (среднее)");
Console.WriteLine($"{name2}: {total2 / iterations:N0} байт/итерация (среднее)");
Console.WriteLine($"Соотношение: {(double)total1 / Math.Max(1, total2):F2}x");
}
// Статистика поколений GC
public static void PrintGcStats()
{
Console.WriteLine($"Сборки Gen 0: {GC.CollectionCount(0)}");
Console.WriteLine($"Сборки Gen 1: {GC.CollectionCount(1)}");
Console.WriteLine($"Сборки Gen 2: {GC.CollectionCount(2)}");
Console.WriteLine($"Общая память: {GC.GetTotalMemory(false):N0} байт");
Console.WriteLine($"Server GC: {GC.IsServerGC}");
Console.WriteLine($"Максимальное поколение: {GC.MaxGeneration}");
}
}
// Использование:
AllocationDiagnostics.CompareAllocations(
"List<T>",
() => { var list = new List<int>(); for (int i = 0; i < 1000; i++) list.Add(i); },
"ArrayPool",
() => {
var arr = ArrayPool<int>.Shared.Rent(1000);
for (int i = 0; i < 1000; i++) arr[i] = i;
ArrayPool<int>.Shared.Return(arr);
});Контрольная точка модуля — высокопроизводительная библиотека коллекций
Сводка требований проекта
Создать высокопроизводительную библиотеку коллекций с:
- API без выделения памяти через Span
Span<T> / ReadOnlySpan<T>
- Внутренние буферы используют stackalloc или ArrayPool<T>
- Без промежуточных выделений памяти во время операций
- Пользовательские компараторы и провайдеры хеш-кодов - Поддержка пользовательских
IEqualityComparer<T>
- Специализированные компараторы для распространённых типов (регистронезависимые строки и т.д.)
- Настраиваемая хеш-функция для устойчивости к коллизиям
- Полный LINQ-совместимый интерфейс - Реализация
IEnumerable<T> где возможно
- Методы расширения для операций на основе Span
- Паттерны отложенного выполнения с yield return
- Сравнительный бенчмарк с эквивалентами BCL - Использование BenchmarkDotNet для точных измерений - Сравнение с List
Критерии оценки
- Все бенчмарки показывают конкурентоспособную производительность по сравнению с BCL
- Покрытие кода > 90%
- XML-документация для всего публичного API