1053_60_cpp_antipatterns_ru/image2.png


Перед вами обновлённая коллекция вредных советов для C++ программистов, которая превратилась в целую электронную книгу. Всего их 60, и каждый сопровождается пояснением, почему на самом деле ему не стоит следовать. Всё будет одновременно и в шутку, и серьёзно. Как бы глупо ни смотрелся вредный совет, он не выдуман, а подсмотрен в реальном мире программирования.


Я буду публиковать советы по 5 штук, чтобы не утомить вас, так как мини-книга содержит много интересных отсылок на другие статьи, видео и т. д. Однако, если вам не терпится, здесь вы можете сразу перейти к её полному варианту: "60 антипаттернов для С++ программиста". В любом случае желаю приятного чтения.


Вредный совет N26. Напишу всё сам


Не пользуйтесь стандартной библиотекой языка. Что может быть интереснее, чем написать свои строки и списки с уникальным синтаксисом и семантикой?


Может быть, это действительно интересно. Вот только это отнимает много времени, причём результат, скорее всего, окажется более низкого качества, чем у существующих стандартных решений. На практике выходит, что непросто написать без ошибок даже аналоги таких простых функций, как strdup или memcpy. А теперь представьте, сколько недостатков будет в более сложных функциях и классах.


Не верите про strdup и memcpy? Дело в том, что я уже начал даже коллекционировать ошибки в самодельных функциях копирования данных. Возможно, когда-то я сделаю про это отдельную статью. А сейчас пара proof-ов.


В статье про проверку Zephyr RTOS я описал вот такую неудачную попытку реализации аналога функции strdup:


static char *mntpt_prepare(char *mntpt)
{
  char *cpy_mntpt;

  cpy_mntpt = k_malloc(strlen(mntpt) + 1);
  if (cpy_mntpt) {
    ((u8_t *)mntpt)[strlen(mntpt)] = '\0';
    memcpy(cpy_mntpt, mntpt, strlen(mntpt));
  }
  return cpy_mntpt;
}

Предупреждение PVS-Studio: V575 [CWE-628] The 'memcpy' function doesn't copy the whole string. Use 'strcpy / strcpy_s' function to preserve terminal null. shell.c 427


Анализатор сообщает, что функция memcpy копирует строчку, но не копирует терминальный ноль, и это очень подозрительно. Кажется, что этот терминальный 0 копируется здесь:


((u8_t *)mntpt)[strlen(mntpt)] = '\0';

Нет, здесь опечатка, из-за которой терминальный ноль копируется сам в себя. Обратите внимание, что запись происходит в массив mntpt, а не в cpy_mntpt. В итоге функция mntpt_prepare возвращает строку, не завершённую терминальным нулём.


На самом деле, программист хотел написать так:


((u8_t *)cpy_mntpt)[strlen(mntpt)] = '\0';

Непонятно только, зачем код написан так запутанно и нестандартно. Как результат, в небольшой и несложной функции допущена серьёзная ошибка. Этот код можно упростить до следующего варианта:


static char *mntpt_prepare(char *mntpt)
{
  char *cpy_mntpt;

  cpy_mntpt = k_malloc(strlen(mntpt) + 1);
  if (cpy_mntpt) {
    strcpy(cpy_mntpt, mntpt);
  }
  return cpy_mntpt;
}

Или вот программист интересуется, на правильном ли он пути.


void myMemCpy(void *dest, void *src, size_t n) 
{ 
   char *csrc = (char *)src; 
   char *cdest = (char *)dest; 
   for (int i=0; i<n; i++) 
     cdest[i] = csrc[i]; 
}

Этот код не мы сами выявили с помощью PVS-Studio, а я случайно встретил его на сайте Stack Overflow: C and static Code analysis: Is this safer than memcpy?


Впрочем, если проверить эту функцию с помощью анализатора PVS-Studio, он справедливо заметит:


  • V104 Implicit conversion of 'i' to memsize type in an arithmetic expression: i < n test.cpp 26
  • V108 Incorrect index type: cdest[not a memsize-type]. Use memsize type instead. test.cpp 27
  • V108 Incorrect index type: csrc[not a memsize-type]. Use memsize type instead. test.cpp 27

И действительно, этот код содержит недостаток, про который указали и в ответах на Stack Overflow. Нельзя использовать в качестве индекса переменную типа int. В 64-битной программе почти наверняка (экзотические архитектуры не рассматриваем) переменная int будет 32-битной и функция сможет скопировать не более INT_MAX байт. Т. е. не более 2 Гигабайт.


При большем размере копируемого буфера произойдёт переполнение знаковой переменной, что с точки зрения языка C и C++ является неопределённым поведением. Не старайтесь угадать, как именно проявит себя ошибка. Это на самом деле непростая тема, про которую можно прочитать в статье "Undefined behavior ближе, чем вы думаете".


Особенно забавно, что этот код появился как попытка убрать какое-то предупреждение анализатора Checkmarx, возникавшее при вызове функции memcpy. Программист не придумал ничего лучше, как сделать свой собственный велосипед. И несмотря на простоту функции копирования, она всё равно получилась неправильной. То есть по факту человек, скорее всего, сделал ещё хуже, чем было. Вместо того чтобы разобраться в причине предупреждения, он замаскировал проблему написанием своей собственной функции (запутал анализатор). Плюс добавил ошибку, используя для счётчика int. Ах да, такой код ещё может помешать оптимизации. Неэффективно использовать свой собственный код вместо оптимизированной функции memcpy. Не делайте так :)


Вредный совет N27. Удалите stdafx.h


Удалите отовсюду этот дурацкий stdafx.h. Из-за него постоянно возникают какие-то странные ошибки компиляции.


"Вы просто не умеете их готовить" ©. Давайте разберёмся, как в среде Visual Studio работают предкомпилируемые заголовки и как их правильно использовать.


Для чего нужны Precompiled Headers


Precompiled headers предназначены для ускорения сборки проектов. Обычно программисты начинают знакомиться с Visual C++, используя крошечные проекты. На них сложно заметить выигрыш от precompiled headers. Что с ними, что без них — на глаз программа компилируется одинаковое время. Это добавляет путаницы. Человек не видит для себя пользы от этого механизма и решает, что он для специфичных задач и никогда не понадобится. И иногда считает так многие годы.


На самом деле, precompiled headers — весьма полезная технология. Пользу можно заметить, даже если в проекте всего несколько десятков файлов. Особенно выигрыш становится заметен, если используются такие тяжёлые библиотеки, как boost.


Если посмотреть *.cpp файлы в проекте, то можно заметить, что во многие включаются одни и те же наборы заголовочных файлов. Например, <vector>, <string>, <algorithm>. В свою очередь, эти файлы включают другие заголовочные файлы и так далее.


Всё это приводит к тому, что препроцессор в компиляторе вновь и вновь выполняет идентичную работу. Он должен читать одни и те же файлы, вставлять их друг в друга, выбирать #ifdef ветки и подставлять значения макросов. Происходит колоссальное дублирование одних и тех же операций.


Можно существенно сократить объём работы, которую должен проделать препроцессор при компиляции проекта. Идея в том, чтобы заранее препроцессировать группу файлов и затем просто подставлять готовый фрагмент текста.


На самом деле, делается ещё ряд шагов. Можно хранить не просто текст, а более обработанную информацию. Я не знаю, как именно устроено в Visual C++. Но, например, можно хранить текст, уже разбитый на лексемы. Это ещё больше ускорит процесс компиляции.


Как работают Precompiled Headers


Файл, который содержит precompiled headers, имеет расширение ".pch". Имя файла обычно совпадает с названием проекта. Естественно, это и другие используемые имена можно изменить в настройках. Файл может быть весьма большим, и это зависит от того, как много заголовочных файлов в нём раскрыто.


Файл *.pch возникает после компиляции stdafx.cpp. Файл собирается с ключом "/Yc". Этот ключ как раз и говорит компилятору, что нужно создать precompiled header. Файл stdafx.cpp может содержать одну строчку: #include "stdafx.h".


В файле "stdafx.h" находится самое интересное. Сюда нужно включить заголовочные файлы, которые будут заранее препроцессироваться. Пример, как может выглядеть такой файл:


#pragma warning(push)
#pragma warning(disable : 4820)
#pragma warning(disable : 4619)
#pragma warning(disable : 4548)
#pragma warning(disable : 4668)
#pragma warning(disable : 4365)
#pragma warning(disable : 4710)
#pragma warning(disable : 4371)
#pragma warning(disable : 4826)
#pragma warning(disable : 4061)
#pragma warning(disable : 4640)
#include <stdio.h>
#include <string>
#include <vector>
#include <iostream>
#include <fstream>
#include <algorithm>
#include <set>
#include <map>
#include <list>
#include <deque>
#include <memory>
#pragma warning(pop)

Директивы "#pragma warning" нужны, чтобы избавиться от предупреждений, выдаваемых на стандартные библиотеки, если стоит высокий уровень предупреждений в настройках компилятора. Это фрагмент кода из старого проекта. Возможно, сейчас все эти pragma не нужны. Я просто хотел показать, как можно подавить лишние предупреждения, если такие возникнут.


Теперь во все файлы *.c/*.cpp следует включить "stdafx.h". Заодно стоит удалить из этих файлов заголовки, которые уже включаются с помощью "stdafx.h".


А что делать, если используются хотя и похожие, но разные наборы заголовочных файлов? Например, такие:


  • Файл A: <vector>, <string>
  • Файл B: <vector>, <algorithm>
  • Файл C: <string>, <algorithm>

Нужно делать отдельные precompiled headers? Так сделать можно, но не нужно.


Достаточно сделать один precompiled header, в котором будут раскрыты <vector>, <string> и <algorithm>. Выигрыш от того, что при препроцессировании не надо читать множество файлов и вставлять их друг в друга, намного больше, чем потери от синтаксического анализа лишних фрагментов кода.


Как использовать Precompiled Headers


При создании нового проекта Wizard в Visual Studio создаёт два файла: stdafx.h и stdafx.cpp. Именно с помощью них и реализуется механизм precompiled headers.


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


В *.c/*.cpp файле можно использовать только один precompiled header. Однако в одном проекте может присутствовать несколько разных precompiled headers. Пока будем считать, что он у нас только один.


Итак, если вы воспользовались wizard-ом, то у вас уже есть файлы stdafx.h и stdafx.cpp. Плюс выставлены все необходимые ключи компиляции.


Если в проекте не использовался механизм precompiled headers, то давайте рассмотрим, как его включить. Предлагаю следующую последовательность действий:


  1. Во всех конфигурациях для всех *.c/*.cpp файлов включаем использование precompiled headers. Это делается на вкладке "Precompiled Header":
    • Выставляем для параметра "Precompiled Header" значение "Use (/Yu)".
    • Для параметра "Precompiled Header File" указываем "stdafx.h".
    • Для параметра "Precompiled Header Output File" указываем "$(IntDir)$(TargetName).pch".
  2. Создаём и добавляем в проект файл stdafx.h. В дальнейшем в него мы будем включать те заголовочные файлы, которые хотим заранее препроцессировать.
  3. Создаём и добавляем в проект файл stdafx.cpp. В нём одна единственная строка: #include "stdafx.h".
  4. Во всех конфигурациях меняем настройки для файла stdafx.cpp. Выставляем для параметра "Precompiled Header" значение "Create (/Yc)".

Вот мы и включили механизм precompiled headers. Теперь если мы запустим компиляцию, то будет создан *.pch файл. Однако затем компиляция остановится из-за ошибок.


Для всех *.c/*.cpp файлов мы указали, что они должны использовать precompiled headers. Этого мало. Теперь в каждый из файлов нужно добавить #include "stdafx.h".


Заголовочный файл "stdafx.h" должен включаться в *.c/*.cpp файл самым первым. Обязательно! Иначе всё равно возникнут ошибки компиляции.


Если подумать, в этом есть логика. Когда файл "stdafx.h" находится в самом начале, то можно подставить уже препроцессированный текст. Этот текст всегда одинаков и ни от чего не зависит.


Представьте ситуацию, если бы мы могли включить до "stdafx.h" ещё какой-то файл. А в этом файле возьмём и напишем: #define bool char. Возникает неоднозначность. Мы меняем содержимое всех файлов, в которых встречается "bool". Теперь просто так нельзя взять и подставить заранее препроцессированный текст. Ломается весь механизм "precompiled headers". Думаю, это одна из причин, почему "stdafx.h" должен быть расположен в начале. Возможно, есть и другие.


Life hack!


Прописывать #include "stdafx.h" во все *.c/*.cpp файлы достаточно утомительно и неинтересно. Дополнительно получится ревизия в системе контроля версий, где будет изменено огромное количество файлов. Нехорошо.


Ещё одно неудобство вызывают сторонние библиотеки, включаемые в проект в виде файлов с кодом. Править эти файлы нет смыла. По правильному для них нужно отключить использование "precompiled headers". Однако, если используется несколько мелких сторонних библиотек, это неудобно. Программист постоянно спотыкается о precompiled headers.


Есть вариант, как использовать precompiled headers легко и просто. Способ подойдёт не везде и всегда, но мне он часто помогал.


Можно не прописывать во все файлы #include "stdafx.h", а воспользоваться механизмом "Forced Included File".


Идём на вкладку настроек "Advanced". Выбираем все конфигурации. В поле "Forced Included File" пишем:


StdAfx.h;%(ForcedIncludeFiles)

Теперь "stdafx.h" автоматически будет включаться в начало ВСЕХ компилируемых файлов. PROFIT!


Больше не потребуется писать #include "stdafx.h" в начале всех *.c/*.cpp файлов. Компилятор сделает это сам.


Что включать в stdafx.h


Это очень важный момент. Бездумное включение в "stdafx.h" всего подряд не только не ускорит компиляцию, но и наоборот замедлит её.


Все файлы, включающие "stdafx.h", зависят от его содержимого. Пусть в "stdafx.h" включён файл "X.h". Если вы поменяете хоть что-то в "X.h", это может повлечь полную перекомпиляцию всего проекта.


Правило. Включайте в "stdafx.h" только те файлы, которые никогда не изменяются или меняются ОЧЕНЬ редко. Хорошими кандидатами являются заголовочные файлы системных и сторонних библиотек.


Если включаете в "stdafx.h" собственные файлы из проекта, соблюдайте двойную бдительность. Включайте только те файлы, которые меняются очень-очень редко.


Если какой-то *.h файл меняется раз в месяц, это уже слишком часто. Как правило, редко удаётся сделать все правки в h-файле с первого раза. Обычно требуется 2-3 итерации. Согласитесь, 2-3 раза полностью перекомпилировать весь проект — занятие неприятное. Плюс полная перекомпиляция потребуется всем вашим коллегам.


Не увлекайтесь и с неизменяемыми файлами. Включайте только то, что действительно часто используется. Нет смысла включать <set>, если это нужно только в двух местах. Там, где нужно, там и подключите этот заголовочный файл.


Несколько Precompiled Headers


Зачем в одном проекте может понадобиться несколько precompiled headers? Действительно, это нужно нечасто, но приведу пару примеров.


В проекте используются одновременно *.c и *.cpp файлы. Для них нельзя использовать единый *.pch файл. Компилятор выдаст ошибку.


Нужно создать два *.pch файла. Один должен получаться при компилировании C-файла (xx.c), а другой при компилировании C++-файла (yy.cpp). Соответственно, в настройках надо указать, чтобы в С-файлах использовался один precompiled header, а в С++-файлах — другой.


Примечание. Не забудьте указать разные имена для *.pch файлов. Иначе один файл будет перетирать другой.


Другая ситуация. Одна часть проекта использует одну большую библиотеку, а другая часть — другую большую библиотеку.


Естественно, не стоит всем участкам кода знать про обе библиотеки. Плюс в (неудачных) библиотеках могут пересекаться имена каких-то сущностей.


Логично сделать два precompiled headers и использовать их в разных участках программы. Как уже отмечалось, можно задать произвольные имена файлов, из которых генерируются *.pch файлы. Да и имя *.pch файла тоже можно изменить. Всё это, конечно, требуется делать аккуратно, но ничего сложного в использовании двух precompiled headers нет.


Типовые ошибки при использовании Precompiled Headers


Прочитав внимательно материал выше, вы сможете понять и устранить ошибки, связанные с stdafx.h. Но давайте ещё раз пройдёмся по типовым ошибкам компиляции и разберём их причины.


Fatal error C1083: Cannot open precompiled header file: 'Debug\project.pch': No such file or directory


Вы пытаетесь скомпилировать файл, который использует precompiled header. Но соответствующий *.pch файл отсутствует. Возможные причины:


  1. Файл stdafx.cpp не компилировался и, как следствие, *.pch файл ещё не создан. Такое может быть, если, например, в начале очистить проект (Clean Solution), а потом попробовать скомпилировать один *.cpp файл. Решение: скомпилируйте проект целиком или как минимум файл stdafx.cpp.
  2. В настройках ни указано ни одного файла, из которого должен генерироваться *.pch файл. Речь идёт о ключе компиляции /Yc. Как правило, такая ситуация возникает у начинающих, которые захотели использовать precompiled headers для своего проекта. Как это сделать описано выше в разделе "Как использовать Precompiled Headers".

Fatal error C1010: unexpected end of file while looking for precompiled header. Did you forget to add '#include "stdafx.h"' to your source?


Сообщение говорит само за себя, если его прочитать. Файл компилируется с ключом /Yu. Это значит, что следует использовать precompiled header. Но в файл не включён "stdafx.h".


Нужно вписать в файл #include "stdafx.h".


Если это невозможно, то следует не использовать precompiled header для этого *.c/*.cpp файла. Уберите ключ /Yu.


Fatal error C1853: 'project.pch' precompiled header file is from a previous version of the compiler, or the precompiled header is C++ and you are using it from C (or vice versa)


В проекте присутствуют как C (*.c), так и C++ (*.cpp) файлы. Для них нельзя использовать единый precompiled header (*.pch файл).


Возможные решения:


  1. Отключить для всех Си-файлов использование precompiled headers. Как показывает практика, *.с файлы препроцессируются в несколько раз быстрее, чем *.cpp файлы. Если *.c файлов не очень много, то, отключив precompiled headers для них, вы ничего не потеряете;
  2. Завести два precompiled headers. Первый должен создаваться из stdafx_cpp.cpp, stdafx_cpp.h. Второй из stdafx_c.c, stdafx_c.h. Соответственно, в *.c и *.cpp файлах следует использовать разные precompiled headers. Имена *.pch файлов естественно тоже должны различаться.

Из-за precompiled header компилятор глючит


Скорее всего, что-то сделано не так. Например, #include "stdafx.h" расположен не в самом начале.


Рассмотрим пример:


int A = 10;
#include "stdafx.h"
int _tmain(int argc, _TCHAR* argv[]) {
  return A;
}

Этот код не скомпилируется. Компилятор выдаст на первый взгляд странное сообщение об ошибке:


error C2065: 'A' : undeclared identifier

Компилятор считает, что всё, что указано до строчки #include "stdafx.h" (включительно), является precompiled header. При компиляции файла компилятор заменит всё, что до #include "stdafx.h", на текст из *.pch файла. В результате теряется строчка "int A = 10".


Правильный вариант:


#include "stdafx.h"
int A = 10;
int _tmain(int argc, _TCHAR* argv[]) {
  return A;
}

Ещё пример:


#include "my.h"
#include "stdafx.h"

Содержимое файла "my.h" не будет использоваться. В результате, нельзя будет использовать функции, объявленные в этом файле. Такое поведение очень сбивает программистов с толку. Они "лечат" его полным отключением precompiled headers и потом рассказывают байки о глючности Visual C++. Запомните, компилятор — это один из наиболее редко глючащих инструментов. В 99.99% случаев надо не злиться на компилятор, а искать ошибку у себя (см. совет N11).


Чтобы таких ситуаций не было, ВСЕГДА пишите #include "stdafx.h" в самом начале файла. Комментарии перед #include "stdafx.h" можно оставить. Они всё равно никак не участвуют в компиляции.


Ещё один вариант — используйте Forced Included File. См. выше раздел "Life hack".


Из-за precompiled headers проект постоянно перекомпилируется целиком


В stdafx.h включён файл, который регулярно редактируется. Или случайно включён автогенерируемый файл.


Внимательно проверьте содержимое файла "stdafx.h". В него должны входить только заголовочные файлы, которые не изменяются или изменяются крайне редко. Учтите, что включённые файлы могут не меняться, но внутри они ссылаются на другие изменяющиеся *.h файлы.


Творится что-то непонятное


Иногда может возникнуть ситуация, что вы поправили код, а ошибка не исчезает. Отладчик показывает непонятные вещи.


Причиной может быть *.pch файл. Как-то так получилось, что компилятор не замечает изменения в одном из заголовочных файлов и не перестраивает *.pch файл. В результате подставляется старый код. Возможно, это происходило из-за каких-то сбоев, связанных с временем модификации файлов.


Это ОЧЕНЬ редкая ситуация. Но она возможна, и про неё надо знать. Я за многие годы программирования сталкивался с ней только 2-3 раза. Помогает полная перекомпиляция проекта.


Вредный совет N28. Массив на стеке — лучшее решение


И вообще, выделение памяти — зло. char c[256] хватит всем, а если не хватит, то потом поменяем на 512. В крайнем случае – на 1024.


Создание массива на стеке действительно имеет ряд преимуществ, по сравнению с выделением памяти в куче с помощью функции malloc или оператора new []:


  1. Выделение и освобождение памяти происходит моментально. В ассемблерном коде всё сводится к просто уменьшению/увеличению указателя стека.
  2. Не нужно заботиться об освобождении памяти. Она автоматически будет освобождена при выходе из функции.

Так чем же тогда плоха конструкция char c[1024]?


Причина N1. Магическое число 1024 намекает, что автор кода на самом деле точно не знает, сколько памяти понадобится для обработки данных. Он предполагает, что одного килобайта всегда будет достаточно. Так себе надёжность.


Если известно, что буфер определённого размера вместит все необходимые данные, то, скорее всего, мы увидим в коде именованную константу, например char path[MAX_PATH].


Подход с созданием "буфер с запасом" ненадёжен. Во-первых, есть вероятность, что "запаса" при определённом стечении обстоятельств не хватит, что приведёт к переполнению буфера. Во-вторых, такой код является потенциальной уязвимостью. Есть вероятность, что на вход программы можно подать такие данные, которые не просто переполнят буфер, но и изменят поведение программы нужным для злоумышленника образом. Это большая отдельная тема, поэтому просто оставлю несколько ссылок:


  1. Wikipedia. Переполнение буфера.
  2. OWASP. Buffer Overflow.
  3. Common Weakness Enumeration. CWE-120: Buffer Copy without Checking Size of Input ('Classic Buffer Overflow').
  4. "Computer Security: A Hands-on Approach" authored by Wenliang Du. Chapter 4 — Buffer Overflow Attack.

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


Настройки компилятора позволяют задавать, сколько стековой памяти доступно приложению. Например, в Visual C++ для этого используется ключ /F. Можно и здесь указать значение "с запасом". Но это вновь весьма ненадёжное решение, так как невозможно предсказать как много стековой памяти понадобится приложению, тем более если на стеке создаётся множество массивов.


Примечание. Что интересно, некоторые компиляторы для микроконтроллеров выдадут ошибку компиляции, если вычислят, что стека недостаточно для одной из веток выполнения программы. В некоторых контроллерах стек очень маленький, и такая проверка помогает выявить ошибку переполнения стека ещё на этапе компиляции. Компиляторы могут это сделать, потому что и сами программы очень маленькие и простые. В случае классических прикладных приложений невозможно на этапе компиляции вычислить, сколько максимально стека понадобится. Собственно, глубина использования стека может зависеть от входных данных, о которых компилятор ничего не знает.


Как лучше поступать?


Создание массива на стеке является ни хорошим, ни плохим решением. Плюсы: максимально быстрый способ выделения памяти, не требуется заботиться о её освобождении. Минус: риск переполнения стека, особенно если создавать буферы "с запасом". Как и многие другие возможности языка C++, создание массива на стеке — острый полезный нож, которым можно порезаться :).


Можно как-то улучшить ситуацию? Да, можно создавать на стеке массивы не фиксированного размера, а ровно того, который требуется. Их называют "Variable Length Arrays" (VLA).


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


В языке C создать на стеке массив произвольного размера крайне просто. Достаточно написать:


void foo(size_t n)
{
  float array[n];
  // ....
}

Эта возможно, начиная с C99. Подробнее см. раздел "Variable-length arrays" на сайте cppreference.com.


В C++ так не получится. В C++ нет возможности создавать variable-length массивы. Обсуждения на эту тему:


  1. Why aren't variable-length arrays part of the C++ standard?
  2. Legitimate Use of Variable Length Arrays.
  3. Array[n] vs Array[10] — Initializing array with variable vs numeric literal.
  4. Variable Length Arrays in C++14?

Впрочем, некоторые компиляторы, такие как GCC, на самом деле, это позволяют (Arrays of Variable Length). Но это расширение, на которое не стоит полагаться из-за непереносимости кода!


Так есть ли какой-то способ в C++ выделить на стеке буфер произвольного размера? Да, есть. Можно воспользоваться функцией alloca. Эту функцию можно использовать в C и C++ программах. Что удобно, память автоматически освобождается при выходе из функции.


void foo(size_t n)
{
  float *array = (float)alloca(sizeof(float) * n);
  // ....
}

Но опять есть нюанс. Эта функция не является частью стандарта C++. Как и в предыдущем случае, её использование не позволит написать переносимый C++ код. На самом деле, это вообще intrinsic function, вызов которой компилятор превращает в изменение указателя стека.


Возможно, вы начинаете подозревать некий "сговор разработчиков компиляторов", который мешает вам выделять на стеке буфер произвольного размера :). Видимо, что-то такое есть :). Дело в том, что такой код провоцирует возникновение в программах уязвимостей.


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


Дискуссия на эту тему: Why is the use of alloca() not considered good practice?


Причём выход за границы массива — это не единственный способ нарушения работы программы. Например, некий алгоритм может выделять на стеке буфер достаточного размера и корректно обрабатывать данные. Но при этом не учитывать размер этих данных. Подавая извне данные большого размера, можно спровоцировать исчерпание стека и аварийное завершение работы приложения. Это вариант DoS-атаки уровня приложения.


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


Кстати, в стандарте MISRA C есть правило MISRA-C-18.8, указывающее не использовать VLA. В анализаторе PVS-Studio реализована соответствующая диагностика: V2598 — Variable length array types are not allowed.


Другие стандарты, такие как SEI CERT C Coding Standard и Common Weakness Enumeration, не запрещают использовать эти массивы, но призывают проверять перед созданием их размер:


  • ARR32-C — Ensure size arguments for variable length arrays are in a valid range.
  • CWE-129: Improper Validation of Array Index.

Вредный совет N29. Ничего "лишнего"


Не пользуйтесь системой контроля версий. Храните исходники прямо на сервере в виртуалке.


Думаю, никто из настоящих программистов не придерживается этого совета. Те, кто так поступал, или не становятся программистами, или учатся на своих ошибках, потеряв исходные коды.


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


1053_60_cpp_antipatterns_ru/image20.png


Впрочем, встречаются отдельные заповедники динозавров. Мне рассказывал один человек, что, будучи студентом, он попал на практику в государственную организацию. Хотя на дворе был уже где-то 2010 год, они хранили различные версии исходных кодов, просто запаковав их в архив. На недоумение, что так неудобно понять, что, когда, кем и зачем менялось в коде, они подвели его к огромному журналу, где ручкой каждый записывал, что делал.


Вредный совет N30. Необычные конструкции


Все знают, что операторы обращения по индексу к указателю обладают коммутативностью. Так не будьте серыми, как все. Придайте коду оригинальность, используя конструкции вида 1[array] = 0.


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


Наверное, все проходят через стадию, когда хочется максимально применить полученные знания. Кто-то читает книги про паттерны и начинает их использовать, где надо и не надо, перегружая код бессмысленными классами, фабриками т. д. Кто-то вдохновляется книгами типа "Современное проектирование на C++. Обобщённое программирование и прикладные шаблоны проектирования". И затем невозможно понять, как работает вся эта шаблонная магия, и главное — зачем она вообще нужна.


Полезно всё это изучить, переболеть и начать писать простой понятный код.


Новичок напишет обратный цикл так:


for (int i = n - 1; i >= 0; i--)

// Или так:
int i = n;
while (--i >= 0)

Программист, подверженный звёздной болезни, напишет что-то типа этого:


int i = n;
while (i-->0)

Прикольно, красиво, но не нужно. Такая конструкция заставляет задуматься или даже начать подозревать, а не придумали ли в C++ новый оператор "-->" :).


На самом деле, это обыкновенный постфиксный декремент и оператор больше ">". Переменная i будет, как и в предыдущих примерах, принимать значения в диапазоне [n — 1… 0]. Немного подумайте и поймёте, как это работает.


Разобрались? Думаю, да. А теперь вопрос, а стоило ли оно того? Какой смысл был разбираться в этом хитром коде, который на самом деле выполняет очень простую вещь? Никакого.


Поэтому профессиональный программист напишет такой же код, что и новичок:


for (int i = n - 1; i >= 0; i--)

//Или так:
int i = n;
while (--i >= 0)

Важна простота и понятность. Тем более что скорость работы этого кода одинакова.


Примечание. Впрочем, есть ещё один нормальный вариант, как можно написать:


for (int i = n - 1; i >= 0; --i)

Я бы именно так и написал. В данном случае префиксный декремент ничего не меняет, но его использование вместо постфиксного имеет смысл для итераторов. Я пишу префиксный декремент везде для единообразия. Есть ли в этом смысл? Да. См. публикации:



Об этой мини-книге


Автор: Карпов Андрей Николаевич. E-Mail: karpov [@] viva64.com.


Более 15 лет занимается темой статического анализа кода и качества программного обеспечения. Автор большого количества статей, посвящённых написанию качественного кода на языке C++. С 2011 по 2021 год удостаивался награды Microsoft MVP в номинации Developer Technologies. Один из основателей проекта PVS-Studio. Долгое время являлся CTO компании и занимался разработкой С++ ядра анализатора. Основная деятельность на данный момент — управление командами, обучение сотрудников и DevRel активность.


Ссылки на полный текст:



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

Комментарии (9)


  1. eao197
    16.06.2023 07:59

    Совет N28: очень уж хотелось до чего-нибудь доколупаться, так почему бы и не доколупаться до массивов на стеке. Но почему остановились на полдороги? Нужно еще объявить вредным использование std::array, особенно в качестве членов структур-классов. Вот как увидишь такое в коде:

    struct some_packet {
      std::array<unsigned char, 24> header_;
      ...
    };
    

    так смело объявляй код говном, не стесняйся!

    Совет N29: да, да, это вот прямо про C++! Исключительно про C++.

    Ребят, можно понять, что у вас большой опыт копания в различного рода сортах известной субстанции, но набрасывать нужно таки уметь, чтобы это было и смешно, и полезно. У вас же как-то слишком уж часто и не смешно, и не полезно;


    1. unreal_undead2
      16.06.2023 07:59

      Да, тоже 28-ой зацепил - понятно, что для всяческих входных строк char[точно_хватит] не очень, но если нужно действительно небольшое фиксированное число элементов, определямое алгоритмом (скажем, понадобилось FFT на 16 даблов), то VLA - какой-то overkill.


      1. vladimirgamalian
        16.06.2023 07:59
        +2

        Наверное подразумевается, что тогда вместо магического числа будет константа, говорящая о том, что тут все расчитано, как в примере про char path[MAX_PATH].


    1. domix32
      16.06.2023 07:59

      Так наоборот народ топит ЗА использование std::array вместо обычных. Его вроде и переполнить не дадут.


      1. eao197
        16.06.2023 07:59

        Так наоборот народ топит

        Автор статьи не абстрактный народ, а ув.тов. Карпов, и в совете №28 упоминания std::array я не увидел :(
        Отличия std::array<char, 10> от char[10] есть, но не так, чтобы уж сильно много с точки зрения опасности переполнения.


        1. domix32
          16.06.2023 07:59

          В случае char[10] если память не травили санитайзером есть шанс поломать стэк, в std::array вроде как имеются bound checks которые гарантируют падение при обращении по индексу за пределами. Если намеренно не баловаться с сырыми указателями - переполнения случится не должно.


          1. eao197
            16.06.2023 07:59
            +2

            в std::array вроде как имеются bound checks которые гарантируют падение при обращении по индексу за пределами

            И давно operator[] для std::array начал делать проверки в release?

            Если намеренно не баловаться с сырыми указателями - переполнения случится не должно.

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


  1. sv_911
    16.06.2023 07:59
    +1

    Кстати, precompiled headers существуют не только для msvc. Для линукса (gcc или clang или оба, не помню уже) они тоже вполне себе работают


  1. grumegargler
    16.06.2023 07:59
    +1

    Кажется, что пример для совета N30 выбран не совсем удачно, ведь что новичок, что бывалый, на мой взгляд, напишут одинаково так: while ( i-- )