Тонкости работы со строками в Delphi - Строки в записях

ОГЛАВЛЕНИЕ


Строки в записях

Поля в записях могут иметь любой строковый тип без дополнительных ограничений. Однако следует учитывать, что, в отличие от полей простых типов, значения полей типа PChar и AnsiString лежат вне пределов структуры, причем в случае AnsiString это не так бросается в глаза, т.к. вручную выделять и освобождать память не приходится. Это может подложить неприятный сюрприз, если работать со структурой как цельным блоком данных. Чаще всего проблема появляется при записи структуры в поток, файл и т.п. В этом случае записывается только значение указателя, которое не имеет никакого смысла для того, кто потом эти данные читает: такой указатель указывает либо в никуда, либо на данные, никакого отношения к строке не имеющие.

Для иллюстрации этой проблемы, а также методов её решения нам понадобятся два проекта: RecordRead и RecordWrite (лежат в папке RecordReadWrite). Обойтись одним проектом здесь нельзя - указатель, переданный в пределах проекта, остаётся корректным, поэтому проблема маскируется. В проекте RecordWrite три кнопки, соответствующие трём методам сохранения записи в поток TFileStream (в файлы Method1.stm, Method2.stm и Method3.stm соответственно). В три целочисленных поля заносятся текущие час, минута, секунда и сотая доля секунды, строка - произвольная, введённая пользователем в Edit1. Файлы пишутся в текущую папку. В проекте RecordRead три кнопки соответствуют трём методам чтения (каждый - из своего файла). Сначала рассмотрим первый метод - как делать ни в коем случае нельзя.

В проекте RecordWrite:
type
  TMethod1Record = packed record
    Hour: Word;
    Minute: Word;
    Second: Word;
    MSec: Word;
    Msg: string;
  end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Rec: TMethod1Record;
  Stream: TFileStream;
begin
  DecodeTime(Now, Rec.Hour, Rec.Minute, Rec.Second, Rec.MSec);
  Rec.Msg := Edit1.Text;
  Stream := TFileStream.Create('Method1.stm', fmCreate);
  Stream.WriteBuffer(Rec, SizeOf(Rec));
  Stream.Free;
end;
В проекте RecordRead:
procedure TForm1.Button1Click(Sender: TObject);
var
  Rec: TMethod1Record;
  Stream: TFileStream;
begin
  Stream := TFileStream.Create('Method1.stm', fmOpenRead);
  Stream.ReadBuffer(Rec, SizeOf(Rec));
  Stream.Free;
  Label1.Caption := TimeToStr(EncodeTime(Rec.Hour, Rec.Minute, Rec.Second,
    Rec.MSec));
  Label2.Caption := Rec.Msg; { * }
end;
Примечание: В проекте RecordRead объявлена такая же запись TMethod1Record, описание которой во втором случае для краткости опущено.

Запись в файл происходит нормально, но при чтении в строке, отмеченной звёздочкой, скорее всего, возникает исключение Access violation (в некоторых случаях исключения может не быть, но вместо сообщения будет выведен мусор). Причину этого мы уже обсудили выше - указатель Msg, действительный в контексте процесса RecordWrite, не имеет смысла в процессе RecordRead, а сама строка передана не была. Без ошибок этим методом можно передать только пустую строку, потому что пустой строке соответствует указатель nil, имеющий одинаковый смысл во всех процессах. Однако метод передачи строк, умеющий передавать только пустые строки, имеет весьма сомнительную ценность с практической точки зрения.

Самый простой способ исправить ситуацию - изменить тип поля Msg на ShortString. Больше ничего в приведённом коде менять не придётся. Однако использование ShortString имеет два недостатка. Во-первых, длина строки в этом случае ограничена 255-ю символами. Во-вторых, если длина строки меньше максимально возможной, часть памяти, выделенной для структуры, не будет использована. Если средняя длина строки существенно меньше максимальной, то таких неиспользуемых кусков в потоке будет много, т.е. файл окажется неоправданно раздут. Это всегда плохо, а в некоторых случаях - вообще недопустимо, поэтому ShortString можно посоветовать только в тех случаях, когда строки имеют примерно одинаковую длину (напомним, что ShortString позволяет ограничить длину строки меньшим, чем 255, числом символов - в этом случае поле будет занимать меньше места).

С одним из этих недостатков можно бороться: если использовать в записи не ShortString, а статический массив типа Char, можно передавать строки большей, чем 255 символов, длины. Второй метод демонстрирует этот способ.

В проекте RecordWrite:
const
  MsgLen = 15;

type
  TMethod2Record = packed record
    Hour: Word;
    Minute: Word;
    Second: Word;
    MSec: Word;
    Msg: array[0..MsgLen - 1] of Char;
  end;

procedure TForm1.Button2Click(Sender: TObject);
var
  Rec: TMethod2Record;
  Stream: TFileStream;
begin
  DecodeTime(Now, Rec.Hour, Rec.Minute, Rec.Second, Rec.MSec);
  StrPLCopy(Rec.Msg, Edit1.Text, MsgLen - 1);
  Stream := TFileStream.Create('Method2.stm', fmCreate);
  Stream.WriteBuffer(Rec, SizeOf(Rec));
  Stream.Free;
end;
В проекте RecordRead:
procedure TForm1.Button2Click(Sender: TObject);
var
  Rec: TMethod2Record;
  Stream: TFileStream;
begin
  Stream := TFileStream.Create('Method2.stm', fmOpenRead);
  Stream.ReadBuffer(Rec, SizeOf(Rec));
  Stream.Free;
  Label1.Caption := TimeToStr(EncodeTime(Rec.Hour, Rec.Minute, Rec.Second,
    Rec.MSec));
  Label2.Caption := Rec.Msg;
end;
Константа MsgLen задаёт максимальную (вместе с завершающим нулём) длину строки. В приведённом примере она взята достаточно маленькой, чтобы наглядно продемонстрировать, что данный метод имеет ограничения на длину строки. Переделки по сравнению с кодом предыдущего метода минимальны: при записи для копирования значения Edit1.Text вместо присваивания нужно использовать функцию StrPLCopy. В коде RecordRead изменений (за исключением описания самой структуры) вообще нет - это достигается за счёт того, что массив Char считается компилятором совместимым с PChar, а выражения типа PChar могут быть присвоены переменным типа AnsiString - конвертирование выполнится автоматически.

Однако проблему неэффективного использования памяти мы таким образом не решили. Более того, мы до конца не решили и проблему максимальной длины: хотя ограничение на длину строки теперь может быть произвольным, всё равно оно должно быть известно на этапе компиляции. Чтобы полностью избавиться от этих проблем, необходимо вынести строку за пределы записи и сохранять её отдельно, вместе с длиной, чтобы при чтении сначала читалась длина строки, затем выделялась для неё память, и в эту память читалась строка. Именно так работает третий метод.

В проекте RecordWrite:
type
  TMethod3Record = packed record
    Hour: Word;
    Minute: Word;
    Second: Word;
    MSec: Word;
  end;

procedure TForm1.Button3Click(Sender: TObject);
var
  Rec: TMethod3Record;
  Stream: TFileStream;
  Msg: string;
  MsgLen: Integer;
begin
  DecodeTime(Now, Rec.Hour, Rec.Minute, Rec.Second, Rec.MSec);
  Msg := Edit1.Text;
  MsgLen := Length(Msg);
  Stream := TFileStream.Create('Method3.stm', fmCreate);
  Stream.WriteBuffer(Rec, SizeOf(Rec));
  Stream.WriteBuffer(MsgLen, SizeOf(MsgLen));
  if MsgLen > 0 then
    Stream.WriteBuffer(Pointer(Msg)^, MsgLen);
  Stream.Free;
end;
В проекте RecordRead:
procedure TForm1.Button3Click(Sender: TObject);
var
  Rec: TMethod3Record;
  Stream: TFileStream;
  Msg: string;
  MsgLen: Integer;
begin
  Stream := TFileStream.Create('Method3.stm', fmOpenRead);
  Stream.ReadBuffer(Rec, SizeOf(Rec));
  Stream.ReadBuffer(MsgLen, SizeOf(Integer));
  SetLength(Msg, MsgLen);
  if MsgLen > 0 then
    Stream.ReadBuffer(Pointer(Msg)^, MsgLen);
  Stream.Free;
  Label1.Caption := TimeToStr(EncodeTime(Rec.Hour, Rec.Minute, Rec.Second,
    Rec.MSec));
  Label2.Caption := Msg;
end;
Наконец-то мы получили код, который безошибочно передаёт строку, не имея при этом ограничений длины (кроме ограничения на длину AnsiString) и не расходуя понапрасну память. Правда, сам код получился сложнее. Во-первых, из записи исключено поле типа string, и теперь эту запись можно без проблем читать и писать в поток. Во-вторых, в поток после неё записывается длина строки. В-третьих - сама строка.

Параметры вызова методов ReadBuffer и WriteBuffer для чтения/записи строки требуют дополнительного комментария. Метод WriteBuffer пишет в поток ту область памяти, которую занимает указанный в качестве первого параметра объект. Если бы мы указали саму переменную Msg, то записалась бы та часть памяти, которую занимает эта переменная, т.е. сам указатель. А нам не нужен указатель, нам нужна та область памяти, на которую он указывает, поэтому указатель нужно разыменовать с помощью оператора "^". Но просто взять и применить этот оператор к переменной Msg нельзя - с точки зрения синтаксиса она не является указателем. Поэтому приходится сначала приводить её к указателю (здесь подошёл бы любой указатель, не обязательно нетипизированный). То же самое относится и к ReadBuffer: чтобы прочитанные данные укладывались не туда, где хранится указатель на строку, а туда, где хранится сама строка, приходится использовать такую же конструкцию. И обратите внимание, что прежде чем читать строку, нужно зарезервировать для неё память с помощью SetLength.

Вместо приведения строки к указателю с последующим его разыменованием можно было бы использовать другие конструкции:
Stream.ReadBuffer(Msg[1], MsgLen);
и
Stream.WriteBuffer(Msg[1], MsgLen);
Это даёт нужный результат и даже является более наглядным: действительно, при чтении и записи мы используем ту область памяти, которая начинается с первого символа строки, т.е. ту, где хранится сама строка. Но этот способ менее производителен: когда мы обращаемся к отдельным символам строки, производится неявный вызов UniqueString, потому что компилятор не знает, что мы будем делать с этим символом и на всякий случай подстраховывается, чтобы не было побочных изменений других строк. В нашем случае мы и так защищены от побочных изменений (при записи строка не меняется, при чтении она и так уникальна - это обеспечивает SetLength), поэтому мы вполне можем обойтись без этой в данном случае излишней опеки со стороны компилятора.

Примечание: Если сделать MsgLen не независимой переменной, а полем записи, можно сэкономить на одном вызове ReadBuffer и WriteBuffer.

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

Выше мы говорили о том, что копирование записей, содержащих поля типа AnsiString, в рамках одного процесса маскирует проблему, т.к. указатель остаётся допустимым и даже (какое-то время) правильным. Но сейчас с помощью приведённого ниже кода (пример RecordCopy) мы увидим, что проблема не исчезает, а просто становится менее заметной.
type
  TSomeRecord = record
    SomeField: Integer;
    Str: string;
  end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Rec: TSomeRecord;
  S: string;

procedure CopyRecord;
var
  LocalRec: TSomeRecord;
begin
  LocalRec.SomeField := 10;
  LocalRec.Str := 'Hello!!!';
  UniqueString(LocalRec.Str);
  Move(LocalRec, Rec, SizeOf(TSomeRecord));
end;

begin
  CopyRecord;
  S := 'Good bye';
  UniqueString(S);
  Label1.Caption := Rec.Str;
  Pointer(Rec.Str) := nil;
end;
На экране вместо ожидаемого "Hello!!!" появится "Good bye". Это происходит вот почему: процедура Move осуществляет простое побайтное копирование одной области памяти в другую, механизм изменения счётчика ссылок при этом не срабатывает. В результате менеджер памяти не будет знать, что после завершения локальной процедуры CopyRecord остаются ссылки на строку "Hello!!!". Память, выделенная этой строке, освобождается. Но Rec.Str продолжает ссылаться на эту уже освобождённую память. Для строки S выделяется свободная память - та самая, где раньше была строка LocalRec.Str. А так как Rec.Str продолжает ссылаться на эту область памяти, поэтому обращение к ней даёт строку "Good bye", которая теперь там размещена.

Обратите внимание на последнюю строку - приведение Rec.Str к типу Pointer и обнулению. Это сделано для того, чтобы менеджер памяти не пытался финализировать строку Rec.Str после завершения процедуры, иначе он попытается освободить память, которая уже освобождена, и возникнет ошибка.

Чтобы показать, насколько коварна эта ошибка, рассмотрим следующий код (из того же примера):
procedure TForm1.Button2Click(Sender: TObject);
var
  Rec: TSomeRecord;
  S: string;

procedure CopyRecord;
var
  LocalRec: TSomeRecord;
begin
  LocalRec.SomeField := 10;
  LocalRec.Str := 'Привет!';
  Move(LocalRec, Rec, SizeOf(TSomeRecord));
end;

begin
  CopyRecord;
  S := 'Пока!';
  Label1.Caption := Rec.Str;
end;
От предыдущего случая он отличается только тем, что в нём нет вызовов UniqueString, и строки указывают на константы в сегменте кода, которые никогда не удаляются. На экране получаем вполне ожидаемое "Привет!". Обнулять указатель здесь уже нет смысла, потому что освобождать константу менеджер памяти не будет. Так ошибка оказалась скрытой.

Продолжим наши эксперименты. Запустим пример RecordCopy и понажимаем попеременно кнопки Button1 и Button2. Мы видим, что результат не зависит от порядка, в котором мы нажимаем кнопки.

Модифицируем код: в локальной процедуре обработчика Button1Click уберём из строки "Hello!!!" восклицательные знаки, сократив её до "Hello". Теперь можно наблюдать интересный эффект: если после запуска нажать сначала Button1, то никаких изменений мы не заметим. А вот если кнопка Button2 будет нажата раньше, чем Button1, то при последующих нажатиях Button1 никаких видимых эффектов не будет. Это связано с тем, что теперь строка "Hello" не равна по длине строке "Good bye", поэтому разместится ли "Good bye" в том же месте памяти, где раньше была "Hello", или в каком-то другом, зависит от истории выделения и освобождения памяти. Если мы начинаем "с чистого листа", память после строки "Hello" остаётся свободной, поэтому туда можно поставить более длинную строку. А вот если раньше память уже выделялась и освобождалась (внутри методов TLabel), то тот кусочек свободной памяти, который достаточен для "Hello", слишком мал для "Good bye", и эта строка размещается в другом месте. А там, куда указывает Rec.Str, остаётся мусор, работать с которым нормально невозможно, поэтому при попытке присвоить это свойству Label1.Caption последнее не меняется.

Примечание: Если увеличить длину строки "Привет!" хотя бы на один символ, чтобы она была не короче, чем "Good bye" (или наоборот, сократить его так, чтобы оно стало короче "Hello"), мы снова увидим, что порядок нажатия кнопок не влияет на результат. Это происходит потому, что строка "Hello" размещается там, где раньше была строка "Привет!", а вот "Good bye" там уже не помещается. Если же обе строки там помещаются (или обе не помещаются), они снова оказываются в одной области памяти. Внимательный читатель может спросить: а при чём здесь длина строки "Привет!", если эта строка хранится в сегменте кода и никогда не освобождается? Дело в том, что когда мы присваиваем эту строку свойству Label1.Caption, внутри методов TLabel происходит её перенос в динамическую память для внутренних нужд этого класса.

Даже на таком простом примере видно, насколько коварна эта ошибка и как незначительные изменения в коде могут кардинально изменить её проявления. Между тем приведённый здесь код - плод долгого "приручения" этой ошибки, чтобы она всегда проявлялась предсказуемым образом. Но даже сейчас мы не можем дать полной гарантии, что у кого-то из читателей из-за какой-то неучтённой мелочи не возникнет ситуация, когда эта ошибка проявляется как-то по-другому (хотя вероятность этого мала). В реальных проектах всё гораздо сложнее, и поведение программы из-за этой ошибки может стать таким неожиданным, а проявление этой ошибки - настолько далёким от того места, где она сделана, что впору будет прыгать вокруг компьютера с бубном, изгоняя бесов. Чтобы не оказаться в таком положении, нужно очень аккуратно работать со строками (а также с другими автоматически финализируемыми типами: динамическими массивами, интерфейсами, вариантами), чтобы тот код, который неявно генерирует компилятор, не оказался в тупике. Чаще всего проблемы возникают при побайтном копировании переменной типа AnsiString (не обязательно в составе записи) или работе с ней как с указателем другого типа. Это не значит, что приводить AnsiString к другим указателям категорически нельзя - выше мы уже делали это, и вполне успешно. Но применяя любой низкоуровневый механизм к таким строкам, разработчик должен чётко представлять, как это отразится на внутренних механизмах работы с ними. Иначе - вот такая непонятная ошибка.