27 апреля 2018 г.

Ответ на задачку №23

Ответ на задачку №23.

Довольно много людей обратили внимание на такую конструкцию:
try
  SomeVariable := TSomeClass.Create({...});
  // ...
finally
  SomeVariable.Free;
end;
"Это неправильно, правильно - так:"
SomeVariable := TSomeClass.Create({...});
try
  // ...
finally
  SomeVariable.Free;
end;
В чём тут проблема?

Понятно, что если выполнение кода идёт штатно (ошибок / исключений нет), то оба варианта равнозначны и выполняются одинаково. Но что произойдёт, если возникнет какое-то исключение? Ну, если исключение возникнет после выполнения конструктора, то, опять же, оба блока равнозначны: управление перейдёт на блок finally, где будет освобождён объект. Тут всё чисто. Кода до конструктора в примере нет, исключение там возникнуть не может, так что тут тоже всё в порядке.

Но что произойдёт, если исключение будет возбуждено в самом конструкторе?

Если вы не знали, то в этом случае RTL Delphi автоматически вызывает деструктор объекта, чтобы освободить частично-созданный объект. Хм, тогда во втором варианте кода (выше) проблем нет: исключение поднимается наверх, никакой код в функции больше не выполняется. Но в первом варианте кода (выше) в finally блоке у нас же тоже стоит вызов деструктора. Не будет ли он повторно освобождать уже удалённый объект?

Чтобы ответить на этот вопрос, нам нужно понять, что будет в переменной объекта. Если там будет nil, то проблем, опять же, нет: вызов .Free просто ничего не делает, если переменная объекта равна nil.
В отличие от переходника .Free, прямой вызов деструктора .Destroy не проверяет переменную объекта. В этом случае деструктор попытается "удалить" объект, равный nil - что в итоге приведёт к возбуждению Access Violation.
Несложно сообразить, что, в случае возбуждения исключения в конструкторе, значение переменной не меняется. В самом деле, строка SomeVariable := TSomeClass.Create({...}); транслируется на машинный язык так:
регистр_процессора := вызов_конструктора;
переменная := регистр_процессора;
Это также следует из того, что в некоторых случаях переменной просто нет, т.е. вызов конструктора должен работать без переменной и, следовательно, не может влиять на переменную. Ну и, конечно же, вы можете проверить это в машинном отладчике.

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

Но в задачке есть одна "маленькая" деталь:
SomeVariable := nil;
try
  // ...
Переменная инициализируется прямо перед блоком try. Это означает, что в случае возбуждения исключения в конструкторе переменная объекта вовсе не будет неинициализированной, в ней будет записан nil. Следовательно, вызов .Free в finally блоке будет успешен (и ничего не сделает).

Т.е. проблем в этом коде нет, это полностью корректный и рабочий подход.

Но зачем нужно применять такой код, если очевидный вариант с вызовом конструктора до блока try явно короче и "красивее"? В простых случаях, действительно, смысла нет, но, тем не менее, такая конструкция незаменима в следующих двух случаях:
  1. Условное создание объекта.
    Не всегда объект нужно создавать всегда. Иногда он должен создаваться или не создаваться в зависимости от некоторых условий. Т.е. объект может быть опционален. Но остальной код при этом полностью одинаков:
    SomeVariable := nil;
    try
      // ...
      if SomeCondition then
        SomeVariable := TSomeClass.Create({...});
      // ...
    finally
      SomeVariable.Free;
    end;
    Упражнение: что будет в этом случае, если исключение будет возбуждено до выполнения конструктора? Это последний случай, который мы ещё не разобрали.

  2. Создание большого количества объектов.
    Предположим, что нам нужно создать не один, а, скажем, три объекта. Тогда, при "классическом" подходе мы получаем:
    SomeVariable1 := TSomeClass1.Create({...});
    try
      SomeVariable2 := TSomeClass2.Create({...});
      try
        SomeVariable3 := TSomeClass3.Create({...});
        try
          // ...
        finally
          SomeVariable3.Free;
        end; 
      finally
        SomeVariable2.Free;
      end; 
    finally
      SomeVariable1.Free;
    end;
    Как видите, код становится очень громоздким и сильно норовит уползти за экран вправо. Но с альтернативным подходом мы получаем гораздо более компактный и красивый код:
    SomeVariable1 := nil;
    SomeVariable2 := nil;
    SomeVariable3 := nil;
    try
      SomeVariable1 := TSomeClass1.Create({...});
      SomeVariable2 := TSomeClass2.Create({...});
      SomeVariable3 := TSomeClass3.Create({...});
      // ...
    finally
      SomeVariable3.Free;
      SomeVariable2.Free;
      SomeVariable1.Free;
    end;
    Упражнение: почему допустимо размещать все деструкторы в одном блоке finally?

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

Посмотрим на второй блок кода:
try
  try
    Stream := TFileStream.Create({ ... });
  except
    Exit;
  end;
  // что-то делаем
finally
  Stream.Free;
end;
Обратите внимание, что в этом блоке кода нет инициализации переменной перед блоком. Значит ли это, что проблема - в этом (как мы обсудили выше)?

Но переменной же присваивается какое-то значение, верно? Первый блок кода записывает туда nil, а затем и объект. Т.е. переменная же инициализирована?

Давайте посмотрим. Если в переменной записан nil - это может означать, что в первом блоке при вызове конструктора возникло исключение и в переменную не было записано иное значение. Но в этом случае будет выполнен finally секции первого блока кода (который мы разобрали в начале статьи) и управление перейдёт к вызывающему (какому-то следующему по вложенности finally или except блоку, которого здесь мы не видим), и наш второй блок кода вообще не будет выполнен.

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

Но если он был выполнен успешно, то в переменную сохраняется объект, который затем успешно удаляется вызовом .Free в finally первого блока кода. Чему в этом случае будет равна переменная объекта (после его успешного удаления)?

.Free - это вызов метода объекта. Несложно сообразить, что вызов метода не имеет представления о переменной, из которой получается значение объекта. Действительно, вы ведь можете вызвать: Form1.Canvas.Free; - как деструктор TCanvas узнает о внутреннем поле FCanvas формы, который нужно об-nil-ить? Никак. Следовательно, деструктор не имеет доступа к переменной и не может очистить её значение, он может только удалить сам объект. Это легко проверить:
SomeVariable := TSomeClass.Create({...});
SomeVariable.Free;
if Assigned(SomeVariable) then
  ShowMessage('SomeVariable <> nil');
Тестовый код покажет сообщение - что говорит о том, что вызов .Free не об-nil-ивает переменную.

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

Кажется, что вот она, проблема. Ведь мы только что подробно разобрали, что в случае, когда создание объекта происходит внутри блока try, переменная объекта обязана иметь значение nil ещё до входа в блок, в противном случае могут быть ситуации, когда в деструктор из finally блока попадёт не корректное значение.

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

Посмотрим, как это работает. Случаи штатного выполнения разбирать не будем - тут всё тривиально. Сразу перейдём к исключению в конструкторе. В этом случае, как мы обсудили выше, переменная объекта не будет изменена, т.е. в ней останется значение старого удалённого объекта из первого блока. Управление при этом перейдёт на блок except, в котором произойдёт выход из функции (вызов Exit). По задумке автора кода произойдёт выход из подпрограммы и, следовательно, второй finally выполнен не будет.

Но так ли это? Вы можете подтвердить это учебником по Delphi, справкой или же простым экспериментом:
try
  Exit;
finally
  ShowMessage('finally');
end;
При выполнении этого кода будет показано сообщение. Это говорит о том, что блок finally выполняется всегда - даже если выход из него происходит по Exit.
Упражнение: справедливо ли это для выхода из блока finally на метку (goto)?

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

Как можно исправить этот код? Есть много вариантов, но при минимальном изменении:
var
  Stream: TFileStream;
begin
  Stream := nil;
  try
    // что-то делаем
    Stream := TFileStream.Create({ ... });
    // что-то делаем
  finally
    FreeAndNil(Stream); // - изменено!
  end;
  try
    try
      Stream := TFileStream.Create({ ... });
    except
      Exit;
    end;
    // что-то делаем
  finally
    Stream.Free;
  end;
end;
Упражнение: почему вызов FreeAndNil может об-nil-ить переменную? В чём отличие от вызова метода .Free?
Если же исправлять код "по-нормальному", то вот два возможных варианта:
var
  Stream: TFileStream;
begin
  Stream := TFileStream.Create({ ... });
  try
    // что-то делаем
  finally
    FreeAndNil(Stream);
  end;
  Stream := TFileStream.Create({ ... });
  try
    // что-то делаем
  finally
    FreeAndNil(Stream);
  end;
end;
Либо:
var
  Stream: TFileStream;
begin
  Stream := nil;
  try
    // что-то делаем
    Stream := TFileStream.Create({ ... });
    // что-то делаем
  finally
    FreeAndNil(Stream);
  end;
  try
    // что-то делаем
    Stream := TFileStream.Create({ ... });
    // что-то делаем
  finally
    FreeAndNil(Stream);
  end;
end;
в случае, если нужно условное или множественное создание (т.е. код приведён не полностью).

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

  1. >Упражнение: справедливо ли это для выхода из блока finally на метку (goto)?

    У меня вообще не компилируются прыжки ни из блоков обработки исключений, ни внутрь них (FPC trunk).

    В C++ прыжки из скоупа (неявного try-finally) правильно уничтожают переменные, т. е. исполняют неявные блоки finally, сквозь которые прыгают (https://ideone.com/d5GAQM).

    ОтветитьУдалить
  2. Мораль: всегда юзать FreeAndNil :)

    "Иногда он должен создаваться или не создаваться в зависимости от некоторых условий."
    Либо когда конструктор может бросить исключение, которое обрабатывается тут же. Т.к. в Delphi нет конструкции try..except..finally, приходится ловить такие случаи вложенными try try..except finally, что огорчает.

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

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

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

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

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

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

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

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