- PVSM.RU - https://www.pvsm.ru -
Как часто вам приходится сталкиваться с конструкцией sizeof(array)/sizeof(array[0]) для определения размера массива? Очень надеюсь, что не часто, ведь на дворе уже 2024 год. В заметке поговорим о недостатках конструкции, откуда она берётся в современном коде и как от неё наконец избавиться.
Не так давно я бороздил просторы интернета в поисках интересного проекта для проверки. Глаз зацепился за OpenTTD [1] — Open Source симулятор, вдохновлённый Transport Tycoon Deluxe (aka симулятор транспортной компании). "Хороший, зрелый проект", — изначально подумал я. Тем более и повод имеется — недавно ему исполнилось целых 20 лет [2]! Даже PVS-Studio и то моложе :)
Примерно здесь уже было бы хорошо переходить к ошибкам, которые нашёл анализатор, но не тут-то было. Хочется похвалить разработчиков — несмотря на то, что проект существует более 20 лет, их кодовая база выглядит прекрасно: CMake, работа с современными стандартами C++ и относительно небольшое количество ошибок в коде. Всем бы так.
Однако, как вы понимаете, если бы совсем ничего не нашлось, то и не было бы этой заметки. Предлагаю вам посмотреть на следующий код (GitHub [3]):
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 [4] [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 вы будете получать одно и то же значение вне зависимости от реальных размеров буфера. Убедиться в этом можно, взглянув на небольшой пример [5], который я уже приготовил для вас.
Конечно же, реализация и конечный размер контейнера зависят от используемой стандартной библиотеки, а также от различных оптимизаций (см. Small String Optimization [6]), поэтому результат у вас может отличаться. Интересное исследование на тему внутренностей std::string можно прочитать здесь [7].
Итак, в проблеме разобрались и выяснили, что так делать не надо. Но ведь интересно, как к этому пришли?
В случае OpenTTD всё достаточно просто. Судя по blame, почти четыре года назад тип поля default_company_pass изменили [8] с char[NETWORK_PASSWORD_LENGTH] на std::string. Любопытно, что текущее значение, возвращаемое макросом lenghtof, отличается от прошлого ожидаемого: 32 против 33. Каюсь, не стал сильнее вникать в код проекта, но надеюсь, что разработчики учли этот нюанс. Судя по комментарию, после поля default_company_pass 33 символ отвечал за нуль-терминал.
// The maximum length of the password, in bytes including ''
// (must be >= NETWORK_SERVER_ID_LENGTH)
Legacy и небольшая невнимательность при рефакторинге — казалось бы, вот она, причина. Но, как ни странно, такой способ вычисления размера массива встречается даже в новом коде. Если с языком C все понятно — иначе никак, то что не так с С++? За ответом я пошёл в Google Поиск и не сказать, чтобы удивился...
Прямо в самом начале, даже до основных результатов поиска, выдаётся вот это :( Здесь стоит сделать ремарку, что для поиска использовался приватный режим, чистый компьютер и прочие нюансы, которые отметают подозрения в том, что это поиск на основе моих прошлых запросов.
Прим. автора: стало даже немного интересно. Напишите в комментариях, что показывает вам в топе выдачи по такому же запросу.
Печально. Надеюсь, что ИИ, обучающиеся на текущем коде, не будут совершать подобных ошибок.
Было бы некрасиво обозначить проблему и не предложить хороших путей решения. Осталось только понять, что с этим делать. Предлагаю начать по порядку и постепенно дойти до наилучшего на текущий момент решения.
Итак, sizeof((expr)) / sizeof((expr)[0]) — это настоящий магнит [9] для ошибок. Посудите сами:
Для динамически выделенных буферов 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 вернёт размер этого массива, который и будет равен количеству элементов.
Как можно заметить, у функции отсутствует тело. Сделано это для того, чтобы такую функцию можно было использовать ТОЛЬКО в невычисляемом [10] контексте. Например, когда вызов функции находится в sizeof.
Отдельно замечу, что в сигнатуре функции явно указано, что она принимает именно массив, а не что угодно. Благодаря этому и работает защита от указателей. Если всё же попытаться передать указатель в такой ArraySizeHelper, то получим ошибку компиляции:
void foo(uint8_t* data)
{
auto count = countof(arr); // ошибка компиляции
....
}
Насчёт стародавних времён я не преувеличиваю. Мой коллега ещё в 2011 году разбирался [11], как работает эта магия в проекте 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 [12]. Его-то я и предлагаю использовать, начиная с C++17, вместо устаревших sizeof-костылей и ArraySizeHelper'ов. Ещё и не нужно каждый раз писать заново: он становится доступен после включения заголовочного файла практически любого контейнера :)
Ниже я также предлагаю рассмотреть пару распространённых сценариев для тех, кто вдруг попал сюда из поиска. Далее я буду подразумевать, что std::size доступен в стандартной библиотеке. В ином случае можно скопировать описанные выше функции и использовать их как аналоги.
В большинстве случаев лучше использовать функцию-член класса size. Например: std::string::size [13], std::vector::size [14], QList::size [15] и т.п. Начиная с C++17, рекомендую перейти на std::size [12], описанный выше.
std::vector<int> first { 1, 2, 3 };
std::string second { "hello" };
....
const auto firstSize = first.size();
const auto secondSize = second.size();
Также используйте свободную функцию std::size [12]. Как мы уже выяснили выше, она может вернуть количество элементов не только в контейнерах, но в обычных массивах.
static const int MyData[] = { 2, 9, -1, ...., 14 };
....
const auto size = std::size(MyData);
Очевидным плюсом этой функции является то, что при попытке подсунуть ей неподходящий тип или указатель, мы получим ошибку компиляции.
Также используйте свободную функцию std::size [12]. В дополнение к неприхотливости в плане типа объекта она ещё и работает на этапе компиляции.
template <typename Container>
void DoSomeWork(const Container& data)
{
const auto size = std::size(data);
....
}
Здесь возможны два варианта в зависимости от ваших потребностей. Если нужно только узнать размер, то достаточно воспользоваться std::distance [16]:
void SomeFunc(iterator begin, iterator end)
{
const auto size = static_cast<size_t>(std::distance(begin, end));
}
Если нужно что-то интереснее простого получения размера, то можно использовать read-only классы-обёртки: std::string_view [17] для строк, std::span [18] в общем случае и т.д. Например:
void SomeFunc(const char* begin, const char * end)
{
std::string_view view { begin, end };
const auto size = view.size();
....
char first = view[0];
}
Опытные читатели также могут добавить вариант с адресной арифметикой, но, пожалуй, я оставлю его за скобками, т.к. целевой аудиторией заметки являются начинающие программисты. Не будем учить их плохому :)
В большинстве случаев придётся немного переписать программу и добавить передачу размера массива. Увы.
Если же вы работаете именно со строками (const char *, const wchar_t * и т.п.) и точно знаете, что строка содержит нуль-терминал [19], то ситуация немного лучше. В таком случае можно воспользоваться std::basic_string_view [20]:
const char *text = GetSomeText();
std::string_view view { text };
Как и в примере выше, получаем все достоинства view-классов, имея изначально только один указатель.
Также упомяну менее предпочтительный, но полезный в некоторых ситуациях вариант с использованием std::char_traits::length [21]:
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 [22] или std::wsclen [23]. Обратите внимание, что я не просто так уточнил про обязательное наличие в строке нуль-терминала. В противном случае получите неопределённое поведение [24] (undefined behavior).
Надеюсь, мне удалось показать хорошие альтернативы для замены такой простой, но опасной конструкции как sizeof(array) / sizeof(array[0]). Если вам кажется, что я что-то незаслуженно забыл или умолчал — добро пожаловать в комментарии :)
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Mikhail Gelvikh. How not to check array size in C++ [25].
Автор: Михаил Гельвих
Источник [26]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/pvs-studio/390865
Ссылки в тексте:
[1] OpenTTD: https://www.openttd.org/
[2] 20 лет: https://www.openttd.org/news/2024/03/06/happy-birthday
[3] GitHub: https://github.com/OpenTTD/OpenTTD/blob/4af089b9be8f56418cc96447b3ca26f037f7888b/src/network/network_gui.cpp#L2254
[4] V1055: https://pvs-studio.ru/ru/docs/warnings/v1055/
[5] пример: https://godbolt.org/z/WvMKrrbhn
[6] Small String Optimization: https://pvs-studio.ru/ru/blog/terms/6658/
[7] здесь: https://shaharmike.com/cpp/std-string/
[8] изменили: https://github.com/OpenTTD/OpenTTD/commit/c73d64adf984036a99d6974b130eda65dfc18c6c#diff-f6372bcb055e88a1265135229ee51d619b8ab8cee004ff3f10d4d2c6188c9ddeL271
[9] магнит: https://pvs-studio.ru/ru/blog/examples/v511/
[10] невычисляемом: https://en.cppreference.com/w/cpp/language/expressions#Potentially-evaluated_expressions
[11] разбирался: https://pvs-studio.ru/ru/blog/posts/cpp/a0074/
[12] std::size: https://en.cppreference.com/w/cpp/iterator/size
[13] std::string::size: https://en.cppreference.com/w/cpp/string/basic_string/size
[14] std::vector::size: https://en.cppreference.com/w/cpp/container/vector/size
[15] QList::size: https://doc.qt.io/qt-6/qlist.html#size
[16] std::distance: https://en.cppreference.com/w/cpp/iterator/distance
[17] std::string_view: https://en.cppreference.com/w/cpp/header/string_view
[18] std::span: https://en.cppreference.com/w/cpp/container/span
[19] нуль-терминал: https://pvs-studio.ru/ru/blog/terms/0088/
[20] std::basic_string_view: https://en.cppreference.com/w/cpp/string/basic_string_view
[21] std::char_traits::length: https://en.cppreference.com/w/cpp/string/char_traits
[22] std::strlen: https://en.cppreference.com/w/cpp/string/byte/strlen
[23] std::wsclen: https://en.cppreference.com/w/cpp/string/wide/wcslen
[24] неопределённое поведение: https://pvs-studio.ru/ru/blog/terms/0066/
[25] How not to check array size in C++: https://pvs-studio.com/en/blog/posts/cpp/1112/
[26] Источник: https://habr.com/ru/companies/pvs-studio/articles/805673/?utm_source=habrahabr&utm_medium=rss&utm_campaign=805673
Нажмите здесь для печати.