C# / .NET Основы C#: типы, nullable, записи, pattern matching, LINQ и память
0%

Основы C#: типы, nullable, записи, pattern matching, LINQ и память

Основы C#: типы, значения, ссылки и память

Эта статья — фундамент. Мы разберём систему типов не как список ключевых слов, а через главный вопрос: «где живут данные и как они копируются». Понимание этого объясняет 90% поведения C#, включая производительность и коварные баги.

Единая система типов

В C# всё — тип, и все типы наследуются от object (System.Object). Но делятся они на две принципиально разные категории:

  • Value types (типы-значения)int, double, bool, char, все struct, enum. Хранят данные непосредственно. При присваивании и передаче в метод — копируются целиком.
  • Reference types (ссылочные типы)class, interface, record (по умолчанию), массивы, string, делегаты. Переменная хранит ссылку (адрес) на объект. При присваивании копируется ссылка, а не объект.
struct PointS { public int X; public int Y; }
class PointC { public int X; public int Y; }

var a = new PointS { X = 1 };
var b = a;          // КОПИЯ значения
b.X = 99;
// a.X всё ещё 1 — b это независимая копия

var c = new PointC { X = 1 };
var d = c;          // КОПИЯ ССЫЛКИ на тот же объект
d.X = 99;
// c.X теперь 99 — c и d указывают на один объект

Это не педантизм: путаница здесь порождает баги вида «я изменил копию, а оригинал не поменялся» и наоборот. Практическое правило: по умолчанию используйте class/record. struct берите для маленьких (обычно ≤16 байт), неизменяемых значений, когда важна производительность и хочется избежать аллокаций в куче.

Стек, куча и boxing

Грубое, но полезное приближение: value-типы, живущие как локальные переменные, лежат на стеке; объекты классов — в управляемой куче (heap). Стек дёшев (просто сдвиг указателя), куча требует работы сборщика мусора.

Опасная операция — boxing: когда value-тип «заворачивается» в объект на куче (например, при присваивании object o = 42;). Это скрытая аллокация. В горячем коде boxing — частая причина мусора и просадок. Дженерики (List<int>) как раз позволяют работать с value-типами без boxing.

Nullable reference types

Исторически NullReferenceException — самая частая ошибка в .NET. С C# 8 появились nullable reference types: при <Nullable>enable</Nullable> компилятор различает string (не может быть null) и string? (может) и предупреждает о небезопасных разыменованиях.

#nullable enable

string name = null;        // ⚠️ предупреждение: присваивание null non-nullable
string? maybe = null;      // ок, тип явно nullable

void Print(string? s)
{
    // Console.WriteLine(s.Length); // ⚠️ возможен NRE
    if (s is not null)
        Console.WriteLine(s.Length);  // здесь компилятор знает: s не null

    Console.WriteLine(s?.Length ?? 0); // безопасный вызов + значение по умолчанию
}

Полезные операторы:

  • ?. — null-условный доступ: obj?.Prop вернёт null, если obj null.
  • ?? — null-coalescing: x ?? fallback.
  • ??= — присвоить, если null: list ??= new();.
  • ! — null-forgiving: value! говорит компилятору «я знаю, что здесь не null». Используйте осторожно — это отключение проверки, а не гарантия.

Включайте nullable во всех новых проектах. Это буквально сдвиг целого класса ошибок из рантайма в компайл-тайм. Подробно: learn.microsoft.com/dotnet/csharp/nullable-references.

Records: неизменяемые данные с value-семантикой

record — это класс (или record struct), заточенный под представление данных. Компилятор автоматически генерирует конструктор, свойства, Equals/GetHashCode по значению, ToString и метод with для неразрушающего копирования.

// Позиционный record: краткий синтаксис
public record Money(decimal Amount, string Currency);

var price = new Money(9.99m, "USD");
var discounted = price with { Amount = 4.99m }; // копия с изменённым полем

// Равенство по ЗНАЧЕНИЮ, а не по ссылке:
var a = new Money(10m, "EUR");
var b = new Money(10m, "EUR");
Console.WriteLine(a == b);          // True (у class было бы False)
Console.WriteLine(a);               // Money { Amount = 10, Currency = EUR }

Records идеальны для DTO, доменных value-objects, сообщений, событий — везде, где объект определяется своими данными, а не идентичностью. Для сущностей с изменяемым состоянием и идентичностью (например, EF Core entity) чаще берут обычный class.

Pattern matching и switch-выражения

Pattern matching — одна из самых выразительных фич современного C#. Он позволяет проверять форму и содержимое данных декларативно.

abstract record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double Width, double Height) : Shape;
record Triangle(double Base, double Height) : Shape;

static double Area(Shape shape) => shape switch
{
    Circle { Radius: var r }            => Math.PI * r * r,
    Rectangle { Width: var w, Height: var h } => w * h,
    Triangle(var b, var h)              => 0.5 * b * h,   // деконструкция
    null                                => throw new ArgumentNullException(nameof(shape)),
    _                                   => throw new ArgumentException("Неизвестная фигура")
};

Виды паттернов: типовой (is Circle c), свойственный ({ Radius: > 0 }), реляционный (> 100), логический (and/or/not), списковый ([first, .. rest]). Компилятор проверяет исчерпываемость и предупредит, если вы забыли случай. Это делает код безопаснее любого каскада if.

// Реляционные и логические паттерны
static string Classify(int age) => age switch
{
    < 0            => "некорректный",
    0 or 1         => "младенец",
    >= 2 and < 13  => "ребёнок",
    >= 13 and < 20 => "подросток",
    _              => "взрослый"
};

Коллекции

BCL предоставляет богатый набор коллекций. Основные:

  • List<T> — динамический массив, рабочая лошадка.
  • Dictionary<TKey, TValue> — хеш-таблица, O(1) доступ по ключу.
  • HashSet<T> — множество уникальных значений.
  • Queue<T> / Stack<T> — FIFO / LIFO.
  • T[] — массив фиксированного размера.
  • Неизменяемые: ImmutableArray<T>, ImmutableList<T> из System.Collections.Immutable.
  • Потокобезопасные: ConcurrentDictionary<TKey,TValue>, ConcurrentQueue<T> (о них — в статье про конкурентность).

Начиная с C# 12 есть единый синтаксис collection expressions:

int[] numbers = [1, 2, 3, 4, 5];
List<string> names = ["Ann", "Bob"];
int[] combined = [.. numbers, 6, 7];      // spread-оператор

LINQ — язык запросов к данным

LINQ (Language Integrated Query) — набор методов-расширений над IEnumerable<T>, позволяющий декларативно фильтровать, проецировать, группировать и агрегировать данные. Это функциональный слой C#.

using System.Linq;

record Order(int Id, string Customer, decimal Total, DateOnly Date);

var orders = new List<Order>
{
    new(1, "Ann", 120m, new(2026, 1, 5)),
    new(2, "Bob", 80m,  new(2026, 1, 6)),
    new(3, "Ann", 200m, new(2026, 2, 1)),
};

// Сумма заказов по клиентам, отсортировано по убыванию
var report = orders
    .Where(o => o.Total > 50)
    .GroupBy(o => o.Customer)
    .Select(g => new { Customer = g.Key, Sum = g.Sum(o => o.Total) })
    .OrderByDescending(x => x.Sum)
    .ToList();

foreach (var row in report)
    Console.WriteLine($"{row.Customer}: {row.Sum}");

Ключевое свойство LINQ — отложенное выполнение (deferred execution): запрос не исполняется, пока вы не начнёте его перечислять (foreach, ToList(), Count()). Это мощно, но порождает ловушки: повторное перечисление = повторный расчёт; изменение источника после определения запроса меняет результат. Материализуйте через ToList(), когда нужна фиксированная копия.

Тот же LINQ поверх IQueryable<T> в EF Core транслируется в SQL — об этом в статье про архитектуру.

Строки

string в C# — неизменяемый ссылочный тип в UTF-16. Каждая «модификация» создаёт новую строку. Для интенсивной сборки строк используйте StringBuilder. Полезное:

var name = "мир";
var greeting = $"Привет, {name}!";           // интерполяция
var multiline = """
    Многострочный
    "raw" литерал  кавычки внутри не экранируются
    """;                                      // raw string literal (C# 11)
ReadOnlySpan<char> span = greeting.AsSpan();  // без аллокаций для парсинга

Span<T>/ReadOnlySpan<T> — представление участка памяти без копирования; ключевой инструмент высокопроизводительного кода (парсинг, слайсинг) без лишних аллокаций.

Модель памяти и сборка мусора

.NET использует трассирующий сборщик мусора (GC) с поколениями:

  • Gen 0 — короткоживущие объекты (только что созданные). Собираются часто и дёшево.
  • Gen 1 — «буфер» между молодыми и старыми.
  • Gen 2 — долгоживущие объекты. Собираются редко и дорого.
  • LOH (Large Object Heap) — объекты > 85 000 байт.

Практические следствия для продакшена:

  • Меньше аллокаций — меньше работы GC. Избегайте лишнего boxing, ненужных промежуточных коллекций в LINQ на горячем пути, конкатенации строк в циклах.
  • Пулинг. ArrayPool<T>, ObjectPool<T> переиспользуют буферы вместо аллокаций.
  • struct и Span<T> уменьшают давление на кучу.
  • IDisposable — для неуправляемых ресурсов (файлы, сокеты, соединения). GC не освобождает их автоматически вовремя; используйте using (см. следующую статью).
  • Не зовите GC.Collect() в проде без крайней нужды — вы почти всегда сделаете хуже, чем адаптивный сборщик.

Есть два режима GC: Workstation (по умолчанию для десктопа) и Server (для серверов, много потоков сборки, выше throughput) — включается <ServerGarbageCollection>true</ServerGarbageCollection>.

Глубоко о памяти пишет Konrad Kokosa (книга «Pro .NET Memory Management») и блог команды: devblogs.microsoft.com/dotnet/tag/garbage-collector.

Что дальше

Идиоматика и обработка ошибок — как писать код «по-шарповому»: исключения против result-паттернов, IDisposable, паттерны и антипаттерны, стиль.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов