6 — Массивы и указатели в С++
Указатели являются одной из сильных сторон С++. Грамотное их применение позволяет повысить скорость выполнения программы и более эффективно использовать память. Например, во многих случаях в качестве аргументов функции лучше использовать указатели на объект, чем каждый раз создавать их копии.
С указателями используются следующие операции:
- & — взятие адреса переменной;
- * — разъименование указателя, т.е. получение доступа к объекту;
Указатели языка Си
Все, что тут описано справедливо для так называемых «сырых указателей» и работает в языках Си и С++. Для более углубленного изучения рекомендую посмотреть статью: «Указатели и ссылки в С++«. В языке С++ используются также «умные указатели«.
Дональд Кнут считает указатели одним из главных изобретений в языке программирования Си. И это не просто так.
Вам известно, что любая переменная — это именованная область памяти. Все пространство памяти проиндексировано. У каждого байта есть свой адрес.
Переменная x имеет значение 94 . Но когда я вывел на консоль &x , то получил 0x7ffe53575c6c . У вас это значение, скорее всего, будет другим.
Что это за значение ? 0x7ffe53575c6c — адрес переменной x , выведенный в 16-ной форме. Его фактическое значение и смысл для нас не интересен.
Важно: у ЛЮБОЙ переменной есть адрес.
Адрес переменной — значение (число). Но ведь мы умеем хранить значения? Используем для этого переменную особого типа — указатель:
Важно: указатель — переменная, которая может хранить адрес другой переменной.
Синтаксис int* pX означает, что pX может хранить адрес переменной типа int. Мы явно говорим, что указатель создается именно для int . Это необходимо, чтобы затем мы смогли извлечь значение, на которое указывает указатель:
Если pX — адрес переменной x , то *pX — значение, которое расположено по этому адресу. Данная операция называется разыменованием указателя.
Важно: x и *pX — абсолютно ЭКВИВАЛЕНТНЫ. Они представляют собой одно и то же значение, хранимое по одному и тому же адресу.
Следствие: если мы меняем значение x , то меняется и значение *pX , и наоборот:
Как вы наверно знаете, до этого мы использовали только стандартные функции C++. Теперь мы научимся создавать свои функции.
В программировании часто приходится сталкиваться с однотипными объектами-переменными. Например, вот такая программа:
Значения (value), используемые в программе могли бы означать стоимость продуктов, оценку студентов и что угодно другое. Главное — для пяти однотипных объектов. Обратите внимание, как сильно придется менять программу если количество объектов изменится, вам ведь не хочется даже думать о том, что их может оказаться сотня?
Аналогичная программа с использованием массива:
В этой программе уже сотня объектов, но могло бы быть любое другое количество.
Массив — это набор однотипных переменных. Все элементы пронумерованы, номера начинаются с нуля. Пример создания массива:
int array[10];
Мы создаем массив типа integer длиной в 10 элементов, которые пронумерованы от 0 до 9 (включительно). К каждому элементу массива можно обратиться, написав сначала имя, затем индекс(номер) элемента в квадратных скобках « [ ] ».
Индекс элемента всегда целое положительное число. Если в ходе работы программы вы будете обращаться к несуществующему элементу массива, то программа прекратит свою работу.
Элемент массива всегда такого же типа, что и сам массив. Исключений и особенностей нет. Массивы по своей структуре очень похожи на одномерный отрезок:
Скажем мы ввели числа:
14, 78, 26, -98, 1, 0, 14, 9, 3, 456, 12, 71, -21, 0
Тогда наш массив можно представить в виде отрезка a[0, 14) (точки соответствуют индексам):
Допустим, что нам нужно объявить одномерный массив, состоящий из 8-ми элементов и присвоить его элементам значения, вычисленные по формуле: номер элемента массива, умноженный на 2 и минус 1. Т.е. для 3-го элемента будет 3 * 2 — 1 = 5. Смотрим:
Как видно из примера, объявляется массив путем указания типа его элементов, в нашем случае это целые (int), затем указывается его имя, в нашем случае это array, а затем в квадратных скобках указывается количество элементов, в нашем случае их восемь. Итак, память для массива мы зарезервировали и на этапе компиляции программы она будет выделена (8 * 4 байта = 32 байта, т.к один элемент типа int занимает в памяти 4 байта). Но в массиве на данный момент содержится различный «мусор», т.е. различные значения, которые возможно применялись в других программах и так далее. Нужно изменить (задать) эти значения, т.е. выполнить инициализацию массива. Проще всего ее выполнять с помощью цикла, в котором мы последовательно проходим по всем элементам массива.
Но иногда одного индекса недостаточно, поэтому во всех языках программирования реализована возможность создания многомерных массивов. Двумерный массив очень похож на таблицу, трехмерный массив схож с параллелепипедом.
Вот пример объявления и инициализации двумерного массива, состоящего из трех строк и пяти столбцов:
Как видите, двумерный массив имеет два индекса. Сразу при объявлении мы его инициализируем целочисленными величинами. Для удобства мы их записываем в виде матрицы (таблицы): каждая строка с новой строки (их у нас 3), в каждой строке 5 столбцов.
Можно и так записать, как показано ниже. Разницы для компилятора не будет никакой. Разве лишь разница будет в визуальном восприятии для человека.
Либо вообще так, без указания фигурных скобок, которые логически разделяют строки друг от друга:
Кстати, последняя запись демонстрирует то, как на самом деле элементы массива размещаются в памяти компьютера. Как я уже писал в предыдущих главах, они идут последовательно.
Если инициализация двумерного массива происходит одновременно с объявлением, то можно даже не указывать первый индекс, т.е. количество строк массива:
Зная количество столбцов, компилятор при компиляции сам рассчитает количество строк двумерного массива.
Для доступа к элементам двумерного массива нужно, так же, как и для одномерного указать индекс. В данном случае нам нужно будет позаботиться об указании двух индексов. Например, чтобы перезаписать последний элемент второй строки, мы должны использовать такую запись:
В этом случае мы перезапишем значение 9 на 0.
Для прохода по двумерному массиву удобнее всего использовать два цикла for, вложенных друг в друга. Ниже на примере мы заполняем массив и выводим его содержимое на экран:
Первый индекс — это длина таблицы, второй — высота. В трехмерном случае добавляется третий индекс, который будет шириной.
Программа выведет таблицу NxN заполненную числами от 1 до n*n по спирали.
Массиву, как и любой другой переменной, можно присвоить начальное значение. Для этого значения элементов массива нужно перечислить в фигурных скобках через запятую:
Ввод и вывод элементов массива осуществляется поэлементно. Например вот так:
В последующих уроках мы рассмотрим основные операции над массивами, такие как сортировка элементов в массиве, поиск максимального элемента, метод «пузырька» и т. п.. Что касается двумерных массивов, то о них будет подробно рассказано в уроках про матрицы .
Описание массива целых чисел
Перед использованием в программе массив должен быть описан, т. е. должно быть указано имя массива, количество элементов массива и их тип. Это необходимо для того, чтобы выделить участок памяти нужного размера для хранения массива. Общий вид описания одномерного массива:
Пример
var a: array [1..10] of integer;
Здесь описан массив а из 10 целочисленных значений. При выполнении этого оператора в памяти компьютера будет выделено место для хранения десяти целочисленных переменных.
Массив, элементы которого имеют заданные начальные значения, может быть описан в разделе описания констант:
const b: array [1..5] of integer = (1, 2, 3, 5, 7);
В этом случае не просто выделяются последовательные ячейки памяти — в них сразу же заносятся соответствующие значения.
Сети с отличающимися протоколами передачи данных объединяют с помощью …
Сетевой топологии — называют геометрическое описание способа соединения компьютеров в единую сеть. К основным топологиям относятся: общая шина, звезда, кольцо.
Моста — это устройство для объединения сетей или сегментов одной сети с одинаковыми протоколами, разгружающее межсетевой обмен за счет фильтрации передаваемых данных между сетями. Фильтрация позволяет не выпускать широковещательные сообщения из своей сети.
Шлюза – ПРАВИЛЬНЫЙ ОТВЕТ
Кольца-это разновидность сетевой топологии, наряду с топологиями типа звезда и общая шина. Особенностью кольцевой топологии является замкнутая линия связи, где данные циркулируют между транспортными узлами.
Размещение объектов в оперативной памяти. Понятие указателя. Часть 2.
Под арифметикой указателей (или адресной арифметикой ) понимают правила применения к указателям арифметических операций. Допустимыми являются операции: сложение указателя с целым числом, вычитание из указателя целого числа, вычитание одного указателя из другого, а также операции сравнения указателей >, >=,
За исключением перечисленных, другие арифметические операции над указателями не определены — их использование приведёт к ошибкам компиляции программы. Так, ошибку компиляции вызовет попытка сложения двух указателей или сравнения указателей на объекты разного типа, попытка вычитания из указателя числа с плавающей точкой и пр. Те же арифметические операции, что применимы к указателям, в подавляющем большинстве случаев имеют смысл только в контексте работы с массивами (по этой причине арифметика указателей и массивы обычно рассматриваются одновременно).
Массивы, оператор индексации, базовые операции над указателями
Массив — это упорядоченный набор (коллекция) однотипных объектов, размещаемых в едином блоке памяти последовательно друг за другом. Однотипные объекты, входящие в состав массива, называют элементами массива . Каждому элементу массива соответствует порядковый номер — индекс. Индексация элементов начинается с нуля : начальный (первый) элемент массива имеет индекс 0, следующий за ним (второй) — индекс 1, следующий за вторым (третий) — индекс 2 и т. д. Последний элемент массива из k элементов имеет индекс k – 1 .
Рис. 1. Массив из k элементов
Размещение элементов массива в едином блоке памяти является ключевой особенностью массивов. Такое размещение позволяет максимально эффективно получать доступ к любому наперёд заданному элементу массива. Действительно, зная адрес массива — номер ячейки памяти, начиная с которой в ней последовательно располагаются элементы массива, иными словами, зная адрес элемента с индексом 0, и учитывая, что все элементы массива в силу их однотипности занимают в памяти одинаковое количество ячеек, можно легко получить адрес элемента с любым заданным индексом i . Для этого достаточно i раз выполнить смещение от начала массива на количество ячеек, занимаемое одним элементом или, что то же самое, выполнить смещение от начала массива на количество ячеек, равное произведению индекса требуемого элемента на количество ячеек, занимаемое одним элементом (см. рис. 1). Выполнение подобных смещений реализуется с помощью арифметических операций над указателями.
Более конкретно принципы работы с массивами и базовые операции над указателями рассмотрим на следующем небольшом примере.
Массивы, равно как и любые другие виды объектов, могут быть размещены как в стеке, так и в динамической памяти. Вначале рассмотрим размещение массивов в стеке. Код первой строки функции main выполняет такое размещение:
В этой строке определяется массив с именем arr из трёх объектов целочисленного типа int , которые инициализируются значениями 10, 20 и 30 соответственно.
Синтаксис определения массива объектов отличается от синтаксиса определения одного объекта наличием после имени переменной, идентифицирующей массив, квадратных скобок, в которых указывается размер массива — количество составляющих его объектов. Начальные значения элементов массива, в случае необходимости их задания, перечисляются через запятую в фигурных скобках. Если требуется начальная инициализация не всех элементов массива, а некоторого начального их подмножества, то возможен следующий синтаксис:
При таком определении элемент с индексом 0 массива arr будет иметь значение 10, следующий за ним — значение 20, остальные — значение по умолчанию, каковым для объектов большинства встроенных типов является число 0.
ПРИМЕЧАНИЕ
Обрамленный фигурными скобками перечень начальных значений элементов массива называют списком инициализации массива.
Обрабатывая определение локального массива, компилятор генерирует код размещения в стеке входящих в его состав объектов: код выделения памяти под заданное в квадратных скобках количество объектов и код вызова конструктора для каждого из создаваемых объектов. Конструктор выполняет инициализацию отведённой под объект памяти: либо на основе соответствующего значения, указанного в списке инициализации массива, либо значением по умолчанию.
При выполнении первой строки кода функции main в стеке будут выделены ячейки под размещение трёх объектов типа int . В эти ячейки будут записаны значения 10, 20 и 30. Рис. 2 иллюстрирует состояние памяти после выполнения данной строки кода.
Рис. 2
По аналогии с примерами, рассмотренными в первой части статьи, будем считать, что размещение объектов в стеке начинается с ячейки 0x30.
ПРИМЕЧАНИЕ
Ещё раз отметим, что объекты, составляющие массив, размещаются в памяти непосредственно друг за другом — адрес каждого следующего объекта есть адрес предыдущего, смещённый на размер объекта.
С/C++ позволяют создавать в стеке только массивы фиксированного размера. Из этого следует, что в определении массива в квадратных скобках может быть указано только константное выражение — литеральные константы, как в разбираемом примере, или символические константы, как во фрагменте кода ниже:
В общем случае использование символических констант выглядит предпочтительным, поскольку облегчает последующее сопровождение кода. Если по каким-либо причинам потребуется изменить размер массива, то при использовании для задания размера символической константы достаточно будет модифицировать код в одном месте — в месте определения константы, а не во всех тех местах, где этот размер фигурирует — циклах перебора элементов, точках вызова функций, принимающих массив через параметры, и пр.
ПРИМЕЧАНИЕ
С другой стороны, количество элементов размещённого в стеке массива можно рассчитать по формуле sizeof(имя массива) / sizeof(тип элемента) . В рассматриваемом примере значением выражения sizeof(arr) / sizeof(int) будет число 3.
К массивам, создаваемым в динамической памяти, данная формула неприменима.
Наличие модификатора const в определении объекта n существенно. Без этого модификатора n становится переменной; использование переменной для задания размера массива arr приведёт к ошибке компиляции — у компилятора исключительно формальный подход к идентификации константных выражений.
Перейдём теперь к рассмотрению массивов, размещаемых в динамической памяти — в куче. Следующие две строчки функции main демонстрируют создание массива в динамической памяти:
Здесь определяется локальная переменная k — объект целочисленного типа int , инициализируемый значением 6, предназначенный для хранения размера массива. Далее, при помощи специальной формы оператора new (с квадратными скобками), в динамической памяти создается массив из k объектов типа short .
Формат вызова оператора new в случае создания массива объектов таков: ключевое слово «new», за которым следует тип элементов массива, за которым в квадратных скобках указывается размер массива — количество составляющих его объектов.
В целом оператор new с квадратными скобками (далее — оператор new[ ] ) выполняет действия аналогичные тем, что выполняет рассмотренная в первой части статьи форма оператора new без квадратных скобок, используемая для создания в куче одного объекта. Вначале оператор new[ ] запрашивает у диспетчера памяти блок памяти, достаточный для размещения заданного количества элементов заданного типа. Размер запрашиваемого блока памяти рассчитывается как произведение количества элементов массива на количество ячеек, занимаемых одним элементом. Затем, в случае успешного выделения памяти, оператор new[ ] для каждого из создаваемых объектов вызывает конструктор, выполняющий начальную инициализацию отведённой под объект памяти. Возвращаемым значением оператора new[ ] является адрес первого элемента массива (адрес объекта с индексом 0). В тех случаях, когда диспетчер памяти не смог найти свободный блок требуемого размера, оператор new[ ] генерирует исключение std::bad_alloc .
ПРИМЕЧАНИЕ
Как и при создании в куче одного объекта, в ситуациях крайней необходимости избежать использования исключений, непосредственно после ключевого слова «new» в круглых скобках может быть указана константа std::nothrow , тогда при нехватке памяти оператор new[ ] вместо генерации исключения будет возвращать значение NULL .
Конструктор, вызываемый в случае создания с использованием оператора new одного объекта, определяется на основе аргументов, указываемых в круглых скобках после типа объекта; при создании массива объектов нет возможности задать аргументы конструктора — оператор new[ ] всегда вызывает конструктор по умолчанию (конструктор без аргументов).
Отметим, что значение переменной k , т. е. размер массива, мы могли бы не задавать непосредственно в коде, а ввести с клавиатуры, считать из файла или получить каким-либо ещё способом во время выполнения программы.
Конфигурация памяти после выполнения двух рассматриваемых строк кода представлена на рис. 3.
Рис. 3
Для большей определённости будем считать, что созданный оператором new[ ] массив из k ( k = 6 ) объектов типа short располагается в куче по адресу 0xF838, т. е. объекты массива занимают с 63544 по 63555 ячейки включительно. Первый объект занимает ячейки 0xF838 и 0xF839, второй — 0xF83A и 0xF83B, третий — 0xF83С и 0xF83В и т. д.
Обратите внимание, что выполнение строки программы:
порождает в памяти k + 1 объект: массив из k объектов целочисленного типа short в куче и объект-указатель vec в стеке. Адрес начального элемента созданного в куче массива — число 0xF838 — является значением стекового объекта-указателя vec (см. рис. 3).
ПРИМЕЧАНИЕ
Интересная синтаксическая особенность. Следующие две строчки отличаются только формой скобок, в которые заключено число 30:
int* p = new int(30);
int* p = new int[30];
При этом первая строчка создает в куче один объект типа int , инициализируемый числом 30, а вторая строчка создает в куче массив из 30 неинициализированных объектов типа int .
Для доступа к элементам массива, независимо от региона памяти, в котором массив создан, используется оператор индексации (оператор [ ]), возвращающий элемент массива по его индексу.
Строка функции main:
демонстрирует применение оператора [ ] для доступа к элементам размещённого в стеке массива arr . Результатом её выполнения будет отображение на экране чисел 10, 20 и 30 (см. рис. 3).
ПРИМЕЧАНИЕ
Выражение вида arr[i] , где arr — имя массива, а i — индекс элемента в нём, можно рассматривать как имя конкретного ( i-го ) объекта массива.
В следующей строке функции main с использованием оператора индексации выполняется присваивание нового значения элементу с индексом 0 размещённого в куче массива vec .
В этой строке сначала, при помощи оператора [ ], осуществляется переход к соответствующему индексу 0 объекту массива vec , после чего полученному объекту присваивается новое значение — в ячейки памяти, отведённые под этот объект, записывается число 17.
Состояние памяти после выполнения данной строки кода отражено на рис. 4.
Рис. 4
Заметьте, что, с одной стороны, vec — это имя созданного в куче массива, а с другой — указатель на его начальный элемент (см. определение переменной vec ), и, следовательно, к vec можно применить операцию разыменования (операцию *). Операция разыменования обеспечивает переход от указателя на объект к объекту, расположенному в памяти по адресу, хранимому в указателе, т. е. в случае с vec — к начальному элементу массива, объекту, который соответствует индексу 0.
Распечатав значение начального элемента массива и значение объекта, на который указывает vec
мы получим один и тот же результат — число 17. Именно оно является значением объекта с индексом 0, и именно на этот объект указывает переменная vec .
Задание нового значения объекту, адресуемому указателем vec:
незамедлительно отразится и на значении начального элемента массива vec (рис. 5).
Рис. 5
Выполнение строки кода ниже приведёт к отображению числа 29.
Таким образом, выражения vec[0] и *vec эквивалентны — они идентифицируют один и тот же объект в памяти.
Рассмотрим теперь обращение к отличному от начального элементу массива, например, к третьему:
В соответствии с семантикой оператора индексации, здесь осуществляется переход к объекту массива vec с индексом 2, после чего полученному объекту присваивается новое значение — в отведённые под этот объект ячейки памяти записывается число 103 (рис. 6).
Рис. 6
Чтобы осуществить переход к элементу массива с индексом 2, оператору [ ], прежде всего, необходимо получить адрес этого элемента. Как отмечалось ранее, для этого достаточно выполнить смещение от начала массива на количество ячеек, равное произведению индекса требуемого элемента и количества ячеек (байтов), занимаемых одним элементом, т. е. смещение на 2 * sizeof(short) = 4 байта. Так как оператор [ ] в конечном итоге применяется к указателю на начальный элемент массива, то информация о типе элементов массива и адресе начального элемента у него есть.
Для выполнения смещения — здесь мы переходим к адресной арифметике — оператор [ ] производит сложение указателя на начальный элемент массива с индексом требуемого элемента: vec + 2 . Результатом вычисления этого выражения будет адрес третьего элемента массива (элемента с индексом 2).
Суть операции сложения указателя p с целым числом i состоит в следующем: если p указывает на некоторый элемент массива объектов типа T , то p + i указывает на i-й после того, на который указывает p , элемент массива (рис. 7). Результатом сложения будет указатель на объект того же типа T ; значение этого указателя (номер адресуемой им ячейки памяти) будет на i * sizeof(T) больше значения p (как видно, при расчете адреса учитывается тип объекта, на который указывает p ).
Рис. 7. Указатель на элемент массива из k объектов
Если p указывает на объект, который не является элементом массива, то результат сложения p с целым числом не определён (причём к ошибке компиляции такое сложение не приведёт — ошибка возникнет в процессе выполнения программы).
В нашем случае vec — это указатель на начальный элемент массива, следовательно, при вычислении выражения vec + 2 мы должны получить указатель на второй после начального, т. е. на третий элемент массива. В условиях наших допущений о расположении объектов в памяти имеем:
vec + 2 = vec + 2*sizeof(short) = 0xF838 + 2*2 = 0xF838 + 4 = 0xF83C .
Число 0xF83C есть ни что иное как адрес третьего элемента массива (адрес объекта, соответствующего индексу 2) (см. рис. 6).
После получения указателя на требуемый элемент массива оператор [ ] применяет к нему операцию разыменования, осуществляя тем самым переход от указателя на объект к объекту, адресуемому этим указателем.
Если распечатать значения элемента массива с индексом 2 и объекта, на который указывает vec + 2 :
то получится один и тот же результат — число 103, что подтверждает наши рассуждения.
При необходимости результат сложения указателя с целым числом можно сохранить в переменной подходящего типа:
Здесь определяется переменная p , являющаяся, как и vec , указателем на объект типа short . Эта переменная инициализируется адресом элемента с индексом 4 массива vec .
Конфигурация памяти после выполнения данной строки программы показана на рис. 8.
Рис. 8
Сделав такое определение, мы можем обращаться к пятому элементу массива (для чтения и изменения данных, хранящихся в отведённых под него ячейках) не только с использованием оператора индексации, как к vec[4] , но и посредством указателя p .
К примеру, следующий код
производит запись числа 107 в ячейки памяти, отведённые под объект, адрес которого хранится в указателе p , т. е. в ячейки памяти, занимаемые объектом vec[4] (рис. 9).
Рис. 9
Если теперь распечатать значение элемента с индексом 4
то на экран будет выведено число 107.
Как отмечалось ранее, к указателям применима не только операция сложения с целым числом, но и операция вычитания из указателя целого числа . Определяется эта операция аналогично сложению: если p указывает на некоторый элемент массива объектов типа T , а i — целое число, то p – i указывает на i-й элемент массива перед тем, на который указывает p (см. рис. 7). Результатом вычитания будет указатель на объект того же типа T ; значение этого указателя (номер адресуемой им ячейки памяти) будет на i * sizeof(T) меньше значения p .
Например, вычисление выражения p – 4 , где p — указатель на элемент массива vec с индексом 4, даст адрес начального элемента массива, что подтверждается выполнением следующей строки функции main :
В обоих случаях на экран будет выведено одно и то же число (0000F838), так как в условиях наших допущений о расположении объектов в памяти:
p – 4 = p – 4* sizeof(short) = 0xF840 – 4*2 = 0xF838
Число 0xF838 — это адрес начального элемента массива, значение переменной vec (см. рис. 9).
Соответственно, следующая строка программы:
приведёт к отображению на экране числа 29 — значения начального элемента массива.
Надо отметить, что имя размещённого в стеке массива также можно трактовать как указатель на его первый элемент, в том смысле, что к нему применимы операции, определённые для указателей: разыменование, сложение с целым числом, вычитание целого числа и пр.
Так, для обращения к начальному элементу созданного в стеке массива arr можно использовать выражение *arr , для обращения к элементу с индексом 1 — выражение *(arr + 1) и т. п.
Таким образом, мы можем наблюдать тесную связь массивов и указателей: имя массива является указателем на его начальный элемент, а любые вычисления, выполняемые с использованием оператора индексации, могут быть произведены через соответствующие операции над указателями — выражения vec[i] и *(vec + i) тождественны.
ПРИМЕЧАНИЕ
Принцип реализации оператора индексации и коммутативность операции сложения обуславливают любопытную особенность массивов.
Если v — имя массива, а i — индекс элемента этого массива, то выражение v[i] тождественно выражению i[v] . И в первом, и во втором случае результатом будет значение i-го элемента массива v , поскольку i[v] = *(i + v) = *(v + i) = v[i] .
Для удаления созданного в куче массива объектов используется оператор delete с квадратными скобками:
Оператор delete с квадратными скобками (далее — оператор delete[ ] ), подобно рассмотренному в первой части статьи оператору delete без квадратных скобок, производит действия, в точности обратные тем, что совершает оператор new[ ] при создании массива объектов. Сначала оператор delete[ ] для каждого из созданных оператором new[ ] объектов вызывает деструктор. После этого он обращается к диспетчеру памяти, передаёт ему адрес массива объектов (адрес начального элемента) и сообщает, что работа с областью памяти, занимаемой в куче объектами этого массива, завершена. Диспетчер памяти освобождает идентифицируемый указанным адресом блок памяти.
Возникает интересный вопрос: как оператор delete[ ] , получая в качестве аргумента только указатель на начальный элемент массива, узнаёт количество элементов в этом массиве — количество объектов, для которых нужно вызвать деструктор? Секрет в том, что оператор new[ ] (точнее, его реализация в большинстве компиляторов C++) выделяет на несколько ячеек памяти больше, чем требуется для размещения массива объектов. В дополнительные ячейки записываются данные о размере создаваемого массива. Располагаются эти данные непосредственно перед начальным элементом массива — перед тем адресом, который возвращает оператор new[ ] .
Так, вполне вероятно, что при выполнении рассмотренной ранее строки кода:
оператор new[ ] , обращаясь к диспетчеру памяти, запросил у него не k * sizeof(short) ячеек памяти, а k * sizeof(short) + sizeof(int) . Диспетчер выделил блок памяти, начинающийся, в условиях наших предыдущих допущений, с ячейки 0xF834, и вернул оператору new[ ] адрес этого блока. Оператор new[ ] в первые четыре ячейки полученного блока записал значение переменной k (в нашем примере — число 6), а адрес следующей за этими четырьмя ячейками — число 0xF838 — вернул как адрес созданного массива объектов (рис. 10).
Рис. 10. Дополнительные ячейки памяти, используемые для хранения количества элементов массива
Таким образом, указатель на начальный элемент массива (в нашем примере — vec ) определяет положение в памяти не только начального и всех последующих элементов, но и информации о количестве элементов массива — она хранится в ячейках памяти, непосредственно предшествующих ячейке, адресуемой этим указателем.
Оператор delete[ ] знает о том, что оператор new[ ] записывает данные о количестве элементов массива перед его начальным элементом, а также о том, сколько ячеек памяти отводится под хранение этих данных. Следовательно, он может по адресу начального элемента определить размер массива — количество объектов, для которых нужно вызвать деструктор.
ПРИМЕЧАНИЕ
Бьерн Страуструп в книге «Язык программирования C++» [1] отмечает, что «специальный оператор delete[ ] для массивов не является логически необходимым». Этот оператор был добавлен в язык из соображений эффективности. В отсутствие оператора delete[ ] служебную информацию о количестве созданных в куче объектов пришлось бы записывать не только при создании массива объектов оператором new[ ] , но и при создании одиночного объекта оператором new , что привело бы к существенным дополнительным затратам времени и памяти при выполнении программ.
Из приведённых рассуждений следует, что необходимо строго соблюдать парность операторов создания и удаления объектов в куче: объекты, созданные оператором new , должны быть удалены оператором delete ; объекты, созданные оператором new[ ] , должны быть удалены оператором delete[ ] . Нарушение парности этих операторов не выявляется на этапе компиляции, и хотя то, как на самом деле выделяется память, зависит от конкретного компилятора, в большинстве случаев такое нарушение приводит к нестабильному выполнению программы.
ПРИМЕЧАНИЕ
Допускается передача оператору delete[ ] нулевого указателя. В этом случае оператор delete[ ] , как и оператор delete , не производит никаких действий и сразу возвращает управление в вызывающую функцию.
Массивы, созданные в стеке (более точно, объекты, входящие в их состав), подобно любым другим создаваемым в стеке объектам, автоматически разрушаются в точке выхода имени массива из области видимости. В нашем примере объекты, входящие в состав массива arr , будут разрушены в момент выхода процесса выполнения из функции main , равно как и целочисленный объект k , объекты-указатели vec и p .
ПРИМЕЧАНИЕ
С данными любого объекта или массива объектов можно работать как с набором (массивом) значений ячеек (байт) соответствующей области памяти (а при необходимости и трактовать их как данные объекта или массива объектов другого типа). Для этого в C++ предусмотрена возможность неявного преобразования указателя на объект любого типа к указателю на void , а также поддерживаются функции явного преобразования типов объектов: static_cast , const_cast , reinterpret_cast и dynamic_cast .
Возможность интерпретации данных объекта как массива значений ячеек памяти часто используется для начальной инициализации всех полей объекта (или всех элементов массива) нулевым значением. Так, задание значения 0 всем полям объекта obj произвольного типа T можно осуществить посредством вызова функции memset(&obj, 0, sizeof(obj) ) стандартной библиотеки C/C++ (в операционных системах Microsoft Windows для этих целей можно воспользоваться функцией ZeroMemory ).
Практические примеры — функции strcpy и strlen
Вернёмся к арифметике указателей. Определённые над указателями арифметические операции позволяют писать эффективный, компактный и элегантный код. Ярким примером этому является реализация функций работы со строками в стандартной библиотеке С/C++.
Рассмотрим функцию копирования строки strcpy :
Строка (более точно, C-строка) — это массив символов (объектов типа char ), завершающийся нулём (символом ‘ ’).
Функция strcpy принимает в качестве параметров два указателя: указатель s на исходную строку (на массив символов, которые надо скопировать) и указатель t на результирующую строку (на буфер, в который надо скопировать символы строки s ). Предполагается, что память под массив, адресуемый указателем t , выделена, и её достаточно для размещения всех символов строки s .
Для примера предположим, что s указывает на строку «Hello world», размещённую в памяти по адресу 0xF850, а t — на буфер, размещённый по адресу 0xF88C (рис. 11).
Рис. 11. Состояние памяти перед первой итерацией цикла
Непосредственно копирование символов осуществляется во второй строке функции:
Эта строка содержит цикл while с пустым телом. Цикл выполняется до тех пор, пока истинно значение выражения в круглых скобках. В C++ значение выражения истинно, когда оно отлично от 0.
Выражение *p++ = *s++ является выражением присваивания: объекту, получаемому в результате вычисления подвыражения, стоящего слева от знака равенства, присваивается значение подвыражения, стоящего справа от знака равенства. Значением выражения присваивания является значение левостороннего операнда (объекта) после выполнения, собственно, присваивания. Рассмотрим подробно конструкцию:
Поскольку для указателей определены операции сложения с целым числом и вычитания целого числа, то определены и производные от них операции, в частности, операции префиксного и постфиксного инкремента и декремента (++ и —), которые часто используются для реализации итераций по массиву объектов.
ПРИМЕЧАНИЕ
Операторы инкремента и декремента предназначены соответственно для увеличения и уменьшения значения объекта на единицу. Существуют префиксная и постфиксная формы этих операторов. Отличаются данные формы возвращаемым значением (оператор — это функция с предопределённым именем, которая, как и любая другая функция, может иметь возвращаемое значение). Префиксный оператор возвращает новое , увеличенное на единицу в случае инкремента, либо уменьшенное на единицу в случае декремента значение объекта; постфиксный оператор возвращает исходное значение объекта — то значение, которое было у объекта непосредственно перед применением к нему оператора.
Различие между префиксным и постфиксным операторами можно наглядно продемонстрировать на примере. Допустим, что у нас есть два целочисленных объекта i и j с одинаковым начальным значением 7. В следующих строчках кода к объекту i применяется префиксный оператор инкремента, распечатывается возвращаемое этим оператором значение, после чего распечатывается значение объекта i ; к объекту j применяется постфиксный оператор инкремента, распечатывается возвращаемое им значение, после чего распечатывается значение объекта j.
Выполнение первой сроки приведёт к отображению на экране чисел 8 и 8, выполнение второй — чисел 7 и 8. Таким образом, мы можем наблюдать, что значение объекта вне зависимости от того, какой к нему применялся оператор инкремента — префиксный или постфиксный, увеличивается на 1, но значения, непосредственно возвращаемые операторами, различаются: в случае префиксного оператора мы получаем новое значение объекта (число 8), в случае постфиксного — исходное значение (число 7).
Операция постфиксного инкремента имеет более высокий приоритет, чем операция разыменования, следовательно, в выражении *p++ сначала к указателю p применяется операция постфиксного инкремента, после чего к её результату (к значению, возвращенному операцией постфиксного инкремента) применяется операция разыменования.
Инкремент указателя — это увеличение значения указателя на единицу, т. е. сложение значения указателя с числом 1 и сохранение адреса, полученного в результате сложения, в ячейках памяти, отведённых под этот указатель. Так, если значением указателя был адрес i элемента массива, то после выполнения операции инкремента значением указателя будет адрес следующего, i + 1 элемента (соответственно, после выполнения операции декремента значением указателя будет адрес предыдущего, i — 1 элемента).
В выражении *p++ используется постфиксный оператор инкремента. Возвращаемым значением постфиксного оператора является исходное значение того объекта, к которому он применяется, т. е. исходное значение указателя p — адрес объекта (элемента массива), на который указатель p указывал до инкремента. И именно к этому, исходному значению указателя p , применяется операция разыменования.
Таким образом, в выражении *p++ посредством операции разыменования осуществляется переход к объекту, адресуемому указателем p до выполнения этого выражения, значением выражения будет значение этого объекта. При этом после выполнения данного выражения указатель p будет иметь новое значение — адрес объекта (элемента массива), следующего за тем, на который он указывал до выполнения выражения.
ПРИМЕЧАНИЕ
В некотором смысле можно считать, что в случае постфиксного оператора инкремента (декремента) значение объекта сначала используется в выражении, а затем увеличивается (уменьшается) на единицу; в случае префиксного оператора инкремента (декремента) наоборот — значение объекта сначала увеличивается (уменьшается) на единицу, а затем используется в выражении.
Вернёмся ко второй строке функции strcpy:
В начальный момент времени — перед выполнением цикла — переменная s указывает на первый элемент исходного массива символов (на символ ‘H’ копируемой строки); p указывает на первый элемент буфера t (также являющегося массивом символов) (см. рис. 11).
Выполнение цикла while начинается с проверки истинности условия цикла, т. е. с вычисления значения выражения *p++ = *s++ .
Сначала вычисляется правостороннее подвыражение *s++ . К указателю s применяется постфиксный оператор инкремента, который увеличивает значение указателя на единицу и превращает его, таким образом, из указателя на первый символ исходной строки в указатель на второй символ — символ ‘e’. К возвращаемому операцией постфиксного инкремента значению, т. е. к начальному значению указателя s , применяется операция разыменования, посредством которой осуществляется переход от указателя на первый элемент массива непосредственно к первому элементу — объекту типа char , значением которого является код символа ‘H’. Это же значение является значением всего подвыражения.
Аналогичным образом вычисляется левостороннее подвыражение *p++ : после его выполнения указатель p будет адресовать второй элемент массива t ; операция разыменования применяется к указателю на первый элемент массива t .
Далее объекту, полученному в результате вычисления левостороннего подвыражения, присваивается новое значение: в ячейки памяти (более точно — “в ячейку памяти”, sizeof(char) = 1), отведённые под первый элемент массива t , записываются данные, хранящиеся в ячейках, отведённых под первый элемент копируемого массива — код символа ‘H’ (рис. 12).
Рис. 12. Состояние памяти после выполнения первой итерации цикла
Значением всего выражения присваивания *p++ = *s++ будет значение левостороннего операнда после выполнения присваивания — новое значение первого элемента массива символов t — код символа ‘H’. Этот код отличен от 0, следовательно, условие цикла истинно, и поскольку тела у данного цикла нет, то после проверки истинности условия процесс выполнения сразу перейдет к следующей итерации цикла, которая вновь начнётся с вычисления значения выражения *p++ = *s++ . При этом значения переменных s и p на предыдущей (первой) итерации были изменены так, что на момент начала второй итерации они указывают соответственно на второй символ исходной строки и на второй элемент массива t (см. рис. 12), а значит, повторное вычисление выражения *p++ = *s++ приведёт к копированию второго символа строки — символа ‘e’.
Выполнение цикла завершится, когда значение условного выражения цикла станет ложным, т. е. тогда, когда будет скопирован завершающий исходную строку 0 (рис. 13).
Рис. 13. Состояние памяти после выполнения последней итерации цикла
ПРИМЕЧАНИЕ
Инкремент указателя s на последней итерации цикла приводит к тому, что он выходит за границы массива и начинает указывать на следующий за последним элемент (см. рис. 13). Разыменование такого указателя и последующий доступ к объекту приведёт либо к немедленному аварийному завершению работы программы, либо к её нестабильному, не повторяющемуся от одного запуска к другому, поведению. Однако в нашем случае обращение по указателю (разыменование указателя) не производится (цикл уже завершился), поэтому ничего опасного или противоречащего стандарту C++ в подобном выходе за пределы массива нет.
С использованием оператора индексации рассмотренный цикл копирования строки можно было бы записать так:
Как можно заметить, копирование строки с использованием оператора индексации требует написания большего количества кода.
ПРИМЕЧАНИЕ
Приведённые выше фрагменты кода являются также примерами случаев, когда неважно, какой именно оператор инкремента — префиксный или постфиксный — используется для увеличения значения переменной. В этих фрагментах оператор инкремента нужен только для увеличения значения индекса i , непосредственно возвращаемое им значение не используется.
Возможно, выражение while(*p++ = *s++) на первый взгляд и кажется непонятным, однако такая запись удобна, на практике подобный код встречается. О важности его понимания говорит и тот факт, что разобранный нами пример рассмотрен также в книге Бьерна Страуструпа «Язык программирования C++» [1] и книге Брайана Кернигана и Денниса Ритчи «Язык программирования C» [2]. Дополнительно о важности знания аспектов реализации функции strcpy можно почитать в книгах Джоэла Спольски «Джоэл о программировании” [6] и “Джоэл: и снова о программировании» [10].
ПРИМЕЧАНИЕ
Надо отметить, что создать копию строки простым определением указателя на char и присваиванием ему значения указателя на существующую строку ( char* t = s; ) нельзя. Результат такой операции будет совершенно иным: вместо двух строк (двух идентичных массивов символов) мы получим два указателя на одну строку. Освобождение отведённой под эту строку памяти через один из указателей приведёт к недействительности и второго. Поэтому для создания копии строки сначала нужно выделить достаточное для её хранения количество памяти, а затем, посредством вызова функции strcpy , произвести копирование данных.
В заключение раздела рассмотрим операцию вычитания одного указателя из другого. С использованием данной операции реализуется, в частности, функция вычисления длины строки strlen стандартной библиотеки C/C++.
Суть операции вычитания одного указателя из другого состоит в следующем: если p1 и p2 указывают на элементы одного и того же массива , и при этом p1 < p2 , то значением выражения p2 – p1 + 1 будет количество элементов, расположенных в диапазоне от адресуемого p1 до адресуемого p2 включительно (см. рис. 7).
Вместо заключения
Значительная часть ошибок при работе с памятью связана с её утечками, т. е. с не освобождением или неправильным освобождением ранее выделенной динамической памяти. В частности, распространённой ошибкой является удаление массива объектов оператором delete вместо оператора delete[ ] . Такая подмена операторов может никак не проявиться при удалении массива объектов встроенного типа, но при разрушении массива сложных объектов, например массива строк (объектов типа std::string ), использование оператора delete приведёт к тому, что будет удалена только первая строка, остальные останутся в памяти. Для обнаружения ошибок подобного рода созданы специальные инструментальные средства, одно из которых было рассмотрено в первой части статьи.
Хуже обстоят дела с другим видом ошибок — ошибками, вызванными повреждением («протиркой») памяти. Такие ошибки возникают при обращениях по недействительным указателям, в том числе, при выходе в процессе обработки элементов массива за его пределы. Разыменование недействительного указателя и последующая модификация адресуемых им данных часто не заканчиваются аварийным завершением работы программы, а приводит к изменению состояния объекта, который может в этот момент находиться на месте ранее удаленного объекта, что неминуемо нарушает логику работы программы. Последствия подобных ошибок проявляются в совершенно непредсказуемых местах, причём добиться их стабильного проявления бывает весьма сложно.
Таким образом, работа с памятью требует особого внимания и аккуратности, некорректное обращение с нею приводит к довольно трудно выявляемым ошибкам и существенным потерям времени при разработке программного обеспечения.