Версия для печати


Обработка ошибок
http://www.delphikingdom.com/asp/viewitem.asp?catalogID=1392

Александр Алексеев
дата публикации 19-02-2009 23:18

Обработка ошибок

Примечание: для посещения большинства ссылок, связанных с Microsoft, рекомендуется (а иногда это обязательно) использовать Microsoft Internet Explorer. Если ссылки в статье перестанут работать, то просто воспользуйтесь поиском Google по названию ссылки (для более точного совпадения можно попробовать заключать название статьи в кавычки). Особенно это справедливо для статей MSDN, которые периодически меняют адреса.

Автор выражает благодарность Антону Исаеву за помощь в подготовке статьи

0. Вступление от автора

"Access violation at address XXX in module YYY. Read of address ZZZ" — кто никогда не видел такой надписи — тот никогда не программировал на Delphi. Ошибки неизбежны, а совершать их — в природе человека. Поэтому к возникновению ошибок нужно готовить себя заранее. А теперь представьте себе такую ситуацию: вы написали программу, стали её продавать (или вручили заказчику)... И вдруг, в один прекрасный день программа выкидывает на экран окошко с "красной мордой" и "загадочной надписью":


Скажите честно: вам сильно поможет, если ваш клиент скажет вам, что в вашей программе есть глюк? Даже если он дословно запишет сообщение об ошибке или сделает PrintScreen и пришлёт их вам? Я думаю, что нет.

И только не надо говорить, что в правильной и отлаженной программе такого не возникнет. Возникнет, не сомневайтесь. К сожалению, создание программ требует написания кода :) И не важно, как вдумчиво вы планировали архитектуру, внимательно писали код и тщательно тестировали функциональность — вы просто физически не в силах предусмотреть ЛЮБУЮ возможную ситуацию, в которой будет выполняться ваша программа. Кроме того, хотя программа-то всегда выполняет то, что в неё заложили, но вот люди (как программисты, так и пользователи) — они, знаете ли, совершают ошибки. И если ошибка будет не в вашем коде, то уж в чужом точно (вы ведь не пишете программу от и до, включая операционную систему, драйверы и BIOS, верно? Ах да, я забыл — вы же пишете на Delphi :) Это значит, что вы не контролируете как минимум код самой Delphi). Поэтому свою программу просто необходимо подготавливать к возникновению в ней ошибок. И будет лучше, если мы с вами сможем сделать что-то с этой ситуацией заранее.

Данная статья как раз и посвящена ошибкам, способам их обработки и диагностики. Разговор мы будем вести о Delphi и Windows. Никаких .NET, Linux/Kylix и C++ Builder я рассматривать не буду. Наша первичная цель — сделать программу такой, чтобы в случае внештатной ситуации пользователь знал бы, что ему делать дальше, т.е. сумел бы определить и, возможно, исправить проблему. А если он не справился, то вы (как разработчик) смогли бы идентифицировать ошибку и внести необходимые изменения в программу (т.е. исправить ошибку).

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

Зачем эта статья? Было замечено, что на Круглом столе Королевства Delphi задаётся очень много вопросов, касающихся обработки ошибок. Причём тема эта в книгах практически не освещается. Базовые конструкции языка, синтаксис и т.п. — вот и всё, что можно найти в книгах и в большинстве источников в Интернете (причём в Интернете, помимо этого, выложена ещё и куча "велосипедов" со словами "делай так", без объяснений). Но вот КАК применять этот инструментарий языка? КАК искать ошибки? КАК пользоваться отладчиком? КАК определить причину ошибки? КАК правильно обрабатывать ошибки? КАК узнать: какие нужно обрабатывать, а какие — нет? Вот все эти (и многие другие) вопросы часто остаются без ответа. Эта статья — попытка ответить на эти вопросы. Насколько удачная — судить вам.

Для быстрого старта приведём наиболее частые вопросы, задаваемые на Круглом столе:

1). "Вы знаете, на меня какие-то ругательства лезут с экрана...".

Ответ: вы ошиблись сайтом. Это не сайт для бухгалтеров :)

2). "Пытаюсь запустить программу, при запуске программы компьютер ругается, что делать?"

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

3). "Моя программа ругается!".

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

4). "Написал код, запускаю прогу, а он мне что-то непонятное пишет: Project1.exe raised exception class XXX with message YYY. Process Stopped. Use Step or Run to continiue".

Ответ: это сообщение — уведомление отладчика о том, что в вашей программе есть ошибка (или — вероятная ошибка). Почитайте, что такое отладчик и как его использовать: 2.1.1. Как искать причину ошибки: 2.1.2. Если вы незнакомы с понятием "исключение Delphi", то предварительно рекомендуем прочитать раздел 1.2 (хотя бы первые два подпункта).

5). Любые вопросы, связанные с DLL и EAccessViolation.

Ответ: см. статью "Тонкости работы со строками" (особенности работы с DLL, описанные в статье, применимы не только к строкам).

6). "Не был произведён вызов CoInitialize" или "CoInitialize has not been called".

Ответ: см. соответствующий раздел Тематического каталога.

7). Access Violation в _IntfClear (System.pas) или AV при выходе из программы.

Ответ: см. "Интерфейсы.Access Violation в _IntfClear для TComponent".

Примечание: обычно опознать эту ошибку удаётся при включенной опции "Use Debug DCUs".

8). Access Violation в _HandleException (System.pas).

Ответ: скорее всего, вы написали "raise EMyError(MyMessage)" вместо "raise EMyError.Create(MyMessage)" (т.е. вместо вызова конструктора у вас стоит преобразование типов).

9). Любые вопросы с обработкой ошибок в службах/сервисах.

Ответ: см. статью "Создание служб Windows в Delphi с использованием VCL".

10). "В моей программе утечка памяти! Она занимает 1,7 Мб (смотрел в диспетчере задач). После FileOpenDialog1.Execute она занимает около 12 Мб! Можно ли после закрытия диалога вернуть изначальные 1,7 Мб?".

Ответ: это не утечка памяти. См. раздел 2.3.

11). "Ничего криминального кроде не делаю, но при работе программа периодически выдает Access violation. В чем тут загвоздка может быть?"

Ответ: просмотрите весь раздел 2.1, особо внимательно — 2.1.2, из готовых решений — например 2.4.

12). "Нет возможности скачать JCL или EurekaLog, есть ещё варианты?"

Ответ: попробуйте включить опцию "Use Debug DCUs" и установить точки останова с опцией "Log Call Stack" на конструкторы класса Exception — подробнее см. обсуждение точек останова во второй половине раздела 2.1.1.

13). "Ничего не помогает, в программе ошибка, караул!".

Ответ: ещё раз рекомендуем почитать от 2.1 и далее, до конца всего раздела практики.

14). "Всё равно не могу найти причину :( ".

Ответ: смените профессию или работу, заставьте своего начальника нанять Программиста :)

Также можно попробовать просмотреть статьи и вопросы по сообщению ошибки или воспользоваться тематическим каталогом.

И только, если вопрос всё ещё остаётся в силе, то не грех и задать его на Круглом столе! :)

Примечание: в статье будет упоминание о так называемых "новых" и "старых" Delphi. Новые Delphi — это BDS/Delphi 2005 и выше, соответственно старые Delphi — это версии от 7 и ниже. Почему они так называются оставим за рамками статьи ;) В основном в статье будет использоваться D2007, но по мере возможностей также будут упоминаться особенности старых Delphi и Tiburon/D2009.

Также будут упоминаться такие слова как RTL и VCL. Если вдруг вы слышите их впервые, то считайте, что этими словами обозначается стандартный код Delphi (для понимания статьи сойдёт и такое определение, а для интересующихся я бы рекомендовал книжку по Delphi).

1. "Немного" теории

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

Что входит в понятие "ошибка"? Например, это может быть попытка открытия несуществующего файла. Это может быть несоответствие логике программы заявленной в документации (проще говоря: программа делает не то, что от неё ждёт пользователь). Причём последнее — весьма широкая категория. Например, чуть более толстая линия в отчёте, чем должна быть (да — воскликнет заказчик, — ведь это не по ГОСТу! А в документации заявлено соответствие стандарту ГОСТа!). Тем не менее, все "ошибки" могут быть грубо раскиданы по двум категориям (не абсолютным) — программные и все прочие. Не берёмся явно охарактеризовать каждую категорию, тем более что это может зависеть от точки зрения. Например, та же линия в отчёте из примера выше — это результат установки неверного числа в дизайнере/файле шаблона (который вообще не программист составлял), или же это результат неверного составления математического выражения для расчёта параметров линии (т.е. прямой результат невнимательности программиста при наборе)? Но к первой категории будут относиться ошибки вида: попытка обращения к несуществующему объекту, деление на ноль, запрос неподдерживаемого интерфейса, попытка вызова несуществующей функции (переменная процедурного типа равна nil), попытка прочитать элемент из пустого массива, переполнение стека, попытка перевода строки в число (когда строка не содержит правильного представления числа), попытка записи в файл только для чтения, попытка установить соединение с машиной, которая не в сети и т.п. — короче говоря, всё, что может быть определено самой программой (на самом деле программные ошибки можно ещё поделить минимум на три категории, но об этом позже).

Заметим, что многие ошибки могут проявляться или не проявляться, в зависимости от окружения. Например, на одной машине (при одном окружении) файл может создаться успешно, а на другой — на это может не хватить прав (или носитель будет только для чтения). Часто программист вообще забывает (или не знает) о том, что ситуация на его машине — не единственно возможная. Например, он может хранить вещественное число в файле в виде строки, забывая (или не зная?) о том, что строковое представление числа меняется от машины к машине (у некоторых в качестве разделителя целой и дробной части стоит точка, у других — запятая). К сожалению, конкретно в этом случае сделать мало что можно — увы, но это просто недостаток опыта. Остаётся уповать только на тестирование и отзывы пользователей.

В этой статье пойдёт речь именно о программных ошибках. Как правило, минимальными блоками, подвергаемым контролю, являются функция или процедура (подпрограмма). Каждая подпрограмма выполняет определённую задачу. И мы можем ожидать различный уровень "успешности" выполнения этой задачи: выполнение задачи было успешно или же при её выполнении возникла ошибка. Для написания надёжного кода нам совершенно необходим способ обнаружения ошибочных ситуаций — как мы определим, что в функции возникла ошибка? И реагирования на них — так называемое, "восстановление после ошибок" (т.е.: что мы будем делать при возникновении ошибки?). Традиционно, для обработки ошибок используется два основных способа: коды ошибок и исключения.

1.1. Коды ошибок

Коды ошибок — это, пожалуй, самый простой способ реагирования на ошибки. Суть его проста: подпрограмма должна вернуть какой-либо признак успешности выполнения поставленной задачи. Тут есть два варианта: либо она вернёт простой признак (успешно/неуспешно), либо же она вернёт статус выполнения (иначе говоря — "описание ошибки"), т.е. некий код (число) одной из нескольких заранее определённых ситуаций: неверно заданы параметры функции, файл не найден и т.п. В первом случае может существовать дополнительная функция, которая возвращает статус выполнения последней вызванной функции. При таком подходе ошибки, обнаруженные в функции, обычно передаются выше (в вызывающую функцию). Каждая функция должна проверять результаты вызовов других функций на наличие ошибок и выполнять соответствующую обработку. Чаще всего обработка заключается в простой передаче кода ошибки ещё выше, в "более верхнюю" вызывающую функцию. Например: функция A вызывает B, B вызывает C, C обнаруживает ошибку и возвращает код ошибки в B. B проверяет возвращаемый код, видит, что возникла ошибка, и возвращает код ошибки в A. A проверяет возвращаемый код и выдает сообщение об ошибке (либо решает сделать что-нибудь еще).

Почему используется число, а не текст? Дело в том, что числа можно легко закрепить за типом ошибки. Например, сказать, пусть 2 — это "объект не найден". Если же вместо числа использовать само описание, то это порождает многочисленные трудности. Например, как вызываемая функция узнает, на каком языке возвращать описание? А если вы захотите определить, что определённая функция завершилась с ошибкой типа "объект не найден"? Вы что, будете сравнивать описание ошибки с описаниями на всех возможных языках? А что будете делать, если добавится новый язык или изменится текст описания (например, для улучшения читабельности)? Короче говоря, использование чисел для обозначения класса ошибок — самый простой подход. При желании всегда можно сделать функцию, которая по коду ошибки или вместе с ним возвращает и текстовое описание для показа его человеку.

Например, пусть у нас есть следующие две подпрограммы:

procedure DemoProc(const Param1: String; var Param2: Integer);

function DemoFunc(const Param1: String): Integer;

Мы хотим добавить обработку ошибок к этим подпрограммам. Для процедуры мы превращаем её в функцию, которая возвращает Boolean или Integer (код ошибки):

Вариант А:

// Функция возвращает True, если она выполнилась успешно
// и False - в случае возникновения ошибки.
function DemoProc(const Param1: String; var Param2: Integer): Boolean;

// Если DemoProc вернула False, то эта функция вернёт нам код ошибки:
// что же конкретно пошло не так. Для успешного вызова значение функции может быть не определено,
// равняться нулю или, например, не изменяться с прошлого вызова.
function GetMyErrorCode: Integer;

Вариант B:

// Функция сразу возвращает код ошибки или 0 в случае успеха.
function DemoProc(const Param1: String; var Param2: Integer): Integer;

С функцией чуть сложнее. Она уже возвращает какой-то результат и нужно придумать, что с ним делать. Тут есть два варианта. Первый: в диапазоне возвращаемых значений функции есть несколько недопустимых значений. Например, функция, возвращающая число, может вернуть положительное число или ноль, но не отрицательное число. Тогда любое из недопустимых значений можно принять за признак ошибки. Обычно в качестве такого значения берут 0, -1 или (редко) -2. Сделать это можно, например, так:

const 
  // При успешном выполнении функция не может вернуть -1
  DemoFuncInvalidValue = -1; 

// Функция возвращает результат (число >= 0) в случае успеха
// или DemoFuncInvalidValue (-1) в случае ошибки.
function DemoFunc(const Param1): Integer;

// Если DemoFunc вернула DemoFuncInvalidValue, то эта функция вернёт нам код ошибки.
function GetMyErrorCode: Integer;

Разумеется, такая реализация не может возвращать код ошибки напрямую — лишь признак успешности, поэтому здесь только один вариант — с дополнительной функцией GetMyErrorCode.

Второй вариант заключается в том, что бывший результат функции становится её последним выходным параметром (т.е. var— или out-параметром), например:

// Функция возвращает результат работы в Rslt, а сама возвращает код ошибки или признак ошибки.
function DemoFunc(const Param1; out Rslt: Integer): Integer { или Boolean };

// Если DemoFunc сделана в варианте с Boolean, то эта функция вернёт нам код ошибки.
function GetMyErrorCode: Integer;

Поскольку говорим мы с вами о Windows, то всенепременно нужно взглянуть на то, как же сделана обработка ошибок в функциях Windows. В большинстве функций используются именно коды ошибок. Что не означает, что функция не может возбудить исключение — ещё как может, особенно если передать ей указатели, указывающие в космос :) К сожалению, при написании такого большого количества функций не возникло единого стандарта на то, как следует возвращать результат работы. Некоторые функции возвращают результат типа BOOL (аналог Boolean в Delphi), другие — непосредственно код ошибки, третьи — некое специальное значение (обычно 0 или -1). Тем не менее, чаще всего функции возвращают только признак успешности (правда разными способами), а код ошибки получается вызовом функции GetLastError. Причём в случае успеха функции тоже ведут себя по-разному: одни сбрасывают код ошибки в ноль, другие оставляют его без изменений. Для уточнения поведения нужно смотреть на документацию по функции. Причём есть два основных способа кодирования ошибки — это Win32-ошибки и ошибки COM (помимо редких Setup API error codes, NTSTATUS и т.п.).

1.1.1. Коды ошибок Win32

Изначально, в Windows были только ошибки типа Win32 error codes. Это — обычное число типа DWORD. Коды ошибок закреплены и объявлены в модуле Windows. За отсутствие ошибки принимается значение ERROR_SUCCESS или NO_ERROR равное 0. Для всех ошибок определены константы, начинающиеся (обычно) со слова ERROR_, например:

  { Incorrect function. }
  ERROR_INVALID_FUNCTION = 1;   { dderror }

  { The system cannot find the file specified. }
  ERROR_FILE_NOT_FOUND = 2;

  { The system cannot find the path specified. }
  ERROR_PATH_NOT_FOUND = 3;

  { The system cannot open the file. }
  ERROR_TOO_MANY_OPEN_FILES = 4;

  { Access is denied. }
  ERROR_ACCESS_DENIED = 5;

  { The handle is invalid. }
  ERROR_INVALID_HANDLE = 6;
  // ... и т.п.

Полный список кодов можно посмотреть в MSDN/Platform SDK в теме "System Error Codes". Часть списка объявлена в модуле Windows.pas. Обычно, коды ошибок собираются в группы по смыслу. Это делается для удобства управления. Внутри Microsoft диапазон кодов делится на группы, и каждая команда разработчиков получает в своё распоряжение диапазон, в котором они могут создавать новые коды ошибок. Таким образом, гарантируется, что коды ошибок, скажем, от Terminal Services и от WinSock не пересекутся. Примерно разделение кодов выглядит так (пользы от этой информации, конечно, ноль, но просто может быть интересно):

2100-2999Networking
5000-5999Cluster
7500-7999Traffic Control
8000-8999Active Directory
9000-9999DNS
10000-11999Winsock
13000-13999IPSec
14000-14999Side By Side

Описание Win32-ошибки можно получить через функцию FormatMessage. В Delphi для этой системной функции с кучей параметров имеется (конкретно для нашего случая) более удобная для использования оболочка: функция SysErrorMessage. Она, по переданному ей коду ошибки Win32, возвращает его описание. Кстати, обратите внимание, что сообщения возвращаются локализованными. Т.е. если у вас русская Windows, то сообщения будут на русском. Если английская — на английском.

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

procedure TForm1.Button1Click(Sender: TObject);
begin
  // Функция возвращает True, в случае успеха,
  // и False - в случае ошибки
  if not CreateDirectory(PChar(Edit1.Text), nil) then
  begin
    ShowMessage(SysErrorMessage(GetLastError));
    Exit;
  end;
  ShowMessage('Успех');
end;

Если вы введёте в Edit1 допустимое имя каталога для создания, то на экране появится сообщение "Успех". Если же вы укажете, например, каталог на несуществующем диске, то появится описание причины, почему там нельзя создать каталог ("Системе не удаётся найти указанный путь").

При работе с функциями Windows нужно внимательно смотреть документацию по поводу того, как функция сообщает об ошибке в работе. Тут самое главное — не перепутать, кто возвращает -1, а кто — 0. Например, такой код будет неверен:

Handle := CreateFile(...);
if Handle = 0 then
begin
  Result := GetLastError;
  Exit;
end;

Т.к. в случае ошибки CreateFile возвращает INVALID_HANDLE_VALUE (что есть -1), а вовсе не 0.

Заметим, что существуют функции, для определения успешности выполнения которых недостаточно проанализировать результат самой функции: необходимо ещё дополнительно проверить значение GetLastError. Например, функция AdjustTokenPrivileges возвращает True в случае "успешного" выполнения или False в случае неудачи. Слово "успешного" не зря заключено в кавычки. Успешным выполнением функции также считается случай, когда функция завершилась, но при этом не сумела поменять ни одной привилегии! Для определения истинного статуса выполнения вы должны вызвать GetLastError, который для случая "успешного" выполнения AdjustTokenPrivileges возвращает ERROR_SUCCESS (все запрошенные привилегии были изменены) или ERROR_NOT_ALL_ASSIGNED (одна, несколько или вообще все запрошенные привилегии не были изменены). В случае неуспешного выполнения AdjustTokenPrivileges функция GetLastError возвращает, как обычно, код ошибки Win32. Отметим, что вы, таким образом, должны всегда вызвать AdjustTokenPrivileges вместе с GetLastError для определения полного статуса завершения операции (кстати, отсутствие анализа GetLastError, когда AdjustTokenPrivileges возвращает True, является частой ошибкой во многих примерах кода в Интернете).

А вот, например, функция CreateEvent возвращает дескриптор события (не ноль), если ей удалось создать событие или открыть существующее (с заданным именем), если оно было уже создано ранее. Для определения того, было ли событие создано или открыто, нужно опять вызвать функцию GetLastError, которая возвращает ERROR_ALREADY_EXISTS для случая открытия события. В данном случае, хотя сама функция CreateEvent возвращает полную успешность своего выполнения (а не частичную, как AdjustTokenPrivileges), но её статус успешности может быть разным. При желании вы можете уточнить, как именно была выполнена операция с помощью вызова GetLastError.

В своих собственных функциях вы также можете использовать этот стандартный механизм. Для установки кода ошибки служит функция SetLastError. Заметим, что если вам нужно создать свой собственный код ошибки, то вы должны установить 29-й бит в коде ошибки (отсчёт битов идёт с нуля). Microsoft гарантирует, что не создаст коды ошибок с установленным 29-м битом. Таким образом, вы можете создавать свои собственные коды, которые гарантировано не будут пересекаться с системными кодами ни сейчас, ни в будущем (однако это не значит, что не могут пересекаться лично ваши коды и коды, введённые другим программистом, работающим с вами в соседнем отделе). Использовать собственные коды ошибок можно, например, так:

const
  ERROR_MY_CUSTOM_ERROR_1 = (1 shl 29) or 1;  // (1 shl 29) устанавливает 29-й бит
  ERROR_MY_CUSTOM_ERROR_2 = (1 shl 29) or 2;
  ...

function DoSomething: Bool;
begin
  ...
  if SomeError then
  begin
    SetLastError(ERROR_MY_CUSTOM_ERROR_1);
    Result := False;
    Exit;
  end; 
  ...
end;

...

var
  ErrCode: DWord;

...

if not DoSomething then
begin
  ErrCode := GetLastError;
  // Функция SysErrorMessage умеет извлекать сообщения только
  // для системных кодов ошибок. Поэтому нужно самому позаботиться
  // о сообщениях своих ошибок.
  if (ErrCode and (1 shl 29)) <> 0 then // Если установлен 29-й бит...
    case ErrCode of // ... то ErrCode - наша ошибка
      ERROR_MY_CUSTOM_ERROR_1:
        ShowMessage('Описание моей ошибки #1');
      ERROR_MY_CUSTOM_ERROR_2:
        ShowMessage('Описание моей ошибки #2');
    else
      ShowMessage('Неизвестная Custom-ошибка.');
    end
  else // ... а если не установлен, то ErrCode - системная ошибка.
    ShowMessage(SysErrorMessage(GetLastError));
end;

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

1.1.2. Коды ошибок HRESULT

С введением COM Microsoft расширила возможности обычных кодов ошибок Win32 введением типа HRESULT. HRESULT — это тоже число, но теперь уже типа Integer. HRESULT уже не просто код ошибки, он состоит из нескольких частей:

Собственно код ошибки лежит лишь в младших 16-ти битах (от 0 до 15). На рисунке это самый правый блок Code. Далее идёт так называемый Facility-код или код оборудования (устройства). Он обозначает системный сервис, сгенерировавший ошибку. Эти коды выделяются Microsoft по мере необходимости (вы не можете вводить свои). В модуле Windows они обозначены константами, начинающимися с FACILITY_, например:

  FACILITY_WINDOWS                     = 8;
  FACILITY_STORAGE                     = 3;
  FACILITY_RPC                         = 1;
  FACILITY_SSPI                        = 9;
  FACILITY_WIN32                       = 7;
  FACILITY_CONTROL                     = 10;
  FACILITY_NULL                        = 0;
  FACILITY_INTERNET                    = 12;
  FACILITY_ITF                         = 4;
  FACILITY_DISPATCH                    = 2;
  FACILITY_CERT                        = 11;

Для COM наиболее часто используются FACILITY_NULL и FACILITY_ITF (ITF — сокращение от "интерфейс"). FACILITY_WIN32 используется для тех случаев, когда код ошибки HRESULT представляет собой какой-либо код ошибки Win32.

Следующие четыре бита с 27 по 30 включительно (R, C, N, r) зарезервированы. Для введения пользовательских кодов служит код оборудования FACILITY_ITF (см. ниже).

Наиболее важным отличием от кодов Win32 является последний, 31-й бит. Если он установлен в 0, то весь код HRESULT является признаком успешности операции. Если же он установлен в 1, то код HRESULT является признаком возникновения ошибки. В связи с этим уже нельзя использовать сравнение с нулём, т.к. успешных кодов ошибок может быть теперь много (например, функция поиска может вернуть 0 в случае успешного нахождения нескольких элементов и 1 (S_FALSE) в случае, если она ничего не нашла). В некотором смысле возврат ненулевого кода успеха можно рассматривать как "предупреждение".

Для определения успешности/не успешности функции, возвращающей HRESULT, нужно воспользоваться функциями Succeeded и Failed (см. ниже). Поскольку HRESULT — число типа Integer, а 31 — самый старший бит, то он является знаковым битом, следовательно, все коды ошибок HRESULT меньше нуля, а все коды успеха HRESULT — больше или равны нуля. А значит, функции Succeeded и Failed, по сути, проверяют: больше нуля их аргумент или нет.

Если взглянуть на шестнадцатеричное представление HRESULT (например: $8007000E), то визуально легко выделить (грубо) его составные части. Первые четыре цифры — это код ошибки (в нашем примере это $000E, т.е. 14), следующие три — это код оборудования (т.е. $007 или FACILITY_WIN32), последняя цифра — это $0 для успеха или $8 для ошибки. В нашем примере, $8007000E — это код ошибки Win32 номер 14, т.е. ERROR_OUTOFMEMORY (для выяснения этого мы просто провели поиск по Windows.pas).

В модуле Windows.pas определены некоторые коды HRESULT. Они обычно начинаются с E_ (для кодов ошибок), S_ (для кодов успеха) или префикса оборудования (например, ASF_E_ или ASF_S_). Несколько примеров:

  // Основной код успеха
  S_OK    = $00000000;
  // Стандартный дополнительный код успеха
  S_FALSE = $00000001;
  // Аналог S_OK
  NOERROR = 0;

  // Разрушительный сбой 
  E_UNEXPECTED = HRESULT($8000FFFF);

  // Функциональность не реализована
  E_NOTIMPL = HRESULT($80004001);

  // Не хватает памяти (значение HRESULT для ERROR_OUTOFMEMORY)
  E_OUTOFMEMORY = HRESULT($8007000E);

  // Один или более аргументов заданы неверно 
  // = HResultFromWin32(ERROR_INVALID_PARAMETER) 
  E_INVALIDARG = HRESULT($80070057);

  // Интерфейс не поддерживается
  E_NOINTERFACE = HRESULT($80004002);

  // Неверный указатель
  E_POINTER = HRESULT($80004003);

  ...

  { FACILITY_ITF }

  // Общие коды, возвращаемые многими интерфейсами
  OLE_E_FIRST = HRESULT($80040000);
  OLE_E_LAST  = HRESULT($800400FF);
  OLE_S_FIRST = $40000;
  OLE_S_LAST  = $400FF;

  // и т.п.
  ...

Функции, использующие HRESULT, обычно возвращают его же сразу (для COM в некоторых случаях это является обязательным требованием к интерфейсу). А результат своих вычислений они передают в out— или var-параметре, например:

function SetCatalogState(pwcsCat, pwcsMachine: PWCHAR;
  dwNewState: DWORD; var pdwOldState: DWORD): HRESULT; stdcall;

Обычные коды ошибок Win32 являются подмножеством HRESULT. Чтобы из ошибки Win32 собрать значение типа HRESULT, достаточно установить бит ошибки, задать код оборудования равным FACILITY_WIN32 и добавить собственно сам код. Удобно это делать такой функцией (функция не может работать с кодами Win32, определёнными прикладным программистом):

// Примечание: аналогичная функция есть в Windows.pas.
// Мы приводим здесь эту функцию только для примера.
function HResultFromWin32(x: DWORD): HRESULT;
begin
  if HRESULT(x) <= 0 then // У Win32-кода старший бит не может быть установлен
    Result := HRESULT(x)  // Нам передали уже готовый HRESULT
  else
    Result := HRESULT(    // Нам передали код ошибки Win32
      // Код ошибки. Обратите внимание, что используется только младшие 16 бит,
      // т.е. все пользовательские коды ошибок с установленным битом 29
      // не могут быть корректно переведены в HRESULT этой функцией.
      (x and $0000FFFF) or
      // Код оборудования
      (FACILITY_WIN32 shl 16) or
      // Бит (признак) ошибки
      (1 shl 31)
                     );
end;

Если в функцию передать уже готовый HRESULT, то она вернёт его без изменений (хотя это верно только для HRESULT в виде ошибки).

Для работы с HRESULT в модуле Windows.pas есть следующие функции:

// Возвращает True, если переданное значение - код успеха (т.е. >= 0)
function Succeeded(Status: HRESULT): BOOL;

// Возвращает True, если переданное значение - код ошибки (т.е. < 0)
function Failed(Status: HRESULT): BOOL;

// Возвращает код ошибки (младшие 16 битов)
function HResultCode(hr: HRESULT): Integer;

// Возвращает код оборудования
function HResultFacility(hr: HRESULT): Integer;

// Возвращает признак ошибки (31-й бит)
function HResultSeverity(hr: HRESULT): Integer;

// Создаёт HRESULT по признаку ошибки, коду оборудования и коду ошибки
function MakeResult(sev, fac, code: Integer): HResult;

// Возвращает HRESULT по коду ошибки Win32. Функция написана чуть иначе,
// чем мы сделали выше, но по смыслу аналогична нашей реализации.
function HResultFromWin32(x: Integer): HRESULT;

Кстати, если будете смотреть заголовочные файлы (тот же Windows.pas, например), то обратите внимание, что, обычно, коды ошибок Win32 пишут в десятичной системе счисления, а HRESULT — в шестнадцатеричной. Не запутайтесь.

Заметим, что значения типа HRESULT могут успешно использоваться в функциях GetLastError/SetLastError и FormatMessage (а, следовательно, и в SysErrorMessage). А это значит, что HRESULT можно использовать не только с COM, но и в обычных функциях. Самое главное, при использовании в своих функциях не смешивать коды Win32 и HRESULT в одну кучу и чётко документировать, как и что возвращает каждая написанная вами функция.

Для создания пользовательских кодов для HRESULT можно использовать код оборудования FACILITY_ITF. Это привязывает код ошибки к её создателю. Один и тот же код ошибки с facility-кодом FACILITY_ITF может означать одну вещь в одном интерфейсе и совершенно другую — в другом. Словом, создание HRESULT с FACILITY_ITF полностью эквивалентно созданию кода ошибки Win32 с установленным 29-м битом. Разумеется, ничто не мешает использовать эти коды ошибок и в обычных функциях, а не только методах интерфейса. Правда, одно отличие от пользовательских кодов Win32 всё же есть: дело в том, что некоторые стандартные интерфейсы уже используют коды с FACILITY_ITF. В этом нет ничего плохого, и вы можете ввести точно такой же код в своих функциях и использовать его в своём смысле, отличном от определённого в Windows. Это можно сделать, т.к. значение кода COM с FACILITY_ITF зависит от того, от кого оно получено. Получено от стандартного интерфейса — ну тогда это какой-нибудь E_POINTER = HRESULT($80004003), получено от своей функции — ну тогда это какой-нибудь MY_E_STREAM_WRITE_ERROR = HRESULT($80004003). Кстати, обратите внимание, что для определения смысла кода (например, для показа сообщения об ошибке) недостаточно знать сам код HRESULT — нужно ещё и знать, от кого он получен. Но можно сделать и иначе (и это рекомендуется). Можно выделять код ошибки в диапазоне $0200-$FFFF (обычно системные коды ошибок лежат в диапазоне $0000-$01FF). Проще всего это сделать, просто установив старший (15-й) бит. Это позволит меньше путаться в своих и системных кодах.

Если в своей программе будете использовать как свои собственные коды ошибок Win32, так и свои собственные коды HRESULT, то смотрите внимательно, чтобы во время конвертации кодов Win32 -> HRESULT у вас не пропали бы пользовательские коды. Функция HResultFromWin32 их игнорирует, т.к. процесс конвертации пользовательских кодов нельзя автоматизировать. У функции нет сведений о том, какому значению HRESULT соответствует ваш пользовательский Win32-код. Поэтому такого рода перевод (если он вам нужен, разумеется) нужно будет делать руками, возможно, введя для удобства свою функцию перевода.

Также об использовании HRESULT в COM можно почитать, например, в статье "Урок 3. Тип HRESULT".

1.2. Исключения

Исключение — это способ прервать обычное выполнение программы и выполнить некоторый код по обработке возникшей внештатной ситуации (исключения). В некоторых источниках исключения называют "особыми ситуациями", "внештатными ситуациями" или "исключительными ситуациями", но термин "исключение" всё же более устоявшийся. В дальнейшем исключения Delphi мы будем называть именно исключениями, а всякого рода "ситуации" использовать для обозначения необычных ситуаций, а не исключений как программного объекта.

Исключение можно рассматривать как некоторое событие, которое прерывает нормальное выполнение программы. Хотя изначально исключения введены для обработки внештатных (ошибочных) ситуаций, но они также являются и удобным средством прерывания обычного выполнения кода (например — нажатие пользователем кнопки "Отмена"). Также исключения позволяют осуществлять единообразную обработку как программных, так и аппаратных ошибок. Обработка подразумевает написание кода "очистки" и/или кода по исправлению ошибки.

Windows предоставляет стандартный системный механизм исключений, называемый SEH (Structured Exception Handling) — структурированная обработка исключений. Мы не будем рассматривать системную реализацию в этой статье. Кратко ознакомиться с системным механизмом можно в "А что, собственно, происходит, когда бросается исключение?". А более подробно можно почитать в MSDN/Platform SDK: "Structured Exception Handling". Также про системный SEH можно почитать на русском, например, здесь: "Секреты Win32 Win32™ SEH изнутри" (в трёх частях, — часть 2, часть 3).

1.2.1. Возбуждение исключений

В Delphi же на уровне языка используется собственный механизм исключений, являющийся надстройкой над стандартным. На Королевстве Delphi уже есть статья для начинающих, посвящённая исключениям Delphi: "Глава 4. Исключительные ситуации и надежное программирование". Но чтобы изложение было цельным, мы расскажем этот материал здесь. Возможно, что указанная статья написана более понятно, т.к. она не столь перегружена материалом. Если что-то будет не понятно — вы можете переключиться между статьями.

Итак, в основе этого механизма лежат следующие конструкции языка: зарезервированное слово raise, блоки try/finally и try/except (это очень простая обёртка с системному SEH). При выполнении программы её обычный ход может быть прерван в любой момент времени возникновением исключения (или, как обычно говорят, возбуждением, иногда также используют слово генерированием). Это может происходить двумя способами.

Во-первых, программист может сам использовать зарезервированное слово raise, чтобы возбудить исключение. Это — так называемые программные исключения. В конструкцию с raise передаётся экземпляр любого объекта (сам объект исключения). В 99% случаев этот объект является потомком от стандартного класса Exception в Delphi. И в 99% случаев он создаётся непосредственно при вызове raise. Этот объект призван нести некоторую информацию о возникшем прерывании работы. Например, текстовое сообщение об ошибке или код ошибки.

Для возбуждения исключения программист может использовать как стандартные классы (большинство из которых объявлено в SysUtils.pas), так и свои собственные. Стандартные классы вы можете использовать сразу же:

  // Возбуждаем самое общее исключение с текстовым описанием
  // "Неопознанная ошибка". Обычно так никогда делать не следует (с точки зрения хорошего тона),
  // почему - см. 2.6.8.
  raise Exception.Create('Неопознанная ошибка.');

  ...

  // Возбуждаем исключение типа EStreamError (ошибка потока данных)
  // с сообщением "Ошибка чтения данных".
  raise EStreamError.Create('Ошибка чтения данных.');

Примечание: полный список стандартных исключений Delphi можно посмотреть, например, в статье "What are the Borland predefined Exception classes?" (для версии 2006). Если вас интересует какой-то конкретный стандартный класс — посмотрите справку Delphi, поищите описание в Интернете или же просто запустите поиск по имени класса в pas-файлах Delphi. Если же вас интересует, какие исключения могут быть возбуждены кодом, с которым вы работаете — посмотрите документацию на функции или классы, с которыми вы работаете, или их исходный код.

А вот свои собственные классы исключений сперва нужно описать. При этом настоятельно рекомендуется наследовать классы исключений от класса Exception (SysUtils.pas) или его потомков, например:

// Объявление нового типа исключения.
// Делается один раз.
type
  // В Delphi есть традиция начинать название класса исключения с буквы E, 
  // в отличие от T для всех прочих классов. 
  // Следовать ей не обязательно, но рекомендуется.
  // EStreamError - один из стандартных классов
  EMyStreamError = class(EStreamError)
  private
    FStream: TStream;
  public
    // дополнительные данные, ассоциируемые с исключением 
    property Stream: TStream read FStream;
    // опционально - вводим новый конструктор для удобства создания исключения.
    constructor CreateStrm(const AMessage; AStream: TStream);
  end;

constructor EMyStreamError.CreateStrm(const AMessage; AStream: TStream);
begin
  Create(AMessage);
  FStream := AStream;
end;

...

  // Используется где угодно и сколько угодно раз
  raise EMyStreamError.CreateStrm('Ошибка чтения данных.', Self);

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

Взглянем на объявление класса Exception:

type
  Exception = class(TObject)
  private
    FMessage: string;
    FHelpContext: Integer;
  public
    // Все конструкторы ниже представляют собой просто
    // разные варианты заполнения свойств FMessage и FHelpContext 
    constructor Create(const Msg: string);
    constructor CreateFmt(const Msg: string; const Args: array of const);
    constructor CreateRes(Ident: Integer); overload;
    constructor CreateRes(ResStringRec: PResStringRec); overload;
    constructor CreateResFmt(Ident: Integer;
      const Args: array of const); overload;
    constructor CreateResFmt(ResStringRec: PResStringRec;
      const Args: array of const); overload;
    constructor CreateHelp(const Msg: string; AHelpContext: Integer);
    constructor CreateFmtHelp(const Msg: string; const Args: array of const;
      AHelpContext: Integer);
    constructor CreateResHelp(Ident: Integer; AHelpContext: Integer); overload;
    constructor CreateResHelp(ResStringRec: PResStringRec; AHelpContext: Integer);
      overload;
    constructor CreateResFmtHelp(ResStringRec: PResStringRec; const Args: array of const;
      AHelpContext: Integer); overload;
    constructor CreateResFmtHelp(Ident: Integer; const Args: array of const;
      AHelpContext: Integer); overload;
    property HelpContext: Integer read FHelpContext write FHelpContext;
    property Message: string read FMessage write FMessage;
  end;

Базовое исключение Exception — это очень простой объект, который имеет дополнительные свойства Message: String и HelpContext: Integer (последнее используется редко), а также кучу конструкторов по созданию исключения с заполненным Message (например, указание напрямую, сборка из строки форматирования с аргументами, загрузка из строковой таблицы ресурсов и т.п.). Соответственно, любые другие исключения также имеют эти свойства. Большинство стандартных классов имеют и используют только текстовое свойство Message, в котором хранят текстовое сообщений пользователю. Некоторые также имеют код ошибки ErrorCode: Integer. Для ассоциирования с исключением другой информации вам нужно создавать свой собственный класс исключения, как мы и сделали выше.

Примечание: в D2009 Exception имеет такой вид:

type
  Exception = class(TObject)
  private
    FMessage: string;
    FHelpContext: Integer;
    // Новые свойства
    // Хранит вложенное исключение
    FInnerException: Exception;
     // Информация о стеке вызовов в момент генерации исключения
    FStackInfo: Pointer;
     // Нужно ли сохранять вложенное исключение в FInnerException
    FAcquireInnerException: Boolean;
  protected
    // Новые методы
    // Заполняет FInnerException по FAcquireInnerException
    procedure SetInnerException;
    // Устанавливает FStackInfo
    procedure SetStackInfo(AStackInfo: Pointer);
    // Из сохранённой информации FStackInfo формирует текстовое представление стека вызовов 
    function GetStackTrace: string;
    // Хук возбуждения - вызывается перед возбуждением исключения
    procedure RaisingException(P: PExceptionRecord); virtual;
  public
    constructor Create(const Msg: string);
    constructor CreateFmt(const Msg: string; const Args: array of const);
    constructor CreateRes(Ident: Integer); overload;
    constructor CreateRes(ResStringRec: PResStringRec); overload;
    constructor CreateResFmt(Ident: Integer; const Args: array of const); overload;
    constructor CreateResFmt(ResStringRec: PResStringRec; const Args: array of const);
      overload;
    constructor CreateHelp(const Msg: string; AHelpContext: Integer);
    constructor CreateFmtHelp(const Msg: string; const Args: array of const;
      AHelpContext: Integer);
    constructor CreateResHelp(Ident: Integer; AHelpContext: Integer); overload;
    constructor CreateResHelp(ResStringRec: PResStringRec; AHelpContext: Integer); overload;
    constructor CreateResFmtHelp(ResStringRec: PResStringRec; const Args: array of const;
      AHelpContext: Integer); overload;
    constructor CreateResFmtHelp(Ident: Integer; const Args: array of const;
      AHelpContext: Integer); overload;
    // Новые методы/свойства 
    // Удаляет FInnerException и FStackInfo
    destructor Destroy; override;
    // Возвращает максимально вложенное FInnerException
    function GetBaseException: Exception; virtual;
    // Собирает свойства Message (по одному в каждой строке) с текущего
    // и со всех вложенных исключений
    function ToString: string; override;
    property BaseException: Exception read GetBaseException; 
    property HelpContext: Integer read FHelpContext write FHelpContext;
    property InnerException: Exception read FInnerException; 
    property Message: string read FMessage write FMessage;
    property StackTrace: string read GetStackTrace;
    property StackInfo: Pointer read FStackInfo;
  class var
    // Вызывается для создания информации о стеке вызовов в момент
    // возбуждения исключения для передачи в SetStackInfo
    GetExceptionStackInfoProc: function (P: PExceptionRecord): Pointer;
    // Используется в GetStackTrace для конвертации стека вызовов
    // в текстовое представление
    GetStackInfoStringProc: function (Info: Pointer): string;
    // Удаляет информацию о стеке вызовов
    CleanUpStackInfoProc: procedure (Info: Pointer);
    // Возбуждает исключение с установленным FAcquireInnerException
    class procedure RaiseOuterException(E: Exception); static;
    // Аналог RaiseOuterException, но с именем в стиле C++
    class procedure ThrowOuterException(E: Exception); static;
  end;

О новых полях и методах мы поговорим попозже — в пунктах 1.2.4, 1.3.7 и 2.1.5.

Если вам не нужна дополнительная информация, вы можете просто создать различные классы исключений для разных типов ошибок (зачем это нужно — см. 2.6.11), например:

type
  // Базовый класс для ошибок в компоненте TMyComponent
  EMyComponentError = class(Exception);
    // Ошибка загрузки данных 
    EMCLoadDataError = class(EMyComponentError);
    // Ошибка модификации данных 
    EMCModifyError = class(EMyComponentError);
      // Ошибка загрузки данных
      EMCLoadError = class(EMCModifyError);
      // Ошибка добавления данных
      EMCAddError = class(EMCModifyError);
      // Ошибка вставки данных
      EMCInsertError = class(EMCModifyError);
      // Ошибка удаления данных 
      EMCDeleteError = class(EMCModifyError);
    // Ошибка "объект уже присвоен"
    EMCSetAlreadyAssignedError = class(EMyComponentError);
  ...

В некотором смысле различные классы исключений аналогичны различным кодам ошибок Win32 или HRESULT. С другой стороны, помимо классификации, класс исключения несёт в себе дополнительную информацию об ошибке, чего нет у обычных системных кодов ошибок. Кстати говоря, "преобразование" системного кода ошибки в исключение можно произвести процедурой RaiseLastOSError (RaiseLastWin32Error в некоторых старых версиях Delphi). Достаточно просто вызвать эту процедуру без параметров, и будет возбуждено исключение класса EOSError (EWin32Error в некоторых старых версиях Delphi), соответствующее текущему коду ошибки (разумеется, не пользовательскому). Свойство Message будет содержать описание кода ошибки, полученное от SysErrorMessage, а свойство ErrorCode — сам код ошибки, например:

procedure TForm1.Button1Click(Sender: TObject);
begin
  // Функция CreateDirectory возвращает True, в случае успеха, и False - в случае ошибки
  if not CreateDirectory(PChar(Edit1.Text), nil) then
    RaiseLastOSError; // Возбуждает исключение типа EOSError
  ShowMessage('Успех');
end;

Вторая категория исключений (второй способ возбуждения) — аппаратные. Это исключения, которые создаются модулем SysUtils в ответ на возникновение run-time ошибок. Здесь уже исключения возбуждаются сами (без явной команды программиста), в ответ на определённые ситуации. Например, самая часто встречаемая ошибка в Delphi — это исключение класса EAccessViolation (на самом деле, изначально-то возбуждается системное исключение, но Delphi сразу же "оборачивает" его в своё исключение). Это исключение возбуждается, когда программа пытается прочитать или записать данные (а также выполнить код) по недействительному указателю (адресу). Например, когда удалили объект, а ссылка на него осталась, и программист пытается прочитать свойство этого объекта (уже несуществующего), используя уже недействительную ссылку. Другой пример аппаратного исключения — деление числа на ноль. При этом возбуждается исключение типа EDivByZero. А при переполнении стека возникает EStackOverflow. В принципе, при желании программист может и сам возбудить исключение этих классов, но делать так не следует, т.к. это только внесёт путаницу в программу.

Поскольку исключения Delphi — это надстройка над стандартным SEH, то могут возникнуть ситуации, когда, вызванная из программы Delphi, функция (написанная не на Delphi) возбуждает неизвестное Delphi исключение (не важно, аппаратное или программное). В этом случае Delphi "оборачивает" исключение в EExternalException. У EExternalException есть свойство ExceptionRecord, которое содержит информацию о возникшем системном исключении. В частности, там есть системный код исключения и другие параметры, которые передаются с системными исключениями.

Примечание: EExternalException, а также все классы аппаратных исключений (EAccessViolation, EDivByZero и т.п.) наследуются от EExternal, у которого, собственно, и объявлено свойство ExceptionRecord.

Заметим, что вы можете поменять опции в вашем проекте, которые отвечают за генерацию исключений в некоторых ситуациях. Для этого зайдите в меню "Project"/"Options", на вкладке "Compiler" вы увидите такие опции:


Опция "Range checking" (директива {$R+} или {$R-}) служит помощником в поиске проблем при работе с массивами. Если её включить, то для любого кода, который работает с массивами и строками, компилятор добавляет проверочный код, который следит за правильностью индексов. Если при проверке обнаруживается, что вы вылезаете за границы массива, то будет сгенерировано исключение класса ERangeError. При этом вы можете идентифцировать ошибку обычной отладкой (как — см. раздел практики). Если же опция выключена, то никакого дополнительного кода в программу не добавляется. Включение опции немного увеличивает размер программы и замедляет её выполнение. Рекомендуется включать эту опцию только в отладочной версии программы.

Опция "I/O checking" имеет смысл только для кода, который работает с файлами Паскаля. Поскольку этот механизм считается устаревшим, мы не будем рассматривать её здесь.

Опция "Overflow checking" (директива {$Q+} или {$Q-}) похожа на опцию "Range checking", только проверочный код добавляется для всех арифметических целочисленных операций. Если результат выполнения такой операции выходит за размерность (происходит переполнение результата), то возбуждается исключение класса EIntOverflow. Пример — к байтовой переменной, равной 255, прибавляется 2. Должно получиться 257, но это число больше того, что помещается в байте, поэтому реальный результат будет равен 1. Это и есть переполнение. Эта опция используется редко по трём причинам. Во-первых, самый разный код может рассчитывать на то, что эта опция выключена (часто это различного рода криптографические операции, подсчёт контрольной суммы и т.п., но не только). В связи с этим при включении этой опции могут начаться совершенно различные проблемы. Например: Ошибка в Delphi RTL — функция GetPropValue неверно работает с со свойствами типа Cardinal. Во-вторых, в обычных ситуациях работают с четырёхбайтовыми знаковыми величинами, и работа около границ диапазонов представления происходит редко. В-третьих, арифметические операции с целыми — достаточно частый код (в отличие от операций с массивами), и добавление дополнительной работы на каждую операцию иногда может быть заметно (в смысле производительности).

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

1). Ошибки программиста.

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

2). Ошибки клиентского кода.

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

3). Ошибки доступа к ресурсам.

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

1.2.2. Обработка исключений

Итак, с созданием исключения должно быть уже что-то понятно. Как только исключение возбудили — нормальная работа программы останавливается:

raise EAbort.Create('Операция отменена пользователем.');
// операторы ниже никогда не будут выполнены, т.к. выполнение
// этого блока кода прервано возбуждением исключения.
Exit; // никогда не выполняется

Что же происходит потом? Исключение остаётся возбуждённым до тех пор, пока не будет обработано или пока не произойдёт выход из программы. После возбуждения исключения запускаются блоки обработки исключения.

В Delphi они бывают двух видов: try-finally и try-except. Первый блок реализует схему, когда вам ну просто необходимо выполнить какой-то блок кода, даже при возникновении исключения (т.е. использовать код "очистки"). Например:

// обычный код
try
  // защищаемый блок. Если здесь возникло исключение, то управление
  // переходит к коду между finally и end (т.н. блок finally).
finally
  // выполняется всегда, вне зависимости от того,
  // возникло ли в защищаемом блоке исключение или нет.
  // не выполняется, если исключение возникло до начала всей конструкции try-finally.
  // после выполнения блока finally исключение остаётся необработанным.
end;
// обычный код 
// не выполняется, если выше возникло исключение (выше - это до try,
// в защищаемом блоке или в блоке finally), т.к. исключение после выхода
// из try-finally будет "активно".

Блок кода между словами try и finally (а также try и except, см. ниже) называется защищаемым блоком.

Заметим, что блок try-finally не обрабатывает исключения — он просто позволяет реагировать на них. После завершения блока try-finally исключение всё ещё возбуждено.

Кстати, блок try-finally работает не только с исключениями, но и с операциями типа Exit, например:

procedure A;
begin
  try
    // обычная работа 
    if SomeCondition then
      Exit; // сперва выполнится блок finally и лишь затем произойдёт выход из процедуры
    // ещё работа
  finally
    // действия, которые гарантировано нужно выполнить перед выходом из процедуры 
  end;  
end;

Короче говоря, try-finally гарантирует выполнение блока finally в любых условиях (ладно, кроме убийства программы или потока :) ). Кстати, использование try-finally накладывает ограничения на оператор goto: переход по goto не может пересекать границы блоков try-finally (и try-except).

Обычно такая конструкция используется при работе с ресурсами:

// выделили/захватили ресурс
try
  // защищаемый блок: работаем с ресурсом
finally
  // освободили ресурс
end;

Это гарантирует отсутствие утечек памяти, вечной блокировки общих ресурсов и т.п. Если блока try/finally нет, то возможна ситуация, когда в (бывшем) защищаемом блоке возникнет исключение, и тогда весь код ниже будет пропущен — в том числе и код по освобождению ресурса. Заметим, что от EAccessViolation в любом месте никто не застрахован :) Так что никогда не следует полагаться на то, что в блоке кода никогда не возникнет исключения. Секции try-finally могут быть и вложенными, например:

var
  P: Pointer;
  Size: Integer;
  C: TSomeObject;
// ...
// В коде ниже Buffer - общий ресурс, защищаемый критической секцией BufferCS
begin
  // 1. ... какая-то работа
  GetMem(P, Size); // 2. выделили память под буфер для работы
  try
    EnterCriticalSection(BufferCS); // 3. заблокировали какой-то общий ресурс
    try 
      Move(Buffer, P^, Size);  // 4. какая-то работа с общим ресурсом
    finally
      LeaveCriticalSection(BufferCS); // 5. гарантированно разблокировали общий ресурс
    end;
    C := TSomeObject.Create(P);  // 6. создали объект для работы
    try
      // 7. ... какая-то работа с объектом 
    finally
      FreeAndNil(C);  // 8. гарантировано освободили объект 
    end;
    // 9. ... ещё какие-то действия
  finally
    FreeMem(P); // 10. гарантировано освободили память
    P := nil;
  end;
  // 11. ... продолжение работы
end;

При выполнении такого кода без возникновения исключения он выполняется в обычном порядке сверху-вниз (1-11). При возникновении исключения, блоки обработки исключения вызываются друг за другом, в порядке от самого вложенного к внешнему. Например, если исключение возникнет, скажем, в блоке #4, то код будет выполняться в такой последовательности: 1-5, 10. При возникновении в блоке #7: 1-8, 10. В блоке #6: 1-6, 10. Процесс выполнения всех блоков обработки исключения называется раскруткой исключения.

Блок try-finally не позволяет обрабатывать исключения как таковые — мы можем только лишь среагировать на их появление. Блок finally выполняется всегда — и когда исключение возникло, и когда — нет. Хотя мы можем определить сам факт возникновения исключения — по глобальной переменной ExceptObject (реально это функция). Она равна nil, если исключения не возникло, и содержит объект исключения, если оно было возбуждено. Её, кстати, можно использовать в любом месте программы. Но после завершения finally-блока исключение всё ещё остаётся необработанным, и управление получает вышестоящий обработчик исключения.

Для собственно обработки исключений мы можем использовать блок типа try-except:

// обычный код
try
  // защищаемый блок
  // если здесь возникает исключение, обработка передаётся в блок except
except
  // обработка исключения, этот блок НЕ вызывается,
  // если в защищаемом блоке не произошло исключение
  // здесь мы можем обработать исключение или не обрабатывать его
  // если мы его обработаем, то выполнение кода продолжится
  // сразу после end блока try-except
  // если исключение не будет обработано, то управление получит
  // вышестоящий блок except или finally
end;
// обычный код.
// не выполняется только в случае, если исключение возникло
// в защищаемом блоке и не было обработано в блоке except.

Обрабатывать исключения можно двумя способами: все подряд и с отбором. Все подряд исключения мы можем обрабатывать так:

// Внимание: это учебый пример. В реальных программах
// так прямолинейно поступать не рекомендуется. См. 2.6.1 и 2.6.7
try
  Result := CalcSomething;
except
  Result := -1; 
end; // исключение было полностью обработано,
     // поэтому выполнение продолжится после этого end

В этом примере Result получит некоторое, вычисляемое функцией, значение или -1 при любой ошибке (исключении).

Для условной обработки мы можем использовать такую конструкцию (в квадратные скобки заключены необязательные части):

try
  // ...
except
  on [VarName1:] ExceptionClass1 do // один или более фильтров
    Something1;
  on [VarName2:] ExceptionClass2 do
    Something2;
  // ... и т.п. сколь угодно много раз
  [else  // все прочие исключения
    Something3;]
end;

Здесь VarName1-2 — это произвольные имена переменных, ExceptionClass1-2 — имена известных классов исключений, Something1-3 — корректные операторы языка, выполняющие действия по обработке. Если вы хотите выполнить несколько операторов после do или else, то их нужно заключить в begin-end.

При этом фильтрация происходит следующим образом. При возникновении исключения, мы попадаем в блок except. Вспомним, что исключение у нас возбуждается какого-то определённого класса (Exception, EAccessViolation, EStreamError и т.п.). При выполнении блока except класс исключения сравнивается с каждым фильтром вида "on [VarName:] ExceptionClass do" в блоке except. Если класс исключения — это ровно ExceptionClass или унаследованный (обратите внимание: это важно) от него класс, то будут выполнены операторы после "do". После этого исключение считается обработанным и выполнение блока except заканчивается. Если же класс исключения не подошёл — берётся следующий по списку "on". Фильтры просматриваются сверху вниз. Поэтому бессмысленно делать так:

try
  // ...
except
  on EMathError do 
    Something1;

  on EDivByZero do 
    Something2;  // никогда не сработает, т.к. EDivByZero будет
                 // обработан выше, поскольку он унаследован от EMathError

  on Exception do  // перехватывает вообще все исключения,
                   // т.к. все исключения являются наследниками от Exception.
                   // Исключения, унаследованные от EMathError, сюда не попадают,
                   // т.к. они обработаны выше.
    Something3;

  on EAccessViolation do // вообще никогда не выполняется, т.к. все исключения
                         // точно отфильтруются предыдущим блоком
    Something4; 
end;

Нужно выстроить обработчики в таком порядке:

try
  // ...
except
  on EDivByZero do // нельзя ставить после EMathError или Exception 
    Something2;
  on EMathError do // нельзя ставить после Exception
    Something1;
  on EAccessViolation do // можно сдвинуть на любую позицию выше, но не ниже
    Something4; 
  on Exception do // всегда должен идти последним
    Something3;
end;

Если все фильтры пройдены, но подходящий так и не найден, то вызываются операторы, стоящие после else (если этот блок присутствует). Если же блок с else отсутствует — то исключение считается необработанным и управление получает вышестоящий обработчик except или finally.

Например:

try
  Stream.ReadBuffer(Result, SizeOf(Result));
  Result := 1 / Result;
except
  on EStreamError do // Если возникло исключение класса EStreamError
    Result := DefaultValue; // то выполнить эти действия, иначе - продолжить фильтрацию
  on EDivByZero do // Если возникло деление на 0, то выполнить блок begin-end ниже
  begin
    InvalidValue := True;
    Result := DefaultValue;
  end;
  // Все прочие исключения будут необработанными
end;

В этом примере мы читаем из потока некоторое значение. Если при этом возникнет ошибка чтения или если значение будет равно 0, то в Result мы запишем DefaultValue. При этом InvalidValue станет True, если файл содержит ошибочные значения.

Кстати, указание фильтра "on Exception do" в конце списка фильтров фактически эквивалентно указанию блока else. Ещё можно привести такую аналогию, код:

try
  // ...
except
  on EDivByZero do 
    Something2;
  on EMathError do
    Something1;
  on EAccessViolation do
    Something4; 
  else
    Something3;
end;

Эквивалентен (псевдокод):

try
  // ...
except
  // пусть здесь переменная E: Exception представляет собой возникшее исключение
  if E is EDivByZero then 
    Something2
  else
  if E is EMathError then 
    Something1
  else
  if E is EAccessViolation then
    Something4
  else
    Something3;
end;

Также в фильтре "on ... do" можно указывать переменную исключения. Тогда в блоке после "do" будет доступна переменная с таким именем и указанным классом. Она представляет собой исключение, которое было возбуждено. Это можно использовать для доступа к свойствам исключения, например:

try
  // ...
except
  on Err: EStreamError do // Err - это имя, которое вы выбираете сами.
                          // Это эквивалентно объявлению переменной.
                          // Объявление действует только в пределах блока после "do" 
    ShowMessage('Возникла ошибка: ' + Err.Message); // Здесь Err -
                                                    // переменная типа EStreamError
end;

В блоке except в любом месте вы также можете указать ключевое слово raise (без параметров). Это будет указанием к перевозбуждению исключения. Т.е.:

function F: TSomeObject;
begin
  Result := TSomeObject.Create;
  try
    // некоторые действия по работе с Result
  except
    Result.Free; // при любой ошибке мы должны освободить Result,
                 // чтобы не было утечек ресурсов. При нормальной работе блок
                 // except не активируется и Result не освобождается.
    raise; // Мы не хотим обрабатывать здесь исключение, поэтому перевозбуждаем его.
    // операторы после raise не выполнятся, т.к. было возбуждено исключение.
  end;
end;

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

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



Необработанное исключение в Windows XP


Необработанное исключение в Windows Vista

Однако если отключено уведомление о критических ошибках, то программа будет закрыта вообще без какого-либо сообщения. Более подробно об отчёте об ошибках Windows (и службе Windows Error Reporting) мы поговорим ниже, в разделе 2.5.

Правда, в VCL-приложениях обычно весь пользовательский код обёрнут в try/except (поэтому, в VCL приложении необработанных исключений не бывает почти никогда). В случае если пользовательский код не обрабатывает исключение (в обработчике сообщения Windows), то вызывается событие Application.OnException (по-умолчанию оно не назначено), а если оно не назначено — то для исключения вызывается метод Application.HandleException, который просто отображает сообщение (Message), ассоциированное с исключением:


Случай обработки исключения VCL (модулем Forms)

После чего выполнение программы продолжается (начинается обработка следующего сообщения Windows).

Даже, если пишется не VCL приложение, то при использовании SysUtils, вызывается его (модуля SysUtils) обработчик необработанных исключений, который отображает на экране такое сообщение (и завершает программу, разумеется):


Случай обработки исключения модулем SysUtils

А без использования SysUtils необработанное исключение Delphi приводит к возникновению run-time ошибки номер 217/230 (об этом мы ещё подробнее поговорим позже), после чего программа также закрывается:


Случай обработки исключения модулем System

Для аппаратных исключений появляется такое же сообщение, но только с номером ошибки, соответствующим исключению (подробнее — чуть ниже).

Так что, для того, чтобы получить истинно необработанное исключение в программе на Delphi — нужно довольно сильно "постараться".

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

Диаграмму надо понимать так, что показанное сообщение об ошибке действует с указанного момента и ниже, пока на схеме не встретится другое сообщение. Например, если напротив строки "Установка обработчика исключений" показан рисунок с сообщением об ошибке модуля System "Runtime error 217 at XXX", то это значит: если исключение возникнет в этом блоке (начиная с этой строки и до строки "Инициализация SysUtils, установка обработчика исключений"), то будет показано именно это сообщение об ошибке (т.е. исключение будет обрабатываться модулем System). Напомним, что схема весьма примерная и полная последовательность выполнения на ней не показана. Отображены лишь ключевые моменты. Основное предназначение схемы — показать взаимное расположение основных обработчиков исключений и сформировать у вас представление об общей картине. Теперь, увидев различные типы сообщений об ошибках, вы можете примерно предположить место возникновения исключения. Например, если вы видите сообщение о runtime-ошибке, то, судя по приведённой схеме, маловероятно, чтобы ошибка возникла в обработчиках событий на форме. Зато гораздо вероятнее, что она возникает, скажем, в какой-то секции finalization (которая выполняется после секции finalization модуля SysUtils) или в назначенной процедуре ExitProcessProc. Но, разумеется, причина ошибки может сидеть где угодно — в том числе и в упоминаемых обработчиках событий.

Кроме того, в отладчике Delphi (см. также пункт 2.1.1 — как работать с отладчиком) есть полезнейшая возможность раннего уведомления об исключениях. Каждый раз, когда в программе возникает исключение, отладчик отображает такое окно:


Вид окна в старых Delphi

Вид окна в новых Delphi

Формат сообщения всегда одинаков: "Project XXX raised exception class YYY with message ZZZ". Где XXX — имя процесса (проекта), где возникло исключение, YYY — имя класса исключения и ZZZ — сообщение об ошибке в объекте исключения.

Это окно возникает прямо в момент возбуждения исключения до того, как получит управление хоть один блок обработки исключения. Заметим, что окно это появляется только при отладке. Его появление во время запуска программы из-под Delphi ещё не говорит о том, что при запуске программы вне среды появится хоть какое-то сообщение. Нажав на "Continue" (только в новых Delphi), вы продолжите выполнение программы (с первого блока обработки исключения), а нажав на "Break"/"Ok", вы перейдёте в отладчик, где сможете исследовать ситуацию возникновения исключения.

Иногда в этом окне также появляется опция "Show CPU view":


Она показывается только в том случае, если место возникновения исключения не соответствует строчке исходного кода. Если галочка будет установлена, то после нажатия на "Break" откроется окно CPU-отладчика точно спозиционированное на место возникновения ошибки, иначе (галочка сброшена) — редактор исходного кода с ближайшим местом по стеку вызова (если отладчик вообще сумеет что-то найти).

Заметим, что опция "Show CPU view" показывается достаточно редко. Кстати говоря, её отсутствие в окне уведомления отладчика ещё не говорит о том, что при нажатии на "Break" вы не увидите CPU-отладчика. Более подробно об отладчике мы ещё поговорим в начале раздела 2.

Отметив галочку "Ignore this exception type" (только в новых Delphi), вы можете добавить исключение в список игнорируемых отладчиком. Отладчик реагирует не на все исключения. Например, по-умолчанию он не реагирует на исключения EAbort (и унаследованные). Как реагировать на то или иное исключение — это настраивается в опциях отладчика:



Настройки отладчика в старых Delphi


Настройки отладчика в новых Delphi

EAbort — специальное "тихое" исключение (специально оно только тем, что по-умолчанию внесено в список игнорируемых исключений отладчиком и его нельзя оттуда удалить). Для возбуждения исключения EAbort (с сообщением "Operation aborted") есть даже специальная процедура Abort. Это исключение предназначено для прерывания обработки текущего защищаемого блока кода и должно либо игнорироваться в обработчиках исключений, либо гаситься (без дополнительной обработки). В частности, обработчики VCL игнорируют EAbort, вовсе не показывая обычное сообщение об ошибке. В некоторых случаях EAbort можно использовать для отмены действия в обработчиках событий, которые не предусматривают флага продолжения типа Handled. В этом смысле вызов Abort эквивалентен вызову Exit. Только Exit выходит из текущей подпрограммы (процедуры или функции), а Abort — из текущего блока обработки исключений, т.е. он может выходить как из части процедуры, так и сразу из нескольких процедур (смотря как расставлены блоки except).

Иногда вместо обычного уведомления отладчика появляется такое:


Это окно появляется, когда ваша программа, запущенная из-под отладчика, сейчас будет завершена из-за появления истинно необработанного исключения. Т.е. исключение умудрилось пройти сквозь все обработчики в программе и осталось неперехваченным. Если бы программа не была запущена под отладчиком, то вы бы увидели обычный диалог WER (Windows Error Reporting) "Обнаружена ошибка. Программа будет закрыта" (ну или она бы аварийно закрылась вообще безо всякого сообщения). Обычно такие ошибки непросто диагностировать, потому что, скорее всего, к моменту возбуждения исключения в вашей программе не работает никакой служебный код.

Также заслуживают упоминания ещё одна специальная конструкция языка — отладочные проверки: процедура Assert и исключение EAssertionFailed. Суть их заключается в следующем: бывает, что некоторый код может быть доступен только в отладочной версии. Например, проверка аргумента функции на nil. В отладочной версии мы хотим проверять это условие, т.к. мы пишем сырой код, который ещё не отлажен и может передавать nil в функцию. В финальной же сборке мы хотим вырезать это условие, т.к. предполагается, что функцию не должны вызывать с nil-ом. Ну а если вызовут — то ничего страшного, если мы вылетим с EAccessViolation (подробнее об этой логике см. пункт 2.6.28). Конечно, можно такой проверочный код оформлять директивами условной компиляции "{$IFDEF DEBUG}", но это страшно неудобно. Поэтому в язык была добавлена процедура Assert:

procedure Assert(expr: Boolean); overload;
procedure Assert(expr: Boolean; const msg: string); overload;

Она предназначена для проверки того, что некоторое условие всегда истинно и никогда не нарушается. Если её первый аргумент равен True, то она ничего не делает, а если False — то процедура возбуждает исключение класса EAssertionFailed. При отключенном SysUtils — run-time ошибку с номером 227. Сообщение исключения (свойство Message) будет "Assertion failure (D:\Documents\RAD Studio\Projects\Unit1.pas, line 40)" (разумеется, вместо моего пути у вас будет путь к вашему модулю, где стоит ваш Assert). Для второй формы Assert вы можете указать своё сообщение, и тогда оно появится в свойстве Message исключения вместо "Assertion failure".

Пока ничего особенного в этой процедуре не было. А вот теперь её специальность: если мы переключим профиль (только в новых Delphi) с отладочного (debug) на финальный (release), то все вызовы Assert просто не будут компилироваться! Т.е. в коде-то они останутся, но вот в готовую программу не попадут. На самом деле, будут ли создаваться проверки Assert или нет — это определяется настройками компилятора: в опциях проекта это опция "Assertions" на вкладке "Compiler". Переключение опции вручную — единственный способ управлять компиляцией Assert-ов в старых Delphi. Также имеется возможность управлять созданием проверок Assert с помощью директив {$ASSERTIONS ON/OFF} или {$C+/-} (короткая форма).

Использовать эту конструкцию можно, например, так:

// Эту процедуру мы назначаем куче кнопок
procedure TfmMain.ButtonsClick(Sender: TObject);
begin
  // Положим, эта процедура может вызываться не только
  // по нажатию на кнопку, но и из нашего кода.
  // Поэтому проверим, что Sender - это кнопка
  Assert(Sender is TButton);
  // Код ниже не нуждается в дополнительных проверках, т.к. Assert
  // гарантирует нам, что Sender - это TButton
  with TButton(Sender) do
  begin
    // ... 
  end;
end;

Или:

procedure TfmMain.DoSomething(AList: TStrings);
begin
  Assert(AList <> nil, 'TfmMain.DoSomething: AList должен быть <> nil.');
end;

Главное не переборщите с этим. Assert предназначен именно для отлова ситуаций, которые могут (ошибочно) возникнуть в отлаживаемом коде, но никогда — в финальной версии. Не следует заключать вообще все проверки в Assert, т.к. при сборке финальной версии проверки Assert в неё не попадут, и ваша программа останется без проверок вообще! Если вы не уверены в своём выборе — лучше поставьте обычную проверку, чем Assert. Конечно, вы в принципе можете включить Assert-ы и в финальную версию (путём установки директив), но это будет использование инструмента не по назначению (о том, как получить для любого исключения имя модуля и номер строки возникновения мы поговорим в разделе 2). Самое простое правило для расстановки Assert: Assert должен проверять условия, которые никогда не могут возникнуть. Вот так. Именно в этом его предназначение. На первый взгляд это может показаться странным: зачем проверять условия, которые никогда не возникнут? На самом деле, если подумать, то ничего странного тут нет. Ключевыми для понимания словами будут: "условия, которые не должны возникнуть". Вы можете поручиться за выполнение этих условий? А вот Assert как раз и гарантирует их выполнение. См. также интересный вопрос.

Заметим, что возникновение исключения в программе (и появление окна отладчика) ещё не говорит об ошибке — это может быть и вполне штатная ситуация, в этом случае нужно просто нажать "Continue" (в новых Delphi) или перейти в отладчик и нажать F9 или "Run"/"Run" (в старых Delphi). Однако бывают ситуации, когда эти уведомления отладчика мешаются. Например, исключения возникают в программе часто, а с ними вполне справляется встроенная обработка исключений (т.е. это штатная ситуация), и мы не хотим обращать на них внимание. В этом случае есть несколько способов работы. Например, можно запустить программу вне среды Delphi или, что то же самое, через "Run"/"Run without debugging" (Shift + Ctrl + F9):


Но в этом случае отладчик вообще не будет доступен. Зато это самый быстрый и простой способ (фактически он эквивалентен компиляции приложения и последующему его запуску вне IDE).

Вариант два — в настройках отладчика снять галочку "Notify on language exceptions" (см. рисунки настроек отладчика выше) или "Stop on Delphi Exceptions" (в старых Delphi). Снятие этой галочки просто заставляет отладчик игнорировать все типы исключений. Для удобства можно и "Run without debugging" и "Notify on language exceptions" вынести на панель инструментов среды:


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

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

Хотя собственно обработка исключений может производиться в любой программе, но полноценная (с точки зрения Delphi) поддержка будет доступна только в том случае, если в программе подключается модуль SysUtils.pas. В этом модуле определены все основные классы исключений. Также, без подключенного SysUtils.pas отладчик Delphi не сможет реагировать на исключения. Но самое главное — это то, что без подключенного SysUtils.pas возникновение любой run-time ошибки не приводит к возникновению исключения. Вместо этого вызывается обработчик run-time ошибок (System.ErrorProc), который по-умолчанию (т.е. если вы его сами вручную не переопределили) немедленно завершает приложение с сообщением:


В данном случае это ошибка номер 216. При подключенном модуле SysUtils.pas эта ошибка привела бы к возникновению исключения типа EAccessViolation. Для исключений Delphi (не аппаратных) код ошибки будет 217:

// Обработчик исключений Delphi в модуле System
procedure _ExceptionHandler;
...
@@noExceptProc: // необработанное исключение и пользовательский обработчик не установлен
        MOV     ECX,[ESP+4]
        MOV     EAX,217  // сгенерировать run-time ошибку номер 217
        MOV     EDX,[ECX].TExceptionRecord.ExceptAddr
        MOV     [ESP],EDX
        JMP     _RunError
...

В документации и по исходным кодам сказано, что 217 — это run-time ошибка, возникающая при нажатии клавиш Ctrl + C (остановка выполнения программы в консольных приложениях). В некоторых версиях Delphi даже сказано, что код ошибки типа "необработанное исключение" — это 230. Почему имеется такое странное поведение и несоответствие реального кода и документации? Проблема в том, что никакой ошибки номер 230 в Delphi вообще нет.

Когда возникает исключение, нужно сгенерировать хоть какую-то run-time ошибку (разработчики Delphi руководствовались соображением: ни за что не вылетать!), поэтому при реализации был выбран ближайший по смыслу код — 217 (общий связующий смысл: останов программы). Когда вышел Kylix (Delphi под Linux), то в нём ввели искусственный код ошибки 230 и связали его с ситуацией необработанного исключения:

// Обработчик необработанных исключений в Kylix
procedure _UnhandledException;
type TExceptProc = procedure (Obj: TObject; Addr: Pointer);
begin
  if Assigned(ExceptProc) then
    TExceptProc(ExceptProc)(ExceptObject, ExceptAddr)
  else
    // Нет пользовательского обработчика - генерируем run-time ошибку номер 230
    RunErrorAt(230, ExceptAddr);
end;

И отразив этот момент в документации. При этом соответствующее поведение в Delphi не поменяли (возможно, из-за соображений совместимости?) и в документации никак не отразили, что, на самом деле, поведение в Delphi и Kylix — разное. Отсюда и полезло это разногласие.

Примечание: соответствующий отчёт в Quality Central, версия для D2009.

Подробнее о кодах run-time ошибок можно посмотреть в справке Delphi в темах "Delphi Runtime Errors" или "Runtime errors". Для ручного возбуждения run-time ошибок при отключенном SysUtils.pas можно воспользоваться процедурой RunError (подробнее см. в справке Delphi). См. также статью "Генерация и обработка исключений без подключения SysUtils".

Кстати, если будете смотреть исходники RTL, то вам может встретиться условная директива PC_MAPPED_EXCEPTIONS. Для Delphi/Windows она всегда выключена и используется только в Kylix/Linux.

1.2.3. SafeCall-исключения

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

Хоть эта статья и не говорит о программировании COM, но, тем не менее, мы считаем целесообразным упомянуть про SafeCall-исключения. Чуть позже (например, в пункте 1.3.5) мы будем говорить о том, что исключения не должны пересекать границы модулей. Пока заметим только, что эта проблема связана с различной обработкой исключений в разных языках (грубо говоря: программа на C++ ничего не знает про исключения Delphi). И такое поведение для COM является неотъемлемым требованием и сделано обязательным правилом. Однако возня с кодами ошибок является страшно неудобной. Более того, до COM не существовало стандарта на то, как передавать дополнительную информацию. В COM для этого есть IErrorInfo, CreateErrorInfo и т.п. Мы не будем разбирать здесь системную реализацию, а посмотрим, что предлагает нам Delphi. Сразу отметим, что говорить в этом пункте мы будем про объекты и методы, но, в принципе, те же факты справедливы и для простых функций. Но для простых функций компилятор не использует всю свою "магию", поэтому обычно нет смысла использовать safecall-для простых функций: не получается полной автоматизации.

Заранее оговоримся, что мы не будем здесь затрагивать COM, TComObject и т.п. Мы говорим о SafeCall в чистом виде. Но когда мы разберём эту тему до конца, вы самостоятельно сможете посмотреть работу SafeCall для COM: ничего нового там не будет.

Итак, в Delphi есть понятие safecall-вызова. Он характеризуется тем, что сам компилятор следит за тем, чтобы исключение не вышло за пределы метода в явном виде, а только в виде кода ошибки. Любое исключение, пересекающее границу SafeCall-метода таким образом, иногда называют safecall-исключением. По сути же оно ничем не отличается от остальных исключений. Для примера рассмотрим, например, такой простой объект:

type
  ETestException = class(Exception);

  TTestObject = class(TObject)
    function TestMe: Integer; safecall;
  end;

function TTestObject.TestMe: Integer; safecall;
begin
  raise ETestException.Create('Тестовое исключение.');
  Result := -1;
end;

Здесь мы самым нахальным образом нарушаем требование COM о том, что метод обязан ловить все исключения и преобразовывать их в HRESULT.

Из-за того, что метод помечен как SafeCall, компилятор предпринимает дополнительные действия. Во-первых, несмотря на то, что мы объявили метод как возвращающий значение типа Integer, компилятор воспринимает его, как возвращающий тип HRESULT. Посмотрим на скомпилированный код в виде псевдокода:

function TTestObject.TestMe(out AResult: Integer): HResult; stdcall;
begin
  try

    // Начало кода функции 
    raise ETestException.Create('Тестовое исключение.');
    AResult := -1;
    // Конец кода функции

    Result := S_OK; 
  except
    on E: Exception do
      Result := HandleAutoException(E);
  end;
end;

Как видим, кроме модификаций прототипа (заголовка), компилятор обернул тело функции в try-except. Грубо говоря, HandleAutoException делает две вещи: вызывает виртуальную функцию TObject.SafeCallException и удаляет объект исключения E (помните: исключение не должно выйти за пределы метода!). Назначение этой функции просто: вы должны конвертировать в ней исключение в код ошибки. Поскольку TObject ничего не знает о том, как вы хотите обрабатывать исключения, ни о том, какие исключения могут возникнуть в ваших методах, то его умалчиваемая реализация предельно проста:

function TObject.SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer): HResult;
begin
  Result := HResult($8000FFFF); { E_UNEXPECTED }
end;

Чуть позже мы приведём пример своей реализации этого метода, а пока посмотрим, как работает магия компилятора при вызове SafeCall-метода. Код "I := TestObject.TestMe;" компилятор компилирует в:

CheckAutoResult(TestObject.TestMe(I));

Где CheckAutoResult проверяет возвращаемое значение и, если оно неуспешно (в смысле HRESULT), то вызывает функцию SafeCallErrorProc, а если она не назначена — то возбуждает run-time ошибку номер 229, которая при подключенном SysUtils преобразуется в ESafeCallException.

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

Итак, возвращаемся к объектам. В реализации по-умолчанию в TObject исключения в safecall-методах перехватываются и конвертируются в код ошибки E_UNEXPECTED, на вызывающей стороне при отключенном SysUtils это приводит к возникновению runtime-ошибки 229:


Обработка SafeCall-исключения модулем System

При подключенном SysUtils мы получаем ESafeCallException ("Исключение в SafeCall-методе"):


Обработка SafeCall-исключения модулем SysUtils

А при подключении модуля ComObj подключается пользовательская процедура SafeCallErrorProc, которая возбуждает EOleException, которое, в отличие от ESafeCallException, уже учитывает информацию об исключении (напомним, что E_UNEXPECTED — это ошибка типа "Разрушительный сбой"):


Обработка SafeCall-исключения модулем ComObj

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

Как вы уже поняли, много полезных функций для SafeCall-методов находятся именно в модуле ComObj. Например, там есть функция HandleSafeCallException, позволяющая передать вместе с кодом ещё и дополнительную информацию об исключении. При этом используются стандартные способы ОС, поэтому этой информацией может воспользоваться вызывающий код. К сожалению, доступно только ограниченное количество полей для передачи. Во-первых, это код ошибки. Если возникшее исключение будет класса EOleSysError, то код ошибки возьмётся из свойства ErrorCode объекта исключения, для всех прочих исключений это будет E_UNEXPECTED. Во-вторых, это само сообщение исключения (свойство Message исключения). В-третьих, это GUID объекта, возбудившего исключение. Может быть пустым GUID или вы можете сгенерировать GUID для своего объекта и указать его. Только не забудьте, что GUID должен быть уникальным — не следует использовать один и тот же GUID для двух разных классов. Далее, это идентификатор места возникновения ошибки — произвольная строка (иногда здесь удобно передавать имя класса исключения). И, наконец, имя файла справки, ассоциированного с исключением. Если в вашем приложении есть файл справки с описанием ошибок, то в этом поле должно идти полное имя этого файла справки. Конкретный контекст (раздел справки) берётся из свойства HelpContext объекта исключения. Несмотря на то, что у HandleSafeCallException есть параметр ExceptAddr, в текущей реализации под Windows он не используется. Заметим, что его обычно и не нужно передавать. Дело в том, что исходное исключение всё равно заканчивает свою жизнь на границе метода, поэтому обычно этот адрес лишён смысла для клиентской (вызывающей) стороны.

Кроме того, следует сказать, что, чтобы использовать этот механизм в COM-объектах, объект должен ещё реализовывать интерфейс ISupportErrorInfo. Вызывая ISupportErrorInfo.InterfaceSupportsErrorInfo, клиентская сторона может определить, что объект поддерживает дополнительную информацию. Но для обычных объектов (не являющихся COM-объектами) этого делать, разумеется, не обязательно. Ведь достаточно просто указать в документации к своему коду, как его нужно использовать. Например, такие слова: Delphi-программисты — используйте SafeCall; все остальные — используйте IErrorInfo. См. также "ISupportErrorInfo Interface" и "COM objects must implement the ISupportErrorInfo interface if the COM objects are consumed by a Visual Basic application".

Итак, с учётом сказанного, мы можем дописать наш пример так:

const
  TestObjGUID: TGUID = '{9044E2E9-B9D9-4E03-9264-8CB0BFB65FD0}';

type
  ETestException = class(Exception);

  TTestObject = class(TObject)
    function SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer):
      HResult; override;
    function TestMe: Integer; safecall;
  end;

function TTestObject.TestMe: Integer; safecall;
begin
  raise ETestException.Create('Тестовое исключение.');
  Result := -1;
end;

function TTestObject.SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer): HResult;
begin
  // Здесь Result - это код ошибки, вы можете вернуть свой код
  // в зависимости от типа исключения.
  // HandleSafeCallException релизует стандартное добавление информации к исключению
  Result := HandleSafeCallException(ExceptObject, ExceptAddr, TestObjGUID,
    String(ExceptObject.ClassName), '' { файл справки });
end;

Теперь при вызове метода TestMe мы получим более подробное сообщение об ошибке (разумеется, только при подключенном модуле ComObj):


Обработка SafeCall-исключения с дополнительной информацией

Как видим, чисто визуально картина не отличается от обработки обычного (не SafeCall) исключения модулем Forms. При этом на вызывающей стороне возбуждается исключение типа EOleException, у которого заполнены свойства ErrorCode (для нашего примера это E_UNEXPECTED), Message ('Тестовое исключение.'), Source ('ETestException'), HelpFile ('') и HelpContext (0). GUID объекта в нашей реализации никуда не сохраняется. Разумеется, ничто не мешает вам переопределить поведение по-умолчанию: заменить SafeCallErrorProc на свою функцию, в которой, например, возбуждать не EOleException, а конкретное исключение, в зависимости от кода ошибки или поля Source. Например, можно возбуждать EOSError, если код принадлежит FACILITY_WIN32. Разумеется, предварительно нужно добавить в TTestObject.SafeCallException преобразование EOSError (в частности свойства ErrorCode) в нужный код HRESULT (немного жаль, что в RTL/VCL нет готовой реализации для этого).

Кроме этого, в ComObj есть объекты типа TComObject и TAutoIntfObject, которые также заменяют стандартный TObject.SafeCallException. Например, чтобы можно было повесить обработчик исключений — см. TComObject.ServerExceptionHandler.OnException. Впрочем, как уже было сказано, COM-программирование — не тема этой статьи.

1.2.4. Исключения — неочевидные факты и подводные камни

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

1). Блок finally выполняется всегда, вне зависимости от того, вызывается ли внутри блока Exit, Break, Continue, Abort, Application.Terminate, TForm.Close или возбуждается исключение Delphi. Единственное исключение из этого правила (не будем учитывать неожиданное отключение электропитания :) ) — ExitProcess/ExitThread и все, кто их вызывают (например, Halt). Основной проблемой тут является, что программист может поставить двойное освобождение ресурса. Если для некоторых видов ресурсов это не критично (например, удаление объекта с помощью FreeAndNil) и может сойти вас с рук, то для других это может быть фатально — например, для критической секции:

CS.Enter;
try
  ...
  if SomethingBadHappens then
  begin
    CS.Leave; // грубая ошибка
    Exit;
  end;
  ...
finally
  CS.Leave; // здесь может произойти повторное освобождение ресурса
end;

В этом примере при условии SomethingBadHappens произойдёт двойное освобождение ресурса (критической секции): один раз в begin/end при Exit, второй раз — в finally. При этом нарушается одно из основных правил для всех объектов синхронизации: вы не должны освобождать объект, которым не владеете (при первом освобождении вы владеете объектом и отпускаете его, при втором — освобождаете объект, которым не владеете). И тогда, при ближайшем же после нашего кода CS.Enter, произойдёт навечная блокировка потока (в MSDN: "If a thread calls LeaveCriticalSection when it does not have ownership of the specified critical section object, an error occurs that may cause another thread using EnterCriticalSection to wait indefinitely"). Забавно, что мы получаем блокировку, используя всего один поток (вместо привычной многопоточной блокировки). Правда такое поведение критических секций проявляется только до Windows Vista — в Vista это поведение изменено.

2). Код после возбуждения исключения никогда не выполняется:

raise Exception.Create('');
ShowMessage('Эту строку вы никогда не увидите.');

Или:

Abort; // Возбуждается EAbort
ShowMessage('И эту строку вы никогда не увидите :)');

Или:

X := 5 / ZeroExpression; // Возбуждается EDivByZero
ShowMessage('Да что ж такое, и эту вы не увидите!');

Факт вроде бы очевидный, но иногда приходится видеть такое в чужом коде. В частности, обычно ставят Exit после raise или RaiseLastOSError (и вводят этим лишний begin/end):

if not SomeFunc then
begin
  RaiseLastOSError;
  Exit;               // <- не нужно
end;

3). Код в блоках finally и except также может вызвать исключение. Это уже не совсем очевидный факт, например:

// Например, функция возвращает объект или nil в случае ошибки.
F := TSomeObject.Create;
try
  // работа с F
except
  on E: Exception do
    FreeAndNil(F);
end;
Result := F;

Вопрос: как может этот блок кода вообще выпускать наружу исключения (предположим, что конструктор успешно создал объект)? Вроде бы в except-блоке мы обрабатываем все возникающие исключения! Ответ заключается в том, что защищаемый блок находится только между try и до finally или except, но не далее. Т.е. сам код в блоках except и finally не является защищаемым. В нашем случае приведённый код может вызвать исключение, если оно возникает в конструкторе объекта (до try) или в деструкторе объекта (который вызывается из FreeAndNil в блоке except).

Напомним, что вы также можете сами вызвать исключение из обработчика исключений, например:

// Например, функция возвращает объект или исключение в случае ошибки
// (утечек ресурсов при этом не происходит).
F := TSomeObject.Create;
try
  // работа с F
except
  on E: Exception do
  begin 
    FreeAndNil(F);
    raise; 
  end; 
end;
Result := F;

Или:

F := TSomeObject.Create;
try
  // работа с F
except
  on E: Exception do
  begin 
    FreeAndNil(F);
    raise EMyNotAvailable.Create('Запрошенная функциональность недоступна.'); 
  end; 
end;
Result := F;

4). И наоборот. Исключение, возникшее в блоках except или finally, "скроет" предыдущее исключение. Это может быть не совсем очевидно для блока finally, т.к. "есть ощущение", что блок finally вообще не обрабатывает (а, следовательно, — не глушит) исключения, например:

F := TSomeObject.Create;
try
  // работа с F, пусть здесь возникает EStreamError
finally
  FreeAndNil(F); 
  // если в деструкторе F возникает EAccessViolation, то в итоге
  // EStreamError пропадёт, а весь блок кода целиком передаст
  // исключение EAccessViolation во внешний обработчик исключений.
end;

Примечание: в некоторых случаях для нового класса Exception, появившегося в D2009, исходное исключение (EStreamError) можно прочитать из свойства BaseException — подробнее см. пункт 1.3.7.

5). Исключения, вызываемые Assert или Abort, можно обрабатывать так же, как и все остальные исключения. К примеру, можно сконвертировать assert-ислючение в тихое исключение или наоборот:

type
  // Нехорошо использовать один класс (EAbort) для разных целей, поэтому создадим свой
  EAssertionFailedSilent = class(EAbort);

...

  try
    Assert(I > 0);
  except
    on E: EAssertionFailed do
      raise EAssertionFailedSilent.Create(E.Message);
  end;

6). В блоке except важен порядок следования фильтров "on". Т.е. важно знать дерево наследований исключений (ну или смотреть его каждый раз, когда пишешь много фильтров). Этот момент мы подробно разбирали в предыдущем разделе, но всё равно программисты делают и такие ошибки.

7). Блок вида:

try
  ...
except
  on EDivByZero do  
    Action1;
  on EStreamError do
    Action2;
  else
    Action3;  
end;

эквивалентен:

try
  ...
except
  on E: Exception do
  begin
    if E is EDivByZero then
      Action1
    else  
    if E is EStreamError then
      Action2
    else
      Action3;  
  end;
end;

Второй вариант может быть полезен, если вы хотите выполнить одинаковые действия на несколько типов исключений, например:

  ...
except
  on E: Exception do
  begin
    if (E is EDivByZero) or (E is EStreamError) or (E is EListError) then
      Action1
    else
      Action2;  
  end;
end;

Кстати, блок вида:

try
  ...
except
  on EDivByZero do  
    Action1;
  on EStreamError do
    Action2;
end;

эквивалентен:

try
  ...
except
  on EDivByZero do  
    Action1;
  on EStreamError do
    Action2;
  else
    raise;
end;

8). Полноценная поддержка исключений реализуется только при подключенном модуле SysUtils. Проблема тут в том, что модуля инициализируются последовательно, т.е. в вашей программе могут быть модули, инициализация которых происходит до инициализации SysUtils. Это значит, что любое исключение в секциях initialization/finalization этих модулей (среди них могут быть и ваши!) приведёт к возникновению run-time ошибок. Простейший способ избежать этой проблемы для своего модуля — подключить SysUtils в uses своего модуля или первым в uses dpr-файла. Просто будьте аккуратны. Конечно, это не гарантирует отсутствия проблем. Дело в том, что всегда есть модули, инициализируемые до SysUtils, например — модуль System. А в System располагается, например, менеджер памяти программы. Т.е. если вы будете неаккуратны и перезатрёте служебные структуры менеджера памяти, то при выходе из программы вы немедленно получите run-time error #216 (EAccessViolation).

9). Не следует удалять объект исключения вручную. Это правило несколько расходится с обычными правилами типа "выделил ресурс — освободи его!". После возбуждения исключения оно переходит в собственность RTL языка. И ответственность за корректное освобождение объекта тоже переходит на RTL. Вам ничего делать не нужно.

10). Кратко рассмотрим служебные процедуры, относящиеся к обработке исключений (в модуле System). Здесь будем предполагать, что активна обработка исключений модуля System.

а). ExceptProc: Pointer; // procedure (ExceptObject: TObject; ExceptAddr: Pointer);

Обработчик необработанных исключений. Вызывается при появлении в программе необработанного исключения перед возбуждением run-time ошибки. По-умолчанию равен nil. В этом случае любое исключение приводит к появлению соответствующей run-time ошибки. Модуль SysUtils назначает сюда такую процедуру обработки (закрывает программу с подходящим сообщением об ошибке):

procedure ExceptHandler(ExceptObject: TObject; ExceptAddr: Pointer); far;
begin
  ShowException(ExceptObject, ExceptAddr);
  Halt(1);
end;

Напомним, что модуль Forms самостоятельно заключает обработчики всех сообщений в блок try/except и вызывает Application.HandleException на любое исключение. Поэтому исключения, возникшие в обработчиках сообщений, приводят к вызову Application.HandleException, а не к вызову ExceptProc.

б). ErrorProc: procedure(ErrorCode: Byte; ErrorAddr: Pointer);

Обработчик run-time ошибок. Вызывается перед возбуждением run-time ошибки. По-умолчанию равен nil. Возбуждение run-time ошибки приводит к показу сообщения об ошибке и немедленному завершению программы. Модуль SysUtils назначает сюда такой обработчик (возбуждает соответствующее исключение, поэтому до показа ошибки и завершения программы дело не доходит):

procedure ErrorHandler(ErrorCode: Byte; ErrorAddr: Pointer); export;
var
  E: Exception;
begin
  case ErrorCode of
    Ord(reOutOfMemory):
      E := OutOfMemory;
    Ord(reInvalidPtr):
      E := InvalidPointer;
    Ord(reDivByZero)..Ord(High(TRuntimeError)):
      begin
        with ExceptMap[ErrorCode] do
          E := EClass.Create(EIdent);
      end;
  else
    E := CreateInOutError;
  end;
  raise E at ErrorAddr;
end;

Заметим, что ErrorProc вызывается только на аппаратные run-time ошибки. Например, если в программе возникло необработанное Delphi-исключение, и ExceptProc равна nil, то будет возбуждена run-time ошибка. Но ErrorProc для неё не вызывается, т.к. эта run-time ошибка генерируется самой программой с помощью обычного RunError, который приводит к появлению сообщения об ошибке и завершению программы (см. также ниже обсуждение ErrorCode и ErrorAddr).

в). ExceptClsProc: Pointer; // function(P: PExceptionRecord): ExceptClass;

Возвращает класс исключения по параметрам системного исключения. Служит для возбуждения исключения Delphi по не-Delphi исключению (например, возбуждается исключение Delphi EAccessViolation в ответ на системное исключение STATUS_ACCESS_VIOLATION, которое мы в программе даже не видим). На самом деле, эта процедура используется только когда Delphi проверяет фильтры "on" в обработчике except. ExceptClsProc позволяет просмотреть фильтры, не создавая (экономя на создании) объекта исключения. Обычное преобразование системного исключения к исключению Delphi выполняет функция ExceptObjProc (см. следующий пункт). По-умолчанию функция равна nil. Модуль SysUtils назначает такой обработчик (сперва конвертирует код системного исключения в код run-time ошибки, а затем код run-time ошибки — в класс соответствующего исключения):

function GetExceptionClass(P: PExceptionRecord): ExceptClass;
var
  ErrorCode: Byte;
begin
  ErrorCode := Byte(MapException(P));
  Result := ExceptMap[ErrorCode].EClass;
end;

г). ExceptObjProc: Pointer; // function(P: PExceptionRecord): Exception;

Конструирует объект исключения Delphi по не-Delphi исключению. По-умолчанию равна nil. Модуль SysUtils назначает такую функцию:

function GetExceptionObject(P: PExceptionRecord): Exception;
var
  ErrorCode: Integer;

  function CreateAVObject: Exception;
  var
    AccessOp: string; // string ID indicating the access type READ or WRITE
    AccessAddress: Pointer;
    MemInfo: TMemoryBasicInformation;
    ModName: array[0..MAX_PATH] of Char;
  begin
    with P^ do
    begin
      if ExceptionInformation[0] = 0 then
        AccessOp := SReadAccess
      else
        AccessOp := SWriteAccess;
      AccessAddress := Pointer(ExceptionInformation[1]);
      VirtualQuery(ExceptionAddress, MemInfo, SizeOf(MemInfo));
      if (MemInfo.State = MEM_COMMIT) and
         (GetModuleFileName(THandle(MemInfo.AllocationBase), ModName, SizeOf(ModName)) <> 0) then
        Result := EAccessViolation.CreateFmt(sModuleAccessViolation,
          [ExceptionAddress, ExtractFileName(ModName), AccessOp,
          AccessAddress])
      else
        Result := EAccessViolation.CreateFmt(SAccessViolationArg3,
          [ExceptionAddress, AccessOp, AccessAddress]);
    end;
  end;

begin
  ErrorCode := Byte(MapException(P));
  case ErrorCode of
    3..10, 12..21:
      with ExceptMap[ErrorCode] do Result := EClass.Create(EIdent);
    11: Result := CreateAVObject;
  else
    Result := EExternalException.CreateFmt(SExternalException, [P.ExceptionCode]);
  end;
  if Result is EExternal then EExternal(Result).ExceptionRecord := P;
end;

Отсутствие ExceptClsProc и ExceptObjProc является причиной, почему при отключенном модуле SysUtils возникновение аппаратных системных исключений не приводит к возбуждению исключения, а к возникновению run-time ошибки. Т.к. если процедуры равны nil, то возбуждается соответствующая run-time ошибка (код которой получается по параметрам системного исключения функцией System.MapToRunError — аналога функции MapException в модуле SysUtils).

д). RaiseExceptionProc: Pointer; // procedure(dwExceptionCode, dwExceptionFlags, nNumberOfArguments: DWORD; lpArguments: PDWORD); stdcall;

Возбуждает исключение (как системное, так и исключение Delphi). Всегда равна стандартной WinAPI функции @Windows.RaiseException (устанавливается непосредственно перед установкой обработчика исключений модуля System) и ни в коем случае не должна быть nil. Используется всякий раз, когда нужно возбудить исключение. Можно использовать, например, для хука исключений.

е). RTLUnwindProc: Pointer; // procedure(TargetFrame: Pointer; TargetIp: Pointer; ExceptionRecord: PExceptionRecord; ReturnValue: Pointer); stdcall;

Служебная функция, которая используется для раскрутки стека вызовов функций. Это та самая функция, которая делает возможным выход из множества вложенных процедур одним махом при возбуждении исключения. Устанавливается вместе с RaiseExceptionProc и всегда равна @Windows.RTLUnwind. Не может быть равна nil. Более подробно см. "Секреты Win32 Win32™ SEH изнутри", часть 3.

ж). ExceptionClass: TClass;

Указывает на корневой класс исключений. Не используется в самой программе. Не ясно, для чего нужна эта переменная — в справке по Delphi она никак не документируется. Возможно, она предназначена для использования пользовательским кодом или отладчиком. В любом случае, модуль SysUtils записывает в эту переменную класс Exception. Если вы не используете модуль SysUtils, а используете свои классы исключений, то будет хорошей идеей записать в эту переменную свой корневой класс для исключений.

з). SafeCallErrorProc: TSafeCallErrorProc; // procedure(ErrorCode: HResult; ErrorAddr: Pointer);

Обработчик SafeCall-ошибок. Вызывается в CheckAutoResult (см. пункт 1.2.3 про SafeCall-исключения) перед возбуждением run-time ошибки. По-умолчанию равен nil. Модуль ComObj устанавливает такой обработчик (возбуждает соответствующее исключение):

procedure SafeCallError(ErrorCode: Integer; ErrorAddr: Pointer);
var
  ErrorInfo: IErrorInfo;
  Source, Description, HelpFile: WideString;
  HelpContext: Longint;
begin
  HelpContext := 0;
  if GetErrorInfo(0, ErrorInfo) = S_OK then
  begin
    ErrorInfo.GetSource(Source);
    ErrorInfo.GetDescription(Description);
    ErrorInfo.GetHelpFile(HelpFile);
    ErrorInfo.GetHelpContext(HelpContext);
  end;
  raise EOleException.Create(Description, ErrorCode, Source,
    HelpFile, HelpContext) at ErrorAddr;
end;

и). AssertErrorProc: TAssertErrorProc; // procedure(const Message, Filename: string; LineNumber: Integer; ErrorAddr: Pointer);

Обработчик assert-ошибок. Вызывается процедурой Assert вместо возбуждения run-time ошибки. По-умолчанию равен nil. Заменяется модулем SysUtils на (возбуждает соответствующее исключение):

procedure AssertErrorHandler(const Message, Filename: string;
  LineNumber: Integer; ErrorAddr: Pointer);
var
  E: Exception;
begin
   E := CreateAssertException(Message, Filename, LineNumber);
   RaiseAssertException(E, ErrorAddr, PChar(@ErrorAddr)+4);
end;

к). AbstractErrorProc: procedure;

Обработчик вызовов абстрактных методов в объекте. Вызывается перед возбуждением соответствующей run-time ошибки. По-умолчанию равен nil. Модуль SysUtils заменяет его на:

procedure AbstractErrorHandler;
begin
  raise EAbstractError.CreateRes(@SAbstractError);
end;

л). InitProc: Pointer; // procedure;

Задаёт процедуру для пост-инициализации. Некий аналог гипотетического события OnAfterUnitsInitialization. По-умолчанию равна nil. Может использоваться модулями Delphi для выполнения задач инициализации, которые должны быть выполнены после завершения инициализации (выполнение секций initialization) всех модулей. Эта процедура назначается модулями SockApp, ComObj, ComServ, OleAuto. Если вы задаёте свою процедуру, то не забудьте сохранить и вызывать предыдущую. Вызывается модулем Forms или WebBroker в методе Initialize объекта Application:

procedure TApplication.Initialize;
begin
  if InitProc <> nil then TProcedure(InitProc);
end;

Т.е. первым действием при выполнении begin-end в DPR-файле. По этой причине не рекомендуется вставлять какие-либо действия перед вызовом Application.Initialize. Если вы не используете модуль Forms или WebBroker, то не лишним будет вызвать InitProc первым же действием в своей программе.

м). ExitProc: Pointer; // procedure;

Процедура выхода, вызывается первым действием перед выходом из программы (процедурой Halt). По-умолчанию равна nil. Может использоваться модулями Delphi для назначения задач, выполняемых перед выполнением секций finalization модулей. Если вы задаёте свою процедуру, то не забудьте сохранить и вызывать предыдущую. Если вы используете модуль SysUtils, то вместо ручных манипуляций с ExitProc используйте AddExitProc в модуле SysUtils. Эта процедура добавит заданный вами обработчик в очередь элементов, которые будут выполнены при выходе. При этом вам не нужно выполнять никакой дополнительной работы: SysUtils автоматически выполняет все элементы очереди.

н). ExitProcessProc: procedure;

Вызывается при выходе из программы непосредственно перед вызовом ExitProcess. По-умолчанию равна nil. При назначении своего обработчика следует быть предельно внимательным, т.к. к этому моменту в программе весь код уже завершил свою работу, и вы не можете использовать никакого кода, который требует поддержки RTL Delphi. В частности, вы не можете использовать даже строки (!), не можете создавать объекты и т.п., поскольку менеджер памяти уже выключился. Используется очень редко.

о). ExitCode: Integer = 0;

Код run-time ошибки. Устанавливается различными вариантами процедур RunError (которая также вызывается автоматически при возбуждении run-time ошибки) и Halt. Определяет код выхода процесса при его завершении. Для DLL также определяет код успешности выполнения DllProc. Для исключений есть похожая функция ExceptObject (см. ниже).

п). ErrorAddr: Pointer = nil;

Адрес кода, при выполнении которого возникла run-time ошибка. Заполняется автоматически при появлении run-time ошибки. Равен nil, если ошибки не было. Также может быть равен Pointer(-1), если ошибка была, но адрес для неё недоступен (например, при ручном возбуждении без указания адреса). Если при выходе из программы ErrorAddr будет отличен от nil, то будет показано сообщение об ошибке (при условии что переменная NoErrMsg равна False). Вы также можете сбросить ErrorAddr в nil вручную. Для исключений есть похожая функция ExceptAddr (см. ниже).

р). DebugHook: Byte platform = 0;

Некий аналог ExceptionClass, но для не-Delphi исключений. Задаёт уведомления отладчику о не-Delphi исключениях. Переменная всегда равна 0 при запуске программы вне отладчика и > 0 при отладке программы. Является удобным признаком запуска программы из IDE Delphi (ок, за исключением ситуации использования "Run without debugging" или отключения отладчика сбросом галочки "Integrated debugging"). Обычно не следует менять это значение вручную.

с). JITEnable: Byte platform = 0;

Признак вызова фильтра исключений. Обычно всегда равен 0 и не меняется программой. Значение 1 заставляет вызывать UnhandledExceptionFilter для не-Delphi исключений, а значение > 1 — для всех исключений. Пример использования — см. обсуждение WER в пункте 2.5.

т). NoErrMsg: Boolean platform = False;

Указывает, нужно ли отображать сообщение о run-time ошибке при выходе из программы. Не меняется в коде Delphi. Также вы можете сбросить ErrorAddr в nil.

у). function AcquireExceptionObject: Pointer; и procedure ReleaseExceptionObject;

Используются при ручном управлении жизнью исключений. По-умолчанию, объекты исключений создаются и удаляются автоматически кодом RTL. Вы можете использовать эти функции, чтобы указать RTL, что вы сами удалите объект исключения. Например, вы можете вставить вызов AcquireExceptionObject в блок except в функции потока, созданного Begin/CreateThread. Полученный от этой функции объект исключения вы можете передать в главный поток для анализа и/или повторного возбуждения.

ф). function ExceptObject: TObject;

Аналог ErrorCode, но для исключений, а не run-time ошибок. Возвращает nil, если никакого исключения не возникало. Возвращает объект исключения, если сейчас идёт обработка (раскрутка) исключения. Вы можете использовать эту функцию в любом месте, необязательно в finally или except-блоках. Обычно всегда используется в паре с ExceptAddr. Если ExceptObject отличен от nil при выходе из программы (выполнении секции finalization модуля SysUtils), то будет показано сообщение об ошибке с помощью функции SysUtils.ExceptHandler. Не может быть сброшено вручную, сбрасывается автоматически при завершении обработки исключения.

х). function ExceptAddr: Pointer;

Аналог ErrorAddr, но для исключений, а не run-time ошибок. Возвращает адрес инструкции, возбудившей исключение. Обычно всегда используется в паре с ExceptObject. Не может быть сброшено вручную, сбрасывается автоматически при завершении обработки исключения.

ц). (только D2009 и выше) RaiseExceptObjProc: Pointer; // procedure(P: PExceptionRecord);

Хук возбуждения исключений. Вызывается непосредственно перед вызовом RaiseExceptionProc. По-умолчанию равен nil. Модуль SysUtils устанавливает такой обработчик (вызывает уведомление RaisingException, которое создаёт стек вызовов и сохраняет вложенное исключение):

procedure RaiseExceptObject(P: PExceptionRecord);
begin
  if TObject(P.ExceptObject) is Exception then
    Exception(P.ExceptObject).RaisingException(P);
end;

ч). (только D2009 и выше) ExceptionAcquired: Pointer; // procedure(Obj: Pointer);

Обработчик уведомлений о захвате исключений. Вызывается из AcquireExceptionObject, если она возвращает не nil. По-умолчанию обработчик равен nil. В Obj предаётся Result из AcquireExceptionObject. В RTL Delphi нигде не устанавливается. Предназначен для использования программистом.

Также в разделе практики (раздел 2.6) мы рассмотрим рекомендации и антирекомендации по использованию исключений.

1.3. Коды ошибок или исключения — что выбрать?

Итак, как же правильно подойти к работе над ошибками в новом приложении? Замечу, что если речь идёт о частично написанном приложении, то такого выбора не стоит, т.к. выбор диктуется уже сложившейся философией архитектуры приложения. Обычно, не имеет смысла переделывать уже написанный код. В случае же создания приложения или его автономной части с чистого листа у вас есть выбор. Что предпочесть: коды ошибок или исключения? Именно предпочесть, т.к., на самом деле, существуют ситуации, наилучшим образом подходящие для какого-то одного подхода. Поэтому, в реальном приложении нужно сочетать оба подхода (наверняка в ваших программах уже полно конструкций типа "if not SomeFunc(...) then" и "try ... finally ... end;"). Но часть ситуаций не диктует такого чёткого выбора и у вас есть возможность выбрать: использовать ли в этой ситуации коды ошибок или же исключения.

Давайте взглянем на pro и contra каждого подхода. Сразу заметим, что речь будет вестись не об исключениях и кодах ошибок вообще, а именно о конкретной реализации этих механизмов в Windows и Delphi. Потому что, на самом деле, в общем случае эти два механизма имеют больше сходств, чем различий. Но на практике обычно не имеет смысла придумывать свою реализацию механизмов управления ошибками, т.к. это может привести к путанице. Кроме того, полностью избавиться нельзя ни от одного подхода, ни от другого. В случае кодов ошибок: все системные функции используют их для уведомления об ошибке; в случае исключений: нельзя избавиться от аппаратных исключений (в частности — от EAccessViolation); кроме того, код RTL/VCL Delphi обычно использует исключения для управления ошибками.

Для сравнения двух подходов напишем небольшую функцию в двух вариантах. Пусть это будет функция для гипотетического менеджера CD/DVD дисков (типа WhereIsIt). Она должна по идентификатору диска и выбранного элемента (файла или папки на диске) вернуть число описаний, связанных с ним (например, текст/содержимое текстового файла или эскиз/thumbnail рисунка или видео). Причём эта информация создаётся плагинами, а в старой версии программы плагинов ещё не было. И добавим ещё обработку многопоточности. Вот вариант с кодами ошибок:

// Функция по переданному ИД диска (AID) и ИД файла/каталога (AItemID)
// возвращает число сохранённых в БД описаний.
function PluginsInfoCount(сonst AID: Integer; const AItemID: Integer): Integer;
var
  CurrPos: Integer;
begin
  // Сразу присвоим значение по-умолчанию, чтобы не писать эту строчку
  // каждый раз, когда мы делаем Exit
  // -1 - признак ошибки, т.к. корректное число описаний не может быть
  // меньше нуля. Оно или 0 или больше нуля.
  Result := -1;

  // Проверяем допустимость номера диска
  if (AID < 0) or (AID > High(CDs)) then
  begin
    // Result установлен выше 
    SetLastError(ERROR_INVALID_PARAMETER);
    Exit;
  end;

  // Проверяем версию: для файла, сохранённого в старой версии программы,
  // нет дополнительной информации
  if CDs[AID].Version <= 100 then
  begin
    // Result установлен выше 
    SetLastError(ERROR_UNSUPPORTED);
    Exit;
  end;
  // Проверяем: не заблокирован ли диск (например, он создаётся или изменяется)
  // (пусть функция блокирует диск при успешном вызове)
  case IsCDLocked(AID) of
    -1: // Ошибка
    begin
      // Result установлен выше, код ошибки установлен в IsCDLocked
      Exit;
    end;
    0: // Диск не был блокирован и вызов IsCDLocked его заблокировал
    begin
      // Успех: диск заблокирован
    end;
    1: // Диск блокирован операцией
    begin
      SetLastError(ERROR_BUSY);
      // Result установлен выше 
      Exit;
    end;
  end;

  // К этому моменту диск уже заблокирован, мы можем его использовать

  // Проверяем допустимость индекса элемента диска
  if (AItemID < 0) or (AItemID > High(CDs[AID].BrowseItems)) then
  begin
    UnlockCD(AID); // т.к. мы заблокироввали CD, то при каждом выходе его нужно разблокировать
    // Result установлен выше 
    SetLastError(ERROR_INVALID_PARAMETER);
    Exit;
  end;

  // Перечисляем сохранённую информацию
  CurrPos := BrowseItems[AItemID].PluginSeek;
  if CurrPos < 0 then
  begin
    UnlockCD(AID);
    // Result установлен выше, код ошибки установлен вызовом PluginSeek
    Exit;
  end;
  // Вообще нет ассоциированной инфы 
  if CurrPos = 0 then
  begin
    UnlockCD(AID);
    Result := 0;
    Exit;
  end;
  // Открыли диск
  if not OpenCD(AID) then
  begin
    UnlockCD(AID);
    // Result установлен выше, код ошибки установлен вызовом OpenCD
    Exit;
  end;
  // Сосчитали число блоков с информацией
  while CurrPos > 0 do
  begin
    CDs[AID].FS.Position := CurrPos + 4;
    CDs[AID].FS.ReadBuffer(CurrPos, 4);
    Inc(Result);
  end;
  // Закрыли и разблокировали диск
  CloseCD(AID);
  UnlockCD(AID);
end;

// Использование PluginsInfoCount:
C := PluginsInfoCount(ID, ItemID);
if C < 0 then
// ошибка (здесь ещё код по выводу сообщения об ошибке)
else
// успех, C - результат функции

Та же функция, в варианте с исключениями:

resourcestring
  rsInvalidIndex = 'Указан неверный индекс: %d.';
  rsUnsupportedFeature = 'Для этого формата недоступна функциональность %s.';
  rsCDIsLocked = 'CD заблокирован другой функцией.';

function PluginsInfoCount(сonst AID: Integer; const AItemID: Integer): Integer;
var
  CurrPos: Integer;
begin
  if (AID < 0) or (AID > High(CDs)) then
    raise ECDInvalidIndexError.CreateFmt(rsInvalidIndex, [AID]);

  if CDs[AID].Version <= 100 then
    raise EUnsupportedFeatureError.CreateFmt(rsUnsupportedFeature, ['Plugins']);
  if IsCDLocked(AID) then
    raise ECDBusyError.Create(rsCDIsLocked);
  try
    if (AItemID < 0) or (AItemID > High(BrowseItems)) then
      raise EItemInvalidIndexError.CreateFmt(rsInvalidIndex, [AID]);

    CurrPos := BrowseItems[AItemID].PluginSeek;
    if CurrPos = 0 then
    begin
      Result := 0;
      Exit;
    end;
    OpenCD(AID);
    try
      while CurrPos > 0 do
      begin
        CDs[AID].FS.Position := CurrPos + 4;
        CDs[AID].FS.ReadBuffer(CurrPos, 4);
        Inc(Result);
      end;
    finally
      CloseCD(AID);
    end;
  finally
    UnlockCD(AID);
  end;
end;

// Использование PluginsInfoCount:
C := PluginsInfoCount(ID, ItemID);
// успех, C - результат функции
// ошибка обрабатывается в ближайшем блоке except

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

1.3.1. Идентификация места ошибки

Поскольку при подходе с кодами ошибок все ошибки обрабатываются явно, то легко определить место возникновения ошибки: if not A then { ошибка возникла в A }. В случае же с исключениями это не всегда очевидно (или так кажется в начале):

try
  Open;
  try
    Read;
    Write;
  finally
    Close;
  end;
except
  on EStreamError do
    // В какой процедуре возникла ошибка?
end;

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

try
  Open;
  try
    try
      Read;
    except
      on EStreamError do
        // Ошибка в Read
    end;
    try
      Write;
    except
      on EStreamError do
        // Ошибка в Write
    end;
  finally
    Close;
  end;
except
  on EStreamError do
    // Ошибка в Open
end;

или

type
  EReadError = class(EStreamError);
  EWriteError = class(EStreamError);
...
try
  Open;
  try
    Read; // внутри весь код обёрнут в try/except on EStreamError do raise EReadError.Create(...);
    Write; // аналогично 
  finally
    Close;
  end;
except
  // Важен порядок
  on EReadError do
    // Ошибка в Read
  on EWriteError do
    // Ошибка в Write
  on EStreamError do
    // Ошибка в Open
end;

Это в точности эквивалентно тому, как при подходе с кодами ошибок мы бы вместо:

if not Open then
begin
  ProcessError; // или goto ProcessError
  Exit;
end;
if not Read then
begin
  ProcessError; 
  Close; 
  Exit;
end;
if not Write then
begin
  ProcessError; 
  Close; 
  Exit;
end;
Close;

Писали бы:

if not Open then
begin
  ProcessOpenError;
  Exit;
end;
if not Read then
begin
  ProcessReadError;
  Close; 
  Exit;
end;
if not Write then
begin
  ProcessWriteError;
  Close; 
  Exit;
end;
Close;

На самом деле, для исключений обычно не важно точное место возникновения ошибки. Впрочем, в разделе практики мы рассмотрим способы получения подробной информации о месте возникновения (в первую очередь — с целью отладки).

Помимо этого, можно извлечь точный адрес инструкции, вызвавшей исключения (для подхода с кодами ошибок можно получить только адрес для run-time ошибок). Он хранится в переменной ExceptAddr (ErrorAddr для run-time ошибок). Кстати, этот адрес можно вручную задать при возбуждении исключения через raise — подробнее см. справку. В то же время, для кодов ошибок точное место возникновения ошибки "в глубину" отследить весьма сложно. Например, пусть в процедуре Test вызывается функция A, которая в свою очередь вызывает B, а та — C. Пусть при вызове из Test функция A вернула ошибку. Мы легко определим, что ошибка возникла в A, но где именно: в самой A, в B или в C? Неизвестно.

1.3.2. Производительность

Понятно, что самым быстрым механизмом является подход с кодами ошибок. Обработка же исключений работает почти так же быстро, но только если исключения не происходит. Т.е. если блоки except не вызываются, а блоки finally выполняются при "естественном" выходе. В противном же случае накладные расходы оказываются заметны. Нет, это не значит, что ваша программа будет тратить по секунде на раскрутку исключения. В большинстве случаев разница незаметна (ну, грубо скажем, менее 0.1%). Но если у вас возбуждение исключения — нормальная ситуация в функции, которая вызывается в цикле 100'000 раз, то разница во времени выполнения будет довольно заметна. Кстати, именно поэтому вместо:

function ...
...
begin
  ...
  try
    for X := 0 to List.Count - 1 do
    begin
      ...
      if SomeCondition then
      begin
        Result := -1;
        Exit; // неестественный выход из finally
      end;
      ...
    end;
  finally
    ...
  end;
end;

Лучше писать:

function ...
...
begin
  ...
  try
    for X := 0 to List.Count - 1 do
    begin
      ...
      if SomeCondition then
      begin
        Result := -1;
        Break; // естественный выход из finally
      end;
      ...
    end;
  finally
    ...
  end;
end;

Хотя здесь (для break) разница во времени выполнения мала. Для случая с возникновением исключения она чуть больше — ведь там и действий больше: раскрутка стека, например.

1.3.3. "Всплытие" ошибки

На самом деле, это, пожалуй, единственное существенное различие между двумя подходами (если говорить вообще, а не про конкретные реализации). Как видно из примеров, для случая с кодами ошибок мы просто вынуждены писать код, который проверяет результат работы функции и (в случае ошибки) передаёт код ошибки выше. Для исключений же никакого дополнительного кода писать не нужно — всё делается автоматически. Это настолько удобно, что часто можно увидеть, как генерируется EAbort только для того, чтобы быстро (имеется в виду — без дополнительного кода) выйти ("всплыть") из глубоко вложенных функций. И для случая с исключениями это выглядит нагляднее.

1.3.4. Ассоциированная информация

С ошибкой просто необходимо связывать информацию, описывающую ситуацию при её появлении. Чаще всего такая информация — это текстовое описание ошибки для пользователя. Для случая с кодами ошибок у нас есть ограниченные возможности: мы можем только использовать стандартные сообщения, которые возвращает нам SysErrorMessage. Также у нас есть возможность использовать и свои сообщения, если мы определим пользовательские коды ошибок. Но для этого нужно ещё дополнительно ввести и придумать механизм получения сообщения (впрочем, можно использовать и стандартный FormatMessage, но и там будет не всё просто). И тут бы не забыть про локализацию строк. Кроме того, все эти строки статичны. Т.е. мы не можем вставить в сообщение об ошибке, скажем, имя конкретного файла (при работе с которым произошла ошибка). Конечно, можно использовать спецификаторы форматирования, а после получения сообщения, вставлять данные с помощью функции Format. Можно придумать и другие подходы. Но — всё это нужно делать ручками, и удобство такого подхода начинает стремиться к нулю. А если мы ещё захотим добавить дополнительную информацию (не только текстовое сообщение об ошибке), то... Для исключений же тут совсем всё просто. Сообщение (причём произвольное) есть вообще у всех исключений. Хотите ещё что-то добавить — просто объявите новый класс.

1.3.5. Пересечение границ модулей

До сих пор мы говорили только о ситуации с одним модулем — exe-файлом. Но как ведут себя наши два механизма при использовании DLL-библиотек? Понятно, что с кодами ошибок проблем нет — они используют стандартные функции системы, доступные всем модулям. Единственная особенность — пользовательские коды ошибок. В этом случае нужно только предусмотреть экспорт/импорт функции по выдаче текстовых сообщений, соответствующих пользовательским кодам ошибок.

С исключениями ситуация сложнее. Правилом хорошего тона будет не выпускать исключения за границу модуля. Дело в том, что почти каждый язык использует свою надстройку над системным SEH. Что будет делать библиотека C++ с исключением Delphi? Как она получит доступ к объекту Exception? А как она освободит его? А наоборот? Поймать исключение-то не проблема — в конце концов, это просто обычное системное исключение, которое имеет (в случае исключения Delphi) один из следующих кодов:

cDelphiException    = $0EEDFADE;
cDelphiReRaise      = $0EEDFADF;
cDelphiExcept       = $0EEDFAE0;
cDelphiFinally      = $0EEDFAE1;
cDelphiTerminate    = $0EEDFAE2;
cDelphiUnhandled    = $0EEDFAE3;
cNonDelphiException = $0EEDFAE4;
cDelphiExitFinally  = $0EEDFAE5;
cCppException       = $0EEFFACE; { используется Borland C++ Builder }

С исключением будет ассоциирован объект типа Exception. А проблема состоит в том, что другие языки не знают, как обходиться с этими исключениями. И они не смогут их обработать. Конечно, в спецификации к своему модулю вы можете написать: дескать, мои исключения нужно обрабатывать так-то и сяк-то (указав, как нужно освободить связанный объект). А что, если язык чужой библиотеки не предоставляет возможностей (или делает это неудобно) по оперированию на таком низком уровне? А если вам нужно будет использовать чужую DLL, то как, не зная заранее, на каком языке она будет написана (например, в случае плагинов), вы собираетесь обрабатывать чужие исключения от неизвестного языка?

Именно поэтому все функции и процедуры, имеющие видимость снаружи модуля, нужно обернуть в try/except и сконвертировать возникшее исключение в код ошибки (возможно пользовательский). Здесь возникает проблема с передачей вовне модуля дополнительной информации, ассоциированной с исключением. Универсального решения, пожалуй, не существует. Но часто можно передавать дополнительную информацию, введя служебную функцию вроде такой:

threadvar
  ErrMsg: String;
  ErrCode: Integer;

function GetLastErrorDescription(SomeInfo: PInteger): PChar; stdcall;
begin
  if SomeInfo <> nil then
    SomeInfo^ := ErrCode;
  Result := PChar(ErrMsg);
end;

function DoSomething: Bool;
begin
  try
    ... // работа
    Result := True;
  except
    on E: Exception do
    begin
      ErrMsg := E.Message;
      if E is EOSError then
        ErrCode := EOSError(E).ErrCode
      else
        ErrCode := 0;  
      SetLastError(MY_EXTENDED_ERROR); // устанавливаем пользовательский код ошибки
      Result := False;
    end;
  end;
end;

...

exports
...
  GetLastErrorDescription;

В этом примере любая внешняя функция может вызвать GetLastError для получения кода ошибки выполнения функции. И если GetLastError вернула MY_EXTENDED_ERROR — то вызвать GetLastErrorDescription для получения свойств Message и ErrorCode (для EOSError) исключения, возникшего при последнем вызове любой функции библиотеки (в примере одна из них — DoSomething). Эта дополнительная информация о последнем исключении хранится в глобальных переменных. По аналогии можно возвращать и другую информацию.

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

См. также обсуждение safecall-исключений выше (в разделе 1.2.3).

1.3.6. Гарантированное освобождение ресурсов

Можно заметить, что без блока try/finally становится очень сложно следить за освобождением ресурсов. Exit, добавленный в середину блока работы с ресурсами, приведёт к его (ресурса) утечке, если только мы не забудем освободить ресурс. Посмотрите в примере выше — для случая с кодами ошибок нам пришлось везде ставить UnlockCD, чтобы гарантированно освобождать ресурс. А если бы мы забыли поставить вызов этой функции, хоть в одном месте? А если бы ресурсов было бы не один, а штук 10, причём создавались и освобождались бы они не в одном месте, а в разных? Чем больше кода и ресурсов, тем сложнее становится отслеживать зависимости кода. Прежде чем модифицировать код, нужно убедиться, что вносимые изменения не приведут к утечкам ресурсов. Причём для этого ведь нужно внимательно просмотреть всю функцию и проследить все пути выполнения. Словом, непростая работа. Кстати, частично этот пункт — следствие пункта 1.3.3. С исключениями же нет нужды в такой работе. Если мы всегда расставляем блоки try/finally, то мы можем безбоязненно модифицировать код — все ресурсы будут гарантировано освобождены.

1.3.7. Вложенные исключения

Не нужно забывать, что конечная цель всей системы обработки ошибок — помочь пользователю. Когда пользователю показывается сообщение об ошибке, его (сообщения) первичная цель — сформировать у пользователя представление о том, что произошло в программе и какие возможны действия (что делать дальше, можно ли устранить или обойти ошибку и т.п.). Мы не будем здесь касаться стиля оформления диалогов и сообщений об ошибках. Лучше рассмотрим два типичных (в чём-то противоположных) подхода к обработке ошибок (подходы не зависят от того, используем ли мы коды ошибок или исключения). Вариант первый ("ошибка на низком уровне"): при возникновении ошибки мы всплываем на максимально высокий уровень и выдаём сообщение пользователю (в варианте с исключениями это значит, что, возбудив исключение, мы вообще не обрабатываем его, позволяя Application.HandleException показать стандартное сообщение об ошибке). Вариант второй ("ошибка на высоком уровне"): мы всплываем до самой верхней функции (или до первой функции без контроля ошибок), после чего она показывает своё сообщение об ошибке, например:

procedure TForm1.Button1Click(Sender: TObject);
begin
  try
    // ... какие-то действия по подключению к БД
  except
    ShowMessage('Возникла ошибка при подключении к базе данных.');
  end;
end;

Проблема с обоими подходами в том, что они или показывают слишком конкретное сообщение ("ошибка на низком уровне") или слишком общее ("ошибка на высоком уровне"). Например, в первом случае это будет что-то типа: "Файл не найден", во втором — "Возникла ошибка при подключении к базе данных". И реально эти сообщения нисколько не помогают пользователю, т.к. они ему ничего не говорят. Вы могли бы вообще ничего не показывать — результат был бы тот же самый: пользователь по-прежнему не знает, то ему делать. Как видим, и коды ошибок и исключения мало отличаются друг от друга в этом аспекте. На помощь тут может прийти возможность исключений связывать с ними дополнительную информацию. Одной из возможных реализаций по устранению таких недостатков могла бы стать такая: нужно сохранять и показывать максимальное количество информации об ошибке (мы заранее не знаем, какая именно информация может оказаться полезной). Можно определить как можно более подробные классы исключений и делать обработку ошибок как можно чаще. Например, добавить к классу исключения дополнительную информацию: вложенное исключение (только для Delphi ниже D2009, для D2009 эта функциональность уже добавлена в класс Exception):

type
  ExceptionEx = class(Exception)
  private
    FInnerException: Exception;
  protected
    procedure SetInnerException;
    function GetBaseException: Exception; virtual;
  public
    constructor Create(const Msg: string);
    // ... и другие конструкторы
    destructor Destroy; override;
    property BaseException: Exception read GetBaseException;
    property InnerException: Exception read FInnerException;
  end;

  ESomeTypeError = class(ExceptionEx);

...

constructor ExceptionEx.Create(const Msg: string);
begin
  SetInnerException;
  inherited Create(Msg);
end;

// ... аналогично и другие конструкторы

destructor ExceptionEx.Destroy;
begin
  FreeAndNil(FInnerException);
  inherited Destroy;
end;

function ExceptionEx.GetBaseException: Exception;
begin
  Result := Self;
  while Result.InnerException <> nil do
    Result := Result.InnerException;
end;

procedure ExceptionEx.SetInnerException;
begin
  if TObject(ExceptObject) is Exception then
    FInnerException := AcquireExceptionObject;
end;

А код каждой функции оборачивать так:

function Foo(...
...
begin
  try
    ...
  except
    raise ESomeTypeError.Create('Ошибка функции Foo.');  
      // при отображении ошибки пользователю можно будет просмотреть
      // список ошибок, перебирая в цикле свойство InnerException
  end;
end;

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

Ошибка соединения с базой данных.
EConnectionCreateError: Ошибка создания соединения с базой данных.
EInitInterfaceError: Ошибка инициализации клиентского интерфейса БД.
ELoadLibraryError: Ошибка загрузки libpq.dll.
EOSError: Файл не найден

А при таком сообщении становится понятно, что соединение к БД не может быть установлено, потому что приложение не может найти библиотеку libpq.dll. Обратите внимание, что ключевое (для понимания) сообщение может находиться в середине цепочки, или, вообще, быть составлено из нескольких строк (в нашем примере — из двух последних). Такие исключения называются цепочечными (chained) или вложенными (nested) исключениями. Общий смысл в том, что исключение более высокого уровня содержит в себе исключение (или информацию о нём) более низкого уровня. Это позволяет не потерять ни грамма ценной информации.

Также можно заметить, что в этом сообщении всё ещё отсутствует важный компонент — список путей, где именно программа пытается найти библиотеку. Конечно, это не единственный возможный вариант реализации. Можно придумать и другие способы.

Этого вопроса мы ещё коснёмся во втором разделе.

Что касается Exception в D2009, то, как уже было сказано, подобная поддержка реализована в базовом классе Exception:

type
  Exception = class(TObject)
  private
    FMessage: string;
    FHelpContext: Integer;
    FInnerException: Exception;
    FStackInfo: Pointer;
    FAcquireInnerException: Boolean;
  protected
    procedure SetInnerException;
    procedure SetStackInfo(AStackInfo: Pointer);
    function GetStackTrace: string;
    procedure RaisingException(P: PExceptionRecord); virtual;
  public
    constructor Create(const Msg: string);
    constructor CreateFmt(const Msg: string; const Args: array of const);
    constructor CreateRes(Ident: Integer); overload;
    constructor CreateRes(ResStringRec: PResStringRec); overload;
    constructor CreateResFmt(Ident: Integer; const Args: array of const); overload;
    constructor CreateResFmt(ResStringRec: PResStringRec; const Args: array of const); overload;
    constructor CreateHelp(const Msg: string; AHelpContext: Integer);
    constructor CreateFmtHelp(const Msg: string; const Args: array of const;
      AHelpContext: Integer);
    constructor CreateResHelp(Ident: Integer; AHelpContext: Integer); overload;
    constructor CreateResHelp(ResStringRec: PResStringRec; AHelpContext: Integer); overload;
    constructor CreateResFmtHelp(ResStringRec: PResStringRec; const Args: array of const;
      AHelpContext: Integer); overload;
    constructor CreateResFmtHelp(Ident: Integer; const Args: array of const;
      AHelpContext: Integer); overload;
    destructor Destroy; override;
    function GetBaseException: Exception; virtual;
    function ToString: string; override;
    property BaseException: Exception read GetBaseException;
    property HelpContext: Integer read FHelpContext write FHelpContext;
    property InnerException: Exception read FInnerException;
    property Message: string read FMessage write FMessage;
    property StackTrace: string read GetStackTrace;
    property StackInfo: Pointer read FStackInfo;
  class var
    GetExceptionStackInfoProc: function (P: PExceptionRecord): Pointer;
    GetStackInfoStringProc: function (Info: Pointer): string;
    CleanUpStackInfoProc: procedure (Info: Pointer);
    class procedure RaiseOuterException(E: Exception); static;
    class procedure ThrowOuterException(E: Exception); static;
  end;

Здесь появились три дополнительных поля: FInnerException, FStackInfo и FAcquireInnerException, несколько новых методов и несколько глобальных переменных. Во-первых, стоит отметить protected-метод RaisingException. Он вызывается RTL Delphi непосредственно перед вызовом raise для объекта исключения. Т.е. это самое удобное место для запоминания вложенного исключения и создания стека вызовов. По-умолчанию именно это он и делает: вызывает SetInnerException и запоминает стек вызовов, созданный глобальной функцией GetExceptionStackInfoProc (если она, конечно, назначена). FInnerException хранит вложенное исключение ровно также, как и в нашем примере выше. Заполняется оно методом SetInnerException (в отличие от нашего самодельного примера, SetInnerException вызывается из RaisingException, а не из конструкторов исключения). FStackInfo хранит информацию о стеке вызовов. Заполняется внутри RaisingException с помощью GetExceptionStackInfoProc. Для удаления созданной информации в деструкторе исключения используется процедура CleanUpStackInfoProc. Несколько свойств (BaseException, InnerException, StackTrace и StackInfo) дают доступ к этой информации. Кроме того, есть удобная функция ToString, которая возвращает все сообщения, связанные с объектом исключения. Если InnerException = nil, то ToString возвращает просто свойство Message. Если же с объектом исключения ассоциирована цепочка исключений, то ToString вернёт все свойства Message (каждое — просто с новой строки), начиная от текущего и заканчивая BaseException.

В целом вся эта конструкция ведёт себя так же, как и наш самодельный пример. Есть, правда, одно отличие: SetInnerException сохраняет вложенное ислючение только, если установлен флаг FAcquireInnerException. По-умолчанию он сброшен и не устанавливается RTL Delphi. Единственный способ его установить — возбудить исключение не с помощью raise, а с помощью RaiseOuterException:

function Foo(...
...
begin
  try
    ...
  except
    Exception.RaiseOuterException(ESomeTypeError.Create('Ошибка функции Foo.'));  
    // В ESomeTypeError будет заполнены свойсва InnerException и BaseException.
  end;
end;

1.3.8. Немедленное уведомление или что проще заметить

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

1.3.9. Возможность ошибки программиста

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

1.3.10. Элегантность кода

Глядя на наш пример, можно заметить, что вариант с исключениями выглядит короче и более "гладким", поскольку весь дополнительный код обработки ошибок вынесен за пределы функции. Т.е. подход с исключениями помог разделить чисто функциональный код и код восстановления после ошибок. Но это становится неудобством при необходимости обработать ошибку "на месте". В случае кодов ошибок достаточно написать if not SomeFunc then, а для исключений придётся писать целый блок кода except. Впрочем, наверное, этот параметр больше субъективный, чем объективный.

1.3.11. Сложность написания качественного кода

Хотя этот аспект идёт последним, но, пожалуй, он один из самых важных (если не самый важный).

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

function Add: TSomeObject;
begin
  // Создание объекта
  Result := TSomeObject.Create;
  // Добавление его в список
  SomeList.Add(Result);
  // Настройка объекта
  Result.ParentList := SomeList;
  // ... и другие свойства
end;

Казалось бы: что может быть проще? Однако когда мы программируем с использованием исключений, мы всегда должны думать: а что будет, если код в этой, только что написанной мною строке, вызовет исключение? С исключениями вы просто обязаны это делать! Что будет, если исключение возникнет при создании объекта? При добавлении его в список? При настройке объекта?

Ок, давайте посмотрим. Если исключение возникнет при создании объекта — ничего страшного, мы ещё ничего и не успели сделать. При добавлении в список уже хуже — созданный объект никем не используется (он не добавлен в список и функция его не вернёт) и, тем самым, происходит утечка памяти. Но самое страшное возникнет при возбуждении исключения во время выполнения настройки объекта. Объект уже создан. Он занесён в список. Но его свойства не заданы. Включая те, которые должны быть обязательными (например, свойство ParentList, которое содержит ссылку на список, в котором находится объект). Что это значит? Это значит, что если мы потом, после окончательной обработки исключения (когда мы уже и думать забыли про него), вдруг захотим пробежаться по списку и при этом обратимся к какому-нибудь из обязательных свойств, которые должны быть заполнены (а они у нас пусты), то неминуемо словим или AV или (ещё чего хуже) какой-нибудь глюк, который непонятно откуда взялся. И попробуй потом пойми, что не так в программе!

Как мы можем исправить ситуацию? Например, переписать код так (мы пока говорим о возникновении исключения в третьей строке):

function Add: TSomeObject;
begin
  // Создание объекта
  Result := TSomeObject.Create;
  // Настройка объекта
  Result.ParentList := SomeList;
  // ... и другие свойства
  // Добавление его в список
  SomeList.Add(Result);
end;

Мы поменяли местами два последних действия. Очень малозначительное изменение, верно? Но оно гарантирует, что если в список SomeList и попадает какой-либо объект, то только верно заполненный!

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

Легко: писать плохой код на кодах ошибок.

Легко: писать плохой код на исключениях.

Тяжело: писать хороший код на кодах ошибок.

Очень тяжело: писать хороший код на исключениях.

Если бы это было не так, зачем нужна была бы эта статья, верно? :)

Вернёмся к нашему примеру с менеджером CD-дисков в начале пункта. Посмотрите на код, основанный на кодах ошибок. Он написан не плохо. Ок, может быть, он не великолепен и в нём есть ошибки (от них никто не застрахован). Однако мы легко можем увидеть, что код почти верен: можно легко увидеть, как проверяется результат каждой функции, как обрабатываются ошибки и т.п. Конечно, не сразу можно сказать, что восстановление после ошибок корректно, но то, что оно есть или нет — это 100%. Плохой код на кодах ошибок выглядел бы как-то так:

function PluginsInfoCount(сonst AID: Integer; const AItemID: Integer): Integer;
var
  CurrPos: Integer;
begin
  Result := -1;

  IsCDLocked(AID);

  CurrPos := BrowseItems[AItemID].PluginSeek;

// ... и т.д.

Легко увидеть разницу, не так-ли? :) Очевидно, что этот код плохой. В плохом коде никаких проверок, никаких if-ов. В хорошем коде — куча проверок (он может не быть отличным, но он уже не плох). Если вы в спешке, просто для теста (или как временную заглушку), написали функцию без проверок, как выше, — то вы легко можете вернуться попозже и добавить обработку ошибок, поскольку вы легко визуально увидите, где код ещё не закончен.

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

Легко: понять, что код на кодах ошибок написан плохо.

Легко: увидеть разницу между плохим и хорошим кодом на кодах ошибок.

Тяжело: понять, что код на кодах ошибок написан хорошо.

Очень тяжело: понять, что код на исключениях написан плохо.

Очень тяжело: понять, что код на исключениях написан хорошо.

Очень тяжело: увидеть разницу между плохим и хорошим кодом на исключениях.

Какой из этого нужно сделать вывод? Нет, мы не хотим сказать, что исключения — это плохо и что на них не нужно писать. Мы лишь хотим сказать, что если уж вы взялись писать с использованием исключений, то нужно изначально делать это хорошо. Фактически, исключения сами подталкивают вас к тому, чтобы вы писали хороший код. Да, они сидят у вас за спиной и шепчут вам на ухо: "а предусмотрел ли ты все возможные случаи?". Вы просто не можете написать плохой код, а потом взять и исправить его на хороший — вам просто придётся переписать его — если, конечно, вы потом его вообще найдёте среди другого кода.

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

2. Практикум

Раздел практики мы посвятим практической работе с исключениями. Дело в том, что коды ошибок просты как палка с камнем и не представляют никакого интереса для обсуждения :) А вот для исключений можно много всего накопать.

Начнётся раздел с материала для совсем уж начинающих — что такое отладчик (debugger), как им пользоваться, как самому искать причины ошибок. Далее по нарастающей пойдут темы для средней руки программистов — стек вызовов исключений, работа с отладочной информацией и т.п. И закончится раздел советами по использованию исключений (для опытных программистов).

Итак, сначала — инструменты. Для практической работы нам понадобятся библиотека JCL (примерно 4 Mb без справки), а также альтернативный менеджер памяти для Delphi — Fast Memory Manager (примерно 170 Kb). В принципе, вместо JCL можно использовать madExcept или EurekaLog, но они платные.

Первый пункт, библиотека JCL (JEDI Code Library) из проекта JEDI (Joint Endeavor of Delphi Innovators) — это просто обязательный must-have для всех программистов на Delphi, большой сборник самых разнообразных функций на все (ну или почти все) случаи жизни.

Второй пункт — это быстрый и мощный (в смысле "вкусностей") менеджер памяти, в возможности которого входит (в том числе) поиск утечек памяти (при этом используется интеграция с JCL, madExcept или EurekaLog). Кстати, модифицированная версия FastMM входит в BDS 2006 и выше, как стандартный менеджер памяти. К сожалению, его нельзя использовать для полноценной диагностики утечек памяти. Но ничто не мешает использовать стандартный FastMM вместо встроенного.

2.1. Исключения? Hunt'em down!

2.1.1. Основы отладки

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

Перечислим некоторые возможности, которые доступны нам через механизм отладчика:

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

Как же работать с отладчиком и где его искать? Все основные команды, через которые Delphi переходит в режим отладки, находятся в меню "Run":


На самом деле, всякий раз, когда вы запускаете программу из среды Delphi по F9 или командой меню "Run"/"Run", вы запускаете программу под отладчиком. Для просто запуска программы есть команда "Run without debugging" (Ctrl+Shift+F9).

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

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

На самом деле, отладчик можно и полностью выключить, но обычно этого не делают. Но если он у вас не работает, то просто зайдите в опции и убедитесь, что галочка "Integrated debugging" включена.



Настройки отладчика в старых Delphi


Настройки отладчика в новых Delphi

Раз работа отладчика так незаметна — давайте посмотрим, какие же возможности он нам даёт. Пока программа работает, вы немного можете с ней сделать. Для того чтобы воспользоваться отладчиком, вам нужно приостановить её выполнение. У вас на выбор есть три варианта, первый — нажать на кнопку паузы ("Run"/"Program pause"), второй — расставить в нужных местах точки останова (breakpoint-ы, брейкпойнты или просто "бряки"), третий — возбудить в программе исключение.

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

Второй способ является основным. Заключается он в том, что по тексту программы вы мышкой отмечаете места, где вы хотели бы остановиться во время работы программы. Слева от кода вы видите полоску, в которой появляются синие точки:


Каждая синяя точка говорит о том, что в реальном машинном коде программы есть код, который попал туда именно в процессе компиляции этой строки. Т.е. строка, отмеченная синей точкой, компилируется в код, приводит к появлению кода. Если напротив строки с кодом вы не видите точки, то, во-первых, возможно, что вам нужно просто сделать Build проекту. Во-вторых, может быть, включена оптимизация и компилятор выкинул эту строчку. В этом случае у вас при компиляции будет hint (подсказка) или warning (предупреждение) о том, что эта строка выброшена. Наконец, есть вероятность, что сбились настройки проекта. Вы загрузили текст модуля в IDE, но реально компилятор не может найти pas-файл в своих путях поиска и использует устаревший dcu-файл (уже скомпилированный pas). В этом случае нужно удалить все dcu-файлы и попробовать сделать полный Build проекту. Ещё несколько причин и решений можно посмотреть здесь и здесь.

Итак, после того, как вы сделали Build проекту и видите синие точки — теперь вы можете выбрать место для установки точки останова. Щёлкните мышкой по любой синей точке, и она изменится на большую красную точку, а сама строка выделяется (кстати, если вы не видите синих точек вообще, необязательно делать компиляцию, чтобы их увидеть — просто щёлкайте слева от кода, где вы хотели бы остановиться):


В этом случае мы захотели остановиться перед выполнением строки с присваиванием свойства Caption. Заметим, что breakpoint-ы вы можете ставить, как во время проектирования, так и во время работы или приостановки программы. Теперь, после запуска программы, как только выполнение дойдёт до одной из заданных вами точек останова, отладчик немедленно остановит программу. Как вариант этого способа можно рассматривать команду "Run to cursor" (F4) — некий аналог временного breakpoint-а. Вы устанавливаете текстовый курсор в любую строчку, где вы хотите остановиться (например — на первую команду после begin в TForm1.Button1Click), и нажимаете F4. Программа запустится на выполнение (или продолжит выполнение, если до этого была приостановлена) и остановится, как только выполнение дойдёт до строки, где вы поставили курсор (т.е. как только вы нажмёте на кнопку Button1). Это полностью эквивалентно тому, как если бы вы установили breakpoint в этой строке, запустили программу как обычно и, после остановки программы, сняли бы breakpoint.

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

Итак, после остановки программы в отладчике вы можете использовать его возможности для анализа программы. Для примера возьмите любую свою программу, поставьте breakpoint на первое действие при нажатии какой-нибудь кнопки, запустите программу и щёлкните по кнопке (мы сейчас будем обсуждать возможности отладчика, а вы сможете щупать их прямо на своей программе). Если вы используете новые Delphi, то заметите, как преображается при этом среда — исчезает инспектор объектов, палитра компонентов и т.п. Зато появляется множество окон: "Call Stack", "Watch List", "Local Variables" и т.п. Каждое из этих окон предоставляет вам какую-то возможность отладчика. Если какого-то окна на экране нет, вы можете показать его, используя меню "View"/"Debug windows":


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

Начнём с окна "Local Variables". Для демонстраций мы будем использовать совершенно глупый код, который не делает ничего полезного (даже не пытайтесь искать в нём смысл):


Итак, мы поставили breakpoint на строчку с присваиванием свойства Tag в процедуре A, затем мы запустили программу и щёлкнули по кнопке. Отладчик остановил выполнение программы, как только выполнение дошло до установленного breakpoint-а. Заметим, что здесь и далее мы будем рассматривать этот пример. Наша программа стоит на паузе. Это важно, т.к. это значит, что мы находимся в режиме отладки. Большинство вещей, о которых мы будем сейчас говорить, доступны именно в режиме отладки (например, в контекстное меню редактора кода в режиме отладки добавляются новые команды). Вы можете определить, в каком режиме находится среда, взглянув на заголовок окна:


Режим проектирования (нет дописки)

Режим прогона, программа работает ("Running")

Режим отладки, программа приостановлена ("Stopped")

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


Вы можете видеть, что наша программа как бы висит (последним сворачивалось окно Delphi, поэтому рисунок окна Delphi отпечатался на окне нашей программы). Она не прорисовывается, она не реагирует на ваши действия, другим словом — висит. Да, но не забывайте, что мы только что поставили с вами программу на паузу! Это значит, что она не работает. А если программа не работает, то она и не может ни перерисовываться, ни реагировать на ваши щелчки мышью. Так что ничего страшного в таком поведении нет — так и должно быть. Как только вы возобновите работу программы (снимите её с паузы), она снова будет вести себя как полагается.

Итак, продолжаем. Наша программа стоит на паузе. В окне "Local Variables" показываются локальные переменные в текущем месте. Смотрите, в данном случае у нас есть переменная X — она объявлена в var, а также параметр процедуры ACount. Как только мы остановились, отладчик показывает нам, что X равен 1. А вот для ACount он нам говорит, что переменная ACount уже недоступна ("Variable 'ACount' inaccessible here due to optimization"). Это работа оптимизатора (кстати, вы можете отключить его, сбросив в опциях проекта галочку "Optimization"). Он выбрасывает переменную, как только в ней отпадёт необходимость. В нашем случае ACount используется один только раз — при задании работы цикла. Поскольку мы поставили breakpoint на строку после for, то к моменту остановки программы строчка с инициализацией цикла уже отработала и поэтому переменная ACount к этому моменту уже удалена. Если бы мы либо отключили оптимизацию, либо как-то использовали ACount после цикла или установили точку останова до цикла, то во всех этих случаях отладчик показал бы нам "три" в качестве значения переменной ACount (задаётся при вызове процедуры A из процедуры P). Итак, "Local Variables" — удобное окно для просмотра локальных переменных. Что делать, если хочется посмотреть не локальную переменную, например, свойство Tag? Можно воспользоваться окном "Watches". Для этого щёлкните правой кнопкой по свободной области окна "Watch List" и выберите "Add watch" — появится окно ввода параметров наблюдения:


В поле "Expression" вы можете ввести имя переменной, за которой хотите следить. Кстати, это не обязательно должна быть переменная — вы можете ввести любое выражение, которое поддаётся вычислению. Например, выражение "X = 1" (без кавычек, разумеется) — оно будет равно True или False. Остальные опции отвечают за форматирование отображения. Другой способ добавить выражения для слежки — выделить их в редакторе кода, щёлкнуть правой кнопкой и выбрать "Add watch at cursor" (появляется только во время отладки программы). Примечание: обычно команды располагаются в подменю "Debug", но если в настройках отладчика включить опцию "Rearrange editor local menu on run", то на время отладки все пункты контекстного меню редактора, связанные с отладкой, для удобства выносятся наверх. Например, мы добавили несколько примеров выражений, пока мы ещё стоим на строчке "Tag := X" в нашем примере:


Последние два выражения с X демонстрируют два различных вида представления одной и той же величины. В первом случае мы не меняли способ отображения, а во втором — установили значение в "Memory Dump". Это может быть полезно, если умалчиваемый вид не даёт достаточной информации — см., например, вопрос №65263. Заметим, что выражение "IntToStr(Tag)" не может быть вычислено ("Inaccessible value"), т.к. для того, чтобы посмотреть значение этого выражения, нужно вызвать функцию (а именно — функцию IntToStr). Вызов функции не является безопасным действием, т.к. может иметь побочные эффекты. Например, если бы процедура P в нашем примере была бы функцией, её вызов отладчиком в любой момент времени был бы нежелателен, т.к. она меняет значение внешней переменной Tag (более того, она ещё и три сообщения показывает!). Но если вы уверены, что введённое вами значение вычислять безопасно, вы можете зайти в свойства watch-а и установить галочку "Allow function calls". После этого отладчик сможет показать значение выражения "IntToStr(Tag)", а именно — '1' (строка, а не число). Но будьте аккуратны!

Ещё одно полезное свойство — "Group name". Оно определяет название вкладки, на которой появляются watch-и. Вы можете ввести в это поле любую строку или выбрать из списка. Вот как выглядит окно "Watch List" с двумя вкладками ("Watches" и "Tag"):


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

Заметим, что во всех указанных окнах ("Local Variables", "Watch List" и редакторе кода) вы можете выделить выражение, щёлкнуть по нему правой кнопкой мыши и в появившемся меню выбрать "Inspect" или вместо этого нажать Ctrl + I (Alt+F5 для редактора кода). При этом появляется такое окно (мы открыли три разных окна, причём соединили второе и третье в одно окошко):


Это окно помимо собственно значения переменной показывает тип выражения и где это выражение хранится (например, машинный регистр для X, участок памяти для Tag или вовсе вычисляемое выражение для "X = 1", которое нигде не хранится). Кроме того, для некоторых видов данных (например, для строк и динамических массивов) показывается два адреса — адрес объекта (переменной) и адрес его данных. Более того, для некоторых случаев это окно позволяет менять значение переменных. Например, для X есть возможность нажать на кнопку "..." справа и ввести любое другое значение. Например, введя число 2, мы пропустим первую итерацию нашего цикла for. Возможность менять значения переменных — мощная возможность. Она позволяет отвечать на вопросы типа "а что если...". С её помощью вы можете менять число итераций циклов, менять участки выполнения программы с помощью инвертирования выражений в операторах "if" и т.п. Вы также можете выполнять участки кода, которые в обычных условиях вы проверить не можете, т.к. в вашей ситуации выполнение никогда не переходит по интересующим вас путям. Примечание: часто более удобной альтернативой для этого является использование команды "Set Next Statement" (см. ниже).

Ещё одним вариантом для просмотра переменных является окно "Evaluate/Modify". Вы выделяете в редакторе кода выражение, которое хотите вычислить, щёлкаете правой кнопкой мыши по нему и выбираете в меню "Evaluate/Modify...". После этого на экране появляется такое окно:


В поле "Expression" вы видите выражение, которое вы выделяли в редакторе кода (в нашем случае мы просто поставили курсор на слово "Tag"). В поле "Result" показывается текущее значение выражения. Вы можете изменять выражение и нажимать кнопку "Evaluate" для вычисления введённого значения. Также вы можете задать новое значение в поле "New value" и нажать кнопку "Modify". Разумеется, возможность модификации доступна не всегда. Например, вы не можете модифицировать выражение "Tag = 1", равное True, на значение False. Вместо этого вы должны модифицировать значение самого Tag — одной из переменных, участвующих в выражении. Кнопка "Watch" добавит введённое вами выражение в список "Watch List", а кнопка "Inspect" покажет уже знакомое нам окно "Debug Inspector". Таким образом, окно "Evaluate/Modify" является неким центром для слежения за переменными.

Кстати говоря, не следует думать, что модификация переменной в любом окне отладчика — это очень простая операция, заключающаяся в изменении памяти, занимаемой переменной. Это может быть и верно для простых типов типа Integer, но не для сложных динамических типов типа String и массивов. Дело в том, что для них ведь нужно выделить память, а старое значение нужно удалить. Поэтому изменение таких переменных ведёт к вызову функций менеджера памяти программы — несмотря на то, что при этом вся пограмма находится на паузе! В типичных ситуациях это не имеет значения, но в некоторых из-за таких побочных эффектов может получаться самое различное поведение программы. Просто имейте этот момент в виду.

Если вы хотите один раз просмотреть переменную, вам не обязательно добавлять её в список "Watch List" или вызывать окно "Inspect" или "Evaluate/Modify" — достаточно подвести курсор мыши к имени переменной в редакторе кода и через короткое время всплывёт подсказка со значением переменной (в случае, если выражение можно вычислить):


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


Хотя если подсказка не всплывает — это ещё не значит, что интересующее вас выражение нельзя вычислить. Возможно, среда просто не понимает, чего вы хотите :) Попробуйте посмотреть выражение через "Inspect" или "Evaluate/Modify".

Заметим, что вы также можете просматривать значения сложных элементов — таких, как массивы, записи и классы. К примеру, вот как выглядит Self в "Local Variables", окне "Inspect" и в подсказке:


Следующее окно, которое мы рассмотрим — это "Call Stack". Так называемый стек вызовов:


Оно показывает, какие процедуры вызывались до того, как выполнение дошло до текущего момента (текущего — т.е. там, где мы встали на паузу). Читать его нужно снизу вверх (текущий момент находится сверху, а начало программы — в самом низу). Например, мы видим, что наша процедура A вызывалась из P, которая в свою очередь вызвалась из Button2Click (мы смотрим сверху вниз, т.е. в обратном направлении). Также это окно пытается показывать аргументы вызова. Но для этого они должны быть доступны. Помните, что мы говорили про ACount и оптимизатор в обсуждении окна "Local Variables"? Те же слова применимы и здесь. Поскольку в нашем случае и ACount у процедуры A и Sender у Button2Click уже недоступны, то и показать их значения окно "Call Stack" не может (на рисунке вы видите знаки вопроса вместо аргументов вызова). Также текущая процедура (т.е. та, в которой мы находимся) в этом окне маркируется стрелочкой. По поводу странного вида процедур до Button2Click мы ещё поговорим позже. Это окно — очень важный инструмент при поиске источника ошибок. Например, при остановке после исключения вы ведь понятия не имеете, что происходит в программе. Взглянув на "Call Stack", вы легко определите, где вы находитесь и как вы сюда попали. Более того, вы можете дважды щёлкнуть по любой строке в этом окне — и вы автоматически попадёте в соответствующее место. Например, если вы сейчас щёлкните по строке с "Unit9.P" в окне "Call Stack", то вы мало того, что перейдёте в редакторе кода к процедуре P, так ещё и строка вызова процедуры A будет подсвечена красным цветом. Очень удобно, если одна процедура вызыватся несколько раз в разных местах. Щёлкнув по нужной строке в этом окне, мы легко определим, откуда был сделан вызов.

Итак, с помощью рассмотренной функциональности вы можете анализировать любую ситуацию в программе — проверять, чему равны у вас переменные, даже вычислять выражения. Но это только одна статичная ситуация из множества возможных. Мы пока всё ещё стоим на месте. Но отладчик позволяет больше, а именно: он позволяет выполнять программу по шагам, по строчкам. Посмотрите на последний снимок экрана: мы встали на заданной точке останова. Точка останова показана красной точкой слева от строки кода. Но вы также можете видеть поверх неё небольшую голубую стрелочку, которой не было, когда мы устанавливали бряк в режиме проектирования. Эта стрелочка показывает, что сейчас будет выполнена указанная строка. Для выполнения есть две основные команды — "Step over" (F8) и "Trace into" (F7). Нажмите, например, на F8. Вы увидите, как стрелочка переместится к следующей строке:


Это значит, что только что наша программа выполнила строку "Tag := X;" и готова к выполнению строки с ShowMessage. Вы можете видеть установленный breakpoint и сдвинутую на одну строку вниз стрелочку. Нажмите на F8 ещё раз. Вы увидите, что стрелочка пропадёт, в окнах отладчика появятся надписи "process not accessible", а в заголовке появится приписка "[Running]":


Это значит, что наша программа больше не стоит на паузе, а работает. Переключитесь на свою программу. Вы увидите, что она показала сообщение (ShowMessage) с текстом '1' (текстовое представление Tag, который равен 1). Программа полостью работает, вы можете таскать окно по рабочему столу. Закройте окно сообщения своей программы. Немедленно всплывёт окно среды:


Вы видите, что программа снова стоит на паузе. Мы только что выполнили строчку с ShowMessage.

Нажмите теперь F9 ("Run"). Вы увидите, что программа прошла несколько строчек (а именно — строку с end, for и begin) и снова остановилась на нашем бряке:


Вы можете видеть, как меняются переменные в окнах отладчика. Например, X равен теперь 2, а выражение "X = 1" стало ложным. Мы только что пошагово прошли с вами одну полную итерацию цикла и находимся там же, где и в самом начале отладки, только уже на второй, а не первой итерации. Нажав на F8, вы увидите, как меняет своё значение Tag с 1 на 2 (X равен 2, а в строке мы присваиваем Tag значение X):


Таким образом, мы с вами можем выполнять по шагам любой блок кода. Если вы не можете понять, почему ваша программа ведёт себя так, а не иначе — просто поставьте бряк на свой код, и пройдитесь по нему, выполняя каждую строчку и смотря, как и куда идёт выполнение кода, какие значения каким переменным назначаются и т.п. Большие блоки кода вы можете пропускать, ставя новые бряки и используя команду "Run"/"Run" (F9) или устанавливая курсор в нужную строку и используя "Run to cursor" (F4).

Напомним, что у нас есть две команды для пошагового выполнения — "Step Over" (F8) и "Trace Into" (F7). С первой мы уже познакомились — она просто выполняет текущую строчку и переходит на следующую. "Trace Into" работает похожим образом, но с одним отличием: если в текущей строчке есть вызов процедуры, то "Trace Into" зайдёт внутрь процедуры, в то время как "Step Over" выполнит всю процедуру одним махом. Если никаких вызовов процедур нет, то эти команды ведут себя одинаково. Например, положим, что мы вместо нашего бряка поставили бы бряк на строчку "P;" (вызов процедуры P в Button2Click). Тогда, если бы вы стояли на "P;" и нажали бы F8, то программа выполнила бы P целиком, показав три сообщения, после чего мы бы оказались в отладчике на строке после "P;" — т.е. на "end;" процедуры Button2Click. А если бы нажали на F7, то мы перешли бы в процедуру P, оказавшись на сроке "begin" перед "A(3);". Разумеется, если бы мы поставили оба бряка (один на вызов P, а второй на "Tag := X;"), то при попытке выполнить строчку с "P;" "одним махом" с помощью F8, мы всё равно оказались бы на втором бряке в строке "Tag := X;". Это полностью соответствует описанной логике. С одной стороны, F8 выполняет строчку целиком. С другой стороны, любой бряк приводит к остановке выполнения программы. Поэтому, когда F8 выполняет строку и в процессе этого выполнения натыкается на бряк, то она останавливает выполнение программы.

Обычно при отладке вы используете F8, выполняя код строго по строчкам. Например, в нашем примере нас не интересует выполнение процедур ShowMessage и IntToStr, а важен лишь конечный результат. Некоторые из этих процедур могут быть весьма нетривиальными — например, запрос реквизитов пользователя и подключение к серверу могут выполняться одной процедурой, которую мы можем выполнить одним нажатием на F8. С другой стороны, мы можем быть заинтересованы в отслеживании выполнения своих собственных процедур, поэтому, когда из одной нашей процедуры вызывается другая наша процедура, то мы будем использовать F7, чтобы зайти внутрь второй нашей процедуры и проследить её выполнение. Но опять же, если мы заранее знаем, чем кончится дело, то мы не обязаны шагать по шагам по всем процедурам — мы вполне можем использовать и F8.

Кроме этих команд ещё есть "Run until return" (Shift + F8). Она выполняет текущую процедуру и останавливается перед тем, как из неё выйти. Очень удобно, если вы по ошибке использовали F7 вместо F8 и зашли в большую процедуру. Чтобы избавить себя от необходимости проходить её целиком, вы просто жмёте Shift + F8 и оказываетесь в конце этой процедуры.

Мы рассмотрели большинство основных возможностей для отладки программы. Два главных инструмента отладчика — это наблюдение за переменными и пошаговое выполнение. Если вы используете описанный инструментарий несколько раз, то у вас появится потребность завершить выполнение программы раньше положенного. Например, вы запустили программу, стали её отлаживать и нашли причину ошибки. Теперь вам нужно её исправить. Но ваша программа сейчас работает или стоит на паузе. Прежде, чем вернуться к редактированию текста, вы должны завершить её. Что вы будете делать? Снимать все бряки, возобновлять выполнение программы и выходить из неё? Есть способ проще — вы можете использовать "Program reset" (Ctrl + F2). Эта команда немедленно обрывает выполнение программы. Её можно рассматривать как аналог команды "Завершить процесс" в Диспетчере Задач, только чуть более гуманный по отношению к среде Delphi. Кстати: никогда не используйте обычное снятие процесса отлаживаемой программы! Всегда используйте только "Program reset".

Заметим, что в опциях отладчика есть опция "Mark buffers read-only on run". Если она включена, то изменять текст программы во время её работы нельзя. Если же она сброшена, то вы можете начать исправлять ошибку, пока программа запущена. Но в этом случае ваши изменения не будут учтены, пока вы не перезапустите программу. Более того, как только вы воспользуетесь любой командой возобновления или паузы, отладчик заметит, что исходный код больше не соответствует выполняемой программе и спросит, что делать дальше:


Вы можете нажать Cancel для отмены действия, которое вы начали. Кнопка Yes приведёт к немедленному завершению программы, её перекомпиляции и запуску (разумеется, при условии, что компиляция успешно завершилась после всех ваших изменений кода). Кнопка No позволит вам продолжить отладку без перекомпиляции. Заметим, что в этом случае текст программы больше не соответствует её коду. Поэтому при пошаговой отладке вы можете увидеть, как отладчик пропускает целые блоки кода без видимой причины. На самом деле, эти куски кода вы только что или написали или поменяли. И слева вы можете увидеть, что для этого кода нет синих точек, т.е. он не входит в программу.

Если вы видите это окно, но вы не вносили никаких изменений в программу — значит, каким-то образом произошла рассинхронизация исходных файлов и актуальной программы. Чаще такое бывает при отладке DLL-библиотек. Чтобы этого не происходило — делате Build проекту перед отладкой.

Обычно имеет смысл перекомпилировать программу сразу после внесения изменений, т.к. часто изменения вносятся по ошибке: мы увидели ошибку — мы её исправляем, забыв снять программу с выполнения. Однако, есть и другой вариант — мы увидели ошибку, и мы её исправили, специально не снимая программы, т.к. мы хотим найти ещё ошибку. Мы исправили одну ошибку в коде, код изменился, но нам это не страшно, т.к. мы больше не будем к нему возвращаться — мы просто двигаемся дальше и ищем другую ошибку. Но если вы видите, что вы внесли так много изменений, что отладчик уже "плывёт" — перекомпилируйте программу.

Поговорим теперь о понятии отладочной информации. На самом деле вы не можете взять произвольную программу и начать её отлаживать так, как сейчас это сделали мы. Программа представляет собой набор машинных команд. Текст программы представляет собой текстовый файл. Вопрос: как отладчик узнаёт, когда надо остановиться, если вы поставили бряк на строку в тексте? Где же соответствие между текстовым файлом и набором байт в exe-файле? Вот для такой связи и служит отладочная информация. Это, грубо говоря, набор инструкций типа: "машинные коды с 1056 по 1059 относятся к строке 234 модуля Unit1.pas". Вот с помощью такой информации и работает отладчик. По-умолчанию она включена. Делается это в опциях проекта на вкладке "Compiler" в разделе "Debugging":


Опции проекта в новых Delphi

Опции проекта в D2009

Здесь вы видите целую кучу опций. Обычно они все (или почти все) включены для отладочной версии программы и отключаются для конечной версии, которую вы будете распространять. Напомним, что после переключения любой из этих опций необходимо делать полный Build проекта, чтобы опция вступила в силу. Про опцию "Assertions" мы уже говорили в разделе 1.2.2 — она включает или выключает "ассерты". Остальные опции нам ещё следует разобрать.

"Debug information" (директива {$D+} или {$D-}) — это собственно и есть отладочная информация. Т.е. соответствие между текстом программы и её машинным кодом. Вы должны включить эту опцию, если хотите ставить бряки и выполнять пошаговую отладку. Отладочная информация сохраняется вместе с кодом модуля в dcu-файле. Т.е. один и тот же Unit1.dcu может быть скомпилирован как с отладочной информацией, так и без неё. Отладочная информация увеличивает время компиляции, размер dcu-файлов, но не влияет на размер и скорость работы полученного exe-файла (т.е. отладочная информация не подключается к exe-файлу).

"Local symbols" (директива {$L+} или {$L-}) — является дополнением к отладочной информации. Она отвечает за соответствие между данными в exe-файле и именами переменных. Если вы включаете эту опцию, то отладчик позволит вам просматривать и изменять переменные. Также окно "Call Stack" будет способно отражать переданные в процедуры параметры.

"Reference info" — это дополнительная информация для редактора кода, которая позволяет ему отображать более подробную информацию об идентификаторах. Например, где была объявлена переменная.

Заметим, что эти три опции ("Debug information", "Local symbols" и "Reference info") обычно имеет смысл включать или отключать одновременно. Опцию "Definitions only" включать особого смысла нет.

Зачем мы говорим об этом так подробно? Дело в том, что если модуль был скомпилирован без отладочной информации, то использовать обычный отладчик для него вы не сможете. Т.е. не будут работать бряки, поставленные на код этого модуля. Вы не сможете зайти по F7 в любую процедуру этого модуля и т.п. Посмотрите хотя бы на наш пример, что мы жевали здесь довольно долго: у нас есть вызовы ShowMessage и IntToStr. Попробуйте в них зайти. Попробовали? Да, ничего не вышло — F7 сработала как обычная F8. Это как раз и происходит потому, что нет отладочной информации для модулей Dialogs и SysUtils соответственно. Все стандартные модуля Delphi не имеют отладочной информации. Обычно это очень удобно — ведь вам большую часть времени не нужно отлаживаться внутри стандартных процедур. Однако если вам всё же нужно это сделать (например, по непонятным причинам вылетает Assign для стандартного TTreeView и вы должны выяснить почему), то вы можете переключиться между обычной и отладочной версией системных модулей. Для этого вы устанавливаете галочку "Use debug DCUs". После этого вы можете использовать F7, чтобы заходить в стандартные процедуры, в частности, вы теперь можете зайти и в IntToStr. Разумеется, эта опция работает только для стандартных модулей Delphi. Для того чтобы использовать отладочную версию своих модулей — вы должны перекомпилировать их с нужными опциями. Есть тут ещё один тонкий момент. Если вы компилируете своё приложение с пакетами времени выполнения, то модули, для которых вы хотите включить/выключить отладку могут находиться в пакете, а не в программе. И на них эти опции влиять, разумеется, не будут. Возможно, вам придётся пересобрать свои пакеты или временно отключить компиляцию с пакетами.

Посмотрите на наш пример, когда мы говорили про окно "Call Stack". Мы заметили, что все процедуры ниже Button2Click имеют странный вид. Это как раз и происходило потому, что все эти процедуры являлись стандартными процедурами Delphi и поэтому размещались в модулях без отладочной информации. Если бы мы включили опцию "Use debug DCUs", то наш стек вызовов выглядел бы так:

Как видим, "странными" у нас остались только функции из системных библиотек — для них, очевидно, отладочной информации у нас нет.

Давайте теперь проведём два примера по поиску и устранению ошибок с использованием отладчика, чтобы куча сказанной информации улеглась у вас в голове.

Создайте новое приложение, плюхните кнопку и напишите для неё такой код:

procedure TForm1.Button1Click(Sender: TObject);

  function FindAnswer(const A, B, C: Extended): Extended;

    function Func1(const X, Y: Extended): Extended;
    begin
      Result := (X + Y) / (X - Y);
    end;

    function Func2(const X, Y: Extended): Extended;
    begin
      Result := X + Y;
    end;

  begin
    Result := Func1(A, B) / Func2(B, C);
  end;

var
  X: Extended;
begin
  X := FindAnswer(3, 3, 3);
  ShowMessage('Ответ: ' + FloatToStr(X));
end;

Здесь мы ввели несколько функций, которые вычисляют некоторое выражение. Как и ранее, для примера мы взяли совершенно бессмысленные действия, они не делают ничего полезного, но позволят нам продемонстрировать технику поиска ошибок. В нашем случае пусть ошибка заключается в том, что мы перепутали знаки плюс и минус в строке "Result := (X + Y) / (X — Y);" и, кроме того, забыли умножить -Y в этой строке на 2. Другими словами, правильный код функции Func1 должен выглядеть следующим образом:

function Func1(const X, Y: Extended): Extended;
begin
  Result := (X - 2*Y) / (X + Y);
end;

Предположим, что мы этого не заметили и запускаем программу на выполнение. Мы жмём на кнопку, и — бах! "Floating point division by zero"! О, как. Это явно не то, что мы ждали. Мы ожидали увидеть ShowMessage с ответом, а сами получили сообщение об ошибке. Наша задача — найти ошибку с помощью отладчика. Итак, запускаем программу под отладчиком, жмём на кнопку и видим уведомление отладчика о том, что в программе возникло исключение. Мы жмём на кнопку "Debug" и отладчик переносит нас сразу в строку, которая возбудила исключение:

Поскольку исключение уже произошло, мы не можем просмотреть состояние программы перед выполнением этой строки. Но, по крайней мере, нам уже ткнули на строку. Поставим на неё бряк и запустим программу ещё раз (нажмём "Program reset", а затем "Run"), нажмём на кнопку и остановимся на бряке:

Теперь мы находимся перед выполнением проблемной строчки. Предположим, что мы всё ещё не заметили проблемы (как говорится, глаза видят лишь то, что мы хотим видеть), поэтому мы начинаем анализ ситуации. Мы смотрим в "Local Variables" и видим аргументы функции. Любым доступным способом (в уме, на бумажке, используя "Watches", "Inspect", "Evaluate/Modify" или подсказку) мы проверяем составные части выражения. Мы видим, что делитель у нас равен нулю, поэтому всё выражение в целом не может быть вычислено (мы 6 делим на 0)! Мы осознаём, что мы перепутали знаки в выражении (делить мы должны на 6).

Проклиная себя за невнимательность, мы меняем знаки местами и перезапускаем программу (предположим, что мы настолько спешим всё исправить, что не заметили пропущенное "2 *" в этой же строке), жмём на кнопку и видим ответ: ноль. Странно, говорим мы. Результат не должен быть равен нулю. Для поиска проблемы мы ставим бряк на начало функции Button1Click и запускаем программу на отладку:

Итак, нам известно, что наш X стал равен 0, что явно неверно. Мы должны выяснить почему. Для этого мы проследим выполнение программы. При этом мы будем внимательно следить за переменными программы (либо используя окна типа "Watch List" и "Local Variables", либо попросту используя подсказки). Мы нажимаем F7 и переносимся в функцию FindAnswer. Нажимая F7, далее мы заходим в функцию Func1. Проходя её до конца (нажимая F8), мы видим, что её результат (переменная Result) оказался равен нулю. Почему нам это кажется подозрительным? Потому что мы проверяем сейчас контрольный пример, мы проверяем, что программа вообще считает правильно. Затем мы будем подставлять реальные цифры в FindAnswer, а сейчас у нас есть пример с фиксированными цифрами, который мы ручками посчитали на бумажке, и мы видим, что результат программы расходится с тем, что на бумаге. Да, мы знаем, что пример надуман, но ведь это всего лишь пример! В реальности любой дурак, взглянув на код, скажет, что ноль делить на любое число будет ноль. А раз Func1 возвращает ноль, то и общий результат будет равен нулю, следовательно, Func1 не должна возвращать ноль. Итак, вот наша ситуация, как она выглядит в отладчике (напомним, что мы засекли, что Result равен 0, и это вызвало наши подозрения):

Мы снова начинаем анализ ситуации. Мы проверяем каждое выражение (используя, как обычно, окна отладчика или подсказки) и видим, что выражение "(X — Y)" равно нулю, хотя оно должно быть равно -3. Внимательно вглядевшись в него, мы видим, что пропущено "2 *" перед Y. Проклиная себя, вы дописываете пропущенный код, перезапускаете программу и видите, что она показывает правильный ответ: -0.08(3).

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

procedure TForm1.Button1Click(Sender: TObject);

  function FindAnswer(const A, B, C: Extended): Extended;

    function Func1(const X, Y: Extended): Extended;
    begin
      Result := (X - 2*Y) / (X + Y);
    end;

    function Func2(const X, Y: Extended): Extended;
    begin
      Result := X + Y;
    end;

  begin
    Result := Func1(A, B) / Func2(B, C);
  end;

var
  X: Extended;
begin
  X := FindAnswer(3, 3, 3);
  ShowMessage('Ответ: ' + FloatToStr(X));
end;

Да, это была простая ситуация. В конце концов, ведь это всего лишь пример. Его цель — просто показать, как нужно использовать отладчик для отладки программ. Но ровно те же приёмы вы будете использовать при отладке своих программ. Да, в нашем случае обе ошибки было легко обнаружить, просто внимательно просмотрев код или прикинув его выполнение в уме. В реальных же ситуациях ошибка закопана где-то среди сотен строк кода и двух десятков переменных. На глаз определить место возникновения ошибки уже не так просто. Используя отладчик, это становится гораздо проще сделать. На самом деле, вы не будете по шагам проходить весь код. Когда вы встречаетесь с конкретной проблемой, вы можете мысленно пробежаться по коду и отметить места, вызывающие у вас сомнение — вероятные места для возникновения ошибки — и проверить в первую очередь их. Например, если вернуться к нашему примеру, то при возникновении ошибки деления на ноль мы могли взглянуть на все операции деления (а их у нас две) и, внимательно просмотрев обе строки, найти опечатку. В случае с нулевым результатом мы могли прикинуть: какие должны быть условия, чтобы результат получился нулевым? Очевидно, что для этого Func1 должна вернуть ноль. Какие условия должны выполниться, чтобы Func1 вернула ноль? Разумеется, это будет, когда делимое равно нулю. Посмотрев на наше делимое, мы бы увидели ошибку. Поэтому в реальных ситуациях комбинируются два подхода: сперва мы пытаемся логически выяснить причину ошибки, а если это не удаётся, то используем отладчик. И даже, если мы его используем, то не в лоб просматривая весь код, а в первую очередь проверяя наши подозрения (интуиция играет здесь тоже не последнюю роль).

Если же этот надуманный пример не показался вам наглядным — попробуйте посмотреть, например, вопрос №65903.

Итак, мы рассмотрели основные возможности отладчика. Если вы чувствуете, что ещё плаваете в этих вопросах, то мы рекомендуем к просмотру видеоурок (также его можно скачать — чуть менее 50 Мб) от Ника Ходжеса, посвящённый отладчику (все видеоуроки: Thirty Camtasia Demos in Thirty Days). Да, к сожалению, видео на английском и субтитров нет. Однако даже без особого знания английского оно может иметь пользу, потому что рассказывает о тех же вещах, о которых мы говорили в этом пункте. Это видео можно смотреть просто как визуальную демонстрацию работы с отладчиком. Вы без особого труда поймёте, что там происходит, если вы читали данный пункт до этого момента. Примечание: возможно, во время просмотра видео вам будет удобнее перевести браузер в полноэкранный режим — часто это кнопка F11.

В последующих пунктах мы подробнее рассмотрим ситуацию возникновения исключения и что с ней можно сделать (не всегда диагностика исключения так проста, как в нашем примере). На самом деле, есть ещё много интересного, что можно было бы рассказать про отладчик — например, окно "Breakpoints", бряки на память, свойства бряков вообще, окна "Threads", "Modules" и достаточно важное окно "Event Log". Кроме того, мы вообще не затрагивали тему CPU-отладчика, а также отладки DLL и чужих процессов ("Attach to Process..."). До конца этого пункта мы рассмотрим некоторые из этих вещей, но поскольку это уже темы для более подробного изучения, вы, возможно, захотите пропустить на первый раз нижеследующее описание и переключиться на другие пункты статьи.

Итак, для тех, кто ещё с нами, мы продолжаем знакомиться с отладчиком. Для начала мы рассмотрим мощную, но слабо документированную команду — "Set Next Statement". В старых Delphi её не было, и даже в новых она малоизвестна, т.к. вызывается она только из контекстного меню редактора кода. Используется она следующим образом: вы ставите программу на паузу (если она ещё не стоит), затем устанавливаете курсор на строчку, которую вы хотите выполнить следующей, щёлкаете по ней (по редактору кода) правой кнопкой мыши и в контекстном меню выбираете команду "Set Next Statement":

После чего вы увидите, как синяя стрелочка (отметка текущей команды) перейдёт на указанную вами строчку! Например, пусть у нас есть такая ситуация:

Здесь в FOnCreate назначен какой-то обработчик события. Предположим, что мы не хотим выполнять его. Один вариант мы уже рассматривали: используя возможности отладчика по модификации переменных, об-nil-ить FOnCreate, выполнить if, затем вернуть значение FOnCreate на место. Не очень сложно, но хлопотно. Используя "Set Next Statement", вы можете просто поставить курсор на строчку после except-блока (на "if fsVisible in...") и вызвать команду "Set Next Statement":

И всё! Вы только что перешли по другой ветке кода. При этом условие на обработчике события даже не проверялось (и вообще не выполнилось ни одной строки кода, как это бывает при "Step Into" и "Trace Over"). Необычайно удобная команда для ответа на вопросы типа "а что, если...?".

Т.е. эта команда позволяет вам как угодно изменять порядок выполнения команд в программе, проходя даже по тем путям выполнения, которые вообще не могут возникнуть в реальных ситуациях. Более того, вы можете даже выполнять программу "вспять". Например, в нашем примере мы снова можем поставить курсор на первую после begin строчку (на "if Assigned...") и снова вызвать "Set Next Statement". Получится, что мы только что выполнили операторы в обратном порядке. Причём, если в первом случае промоделированная нами ситуация в принципе была возможна (если FOnCreate было бы равно nil), то во втором случае мы промоделировали невозможную ситуацию: ни при каких условиях if-ы в этом блоке кода не могут выполняться в обратном порядке (невозможный путь выполнения). Поэтому, команда "Set Next Statement" — это очень мощный инструмент, которым нужно пользоваться очень аккуратно. В частности, в нашем же примере ничто не мешает нам перепрыгнуть внутрь блока except. Разумеется, это неминуемо приведёт к слёту программы (access violation), т.к. перепрыгнув внутрь блока except, мы обошли обычный путь выполнения, при котором производятся дополнительные действия (напомним, что, используя goto, вы также не можете пересекать границы try-except-finally блоков).

Далее мы рассмотрим функцию OutputDebugString. Это функция из модуля Windows.pas, которая принимает PChar-строку. При обычном выполнении программы (без отладчика) эта функция ничего не делает. Если программа запущена под отладчиком Delphi, то задаваемая строка попадает в окно "Event Log" (мы обсудим его чуть позже). Кроме того, вы можете использовать программу Debug View для просмотра этих сообщений вне отладчика. Просто запустите эту программу, и она будет показывать все сообщения от OutputDebugString. Эта функция — удобная замена обычным ShowMessage и логгированию в файл, которые начинающие программисты так любят расставлять по программе для отладки. Её плюсы: не требует других модулей, кроме Windows.pas (ShowMessage требует Dialogs.pas); работает везде — в консольных приложениях, в службах и т.п.; её результаты видимы только при использовании средств отладки и невидимы при обычном запуске (что не значит, что вы должны вызывать эту функцию в финальной версии!); не требует написания кода (в отличие от ручного логгирования в файл); обычно нет проблем с правами (например, для случая отладки службы). Используйте OutputDebugString для логгирования в отладочной версии вместо ShowMessage и подобных самоделкиных решений. См. также "Understanding Win32 OutputDebugString".

Следующее окно для ознакомления — "Event Log":

Это вид окна при запуске процесса. А вот его вид после некоторой работы:

В окно "Event Log" попадает различная информация по ходу работы программы: во-первых, это уведомления о загрузке/выгрузке модулей (голубой цвет), запуске и остановке потоков и процесса (тёмно-красный и серый цвет). Во-вторых, в него помещается вывод функции OutputDebugString (синий цвет). Для создания такой строчки, как на скриншоте, в программе была строка "OutputDebugString('Отладочный вывод от OutputDebugString.');". В-третьих, это различные сообщения, связанные с точками останова (светло-красный цвет), а также сообщения от точек станова (красный цвет) и стек вызовов от них же (оранжевый цвет). Чуть позже мы обсудим точки останова более подробно. Кроме того, в это окно можно добавлять строчки и вручную — выберите пункт "Add Comment..." из контекстного меню (чёрный цвет). Также сюда добавляются уведомления об исключениях, и ещё можно включить логгинг сообщений Windows.

По-умолчанию, лог очищается при каждом запуске процесса. Вы также можете сохранить его в файл для анализа или очистить руками в середине работы — для этого воспользуйтесь соответствующими командами из контекстного меню. Кроме того, в опциях отладчика есть настройка окна "Event Log" (которая также доступна из контекстного меню окна "Event Log"):

В частности, помимо настройки поведения и внешнего вида, здесь можно включить/отключить логгинг определённых типов событий. Если интересующее вас событие происходит редко и/или тонет в общей массе событий, можно просто выключить все другие типы событий. Именно это является причиной, почему по-умолчанию отключен логгинг сообщений Windows — их всегда бывает очень много. Кроме того, вероятно, вы захотите отключить опцию "Display process info with events" — она показывает дополнительную информацию о процессе, вызвавшем событие. Поскольку чаще всего вы будете отлаживать только один процесс, эта информация не несёт полезной нагрузки и только создаёт шум в логе. В случае отладки двух процессов эта опция позволит отличать события от разных процессов.

В самом начале этого пункта мы буквально краем коснулись точек останова с целью быстрее познакомить вас с возможностями отладчика, т.к. они (возможности) доступны только в режиме остановки программы, а точки останова являются основным средством для установки программы на паузу. Теперь мы рассмотрим их более подробно. И для этого сначала взглянем на окно "Breakpoints":

Это окно содержит список всех точек останова в вашем проекте. Отсюда вы можете управлять ими всеми. Можно, например, удалить все точки останова, когда вы закончили отладку. Можно добавлять точки останова. Можно редактировать их свойства и временно отключать (disable). Точка останова не активна (т.е. не работает), если галочка слева от неё сброшена. Удобно временно отключать точку останова, если сейчас она вам только мешается, но в будущем ещё понадобится. Тогда вы сейчас её отключаете, а когда она снова понадобится — включаете (enable) обратно.

Кстати, включить/выключить точку останова, а также открыть окно её свойств вы можете, щёлкнув правой кнопкой мыши по красному кружку точки останова в левой части редактора кода:

Взглянем теперь на свойства точки останова (заметим, что некоторые их этих свойств вы можете редактировать прямо в окне "Breakpoints", не открывая окна свойств):

Первые две строки задают место установки точки останова (они меняются для разных типов точек останова, но об этом потом). Обычно они задаются автоматически, когда вы мышью ставите точку останова, но вы можете указывать их и руками — например, при ручном добавлении точки останова через команду "Add breakpoint". Строка "Condition" задаёт дополнительное условие. Если она пуста (по-умолчанию) — бряк срабатывает каждый раз, когда до него доходит выполнение, если она не пуста (задана), то он срабатывает только в случае, если условие в данном поле истинно. Например, если бы мы в нашем примере выше для бряка на строке "Tag := X;" вписали бы в строку "Condition" условие "X = 1" (без кавычек, разумеется), то бряк сработал бы только один раз — на первой итерации цикла (поскольку только на ней X равен единице). Разумеется, то, что вы сюда впишете, должно вычисляться, когда выполнение доходит до точки останова, и, кроме того, всё выражение в целом должно иметь тип Boolean.

Строка "Pass Count" определяет, на который проход мимо точки останова отладчик остановит программу. 0 или 1 означает немедленную остановку. Например, если бы мы указали "Pass Count" равным двум в нашем примере, то мы бы пропустили первую итерацию цикла и остановились бы только на второй итерации. После срабатывания точки останова счётчик сбрасывается, и отсчёт начинается снова (поэтому, мы пропустили бы третью итерацию цикла и остановились бы на четвёртой, если бы она у нас была). Может комбинироваться с полем "Condition". В этом случае сперва высчитывается поле "Condition" и, если оно равно True, то проверяется/изменяется счётчик "Pass Count".

Поле "Group" определяет группу, в которую входит точка останова. Обычно используется, если у вас много точек исключения. В этом случае их можно сгруппировать в группу и управлять всеми точками останова в группе (например, включать/выключать) одновременно как единым целым. Для включения точки останова в группу просто введите её имя в поле "Group". Если вы уже вводили название группы для другой точки останова, то вместо повторного ввода вы можете выбрать группу из раскрывающегося списка. Иногда имеет смысл включать в группу одну-единственную точку останова. Это бывает в случаях, когда вы создаёте сложные условия с помощью продвинутых (advanced) опций (описание чуть ниже).

Флажок "Keep existing breakpoint" (в старых Delphi его нет) служит для создания новой точки останова при модификации свойств уже существующей. Например, вы поставили точку останова, задали ей свойства, а потом решили поставить точно такую же точку останова, но чуть ниже, на другую строчку. Чтобы не создавать новую точку останова и не вводить все свойства заново, вы можете открыть свойства уже существующей точки останова (с проставленными свойствами), установить галочку "Keep existing breakpoint" и изменить поле "Line number" (разумеется, сначала вам нужно посмотреть в редакторе кода номер строки, на которую вы хотите установить новую точку останова).

В режиме "Advanced" (кнопка "Advanced" сворачивает или разворачивает нижнюю часть окна) вам доступны продвинутые режимы использования точек останова, которые используются значительно реже. Флажок "Break", если он установлен, определяет обычное поведение точки останова. Если вы его сбросите, то точка останова не будет приводить к остановке программы. Зачем, в таком случае, она нужна? Дело в том, что вы можете назначить некоторые события, которые будут выполняться при прохождении точки останова. Все опции в разделе "Advanced" делают именно это. Для многих из них вы, вероятно, захотите сбросить опцию "Break", т.к. вам нужно, чтобы просто сработало событие, но не нужно при этом останавливаться. В этом случае точка останова ведёт себя подобно триггеру на задаваемое действие.

Опции "Ignore subsequent exceptions" и "Handle subsequent exceptions" обычно работают парой. Если выполнение программы проходит мимо точки останова с установленной опцией "Ignore subsequent exceptions", то отладчик отключает свои уведомления об исключениях. Опция "Handle subsequent exceptions" действует ровно наоборот — она включает уведомления. Если вы отлаживаете код, в котором часто возникают исключения перед тем, как выполнение дойдёт до интересующего вас места, то вы можете установить точку останова до и после кода, возбуждающего исключения. Последовательно задавая этим точкам останова опции "Ignore subsequent exceptions" и "Handle subsequent exceptions" и сбрасывая опцию "Break", вы добьётесь игнорирования отладчиком исключений на проблемном участке кода.

Опция "Log message" заносит заданное сообщение в окно "Event Log" каждый раз, когда срабатывает точка останова.

Опция "Eval expression" вычисляет заданное выражение каждый раз при срабатывании бряка. Если при этом включена опция "Log result", то результат вычислений добавляется в "Event Log". Очень полезная функция (вместе с "Log message"), которую можно использовать для логгирования без модификации исходного кода, т.е. устанавливаются точки останова вместо OutputDebugString в коде, и логгинг работает сразу — нет необходимости перекомпилировать и перезапускать программу. Разумеется, в отличие от OutputDebugString, логгинг средствами точек останова работает только при отладке из-под отладчика Delphi и не доступен при автономном прогоне программы (для OutputDebugString при этом доступен вывод от программы DebugView). Удобно использовать эти опции для "лёгкого профайлинга" (лёгкого — в смысле примитивного): для замера времени выполнения какого-то кода, установите вокруг него две точки останова. В "Eval expression" впишите GetTickCount и сбросьте опцию "Break". После прогона разница значений в логе даст вам приближённое время выполнения участка кода в миллисекундах.

"Enable/Disable group" включает или выключает группу брейкпойнтов при срабатывании текущей точки останова. Используются довольно редко, т.к. необходимы для задания довольно сложного поведения точек останова. Один из вероятных сценариев использования этих опций — отладка двух разных потоков сразу. Например, при достижении точки останова в первом потоке отключаются все точки останова во втором потоке и наоборот. Таким образом, начав отладку одного потока (первого, в котором сработает точка останова), наш процесс отладки не прервётся другим потоком. Это избавляет вас от ручного включения и выключения точек останова при нескольких проходах отладки. Более подробно о потоках мы ещё поговорим при обсуждении окна "Threads". Другой вариант использования этих опций — отладка системных модулей. Например, вы расставили точки останова в коде VCL. Но нужный вам код VCL выполняется при запуске приложения, а вам нужно, чтобы точки останова срабатывали только после, например, нажатия на кнопку. Поэтому можно отключить точки останова, а в нужное место (например, в dpr-файле после создания форм) поставить пустую точку останова, указав, что при проходе она должна включать все точки останова. Тогда получится, что, во время загрузки приложения точки останова будут молчать, а как только пойдёт работать ваш код — тут они и сработают.

"Log Call Stack" (нет в старых Delphi) заносит в "Event Log" стек вызовов при прохождении точки останова. Например, установив бряк с этой опцией (и без опции "Break") на начало функции, можно логгировать, кто вызывает эту функцию. Опции "Entire stack" и "Partial stack" переключают логгинг всего стека или только первых "Number of frames" записей. Это невероятно удобная опция, если у вас нет под рукой готового инструмента типа JCL или EurekaLog. Поставив точки останова с этой опцией на конструкторы класса Exception (разумеется, только с включённой опцией "Use Debug DCUs", т.к. класс Exception сидит в стандартном модуле SysUtils.pas), вы во многих случаях можете упростить отладку, т.к. при возникновении исключения в "Event Log" будет попадать стек вызова для возникшего ислючения.

Но это ещё не всё. До сих пор мы имели дело только с точками останова на код (source breakpoint). Такая точка останова характеризуется именем файла с исходным текстом модуля и номером строки в нём. Помимо таких точек останова есть ещё три типа точек останова. Их можно добавить, используя пункт "Add breakpoint" в меню "Run" или в окне "Breakpoints" и "Modules" (об этом окне попозже). Итак, есть ещё такие точки останова:

Как видите, в центральной части окна показан список загруженных модулей. Красной точкой отмечены те из них, на загрузку которых стоит точка останова. Разумеется, обычно имеет смысл ставить точку останова только для тех модулей, которые ещё не загружены. Поэтому для добавления модуля в список можно воспользоваться "Add module" из контекстного меню окна или же командой "Add module breakpoint" из меню "Run"/"Add breakpoint". В нижней части окна для Delphi-модулей показываются входящие в исполняемый модуль (module) модуля (unit) Delphi. Да, к сожалению, на русский и слово "module" (исполняемый модуль, exe или dll-файл) и слово "unit" (модуль Delphi, pas-файл с исходным текстом) переводятся как "модуль", поэтому может возникнуть некоторая путаница в терминах.

В правой части окна показываются экспортируемые (для DLL) или импортируемые (для exe) функции. Заметим, что гораздо больше информации о модулях процесса можно получить, используя утилиту Process Explorer. Мы рекомендуем использовать её в сеансах отладки параллельно с отладчиком Delphi.

Очередное окно для рассмотрения — это окно "Threads":

Оно показывает список отлаживаемых процессов и список потоков в них. Активный поток (т.е. поток, который сейчас отлаживается) показан зелёной стрелочкой. Сам поток характерируется своим ID (первая колонка) и текущей точкой выполнения (последняя колонка). Колонка "State" обозначает состояние потока и принимает значения Runnable (поток работает), Stopped (поток остановлен), Blocked или None. Колонка "Status" показывает статус потока и может быть Init (поток только что был создан), Breakpoint (выполнение программы было остановлено из-за точки останова в этом потоке), Faulted (программа была остановлена из-за аппаратного исключения в этом потоке), Unknown (поток сейчас не отлаживается (не активен), поэтому его статус не известен) или Stepped (в этом потоке была выполнена команда пошаговой отладки). Кстати, если у вас D2009 и Vista, то для потока показываются его зависимости (колонка "Wait Chain"):

Здесь мы видим, что поток 1284 ждёт поток 3792, который, в свою очередь, ждёт поток 2792, который снова ждёт 3792. Т.е. получается, что потоки 2792 и 3792 ждут друг друга — т.е. налицо взаимная блокировка. Более того, указывается и причина ожидания: рабочий поток 3792 захватил критическую секцию и отправил SendMessage сообщение главному потоку 2792. Обработчик главного потока пытается захватить критическую секцию, занятую рабочим потоком — вот вам и причина зависания программы! Итак, колонка "Wait Chain" — это мощнейшее средство для поиска причин различных блокировок. И +1 причина для перехода на Vista ;)

Заметим, что обновление статуса происходит не постоянно, а лишь во время некоторых событий, например — приостановке программы. Т.е. если поток меняет своё состояние или статус во время работы программы, то вы не увидите изменений в окне "Threads" пока, скажем, не встретите точку останова. Разумеется, запуск или остановка потока также являются событиями, при которых происходит обновление окна "Threads".

Вообще, в процессе отладки многопоточных приложений каждый раз, когда программа ставится на паузу (например, точкой останова), все потоки в программе приостанавливаются отладчиком. При этом поток, в котором сработала точка останова, будет активным — вы отлаживаете именно его. Хотя все прочие потоки также имеют понятие текущего места выполнения, но отладчик может показать вам только один редактор кода с только одной строкой с синей стрелочкой — именно для активного потока. Когда вы выполняете любую команду выполнения (запуск или пошаговую отладку), начинают выполняться все приостановленные потоки. При этом ваша команда имеет смысл только для активного потока. Например, пусть в программе два потока, программа стоит на паузе, первый поток является активным, и вы нажали F8 ("Step Over"). После этого оба потока продолжат своё выполнение, и программа будет остановлена, как только будет выполнена (одна) текущая строчка в первом потоке. При этом второй поток может успеть (или не успеть) выполнить ноль, одну или несколько строк. Т.е. ваша команда ("Step Over") выполняется только для активного потока. Все прочие потоки работают как обычно, успевая сделать, сколько они успеют сделать за время между двумя паузами. Конечно, если при этом в коде второго потока встретится точка останова, то программа будет остановлена, и активным станет второй поток. В принципе, переключаться между потоками можно и в окне "Threads", используя контекстное меню, но чаще всего этого добиваются именно расстановкой точек останова на потоки. Обычно с помощью окна "Threads" переключаются между потоками, когда хотят "визуально" посмотреть, где сейчас стоит поток.

И, наконец, последнее окно, которое мы рассмотрим — это окно CPU-отладчика (в нём изображён всё тот же наш пример из этого пункта с установленной точкой останова на "Tag := X;"):

Это окно позволяет отлаживать программу на низком уровне. Основная часть окна показывает программу в том виде, как она выполняется процессором — в виде машинных команд. Для удобства человека машинные команды именуются, и эта система наименования называется ассемблером. Заметим, что это именно просто система названий, чтобы вместо цифр иметь дело с текстовыми строками. Т.е. ассемблер является просто символическим представлением машинного кода. Когда мы говорим об одном из них, всегда можно машинный код заменить на ассемблер и наоборот — ровно один к одному. Т.е. фактически, ассемблер и машинный код — просто два разных представления (в виде текста и цифр) одной сущности.

Чаще всего вы будете иметь дело с этим окном, когда вы пытаетесь отлаживать код, не имея соответствующей отладочной информации. Обычно это бывает при отладке библиотек, но может произойти и для обычного приложения. При этом у вас есть выбор — или попытаться пересобрать проект, включив предварительно отладочную информацию, или отлаживаться в CPU-отладчике, если включение отладочной информации невозможно. По вопросам отладки DLL библиотек можно дополнительно посмотреть: Delphi dll debugging, Отладка Shell extensions с помощью Delphi.

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

Конечно, чтобы полноценно воспользоваться CPU-отладчиком, нужно хоть немного знать ассемблер (для начала сойдёт буквально любое руководство или сравочник, которые расшифровывают, кто такие "mov", "jle" или "lea"). Не будем рассказывать здесь для тех, кто разбирается в ассемблере — навряд ли они станут читать это место статьи (а, даже если и будут, — то сумеют разобраться самостоятельно). Однако это окно может принести пользу, даже если вы понятия не имеете об ассемблере. Дело в том, что помимо собственно текста программы окно CPU-отладчика содержит ещё другие окна. Кстати, можно вызывать окно CPU-отладчика не целиком, а по частям. Любую часть этого окна можно вызвать как самостоятельное отдельное окно — для этого воспользуйтесь меню "View"/"Debug Windows"/"CPU Windows" и выберите интересующее вас окно. Конечно, при этом желательно иметь представления о внутренностях процесса вообще. Кто такие адресное пространство, стек, регистры и т.п. Конечно, лучше всего почитать книжку по ассемблеру, но для начала сойдут и базовые сведения, которые можно подчерпнуть, скажем, в Википедии (на момент написания статьи в русскоязычной Вики была достаточно скудная информация, в английской — намного больше). Если чувствуете в себе достаточно сил — можете попробовать почитать "Введение в машинный код" и другие статьи на сайте wasm.ru.

Например, нижняя часть окна является просмотрщиком памяти процесса. Да, в этом окне показывается всё адресное пространство вашего процесса (2 Гб памяти пользовательского режима для 32-разрядных процессов) — от 0 (nil) до $7FFFFFFF. Зачем оно может понадобиться? Например, в любых исследованиях внутренних структур Delphi. К примеру, мы хотим знать, как устроена строка типа String (да, вообще-то есть документация и исходный код, но иногда полезно пощупать и вживую). Или вы просто хотите узнать текущий счётчик ссылок. С помощью окна "Inspect" вы выясняете адрес, где хранится строка String, затем вы открываете окно "Memory" (кстати, их можно открыть аж 4 штуки), и в контекстном меню выбираете команду "Goto Address". Вводите адрес строки (вы же скопировали его в буфер обмена из окна "Inspect"?) и смотрите на её внутреннее устройство (в этом примере адрес был $A50C48, строка типа AnsiString и рассматривается Delphi до D2009 — в D2009 другая структура строк):

Здесь левый блок цифр представляет собой адреса памяти, центральная часть — содержимое памяти в hex-виде по этим адресам, а правая часть — ровно то же содержимое, но в текстовом виде. Точка означает отсутствие подходящего символа для отображения (аналог квадратика или вопросика в других программах). Данные памяти выводятся с группировкой по строкам в восемь байт, поэтому адреса в левой колонке отличаются друг от друга на 8 (кстати, они необязательно кратны восьми). Например, байты в самой верхней строке (там ещё на первом байте стоит прямугольник выделения) имеют адреса $A50C00, $A50C01, $A50C02, $A50C03, $A50C04, $A50C05, $A50C06 и $A50C07 (слева направо). Байт по адресу $A50C08 переносится на вторую строчку.

В данном случае данные ANSI-строки (строка 'assd') располагаются по адресу $A50C48. Чёрным цветом выделены собственно данные строки (не забываем про терминатор в виде нулевого символа в конце строки), синим — служебные данные о длине строки (равна 4 — не забываем, что число типа Integer хранится в памяти в виде: младший байт по младшему адресу), а красным — счётчик ссылок. Кстати, суммарно строка занимает 15 байт (не считая размера самой переменной, равного ещё 4-м байтам). Как видим, счётчик ссылок равен двум, поэтому в текущем месте нельзя модифицировать строку напрямую (например, руками приводя её к Pointer): сперва нужно получить уникальную копию вызовов UniqueString. Таким образом, это окно можно использовать для поиска странных глюков со строками (и не только), когда высокоуровневый отладчик уже не помогает.

Заметим, что вы также можете производить поиск значения в памяти процесса или редактировать память в произвольном месте (!). Также вы можете поменять вид окна. Все эти команды вызываются из контекстного меню окна "Memory". Вот, например, то же окно, но с группировкой данных по DWord:

В этом случае Integer-числа показаны в их "естественном" виде, более удобным для чтения человеком (2 и 4). Правда, сами данные строки теперь стали неудобоваримыми — вместо цепочки байт показана их группировка по DWord-ам, что довольно бесполезно.

Следующее окно, которое может пригодиться — окно стека. Если у вас когда-нибудь возникнет жгучая потребность посмотреть, что же происходит в стеке — у вас есть такая возможность (в окне CPU-отладчика это окно находится в правом-нижнем углу):

Само окно имеет такую же структуру, как и окно "Memory": адреса, данные, текстовое представление. Единственное отличие: данные показываются по 4 байта на строку и с группировкой по 4 байта — это связано с тем, что размер любых данных, помещаемых в стек, всегда кратен 4 (впрочем, как и для окна памяти, у вас есть возможности поменять вид окна, хотя этих возможностей меньше). Кроме того, для окна стека слева показывается синяя стрелочка — текущее положение в стеке. Данные справа от стрелочки и все данные в строках выше — это занятая часть стека. В ней лежат локальные переменные, аргументы функций, временные переменные, блоки try и т.п. от всех функций, что вызывались ранее (стек вызовов). Всё, что ниже строки со стрелочкой — свободная часть стека. В данном случае тут лежит какой-то мусор, оставшийся от работы других функций. Да, вот такая странная организация — стек растёт в обратную сторону: от старших (больших) адресов в сторону младших (меньших) адресов. Нужно также понимать, что стек — это не более чем кусок памяти. Поэтому, спозиционировав окно "Memory" на адрес, скажем, $0012F5C8, мы увидим ровно ту же картину (только без стрелочки :) ). Вот весьма грубая схема адресного пространства 32-разрядного процесса (масштаб также не соблюдён):


На рисунке чёрным цветом отмечены пустые (свободные) участки адресного пространства. Тёмно-серым цветом отмечены полностью недоступные части — в первую очередь это начало адресного пространства и вся часть от 2-х до 4-х Гб, которая отведена под данные режима ядра. Если вы попытаетесь прочесть или записать данные по адресам чёрных и тёмно-серых участков, то получите Access Violation. Белым цветом показаны занятые и используемые части, а светло-серый цвет обозначает занятые, но временно не используемые участки памяти (т.е. память с мусором) — обращение к ним является программной ошибкой, но (к сожалению) не приводит к Access Violation. В области с данными менеджера памяти (обычно их больше одной) располагаются все динамические данные программы — строки, объекты, динамические массивы и т.п. Эта область растёт по мере необходимости, пока у программы есть свободные (чёрные) участки (и свободная виртуальная память у системы, конечно). Обратите внимание, что в этой области лежат только сами данные. Переменные (указатели) обычно лежат в стеке (слева от exe для главного потока) или в области глобальных переменных (на рисунке специально не показана). Также на рисунке видно, что стек растёт в обратную сторону — справа налево (не забываем также, что у каждого потока в программе свой стек). Окно "Memory" показывает любые данные в адресном пространстве до 2 Гб, а окно стека просто автоматически позиционируется на стек текущего (отлаживаемого) потока. Также заметим, что на рисунке секции с кодом (exe и dll) и секции с данными (стек, данные менеджера памяти) показаны одинаково, хотя данные и код — не одно и то же.

Итак, возвращаемся к окну стека. Зачем можно использовать окно стека? Хороший пример — поиск проблем с затиранием адреса возврата. Например, вы написали глючную функцию, в которой вы неаккуратно используете локальный массив (который хранится в стеке), выходя за его рамки и повреждая при этом стек. В процессе работы вашей функции адрес возврата перезаписывается, поэтому, хотя сама функция выполняется успешно, но, когда вы пытаетесь выйти из неё (вернуться в вызывающую функцию), возникает исключение, т.к. вы перешли в неверное место. На снимке выше мы открыли окно стека сразу после входа в функцию. Т.е. мы поставили точку останова на первый begin в функции и после остановки программы открыли окно стека, затем сделали скриншот. Таким образом, сейчас в стеке последним занесённым в него элементом является адрес возврата — в нашем примере это $44061A (напомним, что справа и ниже от стрелочки показывается пустая часть стека, а сверху от неё — занятая). $44061A — это адрес первой инструкции после вызова Button1Click в системном модуле Delphi, который вызвал наш обработчик. Обратите внимание, что это число (адрес возврата) хранится по адресу $12F5C8. И данные по этому адресу оказываются испорченными. Догадались? Да, мы можем поставить точку останова на данные (data breakpoint) на этот адрес (размер, разумеется, 4 байта, т.к. это SizeOf(Pointer)). Как только наш кривой код в функции попытается затереть его — мы сразу же остановимся в отладчике и сможем исследовать ситуацию.

Примечание: обычно проблемы с массивами решаются включением опции проверки диапазонов "Range checking", но данная опция не всегда может помочь (например, если с массивом общаются через указатель), поэтому приходится работать головой и руками. На самом деле чаще всего помогает внимательный анализ кода, а отладчик используется только в самых запущенных случаях.

Кроме того, обычно сразу после адреса возврата в стек заносится текущая база стека. Поэтому при последовательном затирании стека сперва затрётся сохранённый указатель на предыдущую базу стека, а только потом адрес возврата. Поэтому, когда мы встанем по точке останова, мы не сможем использовать окно "Call Stack", т.к. уже испорчена цепочка, по которой работает это окно. Поэтому имеет смысл выполнить команду "push ebp" (самая первая команда функции) в CPU-отладчике и установить точку останова на текущую вершину стека — это будет адрес, меньший адреса, по которому лежит адрес возврата, на 4 байта. В нашем примере нужно ставить точку останова на $12F5C4 ( = $12F5C8 — 4). 4 байта — это размер только что занесённого в стек указателя. Это заставит точку останова сработать раньше, сохраняя работоспособность окна "Call Stack". Но поскольку точка останова срабатывает после выполнения операции перезаписи, то перед тем, как воспользоваться окном "Call Stack", вам нужно будет ещё восстановить нужное значение в этой ячейке (разумеется, для этого вам нужно будет его предварительно записать на бумажку сразу после выполнения команды "push ebp").

Уфф, если у вас уже голова распухла от всей этой информации, то не волнуйтесь — мы уже заканчиваем :)

Также у CPU-отладчика есть окна, отображающие состояния регистров, флагов, математического сопроцессора и мультимедийных регистров (MMX, SSE). Однако все эти окна могут использоваться только во время обычной отладке в CPU-отладчике, поэтому, если вы не знаете ассемблера, то для вас они будут бесполезны. Само окно отладчика с ассемблерным листингом программы в этом случае тоже практически бесполезно. Единственное, что можно с ним делать — удовлетворить своё любопытство, посмотрев, во что превращается написанный код. К примеру, как мы видим (см. самый первый скриншот CPU-отладчика выше), такая простая операция как присваивание "Tag := X;" скомпилировалась аж в целых пять операция копирования (mov — это команда копирования, от слова move). Произошло это, кстати, по той причине, что Tag — это не переменная, а свойство объекта, который, кстати, недоступен в текущей функции A напрямую, а только как переменная у вышестоящей функции (Button1Click). Да ещё между ними одна промежуточная функция есть — P. В принципе, если вдруг простейшая операция начинает вести себя совершенно непонятным образом, то можно глянуть в CPU-отлачике, во что она превратилась, т.к. язык высокого уровня Delphi скрывает довольно много деталей реализации. Поэтому, взглянув на код, можно увидеть, что "Tag := X;" трактуется как: "у меня есть ссылка на вызвавшую меня функцию, у вызывающей меня функции есть ссылка на вызывающую её функцию, а вот у неё есть локальные переменные, надо взять первую из них (Self), это будет объект, и в энцатое его свойство записать X". Вот хороший пример, когда высокоуровневый отладчик не помогает и нужно использовать CPU-отладчик, причём достаточно просто посмотреть на сгенерированный код: Причуды Delphi-ского компилятора. Проблема, кстати, связанна именно с локальными переменными в вышестоящей функции. Высокоуровневый отладчик в данном случае не помогает, т.к. он ориентируется на текстовое имя в исходном коде, поэтому всегда правильно находит все переменные. Реальный же код использует относительные ссылки, поэтому для анализа ситуации нужно воспользоваться именно CPU-отладчиком.

На этом с CPU-отладчиком, пожалуй, всё.

Взглянем теперь ещё на некоторые возможности отладчика по управлению отлаживаемыми процессами. При использовании команды "Run"/"Run" у вас есть возможность указать дополнительные параметры. Для этого вызовите пункт меню "Run"/"Parameters" (в D2009 эти параметры также доступны в опциях проекта):


Вид окна в старых Delphi

Вид окна в новых Delphi

В этом окне вы можете задать парамеры командной строки ("Parameters", по-умолчанию — пуста), текущую папку запуска ("Working directory", по-умолчанию — папка с программой) или переопределить переменные окружения ("Environment Block", по-умолчанию — не изменяются). Опция "Host application" используется только при разработке DLL или пакетов. Ведь как таковые, они не могут быть запущены, т.к. представляют собой файл-библиотеку с набором функций, но никак не программу, которую можно запустить. Тем не менее, если вы укажете в "Host application" полное имя exe-файла, то вы сможете использовать "Run"/"Run" и для проектов DLL или пакетов. В этом случае, при нажатии F9 будет запущено приложение, указанное в "Host application". Предполагается, что вы будете указывать там приложение, которое использует вашу DLL. Разумеется, при этом DLL может быть загружена не сразу, а только когда приложение запросит её, или даже не загружена вовсе.

Опция "Source path" — это просто очередная опция, указывающая, где можно найти pas-файлы. Всего их три, и просматриваются они в таком порядке: "Source path", "Browsing path" в опциях среды ("Environment options"/"Library — Win32"), "Debug Source path" в опциях отладчика.

Следующая команда для рассмотреня — "Run"/"Load Process":


Вид окна в старых Delphi

Вид окна в новых Delphi

Это окно для новых Delphi по виду весьма похоже на предыдущее, а по функциональности — на команду "Run"/"Run". Отличие в том, что команда "Run" всегда запускает на выполнение текущий проект, а "Load Process" запускает для отладки новую программу, указанную в "Process". Причём последняя не обязательно должна быть Delphi-проектом. Т.е. "Run"/"Load Process" в этом смысле предназначано больше для отладки чужих программ. Также, это окно может использоваться для запуска процесса на отладку на удаленной машине (для этой цели, очевидно, "Run"/"Run" уже не подойдёт) — и это единственная доступная функциональность этого окна в старых Delphi. Кроме того, при использовании "Load Process" запускаемый процесс всегда ставится на паузу сразу после запуска. Правда, для Delphi-проектов есть ещё возможность выбора: если галочка "Execute startup code on Load" сброшена, то и Delphi-проект и все прочие exe будут остановлены сразу — на точке входа в программу, до того, как успеет выполниться хотя бы одна машинная команда. Если же галочка "Execute startup code on Load" установлена — то Delphi-проект сперва выполнит инициализацию, а только потом встанет на паузу, когда дойдёт до begin в DPR-файле. При этом, скорее всего, при остановке будет открыто окно CPU-отладчика, т.к. программа не имеет отладочной информации. Заметим, что в данном случае не идёт речь об отладочной информации, которая содержится в dcu-файлах и не идёт в сам exe. Необходима именно отладочная информация в самом exe или dll-файле. Для Delphi-проектов вы можете включить добавление отладочной информации в исполняемый модуль, включив опцию "Include TD32 debug info" в настройках проекта на вкладке "Linker" (для D2009 опция называется "Debug information" на вкладке "Linking"). При этом размер исполняемого модуля значительно увеличивается — от 5 до 10 раз. Включение отладочной информации в исполняемый модуль позволит вам использовать высокоуровневый отладчик вместо CPU-отладчика.

Но что делать, если нужно отладить уже запущенный процесс? Например, мы запустили программу без отладчика, в ней возникла ошибка, и мы хотим отладить её, не перезапуская программу под отладчиком. Для этого есть команда "Attach to Process":

Вы просто выбираете процесс из списка и жмёте кнопку "Attach". При этом отладчик подключается к уже запущенной программе, и вы можете начать её отладку. Разумеется, в этом случае также требуется внешняя отладочная информация, создаваемая установкой галочки "Include TD32 debug info"/"Debug information" (если только вы не хотите использовать CPU-отладчик).

Также в отладчике есть команда "Detach From Program". В некотором смысле она противоположна "Attach to Process": эта команда отключает отладчик от программы. Программа продолжает выполняться, но уже без отладчика. Используется команда достаточно редко.

Кроме перечисленных возможностей, отладчик Delphi поддерживает ещё и удалённую отладку. Она может помочь в случае, когда какая-либо проблема восроизводится только на той машине, где нет Delphi. Удалённая отладка подразумевает, что программа запускается на машине без Delphi (удалённая машина), а машина с Delphi и отладчиком (локальная машина) находится в той же сети (TCP/IP), что и машина с программой. Для осуществления такого сценария необходимо, во-первых, установить на удалённую машину сервер отладчика. А, во-вторых, включить в программу дополнительную отладочную информацию.

Для установки сервера отладчика вы можете воспользоваться установочным диском или уже установленной Delphi. Проще всего воспользоваться диском — просто вставляете диск и в меню выбираете пункт "Install Remote Debugger". Можно и просто скопировать дистрибутив отладчика с диска. Например, для новых Delphi он лежит в D:\RemoteDebugger\RemoteDebugger.exe (здесь D: — это диск с дистрибутивом Delphi).

Если диска нет, то забирайте из папки bin установленной Delphi файлы rmtdbg105.exe, bccide.dll, bordbk105.dll, bordbk105N.dll, comp32x.dll, dbkpro100.dll, dcc100.dll (цифры могут меняться, в зависимости от версии вашей Delphi — точный список файлов можно посмотреть в справке Delphi) и копируйте их на удалённую машину в папку System32. Затем нужно зарегистрировать bordbk105.dll и bordbk105n.dll. Делается это запуском regsvr32.exe из командной строки (или из "Пуск"/"Выполнить"), например, так: "C:\Windows\System32\regsvr32.exe bordbk105.dll" (и аналогично для bordbk105n.dll).

Теперь нужно добавить в программу информацию для сервера отладчика. Для этого в опциях проекта на вкладке "Linker" включите опцию "Include remote debug symbols" и сделайте Build проекту. При этом для программы будет создан rsm-файл. Скопируйте rsm-файл вместе с программой на удалённую машину.

Теперь всё готово к отладке. На удалённой машине запускайте отладчик с параметром "-listen". Сделать это можно командой "rmtdbg105.exe -listen" (не забудьте подставить свои цифры). После запуска сервер отладчика виден в системном трее и панели задач:

Теперь вы можете использовать уже обсуждавшиеся команды меню "Attach to Process" и "Load Process" (перед этим не забудьте проверить свой файрвол, если он у вас есть). В обоих случаях вы вводите имя (или IP-адрес) удалённой машины. Затем для "Attach to Process" вы нажимаете на "Refresh", выбираете процесс и нажимаете кнопку "Attach":

Для "Load Process" вы вводите полный путь к exe файлу программы и нажимаете кнопку "Load":


Вид окна в старых Delphi

Вид окна в новых Delphi

После этого вы начнёте отладку удалённой программы. Поведение отладчика при этом практически ничем не отличается от локального случая. По окончании отладки вы можете просто закрыть сервер отладчика или выбрать "Exit" в меню иконки в трее. Заметим, что отладчик позволяет вам только отлаживать программу, но не взаимодействовать с ней. Поэтому, если локальная и удалённая машина не стоят у вас на одном столе, то вам потребуется ещё и какой-то способ по удалённому управлению, например, RDP (удалённый рабочий стол) или программа типа RAdmin. Удобно при этом использовать машину с двумя мониторами. На одном висит среда, на втором открыт удалённый рабочий стол с отлаживаемой программой.

2.1.2. Когда ничего нет (отладка вручную)

Итак, предположим, в вашей программе возникла ошибка (исключение) и вам нужно определить почему.

0). Сначала нужно успокоиться и не паниковать :)

1). Самым первым действием будет запуск программы под отладчиком. Если вы напоролись на ошибку, когда запустили программу из-под среды — то переходите сразу к пункту 3. В противном случае вам нужно воспроизвести ошибку. Т.е. найти последовательность действий, которые нужно выполнить, чтобы ошибка появилась снова. Вспомните, что вы делали до появления сообщения об ошибке, и попробуйте повторить эти действия. Ошибка появилась снова? Если да, то вы нашли нужную последовательность действий и можете переходить к третьему пункту. Если нет — то нужно искать дальше, экспериментируйте, пока не найдёте нужную последовательность. Если отладчик находится на одной машине, а ошибка воспроизводится только на второй машине, то либо вы устанавливаете Delphi на вторую машину, либо используете удалённую отладку, либо читаете пункты 2.1.3 и далее. Аналогично нужно поступить, если не удаётся найти последовательность по воспроизведению ошибки: ошибка то появляется, то нет. В этом случае она называется "плавающей" или "случайной" ошибкой. В последних случаях воспользоваться ручным решением не получится и придётся использовать программные инструменты для поиска причины ошибки — см. 2.1.3 и далее.

На самом деле, название "случайная" совершенно неверно отражает суть вещей. Ведь в компьютере достаточно тяжело получить истинно случайные события. Закономерность появления ошибки является случайной лишь для технически неграмотного человека и, на самом деле, является следствием взаимодействия сложных процессов, которых вы не понимаете полностью. Поэтому, осознав, что случайность ошибки является весьма маловероятным событием, вы можете начать поиск потенциальных причин для возникновения ошибки. Вместо того, чтобы просто назвать ошибку случайной и не поддающейся анализу, лучше потратить время на действительный анализ ситуации. Спросите себя, что могло бы привести к такой ошибке. Устраняя потенциально опасные места, вы вполне можете решить проблему. При этом вы вполне можете решить проблему, так и не поняв её причины. Это нормально. Бывает, что причина проблемы слишком сложна, и вы просто не сможете её понять. Ведь есть множество вещей, про которые вы просто ничего не знаете.

2). (Опционально) Во-первых, как вариант, вы можете просто продолжить выполнение программы и посмотреть, как программа сумеет обработать исключение. Делать это надо в том случае, если в вашей программе есть код по специфической обработке исключений. Разумеется, если вы изначально получили ошибку вне отладчика, то делать этого не нужно — т.к. уже заранее известно, что дальнейшая раскрутка исключения приведёт к появлению сообщения об ошибке. Если программа сумела справиться с ошибкой (т.е. восстановиться после ошибки), то никакой проблемы и вовсе нет. Если же в итоге появилось сообщение об ошибке, неверное поведение и т.п. — переходим к пункту 3.

Кстати, если есть подозрение, что ошибка плавающая, то продолжать выполнение программы нельзя ни в коем случае. Т.к., если в итоге окажется, что это действительно ошибка (и, причём, плавающая), то чёрта с два вы её повторите второй раз. Поймать её под отладчиком во второй раз будет ой как непросто, и вам останется только мысленный анализ.

3). Итак, вы запустили программу под отладчиком, повторили последовательность действий, приводящих к ошибке, и у вас всплыло уведомление отладчика. Вы должны нажать на кнопку "Ok"/"Debug" (в зависимости от версии Delphi) для начала отладки. В случае удачи отладчик при этом установит курсор на строчку, которая вызвала ошибку. Если он это сделать не сумел (например, показывает какой-то "мусор") — попробуйте следующие пункты.

Если же отладчик успешно локализовал ошибку, то после остановки вы можете просмотреть значения переменных и определить, почему здесь возникло исключение. Посмотрите, не вылезли ли вы за границы массивов, не равны ли ваши указатели/объекты nil. Если в строке вызываются какие-либо функции, то допустимы ли их аргументы. И так далее. Разумеется, действовать нужно исходя из того, какого рода исключение возникло. Например, если это EStreamError, то оно возникло в каком-то методе работы с потоком. Нужно проверить состояние потока до выполнения проблемной строки (к примеру, не пытаемся ли мы читать из пустого потока и т.п.). Возможно, при этом вам нужно будет добавить какой-нибудь отладочный код, выводящий содержимое потока, или пройти блок кода работы с потоком по шагам. Если это ERangeError, то нужно проверять всяческие выходы за границы массивов и так далее. Если вы находитесь в методе объекта, то проверьте, не равен ли случайно Self nil. Если вы себе слабо представляете, какие причины могут привести к возбуждению конкретного исключения, то вы можете, например, поискать вопросы на Круглом Столе по сообщению ошибки. Кроме того, вы можете поискать строки с именем класса исключения в pas-файлах проекта, Delphi и других используемых проектом библиотек.

4). Если при остановке в отладчике вы видите что-то "странное" вместо кода (CPU-отладчик, например), то попробуйте включить опцию "Use Debug DCUs" и сделать Build проекту. Если после этого вы останавливаетесь в "осмысленном" месте, то это значит, что ошибка возникает в системных модулях. Заметим, что это не означает, что причина находится не в вашем коде :) Например, вы передали nil в функцию Delphi, которая этого просто не ожидала. Т.е. в документации по функции было сказано, что параметр не может быть nil, а вы взяли и нарушили это соглашение (случайно, разумеется). Просто выясните, почему возникает ошибка, и устраните причину её появления. Если ваше приложение использует пакеты, то попробуйте также скомпилировать его без них.

5). Если у вас проблемы с просмотром переменных — возможно, что это влияние оптимизации. Попробуйте выключить опцию "Optimization" в опциях проекта и сделать Build. После устранения ошибки не забудьте вернуть опцию на место.

6). Если в предыдущих пунктах вы не сумели догадаться о причинах ошибки или отладчик не сумел показать значения переменных — вы можете поставить Breakpoint на проблемную строку и повторить запуск программы.

6а). Если строчка с Breakpoint-ом выполняется много раз и только один из них неудачный, то временно снимите Breakpoint и поставьте перед строкой логгирование (например, используя OutputDebugString) и сосчитайте, сколько раз выполнится проблемная строчка до появления в ней ошибки. Затем снова поставьте Breakpoint и задайте в условиях к нему, что он должен сработать только после N-1 проходов (на N-ный проход). Главное здесь — не промахнитесь на +/— единицу. Впрочем, если вы поставили Breakpoint на срабатывание чуть раньше — не страшно, просто пропустите одну итерацию цикла.

Итак, вы остановились в проблемной строчке по Breakpoint-у. Кстати, если прямо сейчас нажать F8 — у вас должно возникнуть исключение. Это — просто проверка, что вы нашли нужное место, она нужна только для уверенности, что проблема в подозреваемой строке. Не нужно выполнять проблемную строчку (и возбуждать исключение) перед её анализом. Теперь вы снова (как и в предыдущем пункте) можете проанализировать переменные и определить причину проблемы.

7). Если строка очень длинная (одновременно выполняет кучу действий), то попробуйте разбить её на большее число строчек. Возможно, вам придётся ввести новые переменные, которые будут хранить промежуточные результаты.

8). Если проблемы всё ещё не видно (обычно это бывает из-за недостатка опыта), то попробуйте сравнить значения переменных, участвующих в проблемной строке, при успешных проходах/запусках и при проблемных. Выясните, какие различия приводят к ошибке и почему они возникают.

9). Если при анализе переменных возникли какие-то проблемы — попробуйте также поставить вывод в лог значения переменных до выполнения проблемной строчки и слово "Done" — после. После чего запустите программу. Возникнет ошибка. Посмотрите лог. В логе должны появиться наборы строк. Если в логе вообще ничего нет или же он заканчивается словом "Done" — значит, ошибка появляется при попытке вычислить значения переменных, которые вы логгируете. Тогда внимательно проверяйте аргументы. Если же лог заканчивается строкой с выводом переменных, и к ней нет парного "Done" — то вот вам ваши проблемные переменные, записаны в логе, смотрите, что с ними не так.

10). Если вы не можете определить проблему, находясь в самой строке с ошибкой — попробуйте пройти по шагам блок кода до проблемной строки. Удобным местом для установки breakpoint-а будет начало функции, содержащей проблемную строчку. При остановке в этом месте, прежде всего, проверьте — допустимы ли аргументы функции. К примеру, если функция требует адрес объекта (например, "Sender: TObject"), то проверьте, что он не равен nil и т.п. После проверки аргументов начните выполнение функции по шагам, внимательно смотря за изменениями, производимыми кодом. Возможно, вам понадобится не один такой проход, чтобы понять, в чём проблема. Если же вы обнаружили, что функцию вызвали с недопустимыми аргументами — посмотрите по окну "Call Stack", кто это сделал. Т.е. найдите вызывающую функцию и начните отлаживать её.

11). Для некоторых сообщений об низкоуровневых ошибках вам также выводится адреса, связанные с ошибкой. Например, "Runtime error 217 at $004564AA" или "Access violation at address 004564AA in module 'Project1.exe'. Read of address 00000000". Давайте посмотрим, что отсюда можно вытащить. В случае, когда адрес один (кстати, адреса указываются в шестнадцатеричной системе счисления) — это, вероятнее всего, адрес машинной инструкции, вызвавшей ошибку.

Во-первых, вы можете хотя бы примерно определить, в каком модуле (имеется в виду не модуль Delphi, а исполняемый модуль — exe или dll файл) возникла ошибка. Нужно определить, какому модулю принадлежит адрес ошибки (если это, конечно, не указано в самом сообщении, как в случае с EAccessViolation). Сделать это можно, например, руками. Для этого просмотрите набор загруженных библиотек. Сделать это можно чем угодно, например, воспользовавшись Process Explorer. Откройте список загруженных в процесс модулей. Найдите максимальный базовый адрес (колонка "Base"), который будет меньше адреса ошибки. Для этого удобно отсортировать список модулей по базовому адресу и пройтись по списку сверху вниз, выбрав последний модуль, базовый адрес которого меньше адреса ошибки. Убедитесь, что базовый адрес выбранного вами модуля плюс его размер будут больше адреса ошибки. Например, если адрес в сообщении об ошибке равен $004564AA, а exe-модуль загружен по адресу $00400000 и имеет размер $00064000, то адрес ошибки попадает в промежуток: [$00400000] < $004564AA < [$00400000 + $64000 = $00464000]. Это значит, что ошибка возникла при выполнении кода в exe-файле. Ещё пример. Пусть у нас ошибка возникла по адресу $6A000000. Мы смотрим на список модулей процесса и видим, что по адресу $65000000 загружена библиотека user32.dll размером $00400000 (все размеры и адреса для примера взяты случайным образом). Сразу за ней по адресу $70000000 идёт библиотека gdi32.dll размером $00A00000. Т.е. между user32.dll и gdi32.dll никаких модулей не загружено. Мы видим, что адрес ошибки $6A000000 лежит между двумя библиотеками: [$65000000 + $400000 = $65400000] < $6A000000 < [$70000000]. Это значит, что у вас ошибка не принадлежит ни одному модулю, т.е. связанная с переходом выполнения программы по некоторому адресу (поскольку в этом месте нет кода, то очевидно, что мы не могли там выполняться, мы могли туда только перейти). Например, попытка вызвать функцию уже выгруженной библиотеки.

Как посмотреть сам код, соответствующий месту возникновения ошибки? Для этого нужно запустить программу под отладчиком (если она у вас ещё не запущена) и нажать на паузу. Или дождаться всплытия сообщения отладчика, записать адрес и нажать "Ok"/"Debug". Далее, выбрать в главном меню Delphi "Search"/"Go to address" и ввести записанный ранее адрес, не забыв указать символ $ перед цифрами, например: "$004564AA" (без кавычек, разумеется). Отладчик перейдёт в место возникновения ошибки. Если для указанного места есть отладочная информация — откроется высокоуровневый отладчик, спозиционировавшись на строчку в pas-файле. Если же для этого адреса отладочной информации нет — откроется CPU-отладчик, спозиционировавшись на адрес (машинную инструкцию). Далее можно действовать, как и ранее, в предыдущих пунктах. Только нужно убедиться, что у вас этот адрес не "прыгает" от запуска к запуску. Т.е. он постоянен при каждом прогоне программы. В противном случае адрес от предыдущего запуска будет бесполезен при текущем запуске — вам нужно получить именно значение адреса для текущего запуска.

Ещё момент — если этот адрес указывает на какой-то мусор (например, открылся CPU-отладчик, но в центральной части окна нет кода — одни нули) или вовсе равен 00000000, то есть вероятность, что у вас проблемы с переходом выполнения программы по адресу (если, конечно, не считать случая, когда вы приняли за адрес инструкции что-то другое). Например, переменная процедурного типа равна nil, а вы вызываете эту процедуру. Или же в неё попало и вовсе какое-то левое значение. Возможно, что вы пытаетесь вызвать процедуру из уже выгруженной DLL-библиотеки.

Также, такое бывает при повреждении стека. Например, при вызове любой процедуры в стек заносится адрес возврата (т.е. место, откуда продолжить выполнение программы после того, как процедура завершит своё выполнение). А вы в своей процедуре используете локальный массив (все локальные переменные также размещаются в стеке). Причём используете неаккуратно и выходите за границы массива. А это значит, что вы залезете на (и перезапишете) такие системные данные, как адрес возврата из процедуры. Соответственно, хотя внешне выполнение процедуры пройдёт успешно, но стек уже будет безвозвратно испорчен. Стоит только попытаться выйти из процедуры и перейти по адресу, записанному в стеке (и испорченному вами), как вы попадёте в совершенно случайное место своей программы и неминуемо словите EAccessViolation (впрочем, если вам очень сильно не повезёт, то вы можете ещё некоторое время выполняться, уводя выполнение всё дальше от места ошибки). Кстати, ошибки такого рода называются переполнением буфера. Помимо собственно тщательного анализа кода на предмет такого рода косяков, можно также попробовать включить опцию "Range checking" или (временно, только для поиска проблемы) сделать массивы глобальными.

Кроме того, такого же рода ошибка может возникнуть из-за несоответствия прототипов процедуры (обычно такое бывает при импорте процедуры из DLL), например, в вызывающем коде указаны не ровно те же параметры, что в исходной процедуре. Или напутано соглашение вызова: используется register вместо stdcall. Хотя собственно повреждения стека здесь не происходит, но проблема заключается в том, что вместо кода возврата в стек на ожидаемое процедурой место попадают какие-то другие данные (т.е. код в DLL и exe из-за разных объявлений процедуры считают, что адрес возврата находится в разных местах стека).

12). В случае EAccessViolation у нас есть два адреса: "Access violation at address 004564AA in module 'Project1.exe'. Read of address 00000000". Первый из них ("at address 004564AA") — это адрес проблемной инструкции. С ним можно поступить, как и в предыдущем пункте. Кроме него, в этом сообщении ещё интересны слова "Read" или "Write" и второй адрес ("of address 00000000"). "Read" указывает на то, что ваша программа пытается прочитать что-то из недоступной памяти (или, иногда, — выполнить), а "Write" — на то, что она пытается что-то записать. Это поможет вам проанализировать проблемную строчку — нужно анализировать только места чтения или только места записи в строке.

Далее, второй адрес в сообщении указывает, откуда/куда программа пытается читать или писать данные. Обычно это значение само по себе не очень полезно, однако из него тоже можно извлечь кой-какую информацию. Например, это значение может быть очень мало, например 00000000, 00000008 или 000000A1. В этом случае можно биться об заклад, что у вас какой-то объект равен nil, а вы пытаетесь с ним работать. Например, объект равен nil, и вы читаете/пишете его свойство. Или указатель на массив равен nil, а вы пытаетесь прочитать значение его ячейки. Откуда это следует — оставим вам в качестве домашнего задания :)

Если же это значение относительно велико, например 7FEB0010, 70000008 и т.п., то велики шансы, что у вас мусорная ссылка. Например, вы освободили объект, а потом позже пытаетесь к нему обратиться. Кстати, также велика вероятность, что эта ошибка — плавающая. Поэтому, если вы встретились с ней по время прогона под Delphi, не мешкайте — начинайте отлаживаться с места, а то потом можете и не повторить её. Для профилактики таких ошибок используйте процедуру FreeAndNil вместо метода Free, FreeMemAndNil вместо FreeMem или же явно обнуляйте все ссылки после освобождения объектов. Также, внимательно просмотрите свой код на предмет того, не используете ли вы где две ссылки на один и тот же объект. И если да, то обнуляете ли вы все ссылки при удалении объекта. Например, вы занесли объект в массив/список с автоосвобождением, и у вас есть ещё ссылка на объект.

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

13). Попробуйте также добавить такой обработчик исключений в Application.OnException:

type
  TForm1 = class(TForm)
  ...
    procedure MyCustomExceptionHandler(Sender: TObject; E: Exception);
  end;

...

procedure TForm1.FormCreate(Sender: TObject);
begin
  Application.OnException := MyCustomExceptionHandler;
end;

procedure TForm1.MyCustomExceptionHandler(Sender: TObject; E: Exception);
begin
  Application.MessageBox(PChar(
      
      // К обычному сообщению об ошибке добавляем адрес её возникновения.
      E.Message + #13#10 +
      '$' + IntToHex(Integer(ExceptAddr), 8)

    ), 'Ошибка', MB_OK or MB_ICONSTOP);
end;

Теперь при возникновении ошибки во второй строке у вас будет что-то вроде $004564AA — это адрес инструкции, возбудившей ошибку. Поступайте с ним также как и ранее (как в предыдущих пунктах).

14). Попробуйте посмотреть обсуждение Вопроса №43378, там приведено ещё несколько потенциальных причин для ошибок типа EAccessViolation (как ни странно, один из ответов начинается так же, как и эта статья). Внимательный просмотр кода иногда может устранить проблему. С целью профилактики возникновения ошибок рекомендуем вам ознакомиться и со статьёй Delphi. Работа над ошибками.

2.1.3. Отладочная информация

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

Вопрос первый: как узнать, в каком месте возникла ошибка? В принципе, по объекту исключения и переменной ExceptAddr/ErrorAddr мы спокойно получим адрес в программе, где было возбуждено исключение. Однако тогда возникает второй вопрос: как привести адрес в программе к читабельному виду? Обратимся за помощью к компилятору Delphi, а точнее к его линкёру. Дело в том, что линкёр может сгенерировать нам при компиляции файлик, в который он запишет информацию по всему коду: какой код, каким модулям, каким функциям и даже каким строкам соответствует. Соответственно, мы можем прочитать этот файлик, найти там интересующий нас адрес и посмотреть, какой строке и в каком модуле он соответствует. После чего, наше сообщение об ошибке будет выглядеть уже не как "... ошибка по адресу-такому-то", а как "... ошибка в модуле Unit5, процедуре Funcenstein, строке 413".

Для включения генерации такого файла нужно зайти в меню "Project"/"Options" и на вкладке "Linker" переключить "Map file" в любую позицию, отличную от "Off":

Разные опции соответствуют разным уровням детализации информации, "Off" — генерация файла отключена. Рекомендую всегда ставить в позицию "Detailed" — это самая функциональная позиция. Итак, при включении генерации файла карты во время компиляции будет создаваться файл с тем же именем, что и исполняемый файл проекта, только с расширением .map. Вот вырезки из его содержимого, чтобы вы представляли, о чём идёт речь:

 Start         Length     Name                   Class
 0001:00000000 00060508H .text                   CODE
 0002:00000000 0000181CH .data                   DATA
 0002:0000181C 0000125DH .bss                    BSS

Detailed map of segments

 0001:00000000 00005103 C=CODE     S=.text    G=(none)   M=System   ACBP=A9
 0001:00005104 00000140 C=CODE     S=.text    G=(none)   M=SysInit  ACBP=A9
 0001:00005244 00000078 C=CODE     S=.text    G=(none)   M=Types    ACBP=A9
 0001:000052BC 00000CEC C=CODE     S=.text    G=(none)   M=Windows  ACBP=A9
 0001:00005FA8 00000038 C=CODE     S=.text    G=(none)   M=Messages ACBP=A9
 0001:00005FE0 00000320 C=CODE     S=.text    G=(none)   M=SysConst ACBP=A9
 0001:00006300 00006B24 C=CODE     S=.text    G=(none)   M=SysUtils ACBP=A9
 0001:0000CE24 000007FB C=CODE     S=.text    G=(none)   M=VarUtils ACBP=A9
 0001:0000D620 00002A42 C=CODE     S=.text    G=(none)   M=Variants ACBP=A9
 0001:00010064 00000120 C=CODE     S=.text    G=(none)   M=RTLConsts ACBP=A9
 0001:00010184 000007E8 C=CODE     S=.text    G=(none)   M=TypInfo  ACBP=A9
 0001:0001096C 00000038 C=CODE     S=.text    G=(none)   M=ActiveX  ACBP=A9
 0001:000109A4 00009896 C=CODE     S=.text    G=(none)   M=Classes  ACBP=A9
 0001:0001A23C 000002C8 C=CODE     S=.text    G=(none)   M=Consts   ACBP=A9
 0001:0001A504 00008D47 C=CODE     S=.text    G=(none)   M=Graphics ACBP=A9
 0001:0002324C 00000048 C=CODE     S=.text    G=(none)   M=Math     ACBP=A9
 0001:00023294 00000564 C=CODE     S=.text    G=(none)   M=Contnrs  ACBP=A9
 0001:000237F8 000000F4 C=CODE     S=.text    G=(none)   M=CommCtrl ACBP=A9
 0001:000238EC 00000787 C=CODE     S=.text    G=(none)   M=MultiMon ACBP=A9
 0001:00024074 00000038 C=CODE     S=.text    G=(none)   M=Imm      ACBP=A9
 0001:000240AC 00000FF8 C=CODE     S=.text    G=(none)   M=HelpIntfs ACBP=A9
...
 0002:00003220 00000004 C=BSS      S=.bss     G=DGROUP   M=JclSynch ACBP=A9
 0002:00003224 00000014 C=BSS      S=.bss     G=DGROUP   M=JclHookExcept ACBP=A9
 0002:00003238 0000001C C=BSS      S=.bss     G=DGROUP   M=JclDebug ACBP=A9
 0002:00003254 00000008 C=BSS      S=.bss     G=DGROUP   M=Unit1    ACBP=A9


  Address         Publics by Name

 0002:00003138       .01
 0002:00002A44       .1
 0002:00002668       .1
 0002:00002A34       .1
 0002:00002C00       .1
...
 0001:00023808       ImageList_GetImageCount
 0001:00023894       ImageList_Read
 0001:0002384C       ImageList_Remove
 0001:00023818       ImageList_ReplaceIcon
 0001:00023820       ImageList_SetBkColor
 0001:0002387C       ImageList_SetDragCursorImage
 0001:000238AC       ImageList_SetIconSize
 0001:0002389C       ImageList_Write
 0001:00055AB4       ImageRvaToSection
 0001:00055B10       ImageRvaToVa
 0002:00000BDC       Images
 0001:000414F4       ImgList
 0001:000240A4       Imm
 0002:00000BC0       IMM32DLL
 0001:0003E7E0       Imm32IsIME
 0002:00000180       IMSecsPerDay
 0001:0000E3C8       InBounds
 0001:0000E128       InBounds
 0001:0000E158       Increment
...

Line numbers for JclHookExcept(JclHookExcept.pas) segment .text

   145 0001:0005BBB0   146 0001:0005BBB1   147 0001:0005BBC9   148 0001:0005BBE6
   153 0001:0005BC7C   154 0001:0005BC82   155 0001:0005BC8E   156 0001:0005BC9C
   157 0001:0005BCA7   156 0001:0005BCB6   159 0001:0005BCC6   161 0001:0005BCD8
   162 0001:0005BCE2   167 0001:0005BCE8   168 0001:0005BD00   169 0001:0005BD09
   170 0001:0005BD0C   171 0001:0005BD29   181 0001:0005BD30   182 0001:0005BD34
   183 0001:0005BD3A   185 0001:0005BD48   186 0001:0005BD4F   187 0001:0005BD5B
   195 0001:0005BD60   196 0001:0005BD71   198 0001:0005BD83   200 0001:0005BD90
   201 0001:0005BD9C   202 0001:0005BDA9   203 0001:0005BDB7   204 0001:0005BDC4
   205 0001:0005BDD2   206 0001:0005BDD6   207 0001:0005BDE4   208 0001:0005BDEE
   209 0001:0005BDF6   206 0001:0005BE06   209 0001:0005BE09   205 0001:0005BE0C
   211 0001:0005BE1F   214 0001:0005BE3E   217 0001:0005BE52   228 0001:0005BE58
   229 0001:0005BE67   231 0001:0005BE84   232 0001:0005BE96   233 0001:0005BEA3
   238 0001:0005BEAC   239 0001:0005BEB3   240 0001:0005BEBD   241 0001:0005BEC9
   242 0001:0005BED4   243 0001:0005BED8   244 0001:0005BEDC   254 0001:0005BEE0
   257 0001:0005BEFB   260 0001:0005BEFC   261 0001:0005BF07   262 0001:0005BF0D
   263 0001:0005BF13   264 0001:0005BF1F   265 0001:0005BF2D   267 0001:0005BF52
   269 0001:0005BF67   287 0001:0005BF70   288 0001:0005BF7B   289 0001:0005BF83
   290 0001:0005BF89   291 0001:0005BF96   292 0001:0005BFA4   294 0001:0005BFB2
   295 0001:0005BFBC   297 0001:0005BFC4   298 0001:0005BFC9   300 0001:0005BFD5
   292 0001:0005BFD6   301 0001:0005BFD9   303 0001:0005BFEE   305 0001:0005C003
   341 0001:0005C00C   342 0001:0005C00D   344 0001:0005C016   345 0001:0005C022
   346 0001:0005C02A   348 0001:0005C04E   350 0001:0005C052   351 0001:0005C05B
   352 0001:0005C069   354 0001:0005C075   357 0001:0005C07C   358 0001:0005C07E
   441 0001:0005C098   443 0001:0005C09A   545 0001:0005C09C   575 0001:0005C0CF
   538 0001:0005C0D4   539 0001:0005C0DD   541 0001:0005C0EE

Line numbers for JclDebug(JclDebug.pas) segment .text

   712 0001:0005C91C   713 0001:0005C91E   717 0001:0005C920   718 0001:0005C922
   722 0001:0005C924   723 0001:0005C926   724 0001:0005C929   731 0001:0005C92C
   732 0001:0005C933   786 0001:0005C934   787 0001:0005C94D   788 0001:0005C958
   789 0001:0005C95B   790 0001:0005C961   791 0001:0005C967   792 0001:0005C987
   795 0001:0005C990   796 0001:0005C99A   798 0001:0005C9BE   805 0001:0005C9C4
...
  3684 0001:0005FD38  3686 0001:0005FD41  3687 0001:0005FD4B  3690 0001:0005FD53
  3691 0001:0005FD55  4026 0001:0005FD58  4027 0001:0005FD7A  4028 0001:0005FD84
  4029 0001:0005FD8E  4030 0001:0005FD98  4031 0001:0005FDA2  4090 0001:0005FE2A
  4017 0001:0005FE2C  4018 0001:0005FE35  4019 0001:0005FE46  4020 0001:0005FE57
  4022 0001:0005FE68

Line numbers for Unit1(Unit1.pas) segment .text

    37 0001:00060054    38 0001:00060067    39 0001:00060098    42 0001:0006009C
    43 0001:000600A1    47 0001:000600A4    48 0001:000600A9    56 0001:000600AC
    57 0001:000600CB    58 0001:000600D2    59 0001:000600DD    60 0001:000600E8
    59 0001:00060120    61 0001:00060123    62 0001:00060140    63 0001:00060173
    69 0001:00060190    71 0001:000601C3    65 0001:000601C8    66 0001:000601D1
    68 0001:000601D6

Line numbers for Project1(H:\Project1.dpr) segment .text

     9 0001:000604C0    10 0001:000604D0    11 0001:000604DC    12 0001:000604F4
    13 0001:00060500

Bound resource files

e:\programming\delphi7\Lib\Buttons.res
e:\programming\delphi7\Lib\ExtDlgs.res
e:\programming\delphi7\Lib\Controls.res
Unit1.dfm
Project1.res
Project1.drf


Program entry point at 0001:000604C0

Т.е. по сути, map-файл — это просто отладочная информация, выгруженная в текстовый файл.

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

Хотя вопрос, как хранить и распространять отладочную информацию, достаточно интересен. Есть два варианта: либо вы храните всю отладочную информацию у себя на машине и не распространяете вместе с приложением (вариант A), либо же встраиваете её в файл приложения или распространяете вместе с приложением, но в отдельном файле (вариант B). Какие плюсы и минусы у обоих подходов:

1). Размер приложения. При варианте A конечный exe весит примерно процентов на 5 меньше, чем при варианте B. Конкретная величина зависит от способа хранения (со сжатием или без), числа модулей с отладочной информацией в программе и количества (уровня подробности) отладочной информации.

2). Показ информации на клиентском рабочем месте. Разумеется, при способе A нет никакой возможности отобразить что-то читабельное на машине конкретного пользователя — только ничего не говорящие адреса. Однако, при необходимости, вы можете, скажем, предусмотреть возможность выслать отладочную информацию отдельно. Разумеется, это предполагает, что пользователь будет с вами специально сотрудничать для поиска ошибки. Это может и применимо для корпоративного софта, но уж точно не для коробочного.

3). Соответствие версий. С вариантом B здесь нет никаких проблем: отладочная информация, подцепленная к exe, полностью ему (exe) соответствует. С вариантом A есть проблема: вы ведь наверняка за всю свою жизнь напишете не одну программу и даже не одну версию каждой программы. Когда вам приходит баг-отчёт с RAW-адресами, вы должны сообразить, из какого же файла в хранилище отладочной информации на своей машине нужно брать информацию для получения читабельного отчёта. Т.е. нужно как-то отслеживать соответствие между файлами с отладочной информацией и exe-файлами, которым она соответствует. Например, используя хэши (контрольные суммы). Ну и прибавим к этому сценарий утери отладочной информации. Короче говоря, с вариантом А есть неудобства.

4). Взлом shareware-защиты. Проблема с вариантом B в том, что вы фактически добровольно добавляете в свою программу информацию о большинстве имён внутреннего содержимого. Где какая у вас процедура и как она называется. Не то, чтобы это автоматически означало взлом вашей программы, но всё же это сильно облегчает взлом. Даже, если вы зашифруете отладочную информацию, то всё равно, вы же её показываете при возникновении исключения, а значит, что ключ для её расшифровки хранится в вашей программе. Т.е. взломщику ничто не мешает расшифровать эту отладочную информацию. Известны различные способы борьбы: не называть ключевые для защиты объекты по прямому назначению, размазывать защиту по exe, выключать/вырезать отладочную информацию для участков кода, ну и так далее — не будем касаться темы защиты в этой статье. Правда есть и такой вариант, что вы шифруете отладочную информацию, но не храните ключ в программе. Разумеется, в этом случае пользователь не сможет увидеть описание — только адреса (как если бы отладочной информации вообще не было в программе) и зашифрованные названия процедур. Зато описание можете увидеть вы — у вас есть ключ для расшифровки. Вопрос: зачем хранить отладочную информацию в exe, если она всё равно никак не используется на конечном месте пользователя? Ответ: это исключает проблемы с несоответствием отладочной информации и реальной программы (см. предыдущий пункт).

Обратите внимание, что отладочная информация может быть не доступна для некоторых модулей. Например, если модуль был скомпилирован с отключенной отладочной информацией (у нас на руках получился dcu, а pas файла нет), то при сборке программы с таким модулем информации о нём в map файле не будет.

В первую очередь, это относится к стандартным модулям RTL/VCL Delphi. Посмотрите: исходники (pas) из папки \Source никогда не компилируются. Вместо них используются уже готовые (dcu) модуля в \Lib. Чтобы переключится между версией dcu без отладочной информации и с ней — переключите в опциях проекта настройку "Use debug DCUs" (после переключения этой опции обязательно нужно сделать полный Build проекта). Обратите внимание, что если вы используете run-time пакеты, то многие модули (в частности — стандартные) находятся в уже скомпилированных пакетах и на них эта опция не влияет. В частности, для старых Delphi нет возможности получить отладочную информацию для стандартных bpl-к (для новых Delphi для bpl-к предоставляется отладочная информация в формате JDBG — об этом позднее).

Если вы не хотите решать все эти задачи самостоятельно — вы можете воспользоваться готовыми решениями. Например, бесплатным JCL (см. ниже пункт 2.2) или платным EurekaLog (2.4). Главное, чтобы вы представляли себе, как это всё крутится.

2.1.4. Трассировка стека

Итак, мы сумели раздобыть информацию о месте возникновения ошибки, но нам ещё хотелось бы знать: как же мы пришли в это самое место? Если вы помните, то в отладчике Delphi есть такое окошко как "Call stack". Было бы неплохо получить для исключения такую же информацию. К сожалению, для этого нам придётся воспользоваться ассемблером.

В программах есть такая структура, как стек. В нём хранятся различные данные, но среди них есть и адреса процедур. Каждый раз, когда вы вызываете процедуру, функцию или метод, в стек заносится адрес возврата — т.е. указатель на следующую инструкцию после вызова. Таким образом, при возникновении исключения в стеке лежит (вместе с другими данными) список вызванных процедур. Получив этот список, мы получим и наш "call stack". Затем применим механизм предыдущего раздела и получим human-readable (т.е. читабельный) список функций, вызовы которых привели нас в место ошибки. Существует два метода анализа стека: фреймовый и RAW.

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

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

Стоит отметить, что этот способ может пропускать некоторые вызовы, если для них не был сгенерирован фрейм или в случае повреждения стека. Обычно фрейм опускается для очень коротких процедур (когда в нём нет необходимости). Дело можно исправить: идём в "Project"/"Options" и на вкладке "Compiler" устанавливаем опцию "Stack Frames". Теперь фреймы будут генерироваться всегда, даже, если в них нет необходимости. Мы рекомендуем устанавливать эту опцию всегда. Расход нескольких лишних машинных операций и четырёх байт при каждом входе-выходе из процедуры не представляется большой тратой в современном мире.

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

Итак, у нас есть три способа получения списка вызовов функций (по фреймам, по фреймам + опция "Stack Frames", RAW). Мы рекомендуем выбирать фреймовый способ с опцией "Stack Frames", как наиболее оптимальный, т.к. он не пропускает вызовы и не создаёт ложные.

2.1.5. Перехват исключений

Теперь остаётся только применить полученные знания для вывода информации об ошибках в программе. А для этого нам нужно сделать так, чтобы каждый раз при возникновении исключения наш код получал бы управление и заносил бы куда-нибудь информацию об исключении. Если мы будем получать информацию не сразу, а позже (например, в OnException у ApplicationEvents), то мы просто потеряем стек вызовов. Короче говоря, нам нужно научиться перехватывать исключения. Технология очень проста: любое внешнее исключение среда Delphi немедленно заворачивает в объект типа Exception и перевозбуждает уже как чисто Дельфёвское исключение. Все исключения возбуждаются через функцию Kernel32.RaiseException. Соответственно, всё, что нам нужно — перехватить функцию Kernel32.RaiseException. Делается это, например, изменением адреса в таблице импорта на свой хук. Тогда любое исключение приведёт к вызову нашего хука, и мы сможем обработать исключение до того, как получит управление любой код раскрутки.

Если вы хотите углубиться в детали, то вот отличная статья для этого: "Exceptional Stack Tracing". Она основана на коде, который позднее вошёл в JCL (см. следующий раздел).

Как более гуманный вариант: вы можете использовать переменную RaiseExceptionProc — см. обсуждение в разделе 1.2.4. Разница между ними проста: RaiseExceptionProc использует только код Delphi, причём только в вашем модуле (с поправкой на пакеты). Kernel32.RaiseException используют все, не только вы и не только Delphi-программы. В большинстве случаев достаточно использовать RaiseExceptionProc. Делать хук на Kernel32.RaiseException обычно имеет смысл только в совсем старх Delphi, где нет RaiseExceptionProc.

В D2009 несколько иная ситуация. Во-первых, в D2009 нет необходимости перехватывать исключений вообще. Дело в том, что такой перехват уже реализован модулем SysUtils — напомним, что перед возбуждением исключения вызывается Exception.RaisingException, в котором вызывается функция создания стека вызовов. Для управления стеком вызовов есть три функции: GetExceptionStackInfoProc, GetStackInfoStringProc и CleanUpStackInfoProc (по-умолчанию все три равны nil). Первая и последняя функции создают и уничтожают стек вызовов соответственно. При этом они оперируют с нетипизированным указателем — произвольным представлением стека вызова в удобном для вас формате. Например, это может быть String, TStringList, массив строк, запись или информация в формате JCL/EurekaLog. Функция GetStackInfoStringProc переводит эту внутреннюю информацию в строку для чтения человеком. Вызывается она при чтении свойства StackTrace.

Кроме того, в D2009 для стандартного класса Exception реализована дополнительная поддержка по перехвату исключений. В частности, есть глобальная процедура в модуле System, которая вызывается непосредственно перед вызовом RaiseExceptionProc — это процедура RaiseExceptObjProc (см. подробное описание в пункте 1.2.4). По-умолчанию, именно она используется модулем SysUtils для вызова Exception.RaisingException. Вы можете назначить свою функцию для выполнения своих действий, не забывая вызывать старую функцию. Например, вы можете форсировано включать FAcquireInnerException.

Ну и, разумеется, все старые процедуры остались и в D2009 — ваш старый код, скорее всего, будет работать и в D2009 без изменений.

2.2. JCL (JEDI Code Library)

Посмотрим теперь, как пункты 2.1.3-5 реализованы библиотекой JCL.

В JCL все задачи раздела 2.1.3 решает модуль JclDebug. После установки JCL нет никакой необходимости лазить по опциям проекта или ручками писать парсеры .map-файлов. Достаточно зайти в меню "Project" и включить опцию "Insert JCL Debug Data" (появляется только после установки JCL):


Меню отладочной информации в старых версиях JCL

Меню отладочной информации в новых версиях JCL

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

После чего включить в uses модуль JclDebug и вызвать GetLocationInfo или GetLocationInfoStr для некоторого адреса. Включение опции в меню заставит линкёр генерировать подробный .map-файл, кроме того, эта же опция во время компиляции сконвертирует .map-файл в формат JDBG (бинарный формат библиотеки JCL для отладочной информации из .map-файлов) и включит эти данные в исполняемый файл проекта! Соответственно функции GetLocationInfoXXX ищут информацию о запрошенном адресе в инжектированной в исполняемый файл отладочной информации в формате JDBG, а если её нет, то пытаются найти её в .map или .jdbg файлах в той же папке, что и .exe-файл. Кстати, D2005 и выше даже поставляются вместе с .jdbg-файлами для своих .bpl-к, что позволяет получать текстовую информацию по адресам даже, если ваша программа скомпилирована с run-time пакетами.

Замечу, что функции GetLocationInfoXXX могут выдавать информацию и по другим (не текущим) модулям. Вы можете определить, доступна ли отладочная информация для какого-то модуля, вызвав для него функцию DebugInfoAvailable. Функция ClearLocationData выгрузит из памяти всю отладочную информацию (она загружается в память при первом обращении к GetLocationInfoXXX), но обычно вызывать её нет необходимости. При желании, часть операций можно выполнить и вручню, например, сконвертировать текстовый .map-файл в бинарный формат JDBG можно с помощью функции ConvertMapFileToJdbgFile, а инжектировать .jdbg в исполняемый модуль — с помощью функции InsertDebugDataIntoExecutableFile.

Для постройки стека вызовов служит функция JclCreateStackList (есть и другие функции JclXXXStackList) — она возвращает объект типа TJclStackInfoList — список из адресов. Освобождать возвращаемый объект не следует — временем его жизни распоряжается модуль JclDebug.

Для перехвата исключений служит модуль JclHookExcept. Функция JclHookExceptions устанавливает хук на исключения, а функция JclUnhookExceptions — снимает хук. Чтобы хук модуля JclHookExcept вызывал ваш обработчик, вы добавляете его в список обработчиков с помощью функции JclAddExceptNotifier, снять обработчик можно функцией JclRemoveExceptNotifier. Обработчик может быть как функцией, так и методом объекта. Просто, не так ли?

Более того, модуль JclDebug содержит код для автосбора информации о возбуждаемых исключениях. Функция JclStartExceptionTracking, во-первых, вызывает JclHookExcept.JclHookExceptions для установки хуков, а, во-вторых, добавляет обработчик исключений из JclDebug в список обработчиков JclHookExcept. Сам же обработчик для каждого исключения записывает стек вызовов. Получить этот стек можно функцией JclLastExceptStackList. Как и ранее, результат функции (объект TJclStackInfoList) освобождать не следует. Функция JclStopExceptionTracking снимает обработчик модуля JclDebug и вызывает JclHookExcept.JclUnhookExceptions.

Чтобы продемонстрировать сказанное, приведём пример (см. также Вопрос №57687). Создадим пустой проект, положим кнопку и по кнопке возбудим Access Violation:

procedure TForm1.Button1Click(Sender: TObject);

  procedure A;

    procedure B;
    begin
      ShowMessage(IntToStr(Integer(nil^)));
    end;

  begin
    B;
  end;

begin
  A;
end;

Здесь мы эмулируем возникновение ошибки где-то во вложенных рабочих функциях. Можно запустить проект и жмахнуть по кнопке. Здесь и далее рекомендуется запускать приложение не из среды, а как отдельный exe (или воспользоваться функцией "Run without debugging"), чтобы не мешался отладчик. Как и ожидалось, на экране появится малоинформативное сообщение об ошибке.

Окей, теперь пропишем в любой uses модуль JclDebug, затем впишем в секцию инициализации модуля вызов функции JclStartExceptionTracking, а в секции финализации — JclStopExceptionTracking. В итоге получим примерно следующее:

...

implementation

uses
  JclDebug;

{$R *.dfm}

procedure TForm1.Button1Click(Sender: TObject);

  procedure A;

    procedure B;
    begin
      ShowMessage(IntToStr(Integer(nil^)));
    end;

  begin
    B;
  end;

begin
  A;
end;

initialization
  JclStartExceptionTracking;

finalization
  JclStopExceptionTracking;

end.

Плюхнем на форму компонент ApplicationEvents и в обработчике события OnException напишем следующее:

procedure TForm1.ApplicationEvents1Exception(Sender: TObject;  E: Exception);
var
  Str: TStringList;
begin
  Str := TStringList.Create;
  try
    JclLastExceptStackListToStrings(Str, True, True, True, True);
    Str.Insert(0, E.Message);
    Str.Insert(1, '');
    Application.MessageBox(PChar(Str.Text), 'Ошибка', MB_OK or MB_ICONSTOP);
  finally
    FreeAndNil(Str);
end;
end;

Не забудем включить опцию "Insert JCL Debug Data" и сделать полный build. Пускайте проект и жмите по кнопке. Появится сообщение об ошибке, в котором будет написано:

Access violation at address 00488A8C in module 'Project1.exe'. Read of address 00000000
(00087A8C){Project1.exe} [00488A8C] Unit1.B (Line 40, "Unit1.pas" + 1) + $5
(00087ABC){Project1.exe} [00488ABC] Unit1.A (Line 44, "Unit1.pas" + 0) + $0
(00055523){Project1.exe} [00456523] Controls.TWinControl.WndProc + $513
(0003AA8C){Project1.exe} [0043BA8C] StdCtrls.TButtonControl.WndProc + $6C
(00055673){Project1.exe} [00456673] Controls.DoControlMsg + $23
(00055523){Project1.exe} [00456523] Controls.TWinControl.WndProc + $513
(00068584){Project1.exe} [00469584] Forms.TCustomForm.WndProc + $594
(00054C3C){Project1.exe} [00455C3C] Controls.TWinControl.MainWndProc + $2C
(00025724){Project1.exe} [00426724] Classes.StdWndProc + $14
(0005561F){Project1.exe} [0045661F] Controls.TWinControl.DefaultHandler + $D7
(00055523){Project1.exe} [00456523] Controls.TWinControl.WndProc + $513
(0003AA8C){Project1.exe} [0043BA8C] StdCtrls.TButtonControl.WndProc + $6C
(00025724){Project1.exe} [00426724] Classes.StdWndProc + $14

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

Согласитесь, что по такой информации определить место и причину возникновения ошибки значительно проще!

Обратите внимание, что в этом списке отсутствует информация о Button1Click. Это потому что используется фреймовый способ постройки стека, а опцию "Stack Frames" мы не включали. Если вы включите её и запустите проект, то увидите, что функций в списке вызова стало больше:

Access violation at address 00488A8C in module 'Project1.exe'. Read of address 00000000

(00087A8C){Project1.exe} [00488A8C] Unit1.B (Line 40, "Unit1.pas" + 1) + $5
(00087ABF){Project1.exe} [00488ABF] Unit1.A (Line 44, "Unit1.pas" + 1) + $0
(00087ACB){Project1.exe} [00488ACB] Unit1.TForm1.Button1Click (Line 48, "Unit1.pas" + 1) + $0
(00051587){Project1.exe} [00452587] Controls.TControl.Click + $6F
(00055523){Project1.exe} [00456523] Controls.TWinControl.WndProc + $513
(0003AA8C){Project1.exe} [0043BA8C] StdCtrls.TButtonControl.WndProc + $6C
(00055673){Project1.exe} [00456673] Controls.DoControlMsg + $23
(00055523){Project1.exe} [00456523] Controls.TWinControl.WndProc + $513
(00068584){Project1.exe} [00469584] Forms.TCustomForm.WndProc + $594
(00054C3C){Project1.exe} [00455C3C] Controls.TWinControl.MainWndProc + $2C
(00025724){Project1.exe} [00426724] Classes.StdWndProc + $14
(0005561F){Project1.exe} [0045661F] Controls.TWinControl.DefaultHandler + $D7
(00055523){Project1.exe} [00456523] Controls.TWinControl.WndProc + $513
(0003AA8C){Project1.exe} [0043BA8C] StdCtrls.TButtonControl.WndProc + $6C
(00025724){Project1.exe} [00426724] Classes.StdWndProc + $14

Для включения RAW-метода нужно добавить опцию stRAWMode:

...
initialization
  JclStackTrackingOptions := JclStackTrackingOptions + [stRAWMode];
  JclStartExceptionTracking;
...

Сам же стек вызовов после этого выглядит так:

Access violation at address 00488A8C in module 'Project1.exe'. Read of address 00000000

(00087A8C){Project1.exe} [00488A8C] Unit1.B (Line 40, "Unit1.pas" + 1) + $5
(00087ABF){Project1.exe} [00488ABF] Unit1.A (Line 44, "Unit1.pas" + 1) + $0
(00087ACB){Project1.exe} [00488ACB] Unit1.TForm1.Button1Click (Line 48, "Unit1.pas" + 1) + $0
(00051587){Project1.exe} [00452587] Controls.TControl.Click + $6F
(0003ADC6){Project1.exe} [0043BDC6] StdCtrls.TCustomButton.Click + $1E
(0003B828){Project1.exe} [0043C828] StdCtrls.TCustomButton.CNCommand + $10
(0005101E){Project1.exe} [0045201E] Controls.TControl.WndProc + $2D2
(00055523){Project1.exe} [00456523] Controls.TWinControl.WndProc + $513
(0003AA8C){Project1.exe} [0043BA8C] StdCtrls.TButtonControl.WndProc + $6C
(00050C58){Project1.exe} [00451C58] Controls.TControl.Perform + $24
(00055673){Project1.exe} [00456673] Controls.DoControlMsg + $23
(0005606F){Project1.exe} [0045706F] Controls.TWinControl.WMCommand + $B
(0006B560){Project1.exe} [0046C560] Forms.TCustomForm.WMCommand + $2C
(0005101E){Project1.exe} [0045201E] Controls.TControl.WndProc + $2D2
(00025724){Project1.exe} [00426724] Classes.StdWndProc + $14
(00055523){Project1.exe} [00456523] Controls.TWinControl.WndProc + $513
(0001D4C4){Project1.exe} [0041E4C4] Classes.TThreadList.UnlockList + $4
(00068584){Project1.exe} [00469584] Forms.TCustomForm.WndProc + $594
(00054C3C){Project1.exe} [00455C3C] Controls.TWinControl.MainWndProc + $2C
(00025724){Project1.exe} [00426724] Classes.StdWndProc + $14
(0005561F){Project1.exe} [0045661F] Controls.TWinControl.DefaultHandler + $D7
(000519A4){Project1.exe} [004529A4] Controls.TControl.WMLButtonUp + $10
(0005101E){Project1.exe} [0045201E] Controls.TControl.WndProc + $2D2
(00054E2F){Project1.exe} [00455E2F] Controls.TWinControl.IsControlMouseMsg + $13
(00055523){Project1.exe} [00456523] Controls.TWinControl.WndProc + $513
(0003AA8C){Project1.exe} [0043BA8C] StdCtrls.TButtonControl.WndProc + $6C
(00054C3C){Project1.exe} [00455C3C] Controls.TWinControl.MainWndProc + $2C
(00025724){Project1.exe} [00426724] Classes.StdWndProc + $14
(00025D9E){Project1.exe} [00426D9E] Contnrs.TComponentList.GetItems + $A
(00070A3F){Project1.exe} [00471A3F] Forms.TApplication.ProcessMessage + $F3
(00070A6A){Project1.exe} [00471A6A] Forms.TApplication.HandleMessage + $A
(00070D95){Project1.exe} [00471D95] Forms.TApplication.Run + $C9
(00088C69){Project1.exe} [00489C69] Project1. Project1 (Line 13, "" + 4) + $7

Кроме того, вы можете видеть, что для стандартных модулей нет отладочной информации. Если вы отключите run-time пакеты и включите опцию "Use Debug DCUs", то стек вызовов станет выглядеть так (фреймовый способ + опция "Stack Frames"):

Access violation at address 0048D6C8 in module 'Project1.exe'. Read of address 00000000

(0008C6C8){Project1.exe} [0048D6C8] Unit1.B (Line 40, "Unit1.pas" + 1) + $5
(0008C6FB){Project1.exe} [0048D6FB] Unit1.A (Line 44, "Unit1.pas" + 1) + $0
(0008C707){Project1.exe} [0048D707] Unit1.TForm1.Button1Click
  (Line 48, "Unit1.pas" + 1) + $0
(000515A7){Project1.exe} [004525A7] Controls.TControl.Click
  (Line 6758, "Controls.pas" + 9) + $8
(00055543){Project1.exe} [00456543] Controls.TWinControl.WndProc
  (Line 9336, "Controls.pas" + 136) + $6
(0003AAAC){Project1.exe} [0043BAAC] StdCtrls.TButtonControl.WndProc
  (Line 4269, "StdCtrls.pas" + 13) + $4
(00055693){Project1.exe} [00456693] Controls.DoControlMsg
  (Line 9405, "Controls.pas" + 12) + $11
(00055543){Project1.exe} [00456543] Controls.TWinControl.WndProc
  (Line 9336, "Controls.pas" + 136) + $6
(000685A4){Project1.exe} [004695A4] Forms.TCustomForm.WndProc
  (Line 3902, "Forms.pas" + 191) + $5
(00054C5C){Project1.exe} [00455C5C] Controls.TWinControl.MainWndProc
  (Line 9065, "Controls.pas" + 3) + $6
(00025744){Project1.exe} [00426744] Classes.StdWndProc
  (Line 12723, "Classes.pas" + 8) + $0
(0005563F){Project1.exe} [0045663F] Controls.TWinControl.DefaultHandler
  (Line 9377, "Controls.pas" + 30) + $17
(00055543){Project1.exe} [00456543] Controls.TWinControl.WndProc
  (Line 9336, "Controls.pas" + 136) + $6
(0003AAAC){Project1.exe} [0043BAAC] StdCtrls.TButtonControl.WndProc
  (Line 4269, "StdCtrls.pas" + 13) + $4
(00025744){Project1.exe} [00426744] Classes.StdWndProc
  (Line 12723, "Classes.pas" + 8) + $0

На самом деле можно использовать возможности этих модулей гораздо шире. Например, добавив свой обработчик исключений, можно заносить ВСЕ исключения в лог сразу по месту их возникновения, не дожидаясь их раскрутки. Потом этот лог можно автоматом заемаилить от клиентов к разработчику и проанализировать. На основе этого анализа можно перепроставить try-except-finally. Короче говоря, такой лог предоставит кучу информации для размышления.

2.3. Поиск утечек памяти (FastMM)

Рассмотрим такой код:

procedure TForm1.Button1Click(Sender: TObject);

  procedure A;

    procedure B;
    var
      P: Pointer;
    begin
      GetMem(P, 1*1024*1024);
    end;

  begin
    B;
  end;

begin
  A;
end;

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

Примечание: некоторые люди ошибочно путают рабочую песочницу (working set) приложения и выделенную память. Они неправильно используют Task Manager для определения выделенной памяти и поиска утечек памяти. Поскольку по-умолчанию менеджер задач показывает именно память песочницы (а не выделенную приложением память), то люди делают неверный вывод о наличии (или отсутствии) утечки памяти в приложении. Что есть что: Вопрос №63027, ссылки на вопросы о песочнице. Общий смысл примерно такой: это не утечка и не нужно с этим бороться, т.к. вы только затормозите работу своей программы и ничего не выиграете.

Как можно уследить за (настоящей) утекаемой памятью? Как известно, вся работа с памятью в Delphi осуществляется через Дельфёвский менеджер памяти. GetMem/FreeMem, New/Dispose, создание классов и работа со строками — всё идёт через стандартный менеджер памяти. Менеджер представляет собой просто набор из трёх функций — выделить память, освободить память и перераспределить память:

TMemoryManager = record
  GetMem: function(Size: Integer): Pointer;
  FreeMem: function(P: Pointer): Integer;
  ReallocMem: function(P: Pointer; Size: Integer): Pointer;
end;

Стандартный менеджер памяти (в старых Delphi) весьма прост — найти его можно в файле \Source\Rtl\Sys\getmem.inc (путь относительно папки с установленным Delphi). Из статистики он ведёт только переменные AllocMemCount (число выделенных блоков), да AllocMemSize (суммарный размер блоков). В новых Delphi стоит модифицированный FastMM (который, кстати, не ведёт эти переменные) — об этом позднее.

Для того чтобы получить полный контроль над распределением памяти в своём приложении, достаточно заместить стандартный менеджер памяти на свой — и мы получаем в свои загребущие руки всю информацию по выделению памяти. Установка альтернативного менеджера памяти выполняется через функцию SetMemoryManager. Текущий менеджер памяти можно получить через GetMemoryManager.

Так что мы можем вызвать GetMemoryManager, запомнить стандартный менеджер у себя, затем через SetMemoryManager установить свой — просто заглушку. В своём менеджере мы будем перенаправлять все вызовы на старый менеджер, полученный через GetMemoryManager, но при этом ещё вести учёт выделяемой памяти. Разумеется, при выделении памяти мы можем собрать информацию о месте выделения памяти (используя трассировку стека). При завершении работы программы мы можем посмотреть: а не осталось ли у нас неосвобождённых блоков памяти? Если да, то закинуть всю информацию по ним в отчёт.

Отметим ещё один момент: установка менеджера памяти должна производиться первым же действием в программе. Представьте себе такую ситуацию: в модуле была выделена строка (например), потом вы установили свой менеджер памяти, затем модуль свою строку удаляет. Но запрос на удаление он посылает не старому менеджеру, а вашему! Или, наоборот: в вашем менеджере была выделена память, а потом, при завершении работы, вы вернули на место старый менеджер, и память освобождается в старом менеджере, вместо вашего. Именно по этой причине вы не можете использовать в модуле, реализующем новый менеджер памяти, практически никаких модулей — чисто заголовочные модуля (как-то: Windows, Messages и т.п.) являются безопасным минимумом. Никаких SysUtils и Classes. По этой же причине установка/удаление нового менеджера памяти должна производиться в секции инициализации/финализации модуля и модуль этот должен быть подключен первым, до подключения любых других модулей.

По таким же принципам работает и Fast Memory Manager. Стоит заметить, что отчётность по утечкам памяти является лишь его побочной функциональностью. На самом деле основная цель этих исходников — сделать более быстрый, гибкий и функциональный менеджер памяти, чем стандартный. Посмотрите хотя бы на его возможные опции в файле FastMM4Options.inc или почитайте FastMM4_FAQ.txt, FastMM4_Readme.txt — и вам станет ясно, что он отличается от стандартного менеджера памяти Delphi как небо от земли. Одной из весьма мощных возможностей является поддержка разделяемого менеджера памяти (т.е. одного менеджера памяти на EXE и DLL) без отдельной DLL. Этот менеджер памяти оказался настолько успешен, что включается в новые Delphi как менеджер памяти по-умолчанию. К сожалению, в базовой поставке в этом случае практически отсутствуют возможности по диагностике утечек памяти, поэтому вам всё равно придётся скачать и установить внешнюю версию менеджера, как и для старых Delphi.

Покажем теперь, как используетеся Fast Memory Manager для диагностики утечек памяти. Идём в "Project"/"View sources" и вписываем модуль FastMM4 первым в список uses dpr-файла:

program Project1;

uses
  FastMM4,
  Forms,
  Unit1 in 'Unit1.pas' {Form1};

{$R *.res}

begin
  Application.Initialize;
  Application.CreateForm(TForm1, Form1);
  Application.Run;
end.

Не забудем установить галочку в меню "Project"/"Insert JCL Debug Data" и сделаем Build проекту.

Теперь нужно в опциях Fast Memory Manager включить отчёт об утечках памяти. Для этого откройте файл FastMM4Options.inc и проверьте, что следующие директивы имеют такой вид (описание каждой директивы расположено в FastMM4Options.inc рядом с ней):

{$define FullDebugMode}
  {.$define CatchUseOfFreedInterfaces}
  {$define LogErrorsToFile}
  {$define LogMemoryLeakDetailToFile}
  {.$define ClearLogFileOnStartup}
  {.$define LoadDebugDLLDynamically}
  {$define AlwaysAllocateTopDown}
{$define EnableMemoryLeakReporting}
  {$define HideExpectedLeaksRegisteredByPointer}
  {.$define RequireIDEPresenceForLeakReporting}
  {.$define RequireDebuggerPresenceForLeakReporting}
  {.$define RequireDebugInfoForLeakReporting}
  {.$define ManualLeakReportingControl}
  {.$define HideMemoryLeakHintMessage}

Опцию "RawStackTraces" вы можете установить на свой вкус. Для визуализации отчёта об утечках памяти FastMM4 использует внешнюю DLL. Готовую и скомпилированную вы можете найти в папке с FastMM4: \FastMM\FullDebugMode DLL\Precompiled\FastMM_FullDebugMode.dll, в папке \FastMM\FullDebugMode DLL\ лежат её исходники. Эту DLL нужно скопировать куда-нибудь, чтобы приложение её нашло — например, в C:\Windows\System32 или в папку с программой.

Запустите программу теперь, нажмите пару раз по кнопке и закройте её. На экране теперь появится окошко, в котором говорится, что в вашей программе есть баг:

This application has leaked memory. The sizes of leaked medium and
large blocks are (excluding expected leaks registered by pointer):
1113980, 1113980

Закройте сообщение и посмотрите в папку с программой: там появился файл Project1_MemoryManager_EventLog.txt такого содержания:

--------------------------------2006/7/3 10:48:44--------------------------------
A memory block has been leaked. The size is: 1113980

Stack trace of when this block was allocated (return addresses):
402D38 [System][@GetMem]
45263E [Unit1.pas][Unit1][ B ][33]
452645 [Unit1.pas][Unit1][ A ][37]
45264D [Unit1.pas][Unit1][TForm1.Button1Click][41]
432F02 [Controls][TControl.Click]
42B789 [StdCtrls][TButton.Click]
42B87D [StdCtrls][TButton.CNCommand]
432D67 [Controls][TControl.WndProc]
77D3F665 [DrawFocusRect]

The block is currently used for an object of class: Unknown

The allocation number is: 392

Current memory dump of 256 bytes starting at pointer address 7FD90078:
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €

--------------------------------2006/7/3 10:48:44--------------------------------
A memory block has been leaked. The size is: 1113980

Stack trace of when this block was allocated (return addresses):
402D38 [System][@GetMem]
45263E [Unit1.pas][Unit1][ B ][33]
452645 [Unit1.pas][Unit1][ A ][37]
45264D [Unit1.pas][Unit1][TForm1.Button1Click][41]
432F02 [Controls][TControl.Click]
42B789 [StdCtrls][TButton.Click]
42B87D [StdCtrls][TButton.CNCommand]
432D67 [Controls][TControl.WndProc]
77D3F665 [DrawFocusRect]

The block is currently used for an object of class: Unknown

The allocation number is: 391

Current memory dump of 256 bytes starting at pointer address 7FEA0078:
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €
€  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €  €

--------------------------------2006/7/3 10:48:44--------------------------------
This application has leaked memory. The sizes of leaked medium and large blocks are (excluding
expected leaks registered by pointer): 1113980, 1113980

Note: Memory leak detail is logged to a text file in the same folder as this application.
To disable this memory leak check, undefine "EnableMemoryLeakReporting".

Посмотрите: в этом отчёте указаны все утечки памяти в вашем приложении, указано где, в каком модуле, в какой строке была выделена память и даже приводится дамп первых 256-ти байтов в области памяти (в нашем случае — просто мусор, ведь мы не заполняли память по указателю P).

Стоит ещё немного осветить момент выдачи отчёта. Для чего нам нужна внешняя DLL? Один из моментов: независимость менеджеров памяти. У вас в приложении свой менеджер, во внешней DLL — свой. И они не мешают друг другу. Дело в том, что вы же не можете составлять отчёт об ошибках выделения памяти и при этом ещё и выделять/освобождать память, верно? Вот поэтому составление отчёта вы оставляете на совести DLL. Пусть она там использует всю мощь SysUtils, Classes и т.п. — нам это безразлично, поскольку изменения в распределении памяти из DLL никак не отразятся на состоянии памяти в приложении. Ещё одно соображение — вынесение кода в отдельную DLL. Так что для смены библиотеки отладки можно просто подменить DLL, без перекомпиляции exe-файла. Замечу, что Fast Memory Manager по-умолчанию использует JCL (а именно модуль JclDebug) для вывода отладочной информации в стеке вызовов. Но он также поддерживает madExcept и EurekaLog. Поскольку у вас уже есть JCL, навряд ли вам понадобятся другие библиотеки. Но в случае необходимости вы можете переключить директивы:

{$define JCLDebug}
{.$define madExcept}
{.$define EurekaLog}

И перекомпилировать \FastMM\FullDebugMode DLL\FastMM_FullDebugMode.dpr.

И последний момент на сегодня: если вам сильно охота, то вы можете заменить \FastMM\FastMM4Messages.pas на \FastMM\Translations\Russian\FastMM4Messages.pas, чтобы Fast Memory Manager "говорил" по-русски.

Заметим, что подобным образом вы можете обнаружить только явные утечки памяти. Утечки других видов ресурсов (например, открыли и не закрыли файл) вы не обнаружите. Кроме того, не обнаружите вы и скрытых утечек. Например, по ходу работы вы создаёте объекты и помещаете их в глобальный список. Но вы забываете их удалять. Т.е. с течением времени объектов в списке становится всё больше и больше. Однако это не является утечкой памяти в явном виде, т.к. объекты будут в списке. При закрытии программы список освобождается вместе со всеми находящимися в нём объектами.

Для поиска таких неявных утечек есть один хитрый способ. Дайте поработать своей программе много часов. Пусть она сожрёт всю доступную ей память. Ткните теперь в любое место памяти в программе. Что вы увидите? Неявную утечку. Почему так происходит? Представим, что реальные данные программы занимают 50 Mb памяти. Пусть программа поработала и занимает 60 Mb. Из них 50 Mb — это наши данные и 10 Mb — объекты в нашем списке, которые мы забыли освободить. Пусть теперь программа занимает 2 Gb памяти. Среди них 50 Mb — это данные нашей программы. Всё остальное место занимают данные забытых объектов. Ткните в любой адрес (разумеется, тыкать нужно не в абсолютно произвольный адрес, а в случайный среди адресов, выделенных менеджером памяти). Есть вероятность в (2048 — 50) / 2048 = 98%, что вы попадёте в память для потерянного объекта. Стек вызовов каждого выделения хранится менеджером памяти. Осталось только вывести его. Чем больше памяти займёт программа, тем больше в ней будет мусора, тем меньше полезных данных, и тем проще будет найти утечку. Даже 50% шанс (мусора ровно столько же, сколько и полезных данных) уже неплох — просто нужно будет проверить несколько значений.

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

Для выполнения этих задач можно воспользоваться LogAllocatedBlocksToFile — функцией сохранения информации о выделенных блоках в файл. Возможно, вам пригодятся и другие отладочные функции FastMM — просто посмотрите секцию interface его главного модуля. Если под ваш сценарий не нашлось готового решения, всегда можно по-быстрому написать менеджер-заглушку для сбора нужной информации.

Если возможностей FastMM вам оказалось недостаточно, можно попробовать воспользоваться внешними утилитами, такими как, например, MemProof. Это бесплатная утилита для поиска утечек ресурсов (не только памяти). На официальном сайте её больше нет, а вместо неё предлагается воспользоваться AQtime — платным и более мощным профайлером. Тем не менее, её ещё можно найти в сети. См. также Использование AutomatedQA MemProof.

2.4. EurekaLog

Одним из полностью готовых решений является EurekaLog (www.eurekalog.com). Это платный код (минимальной стоимостью около $100, в рамках одной версии обновления бесплатны, переход на следующую версию осуществляется за 50% стоимости). Список возможностей и различия редакций можно посмотреть здесь. EurekaLog представляет собой полное решение вопроса диагностики ошибок. Например, в случае с JCL у вас есть готовые инструменты по диагностике исключений. В случае FastMM у вас есть диагностика для утечек памяти. Но как вы будете применять эту информацию? Вам ещё нужно писать код по показу этой информации пользователю. Вам нужно писать код по созданию и/или отправке отчёта о проблеме лично вам, как разработчику и т.д. Короче говоря, нужен ещё код, который вам всё ещё нужно написать. EurekaLog — это пакет модулей типа "всё-в-одном", где собраны решения всех вопросов, возникающих при добавлении в приложение архитектуры обслуживания ошибочных ситуаций. Пакет обладает возможностями по настройке, так что вы сможете легко приспособить его к своей уникальной ситуации, просто попереключав опции проекта и не написав ни единой строчки кода. При желании, стандартное поведение можно расширить написанием обработчиком событий. Этих возможностей оказывается достаточно, чтобы покрыть 99% ситуаций и не прибегать к доработке напильником. Вы можете сэкономить свои деньги и писать всё руками — или вы можете сэкономть своё время и купить EurekaLog. Всё зависит от баланса ваших возможностей и потребностей.

Примечание: при желании, EurekaLog можно интегрировать с JCL и с FastMM.

После скачивания вы получаете дистрибутив в виде одного файла EurekaLog-XXX-YYY.exe размером около 20 Mb, где XXX — номер версии, YYY — код редакции (Professional или Enterprise, т.е. без исходников или с ними).

Примечание: в данном пункте для описания используется версия 6.0.15. Ваша версия EurekaLog может иметь отличия от описываемой. В версии 6.0.15 поддерживаются Delphi от 3 до 2009. Это может иметь значение, если вы используете версию без исходников — в этом случае для вас важна бинарная совместимость dcu-файлов. Если у вас версия с исходниками, то вы можете собрать EurekaLog и руками.

Установка проходит как обычно, установщик сделан в Inno Setup. При установке спрашивается папка для установки, затем предлагается отметить галочкой среды, в которые нужно установить EurekaLog (можно поставить в несколько сред одновременно, например, в D7 и D2007). Перед установкой EurekaLog нужно закрыть Delphi, иначе установщик будет ругаться на запущенную Delphi и откажется устанавливаться, пока вы её не закроете. При обновлении со старой версии EurekaLog установщик сперва удаляет старую версию (путь установки запоминает). В конце установки показывается краткое описание на тему, как включить EurekaLog в своих приложениях, а также предлагается просмотреть видео-урок (устанавливается локально вместе с EurekaLog) по использованию основных возможностей. Видео (флэш) также доступно для просмотра и в Интернете. Разумеется, оно на английском. Но знание английского особо и не потребуется — поскольку всё показывается прямо перед вами, то происходящее становится понятным и без перевода, тем более, что фраз там всего ничего, и те написаны текстом. Если у вас достаточный канал в Интернет или у вас есть локальная копия, мы рекомендуем отвлечься и прямо сейчас посмотреть эти видео-уроки — как говорится, лучше один раз увидеть, чем сто раз услышать (прочитать).

Итак, после установки папка выглядит примерно так:

Т.е. создаётся по папке на каждую среду. В данном случае EurekaLog устанавливалась для BDS 2007, поэтому были созданы папки для D2007 (Delphi11) и C++ Builder 2007 (CBuilder11). В каждой папке лежат скомпилированные модули, а также исходники (только для Enterprise версии) и папка с примерами. Также в папке с установленным EurekaLog лежит менеджер/просмотрщик баг-отчётов (EurekaLog Viewer) и папка с видео уроками (Tutorials). Плюс в самой папке лежит справка в формате CHM и PDF. Сразу скажем, что справка довольно лаконичная, хоть и описывает всю доступную (документированную?) функциональность. Также эту справку можно посмотреть в Интернете.

Для включения EurekaLog в своих проектах нужно выбрать меню "Project"/"EurekaLog options":

И отметить галочку "Activate EurekaLog":

При этом модифицируются файлы настроек проекта, и в uses DPR файла добавляется модуль ExceptionLog — основной модуль EurekaLog. Собственно, это всё, что нужно сделать, чтобы включить поддержку баг-отчётов в своей программе.

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

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

Это демка GUI. Слева вы можете выбрать различные виды ошибок: возбуждение исключений в вашем EXE-файле или внешней DLL, исключение в потоке, утечки памяти и т.п. Сбросив галочку "Activate EurekaLog" и нажав на кнопку "Execute selected action", вы можете увидеть, как обычная программа реагирует на эти ошибки. Обычно это стандартный MessageBox с текстом исключения или вовсе игнорирование ошибки (например, в случае утечек памяти).

Если вы установите флажок "Activate EurekaLog" и нажмёте на кнопку "Execute selected action", то вы увидите, как эта же ошибка обрабатывается в EurekaLog:

Это диалог об ошибке в стиле MS. На выбор доступно ещё два диалога — в стиле EurekaLog (самый настраиваемый):

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

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

Также, по нажатию на соответствующую кнопку, пользователь может просмотреть отчёт. Доступны несколько вкладок с различной информацией:






Также вы можете изменять набор собираемой информации, в том числе — добавлять свою информацию в отчёт (например, какие-то настройки своей программы). Она появится в дополнительном разделе на вкладке "General". Плюс имеется возможность прикрепить к отчёту файлы (например, файл конфигурации программы или документ, с которым работал пользователь в момент вылета).

EurekaLog поддерживает обычные GUI приложения, библиотеки DLL, пакеты BPL, консольные приложения и WEB. Для любого неподдерживаемого типа приложения EurekaLog можно назначить вручную нужные обработчики событий (подробнее см. раздел справки "Unsupported applications"). В скомпилированном виде программа прибавит минимум 300 Kb плюс размер отладочной информации (в текущей версии вы можете уменьшить её размер, меняя подробность информации, но не можете её исключить полностью). Если вы включаете контроль над утечками памяти, то программа теряет в быстродействии до 5%.

Посмотрим теперь, что мы можем настраивать в EurekaLog (заметим, что все настройки вы можете менять программно прямо во время выполнения программы):

Сначала пару слов о нижней панели. Опция "Default" указывает: настраиваете ли вы текущий проект или же правите настройки по-умолчанию для новых создаваемых проектов. Это полный аналог галочки Default в стандартных опциях проекта Delphi. Кнопки "Import"/"Export" позволяют сохранять и загружать настройки в файл. Кнопка "Variables" позволяет вставить в поле ввода системную переменную окружения. Вы нажимаете на кнопку, выбираете переменную, копируете её в буфер и можете вставлять её в поле ввода. Сделано это просто для удобства. Переменные окружения удобны для задания динамических параметров. Например, по-умолчанию лог-файл сохраняется в папку с программой. Это не очень правильно, особенно, если ваша программа будет работать под Vista, поэтому удобно указать папку Application Data в профиле пользователя для хранения логов. Но ведь эта папка каждый раз разная. Вот здесь и можно использовать переменные. В качестве пути для лог-файла (об этой настройке ниже) вы указываете, например, "%AppData%\MyAppLogFile" и все лог-файлы будут теперь сохраняться в папке "C:\Documents and Settings\Шурик\Application Data\MyAppLogFile\" (разумеется, имя пользователя будет меняться). Напомним, что при необходимости вы можете поправить настройки и программно, во время работы своего приложения.

Итак, на первой вкладке вы можете настроить отправку отчёта по Интернету. Вы можете отправлять отчёт почтой (e-mail) или постить на web-интерфейс раздела своего сайта. Для e-mail отправки вы можете указать режим отправки: "e-mail client" — будет использован клиент по-умолчанию (например, Outlook). В этом случае пользователь обязан использовать свой реальный e-mail адрес для отправки. При отправке отчёта откроется соответствующая программа с введённым в неё отчётом, готовым к отправке. Пользователь может установить подключение к Интернету, выбрать свою учётную запись для отправки и отправить письмо с отчётом.

"SMTP client" — в этом случае вы должны указать имя SMTP-сервера и (если необходимо), авторизацию на нём. Например, если вы хотите получать баг-отчёты на bugreport.mysoft@gmail.com, то в поле Addresses вы вводите "bugreport.mysoft@gmail.com" (без кавычек, разумеется), а в Host, например, — "smtp.mail.ru". В поле From вы должны указать адрес, с которого будет отправлено письмо. Если пользователь укажет свой e-mail адрес, то будет использоваться он, если нет — то указанный здесь. Желательно, чтобы он соответствовал серверу (т.е. если в Host написано "smtp.mail.ru", то в From должно стоять "something@mail.ru"), т.к. многие сервера не позволяют отправлять e-mail с произвольным адресом. Более того, конкретно mail.ru требует обязательной авторизации. Это значит, что для отправки письма с его помощью вы обязаны указать в поле From валидный e-mail адрес и задать логин и пароль этой учётки. Разумеется, вы также вольны выбрать любой другой сервер (который не требует авторизации) или использовать другие методы отправки отчёта.

"SMTP server" — при таком способе ваша программа будет действовать как самостоятельный SMTP-сервер. Обычно, этот выбор является оптимальным. В поле From при этом вы можете вбить любой (в том числе не существующий) e-mail адрес — единственная его функция в том, что он используется в поле письма "От" (адресат), при условии, что пользователь не указал свой e-mail. Больше ничего указывать не нужно.

Для WEB-отправки вы можете закачать к себе отчёт по HTTP/FTP или запостить на свой online баг-трекер. Среди баг-трекеров поддерживаются BugZilla, FogBugs и Mantis.

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

Здесь вы можете выбрать различные опции отправки отчёта: показывать ли диалог отправки отчёта, высылать ли весь лог целиком или только последнюю ошибку и т.п. В частности опция "Attached files" может использоваться для подключения к отчёту дополнительных файлов — напомним, что вы можете задавать все опции и программно. Опция "Zip password" позволяет указать пароль для шифрования архива с отчётом перед отправкой. Разумеется, пароль хранится в самой программе, поэтому он не является гарантией, что содержимое zip-архива нельзя будет посмотреть. При указании пароля и получении зашифрованного отчёта вы должны будете распаковать архив, введя пароль, прежде чем сможете посмотреть содержимое отчёта.

А здесь вы можете кастомизировать сам отчёт. Опция "Save Log file" определяет надо ли вообще сохранять отчёт на диск. Также можно указать максимальное число ошибок в логе (чтобы он не рос бесконечно) — старые ошибки из лога будут удаляться. И задать папку, где хранить файл. По-умолчанию, лог-файл хранится в папке с программой, а если туда нельзя писать — то в папке "%AppData%\EurekaLog\{ProjectName}". Опция "Do not save duplicate errors" позволяет не хранить в логе повторы ошибок. Дубликаты определяются по ID исключения. ID исключения считается как простейшая контрольная сумма по имени приложения, модулю возникновения исключения, типу исключения и стеку вызовов. Положительным моментом является то, что при небольших изменениях программы ID исключения может не меняться. Итак, если с включенной опцией одинаковое исключение возбуждается два раза, то вместо двух записей в логе будет одна запись с параметром "Count" равным 2 (раздел лога 2.8 на вкладке "General"). Остальные опции прозрачны для понимания ;)

Здесь настраиваются диалоги об ошибках. По-умолчанию выбран стиль EurekaLog. Для него доступны все опции. Для других диалогов (типы диалогов мы рассмотрели ранее) опций меньше. В принципе здесь названия опций говорят сами за себя. Опция "Use EurekaLog 'Look and Feel'" доступна только для диалога типа EurekaLog и преображает стандартный диалог (скриншот мы приводили выше) к виду:

Аналогично меняется и подробный (Detailed) вид диалога.

На вкладке "Messages Texts" вы можете поменять все сообщения в вашей программе. Готовые наборы сообщений можно также сохранить и затем переключаться между ними.

На этой вкладке вы можете задать особые условия обработки для различных классов исключений. Вы можете определить один или несколько фильтров исключений. Вы указываете класс и тип (All/Handled/Unhandled) исключения и выбираете, кто его будет обрабатывать (Handler): никто (none, исключение просто глушится), RTL (как если бы EurekaLog был не установлен) или EurekaLog. В последнем случае вы можете задать дополнительные опции: форсировано поменять сообщение об ошибке (например, вместо стандартного "Access violation at..." показывать "В программе возникла ошибка, мы приносим свои извинения."), заменить диалог (none/Unchanged/MessageBox/MS Classic/EurekaLog) и выбрать действие, которое будет выполнено после закрытия диалога (none/Terminate/Restart). Если диалог установлен в none, то при возникновении исключения действие будет выполнено сразу без каких-либо сообщений.

На этой вкладке расположено несколько важных опций. Опция "Do not store the Class/Procedure names" позволяет уменьшить объём отладочной информации за счёт уменьшения детализации. На самом деле её назначение — не уменьшить размер exe-файла, а усилить защиту от взлома за счёт вырезания потенциально опасной информации. "Encryption password" позволяет задать пароль для шифрования отладочной информации. Пароль этот в программе не хранится. Если вы задаёте пароль, то пользователь не сможет увидеть информацию о ваших процедурах в лог-файле — они будут зашифрованы. Зато при получении отчёта вы можете ввести пароль в EurekaLog Viewer (об этом позже) и просмотреть его в уже расшифрованном виде.

В правой части можно включить контроль над утечками памяти и зависаниями программы (по-умолчанию обе опции выключены). Если с утечками памяти всё понятно (это аналог уже рассмотренной функциональности FastMM), то определение зависаний программы следует прокомментировать. Эта функциональность заключается в том, что при запуске программы создаётся отдельный поток, который занимается тем, что периодически отправляет сообщение WM_NULL приложению с помощью функции SendMessageTimeout. Если ответа на сообщение не поступит в указанное в настройках время, то считается, что приложение повисло и в нём (в главном потоке) возбуждается EFrozenApplication. Заметим, что это эвристическая проверка. Нет 100% гарантии, что приложение именно висит. Вполне возможно, что оно занято обработкой данных. Поэтому эту опцию нужно включать только, если вы спроектировали своё приложение так, что в нём не может быть долгих подвисаний интерфейса.

Далее, вы можете указать авто-завершение или авто-рестарт приложения после определённого числа ошибок. Не следует путать эту опцию с опцией показа кнопки перезапуска в разделе "Exception Dialogs". Опция "Use Main Module options" используется только для библиотек и говорит о том, что если библиотека используется приложением с EurekaLog, то следует брать настройки из приложения, а не использовать свои. В этом случае настройки проекта библиотеки будут игнорироваться. "Call RTL OnException event" заставляет вызывать стандартный обработчик исключений. По-умолчанию, при включенном EurekaLog стандартный обработчик (например, Application.OnException) не вызывается. Опция "Catch Handled exceptions" заставляет EurekaLog всплывать на обрабатываемых исключениях. Т.е. тех исключениях, которые возникли внутри try-except, определённого программистом. Например, при таком коде:

procedure TForm1.Button1Click(Sender: TObject);
begin
  try
    raise Exception.Create('Тест.');
  except
    ShowMessage('Ошибка!');
  end;
end;

и включенной опции, при нажатии на кнопку сперва появится сообщение от ShowMessage, а затем — уведомление EurekaLog.

С настройками на этом всё.

Но возможности EurekaLog не ограничиваются статическим заданием настроек проекта. Вы можете писать дополнительный код, использовать обработчики событий и т.п. Для этого нужно просто подключить в uses модуль ExceptionLog (в dpr-файл он добавляется и убирается автоматически, не следует его трогать там руками). Если хотите — можете при написании кода использовать директивы {$IFDEF EurekaLog}, чтобы ваш код мог компилировать и с установленным EurekaLog и без него. Вам доступны несколько обработчиков событий (описание можно найти в справке), а также компонент TEurekaLog для более удобного их присвоения. Обычно обработчики событий используются для нестандартных ситуаций. Также в модуле можно найти много полезных подпрограмм, вот только часть из них (приведены лишь краткие описания — подробные описания вместе с примерами применения можно посмотреть в справке):

var
  // Обработчик исключений. Установив Handled в False, вы заставите
  // исключение обрабатываться стандартными средствами Delphi.
  ExceptionNotify: TExceptionNotifyProc;
  // Аналог ExceptionNotify, но для обработанных исключений. Вызывается
  // только при включённой опции "Catch Handled exceptions"
  HandledExceptionNotify: TExceptionNotifyProc;
  // Вызывается перед и после любого действия, выполняемого EurekaLog (показ
  // диалога, отправка отчёта, сохранение лога и т.п.). С помощью этого обаботчика
  // вы можете заблокировать или модифицировать успешность выполнения любого действия.
  ExceptionActionNotify: TExceptionActionProc;
  // Вызывается, если при обработке исключения EurekaLog возникло другое
  // исключение. Например, при отправке отчёта.
  ExceptionErrorNotify: TExceptionErrorProc;
  // Вызывается перед показом диалога EurekaLog, если вы указали пароль
  // для шифрования отладочной информации. Используется для получения
  // пароля расшифровки отладочной информации при показе отчёта пользователю.
  PasswordRequest: TPasswordRequestProc;
  // Вызывается для добавления в отчёт дополнительных данных. Любые данные,
  // собранные этой процедурой, появятся в разделе на вкладке "General".
  CustomDataRequest: TCustomDataRequestProc;
  // Вызывается при сборке файлов в отчёт. Здесь вы можете задать
  // дополнительные файлы для включения их в баг-отчёт (например,
  // документ с которым работал пользователь в момент вылета).
  AttachedFilesRequest: TAttachedFilesRequestProc;
  // Если ваш отчёт отправляется по HTTP, то здесь вы можете задать
  // дополнительные поля для post-отправки.
  CustomWebFieldsRequest: TCustomWebFieldsRequestProc;
  // Если вы включили "Show a custom 'Help' button", то при щелчке
  // пользователя по кнопке справки в диалогах EurekaLog
  // вызывается эта процедура.
  CustomButtonClickNotify: TCustomButtonClickProc;

// Возвращает True, если EurekaLog вообще установлена в программе.
// Возвращает False, если вы подключили модуль ExceptionLog руками,
// но не включили EurekaLog в опциях проекта.
function IsEurekaLogInstalled: Boolean;
// Включает или выключает EurekaLog в текущем и чужом потоке.
procedure SetEurekaLogState(Activate: Boolean);
procedure SetEurekaLogInThread(ThreadID: DWord; Activate: Boolean);
// Возвращает признак активности EurekaLog в потоках.
function IsEurekaLogActive: Boolean;
function IsEurekaLogActiveInThread(ThreadID: DWord): Boolean;
// Возвращает True, если указанный модуль был скомпилирован с EurekaLog.
function IsEurekaLogModule(HModule: THandle): Boolean;
// Возвращает версию EurekaLog для заданного модуля.
function GetEurekaLogModuleVersion(HModule: THandle): Word;

// Возвращает дату и время компиляции указанного модуля. Функция
// возвращае True, если указанный модуль был скомпилирован с EurekaLog.
function GetCompilationDate(HModule: THandle; LocalTime: Boolean;
  var Date: TDateTime): Boolean;

// Возвращает настройки EurekaLog для текущего модуля.
function CurrentEurekaLogOptions: TEurekaModuleOptions;
function DotNetEurekaLogOptions: TEurekaModuleOptions;

// Вызывает обработку EurekaLog для заданного объекта исключения и адреса
// возбуждения. Возвращает True, если EurekaLog активна.
function StandardEurekaNotify(Obj: TObject; Addr: pointer): Boolean;
// Аналог StandardEurekaNotify, только всегда возбуждает
// EeurekaLogGeneralError с заданным сообщением.
function StandardEurekaError(const Error: string): Boolean;
// Возвращает ErrorAddr последнего возникшего исключения.
function GetLastExceptionAddress: Pointer;
// Возвращает ExceptObject последнего возникшего исключения.
function GetLastExceptionObject: TObject;
// Возвращает стек вызовов для последнего исключения.
function GetLastExceptionCallStack: TEurekaStackList;
// Эквивалент StandardEurekaNotify(GetLastExceptionObject, GetLastExceptionAddress);
procedure ShowLastExceptionData;

// Устанавливает действие для кнопки завершения приложения от ближайшего исключения,
// которое будет обрабатываться.
function ForceApplicationTermination(TrmType: TTerminateBtnOperation): Boolean;

// Возвращает статус выполнения внутренних операций EurekaLog. Можно использовать,
// например, для проверки успешности отправки отчёта.
function GetLastEurekaLogErrorCode: TEurekaLogErrorCode;
function GetLastEurekaLogErrorMsg: string;

// Функции по работе со стеками вызовов.
function GetCurrentCallStack: TEurekaStackList;
function GetCallStackByLevels(StartLevel, LevelsNumber: Integer): TEurekaStackList;
procedure CallStackToStrings(CallStack: TEurekaStackList; Strings: TStrings);

// Позволяет заменить все сообщения об ошибке на заданное. Пустая строка сбрасывает такое поведение.
procedure SetCustomErrorMessage(const Value: string);

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

В предыдущих версиях эта программа была простым просмотрщиком логов, а в более старших версиях она стала небольшим менеджером ошибок. При установке EurekaLog она ассоциируется с файлами elf, так что их можно открыть по двойному щелчку. Также, Viewer может быть запущен из меню EurekaLog в Delphi:

В этом случае он показывает только отчёты, ассоциированные с текущим проектом.

Очень полезным свойством является возможность дважды щёлкнуть по строке в стеке вызовов в окне просмотра отчёта. При этом EurekaLog автоматически спозиционирует редактор Delphi на нужную строчку (разумеется, при условии, что открыт проект, соответствующий отчёту). Пароль для зашифрованных отчётов водится в правом верхнем углу.

Заметим, что, после своей установки, EurekaLog начинает перехватывать исключения в самой среде Delphi, помогая с диагностикой проблем. Но вам это может оказаться ненужным (особенно это справедливо для старших версий Delphi, в которых уже есть встроенный механизм баг-репортов). Для отключения этой функциональности зайдите в меню "EurekaLog"/"EurekaLog IDE Options" и снимите галочку "IDE Integration".

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

2.5. Windows Error Reporting

Windows Error Reporting (WER) — это набор технологий, встроенных в Windows, который собирает информацию о сбое в приложении при его вылете и отправляет её на сервер WinQual. Разработчик программного обеспечения по этой информации может разработать и опубликовать соответствующее обновление. Затем конечный пользователь, отправляя отчёт, увидит, что для этой ошибки в программе доступно исправление, сможет скачать его и обновить программу.

WER — это более продвинутая версия того, что когда-то было Доктором Ватсоном: Описание средства "Доктор Ватсон для Windows" (Drwtsn32.exe), Вызывая доктора Ватсона. В Windows XP WER представлен dwwin.exe (Microsoft Application Error Reporting) и Faultrep.dll, в Windows Vista — wermgr.exe и Wer.dll.

Просьба отправить отчёт об ошибке на сервер Microsoft не нравится многим людям. У них есть сомнения по поводу данных, которые содержатся в отчёте. Что содержится в отчёте? Почему отчёт отправляется в Microsoft, а не разработчику программы? Используются ли эти отчёты, чтобы следить за пользователями?

WER был создан, чтобы помочь Microsoft (а заодно и сторонним разработчикам) улучшить качество программ за счёт исправления багов. Один из сложных случаев — когда баг не воспроизводится на машине разработчика. Если баг нельзя воспроизвести, то его практически невозможно и исправить. Как один из вариантов решения этой проблемы — сделать "снимок" процесса во время сбоя на клиентском месте и отправить его разработчику. Обычно реализовать такую схему не просто. Хуже всего, что пользователи не обременяют себя отправкой отчётов — им проще просто перезапустить программу. Соответственно, разработчик не может исправить проблему, поскольку не знает о ней.

Любой баг, который приводит к вылету программы, является исключением. В этом смысле WER можно рассматривать как глобальный обработчик исключений. Как работает WER? Когда приложение вылетает из-за необработанного исключения, к процессу подключается WER, он собирает информацию о возникшем исключении и показывает пользователю стандартное диалоговое окно об ошибке (показаны различные варианты окна в Windows XP и Windows Vista, в зависимости от настроек):




WER вызывается всегда, если только приложение не имеет своего собственного глобального обработчика. Программы со своим собственным глобальным обработчиком также могут использовать WER, вызывая его вручную в нужный момент. Если пользователь выберет "Отправить отчёт", тогда WER подключится по защищённому SSL соединению к серверу Microsoft и отправит собранные данные, после чего исходный процесс, скорее всего, будет завершён ("скорее всего" — т.к. действия после отправки отчёта может определять сама программа, и плохо спроектированные программы могут вести себя как угодно, приводя к необходимости снятия приложения через Диспетчер Задач). На сервере полученная информация сортируется и ассоциируется с разработчиком приложения. Разработчики могут получить эти баг-отчёты для своих приложений, проанализировать их и установить источник проблемы. Затем они могут выпустить обновление, которое будет доступно конечным пользователям через Windows Update, во время повторной отправки отчёта об ошибке (только в Windows Vista) или в центре поиска решения проблем.

До Windows Vista пользователь мог получить обновление только по Windows Update (кстати, не факт, что разработчик ещё будет его использовать) или руками скачав патч/новую версию программы с сайта разработчика. Для Windows Vista у пользователя есть дополнительная возможность. В Vista есть специальная служба "Отчёты о проблемах и их решениях":

Запускается она из Панели управления:

В этой службе также можно посмотреть историю отправки отчётов:

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


Кроме того, при вылете приложения, Windows Vista автоматически проверяет наличие исправления для возникшей проблемы:

Причина, по которой отчёты об ошибках отсылаются на централизованный сервер в Microsoft, связана с тем, что ошибка в программе может быть не её виной. Например, вылет проводника может быть из-за кривого расширения оболочки. Вылет игры может быть обусловлен глюком в видеодрайвере и т.д. С помощью централизованного хранилища отчёт может быть отправлен разработчикам каждого продукта, участвующего в проблемной ситуации. Кроме того, сервер WinQual сортирует отчёты по кучам (buckets), которые идентифицируются по имени и версии приложения и модуля, а также адресу ошибки. Например, один баг может привести к большому количеству однотипных отчётов об ошибках. Разработчику не нужны все эти отчёты (вместе с дампами памяти!) сразу — достаточно одного-двух. По общему количеству он может судить о серьёзности бага и исправлять в первую очередь "самые популярные" баги.

Microsoft не использует данные отчётов для каких-либо маркетинговых анализов, анализов частоты ошибок в различных программах т.п. Все данные пользователей защищаются от постороннего доступа и используются только для поиска причины ошибки. Отчёты отправляются по защищённому SSL соединению и хранятся на защищённых серверах, которые не используются ни для каких других целей. Для доступа к отчётам нужно предоставить логин и пароль (задаваемые при регистрации в WinQual). Любой разработчик может видеть только отчёты для своих программ. Все компании, использующие WER, обязуются следовать аналогичным политикам. Разумеется, нет гарантий, что небольшая компания из трёх человек, занимающаяся разработкой shareware-софта, заинтересована в соблюдении вашей конфиденциальности столько, сколько сама Microsoft (хотя она и согласилась следовать соответствующим политикам). Кроме того, по-умолчанию данные отчёта не содержат никаких данных пользователя, кроме тех, которые случайно попадут в дамп. Т.е. персональные данные специально не собираются. В отчёт они могут попасть только случайно. Тем не менее, вы можете посмотреть данные отчёта перед отправкой в случае, если вы работали с важными данными перед вылетом программы.

WER может использовать кто угодно, для этого ваша программа не обязана проходить сертификацию на соответствие требованиям Windows (т.е. не обязана иметь логотип "Designed for Windows XP/Vista" или "Certified for Windows XP/Vista"). С другой стороны, одним из требований успешной сертификации вашей программы в Microsoft как раз является использование WER, а не самодельного решения. Более подробно о требованиях для сертификации программ можно почитать: здесь и здесь. В частности, там можно увидеть такие слова:

3.2 Resilient Software: Eliminate Application Failures

Criteria

1). Any crashes or hangs that occur during the logo certification process must be fixed.

Any errors found in the following Application Verifier tests must be fixed:

Basics: Exceptions, Handles, Heaps, Locks, Memory, TLS

Miscellaneous: DangerousAPIs, DirtyStacks

2). Applications must handle only exceptions that are known and expected, and Windows Error Reporting must not be disabled. If a fault (such as an Access Violation) is injected into an application, the application must allow Windows Error Reporting to report this crash.

3). ISVs must sign up to receive their crash data from Windows Error Reporting. This can be accomplished by signing up at: https://winqual.microsoft.com. ISVs must maintain map their certified applications to their company at this site and maintain those mappings while the product is supported. ISVs must target fixing 60% of their crash volume over the life of the product and maintain an average fix rate of 10 buckets per month or greater until this target is reached. (Buckets with fewer than 200 hits can be disregarded). When fixes are available through patches or service packs, a response must be submitted through the Winqual site.

Rationale

Application failures such as crashes and hangs are top drivers of customer dissatisfaction. Eliminating such failures improves application stability/responsiveness and reduces loss of time, work, data, and control for the user.

Additional Information

The Application Verifier can be downloaded from:

http://www.microsoft.com/downloads/

In order to meet the ongoing reliability requirement above, developers must register with the Windows Developer Portal (https://winqual.microsoft.com) and get access to customer feedback data on application failures. After registration, developers must specify ownership of failure events so that reports can be created for failures in their applications. This can be done by mapping the binary name and version used to identify crash signatures with an organization. Once the failure events have been mapped to an organization, developers can view top failures for applications based on volume and growth. It is important to regularly review top failures reported through the Developer Portal, provide fixes for these problems, and notify users by creating responses from the portal.

Мы не будем здесь подробно разбирать процесс регистрации для использования WER, опишем его только кратко (подробнее можно почитать в MSDN и на сайте Microsoft). Лучше мы детально рассмотрим, что требуется от наших Delphi приложений для правильной поддержки WER.

Итак, чтобы использовать WER, вам, во-первых, понадобится Windows Live ID (бывший .NET Passport). Завести его можно бесплатно здесь: www.passport.net. Далее, вам нужно стать зарегистрированным партнёром Microsoft. Это бесплатно, но регистрируются там организации. Если ваша организация уже зарегистрирована, то вы можете подсоединить свой MS Live ID к ней. Это были подготовительные действия.

Далее, вам нужно завести собственно аккаунт WinQual. Хотя сам аккаунт бесплатен, но вам потребуется выписать сертификат, чтобы подписывать им свои программы (да, регистрация на WinQual нужна не только для использования WER, но и для того, чтобы снабжать свои программы цифровой подписью). Вам нужен "VeriSign 'Microsoft Authenticode' Digital ID" (который сейчас стоит около $400). Как только вы получили сертификат, вы можете залогиниться на сайт WinQual и зайти в раздел Windows Error Reports. Далее вы настраиваете "маппинг" — т.е. сопоставляете с собой свои программы. Каждый раз, когда отправляется отчёт об ошибках на сервер WinQual, он сперва проходит предварительную обработку, в ходе которой определяется, от чьей программы этот отчёт. Обычно отчёт появится в вашем меню после двух-трёх дней после его получения, однако задержка может составлять и неделю. Кстати, на сайте WinQual есть раздел справки Getting Started — в нём описываются типичные действия, которые вы захотите совершить, попав на WinQual.

Что касается поддержки WER со стороны программ, то здесь есть проблема. Delphi реализует идеологию: "ни за что не вылетать". Любые ошибки, исключения приводят к появлению обычного сообщения об ошибке и (иногда) к корректному выходу из программы. Ведь изначально (до появления WER) было так, что если программа вылетает, то это всё, конец игры — никого нет, чтобы хоть что-то сделать. Эта идеология вступает в противоречие с требованиями WER, которые говорят, что если в программе возникает ошибка, которую она не знает, как обрабатывать (например, Access Violation), то программа обязана вылететь. В этот момент к ней подключится отладчик WER, соберёт необходимую информацию, отправит отчёт, ну и так далее.

Итак, проблема состоит в том, что любое Delphi приложение содержит в себе обработчики исключений, которые не пропускают сквозь себя никого. Вы не можете их убрать, но один трюк провернуть можно. Дело в том, что отчёт WER не обязательно должен генерироваться автоматически. Вы можете создать его и вручную. Конечно, это не очень "правильно" и не рекомендуется Microsoft (поэтому практическую информацию об этом найти тяжело), но, тем не менее, это вполне корректный путь. В Windows XP для мануальной инициализации WER существует функция ReportFault. В Windows Vista эта функция оставлена для обратной совместимости, а рекомендуется использовать обновлённые функции WerReportCreate, WerReportSubmit и т.п. В отличие от ReportFault, новый API позволяет вам настроить отчёт (и, разумеется, допустить при этом ошибки :) ). В частности, вы можете добавить к отчёту свои данные. Например, если вы предпочитаете какое-то готовое решение для диагностики, скажем, JCL или EurekaLog, то вы можете прикрепить к отчёту лог файлы от JCL или EurekaLog.

Для того чтобы сгенерировать отчёт WER в нужном месте, вам требуется перехватить появление исключения до того, как за него возьмутся обработчики исключений в программе. Для этого в модуле System.pas есть подходящая опция JITEnable:

var
  JITEnable: Byte platform = 0;
  // 0 - UnhandledExceptionFilter не вызывается
  // 1 - UnhandledExceptionFilter вызывается только для всех не-Delphi исключений
  // > 1 - UnhandledExceptionFilter вызывается вообще для всех исключений

Для установки пользовательского обработчика на это событие есть функция SetUnhandledExceptionFilter. Обработчик должен иметь прототип:

function(AExceptionInfo: PEXCEPTION_POINTERS): Cardinal; stdcall;

Т.к. это обработчик низкого уровня, то никакого объекта Exception здесь в явном виде нет. Есть только системная информация об исключении. Обработчик может вернуть 1 (EXCEPTION_EXECUTE_HANDLER), чтобы продолжить выполнение программы (в этом случае выполнится ближайший к месту возникновения исключения finally или except-блок). Есть ещё 0 (EXCEPTION_CONTINUE_SEARCH) и DWORD(-1) (EXCEPTION_CONTINUE_EXECUTION), но большого смысла в них для нашего случая нет.

Что касается самого обработчика, то он должен проанализировать AExceptionInfo. Если текущее исключение — то, по которому мы должны вылететь (например, Access Violation), знаит мы вызываем WER и завершаем работу программы. Если же это обычное исключении, то возвращаем EXCEPTION_EXECUTE_HANDLER. Проверить тип исключения можно, например, так:

// Текущее исключении - это Access Violation?
if AExceptionInfo^.ExceptionRecord^.ExceptionCode = EXCEPTION_ACCESS_VIOLATION then

Для других типов исключений можно использовать и другие константы — см. набор констант EXCEPTION_XXX в модуле Windows.pas. Напомним, что исключения Delphi имеют такие коды:

cDelphiException    = $0EEDFADE;
cDelphiReRaise      = $0EEDFADF;
cDelphiExcept       = $0EEDFAE0;
cDelphiFinally      = $0EEDFAE1;
cDelphiTerminate    = $0EEDFAE2;
cDelphiUnhandled    = $0EEDFAE3;
cNonDelphiException = $0EEDFAE4;
cDelphiExitFinally  = $0EEDFAE5;
cCppException       = $0EEFFACE; { used by BCB }

Объект исключения (только для Delphi исключений) можно получить из:

TObject(AExceptionInfo^.ExceptionRecord^.ExceptionInformation[1])

А адрес — из:

Pointer(AExceptionInfo^.ExceptionRecord^.ExceptionInformation[0])

Итак, наш план выглядит так:

  1. Написать фильтр, в котором при возникновении нужного исключения вызывается WER и происходит выход из программы.
  2. Где-нибудь при ранней инициализации установить фильтр вызовом SetUnhandledExceptionFilter. Лучше всего вынести это дело в отдельный модуль и подключить его первым в DPR файле.
  3. Установить JITEnable в 1 (или в 2, если вы хотите вызывать WER и для исключений Delphi).

Заметим, что вызывать WER (пункт 1) можно как вызывая руками API (ReportFault или набор функций WerReportXXX), так и вызывая унаследованный обработчик UnhandledExceptionFilter. Напомним, что WER, по сути, является глобальным обработчиком исключений, а значит его обработчик установлен по-умолчанию в UnhandledExceptionFilter. Первый способ рекомендуется Microsoft для сообщения не фатальных ошибок (например — ошибок распознавания рукописного ввода :) ), второй подход рекомендуется для сообщений о вылетах приложений. Грубо говоря, в первом случае вы всё делаете и контролируете сами, а во втором случае вы позволяете WER самому создать отчёт. Единственное, что в случае автоматического создания отчёта нужно будет не забыть предусмотреть выход из процесса или подавление обычного сообщения об ошибке. Последний подход демонстрируется, например, в статье "Allowing certain fatal exceptions to be handled by the OS instead of the VCL/RTL". При таком способе использования WER для подключения дополнительной информации вы можете использовать функции WerRegisterFile и WerRegisterMemoryBlock (см. также статью "Vista: WerRegisterFile"). Разумеется, для этого вам нужно знать место размещения данных заранее, до вылета. Также, вы можете изменять некоторые опции отправки отчётов с помощью функции WerSetFlags. См. также "Using WER" и "Windows Feedback Services".

Есть ещё проблема со свежими Delphi-заголовочниками для новых функций в Windows Vista. Если функции для Windows XP вы без проблем найдёте в JEDI Api Library (ссылка на SourceForge-е), то для Windows Vista вам нужно или ждать или делать всё самому. Впрочем, в примерах к статье вы найдёте наш перевод werapi.h.

Имеет смысл сказать ещё несколько слов по поводу составления отчёта WER. Во-первых, само появление отчёта настраивается в панели управления. Поэтому, если отчёт отключен, то функция отправки отчёта может вернуть соответствующую ошибку. Далее, пользователь может нажать по кнопке "Отладка" в окне отправки отчёта. В этом случае вы должны будете запустить отладчик (это целиком ваша задача). Для этого вы должны прочитать путь к отладчику из HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\AeDebug\Debugger (более подробный пример можно посмотреть в прилагаемом к статье архиве). Кстати говоря, в этом же ключе есть ещё опция Auto. Если её поставить в 1, то отладчик будет запускаться автоматически, либо кнопка Debug будет отключена (если отладчика нет или это доктор Ватсон). См. ещё статью "Registering BDS 2006 / Delphi 2006 / C++Builder 2006 / Delphi 2007 / C++Builder 2007 as the JIT Debugger". Также, на Vista, WER создаёт минидампы памяти только, если ему на это указывает сервер WinQual. А для этого ваше приложение должно быть зарегистрировано на WinQual. См. также How to create a user-mode process dump file in Windows Vista.

По вопросам правильного составления отчётов с новыми функциями WER можно ещё почитать ReportFault() vs. WerReportCreate/WerReportSubmit() on Vista, блог Клауса Брода, в частности статью "Crashing with style on Vista, part II". По вопросам отладки и анализа можно обратиться к MSDN: "Analyzing a User-Mode Dump File with WinDbg" или на сайт Crash Dump Analysis and Debugging Portal.

Кстати говоря, в Vista появился новый API, предназначенный для автоматического перезапуска приложения после сбоя. Разумеется, предназначен он для прикладных программ, а не для служб, и избавляет разработчика от необходимости писать свой собственный "авто-рестартер". Мы не будем касаться этого вопроса в рамках нашей статьи. API этот весьма прост и с ним легко разобраться самостоятельно, см. например, RegisterApplicationRestart.

2.6. Полезные советы, шаблоны использования и анти-примеры

В этом разделе собраны советы по использованию, типичные ситуации, примеры и "вредные" советы. Короче говоря, все те факты, которые обычно достаются только путём набивания шишек с ростом опыта. Именно поэтому, этот раздел почти бесполезен :) Большинство из нас уже слышало или знакомо с этими прописными истинами, пусть даже на бессознательном уровне :) Причём все программисты делятся на две категории: те программисты, что реально используют эти рекомендации (меньшинство), и те, что знают про них, но игнорируют (большинство). Наверняка все читали умные книжки, высказывания умных дядек и тут же выкидывали эти сведения из головы. Как говориться, урок без боли — бесполезен. Пока очередной косяк больно-пребольно не ударит по лицу, кто там будет следовать этим советам и "лучшим практикам"! :) Тем не менее, мы приведём эти сведения. Может быть, в сформулированном чёткими словами явном виде и в одном месте они заставят задуматься некоторых программистов. В этом случае мы будем считать свою задачу выполненной.

2.6.1. Не глушите исключения

Худшее, что вы можете сделать при обработке исключений — это глушить их, например, так:

try
  SomeBuggyCode;
except
  // Ничего не делать
end;

Часто такие блоки ставят не очень опытные программисты на код, который, например, иногда вызывает EAccessViolation, или регулярно возбуждает EStreamError (а программист просто не знает, что с этим делать). Иногда это делают, чтобы пользователь не видел никаких сообщений об ошибках. Иногда им лениво писать обработку исключений!

Проблема в том, что такой код, хотя внешне он завершается успешно, но, тем не менее, в реальности он может отрабатывать с ошибками. Ведь блок except поглотит любые исключения: EOutOfMemory, EDatabaseError, EStreamError. Очевидно, что всегда лучше вернуть признак ошибки, чем позволить вызывающему коду продолжить своё выполнение в полной уверенности, что всё идёт хорошо (и, возможно, исходя из этого предположения, испортить корректные данные).

Если же код вообще переполнен багами, и блок был поставлен для скрытия глюков работы — то нужно ведь искать причину ошибки, а не скрывать её (мы же собрались писать надёжные приложения)! Скрытие ошибки — это просто способ убежать от реальности.

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

Конечно, бывают ситуации, когда исключения нужно глушить. Примеры были выше — например, при передаче управления через границу модулей (из exe в DLL или наоборот). Но даже в этом случае исключение не исчезает бесследно — оно конвертируется в код ошибки. В конце концов, можно добавить хотя бы логгирование исключений — хоть что-то, чтобы ситуацию с ошибкой можно было исправить.

Бывает и такая ситуация: например, мы делаем пакетную обработку файлов (конвертируем из jpg в png, закачиваем в БД и т.п.) и мы не хотим, чтобы процесс остановился из-за ошибки обработки одного файла. Но и в этом случае тупо глушить все исключения — наихудший вариант. Можно сделать примерно так:

StopOnErrors := True;
// Цикл по всем файлам для обработки
for X := 0 to High(Files) do
try
  // ... здесь загрузка и обработка файла Files[X]
except
  on E: Exception do
  begin
    // Ловим только известные исключения!
    // В нашем случае это будут ошибки открытия/чтения файла (EStreamError),
    // ошибки конвертации (гипотетический EMyConvertJPGError)
    if (E is EStreamError) or (E is EMyConvertJPGError) then
    begin
      // Если это самая первая ошибка
      if StopOnErrors then
        // Покажем пользователю диалог - типа при обработке файла такого-то возникла
        // такая-то ошибка - продолжать или нет? И пусть диалог висит секунд 40, затем
        // автоматически закроется с результатом "продолжать" (на случай,
        // если пользователь не у консоли).
        if AskUserToContinue(Files[X], E) then
          StopOnErrors := False
        else
          // Если же пользователь сказал: всё, стоп - то останавливаем цикл.
          raise;
      // Любую запланированную ошибку добавим в отчёт по операции.
      AddExceptionToLog(Files[X], E);
    end
    else
      raise; // Все прочие ошибки немедленно останавливают обработку
             // (например EAccessViolation)
  end;
end;
// После завершения цикла покажем отчёт: при обработке такой-то
// и такой-то файл не были обработаны потому-то.
// Записи в отчёт добавляются AddExceptionToLog, если отчёт пуст - ничего
// не показываем, просто сообщение: процесс завершён.
ShowProcessErrors;

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

Имейте в виду, что здесь речь идёт именно о перехвате и заглатывании исключений. Ничего плохого в таком коде нет:

function GetSomeObject: TMyObject;
begin
  Result := TMyObject.Create;
  try
    // заполнение Result
  except
    on Exception do
    begin
      FreeAndNil(Result); // заботимся, чтобы не было утечек ресурсов
      raise; // мы НЕ перехватываем исключения, а только используем их для нотификации.
             // Что-то вроде try-finally, но с другой логикой обработки.
    end;
  end;
end;

2.6.2. Перехватывайте только (в точности) те исключения, с которыми вы знаете что делать

Аналог предыдущего пункта, только теперь вы показываете сообщение об ошибке:

try
  InvokeApocalypse;
except
  on E: Exception do
    ShowMessage(E.Message); // Эй, я - фатально-фатальная ошибка! Я хочу выйти!
                            // Что вы со мной делаете?
end;

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

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

Частично дело тут и в иерархии исключений. Например, пусть есть исключение EMyError и вам нужно его обработать:

try
  // ...
except
  on EMyError do
    ProcessMyError;
end;

Что будет, если позднее кто-то объявит EMyCustomError = class(EMyError)? Вы, вероятно, не хотели бы, чтобы ваш код перехватывал это исключение (вы о нём ничего не знаете, поэтому разумнее будет передать его выше). Условия отбора могут ограничиваться не только этим: например, для EOSError вы, вероятно, захотите обрабатывать только исключения с определённым ErrorCode и никакие другие.

Все эти условия реализуются примерно так:

try
  // ...
except
  on E: Exception do
    // В этом условии определим, что E - это именно то, что нам нужно
    if IWantThatException(E) then
      ProcessError
    else
      raise;  // все прочие исключения - передаём выше
end;

2.6.3. Показывайте пользователю сообщения об ошибках

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

2.6.4. Будьте информативными в своих сообщениях об ошибках

К сожалению, самый плохой подход по написанию сообщений об ошибках у нас прямо под носом — это VCL, увы. Эти её супер-краткие сообщения типа: "File not found", "Invalid filename", "Index out of range", "Abstract error" и т.п. — несут ноль полезной информации. Они никак не помогают идентифицировать ошибку и в большинстве ситуаций ничем не отличаются от общего "В программе произошла ошибка". Не стесняйтесь — пишите максимально подробные сообщения, включайте в них всю информацию, которая может помочь в идентификации ошибки. Файл не найден? Ну, включите в сообщение хотя бы имя файла. Используйте многострочные сообщения, если в одну строку не умещаетесь. Для "высокоуровневых" исключений не стесняйтесь перечислить возможные причины ошибки и способы их устранения. Не забывайте, сообщение исключения — это сообщение пользователю.

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

// См. также пункт 2.6.7.
try
  Assign(F, FileName);
  Reset(F); // Здесь может быть ошибка EInOutError с кодом 2 и сообщением "File not found".
except
  on E: EInOutError do
    if E.ErrorCode = 2 then // 2 = Файл не найден 
      raise EFOpenError.CreateFmt('Файл не найден:'#13#10'%s', [FileName])
    else
      raise;
end;

Кстати, приведённый код — отличный пример на тему, почему нужно использовать TFileStream вместо устаревших Assign/Reset и т.п. В TFileStream генерируется развёрнутое сообщение "Cannot open file C:\temp.txt. Файл не найден." (про локализацию сообщений мы ещё скажем отдельно).

Сказанное не означает, что нужно растекаться мыслью по древу и писать трактат на всю страницу — в конце концов, это сообщение потом будет читаться пользователем, следовательно, оно должно быть максимально лаконичным (есть даже такое правило: пользователи не читают сообщения). Да, это входит в противоречие со сказанным ранее, но вам придётся самому выбирать баланс: либо ваши сообщения не будут читать, либо они не будут содержать полезной информации. Этот вопрос, таким образом, становится близок проектированию пользовательского интерфейса (и поэтому мы выведем его за пределы нашей статьи).

Ещё один аспект — безопасность, а, вернее, конфиденциальность. Следует следить, чтобы в сообщение об ошибке (и баг-отчёт) не попали бы критические данные. Например, тот же пример сообщения с файлом в некоторых ситуациях может быть неприменим. Очевидно, что вы не захотите показать пользователю такое сообщение: "Cannot open file C:\secretstuff\docs\temp.txt.", если здесь в сообщение раскрывается информация, которую вы, скажем, хотели бы скрыть от простых пользователей (а доступ к ней имели бы только администраторы). Как вариант: сам пользователь может не захотеть, чтобы в баг-отчёте к разработчику светилось бы "Cannot open file C:\topsecretmilitarydocs\futureweapons.doc." :) Впрочем, опять-таки, безопасность — это тоже не тема этой статьи.

2.6.5. Не отключайте механизм отладчика уведомления об исключениях

Как бы отладчик вам ни надоедал, никогда не отключайте уведомления об исключениях — это будет не лучше предыдущих пунктов: вы скрываете ошибку, не разбираясь в причинах её возникновения. И только если вы действительно разобрались в ситуации и уверены (!), что всё идёт, как запланировано, — вы можете добавить исключение в список игнорируемых. Но лучше всё-таки пересмотреть дизайн кода и разобраться, почему это исключение возникает в ожидаемой ситуации (а ситуация, которая возникает настолько часто, что отладчик вам успел надоесть, определённо является типичной).

2.6.6. Не используйте исключения в обычных ситуациях

Исключение — это именно уведомления о нетипичных (исключительных!) ситуациях. Ситуациях, которые требуют особой обработки, изменения обычного потока выполнения программы. Исключения нужно возбуждать только тогда, когда компонент не может продолжать выполнение обычного пути (и, кстати, задача обработки исключений — вернуть выполнение кода "на правильный путь", в согласованное состояние). Не следует писать код на манер такого:

raise ENormalBehaviour.Create('Произошло совершенно обычное событие.');

Вы вводите этим сразу несколько проблем. Например — производительность (см. 1.3.2). А отладчик? Да он вас достанет своими уведомлениями (ладно, конкретно вы, может, и вообще вырубите уведомления, зато любой программист, что будет использовать ваш код — точно нет). Кстати, выполняя этот пункт, вы помогаете выполнить и предыдущий.

Сюда же относится и изменение обычного хода выполнения программы с помощью исключений. Иногда так надо — например, при отмене операции по кнопке "Отмена", но чаще всего это — самая обычная ситуация (а не исключительная). Тогда нужно просто перепроектировать код. Попробуйте разбить код на дополнительные процедуры. А может быть, вам поможет goto? Только не делайте модификаций в ущерб читабельности кода.

2.6.7. Не ждите, когда возникнет исключение — предупредите его заранее

Никогда не пишите код вроде такого (это просто пример):

try

I := StrToInt(S); // Мы знаем, что тут может быть исключение...

except

on EConvertError do

I := -1; // ...и специально ждём его, чтобы подставить значение по-умолчанию.

end;

Лучше используйте такой:

I := StrToIntDef(S, -1);

Этот пункт — прямое следствие предыдущих. Не множьте исключения сверх необходимого. Если вы ожидаете ошибку в блоке — проверьте её заранее if-ом. Всегда, когда вероятность возникновения исключений высока (а, следовательно, является "типичной" ситуацией), старайтесь предупредить их возникновение заранее и не допускать их возбуждения.

2.6.8. Пишите код в двух вариантах

Мы уже говорили с вами по поводу выбора между одним и другим подходом (коды ошибок vs исключения). Когда вы пишите свой код, который могут использовать другие люди, не забывайте, что они могут сделать другой выбор, отличный от вашего. Конечно, предоставить весь код в двух вариантах вы не в силах, да и это не нужно. Но посмотрите, может быть, часть функций легко удастся сделать в двух вариантах. Хороший пример из того же VCL — это StrToInt/TryStrToInt (ну и StrToIntDef):

function StrToInt(const S: string): Integer;
var
  E: Integer;
begin
  Val(S, Result, E);
  if E <> 0 then ConvertErrorFmt(@SInvalidInteger, [S]);
end;

// При ошибках конвертации возвращает False
function TryStrToInt(const S: string; out Value: Integer): Boolean;
var
  E: Integer;
begin
  Val(S, Value, E);
  Result := E = 0;
end;

или GetClass/FindClass:

// Класс не найден - возвращает nil
function GetClass(const AClassName: string): TPersistentClass;
begin
  RegGroups.Lock;
  try
    Result := RegGroups.GetClass(AClassName);
  finally
    RegGroups.Unlock;
  end;
end;

// Класс не найден - возбуждает исключение EClassNotFound
function FindClass(const ClassName: string): TPersistentClass;
begin
  // Обратите внимание, что для реализации варианта с исключениями функция
  // просто использует уже готовую реализацию с кодами ошибок
  Result := GetClass(ClassName);
  if Result = nil then ClassNotFound(ClassName);
end;

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

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

Разрабатываете ли вы функцию, компонент, класс или ещё какой автономный блок кода — не обрабатывайте исключения, о которых вы не знаете. Постарайтесь свести обработку исключений к минимуму. Дело в том, что вы не знаете наперёд, как будет использоваться ваш код, поэтому вы не можете заложить предопределённую обработку исключений — она не подойдёт на все случаи жизни. А вот автор приложения уже может ответить, что делать с ошибкой открытия файла в функции, скажем, конвертирования jpg в png (наш пример из 2.6.1): игнорировать или как-то обработать. Сам код конвертирования в нашем примере не знает, что делать с этой ошибкой и поэтому не должен её ловить. Да он в жизни не догадался бы, что мы хотим заносить исключение в лог, причём не всегда, а с разрешения пользователя!

2.6.10. Не обрабатывайте исключения по месту их возникновения

Иногда рекомендацию типа "обрабатывайте исключения как можно ближе к месту их возникновения" понимают слишком буквально: программисты оборачивают почти каждую конструкцию в try/except, производя обработку исключений прямо на месте. Этим вы полностью убиваете весь смысл исключений: вынести обработку ошибок в отдельное место от основного кода.

Да, обработку ошибок нужно производить пораньше, прежде чем потеряна важная информация о месте возникновения ошибки, но это же не значит, что нужно входить в такие крайности! Не нужно делать обработку раньше, чем она того заслуживает. Попробуйте руководствоваться правилом 2.6.9 и ищите оптимальное место для размещения своих обработчиков ошибок.

Посмотрите хотя бы на пример в 2.6.4: обработчик исключения стоит сразу после конструкции, вызывающей исключение — но ведь это просто учебный код! В реальном приложении нет никаких причин располагать этот обработчик так близко — выносите его выше, заодно вы можете захватить и другие проблемные вызовы, охватив, таким образом, одним обработчиком сразу кучу кода. Главное — не вынести его слишком высоко, когда потеряется важная информация об исключении (в нашем примере — имя файла). Кроме того, код, который находится слишком далеко от места возникновения исключения, может быть не информирован достаточно, чтобы принять нужное решение по обработке исключения.

2.6.11. Используйте свои собственные классы исключений

При возбуждении не используйте стандартные классы (за очень редкими исключениями) — определяйте свои собственные классы исключений. Это поможет точнее делать обработку ошибок. Представьте, что будет, если вы не сделаете отдельный класс, а воспользуетесь общим классом (а хуже всего — Exception). Теперь любой код, который захочет обработать именно ваше исключение и никакое другое (не важно, что вы не видите, кому это может понадобиться, рано или поздно — такие найдутся), должен будет (о, ужас!) анализировать текстовое сообщение исключения. А если оно может быть написано на разных языках? А если оно вообще на 98% составляется из динамических данных (имя файла и т.п.)?

Кстати, пример со своими классами был у нас в начале раздела 1.2.2.

Но вы можете наследоваться от стандартных классов — только делайте это со смыслом. Например, если вы делаете свой собственный поток данных (наследник от TStream), то логично все свои исключения в этом потоке наследовать от EStreamError.

2.6.12. Избегайте объявления слишком многих типов исключений

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

Например, все ошибки Win32 представлены одним классом — EOSError. Фактически они различаются только значением свойства ErrorCode.

2.6.13. Называйте исключение по причине ошибки, а не по вызывающему его

Это обычное соглашение по наименованию исключений. Во-первых, класс исключения следует начинать с заглавной E, во-вторых, называть причину ошибки, в-третьих, в конец иногда добавляют слово Error или Exception.

Например: EDivByZero (а не EErrorInDivideOperator).

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

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

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

О чём-то похожем мы могли уже говорить. Например, в 1.3.7. Главное — не мешать в одну кучу логгирование исключений и выдачу сообщений пользователю. Не следует каждую функцию оборачивать в try-except. Делайте это только на границах модулей (имеется ввиду не unit Delphi, а логический/архитектурный модуль). Проверяйте также, что при перевозбуждении исключений вы не теряете важную информацию об исходном исключении (а самое главное — о месте его возникновения). Наиболее простой способ — использовать chained-исключения.

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

2.6.15. Не пытайтесь обрабатывать ошибки написания кода

Такие исключения как EDivByZero, EAccessViolation, ERangeError и т.п. могут возникнуть только в одном случае — если вы написали неверный код. Обычно не имеет смысла их ловить и как-то обрабатывать — лучше сразу "крешнуться", позволив самому верхнему обработчику перехватить исключение и показать сообщение об ошибке. Разумеется, предварительно собрав максимум возможной информации, создав лог и т.п. — т.е. сделав всё для дальнейшей диагностики проблемы (разработчиком).

Проблема здесь в том, что вы просто не можете предложить приемлемого способа восстановления после ошибки. EAccessViolation? Может быть, у вас бажный код потёр служебные структуры менеджера памяти, а? Что вы будете делать? Лучший выход — уведомить пользователя и порекомендовать немедленный перезапуск программы (с предварительным сохранением данных). Конечно, есть шанс, что мы всё же можем продолжить своё выполнение, но всё же лучше один рестарт, чем испорченные данные пользователя.

Кстати говоря, для таких ошибок можно вовсе не показывать сообщение конкретной ошибки пользователю. Что он поймёт в нем? Для него, что EAccessViolation, что EDivByZero, что EExternalException — все равны. Эти слова не несут для него никакой полезной информации. Более того, он не сразу может понять, что случилось. Поэтому лучше показать общее сообщение, не упоминая возникшей ошибки вообще: "В программе возникла критическая ошибка. Мы приносим свои извинения. Рекомендуется сохранить все данные и перезапустить программу." и добавить кнопку отправки отчёта разработчикам (с предварительным просмотром отправляемых данных).

2.6.16. Не управляйте жизнью исключений

Объекты исключений в Delphi не следует удалять вручную. Жизнью этих объектом управляет библиотека поддержки языка. Конечно, бывают и исключения из этого правила, но они единичные. Обычно это реализация собственного механизма управления ошибками. Отсюда, в частности следует, что если вы объявили переменную в блоке except, то не следует использовать её вне этого блока (имеется в виду, копировать в другую переменную и использовать вне блока).

2.6.17. Обработка исключений в конструкторах

Хотелось бы поговорить и о том, как обрабатываются исключения в конструкторе объектов. Дело в том, что тут есть несколько тонких моментов, которые обычно скрыты "под капотом" языка. Для этого нам придётся в деталях рассмотреть процесс создания объекта.

Для удобства, приведём объявление класса TObject (не забываем, в Delphi любые классы являются наследником TObject):

TObject = class
  constructor Create;
  procedure Free;
  class function InitInstance(Instance: Pointer): TObject;
  procedure CleanupInstance;
  class function InstanceSize: Longint;
  procedure AfterConstruction; virtual;
  procedure BeforeDestruction; virtual;
  procedure FreeInstance; virtual;
  destructor Destroy; virtual;
  ... // Другие методы, не имеющие отношения к созданию-удалению объекта.
end;

И рассмотрим такой код (здесь TMyObj = class(TObject)):

MyObj := TMyObj.Create;
try
  // 
finally
  FreeAndNil(MyObj);
end;

Пусть конструктор TMyObj выглядит так:

constructor TMyObj.Create;
begin
  inherited Create;
end;

Теперь, если мы посмотрим на сгенерированный код для этого примера (не будем приводить ассемблерный листинг), то увидим, что конструктор трактуется весьма необычным способом. Во-первых, конструктору неявно передаётся указатель на класс (в нашем случае — на TMyObj), во-вторых, ему также неявно передаётся режим работы. Дело в том, что конструктор ведь можно вызвать двумя разными способами: как метод объекта (например, внутри конструктора: "inherited Create;") и как статический метод класса (например, при создании: " := TMyObj.Create;"). Вот этот скрытый переключатель и устанавливает, в каком режиме вызывается конструктор. Обратите внимание, что все эти действия компилятор вставил в программу втихую. Если попытаться написать выполняемые компилятором действия в виде псевдокода, то получится примерно следующее:

// Фактически конструктор трактуется как функция класса, возвращающая объект,
// к которой компилятор применяет свою "магию"
class function TMyObj.Create(AClassOrInstance: Pointer; ACreateInstance: Boolean
  { если у конструктора есть аргументы, то они перечисляются здесь }): TMyObj;
begin
  if ACreateInstance then  // Вариант с F := TF.Create;
    Result := _ClassCreate(TClass(AClassOrInstance)) 
  else                     // Вариант с inherited Create;  
    Result := TMyObj(AClassOrInstance);
 
  // Здесь располагается код конструктора, что вы написали в Delphi, в нашем случае это:
  TObject.Create(Result, False); // напомним: конструктор - это функция класса
  // Конец кода конструктора

  if ACreateInstance then
  begin
    _AfterConstruction;

    // Блок try ставится в _ClassCreate. Да, на Паскале такого не сделаешь,
    // а вот ассемблером - ради бога. 
    except
      Result.Free;
      raise;
    end;
  end;
end;

и "MyObj := TMyObj.Create;" в псевдо-коде выглядит как:

MyObj := TMyObj.Create(TMyObj, True);

А _ClassCreate выглядит так:

function _ClassCreate(AClass: TClass): TObject;
begin
  Result := AClass.NewInstance;
  try // Блок try/except, который заканчивается в конструкторе
      // (не забывайте: это - псевдокод).
end;

Ну и для полноты картины ещё и _AfterConstruction приведём:

function _AfterConstruction(Instance: TObject): TObject;
begin
  try
    Instance.AfterConstruction;
    Result := Instance;
  except
    _BeforeDestruction(Instance, 1);
    raise;
  end;
end;

Вдумчиво просмотрев весь приведённый код, можно сделать вывод, что весь код любого конструктора обёрнут в try/except. Причём, при любом исключении в конструкторе вызывается деструктор объекта (который последним действием удаляет объект). Т.е. суммарно мы получаем такую картину:

constructor TMyObj.Create
begin
  //_CreateClass:
    NewInstance; // выделили память под объект

  try

    // код конструктора
     
    //_AfterConstruction:
      try
        AfterConstruction;
      except
        BeforeDestruction;
        raise;
      end;

  except
    Result.Free; // при ошибках - вызвали деструктор и освободили память
    raise;
  end;
end;

Т.е. любое исключение в конструкторе приводит к вызову деструктора и удалению объекта (и никаких утечек ресурсов не происходит). А что отсюда следует? А то, что, во-первых, не следует пытаться ловить исключения в конструкторе для освобождения ресурсов — для целей очистки будет вызван деструктор (где вы и выполните все действия по очистке). И, во-вторых, что деструктор должен быть готовым к удалению частично-инициализированного объекта. Например, если в конструкторе заполняются поля FField1 и FField2 и после инициализации FField1 возникло исключение, то в деструкторе FField1 будет заполнено, а FField2 будет равно nil.

Если вы будете писать деструктор недостаточно аккуратно, то может быть такая ситуация: в конструкторе объекта возбуждается исключение. Например, что-то о невозможности подключения к базе данных. Автоматически вызывается деструктор объекта. В нём вы делаете вызов Clear для какого-то списка. Но список этот иницилизируется в самом конце конструктора и ещё не был создан (т.к. возникло исключение). Когда деструктор вызывается из-за исключения, этот список равен nil. Вызов Clear для него ведёт к Access Violation. Получаем, что исходное исключение о невозможности подключения к БД оказывается скрыто (потеряно, если только не используются chained-исключения). Иными словами, пользователь вводит неверную пару логин/пароль и получает AV. Отличная работа! :) Эту ошибка опасна ещё и тем, что вызов деструктора не всегда может показываться в стеке вызовов для исключения (это к вопросу анализа стека в баг-отчётах). Просто не забывайте, что деструкторы вызываются автоматически для исключений в конструкторах.

См. также, кстати, связанный Вопрос №63889 (хоть он и не про исключения).

Заметим, что сказанное не означает, что исключение, возникшее в событии OnCreate формы, прервёт процесс её создания. Дело в том, что код, вызывающий OnCreate, обёрнут в try-except. Любое возникшее там исключение передаётся в метод формы HandleCreateException, который по-умолчанию просто показывает сообщение на экране (вызовом Application.HandleException). Если нужно, чтобы ваша форма не ловила исключения в OnCreate — переопределите HandleCreateException.

По поводу строения TObject см. также "Путешествуя по TObject. Или как оно работает", а по теме жизненного цикла объектов вообще — "Жизнь и смерть в режиме run-time".

2.6.18. Если функция создаёт и возвращает объект - убедитесь, что нет утечки

Сценарий с выделением-работой-освобождением ресурса с использованием try-finally достаточно хорошо известен. Гораздо реже используется другой сценарий: выделение-наполнение-возврат ресурса. Два примера реализации таких сценариев:

function GetMyObject: TMyObject;
begin
  Result := TMyObject.Create;
  Result.SomeStream.LoadFromFile('filename');
end;

...
  A := TMyObject.Create;
  A.SomeStream.LoadFromFile('filename');
  List.AddObject(A);
  // A далее не используется
...

Проблема с такими сценариями в том, что здесь есть утечка ресурсов. Что будет, если исключение возникнет при LoadFromFile? Мы получим, что созданный экземпляр объекта TMyObject нигде не используется ("утёк"). Вариант решения может быть таким:

function GetMyObject: TMyObject;
begin
  Result := TMyObject.Create;
  try 
    Result.SomeStream.LoadFromFile('filename');
  except
    FreeAndNil(Result);
    raise;
  end;
end;

...
  A := TMyObject.Create;
  try
    A.SomeStream.LoadFromFile('filename');
    List.AddObject(A); // предполагается, что при возникновении исключения в этой строке,
                       // объект A в список List не попадает
  except
    FreeAndNil(A);
    raise;
  end;
...

Как вариант — вместо try-except можно использовать try-finally с проверкой ExceptObject. raise в этом случае, разумеется, убирается.

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

2.6.19. Удаление/освобождение обязано быть успешным

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

На это правило есть множество причин. Во-первых, число логически: мы удаляем объект, он нам больше не нужен. Какие ещё могут быть ошибки? Всё, мы завершаем его жизненный цикл. Во-вторых, если удаление было неудачным — как вы собираетесь восстанавливаться после него? Был ли объект удалён до конца или нет? А как тогда предпринять повторное освобождение? В-третьих, это гарантирует отсутствие исключений в finally блоках при работе с ресурсами (напомним: см. 1.2.3 пункты 3 и 4). В-четвёртых, если при ошибке какие-то действия можно выполнить вне кода удаления объекта (и затем повторить удаление), то почему бы не выполнить их же прямо во время удаления?

Иными словами — удаление объекта обязано быть успешным. Если в вашем деструкторе вы ожидаете какие-то исключения, то их нужно обработать прямо в деструкторе.

2.6.20. Документируйте исключения

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

Бесполезно генерировать какого-либо рода ошибки и не сообщить об этом в документации. Если информации об ошибках нет (исходные коды могут быть ведь и недоступны), то, соответственно, код, вызывающий вас, не будет их обрабатывать (ибо не знает, что и когда обрабатывать). А зачем же вы возбуждали ошибки, если не для их обработки вызывающей стороной?

См. также 2.6.26.

2.6.21. Исключения в потоках

Пока мы не говорили о том, какие возникают особенности при обработке исключений во вторичных потоках. Если вы используете "голые" потоки Windows, то тут всё прямолинейно: вы никогда-никогда не должны выпускать исключение из потока, созданного CreateThread/BeginThread. Потому что эти исключения являются необработанными и немедленно приведут к появлению стандартного диалогового окна WER "Обнаружена ошибка. Приложение будет закрыто" и закрытию вашей программы. И это справедливо. Поэтому всегда заключайте код функции потока в try/except и предусматривайте подходящую обработку исключения. Один из вариантов обработки — передать исключение в главный поток для возбуждения его там и последующей обработки.

При использовании TThread у вас уже есть возможности для манёвра. Дело в том, что функция потока в TThread оборачивает вызов Execute в try/except. Любое исключение, сбежавшее из Execute, заносится в свойство FatalException потока. Исключение будет удалено в деструкторе TThread. А вы можете проанализировать этот код из события OnTerminate или из кода главного потока, например:

T := TMyThread.Create(False);
try

  // ... <- Выполняем какие-то действия. Поток в это время также выполняет работу.

  // Мы свою работу выполнили - ждём конца работы потока.
  T.WaitFor;
  // Анализируем: успешно ли завершился поток?
  if T.FatalException <> nil then // есть ошибка
  begin
    // Обработка ошибки потока.
  end;
finally
  FreeAndNil(T);
end;

Обратите внимание, что для потоков нет никакого умалчиваемого обработчика, который показывал бы сообщение пользователю. Эта задача целиком ложится на вас. Если вы не проверяете свойство FatalException и не заключаете Execute в блок try/except, то вы можете вообще не заметить исключение (только при запуске вне отладчика, разумеется). Это, кстати, действительно проблема использования TThread. Ведь бывает, что программист даже не знает про свойство FatalException. Кстати, это ещё один аргумент против установки свойства FreeOnTerminate (некому анализировать FatalException).

См. также Вопрос №64008.

2.6.22. Предпочитайте специальную обработку ошибок общей

Сперва поясним, что имеется ввиду. Посмотрите на пример 1.3.7 или на почти весь раздел 2. Везде идёт обсуждение, как одним кодом обработать любую ситуацию (это и есть общая обработка ошибок). Обратите внимание, что в этом случае ситуация фактически представляет собой необработанную ошибку. Да, показаны способы, как добиться максимальной информативности в этом случае. Быть может даже показать сообщение, помогающее идентифицировать проблему. Но обратите внимание, что всё это не может быть использовано конечным пользователем! Тот же пример с 1.3.7 — да это сообщение повергнет обычного пользователя в ужас (эй, не забываем, речь идёт о Windows, а, следовательно, рядовой пользователь — это домохозяйка)! В этом смысле оно бесполезно. Его первичная цель — идентификация ошибки для вас (программиста, разработчика, отдела поддержки), ну и, может быть, опытного или технически подкованного пользователя.

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

Конечно, реализовать такую особую проверку тяжелее, чем общую. Нужно выделять особые случаи, производить фильтрацию (отбор) исключений, делать специальные диалоги, писать справку и т.п. Ещё бывает искушение оставить только общую обработку и не делать специальную вовсе. Наиболее запущенный случай — не писать обработку ошибок вообще, возлагая все надежды на стандартный механизм обработки ошибок: показ сообщения методом Application.HandleException. Типа, и так же всё показывается, разберутся: что тут не понять-то? Но это действительно тот пункт (один из), что отделяет качественное ПО от всего остального. Поэтому не ведитесь на поводу у своей лени! :)

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

2.6.23. Случаи особой обработки определяются тестированием

Следующий вопрос: а как же реализовать такую обработку? Как определить когда и что ловить? А какие классы исключений? А что писать пользователю? Правильный ответ на этот вопрос прост (скажем наперёд: некоторым он не понравится): не пытайтесь предусмотреть все возможные ситуации во время написания кода. Вам это не удастся, и вы просто впустую потратите время. Нет, если, конечно, вы явно увидели место для редактирования — пишите, но специально искать такие места не нужно. Эти ситуации определяются во время тестирования (напомним, речь не идёт о таких тривиальных вещах, как проверка аргументов и т.п.).

Изначально вы реализуете только общую обработку с подробнейшими сообщениями об ошибках общего вида (базовые проверки). Затем вы начинаете тестировать свою программу, гоняете её во всех возможных ("и даже некоторых невозможных" © Half-Life 2) ситуациях. Любую ситуацию возникновения общей ошибки вы фиксируете как баг и затем исправляете её: вы выделяете те условия, что выделяют этот баг от остальных, пишете код по его фильтрации, составляете описание ситуации, добавляете в справку раздел и т.п. Короче говоря, шаг за шагом реализуете обработку ситуации.

Надо заметить, что у нас в стране часто практикуется "драконовский" подход к разработке приложений. Это когда программист и архитектор, и дизайнер, и сам-себе-постановщик-задач, и кодер, и тестировщик и т.п. (на работу требуется водитель с умением водить минивэн, грузовик, самокат, внедорожник, аэроплан, бронированный лимузин, навыки вождения танка, грузового самолёта и БМП приветствуется). При этом никакой команды тестеров просто нет, а сам программист гонять-тестировать свою программу ой как не любит :) Поэтому и было сказано, что ответ может многим не понравиться.

Однако не надо думать, что вот прям всю и каждую ситуацию мы будем отрабатывать таким образом. Нет, это не обязательно. Тут тоже действует "волшебное" правило 10/90. Т.е. 10% ошибок возникают в 90% случаев. Таким образом, достаточно качественно покрыть только наиболее часто встречающиеся ошибки, а все остальные — оставить общими. Чем больше ошибок вы обработаете специальным образом — тем лучше получится программа. Да вот только зависимость тут не линейная. Чем больше вы уже сделали, тем меньше пользы приносит добавление спец-обработки очередной ошибки (т.к. каждая такая новая ошибка встречается всё реже и реже).

2.6.24. Чем раньше вы добавите обработку ошибок в программу — тем лучше

Представьте себе, что вы писали весь код, не используя try/finally для освобождения ресурсов. Сколько времени вам понадобится, чтобы внимательно просмотреть весь код и расставить эти конструкции? Чего стоило добавлять их по мере написания кода? См. также пункт 1.3.11.

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

2.6.25. Исключения — не всегда ошибки

У многих программистов есть твёрдое убеждение, что исключения — это всегда ошибки. Более того, некоторые совсем начинающие программисты легко впадают в панику просто при виде уведомления отладчика об исключении :) Это, конечно, не так. Если бы исключения были бы ошибками, они бы так и назывались — ошибки. Но они называются исключения. Т.е. внештатными (исключительными) ситуациями. Исключение — это что-то необычное, прерывание основного потока выполнения программы.

Конечно, в большинстве случаев, исключения сообщают об ошибочных ситуациях в программе. Но это не всегда так. Пожалуй, самый яркий пример — EAbort (мы уже подробно о нём говорили ранее). Поэтому, возникновение исключения в программе — ещё не признак ошибки в ней. Каждую ситуацию нужно анализировать отдельно. Кстати, иногда рассматривать ли конкретную ситуацию как случай возникновения ошибки или нет — зависит от точки зрения на то, что понимать под ошибкой.

2.6.26. Потоки с именами

Если вы используете в своей программе несколько потоков, то вы можете назначить своим потокам читабельные имена. Это позволит вам видеть название потоков в отладчике Delphi. Для этого, в начале работы потока нужно из него вызвать такую функцию:

procedure SetCurrentThreadName(const AName: String);
type
  TThreadNameInfo = record
      RecType: LongWord;
      Name: PChar;
      ThreadID: LongWord;
      Flags: LongWord;
    end;
var
  LThreadNameInfo: TThreadNameInfo;
begin
  with LThreadNameInfo do
  begin
    RecType := $1000;
    Name := PChar(AName);
    ThreadID := $FFFFFFFF; // -1 - текущий поток; также сюда можно вставить ID другого потока
    Flags := 0;
  end;
  try
    RaiseException($406D1388, 0, SizeOf(LThreadNameInfo) div SizeOf(LongWord),
      PDWord(@LThreadNameInfo));
  except
  end;
end;

Примечание: несмотря на странность кода, это — не хак, а вполне документированная возможность: "Setting a Thread Name (Unmanaged)".

В некоторых Delphi при создании нового объекта потока Wizard предлагает создать именованный поток:

Это создаст новый объект потока с уже установленным именем (на рисунке — "Worker"). При этом используется в точности тот код, что мы привели выше.

Кстати, вы можете задать имя и главному потоку. Вот, например, как выглядит в отладчике поток с именем (мы дали главному потоку имя "Main"):

2.6.27. Важность совместимости

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

Давным-давно, во времена DOS :) была такая вещь, как "MS-DOS 2.0 reference manual". И в этом мануале в описании функции создания файлов были строки, описывающие возникающие ошибки. И выглядели они так:

Carry set: AX
  3 = Path not found
  4 = Too many open files
  5 = Access denied

Заметили? В этом списке нет никакой строки типа "other values". Вот так. Когда-то в документации указывали полный набор ошибок, возвращаемых функциями. И прикладные программисты следовали букве мануала: они считали, что функция может вернуть ТОЛЬКО коды ошибок номер 3, 4 или 5.

Позднее в DOS добавили поддержку сети. А сеть предоставляет кучу новых возможностей для неудачного завершения функции создания файла, типа: сервер не отвечает, файл занят другим пользователем и т.п. Для этих новых ситуаций нужно было ввести новые коды ошибок. И в Microsoft попробовали это сделать в лоб: т.е. добавили коды к этому списку выше. И знаете что? Буквально КАЖДОЕ написанное приложение MS-DOS вылетало, когда встречало новый код ошибки. Такая ситуация была просто не предусмотрена в их коде! Если кому-то это покажется невероятным — напомним, что речь идёт о "доисторических" временах, когда экономили на всём. И уж тем более не допускали никакого лишнего кода, который вообще никогда не выполнится (согласно существовавшему reference manual).

Не суть важно, как выкрутились в Microsoft из этой ситуации, а суть в том, что нельзя закладываться на заранее предопределённый список ошибок. Хотите полный список? Пожалуйста, берите такой: любая функция может вернуть любую ошибку! Вот так-то. Реализация функции может измениться в любой момент. Вместо одного класса в ней может начать использоваться другой класс. Меняется и список ошибок. Если вы загляните в MSDN, то увидите, что именно так там и сделано — в описании каждой функции есть слова, сводящиеся к: "для получения описания ошибки вызывайте GetLastError" (а GetLastError может вернуть любой код). Ну и рядышком перечислены типичные коды ошибок. Именно они закреплены документацией и не будут меняться (см. далее). Один раз Microsoft обожглась и больше не повторяет этой ошибки (и мы вам не советуем её повторять). Дай людям в руки возможность — и они обязательно будут использовать её неправильно :)

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

Но это совместимость в одну сторону. В другую же это выглядит так: никогда не изменяйте возвращаемые коды ошибок (исключения). Расскажем по этой теме ещё один пример. Была одна программа во времена DOS, которая пыталась открыть файл "" (т.е. файл с пустым именем) и выводила в него данные. Функция открытия файла возвращала в этом случае код ошибки 2 — "файл не найден". Но программа принимала это значение за дескриптор файла и выводила в него данные. В MS-DOS дескриптор 2 соответствовал экрану, и вывод программы появлялся на мониторе. Т.е. программа работала просто случайно, благодаря стечению обстоятельств! Более точно это называется "использованием деталей реализации".

С переходом на Windows многие функции были переработаны, и функция открытия файла для пустой строки стала возвращать ошибку номер 3 — "путь не найден". В MS-DOS дескриптор 3 соответствует COM-порту. В обычной конфигурации компьютера к COM-порту ничего не присоединено, т.е. запись останавливается. Результат: зависшая программа. В итоге, чтобы обеспечить работу старых программ, пришлось переделать новые функции так, чтобы они возвращали такие же коды ошибок, как и в DOS.

2.6.28. Если вызывающая сторона не выполняет контракт — вы вправе делать что угодно

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

Что если вызывающая сторона сознательно или по ошибке не выполнит свою часть контракта? В частности, что будет, если вы ожидаете указатель на блок памяти, чтобы заполнить его информацией, а вам передают nil или блок памяти меньшего размера?

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

Однако наилучшим вариантом в этом случае будет просто вылететь с EAccessViolation. Смотрите сами. Очевидно, что эта ситуация (передача неверных параметров) — нетипичная (т.к. не должна происходить в отлаженной программе), а значит, что исключение будет тут как никак кстати. Кроме того, проверка, основываемая на том, что вы ловите EAccessViolation и возвращаете ERROR_INVALID_ARGUMENT, противоречит предыдущим рекомендациям по написанию кода (не ловите ошибки кода и т.п.). Более того, код ошибки проще не заметить, чем исключение, а значит, что ошибка, которая привела к передаче неверного указателя в функцию, может проявиться намного позже — например через 15 минут нормальной работы программы. Вызывающий не выполнил своих обязательств — а, значит, вы вольны делать всё, что хотите. И "крешнуться" без лишних действий — самый нормальный вариант. Кроме того, если в вашу функцию случайно был передан указатель на выделенный (но не тот) блок памяти, то есть шансы, что этот блок памяти будет освобождёт другим потоком (ок, может быть не вашим лично, но каким-нибудь системным) в промежутке между проверкой и использованием. Т.е. получится, что вы проверили блок памяти — он корректен, а стали использовать его — и... "крешнулись"! Иначе говоря, от необходимости готовиться к EAccessViolation вы всё равно не избавитесь, поэтому и смысла добавлять лишний проверочный код нет.

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

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

Как пример — передача данных по IPC между двумя процессами (назовём их клиентом и сервером). Обычно с клиентской стороны будет заглушка, которая принимает аргументы, упаковывает их и передаёт процессу. Так вот, "вылететь" может только заглушка с клиентской стороны, т.к. она находится в том же контексте, что и вызывающий код, но не процесс-сервер! В клиентской заглушке вы упаковываете данные, не заботясь о проверках. Чуть что не так — исключение. При этом программист может поймать исключение и использовать отладку для поиска причины, что он сделал не так. На серверной стороне вы проверяете валидность принятого сообщения (возможно, распаковываете пришедшее сообщение в тот же набор параметров, что и на клиентской стороне). И вот здесь уже никаких непредвиденных исключений вы допускать не должны!

2.6.29. Приложение вылетает без сообщения об ошибке? Проверьте стек

Очень редко бывают ситуации, когда приложение просто закрывается (вылетает) без всякого сообщения об ошибке и без стандартного диалога WER. Заметим, что здесь не идёт речь о ситуациях, когда пользователь отключил показ сообщений об ошибках в панели управления. В первую очередь убедитесь, что отчёт об ошибках включён. Также убедитесь, что вы не балуетесь с функцией SetErrorMode.

Для собственно же вылета без сообщения есть несколько причин, но одной из самых частых является повреждение стека программы. Дело в том, что диалоговое окно с ошибками показывается процессом программы. А для работы функции необходим стек. Поэтому, если, к примеру, ваше приложение поймало EStackOverflow, а затем (из-за плохого проектирования) начало обрабатывать его, и при этом снова использовать стек, то ваше приложение вылетит без сообщения об ошибке, т.к. весь стек будет занят и сообщение об ошибке нельзя будет показать. Если вы получаете такую ситуацию под отладчиком, то он вам покажет примерно такое сообщение: "Project XXX faulted with message: 'access violation at YYY: write at address ZZZ'. Process stopped. Use Step or Run to continue". Здесь программа попыталась использовать стек (например, вызвав процедуру), а поскольку стек закончился, то произошла попытка записи в заблокированную память за стеком, что и привело к фатальному AV, которое не может быть обработано.

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

Также, кроме стека, причиной пропадания приложения иногда может служить DEP — см. "A detailed description of the Data Execution Prevention (DEP) feature in Windows XP Service Pack 2, Windows XP Tablet PC Edition 2005, and Windows Server 2003" (в варианте на русском: "Подробное описание функции предотвращения выполнения данных, входящей в состав Windows XP с пакетом обновлений 2 (SP2), Windows XP Tablet PC Edition 2005 и Windows Server 2003").

Ещё одна проблема — все сообщения об ошибках RTL, VCL и сторонних компонент написаны на английском языке. Обычно вам нужно сделать приложение полностью на русском. К сожалению, локализация сообщений выходит за рамки данной статьи. Возможно, мы рассмотрим эту тему в следующих статьях. Пока можно почитать статью (и комментарии к ней) "MultiLanguage инструменты". Для сообщений о своих собственных ошибках мы рекомендуем использовать тот же подход, что и в RTL/VCL: вынесите все свои сообщения в отдельный модуль как resourcestring. Кстати, заметим, что в конце сообщений об ошибках для исключений не следует ставить точку. При показе сообщения стандартными средствами точка будет автоматически добавлена. Разумеется, это просто очередное соглашение хорошего тона.

2.6.31. Анализируйте все сообщения компилятора

Речь идёт о предупреждениях (warning) и подсказках (hints) компилятора. На Королевстве Delphi уже есть отличная статья по этой теме, поэтому не грех здесь и на неё сослаться: "Hints and Warnings или Спасение утопающих". Добивайтесь удаления всех предупреждений компилятора из своей программы (эй, стоять! Куда полезли! Код исправляйте, а не отключайте предупреждения!). Поверьте, это позволит снизить вероятность возникновения ошибки при работе приложения. Прежде, чем просить помощи со своей проблемой, сперва вылижите свой код.

3. Ссылки

При составлении этой статьи не обошлось без внешней помощи.

  1. Справка и исходники Delphi — http://docs.codegear.com/.
  2. MSDN — http://msdn.microsoft.com/.
  3. Блог Ника Ходжеса (Nick Hodges) — http://blogs.codegear.com/nickhodges/.
  4. Блог Реймонда Чена (Raymond Chen) — http://blogs.msdn.com/oldnewthing/.
  5. Блог Ларри Остермана (Larry Osterman) — http://blogs.msdn.com/LarryOsterman/.
  6. Блог Клауса Брода (Claus Brod) — http://www.clausbrod.de/blog/.

Чтож, на сегодня у меня всё и... побольше вам ошибок! XD

К статье прилагаются примеры