воскресенье, 19 октября 2014 г.

Про смешивание объектной и интерфейсной техник

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

Фрагмент кода, приводимый ниже приводит к возбуждению 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:
«Свойства типа интерфейс

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

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

3 комментария :

  1. Ха, хорошо что хоть при выходе из процедуры ссылка убивается, а не при выходе из try. Почитайте про области видимости в js или ruby, вот там скандалы, вот там интриги :) До расследований, правда, дело не доходит, потому как GC всё прибирает. Кстати, в rust Вы так просто с ссылкой не поиграетесь. Фишка языка в определении указателей как принадлежащих коду, либо заимствованных. В данном случае Вам дали в заимствование, то есть грохать ссылку должны не Вы, а тот, кто инициировал под неё память. Как-то так... Интересный язык... Вообще, интерфейсы довольно интересная и опасная тема; в скриптовых языках решили не заморачиваться, а делать через миксины.

    ОтветитьУдалить
  2. Позвольте я Вам оставлю пару ссылок на Александра Алексеева:
    Задачка №13
    Ответ на задачку №13

    ОтветитьУдалить
  3. Спасибо!
    Признаться, эту статью я даже проглядывал, но вероятно не запомнилось, поскольку наш материал (фрагмент из которого я привожу в тексте статьи) появился на год раньше.

    Кстати, в примере, который приводит автор, я бы наверное действовал немного по-другому.
    Мне показалось, что классы плагинов, поддерживающие интерфейс 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".
    Думаю, это вполне формальный алгоритм, хотя он конечно сложнее, чем тот, что используется сейчас.

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