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

Как переменные хранятся в памяти

C хранит все переменные в некоторой памяти. Это может быть стек, куча или какая-либо другая форма памяти. Однако, если они не сохранены в регистре, они будут иметь адрес памяти. На самом деле, это основная причина для типов в C. Тип переменной при ее объявлении сообщает компилятору, сколько памяти нужно выделить в стеке для переменной. Вот почему в C так много такие типы, как char, short, int, long и т. д. Точный размер этих типов зависит от компилятора; однако, как правило, char — это один байт, short — два байта, int — четыре байта, а long — восемь байтов. Я подозреваю, что причина, по которой C не имеет собственного логического типа, заключается в том, что отдельные биты не адресуются, а наименьший логический тип может быть одним байтом. Обычное объявление и определение переменной выглядит так:

int sum = 0;

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

float cash = 5.25f;

Число с плавающей запятой обычно хранится в экспоненциальном представлении в двоичном формате в пределах четырех байтов. Один бит используется для знака, затем (часто, но не всегда) восемь бит используются для экспоненты, а оставшиеся 23 бита используются для значащего значения, также называемого мантисса. Таким образом, в то время как целое число 5 может быть сохранено как 00000000 00000000 00000000 00000101, число с плавающей запятой 5.25 может быть сохранено как 01000000 10101000 00000000 00000000. Целое число может быть интерпретировано просто как целое число с дополнением до двух 00000000000000000000000000000101, но число с плавающей запятой должно интерпретироваться как 0, указывающее на положительное значение, 1000000 1, указывающее на показатель степени -2, и 01010000000000000000000, указывающее на мантиссу, равную 525. Это не совсем очевидно. Во-первых, все мантиссы в бинарнике будут начинаться с единицы, поэтому хранить ее необязательно. Существует много умных тактик, используемых для интерпретации значения поплавка, и я рекомендую продолжать изучать это, если вам это интересно.

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

Использование указателей

В C есть несколько операторов, связанных с указателями, два основных из них: оператор адреса & и оператор разыменования *. Обратите внимание, что эти операторы используются в C для других целей; однако при использовании в качестве левоунарных операторов эти символы означают адрес или разыменование. Левоунарный оператор — это оператор, который принимает один операнд и обозначается слева от него. Чтобы объявить указатель в C, вы просто добавляете * справа от типа, на который хотите указать. Любой из следующих правильных способов объявления целочисленного указателя:

int* pointer;
int * pointer;
int *pointer;

Это зависит от ваших личных предпочтений; Я предпочитаю писать int* pointer на C++, а int *pointer на C. У обоих есть преимущества; тем не менее, это простой стилистический выбор, и до тех пор, пока вы последовательны, подойдет любой. Подобно тому, как вы объявляете переменную без ее определения, только что объявленный указатель будет иметь ненужное значение того, что было в этой памяти раньше. Здесь могут возникнуть сложности, но указатель — это просто причудливое целое число. Указатель хранит адрес памяти, который является целым числом. Это означает, что сам указатель является просто целым числом, и поэтому указатель имеет свой собственный адрес в памяти. Как и в Inception, вы можете иметь указатели на указатели на указатели.

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

int value = *pointer;

Поскольку указатель был объявлен как тип int *, компилятор знает, что это указатель на целое число. Когда мы используем оператор разыменования указателя, компилятор знает, как найти значение; лежащее в основе целое число. Имейте в виду, что адрес, хранящийся в указателе, — это просто первый адрес базового значения в памяти. Ранее мы упоминали, что при объявлении int в памяти выделяется четыре байта. Указатель на это целое число будет хранить адрес первого из этих байтов. Компилятор может найти все четыре байта, потому что они выделяются последовательно, а тип int сообщает компилятору, сколько байтов нужно просмотреть.

Обратите внимание, что вы также можете записать базовое целое число, выполнив, например, *pointer = 5;. Это означает установить значение, на которое ссылается указатель, равным 5. Если вы сделали pointer = 5;, это означало бы установить указатель на адрес памяти 5. Подводя итог, чтобы получить или установить значение объявленного указателя, используйте *pointer. Чтобы получить или установить адрес памяти, хранящийся в объявленном указателе, просто используйте pointer. Теперь давайте покажем, как на самом деле можно указывать на переменные:

float x = 0.0f;
float *p = &x;
// *p = x = 0.0f while p = &x = 0x6aff (or some memory address)
*p = 5.0f;
// *p = x = 5.0f

Чтобы указать на переменную, вам нужно установить указатель на адрес памяти переменной. Это делается с помощью оператора адреса &, который возвращает адрес памяти своего операнда. Как только указатель указывает на переменную, вы можете использовать этот указатель для изменения значения переменной. Напоминаем, что переменная — это просто псевдоним массива байтов в памяти; указатель может изменить эти байты. Указатели можно переопределять несколько раз по мере необходимости. Если вы хотите создать указатель на указатель, это довольно просто:

char z = 'z';
char *single = &z;
char **double = &single;
// We can set z to 'a' one of three ways:
z = 'a' // ||
*single = 'a' // ||
**double = 'a'
// We can also tell single to point to a new char one of two ways:
char y = 'y';
single = &y; // ||
*double = &y;

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

Использование const с указателями

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

int a = 0;
const int b = 1;
const int *pointer = &b; // A pointer to a constant variable
int *const pointer = &a; // A constant pointer to a variable
const int *const pointer = &b; // Both

Ключевое слово const изменяет то, что находится справа от него; когда он находится слева от типа, это означает, что тип является постоянным. Когда он находится слева от имени указателя, это означает, что указатель является постоянным. Наверное, это хороший способ запомнить это. В C типы просто сообщают компилятору, как интерпретировать значение. Это означает, что очень легко создать неопределенное поведение. Например, если это явно не запрещено компилятором C, вы можете просто создать неконстантный указатель на константное значение и изменить его. Однако, как правило, лучше использовать постоянные указатели с постоянными значениями, чтобы избежать случайных изменений.

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

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

Массивы и другие операторы

В C элементы массива хранятся в последовательных адресах памяти. Чтобы создать массив целых чисел, у вас есть несколько вариантов записи:

(1) int list[5];
(2) int list[] = {0, 0, 0, 0, 0};
(3) int *list = {0, 0, 0, 0, 0};

Все это можно использовать для «создания списка». Однако они очень разные.

  1. int list[5];
    Эта запись создает список целых чисел размера пять. Он будет выделять 20 последовательных байтов в стеке. Это связано с тем, что существует пять целых чисел, и каждое целое число занимает четыре байта. Они сохранят все ненужные значения, которые ранее находились в этих местах.
  2. int list[] = {0, 0, 0, 0, 0);
    Для создания массива используется список инициализаторов. Поскольку список считается исчерпывающим, указывать размер в квадратных скобках не требуется. Это выделит 20 последовательных байтов в стеке и заполнит их предоставленными целыми числами.
  3. int *list;
    Здесь все меняется; эта строка не создает массив. Однако он создает точку доступа к потенциальному массиву. Позвольте мне показать вам, что я имею в виду.
int list[] = {1, 2, 3, 4, 5};
int *pointer = list;

Теперь указатель и список являются псевдонимами друг друга. Чтобы создать массив в стеке, вам нужно использовать нотацию с квадратными скобками. Однако это просто говорит компилятору выделить место; по сути, наша переменная list — это просто указатель типа int. Теперь пришло время ввести еще несколько операций с указателями. Вы можете быть знакомы с индексированием массивов из многих языков: list[2] = 6;; это обозначение является просто сокращением. Все три следующих блока эквивалентны:

int list[] = {5, 6, 7};
int list[3];
list[0] = 5;
list[1] = 6;
list[2] = 7;
int list[3];
*list = 5;
*(list + 1) = 6;
*(list + 2) = 7;

Оператор нижнего индекса x[a] — это просто более удобное обозначение для *(x + a). Как это работает? Опять же, тип указателя говорит C, как его использовать. Для указателя типа T, когда вы добавляете единицу к этому указателю, компилятор берет значение указателя (адрес памяти некоторого T) и добавляет один sizeof(T). В случае int добавление единицы добавляет четыре.

Простой способ увидеть это в действии — использовать указатель void. void — странный тип в C, который означает ноль или ничего. Если функция ничего не возвращает, она на самом деле возвращает void. Если он не принимает никаких параметров, он фактически принимает значение void. В C вы можете создавать пустые указатели. Они могут быть неудобными, но позволяют делать некоторые очень полезные вещи. Указатель void может получить доступ к отдельным байтам массива и обойти вышеупомянутое декодирование типа C. Давайте посмотрим на следующий пример, который также эквивалентен предыдущим:

int arr[3];
void *list = (void *)arr;
*(int *)list = 5;
*(int *)(list + 4) = 6;
*(int *)(list + 8) = 7;

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

  1. Это создает указатель с именем «arr» на 12 последовательно выделенных байтов памяти. Компилятор ожидает, что эти байты будут содержать три целых числа.
  2. Мы создаем указатель на пустоту с именем «список» и устанавливаем его равным адресу памяти первого элемента, приведенному к пустому адресу памяти.

Все следующие строки говорят: установите значение этого адреса, интерпретируемого как указатель int, на следующее целое число. Заметьте, однако, что мы должны вручную добавить четыре или восемь, потому что list в этом контексте является пустым указателем. Также обратите внимание на задействованный порядок операций. Вот некоторый порядок операций, который полезно знать, где === означает эквивалентно:

*list++; === *list; list += 1;
*list--; === *list; list -= 1;
*++list; === *(list += 1);
*--list; === *(list -= 1);
++*list; === (*list) += 1;
--*list; === (*list) -= 1;
*(int *)list; === *((int *)list);
(int *)*list; === (int *)(*list);
*list[x]; === *(list[x]);
*list.x; === *(list.x);
*list->x; === *(list->x);

Обратите внимание, что для всех левоунарных операторов приоритет одинаков, но операции выполняются справа налево. Правые унарные операторы имеют приоритет над левоунарными операторами, такими как *; однако в случае приращения и уменьшения постфикса, хотя эта операция выполняется первой, эти операции сначала возвращают значение, а затем увеличиваются/уменьшаются. Таким образом, исходное значение разыменовывается. Обязательно соблюдайте порядок и не расстраивайтесь, если вам придется время от времени проверять шпаргалку.

Это также первое введение оператора стрелки. Если вы знакомы со структурами и объединениями, вы знаете, что можете получить доступ к их членам, используя следующую нотацию (предположим, что у нас есть предопределенное struct animal):

struct animal cat = // some initialization
cat.name = "Sarah";

Оператор стрелки — это указательная версия оператора точки.

struct animal *pointer = &cat;
cat.name = "Sarah"; // ||
pointer->name = "Sarah";

Это работает как для получения, так и для установки значений. Если вы понимаете, как указатель указывает на значение, это должно быть довольно интуитивно понятно. Написание pointer->x эквивалентно написанию (*pointer).x; однако, как и в случае с оператором нижнего индекса, это просто более понятная нотация. Тем более, что оператор точки имеет более высокий приоритет, чем оператор разыменования, что требует скобок.

Струны

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

char str[] = {'H', 'i', '\0'};
char *str = "Hi";
char str[3];
str[0] = 'H';
str[1] = 'i';
str[2] = '\0';

Подобно другим языкам, существует нотация с двойными кавычками для простого построения строк. Однако вы должны знать, что строки в C должны заканчиваться символом «\0», который называется нулевым символом. Строки в C заканчиваются нулем. Это связано с тем, что, в отличие от таких языков, как Java или Python, массивы в C не отслеживают собственный размер. Чтобы выполнить итерацию по массиву в C, вам нужно либо отдельно отслеживать размер, либо иметь узнаваемый терминал в конце массива. Технически вам не нужно иметь нулевой терминал в вашей строке, если вы сами отслеживаете размер. Однако, если вы собираетесь использовать какую-либо встроенную функцию C со своими строками, они должны заканчиваться нулем, иначе C будет продолжать считывать память до тех пор, пока не встретит случайный нулевой символ или пока не произойдет сбой. Использование нотации кавычек автоматически добавляет нулевой терминал. Имейте в виду, это означает, что ваша строка должна иметь дополнительный символ в размере.

Итерация

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

const int size = 100;
int numbers[size];
for(int *p = numbers; p < numbers + size; ++p) *p = 1;

Это создаст массив из ста единиц. Обычно хорошей идеей является создание нового указателя для изменения. В противном случае вы измените тот, который указывает на начало массива. Условием в цикле может быть несколько вещей. Вы можете отслеживать, сколько итераций произошло, и просто сохранить этот размер ниже. Однако, поскольку я знаю, как работают указатели, я знаю, что последний элемент массива находится по адресу numbers + size — 1; помните, что это эквивалентно numbers[size — 1]. Как только наш фиктивный указатель проходит это значение, мы выходим за пределы массива. Однако вместо этого мы могли бы использовать следующий цикл:

for(int i = 0; i < size; ++i) numbers[i] = 1;

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

char *str = "Hello World!";
for(char *p = str; *p != '\0'; ++p) printf("%c", *p);

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

for(int i = 0; str[i] != '\0'; ++i) printf("%c", str[i]);

Обратите внимание, что строки, построенные таким образом, являются строковыми литералами, которые неизменяемы в C. Это означает, что p и str должны на самом деле иметь тип const char *. Если вы измените их, это приведет к неопределенному поведению. Также обратите внимание, что строки в printf и scanf ведут себя иначе, чем в других типах.

const char *str = "Hello World!";
printf("%s", str);
const char c = 'c';
const char *letter = &c;
printf("%c", *letter);

Когда вы используете %s, C ожидает char *. Однако, когда вы используете %c, C ожидает char. Это означает, что в некоторых случаях вам нужно разыменовать, а в других нет. Помните, что разыменование строки дает вам первый символ в перестановке.

char str[255];
scanf(" %s", str);
int num;
scanf(" %i", &num);

Обратите внимание, что scanf принимает адрес памяти, в который помещается введенное значение. Это означает, что поскольку строки уже являются адресом памяти, вы просто указываете имя строки; однако для других типов необходимо использовать оператор адреса. Обратите внимание, что я включил пробел в начале scanf, потому что он будет захватывать все пробелы или не захватывать их вообще, и это предотвращает проблемы, требующие очистки буфера. Также имейте в виду, что массивы не могут быть изменены в C. Это означает, что если вы вводите данные в строку, вы должны заранее выделить достаточно места (так называемого буфера). Вы можете изменить размер массива только путем создания нового, потому что массивы должны быть последовательными, а окружающая память массива занята другими вещами.

Стек и куча памяти

В этой статье я очень часто упоминал стек. Пришло время поговорить о типах памяти в C. Во-первых, позвольте мне поговорить о регистрах. ЦП имеет регистры, в которых хранятся данные во время выполнения вычислений. Эта память невероятно быстра, но ее невероятно мало. Если вы часто используете значение, вы можете использовать ключевое слово register, чтобы сообщить C, что он может повысить производительность, поместив это значение в регистр. Обратите внимание, что компилятор может фактически поместить или не поместить его в регистр. Вот пример регистровой переменной:

// Sums up the elements of a list
int sum_up(const int* list, const int size) 
{
    register int sum = 0;
    for(const int* p = list; p < list + size; ++p) sum += *p;
    return sum;
}

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

Теперь в стек и кучу. Если вы знакомы со структурами данных, вы должны распознать имена структур стека и кучи. Стек — это структура данных по принципу «последний пришел — первый ушел» (LIFO). Визуализируйте стопку компакт-дисков на шпинделе. Вы можете добавить компакт-диск в стопку, только поместив его на вершину стопки. Вы можете удалить только верхний компакт-диск. Таким образом, последний CD, который вы добавляете, должен быть первым, который вы удаляете. Это все, что представляет собой стек; причудливый список с ограничениями на добавление и удаление элементов. Когда вы запускаете программу, она создает кадры стека. Представьте, что вы запускаете основную функцию, а затем вызываете функцию, которая вызывает функцию. Каждая из этих функций имеет свою собственную память. При вызове функция имеет свой собственный фрейм стека и собственную память. Эта память обычно довольно быстрая из-за принципа LIFO. Он также довольно мал, что облегчает поиск хранящихся в нем переменных.

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

  1. В минимальной куче каждый дочерний элемент должен быть меньше, чем его родитель. В max-heap каждый дочерний элемент должен быть больше или равен своему родителю.

Переменные, созданные в куче памяти, существуют до тех пор, пока не будут удалены или пока программа не закроется. Чтобы взаимодействовать с динамической памятью в C, вы должны включить stdlib.h. В этой библиотеке есть четыре полезные функции: malloc, calloc, realloc и free.

Переменные, созданные в куче памяти, существуют до тех пор, пока не будут удалены или пока программа не закроется. Чтобы взаимодействовать с динамической памятью в C, вы должны включить stdlib.h. В этой библиотеке есть четыре полезные функции: malloc, calloc, realloc и free.

malloc

Объявление malloc void *malloc(size_t size). Он возвращает пустой указатель, чтобы оставаться универсальным. size_t — это тип, означающий максимально возможный целочисленный тип без знака, поддерживаемый конкретным компилятором, который вы используете. Это просто используется, чтобы обеспечить максимально возможный размер памяти.

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

int *list = (int *)malloc(sizeof(int) * 5);

Это создаст массив из пяти целых чисел в куче. Оператор sizeof просто возвращает размер байта данного типа. Поскольку malloc возвращает указатель типа void, мы должны преобразовать результат в указатель типа int. Отсюда мы можем использовать этот массив как обычно. За исключением того, что в конце мы должны освободить память.

Если вашей системе не хватает памяти или по какой-то другой причине не удается выделить память, malloc вернет NULL (то есть 0). В принципе, вы захотите проверить, является ли указатель нулевым, прежде чем что-либо делать с ним. Тем не менее, это очень редко случается на самом деле. malloc — это сокращение от выделения памяти.

бесплатно

Декларация бесплатно void free(void *ptr). Он принимает пустой указатель (поэтому любой указатель). Обратите внимание, что в этом контексте C будет неявно приводить любой указатель к пустому указателю. Итак, мы можем освободить наш ранее составленный список, просто выполнив:

free(list);

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

каллок

Объявление calloc — void *calloc(size_t nitems, size_t size). Это похоже на malloc, за исключением того, что он устанавливает все выделенные значения в 0, а malloc — нет. Это означает, что malloc быстрее, но calloc необходим для целей безопасности certian, чтобы предотвратить увековечивание конфиденциальных нежелательных значений. Это также полезно, когда вы все равно собираетесь установить значения на 0. Он также отличается тем, что принимает два параметра: количество элементов и размер каждого элемента.

И снова calloc вернет указатель void, который необходимо преобразовать, и, если он не может выделить память, он вернет NULL. Кажется, что calloc означает clear allocate, хотя консенсуса нет. Если вы хотите создать массив из четырех нулей в куче, есть два способа сделать это. Во-первых, используя malloc:

const unsigned int count = 1000;
int *list = (int *)malloc(sizeof(int) * count);
for(unsigned int i = 0; i < count; ++i) list[i] = 0;

и используя calloc:

const unsigned int count = 1000;
int *list = (int *)calloc(count, sizeof(int));

перераспределение

Объявление reallocvoid *(void *ptr, size_t size). Эта функция пытается изменить размер существующего массива. Если ptr равно NULL, то это будет вести себя точно так же, как и malloc. Если size равно 0, а ptr указывает на массив, отличный от NULL, то это будет вести себя точно так же, как ведет себя free и возвращает NULL. Если запрос не удался, он вернет NULL.

Это в первую очередь изменение размера массива после выделения. Обратите внимание, что внутри он просто создаст новый массив и скопирует данные. Если вы увеличите массив, новые элементы будут неинициализированы, как в случае с malloc. Если вы уменьшите массив, данные будут вырезаны с конца. Интуитивно понятно, что realloc означает перераспределение. Предположим, мы хотим создать и увеличить строку в куче.

char *str = (char *)malloc(2);
str[0] = 'a';
str[1] = '\0';
printf("%s\n", str); // Results in "a"

Поскольку размер символа составляет один байт, я могу просто указать количество символов в malloc. Затем я могу установить каждый символ, как я хочу. Теперь давайте расширим нашу строку до «abc».

str = (char *)realloc(str, 4);
printf("%s\n", str); // Still results in "a", as our string is:
                     // {'a', '\0', something, something}
str[1] = 'b';
str[2] = 'c';
str[3] = '\0';
printf("%s\n", str); // Prints out "abc"

Обратите внимание, что мы также можем уменьшить строку:

str = (char *)realloc(str, 3);
// Be sure to add a null terminal again
str[2] = '\0';
printf("%s\n", str); // Prints out "abc"

Когда мы закончим, мы можем вызвать free(str) или потенциально str = (char *)realloc(str, 0), чтобы очистить нашу память.

Необходимое использование динамической памяти и указателей

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

int increment(int i) { return i + 1; }
...
int i = 0; // stack integer
int *j = (int *)calloc(1, 4); // heap integer
i = increment(i);
*j = increment(*j);

or

void increment(int *i) { *i += 1; }
...
int i = 0; // stack integer
int *j = (int *)calloc(1, 4); // heap integer
increment(j);
increment(&i);

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

Вот ситуация, когда необходимо использовать данные в куче. Рассмотрим следующую функцию, которая работает с указателем стека:

char *doubleString(const char *str, unsigned int size)
{
    const unsigned long doubleSize = size * 2 + 1;
    char result[doubleSize];
    for(unsigned long i = 0; i < doubleSize - 1; ++i) 
    { 
        result[i] = str[i % size];
    }
    result[doubleSize - 1] = '\0';
    return result;
}

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

char *doubleString(const char *str, unsigned int size)
{
    const unsigned long doubleSize = size * 2 + 1;
    char *result = (char *)malloc(doubleSize);
    for(unsigned long i = 0; i < doubleSize - 1; ++i) 
    { 
       result[i] = str[i % size];
    }
    result[doubleSize - 1] = '\0';
    return result;
}

Обязательно еще раз освободите свою память после того, как закончите ее использовать! Сбои памяти очень трудно отлаживать и диагностировать. Последнее, что нам нужно, — это больше программного обеспечения, которое дает сбой из-за утечки памяти. Я рекомендую использовать Valgrind, чтобы убедиться, что любой код, который вы делаете, не приводит к утечке памяти.

Вывод

Вот оно! В конце концов, указатели не слишком сложны, не так ли? Это невероятно полезный — фактически необходимый — инструмент для разработчиков C. Указатели необходимы для использования строк, массивов и памяти кучи в C, а также обеспечивают полезность для памяти стека. Они также обеспечивают бонус к эффективности, поскольку позволяют избежать ненужного копирования данных в функции и из них. Если вы продолжите практиковаться, вы в конечном итоге получите интуитивное представление об указателях и о том, как их использовать. Желаю вам удачи в вашем путешествии по разработке C.

Если вам интересно, я создаю язык под названием Sea, который представляет собой разновидность языка программирования C с синтаксисом, более похожим на Python. Я документирую свой процесс, чтобы вы могли даже создать свой собственный язык!

Бонус: ссылки на C++

Эта статья в основном посвящена C, который является основой для C++. Многие возможности C работают в C++; однако в C++ часто есть лучшие способы делать вещи. Например, вы можете использовать malloc и free в C++; при этом лучше использовать new и delete. Если вы используете выделение в стиле C, обязательно придерживайтесь его. Вызовите delete только для объекта, созданного с помощью new, и вызовите только free для объекта, созданного с помощью malloc.

В C++ есть прекрасная функция, называемая ссылками, которая очень похожа на указатели. Ссылка — это псевдоним для другой переменной; точнее, ссылка ограничена адресом памяти. Таким образом, ссылка не может быть нулевой и не может быть переназначена.

int i = 0;
int* iPoint = &i; // Pointer to i
int& iRef = i; // Reference to i

Ссылка может использоваться точно как переменная; его не нужно разыменовывать. Ссылки так хороши по двум основным причинам: их проще использовать, чем указатели, и они безопаснее, поскольку не могут быть нулевыми.

Одно из лучших применений ссылок — это постоянные параметры. Следующие два блока кода в основном эквивалентны:

void func(const int* ptr);
int i = 0;
func(&i);
void func(const int& ref);
int i = 0;
func(i);

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

С помощью ссылок вы избегаете (или прячете) косвенность. Во многих случаях компилятор C++ просто определяет ссылки в терминах указателей, поэтому косвенность скрыта; это упрощает синтаксис. Однако ссылки могут быть определены независимо, и в этом случае они избегают непрямого обращения и могут получить многочисленные преимущества в производительности. Поскольку вы избегаете косвенности, вы избегаете множественных операций. Поскольку ссылки не могут быть нулевыми, компилятору не нужно (и вам не нужно) проверять, являются ли они нулевыми. Если вас интересует дополнительная оптимизация, загляните в это обсуждение.