Студенты, ничего не хочу сказать - часто встречаются очень светлые головы, но я давно заметил, что ошибки, которые они совершают, не всегда просто выявить.
Ситуации, с которыми мне приходилось сталкиваться были иногда настолько экзотическими, что заставляли реально напрягаться с тем, чтобы найти-таки причину проблемы.
Причина этой экзотики часто состоит в том, что они (студенты) в силу своей неопытности легко делают вещи, которые вы делать уж точно не станете, причём совершенно автоматически.
Вот например, сегодняшний случай...
Фрагмент кода, приводимый ниже приводит к возбуждению 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".
Думаю, это вполне формальный алгоритм, хотя он конечно сложнее, чем тот, что используется сейчас.