План лекции
  • Указатели и ссылки
    • Еще раз: адрес по переменной и переменная по адресу
    • Указатель для передачи параметра
    • Указатель для передачи параметра-массива
    • Преимущества указателей в C, скорость; функция strlen
    • Нулевой указатель
    • C++: ссылки как удобная замена указателям
  • Динамическое распределение памяти
    • Куча
    • Версия C: malloc и free, вариации
    • void*
    • Двумерный массив
    • Время работы
    • Фокус для двумерного массива
    • new и delete
    • Не смешиваем!
    • Существенные отличия между malloc/free и new/delete -- в след. раз


Указатели. Продолжение.

Небольшой обзор ранее пройденного материала:
Для любого типа возможен такой код (рассмотрим на примере int):
  int N;  //объявление переменной целого типа
  int *p; //объявление указателя на переменную целого типа
  p=&N;   //присваивает указателю p адрес переменной N
  N=*p;   //присваивает переменной N nо значение, которое находится по адресу, сохраненному в p

Полезные применения указателей.

  1. Необходимо написать функцию, возвращающую не одно, а несколько значений.
  2. Пример:
    Функция, меняющая местами значения двух переменных - swap(a,b):
    void swap(double a, double b){
      double tmp = a;
      x = y;
      y = tmp;
    }
    ожидаемого результата не будет, т.к. при вызове функции
      swap(a, b);
    сначала на стек положится значение переменной a, затем переменной b и адрес возврата, затем значения поменяются на стеке местами и функция завершит свое выполнение.

    Чтобы функция корректно работала, необходимо изменить её следующим образом:
    void swap(double *a, double *b){
      double tmp = *a;
      *x = *y;
      *y = tmp;
    }
    Теперь функция действительно поменяет значения переданных в нее переменных

    Но! Вызов функции изменится на такой: swap(&x, &y);
    Т.е. мы передаем в функцию не значения переменных, а их адреса, и внутри функции работаем именно с адресами.

    Вывод: если нужно вернуть из функции несколько значений, то необходимо передавать в нее адреса.

    Пример 2:
    Часто при написании программ используются функции printf и scanf. На их примере можно рассмотреть использование указателей в стандартной библиотеке.

    int a = 1, b = 2;
    printf("%d * %d = %d", a, b, a * b); //выведет 1 * 2 = 2


    Но если использовать функцию scanf, то можно столкнуться с ошибкой:
    запись scanf("%d", a); не запишет ничего в переменную a. Происходит это из-за того, что в функцию нужно передать именно адрес (если мы не передадим адрес, то тогда функция не сможет изменить значение переданной в нее переменной). Поэтому, изменив вызов этой функции на такой - scanf("%d", &a); мы получим желанный эффект - запись в переменную a значения, введенного с клавиатуры пользователем.

    Пояснение: использование адресов целесообразно в случае, когда функция должна менять значение передаваемой в нее переменной.

  3. Для передачи массивов
  4. Рассмотрим стандартную функцию strlen на примере кода:
    char str[] = "Hello";
    int l = strlen(str);
    Функция strlen возврещает длину переданной в нее строки.

    Но что же конкретно в нее передается?
    Передавать в нее массив неэффективно, т.к. копирование всех элементов последнего может занять длительное время.
    Поэтому передается лишь указатель на массив.
    Т.к. str в памяти компьютера записана в виде |H|e|l|l|o|\0|, то сообщив функции адрес символа 'H' мы даем ей всю необходимую информацию о строке.

  5. Для написания эффективного кода.

  6. Покажем, как можно писать эффективный код на примере реализации функции strlen.

    Вариант 1 реализации:

    int strlen(char* ptr){
      int len = 0;
      while (ptr[len] != '\0'){
        ++len;
      }
      return len;
    }

    Вариант 2 реализации:

    int strlen(char* ptr){
      char* p = ptr;
      while (*p != '\0'){
        ++p;
      }
      return p - ptr;
    }

    На пятом символе цикл остановится и значением функции в обоих случаях станет 5. Тогда возникает вопрос: зачем использовать второй вариант написания функции?
    Для того, чтобы лучше разобраться в этом, сравним действия, которые выполнялись внутри функции:

    Рассмотрим подробнее реализации этого алгоритма:

    Вариант 1:

    int strlen(char* ptr){
      int len = 0;
      while (ptr[len] != '\0'){
                 //1) ptr + len - сложение указателя и числа;
                 //2) *(ptr + len) - взятие по адресу;
                 //3) *(ptr + len) != '\0' - сравнение с 0;
        ++len;
                 /* инкрементирование длины */
      }
      return len;
    }

    Вариант 2:

    int strlen(char* ptr){
      char* p = ptr;
      while (*p != '\0'){
                 //1) *p - взятие по адресу;
                 //2) *p != '\0' - сравнение с 0;
        ++p;
                 /* инкрементирование указателя */
      }
      return p - ptr;
    }

    Как оказалось, именно сложение является лишним действием в первом случае, однако за счет такой записи код функции становится более понятным и читаемым, что теряется во втором варианте.

    Вывод: При использовании указателей достигается более высокая скорость работы программы, однако она будет критична только для очень важных функций, которые будут выполняться часто. К тому же многие современные компиляторы одинаково быстро выполнят как первый вариант записи, так и второй.
    Но именно эффективность работы с указателями была основной причиной их появления в языке С.

Проблемы, возникающие при работе с указателями.

Обычно то, что эффективно и быстро, не всегда безопасно. Эта проблема существует и у указателей. Приведем типичный пример ошибочного кода:

int N; /* неинициализированная переменная содержит случайное значение */
int *p; /* неинициализированный указатель указывает на случайную область памяти */
*p = ...;


В этом месте программа скорее всего "упадет", т.к. с большой долей вероятности указатель будет указывать на область памяти, не относящуюся к нашей программе, следовательно, не сможет изменить значение по этому адресу.

Чтобы избежать ошибок с неинициализированными указателями, им можно дать начальное значение NULL

int *p = NULL;
Такая запись принята для языка С, в С++ пишут обычно так:
int *p = 0;
И уже в нужном месте можно сделать такую проверку:
if (p == NULL){
  /* Обработка ситуации */
}

Ссылки

Вспомним функцию swap. У нее был неудобный вызов - swap(&a, &b). В языке С++ стало возможным избавиться от этого неудобства. В нем можно писать просто swap(a, b), нужно только правильно объявить и описать функцию:

void swap(double &a, double &b){
  double tmp = a;
  a = b;
  b = tmp;
}

В этом примере использовался особый тип данных, которого нет в языке С - ссылка (double & - ссылка на тип double). При таком объявлении функции при вызове на стек положатся не значения переменных, а их адреса.
Ссылка - это тот же указатель, который воспринимается и используется программистом как обычная переменная. Отличия в том, что ссылка не может быть равна NULL или не инициализирована.

Разберемся с условными обозначениями, используемыми в С++:
Для указателей используется *
для взятия адреса - &
для ссылок - &.
Но два последних случая не имеют ничего общего! Это не связанные между собой обозначения разных действий, разные значения одного символа.

Куча

До этого момента мы рассматривали всего две области, где программы могут хранить свои данные - глобальная память и стек. И для того, чтобы отводить различный объем памяти под переменные нужно было сообщить, сколько памяти они будут занимать. Для динамического распределения памяти существует третья область, называемая куча (heap).

3 области памяти

Глобальная память
Стек
Куча
Размер определяется в момент компиляции Размер определяется в момент запуска Размер увеличивается в процессе работы программы

Область памяти "куча" есть у каждой программы. Ее размером управляет операционная система. Запросы на изменение ее размера обрабатывает тоже операционная система.
Не имеет смысла отводить в куче место под простые типы данных - ведь размер указателя на эту область памяти в стеке будет иметь почти такой же размер, что и сама переменная. В итоге мы будем иметь переменную, занимающую почти в два раза больше, чем если бы она лежала на стеке. Следовательно, в куче выгодно отводить место под хранение массивов и других крупных структур и невыгодно для простых типов.

Выделение памяти в языке С:
int *p = malloc(1000000 * sizeof(int)); /* выделение памяти под 1 000 000 элементов типа int */

Для языка С++ это объявление изменится на такое:
int *p = (int *)malloc(1000000 * sizeof(int)); /* т.е. в языке С++ обязательно привести указатель void* к типу int* (объявление функции malloc выглядит так: void* malloc(size_t size); - поэтому в С++ его нужно привести к типу int*) */

Функция malloc обращается к операционной среде с просьбой выделить место в куче и, если ОС выделяет это место, возвращает укзатель на начало области. Если выделить память не удалось, malloc возвращает 0.
if (p == 0){
  /* памяти не хватило */
};

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

Возможные ошибки

int *p = (int *)malloc(1000000 * sizeof(int));
p = (int *)malloc(1000000 * sizeof(int)); /* или p = 0 */
В этом случае р будет указывать на вторую область памяти, а первая, хоть и будет числиться за нашей программой, не будет нам доступна, т.к. указатель на нее потерян.
Эта ситуация называется "утечка памяти" (memory leak). Многие программы имеют утечку памяти. В наше время, когда память стала доступнее, эта проблема не столь критична. Она будет опасна только в случае, если программе необходимо работать длительное время, и утечка памяти будет довольно внушительной, что приведет к тому, что памяти может не хватить.
Еще одна проблема - это повторное освобождение памяти. Она не так часто встречается, как первая, но и о ней не стоит забывать.

Задача: Завести матрицу размером N*N элементов в куче.
Решение: завести указатель на массив указателей на массивы чисел.
int **M = (int **)malloc(N * sizeof(int*)) /* место в куче под массив указателей */
for (int i = 0; i < N; ++i){
  M[i] = (int *)malloc(N * sizeof(int)); /* место в куче для i-го массива чисел */
}
/* работа с массивом */
/* освобождение памяти: */
for (int i = 0; i < N; ++i){
  free(M[i]); /* освобождение памяти из-под i-го массива чисел */
}
free(M); /* освобождение памяти от массива указателей */


Есть определенные недостатки от использования такого подхода в выделении памяти для массива: malloc - очень медленная функция, т.к. ей приходится обращаться к ОС, которая может быть занята и не сразу ответить на запрос о выделении памяти, к тому же ОС может долго искать свободное место нужного размера в куче.
Аналогично, free тоже не быстрая функция. Следовательно, весь процесс выделения-освобождения памяти занимает обычно длительное время и нужно его оптимизировать в сторону уменьшения количества использований функций malloc и free.

Способ оптимизации: завести массив указателей на int* длины N и массив из int размера N*N. После этого записать в M[i] указатель на i-й участок второго массива.
Итого malloc вызван всего 2 раза вместо N+1 раза. (реализация этого метода выделения памяти будет заданием на следующей проверочной работе)

В языке С есть еще несколько функций, связанных с работой с памятью: В языке С++ есть альтернатива malloc и free:
int *p = new int[size]; /* вернет 0, если не хватает памяти */
delete[] p; /* освобождает память */

В этом случае нам не нужно писать, сколько байт выделить для массива, достаточно количества элементов, ведь компилятор уже знает, какого типа они будут.

Важно:

Нельзя использовать вместе new и free или malloc и delete!