Оглавление
Весь код можно найти в этом репозитории
В третьей статье пойдет речь уже о готовом аллокаторе который вполне пригоден для распределения памяти. Он полностью переписан, но идея та же самая, неявный список свободных блоков с граничными тегами сверху и снизу, но с массивом списков свободных блоков. Т.е. по сути с бинами.
Бины реализованы как массив размером 256 указателей на двусвязные списки. Блоки хранятся в бине индекс которого равен размеру блока если блок <= 255 байт, остальные блоки лежат в бине с индексом 255 и отсортированы по размеру. В бинах с меньшими индексами свободный блок просто добавляется в начало списка со сложностью O(1). Так же поиск бина получается быстрым так как доступ по индексу в массиве так же имеет сложность O(1), это оптимизирует работу с блоками небольшого размера. Для поиска больших блоков придется довольствоваться поиском в отсортированном по размеру списке.
Вот общая схема:

Индекс бина вычисляется следующим образом:
static size_t bin_index_from_size(size_t size) { if (size >= kHugeBlockMinSize) { return kHugeBinIndex; } return size; }
Связи хранятся в так называемом payload, т.е. в пространстве которое будет использовано если блок занят, но если блок свободен, то мы можем использовать его как захотим. Следует отметить что это обязывает нас сделать минимальный размер блока 16 байт, или sizeof(void*) * 2

Это обеспечивается простым набором функций в стиле С. Все функции высокого уровня работают только с указателями на payload блока. Функции высокого уровня это функции которые работают с блоками, а не с сырой памятью. Это важно!
Ниже функции работающие с сырой памятью. Именно они делают из нее блоки.
/** * Memory block related stuff */ // Возвращает указатель типа size_t* на память на которую укзывает __p // нужно для чтения и записи служебной информации хранящейся в граничных тегах static size_t *mem_block_size_t_ptr(void *_p) { return reinterpret_cast<size_t *>(_p); } // Возвращает размер блока. Предполагается что __p указывает на хедер // или футер блока static size_t mem_block_get_size(void *_p) { return *mem_block_size_t_ptr(_p) & ~0x01; } // Возвращает указатель типа char* на память на которую укзывает __p // нужно для арифметических операций с указателями, что бы шаг был размером в байт static char *mem_block_char_ptr(void *_p) { return reinterpret_cast<char *>(_p); } // Возвращает указатель на payload из указателя на заголовок блока // Все функции высокого уровня работают с указателями на payload static char *mem_block_user_ptr(void *_p) { return mem_block_char_ptr(_p) + kHeaderSize; } // Упаковывает вместе размер блока и бит состояния. Сободен\занят static void mem_block_pack(void *_p, size_t _sz, size_t _st) { *mem_block_size_t_ptr(_p) = _sz | _st; } // получает указатель на заголовок блока из указателя на payload static char *mem_block_header(void *_p) { return mem_block_char_ptr(_p) - kHeaderSize; } // Возвращает размер блока из указателя на payload static size_t mem_block_size(void *_p) { return mem_block_get_size(mem_block_header(_p)); } // Возвращает указатель на эпилог блока из указателя на payload static char *mem_block_footer(void *_p) { return mem_block_char_ptr(_p) + mem_block_size(_p); } // Читает занят ли блок из указателя на один из граничных тегов static size_t mem_block_get_alloc(void *p) { return (*mem_block_size_t_ptr(p) & 0x01); } // Проверяет аллоцирован ли блок из указателя на payload static bool mem_block_is_allocated(void *p) { return mem_block_get_alloc(mem_block_header(p)) == kBlockAllocated; } static bool mem_block_is_free(void *p) { return !mem_block_is_allocated(p); } // Кладет служебную информацию в заголовок блока. __p указатель на payload static void mem_block_put_to_header(void *_p, size_t _sz, size_t state) { mem_block_pack(mem_block_header(_p), _sz, state); } // Кладет служебную информацию в футер блока. __p указатель на payload static void mem_block_put_to_footer(void *_p, size_t _sz, size_t state) { mem_block_pack(mem_block_footer(_p), _sz, state); } // Возвращает следующий блок из неявного списка. Т.е. следующий смежный блок // возвращается указатель на payload. __p указатель на payload static void *mem_block_next(void *_p) { return mem_block_char_ptr(_p) + mem_block_size(_p) + kOverheadSize; } // Тривиальное заполнение оверхеда. __p указатель на payload // Возвращается указатель на payload static inline void *mem_block_init_block(void *_p, size_t _sz, size_t state) { // сначала хедер mem_block_put_to_header(_p, _sz, state); // потом футер mem_block_put_to_footer(_p, _sz, state); return _p; } // Возвращает предыдущий блок из неявного списка. Т.е. предыдущий смежный блок // возвращается указатель на payload // __p указатель на payload static void *mem_block_prev(void *_p) { char *header = mem_block_char_ptr(mem_block_header(_p)); char *prev_footer = header - kFooterSize; size_t prev_size = mem_block_get_size(prev_footer); return prev_footer - prev_size; } // Размер блока с оверхедом, т.е. размерами заголовка и футера // ptr указатель на payload static inline size_t mem_block_size_with_overhead(void *ptr) { return mem_block_size(ptr) + kOverheadSize; }
Начнем с рассмотрения того как аллокатор инициализирует себя в начале. Он кладет единственный большой свободный блок в соответствующий бин.
int mem_initialize(void *base, size_t size) { if (base && size > 0 && (size % 2 == 0)) { size_t binsSize = kBinCount * sizeof(void *); if (size > binsSize) { gMemStart = mem_block_char_ptr(base); // Если определен этот макрос то резервируем первые sizeof(void*) * 256 // байт для наших бинов иначе используем стековый массив #if BINS_ARE_IN_HEAP gMemStart = mem_block_char_ptr(base) + binsSize; size -= binsSize; gBinList = reinterpret_cast<ListHead **>(base); ALOGD("gBinList %p binsSize %zu gMemStart %p", gBinList, binsSize, gMemStart); #endif // выставляем все бины в nullptr memset(gBinList, 0, binsSize); // Первый и последний блоки являются служебными и всегда аллоцированы // это сделано для упрощения слияния mem_block_pack(gMemStart, kOverheadSize, kBlockAllocated); mem_block_pack(gMemStart + kHeaderSize + kOverheadSize, kOverheadSize, kBlockAllocated); // Уменьшаем размер кучи на // 5 оверхедов это оверхеды служебных блоков в начале и в конце // и их размеры равные оверхеду блока и пятый это оверхед самого свободного // блока size_t heapSize = size - (kOverheadSize * 5); void *heap = mem_block_next(mem_block_user_ptr(gMemStart)); mem_block_init_block(heap, heapSize, kBlockFree); // Последний блок так же является служебным и всегда аллоцирован gMemEnd = mem_block_char_ptr(mem_block_next(heap)) - kHeaderSize; mem_block_pack(gMemEnd, kOverheadSize, kBlockAllocated); mem_block_pack(gMemEnd + kOverheadSize + kHeaderSize, kOverheadSize, kBlockAllocated); auto firstBlock = mem_block_list_head(heap); // После инициализации служебных блоков кладем в бин единственный свободный блок bin_insert(firstBlock); return 0; } } else { ALOGE("Could not initialize memory with params base %p size %zu", base, size); } ALOGD("gMemStart %p gMemEnd %p size %td", gMemStart, gMemEnd, mem_block_char_ptr(gMemEnd) - mem_block_char_ptr(gMemStart)); return EINVAL; }
Перейдем теперь к выделению свободного блока и потихоньку перейдем к бинам.
void *mem_malloc(size_t size) { void *block = nullptr; if (gMemStart) { if (size > 0) { // Вернет минимальный размер блока sizeof(void*) * 2 без учета // оверхеда. Он будет учтен в другом месте, а именно в mem_block_place size_t aligned_size = mem_block_aligned_size(size); // Проверяем бины, если там есть свободный блок возвращаем его auto memoryBlock = bin_find_free_block(aligned_size); if (memoryBlock) { block = memoryBlock; // удаляем найденный блок из бина bin_erase(block); // распределяем блок отрезая от него кусок если нужно block = mem_block_place(block, aligned_size); // Если мы отрежем от блока кусок, то сделаем это с начала. Тогда // получается что если блок большой и мы взяли только его часть // то следующий смежный блок должен быть свободен auto next = mem_block_next(block); if (next < gMemEnd && next > gMemStart && mem_block_is_free(next)) { auto nextBlock = mem_block_list_head(next); // если это так то кладем его в бин bin_insert(nextBlock); } } } else { ALOGE("Could not allocate block with size %zu", size); errno = EINVAL; } } else { errno = EINVAL; ALOGE("Not initialized"); } return block; }
Для понимания того как работает mem_malloc нам нужно разобраться с несколькими функциями:
mem_block_place, bin_find_free_block и bin_insert
Давайте начнем с mem_block_place она самая сложная, хотя на первый взгляд может показаться тривиальной.
static void *mem_block_place(void *block, size_t sz) { // выясняем размер текущего блока. Напоминаю он свободен раз мы тут size_t cur_size = mem_block_size(block); // нам нужно понимать сколько останется от блока если мы попробуем // отрезать от него sz байт size_t remain = cur_size - sz; // Если это минимальный размер блока, т.е. 2 оверхеда потому что payload блока // может иметь минимальный размер оверхед + ему нужен его собственный оверхед // и того sizeof(void*) * 4 // то отрезаем от начала блока sz байт меняя имеющийся заголовок if (remain >= kOverheadSize * 2) { // принимая в расчет что мы создадим новый футер и новый хедер // футер для нового блока и хедер для оставшегося куска памяти remain -= kOverheadSize; // создаем новый блок mem_block_put_to_header(block, sz, kBlockAllocated); mem_block_put_to_footer(block, sz, kBlockAllocated); // готово // заполняем оверхед следующего блока новым размером и уходим auto next = mem_block_next(block); mem_block_put_to_header(next, remain, kBlockFree); mem_block_put_to_footer(next, remain, kBlockFree); return block; } // если remain меньше чем sizeof(void*) * 4 // мы пренебрегаем этим и распределяем весь блок mem_block_put_to_header(block, cur_size, kBlockAllocated); mem_block_put_to_footer(block, cur_size, kBlockAllocated); return block; }
Причина по которой aligned_size не учитывает оверхед заключается в том что сам свободный блок уже его имеет и как следует из реализации mem_block_place мы либо отрезаем достаточно что бы создать новый оверхед либо отдаем имеющийся блок целиком
Сами свободные блоки лежат в двусвязном списке который реализован в коде в виде следующей структуры:
struct ListHead { ListHead *next = nullptr; ListHead *prev = nullptr; };
Так же вспомогательная функция которая умеет создавать из блока памяти объект типа ListHead*
static inline ListHead *mem_block_list_head(void *ptr) { auto head = reinterpret_cast<ListHead *>(ptr); head->next = nullptr; head->prev = nullptr; return head; }
Как упоминалось ранее более высоко уровневые функции которые работают не с сырой памятью, а с блоками всегда принимают указатель на payload. Получается что в payload свободного блока помещаются как минимум две ноды списка. Я решил пожертвовать дополнительной памятью что бы реализовать удаление за O(1).
static ListHead *list_erase(ListHead *head) { auto prev = head->prev; auto next = head->next; if (prev) { prev->next = next; } if (next) { next->prev = prev; } head->next = nullptr; head->prev = nullptr; return next; } static void bin_erase(void *block) { auto *head = reinterpret_cast<ListHead *>(block); size_t index = bin_index_from_size(mem_block_size(block)); list_erase(head); if (gBinList[index] == head) { gBinList[index] = head->next; } head->next = nullptr; head->prev = nullptr; }
Это необходимо делать каждый раз при слиянии блоков. Но об этом позже. Сейчас рассмотрим поиск свободного блока:
static ListHead *bin_find(size_t index, size_t size) { auto block = gBinList[index]; while (block) { if (mem_block_size(block) >= size) { break; } block = block->next; } return block; } static ListHead *bin_find_free_block(size_t size) { // Получаем индекс бина из размера блока size_t index = bin_index_from_size(size); // Получаем бин из массива за O(1) auto block = gBinList[index]; // Далее if (block) { if (index == kHugeBinIndex) { // если блок большой ищем в большом бине // операция может быть O(n) в худшем случае, если мы ищем очень большой блок // который будет последним в списке block = bin_find(index, size); } } else { // иначе перебираем все имеющиеся бины начиная с текущего индекса. // Напоминаю что в маленьких бинах // лежат блоки одинакового размера, так что нам подойдет первый // попавшийся с нужным размером // номинально операция линейная, но фактически идет перебор максимум 256 // элементов и по факту первый же бин который != nullptr даст нам список за // O(1) кроме того перебор всех 256 бинов невозможен, учитывая минимальный // размер блока for (size_t i = index; i <= kHugeBinIndex && !block; ++i) { block = bin_find(i, size); } } return block; }
Рассмотрим теперь вставку в бин
// Тривиальная вставка в начало списка template<typename _Node> _Node *free_list_prepend(_Node *head, _Node *block) { block->next = nullptr; if (!head) { return block; } if (head != block) { block->next = head; head->prev = block; } return block; } // Вставка в отсортированный список без оптимизации случая вставки в середину // за отсутствием просто такого кейса в коде template<typename _Node, typename _Cmp> _Node *__free_list_insert_sorted(_Node *head, _Node *block, _Cmp cmp) { if (!head) { head = block; head->next = nullptr; return head; } auto cursor = head; if (cmp(block, cursor)) { return free_list_prepend(cursor, head); } while (cursor->next && !cmp(block, cursor->next)) { cursor = cursor->next; } block->next = cursor->next; cursor->next = block; block->prev = cursor; if (cursor->next) { cursor->next->prev = block; } return head; } // Вызывает функцию выше с предикатом в виде лямбды template<typename _Node> _Node *free_list_insert_sorted_by_size(_Node *head, _Node *block) { block->next = nullptr; if (!head) { return block; } if (head != block) { auto cmp = [](_Node *first, _Node *second) { return mem_block_size(first) <= mem_block_size(second); }; return __free_list_insert_sorted<_Node, decltype(cmp)>(head, block, cmp); } return block; } // Собственно вставка template<typename _Node> _Node *bin_insert(_Node *block) { size_t index = mem_block_size(block); if (index < kHugeBinIndex) { gBinList[index] = free_list_prepend(gBinList[index], block); } else { gBinList[kHugeBinIndex] = free_list_insert_sorted_by_size(gBinList[kHugeBinIndex], block); } return block; }
Теперь перейдем к освобождению памяти, там видеть операции со списком за O(1) особенно радостно.
// удаляет из бинов смежные блоки перед слиянием если они свободны static void *mem_block_erase_merge(void *block) { auto current = mem_block_prev(block); if (mem_block_is_free(current)) { bin_erase(current); /* O(1) */ } current = mem_block_next(block); if (mem_block_is_free(current)) { bin_erase(current); /* O(1) */ } // эту функцию рассмотрим отдельно return mem_block_merge(block); } // получается амортизированно O(1) со слиянием! void mem_free(void *ptr) { if (ptr != nullptr && mem_block_is_allocated(ptr)) { // берем размер что бы проинициализировать блок снова // как свободный size_t size = mem_block_size(ptr); mem_block_init_block(ptr, size, kBlockFree); // готово сливаем со смежными свободными блоками предварительно // удалив их из бинов ptr = mem_block_erase_merge(ptr); // вставляем блок в соответствующий бин bin_insert(mem_block_list_head(ptr)); } else { ALOGE("%s(): Invalid pointer (%p)\n", __func__, ptr); } }
Теперь рассмотрим функцию слияния:
static void *mem_block_merge(void *ptr) { // берем следуюий и предыдущий смежные блоки auto next = mem_block_next(ptr); auto prev = mem_block_prev(ptr); // выясняем аллоцированы ли они bool next_allocated = mem_block_is_allocated(next); bool prev_allocated = mem_block_is_allocated(prev); // запоминаем размер переданного блока, он еще пригодится ... size_t size = mem_block_size(ptr); if (prev_allocated && next_allocated) { /* соседи аллоцированы, нечего сливать */ } else if (prev_allocated && !next_allocated) { // сливаем со следующим, т.е. наращиваем размер блоку ptr с конца // уитываем освободившееся место из за отсутствия старого футера блока // ptr и хедера блока с которым мы сливаемся size += mem_block_size(next) + kOverheadSize; mem_block_put_to_header(ptr, size, kBlockFree); void *footer = mem_block_footer(ptr); mem_block_pack(footer, size, kBlockFree); } else if (!prev_allocated && next_allocated) { // тут зеркальный случай, только наращивем блок ptr к предыдущему блоку // в остальном все в точности тоже самое void *footer = mem_block_footer(ptr); // больше нет хедера ptr и футера предыдущего блока, футер ptr теперь футер // предыдущего блока size += mem_block_size(prev) + kOverheadSize; mem_block_put_to_header(prev, size, kBlockFree); mem_block_pack(footer, size, kBlockFree); return prev; } else if (!prev_allocated && !next_allocated) { // сливаемся с обоими, тоже ничего сложного. Все тоже самое что выше // только остаются хедер предыдущего блока и футер следующего void *header = mem_block_header(prev); void *footer = mem_block_footer(next); // учитываем что освобождаются футер предыдущего, весь оверхед блока ptr // и хедер следующего, т.е. в сумме, по размеру 2 полных оверхеда size += mem_block_size(prev) + mem_block_size(next) + kOverheadSize * 2; mem_block_pack(header, size, kBlockFree); mem_block_pack(footer, size, kBlockFree); return prev; } return ptr; }
Таким образом уменьшается фрагментация памяти и упрощается реализация, не нужна реализация отдельного алгоритма слияния блоков.

Так же есть функции mem_realloc и mem_calloc, их реализация тривиальна:
void *mem_calloc(size_t num, size_t size) { size_t count = size * num; void *p = mem_malloc(count); if (p != nullptr) { memset(p, 0, count); } return p; } void *mem_realloc(void *p, size_t new_sz) { if (!p) { return mem_malloc(new_sz); } auto block = mem_malloc(new_sz); if (block) { memmove(block, p, min(new_sz, mem_block_size(p))); mem_free(p); } return block; }
На этот раз я реализовал возможности какой никакой диагностики помимо дампа как в предыдущих статьях. Теперь можно хоть как-то проверить целостность кучи:
bool mem_check_block(void *p) { if (p) { // проверяем выравнивание, если оно сломано то это не наш указатель if ((reinterpret_cast<size_t>(p) % kPointerSize) == 0) { // хедер и футер должны быть равны друг другу по содержанию if (*mem_block_header(p) == *mem_block_footer(p)) { // На этом диагностика заканчивается :-) return true; } else { ALOGE("Bad block. Header and footer are not the same"); } } else { ALOGE("Bad block (%p). The address is not aligned by %zu", p, kPointerSize); } } else { ALOGE("Bad block (%p)", p); } return false; } bool mem_check(bool verbose) { char buffer[kMaxMessageLen]; if (gMemStart != nullptr && gMemEnd != nullptr) { for (void *cur_blk = mem_block_user_ptr(gMemStart); cur_blk <= mem_block_user_ptr(gMemEnd); cur_blk = mem_block_next(cur_blk)) { if (verbose) { mem_print_block_to_str(cur_blk, buffer); if (!mem_check_block(cur_blk)) { ALOGD("block %s BAD", buffer); return false; } ALOGD("%s OK", buffer); } } } return true; }
Вот пример использования:
#include <iostream> #include <memory> #include <vector> #include <string> #include <string.h> #include "memory.h" #define LOG_TAG "main" #include "logging.h" #define PAGE_SIZE 4096 #define HEAP_SIZE (4096 + 1000000 + 2048) #define ITEMS_NUMBER 4 static char *char_alloc(size_t size) __attribute__((unused)); static char *char_alloc(size_t size) { auto p = mem_malloc(size); //std::cout << __func__ << ": p = " << std::hex << p << std::endl; return reinterpret_cast<char *>( p); } int main() { std::unique_ptr<char[]> heap(new char[HEAP_SIZE]); std::vector<char *> vptr; vptr.reserve(ITEMS_NUMBER); auto p = heap.get(); *p = 'a'; mem_initialize(heap.get(), HEAP_SIZE); dump_mem(); dump_bins(); for (int i = 1; i < ITEMS_NUMBER; ++i) { vptr[i] = char_alloc(i); } mem_check(true); for (int i = 1; i < ITEMS_NUMBER; ++i) { if ((i % 2) == 0) { mem_free(vptr[i]); } } dump_mem(); dump_bins(); mem_check(true); for (int i = 0; i < ITEMS_NUMBER; ++i) { if ((i % 2) != 0) { mem_free(vptr[i]); } } dump_mem(); dump_bins(); mem_check(true); return 0; }
вывод:
D:\osdev\small_allocator\cmake-build-debug\malloc.exe memory: *************************MEMORY DUMP************************* memory: service block address 000001ce78820088 size 16 size with overhead 32 state allocated memory: block address 000001ce788200a8 size 1006064 size with overhead 1006080 state free memory: service block address 000001ce78915aa8 size 16 size with overhead 32 state allocated memory: **********************END OF MEMORY DUMP********************** memory: total memory 1006096 bytes memory: total memory with overhead 1006144 bytes memory: total total_blocks count 3 memory: total allocated blocks 2 memory: total free blocks 1 memory: block 000001ce788200a8 size 1006064 size with overhead 1006080 mem bin[255] prev addr 0000000000000000 next addr 0000000000000000 memory: Total free blocks in all bins 1 memory: block (000001ce78820088) header (000001ce78820080) [16:1] footer (000001ce78820098) [16:1] OK memory: block (000001ce788200a8) header (000001ce788200a0) [16:1] footer (000001ce788200b8) [16:1] OK memory: block (000001ce788200c8) header (000001ce788200c0) [16:1] footer (000001ce788200d8) [16:1] OK memory: block (000001ce788200e8) header (000001ce788200e0) [16:1] footer (000001ce788200f8) [16:1] OK memory: block (000001ce78820108) header (000001ce78820100) [1005968:0] footer (000001ce78915a98) [1005968:0] OK memory: block (000001ce78915aa8) header (000001ce78915aa0) [16:1] footer (000001ce78915ab8) [16:1] OK memory: *************************MEMORY DUMP************************* memory: service block address 000001ce78820088 size 16 size with overhead 32 state allocated memory: block address 000001ce788200a8 size 16 size with overhead 32 state allocated memory: block address 000001ce788200c8 size 16 size with overhead 32 state free memory: block address 000001ce788200e8 size 16 size with overhead 32 state allocated memory: block address 000001ce78820108 size 1005968 size with overhead 1005984 state free memory: service block address 000001ce78915aa8 size 16 size with overhead 32 state allocated memory: **********************END OF MEMORY DUMP********************** memory: total memory 1006048 bytes memory: total memory with overhead 1006144 bytes memory: total total_blocks count 6 memory: total allocated blocks 4 memory: total free blocks 2 memory: block 000001ce788200c8 size 16 size with overhead 32 mem bin[16] prev addr 0000000000000000 next addr 0000000000 000000 memory: block 000001ce78820108 size 1005968 size with overhead 1005984 mem bin[255] prev addr 0000000000000000 next addr 0000000000000000 memory: Total free blocks in all bins 2 memory: block (000001ce78820088) header (000001ce78820080) [16:1] footer (000001ce78820098) [16:1] OK memory: block (000001ce788200a8) header (000001ce788200a0) [16:1] footer (000001ce788200b8) [16:1] OK memory: block (000001ce788200c8) header (000001ce788200c0) [16:0] footer (000001ce788200d8) [16:0] OK memory: block (000001ce788200e8) header (000001ce788200e0) [16:1] footer (000001ce788200f8) [16:1] OK memory: block (000001ce78820108) header (000001ce78820100) [1005968:0] footer (000001ce78915a98) [1005968:0] OK memory: block (000001ce78915aa8) header (000001ce78915aa0) [16:1] footer (000001ce78915ab8) [16:1] OK memory: *************************MEMORY DUMP************************* memory: service block address 000001ce78820088 size 16 size with overhead 32 state allocated memory: block address 000001ce788200a8 size 1006064 size with overhead 1006080 state free memory: service block address 000001ce78915aa8 size 16 size with overhead 32 state allocated memory: **********************END OF MEMORY DUMP********************** memory: total memory 1006096 bytes memory: total memory with overhead 1006144 bytes memory: total total_blocks count 3 memory: total allocated blocks 2 memory: total free blocks 1 memory: block 000001ce788200a8 size 1006064 size with overhead 1006080 mem bin[255] prev addr 0000000000000000 next addr 0000000000000000 memory: Total free blocks in all bins 1 memory: block (000001ce78820088) header (000001ce78820080) [16:1] footer (000001ce78820098) [16:1] OK memory: block (000001ce788200a8) header (000001ce788200a0) [1006064:0] footer (000001ce78915a98) [1006064:0] OK memory: block (000001ce78915aa8) header (000001ce78915aa0) [16:1] footer (000001ce78915ab8) [16:1] OK Process finished with exit code 0
Ну вот и все! Важная часть библиотеки написана!
До новых встреч!
