Как не надо проверять размер массива в С++

от автора

Как часто вам приходится сталкиваться с конструкцией 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]) — это настоящий магнит для ошибок. Посудите сами:

  1. Для динамически выделенных буферов sizeof посчитает не то, что надо;

  2. Если 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++.


ссылка на оригинал статьи https://habr.com/ru/articles/805673/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *