Иногда единственным методом отладки является использование протоколирования событий приложения. К недостаткам протоколирования (логирования) можно отнести большой объем кода, который приходится писать вручную для сохранения всей необходимой информации. В статье рассматривается методика, позволяющая построить систему автоматического протоколирования кода на языке Си/Си++.
Несмотря на время, языки Си и Си++ продолжают занимать лидирующие позиции во многих областях программирования. И в ближайшие 10 лет вряд ли что-то существенно изменится. Появилось много красивых и интересных языков, однако на практике часто предпочтение все-таки отдается Си и Си++. Это одни из лучших существующих языков, универсальные, высокоэффективные и с широкой поддержкой. Если вы хотите лучше понять, чем же привлекательны эти языки, то я предлагаю вам познакомиться c интересной подборкой сообщений из форума comp.lang.c++.moderated "Why do you program in c++?". Все это значит, что разработка инструментария для языков Си/Си++ по-прежнему актуальна.
Во всех языках разработчики допускают ошибки, и языки Си/Си++ здесь не исключение. Более того, как и многие профессиональные инструменты, они требуют большего внимания и аккуратности при их использовании. В результате одной из важнейших задач при разработке приложений является отладка кода, то есть поиск и устранение ошибок.
Способы отладки приложений можно разделить на следующие основные группы:
У каждого из перечисленных методов есть свои достоинства и недостатки. Но в рамках нашей статьи мы поговорим о протоколировании (логировании) и методах его автоматизации.
На первый взгляд протоколирование работы приложений может показаться неактуальным. Возможно, это атавизм того времени, когда еще было принято выводить результаты работы программы сразу на принтер? Нет, это очень эффективная и часто незаменимая методика, позволяющая отлаживать сложные, параллельные или специфические приложения.
Рассмотрим области, где протоколирование является незаменимым по удобству и эффективности решением:
Основательный список, чтобы не списывать метод протоколирования на пенсию. Хочется надеяться, что статья натолкнет и на другие мысли, как можно с пользой использовать описываемую методологию протоколирования.
Начнем с требований, которыми должна обладать современная система протоколирования:
Система протоколирования, отвечающая таким качествам, позволяет универсально решать задачи, начиная от разработки механизмов защиты до поиска ошибок в параллельных алгоритмах [1].
Данная статья, хотя и посвящена системе протоколирования данных, в ней не будет рассматриваться какой-то завершенный вариант такой системы. Универсальный вариант невозможен, так как он сильно зависит от среды разработки, особенностей проекта, предпочтений разработчика и многого другого. Кстати, отметим, что существует ряд статей, в которых рассматриваются эти вопросы. Например, "Logging In C++" [2].
Сейчас мы остановимся на рассмотрении ряда технических решений, которые помогут вам создать удобную и эффективную систему протоколирования, если в том возникнет необходимость.
Самым простым способом осуществить протоколирование является использование функции, аналогичной printf, как показано в примере:
int x = 5, y = 10;
...
printf("Coordinate = (%d, %d)\n", x, y);
Естественным недостатком является то, что информация будет выводиться как в отладочном режиме, так и в конечном продукте. Поэтому, следует модернизировать код следующим образом:
#ifdef DEBUG_MODE
#define WriteLog printf
#else
#define WriteLog(a)
#endif
WriteLog("Coordinate = (%d, %d)\n", x, y);
Это уже лучше. Причем обратите внимание, что мы используем для выбора реализации функции WriteLog не стандартный макрос _DEBUG, а собственный макрос DEBUG_MODE. Это позволяет включать отладочную информацию в Release-версии, что важно при отладке на большом объеме данных.
К сожалению, теперь при компиляции не отладочной версии, например, в среде Visual C++ возникает предупреждение: "warning C4002: too many actual parameters for macro 'WriteLog'". Можно отключить это предупреждение, но это является плохим стилем. Можно переписать код, как показано ниже:
#ifdef DEBUG_MODE
#define WriteLog(a) printf a
#else
#define WriteLog(a)
#endif
WriteLog(("Coordinate = (%d, %d)\n", x, y));
Приведенный код не является элегантным, так как приходится использовать двойные пары скобок, что часто забывается. Поэтому внесем новое усовершенствование:
#ifdef DEBUG_MODE
#define WriteLog printf
#else
inline int StubElepsisFunctionForLog(...) { return 0; }
static class StubClassForLog {
public:
inline void operator =(size_t) {}
private:
inline StubClassForLog &operator =(const StubClassForLog &)
{ return *this; }
} StubForLogObject;
#define WriteLog \
StubForLogObject = sizeof StubElepsisFunctionForLog
#endif
WriteLog("Coordinate = (%d, %d)\n", x, y);
Этот код выглядит сложным, но он позволяет писать одинарные скобки. При выключенном DEBUG_MODE этот код превращается в ничто, и его можно смело использовать в критических участках кода.
Следующим усовершенствованием может стать добавление к функции протоколирования таких параметров как уровень детализации и тип выводимой информации. Уровень детализации можно задать как параметр, например:
enum E_LogVerbose {
Main,
Full
};
#ifdef DEBUG_MODE
void WriteLog(E_LogVerbose,
const char *strFormat, ...)
{
...
}
#else
...
#endif
WriteLog (Full, "Coordinate = (%d, %d)\n", x, y);
Этот способ удобен тем, что решение, отфильтровать или не отфильтровать маловажные сообщения можно принять уже после завершения работы программы, используя специальную утилиту. Недостаток такого метода в том, что всегда происходит вывод всей информации, как важной, так и второстепенной, что может снижать производительность. Поэтому можно создать несколько функций вида WriteLogMain, WriteLogFull и подобные им, реализация которых будет зависеть от режима сборки программы.
Мы упоминали о том, что запись отладочной информации должна как можно меньше влиять на скорость работы алгоритма. Этого можно достичь, создав систему накопления сообщений, запись которых происходит в параллельно выполняемом потоке. Сейчас это получило дополнительные преимущества в связи с широким распространением многоядерных (многопроцессорных) систем. Схематично этот механизм представлен на рисунке N1.
Рисунок N1. Система протоколирования с отложенной записью данных.
Как можно видеть на рисунке, запись очередной порции данных происходит в промежуточный массив строк фиксированной длины. Фиксированный размер массива и строк в нем позволяет исключить дорогостоящие операции выделения памяти. Это нисколько не снижает возможности такой системы. Достаточно выбрать длину строк и размер массива с запасом. Например, 5000 строк длиной в 4000 символов будет достаточно для отладки практически любой системы. А объем памяти в 20 мегабайт необходимый для этого, согласитесь, не критичен для современных систем. Если же массив все равно будет переполнен, то несложно предусмотреть механизм досрочной записи информации в файл.
Приведенный механизм обеспечивает практически моментальное выполнение функции WriteLog. Если в системе присутствуют ненагруженные процессорные ядра, то и запись в файл будет прозрачна для основных потоков протоколируемой программы.
Преимущество описываемой системы в том, что она практически без изменений способна функционировать при отладке параллельной программы, когда в лог пишут сразу несколько потоков. Следует только добавить сохранение идентификатора потока (thread), чтобы потом можно было узнать, от каких потоков были получены сообщения (смотри рисунок N2).
Рисунок N2. Система протоколирования при отладке многопоточных приложений.
Такая схема может привести к изменению временных характеристик, так как несколько протоколируемых потоков вынуждены ждать друг друга, чтобы осуществить запись информации. Если это является критичным, то можно создать отдельные хранилища сообщений для каждого из потоков, как показано на рисунке N3. Дополнительно, в этом случае следует записывать время событий, чтобы было возможно слить два лога в один.
Рисунок N3. Улучшенная система протоколирования при отладке многопоточных приложений.
Последнее усовершенствование, которое хочется предложить, это организация показа уровня вложенности сообщений при вызове функций или начале логического блока. Это можно легко организовать, используя специальный класс, который в конструкторе записывает в лог идентификатор начала блока, а в деструкторе - идентификатор конца блока. Написав небольшую утилиту, можно трансформировать лог, опираясь на информацию об идентификаторах. Попробуем показать это на примере.
Код программы:
class NewLevel {
public:
NewLevel() { WriteLog("__BEGIN_LEVEL__\n"); }
~NewLevel() { WriteLog("__END_LEVEL__\n"); }
};
#define NEW_LEVEL NewLevel tempLevelObject;
void MyFoo() {
WriteLog("Begin MyFoo()\n");
NEW_LEVEL;
int x = 5, y = 10;
printf("Coordinate = (%d, %d)\n", x, y);
WriteLog("Begin Loop:\n");
for (unsigned i = 0; i != 3; ++i)
{
NEW_LEVEL;
WriteLog("i=%u\n", i);
}
}
Содержимое лога:
Begin MyFoo()
__BEGIN_LEVEL__
Coordinate = (5, 10)
Begin Loop:
__BEGIN_LEVEL__
i=0
__END_LEVEL__
__BEGIN_LEVEL__
i=1
__END_LEVEL__
__BEGIN_LEVEL__
i=2
__END_LEVEL__
Coordinate = (5, 10)
__END_LEVEL__
Лог после трансформации:
Begin MyFoo()
Coordinate = (5, 10)
Begin Loop:
i=0
i=1
i=2
Coordinate = (5, 10)
Мы рассмотрели принципы, на которых может быть реализована система протоколирования. В такой системе можно реализовать все требования, описанные в первой части статьи. Но серьезным недостатком в применении такой системы становится необходимость написания большого количества кода для записи всех необходимых данных. Есть и другие недостатки:
Все эти и ряд других неудобств можно избежать, если построить систему автоматического протоколирования испытаний работы приложений.
Реализовать такую систему возможно на основе методологии метапрограммирования, введя в язык Си/Си++ новые конструкции для записи данных.
Метапрограммирование — это создание программ, которые создают другие программы [3]. В метапрограммировании можно выделить два основных направления: генерация кода и самомодифицирующийся код. Нас интересует генерация кода. В этом случае код программы с встроенными механизмами протоколирования не пишется вручную, а создается автоматически программой-генератором на основе другой, более простой программы. Это позволяет получить программу при меньших затратах времени и усилий, чем если бы программист самостоятельно реализовывал весь код.
Существуют языки, для которых метапрограммирование является естественно составной частью. Примером является язык Nemerle, с которым можно познакомиться в статье "Метапрограммирование в Nemerle" [4]. Но в случае с Си/Си++ все сложнее, и в них метапрограммирование реализуется следующими двумя путями:
Введя в язык Си/Си++ новые ключевые слова, можно получить гибкую и удобную систему программирования с мощнейшими возможностями протоколирования. Использование метаязыка открывает широкие возможности в выборе оформления записи необходимой информации. Можно протоколировать данные, введя привычный формат функций:
EnableLogFunctionCallForFhisFile(true);
...
ObjectsArray &objects = getObjects();
WriteLog("objects.size = %u", objects.size());
for (size_t i = 0; i != objects.size(); ++i) {
WriteLog("object type = %i", int(objects [i]->getType()));
if (objects[i]->getType() != TYPE_1)
...
Поскольку генератор кода может распознать используемые типы данных, то можно упростить протоколирование переменных и сократить запись. Например.
ObjectsArray &objects = getObjects();
MyLog "objects.size = ", objects.size()
for (size_t i = 0; i != objects.size(); ++i) {
MyLog "object type = ", int(objects [i]->getType())
if (objects[i]->getType() != TYPE_1)
...
Можно шагнуть дальше, так как здесь все зависит от фантазии автора:
thisfile.log.FunctionCall = ON
...
ObjectsArray &objects = getObjects();
for (size_t i = 0;
i != <log>"objects.size = "objects.size()<log>;
++i) {
LOG: "object type = " {as int} objects [i]->getType();
if (objects[i]->getType() != TYPE_1)
...
Новый метаязык можно реализовать в качестве промежуточного звена между препроцессором и компилятором. Что именно должен представлять этот промежуточный компонент для трансляции метапрограммы зависит от среды разработки. В общем случае схему функционирования можно представить, как показано на рисунке N4.
Рисунок 4. Участие транслятора метаязыка в процессе компиляции.
Теперь рассмотрим сам транслятор. Его принципиальная схема представлена на рисунке N5.
Рисунок 5. Принципиальная схема транслятора языка для генерации кода с поддержкой протоколирования.
Остановимся на генераторе кода более подробно. Генератор представляет собой алгоритм обхода дерева и выполнения над ним трех операций. Во-первых, получение типов всех функций и объектов, определение области их видимости. Это позволит автоматически генерировать код для корректной записи аргументов функций и иных объектов. Во-вторых, происходит раскрытие конструкций метаязыка в новые ветви дерева. И последнее - запись обработанных ветвей кода обратно в файл.
Реализовать самостоятельно такой генератор дело достаточное непростое, впрочем, как и синтаксический анализатор. Но существуют соответствующие библиотеки, о которых будет сказано ниже.
Посмотрим, как генерация кода программы решает перечисленные ранее недостатки системы, построенной исключительно на основе макросов и шаблонов:
К подводным камням предложенной схемы следует отнести проблему с отладкой таких модифицированных программ с помощью отладчика. С одной стороны, если имеется система протоколирования, то использование отладчика не столь важный компонент разработки приложения. Но все-таки часто отладчик бывает крайне полезным инструментом, отказываться от которого не хочется.
Проблема в том, что после преобразования кода (раскрытия операторов для протоколирования) возникнет проблема навигации по номерам строк. Здесь могут помочь специфические программные меры, которые будут индивидуальны для каждой среды разработки. Можно пойти более простым путем. И применить подход, аналогичный подходу в OpenMP, то есть использовать "#pragma". В этом случае код протоколирования будет выглядеть так:
ObjectsArray &objects = getObjects();
#pragma Log("objects.size = ", objects.size())
for (size_t i = 0; i != objects.size(); ++i) {
#pragma Log("object type = ", objects [i]->getType())
if (objects[i]->getType() != TYPE_1)
...
Это несколько менее красиво из-за лишнего слова "#pragma", но у такого текста программы имеются большие преимущества. Такой код может быть безболезненно скомпилирован на другой системе, где не используется система автоматического протоколирования. Такой код легко может быть перенесен на другую систему или отдан сторонним разработчикам. И конечно нет никаких препятствий для работы с этим кодом в отладчике.
Автору, к сожалению, не известны инструменты автоматизированного протоколирования на основе метаязыка, близкие по функциональности к описываемой в статье системе. Если дальнейшие исследования покажут отсутствие подобных разработок, то автор, возможно, примет участие в новом проекте, который сейчас находится на этапе проектирования. Можно сказать, что эта статья является своего рода исследованием актуальности создания универсальной системы протоколирования для языков Си/Си++.
Пока же заинтересовавшимся читателям предлагается самостоятельно создать систему протоколирования. Будет это просто набор шаблонов и макросов, или полноценная система генерации кода, зависит от востребованности такой системы и количества ресурсов для ее создания.
Если вы остановитесь на создании метаязыка, то мы подскажем на основе чего можно его создать.
Существует две наиболее подходящие бесплатные библиотеки, позволяющие представить программу в виде дерева, преобразовать ее и вновь сохранить в виде программы.
Первая из них OpenC++ (OpenCxx) [5]. Эта открытая бесплатная библиотека представляющий собой "source-to-source" транслятор. Библиотека поддерживает метапрограммирование и позволяет создавать на своей основе расширения языка Си++. На основе этой библиотеки созданы такие решения как среда исполнения OpenTS для языка программирования T++ (разработка Института программных систем РАН) и инструмент Synopsis для подготовки документации по исходному коду.
Второй библиотекой является VivaCore, которая представляет собой развитие библиотеки OpenC++, ориентированное на среду разработки Microsoft Visual Studio 2005/2008 [6]. Эта библиотека также открыта и бесплатна. К основным отличиям можно отнести реализованную поддержку языка Си и поддержку некоторых современных элементов языка Си++. Примером использования библиотеки является статический анализатор PVS-Studio.
Если вы захотите использовать эти библиотеки для создания своих расширений, то вам понадобится препроцессор, который должен быть запущен до начала их работы. Скорее всего, встроенные в компилятор средства препроцессирования будут достаточны. Если нет, то вы можете попробовать воспользоваться библиотекой The Wave C++ preprocessor library.
Статья носит теоретический характер, но хочется надеяться, что разработчики найдут в ней много интересных и полезных для себя идей в области протоколирования кода своих программ. Успехов!
Карпов Андрей Николаевич, к.ф.-м.н., научный консультант компании "СиПроВер", специализирующейся на вопросах повышения качества программных систем. Занимается теоретическими и практическими вопросами статического анализа кода. Участвует в проектах по созданию библиотеки VivaCore и анализатора кода PVS-Studio.
Страница на сайте LinkedIn: https://www.linkedin.com/profile/view?id=13363611
E-Mail: karpov@viva64.com
0