ПрограммированиеСтатьиОбщее

Использование контейнеров в разработке игр.

Автор:

Ни одна игра, сложная или простая не может обойтись без контейнеров. В своих играх вы используете собственные контейнеры (возможно не подозревая как они называются:) или стандартные на основе шаблонов. Контейнерами называют объекты, способные хранить списки данных определенного типа. Контейнеры делятся по способу обращения к информации на векторы, карты (или словари) и списки. Шаблоны контейнеров входят в стандартную библиотеку C++ — STL. Целью этой серии статей является рассмотреть применение контейнеров в разработке игр. Подразумевается, что вы владеете C++ и хотя бы чуть-чуть знакомы с STL. (Впрочем, никогда не поздно ознакомиться, тем более описаний ее достаточно в интернете. MSDN также содержит полное описание STL, входящей в пакет VC++).

Быстрые списки
Карты ресурсов

Быстрые списки

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

Стандартная библиотека STL содержит шаблон std::list который позволяет создавать списки для различных типов данных. Например:

#include <list>   // заголовок, где описан std::list
using namespace std; // используем область имен std

typedef   list<int>  INT_LIST;    // список int-ов
typedef   list<LPVOID>  PTR_LIST;  // список из указателей
typedef   list<POINT>  POINT_LIST;    // список координат

В принципе, стандартные списки дают все, что нам нужно за одним исключением. При добавлении нового элемента std::list выделяет новый блок памяти, а при удалении - освобождает его. А это медленные операции. К тому же вставка нового элемента происходит следующим образом:

list::push_back( MyVariable );
То есть мы не можем просто создать элемент и добавить его в список. Мы плюс к этому производим инициализацию элемента путем копирования из переменной MyVariable. Попробуем написать свой список, который будет лишен этих недостатков.

Прежде всего, как решить проблему выделения памяти? - Выделить ее сразу столько, сколько может понадобиться. Этот путь похож на реализацию массива. Так и есть - мы создаем массив. Итак, наш шаблон будет выглядеть следующим образом:

template <class Type> class list

Объявляем указатель на данные:

typedef Type * t_ptr; // указатель на данные

Переменная для хранения указателя на массив данных:

t_ptr m_pData; // массив для данных

Код выделения m_Capacity элементов:

m_pData = new Type[m_Capacity];

Как теперь организовать список? Самый быстрый вариант списка - связной список. Он представляет собой набор элементов, каждый из которых хранит указатель на следующий и предыдущий. Конечно, элемент связного списка может иметь указатель только на следующий элемент, но тогда мы сможем перемещаться только в одну сторону (чего обычно достаточно) и второй недостаток - медленное удаление единичного элемента из списка. Т.е. такой список может накоплять элементы. Удалять же можно только все  элементы сразу. Поэтому мы будем использовать следующую структуру для элементов списка:

typedef struct { 
         void * pNext;   //  указатель на следующий элемент
         void * pPrior;   //  указатель на предыдущий элемент
         t_ptr pData;      //  указатель на данные
} t_listitem; 

Чтобы не выделять память каждый раз для нового элемента t_listitem мы выделим ее сразу для всех:

m_pCache = m_pItems = new t_listitem[m_Capacity];

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

// связываем элементы списка в одну цепочку 
t_listitem *pLast = NULL; 
t_listitem *pCurrent = m_pCache; 
for(int n=0; n < m_Capacity; n++, pCurrent++){ 
      pCurrent->pPrior = (void*)pLast; 
      pCurrent->pData = m_pData + n; 
      if(pLast!=0) pLast->pNext = (void*)pCurrent; 
      pLast = pCurrent; 
} 
m_pCache[m_Capacity-1].pNext = 0; 

Теперь реализуем добавление нового элемента на примере добавления в начало списка:

Type & add_first() { 
     // перемещаем элемент из резерва в начало списка 
     t_listitem *item = m_pCache;   // первый свободный элемент
     m_pCache = (t_listitem*)item->pNext; // убираем его из резерва
     if(m_pCache!=0) m_pCache->pPrior = 0; 
     // добавляем в список
     if(m_pBegin== 0) m_pEnd= item; 
     else m_pBegin->pPrior = (void*)item; 
     item->pNext = (void*)m_pBegin; 
     m_pBegin = item; 
     // приводим в соответствие количества элементов в списке и резерве 
     m_Size++; 
     m_Capacity--; 
     return *(item->pData); 
} 

Чтобы удалить элемент из списка нужно просто связать между собой предыдущий и следующий элементы:

void remove(const iterator & i) { 
     if(i.m_pPosition->pPrior!=0) 
         ((t_listitem*)(i.m_pPosition->pPrior))->pNext = i.m_pPosition->pNext; 
     if(i.m_pPosition->pNext!=0) 
         ((t_listitem*)(i.m_pPosition->pNext))->pPrior = i.m_pPosition->pPrior; 
     if(i.m_pPosition==m_pBegin) 
         m_pBegin = (t_listitem*)(i.m_pPosition->pNext); 
     if(i.m_pPosition==m_pEnd) 
         m_pEnd = (t_listitem*)(i.m_pPosition->pPrior); 
     // приводим в соответствие количества элементов в списке и резерве 
     m_Size--; 
     m_Capacity++; 
}

Тут мы использовали класс iterator (итератор). Итератор нужен для позиционирования в списке, он выполняет роль своеобразного индекса. Итератор скрывает внутреннюю реализацию списка. Вся функциональность итератора заключена в четырех операторах:

*оператор получения данных
++оператор перемещения вперед по списку
––оператор перемещения назад по списку
=оператор присваивания

Код класса iterator небольшой, поэтому приведу его полностью:

class iterator // класс для перебора записей 
{ 
friend class legionary::list; 
protected: 
     t_listitem * m_pPosition; // указатель на текущий элемент цепочки 
public: 
     iterator() { m_pPosition = 0; } 
     ~iterator() {} 
     Type & operator*() { // оператор для получения данных (*iter) 
          return *(m_pPosition->pData); 
     } 
     iterator& operator++() { // для движения по списку вперед 
          m_pPosition = (t_listitem*)m_pPosition->pNext; return (*this); } 
     iterator& operator--() { // для движения по списку назад 
          m_pPosition = (t_listitem*)m_pPosition->pPrior; return (*this); } 
     iterator& operator=(const iterator& iter) { // для движения присвоения 
          m_pPosition = iter.m_pPosition; return (*this); } 
     // проверка возможности перемещения 
     bool valid() { return (m_pPosition!=0); } 
     bool last() { return (m_pPosition->pNext!=0); } 
     bool first() { return (m_pPosition->pPrior!=0); } 
};

Ну, вот и все, шаблон списка готов. Полностью код legionary::list можно найти в файле baseobj.h. Хотелось бы узнать его эффективность. Для этого я написал тестовую программу test.cpp. Результаты теста показывают, что полученный список работает на порядок быстрее (до 30 раз) чем стандартный при операциях вставки и удаления элементов и до 4х раз быстрее при перемещении по списку.

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

Страницы: 1 2 Следующая »

#STL, #итераторы, #контейнеры

14 июня 2002 (Обновление: 17 сен 2009)

Комментарии [6]