Статические члены класса в C++: разбор на живом примере
Static-поле живёт вне объекта, инициализируется один раз и до C++17 требует определения в .cpp. Разбираем, где это выстреливает и как inline-переменные и function-local static меняют картину.

Вы объявили static int counter; внутри класса, скомпилировали — и получили undefined reference to Widget::counter на этапе линковки. Классическая ловушка: объявление статического поля в теле класса не создаёт саму переменную. До C++17 её надо определить отдельно, в одном единственном файле. Разберём, как статические члены устроены, где они экономят память и время, и в каких трёх местах разработчики регулярно наступают на грабли.
Материал для тех, кто уже пишет на C++ и упирается в конкретный вопрос: почему счётчик не линкуется, когда static-поле инициализируется, чем inline static из C++17 отличается от старого способа. Примеры собираются на GCC 13 / Clang 17 в режиме -std=c++17, отдельно отмечаю, что менялось между стандартами.
Что такое статический член и чем он отличается от обычного
Обычное поле принадлежит объекту: у каждого экземпляра свой экземпляр поля. Статическое поле принадлежит классу — оно одно на все объекты и существует, даже если не создано ни одного экземпляра. То же с методами: статический метод не получает указатель this и вызывается через имя класса.
Практический смысл: если данные общие для всех объектов (счётчик созданных экземпляров, общий пул, разделяемая конфигурация) или если функция логически относится к классу, но не к конкретному объекту (фабрика, парсер, утилита), — это кандидаты на static.
Статическое поле — это глобальная переменная, которой класс дал имя и область видимости. Все плюсы и все минусы глобального состояния переносятся вместе с ней: одна точка отказа при многопоточности, порядок инициализации между единицами трансляции и невозможность независимого тестирования экземпляров.
Счётчик экземпляров — минимальный рабочий пример
Соберём класс, который считает, сколько объектов сейчас живо. Файл widget.h:
// widget.h — C++17, компилируется с -std=c++17
#pragma once
class Widget {
public:
Widget() { ++liveCount; }
~Widget() { --liveCount; }
// статический метод: не имеет this, читает статическое поле
static int alive() { return liveCount; }
private:
// объявление статического поля внутри класса
static int liveCount;
};
Файл widget.cpp — здесь определение поля. Без этой строки код не слинкуется:
// widget.cpp
#include "widget.h"
// определение и инициализация статического поля
int Widget::liveCount = 0;
Проверка в main.cpp:
// main.cpp
#include "widget.h"
#include <iostream>
int main() {
std::cout << Widget::alive() << "\n"; // 0
Widget a;
{
Widget b, c;
std::cout << Widget::alive() << "\n"; // 3
}
std::cout << Widget::alive() << "\n"; // 1
}
Сборка: g++ -std=c++17 main.cpp widget.cpp -o demo. На выходе 0, 3, 1. Если увидите 0 3 1 — счётчик работает как задумано: liveCount общий для всех объектов, метод alive() читает его без экземпляра.
Почему объявление и определение разделены
Заголовок widget.h попадает в каждую единицу трансляции, которая его включает. Если бы строка int Widget::liveCount = 0; стояла в заголовке, компилятор создал бы её копию в каждом объектном файле, и линковщик увидел бы несколько определений одного символа — ошибка multiple definition. Поэтому до C++17 действовало правило: объявление в классе, ровно одно определение в отдельном .cpp.
Исключение существовало для целочисленных и enum-констант: их можно инициализировать прямо в классе, если они помечены static const. Но и здесь есть подвох.
class Config {
public:
// разрешено с давних времён: константа времени компиляции
static const int maxRetries = 3;
};
Такое поле можно читать как значение. Но если вы возьмёте его адрес или передадите по ссылке (например, в std::max(Config::maxRetries, x), где параметры — ссылки), понадобится определение вне класса: const int Config::maxRetries; в .cpp. Иначе снова undefined reference, и причина неочевидна — код читается, а линковка падает. С constexpr и inline из C++17 эта боль ушла.
inline static из C++17 — определение прямо в заголовке
C++17 ввёл inline-переменные. Помеченное inline static поле можно определить внутри класса в заголовке — линковщик сам схлопнет копии в один символ. Отдельный .cpp больше не нужен.
// widget.h — вариант C++17, header-only
#pragma once
class Widget {
public:
Widget() { ++liveCount; }
~Widget() { --liveCount; }
static int alive() { return liveCount; }
private:
// определение прямо здесь, отдельный .cpp не нужен
inline static int liveCount = 0;
};
Для констант времени компиляции лучше static constexpr — он неявно inline начиная с C++17, определять отдельно ничего не надо:
class Config {
public:
static constexpr int maxRetries = 3; // можно брать адрес, C++17+
static constexpr double timeout = 2.5; // constexpr работает и для double
};
Сравнение способов объявить статические данные
| Способ | Где определять | Стандарт | Когда применять |
|---|---|---|---|
static T x; + определение в .cpp | отдельный .cpp | C++98+ | любые типы, когда нужен один .cpp на TU |
static const int x = 3; | в классе, для адреса — ещё в .cpp | C++98+ | целочисленные константы, только чтение значения |
inline static T x = …; | в классе, в заголовке | C++17+ | header-only библиотеки, любые типы |
static constexpr T x = …; | в классе, в заголовке | C++17+ (неявно inline) | константы времени компиляции |
Три места, где это ломается
- Undefined reference при линковке. Объявили
static-поле в классе, забыли определение в.cpp. Текст:undefined reference to `Widget::liveCount'. Лечится либо строкойint Widget::liveCount = 0;в одном.cpp, либо переходом наinline staticпри C++17. - Порядок инициализации между единицами трансляции (static initialization order fiasco). Два статических объекта в разных
.cpp, один в конструкторе обращается к другому. Порядок инициализации между файлами не определён — можно получить обращение к ещё не сконструированному объекту. Признак: падение или мусор в данных до входа вmain. - Гонка при первом обращении в многопоточном коде. Ленивая инициализация «singleton через static-указатель» с ручной проверкой на
nullptrбез синхронизации даёт гонку. Об этом ниже.
Function-local static как безопасный синглтон
Отдельный вид статики — локальная статическая переменная функции. Она инициализируется один раз, при первом заходе в функцию. С C++11 стандарт гарантирует, что эта инициализация потокобезопасна: конкурирующие потоки будут ждать, пока первый завершит конструирование. Это чинит и порядок инициализации, и гонку разом.
// C++11+: инициализация instance потокобезопасна
class Logger {
public:
static Logger& instance() {
static Logger obj; // создаётся один раз, лениво
return obj;
}
void write(const std::string& msg);
private:
Logger() = default;
};
Такой синглтон конструируется в момент первого вызова Logger::instance(), а не до main — это снимает fiasco с порядком инициализации между файлами. Обратная сторона: глобальное состояние остаётся глобальным, и в тестах его сложно подменить.
Что смотреть дальше
Если вы работаете на C++17 и новее — по умолчанию используйте inline static и static constexpr, они убирают отдельные определения и делают заголовки самодостаточными. Три следующих шага:
- Проверьте флаг компиляции: без
-std=c++17(или новее)inline-переменные не соберутся, GCC/Clang выдадут ошибку синтаксиса наinline static. - Если тянете старый код на C++11/14 — держите правило «объявление в классе, одно определение в .cpp» и проверяйте, что для констант, у которых берут адрес, определение есть.
- Для разделяемого состояния между потоками смотрите в сторону
std::atomicдля счётчиков и function-local static для ленивой инициализации. Инвалидация и жизненный цикл синглтонов — отдельная тема, здесь мы разбирали только механику самих статических членов.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: cppreference — static members, cppreference — inline specifier (inline variables)
Частые вопросы
Почему я получаю undefined reference, хотя поле объявлено в классе?
А если у меня C++11 или C++14, а не C++17?
Можно ли инициализировать static-поле прямо в заголовке без C++17?
Чем static-метод отличается от обычного?
Потокобезопасна ли инициализация статических полей?
Что делать, если код падает ещё до входа в main?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.