Как стать автором
Обновить
767.82
OTUS
Цифровые навыки от ведущих экспертов

Ссылки и указатели в C++

Уровень сложностиПростой
Время на прочтение6 мин
Количество просмотров6K

Привет, Хабр!

Сегодня разбираем &&* неувядающую классику 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 до объекта?

sizeof(int&)

Itanium (GCC/Clang на *nix)

ровно T*, помеченный const

да, это прямой адрес

sizeof(void*)

MSVC (x86-64)

тот же T*; в дебаге может хранить «tag» для Catchable Type

да

sizeof(void*)

ARM AAPCS

T*; в Thumb-режиме возможна PAC-подпись

да

sizeof(void*)

Почему иногда пишут, что 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

obj, *ptr

есть имя, есть адрес

xvalue

std::move(obj)

ресурс можно украсть

prvalue

42, {}

безадресное временное

Каллиграфия коллапса

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

  1. Выравнивание alias-объекта
    Reference обязана уважать alignof(T). На 64-битах long double& может потребовать 16-байтовый aligned move, и тогда __ref всё равно останется указателем, иначе архитектура ломается.

  2. Exceptions & catch(T&)
    Грубо говоря, в Itanium-ABI параметр-catch передаётся как ссылочный alias, MSVC — как копия. Поэтому одна и та же .dll/.so может ловить чужие C++-исключения по-разному. Поэтму не кидаем эксепшены через бинарный шов.

  3. 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)

Вопрос

Краткий ответ

Последствия

Что лежит в p?

Машинный адрес + provenance-тег

АСan/TSan отслеживают к чьей зоне принадлежит

Можно ли писать через const int*?

Нельзя, тк const защищает pointed-to объект

Протягивайте const correctness до API-границы

Почему sizeof(int*) == sizeof(void*)?

В ABI pointer размер не зависит от T

Можно memcpy массив указателей, не заботясь о типах

Арифметика, массивы, 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++, который не пугает ни других разработчиков, ни отладчик.

А освоить базовые навыки IT, необходимые C++ разработчику для успешного старта, можно на курсе "C++ Developer. Basic" под руководством преподавателей-практиков.

Теги:
Хабы:
+10
Комментарии15

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS