Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

ТЕМА КОНТЕЙНЕРЫ / Лаптев Глава 6 Контейнеры

.pdf
Скачиваний:
19
Добавлен:
09.02.2015
Размер:
861.82 Кб
Скачать

Глава 6 Контейнеры

В реальных задачах обычно требуется обрабатывать группы данных довольно большого объема. Например, посчитать зарплату для всех сотрудников университета или транслировать программу, состоящую из нескольких тысяч строк исходного текста. Поэтому в любом языке программирования, в том числе и в С++, существуют средства объединения данных в группы

— обычно это массивы. Однако в С++ массивы, как было отмечено в предыдущей главе, являются слишком простыми, а потому очень ненадежными конструкциями, и для преодоления их ограничений мы разработали «умный» массив. Однако одних массивов как средств объединения однородных данных явно недостаточно. В процессе развития языков программирования, и С + + в частности, программистское сообщество выработало более общую конструкцию объединения однородных данных в группу — контейнер. Несколько лет назад употреблялся термин «коллекция», однако с момента принятия стандарта С + + термин «контейнер» стал общепринятым — именно так называются наборы данных в стандартной библиотеке шаблонов (STL).

Характеристики контейнеров

В языке С++ (да и в любом другом) контейнер — это набор однотипных элементов. Я не случайно употребил слово «набор», а не «множество», так как множество — это тоже контейнер. По этому определению массив — это контейнер. Каталог файлов на диске — тоже контейнер.

Каждый контейнер характеризуется, в первую очередь, своим именем и типом входящих в него элементов. Имя контейнера — это имя переменной в программе, которое подчиняется правилам видимости С++. Как объект, контейнер должен обладать временем жизни в зависимости от места и времени создания, причем время жизни контейнера в общем случае не зависит от времени жизни его элементов.

Характеристики контейнеров

2

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

И наконец, надо сказать о размере контейнера. Размер контейнера может быть либо определен при объявлении, либо не задан. В первом случае получаем контейнер фиксированной длины. Именно таким контейнером является «умный» массив (см. листинг 5.2), который не изменяет количество своих элементов за время жизни. Однако в общем случае количество элементов контейнера с заданной фиксированной длиной может изменяться от нуля до объявленного количества. Пример нашего класса строк TS t r i n g (см. листинг 4.2) показывает, что размер контейнера (255 элементов) и количество элементов в нем (определяемое методом Le n gt h O )

— это разные вещи.

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

Доступ к элементам контейнера

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

v[7l

Это выражение означает, что мы хотим оперировать элементом контейнера v , имеющим номер (индекс) 7. Нумерация элементов может начинаться, вообще говоря, с любого числа, однако в С + + принято нумерацию начинать с нуля, так как для встроенных массивов (которые являются частным случаем контейнера) принята именно такая нумерация.

Последовательный доступ отличается тем, что мы не имеем в распоряжении индексов элементов, зато можем перемещаться последовательно от элемента к элементу. Можно считать, что существует невидимая «стрелка»-индикатор, которую перемещают по элементам контейнера с помощью некоторого множества операций. Тот элемент, на который в данный момент «стрелка» показывает, называется текущим.

Обычно набор операций для последовательного доступа включает операции:

перехода к первому элементу;

перехода к последнему элементу;

перехода к следующему элементу;

перехода к предыдущему элементу;

перехода на п элементов вперед (от первого в сторону последнего элемента контейнера);

перехода на п элементов назад (от конца к началу контейнера);

получения (изменения) значения текущего элемента.

Эти операции могут быть представлены в функциональной форме, например:

next(v);

//

перейти

к

следующему

prev(v);

//

перейти

к

предыдущему

Характеристики контейнеров

3

first(v);

//

перейти к первому

last(v);

//

перейти к последнему

current(v);

//

получить текущий

forward(v, п);

//

перейти на п элементов вперед

back(v, п);

//

перейти на п элементов назад

Операция изменения текущего элемента — это, естественно, операция присваивания, например:

current(v) = value;

В этом случае функция c u r r e n t ( ) должна возвращать ссылку на элемент контейнера.

Те же операции, реализованные как методы класса (контейнера), можно представить следующим образом:

v.nextO;

// перейти к следующему

v.prevQ;

// перейти к предыдущему

v.firstO;

// перейти к первому

v.last();

// перейти к последнему

v.currentO;

// получить текущий

v.skip(n);

// перейти на п элементов вперед

v.skip(-n);

// перейти на п элементов назад

Однако в С++ «стрелку»-индикатор удобнее представить в виде некоторого объекта, связанного с контейнером. Если этот объект имеет имя i v, то те же операции могут быть реализованы и так:

iv = v.beginO;

// перейти к первому

iv = v.last();

// перейти к последнему

++iv;

// перейти к следующему

--iv;

// перейти к предыдущему

iv+=n;

// перейти на п элементов вперед

iv-=n;

// перейти на п элементов назад

*iv

// получить значение текущего элемента

Не правда ли, очень похоже на указатель?! Однако это не указатель1 — в практике объектноориентированного программирования такой объект называется итератором. Итератор — это объект, обеспечивающий последовательный доступ к элементам контейнера. Так же как контейнер представляет собой более общую концепцию, чем массив, так и итератор является более общей концепцией, чем указатель. В [17] итератор описан как один из шаблонов 1 Обычным указателем такой объект будет только для контейнера-массива.

(паттернов) программирования — I t e r a t o r .

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

class TAccount

 

{

string Family;

// фамилия

 

unsigned long Count;

// номер счета

 

Date Open;

// дата открытия счета

 

public:

 

//

. . . }:

 

Если контейнер v содержит такие объекты, то выражение представляет собой счет, открытый на имя Стенли Липпмана:

Характеристики контейнеров

4

v["Lippman"]

Такое выражение в С++ является вполне корректным, так как операция индексирования может быть перегружена для аргумента любого типа.

Поле, с содержимым которого ассоциируется элемент контейнера, называется ключом (полем доступа). Элемент контейнера, соответствующий некоторому значению ключа, обычно так и называется значением. Ассоциативный контейнер, таким образом, состоит из множества пар «ключ-значение». Как правило, ассоциативный контейнер упорядочен некоторым образом по ключу. В данном случае контейнер, содержащий счета клиентов, отсортирован по полю F a m i l y , поэтому элементом, предшествующим записи с фамилией Lippman, может быть запись с фамилией Kupaev, а следующим — запись с фамилией Martin.

Методы доступа к контейнеру — настолько важная характеристика, что в стандартной библиотеке (см. п. 17 в [1]) различают контейнеры последовательные и ассоциативные. Последовательными контейнерами, которые обеспечивают и прямой, и последовательный варианты доступа, являются контейнеры ve c t o r и d e q u e , а ассоциативным — контейнер т а р . Контейнер, в котором доступ только последовательный, — это l i s t .

Операции с контейнером

Все операции с контейнером можно разделить на несколько групп:

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

операции добавления и удаления отдельных элементов или групп элементов;

операции поиска элементов и групп элементов;

операции с контейнером как объектом; в частности, важными являются операции объединения контейнеров;

прочие (специальные) операции, зависящие от вида контейнера.

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

добавлять и удалять элементы в начале контейнера;

то же самое делать в «хвосте» контейнера;

вставлять элементы перед текущим элементом или после него, удалять текущий элемент;

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

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

Первые три операции обычно применяются к последовательным контейнерам. Способ вставки и удаления определяет вид последовательного контейнера. Если вставка и удаление выполняются только на одном конце контейнера, то такой контейнер называется стеком, а работает он в соответствии с дисциплиной обслуживания LIFO (Last I n First Out — последним вошел, первым вышел). Говорят, что текущий элемент находится на вершине стека. Если же элементы добавляются на одном конце, а удаляются из другого, контейнер называется очередью. Очередь работает в соответствии с дисциплиной обслуживания FIFO (First In First Out — первым пришел, первым ушел). Можно выполнять и вставку, и удаление на обоих концах контейнера — такой контейнер называется деком (от английского термина deque1,

Характеристики контейнеров

5

который является аббревиатурой от «double ended queue», то есть «очередь с двумя концами»). Таким образом, дек представляет собой обобщение очереди и стека.

Если же контейнер ассоциативный, то он упорядочен по полю доступа, поэтому операции вставки и удаления всегда выполняются в последних вариантах. Но и последовательный контейнер может быть отсортирован, поэтому операция вставки тоже может вставлять «по порядку». Примером отсортированного последовательного контейнера является приоритетная очередь p r i o r i t y_ q u e u e — один из последовательных контейнеров стандартной библиотеки.

Все операции, которые мы рассматривали до сих пор, — это операции с отдельными элементами контейнера. Но и с самим контейнером или его частью можно выполнять некоторые операции — вспомните класс TS t r i n g (см. листинг 4.2). Обычно строки можно инициализировать другими строками и присваивать. Строки можно объединять разными способами, можно выполнять много разных операций с подстроками. Аналогично — и с контейнерами. Наиболее часто используется операция объединения двух контейнеров с получением нового контейнера, которая может быть реализована в различных вариантах:

простое сцепление двух контейнеров, в новый контейнер попадают все элементы и первого, и второго контейнеров; операция не коммутативна;

Этот термин, ставший ныне общепринятым, впервые использовал Дональд Кнут.

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

объединение двух контейнеров как объединение множеств, в новый контейнер попадают только те элементы, которые есть хотя бы в одном контейнере; операция коммутативна;

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

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

Отдельно необходимо сказать о контейнерах-множествах. Множество — это контейнер, в котором каждый элемент единственный. Помимо операций объединения и пересечения, для контейнеров-множеств реализуется операция вычитания множеств: в контейнер-результат попадают только те элементы первого контейнера, которых нет во втором; операция не коммутативна. Очень часто с множествами выполняется операция проверки включения, которая фактически является операцией поиска (как отдельного элемента, так и подмножества элементов).

Тип элементов оказывает существенное влияние на то, какие операции могут выполняться с контейнером. Например, для строк операция сортировки обычно не нужна, а для числовых контейнеров или для списка счетов в банке такая операция может быть не только полезной, но и необходимой. Для числовых контейнеров, очевидно, часто необходимы операции поиска минимума и максимума, суммы и произведения элементов контейнера — как в нашем «умном» массиве (см. листинг 5.2).

Реализация контейнеров

Контейнеры, как правило, реализуются с помощью указателей и динамической памяти (см. п. п. 3.7.3 в [1]). Использование указателей и динамических переменных в классах в сочетании с

Реализация контейнеров

6

перегрузкой операций представляет собой удивительно мощный механизм создания новых типов данных — контейнеры стандартной библиотеки тому наглядный пример.

При реализации контейнеров нам предстоит решить несколько важных вопросов. Во-первых, каким образом выделяется память?

Выделение памяти операцией n e w [ ] обеспечивает выделение непрерывной области памяти. Количество элементов обычно задается выражением, вычисляемым во время работы программы. Такая форма практически всегда используется для реализации динамических массивов — именно так был реализован «умный» массив (см. листинг 5.2). Способ выделения памяти настолько важен, что даже в стандарте указано, что контейнер ve c t o r (см. п. п. 23.2.4 в [1]), входящий в стандартную библиотеку С++, должен быть реализован с помощью операции n e w[ ] .

Второй способ распределения памяти — выделение одиночного элемента операцией n e w . Выделение памяти для одиночного элемента обычно требуется для реализации контейнеров с переменным количеством элементов. В этом случае не только память для элемента выделяется динамически, но и в состав самого элемента входят один или несколько (чаще всего два) указателей для связи элементов друг с другом. Обычно такие контейнеры либо последовательные, либо ассоциативные.

Как правило, выделением памяти занимается конструктор контейнера. Если память выделяется, то надо ее возвращать — иначе в программе возникает утечка памяти. Решение этой проблемы и составляет ответ на второй вопрос: каким образом возвратить память системе. Обычно эту работу «возлагают» на деструктор. Как мы знаем, операции возврата памяти являются «парными» для операций выделения памяти. Если память выделялась операцией n e w , то возвращать память нужно операцией d e l e t e . Если же память выделялась массивом (операцией n e w[ ] ) , то и возвращать ее нужно соответствующей операцией d e l e t e [ ] .

И наконец, третий вопрос связан с копированием и присваиванием. Для динамических классов, в которых используются указатели, предлагаемые по умолчанию операции совершенно не подходят, требуется явная реализация — как для «умного» массива (см.

листинги 5.10, 5.11 и 5.13).

Последовательный контейнер

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

ТАггау array(TArray source, double L, double R)

 

{ TArray result:

// массив-результат

for(uint i = 0; i < source.size(); ++i)

 

if ((L<=source[i])&&(source[i]<=R))

// если в диапазоне

result+=source[i];

// прицепляем элемент

return result;

// возвращаем массив

}

 

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

Реализация контейнеров

7

Кроме того, наш «умный» массив представляет собой контейнер фиксированной длины, доступ к элементам которого осуществляется по индексу. Последовательный доступ реализуется в цикле последовательным изменением индекса. Чтобы разобраться, каким образом выполняется последовательный доступ с помощью итераторов, реализуем контейнердек с помощью двусвязного списка. Типом контейнера по сложившейся уже традиции пусть будет T D e q u e . Не задумываясь пока об общности, опять задействуем дробные числа типа d o u b l e .

Вложенные классы

Простейшая структура элемента1 последовательного контейнера очевидна: struct Elem

{ double item;

// информационная часть

// связующая часть

 

 

Elem *next;

//

следующий элемент

Elem *prev;

//

предыдущий элемент

> :

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

Elem(const double &а):item(a){}

Отметим, что программа-клиент, которая собирается использовать дек, ничего не должна знать о внутренней структуре узла. Чтобы это обеспечить, инкапсулируем узел в классеконтейнере и реализуем его в приватной части класса TDeque в качестве вложенного (см. п. 9.7 в [1]).

Вложенным называется класс, объявленный внутри другого класса. Он является членом объемлющего класса, а его определение может находиться в любой из секций ри|Я i с или pr i vat е. Уровень вложенности стандартом не ограничивается.

Вложенный класс по умолчанию не имеет доступа к приватным элементам объемлющего класса, так же как объемлющий класс — к приватным элементам вложенного. Чтобы это было возможно, нужно использовать механизм друзей, например:

class Outer {

 

 

//...

 

 

friend class Inner;

//

дня Inner доступна приватная часть Outer

class Inner

//

вложенный класс

{ friend class Outer;

 

// для Outer доступна приватная часть Inner

/ / . . .

} ;

/ / . . .

} :

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

void Outer::Inner::Methodlnner(const Outer &t) {

//...

memlnner = t.MethodOuter(); // вызов метода объемлющего класса //...

}

Здесь метод Methodlnner ( ) вложенного класса получает ссылку на объект объемлющего класса и обычным способом вызывает метод MethodOuter () для инициализации своего поля memlnner.

Обычно элемент списка называют узлом (node).

Последовательный контейнер

8

Внутри методов вложенного класса указатель t h i s является указателем на текущий объект вложенного класса.

Если вложенный класс объявлен как p u b l i c , то его можно использовать в качестве типа во всей программе. Но поскольку класс вложенный, то его имя нужно писать с префиксом — именем объемлющего класса, например:

Outer::Inner *pointer;

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

class Outer {

 

 

//...

 

 

friend class Inner;

//

дпя Inner доступна приватная часть Outer

structure Inner

//

все эпементы доступны в Outer

{/ / . . .

};

// . . .

} ;

Естественно, имя вложенного класса должно быть уникально в объемлющем классе, но может совпадать с другими именами вне класса, например:

class

А {/*...-*/ };

// внешний класс

class

В

 

{ //...

 

class А { / * . . . * / };

// вложенный класс

/ / . . .

} ;

В этом определении внешний класс А не конфликтует с вложенным классом А.

Методы вложенного класса можно реализовать непосредственно внутри класса. Если же методы вложенного класса определяются вне класса, то определение необходимо писать вне самого внешнего из объемлющих классов — в глобальной области видимости. Естественно, имя метода в таком случае должно иметь два префикса, образованные из имен вложенного и объемлющего классов. Вообще, количество префиксов должно быть равно уровню вложенности классов: первый префикс является именем самого объемлющего класса, второй

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

Outer::Inner::Inner()

{ / / . . .

}

Первое имя Inner это имя класса, второе — имя самого конструктора.

В глобальной области видимости вне объемлющего класса можно определить и сам вложенный класс. Подобное определение выглядит отчасти парадоксально, но С++ разрешает это делать — если в объемлющем классе задать объявление класса. Например:

class А

I I . . .

class В;

I I объявление вложенного класса

I I . . .

 

} ;

class А::В { //...

Последовательный контейнер

9

} ;

// внешнее определение вложенного класса

Доступность определенного таким образом класса зависит от того, в какой части объемлющего класса задано объявление: если объявление приватное, то и определение является приватным в объемлющем классе.

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

Вернемся к разработке дека.

Итератор для последовательного контейнера

Для дека нам потребуются конструкторы и деструктор. Минимально необходимое множество операций включает в себя операции добавления элемента в начало и конец дека, операции удаления первого и последнего элементов. Еще необходимы функция проверки, есть ли в контейнере элементы, и функция, выдающая количество элементов в контейнере. Обычно в состав методов включают и методы получения значений первого и последнего элементов контейнера. Рассмотрим более подробно операцию доступа к элементам контейнера.

Так как внутренняя структура элемента скрыта, поскольку определена в приватной части класса-дека, программа-клиент не сможет работать с элементами контейнера посредством указателей. Для перебора элементов контейнер должен обеспечить последовательный доступ к ним по-другому. Это можно сделать одним из двух способов:

реализовать методы доступа непосредственно как методы контейнера;

инкапсулировать все операции доступа в отдельный класс-итератор.

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

ВНИМАНИЕ -----------------------------------------------------------------------------------------------------------------------------------------------------

Именно таким образом реализованы итераторы в стандартной библиотеке шаблонов (STL). Это — одно из решений, оказавших существенное влияние на ее состав и структуру.

О раздельной трансляции см. главу 13.

Класс-итератор можно реализовать как отдельный независимый класс. Но так как итератор должен иметь доступ к внутренней структуре элемента контейнера, он должен с этим классом «дружить». Очевидно, что итератор очень тесно связан с внутренней организацией контейнера, поэтому лучше его реализовать в качестве вложенного в класс-контейнер класса (см. п. 9.7 в [1]), как и класс-узел. Поскольку программе-клиенту потребуется создавать объектыитераторы, этот класс должен быть определен в открытой части класса контейнера.

Назовем вложенный класс-итератор именем i t e r a t o r . При создании итератора программаклиент обязана будет указать префикс — имя объемлющего класса, например:

TDeque::iterator it;

Значение итератора представляет собой позицию в контейнере. Набор операций с итераторами фактически уже описан нами (см. ранее раздел «Доступ к элементам контейнера»), однако полезно еще раз на этом остановиться. Итак, класс-итератор должен обеспечивать следующий минимальный набор операций:

Последовательный контейнер

10

получение элемента в текущей позиции итератора (*);

присваивание итератора (=);

проверка совпадения позиций, представленных двумя итераторами ( = = и ! =);

перемещение итератора к следующему элементу контейнера (++).

Итератор с таким набором операций в стандартной библиотеке называется прямым. Если мы добавим операцию перемещения к предыдущей позиции (декремент - -), то получим итератор, который в стандартной библиотеке называется двунаправленным. Отметим, что набор операций двунаправленного итератора соответствует множеству операций с указателями при переборе элементов массива. Это позволяет одинаковым образом обращаться и с массивами, и с контейнерами. Однако надо отметить, что встроенные указатели, по выражению Д. Элджера [25], являются «глупыми», тогда как итератор мы можем сделать настолько «умным», насколько пожелаем.

Для того чтобы начать перебирать элементы контейнера, итератору надо присвоить первоначальное значение, соответствующее первому элементу контейнера. Обычно для этого в контейнер включают метод b e g i n ( ) , который в качестве результата возвращает итератор, установленный в начало последовательности элементов контейнера.

Метод e n d ( ) возвращает итератор, установленный в конец последовательности элементов контейнера. Что считать концом последовательности, составляет важный вопрос реализации. По примеру STL (STL — прекрасный пример для подражания!) будем считать, что концом последовательности является позиция за последним элементом последовательности. Таким образом, пара методов, b e gi n ( ) , e n d ( ) , определяет полуоткрытый интервал, который содержит первый элемент, но выходит за пределы последнего элемента (рис. 6.1). Это в точности соответствует ситуации, описанной нами при реализации конструктора класса Т А г г а у (см. листинг 5.4).

1 шг *

J Щ__________

begin() end()

Рис. 6.1. Методы begin() и end()

Полуоткрытый интервал обладает двумя достоинствами:

не нужно специально обрабатывать пустой интервал, так как в пустом интервале значения b e gi п ( ) и e n d () равны;

упрощается проверка завершения перебора элементов контейнера — цикл продолжается до тех пор, пока итератор не достигнет позиции p n d ( ) .

Для реализации полуоткрытого интервала в контейнер обычно добавляют «пустой» фиктивный элемент, не содержащий данных (рис. 6.2).

Head

Elem item Tail

next с prev

Соседние файлы в папке ТЕМА КОНТЕЙНЕРЫ