Большой брат помогает тебе

Андрей Карпов
Статей: 375



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

Читайте далее байтораздирающую историю про класс CString и дочь его, функцию Format.

Молитесь, молитесь на компиляторы и их разработчиков. Они столько сил прилагают, чтобы наши программы работали, несмотря на многие недостатки и даже ошибки. Причем эта их работа трудна и не видна. Они - благородные рыцари кодирования и ангелы-покровители для всех нас.

Я знал, что в Microsoft существует отдел, который занимается вопросами обеспечения максимальной совместимости новых версий операционных систем со старыми приложениями. В их базе более 10000 наиболее известных старых программ, которые должны обязательно работать в новых версиях Windows. Именно благодаря таким усилиям я недавно смог без проблем поиграть в Heroes of Might and Magic II (игра 1996 года) под управлением 64-битной Windows Vista. Думаю, игра успешно запустится и в Windows 7. Вот интересные заметки Алексея Пахунова на тему совместимости [1, 2, 3], очень рекомендую почитать.

Но видимо существуют еще и отделы, которые занимаются тем, чтобы помочь нашему ужасному коду на Си/Си++ работать, работать и работать. Начну эту историю с самого начала.

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

Множество ошибок связано с использованием в программах эллипсисов. Теоретическая справка:

Существуют функции, в описании которых невозможно указать число и типы всех допустимых параметров. Тогда список формальных параметров завершается эллипсисом (...), что означает: "и, возможно, еще несколько аргументов". Например: int printf(const char* ...);

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

wchar_t buf[100];
std::wstring ws(L"12345");
swprintf(buf, L"%s", ws);

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

Корректный вариант кода должен выглядеть так:

wchar_t buf[100];
std::wstring ws(L"12345");
swprintf(buf, L"%s", ws.c_str());

Именно из-за того, что в функции с переменным количеством аргументов можно передать все что угодно их и не рекомендуют использовать практически во всех книгах по программированию на языке Си++. Вместо этого предлагается использовать безопасные механизмы, например, boost::format. Однако рекомендации рекомендациями, а кода с разными printf, sprintf, CString::Format огромное количество и мы с ним будем жить еще очень долго. Именно поэтому мы и реализовали диагностическое правило, выявляющее подобные опасные конструкции.

Давайте разберемся теоретически, в чем неверен приведенный выше код. Оказывается он некорректен дважды.

  • Несоответствие аргумента заданному формату. Раз мы указываем "%s", то и передать должны указатель на строку. Однако теоретически мы можем написать свою функцию sprintf, которая будет знать, что ей передан объект класса std::wstring и корректно распечатает его. Однако и это невозможно в силу причины номер 2.
  • Аргументом для эллипсиса "..." может быть только POD-тип. А std::string POD типом не является.

Теоретическая справка про POD типы:

POD это аббревиатура от "Plain Old Data", что можно перевести как "Простые данные в стиле Си". К POD-типам относятся:

  • все встроенные арифметические типы (включая wchar_t и bool);
  • типы, объявленные с помощью ключевого слова enum;
  • указатели;
  • POD-структуры (struct или class) и POD-объединения (union), которые удовлетворяют нижеприведенным требованиям:
    • не содержат пользовательских конструкторов, деструктора или копирующего оператора присваивания;
    • не имеют базовых классов;
    • не содержат виртуальных функций;
    • не содержат защищенных (protected) или закрытых (private) нестатических членов данных;
    • не содержат нестатических членов данных не-POD-типов (или массивов из таких типов), а также ссылок.

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

При этом если вы передаете в эллипсис объект, не являющимся POD типом, то это приводит к неопределенному поведению. Таким образом, по крайней мере, теоретически, мы никак не можем корректно передать объект типа std::wstring в качестве эллипсис аргумента.

Та же самая картина у нас должна наблюдаться и с функций Format из класса CString. Некорректный вариант код:

CString s;
CString arg(L"OK");
s.Format(L"Test CString: %s\n", arg);

Корректный вариант кода:

s.Format(L"Test CString: %s\n", arg.GetString());

Или как предлагается в MSDN [4] для получения указателя на строку можно использовать явный оператор приведения LPCTSTR, реализованный в классе CString. Пример корректного кода из MSDN:

CString kindOfFruit = "bananas";
int howmany = 25;
printf("You have %d %s\n", howmany, (LPCTSTR)kindOfFruit); 

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

Это и было и сделано. И вот здесь я был шокирован результатом. Оказывается большинство разработчиков вообще никогда не задумываются над этими проблемами и спокойно пишут код вида:

class CRuleDesc
{
  CString GetProtocol();
  CString GetSrcIp();
  CString GetDestIp();
  CString GetSrcPort();
  CString GetIpDesc(CString strIp);
...

CString CRuleDesc::GetRuleDesc()
{
  CString strDesc;
  strDesc.Format(
    _T("%s all network traffic from <br>%s "
       "on %s<br>to %s on %s <br>for the %s"),
    GetAction(), GetSrcIp(), GetSrcPort(),
    GetDestIp(), GetDestPort(), GetProtocol());
  return strDesc;
}
//---------------

CString strText;
CString _strProcName(L"");
...
strText.Format(_T("%s"), _strProcName);

//---------------

CString m_strDriverDosName;
CString m_strDriverName;
...
m_strDriverDosName.Format(
  _T("\\\\.\\%s"), m_strDriverName);

//---------------

CString __stdcall GetResString(UINT dwStringID);
...
_stprintf(acBuf, _T("%s"),
  GetResString(IDS_SV_SERVERINFO));

//---------------

// Думаю понятно,
// что примеры можно приводить и приводить.

А некоторые и задумываются, но забываются. И поэтому так трогательно смотрится код следующего вида:

CString sAddr;
CString m_sName;
CString sTo = GetNick( hContact );

sAddr.Format(_T("\\\\%s\\mailslot\\%s"),
  sTo, (LPCTSTR)m_sName);

И таких примеров в проектах, на которых мы тестируем PVS-Studio, оказалась столько, что стало не понятно, как это вообще может быть. А, тем не менее, это все замечательно работает, в чем я смог убедиться, написав тестовую программу и попробовав различные варианты использования CString.

В чем же дело? Видимо разработчики компиляторов не выдержали бесконечных вопросов почему программы индусов, использующие CString не работают и обвинений в "глючности компилятора, который неверно работает со строками". И они тихо совершили священный ритуал экзорцизма, изгнав зло из CString. Они сделали невозможное возможным. А именно класс CString реализован специальным хитрым образом, так, чтобы его можно было передавать в функции вида printf, Format.

Сделано это достаточно хитро и кто интересуется, то может почитать исходный код класса CStringT. Я вдаваться в подробности не буду. Отмечу только важный момент. Специальная реализация CString не достаточна, теоретически передача не POD-типа приводит к непредсказуемому поведению. Так вот разработчики Visual C++, а вместе с ними и Intel C++ сделали так, что непредсказуемое поведение представляет из себя всегда корректный результат. :) Ведь правильная работа программы вполне себе подмножество непредсказуемого поведения. :)

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

size_t n = BigValue;
for (unsigned i = 0; i < n; i++) { ... }

Теоретически, если значение n > UINT_MAX больше, то должен возникнуть бесконечный цикл. Однако в Release версии он не возникает, так как для переменной "i" используется 64-битный регистр. Конечно, если код будет посложнее, то бесконечный цикл возникнет, но хотя бы в ряде случаев программе повезет. Подробнее я писал про это в статье "64-битный конь, который умеет считать" [6].

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

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

Желаю вам безглючного кода!

Библиографический список



Найдите ошибки в своем C, C++, C# и Java коде

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

goto PVS-Studio;

Андрей Карпов
Статей: 375


Найденные ошибки

Проверено проектов
361
Собрано ошибок
13 428

А ты совершаешь ошибки в коде?

Проверь с помощью
PVS-Studio

Статический анализ
кода для C, C++, C#
и Java

goto PVS-Studio;