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

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

Комментарии (14)


  1. dersoverflow
    04.06.2025 20:38

    НЕ ИСПОЛЬЗУЙТЕ ССЫЛКИ и вы избежите почти всех проблем! (ц)


  1. NeoCode
    04.06.2025 20:38

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

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

    Ссылки - более высокоуровневая конструкция, также они кажутся более безопасными (хотя разумеется это не так - можно например сделать ссылку на разыменованный указатель, указывающий на память, которую затем освободить).

    Если подумать то получаются как минимум следующие аспекты:

    • Явность/неявность при обращении (нужно или нет "разыменовывать")

    • Встроенная нуллабельность указателей и ненуллабельность ссылок

    • Возможность адресной арифметики

    Адресная арифметика считается источником ошибок, хотя в низкоуровневом программировании от нее никуда не деться. Но это как раз решаемый вопрос, можно потреборвать чтобы она выполнялась только в каком нибудь "unsafe" контексте. В большинстве случаев указатели нужны лишь для динамически создаваемых объектов и там никакая арифметика не нужна.

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

    Остается явность/неявность - самое непростое, поскольку касается самой идеологии написания кода. Иногда важно явно выразить что происходит именно обращение по указателю; а иногда нам все равно, что там внутри - переменная просто представляет объект, а как он там хранится - без разницы.В Си (еще до C++) придумали операцию "стрелка" "->" специально для явного обращения к полям структуры по указателю, хотя ничто не мешало задействовать операцию "точка" - конфликта не было бы, в Си указатель не может быть одновременно составным объектом. Сделали так именно для явного обращения, такова идеология языка. В последующих языках от этого отказались, там везде "точка". В Go работают оба способа: (*myPointer).field и myPointer.field


    1. unreal_undead2
      04.06.2025 20:38

      В большинстве случаев указатели нужны лишь для динамически создаваемых объектов

      В прикладном коде. А так то ещё MMIO есть.


  1. Jijiki
    04.06.2025 20:38

    спасибо это классика )


  1. yaroslavp
    04.06.2025 20:38

    В 2025 году вроде уже можно обмазаться смарт поинтерами и не заморачиваться


    1. unreal_undead2
      04.06.2025 20:38

      И потом неожиданно обнаружить, что заметная доля времени уходит на апдейты счётчиков ссылок и прочие не имеющие отношения к целевой задаче вещи.


      1. OldFisher
        04.06.2025 20:38

        Но ведь это произойдёт только если сознательно выбрать смарт-поинтеры, реализующие модель совместного владения. Что, по идее, следует делать только если в совместном владении есть необходимость, и в таком случае оплата цены в виде счётчиков неизбежна.


        1. unreal_undead2
          04.06.2025 20:38

          Подумать о том, что даже если пойнтер сохраняется и используется в куче мест, владелец реально может быть один - это уже надо заморочиться )


          1. OldFisher
            04.06.2025 20:38

            Я подозреваю, что при таком отношении в результирующем приложении "заметная доля времени" будет уходить не на апдейты счётчиков ссылок, а на куда более прожорливые и неэффективные развлечения, по сравнению с которыми расходы на счётчики будут скорее гомеопатическими.


  1. DarkTranquillity
    04.06.2025 20:38

    Reference is just a const T* (но не всегда)

    const T* это неизменяемость объекта, что конечно же для не-const ссылки неверно.

    T* const = T&

    const T* const = const T&


  1. AbitLogic
    04.06.2025 20:38

    Из статьи так и не понял зачем ключевое слово ref в Rust, если есть &


    1. OldFisher
      04.06.2025 20:38

      В ветках match знак & является частью описания типа, по которому производится попытка сопоставления, a ref - не является, он говорит "сопоставь с таким-то типом, а потом дай мне на него ссылку вместо сожрать насовсем".


      1. AbitLogic
        04.06.2025 20:38

        Почему не сделать чтобы

        let x=&5

        let &x=5

        Были эквивалентны, тогда ref вроде как будет не нужен


        1. AbitLogic
          04.06.2025 20:38

          Хотя примерно понял, если подумал, тогда

          let &x = &5

          Непонятно нужно создавать ссылку или разыменовывать её