Pull to refresh

Упрощённое руководство по работе с памятью Си

Reading time 16 min
Views 14K

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

Размерность в Си

Итак, перед тем, как рассказывать про байты, я приведу один точный пример, чтобы разубедить всех, что программирование работает в основном с битами.

Итак, если размер int = 4 байта, размер short int = 2 байта, размер сhar = 1 байт, то какой размер у булевой переменной? Ответ: булевой переменной в принципе не существует без подключения стандартных библиотек. А при подключении библиотеки stdbool.h, размер bool = 1 байт(Зависит от платформы и компилятора, используя компьютер с 32-битным процессором и компилятор gcc я заметил, что размер bool = 4 байта).

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

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

#include <stdbool.h>
#include <stdio.h>

int main(int argc, char** argv) {
  printf("%llu\n", sizeof(bool)); // sizeof возвращает размер в байтах. А в библиотеке
                                  // stdint существует соответсвующий символу тип, "uint8_t", где 8 - это количество бит. Но размер возвращает 1 байт.
  // %llu для вывода long long unsigned int, или, коротко, size_t.
  return 0;
}
// Вывод:
// sizeof bool: 1 // или что-то ещё.

Пояснение: булева переменная - это число, которое принимает либо 0(false), либо 1(true), нужен ровно 1 бит, 1/8 байта.

Если говорить о размерах, то лучше сразу вывести размеры различных типов:

#include <stdio.h>
#include <stdint.h>


// Windows 10, 64-разрядный процессор, компилятор GCC
int main(int argc, char** argv) {
  // вот размеры разных типов                         // Полные названия типов
  printf("sizeof int8_t: %llu\n", sizeof(int8_t));    // char
  printf("sizeof uint8_t: %llu\n", sizeof(uint8_t);   // unsigned char
  printf("sizeof int16_t: %llu\n", sizeof(int16_t);   // short int
  printf("sizeof uint16_t: %llu\n", sizeof(uint16_t); // unsigned short int
  printf("sizeof int32_t: %llu\n", sizeof(int32_t);   // int
  printf("sizeof uint32_t: %llu\n", sizeof(uint32_t); // unsigned int
  printf("sizeof int64_t: %llu\n", sizeof(int64_t);   // long long int
  printf("sizeof uint64_t: %llu\n", sizeof(uint64_t); // unsigned long long int
  
  // Но это не всё. Сразу отмечу ещё один момент, который меняет ВСЁ.  
  printf("sizeof uint8_t*: %llu\n", sizeof(uint8_t*);   // unsigned char*
  printf("sizeof uint16_t*: %llu\n", sizeof(uint16_t*); // unsigned short int*
  printf("sizeof uint32_t*: %llu\n", sizeof(uint32_t*); // unsigned int*
  printf("sizeof uint64_t*: %llu\n", sizeof(uint64_t*); // unsigned long long int*
  printf("sizeof void*: %llu\n", sizeof(void*);         // void*
      // В этом блоке размеры РАВНЫ.
      // Это потому что размер указателя диктуется РАЗРЯДНОСТЬЮ ПРОЦЕССОРА,
      // где 64-битные процессоры соответсвуют 8-байтным указателям,
      // в следствие чего меняется и максимальный размер оперативной памяти.

  // И раз зашла речь о типах данных, то в библиотеке stdint.h
  // содержится ещё несколько интересных типов
  printf("sizeof uintptr_t: %llu\n", sizeof(uintptr_t));
  printf("sizeof intptr_t: %llu\n", sizeof(uintptr_t));
  return 0;
}
// Вывод:
// sizeof int8_t: 1
// sizeof uint8_t: 1
// sizeof int16_t: 2
// sizeof uint16_t: 2
// sizeof int32_t: 4
// sizeof uint32_t: 4
// sizeof int64_t: 8
// sizeof uint64_t: 8
// sizeof uint8_t*: 8  // Размеры указателей верны для 64-битного процессора.
// sizeof uint16_t*: 8 // На других архитектурах возможны другие размеры.
// sizeof uint32_t*: 8 
// sizeof uint64_t*: 8 
// sizeof void*: 8     
// sizeof uintptr_t: 8 // Этот два типа представляет собой указатель, как число.
// sizeof intptr_t: 8  // Да, В него можно преобразовать указатель. В этом даже фишка

Если у вас ничего не щёлкнуло в голове, то поясню - все указатели имеют одинаковый размер, а значит их можно относительно безболезненно преобразовывать между собой (Что-то вроде union). unsigned int* uint_ptr = (unsigned int*) int_ptr; Использовать предельно аккуратно, так как при преобразовании указателя, данные внутри не преобразовываются: символ со знаком -0x7F равен символу без знака 0xFF из-за того, что самый большой бит ответственен за знак. А если мы преобразовываем указатель с размером типа 2 байта, в указатель с размером типа 1 байт, мы гарантированно получим только первую половину данных, если считать от адреса указателя.

UPD 29.01.2023: Пользователь @MiraclePtr поделился статьёй про синонимы.

Структуры(struct), объединения(union) и немного enum.

Итак, начнём со структур, потому что без них нельзя объяснить смысл и некоторое удобство объединений.

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

#include <stdio.h>

struct s_data {
  unsigned char type; // sizeof(char) = 1;
  int x, y, z; // (sizeof(int) = 4) + (sizeof(int) = 4) + (sizeof(int) = 4);
}; // суммарно 13

struct s_data_arr {
  unsigned char type; // sizeof(char) = 1;
  int values[3]; // sizeof(int) * 3 = 4 * 3;
}; // Суммарно 13

int main(int argc, char** argv) {
  printf("sizeof struct s_data: %llu\n", sizeof(struct s_data));
  printf("sizeof struct s_data_arr: %llu\n", sizeof(struct s_data_arr));
  printf("sizeof struct s_data*: %llu\n", sizeof(struct s_data*));
  return 0;
}
// Вывод
// sizeof struct s_data: 16     //(Всё дело в выравнивании по байтам.
// sizeof struct s_data_arr: 16 // Это будет представлено
// sizeof struct s_data*: 8     // так: 
                              // char, NULL_byte, NULL_byte, NULL_byte, int, int, int)
                              // Выравнивание по n sizeof(type), где type - тип,
                              // а n - положение на линейке оперативной памяти
                              // Ссылку на более подробное описание добавлю в конце
                              // статьи, так как сам только недавно прочитал.
// Пы. Сы. Зато в эти 3 байта можно вписать ещё переменных. Вроде такого:

struct s_data__ {
  unsigned char type; // sizeof(char) = 1;
  unsigned char chr; // sizeof(char) = 1;
  unsigned short int count; // sizeof(unsigned short int) = 2;
  int x, y, z; // (sizeof(int) = 4) + (sizeof(int) = 4) + (sizeof(int) = 4);
}; // суммарно 16. sizeof(s_data__) = 16

// P. P. S. Для того, чтобы получить 13-байтовую структуру, нам потребуется упаковать её, или перетасовать свойства:
#pragma pack(1) // выровнять по 1 байту
struct s_data_1 {
  unsigned char type; // sizeof(char) = 1;
  int values[3]; // sizeof(int) * 3 = 4 * 3;
} // фактически 13 байт.
# pragma pack(show)

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

#include <stdio.h>

struct s_data_xyz {
  unsigned char type; // sizeof(char) = 1;
  int x, y, z; // sizeof(int) = 4;
}; // суммарно 13, но 16, хотя это для нас не важно, доверимся компилятору.

struct s_data_arr {
  unsigned char type; // sizeof(char) = 1
  int values[3]; // sizeof(int) * 3 = 4 * 3
}; // Суммарно 13, но 16

union pos {
  unsigned char type;
  struct s_data_xyz as_xyz;
  struct s_data_arr as_arr;
};

int main(int argc, char** argv) {
  union pos p;
  printf("sizeof union pos: %llu\n", sizeof(union p));
  p.type = 0;
  p.as_xyz.x = 12;
  p.as_xyz.y = 3;
  p.as_xyz.z = 7;

  printf("p.type: %u\n", p.type);
  printf("p.as_xyz.type: %u\n", p.as_xyz.type);
  printf("p.as_arr.type: %u\n", p.as_arr.type);

  printf("arr elems:\n");
  for (int i = 0; i < 3; i++) {
    printf("%d: %d\n", i, p.as_arr.values[i]);
  }
  
  return 0;
}
// Вывод
// sizeof union pos: 16
// p.type: 0 // Указатели на один и тот же байт без указателей. Всё это.
// p.as_xyz.type: 0
// p.as_arr.type: 0
// arr elems:
// 0: 12
// 1: 3
// 2: 7

Рассказывать о перечислениях(enum) Нечего, потому что это массив чисел, который компилятор удобно подписал ключевыми словами. Не хуже справляется команда препроцессора #define.

В любом случае покажу на примере:

enum {
  ELEM_1, ELEM_2, ELEM_3, ELEM_MAX
}; // Всё int
enum Elems {
  ELEM_1, ELEM_2, ELEM_3, ELEM_MAX
}; // Всё Elems, который typedef int Elems;
// Работает только с int
typedef unsigned char Elems;
#define ELEM_1    ((Elems) 0x00) // Не уверен в том, что это не будет воспринято как препроцессорная функция
#define ELEM_2    ((Elems) 0x01)
#define ELEM_3    ((Elems) 0x02)
#define ELEM_MAX  ((Elems) 0x03)

void fn(Elems a) {} // Так используется в объявлении функций.

Указатели

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

Указатель - это число равное, или меньшее размером, чем разрядность процессора(Большее число просто не выйдет просчитать так же эффективно).

То есть на 64-битном процессоре это 8-байтовые указатели, или меньшие, 4-байтовые и 2-байтовые, на 32-битном - 4-байтовые, или меньшие, 2-байтовые. uintptr_t в свою очередь по размеру совпадает с максимальным допустимым размером указателя на процессоре.

Указатель представляет собой адрес переменной, структуры, константы и чего-либо другого в оперативной памяти. Почему в оперативной памяти, хотя есть куча(от английского heap, место в памяти, выделенное на время выполнения программы) и стек(от английского stack, модель данных, цепочка структур, которые указывают в простейшей реализации на себя и на следующий элемент)? Потому что есть куча и стек, которые существуют одновременно в оперативной памяти, как и многое другое, в том числе другие программы.

UPD 29.01.2023: на микроконтроллерах AVR указатели могут быть 24-битными(3 байта) и 16-битными(2 байта), как подсказал в комментариях пользователь @sun-ami.

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

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

Для примера напишем функцию, которая принимает указатель на число и увеличивает значение данных по его адресу на 1 (В данном примере мы ещё не выделяем и не освобождаем память, а лишь передаём адреса):

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

// Объявления
void increace_value(int* pvalue);
int main(int argc, char** argv);

// Реализации
int main(int argc, char** argv) {
  int a = 2;
  printf("start_\ta: %d\n", a);
  increace_value( // Вызываем функцию
    &a // Передаём АДРЕС переменной в функцию ( Увеличивает количество звёзд после типа на 1)
  );   // тип &a = int*, &&a = int**, &&&a = int*** и так далее
  printf("inc_\ta: %d\n", a);
  return 0;
}

void increace_value(
  int* pvalue // Прибавляем префикс p столько же раз, сколько есть звёздочка.
              // Так удобно воспринимать аргументы
) {
  *pvalue += 1; // обращаемся к значению по адресу и увеличиваем на один.
} // В свою очередь, если int*** val, то *val = int**, val** = int*, val*** = int

// Вывод:
// start_   a: 2
// inc_     a: 3

А теперь перепишем его так, чтобы a был изначально указателем:

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

// Объявления. Можно вынести в main.h и подключать его с помощью #include
void increace_value(int* pvalue);
int main(int argc, char** argv);

// Реализации
int main(int argc, char** argv) {
  int* a = (int*) malloc(sizeof(int)); // Выделяем байты по размеру числа. *alloc функции возвращают адрес в оперативной памяти. 
  *a = 5; // задаём значение переменной по адресу, как в функции.
  printf("start_\ta: %d\n", *a); // выводим значение из адреса
  increace_value(a); // Передаём указатель
  printf("inc_\ta: %d\n", *a); // повторяем предыдущий вывод, уточняя, что выводим после увелечения значения
  free(a); // очищаем память
  return 0;
}

void increace_value(
  int* pvalue // Прибавляем префикс p столько же раз, сколько есть звёздочка.
              // Так удобно воспринимать аргументы
) {
  *pvalue += 1; // обращаемся к значению и увеличиваем на один.
}

// Вывод:
// start_  a: 5
// inc_    a: 6

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

Куча(heap) представляет собой все динамически(во время выполнения программы) выделяемые данные. (Каждый раз, когда мы используем *alloc функции, мы получаем адрес начала блока из области кучи, а также размеченную часть данных)

Стек в свою очередь можно сравнить со стопкой тарелок, или магазином от автомата, пистолета, или любого другого магазинного орудия. Если точнее, то стек - это модель памяти. В стек попадают все локальные переменные в самый конец, откуда первыми могут быть взяты(Принцип "Последний пришёл, первый ушёл", LIFO), созданные между { и }, инструкции между которыми представляют собой блок кода. Куча может в программе и не появиться (Это также зависит от платформы и компилятора), но стек обязательно появится, как минимум для хранения данных о пустом int main(). (Без данной функции мы не сможем создать исполняемый файл через компилятор, конечно мы не используем фреймворк, который имеет собственный main, как тот же winapi, который требует метод win_main в качестве начала программы)

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

Массивы в Си

Массивы в Си - это указатели на области памяти, которые имеют N размеров типа данных(N * sizeof(type)). Вот так выделяется память для массивов через malloc:

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

int main(int argc, char** argv) {
  int* iarr = (int*) malloc(sizeof(int) * 4)); // выделяем память для 4 элементов типа int
  // умножение дольше, чем сложение, поэтому неоптимально.
  for (int i = 0; i < 4; i++) {
    iarr[i] = i * 2; // записываем значения.
  }
  for (int i = 0; i < 4; i++) {
    printf("%d: %d\n", i, iarr[i]); // вывод
  }
  free(iarr); // очищаем память
  return 0;
}

Однако для создания массива лучше подойдёт функция calloc, которая принимает количество элементов и размер одного элемента:

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

int main(int argc, char** argv) {
/*
(тип*)calloc(кол-во элементов, размер одного элемента.);*/
  int* iarr = (int*) calloc(4, sizeof(int))); // выделяем память для 4 элементов типа int
  // вопрос умножения решается в стандартной библиотеке.
  for (int i = 0; i < 4; i++) {
    iarr[i] = i * 2; // записываем значения.
  }
  for (int i = 0; i < 4; i++) {
    printf("%d: %d\n", i, iarr[i]); // вывод
  }
  free(iarr); // очищаем память
  return 0;
}

А теперь попробуем перебрать массив в цикле while:

#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
int main() {
//(тип*)calloc(кол-во элементов, размер одного элемента.);
    int* iarr = (int*) calloc(8, sizeof(int)), * iterator = iarr;
    // выделяем память для 4 элементов типа int
    ptrdiff_t diff;
    // вопрос умножения решается в стандартной библиотеке.
    for (int i = 0; i < 8; i++) {
        iarr[i] = i; // записываем значения.
    }
    while ((diff = iterator - iarr) < 8) 
    {
        printf("%d\n", *iterator); // вывод 
        printf("ptr: 0x%p\n", iterator); // Интересный момент, массивы размером от 16 байт и больше, выравниваются в оперативной памяти по 16 байт, так как нумерация на моей системе 0, 4, 8, C, 0, 4, 8, C...(Последний 16-ричный разряд адреса)
        iterator++; // iterator инкрементить в последнем упоминании блока
    } 
    free(iarr); // очищаем память, удалять указатель iterator не требуется
    return 0;
}
// Вывод:
// 0
// ptr: 0x##############C0
// 1
// ptr: 0x##############C4
// 2
// ptr: 0x##############C8
// 3
// ptr: 0x##############CC
// 4
// ptr: 0x##############D0
// 5
// ptr: 0x##############D4
// 6
// ptr: 0x##############D8
// 7
// ptr: 0x##############DC

Инкремент увеличивает значение указателя не на 1, а на размер типа указателя(В нашем случае на 4, если записатьiteratorи iarr как char*, то мы будем увеличивать значение на размер 1 символа, на байт).

ptrdiff_t позволяет рассчитывать разницу(разность) между указателями. То есть, при вычитании указателя на массив из указателя на элемент массива, мы получаем номер элемента. На это указывают 8 выводов, от нулевого до седьмого.

Также поговорим немного про многомерные массивы, если точнее, то про массивы указателей. Они представляют собой массив, который содержит другой массив(повторить n - 1 раз, где n - мерность массива), который содержит значения. Каждый компилятор ограничивает как глубину рекурсии, так и глубину мерности массива по своему, обычно глубина мерности массива стоит на 12(чтобы вы понимали, это поставить [] 12 раз и везде указать размер после названия переменной). Сразу приведу пример

#include <stdio.h>

int main() {
  int arr[4][4][4]; /*трёхмерный массив четыре на четыре на четыре*/
  for (int z = 0; z < 4; z++) {
    for (int y = 0; y < 4; y++) {
      for (int x = 0; x < 4; x++) {
        arr[z][y][x] = z * 4 * 4 + y * 4 + x; // Всё сделано за нас, компилятор уже выделил память.
      }
    }
  }
  for (int z = 0; z < 4; z++) {
    for (int y = 0; y < 4; y++) {
      for (int x = 0; x < 4; x++) {
        printf("{%d, %d, %d}: %d\n", x, y, z, arr[z][y][x]);
      }
    }
  }
  return 0;
}
// {0, 0, 0}: 0
// {1, 0, 0}: 1
// {2, 0, 0}: 2
// {3, 0, 0}: 3
// {0, 1, 0}: 4
// {1, 1, 0}: 5
// {2, 1, 0}: 6
// {3, 1, 0}: 7
// ...
// {0, 2, 3}: 56
// {1, 2, 3}: 57
// {2, 2, 3}: 58
// {3, 2, 3}: 59
// {0, 3, 3}: 60
// {1, 3, 3}: 61
// {2, 3, 3}: 62
// {3, 3, 3}: 63

Однако, использование многомерных массивов не всегда может выглядеть уместным. Как минимум делать то же самое для создания массива динамического размера будет сложнее. И вот почему:

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

int main() {
  int*** arr = (int***) calloc(4, sizeof(int**)); /*трёхмерный массив четыре на четыре на четыре*/
  for (int z = 0; z < 4; z++) {
    arr[z] = (int**) calloc(4, sizeof(int*));
    for (int y = 0; y < 4; y++) {
      arr[z][y] = (int*) calloc(4, sizeof(int));
      for (int x = 0; x < 4; x++) {
        arr[z][y][x] = z * 4 * 4 + y * 4 + x; // Наконец пишем значение
      }
    }
  }
  for (int z = 0; z < 4; z++) {
    for (int y = 0; y < 4; y++) {
      for (int x = 0; x < 4; x++) {
        printf("{%d, %d, %d}: %d\n", x, y, z, arr[z][y][x]);
      }
    }
  }
  // А теперь очищаем память
  for (int z = 3; z >= 0; z--) {
    for (int y = 3; y >= 0; y--) {
      free(arr[z][y]);
    }
    free(arr[z]);
  }
  free(arr); // Долго... Слишком долго.
  return 0;
}
// Вывод будет как с константными размерами

Именно поэтому нужно знание хотя бы немного геометрии. Точнее умение считать площадь, объём и возможно гипер-объём(для 5-мерных массивов, но тут не сложно, идею можно по четырёхмерным продолжить).

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

// препроцессорные команды. Они подменят текст ниже на значения. То есть вместо
// volume(WIDTH, HEIGHT, DEPTH) будет сразу 4 * 4 * 4
#define volume(w, h, d) w * h * d
#define WIDTH 4
#define HEIGHT 4
#define DEPTH 4
// x = 1 * слой
// y = длина w * слой
// z = площадь wh * слой
// w = объём dwh * слой
// и так далее, где слой - название переменной, которая отвечает за определённую координату
#define get_id_3d(x, y, z, width, height) (z * width * height + y * width + x)

int main() {
  int* array = (int*) calloc(volume(WIDTH, HEIGHT, DEPTH), sizeof(int));
  for (size_t i = 0; i < volume(WIDTH, HEIGHT, DEPTH); i++) {
    array[i] = i; // Просто поставим значение, равное i в iтый элемент.
  }

  for (size_t z = 0; z < DEPTH; z++) {
    for (size_t y = 0; y < HEIGHT; y++) {
      for (size_t x = 0; x < WIDTH; x++) {
        printf("{%d, %d, %d}: %d\n", x, y, z, array[get_id_3d(x, y, z, WIDTH, HEIGHT)]);
      }
    }
  }
  free(array);
  return 0;
}
// вывод повторяет предыдущие выводы многомерных массивов

Вот и вся суть массива. Не злоупотребляйте, либо злоупотребляйте хотя бы в меру. Иначе вылетит stack trace.

Вектор на Си и realloc.

Для наглядного примера управления памятью я предлагаю в качестве примера реализацию одномерного вектора. Если точнее, то целочисленного вектора, который будет держать обычный int, но это из-за того, что в Си ещё нет шаблонов и ООП. ООП можно создать, но это выглядит бесполезно, как минимум из-за того, что Си - это структурный, процедурный, или, по-современному, функциональный язык программирования. В любом случае начнём с того, что определим цель создания данной библиотеки(Что и зачем писать):

  • Допустим, мы хотим уметь держать динамически(чтобы можно было добавить сколько пожелаешь элементов, пока позволяет память в "куче") расширяемый список;

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

  • Он будет создаваться посредством передачи указателя на обыкновенную переменную функции (А не заставлять будущего пользователя самостоятельно выделять память для элемента и писать его свойства);

  • Мы хотим от списка лишь возможность класть новые элементы сверху, мы не хотим, чтобы разработчик клал их где-то посередине, максимум заменить существующий элемент;

  • Также библиотека должна уметь копировать векторы.

Итак, цели определены, значит определим функции.

  • vcCreateVectori(vectori* pvector);

  • vcDestroyVectori(vectori vector);

  • vcPushBacki(vectori* pvector, int value);

  • vcReplacei(vectori vector, size_t id, int new_value);

  • vcDuplicatei(vectori vector, vectori* ptarget);

  • vcGeti(vectori vector, size_t id, int* ptarget);

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

// inc/vc/vectori.h
#ifndef VC_VECTORI_H
#define VC_VECTORI_H

#define OK 0 // объявляем нормальное выполнение программы.
#define ERR_CANNOT_ALLOC 1 // ОШИБКА! Не смогли выделить память под массив
#define ERR_OUT_OF_BOUNDS 2 // Ошибка! Вы просите изменить элемент вне вектора!
#define ERR_TARGET_IS_ALIAS_OF_SOURCE 3 // Ошибка! Вы дали синоним входного значения как выходное

#include <stdlib.h> // для size_t и malloc, calloc, realloc и free

typedef struct s_vectori {
  size_t length; // длина
  size_t last; // последний элемент
  int* parray; // Наш массив данных
} vectori;

int vcCreateVectori(vectori* pvector);
void vcDestroyVectori(vectori vector);
int vcPushBacki(vectori* pvector, int value); // vectori*, так как мы увеличиваем last
                                              // и length
int vcReplacei(vectori vector, size_t id, int new_value);
int vcDuplicatei(vectori vector, vectori* ptarget);
int vcGeti(vectori vector, size_t id, int* ptarget);

#endif//VC_VECTORI_H

Методы описаны. Там, где возвращаем int - возвращаем успешность выполнения.

// src/vc/vectori.c
#include <vc/vectori.h>
#include <string.h> // Здесь управление строками и в частности массивами


int vcCreateVectori(vectori* pvector) {
  pvector->length = 1;
  pvector->last = 0;
  pvector->parray = (int*) calloc(pvector->length, sizeof(int)); // выделяем память для массива данных
  if (pvector->parray == NULL) { // Если calloc вернул NULL, значит память не выделилась.
    return ERR_CANNOT_ALLOC; // Не удалось выделить место
  }
  return OK; // Возвращаем, что всё нормально
}
void vcDestroyVectori(vectori vector) {
  free(vector.parray); // Мы выделили память только для этого элемента. Остальное за разработчиком
}
int vcPushBacki(vectori* pvector, int value) {
  if (pvector->last + 1 == pvector->length) {
    pvector->length <<= 1; // побитовый сдвиг, чтобы удвоить размер
    pvector->parray = (int*) realloc(pvector->parray, pvector->length * sizeof(int));
    if (pvector->parray == NULL) {
      pvector->length >>= 1; // Возвращаем всё назад.
      return ERR_CANNOT_ALLOC;
    } // Не удалось выделить место
  }
  pvector->parray[pvector->last++] = value; // Наконец задаём значение
  return OK;
}
int vcReplacei(vectori vector, size_t id, int new_value) {
  if (id > vector.last) return ERR_OUT_OF_BOUNDS; // Желанное место вне вектора
  vector.parray[id] = new_value; // Мы передаём копию вектора, но указатель копируется тоже и сохраняет значение
  return OK;
}
int vcDuplicatei(vectori vector, vectori* ptarget) {
  if (vector.parray == ptarget->parray) return ERR_TARGET_IS_ALIAS_OF_SOURCE;
  ptarget->length = vector.length;
  ptarget->last = vector.last;
  ptarget->parray = (int*) calloc(vector.length, sizeof(int));
  memcpy(ptarget->parray, vector.parray, sizeof(int) * vector.length);
  if (ptarget->parray == NULL) {
    return ERR_CANNOT_ALLOC; // Не удалось выделить место
  }
  return OK;
}

int vcGeti(vectori vector, size_t id, int* ptarget) {
    if (id > vector.last) return ERR_OUT_OF_BOUNDS;
    *ptarget = vector.parray[id];
    return OK;
}

А теперь проведём тесты наших векторов.

#include <proj1/vectori.h>
#include <stdio.h>

int main() {
  // Инициализируем переменные
  vectori vec, copy;
  int buffer;
  // Создаём пустой вектор.
  vcCreateVectori(&vec); 
  // Вносим элементы в вектор. Можно сделать через цикл
  vcPushBacki(&vec, 3);  // for (int i = 3; i >= 0; i--) {
  vcPushBacki(&vec, 2);  //   vcPushBack(&vec, i);
  vcPushBacki(&vec, 1);  // }
  vcPushBacki(&vec, 0);
  printf("source\n");    // Вывод исходного
  for (unsigned int i = 0U; i < vec.last; i++) {
    // получить элемент и вывести в консоль
    vcGeti(vec, i, &buffer);
    printf("%d: %d\n", i, buffer);
  }
  // Проверка дублирования
  vcDuplicatei(vec, &copy);
  vcPushBacki(&copy, 6); // добавляем в конец копии 6
  printf("copy\n"); // Вывод копии
  for (unsigned int i = 0U; i < copy.last; i++) {
    vcGeti(copy, i, &buffer);
    printf("%d: %d\n", i, buffer);
  }
  printf("source again\n"); // Вывод исходного снова
  for (unsigned int i = 0U; i < vec.last; i++) {
    vcGeti(vec, i, &buffer);
    printf("%d: %d\n", i, buffer);
  }
  // Освобождаем память
  vcDestroyVectori(copy);
  vcDestroyVectori(vec);

  return OK;
}

// Вывод:
// source
// 0: 3
// 1: 2
// 2: 1
// 3: 0
// copy
// 0: 3
// 1: 2
// 2: 1
// 3: 0
// 4: 6
// source again
// 0: 3
// 1: 2
// 2: 1
// 3: 0

Заключение

Управление памятью одновременно невероятно сложно и невероятно просто. Одни могут и не понять его и за годы, другие поймут с ходу. Всё зависит от представления об оперативной памяти. Надеюсь мои объяснения смогли не понимающим дать это самое понимание, а понимающим укрепить свои познания. Благодарю за прочтение.

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

Название книги, которую мне посоветовали в комментариях: "Программирование на языке Си" Подбельский В.В., Фомин С.С.

Tags:
Hubs:
-9
Comments 49
Comments Comments 49

Articles