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 вместо встроенных фиксированных массивов для любого нетривиального использования массива.
