8 мая 2011 г.

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

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

Итак, что же за проблемы ожидают вас при использовании этого кода?
var
  Enum: IEnumVariant;
  Item: Variant;
  Value: LongWord;
begin
  ...
  while Enum.Next(1, Item, Value) = 0 do
  begin
  end;
Этот код (который, кстати, производит перечисление объектов в коллекции) может появиться у вас в программе благодаря народному методу copy&paste. Кажется, что чаще всего он встречается в сочетании с использованием WMI.

Как уже было замечено, у этого кода есть несколько проблем:
  • Использование волшебной константы 0 вместо S_OK.
  • Использование Variant вместо OleVariant.
И хотя это действительно проблемы с кодом, не они являются темой этой задачки, потому что даже с ними код "будет работать". Вы не увидите на форумах людей, жалующихся на эти проблемы. Но что же тогда вы увидите?

Если вы пытались работать с WMI (и использовали этот код), либо, быть может, вы видели такие вопросы на форуме, то вы знаете, что люди, бездумно копирующие этот код, начинают жаловаться на утечки памяти в их программах.

С первого раза, казалось бы, что тут может пойти не так? Variant и OleVariant относятся к авто-финализируемым типам в Delphi (равно как и строки и интерфейсы). Это значит, что за корректностью освобождения и выделения памяти следит компилятор. Т.е. вам не нужно ничего делать - компилятор всё сделает за вас.

Но давайте внимательнее посмотрим на вызов метода Next в этом коде. Если бы там стоял всего один параметр - это было бы хорошо, понятно, предсказуемо и, наверняка, не принесло бы проблем: метод возвращает очередной элемент, мы вызываем его, пока не исчерпаем элементы в коллекции. Но что это за подозрительная единичка? Очередное волшебное число?

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

Если вы посмотрите на прототип (да хотя бы на сам код вызова), то можно увидеть, что там нет никаких массивов или указателей, а параметр объявлен как обычное значение по ссылке:
function Next(celt: LongWord; var rgvar: OleVariant; out pceltFetched: LongWord): HResult; stdcall;
Предполагается, что если вам нужно передать массив значений - вы передаёте просто первый элемент. Поскольку под капотом (на уровне машинного языка) тут всё равно передаётся указатель - то это будет эквивалентно передаче массива (размер которого мы указываем первым параметром). Это достаточно простой способ спроектировать прототип метода так, чтобы метод было удобно использовать в самом частом случае (передача ровно одного элемента), но при этом также была бы и возможность расширенного использования (передачи массива).

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

Но обратите внимание на то, что хотя третий параметр объявлен как выходной (out), второй параметр имеет тип var - т.е. входной-выходной. Почему?

Потому что вы не можете объявить его как out. Представьте себе, что было бы, если мы бы так сделали.

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

Ну, случай с одним элементом тривиален: мы передаём элемент, до вызова метода его обнулят (автоматически, компилятором), после мы его используем - всё корректно и красиво. Но что будет, если мы передадим массив?

Первый элемент массива будет финализирован и обнулён - для подготовки его к передаче как out-параметра. Но вот остальные элементы в массиве ничего такого не должны. Ведь в прототипе функции никак не указано, что они туда хоть как-то передаются. Это мы говорим методу: используй N параметров. Но компилятор про это ничего не знает. Вот и получится, что первый параметр у нас передастся красиво, а все остальные - как повезёт.

Вот поэтому второй параметр объявлен как var: для единообразной обработки двух случаев (передача лишь одного элемента и массива).

Постойте-ка: а как надо передавать-то? С обнулением или нет?

Логика подсказывает, что логически - параметр выходной. Но в документации про это специально ничего не сказано. Однако, пример кода в документации показывает пример реализации метода Next - откуда мы можем видеть (не знаете C++?), что первым действием для этих элементов является вызов VariantInit. Т.е. то, как ведёт себя метод Next и то, передавать ему надо обнулённые "out-параметры", либо же обычные по ссылке - определяется функцией VariantInit: смотря по тому, что она делает с параметром.

Ну, чего же мы ждём?
The VariantInit function initializes the VARIANTARG by setting the vt field to VT_EMPTY. Unlike VariantClear, this function does not interpret the current contents of the VARIANTARG. Use VariantInit to initialize new local variables of type VARIANTARG (or VARIANT).
Вот так, функция просто зачищает значение, не пытаясь его интерпретировать и очищать.

Что в итоге означает, что в метод Next нам надо передавать обнулённые значения.

Что же происходит у нас?

На первой итерации цикла всё нормально: метод Next успешно инициализирует и так пустую переменную и запишет туда первый элемент коллекции. Потом начинается вторая итерация. Эта же переменная, которая сейчас содержит первый элемент коллекции, передаётся на второй вызов метода Next. Компилятор переменную не освобождает - ведь параметр входной-выходной. Но тогда метод Next затирает и инициализирует её, не пытаясь освободить. Опс.

Иными словами мы получили что-то вроде этого:
  function DoSomething(var S: String): Boolean;
  begin
    Integer(S) := 0;
    // ...
    S := // ...
    // ...
  end;

...

var
  S: String;
begin
...
  while DoSomething(S) do
    Memo1.Lines.Add(S);
Иными словами, каждый вызов метода теряет ссылку на объект, и он (объект) утекает. Вот вам и утечка памяти, вот вам и "что может пойти не так".

Как это исправить? Убедитесь, что вы передаёте финализированный вариант в метод Next:
var
  Enum: IEnumVariant;
  Item: OleVariant;
  Value: LongWord;
begin
  ...
  while Enum.Next(1, Item, Value) = S_OK do
  begin
    // ...
    VariantClear(Item); // очистка варианта в конце работы
  end;
Разумеется, вы должны вставить вызов VariantClear до цикла, если вы работаете с этой переменной в коде выше. Кроме того, заметьте, что здесь нет никакого блока try-finally. Почему? Потому что (Ole)Variant - авто-финализируемые типы :) Delphi сама позаботится об их очистке при возникновении исключения, да и любых других операциях тоже. Единственная задача этого самого вызова VariantClear - предотвратить передачу варианта с осмысленным значением в метод Next.

Мораль истории?
  1. НЕ НАДО бездумно копировать код. Скопируйте и разберитесь, как он работает. Исправьте ошибки. Ещё лучше - напишите свой код, руководствуясь примером как образцом.
  2. Иногда ответ на вопрос можно получить за 15 минут чтения документации - это быстрее, чем спрашивать на форуме, потратить несколько дней на советчиков "возьми этот код: он рабочий - я гарантирую это", либо же потратить кучу времени на отладку. Вы МОЖЕТЕ решать проблемы самостоятельно. Это несложно.
  3. WMI - это очень клёвая штука. Не надо от неё отказываться только потому, что вы обожглись на говно-коде. Это - не вина WMI.
  4. И самое главное: как только вы видите явное указание размера переменной рядом с переменной авто-управляемого типа (в нашем примере мы указывали явный размер в 1 элемент для динамического массива) - опасайтесь: тут что-то не так. Нужно внимательно проверить код на предмет того, что доступ к данным осуществляется напрямую, в обход компилятора и его заботливых рук.

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

  1. Мне кажется, что использование VariantInit вместо VariantClear внутри Next является багом. А необходимость в своем коде вызывать VariantClear - соответственно костыль. Впрочем не самый страшный. Мне вот недавно пришлось разбиратся с дропом файлов в окно проги при включенном UAC. Так без помощи форумов ничего бы у меня не вышло. В результате даже комент к статье в MSDN пришлось писать.

    ОтветитьУдалить
  2. Ну собственно как я и говорил. Нужно добавить вызов VariantClear. Ктати давно пришел к выводу, что при чтении мсдн если не написано что эта функция ДЕЛАЕТ ЭТО, то считать что эта функция не делает этого, а не руководствоваться моделью вызова. Пару раз спотыкался об подобное, вот только сейчас уже не припомню где.

    p.s. Ну и всегда стоит поглядывать на комментарии, т.к. опечатки там встречаются и часто комментируются.

    ОтветитьУдалить
  3. На самом деле в примере к IEnumVARIANT.Next() в MSDN написал индусский код, т.к. согласно куда более здравому http://support.microsoft.com/kb/104960 костыль с VariantClear() в коде клиента не нужен. Ну а как реализованы реальные конкретные провайдеры WMI - на совести их разработчиков, это да.

    ОтветитьУдалить
  4. Речь про "A variant must be initialized using VariantInit after creation and before it is passed to a function"?

    Если да, то лично я не вижу противоречия. Частное имеет более сильный приоритет, чем общее. "Должно быть" - если не оговорено иное. Ну а в данном случае - оговорено.

    Да дело даже не в этом. Аргумент не передаётся в функцию. Он из неё возвращается, он выходной. Очевидное правило для выходных параметров - они инициализируются в функции, а не в вызывающем коде. Мы сами не создаём вариантное значение, поэтому мы не вызываем VariantInit. Вариант создаёт функция - но тогда нам нужно убедится, что она не перезатрёт существующие данные. Именно для этого нужен VariantClear.

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

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

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

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

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

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