Студенты, ничего не хочу сказать - часто встречаются очень светлые головы, но я давно заметил, что ошибки, которые они совершают, не всегда просто выявить.
Ситуации, с которыми мне приходилось сталкиваться были иногда настолько экзотическими, что заставляли реально напрягаться с тем, чтобы найти-таки причину проблемы.
Причина этой экзотики часто состоит в том, что они (студенты) в силу своей неопытности легко делают вещи, которые вы делать уж точно не станете, причём совершенно автоматически.
Вот например, сегодняшний случай...
Фрагмент кода, приводимый ниже приводит к возбуждению Access Violation при выходе из процедуры при условии, что используется FastMM4 и режим FullDebugMode (студенты выполняют разработку именно в этом режиме, для того, чтобы сразу вычищать грязь в коде, по крайней мере такую, которую способен выявить менеджер кучи):
procedure Execute; var dbSchemaSpec: TDBSchemaSpec; tableSpec: TTableSpec; intfDBSchemaSpec: IDBSchemaSpec; intfTableSpec: ITableSpec; begin dbSchemaSpec := createSchema; tableSpec := TTableSpec.Create; tableSpec.SetName('N3'); try dbSchemaSpec.AddTable(tableSpec); dbSchemaSpec.AddTable(createTable('N1')); dbSchemaSpec.AddTable(createTable('N2')); dbSchemaSpec.AddDomainSpec(createDomain); intfDBSchemaSpec := dbSchemaSpec; writeln(intfDBSchemaSpec.GetDomainSpecCount); writeln(intfDBSchemaSpec.GetDomainSpec(0).GetDomainName); // <-- problem here! writeln(intfDBSchemaSpec.GetTableSpecCount); writeln(intfDBSchemaSpec.GetTableSpec(0).GetName); writeln(intfDBSchemaSpec.GetTableSpec(1).GetName); if intfDBSchemaSpec.TryGetTableSpecByName('N3', intfTableSpec) then writeln(intfTableSpec.GetName); if intfDBSchemaSpec.TryGetTableSpecByName('N1', intfTableSpec) then writeln(intfTableSpec.GetName, ' ', intfTableSpec.GetIndexSpecCount); finally intfDBSchemaSpec := nil; intfTableSpec := nil; dbSchemaSpec.Free; end; writeln('close finally'); end;Суть выполняемых действий - создать некоторое множество связанных объектов (классы TDBSchemaSpec, TTableSpec) затем - поработать с ними с помощью реализованных в них интерфейсов.
Упомянутые классы содержат собственную реализацию IInterface, обеспечивающую их "выживание" при финализации интерфейсных ссылок на них
ПоказатьСкрыть
unit InterfacedBaseClass; interface type TInterfacedBaseClass = class(TObject, IInterface) public function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall; function _AddRef: Integer; stdcall; function _Release: Integer; stdcall; end; implementation function TInterfacedBaseClass.QueryInterface; begin if GetInterface(IID, Obj) then Result := 0 else Result := E_NOINTERFACE; end; function TInterfacedBaseClass._AddRef: Integer; begin Result := -1; end; function TInterfacedBaseClass._Release: Integer; begin Result := -1; end; end.
Для того, чтобы иметь свободу эту реализацию изменить, или предоставить альтернативную, обеспечивающую возможность работы в том же программном интерфейсе, но в иных условиях (например, в ситуации, когда непосредственного доступа к интересующим данным нет, и их приходится запрашивать с сервера приложений).
Но вернёмся к AV. Под отладчиком было отчётливо видно, что исключение возбуждается в System._IntfClear, в строке:
CALL DWORD PTR [EAX] + VMTOFFSET IInterface._Releaseчто прямо указывает на попытку вызова метода _Release у уже освобождённого объекта.
Вне всякого сомнения, Вы Уважаемый читатель, уже догадались, в чём суть происходящего :-)
Но для людей, которые соображают не столь быстро (для таких как я, например) я продолжу.
Так вот, совершенно понятно, что такое происходит практически всегда, когда объект освобождён, а существующая на него интерфейсная ссылка ещё не вышла из контекста своего определения (или из "области видимости", как принято часто говорить, хотя в данном случае это чушь :-)). Но что это за ссылка?! WTF?!
В процедуре Execute определены две ссылки, которые могли бы претендовать на эту роль:
intfDBSchemaSpec: IDBSchemaSpec; intfTableSpec: ITableSpec;но обе они старательно инициализированы молодым человеком в секции finally, именно в свете обозначенных выше их особенностей!
Я посмотрел на часы, у меня оставалось меньше получаса.
Опущу подробности, в конце концов мне было очевидно, что проблема в какой-нибудь ерунде - я сосредоточился на том, чтобы объяснить людям, как локализовать ошибку. Т.е. дистиллировать ситуацию, довести набор действий, приводящих к проблеме до абсолютного минимума с тем, чтобы их исследовать для выявления причин происходящего. Все эти объяснения заняли некоторое время, а перерывом мне воспользоваться не дали, хотя я уже догадывался в чём дело, поскольку в процессе дистилляции стало очевидно, что ошибка вызвана кодом, отмеченным комментарием "// <-- problem here!". Отчётливое понимание пришло уже в фойе - пришлось развернуться, найти молодого человека и объяснить ему в чём дело и как с этим быть.
Собственно, объяснение очень простое.
Всё верно, в коде действительно не объявлено интерфейсных ссылок, которые нуждались бы в инициализации. Такие ссылки появились неявно, и мне известен только один способ их инициализировать :-) Приведу здесь "в тему" небольшой инсайд - выдержку из нашего корпоративного документа, описывающего некоторые особенности работы с интерфейсами в Delphi:
«Свойства типа интерфейс
Создание свойств типа интерфейс провоцирует к написанию кода следующего вида:
Таким образом, для возвращения интерфейса лучше использовать функции с мнемоническим названием вида GetIXXX, что бы вызывающий функцию программист "знал" (или догадался), что ему вернут интерфейс и он должен с ним обойтись подобающим образом.»
Обсуждаемый случай совершенно аналогичен ситуации, изложенной в корпоративном документе. Всё та же неявная интерфейсная ссылка, появляющаяся при обращении к свойству (или методу), возвращающему интерфейс, к которой нет доступа для того, чтобы её инициализировать. Всё-тот же объект, в контексте которого существует то, на что направлена эта ссылка, и всё то же освобождение этого объекта, влекущее за собой освобождение того, на что направлена ссылка, что делает ссылку "провисшей" с неминуемой попыткой вызова _Release уже освобождённого объекта при выходе из процедуры.Создание свойств типа интерфейс провоцирует к написанию кода следующего вида:
type ISomeInterface = interface ... procedure SomeProcedure; ... end; TSomeClass = class(TSomeParentClass) ... protected function GetISomeInterface: ISomeInterface; public property SomeInterface: ISomeInterface read GetISomeInterface; end; ... procedure... var instance: TSomeClass; begin instance := TSomeClass.Create; try // использование someClass instance.SomeInterface.SomeProcedure; // (*) finally instance.Free; end; end;(*) здесь, неявно (т.е. уже противоречит дзену Питона), была создана переменная типа интерфейс, увеличен её счетчик ссылок, а уменьшен он будет только при вы ходе из процедуры, т.е. после уничтожения самого объекта.
Таким образом, для возвращения интерфейса лучше использовать функции с мнемоническим названием вида GetIXXX, что бы вызывающий функцию программист "знал" (или догадался), что ему вернут интерфейс и он должен с ним обойтись подобающим образом.»
Собственно, с вопросом "почему?" надеюсь, всё относительно понятно.
Теперь несколько слов относительно вопроса "Как?". В смысле "Как с этим жить?".
Здесь всё тоже относительно просто. - Не делать так. Не смешивать в одной процедуре объектную технику с интерфейсной, если объектным и интерфейсным ссылкам соответствуют одни и те же экземпляры классов.
В сущности, основная масса проблем вызывается тем, что при таком "смешении" возникает ситуация, когда объект явным образом освобождается - т.е. для объектной ссылки вызывается метод Free (ну, или Destroy у тех, кто любит острые ощущения).
К сожалению, часто оказывается сложным почувствовать опасность в каждом конкретном случае. Ну, для меня не очевидно хотя бы то, что место, отмеченное мною "// <-- problem here!" приводит к появлению неявной интерфейсной ссылки - для этого нужно знать, что метод IDBSchemaSpec.GetDomainSpec(0) возвращает интерфейс, а потом вспомнить, к чему это приводит (к появлению неявной интерфейсной ссылки).
Сам я стараюсь следовать следующей парадигиме. Если в коде процедуры используется объектная ссылка у которой запрашивается интерфейс, то запрос интерфейса и его использование должно производиться в отдельной процедуре.
Например, если оригинальный исходный текст изменить способом, приведённым ниже, проблем не возникнет:
procedure Execute; procedure ExecuteInterfaceTests(ADBSchemaSpec: IDBSchemaSpec); var intfTableSpec: ITableSpec; begin writeln(ADBSchemaSpec.GetDomainSpecCount); writeln(ADBSchemaSpec.GetDomainSpec(0).GetDomainName); writeln(ADBSchemaSpec.GetTableSpecCount); writeln(ADBSchemaSpec.GetTableSpec(0).GetName); writeln(ADBSchemaSpec.GetTableSpec(1).GetName); if ADBSchemaSpec.TryGetTableSpecByName('N3', intfTableSpec) then writeln(intfTableSpec.GetName); if ADBSchemaSpec.TryGetTableSpecByName('N1', intfTableSpec) then writeln(intfTableSpec.GetName, ' ', intfTableSpec.GetIndexSpecCount); end; var dbSchemaSpec: TDBSchemaSpec; tableSpec: TTableSpec; begin dbSchemaSpec := createSchema; tableSpec := TTableSpec.Create; tableSpec.SetName('N3'); try dbSchemaSpec.AddTable(tableSpec); dbSchemaSpec.AddTable(createTable('N1')); dbSchemaSpec.AddTable(createTable('N2')); dbSchemaSpec.AddDomainSpec(createDomain); ExecuteInterfaceTests(dbSchemaSpec); finally dbSchemaSpec.Free; end; writeln('close finally'); end;Неявные интерфейсные ссылки как появлялись, так и появляются, но происходит это в контексте процедуры ExecuteInterfaceTests. И время жизни таких ссылок ограничено этой процедурой.
Но поскольку выполнение ExecuteInterfaceTests завершается до освобождения объекта dbSchemaSpec, на детали которого направлены интерфейсные ссылки, всё проходит благополучно.
Кстати, в теле ExecuteInterfaceTests уже не нужен и блок try..finally, и инициализация объявленных в этой процедуре интерфейсных ссылок.
На этом заканчиваю, спасибо всем кто дочитал до конца.
PS:
ПоказатьСкрыть
Интересно, что мешало сделать автоматическую финализацию этих неявных интерфейсных ссылок?
На уровне компилятора, которому известен контекст?
Ну ведь совершенно же понятно, что если запрос интерфейса производится в контексте цепочки вызовов, результатом этого запроса (интерфейсной ссылкой) нельзя воспользоваться за рамками этой цепочки!
Так что же мешает в описанном сценарии эту ссылку автоматически исключить из контекста, а не откладывать это до завершения процедуры?!
Кто бы сказал... :-((
На уровне компилятора, которому известен контекст?
Ну ведь совершенно же понятно, что если запрос интерфейса производится в контексте цепочки вызовов, результатом этого запроса (интерфейсной ссылкой) нельзя воспользоваться за рамками этой цепочки!
Так что же мешает в описанном сценарии эту ссылку автоматически исключить из контекста, а не откладывать это до завершения процедуры?!
Кто бы сказал... :-((
Ха, хорошо что хоть при выходе из процедуры ссылка убивается, а не при выходе из try. Почитайте про области видимости в js или ruby, вот там скандалы, вот там интриги :) До расследований, правда, дело не доходит, потому как GC всё прибирает. Кстати, в rust Вы так просто с ссылкой не поиграетесь. Фишка языка в определении указателей как принадлежащих коду, либо заимствованных. В данном случае Вам дали в заимствование, то есть грохать ссылку должны не Вы, а тот, кто инициировал под неё память. Как-то так... Интересный язык... Вообще, интерфейсы довольно интересная и опасная тема; в скриптовых языках решили не заморачиваться, а делать через миксины.
ОтветитьУдалитьПозвольте я Вам оставлю пару ссылок на Александра Алексеева:
ОтветитьУдалитьЗадачка №13
Ответ на задачку №13
Спасибо!
ОтветитьУдалитьПризнаться, эту статью я даже проглядывал, но вероятно не запомнилось, поскольку наш материал (фрагмент из которого я привожу в тексте статьи) появился на год раньше.
Кстати, в примере, который приводит автор, я бы наверное действовал немного по-другому.
Мне показалось, что классы плагинов, поддерживающие интерфейс IPlugin можно было бы сделать потомками TInterfacedObject или обеспечить в них аналогичный подсчёт ссылок.
Это позволило бы заменить:
<code>
for i := 0 to Count - 1 do
Items[i].Done;
</code>
на:
<code>
{ поскольку плагины могут зависеть друг от друга (почему нет?), сначала
выполним их финализацию, затем - выгрузим соответствующие им DLL }
{ финализируем классы плагинов: присовение nil единственной интерфейсной
ссылке должно приводить к вызову деструктора у потомков TInterfacedObject }
for item in FItems do
item.Plugin := nil;
{ выгружаем соответствующие плагинам DLL }
for item in FItems do
FreeLibrary(FItems[i].Handle);
</code>
Ну и, пожалуй, прокоментирую здесь вот это:
«Ну, завёл компилятор переменную, казалось бы, и завёл. Но ведь у нас не простая переменная, а переменная автоуправляемого типа. Это значит, что её нужно удалять компилятору. В данном случае - компилятору нужно вызвать для неё метод _Release . Тогда возникает вопрос: а где он будет это делать?
Наивному читателю может показаться, что компилятору стоит сделать это в той же строке: выделил переменную, использовал её и тут же удалил.
Поначалу это кажется логичным, пока вы не посмотрите на такой код...»
-- поскольку текст имеет прямое отношение к моему поскриптуму.
На мой взгляд, компилятор при генерации исполняемого кода "знает" достаточно, чтобы обеспечить удаление "временной переменной" (в терминах автора).
Достаточно убедиться, что *значение* ссылки на временную переменную никуда не присваивается.
В примере, который привёл автор, присвоение имеет место:
<code>
SEI.lpFile := PChar(ExtractFilePath(ParamStr(0)) + 'Help.chm')
</code>
"временная переменная" содержит строку (результат ExtractFilePath(ParamStr(0)) + 'Help.chm'), адрес котрой помещается в локальную переменную SEI.lpFile.
Соответственно, финализацию строки следовало бы отложить на момент выхода из контекста переменой "SEI".
Думаю, это вполне формальный алгоритм, хотя он конечно сложнее, чем тот, что используется сейчас.