Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Kernigan_B__Payk_R_Praktika_programmirovania.pdf
Скачиваний:
76
Добавлен:
18.03.2016
Размер:
2.53 Mб
Скачать

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

Упражнение 2-5

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

Упражнение 2-6

Внесите необходимые изменения в функции delname и addname, чтобы удаленные элементы помечались как неиспользуемые. Насколько остальная программа не зависит от этого изменения?

Списки

По своей встречаемости в типичных программах списки занимают второе место после массивов. Многие языки имеют встроенные типы списков, некоторые, такие как Lisp, даже построены на них, но в языке С мы должны конструировать их самостоятельно. В C++ и Java работа со списками поддерживается стандартными библиотеками, но и в этом случае нужно знать их возможности и типичные применения. В данном параграфе мы собираемся обсудить использование списков в С, но уроки из этогр обсуждения можно извлечь и для более широкого применения.

Простым цепным списком (single-linked list) называется последовательность элементов, каждый из которых содержит данные и указатель на следующий элемент. Головой списка является указатель на первый элемент, а конец помечен нулевым указателем. Ниже показан список из четырех элементов:

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

Эти различия подсказывают, что если набор данных будет часто меняться, особенно при непредсказуемом количестве элементов, то нужно использовать список; напротив, массив больше подходит для относительно статичных данных.

Фундаментальных операций со списком совсем немного: добавить элемент в начало или конец списка, найти указанный элемент, добавить новый элемент до или после

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

Вместо того чтобы определить специальный тип List, обычно в С начинают определение списка с описания типа для элементов, вроде нашего Nameval, и добавляют в него указатель на следующий элемент:

typedef struct Nameval Nameval; struct Nameval {

char *name: int value; Nameval «next;

/* следующий в списке */ };

Инициализировать непустой список во время компиляции трудно, поэтому списки, не в пример массивам, создаются динамически. Для начала нам нужен способ создания элемента. Наиболее простой подход — выделить под него память специальной функцией, которую мы назвали newitem:

/* newitem: создать новый элемент по полям name и value */ Nameval *newitem(char *name, int value)

{

Nameval *newp;

newp = (Nameval *) emalloc(sizeof(Nameval)); newp->name = name; newp->value

= value; newp->next = NULL; return newp; }

Функцию emalloc мы будем использовать и далее во всей книге; она вызывает mall ос, а при ошибке выделения памяти выводит сообщение и завершает программу. Мы представим код этой функции в главе 4, а пока считайте, что эта функция всегда корректно и без сбоев выделяет память.

Простейший и самый быстрый способ собрать список — это добавлять новые элементы в его начало:

/* addfront: добавить элемент newp в начало списка listp */ Nameval *addfront

(Nameval *listp, Nameval *newp)

{

newp->next = listp; return newp; }

При изменении списка у него может измениться первый элемент, что и происходит при вызове addf ront. Функции, изменяющие список, должны возвращать указатель на новый первый элемент, который хранится в переменной, указывающей на список. Функция addfront и другие функции этой группы передают указатель на первый элемент в качестве возвращаемого значения; вот типичное использование таких функций:

nvlist = addf ront(nvlist, newitem( "smiley", Ox263A));

Такая конструкция работает, даже если существующий список пуст (NULL), она хороша и тем, что позволяет легко объединять вызовы функций в выражениях. Это более естественно, чем альтернативный вариант — передавать указатель на указатель на голову списка.

Добавление элемента в конец списка — процедура порядка 0(п), поскольку нам нужно пройтись по всему списку до конца:

/* addend: добавить элемент newp в конец списка listp */

Nameval *addend(Nameval *listp, Namevai *newp)

{

Nameval *p;

if (listp == NULL)

return newp; for (p = listp; p->next != NULL; p = p->next)

p->next = newp; return listp; }

Чтобы сделать addend операцией порядка 0(1), мы могли бы завести отдельный указатель на конец списка. Недостаток этого подхода, кроме того, что нам нужно заботиться о корректности этого указателя, состоит в том, что список теперь уже представлен не одной переменной, а двумя. Мы будем придерживаться более простого стиля.

Для поиска элемента с заданным именем нужно пройтись по указателям next:

/* lookup: последовательный поиск имени в списке */

Nameval *lookup(Nameval *listp, char *name)

{

for ( ; listp != NULL; listp = listp->next) if (strcmp(name, listp->name) == 0) return listp;

return NULL; /* нет совпадений */ }

Поиск занимает время порядка 0(п), и, в принципе, эту оценку не улучшить. Даже если список отсортирован, нам все равно нужно пройтись по нему, чтобы добраться до нужного элемента. Двоичный поиск к спискам неприменим.

Для печати элементов списка мы можем написать функцию, проходящую по списку и печатающую каждый элемент; для вычисления длины списка — функцию, проходящую по нему, увеличивая счетчик, и т. д. Альтернативный подход — написать одну функцию, apply, которая проходит по списку и вызывает другую функцию для каждого элемента. Мы можем сделать функцию apply более гибкой, предоставив ей аргумент, который нужно передавать при каждом вызове функции. Таким образом, у apply три аргумента: сам список, функция, которую нужно применить к каждому элементу списка, и аргумент для этой функции:

/* apply: применить

функцию fn

для каждого элемента списка listp */ void apply(Nameval *listp,

void (*fn)(Nameval*, void*), void *arg)

{

for ( ; listp != NULL; listp

= listp->next) (*fn)(listp, arg); /* вызов функции */ }

Второй аргумент apply — указатель на функцию, которая принимает два параметра и возвращает void. Стандартный, хотя и весьма неуклюжий, синтаксис

* void (*fn)(Nameval*, void*)

определяет f n как указатель на функцию с возвращаемым значением типа void, то есть как переменную, содержащую адрес функции, которая возвращает void. Функция имеет два параметра — типа Nameval * (элемент списка) и void * (обобщенный указатель на аргумент для этой функции).

Для использования apply, например для вывода элементов списка, мы можем написать тривиальную функцию, параметр которой будет восприниматься как строка форматирования:

/* printnv: вывод имени и значения с использованием формата в arg */ void printnv(Nameval *p, void *arg) { char *fmt;

fmt = (char *) arg; printf(fmt, p->name, p->value); }

тогда вызывать мы ее будем так: apply(nvlist, printnv, "%s: %x\n");

Для подсчета количества элементов мы определяем функцию, параметром которой будет указатель на увеличиваемый счетчик:

/* inccounter: увеличить счетчик *arg */ void inccounter(Nameval

*p, void *arg) { int *ip;

/*' p не используется

*/ ip = (int *) arg; (*ip)++; }

Вызывается она следующим образом:

int n; n = 0;

apply(nvlist, inccounter, &n); printf("B nvlist %d элементов\n", n);

He каждую операцию над списками удобно выполнять таким образом. Например, при удалении списка надо действовать более аккуратно:

/* freeall:

освободить все элементы списка listp */ void freeall(Nameval *listp)

{

Nameval *next;

for ( ; listp != NULL; listp = next)

{ next = listp->next; /* считаем, что память, занятая строкой name, освобождена где-то в

другом месте */ free(listp);

}

}

Память нельзя использовать после того, как мы ее освободили, поэтому до освобождения элемента, на который указывает listp, указатель listp->next нужно сохранить в локальной переменной next. Если бы цикл, как и раньше, выглядел так:

? for ( ; listp != NULL; listp = listp->next) ? free(listp);

то значение listp->next могло быть затерто вызовом free и код бы не работал.

Заметьте, что функция freeall не освобождает память, выделенную под строку listp- >name. Это подразумевает, что поле name каждого элемента типа Nameval было освобождено где-то еще либо память под него не была выделена. Чтобы обеспечить корректное выделение памяти под элементы и ее освобождение, нужно согласование работы newitem и f гее-all; это некий компромисс между гарантиями того, что память будет освобождена, и того, что ничего лишнего освобождено не будет. Именно здесь при неграмотной реализации часто возникают ошибки. В других языках, включая Java, данную проблему за вас решает сборка мусора. К теме управления ресурсами мы еще вернемся в главе 4.

Удаление одного элемента из списка — более сложный процесс, чем добавление:

/* delitem: удалить первое вхождение

"name" в listp */

Nameval *delitem(Nameval *listp, char *name)

{

Nameval *p, *prev; prev = NULL;

for (p = listp; p != NULL; p = p->next)

{if (strcmp(name, p->name) == 0)

{if (prev == NULL)

listp = p->next; else prev->next = p->next; f ree(p); return listp; }

prev = p; } eprintf("delitem: %s в

списке отсутствует", name);

return NULL;

/* сюда не дойдет */

}

Как и в f reeall, delitem не освобождает память, занятую полем namе.

Функция eprintf выводит сообщение об ошибке и завершает программу, что в лучшем случае неуклюже. Грамотное восстановление после произошедших ошибок может быть весьма трудным и требует долгого обсуждения, которое мы отложим до главы 4, где покажем также реализацию eprintf.

Представленные основные списочные структуры и операции применимы в подавляющем большинстве случаев, которые могут встретиться в ваших программах. Однако есть много альтернатив. Некоторые библиотеки, включая библиотеку стандартных шаблонов (Standard Template Library, STL) в C++,

поддерживают двухсвязные списки (double-linked lists: списки с двойными связями), в которых у каждого элемента есть два указателя: один — на последующий, а другой

— на предыдущий элемент. Двухсвязные списки требуют больше ресурсов, но поиск последнего элемента и удаление текущего — операции порядка О( 1). Иногда память под указатели списка выделяют отдельно от данных, которые они связывают;