Кратко об указателях в Си: присваивание, разыменование и перемещение по массивам

Kate

Administrator
Команда форума

Введение​

Указатель - переменная, которая хранит адрес сущностей (т.е. других переменных любого типа, будь то структура, или массив), и над которой возможно выполнять операцию разыменования (dereferencing). Адрес обычно выражен целым положительным числом. Диапазон адресов зависит от архитектуры компьютера. Указателю надо указать тип переменной, адрес которой он хранит, или же использовать ключевое слово void, для обозначения указателя, хранящего адрес чего-угодно (т.е. разрешён любой тип). Указатели объявляются как и обычные переменные, с той разницей, что имя типа переменной указателя имеет префикс, состоящий как минимум из одной звёздочки (*). Например:

int a = 12; /* usual variable */
int * ptr = &a; /* ptr-variable which contains address of variable a */
int **pptr = &ptr; /* ptr-variable which contains address of variable ptr */
int aval = **pptr; /* get value by adress which is contained in pptr. */
int aval2 = *ptr; /* get value of a by address (value of ptr) */
Количество звёздочек лишь указывает на длину цепочек хранимых адресов. Поскольку указатель также является переменной и имеет адрес, то его адрес также можно хранить в другом указателе. В выше приведённом примере адрес переменной a сохраняется в переменной-указателе ptr. Адрес же самой переменной ptr сохраняется в другом указателе pptr. Чтобы получить адрес переменной, перед её именем надо поставить знак амперсанда (&). Наконец, чтобы выполнить обратную операцию, т.е. получить значение (содержимое) по адресу, хранимому в указателе, имя указателя предваряется звёздочкой, почти как при объявлении. Почти, потому что одной звёздочки достаточно чтобы "распаковать" указатель. Поскольку pptr указывает по адресу на значение, хранимое в ptr, то необходимо два раза применить операцию разыменования.

Указатели в предыдущем примере хранят адрес переменной определённого типа. В случае, когда применяются указатели типа void (любого типа), то прежде чем распаковать значение по адресу, необходимо выполнить приведение к типизированному указателю. Следующий пример является версией предыдущего, но с использованием указателя любого типа.

int b = 0xff;
void *pb = &b;
void **ppb = &pb;
int bval1 = *((int *) pb);
int bval2 = *((int *) *ppb);
В данном примере адреса хранятся в указателе типа void. Перед получением значения по адресу, хранимым в pb, необходимо привести указатель pb к типу int*. Затем, воспользоваться стандартной операцией разыменования. Что касается указателя ppb, то он разыменовывается два раза. Первый раз до приведения к типу, для получения содержимого переменной pb, на которую он указывает. Второй раз - после приведения к типу int*.

Изменения значения переменной через указатель.​

Так как указатель хранит адрес переменной, мы можем через адрес не только получить значение самой переменной, но также его изменить. Например:

char a = 'x';
char *pa = &a; /* save address of a into pa */
*pa = 'y'; /* change content of variable a */
printf("%c\n", a); /* prints: y */
Как было сказано выше, указатели хранят адреса. Естественно, что адреса могут указывать не только на ячейки данных переменных в вашей программе, но и на другие вещи: адрес стека процедур, адрес начала сегмента кода, адрес какой-то процедуры ядра ОС, адрес в куче и т. д. Логично, что не все адреса можно использовать напрямую в программе, поскольку некоторые из них указывают на те участки памяти, которые нельзя изменять (доступ для чтения), или которые нельзя затирать. В случае, при обращении к участку, доступному только для чтения, при попытке изменить значение получим ошибку Segmentation Fault (SF).

Кроме того, в языке Си определён макрос с именем NULL, для обозначения указателя с нулевым адресом. Данный адрес обычно используется операционной системой для сигнала об ошибке при работе с памятью. При попытке что либо читать по этому адресу, программа может получить неопределённое поведение. Поэтому ни в коем случае не пытайтесь извлечь значение по пустому указателю.

Передача параметров через указатели.​

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

int swap(int *a, int *b){
if(a == NULL || b == NULL)
return -1;
int temp = *a;
*a = *b;
*b = temp;
return 0;
}
Здесь переменные а и b меняются своими значениями друг с другом (при условии, что параметры содержат не нулевой адрес). Отметим ещё раз, что мы можем изменить содержимое, указываемое по параметру-указателю методов. И, конечно, мы можем стереть данный адрес, присвоив параметру новое значение.

Проверка типов и массивы​

Как было сказано, указатели хранят адреса переменных. Несмотря на указание типа для переменной указателя, это не мешает присвоить ему адрес переменной другого типа, если вы компилируете БЕЗ флагов. Например, следующий код не скомпилируется, если вы включили флаги -Werror -Wall.

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

int main(int argc, char **argv){
int *ptr = NULL;
float a = 23.2;
ptr = &a;
printf("%.1f\n", *ptr);
return 0;
}
Конечно, компилятор и без -Wall заметит недопустимую операцию в 7 строке кода. Флаг -Wall покажет все предупреждения компилятора. Главный флаг -Werror не позволит компилировать код, если есть предупреждения.

Что же касается массивов, то для массива не нужно предварять имя переменной амперсандом, поскольку компилятор автоматически при присваивании адреса массива присвоит адрес первого его элемента в указатель. Для многомерных массивов потребуются указатели на массивы, а не массивы указателей. Первые имеют форму объявления вида int (*arr)[], а вторые вида int *arr[]. В квадратных скобках обязательно нужно указать размер массива. Для трёхмерных массивов потребуется уже две пары скобок, например int (*arr)[2][2]. Для четырёхмерных - три и так далее.

// В ПУСТОМ теле метода main.

int A[2] = {40, 20};

// A -> (int *) ptr to A[0] element, &A -> (int (*)[]) -> ptr to whole Array.
int *ptr = A;
printf("ptr -> A[1] = %d\n", *(ptr + 1)); // A[1] => 20.

//Illegal usage of A.
// int a_2 = ++A; //expected lvalue.

//But with ptr you can do this.
int b_2 = *++ptr; //Now ptr contains address of A[1]. (b_2 = A[1]);

int (*ptr2)[2] = &A; //ptr to array, not to literal element.

//*ptr2 => get array.
//**ptr2 => get first element of array.
//*ptr2 + 1 => get address of second element of array.
printf("ptr2 -> A[1] = %d\n", *( *ptr2 + 1) );

int M[2][2] = { {1, 2} , {3, 4} };

// (*mp)[k] => (*mp)[k] => mp[0][k].
int (*mp)[2] = M; //again you must not add '&' to variable M.
printf("M[0][0] = %d\n", **mp);//get array and extract it first element
printf("M[1][0] = %d\n", **(mp + 1));//move to the address of second element
printf("M[1][1] = %d\n", *( *(mp + 1) + 1));
В выше приведённом коде даны примеры для работы с массивами (одномерными и двумерными). В квадратных скобках указывается размер последнего измерения. Важно помнить, что первое разыменование приводит вас ко всему массиву (т. е. к типу int *). А второе разыменование распаковывает элемент данного массива. В случае одномерного массива, у нас всего одна ячейка, и указатель ссылается на неё. В случае двумерного массива, у нас две ячейки - массивы, а указатель ссылается на первую. Для перемещения на второй массив, достаточно прибавить единицу к адресу, хранимому в переменной mp, например, так mp + 1 . Чтобы получить первый элемент второго массива, надо два раза распаковать указатель с соответствующим адресом массива, т.е. **(mp + 1).

 
Сверху