9 марта 2012 г.

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

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

Напомню проблемный код в вопросе:
var
  Buf: RawByteString;  // AnsiString для старых Delphi
  Data: RawByteString; 
  FS: TFileStream;
begin
  ...
  Data := '';
  SetLength(Buf, 10240);
  while FS.Position < FS.Size do
  begin
    // SetLength сработает для последнего куска
    SetLength(Buf, FS.Read(Pointer(Buf)^, Length(Buf))); 
    Data := Data + Buf;
  end;
end;
Если вы запустите этот код для чтения файла более 10 Кб байт, то увидите, что почему-то при этом теряется первый блок данных, зато второй блок дублируется два раза.

Давайте разберём чтение из файла. Мы читаем в буфер, передавая его так: Pointer(Buf)^. Buf - это динамическая строка, поэтому она представляет собой указатель на данные строки. Непосредственно перед ними идёт служебный заголовок (по отрицательному смещению). Поэтому, если мы преобразуем строку в указатель - мы получим указатель на данные строки, в нашем случае - на буфер.

Фишка же здесь заключается в том, что строка (равно как и динамические массивы и интерфейсы) имеет счётчик ссылок: на одну строку может ссылаться несколько переменных. Если вы меняете строку - компилятор отслеживает это и разрывает связь переменной с общим буфером, выделяя вам уникальную копию - так что ваши изменения в строке не повлияют на другие переменные. Это происходит автоматически. Например:
var
  S1, S2: String;
begin
  S1 := IntToStr(10); // S1 = '10', счётчик = 1
  S2 := S1;           // S1 = S2 = '10', счётчик = 2
  S1 := IntToStr(11); // счётчик '10' уменьшается до 1, а S1 создаётся как новая строка
end;
Однако если вы работаете со строкой напрямую, в обход заботливых рук компилятора - вы не получаете этой проверки.

На первый взгляд, в вышеуказанном коде нет этой проблемы. Ведь у нас просто нет копий строки. SetLength всегда создаёт новую строку. Счётчик ссылок будет 1 и у нас нет присваиваний строк в коде - так что мы в порядке, верно?

Вообще-то нет. Трюк тут сидит в строке Data := Data + Buf. Как работает эта строчка? Вроде очевидно, что она дописывает в строку Data строку Buf. Вроде как при этом строка меняется - следовательно, счётчик будет объединичен. Но что если Data - пуста? Тогда получится, что эта строка эквивалентна Data := Buf.

Иными словами, если мы распишем первые две итерации цикла линейно, то получим:
...
SetLength(Buf, 10240);               // Buf refcount = 1
FS.Read(Pointer(Buf)^, Length(Buf)); // Buf refcount = 1 
Data := Buf;                         // Buf refcount = 2, Data = Buf
FS.Read(Pointer(Buf)^, Length(Buf)); // Buf refcount = 2
Data := Data + Buf;                  // Buf refcount = 1, Data refcount = 1 
...
Таким образом, второй вызов Read пишет данные сразу "в два места" - в буфер и в итоговую строку, затирая, таким образом, предыдущее содержание (первый блок), что приводит к дублированию второго блока.

Все дальнейшие итерации работают обычным образом.

Почему я сказал, что это задачка на знание справки? Потому что в разделе "Строковые типы" справки Delphi есть пункт "Смешивание типов строк", где чёрным по белому написано:
When you cast a UnicodeString or AnsiString expression to a pointer, the pointer should usually be considered read-only. You can safely use the pointer to modify the string only when all of the following conditions are satisfied:
  • The expression cast is a UnicodeString or AnsiString variable.
  • The string is not empty.
  • The string is unique - that is, has a reference count of one. To guarantee that the string is unique, call the SetLength, SetString, or UniqueString procedures.
  • The string has not been modified since the typecast was made.
  • The characters modified are all within the string. Be careful not to use an out-of-range index on the pointer.
Или:
Когда вы делаете приведение типа UnicodeString или AnsiString к указателю, то вы должны рассматривать этот указатель как указатель на данные только для чтения. Вы можете безопасно использовать этот указатель для изменения данных только при выполнении всех следующих условий:
  • Выражение имеет тип UnicodeString или AnsiString.
  • Строка не пуста.
  • Строка уникальна - т.е. её счётчик ссылок равен 1. Чтобы гарантировать уникальность строки, вызовите для неё SetLength, SetString или UniqueString.
  • Строка не модифицировалась с момента приведения типа.
  • Все модифицируемые символы находятся в пределах строки. Вам нужно быть особенно осторожным, чтобы не выйти за пределы строки с использованием указателя.
Сложность тут заключается в неочевидности места, где происходит дублирование строки: уникальный Buf (SetLength) дублируется в Data, что делает обе строки неуникальными - и, значит, мы уже не можем менять Buf.

Как это исправить? Ну, можно поступить буквально, как говорит нам справка Delphi:
var
  Buf: RawByteString; 
  Data: RawByteString; 
  FS: TFileStream;
begin
  ...
  Data := '';
  SetLength(Buf, 10240);
  while FS.Position < FS.Size do
  begin
    SetLength(Buf, FS.Read(Pointer(Buf)^, Length(Buf))); 
    Data := Data + Buf;
    UniqueString(Data); // <- добавили
  end;
end;
(в Delphi нет варианта UniqueString для RawByteString, поэтому нужно писать UniqueString(AnsiString(Data)))

На первой итерации это разорвёт связь Data и Buf, так что код станет работать правильно.

Помимо этого очевидного исправления можно сделать и такой финт ушами:
var
  Buf: RawByteString; 
  Data: RawByteString; 
  FS: TFileStream;
begin
  ...
  Data := '';
  SetLength(Buf, 10240);
  while FS.Position < FS.Size do
  begin
    SetLength(Buf, FS.Read(Buf[1], Length(Buf))); // <- использовали другое выражение
    Data := Data + Buf;
  end;
end;
Это работает так: Buf[1] - это первый символ в строке, с него начинаются данные строки. Т.е. передавая это выражение куда-либо по ссылке, мы, фактически, передаём указатель на данные строки.

Хитрость же тут заключается в следующем. Вообще говоря, в справке Delphi не сказано о какой-либо специальной трактовке подобной передачи параметров. И если вы используете эту конструкцию локально, без передачи во внешний код (по отношению к локальной процедуре), то не увидите ничего необычного:
Unit1.pas.37: C := S[1];
00511362 8B45F8           mov eax,[ebp-$08]
00511365 668B00           mov ax,[eax]
00511368 668945F2         mov [ebp-$0e],ax
Магия начнётся, если вы используете это выражение для получения указателя:
Unit1.pas.53: PCharVar := @S[1];
00511386 8D45F8           lea eax,[ebp-$08]
00511389 E8865DEFFF       call @UniqueStringU
0051138E 8945EC           mov [ebp-$14],eax
или:
Unit1.pas.54: Func(S[1]);
00511391 8D45F8           lea eax,[ebp-$08]
00511394 E87B5DEFFF       call @UniqueStringU
00511399 E886FFFFFF       call Func
Иными словами, компилятор считает, что если вы получаете указатель на строку (любую её часть), то вы хотите её менять. И поэтому он на всякий случай автоматически вызывает UniqueString. Этого не происходит, если вы используете выражение типа Char, не беря указатель. Поэтому, к примеру, цикл перебора символов в строке будет работать с полной скоростью, без накладных расходов на вызов процедуры UniqueString.

Мне не удалось найти ссылку на это поведение в справке Delphi, хотя я почему-то уверен, что где-то его видел. До тех пор - я предпочитаю первый вариант исправления кода. Особенно, если учесть, что Pointer(Var)^ или PChar(Var)^ будет работать всегда, а Var[1] - только для непустой строки (это выражение выбьет ERangeError на пустой строке из-за попытки доступа к несуществующему символу). Конкретно в нашем случае это не имеет значения, поскольку Buf гарантированно не пуста, но в общем случае это может иметь значение.

P.S. В этом примере не играет роли - строка у нас или массив. Строки мало чем отличаются от динамических массивов.

P.P.S.
Мне кажется, что проблема заключается в том, что FS.Read в случае ошибки чтения вернёт 0 (фактическое кол-во прочитанных байт), что приведёт к... бесконечному циклу.

Вероятность возникновения такой ошибки - не велика, и потому этот код работает. Но если есть вероятность выше ноля, то такое когда-нибудь да и произойдёт. По закону Мёрфи :)
Хотя это верно для произвольного потока данных, но если мы открыли файл с нужными правами доступа и флагами разделения, то это означает, что всё содержимое файла в нашем распоряжении. Его нельзя изменить, нельзя удалить, нельзя заблокировать. Т.е. единственными ошибками могут быть только наведённые - типа, из-за проблем с многопоточностью мы портим (удаляем) буфер и функция Read возвращает ERROR_INVALID_PARAMETER. Но это не проблема с этим кодом. Это проблема в другом месте.

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

  1. "Хотя это верно для произвольного потока данных, но если мы открыли файл с нужными правами доступа и флагами разделения, то это означает, что всё содержимое файла в нашем распоряжении."
    Я почему то сразу же вспомнил про дискеты :) А ведь есть еще плохо читающиеся dvd-диски и нестабильный вайфай.

    ОтветитьУдалить
  2. Непонятно, зачем мучить конкатенацию, если размер итогового массива данных заранее известен — равен длине файла. Нужно установить длину строки Data и читать данные из файла непосредственно в неё.

    ОтветитьУдалить
  3. P.S. В этом примере не играет роли - строка у нас или массив. Строки мало чем отличаются от динамических массивов. Все они используют одну и ту же семантику copy on write.

    - не верно - динамические массивы не поддерживают copy on write - сделать динамический массив уникальным можно только явным вызовом SetLength...

    ОтветитьУдалить
  4. Действительно, держал в голове счётчик ссылок массивов - назвал его copy on write...

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

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

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

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

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

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