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

Я буду выкладывать сразу по 5 советов, чтобы не утомлять вас — в книге много ссылок на другие интересные статьи, видео и т. д. Однако, если вам не терпится, сразу перейти к полной версии книги: 60 ужасные советы для разработчика C++». В любом случае, приятного чтения!

Ужасный совет N26. я сделаю это сам

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

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

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

В статье о проверке 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] Функция memcpy не копирует всю строку. Используйте функцию «strcpy / strcpy_s», чтобы сохранить терминальный нуль. оболочка.c 427

Анализатор определяет, что функция memcpy копирует строку, но не копирует terminal null, и это странно. Похоже, сюда скопирован терминальный нуль:

((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 и статический анализ кода: это безопаснее, чем memcpy?

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

  • V104 Неявное преобразование i в тип memsize в арифметическом выражении: i ‹ n test.cpp 26
  • V108 Неверный тип индекса: cdest[не memsize-тип]. Вместо этого используйте тип memsize. test.cpp 27
  • V108 Неверный тип индекса: csrc[не memsize-тип]. Вместо этого используйте тип memsize. test.cpp 27

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

При большем размере копируемого буфера произойдет целочисленное переполнение, которое C и C++ интерпретируют как поведение undefined. Не пытайтесь угадать, как проявит себя ошибка. Это сложная тема. Подробнее об этом можно прочитать в статье: «Неопределенное поведение ближе, чем вы думаете».

Особенно забавно, что этот код является результатом попытки избавиться от предупреждения Checkmarx, выдаваемого при вызове функции memcpy. Разработчики решили изобрести собственное колесо, которое, несмотря на простоту функции копирования, оказалось неправильным. Значит, кто-то сделал еще хуже, чем раньше. Вместо того, чтобы разобраться с причиной выдачи предупреждения, они скрыли проблему, написав собственную функцию — и запутали анализатор. Кроме того, они добавили ошибку, используя int в качестве счетчика. Да, кстати, такой код может нарушить оптимизацию. Неэффективно использовать собственную функцию вместо оптимизированной memcpy. Не делай этого :).

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

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

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

Зачем нужны предварительно скомпилированные заголовки

Предварительно скомпилированные заголовки предназначены для ускорения сборки проекта. Обычно разработчики знакомятся с Visual C++, используя небольшие проекты. Эти проекты вряд ли показывают какую-либо пользу от предварительно скомпилированных заголовков. С ними и без них время компиляции программы на глаз одинаковое. Это смущает. Человек не видит никакой пользы от этого механизма и решает, что он только для конкретных задач и никогда не понадобится. И никогда ими не пользуется.

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

Если вы посмотрите на файлы *.cpp в проекте, то заметите, что многие из них содержат одни и те же наборы заголовочных файлов. Например, ‹вектор›, ‹строка›, ‹алгоритм›. Эти заголовочные файлы, в свою очередь, включают в себя другие заголовочные файлы и так далее.

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

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

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

Как работают предварительно скомпилированные заголовки

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

Файл *.pch появляется после компиляции stdafx.cpp. Файл создается с ключом «/Yc». Этот ключ указывает компилятору создать предварительно скомпилированный заголовок. Файл 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» необходимы для избавления от предупреждений, выдаваемых на стандартных библиотеках, если в настройках компилятора включен высокий уровень предупреждений. Это фрагмент кода из старого проекта. Возможно, теперь все эти прагмы не нужны. Я просто хотел показать вам, как подавить избыточные предупреждения, если таковые возникают.

Теперь «stdafx.h» должен быть включен во все файлы *.c/*.cpp. При этом нужно удалить уже включенные заголовки с «stdafx.h».

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

  • Файл А: ‹вектор›, ‹строка›
  • Файл B: ‹вектор›, ‹алгоритм›
  • Файл C: ‹строка›, ‹алгоритм›

Должны ли мы создавать отдельные предварительно скомпилированные заголовки? Вы можете это сделать, но это не обязательно.

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

Как использовать предварительно скомпилированные заголовки

Когда вы создаете новый проект, мастер Visual Studio создает два файла: stdafx.h и stdafx.cpp. С их помощью реализован механизм предкомпилированных заголовков.

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

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

Итак, если вы используете мастер, у вас уже есть stdafx.h и stdafx.cpp. Кроме того, вы устанавливаете все необходимые ключи компиляции.

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

  1. Включите использование предварительно скомпилированных заголовков во всех конфигурациях для всех файлов *.c/*.cpp. Вы можете сделать это во вкладке «Предварительно скомпилированный заголовок»:
  • Установите значение «Использовать (/Yu)» для параметра «Предварительно скомпилированный заголовок».
  • Установите «stdafx.h» для параметра «Предварительно скомпилированный файл заголовка».
  • Установите «$(IntDir)$(TargetName).pch» для параметра «Выходной файл предварительно скомпилированного заголовка».

2. Создайте файл stdafx.h и добавьте его в проект. В дальнейшем мы добавим в него те заголовочные файлы, которые мы хотим предварительно обработать.

3. Создайте файл stdafx.cpp и добавьте его в проект. В нем всего одна строка: #include «stdafx.h».

4. Во всех конфигурациях измените настройки файла stdafx.cpp. Установите значение «Создать (/Yc)» для параметра «Предварительно скомпилированный заголовок».

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

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

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

Если подумать, в этом есть логика. Когда «stdafx.h» находится в самом начале, вы можете заменить уже предварительно обработанный текст. Текст всегда один и тот же и ни от чего не зависит.

Представьте, если бы мы могли включить другой файл перед «stdafx.h». И напишите в этом файле #define bool char. Возникает двусмысленность. Изменяем содержимое всех файлов, в которых встречается «bool». Теперь вы не можете заменить предварительно обработанный текст. Весь механизм «предварительно скомпилированных заголовков» ломается. Я думаю, что это одна из причин, по которой «stdafx.h» должен располагаться в начале. Возможно, есть и другие.

Ухищрение!

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

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

Есть вариант, как легко и просто использовать предварительно скомпилированные заголовки. Этот вариант не универсальный, но мне часто помогал.

Вы можете не писать #include «stdafx.h» во всех файлах и использовать механизм «Forced Included File».

Откройте настройки, перейдите на вкладку «Дополнительно». Выберите все конфигурации. В «Forced Included File» напишите:

StdAfx.h;%(ForcedIncludeFiles)

Теперь «stdafx.h» будет автоматически включаться в начало ВСЕХ скомпилированных файлов. ВЫГОДА!

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

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

Это решающий момент. Если вы бездумно включите все в «stdafx.h», компиляция просто замедлится.

Все файлы, включающие «stdafx.h», зависят от его содержимого. Допустим, файл «X.h» включен в «stdafx.h». Если вы измените что-либо в «X.h», это может привести к полной перекомпиляции проекта.

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

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

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

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

Несколько предварительно скомпилированных заголовков

Зачем вам нужно несколько предварительно скомпилированных заголовков в одном проекте? Это случается нечасто, но позвольте мне показать вам пару примеров.

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

Вам нужно создать два файла *.pch. Один является результатом компиляции файла C (xx.c), а другой — результатом компиляции файла C++ (yy.cpp). Поэтому нужно указать в настройках, что один предкомпилированный заголовок используется в файлах C, а другой — в файлах C++.

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

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

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

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

Типичные ошибки с предварительно скомпилированными заголовками

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

Неустранимая ошибка C1083: не удается открыть предварительно скомпилированный заголовочный файл: «Debug\project.pch»: нет такого файла или каталога

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

  • Файл stdafx.cpp не был скомпилирован, и, как следствие, файл *.pch еще не создан. Такое бывает, если сначала почистить проект (Clean Solution), а потом попытаться скомпилировать один *.cpp файл. Решение: скомпилируйте весь проект или хотя бы stdafx.cpp. файл.
  • В настройках не указан файл, из которого должен быть сгенерирован файл *.pch. Это относится к ключу компиляции /Yc. Обычно новички попадают в такую ​​ситуацию, когда хотят использовать прекомпилированные заголовки для своего проекта. См. раздел «Как использовать предварительно скомпилированные заголовки», чтобы узнать, как с этим справиться.

Неустранимая ошибка C1010: неожиданный конец файла при поиске предварительно скомпилированного заголовка. Вы забыли добавить «#include «stdafx.h»» в исходный код?

Сообщение очень ясное. Файл компилируется с ключом /Yu. Это означает, что вы должны использовать предварительно скомпилированные заголовки. Однако файл «stdafx.h» не включен в файл.

Вам нужно написать #include «stdafx.h» в файл.

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

Неустранимая ошибка C1853: предварительно скомпилированный заголовочный файл "project.pch" относится к предыдущей версии компилятора или предварительно скомпилированный заголовок относится к C++, и вы используете его из C (или наоборот)

Проект содержит файлы C (*.c) и C++ (*.cpp). Вы не можете использовать файл *.pch для них обоих.

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

  • Отключите использование предварительно скомпилированных заголовков для всех файлов C. Опыт показывает, что файлы *.c препроцессируются в несколько раз быстрее, чем файлы *.cpp. Если файлов *.c не так много, то отключив для них предкомпилированные заголовки, вы ничего не потеряете;
  • Создайте два предварительно скомпилированных заголовка. Первый должен быть создан из stdafx_cpp.cpp, stdafx_cpp.h. Второй — из stdafx_c.c, stdafx_c.h. Поэтому в файлах *.c и *.cpp должны использоваться разные предварительно скомпилированные заголовки. Разумеется, имена файлов *.pch также должны различаться.

Компилятор содержит ошибки из-за предварительно скомпилированного заголовка

Скорее всего, что-то было сделано не так. Например, #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» (включая его), является предварительно скомпилированным заголовком. При компиляции файла компилятор заменит все до #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» не будет использоваться. В результате вы не можете использовать функции, объявленные в этом файле. Такое поведение очень сбивает с толку программистов. Они «лечат» это тем, что полностью отключают прекомпилированные заголовки, а потом рассказывают истории о глюках в Visual C++. Помните, что компилятор — это один из инструментов, в котором редко бывают ошибки. В 99,99% случаев нужно не злиться на компилятор, а искать ошибку самому (см. страшный совет N11).

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

Другой вариант — использовать Forced Included File. Смотрите «Лайфхак!» раздел выше.

Из-за предварительно скомпилированных заголовков проект постоянно перекомпилируется

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

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

Происходит что-то странное

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

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

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

Ужасный совет N28. Массив в стеке — лучшая вещь на свете

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

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

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

Итак, что не так с конструкцией char c[1024]?

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

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

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

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

Настройки компилятора позволяют указать, сколько памяти стека доступно приложению. Например, в Visual Studio для этого используется ключ /F. Здесь можно указать резерв. Однако это решение по-прежнему ненадежно — вы не можете предсказать, сколько стековой памяти потребуется приложению, особенно если в стеке создается много массивов.

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

Как добиться наилучшего результата?

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

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

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

В языке C мы можем легко создать в стеке массив произвольного размера. Просто пиши:

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

Это возможно начиная с C99. См. раздел «Массивы переменной длины» на сайте cppreference.com.

В C++ вы не можете этого сделать. В нем нет массивов переменной длины. Вот некоторые связанные обсуждения:

На самом деле, некоторые компиляторы (например, GCC) это позволяют (Массивы переменной длины). Однако это расширение, на него лучше не полагаться, потому что код нельзя перенести!

Итак, есть ли способ выделить в стеке буфер произвольного размера в C++? Да, есть. Вы можете использовать функцию alloca. Вы можете использовать его как в программах C, так и в C++. Удобно, что память автоматически освобождается при выходе из функции.

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

Однако есть и плохие новости. Функция не является частью стандарта C++. Как и в предыдущем случае, эта функция делает код непригодным для миграции. По сути, это внутренняя функция — компилятор превращает вызов этой функции в изменение указателя стека.

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

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

Вот обсуждение: Почему использование alloca() не считается хорошей практикой?

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

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

Кстати, в стандарте MISRA есть правило (MISRA-C-18.8), запрещающее использование VLA. В анализаторе PVS-Studio есть соответствующее диагностическое правило: V2598 — Запрещены типы массивов переменной длины.

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

  • ARR32-C — Убедитесь, что аргументы размера для массивов переменной длины находятся в допустимом диапазоне.
  • CWE-129: неправильная проверка индекса массива.

Ужасный совет N29. Ничего лишнего

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

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

Обычно такое бывает со студентами в вузах — вдруг флешка со всей вашей работой (программой или курсовой) не читается. И если повезет, останется только старая копия, иначе — ничего нет :).

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

Ужасный совет N30. Необычные конструкции

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

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

Я считаю, что каждый проходит через этап, когда вы хотите использовать все полученные знания. Кто-то читает книги о паттернах и начинает их повсеместно использовать, перегружая код бессмысленными классами, фабриками и т. д. Кто-то вдохновляется «Современным дизайном C++. Применение общего шаблона программирования и проектирования». Тогда становится невозможно понять, как работает эта шаблонная магия, а главное — зачем она здесь используется.

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

Новички написали бы обратный цикл следующим образом:

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

// Or this:
int i = n;
while (--i >= 0)

Разработчики с огромным эго написали бы что-то вроде этого:

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

Красиво, круто, но бесполезно. Такая конструкция заставляет задуматься или заподозрить, не был ли изобретен новый оператор «→» :).

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

Вы поняли это? Я думаю да. Теперь вопрос: стоило ли оно того? Какой смысл разбираться в таком хитром коде, если он выполняет простую вещь? Никто.

Итак, опытный разработчик напишет то же, что и новичок:

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

// Or:
int i = n;
while (--i >= 0)

Простота и ясность — вот что важно. Особенно, если код работает с той же скоростью.

Примечание. Есть еще один способ написать этот код:

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

Я бы именно так и написал. В этом случае декремент префикса ничего не меняет. Однако этот декремент (вместо постфиксного) имеет смысл для итераторов. Я везде пишу префикс декремента — это делает код согласованным. Имеет ли это смысл? Да. Смотрите эти сообщения:

Автор: Андрей Карпов. Электронная почта: karpov[@] viva64.com.

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

Полный текст по ссылке: 60 ужасных советов C++-разработчику.

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