10.22 – Знакомство с std::array
В предыдущих уроках мы подробно говорили о фиксированных и динамических массивах. Хотя оба они встроены прямо в язык C++, у них обоих есть недостатки: фиксированные массивы распадаются на указатели, при этом теряется информация о длине массива, а у динамических массивов возникают проблемы с освобождением памяти, и их сложно изменить без допущения ошибок.
Для решения этих проблем стандартная библиотека C++ включает в себя функционал, упрощающий управление массивами, std::array
и std::vector
. В этом уроке мы рассмотрим std::array
, а в следующем – std::vector
.
Знакомство с std::array
std::array
обеспечивает функциональность фиксированного массива, которая не пропадает при передаче в функцию. std::array
определяется в заголовке <array>
внутри пространства имен std
.
Объявить переменную std::array
очень просто:
#include <array>
std::array<int, 3> myArray; // объявляем массив значений int длиной 3
Как и в случае с нативной реализацией фиксированных массивов, длина std::array
должна быть известна во время компиляции.
std::array
может быть инициализирован с помощью списка инициализаторов или инициализации списком:
std::array<int, 5> myArray = { 9, 7, 5, 3, 1 }; // список инициализаторов
std::array<int, 5> myArray2 { 9, 7, 5, 3, 1 }; // инициализация списком
В отличие от встроенных фиксированных массивов, с std::array
вы не можете опустить длину массива при предоставлении инициализатора:
std::array<int, > myArray { 9, 7, 5, 3, 1 };// недопустимо, должна быть указана длина массива
std::array<int> myArray { 9, 7, 5, 3, 1 }; // недопустимо, должна быть указана длина массива
Однако, начиная с C++17, можно не указывать тип и размер. Их можно опустить только обоих сразу, а не что-то одно, и только если массив инициализирован явно.
std::array myArray { 9, 7, 5, 3, 1 };// Тип выводится как std::array<int, 5>
std::array myArray { 9.7, 7.31 }; // Тип выводится как std::array<double, 2>
Мы предпочитаем использовать этот синтаксис, и не указывать в объявлении тип и размер. Если ваш компилятор не поддерживает C++17, вам нужно вместо этого использовать явный синтаксис.
// std::array myArray { 9, 7, 5, 3, 1 }; // начиная с C++17
std::array<int, 5> myArray { 9, 7, 5, 3, 1 }; // до C++17
// std::array myArray { 9.7, 7.31 }; // начиная с C++17
std::array<double, 2> myArray { 9.7, 7.31 }; // до C++17
Начиная с C++20, можно указать тип элемента, но опустить длину массива. Это делает создание std::array
немного более похожим на создание массивов в стиле C. Чтобы создать массив с определенным типом и предполагаемым размером, мы используем функцию std::to_array
:
auto myArray1 { std::to_array<int, 5>({ 9, 7, 5, 3, 1 }) };// Указываем тип и размер
auto myArray2 { std::to_array<int>({ 9, 7, 5, 3, 1 }) }; // Указываем только тип, вывести размер
auto myArray3 { std::to_array({ 9, 7, 5, 3, 1 }) }; // Вывести тип и размер
К сожалению, std::to_array
дороже, чем создание std::array
напрямую, потому что она фактически копирует все элементы из массива в стиле C в std::array
. По этой причине следует избегать использования std::to_array
, когда массив создается много раз (например, в цикле).
Вы также можете присвоить значения массиву, используя список инициализаторов
std::array<int, 5> myArray;
myArray = { 0, 1, 2, 3, 4 }; // ok
myArray = { 9, 8, 7 }; // ok, элементы 3 и 4 установлены в ноль!
myArray = { 0, 1, 2, 3, 4, 5 }; // недопустимо, слишком много элементов
// в списке инициализаторов!
Доступ к значениям std::array
с помощью оператора индекса работает так же, как и следовало ожидать:
std::cout << myArray[1] << '\n';
myArray[2] = 6;
Как и у встроенных фиксированных массивов, оператор индекса не проверяет границы. Если указан неверный индекс, вероятно, произойдет что-то плохое.
std::array
поддерживает вторую форму доступа к элементам массива (функция at()
), которая выполняет проверку границ:
std::array myArray { 9, 7, 5, 3, 1 };
myArray.at(1) = 6; // элемент массива 1 допустим, устанавливает элемент массива 1 в значение 6
myArray.at(9) = 10; // элемент массива 9 недопустим, выдаст ошибку
В приведенном выше примере вызов myArray.at(1)
проверяет, является ли индекс 1 допустимым, и, поскольку это так, он возвращает ссылку на элемент массива 1. Затем мы присваиваем ему значение 6. Однако вызов myArray.at(9)
завершается неудачно, поскольку элемент массива 9 находится за пределами массива. Вместо возврата ссылки функция at()
выдает ошибку, которая завершает программу (примечание: на самом деле она генерирует исключение типа std::out_of_range
– мы рассмотрим исключения в главе 20). Поскольку at()
выполняет проверку границ, она медленнее (но безопаснее), чем operator[]
.
std::array
будет очищаться, когда выходит за пределы области видимости, поэтому нет необходимости выполнять какую-либо ручную очистку.
Размер и сортировка
Для получения длины массива std::array
можно использовать функцию size()
:
std::array myArray { 9.0, 7.2, 5.4, 3.6, 1.8 };
std::cout << "length: " << myArray.size() << '\n';
Этот код напечатает:
length: 5
Поскольку std::array
не раскладывается на указатель при передаче в функцию, функция size()
будет работать, даже если вы вызовете ее из функции:
#include <array>
#include <iostream>
void printLength(const std::array<double, 5> &myArray)
{
std::cout << "length: " << myArray.size() << '\n';
}
int main()
{
std::array myArray { 9.0, 7.2, 5.4, 3.6, 1.8 };
printLength(myArray);
return 0;
}
Это также напечатает:
length: 5
Обратите внимание, что в стандартной библиотеке термин «size» (размер) используется для обозначения длины массива – не путайте его с результатами sizeof()
для встроенного фиксированного массива, который возвращает фактический размер массива в памяти (произведение размера элемента на длину массива). Да, эта номенклатура противоречива.
Также обратите внимание, что мы передали std::array
по (константной) ссылке. Это сделано для того, чтобы компилятор не создал копию std::array
, когда тот был передан в функцию (по соображениям производительности).
Лучшая практика
Всегда передавайте std::array
по ссылке или по константной ссылке
Поскольку длина всегда известна, с std::array
работают циклы for
на основе диапазона (for-each):
std::array myArray{ 9, 7, 5, 3, 1 };
for (int element : myArray)
std::cout << element << ' ';
Вы можете отсортировать std::array
, используя функцию std::sort
, которая находится в заголовке <algorithm>
:
#include <algorithm> // для std::sort
#include <array>
#include <iostream>
int main()
{
std::array myArray { 7, 3, 1, 9, 5 };
std::sort(myArray.begin(), myArray.end()); // сортируем массив по возрастанию
// std::sort(myArray.rbegin(), myArray.rend()); // сортируем массив по убыванию
for (int element : myArray)
std::cout << element << ' ';
std::cout << '\n';
return 0;
}
Этот код печатает:
1 3 5 7 9
Функция сортировки использует итераторы, концепцию, которую мы еще не рассмотрели. Поэтому пока вы можете рассматривать параметры std::sort()
как небольшую магию. Мы объясним их позже.
Передача массивов std::array
разной длины в функции
В std::array
тип элемента и длина массива являются частью информации о типе. Следовательно, когда мы используем std::array
в качестве параметра функции, мы должны указать тип элемента и длину массива:
#include <array>
#include <iostream>
void printArray(const std::array<int, 5>& myArray)
{
for (auto element : myArray)
std::cout << element << ' ';
std::cout << '\n';
}
int main()
{
std::array myArray5{ 9.0, 7.2, 5.4, 3.6, 1.8 };
printArray(myArray5);
return 0;
}
Обратной стороной здесь является то, что это ограничивает нашу функцию обработкой массивов только этого конкретного типа и длины. Но что, если мы хотим, чтобы наша функция обрабатывала массивы элементов разного типа или длины? Нам нужно будет создать копию функции для каждого типа элемента и/или длины массива, которые мы хотим использовать. Но тогда мы получим много дублирующего кода.
К счастью, мы можем заставить C++ сделать это за нас, используя средство языка, называемое шаблонами. Мы еще не рассмотрели шаблоны, но основная идея заключается в том, что мы можем создать шаблонную функцию, которая параметризует часть или всю информацию о типе, а затем C++ по мере необходимости будет использовать этот шаблон для создания «реальных» функций (с фактическими типами).
#include <array>
#include <cstdef>
#include <iostream>
// printArray - это шаблонная функция
template <class T, std::size_t size> // параметризуем тип элемента и размер
void printArray(const std::array<T, size>& myArray)
{
for (auto element : myArray)
std::cout << element << ' ';
std::cout << '\n';
}
int main()
{
std::array myArray5{ 9.0, 7.2, 5.4, 3.6, 1.8 };
printArray(myArray5);
std::array myArray7{ 9.0, 7.2, 5.4, 3.6, 1.8, 1.2, 0.7 };
printArray(myArray7);
return 0;
}
Связанный контент
Мы подробно рассмотрим шаблоны в главе 19.
Ручное индексирование std::array
через size_type
Популярный вопрос тестов: что не так со следующим кодом?
#include <iostream>
#include <array>
int main()
{
std::array myArray { 7, 3, 1, 9, 5 };
// Обходим массив и выводим значения элементов
for (int i{ 0 }; i < myArray.size(); ++i)
std::cout << myArray[i] << ' ';
std::cout << '\n';
return 0;
}
Ответ заключается в том, что в этом коде есть вероятное несоответствие «со знаком / без знака»! Из-за любопытного решения функция size()
и параметр индекса массива для operator[]
используют тип с именем size_type
, который определен стандартом C++ как целочисленный тип без знака. Наш счетчик/индекс цикла (переменная i
) – это целочисленный тип со знаком, signed int
. Поэтому и сравнение i < myArray.size()
, и индекс массива myArray[i]
имеют несоответствия типов.
Интересно, что size_type
не является глобальным типом (как int
или std::size_t
). Он определен внутри определения std::array
(C++ допускает вложенные типы). Это означает, что когда мы хотим использовать size_type
, мы должны поставить перед ним префикс полного типа массива (подумайте о std::array
, действующем в этом отношении как пространство имен). В нашем примере, приведенном выше, тип size_type
с полным префиксом – это std::array<int, 5>::size_type
!
Следовательно, правильный способ написать приведенный выше код выглядит следующим образом:
#include <array>
#include <iostream>
int main()
{
std::array myArray { 7, 3, 1, 9, 5 };
// std::array<int, 5>::size_type - это тип, возвращаемый функцией size()!
for (std::array<int, 5>::size_type i{ 0 }; i < myArray.size(); ++i)
std::cout << myArray[i] << ' ';
std::cout << '\n';
return 0;
}
Это не очень читабельно. К счастью, std::array::size_type
– это просто псевдоним для std::size_t
, поэтому мы можем использовать его.
#include <array>
#include <cstddef> // std::size_t
#include <iostream>
int main()
{
std::array myArray { 7, 3, 1, 9, 5 };
for (std::size_t i{ 0 }; i < myArray.size(); ++i)
std::cout << myArray[i] << ' ';
std::cout << '\n';
return 0;
}
Лучшее решение – в первую очередь, избегать ручной индексации std::array
. Вместо этого по возможности используйте циклы for
на основе диапазона (или итераторы).
Имейте в виду, что беззнаковые целые числа пойдут по кругу, когда вы достигнете их пределов. Распространенной ошибкой является уменьшение индекса, который уже равен 0, что приводит к переходу к максимальному значению. Вы видели это в уроке о циклах for
, но давайте повторимся.
#include <array>
#include <iostream>
int main()
{
std::array myArray { 7, 3, 1, 9, 5 };
// Распечатываем массив в обратном порядке.
// Мы можем использовать auto, потому что мы не инициализируем i нулем.
// Плохо:
for (auto i{ myArray.size() - 1 }; i >= 0; --i)
std::cout << myArray[i] << ' ';
std::cout << '\n';
return 0;
}
Это бесконечный цикл, приводящий к неопределенному поведению, поскольку i
идет по кругу. Здесь есть две проблемы. Если myArray
пуст, т.е. size()
возвращает 0 (что возможно с std::array
), myArray.size() - 1
переносится к другому концу диапазона. Другая проблема возникает независимо от количества элементов. Условие i >= 0
всегда истинно потому, что целые числа без знака не могут быть меньше 0.
Рабочий вариант обратного цикла for
для целочисленных типов без знака принимает странную форму:
#include <array>
#include <iostream>
int main()
{
std::array myArray { 7, 3, 1, 9, 5 };
// Распечатываем массив в обратном порядке.
for (auto i{ myArray.size() }; i-- > 0; )
std::cout << myArray[i] << ' ';
std::cout << '\n';
return 0;
}
Внезапно теперь мы уменьшаем индекс в условии и используем постфиксный оператор --
. Условие проверяется перед каждой итерацией, включая первую. В первой итерации i
равно myArray.size() - 1
, потому что i
было уменьшено в условии. Когда i
равно 0 и вот-вот перейдет к противоположному концу своего диапазона, условие больше не выполняется, и цикл останавливается. i
на самом деле переносится, когда мы выполняем i--
в последний раз, но потом уже не используется.
Массив структур
Конечно std::array
не ограничивается числами в качестве элементов. Каждый тип, который можно использовать в обычном массиве, можно использовать и в std::array
.
#include <array>
#include <iostream>
struct House
{
int number{};
int stories{};
int roomsPerStory{};
};
int main()
{
std::array<House, 3> houses{};
houses[0] = { 13, 4, 30 };
houses[1] = { 14, 3, 10 };
houses[2] = { 15, 3, 40 };
for (const auto& house : houses)
{
std::cout << "House number " << house.number
<< " has " << (house.stories * house.roomsPerStory)
<< " rooms\n";
}
return 0;
}
Вывод программы:
House number 13 has 120 rooms
House number 14 has 30 rooms
House number 15 has 120 rooms
Однако всё становится немного странно, когда мы пытаемся инициализировать массив.
// Не работает
std::array<House, 3> houses{
{ 13, 4, 30 },
{ 14, 3, 10 },
{ 15, 3, 40 }
};
Хотя мы можем так инициализировать std::array
, если его элементы принадлежат простым типам, таким как int
или std::string
, но это не работает с типами, которым необходимо создать несколько значений. Давайте посмотрим, почему это так.
std::array
– это агрегированный тип, как, например, и House
. Специальной функции для создания std::array
нет. Его внутренний массив инициализируется, как и любая другая переменная-член структуры. Чтобы упростить понимание, мы сами реализуем простой тип массива.
На данный момент мы не можем сделать это без доступа к члену value
. Как это обойти, вы узнаете позже. Но это не влияет на проблему, которую мы наблюдаем.
struct Array
{
int value[3]{};
};
int main()
{
Array array{
11,
12,
13
};
return 0;
}
Как и ожидалось, это работает. То же самое и с std::array
, если мы используем его с элементами int
. Создавая экземпляр структуры, мы можем инициализировать все ее члены. Если мы попытаемся создать массив Array
из структур House
, то получим ошибку.
struct House
{
int number{};
int stories{};
int roomsPerStory{};
};
struct Array
{
// Теперь это массив структур House
House value[3]{};
};
int main()
{
// Если мы попытаемся инициализировать массив, то получим ошибку
Array houses{
{ 13, 4, 30 },
{ 14, 3, 10 },
{ 15, 3, 40 }
};
return 0;
}
Когда мы используем фигурные скобки внутри инициализации, компилятор попытается инициализировать один член структуры для каждой пары фигурных скобок. Вместо того, чтобы инициализировать массив следующим образом:
// Это не верно
Array houses{
{ 13, 4, 30 }, // value[0]
{ 14, 3, 10 }, // value[1]
{ 15, 3, 40 } // value[2]
};
Компилятор пытается инициализировать массив следующим образом:
Array houses{
{ 13, 4, 30 }, // value
{ 14, 3, 10 }, // ???
{ 15, 3, 40 } // ???
};
Первая пара внутренних фигурных скобок инициализирует value
, поскольку value
является первым членом Array
. Без двух других пар фигурных скобок был бы один дом (одна структуру House
) с номером 13, 4 этажами и 30 комнатами на этаж.
Напоминание
Скобки во время агрегированной инициализации можно опустить:
struct S
{
int arr[3]{};
int i{};
};
// Эти две инструкции делают одно и то же
S s1{ { 1, 2, 3 }, 4 };
S s2{ 1, 2, 3, 4 };
Чтобы инициализировать все дома (структуры House
), нам нужно сделать это в первой паре скобок.
Array houses{
{ 13, 4, 30, 14, 3, 10, 15, 3, 40 } // value
};
Это работает, но очень сбивает с толку. Так сбивает с толку, что ваш компилятор может даже предупредить вас об этом. Инициализацию будет намного легче читать, если мы добавим фигурные скобки вокруг каждого элемента массива.
#include <iostream>
struct House
{
int number{};
int stories{};
int roomsPerStory{};
};
struct Array
{
House value[3]{};
};
int main()
{
// С фигурными скобками это работает.
Array houses{
{ { 13, 4, 30 }, { 14, 3, 10 }, { 15, 3, 40 } }
};
for (const auto& house : houses.value)
{
std::cout << "House number " << house.number
<< " has " << (house.stories * house.roomsPerStory)
<< " rooms\n";
}
return 0;
}
Вот почему вы встретите лишнюю пару фигурных скобок при инициализации std::array
.
Резюме
std::array
– отличная замена встроенным фиксированным массивам. Он эффективен, поскольку использует памяти не больше, чем встроенные фиксированные массивы. Единственный реальный недостаток std::array
, по сравнению со встроенным фиксированным массивом, – это немного более неудобный синтаксис, в котором вам нужно явно указать длину массива (компилятор не будет рассчитывать ее из инициализатора вместо вас, если вы также не опустите тип, что не всегда возможно), и проблемы «со знаком / без знака» с размером и индексированием. Но это сравнительно незначительные придирки – мы рекомендуем использовать std::array
вместо встроенных фиксированных массивов для любого нетривиального использования массива.