Основы 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, еслиobjnull.??— 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, паттерны и
антипаттерны, стиль.