sonyps4.ru

Использование new delete для реализации массивов. Работа с памятью с помощью new и delete

  • Tutorial

Привет! Ниже речь пойдет об известных всем операторах new и delete , точнее о том, о чем не пишут в книгах (по крайней мере в книгах для начинающих).
На написание данной статьи меня побудило часто встречаемое заблуждение по поводу new и delete , которое я постоянно вижу на форумах и даже(!!!) в некоторых книгах.
Все ли мы знаем, что такое на самом деле new и delete ? Или только думаем, что знаем?
Эта статья поможет вам разобраться с этим (ну, а те, кто знают, могут покритиковать:))

Note : ниже пойдет речь исключительно об операторе new, для других форм оператора new и для всех форм оператора delete все ниженаписанное также является правдой и применимо по аналогии.

Итак, начнем с того, что обычно пишут в книгах для начинающих, когда описывают new (текст взят «с потолка», но вцелом соответствует правде):

Оператор new выделяет память больше или равную требуемому размеру и, в отличие от функций языка С, вызывает конструктор(ы) для объекта(ов), под которые память выделена… вы можете перегрузить (где-то пишут реализовать) оператор new под свои нужды.

И для примера показывают примитивную перегрузку (реализацию) оператора new, прототип которого выглядит так
void* operator new (std::size_t size) throw (std::bad_alloc);

На что хочется обратить внимание:
1. Нигде не разделяют new key-word языка С++ и оператор new , везде о них говорят как об одной сущности.
2. Везде пишут, что new вызывает конструктор(ы) для объекта(ов).
И первое и второе является распространенным заблуждением.

Но не будем надеяться на книги для начинающих, обратимся к Стандарту, а именно к разделу 5.3.4 и к 18.6.1, в которых собственно и раскрывается (точнее приоткрывается) тема данной статьи.

5.3.4
The new-expression attempts to create an object of the type-id (8.1) or new-type-id to which it is applied. /*дальше нам не интересно*/
18.6.1
void* operator new(std::size_t size) throw(std::bad_alloc);
Effects: The allocation function called by a new-expression (5.3.4) to allocate size bytes of
storage suitably aligned to represent any object of that size /*дальше нам не интересно*/

Тут мы уже видим, что в первом случае new именуется как expression , а во втором он объявлен как operator. И это действительно 2 разные сущности!
Попробуем разобраться почему так, для этого нам понадобятся ассемблерные листинги, полученные после компиляции кода, использующего new. Ну, а теперь обо все по порядку.

new-expression - это оператор языка, такой же как if , while и т.д. (хотя if, while и т.д. все же именуются как statement , но отбросим лирику) Т.е. встречая его в листинге компилятор генерирует определенный код, соответствующий этому оператору. Так же new - это одно из key-words языка С++, что еще раз подтверждает его общность с if "ами, for" ами и т.п. А operator new() в свою очередь - это просто одноименная функция языка С++, поведение которой можно переопределить. ВАЖНО - operator new() НЕ вызывает конструктор(ы) для объекта(ов), под который(ые) выделяется память. Он просто выделяет память нужного размера и все. Его отличие от сишных функций в том, что он может бросить исключение и его можно переопределить, а так же сделать оператором для отдельно взятого класса, тем самым переопределить его только для этого класса (остальное вспомните сами:)).
А вот new-expression как раз и вызывает конструктор(ы) объекта(ов). Хотя правильней сказать, что он тоже ничего не вызывает, просто, встречая его, компилятор генерирует код вызова конструктора(ов).

Для полноты картины рассмотрим следующий пример:

#include class Foo { public: Foo() { std::cout << "Foo()" << std::endl; } }; int main () { Foo *bar = new Foo; }

После исполнения данного кода, как и ожидалось, будет напечатано «Foo()». Разберемся почему, для этого понадобится заглянуть в ассемблер, который я немного прокомментировал для удобства.
(код получен компилятором cl, используемым в MSVS 2012, хотя в основном я использую gcc, но это к делу не относится)
/Foo *bar = new Foo; push 1 ; размер в байтах для объекта Foo call operator new (02013D4h) ; вызываем operator new pop ecx mov dword ptr ,eax ; записываем указатель, вернувшийся из new, в bar and dword ptr ,0 cmp dword ptr ,0 ; проверяем не 0 ли записался в bar je main+69h (0204990h) ; если 0, то уходим отсюда (возможно вообще из main или в какой-то обработчик, в данном случае неважно) mov ecx,dword ptr ; кладем указатель на выделенную память в ecx (MSVS всегда передает this в ecx(rcx)) call Foo::Foo (02011DBh) ; и вызываем конструктор; дальше не интересно
Для тех, кто ничего не понял, вот (почти) аналог того, что получилось на сиподобном псевдокоде (т.е. не надо пробовать это компилировать:))
Foo *bar = operator new (1); // где 1 - требуемый размер bar->Foo(); // вызываем конструктор

Приведенный код подтверждает все, написанное выше, а именно:
1. оператор (языка) new и operator new() - это НЕ одно и тоже.
2. operator new() НЕ вызывает конструктор(ы)
3. вызов конструктора(ов) генерирует компилятор, встречая в коде key-word «new»

Итог: надеюсь, эта статья помогла вам понять разницу между new-expression и operator new() или даже узнать, что она (эта разница) вообще существует, если кто-то не знал.

P.S. оператор delete и operator delete() имеют аналогичное различие, поэтому в начале статьи я сказал, что не буду его описывать. Думаю, теперь вы поняли, почему его описание не имеет смысла и сможете самостоятельно проверить справедливость написанного выше для delete .

Update:
Хабражитель с ником khim в личной переписке предложил следующий код, который хорошо демонстрирует суть написанного выше.
#include class Test { public: Test() { std::cout << "Test::Test()" << std::endl; } void* operator new (std::size_t size) throw (std::bad_alloc) { std::cout << "Test::operator new(" << size << ")" << std::endl; return::operator new(size); } }; int main() { Test *t = new Test(); void *p = Test::operator new(100); // 100 для различия в выводе }
Этот код выведет следующее
Test::operator new(1) Test::Test() Test::operator new(100)
что и следовало ожидать.

Массивы и указатели на самом деле тесно связаны. Имя массива является указателем-константой , значением которой служит адрес первого элемента массива (&arr). Следовательно, имя массива может являться инициализатором указателя к которому будут применимы все правила адресной арифметики, связанной с указателями. Пример программы:
Программа 11.1

#include using namespace std; int main() { const int k = 10; int arr[k]; int *p = arr; // указатель указывает на первый элемент массива for (int i = 0; i < 10; i++){ *p = i; p++; // указатель указывает на следующий элемент } p = arr; // возвращаем указатель на первый элемент for (int i = 0; i < 10; i++){ cout << *p++ << " "; } cout << endl; // аналогично: for (int i = 0; i < 10; i++){ cout << *(arr + i) << " "; } cout << endl; p = arr; // выводим адреса элементов: for (int i = 0; i < 10; i++){ cout << "arr[" << i << "] => " << p++ << endl; } return 0; }

Вывод программы:

0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 arr => 0xbffc8f00 arr => 0xbffc8f04 arr => 0xbffc8f08 arr => 0xbffc8f0c arr => 0xbffc8f10 arr => 0xbffc8f14 arr => 0xbffc8f18 arr => 0xbffc8f1c arr => 0xbffc8f20 arr => 0xbffc8f24

Выражение arr[i] – обращение к элементу по индексу соответствует выражению *(arr + i) , которое называется указателем-смещением (строка 22). Это выражение более наглядно иллюстрирует, как C++ на самом деле работает с элементами массива. Переменная-счетчик i указывает на сколько элементов необходимо сместиться от первого элемента . В строке 17 значение элемента массива выводится после разыменования указателя.

Что означает выражение *p++ ? Оператор * имеет более низкий приоритет, в тоже время постфиксный инкремент ассоциативен слева-направо. Следовательно, в этом сложном выражении сначала будет выполняться косвенная адресация (получение доступа к значению элемента массива), а затем инкрементация указателя. Иначе это выражение можно было бы представить так: cout Примечание . Оператор sizeof() , применяемый к имени массива, вернет размер всего массива (а не первого элемента).
Примечание . Оператор взятия адреса (&) для элементов массива используется также, как и для обычных переменных (элементы массива иногда называют индексированными переменными). Например, &arr . Поэтому можно всегда получить указатель на любой элемент массива. Однако, операция &arr (где arr - имя массива) вернет адрес всего массива и такая, например, операция (&arr + 1) будет означать шаг размером с массив, т. е. получение указателя на элемент, следующий за последним.

Преимущества использования указателей при работе с элементами массива

Рассмотрим два примера программ приводящих к одинаковому результату: элементам массива присваиваются новые значения от 0 до 1999999 и осуществляется их вывод.
Программа 11.2

#include using namespace std; int main() { const int n = 2000000; int mass[n] {}; for (int i = 0; i < n; i++) { mass[i] = i; cout << mass[i]; } return 0; }

Программа 11.3

#include using namespace std; int main() { const int n = 2000000; int mass[n] {}; int *p = mass; for (int i = 0; i < n; i++) { *p = i; cout << *p++; } return 0; }

Программа 11.3 будет выполняться быстрее, чем программа 11.2 (с ростом количества элементов эффективность программы 11.3 будет возрастать)! Причина заключается в том, что в программе 11.2 каждый раз пересчитывается местоположение (адрес) текущего элемента массива относительно первого (11.2, строки 12 и 13). В программе 11.3 обращение к адресу первого элемента происходит один раз в момент инициализации указателя (11.3, строка 11).

Выход за границы массива

Отметим еще одну важный аспект работы с С-массивами в С++. В языке С++ отсутствует контроль соблюдения выхода за границы С-массива . Т. о. ответственность за соблюдение режима обработки элементов в пределах границ массива лежит целиком на разработчике алгоритма. Рассмотрим пример.
Программа 11.4

#include #include #include using namespace std; int main() { int mas; default_random_engine rnd(time(0)); uniform_int_distribution < 10; i++) mas[i] = d(rnd); cout << "Элементы массива:" << endl; for (int i = 0; i < 10; i++) cout << mas[i] << endl; return 0; }

Программа выведет приблизительно следующее:

Элементы массива: 21 58 38 91 23 5 38 -1219324996 -1074960992 0

В программе 11.4 умышленно допущена ошибка. Но компилятор не сообщит об ошибке: в массиве объявлено пять элементов, а в циклах подразумевается, что элементов 10! В итоге, правильно проинициализированы будут только пять элементов (далее возможно повреждение данных), они же и будут выведены вместе с "мусором". С++ предоставляет возможность контроля границ с помощью библиотечных функций begin() и end() (необходимо подключить заголовочный файл iterator). Модифицируем программу 11.4
Программа 11.5

#include #include #include #include using namespace std; int main() { int mas; int *first = begin(mas); int *last = end(mas); default_random_engine rnd(time(0)); uniform_int_distribution d(10, 99); while(first != last) { *first = d(rnd); first++; } first = begin(mas); cout << "Элементы массива:" << endl; while(first != last) { cout << *first++ << " "; } return 0; }

Функции begin() и end() возвращают . Понятие итераторов мы раскроем позже, а пока скажем, что они ведут себя как указатели, указывающие на первый элемент (first) и элемент, следующий за последним (last). В программе 11.5 мы, для компактности и удобства, заменили цикл for на while (поскольку счетчик нам уже здесь не нужен - мы используем арифметику указателей). Имея два указателя мы легко можем сформулировать условие выхода из цикла, так как на каждом шаге цикла указатель first инкрементируется.
Еще одним способом сделать обход элементов массива более безопасным основан на применении цикла range-based for , упомянутого нами в теме ()

Операции new и delete

До момента знакомства с указателями вам был известен единственный способ записи изменяемых данных в память посредством переменных. Переменная - это поименованная область памяти. Блоки памяти для соответствующих переменных выделяются в момент запуска программы и используются до прекращения ее работы. С помощью указателей можно создавать неименованные блоки памяти определенного типа и размера (а также освобождать их) в процессе работы самой программы. В этом проявляется замечательная особенность указателей, наиболее полно раскрывающаяся в объектно-ориентированном программировании при создании классов.
Динамическое выделение памяти осуществляется с помощью операции new . Синтаксис:

Тип_данных *имя_указателя = new тип_данных;

Например:

Int *a = new int; // Объявление указателя типа int int *b = new int(5); // Инициализация указателя

Правая часть выражения говорит о том, что new запрашивает блок памяти для хранения данных типа int . Если память будет найдена, то возвращается адрес, который присваивается переменной-указателем, имеющей тип int . Теперь получить доступ к динамически созданной памяти можно только с помощью указателей! Пример работы с динамической памятью показан в программе 3.
Программа 11.6

#include using namespace std; int main() { int *a = new int(5); int *b = new int(4); int *c = new int; *c = *a + *b; cout << *c << endl; delete a; delete b; delete c; return 0; }

После выполнения работы с выделенной памятью ее необходимо освободить (вернуть, сделать доступной для других данных) с помощью операции delete . Контроль над расходованием памяти - важная сторона разработки приложений. Ошибки, при которых память не освобождается, приводят к "утечкам памяти ", что, в свою очередь, может привести к аварийному завершению программы. Операция delete может применяться к нулевому указателю (nullptr) или созданному с помощью new (т. о. new и delete используются в паре).

Динамические массивы

Динамический массив - это массив, размер которого определяется в процессе работы программы. Строго говоря C-массив не является динамическим в C++. То есть, можно определять только размер массива, а изменение размера массива, в процессе работы программы, по-прежнему невозможно. Для получения массива нужного размера необходимо выделять память под новый массив и копировать в него данные из исходного, а затем освобождать память выделенную ранее под исходный массив. Подлинно динамическим массивом в C++ является тип , который мы рассмотрим позднее. Для выделения памяти под массив используется операция new . Синтаксис выделения памяти для массива имеет вид:
указатель = new тип[размер] . Например:

Int n = 10; int *arr = new int[n];

Освобождение памяти производится с помощью оператора delete:

Delete arr;

При этом размер массива не указывается.
Пример программы. Заполнить динамический целочисленный массив arr1 случайными числами. Показать исходный массив. Переписать в новый динамический целочисленный массив arr2 все элементы с нечетными порядковыми номерами (1, 3, ...). Вывести содержимое массива arr2 .
Программа 11.7

#include #include #include using namespace std; int main() { int n; cout << "n = "; cin >> n; int *arr1 = new int[n]; default_random_engine rnd(time(0)); uniform_int_distribution d(10, 99); for (int i = 0; i < n; i++) { arr1[i] = d(rnd); cout << arr1[i] << " "; } cout << endl; int *arr2 = new int; for (int i = 0; i < n / 2; i++) { arr2[i] = arr1; cout << arr2[i] << " "; } delete arr1; delete arr2; return 0; } n = 10 73 94 17 52 11 76 22 70 57 68 94 52 76 70 68

Мы знаем, что в C++ двумерный массив представляет собой массив массивов. Следовательно, для создания двумерного динамического массива необходимо выделять память в цикле для каждого входящего массива, предварительно определив количество создаваемых массивов. Для этого используется указатель на указатель , иными словами описание массива указателей:

Int **arr = new int *[m];

где m - количество таких массивов (строк двумерного массива).
Пример задачи. Заполнить случайными числами и вывести элементы двумерного динамического массива.
Программа 11.8

#include #include #include #include using namespace std; int main() { int n, m; default_random_engine rnd(time(0)); uniform_int_distribution d(10, 99); cout << "Введите количество строк:" << endl; cout << "m = "; cin >> m; cout << "введите количество столбцов:" << endl; cout << "n = "; cin >> n; int **arr = new int *[m]; // заполнение массива: for (int i = 0; i < m; i++) { arr[i] = new int[n]; for (int j = 0; j < n; j++) { arr[i][j] = d(rnd); } } // вывод массива: for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { cout << arr[i][j] << setw(3); } cout << "\n"; } // освобождение памяти выделенной для каждой // строки: for (int i = 0; i < m; i++) delete arr[i]; // освобождение памяти выделенной под массив: delete arr; return 0; } Введите количество строк: m = 5 введите количество столбцов: n = 10 66 99 17 47 90 70 74 37 97 39 28 67 60 15 76 64 42 65 87 75 17 38 40 81 66 36 15 67 82 48 73 10 47 42 47 90 64 22 79 61 13 98 28 25 13 94 41 98 21 28

Вопросы
  1. В чем заключается связь указателей и массивов?
  2. Почему использование указателей при переборе элементов массива более эффективно, нежели использование операции обращения по индексу ?
  3. В чем суть понятия "утечка памяти"?
  4. Перечислите способы предупреждения выхода за границы массива?
  5. Что такое динамический массив? Почему в С++ С-массив не является динамическим по существу?
  6. Опишите процесс создания динамического двумерного массива
Презентация к уроку
Домашнее задание

Используя динамические массивы решить следующую задачу: Дан целочисленный массив A размера N . Переписать в новый целочисленный массив B все четные числа из исходного массива (в том же порядке) и вывести размер полученного массива B и его содержимое.

Учебник

§62 (10) §40 (11)

Литература
  1. Лафоре Р. Объектно-ориентированное программирование в C++ (4-е изд.). Питер: 2004
  2. Прата, Стивен. Язык программирования C++. Лекции и упражнения, 6-е изд.: Пер. с англ. - М.: ООО «И.Д. Вильяме», 2012
  3. Липпман Б. Стенли, Жози Лажойе, Барбара Э. Му. Язык программирования С++. Базовый курс. Изд. 5-е. М: ООО "И. Д. Вильямс", 2014
  4. Эллайн А. C++. От ламера до программера. СПб.: Питер, 2015
  5. Шилдт Г. С++: Базовый курс, 3-изд. М.: Вильямс, 2010

Как известно, в языке С для динамического выделения и освобождения памяти используются фун­кции malloc() и free(). Вместе с тем С++ содержит два оператора, выполняющих выделение и освобождение памяти более эффективно и более просто. Этими операторами являются new и delete. Их общая форма имеет вид:

переменная_указатель = new тип_переменной;

delete переменная_указатель;

Здесь переменная_указaтель является указателем типа тип_переменной. Оператор new выделяет память для хранения значения типа тип_переменной и возвращает ее адрес. С помощью new могут быть размещены любые типы данных. Оператор delete освобождает память, на которую указывает указатель переменная_указатель.

Если операция выделения памяти не может быть выполнена, то оператор new генерирует ис­ключение типа xalloc. Если программа не перехватит это исключение, тогда она будет снята с выполнения. Хотя для коротких программ такое поведение по умолчанию является удовлетвори­тельным, для реальных прикладных программ обычно требуется перехватить исключение и обра­ботать его соответствующим образом. Для того чтобы отследить это исключение, необходимо вклю­чить заголовочный файл except.h.

Оператор delete следует использовать только для указателей на память, выделенную с исполь­зованием оператора new. Использование оператора delete с другими типами адресов может по­родить серьезные проблемы.

Есть ряд преимуществ использования new перед использованием malloc(). Во-первых, оператор new автоматически вычисляет размер необходимой памяти. Нет необходимости в использовании оператора sizeof(). Более важно то, что он предотвращает случайное выделение неправильного количества памяти. Во-вторых, оператор new автоматически возвращает указатель требуемого типа, так что нет необходимости в использовании оператора преобразования типа. В-третьих, как ско­ро будет описано, имеется возможность инициализации объекта при использовании оператора new. И наконец, имеется возможность перегрузить оператор new и оператор delete глобально или по отношению к тому классу, который создается.

Ниже приведен простой пример использования операторов new и delete. Следует обратить вни­мание на использование блока try/catch для отслеживания ошибок выделения памяти.

#include
#include
int main()
{
int *p;
try {
p = new int; // выделение памяти для int
} catch (xalloc xa) {
cout << "Allocation failure.\n";
return 1;
}
*p = 20; // присвоение данному участку памяти значения 20
cout << *р; // демонстрация работы путем вывода значения
delete р; // освобождение памяти
return 0;
}

Эта программа присваивает переменной р адрес блока памяти, имеющего достаточный размер для того, чтобы содержать число целого типа. Далее этой памяти присваивается значение и содер­жимое памяти выводится на экран. Наконец, динамически выделенная память освобождается.

Как отмечалось, можно инициализировать память с использованием оператора new. Для этого надо указать инициализирующее значение в скобках после имени типа. Например, в следующем примере память, на которую указывает указатель р, инициализируется значением 99:

#include
#include
int main()
{
int *p;
try {
p = new int (99); // инициализация 99-ю
} catch (xalloc xa) {
cout << "Allocation failure.\n";
return 1;
}
cout << *p;
delete p;
return 0;
}

С помощью new можно размещать массивы. Общая форма для одномерного массива имеет вид:

переменная_указатель = new тип_переменной [размер];

Здесь размер определяет число элементов в массиве. Необходимо запомнить важное ограничение при размещении массива: его нельзя инициализировать.

Для освобождения динамически размещенного массива необходимо использовать следующую форму оператора delete:

delete переменная_указатель;

Здесь скобки информируют оператор delete, что необходимо освободить память, выделенную для массива.

В следующей программе выделяется память для массива из 10 элементов типа float. Элементам массива присваиваются значения от 100 до 109, а затем содержимое массива выводится на экран:

#include
#include
int main()
{
float *p;
int i;
try {
p = new float ; // получение десятого элемента массива
} catch(xalloc xa) {
cout << "Allocation failure.\n";
return 1;
}
// присвоение значений от 100 до 109
for (i=0; i<10; i + +) p[i] = 100.00 + i;
// вывод содержимого массива
for (i=0; i<10; i++) cout << p[i] << " ";
delete p; // удаление всего массива
return 0;
}



Загрузка...