на домашнюю страницу автора

к списку статей

Проверяй самого себя!
(использование ASSERT)

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

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

Итак, что такое _ASSERT? Этот макрос определён в файле crtdbg.h. Он определён по разному для отладочной (debug) версии программы и версии предназначенной для конечного пользователя (release). В качестве аргумета макроса выступеат некое выражение. Отладочная версия макроса проверяет его истинность, и если оно оказывается ложно, выдаёт предупреждающее сообщение предлагающее перейти к отладке программы. Release-версия макроса просто ничего не делает, она вообще удаляет из программы это выражение. Таким образом макрос _ASSERT позволяет вставить в отладочную версию программы различные проверки того, насколько правильно она выполняется. В release версии все они будут удалены.

Библиотека MFC использует свой макрос, ASSERT (без символа подчёркивания). Определён он несколько иначе, одако действут практически также, отличаясь лишь форматом выдаваемого сообщения. Далее, я везде буду писать просто ASSERT, а уж какой вариант ипользовать: из CRT, MFC, или вообще определить свой собственный – дело вкуса. Для нас важен общий принцип.

Лично я в дополнении к ASSERT обычно для удобства определяю ещё пару макросов:

#ifdef _DEBUG

#define ASSERT(X) _ASSERT(X)

#define VERIFY(X) _ASSERT(X)

#define ASSUME(X) _ASSERT(X)

#else

#define ASSERT(X)

#define VERIFY(X) (X)

#define ASSUME(X) __assume(X)

#endif

Макрос VERIFY (котрый я тоже подсмотрел в библиотеке MFC) отличается от ASSERT тем, что не удаляет само проверяемое выражение в release-версии программы, хотя и не контролирует его истинность. Макрос ASSUME помогает не только отлавливать баги, но и чуть-чуть оптимизировать прогамму. О нём мы ещё поговорим.

Как пользоваться макросом ASSERT? Просто пока вы пишете код какой-либо функции старайтесь вставить в него как можно больше проверок того, насколько правильно она выполняется. Приведу простой пример. Пусть мы строим класс двусвязанного списка. Каждый элемент списка содержит два указателя: на следующий и предыдущий элементы в списке.

class ListItem {

public: // functions

ListItem( void );
 

void InsertAfter( ListItem* _pItem );
 

private: // data

ListItem* m_pNext;

ListItem* m_pPrevious;

};

Мы будем предполагать, что если элемент не принадлежит спску m_pNext и m_pPrevious указывают самого себя. Реализуем метод InsertAfter, который должен вставить данный элемент в список после элемента указатель на который указан в качестве аргумента:

void ListItem::InsertAfter( ListItem* _pItem )

{

// Проверяем аргументы

ASSERT( this );

ASSERT( _pItem );
 

// Убеждаемся, что элемент не принадлежит уже какому-либо списку

ASSERT( m_pPrevious == this );

ASSERT( m_pNext == this );
 

// Проверяем соответствие указателей

ASSERT( _pItem->m_pNext->m_pPrevious == _pItem );

ASSERT( _pItem->m_pPrevious->m_pNext == _pItem );
 

m_pPrevious = _pItem;

m_pNext = _pItem->m_pNext;

m_pPrevious->m_pNext = this;

m_pNext->m_pPrevious = this;

}

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

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

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

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

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

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

Особо хочу обратить внимание, что тестировать необходимо обе версии: и debug и release, притом одинаково тщательно. С одной стороны ASSERTы в отладочной версии могут выявить некорректные данные, которые могли бы остаться скрытыми. С другой стороны, к сожалению, изредка встречаются ошибки, которые проявляются только в release версии. Ловить их бывает особенно тяжело. В качестве примера можно привести ошибки синхронизации, которые обычно бывают «плавающими», и вероятность проявления которых зависит от времени выполнения некоего участка кода. Часто бывает так, что время выполнения критического участка кода в отладочной версии значительно больше и ошибка даёт о себе знать крайне редко. Впрочем, ошибки синхронизации – это вообще особый случай. Обычная отладка мало помогает в их поиске, здесь требуется глубокий анализ алгоритмов. Этот тот класс ошибок, которые лучше постараться исключить ещё на стадии проектирования. К счастью, в реальной практике настолько хитрые баги попадаются достаточно редко. Я привёл этот пример лишь для того, чтобы проиллюстрировать, почему так важно тестировать release версию наряду с отладочной. В противном случае вас могут ждать очень неприятные сюрпризы.

Ещё раз напомню, что ASSERT – это не более чем средство самоконтроля. Как я уже говорил в самом начале, его никак нельзя путать с системой обработки нештатных ситуаций. Тем не менее между этими темаим существует некоторая связь. И заключается она в том, что ASSERT может помочь нам контролировать в том числе и то, насколько правильно работает система обработки нештатных ситуаций.

Наиболее очевидно эта связь провляется при проверке аргументов функции. В любом учебнике по C++ вам обязательно скажут, что обязательная проверка корректности параметров функции – это такое же важное правило хорошего тона, как требование инициализировать кадую переменную. Но какого рода проверка имеется в виду? Очевидно, что ASSERT во многих случаях будет недостаточно. Вместо этого мы должны будем сделать полноценную систему проверяющую корректность переданных данных. В случае, если данные оказались неправильными, эта система должна будет либо выбросить исключение, либо вернуть код ошибки, либо показать сообщение пользователю, либо предпринять какие-то другие действия в зависиомости от выбранной вами стратегии обработки таких ситуаций. Но если мы будем вставлять такой код в каждую функцию, это приведёт к многократному дублированию одного и того же кода. Хороший стиль программирования требует, чтобы код состоял по-возможности из небольших функций, каждая из котороых ответственна за выполнение одной конкретной задачи. Больших функций следует избегать. Это позволяет сделать код более модульным, разобраться и ориентироваться в нём будет легче. При таком подходе одни и те же данные могут долго путешествоать из одной процедуры в другую. Очевидно, что дублировать их проверку в кадой функции – явное излишество, её не только необходимо но и достаточно сделать всего один раз. (Впрочем, в больших и сложных программах такие «излишние» проверки в наиболее критических местах могут оказаться совсем не лишней предосторожностью, просто во всём стоит знать меру.) Обычно разумнее всего это сделать сразу после того, как данные поступили в программу извне: были прочитаны из файла, переданы из другого программного модуля, введены пользователем, поступили из сети и т.п. После этого можно спокойно передавать данные на обработку в другие функции. Но чтобы быть уверенным, что некорректные данные не проникли куда не следует, в каждую из таких функций, котрые не содержат собственной системы контроля входных данных и предполагают что переданные данные должны быть заведомо корректны, мы должны вставить проверку данных ASSERTами. В ходе тсетирования мы обязательно должны проверить как наша программа реагирует на некорректные данные. Если вместо нормального сообщения об ошибке сработает ASSERT, значит где-то необходимая проверка была пропущена.

Кстати, такой проверкой аргументов в начале метода мы делаем ещё одно полезное дело. Мы фактически пишем документацию на наш метод! Когда программист, разбираясь в незнакомом коде, сталкивается с новой функцией, у него часто сразу же возникают вопросы вроде: «А можно ли здесь предать нулевой указатель?», «А какие значения может принимать даны параметр?», «А что если я передам здесь пустую строку?». Если писавший код программист позаботился вставить ASSERT-проверки параметров, многие подобные вопросы сразу снимаются. Таким образом, проверка параметров с помощью ASSERTов делает код самодокументированным.

Вернёмся ещё раз к нашему примеру. Обратите внимание, что вместе с прочими входными данными мы проверяем также и указатель this! Ведь this – это не что иное, как ещё один параметр любого нестатического метода класса, указатель на конкретный экземпляр класса. Единственное его отличие от других парметров – то, что в C++ он является скрытым и не присутствует явно в списке аргументов. Сделано это исключительно для удобства и красоты кода, но не более того. Поэтому имеет смысл проверить корректность указателя this также как и прочих аргументов.

То, что this может оказаться нулевым указателем на первый взгляд может показаться чем-то диким. Действительно, чаще всего такая ситуация возникает вследствии ошибки программиста. Тем не менее в этом нет ничего криминального и иногда это можно использовать. Нулевые указатели обычно применяются для того, чтобы обозначить некий особый случай, котрый требуется обрабатывать особо или просто исключить из обработки. Поэтому, если вы используете нулевые указатели, код зачастую пестрит проверками типа:

if( pSomeclass ) {

pSomeclass->DoSomething();

}

Если вы применяете метод DoSomething во многих местах и везде вынуждены делать такую проверку, то её вполне можно перенести в саму функцию DoSomething:

void Someclass::DoSomething( void )

{

if( !this ) return;
 

...

}

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

pSomeclass->DoSomething();

её сделает сам функция. Остальной же код станет более компактным, а значит более легко чиаемым и понимаемым.

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

ASSUME( this );

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

Вероятно вы уже заметили, что вместо ASSERT я на этот раз использовал другой макрос. При компиляции версии для конечного пользователя, в отличие от обычного ASSERT, котрорый просто исключается, новый макрос будет заменён специальным ключевым словом __assume. Назначение его состоит в том, чтобы сообщить дополнительные сведения для системы оптимизации компилятора, учитывая которые он сможет создать более оптимальный ассемблерный код. Выражение внутри __assume должно быть всегда истино. Если же оно всегда ложно, значит данное место в программе никогда не исполняется. Естественно, если компилятор не проигнорировал, а каким-то образом использовал информацию, но на практичке выражение оказалось ложным, либо программа всё-же достигла точки помеченной как __assume(false), ни к чему хорошему это не приведёт. Поэтому в отлабочной версии вместо __assume следует подставить обычный ASSERT, чтобы не упустить возможный баг на этапе разаработки.

Итак, чем же нам может помочь конструкция __assume(this)? Предположим, что наш метод должен вызвать некий другой метод принадлежащий базовому классу. Казалось бы, что может быть банальнее? В C++ это выглядит так просто, что обычно мы даже не вспоминаем о том, что прежде чем вызывать метод базового класса, указатель на экземпляр данного класс, то есть this, должен быть преобразован в указатель на базовый класс, ведь компилятор делает это для нас автоматически. Зачастую, а именно в простых случаях, например когда у нас только один базовый класс и нет никаких виртуальных функций, это преобразование тривиально и не требует никакого дополнительного кода. Однако так бывает далеко не всегда. Очень часто базовый класс оказывается размещён не в самом начале включающего его в себя класса, а где-то в середине, после указателя на таблицу виртульных функций и других базовых классов. Поэтому, чтобы привести указатель данный экземпляр класса к указателю на базовый класс к нему необходимо прибавить некую фиксированную величину – смещение. Всё бы хорошо, если бы не одно исключение. Нулевой указатель при приведении к базовому классу должен остаться нулём. Вот поэтому-то, для того чтобы выполнить приведение к базовому классу компилятору приходится создавать код, который на «чистом C» выглядел бы примерно следующим образом:

// Псевдокод

BaseClass* pBaseClass;

if( this ) {

pBaseClass = this + c_BaseClassOffset;

}

else {

pBaseClass = NULL;

}

Компиляторы языков программирования всегда строятся так, чтобы при создании кода они руководствовались лишь самыми общими предположениями и учитывали все, даже маловероятные, возможности. Это необходимо чтобы исключить малейшую возможность того, что в некоторй ситуации код будет работать некорректно. Поэтому, хотя в большинстве случаев нулевой указатель this – это полный абсурд, компилятор обязан учитывать такую возможность. Если же мы вставим в код нашей функции строчку __assume(this), мы тем самым сообщим компилятору, что данный метод всегда используется так, что ему передаётся корректный ненулевой указатель this. Зная это компилятор может упростить код приведения к базовому классу до одной операции сложения:

BaseClass* pBaseClass = this + c_nBaseClassOffset;

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

Стоит ли вообще уделять внимание подобным мелочам? Скажем прямо, в большинстве случаев в реальных программах эффект будет совершенно незаметен. Но всё же я убеждён, что игнорировать такие возможности не стоит. Традиционно слово «оптимизация» ассоциируется с неким сложным программированием на ассемблере, кропотливого изучения кода с помощью программ-«профиловщиков», в общем с чем-то сложным и трудоёмким. В наше время, когда мощность персональных компьютеров и объёмы памяти намного превосходят потребности большинства обычных программ, потребность в такой глубокой оптимизации возникает крайне редко и лишь в весьма специфических случаях. Однако существует немало простых приёмов подобных только что описанному, которые практически не требуют дополнительных трудозатрат. Пренебрегать ими было бы недальновидно. Ну а в нашем случае оптимизация является приятным побочным эффектом, главное же — контроль правильности программы. Поэтому, я всегда придерживаюсь правила: везде, где имеется указатель, котрые по логике работы программы не может оказаться нулевым обязательно вставлять его проверку макросом ASSUME.

Пара замечаний относительно использования ASSUME. Ключевое слово __assume не входит в стандарт языка C++, поэтому если вы используете не Visual C++, сверьтесь с документацией вашего компилятора. Возможно вам надо будет определить макрос ASSUME иначе либо просто превратить в обычный ASSERT. Теоретически, наверно можно было бы вовсе не делать разницы между макросами ASSERT и ASSUME. Но на практике, если в __assume оказывается сложное выражение, например содержащее вызов функции, компилятор выдаёт ошибку. Поэтому я использую два отдельных макроса: ASSRET и ASSUME.

В общем, используйте ASSERT везде, где только возможно. ASSERT — это капкан на багов, и чем больше вы их наставите, тем быстрее их всех поймаете, и тем меньше вероятность, что какой-нибудь хитрый баг таки ускользнёт от вас. Так что минимальные дополнительные трудозатраты на стадии написания кода многократно окупаются на стадии его доводки и тестирования.

© 2010-2014 Алексей Курзенков

Все права на данную статью принадлежат её автору, Курзенкову Алексею. Любое её копирование, полное или частичное, без согласования с автором, является нарушением авторских прав!