Радиоконструктор

Язык C, Часть 30

(Сегодня будет длинный и довольно трудный кусок)

Указатели на функции

Указатели на функции[1] — очень мощное средство языка С. Хотя нельзя не отметить, что это весьма трудный для понимания термин. Функция располагается в памяти по определенному адресу, который можно присвоить указателю в качестве его значения. Адресом функции является ее точка входа. Именно этот адрес используется при вызове функции. Так как указатель хранит адрес функции, то она может быть вызвана с помощью этого указателя. Он позволяет также передавать ее другим функциям в качестве аргумента.
В программе на С адресом функции служит ее имя без скобок и аргументов (это похоже на адрес массива, который равен имени массива без индексов). Рассмотрим следующую 



программу, в которой сравниваются две строки, введенные пользователем. Обратите внимание на объявление функции check() и указатель p внутри main(). Указатель p, как вы увидите, является указателем на функцию.
#include <stdio.h>
#include <string.h>

void check(char *a, char *b,
           int (*cmp)(const char *, const char *));

int main(void)
{
  char s1[80], s2[80];
  int (*p)(const char *, const char *);
               /* указатель на функцию */

  p = strcmp;
  /* присваивает адрес функции strcmp указателю p */

  printf("Введите две строки.\n");
  gets(s1);
  gets(s2);

  check(s1, s2, p); /* Передает адрес функции strcmp
                       посредством указателя p */

  return 0;
}

void check(char *a, char *b,
           int (*cmp)(const char *, const char *))
{
  printf("Проверка на совпадение.\n");
  if(!(*cmp)(a, b)) printf("Равны");
  else printf("Не равны");
}
Проанализируем эту программу подробно. В первую очередь рассмотрим объявление указателя p в main():
int (*p)(const char *, const char *);
Это объявление сообщает компилятору, что p — это указатель на функцию, имеющую два параметра типа const char * и возвращающую значение типа int. Скобки вокруг p необходимы для правильной интерпретации объявления компилятором. Подобная форма объявления используется также для указателей на любые другие функции, нужно лишь внести изменения в зависимости от возвращаемого типа и параметров функции.
Теперь рассмотрим функцию check(). В ней объявлены три параметра: два указателя на символьный тип (a и b) и указатель на функцию cmp. Обратите внимание на то, что указатель функции cmp объявлен в том же формате, что и p. Поэтому в cmp можно хранить значение указателя на функцию, имеющую два параметра типа const char * и возвращающую значение int. Как и в объявлении p, круглые скобки вокруг *cmp необходимы для правильной интерпретации этого объявления компилятором.
Вначале в программе указателю p присваивается адрес стандартной библиотечной функции strcmp(), которая сравнивает строки. Потом программа просит пользователя ввести две строки и передает указатели на них функции check(), которая их сравнивает. Внутри check() выражение
(*cmp)(a, b)
вызывает функцию strcmp(), на которую указывает cmp, с аргументами a и b. Скобки вокруг *cmp обязательны. Существует и другой, более простой, способ вызова функции с помощью указателя:
cmp(a, b);
Однако первый способ используется чаще (и мы рекомендуем использовать именно его), потому что при втором способе вызова указатель cmp очень похож на имя функции, что может сбить с толку читающего программу. В то же время у первого способа записи есть свои преимущества, например, хорошо видно, что функция вызывается с помощью указателя на функцию, а не имени функции. Следует отметить, что первоначально в С был определен именно первый способ вызова.
Вызов функции check() можно записать, используя непосредственно имя strcmp():
check(s1, s2, strcmp);
В этом случае вводить в программу дополнительный указатель p нет необходимости.
У читателя может возникнуть вопрос: какая польза от вызова функции с помощью указателя на функцию? Ведь в данном случае никаких преимуществ не достигнуто, этим мы только усложнили программу. Тем не менее, во многих случаях оказывается более выгодным передать имя функции как параметр или даже создать массив функций. Например, в программе интерпретатора синтаксический анализатор (программа, анализирующая выражения) часто вызывает различные вспомогательные функции, такие как вычисление математических функций, процедуры ввода-вывода и т.п. В таких случаях чаще всего создают список функций и вызывают их с помощью индексов.
Альтернативный подход — использование оператора switch с длинным списком меток case — делает программу более громоздкой и подверженной ошибкам.
В следующем примере рассматривается расширенная версия предыдущей программы. В этой версии функция check() устроена так, что может выполнять разные операции над строками s1 и s2 (например, сравнивать каждый символ с соответствующим символом другой строки или сравнивать числа, записанные в строках) в зависимости от того, какая функция указана в списке аргументов. Например, строки "0123" и "123" отличаются, однако представляют одно и то же числовое значение.
#include <stdio.h>
#include <ctype.h>
#include <stdlib.h>
#include <string.h>

void check(char *a, char *b,
           int (*cmp)(const char *, const char *));
int compvalues(const char *a, const char *b);

int main(void)
{
  char s1[80], s2[80];

  printf("Введите два значения или две строки.\n");
  gets(s1);
  gets(s2);

  if(isdigit(*s1)) {
    printf("Проверка значений на равенство.\n");
    check(s1, s2, compvalues);
  }
  else {
    printf("Проверка строк на равенство.\n");
    check(s1, s2, strcmp);
  }

  return 0;
}

void check(char *a, char *b,
           int (*cmp)(const char *, const char *))
{
  if(!(*cmp)(a, b)) printf("Равны");
  else printf("Не равны");
}

int compvalues(const char *a, const char *b)
{
  if(atoi(a)==atoi(b)) return 0;
  else return 1;
}
Если в этом примере ввести первый символ первой строки как цифру, то check() использует compvalues(), в противном случае — strcmp(). Функция check() вызывает ту функцию, имя которой указано в списке аргументов при вызове check(), поэтому она в разных ситуациях может вызывать разные функции. Ниже приведены результаты работы этой программы в двух случаях:
Введите два значения или две строки.
тест
тест
Проверка строк на равенство.
Равны

Введите два значения или две строки.
0123
123
Проверка значений на равенство.
Равны
Сравнение строк 0123[2] и 123 показывает равенство их значений.


[1]Иногда их называют просто указателями функций. Но следует помнить, что в языках программирования под этим термином подразумевается также средство обращения к подпрограмме-функции или встроенной функции, имеющее конструкцию <имя-функции> (<список-аргументов>).
[2]Обратите внимание, что в языке С нулем начинаются восьмеричные константы. Если бы эта запись была в выражении, то 0123 не было бы равно 123. Однако здесь функция atoi() обрабатывает это число как десятичное.

Функции динамического распределения

Указатели используются для динамического выделения памяти компьютера для хранения данных. Динамическое распределение означает, что программа выделяет память для данных во время своего выполнения. Память для глобальных переменных выделяется во время компиляции, а для нестатических локальных переменных — в стеке. Во время выполнения программы ни глобальным, ни локальным переменным не может быть выделена дополнительная память. Но довольно часто такая необходимость возникает, причем объем требуемой памяти заранее неизвестен. Такое случается, например, при использовании динамических структур данных, таких как связные списки или двоичные деревья. Такие структуры данных при выполнении программы расширяются или сокращаются по мере необходимости. Для реализации таких структур в программе нужны средства, способные по мере необходимости выделять и освобождать для них память.
Память, выделяемая в С функциями динамического распределения данных, находится в т.н. динамически распределяемой области памяти (heap)[1]. Динамически распределяемая область памяти — это свободная область памяти, не используемая программой, операционной системой или другими программами. Размер динамически распределяемой области памяти заранее неизвестен, но как правило в ней достаточно памяти для размещения данных программы. Большинство компиляторов поддерживают библиотечные функции, позволяющие получить текущий размер динамически распределяемой области памяти, однако эти функции не определены в Стандарте С. Хотя размер динамически распределяемой области памяти очень большой, все же она конечна и может быть исчерпана.
Основу системы динамического распределения в С составляют функции malloc() и free(). Эти функции работают совместно. Функция malloc() выделяет память, а free() — освобождает ее. Это значит, что при каждом запросе функция malloc() выделяет требуемый участок свободной памяти, a free() освобождает его, то есть возвращает системе. В программу, использующую эти функции, должен быть включен заголовочный файл <stdlib.h>.
Прототип функции malloc() следующий:
void *malloc(size_t количество_байтов);
Здесь количество_байтов — размер памяти, необходимой для размещения данных. (Тип size_t определен в <stdlib.h> как некоторый целый без знака.) Функция malloc() возвращает указатель типа void *, поэтому его можно присвоить указателю любого типа. При успешном выполнении malloc() возвращает указатель на первый байт непрерывного участка памяти, выделенного в динамически распределяемой области памяти. Если в динамически распределяемой области памяти недостаточно свободной памяти для выполнения запроса, то память не выделяется и malloc() возвращает нуль.
При выполнении следующего фрагмента программы выделяется непрерывный участок памяти объемом 1000 байтов:
char *p;
p = malloc(1000); /* выделение 1000 байтов */
После присвоения указатель p ссылается на первый из 1000 байтов выделенного участка памяти.
В следующем примере выделяется память для 50 целых. Для повышения мобильности (переносимости программы с одной машины на другую) используется оператор sizeof.
int *p;
p = malloc(50*sizeof(int));
Поскольку динамически распределяемая область памяти не бесконечна, при каждом размещении данных необходимо проверять, состоялось ли оно. Если malloc() не смогла по какой-либо причине выделить требуемый участок памяти, то она возвращает нуль. В следующем примере показано, как выполняется проверка успешности размещения:
p = malloc(100);
if(!p) {
  printf("Нехватка памяти.\n");
  exit(1);
}
Конечно, вместо выхода из программы exit() можно поставить какой-либо обработчик ошибки. Обязательным здесь можно назвать лишь требование не использовать указатель р, если он равен нулю.
Функция free() противоположна функции malloc() в том смысле, что она возвращает системе участок памяти, выделенный ранее с помощью функции malloc(). Иными словами, она освобождает участок памяти, который может быть вновь использован функцией malloc(). Функция free() имеет следующий прототип:
void free(void *p)
Здесь р — указатель на участок памяти, выделенный перед этим функцией malloc(). Функцию free() ни в коем случае нельзя вызывать с неправильным аргументом, это мгновенно разрушит всю систему распределения памяти.
Подсистема динамического распределения в С используется совместно с указателями для создания различных программных конструкций, таких как связные списки и двоичные деревья. Несколько примеров использования таких конструкций приведены в части IV. Здесь рассматривается другое важное применение динамического размещения: размещение массивов.

Динамическое выделение памяти для массивов

Довольно часто возникает необходимость выделить память динамически, используя malloc(), но работать с этой памятью удобнее так, будто это массив, который можно индексировать. В этом случае нужно создать динамический массив. Сделать это несложно, потому что каждый указатель можно индексировать как массив. В следующем примере одномерный динамический массив содержит строку:
/* Динамическое распределение строки, строка вводится
   пользователем, а затем распечатывается справа налево. */

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
  char *s;
  register int t;

  s = malloc(80);

  if(!s) {
    printf("Требуемая память не выделена.\n");
    exit(1);
  }

  gets(s);
  for(t=strlen(s)-1; t>=0; t--) putchar(s[t]);
  free(s);

  return 0;
}
Перед первым использованием s программа проверяет, успешно ли прошло выделение памяти. Эта проверка необходима для предотвращения случайного использования нулевого указателя. Обратите внимание на то, что указатель s используется в функции gets(), а также при выводе на экран (но на этот раз уже как обыкновенный массив).
Можно также динамически выделить память для многомерного массива. Для этого нужно объявить указатель, определяющий все, кроме самого левого измерения массива. В следующем примере[2] двухмерный динамический массив содержит таблицу чисел от 1 до 10 в степенях 1, 2, 3 и 4.
#include <stdio.h>
#include <stdlib.h>

int pwr(int a, int b);

int main(void)
{
  /* Объявление указателя на массив из 10 строк
     в которых хранятсяцелые числа (int). */
  int (*p)[10]; 

  register int i, j;

  /* выделение памяти для массива 4 x 10 */
  p = malloc(40*sizeof(int));

  if(!p) {
    printf("Требуемая память не выделена.\n");
    exit(1);
  }

  for(j=1; j<11; j++)
    for(i=1; i<5; i++) p[i-1][j-1] = pwr(j, i);

  for(j=1; j<11; j++) {
    for(i=1; i<5; i++) printf("%10d ", p[i-1][j-1]);
    printf("\n");
  }

  return 0;
}

/* Возведение чисел в степень. */
pwr(int a, int b)
{
  register int  t=1;

  for(; b; b--) t = t*a;
  return t;
}
Программа выводит на экран следующее:
         1         1         1         1
         2         4         8        16
         3         9        27        81
         4        16        64       256
         5        25       125       625
         6        36       216      1296
         7        49       343      2401
         8        64       512      4096
         9        81       729      6561
        10       100      1000     10000
Указатель р в главной программе (main()) объявлен как
int (*p)[10]
Следует отметить, что скобки вокруг обязательны. Такое объявление означает, что р указывает на массив из 10 целых. Если увеличить указатель р на 1, то он будет указывать на следующие 10 целых чисел. Таким образом, р — это указатель на двухмерный массив с 10 числами в каждой строке. Поэтому р можно индексировать как обычный двухмерный массив. Разница только в том, что здесь память выделена с помощью malloc(), а для обыкновенного массива память выделяет компилятор.
Как упоминалось ранее, в C++ нужно преобразовывать типы указателей явно. Поэтому чтобы данная программа была правильной и в С, и в C++, необходимо выполнить явное приведение типа значения, возвращаемого функцией malloc(). Для этого строчку, в которой указателю р присваивается это значение, нужно переписать следующим образом:
p = (int (*)[10]) malloc(40*sizeof(int));
Многие программисты используют явное преобразование типов указателей для обеспечения совместимости с C++.


[1]Применяются и другие названия: динамическая область, динамически распределяемая область, куча, неупорядоченный массив (данных).
[2]В примере динамически размещается только левое измерение массива. Однако это нетрудно сделать и для всех измерений, объявив указатель **р и разместив каждое измерение отдельно. Такой прием особенно удобен при написании функции, один из аргументов которой — двухмерный массив с неизвестными заранее размерами измерений.

Указатели с квалификатором restrict

Стандарт С99 дополнительно вводит новый квалификатор типа restrict, применимый только для указателей. Подробно этот спецификатор обсуждается в части II, здесь приведено только его краткое описание.
Если указатель объявлен с квалификатором restrict, то к объекту, на который он ссылается, можно обратиться только с помощью этого указателя. Обращение к объекту с помощью другого указателя возможно только в том случае, если другой указатель основан на первом. Таким образом, доступ к объекту можно получить только с помощью выражений, основанных на указателе с квалификатором restrict. Указатели restrict используются главным образом как параметры функции или совместно с malloc(). Если указатель объявлен с квалификатором restrict, компилятор способен лучше оптимизировать некоторые процедуры. Например, если два параметра функции определены как указатели с квалификатором restrict, то это сообщает компилятору о том, что они указывают на два разных (не пересекающихся) объекта. Квалификатор restrict не изменяет семантику программы. 

Трудности при работе с указателями

Ничто не может доставить больше неприятностей, чем "дикий" указатель! Указатели похожи на обоюдоострое оружие: их возможности огромны, однако обезвредить ошибки в них особенно трудно.
Ошибочный указатель трудно найти потому, что ошибка в самом указателе никак себя не проявляет. Проблемы возникают при попытке обратиться к объекту с помощью этого указателя. Если значение указателя неправильное, то программа с его помощью обращается к произвольной ячейке памяти. При чтении в программу попадают неправильные данные, а при записи искажаются другие данные, хранящиеся в памяти, или портится участок программы, не имеющий никакого отношения к ошибочному указателю. В обоих случаях ошибка может не проявиться вовсе или проявиться позже в форме, никак не указывающей на ее причину.
Поскольку ошибки, связанные с указателями, особенно трудно обезвредить, при работе с указателями следует соблюдать особую осторожность. Рассмотрим некоторые ошибки, наиболее часто возникающие при работе с указателями. Классический пример — неинициализированный указатель:
/* Это программа содержит ошибку. */
int main(void)
{
  int x, *p;

  x = 10;
  *p = x; /* ошибка, p не инициализирован */

  return 0;
}
Эта программа присваивает значение 10 некоторой неизвестной области памяти. Рассмотрим, почему это происходит. Хотя указателю р не было присвоено никакого значения, но в момент выполнения операции *р = х он имел некоторое (совершенно произвольное!) значение. Поэтому здесь имела место попытка выполнить операцию записи в область памяти, на которую указывал данный указатель. В небольших программах такая ошибка часто остается незамеченной, потому что если программа и данные занимают немного места, то "выстрел наугад" скорее всего будет "промахом". С увеличением размера программы вероятность "попасть" в нее возрастает.
В таком простом случае большинство компиляторов выводят предупреждение о том, что используется неинициализированный указатель. Однако подобная ошибка может произойти и в более завуалированном виде, тогда компилятор не сможет распознать ее.
Вторая распространенная ошибка заключается в простом недоразумении при использовании указателя:
/* Это программа содержит ошибку. */
#include <stdio.h>

int main(void)
{
  int x, *p;

  x = 10;
  p = x;

  printf("%d", *p);

  return 0;
}
Вызов printf() не выводит на экран значение х, равное 10. Выводится произвольная величина, потому что оператор
p = x;
записан неправильно. Он присваивает значение 10 указателю, однако указатель должен содержать адрес, а не значение. Правильный оператор выглядит так:
p = &x;
Большинство компиляторов при попытке присвоить указателю р значение х выведут предупреждающее сообщение, но, как и в предыдущем примере, компилятор не сможет распознать эту ошибку в более завуалированном виде.
Еще одна типичная ошибка происходит иногда при неправильном понимании принципов расположения переменных в памяти. Программисту ничего не известно о том, как используемые им данные располагаются в памяти, будут ли они расположены так же при следующем выполнении программы или как их расположат другие компиляторы. Поэтому сравнивать одни указатели с другими недопустимо. Например, программа
char s[80], y[80];
char *p1, *p2;

p1 = s;
p2 = y;
if(p1 < p2) . . .
в общем случае неправильна. (В некоторых необычных ситуациях иногда определяют относительное положение переменных, но это делают очень редко.)
Похожая ошибка возникает, когда делается необоснованное предположение о расположении массивов. Иногда, предполагая, что массивы расположены рядом, пытаются обращаться к ним с помощью одного и того же указателя, например:
int first[10], second[10];
int *p, t;

p = first;
for(t=0; t<20; ++t)  *p++ = t;
Так присваивать значения массивам first и second нельзя. Если компилятор разместит массивы рядом, это может и не привести к неправильному результату. Однако подобная ошибка особенно неприятна тем, что при проверке она может остаться незамеченной, а потом компилятор будет размещать массивы по-другому и программа выполнится неправильно.
В следующей программе приведен пример очень опасной ошибки. Постарайтесь сами найти ее, не подсматривая в последующее объяснение.
/* Это программа с ошибкой. */
#include <string.h>
#include <stdio.h>

int main(void)
{
  char *p1;
  char s[80];

  p1 = s;
  do {
    gets(s);  /* чтение строки */

    /* печать десятичного эквивалента
       каждого символа */
    while(*p1) printf(" %d", *p1++);

  } while(strcmp(s, "выполнено"));

  return 0;
}
Программа печатает значения символов ASCII, находящихся в строке s. Печать осуществляется с помощью p1, указывающего на s. Ошибка состоит в том, что указателю p1 присвоено значение s только один раз, перед циклом. В первой итерации p1 правильно проходит по символам строки s, однако в следующей итерации он начинает не с первого символа, а с того, которым закончил в предыдущей итерации. Так что во второй итерации p1 может указывать на середину второй строки, если она длиннее первой, или же вообще на конец остатка первой строки. Исправленная версия программы записывается так:
/* Это правильная программа. */
#include <string.h>
#include <stdio.h> 

int main(void)
{
  char *p1;
  char s[80];

  do {
    p1 = s; /* установка p1 в начало строки s */
    gets(s);  /* чтение строки */

    /* печать десятичного эквивалента
       каждого символа */
    while(*p1) printf(" %d", *p1++);

  } while(strcmp(s, "выполнено"));

  return 0;
}
При такой записи указатель p1 в начале каждой итерации устанавливается на первый символ строки s. Об этом необходимо всегда помнить при повторном использовании указателей.
То, что неправильные указатели могут быть очень "коварными", не может служить причиной отказа от их использования. Следует лишь быть осторожным и внимательно проанализировать каждое применение указателя в программе.

Комментарии