Кратко об указателях в Си: присваивание, разыменование и перемещение по массивам
Приветствую вас, дорогие читатели. В данной статье кратко описаны основные сведения об указателях в языке Си. Кроме основных операций с указателями (объявление, взятие адреса, разыменование) рассмотрены вопросы безопасности типов при работе с ними. К сожалению, в данной статье вы не найдёте информацию по операциям сравнений указателей. Однако, статья будет полезна новичкам, а также тем, кто работает с массивами. Все примеры в данной статье компилировались компилятором gcc (восьмой версии).
Введение
Указатель - переменная, которая хранит адрес сущностей (т.е. других переменных любого типа, будь то структура, или массив), и над которой возможно выполнять операцию разыменования (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 a = 123;
int *p1 = &a;
//Теперь p2 хранит тот же адрес, что и p1.
int *p2 = &a;
*p1 -= 3; // a = 123 - 3.
printf("*p2 = %d\n", *p2); //Выведет 120
Этот простой пример показывает, что через адреса можно менять содержимое простых переменных, а также остальных указателей, ссылающихся на тоже самое. Таким образом, указатель p2 как бы является псевдонимом (alias) для p1.
Передача параметров через указатели.
Параметры функций могут быть указателями. В случае вызова таких функций, они копируют значения аргументов в свои параметры как обычно. Единственное отличие здесь в том, что они копируют адреса, содержащиеся в указателях параметрах. И с помощью полученных адресов, можно изменять объекты, на которые указывают параметры. Ниже приведена стандартная процедура обмена значений между двумя целочисленными переменными.
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;
}
Конечно, компилятор gcc и без -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)
.
Постоянные (const) и указатели.
Напомним, чтобы сделать переменную с постоянным, фиксированным значением, надо добавить ключевое слово const перед её именем (до имени типа или после). Например:
const int i1 = 10;
int const i2 = 222;
// Warning: variable e3 is unitialized. With -Werror it won't be compiled.
// (Внимание: переменной e3 не присвоено значение. С флагом gcc -Werror
// данный код не скомпилируется).
// const int e3;
Для объявления указателя на постоянное значение, ключевое слово const должно быть ПЕРЕД звёздочкой.
int A[2] = {100, 200};
const int *a0 = A;
printf("content of a0 = %d\n", *a0);
//*a0 *= 10; //error: cannot change constant value.
a0 = (A + 1); // A[1]
printf("content of a0 = %d\n", *a0); //prints: A[1]
В примере выше была создана переменная-указатель, ссылающееся на постоянное значение. Слово const перед звёздочкой указывает, что нельзя менять содержимое напрямую (путём разыменования, обращения к ячейке). Но сама переменная указатель постоянной не является. А значит, ей можно присвоить новый адрес. Например, адрес следующей ячейки в массиве.
Чтобы запретить менять адрес (значение переменной) указателя, надо добавить слово const ПОСЛЕ звёздочки. Кроме того, можно добавить ключевые слова const перед и после '*'
, чтобы сделать переменную фиксированной ещё сильнее, например так:
// Переменная с постоянным адресом и постоянным содержимым.
const int *const ptr = A; // constant address with constant content
// Переменная с постоянным адресом (содержимое можно менять)
int *const ptr2 = A; // constant address only.
// Переменная с постоянным содержимым, но с изменяемым адресом (значение справа)
const int *ptr3 = A; // constant content only (can change address (rvalue))