27 февраля 2012 г.

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

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

См. также Разработка API (контракта) для своей DLL и Разработка системы плагинов, часть 9: подводные камни.

Оглавление

  1. Основы отладки:
  2. Управлением временем жизни
  3. EAccessViolation
  4. Обработка ошибок в плагинах:
  5. Заключение

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

Сперва мне хотелось бы дать краткое введение в отладку. Если вы уже знакомы с этим материалом, то можете пропустить его. Для тех же, кому краткого введения окажется мало - см. полную версию. Я привожу здесь эту вводную часть потому, что далее я буду говорить слова вроде "ставим non-breaking бряк на строчку XYZ с логгингом стека" и мне хотелось бы, чтобы читатели понимали, о чём идёт речь.

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

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

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

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

Запуск отладки библиотек (плагинов)

Замечу, что запускать на выполнение можно только программы. Библиотеки (DLL или bpl) - это не программы. Это именно что библиотеки - наборы функций. Их нельзя запустить. Но их можно загрузить и вызвать функцию из них. Загружать библиотеку - должен "кто-то", а именно - какая-то программа. Вот её, эту программу, вам и нужно запускать, когда вы хотите отладить библиотеку. Программа запустится, загрузит библиотеку и будет вызывать её функции.

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

Попытка "запустить" DLL или bpl
Если вы хотите скомпилировать плагин (получить .dll или .bpl файл) - вам вовсе не нужно его для этого запускать. Просто выберите Project/Compile (Ctrl+F9) или Project/Build (Shift+F9).

Если же вы хотите именно отлаживать плагин, то вам нужно указать программу, которая будет грузить этот плагин. Естественно, в случае плагинов это будет программа-ядро. Для этого вам нужно открыть меню Run/Parameters и указать главную программу:

Указание программы-сервера (host)

Остановка выполнения программы

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

Пауза

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

Исключения

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

Уведомление отладчика в старых версиях Delphi

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

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

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

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

Если вы хотите посмотреть, как программа будет работать "вживую", без отладчика и его уведомлений - просто запустите программу вне отладчика (через "Run without debugging" или просто запустив программу руками с диска).

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

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

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

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

Точки останова

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

Среда Delphi показывает наличие отладочной информации для строк кода
Каждая синяя точка говорит о том, что в реальном машинном коде программы есть код, который попал туда именно в процессе компиляции этой строки. Т.е. строка, отмеченная синей точкой, компилируется в код, приводит к появлению кода. Если напротив строки с кодом вы не видите точки, то:
  • Возможно, вы открыли файл, не принадлежащий проекту. К примеру, у вас загружен в IDE проект плагина, но вы открыли файл менеджера плагинов. Менеджер плагинов - он, вообще-то, находится в ядре. Если вы хотите его отлаживать, вам нужно открыть проект программы, а не плагина.
  • Возможно, модуль ещё не загружен. К примеру, если вы отлаживаете плагин и запустили программу, то вы не увидите отметки, пока плагин не будет загружен программой. Замечу, что это может и не произойти вовсе - если, к примеру, программа и не собирается грузить плагин.
  • Возможно, эта строка не используется программой. Например, если в модуле есть три функции, но программа вызывает из него только две функции, то третья функция в программу не попадёт, и напротив строк её кода вы не увидите синих отметок. Второй случай - одна строка из функции может быть выкинута оптимизатором. Например, если вы присваиваете значение, которое дальше не используется. Вы не сможете установить точки останова на все эти строки - просто потому что их физически нет в программе.
  • Возможно, что вам нужно просто сделать Build проекту. Т.е. вы смотрите на старый вариант кода. В некоторых случаях нужно почистить папки от старых .dcu файлов.
  • Возможно, в вашем проекте отключена отладочная информация. Отладочная информация - это информации о соответствии машинных инструкций из .exe/.dll/.bpl и строчек текстового исходного кода. Если её не будет - отладчик не сможет работать с исходным кодом и вы увидите только машинный отладчик (CPU View). Отладочную информацию можно включить/выключить в коде (директивы {$D+}/{$D-}) или опциях проекта (Project/Options/Compiler/Debug information). Подробнее см. мою статью о настройке проектов для отладки.
  • Возможно, этот код находится в пакете времени выполнения (.bpl). В этом случае он может быть собран с иными опциями, чем указано у вас в проекте. Конечно же, изменения опций проекта никак не влияют на пакеты. В этом случае либо пересоберите пакет с отладочной информацией, либо отключите сборку с пакетами.
  • Возможно, Delphi не может связать выполняющуюся программу с исходным кодом и отладочной информацией. Что можно сделать, чтобы этого избежать:
    • Не переименовывайте программу или плагины. Изначально задавайте им нужные имена.
    • Не перемещайте программу или плагины в другое место. Вместо этого задайте нужный Output directory в настройках проекта, либо просто храните проект в выходном каталоге.
    • Не запускайте программу через альтернативные имена. К примеру, папка программа может быть видна под различными именами: её имя, жёсткая ссылка на папку, подмонтированный диск (reparse point), subst-диск, сетевая папка. Если у вас есть несколько вариантов путей к одной и той же папке - везде используйте один и тот же путь, лучше всего - родной, без обвеса. К примеру, если папка C:\Users\Александр\Documents является точкой подключения диска D:\, то к файлу
      RAD Studio\Projects\PluginsDemo\richedit.exe
      можно обратиться через два имени:
      C:\Users\Александр\Documents\RAD Studio\Projects\PluginsDemo\richedit.exe
      и
      D:\RAD Studio\Projects\PluginsDemo\richedit.exe
      В этом случае будет предпочтительнее открывать проект
      D:\RAD Studio\Projects\PluginsDemo\richedit.dpr
      вместо
      C:\Users\Александр\Documents\RAD Studio\Projects\PluginsDemo\richedit.dpr
      и запускать
      D:\RAD Studio\Projects\PluginsDemo\richedit.exe
      вместо
      C:\Users\Александр\Documents\RAD Studio\Projects\PluginsDemo\richedit.exe
    • Убедитесь, что в пути к проекту есть только латинские символы (ASCII). Например, плохо: C:\Users\Александр\.... Нормально: C:\Users\Alexandr\...
    • Не изменяйте дату-время .dcu/.exe/.dll/.bpl файлов. В некоторых случаях изменение даты может привести к тому, что отладчик будет считать файлы изменёнными и поэтому не соответствующими друг другу. Иногда дата может меняться антивирусом или примочками к IDE.
    • Не используйте опцию "Use MSBuild externally", либо включайте генерацию удалённой отладочной информации (remote debug information, RSM).
    • Не удаляйте и не перемещайте .dcu файлы. Убедитесь, что выходной каталог для .dcu (Unit output directory) также находится в путях поиска (Search path). В крайнем случае попробуйте выводить .dcu файлы в тот же каталог, что и выходной .exe/.dll/.bpl файл.
    • Убедитесь, что файлы проекта можно найти. Вы также можете попробовать подключить все их явно через Project/Add to project, либо же попробовать, наоборот, перечислить папки с ними в опции Search paths проекта.
    • Как крайнее средство - попробуйте включить отладочную информацию TD32 и информацию для удалённой отладки. Подробнее см. уже упоминавшуюся статью.
    • Ещё как крайнее средство - попробуйте заменить в настройках проекта относительные пути на абсолютные и наоборот. Ещё мощнее - сложить все (проект, выходной файл и .dcu) в одну папку.
    Делайте действия по списку сверху-вниз. После каждого действия очищайте .dcu файлы и делайте Build проекту.
  • Ну и, конечно же, это могут быть баги Delphi. Чем старее версия Delphi - тем больше в ней может быть багов, связанных с этим. Как правило, все они проявляются лишь при экстремально-граничных случаях: больших размерах модулей, констант и т.п. Вы можете погуглить в интернете или на Quality Central.
Итак, после того, как вы сделали Build проекту и видите синие точки — теперь вы можете выбрать место для установки точки останова. Щёлкните мышкой по любой синей точке, и она изменится на большую красную точку, а сама строка выделяется (кстати, если вы не видите синих точек вообще, необязательно делать компиляцию, чтобы их увидеть — просто щёлкайте слева от кода, где вы хотели бы остановиться):

Установленная точка останова
В этом случае мы захотели остановиться перед выполнением строки с присваиванием свойства Caption. Заметим, что breakpoint-ы вы можете ставить, как во время проектирования, так и во время работы или приостановки программы. Теперь, после запуска программы, как только выполнение дойдёт до одной из заданных вами точек останова, отладчик немедленно остановит программу.

Инструменты отладчика

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

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

Среда во время отладки
Если какого-то окна на экране нет, вы можете показать его, используя меню "View"/"Debug windows":

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

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

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

Анализ значений переменных

Итак, продолжаем. Наша программа стоит на паузе. В окне "Local Variables" показываются локальные переменные в текущем месте. Как только мы остановились, отладчик показывает нам чему равны локальные переменные в текущей функции (т.е. функции, в которой мы остановились). Если вы хотите посмотреть значения локальных переменных для других функций - просто дважды щёлкните по нужной функции в окне стека вызова (Call Stack).

Для некоторых переменных отладчик может сказать нам, что он не может получить значение переменной ("Variable 'XYZ' inaccessible here due to optimization"). Это работа оптимизатора (кстати, вы можете отключить его, сбросив в опциях проекта галочку "Optimization"). Он выбрасывает переменную, как только в ней отпадёт необходимость.

Итак, "Local Variables" — удобное окно для просмотра локальных переменных. Что делать, если хочется посмотреть не локальную переменную? Можно воспользоваться окном "Watches". Для этого щёлкните правой кнопкой по свободной области окна "Watch List" и выберите "Add watch" — появится окно ввода параметров наблюдения:

Добавление переменной для наблюдения
В поле "Expression" вы можете ввести имя переменной, за которой хотите следить. Кстати, это не обязательно должна быть переменная — вы можете ввести любое выражение, которое поддаётся вычислению. Например, выражение "X = 1" (без кавычек, разумеется) — оно будет равно 'True' или 'False'. Остальные опции отвечают за форматирование отображения. Другой способ добавить выражения для слежки — выделить их в редакторе кода, щёлкнуть правой кнопкой и выбрать "Add watch at cursor" (Ctrl + F5).

Примечание: обычно команды отладчика располагаются в подменю "Debug" (и многие из них могут быть недоступны, если только программа на стоит на паузе под отладчиком), но если в настройках отладчика включить опцию "Rearrange editor local menu on run", то на время отладки все пункты контекстного меню редактора, связанные с отладкой, для удобства выносятся наверх.

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

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

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

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

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

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

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

Анализ пути выполнения

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

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

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

По поводу странного вида процедур до Button2Click мы ещё поговорим позже.

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

Трассировка

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

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

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

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

Напомним, что у нас есть две команды для пошагового выполнения — "Step Over" (F8) и "Trace Into" (F7). С первой мы уже познакомились — она просто выполняет текущую строчку и переходит на следующую. "Trace Into" работает похожим образом, но с одним отличием: если в текущей строчке есть вызов процедуры, то "Trace Into" зайдёт внутрь процедуры, в то время как "Step Over" выполнит всю процедуру одним махом. Если никаких вызовов процедур нет, то эти команды ведут себя одинаково.

Например, положим, что мы установили точку останова на вызов некоторой функции - скажем, P. Тогда, если бы вы стояли на "P;" и нажали бы F8, то программа выполнила бы P целиком, после чего мы бы оказались в отладчике на строке после "P;". А если бы нажали на F7, то мы перешли бы в процедуру P, оказавшись на сроке "begin". Разумеется, если бы мы ещё поставили точку останова внутри P, то при попытке выполнить строчку с "P;" "одним махом" с помощью F8, мы всё равно оказались бы внутри P - но уже не в результате "захода в функцию", а как результат срабатывания точки останова. Это полностью соответствует описанной логике. С одной стороны, F8 выполняет строчку целиком. С другой стороны, любой бряк приводит к остановке выполнения программы. Поэтому, когда F8 выполняет строку и в процессе этого выполнения натыкается на бряк, то она останавливает выполнение программы.

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

Кроме этих команд ещё есть ещё несколько полезных команд движения по программе - но я оставлю вам их для самостоятельного изучения.

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

Примечание: никогда не используйте обычное снятие процесса отлаживаемой программы. Отладчик крайне болезненно относится к снятию процесса извне. Всегда используйте только "Program reset".

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

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

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

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

События

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

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

Окно Events в процессе работы программы под отладкой
В окно "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" задаёт дополнительное условие. Если она пуста (по-умолчанию) — бряк срабатывает каждый раз, когда до него доходит выполнение, если она не пуста (задана), то он срабатывает только в случае, если условие в данном поле истинно. Разумеется, то, что вы сюда впишете, должно вычисляться, когда выполнение доходит до точки останова, и, кроме того, всё выражение в целом должно иметь тип 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" включает или выключает группу брейкпойнтов при срабатывании текущей точки останова. Используются довольно редко, т.к. необходимы для задания довольно сложного поведения точек останова. Один из вероятных сценариев использования этих опций — отладка двух разных потоков сразу. Например, при достижении точки останова в первом потоке отключаются все точки останова во втором потоке и наоборот. Таким образом, начав отладку одного потока (первого, в котором сработает точка останова), наш процесс отладки не прервётся другим потоком. Это избавляет вас от ручного включения и выключения точек останова при нескольких проходах отладки. Другой вариант использования этих опций — отладка системных модулей. Например, вы расставили точки останова в коде 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" будет попадать стек вызова для возникшего ислючения.

Практический пример

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

Управлением временем жизни

Первая проблема, которой мне хотелось бы коснуться - это управление временем жизни объектов в нашей системе. Поскольку мы реализуем систему на базе интерфейсов, а интерфейсы относятся к авто-управляемым типам данным, то, к счастью, обычно нам не нужно делать никаких специальных действий: RTL Delphi позаботится обо всех вопросах управления памятью.

Тем не менее, если бы это было так просто - этого раздела тут не было бы :)

А дело тут в том, что в нашей программе у нас есть две вещи, которые существенно осложняют нашу жизнь:
  1. Явная выгрузка плагинов
  2. Использование неуправляемых объектов (например, формы)
Эти два момента означают, что в нашей программе имеется смешение стилей управления - ручного (на базе объектов) и автоматического (на базе интерфейсов). И вот как раз стыке двух механизмов у нас могут появляться проблемы.

Собственно, несложно сообразить, что проблем может быть всего две:
  1. Слишком раннее удаление (до обнуления ссылок)
  2. Слишком позднее удаление (не удаляется вообще)
Давайте начнём с первой проблемы.

Двойное удаление

Что означает эта проблема? Она означает, что мы удаляем объект раньше положенного времени - до того, как обнулится его счётчик ссылок. Иными словами, в момент вызова деструктора объекта счётчик ссылок интерфейса будет больше нуля. Это может происходить когда мы удаляем объект с интерфейсом через его объектную ссылку (вызовом Destroy/Free/FreeAndNil). Чем вообще это плохо? Несложно сообразить, что плохо это тем, что когда счётчик ссылок упадёт до нуля - деструктор объекта будет вызван повторно: для уже удалённого объекта!

Поскольку такая проверка потребуется нам везде - и в ядре и в плагинах (мы же всюду используем эту функциональность), то нам нужно поместить этот код в общую для всех папку. Однако, существующие PluginAPI.pas/PluginAPI_TLB.pas для этого не подходят - потому что они содержит заголовочники, а мы хотим написать код поддержки, вспомогательный код. Поэтому создайте новый модуль (File/New/Unit) и сохраните его как \PluginAPI\Headers\Helpers.pas, после чего измените его следующим образом:

unit Helpers;

interface

uses
  Windows,
  SysUtils,
  Classes;

type
  ECheckedInterfacedObjectError = class(Exception);
    ECheckedInterfacedObjectDeleteError = class(ECheckedInterfacedObjectError);

  TDebugName = String[99];

  TCheckedInterfacedObject = class(TInterfacedObject)
  private
    FName: TDebugName;
  protected
    procedure SetName(const AName: String);
  public
    constructor Create;
    procedure BeforeDestruction; override;
  end;

implementation

uses
  PluginAPI;

resourcestring
  rsInvalidDelete = 'Попытка удалить объект %s при активной интерфейсной ссылке; счётчик ссылок: %d';

{ TCheckedInterfacedObject }

constructor TCheckedInterfacedObject.Create;
begin
  FName := TDebugName(Format('[$%s] %s', [IntToHex(PtrUInt(Self), SizeOf(Pointer) * 2), ClassName]));
  inherited;
end;

procedure TCheckedInterfacedObject.SetName(const AName: String);
begin
  FillChar(FName, SizeOf(FName), 0);
  FName := TDebugName(AName);
end;

procedure TCheckedInterfacedObject.BeforeDestruction;
begin
  if FRefCount <> 0 then
    raise ECheckedInterfacedObjectDeleteError.CreateFmt(rsInvalidDelete, [String(FName), FRefCount]);
  inherited;
end;

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

Чтобы как-то идентифицировать объект, с которым происходит такая плохая вещь, мы ввели свойство Name. Оно не выставляется наружу, потому что это внутреннее дело объекта - для отладочных целей. Предполагается, что вы вызовите метод SetName в конструкторе своего объекта, передав туда какую-то строку, однозначно идентифицирующую объект. Если вы это не сделаете, то имя объекта по умолчанию будет содержать hex-представление адреса объекта и имя класса объекта.

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

Вы можете проверить работу этого кода, сэмулировав "плохую" ситуацию:
var
  C: TCheckedInterfacedObject;
  I: IInterface;
begin
  C := TCheckedInterfacedObject.Create;
  I := C;
  C.Free; // <- попытка удалить объект возбудит исключение, 
  // потому что у нас есть активная интерфейсная ссылка I
end;
С другой стороны:
var
  C: TCheckedInterfacedObject;
  I: IInterface;
begin
  C := TCheckedInterfacedObject.Create;
  I := C;
  ...
  I := nil; // <- уменьшает счётчик ссылок до нуля и удаляет объект, нет проблем
end;
Итак, теперь всюду в нашем коде, где у нас есть TInterfacedObject мы должны заменить его на TCheckedInterfacedObject (не забыв вписать в uses модуль Helpers, конечно же), опционально задать в конструкторе уникальное имя - и теперь весь наш код будет защищён от подобной ситуации. Если теперь вы допустите ошибку в коде, наш проверочный код её увидит и возбудит исключение. Вы можете остановиться в отладчике по исключению (как я описывал в начале статьи) и исследовать ситуацию: проверить стек вызовов, чтобы узнать где происходит неверное удаление, и анализом переменных выяснить, кто и почему удаляется.

Однако, если вы попробуете запустить такой тестовый пример (от самого первого примера он отличается тем, что я поменял местами удаление объекта через интерфейс и через объект):
var
  C: TCheckedInterfacedObject;
  I: IInterface;
begin
  C := TCheckedInterfacedObject.Create;
  I := C;
  ...
  I := nil;
  C.Free; // <- добавили
end;
то увидите, что наша реализация не отлавливает такие проблемы. Вместо этого объект будет удалён дважды и, в итоге, свалится с Invalid pointer operation при попытке повторного освобождения памяти.

Мы можем улучшить наш объект следующим образом:
type
  ...
    ECheckedInterfacedObjectDoubleFreeError = class(ECheckedInterfacedObjectError);

  TCheckedInterfacedObject = class(TInterfacedObject)
  private
    ...
    function GetRefCount: Integer;
  protected
    ...
  public
    ...
    property RefCount: Integer read GetRefCount;
  end;

implementation

...

resourcestring
  ...
  rsDoubleFree     = 'Попытка повторно удалить уже удалённый объект %s';

{ TCheckedInterfacedObject }

...

procedure TCheckedInterfacedObject.BeforeDestruction;
begin
  if FRefCount < 0 then
    raise ECheckedInterfacedObjectDoubleFreeError.CreateFmt(rsDoubleFree, [String(FName)])
  else
  if FRefCount <> 0 then
    raise ECheckedInterfacedObjectDeleteError.CreateFmt(rsInvalidDelete, [String(FName), FRefCount]);
  inherited;
  FRefCount := -1;
end;

function TCheckedInterfacedObject.GetRefCount: Integer;
begin
  if FRefCount < 0 then
    Result := 0
  else
    Result := FRefCount;
end;

...
Задача этого кода - предотвратить повторное выполнение деструктора. Для этого мы при первом удалении объекта "скидываем" счётчик ссылок в -1 - это специальное значение, которое я выбрал "от балды" (лишь бы оно не попадало в диапазон допустимых значений счётчика: 0 и положительные). Специальное значение счётчика ссылок используется в качестве маркера "объект уже удалён". Таким образом, если объект будет удалять кто-то ещё - вы увидите сообщение о попытке повторного удаления.
Примечание: повторное выполнение деструктора возможно по той причине, что при освобождении памяти, она просто помечается как "свободная", но не удаляется на самом деле. Поэтому в ней остаётся её старое содержание, к ней также можно обратиться (здесь: "можно" = "не приведёт к исключению", а не "допустимо так поступать"). Вот почему второй вызов деструктора начнёт своё выполнение без проблем. Мы исправили эту ситуацию, записав в эту "освобождаемую" память специальное значение.

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

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

Тогда:
var
  C: TCheckedInterfacedObject;
  I: IInterface;
begin
  C := TCheckedInterfacedObject.Create;
  I := C;
  ...
  I := nil; // <- удалит объект
  C.Free; // <- даст по рукам за повторное удаление
end;
И даже:
var
  C: TCheckedInterfacedObject;
begin
  C := TCheckedInterfacedObject.Create;
  ...
  C.Free; // <- удалит объект
  C.Free; // <- даст по рукам за повторное удаление
end;
И, наконец, последняя аналогичная проблема тут - повторный вызов деструктора через интерфейсную ссылку. Это более хитрая ситуация и обычно она выглядит так: счётчик ссылок объекта падает до нуля и вызывается деструктор этого объекта. Во время выполнения деструктора какой-то из методов объекта передаёт ссылку объекта куда-то. Это приводит к увеличению счётчика ссылок с 0 до 1. Когда вызванный метод вернёт управление, счётчик ссылок уменьшится с 1 до 0, что приведёт к повторному вызову деструктору - и это прямо в самом деструкторе этого же объекта! В итоге вы получите самое разное поведение - Invalid pointer operation, Access Violation и даже зависание.

А решение этой проблемы достаточно просто: нужно возбудить ошибку при попытке увеличения счётчика ссылок с 0 до 1:
type
  ...
    ECheckedInterfacedObjectUseDeletedError = class(ECheckedInterfacedObjectError);

  TCheckedInterfacedObject = class(TInterfacedObject, IInterface)
  private
    ...
  protected
    ...
    function _AddRef: Integer; stdcall;
  public
    ...
  end;

implementation

...

resourcestring
  ...
  rsUseDeleted     = 'Попытка использовать уже удалённый объект %s';

{ TCheckedInterfacedObject }

...

function TCheckedInterfacedObject._AddRef: Integer;
begin
  if FRefCount < 0 then
    raise ECheckedInterfacedObjectUseDeletedError.CreateFmt(rsUseDeleted, [String(FName)]);
  Result := inherited;
end;

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

Эту новую проверку можно проверить таким образом:
var
  C: TCheckedInterfacedObject;
  I: IInterface;
begin
  C := TCheckedInterfacedObject.Create;
  I := C;
  ...
  I := nil; // <- удаление объекта
  I := C; // <- по рукам за использование уже удалённого объекта
end;

Утечки памяти

Итак, с первой проблемой (неверное удаление) мы разобрались. Давайте теперь посмотрим на вторую (противоположную) проблему - пропуск удаления объектов (утечки памяти).

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

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

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

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

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

Итак, пусть у нас есть утечка памяти. Если её у вас нет, то для проверке описываемых техник на практике вы можете руками вызвать _AddRef на какой-нибудь объект. Например, на плагин:
procedure TMainForm.FormCreate(Sender: TObject);
  ...
begin
  ...

  Plugins[0]._AddRef; // <- без парного вызова это - утечка
end;
И тогда запуск программы с FastMM в отладочном режиме даст нам такое сообщение при выходе:

Сообщение отладочного режима FastMM о найденных утечках
А в папке программы будет создан лог-файл с таким содержанием:
--------------------------------2012/2/24 16:32:17--------------------------------
A memory block has been leaked. The size is: 52

This block was allocated by thread 0x1B0C, and the stack trace (return addresses) at the time was:
4043E2 
4E227F [PluginAPI\Core\PluginManager.pas][PluginManager][PluginManager.TPluginManager.LoadPlugin][259]
4E2553 [PluginAPI\Core\PluginManager.pas][PluginManager][PluginManager.TPluginManager.LoadPlugins][299]
4E7298 [remain.pas][remain][remain.TMainForm.FormCreate][467]
4D3B53 [Forms.pas][Forms][Forms.TCustomForm.DoCreate][3319]
4D3713 [Forms.pas][Forms][Forms.TCustomForm.Create][3189]
4DDF85 [Forms.pas][Forms][Forms.TApplication.CreateForm][9879]
4EFD31 [richedit.dpr][richeditdemo][richeditdemo.richeditdemo][18]
74FB339A [BaseThreadInitThunk]
77539EF2 [Unknown function at RtlInitializeExceptionChain]
77539EC5 [Unknown function at RtlInitializeExceptionChain]

The block is currently used for an object of class: UnicodeString

The allocation number is: 7093

Current memory dump of 256 bytes starting at pointer address 7EE69870:
B0 04 02 00 01 00 00 00 13 00 00 00 4D 00 65 00 6E 00 75 00 20 00 44 00 65 00 6D 00 6F 00 20 00
70 00 6C 00 75 00 67 00 69 00 6E 00 20 00 23 00 31 00 00 00 16 94 80 12 00 00 00 00 01 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 53 1E 00 00 E2 43 40 00 02 B1 43 00 69 B2 43 00
B0 DC 46 00 F7 32 4B 00 F7 32 4B 00 71 34 4B 00 53 5F 4B 00 19 3F 4B 00 55 4D 4D 00 73 F2 4A 00
0C 1B 00 00 0C 1B 00 00 FE 43 40 00 02 B1 43 00 69 B2 43 00 B0 DC 46 00 F7 32 4B 00 F7 32 4B 00
71 34 4B 00 53 5F 4B 00 19 3F 4B 00 55 4D 4D 00 73 F2 4A 00 34 00 00 00 B0 04 02 00 3B EB 26 85
68 A2 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00
5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 C4 14 D9 7A 00 00 00 00 71 96 E6 7E
°  .  .  .  .  .  .  .  .  .  .  .  M  .  e  .  n  .  u  .     .  D  .  e  .  m  .  o  .     .
p  .  l  .  u  .  g  .  i  .  n  .     .  #  .  1  .  .  .  .  ”  Ђ  .  .  .  .  .  .  .  .  .
.  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  S  .  .  .  в  C  @  .  .  ±  C  .  i  І  C  .
°  Ь  F  .  ч  2  K  .  ч  2  K  .  q  4  K  .  S  _  K  .  .  ?  K  .  U  M  M  .  s  т  J  .
.  .  .  .  .  .  .  .  ю  C  @  .  .  ±  C  .  i  І  C  .  °  Ь  F  .  ч  2  K  .  ч  2  K  .
q  4  K  .  S  _  K  .  .  ?  K  .  U  M  M  .  s  т  J  .  4  .  .  .  °  .  .  .  ;  л  &  …
h  ў  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .
\  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  Д  .  Щ  z  .  .  .  .  q  –  ж  ~

--------------------------------2012/2/24 16:32:17--------------------------------
A memory block has been leaked. The size is: 36

This block was allocated by thread 0x1B0C, and the stack trace (return addresses) at the time was:
4043E2 
4E227F [PluginAPI\Core\PluginManager.pas][PluginManager][PluginManager.TPluginManager.LoadPlugin][259]
4E2553 [PluginAPI\Core\PluginManager.pas][PluginManager][PluginManager.TPluginManager.LoadPlugins][299]
4E7298 [remain.pas][remain][remain.TMainForm.FormCreate][467]
4D3B53 [Forms.pas][Forms][Forms.TCustomForm.DoCreate][3319]
4D3713 [Forms.pas][Forms][Forms.TCustomForm.Create][3189]
4DDF85 [Forms.pas][Forms][Forms.TApplication.CreateForm][9879]
4EFD31 [richedit.dpr][richeditdemo][richeditdemo.richeditdemo][18]
74FB339A [BaseThreadInitThunk]
77539EF2 [Unknown function at RtlInitializeExceptionChain]
77539EC5 [Unknown function at RtlInitializeExceptionChain]

The block is currently used for an object of class: UnicodeString

The allocation number is: 7094

Current memory dump of 256 bytes starting at pointer address 7EE72890:
B0 04 02 00 01 00 00 00 07 00 00 00 31 00 2E 00 30 00 2E 00 30 00 2E 00 30 00 00 00 52 47 80 12
5C A5 4F 00 5C A5 4F 00 00 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
58 1E 00 00 E2 43 40 00 02 B1 43 00 69 B2 43 00 B0 DC 46 00 F7 32 4B 00 F7 32 4B 00 71 34 4B 00
53 5F 4B 00 19 3F 4B 00 55 4D 4D 00 73 F2 4A 00 0C 1B 00 00 0C 1B 00 00 FE 43 40 00 02 B1 43 00
69 B2 43 00 B0 DC 46 00 F7 32 4B 00 F7 32 4B 00 71 34 4B 00 53 5F 4B 00 19 3F 4B 00 55 4D 4D 00
73 F2 4A 00 1E 00 00 00 B0 04 02 00 3A 7B 27 85 68 A2 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00
5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 C5 84 D8 7A 4F 00 5C A5 4F 00 00 00 00 00 61 27 E7 7E
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 D5 1B 00 00 E2 43 40 00 7F 22 4E 00 53 25 4E 00
°  .  .  .  .  .  .  .  .  .  .  .  1  .  .  .  0  .  .  .  0  .  .  .  0  .  .  .  R  G  Ђ  .
\  Ґ  O  .  \  Ґ  O  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .
X  .  .  .  в  C  @  .  .  ±  C  .  i  І  C  .  °  Ь  F  .  ч  2  K  .  ч  2  K  .  q  4  K  .
S  _  K  .  .  ?  K  .  U  M  M  .  s  т  J  .  .  .  .  .  .  .  .  .  ю  C  @  .  .  ±  C  .
i  І  C  .  °  Ь  F  .  ч  2  K  .  ч  2  K  .  q  4  K  .  S  _  K  .  .  ?  K  .  U  M  M  .
s  т  J  .  .  .  .  .  °  .  .  .  :  {  '  …  h  ў  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .
\  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  Е  „  Ш  z  O  .  \  Ґ  O  .  .  .  .  .  a  '  з  ~
.  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Х  .  .  .  в  C  @  .    "  N  .  S  %  N  .

--------------------------------2012/2/24 16:32:17--------------------------------
A memory block has been leaked. The size is: 212

This block was allocated by thread 0x1B0C, and the stack trace (return addresses) at the time was:
4043E2 
4E7298 [remain.pas][remain][remain.TMainForm.FormCreate][467]
4D3B53 [Forms.pas][Forms][Forms.TCustomForm.DoCreate][3319]
4D3713 [Forms.pas][Forms][Forms.TCustomForm.Create][3189]
4DDF85 [Forms.pas][Forms][Forms.TApplication.CreateForm][9879]
4EFD31 [richedit.dpr][richeditdemo][richeditdemo.richeditdemo][18]
74FB339A [BaseThreadInitThunk]
77539EF2 [Unknown function at RtlInitializeExceptionChain]
77539EC5 [Unknown function at RtlInitializeExceptionChain]

The block is currently used for an object of class: UnicodeString

The allocation number is: 7046

Current memory dump of 256 bytes starting at pointer address 7EEC1890:
B0 04 02 00 01 00 00 00 58 00 00 00 43 00 3A 00 5C 00 55 00 73 00 65 00 72 00 73 00 5C 00 10 04
3B 04 35 04 3A 04 41 04 30 04 3D 04 34 04 40 04 5C 00 44 00 6F 00 63 00 75 00 6D 00 65 00 6E 00
74 00 73 00 5C 00 52 00 41 00 44 00 20 00 53 00 74 00 75 00 64 00 69 00 6F 00 5C 00 50 00 72 00
6F 00 6A 00 65 00 63 00 74 00 73 00 5C 00 50 00 6C 00 75 00 67 00 69 00 6E 00 73 00 5C 00 45 00
78 00 61 00 6D 00 70 00 6C 00 65 00 35 00 5C 00 50 00 6C 00 75 00 67 00 69 00 6E 00 73 00 5C 00
44 00 61 00 74 00 65 00 50 00 6C 00 75 00 67 00 69 00 6E 00 2E 00 72 00 65 00 70 00 00 00 7C 51
6A 1B 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 5C A5 4F 00 00 00 00 00 30 15 EC 7E
00 00 00 00 00 00 00 00 3C E8 40 00 00 00 00 00 87 1B 00 00 E2 43 40 00 7F 22 4E 00 53 25 4E 00
°  .  .  .  .  .  .  .  X  .  .  .  C  .  :  .  \  .  U  .  s  .  e  .  r  .  s  .  \  .  .  .
;  .  5  .  :  .  A  .  0  .  =  .  4  .  @  .  \  .  D  .  o  .  c  .  u  .  m  .  e  .  n  .
t  .  s  .  \  .  R  .  A  .  D  .     .  S  .  t  .  u  .  d  .  i  .  o  .  \  .  P  .  r  .
o  .  j  .  e  .  c  .  t  .  s  .  \  .  P  .  l  .  u  .  g  .  i  .  n  .  s  .  \  .  E  .
x  .  a  .  m  .  p  .  l  .  e  .  5  .  \  .  P  .  l  .  u  .  g  .  i  .  n  .  s  .  \  .
D  .  a  .  t  .  e  .  P  .  l  .  u  .  g  .  i  .  n  .  .  .  r  .  e  .  p  .  .  .  |  Q
j  .  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  \  Ґ  O  .  .  .  .  .  0  .  м  ~
.  .  .  .  .  .  .  .  <  и  @  .  .  .  .  .  ‡  .  .  .  в  C  @  .    "  N  .  S  %  N  .

--------------------------------2012/2/24 16:32:17--------------------------------
A memory block has been leaked. The size is: 212

This block was allocated by thread 0x1B0C, and the stack trace (return addresses) at the time was:
4043E2 
4E227F [PluginAPI\Core\PluginManager.pas][PluginManager][PluginManager.TPluginManager.LoadPlugin][259]
4E2553 [PluginAPI\Core\PluginManager.pas][PluginManager][PluginManager.TPluginManager.LoadPlugins][299]
4E7298 [remain.pas][remain][remain.TMainForm.FormCreate][467]
4D3B53 [Forms.pas][Forms][Forms.TCustomForm.DoCreate][3319]
4D3713 [Forms.pas][Forms][Forms.TCustomForm.Create][3189]
4DDF85 [Forms.pas][Forms][Forms.TApplication.CreateForm][9879]
4EFD31 [richedit.dpr][richeditdemo][richeditdemo.richeditdemo][18]
74FB339A [BaseThreadInitThunk]
77539EF2 [Unknown function at RtlInitializeExceptionChain]
77539EC5 [Unknown function at RtlInitializeExceptionChain]

The block is currently used for an object of class: TPlugin

The allocation number is: 7047

Current memory dump of 256 bytes starting at pointer address 7EEC19F0:
EC 1D 4E 00 01 00 00 00 78 1C 40 00 3F 54 50 6C 75 67 69 6E 28 44 61 74 65 50 6C 75 67 69 6E 2E
72 65 70 29 3A 20 7B 36 44 43 32 34 34 35 31 2D 36 43 37 33 2D 34 37 45 35 2D 39 33 39 37 2D 42
42 37 34 39 38 46 36 38 36 42 44 7D 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 BC 09 4E 00 F0 F2 F6 7E 9C 18 EC 7E 00 00 C9 02
38 B1 D3 02 94 B1 D3 02 FF FF FF FF 00 00 00 00 51 44 C2 6D 73 6C E5 47 93 97 BB 74 98 F6 86 BD
7C 98 E6 7E 9C 28 E7 7E 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 70 1C 4E 00 80 1C 4E 00
94 1C 4E 00 A4 1C 4E 00 B4 1C 4E 00 00 00 00 00 37 08 CE 1A 5C A5 4F 00 00 00 00 00 D1 25 EC 7E
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 9A 1B 00 00 E2 43 40 00 24 8B 4E 00 A8 AC D3 02
м  .  N  .  .  .  .  .  x  .  @  .  ?  T  P  l  u  g  i  n  (  D  a  t  e  P  l  u  g  i  n  .
r  e  p  )  :     {  6  D  C  2  4  4  5  1  -  6  C  7  3  -  4  7  E  5  -  9  3  9  7  -  B
B  7  4  9  8  F  6  8  6  B  D  }  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .
.  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  ј  .  N  .  р  т  ц  ~  њ  .  м  ~  .  .  Й  .
8  ±  У  .  ”  ±  У  .  я  я  я  я  .  .  .  .  Q  D  В  m  s  l  е  G  “  —  »  t  ˜  ц  †  Ѕ
|  ˜  ж  ~  њ  (  з  ~  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  p  .  N  .  Ђ  .  N  .
”  .  N  .  ¤  .  N  .  ґ  .  N  .  .  .  .  .  7  .  О  .  \  Ґ  O  .  .  .  .  .  С  %  м  ~
.  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  љ  .  .  .  в  C  @  .  $  ‹  N  .  Ё  ¬  У  .

--------------------------------2012/2/24 16:32:17--------------------------------
This application has leaked memory. The small block leaks are (excluding expected leaks registered by pointer):

21 - 36 bytes: UnicodeString x 1
37 - 52 bytes: UnicodeString x 1
181 - 212 bytes: TPlugin x 1, UnicodeString x 1

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".
Как мы видим, у нас утекло 3 строки и 1 объект класса TPlugin.

Как правило, более легковесные типы в логе являются "наведёнными" утечками. Т.е. если у вас есть объект с тремя строковыми полями и этот объект утёк, то в утечках появится отчёт о 4-х утечках: объекте и трёх строках. Даже хотя строки сами по себе не утекают: если вы устраните утечку объекта, то это также устранит и "утечку" строк.

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

В нашем случае таковым у нас выступает объект класса TPlugin. Уже только эта информация сообщает нам первый кусок головоломки: теперь мы знаем, что у нас где-то осталась ссылка на объект типа TPlugin. В случае если утёк не класс и не строка, вы можете определить эту часть по стеку вызова в лог-файле. В нашем случае стек выглядит так:
4E227F [PluginAPI\Core\PluginManager.pas][PluginManager][PluginManager.TPluginManager.LoadPlugin][259]
4E2553 [PluginAPI\Core\PluginManager.pas][PluginManager][PluginManager.TPluginManager.LoadPlugins][299]
4E7298 [remain.pas][remain][remain.TMainForm.FormCreate][467]
4D3B53 [Forms.pas][Forms][Forms.TCustomForm.DoCreate][3319]
4D3713 [Forms.pas][Forms][Forms.TCustomForm.Create][3189]
4DDF85 [Forms.pas][Forms][Forms.TApplication.CreateForm][9879]
4EFD31 [richedit.dpr][richeditdemo][richeditdemo.richeditdemo][18]
74FB339A [BaseThreadInitThunk]
Где строка 259 в LoadPlugin выглядит так:
Result := TPlugin.Create(Self, AFileName);
В нашем случае это снова подтверждает нам, что у нас утёк управляющий плагином объект. В других случаях анализ стека даст вам понятие о типе утёкших данных.

Итак, сейчас мы получили представление о том, какого рода данные у нас утекли. Теперь нам нужно их идентифицировать - ведь этих данных у нас в программе много: кто именно их них утёк? Конкретно в нашем случае у нас есть несколько TPlugin - по одному на каждый плагин.

Чтобы идентифицировать объект мы можем посмотреть на дамп памяти объекта:
Current memory dump of 256 bytes starting at pointer address 7EEC19F0:
EC 1D 4E 00 01 00 00 00 78 1C 40 00 3F 54 50 6C 75 67 69 6E 28 44 61 74 65 50 6C 75 67 69 6E 2E
72 65 70 29 3A 20 7B 36 44 43 32 34 34 35 31 2D 36 43 37 33 2D 34 37 45 35 2D 39 33 39 37 2D 42
42 37 34 39 38 46 36 38 36 42 44 7D 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 BC 09 4E 00 F0 F2 F6 7E 9C 18 EC 7E 00 00 C9 02
38 B1 D3 02 94 B1 D3 02 FF FF FF FF 00 00 00 00 51 44 C2 6D 73 6C E5 47 93 97 BB 74 98 F6 86 BD
7C 98 E6 7E 9C 28 E7 7E 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 70 1C 4E 00 80 1C 4E 00
94 1C 4E 00 A4 1C 4E 00 B4 1C 4E 00 00 00 00 00 37 08 CE 1A 5C A5 4F 00 00 00 00 00 D1 25 EC 7E
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 9A 1B 00 00 E2 43 40 00 24 8B 4E 00 A8 AC D3 02
м  .  N  .  .  .  .  .  x  .  @  .  ?  T  P  l  u  g  i  n  (  D  a  t  e  P  l  u  g  i  n  .
r  e  p  )  :     {  6  D  C  2  4  4  5  1  -  6  C  7  3  -  4  7  E  5  -  9  3  9  7  -  B
B  7  4  9  8  F  6  8  6  B  D  }  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .
.  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  ј  .  N  .  р  т  ц  ~  њ  .  м  ~  .  .  Й  .
8  ±  У  .  ”  ±  У  .  я  я  я  я  .  .  .  .  Q  D  В  m  s  l  е  G  “  —  »  t  ˜  ц  †  Ѕ
|  ˜  ж  ~  њ  (  з  ~  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  p  .  N  .  Ђ  .  N  .
”  .  N  .  ¤  .  N  .  ґ  .  N  .  .  .  .  .  7  .  О  .  \  Ґ  O  .  .  .  .  .  С  %  м  ~
.  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  љ  .  .  .  в  C  @  .  $  ‹  N  .  Ё  ¬  У  .
Достаточно легко увидеть в нём такой блок:
...                                    T  P  l  u  g  i  n  (  D  a  t  e  P  l  u  g  i  n  .
r  e  p  )  :     {  6  D  C  2  4  4  5  1  -  6  C  7  3  -  4  7  E  5  -  9  3  9  7  -  B
B  7  4  9  8  F  6  8  6  B  D  }                                                         ...
Или:
TPlugin(DatePlugin.rep): {6DC24451-6C73-47E5-9397-BB7498F686BD}
Вот теперь совершенно очевидно, что у нас проблема с плагином DatePlugin.rep (ну, в данном случае он просто оказался загруженным первым).

Откуда в дампе взялась эта строка? А вот откуда:
constructor TPlugin.Create(const APluginManger: TPluginManager;
  const AFileName: String);
...
begin
  OutputDebugString(PChar('TPlugin.Create: ' + ExtractFileName(AFileName)));
  SetName(Format('TPlugin(%s)', [ExtractFileName(AFileName)]));
  inherited Create;

  ...
  FID := FPlugin.ID;
  ...

  SetName(Format('TPlugin(%s): %s', [ExtractFileName(AFileName), GUIDToString(FID)]));
end;
Т.е. это отладочное имя, которое мы ввели выше в TCheckedInterfacedObject. Сейчас вам должно быть уже понятно, почему в качестве типа данных для FName была выбрана короткая строка: потому что она статическая - т.е. размещается непосредственно в поле данных. Если бы мы использовали бы динамические строки (String, AnsiString, WideString, UnicodeString, PChar, PAnsiChar, PWideChar), то вместо содержания строки мы бы увидели в дампе адрес - указатель на данные строки, которые в дамп как раз не попали бы. Не очень полезно, да. Это же объясняет и вызов FillChar сделано это было, чтобы было удобнее читать строку в дампе. Удобно, когда неиспользуемые данные обнулены, а не заполнены мусором, который мы по ошибке можем принять за строку.

Итак, с помощью подсказки FastMM (имя класса), стека вызовов и дампа памяти мы сумели точно идентифицировать проблемный объект: это управляющий класс-обёртка TPlugin, созданный для плагина DatePlugin.rep.

Хорошо, шаг первый (идентификация ресурса) мы сделали. Теперь шаг два - найти место предполагаемого удаления и выяснить, почему это там не произошло удаления.

Вот тут начинаются более интересные вещи. Если бы у нас был объект или любой другой управляемый вручную тип данных, шаг два был бы тривиален:
O := TSomeObject.Create;
...
FreeAndNil(O);
Всё, что нам нужно было бы сделать - остановиться на строке "O := TSomeObject.Create" и протрассировать программу по шагам до строки "FreeAndNil(O)", следя не теряем ли мы в пути ссылку и доходим ли мы вообще до строки удаления. Всё.

Но поскольку мы имеем интерфейс (авто-управляемый тип данных), то шаг два будет выглядеть существенно сложнее. Не, место предполагаемого удаления найти не проблема - очевидно, что это вызов _Release: ведь объект должен удаляться, когда счётчик ссылок опускается до 0. Вызов деструктора происходит именно внутри _Release. А проблема тут в том, что _Release вызывается не только для удаления объекта, но и вообще весьма даже часто - для учёта ссылок. Более того, сам его вызов не имеет значения - важно, насколько парным он будет для вызова _AddRef. Иными словами, в правильной ситуации у нас должно быть равное число вызовов _AddRef и _Release (на каждый _AddRef приходится вызов _Release). В неправильной (есть утечка) - один из вызовов _Release был пропущен. Ситуация с испорченной ссылкой на интерфейс теоретически возможна, но крайне маловероятна - в таком случае мы в 99.99% случаев получим Access Violation в первый же момент вызова _Release для испорченной ссылки.

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

Для новых Delphi сделать это достаточно просто:
  1. Продублируйте метод TInterfacedObject._Release в TCheckedInterfacedObject._Release.
  2. Установите на TCheckedInterfacedObject._AddRef и TCheckedInterfacedObject._Release две non-breaking точки останова, указав обеим условие "Self.FName = 'TPlugin(DatePlugin.rep): {6DC24451-6C73-47E5-9397-BB7498F686BD}'" (без кавычек, естественно), введя выражение для логгинования "Self.FRefCount" (и снова - без кавычек), а также включив логгирование стека вызовов (можно полностью, а можно только N записей, где N - по вкусу).
  3. Отключите запись всех событий, кроме Breakpoint Messages (и, по вкусу, Output Messages).
  4. Запустите программу, а после её выполнения сохраните содержимое окна Events в лог-файл.
В результате вы получите примерно такой лог (показан полностью, но подредактирован для улучшения читабельности):
Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.LoadPlugins(???,'.rep')
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 2

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.LoadPlugins(???,'.rep')
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 3

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.LoadPlugin('C:\Users\Александр\Documents\RAD Studio\Projects\Plugins\Example5\Plugins\ExportRTF.rep')
    PluginManager.TPluginManager.LoadPlugins(???,'.rep')
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 4

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    remain.TMainForm.BuildFilterList
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 3

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.GetItem(???)
    remain.BuildFilter((1820700819, 31407, 20208, (185, 142, 217, 219, 222, 149, 7, 24)))
    remain.TMainForm.BuildFilterList
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 4

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    remain.TMainForm.BuildFilterList
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 3

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.GetItem(???)
    remain.BuildFilter((155353976, 13498, 17190, (133, 80, 191, 28, 167, 47, 223, 83)))
    remain.TMainForm.BuildFilterList
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 4

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 3

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.GetItem(???)
    PluginManager.TPluginManager.DoLoaded
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 4

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 3

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 4

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 5

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo

Breakpoint Expression Self.FRefCount: 6

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.GetItem(???)
    remain.PopulatePlugins
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 7

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.GetItem(???)
    remain.PopulatePlugins
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 6

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.GetItem(???)
    remain.PopulatePlugins
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 5

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfCopy(???,???)
    PluginManager.TPluginManager.GetItem(???)
    remain.PopulatePlugins
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 4

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 3

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    remain.TMainForm.FormCreate(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 4

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfClear(???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 5

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.TObject.GetInterface((3037498046, 41226, 19417, (170, 23, 3, 17, 50, 111, 225, 166)),(no value))
    System.TInterfacedObject.QueryInterface((3037498046, 41226, 19417, (170, 23, 3, 17, 50, 111, 225, 166)),(no value))
    PluginManager.TPlugin.QueryInterface((3037498046, 41226, 19417, (170, 23, 3, 17, 50, 111, 225, 166)),(no value))
    SysUtils.Supports(Pointer($7EEC1AB8) as IInterface,(3037498046, 41226, 19417, (170, 23, 3, 17, 50, 111, 225, 166)),(no value))
    PluginManager.NotifyRelease
    PluginManager.TPluginManager.UnloadAll
    remain.TMainForm.FormDestroy(???)
    Forms.TCustomForm.DoDestroy
    Forms.TCustomForm.Destroy
    Classes.TComponent.DestroyComponents
    Forms.DoneApplication
    SysUtils.DoExitProc
    System.@Halt0
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 4

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfClear(???)
    PluginManager.NotifyRelease
    PluginManager.TPluginManager.UnloadAll
    remain.TMainForm.FormDestroy(???)
    Forms.TCustomForm.DoDestroy
    Forms.TCustomForm.Destroy
    Classes.TComponent.DestroyComponents
    Forms.DoneApplication
    SysUtils.DoExitProc
    System.@Halt0
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 5

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    PluginManager.TPlugin.ReleasePlugin
    PluginManager.TPlugin.Delete
    PluginManager.NotifyRelease
    PluginManager.TPluginManager.UnloadAll
    remain.TMainForm.FormDestroy(???)
    Forms.TCustomForm.DoDestroy
    Forms.TCustomForm.Destroy
    Classes.TComponent.DestroyComponents
    Forms.DoneApplication
    SysUtils.DoExitProc
    System.@Halt0
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 4

Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfClear(???)
    PluginManager.TPluginManager.UnloadAll
    remain.TMainForm.FormDestroy(???)
    Forms.TCustomForm.DoDestroy
    Forms.TCustomForm.Destroy
    Classes.TComponent.DestroyComponents
    Forms.DoneApplication
    SysUtils.DoExitProc
    System.@Halt0
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 3

Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfClear(???)
    remain.TMainForm.FormDestroy(???)
    Forms.TCustomForm.DoDestroy
    Forms.TCustomForm.Destroy
    Classes.TComponent.DestroyComponents
    Forms.DoneApplication
    SysUtils.DoExitProc
    System.@Halt0
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 2
Во-первых, мы видим, что первая запись в логе начинается с числа 2 - это означает, что было сделано два вызова _AddRef, которые не попали в лог - т.е. они были вызваны до установки отладочного имени объекта. Хорошо, учтём это.

Теперь считаем, сколько в логе есть вызовов. В этом логе есть 12 (+ 2) вызовов _AddRef и 13 вызовов _Release. Т.е. 14 вызовов _AddRef и 13 вызовов _Release. Одного вызова _Release не хватает. Это же подтверждается счётчиком ссылок: он равен 2 в последней записи лога. Поскольку точка останова стоит на начало метода, то это значение - до выполнения метода _Release. После его выполнения 2 станет 1. И никаких больше вызовов у нас нет. Т.е. финальное значение счётчика ссылок - 1. Это также подтверждается дампом памяти от FastMM, где счётчик ссылок лежит первым полем:
1C 1E 4E 00 01 00 00 00 ...
Здесь 1C 1E 4E 00 - развёрнутый указатель на VMT объекта (т.е. $004E1E1C), а 01 00 00 00 - первое поле (т.е. $00000001 или просто 1).

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

К примеру, возьмём первые три записи в логе. Мы видим два _AddRef и один _Release. Все они вызваны из системной функции копирования интерфейсов (IntfCopy), которая в свою очередь вызвана из TPluginManager.LoadPlugins. Отсюда следует, что в TPluginManager.LoadPlugins была сделана копия ссылки на интерфейс. Но если вы посмотрите на исходный код TPluginManager.LoadPlugins, то увидите, что там нет таких мест. Зато там есть вызов TPluginManager.LoadPlugin. Мы делаем предположение, что вызов TPluginManager.LoadPlugin не попал в стек вызова. Такое бывает. Посмотрим, согласуется ли это с нашим логом. В TPluginManager.LoadPlugin есть два места по увеличению ссылок:
  1. Запись ссылки в Result:
    Result := TPlugin.Create(Self, AFileName);
  2. Запись ссылки в FItems:
    FItems[FCount] := Result;
Уменьшения ссылки нет. Но зато оно есть в вызвавшей нас функции TPluginManager.LoadPlugins:
LoadPlugin(Path + SR.Name); // LoadPlugin - функция, возвращает IPlugin
Итак, вроде всё согласуется. Будем считать, что наша гипотеза верна и мы реконструировали ситуацию: LoadPlugin создаёт плагин, записывает ссылку в Result, который затем будет освобождёт в LoadPlugins как ненужный. Кроме того, эта ссылка записывается в массив FItems. Итого: +2 -1 - и у нас осталась ссылка в FItems.

Если вы не можете высказать гипотезу или проверить её - просто включите остановку на точках останова и задайте условия так, чтобы остановиться в нужный момент (на нужной записи). Например, задав Pass Count. После этого выполните пошаговую трассировку, поднимаясь из вызова _AddRef или _Release к вызывающему - это даст вам полную картину происходящего.

Так где же должна удаляться ссылка на объект в FItems? FItems удаляется в UnloadAll. Есть у нас такой вызов? Есть:
Breakpoint Call Stack:
    Helpers.TCheckedInterfacedObject._Release
    PluginManager.TPlugin._Release
    System.@IntfClear(???)
    PluginManager.TPluginManager.UnloadAll
    remain.TMainForm.FormDestroy(???)
    Forms.TCustomForm.DoDestroy
    Forms.TCustomForm.Destroy
    Classes.TComponent.DestroyComponents
    Forms.DoneApplication
    SysUtils.DoExitProc
    System.@Halt0
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 3
Окей, +2 -2. Т.е. мы можем вычеркнуть из лога эти 4 блока. Останется 10 (+2) _AddRef и 11 _Release.

Смотрим дальше. После вычёркивания первые 4 вызова в новом (подправленном) логе являются парными (происходят в BuildFilterList/BuildFilter: +2 -2 - и потому тоже могут быть вычеркнуты. Остаётся 8 (+2) вызовов _AddRef и 9 вызовов _Release.

Следующие два вызова - снова парные. На этот раз - внутри DoLoaded. Вычёркиваем. Осталось 7 (+2) и 8.

Дальше идёт 4 вызова _AddRef подряд и 4 вызова _Release подряд. Хотя стек для _AddRef снова не полон, но стек для _Release даёт нам подсказку, что это происходит в PopulatePlugins. И, действительно:
  procedure PopulatePlugins;
  var
    X: Integer;
    MI: TMenuItem;
  begin
    for X := 0 to Plugins.Count - 1 do
    begin
      MI := TMenuItem.Create(MainMenu);
      MI.Caption := Format('%d: %s', [Plugins[X].Index, Plugins[X].Name]); // раз и два
      MI.Hint := Format('ID: %s', [GUIDToString(Plugins[X].ID)]); // три
      MI.Tag := Plugins[X].Index; // четыре
      MI.OnClick := PluginClick;
      miLoaded.Add(MI);
    end;
  end;
Получается, что все вызовы - парные и, значит, могут быть вычеркнуты. Останется 5 (+2) и 6.

Следующий вызов - как раз проблемный:
Breakpoint Call Stack: 
    Helpers.TCheckedInterfacedObject._AddRef
    PluginManager.TPlugin._AddRef
    System.@IntfCopy(???,???)
    Forms.TCustomForm.DoCreate
    Forms.TCustomForm.Create(???)
    Forms.TApplication.CreateForm(???,(no value))
    richeditdemo.richeditdemo
Breakpoint Expression Self.FRefCount: 3
Для этого вызова нам не удастся найти не то что пару, а даже место в коде, где мы предполагаем очистить ссылку - что и будет говорить о баге в этом месте.

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

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

Если же у вас старые версии Delphi, то вы не сможете построить вышеуказанный лог. В этом случае просто сделайте точки останова обычными (Break). Запустите программу - и следите за стеком вызовов в окне Call Stack каждый раз, когда вы останавливаетесь на точках останова. Ищите парные вызовы, игнорируйте их, а то, что останется (не парные) - это и будет проблемой.

Вот, собственно, и всё.

Замечу только, что вместо того, чтобы искать утечки памяти "в лоб" - можно попробовать решить проблему пересмотром кода. Иногда это может быть быстрее, иногда - нет. Просто просмотрите свой код на предмет сохраняемых в поля объекта интерфейсный ссылок. Убедитесь, что каждая такая ссылка обнуляется в уведомлении от IDestroyNotify. Если нет - добавьте.

Access Violation

Итак, Access Violation. Самая популярная ошибка в Delphi программах (и не только Delphi программах). Причина для неё всего одна - вы обратились к памяти, которая недоступна. Но происходить это может из-за совершенно разных ошибок.

Во-первых, я замечу, что если вы получаете в программе исключение Access Violation, то в первую очередь вам нужно:
  • Включить отладочные опции (в первую очередь - Range Check Errors и Assertions).
  • Просмотреть код и убедиться, что вы не нарушаете правила.
  • Убедиться, что в коде нет проблем с временем жизни (см. пункт 2 в этой статье).
Так что ниже я буду предполагать, что ваша проблема НЕ вызвана одним из вышеуказанных пунктов. Эти случаи были уже более чем подробно описаны, поэтому я не буду дублировать их здесь ещё раз. Кроме того, я не буду описывать общие методы решения проблем, поскольку они так же описаны в отдельной статье. Ниже я приведу только несколько конкретных примеров, специфичных именно для нашей схемы плагинов.

Неверные сигнатуры

Одна из проблем, специфичная для разработки библиотек - несовпадение сигнатур (прототипов) функций. Например:
// DLL:
function DoSomething(A: Integer): Integer; stdcall;

// .exe:
var
  DoSomething: function(A: Integer): Integer;
Как видите, функция объявлена по-разному в DLL и .exe (во втором случае пропущен stdcall). Несложно сообразить, что попытка вызвать функцию приведёт к всяческим плохим вещам (хотя и не всегда).

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

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

Передача данных

Вторая проблема, специфичная для библиотек - неверное управление памятью. Я подробно пояснял это в отдельной статье. В DLL и в .exe есть свои собственные менеджеры памяти. Это - два разных, раздельных менеджера памяти. Поэтому, память, выделенную в одном исполняемом модуле (DLL/.exe), нельзя освободить в другом модуле (.exe/DLL) и наоборот. А если вы попытаетесь это сделать - вы испортите состояние менеджера памяти, что приведёт к Access Violation, вылетам, порче данных или зависанию программы.

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

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

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

В принципе, менеджер памяти в отладочном режиме должен помочь решить эту проблему. При попытке сделать что-то глупое вроде удаления блока памяти из чужого исполняемого модуля он немедленно возбудит ошибку типа Invalid Pointer Operation, так что вы сможете найти источник проблемы.

Многопоточность

Следующая проблема не специфична для библиотек, да и в нашей системе мы пока с ней не сталкивались. Тем не менее, я упомяну её тут отдельным пунктом, потому что тут есть один не совсем очевидный момент.

Итак, если в вашей программе используется несколько потоков и вы получаете совершенно случайные Access Violation в разных, не связанных местах, то ошибки организации многопоточности - первые подозреваемые. При этом проблемы происходят из-за ошибок в синхронизации доступа к общим ресурсам (не обязательно глобальным). Частным случаем этих проблем является неверно установленный режим работы менеджера памяти.

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

Менеджер памяти переводится в многопоточный режим работы (в котором он выполняет дополнительную синхронизацию доступа) использованием TThread, BeginThread или вручную - через IsMultiThreaded.

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

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

Итак, решение проблемы: просто вставьте IsMultiThreaded := True в код инициализации программы и/или плагинов. Чем раньше он будет выполняться - тем лучше.

Обработка ошибок в плагинах

Фух, я сказал УЖАСНО МНОГО всего про отладку плагинов и ядра. Но отладка - это лишь часть методов поиска проблем. Было бы неплохо, если бы сама система помогала бы нам в поиске причин ошибок. Особенно это справедливо для случаев, когда отладку применить не получится. Для этого нам необходимо, чтобы система сообщала нам об ошибках, приводя при этом максимум деталей.

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

Safecall

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

Итак, в 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;
Здесь мы самым нахальным образом нарушаем требование о том, что метод обязан ловить все исключения.

Но из-за того, что метод помечен как 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.

Стандартные средства

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

Обработка SafeCall-исключения модулем System
Пока это не очень похоже на "помогать с идентификацией ошибки", как мы бы хотели. Но, в конце концов, это пока всего-лишь модуль 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, который передаётся с использованием функций SetErrorInfo/GetErrorInfo.

Итак, с учётом сказанного, мы можем дописать наш тестовый пример так:
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 объекта в нашей реализации никуда не сохраняется.

Дополнение стандартных средств

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

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

Актуальные коды исключений

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

Сразу замечу, что особняком здесь будут стоять системные коды ошибок. В Delphi они представлены EOSError.

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

Напомню, что для "своих" кодов в HRESULT предназначен "поставщик" FACILITY_ITF. Эти коды выделяются программистом, а не системой. Причём эти коды зависят от компонента. Т.е. у вас может быть два класса с одинаковыми кодами исключений. А разными их делает тот факт, что возвращаются они разными классами. Сам источник ошибки идентифицируется по GUID. Для системных кодов ошибок используется нулевой GUID. А под непосредственно код ошибки отводится 16 бит (Word).

16 бит - этого достаточно, чтобы разместить в них CRC-код (CRC16). Откуда следует простой алгоритм создания кодов исключений: просто возьмите CRC от имени класса исключения и сохраните полученное значение в HRESULT с FACILITY_ITF.

Итак, давайте напишем функцию получения кода по исключению:
function HResultFromException(const E: Exception): HRESULT;
begin
  // Базовый класс Exception - кода стандартен
  if E.ClassType = Exception.ClassType then
    Result := E_UNEXPECTED 
  else
  // Для EOleSysError у нас уже есть код
  if E is EOleSysError then
    Result := EOleSysError(E).ErrorCode
  else
  // Для EOSError код можно получить
  if E is EOSError then
    Result := HResultFromWin32(EOSError(E).ErrorCode)
  else
  // Для всех прочих - строим код сами
    Result := MakeResult(SEVERITY_ERROR, FACILITY_ITF, CalcCRC16(E.ClassName));
end;
Тогда, базовый класс для всех исключений в системе плагинов будет выглядеть так:
type
  EBaseException = class(EOleSysError)
  private
    function GetDefaultCode: HRESULT;
  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;
  end;

...

{ EBaseException }

function EBaseException.GetDefaultCode: HRESULT;
begin
  Result := MakeResult(SEVERITY_ERROR, FACILITY_ITF, CalcCRC16(ClassName));
end;

constructor EBaseException.Create(const Msg: string);
begin
  inherited Create(Msg, GetDefaultCode, 0);
end;

constructor EBaseException.CreateFmt(const Msg: string; const Args: array of const);
begin
  inherited Create(Format(Msg, Args), GetDefaultCode, 0);
end;

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

Теперь нам нужно во всех наших исходниках заменить строчку "class(Exception)" на "class(EBaseException)", например:
ECheckedInterfacedObjectError = class(EBaseException);
Хотя, специальных классов у нас пока что никаких и нет - только стандартные.

Ладно, в любом случае нам осталось добавить код, строящий коды для произвольных классов исключений. Делается это так:
const
  // GUID, обозначающий "нас"
  GUID_DefaultErrorSource: TGUID = '{EFA9AA52-4A4E-4007-85D8-5F46CB65C426}';

type
  TCheckedInterfacedObject = class(TInterfacedObject, IInterface)
  private
    ...
  protected
    ...
  public
    ...
    function SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer): HResult; override;
  end;

...

function TCheckedInterfacedObject.SafeCallException(ExceptObject: TObject;
  ExceptAddr: Pointer): HResult;
var
  E: TObject;
  CreateError: ICreateErrorInfo;
  ErrorInfo: IErrorInfo;
  Source: WideString;
begin
  // Значение по умолчанию
  Result := E_UNEXPECTED;

  // Получаем информацию
  E := ExceptObject;
  if Succeeded(CreateErrorInfo(CreateError)) then
  begin
    // Источник - ProgID приложения или класса, вызвавшего ошибку
    Source := 'pluginsystem.' + ClassName;
    CreateError.SetSource(PWideChar(Source));

    // Дополнительные данные для исключений
    if E is Exception then
    begin
      // Сообщение
      CreateError.SetDescription(PWideChar(WideString(Exception(E).Message)));

      // ИД темы в справке
      CreateError.SetHelpContext(Exception(E).HelpContext);

      // Путь к справке никак не задаётся, но при желании вы можете сделать:
      // CreateError.SetHelpFile(PWideChar(WideString(Application.HelpFile)));

      // Код ошибки
      Result := HResultFromException(Exception(E));
    end;

    // Для наших ошибок - указываем GUID источника, для всех прочих - пустой
    if HResultFacility(Result) = FACILITY_ITF then
      CreateError.SetGUID(GUID_DefaultErrorSource)
    else
      CreateError.SetGUID(GUID_NULL);

    // Устанавливаем настроенные дополнительные данные
    if CreateError.QueryInterface(IErrorInfo, ErrorInfo) = S_OK then
      SetErrorInfo(0, ErrorInfo);
  end;
end;
Поскольку мы не используем COM, то ProgID и GUID не имеют большого смысла - тем не менее, мы устанавливаем в них значения "похожие на правду". Быть может, они пригодятся вызывающей стороне.

Тогда, если у нас в плагине возбуждается какое-то исключение, то:
// Плагин:
raise ECheckedInterfacedObjectDeleteError.Create('Error Message');
// Ядро:
try
  Plugin[X].DoSomething;
except
  on E: Exception do
  begin
    if E is EOleException then
      Application.MessageBox(PChar(Format(
          'Класс: %s' + sLineBreak +
          'Сообщение: %s' + sLineBreak +
          'Код: %s' + sLineBreak +
          'Источник (GUID): %s' + sLineBreak +
          'Источник (ProgID): %s' + sLineBreak +
          'Файл справки: %s' + sLineBreak +
          'Номер темы: %d',
         [E.ClassName,
          E.Message,
          IntToHex(EOleException(E).ErrorCode, 8),
          'не сохраняется в EOleException',
          EOleException(E).Source,
          EOleException(E).HelpFile,
          EOleException(E).HelpContext])),
        'Исключение', MB_OK or MB_ICONERROR)
    else
      raise;
  end; 
end;

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

Чтобы облегчить работу для программистов на других языках, вы можете выписать в заголовочники коды исключений для основных классов. Для этого, во-первых, создайте такую вспомогательную программу:
procedure TForm1.Button1Click(Sender: TObject);
begin
  Label1.Caption := IntToHex(MakeResult(SEVERITY_ERROR, FACILITY_ITF, CalcCRC16(Edit1.Text)), 8);
  Clipboard.AsText := Label1.Caption;
end;
Запустите её и вводите в Edit имена классов исключений. А в буфере обмена (и в метке) вы получите их код. Останется только записать это в заголовочник, например:
const
  E_CheckedInterfacedObjectError            = HRESULT($80044383);
  E_CheckedInterfacedObjectDeleteError      = HRESULT($80045A95);
  E_CheckedInterfacedObjectDoubleFreeError  = HRESULT($80048672);
  E_CheckedInterfacedObjectUseDeletedError  = HRESULT($8004D50D);
Заметьте, что классы исключений и TCheckedInterfacedObject - это вещи, специфичные именно для Delphi. Ими могут воспользоваться программисты на Delphi. Все прочие же их использовать, понятно, не смогут. Но благодаря тому, что мы использовали языко-независимый подход - они смогут работать в рамках нашей системы. В частности, вместо классов исключений и вспомогательной обёртки TCheckedInterfacedObject программисты на других языках будут использовать коды ошибок в стиле COM (IErrorInfo/GetErrorInfo/SetErrorInfo/HRESULT) - вот и всё. Просто мы сделали удобнее жизнь программистов Delphi.

Примечание: по этой причине в заголовочниках на самом деле вовсе не нужны коды для EChecked-исключений. Это - наши, внутренние исключения, они не имеют смысла для вызывающего. Я показал их только для примера. У нас, пока, нет никаких своих классов исключений, которые имели бы специальный смысл для вызывающего. Вот когда в будущем такие исключения появятся - вот тогда в заголовочники нужно будет вписать коды для них - на манер того, как я это только что показал.

Использование оригинальных классов исключений

Теперь, когда у нас есть уникальные коды ошибок, сделать передачу классов исключений становится очень просто:
type
  OleSysErorClass = class of EOleSysError;
  OleExceptionClass = class of EOleException;

// Наш обработчик safecall-ошибок
procedure CustomSafeCallError(ErrorCode: HResult; ErrorAddr: Pointer);

  // Строит исключение по коду и дополнительной информации
  function CreateExceptionFromCode(ACode: HRESULT): Exception;
  var
    ExceptionClass: ExceptClass;
    ErrorInfo: IErrorInfo;
    Source, Description, HelpFile: WideString;
    HelpContext: Longint;
  begin
    // Определяем класс:
    // а). системные ошибки
    if HResultFacility(ACode) = FACILITY_WIN32 then
      ExceptionClass := EOSError
    else
    // б). наши ошибки
    case HRESULT(ErrorCode) of
      E_CheckedInterfacedObjectError:           ExceptionClass := ECheckedInterfacedObjectError;
      E_CheckedInterfacedObjectDeleteError:     ExceptionClass := ECheckedInterfacedObjectDeleteError;
      E_CheckedInterfacedObjectDoubleFreeError: ExceptionClass := ECheckedInterfacedObjectDoubleFreeError;
      E_CheckedInterfacedObjectUseDeletedError: ExceptionClass := ECheckedInterfacedObjectUseDeletedError;
    else
    // в). все прочие - общий класс
      ExceptionClass := EOleException;
    end;

    // Получаем дополнительную информацию
    if GetErrorInfo(0, ErrorInfo) = S_OK then
    begin
      ErrorInfo.GetSource(Source);
      ErrorInfo.GetDescription(Description);
      ErrorInfo.GetHelpFile(HelpFile);
      ErrorInfo.GetHelpContext(HelpContext);
    end
    else
    begin
      Source := '';
      Description := '';
      HelpFile := '';
      HelpContext := 0;
    end;

    // Создаём объект исключения с информацией
    if ExceptionClass.InheritsFrom(EOleException) then
      Result := OleExceptionClass(ExceptionClass).Create(Description, ACode, Source, HelpFile, HelpContext)
    else
    if ExceptionClass.InheritsFrom(EOleSysError) then
      Result := OleSysErorClass(ExceptionClass).Create(Description, ACode, HelpContext)
    else
    begin
      Result := ExceptionClass.Create(Description);
      if Result is EOSError then
        EOSError(Result).ErrorCode := HResultCode(ACode);
    end;
  end;

var
  E: Exception;
begin
  E := CreateExceptionFromCode(HRESULT(ErrorCode));
  raise E at ErrorAddr;
end;

// Установка и снятие обработчика
initialization
  SafeCallErrorProc := CustomSafeCallError;
finalization
  SafeCallErrorProc := nil;
end.
И снова: я использовал EChecked-исключения только ради примера. Вам нужно будет помещать в этот список коды и классы исключений, участвующих в вашей системе.

Теперь, предыдущий пример даст нам:
// Плагин:
raise ECheckedInterfacedObjectDeleteError.Create('Error Message');
// Ядро:
try
  Plugin[X].DoSomething;
except
  on E: Exception do
  begin
    if E is EOleException then
      Application.MessageBox(PChar(Format(
          'Класс: %s' + sLineBreak +
          'Сообщение: %s' + sLineBreak +
          'Код: %s' + sLineBreak +
          'Источник (GUID): %s' + sLineBreak +
          'Источник (ProgID): %s' + sLineBreak +
          'Файл справки: %s' + sLineBreak +
          'Номер темы: %d',
         [E.ClassName,
          E.Message,
          IntToHex(EOleException(E).ErrorCode, 8),
          'не сохраняется в EOleException',
          EOleException(E).Source,
          EOleException(E).HelpFile,
          EOleException(E).HelpContext])),
        'Исключение', MB_OK or MB_ICONERROR)
    else
    if E is EOleSysError then
      Application.MessageBox(PChar(Format(
          'Класс: %s' + sLineBreak +
          'Сообщение: %s' + sLineBreak +
          'Код: %s',
         [E.ClassName,
          E.Message,
          IntToHex(EOleSysError(E).ErrorCode, 8)])),
        'Исключение', MB_OK or MB_ICONERROR)
    else
      raise;
  end; 
end;

Исключение с custom-классом, как оно видно вызывающему
Во, мы сумели передать исходное исключение через границу модуля без изменений! Сообщение - сохранилось, код - сохранился, класс - сохранился. Класс!

Конечно же, набор классов, которые передаются "прозрачно" жёстко зафиксировано в нашем обработчике - это классы из case. Но это не является проблемой. Когда вы пишете программу и хотите обрабатывать какой-то класс - вы всегда можете добавить его в case. А если почему-то вам лень это делать - вы всегда сможете обработать исключение как EOleException.

Наследование

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

Дополнительная информация

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

К сожалению, в стандартном механизме нет поля, предназначенного для дополнительной информации. У вас есть три варианта:
  1. Не передавать дополнительную информацию
  2. Реализовать свой аналог SetErrorInfo/GetErrorInfo
  3. Упаковывать информацию в "свободное" поле - Source
У меня нет готового совета, как тут лучше поступать. Лично я пока что следовал первому пункту - просто не было необходимости в передаче дополнительных данных. Замечу только, что третий пункт возникает потому, что мы не используем COM, так что это поле свободно для наших целей.

Идентификация точки возбуждения

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

Но что если вам почему-то не удастся воспользоваться отладкой исходного исключения?

Ответ заключается в том, что вы должны использовать в своей программе трейсер исключений и записывать информацию по исключению в лог-файл или передавать вместе с исключением как дополнительную информацию. Для Delphi на сегодня есть такие варианты: JCL, EurekaLog, madExcept.

Мне кажется, что оптимальным решением будет захватывать стек исключения и сохранять его в дополнительную информацию для исключения. Например:
function TCheckedInterfacedObject.SafeCallException(ExceptObject: TObject;
  ExceptAddr: Pointer): HResult;
var
  ...
  Source: WideString;
begin
  ...
    Source := { текстовое представление стека для исключения ExceptObject };
  ...
end;
Тогда эта информация будет доступна вызывающему. Он может показывать её в сообщении об ошибке, например:
type
  TForm1 = class(TForm)
    ...
  private
    procedure CustomExceptionHandler(Sender: TObject; E: Exception);
  end;

...

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

procedure TForm1.CustomExceptionHandler(Sender: TObject; E: Exception);
var
  Msg: String;
begin
  if E is EOleException then
    Msg := E.Message + sLineBreak + EOleException(E).Source
  else
    Msg := E.Message;

  // тут можно сохранить исключение в лог-файл (баг-репорт), если надо

  Application.MessageBox(PChar(Msg), 'Ошибка', MB_OK or MB_ICONERROR);
end;

Аппаратные исключения

Ещё стоит упомянуть об особом случае для safecall-исключений. Дело в том, что обработку через SafeCallException получают только программные исключения. Аппаратные исключения всегда возвращают E_UNEXPECTED. Мне не известно, намеренное ли это решение или баг в реализации. С одной стороны, такое поведение не указано в документации. С другой стороны, оно логично: аппаратная ошибка по определению не имеет смысла для вызывающего. Для него нет разницы между ними, он не будет делать специальную обработку таких ошибок.

Т.е., к примеру, в вашей системе может быть предусмотрено исключение EUnableToSetFocus (E_UnableToSetFocus = $80049330), которое возбуждается, если плагин хочет перевести фокус на редактор нашей программы, но это делать нельзя. Плагин вполне может сделать специальную проверку на этот класс исключения: если исключение = EUnableToSetFocus/E_UnableToSetFocus, то ничего не делать. Ну или сделать что-то ещё. Вот, это - специальный класс, который имеет особый смысл. Он явно отличен от каких-нибудь EInvalidInsert, EMonitor и EProgrammerNotFound :) Но аппаратное исключение не может быть таким специальным исключением. Оно всегда будет обрабатываться единообразно, равно как и все прочие исключения.

Конечно, вы можете обойти эту проблему, просто развернув safecall-метод в обычный stdcall и сделав всю обработку руками. Но зачем это делать? Вы лишаетесь удобства ради случаев, которые всё равно потребуют запуска отладчика, либо использования трейсера.

Итого: если в вашей программе вы видите ошибку "Разрушительный сбой" (напомню, это текст ошибки от E_UNEXPECTED), то знайте, что в ней произошло либо аппаратное, либо неизвестное исключение (чаще всего - исходным исключением будет банальный Access Violation). И чтобы его исправить, нам нужно запустить отладчик ;)

А если вам хочется иметь больше информации, чем просто "Разрушительный сбой" - просто используйте трейсер исключений.

Заключение

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

А раз так, то самое время приступить к дальнейшему усложнению системы...

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

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

К примеру, мы можем захотеть дать плагину возможность отказаться грузится. Например, если плагин предназначен для работы с железкой, которая отсутствует. В принципе, плагин может просто возбудить исключение в методе инициацизации. Но это приведёт к показу сообщения об ошибке загрузки плагина и, что более важно, к его отключению из списка автозагружаемых (если такая функциональность предусмотрена в ядре). Вот поэтому нам нужно ввести специальный класс исключения:
type
  EPluginLoadAbort = class(EBaseException);

const
  E_PluginLoadAbort = HRESULT($80041881);
Плагин может возбудить это исключение в функции инициализации, а ядро может сделать для неё особую обработку: просто отменить загрузку плагина, вернув nil в качестве результата (IPlugin), не показывая сообщения об ошибке и не отключая плагин.

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

С другой стороны, было бы также весьма полезно включить коды для, скажем, EAssertionFailed и EStreamError. По этой причине я включил в примеры несколько стандартных классов - исключительно для удобства (если в вашей версии Delphi нет каких-то классов - просто удалите те строки, которые генерируют ошибку "Неизвестный идентификатор").

Читать далее.

12 комментариев :

  1. Александр, а нет ли у Вас такой возможности (желания) сконвертировать 5 ваших последних постов в pdf с возможностью публичного скачивания? Это было бы реализацией возможности создания "настольного" варианта, т.к. доступ к интернету ограничен корпоративной логикой инф. безопасности.

    ОтветитьУдалить
  2. Наверное так и надо будет сделать, но только когда я закончу серию. У меня в планах ещё минимум три поста.

    ОтветитьУдалить
  3. Грандиозно! Спасибо!

    ОтветитьУдалить
  4. +1 за pdf версию.

    ОтветитьУдалить
  5. Спрошу заранее: а нужен ли бумажный вариант? Я тут подумал, может имеет смысл в итоге сделать PDF + вариант физического носителя. На каком-нибудь сервисе print-on-demand.

    ОтветитьУдалить
  6. >а нужен ли бумажный вариант?
    Да, почему бы и нет.

    ОтветитьУдалить
  7. >Спрошу заранее: а нужен ли бумажный вариант?
    В век айпадов? =)
    Может лучше, чисто теоретически, задуматься и выпустить через iBooks Author

    ОтветитьУдалить
  8. А когда будет продолжение ? ;)

    ОтветитьУдалить
  9. "We'll release it when it'll be ready" (C) Blizzard.

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

    ОтветитьУдалить
  10. Александр, спасибо, за эту серию статей. Это лучшее что я видел по данной тематике.

    ОтветитьУдалить
  11. Раздел "Обработка ошибок в плагинах" действительно очень полезен, а все что до него - это всего лишь повторение статей в блоге и королевстве. Можно было просто дать ссылки на эти статьи и все. Также хотелось бы увидеть пример с использованием трейсера исключений и передачей дополнительной информации, раз уж об этом зашла речь.

    ОтветитьУдалить
  12. > Это всего лишь повторение статей в блоге и королевстве

    Не все читали сей мега-труд, поэтому я привёл адаптированную вырезку. Тем более, что Королевство временами штормит и оно выпадает из поиска Google/Яндекс.

    Насчёт трейсера исключений - материал будет. Будет отдельная статья.

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку.

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

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

Примечание. Отправлять комментарии могут только участники этого блога.