Указатели, массивы и строки

Последнее обновление: 23.01.2024

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

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

#include <stdio.h>

int main(void)
{
	int array[] = {1, 2, 3, 4, 5};
	printf("array[0] = %d", *array);	// array[0] = 1

    return 0;
}

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

#include <stdio.h>

int main(void)
{
	int array[] = {1, 2, 3, 4, 5};
	int second = *(array + 1); // получим второй элемент
	printf("array[1] = %d", second);	// array[1] = 2

    return 0;
}

Более того, когда мы в обращаемся к определенному элементу массива, используя квадратные скобки, например:

array[2]

компилятор рассмотривает эту запись как прибавление индекса к указателю на начальный элемент:

array+2

Поэтому мы даже можем написать 2[array], что также будет валидным обращением к элементу массива:

#include <stdio.h>

int main(void)
{
	int array[] = {1, 2, 3, 4, 5};
    int third = 2[array];
	printf("array[2] = %d", third);	// array[2] = 3

    return 0;
}

Соответственно мы можем пробежаться по всем элементом массива, прибавляя к адресу определенное число:

#include <stdio.h>

int main(void)
{
    int array[5] = {1, 2, 3, 4, 5};
     
    for(int i = 0; i < 5; i++)
    {
        void* address = array + i;  // получаем адрес i-го элемента массива
        int value = *(array + i);   // получаем значение i-го элемента массива
        printf("array[%d]: address=%p \t value=%d \n", i, address, value);
    }
    return 0;
}

То есть, например, адрес второго элемента будет представлять выражение a+1, а его значение - *(a+1).

Со сложением и вычитанием здесь действуют те же правила, что и в операциях с указателями. Добавление единицы означает прибавление к адресу значения, которое равно размеру типа массива. Так, в данном случае массив представляет тип int, размер которого, как правило, составляет 4 байта, поэтому прибавление единицы к адресу означает увеличение адреса на 4. Прибавляя к адресу 2, мы увеличиваем значение адреса на 4 * 2 =8. И так далее.

В итоге в моем случае я получу следующий результат работы программы:

array[0]: address=0060FE98	value=1
array[1]: address=0060FE9C	value=2
array[2]: address=0060FEA0	value=3
array[3]: address=0060FEA4	value=4
array[4]: address=0060FEA8	value=5

В то же время имя массива это не стандартный указатель, мы не можем изменить его адрес, например, так:

int array[5] = {1, 2, 3, 4, 5};
array++;			// так сделать нельзя
int b = 8;
array = &b;			// так тоже сделать нельзя

Использование указателя для работы с массивом

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

#include <stdio.h>
 
int main(void)
{
    int array[5] = {1, 2, 3, 4, 5};
    int *ptr = array;	// указатель ptr хранит адрес первого элемента массива array
    printf("value: %d \n", *ptr);	// 1
    return 0;
}

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

#include <stdio.h>
 
int main(void)
{
    int array[5] = {1, 2, 3, 4, 5};
    int *ptr = array;	// указатель ptr хранит адрес первого элемента массива array
    ptr = ptr + 2;      // перемезаем указатель на 2 элемента вперед
    printf("value: %d \n", *ptr);	// value: 3
    return 0;
}

Здесь указатель ptr изначально указывает на первый элемент массива. Увеличив указатель на 2, мы пропустим 2 элемента в массиве и перейдем к элементу array[2].

И как и другие данные, можно по указателю изменить значение элемента массива:

#include <stdio.h>
 
int main(void)
{
    int array[5] = {1, 2, 3, 4, 5};
    int *ptr = array;	    // указатель ptr хранит адрес первого элемента массива array
    ptr = ptr + 2;          // переходим к третьему элементу
    *ptr = 8;               // меняем значение элемента, на который указывает указатель

    printf("array[2]: %d \n", array[2]);	// array[2] : 8
    return 0;
}

Стоит отметить, что указатель также может использовать индексы, как и массивы:

#include <stdio.h>
 
int main(void)
{
    int array[5] = {1, 2, 3, 4, 5};
    int *ptr = array;	    // указатель ptr хранит адрес первого элемента массива array

    int value = ptr[2];     // используем индексы - получаем 3-й элемент (элемент с индексом 2)

    printf("value: %d \n", value);	// value: 3
    return 0;
}

Строки и указатели

Ранее мы рассмотрели, что строка по сути является набором символов, окончанием которого служит нулевой символ '\0'. И фактически строку можно представить в виде массива:

char hello[] = "Hello METANIT.COM!";

Но в языке Си также для представления строк можно использовать указатели на тип char:

#include <stdio.h>
 
int main(void)
{
	char *hello = "Hello METANIT.COM!";	// указатель на char - фактически строка
	printf("%s", hello);
	return 0;
}

В данном случае оба определения строки - с помощью массива и указателя будут в равнозначны.

Перебор массива с помощью указателей

С помощью указателей легко перебрать массив:

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

for(int *ptr=array; ptr<=&array[4]; ptr++)
{
	printf("address=%p \t value=%d \n", (void*)ptr, *ptr);
}

Так как указатель хранит адрес, то мы можем продолжать цикл, пока адрес в указателе не станет равным адресу последнего элемента (ptr<=&array[4]).

Аналогичным образом можно перебрать и многомерный массив:

#include <stdio.h>

int main(void)
{
	int array[3][4] = { {1, 2, 3, 4} , {5, 6, 7, 8}, {9, 10, 11, 12}};
	int n = sizeof(array)/sizeof(array[0]); 		// число строк
	int m = sizeof(array[0])/sizeof(array[0][0]);	// число столбцов
	
	int *final = array[0] + n * m - 1;	// указатель на самый последний элемент
	for(int *ptr=array[0], i = 1; ptr <= final; ptr++, i++)
	{
		printf("%d \t", *ptr);
		// если остаток от целочисленного деления равен 0,
		// переходим на новую строку
		if(i%m==0)
		{
			printf("\n");
		}
	}	
	return 0;
}

Так как в данном случае мы имеем дело с двухмерным массивом, то адресом первого элемента будет выражение array[0]. Соответственно указатель указывает на этот элемент. С каждой итерацией указатель увеличивается на единицу, пока его значение не станет равным адресу последнего элемента, который хранится в указателе final.

Мы также могли бы обойтись и без указателя на последний элемент, проверяя значение счетчика, пока оно не станет равно общему количеству элементов (m * n):

for(int *ptr = array[0], i = 0; i < m*n;)
{
	printf("%d \t", *ptr++);
	if(++i%m==0)
	{
		printf("\n");
	}
}

Но в любом случае программа вывела бы следующий результат:

1	2	3	4
5	6	7	8
9	10	11	12

String interning

Стоит отметить, что в языке Си для работы со строками применяется такой механизм как string interning или интернирование строк. В этом случае строки в виде строковых литералов сохраняются в приложении в секции .rodata (read-only data), которые предназначены для данных только для чтения, а строковые литералы рассматриваются как неизменяемые данные. Например, возьмем следующую программу:

#include <stdio.h>

char* str1 = "Hello";
char* str2 = "World";

int main(void)
{
    printf("str1 = %p \n", &str1[0]);
    printf("str2 = %p \n", &str2[0]);
     
    return 0;
}

Здесь определены две строки - str1 и str2, в функции main выводим адрес первого символа каждой из этих строк. Так, в моем случае я получу вывод:

str1 = 00007FF75E674000
str2 = 00007FF75E674006

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

Но теперь сделаем строки одинаковыми:

#include <stdio.h>

char* str1 = "Hello World";
char* str2 = "Hello World";

int main(void)
{
    printf("str1 = %p \n", &str1[0]);
    printf("str2 = %p \n", &str2[0]);
     
    return 0;
}

Теперь вывод будет другим:

str1 = 00007FF75F674000
str2 = 00007FF75F674000

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

#include <stdio.h>

int main(void)
{
    char* str1 = "Hello World";
    char* str2 = "Hello World";
    printf("str1 = %p \n", &str1[0]);
    printf("str2 = %p \n", &str2[0]);
     
    return 0;
}
Дополнительные материалы
Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850