Урок 9. Статические массивы
Привет! На этом уроке мы продолжим улучшать наш консольный калькулятор. У нашего калькулятора есть один существенный недостаток — он проводит вычисления только для тех чисел, которые мы задали в тексте программы. В итоге, чтобы произвести вычисления для нового набора чисел, нам необходимо каждый раз пересобирать приложение. Было бы здорово, если бы наше приложение было скомпилировано один раз, а пользователь мог задавать разные значения чисел. Практически во всех самоучителях и книгах этот функционал реализуется с помощью std::cin — объекта из библиотеки C++, который позволяет считывать данные, которые пользователь вводит в консоль. Проблема такого подхода заключается в том, что в продуктовой разработке тебе вряд ли когда-либо придётся столкнуться с std::cin. Зато ты гарантированно столкнёшься с передачей параметров через аргументы командной строки:
denis@debian:~$ ./calculator 1 + 2
1 + 2 = 3
Т.е. при запуске приложения мы указываем числа и операцию, которую над ними нужно выполнить, в ответ получаем результат вычисления либо ошибку, если вычисление не может быть выполнено. Выглядит отлично! Как обычно, для реализации этого функционала нам придётся освоить несколько новых тем :)
Массивы
Массив — это последовательность переменных одного типа, занимающая непрерывную область памяти.
Массивы нужны, чтобы объединять данные в коллекции и эффективно работать с ними. Представь следующую абстракцию: массив — это книжный шкаф, элемент массива — книга. Согласись, намного удобнее найти нужную книгу, если ты знаешь, что она находится в книжном шкафу, а не искать её по всему дому :)
В этом разделе мы будем говорить о статических массивах, т.е. массивах, размер которых известен на этапе компиляции.
Чтобы объявить в C++ статический массив, необходимо воспользоваться следующим рецептом:
тип_элементов имя_массива[количество_элементов];
Массив задания для нашей утилиты должен состоять из 3 элементов. Его можно записать следующим образом:
char task[3] = {'1', '+', '2'};
Забегая вперёд, я подсвечу, что примерно в подобном массиве операционная система будет передавать аргументы командной строки для нашего приложения.
Для инициализации массива используется специальная конструкция, которая называется «список инициализации» — в фигурных скобках через запятую указывается значение, которым будет инициализирован элемент массива.
Нумерация (или, как ещё говорят, индексация) элементов массива в C++ начинается с 0: 1 — первый элемент массива task имеет номер 0, + — имеет номер 1, 2 — имеет номер 2. Обрати внимание, что индекс последнего элемента массива всегда равен размеру массива минус 1. Это очень важный факт, постарайся его запомнить.
Чтобы извлечь значение элемента из массива, необходимо обратиться к элементу по индексу:
имя_массива[индекс_элемента];
Давай выведем в консоль значение первого элемента. Для этого мы должны обратиться к элементу с индексом 0:
std::cout << task[0] << '\n';
Удобство массива заключается в том, что мы можем выполнить одно и то же действие над всеми элементами массива, используя цикл. Как ты понимаешь, цикл позволяет сэкономить десятки строчек кода. Например, мы можем «обойти массив», т.е. обратиться к каждому элементу массива и вывести его значение в консоль:
for (int i = 0; i < 3; ++i)
{
std::cout << task[i] << '\n';
}
Либо, зная соглашение о передаче данных (сначала идёт первое число, потом операция, потом второе число), мы можем получить информацию о задании, которое нужно выполнить:
char a = task[0];
char operation = task[1];
char b = task[2];
Как передать массив в функцию
Создание массива в функции и работа с этим же массивом в функции не составляет труда для начинающего изучать C++. Более сложная задача — передача массива в функцию и работа с массивом как с аргументом функции. Напомню, что операционная система будет передавать массив с аргументами командной строки в функцию main. Как это будет происходить, пока не важно, важен сам факт — нам необходимо освоить механизм передачи массива в функцию.
Ключевой момент в изучении этой технологии состоит в осмыслении следующего факта:
Имя массива — это указатель на первый элемент массива.
Т.е. имя массива можно разыменовать и получить значение первого элемента массива:
char task[3] = {'1', '+', '2'};
std::cout << "task[0]: " << task[0] << '\n';
std::cout << "task[0]: " << *task << '\n';
// Такая запись тоже допустима
char* arrayPtr = task;
std::cout << "task[0]: " << *arrayPtr << '\n';
Давай разбираться. Вспомним определение массива.
Массив — это последовательность переменных одного типа, занимающая непрерывную область памяти.
Получается, что элементы массива task гарантированно расположены в памяти друг за другом. Сначала в памяти находится элемент со значением 1, потом со значением +, и наконец, со значением 2. Стандарт языка C++ гарантирует, что массив всегда занимает непрерывную область памяти, а элементы идут в строгом порядке друг за другом. Я думаю, что у тебя в голове уже возникла идея: если мы знаем положение первого элемента, элементы идут строго друг за другом, получается, что мы можем определить положение всех последующих элементов? Да, именно так! Всё как в школьной задаче про расстояние между городами.
«Положение» переменной в памяти — это её адрес. Но как узнать «расстояние» между элементами? Кстати, «расстояние» между элементами строго одинаковое, потому как массив хранит в себе элементы только одного типа.
Каждый тип данных в C++ имеет определённый размер, т.е. количество памяти, которое используется для хранения переменной данного типа. Размер вычисляется в байтах. Байт — минимальная единица адресации памяти в C++, т.е. нельзя выделить память под переменную размером меньше 1 байта.
Размер любой переменной можно узнать с помощью функции sizeof:
std::cout << "size of char: " << sizeof(char) << '\n'; // 1
sizeof возвращает размер в байтах. Размер char — 1 байт.
Задание для самостоятельного выполнения Какой размер типа int?
Итак, мы знаем адрес первого элемента в массиве и размер каждого элемента. Как на основе этих данных получить адрес второго и последующих элементов?
Операции над указателями
В решении этого вопроса нам помогут операции над указателями: указатель — это переменная, значит, над ней можно выполнять операции. Но указатель — это особая переменная, обладающая особыми требованиями к операциям.
Запомнить, какие операции можно выполнять над указателями, лично мне помогает следующая абстракция: указатель — это как адрес дома на улице. Исходя из этой абстракции, легко запомнить, что:
- указатели запрещено складывать. Допустим, в нашей абстракции есть два дома: Живописная 20 и Живописная 30. Если мы их сложим, мы получим Живописную 50, а на улице всего 40 домов! Т.е. сложение указателей не приводит к логичному результату — эта операция запрещена.
- указатели можно вычитать. Вычтя из адреса Живописная 30 адрес Живописной 20, мы получим количество домов, находящихся между этими адресами. Но! Вычитание из адреса Живописная 30 адреса Кленовая 10 — абсолютно бессмысленная операция! Т.е. указатели можно вычитать друг из друга, но операция вычитания будет иметь смысл только для адресов переменных из одной коллекции (т.е. массива).
- указатели запрещено делить и умножать. По аналогии со сложением указателей мы не можем получить никакого логичного результата.
- указатели можно сравнивать друг с другом. Операция сравнения будет иметь смысл только для адресов переменных из одной коллекции (т.е. массива).
- указатель можно складывать с целым числом (отрицательным и положительным). Это вполне логично: от адреса дома мы можем перейти к адресу предыдущего или следующего дома. Но нужно быть внимательным, чтобы не выйти за границы адресного пространства. Представь, что на улице 50 домов, ты пытаешься получить адрес несуществующего 51 дома — типичный случай UB: можно получить адрес дома на следующей улице, а можно оказаться на пустыре. Так и с элементами массива: можно выйти за границы массива и получить UB.
Я думаю, ты уже догадался, что операция сложения указателя на начало массива с числом позволит нам обратиться к любому элементу массива:
task[0] == *(task + 0);
task[1] == *(task + 1);
task[2] == *(task + 2);
Обрати внимание на использование круглых скобок: сначала выполняется операция сложения указателя с числом (на сленге «перемещение указателя»), т.е. получается значение адреса элемента массива, а только потом выполняется операция разыменования указателя, т.е. получения значения по вычисленному адресу.
Разберём подробнее, что происходит в круглых скобках выражения *(task + 1). К указателю task прибавляется 1, в результате получается адрес элемента массива с индексом 1. Выражение в круглых скобках можно прочитать так: «получить адрес элемента массива с индексом 1». Всю работу по вычислению адреса выполняет компилятор. Ему известен адрес первого элемента массива, а по типу указателя он определяет размер одного элемента. Используя эту информацию, компилятор корректно вычисляет адрес любого элемента массива. Нам, C++-программистам, редко приходится задумываться о таких низкоуровневых деталях — остаётся лишь пользоваться удобным механизмом, который называется «арифметика указателей».
Получается, что, имея указатель на начало массива и количество элементов в массиве, мы можем передать массив в функцию. Давай напишем функцию обхода массива:
#include <iostream>
void print(char* task, int size)
{
for (int i = 0; i < size; ++i)
{
std::cout << "task[" << i << "]: " << task[i] << '\n';
}
}
int main()
{
constexpr int size = 3;
char task[size] = {'1', '+', '2'};
print(task, size);
}
Рассмотрим функцию print. Функция принимает указатель на первый элемент массива и количество элементов в массиве. При обращении к элементу массива я использую [], чтобы подчеркнуть, что я работаю с массивом, а task — указатель на первый элемент массива.
Следующая запись тоже допустима, но является слишком низкоуровневой:
std::cout << "task[" << i << "]: " << *(task + i) << '\n';
Рассмотрим функцию main. constexpr — новое для тебя ключевое слово языка C++. В контексте данного кода constexpr делает size константой времени компиляции, т.е. значение переменной size будет заменено на константу (целочисленный литерал 3) на этапе компиляции. Использование констант в коде вместо безымянных целых чисел (их ещё называют magic numbers) — это правило хорошего стиля кода. Я рекомендую тебе его придерживаться.
При вызове функции print первым аргументом достаточно просто передать имя массива, т.к. имя массива — это указатель на первый элемент массива.
Размер указателя
В заключение этого урока обсудим размер указателя. Указатель — это переменная, поэтому для указателя можно использовать функцию sizeof. Но нужно помнить, что размер указателя зависит от архитектуры операционной системы. Для ОС с архитектурой x32 размер указателя равен 4 байта, для ОС с архитектурой x86_64 размер указателя равен 8 байт.
Важно помнить, что размер указателя не зависит от типа переменной, адрес которой хранит указатель. Размер переменной типа char — 1 байт, int — обычно 4 байта на современных системах. Но размеры указателей для этих типов идентичны:
assert(sizeof(char*) == sizeof(int*));
assert — новая конструкция C++. Проверяет истинность утверждения при выполнении программы. Читается как «утверждаю, что размер указателя на char равен размеру указателя на int». Если условие assert не выполняется, выполнение программы завершится с ошибкой. Для использования assert необходимо подключить библиотеку cassert. Важно: assert работает только при сборке программы с отладочной информацией (или в режиме Debug). Для этого необходимо указать ключ -g при сборке программы. Давай проверим:
#include <cassert>
int main()
{
assert(sizeof(char*) != sizeof(int*));
}
Соберём и запустим:
g++ -g main.cpp
./a.out
Получим ошибку:
a.out: main.cpp:5: int main(): Assertion `sizeof(char*) != sizeof(int*)' failed.
Aborted
На этом на сегодня всё! На следующем уроке мы добавим поддержку аргументов командной строки в наш проект. До встречи на следующем уроке!