- Подписка на печатную версию:
- Подписка на электронную версию:
- Подшивки старых номеров журнала (печатные версии)
LXF102:Куда течет память?
Материал из Linuxformat.
Содержание |
Куда течет память?
- Андрей Кузьменко рассмотрит типовые ошибки и заблуждения, связанные с динамическими массивами в C++, и даст рекомендации по надежному программированию.
Одной из самых острых проблем при программировании на языке С++ является утечка памяти. Под этим понимается ситуация, когда память, выделенная динамически для некоторого объекта программы (переменной, массива, списка), не возвращается в систему, а продолжает числиться занятой, даже после того, как отпала необходимость в объекте, для которого эта память выделялась. В языке С++ нет системы сборки мусора, поэтому вся ответственность за корректную работу по выделению и освобождению динамической памяти целиком ложится на программиста.
Утечки памяти – это беда для всех. Операционная система не может запустить программу, а программист не может использовать динамическую память по своему усмотрению, поскольку ее просто может не быть. «Крайним» в этой ситуации становится пользователь, у которого некорректно работает и программа, и операционная система.
Не будем забывать о том, что помимо персональных компьютеров с большим объемом памяти сейчас все активнее используются смартфоны, КПК, игровые приставки, в которых оперативная память – очень ценный ресурс, поскольку ее мало. Компьютеры проникают в нашу жизнь все глубже и глубже, и сбои в программном обеспечении могут стать причиной больших бед.
Кто подскажет адрес?
Указатель в языке С++ может иметь в качестве значения как адрес одной переменной, так и адрес начала области памяти для массива. «Самостоятельные» многомерные массивы отсутствуют. По правилам синтаксиса существуют только одномерные (линейные) массивы, элементами которых, в свою очередь, тоже могут быть массивы.
При определении массива в программе для него выделяется память, после чего имя массива воспринимается как константный указатель того типа, к которому относятся элементы массива. Справедлива следующая запись:
имя_массива = = &имя_массива[0] = = &имя_массива
Для работы с многомерными массивами применяются особые указатели на массивы, адресуемыми элементами которых являются массивы элементов базового типа. Запись int *mas[10] говорит о том, что в программе определен десятиэлементный массив с именем mas, элементами которого являются указатели на переменные типа int. С другой стороны, этот массив можно рассматривать как массив указателей на массивы с элементами типа int.
Объявление int **mas можно трактовать четырьмя способами. Опишем их все подробно.
Вариант №1
Указатель на одиночный указатель на переменную типа int.
int a, *q, **mas; a=5; q=&a; mas=&q; // В результате будет трижды выведено значения содержимого ячейки памяти, // к которой можно обратиться как по имени, так и посредством адресации через // указатели. cout<<” A = “<<a<<endl; cout<<” A = “<<*q<<endl; cout<<” A = “<<**mas<<endl;
Вариант №2
Указатель на одиночный указатель на массив типа int.
int arr[5] = {11,22,33,44,55}; int *q; int **mas; q = &arr[0]; mas = &q; // Трижды будет выведено значение элемента массива arr[3], // адресуемое различными способами. cout<<” arr[3] = “<<arr[3]<<endl; cout<<” arr[3] = “<<*(q+3)<<endl; cout<<” arr[3] = “<<*(*mas+3)<<endl;
Вариант №3
Указатель на массив, содержащий указатели на одиночные переменные типа int.
int a=1, b=2, c=3; int *arr[3] = {&a, &b, &c}; int **mas=&arr[0]; // Хотим получить значение из ячейки памяти, адресуемой элементом массива arr. cout<<” arr[1] value is “<<*arr[1]<<endl; cout<<” arr[1] value is “<<*mas[1]<<endl;
Вариант №4
Указатель на массив, содержащий указатели на массивы переменных типа int.
int mas1[2]={1,2}; int mas2[2]={10,20}; int *arr[2]={&mas1[0], &mas2[0]}; int **mas = &arr[0]; // Хотим получить значение элемента с индексами [1][0] cout<<” M[1][0] = “<<mas[1][0]<<endl;
Если в своей программе мы хотим оперировать с двумерным массивом (матрицей), размер которого заранее не известен (но станет известен в ходе выполнения программы), можно использовать массивы указателей на массивы и аппарат динамического выделения памяти.
Проиллюстрируем вышесказанное программным кодом. Пусть в программе нам нужен целочисленный массив размером N х N, память для которого выделяется динамически в ходе выполнения программы:
int **mas; int n=3; mas=new int *[n]; for(int i=0; i<n; i++) { mas[i] = new int [n];}
Теперь мы можем обращаться к элементам двумерного массива, используя стандартную процедуру индексации, например, mas[1][1] = 7.
Данный подход к реализации многомерных динамических массивов (матриц), основанный на использовании массива указателей на массивы элементов соответствующего типа, рассмотрен, например, в книгах [1], [2], [3], и активно там используется.
Однако в том, каким образом следует правильно освобождать память, выделенную для динамических массивов (одномерных и многомерных), существует ряд заблуждений, наличие которых в учебной литературе вызывает удивление и настороженность.
Теория
Согласно правилам языка C++, когда объект создается динамически посредством вызова оператора new, происходят два события: во-первых, выделяется память, во-вторых, для этой памяти вызывается конструктор. В случае, если память выделяется под массив объектов размерности N, будут выполнены конструкторы N объектов. Применение оператора delete, в свою очередь, осуществляет вызов одного или, в случае массива, нескольких деструкторов и возвращает память системе. Для компилятора важно знать, является ли указатель в операторе delete указателем на одиночный объект или на массив. «Самостоятельно» он этого может не суметь, и поэтому полагается на программиста. Если нужно удалить массив объектов, то, в соответствии с синтаксисом, программист должен написать delete [ ] указатель_на_массив, а если объект одиночный, то delete указатель_на_объект. При выделении памяти для массива компилятором запоминается его размер, чтобы оператор delete «знал», сколько деструкторов ему нужно вызвать. При выделении памяти под одиночный объект такая информация не используется. Аналогично, при выделении памяти под массив программист пишет: указатель_на_массив = new тип_данных [размер_массива]. Опираясь на синтаксис языка C++, можно сформулировать простое правило: если в операторе new используется индексация [ ] для указателя, то и в операторе delete для этого указателя следует использовать [ ]. Следование этому правилу позволяет гарантировать вызов деструкторов для всех элементов динамического массива и возврат всей выделенной памяти в систему.
Практика
Теперь можно на конкретных примерах рассмотреть заблуждения, присутствующие в известной и популярной учебной литературе, которые связаны с динамическими массивами. Однако перед этим сделаем важное замечание: если тип элементов массива является простым базовым типом (int, float, long и т.п.), то использование delete вместо delete [ ] допускается и не вызывает утечек памяти, что подтверждается тестированием с помощью компиляторов GCC версий 3.x и 4.x, Visual C++ 9.0 Express Edition, Borland Turbo C++ 2006 и Open Watcom C++ 1.6. Однако «великодушие» компилятора – это не повод пренебрегать стандартом и принципами надежного программирования. Нет никаких гарантий, что любые другие компиляторы С++ будут всегда действовать точно так же. Кроме того, когда элементами массива становятся объекты классов, ситуация принципиально меняется.
Обратимся к книге [1]. Раздел 3.2 «Динамические переменные и массивы». На стр. 203 приведено утверждение: «оператор delete получает указатель на уничтожаемую переменную или массив». Далее следует программный код:
double *pdm; //массив динамических переменных pdm = new double [20]; if(pdm!=NULL) { for(i=0;i<20;i++) pdm[i]=0; delete pd; }
В данном фрагменте присутствует опечатка. Поскольку речь идет о массиве с именем pdm, должно быть, вероятно, delete pdm, но дело, конечно, не в этом. Мы видим (и компилятор тоже «видит»), что удалять требуется не массив, а «всего лишь» одну переменную типа double! В данной ситуации в программе может происходить следующее: память, принадлежащая элементам с 1 по 19, вполне возможно, останется в системе «балластом»! Гарантированно удален только нулевой элемент pdm[0] (на рисунке обозначен символом Х), и только для него гарантированно будет освобождена память.
При этом значение указателя pdm не определено. На практике оно может быть любым, и тут все зависит только от реализации конкретного компилятора. Если далее в программе встретится код:
cout<<” pdm[5] = “<<pdm[5]<<endl;
то велика вероятность того, что мы получим корректное «старое» значение, откуда можно сделать вывод о том, что «раз ничего не удалилось, то удалим это снова с помощью delete pdm». Повторное применение delete к указателю в данном случае приводит к неопределенному поведению программы.
На стр. 204 автор пишет: «Заметим, что оператор delete, функции free и realloc не содержат размерности возвращаемой области памяти. Очевидно, что библиотека, управляющая динамической памятью, должна сохранять информацию о размерности выделенных блоков.» Все правильно. Это действительно так, и мы обсуждали это выше. Однако автор не говорит читателю о том, что сохраненный размер блока выделенной памяти будет гарантированно использован только при вызове delete [ ] указатель_на_массив.
Любопытно, что на стр. 360 автор пишет: «В Си++ возможно определение массива объектов класса. При этом конструктор и деструктор автоматически вызываются в цикле для каждого элемента массива и не должны иметь параметров. При выполнении оператора delete, кроме указателя на массив объектов, необходимо также указывать его размерность.» Далее следует программный код:
class date{…}; void main( ) { int i,n; cin>>n; dat *p = new dat[n]; delete [n] p; }
В этом тексте, видимо, опять присутствует опечатка: должно быть date *p=new date[n]. Но важно другое. Автор говорит о том, что правильная форма delete для массива – это форма со скобками [ ]. Синтаксически корректно записать в скобках размерность: delete [n] p, однако, скомпилировав этот пример, в среде Borland C++, мы получим предупреждение «Array size for ‘delete’ ignored» [другие компиляторы могут посмотреть на это, как на ошибку, – прим. ред.]. Анализируя этот пример, можно сделать вывод, что автор понимает необходимость использования скобок в операторе delete для освобождения памяти, занятой динамическим массивом.
Увы! На стр. 367 приводится листинг класса matrix (матрица – двумерный массив). Программный код:
class matrix{ int n,m; //размерности матрицы y,x (снова опечатка?) double **pd; //указатель на ДМУ (Динамический Массив Указателей) на строки //… }; //----Деструктор matrix::~matrix(){ for(int i=0; i<n; i++) delete pd[i]; delete pd; }
К сожалению, он некорректен. Обратимся к графической интерпретации результата работы деструктора:
При выполнении вышеописанного деструктора будут гарантированно удалены элементы и освобождена память для ячеек, помеченных «крестиком». Все остальные элементы динамической матрицы, возможно, останутся «висеть» мертвым грузом.
Обратимся к книге [2]. Глава 6, «Алгоритмы обработки графов». Стр. 362. Листинг 6.10. Алгоритм нахождения транзитивного замыкания. Программный код:
class MatrixGraph{ bool **graph; int vertexNumber; //… }; //Деструктор MatrixGraph::~MatrixGraph( ){ For(int i=0; i<vertexNumber; i++) { delete graph[i];} delete graph;}
Эта ситуация нам знакома – ее графическая интерпретация представлена выше. Здесь опять гарантированно удаляются элементы, и освобождается память для ячеек, помеченных «крестиком»! Судьба остальных элементов определяется исключительно компилятором.
Обратимся теперь к книге [3]. Глава 2 «Лексические основы языка Си++». Раздел 2.4 «Знаки операций». Цитата: «Для освобождения памяти, выделенной для массива, используется следующая модификация того же оператора:
delete [ ] указатель;
где указатель связан с выделенным для массива участком памяти.» Теперь раздел 5.4 «Многомерные массивы, массивы указателей, динамические массивы». Стр. 156. Программа Р5-20.СРР – единичная диагональная матрица с изменяемым порядком. Собственно код:
int n; //порядок матрицы //… float **matr; //указатель для массива указателей //… for(i=0; i<n; i++) delete matr[i]; delete [ ] matr;
Рассмотрим графическую интерпретацию освобождения памяти в этом случае:
Как мы видим, массив указателей на массивы удален полностью, поскольку использована правильная форма оператора delete: delete [ ] matr. Код надежен, и результат гарантирован вне зависимости от компилятора. Однако вместо delete [ ] matr[i] автор пишет delete matr[i]. Результат – в системе может остаться «мусор»…
Резюмируя все примеры, можно сказать, что опасное заблуждение заключается в том, что понятие «освобождение памяти» тождественно понятию «удаление указателя». «Удалил» указатель, значит, освободил память – а в языке C++ это не всегда так.
Возникает резонный вопрос: а откуда, собственно, это заблуждение взялось? На мой взгляд, дело вот в чем. Подавляющее большинство программистов на С++ начинали свой профессиональный путь с языка С. В нем нет понятия «класс» и «объект», «конструктор» и «деструктор». Допустим, мы хотим написать на языке С программу, которая сформирует динамическую целочисленную квадратную матрицу, на главной диагонали которой будут находиться элементы, равные 5. Ее код может выглядеть так:
#include <stdio.h> #include <stdlib.h> int main(void) { int i,j,n; int **matrix; /*наша матрица*/ /*узнаем размер матрицы*/ printf(“ Input matrix size:”); scanf(“%d”,&n); /*выделим память*/ matrix = (int **)calloc(n,sizeof(int *)); for(i=0;i<n;i++) { matrix[i] = (int *)calloc(n, sizeof(int)); } /*зададим значения элементам матрицы*/ for(i=0;i<n;i++) { for(j=0;j<n;j++) { if(j==i) matrix[i][j]=5; else matrix[i][j]=0; } } /*выведем матрицу на экран*/ printf(“ Matrix:\n”); for(i=0; i<n; i++) { for(j=0; j<n; j++) { printf(“\t%d”, matrix[i][j]); } printf(“\n”); } printf(“\n”); /*освободим память*/ for(i=0; i<n; i++) { free(matrix[i]);} free(matrix); return 0; }
Каким образом освобождается память в этой программе? Во-первых, в цикле освобождается память, занимаемая массивами, адресуемыми элементами массива указателей. Во-вторых, освобождается память, занимаемая массивом указателей.
Тот, кто начинал с языка С, пишет в программах delete mas[i] так, как раньше писал free(mas[i]), то есть «автоматом» меняет free() на delete.
Опасность подобных заблуждений очевидна. Восприятие языка С++ как «улучшенного C» играет с программистом злую шутку. Как только начинается использование динамических массивов, элементами которых являются объекты пользовательских классов, проблема утечки памяти проявляется на новом уровне. Простая программа:
#include <iostream> using namespace std; // класс с «говорящими» конструктором и деструктором class ANY { public: ANY( ) { cout<<” A-constructor”<<endl;} ~ANY( ){ cout<<” A-destructor”<<endl;} }; int main(void) { ANY **mas; // матрица объектов ANY int n=2; // размером 2x2 int i; //Выделяем память для динамической матрицы 2х2 mas = new ANY *[n]; for(i=0; i<n; i++) { mas[i] = new ANY [n];}; /*НЕПРАВИЛЬНОЕ освобождение памяти */ for(i=0; i<n; i++) { delete mas[i];} delete mas; return 0; }
ANY-конструкторов было вызвано четыре, а сколько было вызвано ANY-деструкторов?
Совет на дорожку
Основываясь на анализе приведенных примеров, хочется дать следующие рекомендации по надежному программированию:
- Если в операторе new использовались скобки [ ], то и в операторе delete должны использоваться скобки [ ]. Только благодаря скобкам [ ] в операторе delete компилятор может гарантированно «понять», что нужно освободить память, отведенную для массива.
- Синтаксически корректные вызовы оператора delete для массивов (с использованием скобок [ ]) должны быть одинаковы как для пользовательских типов данных (классов), так и для встроенных типов данных (int, char, double). Отсутствие деструкторов для встроенных типов данных не должно быть поводом для использования неправильной синтаксической формы оператора delete.
- После выполнения delete имя_указателя следует написать имя_указателя=NULL. Повторное применение оператора delete к уже освобожденному указателю дает неопределенный результат, применение же delete к NULL-указателю безопасно.
Литература
- ↑ 1,0 1,1 Романов, Е.Л. Практикум по программированию на С++: Уч. пособие. СПб.: БХВ-Петербург; Новосибирск: Изд-во НГТУ, 2004 г.
- ↑ 2,0 2,1 Кубенский, А.А. Структуры и алгоритмы обработки данных: объектно-ориентированный подход и реализация на С++. СПб.: БХВ-Петербург, 2004 г.
- ↑ 3,0 3,1 Подбельский, В.В. Язык СИ++: Учеб. пособие. – 5-е изд. – М.: «Финансы и статистика», 2005 г.