JavaRush /Курсы /C++ SELF /Массив становится указателем

Массив становится указателем

C++ SELF
11 уровень , 2 лекция
Открыта

1. Знакомтсво с array-to-pointer conversion (decay)

С массивами в C++ есть один исторический нюанс: язык поддерживает стиль из времён, когда многие API хотели принимать «просто адрес данных». Поэтому в выражениях массив часто ведёт себя не как «контейнер из N элементов», а как «адрес первого элемента». Это превращение называют array-to-pointer conversion или decay (в разговорной речи — «массив протух до указателя»).

Зачем это сделано? В основном ради удобства и совместимости с языком C: функциям и старым интерфейсам было проще работать с «началом данных». Но удобство имеет цену: адрес не хранит размер, а у массива размер — одна из главных частей смысла.

Ключевая мысль на всю лекцию: «массив как объект» и «имя массива в выражении» — это не одно и то же, и путаница между ними ломает логику программы.

Массив как объект: память, элементы и размер

Важно навести порядок в голове: массив — это объект. Он занимает память под все элементы сразу и имеет фиксированную длину, известную на этапе компиляции.

Давайте развивать наш проект: таблица результатов (например, очки за 5 раундов).

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t N = 5;
    int scores[N] = {10, 12, 8, 15, 9};

    std::cout << scores[0] << ' ' << scores[N - 1] << '\n'; // 10 9
}

Пока мы обращаемся к scores[i] и используем N, всё прозрачно: есть длина, есть элементы, есть корректные границы 0..N-1.

Пока вы держите массив «как массив», вы можете безопасно посчитать количество элементов, сделать sizeof(scores), использовать std::size(scores) и в целом ощущать себя уверенно.

Минимальная модель указателя: «указатель = адрес»

Слово «указатель» звучит так, будто сейчас начнутся страшные истории про утечки памяти, new/delete и ночные кошмары отладчика. Здесь этого не будет: сегодня указатель — это просто переменная, которая хранит адрес, то есть «место в памяти», где лежит какой-то объект.

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

Минимальная практическая мысль, которая нам нужна: если p — указатель на int, то выражение p[0] означает «первый int по этому адресу». Мы не обсуждаем, почему так устроено, и не делаем арифметику указателей — просто принимаем, что в C++ так можно обращаться к данным по адресу.

2. Decay на практике: массив превращается в адрес первого элемента

Теперь — главный фокус лекции. Для массива int scores[N] есть важное свойство:

  • scores как объект — это массив из N элементов;
  • но scores в большинстве выражений автоматически превращается в int*, то есть в адрес первого элемента.

Практически это выглядит так:

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t N = 5;
    int scores[N] = {10, 12, 8, 15, 9};

    int* p = scores; // decay: p "смотрит" на scores[0]

    std::cout << scores[0] << ' ' << p[0] << '\n'; // 10 10
}

Здесь произошло превращение «массив → адрес первого элемента». Указатель p указывает на то же место, где лежит scores[0].

Если хочется увидеть это совсем явно, можно напрямую взять адрес первого элемента:

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t N = 5;
    int scores[N] = {10, 12, 8, 15, 9};

    int* p1 = scores;      // неявно (decay)
    int* p2 = &scores[0];  // явно

    std::cout << p1 << '\n'; // например: 0x7ff... (адрес будет другим у вас)
    std::cout << p2 << '\n'; // тот же адрес
}

std::cout для int* печатает адрес (обычно в шестнадцатеричном виде). Точный формат зависит от реализации, но смысл один: это «координата в памяти».

5. Цена decay: размер потерян

Почему размер нельзя «достать» из T*

Когда у вас был массив scores[N], вы знали N. Когда у вас остался только int* p, вы знаете лишь «где лежит первый элемент». Но вы не знаете, сколько элементов дальше лежит «ваших», а сколько — уже чужих.

Указатель — это не контейнер и не «умная структура». Это буквально адрес. Поэтому запрос «дай мне размер массива, на который указывает p» концептуально невозможен: язык не хранит «размер массива» внутри int*.

Чтобы почувствовать проблему руками, сделаем демонстрацию «плохой идеи»: попытаться вычислить количество элементов через sizeof от указателя.

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t N = 5;
    int scores[N] = {10, 12, 8, 15, 9};

    int* p = scores;

    std::size_t wrongCount = sizeof(p) / sizeof(p[0]);
    std::cout << wrongCount << '\n'; // часто 2 на 64-bit (8 / 4), но это НЕ размер массива
}

Этот результат не связан с количеством элементов. Он связан с тем, что «размер адреса» (например, 8 байт) делится на «размер int» (например, 4 байта). Это случайная математика, а не логика программы.

sizeof(массив) и sizeof(указатель) — разные сущности

sizeof измеряет размер типа/объекта в байтах. И тут массив и указатель отличаются радикально:

  • sizeof(scores) — размер всего массива (все элементы вместе);
  • sizeof(p) — размер переменной-адреса (обычно 4 или 8 байт), но не данных.
#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t N = 5;
    int scores[N] = {10, 12, 8, 15, 9};
    int* p = scores;

    std::cout << "sizeof(scores) = " << sizeof(scores) << '\n';
    std::cout << "sizeof(p)      = " << sizeof(p) << '\n';
}

Типичная картина на 64‑битной системе: sizeof(scores) будет 20 (5 * 4), а sizeof(p) будет 8. Но не привязывайтесь к конкретным числам: даже если int другого размера, логика останется — массив «тяжёлый», указатель «лёгкий».

Как правильно получать длину массива

Новички часто запоминают формулу:

sizeof(a) / sizeof(a[0])

Она действительно работает… пока a — массив. Как только a «протух» в указатель, вы уже делите «размер адреса» на «размер элемента» и получаете бессмыслицу.

#include <cstddef>
#include <iostream>

int main() {
    int a[5] = {1, 2, 3, 4, 5};
    int* p = a;

    std::size_t ok = sizeof(a) / sizeof(a[0]);
    std::size_t bad = sizeof(p) / sizeof(p[0]);

    std::cout << ok << ' ' << bad << '\n'; // 5 2 (bad — мусорная логика)
}

Более читаемый способ получить количество элементов массива — std::size(a). Он понимает, что ему передали массив, и возвращает правильное количество.

#include <cstddef>
#include <iostream>
#include <iterator> // std::size

int main() {
    int a[5] = {1, 2, 3, 4, 5};
    int* p = a;

    std::cout << std::size(a) << '\n'; // 5

    // std::cout << std::size(p) << '\n'; // так нельзя: p не массив, у него нет длины
    (void)p;
}

6. Практика: вывод и обход через указатель

Почему std::cout << a; не печатает элементы

Есть отдельный комичный момент, который ловит почти каждый: вы делаете std::cout << a; и ожидаете увидеть 1 2 3 4 5, а получаете что-то вроде 0x7ff....

Причина та же: в выражении a превращается в int*, а operator<< для указателя печатает адрес.

#include <iostream>

int main() {
    int a[3] = {10, 20, 30};

    std::cout << a << '\n';    // 0x7ff... (адрес, НЕ "10 20 30")
    std::cout << a[0] << '\n'; // 10
}

Если вы хотите вывести элементы, печатайте их циклом (пока без алгоритмов и без range-for, чтобы не убегать вперёд):

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t N = 3;
    int a[N] = {10, 20, 30};

    for (std::size_t i = 0; i < N; ++i) {
        std::cout << a[i] << ' ';
    }
    std::cout << '\n'; // 10 20 30
}

Если у вас остался только T*, длину храните отдельно

Практическое правило, которое спасает нервы: как только вы сделали «шаг в сторону указателя», рядом обязана быть длина.

В рамках этой лекции делаем это максимально прямолинейно: есть N, есть p, и мы обходим p[i], но границу цикла берём из N, а не «пытаемся угадать».

#include <cstddef>
#include <iostream>

int main() {
    constexpr std::size_t N = 5;
    int scores[N] = {10, 12, 8, 15, 9};

    int* p = scores;

    int sum = 0;
    for (std::size_t i = 0; i < N; ++i) {
        sum += p[i];
    }

    std::cout << "sum=" << sum << '\n'; // sum=54
}

Если хочется усилить дисциплину (и уменьшить шанс «случайно поменять N»), можно брать длину из массива через std::size(scores), но только до того, как вы «потеряли массивность»:

#include <cstddef>
#include <iostream>
#include <iterator>

int main() {
    int scores[5] = {10, 12, 8, 15, 9};

    const std::size_t n = std::size(scores);
    int* p = scores;

    for (std::size_t i = 0; i < n; ++i) {
        std::cout << p[i] << ' ';
    }
    std::cout << '\n'; // 10 12 8 15 9
}

Схема: что именно происходит при decay

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

flowchart TD
    A["int scores[N] (массив как объект)
имеет N элементов"] --> B["scores (в выражении)"] B --> C["int* p = scores;
p хранит адрес scores[0]"] C --> D["p[i] (доступ к данным по адресу)
НО i нужно ограничивать отдельно"]

Смысл этой схемы такой: массив и указатель связаны, но это не одно и то же.

7. Типичные ошибки

Ошибка №1: думать, что T* «знает длину массива».
После int* p = a; многие интуитивно ожидают, что p хранит не только адрес, но и размер. Но размер массива не является частью указателя. Если вы обходите p циклом, границу нужно иметь отдельно (константа N, значение std::size(a) до decay и т.д.), иначе очень легко уйти за пределы и получить неопределённое поведение.

Ошибка №2: вычислять количество элементов через sizeof(p) / sizeof(p[0]).
Это выглядит логично, пока не осознаёшь, что sizeof(p) — это размер адреса, а не данных. Получившееся число часто выглядит «правдоподобно» (например, 2), из-за чего ошибка живёт долго. Правильная формула sizeof(a) / sizeof(a[0]) работает только для настоящего массива, где a — массивный объект, а не указатель.

Ошибка №3: писать std::cout << a; и ожидать печать элементов.
Потоковый вывод видит не «массив», а «адрес первого элемента» (результат decay), поэтому и печатает адрес. Чтобы вывести элементы, нужен цикл и явный вывод a[i]. Если вы видите в выводе 0x..., это не «сломался cout», это вы попросили вывести адрес.

Ошибка №4: смешивать «последний индекс» и «размер».
После перехода к указателю особенно легко перепутать границы: размер — это N, последний индекс — N - 1. Ошибка вида i <= N в цикле превращается в попытку прочитать p[N], то есть «шестой элемент» у массива из пяти. Даже если программа «не падает», это не значит, что всё хорошо: она может тихо читать чужую память.

Ошибка №5: пытаться объяснять себе p[i] через арифметику указателей и тут же начинать экспериментировать.
Да, у указателей есть арифметика, но это отдельная тема со своими граблями. В рамках этой лекции держитесь минимальной модели: «указатель — адрес», «p[i] даёт доступ к i‑му элементу», «границы задаём размером отдельно». Это даст устойчивую базу и не превратит обучение в сафари по неопределённому поведению.

1
Задача
C++ SELF, 11 уровень, 2 лекция
Недоступна
Потерянный размер
Потерянный размер
1
Задача
C++ SELF, 11 уровень, 2 лекция
Недоступна
Сумма показаний
Сумма показаний
1
Задача
C++ SELF, 11 уровень, 2 лекция
Недоступна
Просмотр окна
Просмотр окна
1
Задача
C++ SELF, 11 уровень, 2 лекция
Недоступна
Лидер рейтинга
Лидер рейтинга
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ