Привет, Хабр!
Сегодня разбираем &&* неувядающую классику C++ — ссылки & и указатели *. Казалось бы, два оператора, делов-то, но стгоит нырнуть под крышку — и выясняется: тут и разное время жизни, и несменяемость адреса, и прочие вещички. Разберемся в статье подробнее.
Как устроена ссылка
В C++ ссылка reference
— это псевдоним для существующего объекта. Синтаксически она похожа на указатель, но ведёт себя по-другому: у ссылки всегда есть цель, и её нельзя переназначить после инициализации. Это своего рода другой способ обратиться к тому же самому объекту, без лишней обвязки.
Объявляется ссылка просто:
int a = 42;
int& ref = a; // ref — это "второе имя" для a
Теперь ref
и a
— это одно и то же. Измените ref
— изменится a
. Копии не происходит, память не дублируется, всё работает напрямую. По сути, это способ писать код чуть проще, но с теми же самыми адресами под капотом.
Ссылки чаще всего используют:
когда вы хотите передать объект в функцию без копии, но не хотите заниматься проверкой
nullptr
;когда хотите показать, что параметр обязателен (в отличие от указателя);
и — чаще всего — когда пишете перегрузки, шаблоны и интерфейсы, где нужна категория значения.
Reference is just a const T* (но не всегда)
Математика уровня ABI
ABI / компилятор |
Что лежит в памяти |
Можно ли вычислить offset до объекта? |
|
---|---|---|---|
Itanium (GCC/Clang на *nix) |
ровно |
да, это прямой адрес |
|
MSVC (x86-64) |
тот же |
да |
|
ARM AAPCS |
|
да |
|
Почему иногда пишут, что sizeof(int&) == sizeof(int)
?
На платформах ILP32 int
и void*
по 4 байта утверждение банально истинно. На LP64 будет 4 vs 8. Проверьте сами:
static_assert(sizeof(int) == 4);
static_assert(sizeof(int*) == 8);
static_assert(sizeof(int&) == 8); // LP64: совпало с указателем
Почему нельзя переназначить?
Компилятор генерирует скрытый T* const ref = &obj;
. Модифицировать ref
— значит сломать const
. Undef-Behaviour, до свидания переносимость.
int a=10, b=42;
int& ref=a; // mov edi, offset a
// ...
ref = b; // mov eax, DWORD PTR [b]
// mov DWORD PTR [a], eax
Никакого отдельного объекта для ref
в asm уже нет: оптимизатор подставил адрес напрямую.
Null-reference: формально UB, фактически можно, но не надо
Стандарт (C++26 [dcl.ref]/1) жёстко: «A reference shall be bound to an object» → никакого «ну пусть будет null».
Тем не менее, на машинном уровне это реально 0
в регистре — компилятор просто предполагает, что так не случится. Нарушаем — получаем:
int& bad = *static_cast<int*>(nullptr); // UB
std::cout << bad; // -fsanitize=null отловит
Почему потусторонний код иногда так делает?
Бриджи к старому C API: приходится передать отсутствие как ссылку, чтобы не менять сигнатуру. Правило команды: закрываем пробел фабрикой-обёрткой:
std::optional<std::reference_wrapper<T>> make_ref(T* p) {
if (p) return std::ref(*p);
return std::nullopt;
}
Rvalue- и forwarding-ссылки
Категории значений refresher
Категория |
Пример |
Что значит |
---|---|---|
lvalue |
|
есть имя, есть адрес |
xvalue |
|
ресурс можно украсть |
prvalue |
|
безадресное временное |
Каллиграфия коллапса
T& & → T&
T& && → T&
T&& & → T&
T&& && → T&&
Когда шаблонный параметр T = Widget&
, ваша универсальная T&&
раскладывается в Widget&
. Поэтому forwarding-ссылка = «T deduced + &&
».
template<class T>
void sink(T&& val) {
sink_impl(std::forward<T>(val)); // краеугольный камень perfect forwarding
}
Ограничения ABI
Выравнивание alias-объекта
Reference обязана уважать alignof(T). На 64-битахlong double&
может потребовать 16-байтовый aligned move, и тогда__ref
всё равно останется указателем, иначе архитектура ломается.Exceptions &
catch(T&)
Грубо говоря, в Itanium-ABI параметр-catch передаётся как ссылочный alias, MSVC — как копия. Поэтому одна и та же.dll
/.so
может ловить чужие C++-исключения по-разному. Поэтму не кидаем эксепшены через бинарный шов.Member pointer vs reference-to-member
int T::*
— это offset, аint& T::
недопустим вовсе. Почему? Потому что reference всегда привязан к объекту — без полного адреса она не имеет смысла.
Указатели
Если ссылка — это псевдоним объекта, то указатель — это переменная, которая содержит адрес объекта. Указатель — это буквально указка: он не владеет значением сам по себе, он лишь показывает, где это значение лежит в памяти. В отличие от ссылок, указатель можно не только переназначить, но и сделать пустым — т.е нулевым nullptr
.
Объявляется указатель просто:
int a = 5;
int* p = &a; // p указывает на a
Через *p
можно разыменовать указатель и прочитать значение, которое по этому адресу лежит.
Базовые аксиомы
int a = 5;
int* p = &a; // A. захват адреса
*p = 7; // B. разыменование (read-modify-write)
p = nullptr; // C. переназначение (nullable by design)
Вопрос |
Краткий ответ |
Последствия |
---|---|---|
Что лежит в |
Машинный адрес + provenance-тег |
АСan/TSan отслеживают к чьей зоне принадлежит |
Можно ли писать через |
Нельзя, тк |
Протягивайте |
Почему |
В ABI pointer размер не зависит от |
Можно |
Арифметика, массивы, SIMD и alias-клятва компилятору
Pointer + loop == самый дешёвый итератор
std::array<float, 1024> samples;
const float* in = samples.data();
float* out = scratchpad.data();
for (size_t i = 0; i < samples.size(); ++i, ++in, ++out)
*out = *in * window[i];
Каждый инкремент — это LEA rsi, [rsi+4]
на x86-64 (если float
). Создать такой же tight-loop на std::vector<float>::iterator
можно, но придётся довериться inlining; сырой pointer снимает вопрос.
restrict (или restrict/__restrict) — пароль от векторного ускорения
void saxpy(size_t n,
float __restrict__ * __restrict__ y,
const float __restrict__ * __restrict__ x,
float a)
{
for (size_t i = 0; i < n; ++i)
y[i] += a * x[i];
}
Даем майку-обещание «x
и y
не пересекаются». Оптимизатор перестаёт держать y[i]
в регистре на случай alias и сверяет память реже -> GCC/Clang свободно разворачивают петлю и склеивают в 256-/512-битный vfmadd231ps
.
Alignment: страшилка про 16-байтные границы
AVX-512 потребует, чтобы ptr % 64 == 0
для unaligned-free загрузки. Если не уверены — вызывайте:
float* ptr = std::bit_cast<float*>(std::aligned_alloc(64, N * sizeof(float)));
или используйте std::aligned_alloc
/std::pmr::new_delete_resource
.
Обертка std::span: zero-cost, но с контрактом
void convolve(std::span<const float> in,
std::span<float> out,
std::span<const float> kernel);
std::span
— это простая, но мощная обёртка над «указатель + размер». Внутри он реализован как struct { T* data; std::size_t size; }
, и на большинстве архитектур занимает всего 16 байт. Те можно передавать миллионы спанов между функциями без малейшего давления на кеши или стек — overhead практически нулевой.
Частая ошибка: дожить дольше, чем владелец
std::span<int> leak_span;
{
std::vector<int> v = {1,2,3};
leak_span = v; // span на вектор
} // v уничтожен — UB при доступе
span — не владелец. Жизнь span ≤ жизнь данных. Для статического анализа включайте -fanalyzer
и clang-tidy
check bugprone-dangling-handle
.
Вместо вывода
Ссылки и указатели — это не «выбрать один раз и забыть». Это инструментальный сет: как молоток и отвёртка. Нужно ли прибивать гвоздь болгаркой? Скорее нет. Нужна ли на стройке только отвёртка? Тоже нет. В 2025 г. мы живём в раю span
, expected
, unique_ptr
, но по-прежнему встречаем коды, где raw-pointer обязателен, а reference делает интерфейс выразительным.
Берём чек-лист выше, погружаем в clang-tidy, добавляем санитайзеры -fsanitize=address,undefined, и спим спокойнее.
Увидимся в комментариях, коллеги — с удовольствием обсудим любые edge-кейсы, которые остались за кулисами. Happy coding!
Когда ты уверен в разнице между &
и *
, кажется, всё под контролем. Но если баги упрямо прячутся до релиза, а любой рефакторинг превращается в минное поле — значит, пришло время не просто знать синтаксис, а понимать, что происходит под капотом. Эти два урока — про то, как писать код на C++, который не пугает ни других разработчиков, ни отладчик.
9 июня в 20:00
Отлаживаем C++: от printf до asan и зеленых тестов
Как находить ошибки до продакшена — с помощью address sanitizer, assert'ов, логирования и core dump. Всё на живом коде.19 июня в 20:00
Разделяй и абстрагируй: как создавать понятный C++ код
Практика рефакторинга: превращаем сложный код в чистую, тестируемую архитектуру без потери производительности.
А освоить базовые навыки IT, необходимые C++ разработчику для успешного старта, можно на курсе "C++ Developer. Basic" под руководством преподавателей-практиков.
Комментарии (14)
NeoCode
04.06.2025 20:38Ссылки и указатели это отличная тема для обсуждения дизайна языков программирования. Как лучше?
Указатели - это наиболее низкоуровневая абстрация, ближе всего к реальности и значит содержащая меньше всего противоречий. И еще указатели явные, их нужно разыменовывать каждый раз при использовании.
Ссылки - более высокоуровневая конструкция, также они кажутся более безопасными (хотя разумеется это не так - можно например сделать ссылку на разыменованный указатель, указывающий на память, которую затем освободить).
Если подумать то получаются как минимум следующие аспекты:
Явность/неявность при обращении (нужно или нет "разыменовывать")
Встроенная нуллабельность указателей и ненуллабельность ссылок
Возможность адресной арифметики
Адресная арифметика считается источником ошибок, хотя в низкоуровневом программировании от нее никуда не деться. Но это как раз решаемый вопрос, можно потреборвать чтобы она выполнялась только в каком нибудь "unsafe" контексте. В большинстве случаев указатели нужны лишь для динамически создаваемых объектов и там никакая арифметика не нужна.
Нуллабельность... она в языках программирования нужна, во многих случаях есть специальное состояние null, которое нужно выразить явно, а во многих других такого состояния нет. И хорошо если синтаксис языка предоставляет единый механизм для этого.
Остается явность/неявность - самое непростое, поскольку касается самой идеологии написания кода. Иногда важно явно выразить что происходит именно обращение по указателю; а иногда нам все равно, что там внутри - переменная просто представляет объект, а как он там хранится - без разницы.В Си (еще до C++) придумали операцию "стрелка" "->" специально для явного обращения к полям структуры по указателю, хотя ничто не мешало задействовать операцию "точка" - конфликта не было бы, в Си указатель не может быть одновременно составным объектом. Сделали так именно для явного обращения, такова идеология языка. В последующих языках от этого отказались, там везде "точка". В Go работают оба способа: (*myPointer).field и myPointer.field
unreal_undead2
04.06.2025 20:38В большинстве случаев указатели нужны лишь для динамически создаваемых объектов
В прикладном коде. А так то ещё MMIO есть.
yaroslavp
04.06.2025 20:38В 2025 году вроде уже можно обмазаться смарт поинтерами и не заморачиваться
unreal_undead2
04.06.2025 20:38И потом неожиданно обнаружить, что заметная доля времени уходит на апдейты счётчиков ссылок и прочие не имеющие отношения к целевой задаче вещи.
OldFisher
04.06.2025 20:38Но ведь это произойдёт только если сознательно выбрать смарт-поинтеры, реализующие модель совместного владения. Что, по идее, следует делать только если в совместном владении есть необходимость, и в таком случае оплата цены в виде счётчиков неизбежна.
unreal_undead2
04.06.2025 20:38Подумать о том, что даже если пойнтер сохраняется и используется в куче мест, владелец реально может быть один - это уже надо заморочиться )
OldFisher
04.06.2025 20:38Я подозреваю, что при таком отношении в результирующем приложении "заметная доля времени" будет уходить не на апдейты счётчиков ссылок, а на куда более прожорливые и неэффективные развлечения, по сравнению с которыми расходы на счётчики будут скорее гомеопатическими.
DarkTranquillity
04.06.2025 20:38Reference is just a const T* (но не всегда)
const T* это неизменяемость объекта, что конечно же для не-const ссылки неверно.
T* const = T&
const T* const = const T&
AbitLogic
04.06.2025 20:38Из статьи так и не понял зачем ключевое слово ref в Rust, если есть &
OldFisher
04.06.2025 20:38В ветках match знак & является частью описания типа, по которому производится попытка сопоставления, a ref - не является, он говорит "сопоставь с таким-то типом, а потом дай мне на него ссылку вместо сожрать насовсем".
dersoverflow
НЕ ИСПОЛЬЗУЙТЕ ССЫЛКИ и вы избежите почти всех проблем! (ц)