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

Михаил Гельвих
Статей: 12

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

Как часто вам приходится сталкиваться с конструкцией 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]). Если вам кажется, что я что-то незаслуженно забыл или умолчал — добро пожаловать в комментарии :)