Антипаттерн “константа размера массива”

Автор оригинала: Arthur O’Dwyer
  • Перевод
Перевод статьи подготовлен в преддверии старта курса «C++ Developer. Professional».




Хочу обратить ваше внимание на антипаттерн, который я часто встречаю в коде студентов на Code Review StackExchange и даже в довольно большом количестве учебных материалов (!) других людей. У них имеется массив, скажем, из 5 элементов; а затем, поскольку магические числа — это плохо, они вводят именованную константу для обозначения количества элементов «5».

void example()
{
    constexpr int myArraySize = 5;
    int myArray[myArraySize] = {2, 7, 1, 8, 2};
    ...


Но решение это так себе! В приведенном выше коде число пять повторяется: сначала в значении myArraySize = 5, а затем еще раз, когда вы фактически присваиваете элементы myArray. Приведенный выше код столь же ужасен с точки зрения обслуживания, как:

constexpr int messageLength = 45;
const char message[messageLength] =
    "Invalid input. Please enter a valid number.\n";


— который, конечно, никто из нас никогда не напишет.

Код, который повторяется, хорошим не является


Обратите внимание, что в обоих приведенных выше фрагментах кода каждый раз, когда вы меняете содержимое массива или формулировку сообщения, вы должны обновить две строки кода вместо одной. Вот пример того, как мейнтейнер может обновить этот код неправильно:

   constexpr int myArraySize = 5;
-   int myArray[myArraySize] = {2, 7, 1, 8, 2};
+   int myArray[myArraySize] = {3, 1, 4};


Патч выше выглядит так, как будто он изменяет содержимое массива с 2,7,1,8,2 на 3,1,4, но это не так! Фактически он меняет его на 3,1,4,0,0 — с дополнением нулями — потому что мейнтейнер забыл скорректировать myArraySize в соответствии с myArray.

Надежный подход


Что до подсчета, то компьютеры в этом чертовски хороши. Так пусть же считает компьютер!

int myArray[] = {2, 7, 1, 8, 2};
constexpr int myArraySize = std::size(myArray);


Теперь вы можете изменить содержимое массива, скажем, с 2,7,1,8,2 на 3,1,4, изменив только одну строку кода. Дублировать изменение нигде не нужно.

Даже больше, для манипуляций с myArray реальный код обычно использует циклы for и/или алгоритмы на основе диапазона итератора, поэтому ему вообще не понадобится именованная переменная для хранения размера массива.

for (int elt : myArray) {
    use(elt);
}
std::sort(myArray.begin(), myArray.end());
std::ranges::sort(myArray);

// Warning: Unused variable 'myArraySize'


В «плохой» версии этого кода myArraySize всегда используется (в объявлении myArray), и поэтому программист вряд ли увидит, что ее можно исключить. В «хорошей» версии компилятору легко обнаружить, что myArraySize не используется.

Как это сделать с помощью std::array?


Иногда программист делает еще один шаг к Темной Стороне и пишет:

constexpr int myArraySize = 5;
std::array<int, myArraySize> myArray = {2, 7, 1, 8, 2};


Это должно быть переписано по крайней мере как:

std::array<int, 5> myArray = {2, 7, 1, 8, 2};
constexpr int myArraySize = myArray.size();  // или std::size(myArray)


Однако простого способа избавиться от ручного подсчета в первой строке нет. CTAD C++17 позволяет писать

std::array myArray = {2, 7, 1, 8, 2};


но это работает, только если вам нужен массив int — это не сработает, если вам нужен массив short, например, или массив uint32_t.

C++20 дает нам std::to_array, что позволяет нам писать

auto myArray = std::to_array<int>({2, 7, 1, 8, 2});
constexpr int myArraySize = myArray.size();


Обратите внимание, что это создает C-массив, а затем перемещает (move-constructs) его элементы в std::array. Все наши предыдущие примеры инициализировали myArray с помощью списка инициализаторов в фигурных скобках, который запускал агрегатную инициализацию и создавал элементы массива непосредственно на месте.

В любом случае, все эти варианты результируют в большом количестве дополнительных экземпляров шаблонов по сравнению со старыми добрыми C-массивами (которые не требуют создания экземпляров шаблонов). Поэтому я настоятельно предпочитаю T[] более новому std::array<T, N>.

В C++11 и C++14 у std::array было эргономическое преимущество, заключающееся в возможности сказать arr.size(); но это преимущество испарилось, когда C++17 предоставил нам std::size(arr) и для встроенных массивов. У std::array больше нет эргономических преимуществ. Используйте его, если вам нужна его семантика переменной целостного объекта (передать весь массив в функцию! Вернуть массив из функции! Присваивать массивы с помощью =! Сравнить массивы с помощью ==!), Но в противном случае я рекомендую избегать использование std::array.

Точно так же я рекомендую избегать std::list, если вам не нужна стабильность его итератора, быстрая склейка, сортировка без замены элементов и т. д. Я не говорю, что этим типам нет места в C++; Я просто говорю, что у них есть «очень специфический сет скилов», и если вы не используете эти скилы, вы, скорее всего, переплачиваете понапрасну.


Выводы: не городите телегу впереди лошади. На самом деле, телега может даже и не понадобиться. И если вам необходимо использовать зебру для выполнения работы лошади, вам также не следует городить телегу впереди зебры.


Читать ещё:


OTUS. Онлайн-образование
Цифровые навыки от ведущих экспертов

Комментарии 46

    +36
    Читая эту статью сразу захотелось оставить комментарий, но не могу выбрать какой:
    1. Плюсы — это когда получение длины массива — тема для статьи
    2. Плюсы — это когда есть 3 способа создать массив, из которых 2 неправильных

    P.S. Естественно, все это шутки, но пройти мимо не смог
      +5
      Эх, все три неправильные. По-хорошему надо было бы чтобы auto myArr = [1, 2, 3]; создавало объект типа std::array и у него там уже кому надо мог бы брать размер. Но седые мудрецы в комитете плюсов расскажут вам 100500 причин почему такой простоты допустить нельзя никогда.
        –3
        Для этого есть Python, ненужно все яйца складывать в одну корзину.
          –2
          так в статье написано, такая простота уже работает сейчас.
          std::array myArray = {2, 7, 1, 8, 2};

          я так в коде и использую.
          Мудрецы? Проблемы?)
          есть еще
          auto myArray = {2, 7, 1, 8, 2};

          который создаете initializer_list у которого тоже можно брать размер. То что init_list — дерьмо, другой разговор, речь про то что «вот просто взять простым синтаксисом объявить » ;)
        +6

        Почему для size везле int, а не size_t или auto?
        2020 год же уже.

          +1

          Страуструп и компания на одной из QnA сессий шесть лет назад ответили на этот вопрос. Где-то с 12:55 (сам вопрос на 9:50) и 43:14
          https://youtu.be/Puio5dly9N8

            +2
            А есть письменный вариант для тех, кто не хочет смотреть 30 минут видео в надежде получить ответ, укладывающийся в три строки?
              +2
              Из зала: — Вот вы говорите, в общем случае нужно использовать знаковые числительные, но в STL они все почему-то беззнаковые.
              Саттер: — Это неправильно.
              Каррут: — Извините.

              Чуть более развёрнутый ответ от Страуструпа:
              — Это приводит к чрезмерно большому количеству ошибок.
                0

                Спасибо.
                Однако непонятно, почему. Unsigned позволяет как минимум не проверять переданный в публичном АПИ индекс на < 0.

                  0
                  Достаточно часто приходится использовать относительные индексы, вроде a[n-2].
                  Это приводит к необходимости преобразования типов, причём сплошь и рядом.
                  При этом при использовании беззнаковых типов индекс (unsigned)(-1) куда опаснее чем знаковый -1 потому что проверить его валидность сложнее.
                    0
                    Однако непонятно, почему. Unsigned позволяет как минимум не проверять переданный в публичном АПИ индекс на < 0.

                    Проверять на <0 бессмысленно, проверка всегда возвращает false.
                    А ошибки могут быть следующие:
                    1)


                    for(unsigned int i = 100500; i>=0; i--){...}

                    2)


                    unsigned int a = 3;
                    unsigned int b = 4;
                    unsigned int i = a - b;
                    if(i < 0) return; 
                    ... use arr[i] ...

                    Это даже если signed и unsigned не смешивать.

                      0
                      Проверять на <0 бессмысленно, проверка всегда возвращает false.

                      Если публичное API signed, а вы отправили -1, то имеет смысл проверка, потому что иначе будет SIGSEGV в лучшем случае.
                      А ошибки могут быть следующие:
                      Во всех случаях — ошибка программиста программы, а не программиста API.
                        0
                        Если публичное API signed

                        Речь шла о unsigned. Будет тот же sigsegv (ибо в дополнительном коде a+((unsigned int)-1) == a-1, но проверкой <0 такой "-1" не обнаружить, он равен например 0xffffffff.


                        Во всех случаях — ошибка программиста программы, а не программиста API.

                        Ошибки — они и есть ошибки. Их надо исправлять, а не перекладывать.
                        P.S. Кто такой "программист программы" и чем он отличается от обычного программиста?

                          0
                          Речь шла о unsigned.

                          Я имею в виду следующее:
                          1) Когда вы пишете функции, используемые в этом же модуле — проверять границы в функции типа:
                          static void a(int * b, i32 ind) {
                              if (ind < 0) ...
                          }

                          бессмысленно, потому что вы (или ряд программистов, поддерживающих модуль) будете единственным пользователем этой функции, поскольку она расположена в этом же модуле (я не рассматриваю случаи, когда это предполагается с точки зрения codeflow.
                          2) А вот когда вы пишете функции, используемые за пределами модуля (методы класса\публичные функции — то, что называется публичным API), то такая проверка имеет смысл только если такой индекс знаковый. Если индекс беззнаковый — то такая проверка, очевидно, не нужна, поскольку индекс не может быть отрицательным.

                          Касательно проблем с «нельзя отвалидировать индекс» — а разве то, что другой программист прислал в ваше публичное АПИ некорректный индекс — это проблема создателя публичного АПИ? Это проблема ТОЛЬКО того программиста, который написал некорректный код. Проверив индекс на < 0 если он сделал какую-то математику вы только в лучшем случае заглушите ошибку. Это PHP-way, программа не должна работать дальше, если у нее по функциям гуляют некорректные аргументы. SIGSEGV, SIGFPE, SIGILL — да всё что угодно будет лучше того, что кто-то загрузил вам в стек потенциально опасные значения, с которыми вы дальше пытаетесь работать.

                          Ошибки именно нужно исправлять, а не перекладывать. Исправлять там, где они появляются, а не перекладывать это на плечи создателя публичного АПИ, который выведет в консоль «эй, чувак, твой вызов прислал мне -500 как координату X, так дело не пойде~CARRIER LOST»
                            0
                            Касательно проблем с «нельзя отвалидировать индекс» — а разве то, что другой программист прислал в ваше публичное АПИ некорректный индекс — это проблема создателя публичного АПИ? Это проблема ТОЛЬКО того программиста, который написал некорректный код

                            В некотором роде, это проблема создателей языка, когда i>=0 то работает, то не работает в зависимости от типа i. Та же java намеренно не имеет числовых беззнаковых типов, чтобы быть надёжнее проще для не всегда внимательных кодеров.


                            Проверив индекс на < 0 если он сделал какую-то математику вы только в лучшем случае заглушите ошибку.

                            Ну в реальном коде я бы там исключение бросал, чтобы наверняка, это лишь пример с return. SIGSEGV-то не при каждом доступе за пределами массива возникает, так что это ненадёжная проверка, что ошибок нет. Тут вопрос в том, что проверка в моём примере вообще нерабочая.

                              +1
                              В некотором роде, это проблема создателей языка, когда i>=0 то работает, то не работает в зависимости от типа
                              беззнаковость — это дополнительная информация, которую вы и тулинг можете использовать. Ваше «i>=0 не работает» — очень странная претензия. Вам банально компилятор или любой анализатор выдаст предупреждение об избыточности условия. Вы ведь не обязательно хотели итерироваться до -1, возможно, вы хотели написать «i > 0» или «i != 0», или что-нибудь еще в таком духе.
                                0
                                Вы ведь не обязательно хотели итерироваться до -1, возможно, вы хотели написать «i > 0» или «i != 0»,

                                Нет, я хотел итерироваться до 0 включительно (не до 1 и не до -1), поэтому написал i>=0. При чем тут i>0 и i!=0? В этом случае описанной проблемы не было бы. Проблема в i>=0.


                                Да, к слову, проблемы возникают и на граничных значениях signed типов (abs(INT_MIN) не влезает в int), и на пределах точности float-типов. Но у unsigned граница 0, и это очень близко по величине к тем значениям, которые обычно используют, поэтому проблемы тоже находятся ближе.

                                  0
                                  Нет, я хотел итерироваться до 0 включительно (не до 1 и не до -1)
                                  в случае signed счетчика ваш цикл остановится на значении -1.
                                  При чем тут i>0 и i!=0? В этом случае описанной проблемы не было бы. Проблема в i>=0.
                                  проблема в вас — вы хотите чтобы программа вела себя не так как вы её написали.
                                  Да, к слову, проблемы возникают и на граничных значениях signed типов (abs(INT_MIN) не влезает в int)
                                  это особенность двоичного представления целых чисел в общем, и вообще никак не относится к беззнаковым
                                  Но у unsigned граница 0, и это очень близко по величине к тем значениям, которые обычно используют, поэтому проблемы тоже находятся ближе.
                                  какая разница, пытаетесь вы обратиться к -1-му или 9'223'372'036'854'775'807-му элементу массива, если и то и другое — UB?
            +4
            Что до подсчета, то компьютеры в этом чертовски хороши. Так пусть же считает компьютер!

            А если нужно задать массив из 100 элементов, где первые {1,2,3}, а остальные нули?
            А если вообще нужен неинициализованный массив?

            По-моему вредность этого «антипаттерна» в представлении автора (заметил, что перевод) сильно преувеличена.

            Это должно быть переписано по крайней мере как:
            std::array<int, 5> myArray = {2, 7, 1, 8, 2};

            Ну то есть когда пишем просто int myArray[5] — «магическое число» это плохо, а вот когда std::array<int, 5> — это уже совсем другое дело…
              0
              А если нужно задать массив из 100 элементов, где первые {1,2,3}, а остальные нули?
              А если вообще нужен неинициализованный массив?
              По-моему вредность этого «антипаттерна» в представлении автора (заметил, что перевод) сильно преувеличена.

              Пойнт как раз в том, чтобы отличать случаи "мне нужен массив конкретной длины, вот инициализация его части" от случая "мне нужен массив с вот таким содержимым".

                +7
                А если нужно задать массив из 100 элементов, где первые {1,2,3}, а остальные нули?

                Иногда чтение кода на c++ напоминает мне чтение какого нибудь Толстого, Достоевского, Чехова… Всегда при втором прочтении обнаруживаются скрытые смыслы и акценты. А что имел ввиду автор? Умышленно ли он заставил говорить своего героя эту фразу? Или это ничего не значащий эпизод из трехтомника?
                  –1
                  Ну то есть когда пишем просто int myArray[5] — «магическое число» это плохо, а вот когда std::array<int, 5> — это уже совсем другое дело…
                  во втором случае у вас всегда будет arr.size(), который вернет то самое волшебное число. Даже при попытке передать этот массив куда-то.
                  +3
                  int myArray[] = {2, 7, 1, 8, 2};
                  constexpr int myArraySize = std::size(myArray);

                  Это требует C++17. Вариант ниже сработает на любой кофеварке и в обычном C:

                  int myArray[] = {2, 7, 1, 8, 2};
                  int myArraySize = sizeof(myArray)/sizeof(myArray[0]);
                    +6

                    А потом перенесли myArray в кучу и привет.

                      –4

                      И-и-и… Что?

                        +1
                        int *myArray = (int *)malloc(5 * sizeof(int));
                        int notMyArraySize = sizeof(myArray)/sizeof(myArray[0]);
                          +1

                          Тогда непонятно, зачем размер массива второй раз вычислять, если он заранее, на этапе malloc(), известен.

                            +2

                            Конечно когда я показал вам две строчки то всё понятно. Но такие ошибки встречаются, когда что-то поменяли, но забыли сделать во всех местах. Особенно у новичков, но и опытные не застрахованы от глупых ошибок. Обычно компилятор должен помогать в таких случаях, потому что по факту тип переменной поменялся. В С++ он действительно меняется с std::array на std::vector, например. А в Си всё есть указатель причем в худшем случае ещё и на void*, т.е. следить надо самому.

                            0
                            какой смысл с++ программисту писать код так, если есть вектор или std::unique_ptr для массива или std::unique_ptr для std::array?
                              +2

                              Тогда получится sizeof(std::vector), или какое ваше предложение?

                                0
                                ну так хорошие c++ программисты и не будут вычислять размер массива/вектора/чего угодно через sizeof, а будут использовать либо std::size, либо T::size
                                  +1

                                  Перечитайте ветку, об этом и речь.

                                0
                                Иногда приходится поддерживать чужой код — в том числе написанный в С-стиле с malloc и прочим ужасом…
                        0

                        Ещё есть std::experimental::make_array :)

                          –1

                          А я вот с примером с сообщением не согласен, что это вредно.
                          В программировании микроконтроллеров часто так делаю: выделяю буфер с запасом небольшим, в котором лежит сообщение. Потом, при необходимости, буфер переписываю другим сообщением.

                            +1
                            выделяю буфер с запасом небольшим
                            Этот бред уже вошёл в анналы истории:
                            gamedev.ru/flame/forum/?id=122958
                              +2
                              Что делаешь в случае, если новое сообщение не помещается в буфер?
                                –1

                                Делаю так, чтобы помещалось ;-)
                                Свежий пример: логирование цепочки датчиков температуры в порт. Буфер, условно, 100 байт. Один буфер — одна строчка лога. Сначала "шапка" (время и т.п.), потом, строчка за строчкой, показания датчиков. Заполнили, отправили. Если совсем никак не влезает, бьём на две отправки, и т.п.

                                  0
                                  Это на передающей стороне. А что делать на приемной? Вот заполнился приемный буфер, а данные из линии продолжают сыпаться…

                                  Да в некоторых протоколах у приемника бывает возможность управления потоком — как-то попросить передатчик приостановить передачу. Но далеко не во всех.
                                    –1
                                    Значит, мы не успеваем обрабатывать данные в том темпе, в котором они поступают, и у нас проблема.
                                    Вообще, я привёл один из многих примеров, когда «антипаттерн», а котором так категорично рассуждает автор, совсем не является проблемой, а скорее, наоборот.
                                    Вы же мне предлагаете рассмотреть вообще все возможные случаи, когда приведённый мною пример не работает?
                                    Не существует какого-то универсального решения или подхода, который был бы абсолютно правильным. Мир разнообразен, и в каждом случае наиболее подходящее решение будет своё. Более того, одному разработчику в данном конкретном случае кажется правильным одно решение, другому — совсем другое. И это нормально.
                                    А вот категорично рассуждать, подобно автору, о том, что вот данное решение всегда неправильное, я бы не стал.
                                  0
                                  С МК может быть еще веселее — что делать, если сообщение не помещается во всю память?
                                    0
                                    Держаться молодцом, пить водку, думать об архитектуре и покупать другой МК.
                                  +2
                                  А я вот с примером с сообщением не согласен, что это вредно.

                                  Это вредно именно потому, что не понятно, то ли буфер выделен с запасом специально, а то ли по ошибке.

                                  0
                                  Приведенный выше код столь же ужасен с точки зрения обслуживания, как:
                                  Совершенно не согласен.
                                  Ключевая разница — в том, что второй кусок данных константный, в то время как данные в массиве myArray могут быть модифицированы (и, по-нормальному, должны быть — иначе и их лучше сделать константными).
                                  Алгоритмы вида x[n+2]=a*x[n+1]+b*x[n] встречаются сплошь и рядом.
                                    0
                                    Не совсем понял, почему «анти». Это стандартный подход из С, когда мы сначала преаллоцируем память, а потом используем arrSize для итерирования.
                                      +2
                                      Поэтому я настоятельно предпочитаю T[] более новому std::array<T, N>.
                                      std::array всё еще надежнее как минимум потому, что не приводится неявно к указателю и нормально копируется/мувится. И бонусом сверху есть некоторый синтаксический сахар, типа операторов лексикографического сравнения, structured bindings и пр.

                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                      Самое читаемое