Указатели

Теги: Си указатели. Указатель на указатель. Тип указателя. Арифметика указателей. Сравнение указателей.



Указатели

Это, пожалуй, самая сложная и самая важная тема во всём курсе. Без понимания указателей дальнейшее изучении си будет бессмысленным. Указатели – очень простая концепция, очень логичная, но требующая внимания к деталям.

Определение

Указатель – это переменная, которая хранит адрес области памяти. Указатель, как и переменная, имеет тип. Синтаксис объявления указателей

<тип> *<имя>;

Например
float *a;
long long *b;
Два основных оператора для работы с указателями – это оператор & взятия адреса, и оператор * разыменования. Рассмотрим простой пример.

#include <conio.h>
#include <stdio.h>
  
void main() {
	int A = 100;
	int *p;

	//Получаем адрес переменной A
	p = &A;

	//Выводим адрес переменной A
	printf("%p\n", p);

	//Выводим содержимое переменной A
	printf("%d\n", *p);

	//Меняем содержимое переменной A
	*p = 200;

	printf("%d\n", A);
	printf("%d", *p);

    getch();
}

Рассмотрим код внимательно, ещё раз

int A = 100;

Была объявлена переменная с именем A. Она располагается по какому-то адресу в памяти. По этому адресу хранится значение 100.

int *p;

Создали указатель типа int.

p = &A;

Теперь переменная p хранит адрес переменной A. Используя оператор * мы получаем доступ до содержимого переменной A.
Чтобы изменить содержимое, пишем

*p = 200;

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

#include <conio.h>
#include <stdio.h>
  
void main() {
	int A = 100;
	int *a = &A;
	double B = 2.3;
	double *b = &B;

	printf("%d\n", sizeof(A));
	printf("%d\n", sizeof(a));
	printf("%d\n", sizeof(B));
	printf("%d\n", sizeof(b));

	getch();
}

Будет выведено
4
4
8
4
Несмотря на то, что переменные имеют разный тип и размер, указатели на них имеют один размер. Действительно, если указатели хранят адреса, то они должны быть целочисленного типа. Так и есть, указатель сам по себе хранится в переменной типа size_t (а также ptrdiff_t), это тип, который ведёт себя как целочисленный, однако его размер зависит от разрядности системы. В большинстве случаев разницы между ними нет. Зачем тогда указателю нужен тип?

Арифметика указателей

Во-первых, указателю нужен тип для того, чтобы корректно работала операция разыменования (получения содержимого по адресу). Если указатель хранит адрес переменной, необходимо знать, сколько байт нужно взять, начиная от этого адреса, чтобы получить всю переменную.
Во-вторых, указатели поддерживают арифметические операции. Для их выполнения необходимо знать размер.
операция + N сдвигает указатель вперёд на N*sizeof(тип) байт.
Например, если указатель int *p; хранит адрес CC02, то после p += 10; он будет хранить адрес СС02 + sizeof(int)*10 = CC02 + 28 = CC2A (Все операции выполняются в шестнадцатиричном формате). Пусть мы создали указатель на начало массива. После этого мы можем "двигаться" по этому массиву, получая доступ до отдельных элементов.

#include <conio.h>
#include <stdio.h>
  
void main() {
	int A[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
	int *p;

	p = A;

	printf("%d\n", *p);
	p++;
	printf("%d\n", *p);
	p = p + 4;
	printf("%d\n", *p);

	getch();
}

Заметьте, каким образом мы получили адрес первого элемента массива

p = A;

Массив, по сути, сам является указателем, поэтому не нужно использовать оператор &. Мы можем переписать пример по-другому

p = &A[0];

Получить адрес первого элемента и относительно него двигаться по массиву.
Кроме операторов + и - указатели поддерживают операции сравнения. Если у нас есть два указателя a и b, то a > b, если адрес, который хранит a, больше адреса, который хранит b.

#include <conio.h>
#include <stdio.h>
  
void main() {
	int A[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
	int *a, *b;

	a = &A[0];
	b = &A[9];

	printf("&A[0] == %p\n", a);
	printf("&A[9] == %p\n", b);

	if (a < b) {
		printf("a < b");
	} else {
		printf("b < a");
	}

	getch();
}

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

Указатель на указатель

Указатель хранит адрес области памяти. Можно создать указатель на указатель, тогда он будет хранить адрес указателя и сможет обращаться к его содержимому. Указатель на указатель определяется как

<тип> **<имя>;

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

#include <conio.h>
#include <stdio.h>
  
#define SIZE 10

void main() {
	int A;
	int B;
	int *p;
	int **pp;

	A = 10;
	B = 111;
	p = &A;
	pp = &p;

	printf("A = %d\n", A);
	*p = 20;
	printf("A = %d\n", A);
	*(*pp) = 30;	//здесь скобки можно не писать
	printf("A = %d\n", A);

	*pp = &B;
	printf("B = %d\n", *p);
	**pp = 333;
	printf("B = %d", B);

	getch();
}

Указатели и приведение типов

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

#include <conio.h>
#include <stdio.h>
  
#define SIZE 10

void main() {
	int A = 10;
	int *intPtr;
	char *charPtr;

	intPtr = &A;
	printf("%d\n", *intPtr);
	printf("--------------------\n");
	charPtr = (char*)intPtr;
	printf("%d ", *charPtr);
	charPtr++;
	printf("%d ", *charPtr);
	charPtr++;
	printf("%d ", *charPtr);
	charPtr++;
	printf("%d ", *charPtr);
	
	getch();
}

В этом примере мы пользуемся тем, что размер типа int равен 4 байта, а char 1 байт. За счёт этого, получив адрес первого байта, можно пройти по остальным байтам числа и вывести их содержимое.

NULL pointer - нулевой указатель

Указатель до инициализации хранит мусор, как и любая другая переменная. Но в то же время, этот "мусор" вполне может оказаться валидным адресом. Пусть, к примеру, у нас есть указатель. Каким образом узнать, инициализирован он или нет? В общем случае никак. Для решения этой проблемы был введён макрос NULL библиотеки stdlib.
Принято при определении указателя, если он не инициализируется конкретным значением, делать его равным NULL.

int *ptr = NULL;

По стандарту гарантировано, что в этом случае указатель равен NULL, и равен нулю, и может быть использован как булево значение false. Хотя в зависимости от реализации NULL может и не быть равным 0 (в смысле, не равен нулю в побитовом представлении, как например, int или float).
Это значит, что в данном случае

int *ptr = NULL;
if (ptr == 0) {
...
}

вполне корректная операция, а в случае

int a = 0;
if (a == NULL) {
...
}

поведение не определено. То есть указатель можно сравнивать с нулём, или с NULL, но нельзя NULL сравнивать с переменной целого типа или типа с плавающей точкой.

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

void main() {
	int *a = NULL;
	unsigned length, i;
	
	printf("Enter length of array: ");
	scanf("%d", &length);
	
	if (length > 0) {
		//При выделении памяти возвращается указатель.
		//Если память не была выделена, то возвращается NULL
		if ((a = (int*) malloc(length * sizeof(int))) != NULL) {
			for (i = 0; i < length; i++) {
				a[i] = i * i;
			}
		} else {
			printf("Error: can't allocate memory");
		}
	}

	//Если переменая была инициализирована, то очищаем её
	if (a != NULL) {
		free(a);
	}
	getch();
}

Примеры

Теперь несколько примеров работы с указателями
1. Пройдём по массиву и найдём все чётные элементы.

#include <conio.h>
#include <stdio.h>
  
void main() {
	int A[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
	int even[10];
	int evenCounter = 0;
	int *iter, *end;

	//iter хранит адрес первого элемента массива
	//end хранит адрес следующего за последним "элемента" массива
	for (iter = A, end = &A[10]; iter < end; iter++) {
		if (*iter % 2 == 0) {
			even[evenCounter++] = *iter;
		}
	}

	//Выводим задом наперёд чётные числа
	for (--evenCounter; evenCounter >= 0; evenCounter--) {
		printf("%d ", even[evenCounter]);
	}

	getch();
}

2. Когда мы сортируем элементы часто приходится их перемещать. Если объект занимает много места, то операция обмена местами двух элементов будет дорогостоящей. Вместо этого можно создать массив указателей на исходные элементы и отсортировать его. Так как размер указателей меньше, чем размер элементов целевого массива, то и сортировка будет происходить быстрее. Кроме того, массив не будет изменён, часто это важно.

#include <conio.h>
#include <stdio.h>
  
#define SIZE 10

void main() {
	double unsorted[SIZE] = {1.0, 3.0, 2.0, 4.0, 5.0, 6.0, 8.0, 7.0, 9.0, 0.0};
	double *p[SIZE];
	double *tmp;
	char flag = 1;
	unsigned i;

	printf("unsorted array\n");
	for (i = 0; i < SIZE; i++) {
		printf("%.2f ", unsorted[i]);
	}
	printf("\n");

	//Сохраняем в массив p адреса элементов
	for (i = 0; i < SIZE; i++) {
		p[i] = &unsorted[i];
	}

	do {
		flag = 0;
		for (i = 1; i<SIZE; i++) {
			//Сравниваем СОДЕРЖИМОЕ
			if (*p[i] < *p[i-1]) {
				//обмениваем местами АДРЕСА
				tmp = p[i];
				p[i] = p[i-1];
				p[i-1] = tmp;
				flag = 1;
			}
		}
	} while(flag);

	printf("sorted array of pointers\n");
	for (i = 0; i < SIZE; i++) {
		printf("%.2f ", *p[i]);
	}
	printf("\n");

	printf("make sure that unsorted array wasn't modified\n");
	for (i = 0; i < SIZE; i++) {
		printf("%.2f ", unsorted[i]);
	}

	getch();
}

3. Более интересный пример. Так как размер типа char всегда равен 1 байт, то с его помощью можно реализовать операцию swap – обмена местами содержимого двух переменных.

#include <conio.h>
#include <conio.h>
#include <stdio.h>

void main() {
	int length;
	char *p1, *p2;
	char tmp;
	float a = 5.0f;
	float b = 3.0f;

	printf("a = %.3f\n", a);
	printf("b = %.3f\n", b);

	p1 = (char*) &a;
	p2 = (char*) &b;
	//Узнаём сколько байт перемещать
	length = sizeof(float);
	while (length--) {
		//Обмениваем местами содержимое переменных побайтно
		tmp = *p1;
		*p1 = *p2;
		*p2 = tmp;
		//не забываем перемещаться вперёд
		p1++;
		p2++;
	}

	printf("a = %.3f\n", a);
	printf("b = %.3f\n", b);

	getch();
}

В этом примере можно поменять тип переменных a и b на double или любой другой (с соответствующим изменением вывода и вызова sizeof), всё равно мы будет обменивать местами байты двух переменных.

4. Найдём длину строки, введённой пользователем, используя указатель
#include <conio.h>
#include <stdio.h>

void main() {
	char buffer[128];
	char *p;
	unsigned length = 0;

	scanf("%127s", buffer);
	p = buffer;
	while (*p != '\0') {
		p++;
		length++;
	}

	printf("length = %d", length);
	getch();
}

Обратите внимание на участок кода

while (*p != '\0') {
	p++;
	length++;
}

его можно переписать

while (*p != 0) {
	p++;
	length++;
}
или

while (*p) {
	p++;
	length++;
}

или, убрав инкремент в условие

while (*p++) {
	length++;
}
Q&A

Всё ещё не понятно? – пиши вопросы на ящик email
Строки