Управляющие конструкции языка Си. Представление программ в виде функций. Работа с памятью. Структуры
Работа с памятью
В традиционных языках программирования, таких как Си, Фортран, Паскаль, существуют три вида памяти: статическая, стековая и динамическая. Конечно, с физической точки зрения никаких различных видов памяти нет: оперативная память - это массив байтов, каждый байт имеет адрес, начиная с нуля. Когда говорится о видах памяти, имеются в виду способы организации работы с ней, включая выделение и освобождение памяти, а также методы доступа.
Статическая память
Статическая память выделяется еще до начала работы программы, на стадии компиляции и сборки. Статические переменные имеют фиксированный адрес, известный до запуска программы и не изменяющийся в процессе ее работы. Статические переменные создаются и инициализируются до входа в функцию main, с которой начинается выполнение программы.
Существует два типа статических переменных:
-
глобальные переменные - это переменные, определенные вне функций, в описании которых отсутствует слово static. Обычно описания глобальных переменных, включающие слово extern, выносятся в заголовочные файлы (h-файлы). Слово extern означает, что переменная описывается, но не создается в данной точке программы. Определения глобальных переменных, т.е. описания без слова extern, помещаются в файлы реализации (c-файлы или cpp-файлы). Пример: глобальная переменная maxind описывается дважды:
- в h-файле с помощью строкиэто описание сообщает о наличии такой переменной, но не создает эту переменную!
extern int maxind;
- в cpp-файле с помощью строкиэто описание создает переменную maxind и присваивает ей начальное значение 1000. Заметим, что стандарт языка не требует обязательного присвоения начальных значений глобальным переменным, но, тем не менее, это лучше делать всегда, иначе в переменной будет содержаться непредсказуемое значение (мусор, как говорят программисты). Инициализация всех глобальных переменных при их определении - это правило хорошего стиля.
int maxind = 1000;
- в h-файле с помощью строки
-
статические переменные - это переменные, в описании которых присутствует слово static. Как правило, статические переменные описываются вне функций. Такие статические переменные во всем подобны глобальным, с одним исключением: область видимости статической переменной ограничена одним файлом, внутри которого она определена, - и, более того, ее можно использовать только после ее описания, т.е. ниже по тексту. По этой причине описания статических переменных обычно выносятся в начало файла. В отличие от глобальных переменных, статические переменные никогда не описываются в h-файлах (модификаторы extern и static конфликтуют между собой).
Совет: используйте статические переменные, если нужно, чтобы они были доступны только для функций, описанных внутри одного и того же файла. По возможности не применяйте в таких ситуациях глобальные переменные, это позволит избежать конфликтов имен при реализации больших проектов, состоящих из сотен файлов.
- Статическую переменную можно описать и внутри функции, хотя обычно так никто не делает. Переменная размещается не в стеке, а в статической памяти, т.е. ее нельзя использовать при рекурсии, а ее значение сохраняется между различными входами в функцию. Область видимости такой переменной ограничена телом функции, в которой она определена. В остальном она подобна статической или глобальной переменной.
Заметим, что ключевое слово static в языке Си используется для двух различных целей:
- как указание типа памяти: переменная располагается в статической памяти, а не в стеке;
- как способ ограничить область видимости переменной рамками одного файла (в случае описания переменной вне функции).
- Статическую переменную можно описать и внутри функции, хотя обычно так никто не делает. Переменная размещается не в стеке, а в статической памяти, т.е. ее нельзя использовать при рекурсии, а ее значение сохраняется между различными входами в функцию. Область видимости такой переменной ограничена телом функции, в которой она определена. В остальном она подобна статической или глобальной переменной.
Заметим, что ключевое слово static в языке Си используется для двух различных целей:
- Слово static может присутствовать и в заголовке функции. При этом оно используется только для того, чтобы ограничить область видимости имени функции рамками одного файла. Пример:Совет: используйте модификатор static в заголовке функции, если известно, что функция будет вызываться лишь внутри одного файла. Слово static должно присутствовать как в описании прототипа функции, так и в заголовке функции при ее реализации.
static int gcd(int x, int y); // Прототип ф-ции . . . static int gcd(int x, int y) { // Реализация . . . }
Стековая, или локальная, память
Локальные, или стековые, переменные - это переменные, описанные внутри функции. Память для таких переменных выделяется в аппаратном стеке, см. раздел 2.3.2. Память выделяется в момент входа в функцию или блок и освобождается в момент выхода из функции или блока. При этом захват и освобождение памяти происходят практически мгновенно, т.к. компьютер только изменяет регистр, содержащий адрес вершины стека.
Локальные переменные можно использовать при рекурсии, поскольку при повторном входе в функцию в стеке создается новый набор локальных переменных, а предыдущий набор не разрушается. По этой же причине локальные переменные безопасны при использовании нитей в параллельном программировании (см. раздел 2.6.2). Программисты называют такое свойство функции реентерабельностью, от англ. re-enter able - возможность повторного входа. Это очень важное качество с точки зрения надежности и безопасности программы! Программа, работающая со статическими переменными, этим свойством не обладает, поэтому для защиты статических переменных приходится использовать механизмы синхронизации (см. 2.6.2), а логика программы резко усложняется. Всегда следует избегать использования глобальных и статических переменных, если можно обойтись локальными.
Недостатки локальных переменных являются продолжением их достоинств. Локальные переменные создаются при входе в функцию и исчезают после выхода из нее, поэтому их нельзя использовать в качестве данных, разделяемых между несколькими функциями. К тому же, размер аппаратного стека не бесконечен, стек может в один прекрасный момент переполниться (например, при глубокой рекурсии), что приведет к катастрофическому завершению программы. Поэтому локальные переменные не должны иметь большого размера. В частности, нельзя использовать большие массивы в качестве локальных переменных.
Динамическая память, или куча
Помимо статической и стековой памяти, существует еще практически неограниченный ресурс памяти, которая называется динамическая, или куча ( heap ). Программа может захватывать участки динамической памяти нужного размера. После использования ранее захваченный участок динамической памяти следует освободить.
Под динамическую память отводится пространство виртуальной памяти процесса между статической памятью и стеком. (Механизм виртуальной памяти был рассмотрен в разделе 2.6.) Обычно стек располагается в старших адресах виртуальной памяти и растет в сторону уменьшения адресов (см. раздел 2.3). Программа и константные данные размещаются в младших адресах, выше располагаются статические переменные. Пространство выше статических переменных и ниже стека занимает динамическая память:
адрес | содержимое памяти |
---|---|
0 4 8 |
код программы и данные, защищенные от изменения |
... |
статические переменные программы |
динамическая память | |
max. адрес (232-4) |
стек |
Структура динамической памяти автоматически поддерживается исполняющей системой языка Си или C++. Динамическая память состоит из захваченных и свободных сегментов, каждому из которых предшествует описатель сегмента. При выполнении запроса на захват памяти исполняющая система производит поиск свободного сегмента достаточного размера и захватывает в нем отрезок требуемой длины. При освобождении сегмента памяти он помечается как свободный, при необходимости несколько подряд идущих свободных сегментов объединяются.
В языке Си для захвата и освобождения динамической памяти применяются стандартные функции malloc и free, описания их прототипов содержатся в стандартном заголовочном файле " stdlib.h ". (Имя malloc является сокращением от memory allocate - "захват памяти".) Прототипы этих функций выглядят следующим образом:
void *malloc(size_t n); // Захватить участок памяти // размером в n байт void free(void *p); // Освободить участок // памяти с адресом p
Здесь n - это размер захватываемого участка в байтах, size_t - имя одного из целочисленных типов, определяющих максимальный размер захватываемого участка. Тип size_t задается в стандартном заголовочном файле " stdlib.h " с помощью оператора typedef (см. c. 117). Это обеспечивает независимость текста Си-программы от используемой архитектуры. В 32-разрядной архитектуре тип size_t определяется как беззнаковое целое число:
typedef unsigned int size_t;
Функция malloc возвращает адрес захваченного участка памяти или ноль в случае неудачи (когда нет свободного участка достаточно большого размера). Функция free освобождает участок памяти с заданным адресом. Для задания адреса используется указатель общего типа void*. После вызова функции malloc его необходимо привести к указателю на конкретный тип, используя операцию приведения типа, см. раздел 3.4.11. Например, в следующем примере захватывается участок динамической памяти размером в 4000 байтов, его адрес присваивается указателю на массив из 1000 целых чисел:
int *a; // Указатель на массив целых чисел . . . a = (int *) malloc(1000 * sizeof(int));
Выражение в аргументе функции malloc равно 4000, поскольку размер целого числа sizeof(int) равен четырем байтам. Для преобразования указателя используется операция приведения типа (int *) от указателя обобщенного типа к указателю на целое число.
Пример: печать n первых простых чисел
Рассмотрим пример, использующий захват динамической памяти. Требуется ввести целое цисло n и напечатать n первых простых чисел. (Простое число - это число, у которого нет нетривиальных делителей.) Используем следующий алгоритм: последовательно проверяем все нечетные числа, начиная с тройки (двойку рассматриваем отдельно). Делим очередное число на все простые числа, найденные на предыдущих шагах алгоритма и не превосходящие квадратного корня из проверяемого числа. Если оно не делится ни на одно из этих простых чисел, то само является простым; оно печатается и добавляется в массив найденных простых.
Поскольку требуемое количество простых чисел n до начала работы программы неизвестно, невозможно создать массив для их хранения в статической памяти. Выход состоит в том, чтобы захватывать пространство под массив в динамической памяти уже после ввода числа n. Вот полный текст программы:
#include <stdio.h> #include <stdlib.h> #include <math.h> int main() { int n; // Требуемое количество простых чисел int k; // Текущее количество найденных простых чисел int *a; // Указатель на массив найденных простых int p; // Очередное проверяемое число int r; // Целая часть квадратного корня из p int i; // Индекс простого делителя bool prime; // Признак простоты printf("Введите число простых: "); scanf("%d", &n); if (n <= 0) // Некорректное значение => return 1; // завершаем работу с кодом ошибки // Захватываем память под массив простых чисел a = (int *) malloc(n * sizeof(int)); a[0] = 2; k = 1; // Добавляем двойку в массив printf("%d ", a[0]); // и печатаем ее p = 3; while (k < n) { // Проверяем число p на простоту r = (int)( // Целая часть корня sqrt((double) p) + 0.001 ); i = 0; prime = true; while (i < k && a[i] <= r) { if (p % a[i] == 0) { // p делится на a[i] prime = false; // => p не простое, break; // выходим из цикла } ++i; // К следующему простому делителю } if (prime) { // Если нашли простое число, a[k] = p; // то добавляем его в массив ++k; // Увеличиваем число простых printf("%d ", p); // Печатаем простое число if (k % 5 == 0) { // Переход на новую строку printf("\n"); // после каждых пяти чисел } } p += 2; // К следующему нечетному числу } if (k % 5 != 0) { printf("\n"); // Перевести строку } // Освобождаем динамическую память free(a); return 0; }
Пример работы данной программы:
Введите число простых: 50 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 101 103 107 109 113 127 131 137 139 149 151 157 163 167 173 179 181 191 193 197 199 211 223 227 229
Операторы new и delete языка C++
В языке C++ для захвата и освобождения динамической памяти используются операторы new и delete. Они являются частью языка C++, в отличие от функций malloc и free, входящих в библиотеку стандартных функций Си.
Пусть T - некоторый тип языка Си или C++, p - указатель на объект типа T. Тогда для захвата памяти размером в один элемент типа T используется оператор new:
T *p; p = new T;
Например, для захвата восьми байтов под вещественное число типа double используется фрагмент
double *p; p = new double;
При использовании new, в отличие от malloc, не нужно приводить указатель от типа void* к нужному типу: оператор new возвращает указатель на тип, записанный после слова new. Сравните два эквивалентных фрагмента на Си и C++:
Конечно, второй фрагмент гораздо короче и нагляднее.
Оператор new удобен еще и тем, что можно присвоить начальное значение объекту, созданному в динамической памяти (т.е. выполнить инициализацию объекта). Для этого начальное значение записывается в круглых скобках после имени типа, следующего за словом new. Например, в приведенной ниже строке захватывается память под вещественное число, которому присваивается начальное значение 1.5:
double *p = new double(1.5);
Этот фрагмент эквивалентен фрагменту
double *p = new double; *p = 1.5;
С помощью оператора new можно захватывать память под массив элементов заданного типа. Для этого в квадратных скобках указывается длина захватываемого массива, которая может представляться любым целочисленным выражением. Например, в следующем фрагменте в динамической памяти захватывается область для хранения вещественной матрицы размера m*n:
double *a; int m = 100, n = 101; a = new double[m * n];
Такую форму оператора new иногда называют векторной.
Оператор delete освобождает память, захваченную ранее с помощью оператора new, например,
double *p = new double(1.5); // Захват и инициализация . . . delete p; // Освобождение памяти
Если память под массив была захвачена с помощью векторной формы оператора new, то для ее освобождения следует использовать векторную форму оператора delete, в которой после слова delete записываются пустые квадратные скобки:
double *a = new double[100]; // Захватываем массив . . . delete[] a; // Освобождаем массив
- Для массивов, состоящих из элементов базовых типов Си, при освобождении памяти можно использовать и обычную форму оператора delete. Единственное отличие векторной формы: при освобождении массива элементов класса, в котором определен деструктор, т.е. завершающее действие перед уничтожением объекта, этот деструктор вызывается для каждого элемента уничтожаемого массива. Поскольку для базовых типов деструкторы не определены, векторная и обычная формы оператора delete для них эквивалентны.
Приятная особенность оператора delete состоит в том, что при освобождении нулевого указателя ничего не происходит. Например, следующий фрагмент вполне корректен:
double *a = 0; // Нулевой указатель bool b; . . . if (b) { a = new double[1000]; . . . } . . . delete[] a;
Здесь в указатель a вначале записывается нулевой адрес. Затем, если справедливо некоторое условие, захватывается память под массив. Таким образом, при выполнении оператора delete указатель a содержит либо нулевое значение, либо адрес массива. В первом случае оператор delete ничего не делает, во втором освобождает память, занятую массивом. Такая технология применяется практически всеми программистами на C++: всегда инициализировать указатели на динамическую память нулевыми значениями и в результате не иметь никаких проблем при освобождении памяти.
Попытка освобождения нулевого указателя с помощью стандартной функции free может привести к аварийному завершению программы (это зависит от используемой Си-библиотеки: нормальная работа не гарантируется стандартом ANSI).