Чтобы по-настоящему понять, почему указатели ведут себя именно так, нужно перестать мыслить абстрактными переменными и начать визуализировать аппаратное обеспечение. Это простая модель, но она является фундаментом для всего: от сегфолтов (падения программы из-за нарушения доступа к памяти) до уязвимостей безопасности. Если вы не уверены в основах работы с битами, байтами и словами, вам, вероятно, стоит остановиться здесь и освежить эти знания. Дальнейшее объяснение предполагает, что вы знаете разницу между байтом и битом.
В вашем компьютере есть память, или ОЗУ (оперативное запоминающее устройство). У вас может быть установлено 16, 32 или 64 гигабайта такой памяти. Это не просто хранилище; это рабочее пространство. В нем хранится код, который в данный момент выполняет процессор (CPU), и данные, с которыми эти программы работают в эту самую секунду. Представьте ОЗУ как огромный линейный массив байтов.
Каждый байт в этом массиве имеет свой адрес. Первый байт имеет адрес 0. Следующий — 1. Затем 2, 3 и так далее. Эти адреса работают точно так же, как индексы в стандартном массиве программирования. Поскольку процессор может мгновенно перейти к любому адресу, память и называется «оперативной» (random access memory). Ей не нужно читать данные последовательно. Она может получить любые нужные данные в любой момент. Когда компилятору нужно сохранить более крупный тип данных, он просто выделяет блок смежных байтов. Например, стандартное число с плавающей запятой (float) обычно занимает 4 смежных байта.
Давайте посмотрим на глобальное объявление в C:
Для вас это переменная с именем f, хранящая значение типа float. Для компьютера это команда зарезервировать 4 конкретных байта в массиве памяти. Допустим, компилятор назначает эти байты в ячейку памяти с адресом 248 440.
Когда вы пишете:
Компилятор не думает «обновить переменную f». Он думает «загрузить значение 3.14 по адресу памяти 248 440». Абстракция очень тонкая. Под капотом все сводится к адресам и значениям, находящимся по этим адресам.
Такая механическая природа памяти приводит к некоторым неприятным побочным эффектам. Рассмотрим этот фрагмент кода на C:
Вы можете ожидать, что s и t будут содержать 0:0, 1:1, 2:2, 3:3. А u должно остаться равным 0. Вместо этого вывод выглядит так:
Подождите. Что случилось с t[0]? И почему u теперь равно 5?
Проблема заключается в классическом переполнении буфера. Внимательно посмотрите на условие цикла: i<=4. Массив t объявлен как t[4], что означает, что он имеет индексы 0, 1, 2 и 3. Когда i достигает 4, код записывает данные в t[4]. Этот индекс не существует в выделенном пространстве для t. Запись происходит на один элемент за пределами массива.
Поскольку память является непрерывной, компьютер размещает s, t и u прямо рядом друг с другом в куче (heap) или стеке (stack).
Когда вы записываете данные за пределы массива, программа не падает немедленно. Вы перезаписываете то, что находится в ячейке памяти, следующей за массивом.
В данном конкретном случае запись в t[4] перезаписывает ячейку памяти, в которой хранится u (которая изначально была равна 0). Но почему u
Запись в s[4]. Этот индекс не существует. Но компьютеру всё равно. Он не проверяет, не наступаете ли вы себе на ноги. Он просто вычисляет адрес. И этот адрес оказывается адресом t[0].
Вы думаете, что записываете данные в s. На самом деле вы записываете их в t. Система выполняет команду, не моргнув глазом. Логика нарушена. Программа повреждает данные. Всё тихо, пока не станет не тихо.
Теперь попробуйте что-то похуже.
s[1000000] = 5;
Вы записываете в память, которой ваша программа не владеет. Последствия полностью зависят от операционной системы, которую вы используете. В защищённых системах, таких как UNIX, Windows NT или Windows 98, ОС немедленно обнаруживает это нарушение. Она завершает программу. Жёстко. Она защищает остальную систему от вашей ошибки.
Старые системы, такие как Windows 3.1 или классическая Mac OS, не обладают такой роскошью. Они не знают, что вы делаете. В результате вы перезаписываете код или переменные другого приложения. Итог? Сбой. Аварийное завершение. Или, что ещё хуже, системный сбой, который кажется случайным, но на самом деле является прямым результатом неконтролируемого доступа к памяти.
В памяти переменные i, s, t и u расположены рядом, по конкретным адресам. Они соседи. Если вы выйдете за границы одной переменной, вы затронете следующую. Компьютер делает ровно то, что вы сказали. Он просто не заботится о том, что вы действовали с благими намерениями.
Опасность неконтролируемого доступа к массивам
C и C++ не выполняют проверку диапазонов. Они предполагают, что вы знаете, что делаете. Это означает, что вы должны внимательно следить за диапазонами массивов. Если читать или записывать данные за пределами границ, поведение программы будет ошибочным. Неопределённым. Опасным.
Именно из-за отсутствия безопасности ошибки неинициализированных указателей так часто встречаются в C. Рассмотрим этот код:
Компилятор выводит значение p и адрес i. Изначально p содержит мусор или ноль. Адрес i обычно является большим числом. Например, вы можете увидеть:
После p = &i, p содержит адрес i. Просто.
Теперь посмотрите на это:
Здесь выводится значение, на которое указывает p. Но p не инициализирован. Он указывает на адрес 0 или какой-то случайный участок памяти. Результатом почти всегда становится сегментационная ошибка. Ошибка времени выполнения. Вы пытаетесь получить доступ к памяти, которая вам не принадлежит.
Указатели под новым углом
Как только вы поймёте эти риски, отношение к указателям изменится. Они не просто переменные. Это прямые адреса ячеек памяти.
Возьмём эту программу:
Вот что здесь происходит:
Как указатели хранят адреса памяти
Когда вы объявляете переменную целого типа, например i, она занимает 4 байта в оперативной памяти (RAM). Указатель, назовем его p, также занимает 4 байта. Этот размер сохраняется для подавляющего большинства современных систем, где адреса памяти имеют длину 32 бита. Индустрия медленно переходит к 64-битной адресации, но пока 4-байтовый размер остается стандартом.
Представьте, что i — это дом. У него есть конкретный адрес на улице, скажем, 248 440. Указатель p — это не сам дом. Это листок бумаги, на котором написан этот адрес. Когда вы выполняете присваивание p = &i, вы записываете число 248 440 на этот листок.
Это различие критически важно для понимания того, как работают указатели в C. Указатель не хранит значение i. Он хранит место, где находится i. Если вы разыменуете p (используя p *), вы обращаетесь к этому адресу и читаете значение. В этом смысле p и i * функционально идентичны. Они указывают на одни и те же данные.
Просмотр адреса в коде
Вы можете проверить это поведение с помощью простого оператора вывода.
printf("%d", p);
Эта команда не выводит значение i. Она выводит число, записанное на листке. Результатом является фактический адрес памяти переменной i.
Почему это важно? Потому что это позволяет передавать адреса, а не копии данных. Вы можете напрямую манипулировать памятью. Вы можете связывать структуры друг с другом. Вы можете эффективно управлять памятью в куче (heap). Указатель — это ссылка. Переменная — это содержимое. Путаница между ними приводит к ошибкам. Понимание различий дает вам контроль.
Некоторые разработчики относятся к указателям как к чему-то магическому. Это не так. Это просто числа. Адреса памяти — это целые числа. Относитесь к ним соответственно.


















