Как часто вам приходится сталкиваться с конструкцией sizeof(array)/sizeof(array[0]) для определения размера массива? Очень надеюсь, что не часто, ведь на дворе уже 2024 год. В заметке поговорим о недостатках конструкции, откуда она берётся в современном коде и как от неё наконец избавиться.
Чуть больше контекста
Не так давно я бороздил просторы интернета в поисках интересного проекта для проверки. Глаз зацепился за OpenTTD — Open Source симулятор, вдохновлённый Transport Tycoon Deluxe (aka симулятор транспортной компании). "Хороший, зрелый проект", — изначально подумал я. Тем более и повод имеется — недавно ему исполнилось целых 20 лет! Даже PVS-Studio и то моложе :)
Примерно здесь уже было бы хорошо переходить к ошибкам, которые нашёл анализатор, но не тут-то было. Хочется похвалить разработчиков — несмотря на то, что проект существует более 20 лет, их кодовая база выглядит прекрасно: CMake, работа с современными стандартами C++ и относительно небольшое количество ошибок в коде. Всем бы так.
Однако, как вы понимаете, если бы совсем ничего не нашлось, то и не было бы этой заметки. Предлагаю вам посмотреть на следующий код (GitHub):
NetworkCompanyPasswordWindow(WindowDesc *desc, Window *parent)
: Window(desc)
, password_editbox(
lengthof(_settings_client.network.default_company_pass) // <=
)
{
....
}
С виду ничего интересного, но анализатор смутило вычисление размера контейнера _settings_client.network.default_company_pass. При более детальном рассмотрении оказалось, что lengthof — это макрос, и в реальности код выглядит так (чуть-чуть отформатировал для удобства):
NetworkCompanyPasswordWindow(WindowDesc *desc, Window *parent)
: Window(desc)
, password_editbox(
(sizeof(_settings_client.network.default_company_pass) /
sizeof(_settings_client.network.default_company_pass[0]))
)
{
....
}
Ну и раз уж мы выкладываем карты на стол, то можно показать и предупреждение анализатора:
V1055 [CWE-131] The 'sizeof (_settings_client.network.default_company_pass)' expression returns the size of the container type, not the number of elements. Consider using the 'size()' function. network_gui.cpp 2259
В этом случае за _settings_client.network.default_company_pass скрывается std::string. Чаще всего размер объекта контейнера, полученный через sizeof, ничего не говорит о его истинных размерах. Попытка таким образом получить размер строки практически всегда является ошибкой.
Всё дело в особенностях реализации современных контейнеров стандартной библиотеки и std::string в частности. Чаще всего они реализуются с помощью двух указателей (начало и конец буфера), а также переменной, содержащей реальное количество элементов. Именно поэтому при попытке вычислить размер* std::string* c помощью sizeof вы будете получать одно и то же значение вне зависимости от реальных размеров буфера. Убедиться в этом можно, взглянув на небольшой пример, который я уже приготовил для вас.
Конечно же, реализация и конечный размер контейнера зависят от используемой стандартной библиотеки, а также от различных оптимизаций (см. Small String Optimization), поэтому результат у вас может отличаться. Интересное исследование на тему внутренностей std::string можно прочитать здесь.
Почему?
Итак, в проблеме разобрались и выяснили, что так делать не надо. Но ведь интересно, как к этому пришли?
В случае OpenTTD всё достаточно просто. Судя по blame, почти четыре года назад тип поля default_company_pass изменили с char[NETWORK_PASSWORD_LENGTH] на std::string. Любопытно, что текущее значение, возвращаемое макросом lenghtof, отличается от прошлого ожидаемого: 32 против 33. Каюсь, не стал сильнее вникать в код проекта, но надеюсь, что разработчики учли этот нюанс. Судя по комментарию, после поля default_company_pass 33 символ отвечал за нуль-терминал.
// The maximum length of the password, in bytes including '\0'
// (must be >= NETWORK_SERVER_ID_LENGTH)
Legacy и небольшая невнимательность при рефакторинге — казалось бы, вот она, причина. Но, как ни странно, такой способ вычисления размера массива встречается даже в новом коде. Если с языком C все понятно — иначе никак, то что не так с С++? За ответом я пошёл в Google Поиск и не сказать, чтобы удивился...
Прямо в самом начале, даже до основных результатов поиска, выдаётся вот это :( Здесь стоит сделать ремарку, что для поиска использовался приватный режим, чистый компьютер и прочие нюансы, которые отметают подозрения в том, что это поиск на основе моих прошлых запросов.
Прим. автора: стало даже немного интересно. Напишите в комментариях, что показывает вам в топе выдачи по такому же запросу.
Печально. Надеюсь, что ИИ, обучающиеся на текущем коде, не будут совершать подобных ошибок.
Как надо
Было бы некрасиво обозначить проблему и не предложить хороших путей решения. Осталось только понять, что с этим делать. Предлагаю начать по порядку и постепенно дойти до наилучшего на текущий момент решения.
Итак, sizeof((expr)) / sizeof((expr)[0]) — это настоящий магнит для ошибок. Посудите сами:
Для динамически выделенных буферов sizeof посчитает не то, что надо;
Если builtin-массив передали в функцию по копии, то sizeof на нём тоже вернёт не то, что надо.
Раз уж мы тут пишем на С++, то давайте воспользуемся мощью шаблонов! Тут мы приходим к легендарным ArraySizeHelper'ам (aka "безопасный sizeof" в некоторых статьях), которые рано или поздно пишутся почти в каждом проекте. В стародавние времена — до C++11 — можно было встретить таких монстров:
template <typename T, size_t N>
char (&ArraySizeHelper(T (&array)[N]))[N];
#define countof(array) (sizeof(ArraySizeHelper(array)))
Для тех, кто не понял, что тут происходит:
ArraySizeHelper — это шаблон функции, который принимает массив типа T и размера N по ссылке. При этом функция возвращает ссылку на массив типа char размера N.
Чтобы понять, как эта штука работает, рассмотрим небольшой пример:
void foo()
{
int arr[10];
const size_t count = countof(arr);
}
При вызове ArraySizeHelper компилятор должен будет вывести шаблонные параметры из шаблонных аргументов. В нашем случае T будет выведен как int, а N как 10. Возвращаемым типом функции при этом будет тип char (&)[10]. В итоге sizeof вернёт размер этого массива, который и будет равен количеству элементов.
Как можно заметить, у функции отсутствует тело. Сделано это для того, чтобы такую функцию можно было использовать ТОЛЬКО в невычисляемом контексте. Например, когда вызов функции находится в sizeof.
Отдельно замечу, что в сигнатуре функции явно указано, что она принимает именно массив, а не что угодно. Благодаря этому и работает защита от указателей. Если всё же попытаться передать указатель в такой ArraySizeHelper, то получим ошибку компиляции:
void foo(uint8_t* data)
{
auto count = countof(arr); // ошибка компиляции
....
}
Насчёт стародавних времён я не преувеличиваю. Мой коллега ещё в 2011 году разбирался, как работает эта магия в проекте Chromium. С приходом в нашу жизнь C++11 и C++14 писать такие вспомогательные функции стало намного проще:
template <typename T, size_t N>
constexpr size_t countof(T (&arr)[N]) noexcept
{
return N;
}
Но и это ещё не все — можно лучше!
Скорее всего, далее вы столкнётесь с тем, что захотите считать размер контейнеров: std::vector, std::string, QList, — не важно. В таких контейнерах уже есть нужная нам функция — size. Её-то нам и нужно позвать. Добавим перегрузку для функции выше:
template <typename Cont>
constexpr auto countof(const Cont &cont) -> decltype(cont.size())
noexcept(noexcept(cont.size()))
{
return cont.size();
}
Здесь мы просто определили функцию, которая будет принимать любой объект и возвращать результат вызова его функции size. Теперь наша функция имеет защиту от указателей, умеет работать как с builtin-массивами, так и с контейнерами, да ещё и на этапе компиляции.
Ииии я вас поздравляю, мы успешно переизобрели std::size. Его-то я и предлагаю использовать, начиная с C++17, вместо устаревших sizeof-костылей и ArraySizeHelper'ов. Ещё и не нужно каждый раз писать заново: он становится доступен после включения заголовочного файла практически любого контейнера :)
Современный C++: правильное вычисление количества элементов в массивах и контейнерах
Ниже я также предлагаю рассмотреть пару распространённых сценариев для тех, кто вдруг попал сюда из поиска. Далее я буду подразумевать, что std::size доступен в стандартной библиотеке. В ином случае можно скопировать описанные выше функции и использовать их как аналоги.
Я использую какой-нибудь современный контейнер (std::vector, QList и т.п.)
В большинстве случаев лучше использовать функцию-член класса size. Например: std::string::size, std::vector::size, QList::size и т.п. Начиная с C++17, рекомендую перейти на std::size, описанный выше.
std::vector<int> first { 1, 2, 3 };
std::string second { "hello" };
....
const auto firstSize = first.size();
const auto secondSize = second.size();
У меня обычный массив
Также используйте свободную функцию std::size. Как мы уже выяснили выше, она может вернуть количество элементов не только в контейнерах, но в обычных массивах.
static const int MyData[] = { 2, 9, -1, ...., 14 };
....
const auto size = std::size(MyData);
Очевидным плюсом этой функции является то, что при попытке подсунуть ей неподходящий тип или указатель, мы получим ошибку компиляции.
Я внутри шаблона и не знаю, что за контейнер/объект используется на самом деле
Также используйте свободную функцию std::size. В дополнение к неприхотливости в плане типа объекта она ещё и работает на этапе компиляции.
template <typename Container>
void DoSomeWork(const Container& data)
{
const auto size = std::size(data);
....
}
У меня есть два указателя или итератора (начало и конец)
Здесь возможны два варианта в зависимости от ваших потребностей. Если нужно только узнать размер, то достаточно воспользоваться std::distance:
void SomeFunc(iterator begin, iterator end)
{
const auto size = static_cast<size_t>(std::distance(begin, end));
}
Если нужно что-то интереснее простого получения размера, то можно использовать read-only классы-обёртки: std::string_view для строк, std::span в общем случае и т.д. Например:
void SomeFunc(const char* begin, const char * end)
{
std::string_view view { begin, end };
const auto size = view.size();
....
char first = view[0];
}
Опытные читатели также могут добавить вариант с адресной арифметикой, но, пожалуй, я оставлю его за скобками, т.к. целевой аудиторией заметки являются начинающие программисты. Не будем учить их плохому :)
У меня есть только один указатель (например, создали массив через new)
В большинстве случаев придётся немного переписать программу и добавить передачу размера массива. Увы.
Если же вы работаете именно со строками (const char *, const wchar_t * и т.п.) и точно знаете, что строка содержит нуль-терминал, то ситуация немного лучше. В таком случае можно воспользоваться std::basic_string_view:
const char *text = GetSomeText();
std::string_view view { text };
Как и в примере выше, получаем все достоинства view-классов, имея изначально только один указатель.
Также упомяну менее предпочтительный, но полезный в некоторых ситуациях вариант с использованием std::char_traits::length:
const char *text = GetSomeText();
const auto size = std::char_traits<char>::length(text);
std::char_traits — это настоящий швейцарский нож для работы со строками. С его помощью можно писать обобщённые алгоритмы вне зависимости от используемого типа символов в строке (char, wchar_t, char8_t, char16_t, char32_t). Это позволяет не думать о том, какую функцию требуется использовать в тот или иной момент: std::strlen или std::wsclen. Обратите внимание, что я не просто так уточнил про обязательное наличие в строке нуль-терминала. В противном случае получите неопределённое поведение (undefined behavior).
Заключение
Надеюсь, мне удалось показать хорошие альтернативы для замены такой простой, но опасной конструкции как sizeof(array) / sizeof(array[0]). Если вам кажется, что я что-то незаслуженно забыл или умолчал — добро пожаловать в комментарии :)
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Mikhail Gelvikh. How not to check array size in C++.