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

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

Комментариев нет :

Отправить комментарий