Массивы и указатели в стиле Cи

newbie_

Опубликован:  2019-09-21T11:11:11.305426Z
Отредактирован:  2019-09-21T11:10:36.178439Z
Успешное и эффективное программирование на C++, на мой взгляд, невозможно без знания основных типов данных, паттернов и стандартной библиотеки Cи. Этот обзор посвящен массивам и указателям Cи, рассмотрены основные приёмы работы с массивами: объявление, инициализация, заполнение данными, получение данных и т.д. Материал изложен на базе интерпретации C Primer Plus by Stephen Prata.

1. Объявление массива

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

type array_name[ARRAY_SIZE];

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

int tracks[18];       // массив, содержащий 18 элементов типа int
char nickname[16];    // массив, содержащий 16 элементов типа char
double square[3];     // массив, содержащий 3 элемента типа double

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

2. Инициализация массива

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

// first.c - declare an array without initialization

#include <stdio.h>
#define SIZE 4

int main(void)
{
  int some[SIZE];
  int i;
  for (i = 0; i < SIZE; i++)
    printf("%d: %d\n", i, some[i]);
  return 0;
}

Компилирую этот код и пытаюсь его исполнить.

EL596l5zoP.png

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

int some[4] = {456, 312, 274, 180};

В этом случае каждому элементу массива будет присвоено заданное в фигурных скобках значение в заданной последовательности.

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

int some[4] = {};

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

// second.c

#include <stdio.h>
#define SIZE 4

int main(void)
{
  int some[SIZE] = {};
  int i;
  for (i = 0; i < SIZE; i++)
    printf("%d: %d\n", i, some[i]);
  return 0;
}

6YkXgvVVef.png

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

int some[4] = {128, 12};

В этом случае остальные элементы массива будут заполнены нулями.

// third.c

#include <stdio.h>
#define SIZE 4

int main(void)
{
  int some[SIZE] = {128, 12};
  int i;
  for (i = 0; i < SIZE; i++)
    printf("%d: %d\n", i, some[i]);
  return 0;
}

KbLHxxpsVm.png

Следует отметить, что стандарт C99 предлагает ещё одну форму инициализации, при которой указывается индекс конкретного элемента и его значение.

int some[4] = {[2]=255};

Но такая форма инициализации не работает с компилятором g++, это следует иметь ввиду.

// fourth.c

#include <stdio.h>
#define SIZE 4

int main(void)
{
  int some[SIZE] = {[2]=255};
  int i;
  for (i = 0; i < SIZE; i++)
    printf("%d: %d\n", i, some[i]);
  return 0;
}

a5vEpwzBJJ.png

Как видно на снимке экрана выше, компилятор gcc на базе Debian buster справился с этим кодом, а g++ выбросил белый флаг...

Ещё одна форма инициализации массива предполагает, что значения всех элементов массива заданы, при этом количество элементов массива в объявлении опущено.

int some[] = {9, 99, 999, 9999};

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

// fifth.c

#include <stdio.h>

int main(void)
{
  int some[] = {25,27,18,46,33,8};
  int i;
  for (i = 0; i < sizeof some / sizeof(int); i++)
    printf("%d: %d\n", i, some[i]);
  return 0;
}

pQerl3LdLz.png

Ещё одна форма инициализации массива предполагает, что значения элементам массива будут назначены в простом цикле заданным выражением.

// sixth.c

#include <stdio.h>
#define SIZE 18

int main(void)
{
  int some[SIZE];
  int i;
  for (i = 0; i < SIZE; i++)
    some[i] = i * 4;
  for (i = 0; i < SIZE; i++)
    printf("%2d: %d\n", i, some[i]);
  return 0;
}

h1ZjfSKZOC.png

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

3. Границы массива

После того, как массив объявлен, можно получить доступ к его элементам по индексу и, либо получить хранящееся в n-ом элементе значение, либо присвоить этому элементу требуемое значение.

int tracks[18];
tracks[0] = 123;
tracks[1] = 17;

Индексация элементов массива начинается с нуля, то есть индекс первого элемента массива всегда равен нулю, а индекс последнего элемента массива на единицу меньше количества элементов в массиве, заданного в объявлении - эти два параметра (0 и SIZE - 1) определяют границы массива. Компилятор не требует проверки правильности задания границ массива - это ответственность разработчика. Неправильно заданные границы массива могут повлечь за собой серьёзные ошибки. Рассмотрим пример, в котором границы массива заданы неверно.

// bounds.c

#include <stdio.h>
#define SIZE 4

int main(void)
{
  int value1 = 44;
  int arr[SIZE];
  int value2 = 88;
  int i;

  printf("value1 = %d, value2 = %d\n", value1, value2);
  for (i = -1; i <=SIZE; i++)
    arr[i] = 2 * i + 1;
  for (i = -1; i < 7; i++)
    printf("%2d: %d\n", i, arr[i]);
  printf("value1 = %d, value2 = %d\n", value1, value2);
  printf("address of arr[-1]: %p\n", &arr[-1]);
  printf("address of arr[4]:  %p\n", &arr[4]);
  printf("address of value1:  %p\n", &value1);
  printf("address of value2:  %p\n", &value2);
  return 0;
}

Если скомпилировать и исполнить этот код, то можно получить следующий результат.

3p5olTXzB0.png

Как видно на снимке экрана выше, мой компилятор сохранил значение arr[-1] в переменную value2 - их адреса совпадают, таким образом не принадлежащие массиву данные в соседней области оперативной памяти были повреждены при инициализации массива с неверно заданными границами, а компилятор при этом не выдал ошибки. Отсюда вывод: при доступе и изменении данных массива следует тщательно контролировать границы массива.

4. Объявление указателей

Указатели - это особый тип переменных, значение которых является адресом области оперативной памяти компьютера, в которой хранится некоторое значение конкретного, заданного в объявлении указателя типа. Тип значения, хранящегося в заданной указателем области памяти, определяется в объявлении указателя, которое имеет следующую форму:

type * p_name;

Следует иметь ввиду, что указателю можно присвоить только значение адреса оперативной памяти. Значение хранящееся в заданном указателем адресе можно присвоить или получить посредством операции разыменовывания. Рассмотрим самый элементарный пример.

// point.c

#include <stdio.h>

int main(void)
{
  int number;       // переменная типа int
  int * point;      // указатель на значение типа int
  point = &number;  // инициализация указателя  
                    // указатель point указывает на переменную number
  *point = 17;      // операция разыменовывания указателя
                    // изменяет значение переменной number
  printf("number = %d\n", number);
  printf("point  = %p\n", point);
  return 0;
}

Здесь нужно обратить внимание, что тип переменной number соответствует типу указателя point. Адрес любой переменной можно получить при помощи операции &, таким образом в коде этой программы указателю point был присвоен адрес переменной number, а затем значение переменной number было изменено посредством операции разыменовывания указывающего на эту переменную указателя - point.

KFgNHdO72b.png

Важно понимать, что операцию разыменовывания можно применять только к инициализированным указателями. Следующий код неизбежно приведёт к ошибке:

int * pt;  // неинициализированный указатель
*pt = 5;   // грубая ошибка, так делать нельзя
           // присвоить значение посредством операции разыменовывания
           // можно только инициализированному указателю

Методы инициализации указателей я рассмотрю чуть позже в отдельной статье.

5. Указатели и массивы

Имя массива в программах на C - это, в сущности, замаскированный указатель на первый элемент массива. Рассмотрим простой пример.

// apoint.c

#include <stdio.h>
#define SIZE 4

int main(void)
{
  int upper[SIZE] = {4, 3, 2, 1};
  printf("upper:    %p\n", upper);
  printf("upper[0]: %p\n", &upper[0]);
  return 0;
}

4AFCY697dc.png

На снимке экрана выше видно, что программа выхлопнула на экран один и тот же адрес, то есть выражение upper == &upper[0] истинно. Это обстоятельство даёт возможность получить доступ к элементам массива в нотации указателя. Рассмотрим ещё один пример:

// bpoint.c

#include <stdio.h>
#define SIZE 4

int main(void)
{
  int upper[SIZE] = {345, 453, 534, 354};
  int i;
  for (i = 0; i < SIZE; i++)
    printf("%d: %p %d\n", i, upper + i, *(upper + i));
  return 0;
}

Имя массива - это адрес области оперативной памяти, в которой хранится первый элемент массива, размер этой области соответствует типу массива. Если к имени массива прибавить единицу, то получим адрес области оперативной памяти, где хранится следующий элемент массива, значение которого можно получить или изменить при помощи операции разыменовывания (*(upper + 1)). Код примера после компиляции и исполнения даёт в терминале такой выхлоп:

kOOd6W5TP4.png

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

upper + 2 == &upper[2]    // один и тот же адрес
*(upper + 2) == upper[2]  // одно и то же значение

А это значит, что с массивом можно работать в двух нотациях:

  • как с массивом, с доступом к элементам массива по индексу;
  • как с указателем, с доступом к элементам массива по адресу.

6. Функции, массивы и указатели

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

int sum(int * array, int n);  // array - имя массива, n - количество элементов в массиве
int sum(int *, int);
int sum(int array[], int n);
int sum(int [], int);

Все четыре формы абсолютно эквиваленты и отличаются друг от друга только читабельностью. Форма прототипа с именем массива и квадратными скобками даёт явно выраженное представление, что функция имеет дело именно с массивом.

Определение такой функции тоже можно оформить в двух вариантах. Первый вариант имеет указатель в качестве первого аргумента.

int sum(int * array, int n)
{
  // код функции
}

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

int sum(int array[], int n)
{
  // код функции
}

Обе формы абсолютно эквивалентны. Код такой программы может выглядеть как-то так:

// sum_array.c

#include <stdio.h>
#define SIZE 10

int sum(int array[], int n);

int main(void)
{
  int upper[SIZE] = {10,20,5,12,32,17,21,20,43,19};
  int i;
  printf("Data:");
  for (i = 0; i < SIZE; i++)
    printf(" %d", upper[i]);
  printf("\nThe total sum is %d\n", sum(upper, SIZE));
  return 0;
}

int sum(int array[], int n)
{
  int i;
  int total = 0;
  for (i = 0; i < n; i++)
    total += array[i];
  return total;
}

9JfTyceVoY.png

Кроме этого, есть ещё один вариант работы с данными массива в функции, и он предполагает, что в качестве аргументов функции передаются адреса начала и конца массива.

// sum_array.c

#include <stdio.h>
#define SIZE 10

int sum(int * start, int * end);

int main(void)
{
  int upper[SIZE] = {10,20,5,12,32,17,21,20,43,19};
  int i;
  printf("Data:");
  for (i = 0; i < SIZE; i++)
    printf(" %d", upper[i]);
  printf("\nThe total sum is %d\n", sum(upper, upper + SIZE));
  return 0;
}

int sum(int * start, int * end)
{
  int total = 0;
  while (start < end)
    total += *start++;
  return total;
}

Здесь следует обратить внимание, что второй аргумент функции sum - end в действительности указывает на область оперативной памяти, следующую за последним элементом массива. C гарантирует, что, когда вы выделяете пространство под массив, указатель на область памяти следующей за последним элементом массива является действительным указателем. Выхлоп программы с этим вариантом определения функции будет совершенно аналогичным предыдущему примеру.

mqAhOJjCQ9.png

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

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

int sum(const int array[], int n);   // прототип функции

int sum(const int array[], int n)   // определение функции
{
  int i;
  int total = 0;
  for (i = 0; i < n; i++)
    total += array[i];
  return total;
}

const в первом аргументе функции делает невозможным изменение данных массива в теле функции, и если по ошибке в функции будет написано выражение, изменяющее данные в массиве, то компилятор выдаст сообщение об ошибке.

Точно так же можно использовать const и при объявлении и инициализации массива.

const int days[MONTHS] = {31,28,31,30,31,30,31,31,30,31,30,31};

Данные такого массива изменить будет невозможно.

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

double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5};
const double * pd = rates;
*pd = 29.89       // невозможно
pd[2] = 222.22    // невозможно
rates[0] = 99.00  // возможно, потому что rates не является константой
pd++              // возможно

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

double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5};
double * const pc = rates;
pc = &rates[2];  // невозможно
*pc = 92.99;     // OK

Наконец, можно использовать const дважды при объявлении и инициализации указателя.

double rates[5] = {88.99, 100.12, 59.45, 183.11, 340.5};
const double * const pc = rates;
pc = &rates[2];  // невозможно
*pc = 92.99;     // невозможно
Метки:  c, array, pointer, gcc
Комментарии: