Static в C#: что это значит и как работают статические поля, методы и классы

Static в C#: что это значит и как работают статические поля, методы и классы Полезное

Static в C# — это модификатор, который привязывает член к самому типу, а не к его объектам. Статическое поле существует в одном экземпляре на весь тип, статический метод вызывается через имя типа (Math.Max(1, 2)) и не получает this. Обычные члены, наоборот, принадлежат конкретному объекту, созданному через new.

Одним словом static помечают пять разных вещей, и их стоит сразу развести:

  • статическое поле или свойство — общее значение для всего типа;
  • статический метод — операция, которой не нужен объект;
  • статический класс — контейнер только из статических членов, его объекты создать нельзя;
  • статический конструктор — код, который среда выполняет один раз перед первым использованием типа;
  • статическая локальная функция — локальная функция, которой запрещено захватывать переменные окружающего метода.

Код ниже проверен 30.09.2026 на .NET SDK 10.0.401 (C# 14), сообщения компилятора приведены на английском, как их выдает SDK.

Статические поля и методы: минимальный пример

Файл запускается целиком командой dotnet run ex1.cs (запуск одиночного файла без проекта появился в .NET 10; в более ранних версиях тот же код кладут в Program.cs консольного проекта).

using System;

Console.WriteLine(Order.Created);          // 0 - объектов еще нет
var a = new Order("книга");
var b = new Order("лампа");
Console.WriteLine($"{a.Id} {b.Id}");       // 1 2
Console.WriteLine(Order.Created);          // 2
Console.WriteLine(Order.FormatId(b.Id));   // ORD-0002

class Order
{
    public static int Created;   // статическое поле: одно на весь тип
    public int Id;               // поле экземпляра: свое у каждого объекта
    public string Title;

    public Order(string title)
    {
        Created++;
        Id = Created;
        Title = title;
    }

    // статический метод: вызывается через имя типа, объекта не требует
    public static string FormatId(int id) => $"ORD-{id:D4}";
}

В консоли появятся четыре строки: 0, 1 2, 2, ORD-0002.

Что здесь происходит. Поле Created доступно до создания первого объекта и читается через имя класса: Order.Created. Оба объекта увеличивают одну и ту же переменную, поэтому после двух new в ней 2. Поле Id у каждого объекта свое.

Метод FormatId не обращается к полям объекта, ему хватает параметра, поэтому он статический. Конструктор экземпляра при этом свободно читает и меняет статическое поле: из обычных членов статические видны, в обратную сторону — нет.

Что именно меняет static

Где стоит static Что это значит Как обращаться
Поле, свойство Одно значение на тип, живет независимо от объектов Order.Created
Метод Нет this, доступны только статические члены и то, что пришло параметрами Order.FormatId(2)
Класс Только статические члены, new запрещен, наследовать от него нельзя Settings.AppName
Конструктор Вызывается средой один раз, без параметров и модификаторов доступа вручную не вызывается
Локальная функция, лямбда Запрет на захват локальных переменных и this как обычную функцию

Рядом со static часто встречаются const и static readonly, их путают. const — константа времени компиляции: ее значение подставляется прямо в вызывающий код, и она неявно статическая. static readonly — поле, которое вычисляется во время выполнения, при инициализации типа (она происходит не при старте программы, а перед первым использованием типа), и после этого не переприсваивается.

Оговорка про readonly: он запрещает заменить ссылку, но не защищает содержимое. В static readonly List<int> по-прежнему можно добавлять элементы.

Две границы у формулы «одно значение на тип». В обобщенном классе свой набор статических полей у каждого закрытого типа: для static class Counter<T> { public static int Hits; } поля Counter<int>.Hits и Counter<string>.Hits — разные переменные. А поле с атрибутом [ThreadStatic] хранит отдельное значение в каждом потоке.

Статический класс и статический конструктор

Статический класс подходит для набора функций без собственного состояния (как Math или File) и для методов расширения. Статический конструктор нужен, когда инициализацию нельзя записать одним выражением.

using System;

Console.WriteLine("старт");
Console.WriteLine(Settings.MaxRetries);
Console.WriteLine("первое обращение к полю");
Console.WriteLine(Settings.AppName);
Console.WriteLine(Settings.AppName);
Console.WriteLine("привет, статический класс".Shorten(6));

static class Settings
{
    public const int MaxRetries = 3;
    public static readonly string AppName = Load("AppName");
    public static readonly DateTime StartedAt;

    static Settings()
    {
        Console.WriteLine("статический конструктор");
        StartedAt = DateTime.UtcNow;
    }

    static string Load(string key)
    {
        Console.WriteLine($"инициализатор поля {key}");
        return "demo";
    }

    public static string Shorten(this string text, int max) =>
        text.Length <= max ? text : text[..max] + "...";
}

Вывод:

старт
3
первое обращение к полю
инициализатор поля AppName
статический конструктор
demo
demo
привет...

По выводу видно три правила:

  1. Чтение const не запускает инициализацию типа: тройка напечатана раньше, чем сработал конструктор.
  2. При первом обращении к статическому полю сначала выполняются инициализаторы полей в порядке записи, затем тело статического конструктора.
  3. Второе обращение к AppName ничего не инициализирует повторно: конструктор выполняется не больше одного раза за время работы программы.

У статического конструктора нет модификаторов доступа и параметров, вызвать его из кода нельзя. Если в классе он не объявлен, а есть только инициализаторы полей, точный момент их запуска выбирает среда выполнения; гарантируется лишь, что это случится не позже первого обращения к статическому полю. Поэтому на побочные эффекты инициализаторов (вывод, запись в файл) полагаться не стоит.

Метод Shorten — метод расширения: this перед первым параметром позволяет вызывать его как метод строки. Такие методы объявляют только в статических классах, не вложенных и не обобщенных. В C# 14 к этому синтаксису добавились блоки extension, которые позволяют объявлять и свойства-расширения; правило про статический класс для них то же.

Ошибки компиляции: четыре типичных случая

Неверный код:

using System;

var c = new Cart();
Console.WriteLine(c.Count());
var m = new MathTools();

class Cart
{
    int items = 3;
    public static int Count() => items;
    public static Cart Self() => this;
}

static class MathTools
{
    public static int Twice(int x) => x * 2;
}

Результат сборки:

err.cs(10,34): error CS0120: An object reference is required for the non-static field, method, or property 'Cart.items'
err.cs(11,34): error CS0026: Keyword 'this' is not valid in a static property, static method, or static field initializer
err.cs(4,19): error CS0176: Member 'Cart.Count()' cannot be accessed with an instance reference; qualify it with a type name instead
err.cs(5,9): error CS0712: Cannot create an instance of the static class 'MathTools'
err.cs(5,1): error CS0723: Cannot declare a variable of static type 'MathTools'

Как читать эти сообщения:

  • CS0120 — статический метод обратился к полю экземпляра. У него нет объекта, непонятно, чей items брать.
  • CS0026 — в статическом методе нет this.
  • CS0176 — статический метод вызван через переменную-объект. В C#, в отличие от Java, так писать нельзя, нужно имя типа.
  • CS0712 и CS0723 — попытка создать объект статического класса и объявить переменную его типа.

Исправление: если методу нужны данные объекта, он либо становится методом экземпляра, либо получает объект параметром.

using System;

var c = new Cart();
Console.WriteLine(c.Count());          // 3
Console.WriteLine(Cart.Count(c));      // 3
Console.WriteLine(MathTools.Twice(21)); // 42

class Cart
{
    int items = 3;
    public int Count() => items;                   // нужен объект - метод экземпляра
    public static int Count(Cart cart) => cart.items; // или объект приходит параметром
}

static class MathTools
{
    public static int Twice(int x) => x * 2;
}

Программа печатает 3, 3, 42.

Статические локальные функции

Локальная функция объявляется внутри метода и по умолчанию может захватывать его переменные. Модификатор static (доступен с C# 8) этот захват запрещает, и компилятор проверяет, что функция зависит только от своих параметров.

using System;

int rate = 20;
static int AddTax(int price) => price + price * rate / 100;
Console.WriteLine(AddTax(500));

Сборка падает:

local.cs(4,49): error CS8421: A static local function cannot contain a reference to 'rate'.

Исправление — передать значение явно:

using System;

int rate = 20;
static int AddTax(int price, int rate) => price + price * rate / 100;
Console.WriteLine(AddTax(500, rate));   // 600

Польза двойная: из сигнатуры видно все, от чего зависит результат, и не возникает скрытого объекта-замыкания. То же правило работает для лямбд: static x => x * 2 (с C# 9).

Изменяемое статическое состояние и потоки

Статическое поле общее не только для объектов, но и для всех потоков программы (если оно не помечено [ThreadStatic]). Пока оно неизменяемое, проблем нет. Как только в него пишут из нескольких потоков, появляются потерянные обновления.

using System;
using System.Threading;
using System.Threading.Tasks;

Parallel.For(0, 1_000_000, _ => Stats.HitUnsafe());
Parallel.For(0, 1_000_000, _ => Stats.HitSafe());
Console.WriteLine($"без синхронизации: {Stats.UnsafeHits}");
Console.WriteLine($"Interlocked:       {Stats.SafeHits}");

static class Stats
{
    public static int UnsafeHits;
    static int safeHits;
    public static int SafeHits => Volatile.Read(ref safeHits);

    public static void HitUnsafe() => UnsafeHits++;                       // чтение, +1, запись - три шага
    public static void HitSafe() => Interlocked.Increment(ref safeHits);  // один атомарный шаг
}

Три запуска подряд на одной машине дали:

без синхронизации: 754274
Interlocked:       1000000

без синхронизации: 940771
Interlocked:       1000000

без синхронизации: 1000000
Interlocked:       1000000

Первая строка меняется от запуска к запуску и зависит от числа ядер и нагрузки. В третьем прогоне она случайно совпала с верным значением — именно поэтому такие ошибки проходят тесты и всплывают под нагрузкой. UnsafeHits++ — это чтение, сложение и запись; два потока могут прочитать одно и то же старое значение.

Что выбирать для защиты:

  • счетчик — Interlocked.Increment;
  • несколько связанных полей — lock на закрытом static readonly object;
  • общий словарь или очередь — ConcurrentDictionary, ConcurrentQueue;
  • лучший вариант — не хранить изменяемое состояние в статике вовсе.

Сам статический конструктор среда выполняет под своей блокировкой, ровно один раз, даже если к типу одновременно обратились несколько потоков. Но эта гарантия относится только к инициализации, а не к дальнейшей работе с полями.

Исключение в статическом конструкторе

Если инициализатор статического поля или статический конструктор бросает исключение, тип остается непригодным до конца работы программы.

using System;

for (int i = 1; i <= 2; i++)
{
    try { Console.WriteLine(Config.Port); }
    catch (Exception e)
    {
        Console.WriteLine($"{i}: {e.GetType().Name} -> {e.InnerException?.GetType().Name}");
    }
}

static class Config
{
    public static readonly int Port = int.Parse("восемьдесят");
    static Config() { Console.WriteLine("статический конструктор Config"); }
}

Вывод:

1: TypeInitializationException -> FormatException
2: TypeInitializationException -> FormatException

Оба обращения заканчиваются TypeInitializationException, настоящая причина лежит в InnerException. Повторной попытки инициализации нет, а тело конструктора не выполнилось вообще: инициализатор поля упал раньше. Отсюда правило: в статической инициализации не читать файлы, сеть и пользовательский ввод без обработки ошибок; то, что может не получиться, переносить в обычный метод.

Когда применять static, а когда нет

Задача Решение
Функция зависит только от аргументов (формат, расчет, проверка) статический метод
Значение известно при компиляции const
Значение вычисляется один раз во время выполнения и не меняется static readonly
Создание объекта с понятным именем статический фабричный метод, например TimeSpan.FromSeconds(5)
Дополнить чужой тип методом метод расширения в статическом классе
Кеш, счетчик, текущий пользователь, подключение к базе обычный объект, который передают явно или через внедрение зависимостей

Последняя строка — главная ловушка. Изменяемая статика работает как глобальная переменная: ее может поменять любой код, тесты начинают влиять друг на друга, а в веб-приложении данные одного запроса попадают в другой.

Вторая ловушка — память. Объект, на который ссылается статическое поле, сборщик мусора не освободит, пока ссылка не обнулена. Статический список, в который только добавляют, и статическое событие, от которого не отписываются, — типичные источники утечек.

Выводы

  • static привязывает член к типу: поле одно на всех, метод не имеет this и вызывается через имя типа.
  • Из метода экземпляра статические члены доступны, из статического метода члены экземпляра — только через явно переданный объект (иначе CS0120).
  • Статический конструктор выполняется один раз перед первым использованием типа, после инициализаторов полей; исключение в нем делает тип непригодным (TypeInitializationException).
  • Статические локальные функции и лямбды не захватывают окружение, зависимости видны в параметрах.
  • Неизменяемая статика (const, static readonly, чистые функции) безопасна; изменяемая требует синхронизации и обычно заменяется объектом.

Где применяется / связь с практикой

Со static начинающий сталкивается с первой программы: точка входа, Console.WriteLine, Math, int.Parse — все это статические члены. Дальше тема возвращается при разборе классов и объектов, методов расширения LINQ, конфигурации и многопоточности, и каждый раз решает один вопрос: кому принадлежат данные — типу или объекту.

Освойте тему на практике

Системно эти темы, от синтаксиса до ООП и работы с коллекциями, проходят на курсе «C#-разработчик. Базовый уровень». Посмотреть формат занятий и задать вопросы преподавателям можно на открытых уроках Otus.

FAQ

Почему метод Main статический?
Среда выполнения вызывает точку входа, когда ни одного объекта программы еще нет. В файле с инструкциями верхнего уровня Main не виден, но компилятор создает такую же статическую точку входа сам.

Может ли статический класс наследоваться или реализовывать интерфейс?
Нет. Статический класс нельзя наследовать, он сам наследуется только от object и не может реализовывать интерфейсы. Если нужен интерфейс и подмена реализации в тестах, берут обычный класс.

Бывают ли статические члены в интерфейсах?
Да. Статические члены с телом в интерфейсе разрешены с C# 8, а с C# 11 интерфейс может объявлять и static abstract члены, на этом построена обобщенная математика (INumber<T> и родственные интерфейсы). Это отдельный инструмент для обобщенного кода, к статическим классам он отношения не имеет.

OTUS Журнал