Тонкости работы со строками в Delphi

ОГЛАВЛЕНИЕ



Виды строк в Delphi

Для работы с кодировкой ANSI в Delphi существует три вида строк - AnsiString, ShortString и PChar. Между собой они различаются тем, где хранится строка и как выделяется и освобождается память для неё. Зарезервированное слово string по умолчанию означает тип AnsiString, но если после неё стоит число в квадратных скобках, то это означает тип ShortString, а число - ограничение по длине. Кроме того, существует опция компилятора Huge strings (управляется также директивами компилятора {$H+/-} и {$LONGSTRINGS ON/OFF}), которая по умолчанию включена, но если её выключить, то слово string станет эквивалентно ShortString или, что то же самое, string[255]. Эта опция введена для обратной совместимости с Turbo Pascal, в новых программах отключать её нет нужды.

Наиболее просто устроен тип ShortString. Это - массив символов с индексами от 0 до N, где N - число символов, указанное при объявлении переменной (в случае использования идентификатора ShortString N явно не указывается и равно 255). Нулевой элемент массива хранит текущую длину строки, которая может быть меньше или равна объявленной (эту длину мы будем далее обозначать M), элементы с индексами от 1 до M - символы, составляющие строку. Значения элементов с индексами M+1..N не определены. Все стандартные функции для работы со строками игнорируют эти символы. В памяти такая переменная всегда занимает N+1 байт.

Ограничения типа ShortString очевидны: так как на хранение длины отводится только один байт, такая строка не может содержать больше 255-ти символов. Кроме того, такой способ записи длины не совпадает с принятым в Windows, поэтому ShortString несовместим с системными строками.

В системе приняты так называемые нуль-терминированные строки: строка передаётся указателем на её первый символ, длина строки отдельно нигде не хранится, признаком конца строки считается встретившийся в цепочке символов #0. Длина таких строк ограничена только доступной памятью и способом адресации (т.е. в Windows это 4294967295 символов). Для работы с такими строками предусмотрен тип PChar. Переменная такого типа является указателем на начало строки. В литературе нередко можно встретить утверждение, что PChar=^Char, однако это неверно: тип PChar встроен в компилятор и не выводится из других типов. Это позволяет выполнять с ним операции, недопустимые для других указателей. Во-первых, если P - переменная типа PChar, то допустимо обращение к отдельным символам строки с помощью конструкции P[N], где N - целочисленное выражение, определяющее номер символа (в отличие от типа ShortString, здесь символы нумеруются с 0, а не с 1). Во-вторых, к указателям типа PChar разрешено добавлять и вычитать целые числа, смещая указатель на соответствующее количество байт вверх или вниз (здесь речь идёт только об использовании операторов "+" и "-"; адресная арифметика с помощью процедур Inc и Dec доступна для любых типизированных указателей, а не только для PChar).

При использовании PChar программист целиком и полностью отвечает за выделение памяти для строки и за её освобождение. Именно это и служит основным источником ошибок у новичков: они пытаются работать с такими строками так же, как и с AnsiString, надеясь, что операции с памятью будут выполнены автоматически. Это очень грубая ошибка, способная привести к самым непредсказуемым последствиям.

Хотя программист имеет полную свободу выбора в том, как именно выделять и освобождать память для нуль-терминированных строк, в большинстве случаев самыми удобными оказываются специально предназначенные для этого функции StrNew, StrDispose и т.п. Их удобство заключается в том, что менеджер памяти выделяет памяти чуть больше, чем требуется для хранения строки, и в эту дополнительную память записывается, сколько байт было выделено. Благодаря этому функция StrDispose удаляет ровно столько памяти, сколько было выделено, даже если в середину выделенного блока был записан символ #0, уменьшающий длину строки.

Компилятор также позволяет рассматривать статические массивы типа Char, начинающиеся с нулевого индекса, как нуль-терминированные строки. Такие массивы совместимы с типом PChar, что позволяет обойтись без использования динамической памяти при работе со строками.

Тип AnsiString объединяет достоинства типов ShortString и PChar: строки имеют фактически неограниченную длину, заботиться о выделении памяти для них не нужно, в их конец автоматически добавляется символ #0, что делает их совместимыми с системными строками (впрочем, эта совместимость не абсолютная; как и когда можно использовать AnsiString в функциях API, можно прочитать здесь).

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

Счётчик ссылок позволяет реализовать то, что называется copy-on-demand, копирование по необходимости. Если у нас есть две переменные S1, S1 типа AnsiString, присваивание вида S1 := S2 не приводит к копированию всей строки. Вместо этого в указатель S1 копируется значение указателя S2, а счётчик ссылок строки увеличивается на единицу. В дальнейшем, если одну из этих строк потребуется изменить, она сначала будет скопирована (а счётчик ссылок оригинала, естественно, уменьшен) и только потом изменена, чтобы это изменение не затрагивало остальные переменные.

Ниже мы рассмотрим, какие проблемы могут возникнуть при использовании строк разного вида.



Хранение констант

Для работы с этим примером нам понадобится на форму положить пять кнопок и написать следующие обработчики для них (пример Constants):
procedure TForm1.Button1Click(Sender: TObject);
var
  P: PChar;
begin
  P := 'Xest';
  P[0] := 'T'; { * }
  Label1.Caption := P;
end;

procedure TForm1.Button2Click(Sender: TObject);
var
  S: string;
  P: PChar;
begin
  S := 'Xest';
  P := PChar(S);
  P[0] := 'T'; { * }
  Label1.Caption := P;
end;

procedure TForm1.Button3Click(Sender: TObject);
var
  S: string;
begin
  S := 'Xest';
  S[1] := 'T';
  Label1.Caption := S;
end;

procedure TForm1.Button4Click(Sender: TObject);
var
  S: ShortString;
begin
  S := 'Xest';
  S[1] := 'T';
  Label1.Caption := S;
end;

procedure TForm1.Button5Click(Sender: TObject);
var
  S: ShortString;
  P: PChar;
begin
  S := 'Xest';
  P := @S[1];
  P[0] := 'T';
  Label1.Caption := P;
end;
В этом примере только нажатие на третью и четвёртую кнопку приводит к появлению надписи Test., Первые два обработчика вызывают исключение Access violation в строках, отмеченных звёздочками, а при нажатии пятой кнопки программа обычно работает без исключений (хотя в некоторых случаях оно всё же может возникнуть), но к слову "Test" добавляется какой-то мусор. Разберёмся, почему так происходит.

Все строковые константы, встречающиеся в программе, компилятор размещает в сегменте кода, в области, управление которой никогда не передаётся. Встретив в первом обработчике константу 'Test' и определив, что она относится к типу PChar, компилятор выделяет в этой области пять байт (четыре значащих символа и один завершающий ноль), а в указатель P заносится адрес этой константы. Сегмент кода доступен только для чтения, прав на его изменение система программе в целях безопасности не даёт, поэтому попытка изменить то, что находится в этом сегменте, приводит к закономерному результату - Access violation.

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

В третьем случае константа, как и раньше, размещается в сегменте кода. Счётчик ссылок у таких констант всегда равен -1 - это значение указывает менеджеру памяти, что это константа, которая не может быть изменена и память для которой не нужно освобождать. Поэтому при любой попытке изменить переменную, которой присвоена такая константа, срабатывает механизм копирования по необходимости: для строки выделяется место в динамической памяти, затем значение константы копируется в эту область, обновляется значение указателя S, а затем выполняется изменение копии, находящейся в динамической памяти. Так как эта память доступна и для чтения, и для записи, исключение не возникает и всё работает так, как и было задумано.

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

В пятом случае мы получаем указатель на этот участок стека. Обратите внимание, что приведение типов в данном случае не работает: для записи в P адреса первого символа строки приходится использовать оператор получения адреса @. Модификация строки проходит, как и в предыдущем случае, успешно, но при присваивании выражения типа PChar свойству типа AnsiString длина строки определяется по правилам, принятым для PChar, т.е. строка сканируется до обнаружения нулевого символа. Но так как ShortString "не отвечает" за то, что будет содержаться в неиспользуемых символах, там может остаться всякий мусор от предыдущего использования стека. Никакой гарантии, что сразу после последнего символа будет #0, нет. Отсюда и появление непонятных символов на экране.

Общий вывод таков: пока мы не вмешиваемся в работу компилятора с типами ShortString и AnsiString, получаем ожидаемый результат. Работа с этими же строками через PChar в обход стандартных механизмов приводит к появлению проблем. Кроме того, при работе со строками PChar необходимо чётко представлять, где и как выделяется для них память, иначе можно получить неожиданную ошибку.



Сравнение строк

Для типов PChar и AnsiString, которые являются указателями, понятие равенства двух строк может толковаться двояко: либо как равенство указателей, либо как равенство содержимого памяти, на которую эти указатели указывают. Второй вариант предпочтительнее, т.к. он ближе к интуитивному понятию равенства строк. Для типа AnsiString реализован именно этот вариант, т.е. сравнивать такие строки можно, ни о чём не задумываясь. Более сложные ситуации мы проиллюстрируем примером Comparisons. В нём девять кнопок, и обработчик каждой из них иллюстрирует одну из возможных ситуаций.
procedure TForm1.Button1Click(Sender: TObject);
var
  P1, P2: PChar;
begin
  P1 := StrNew('Test');
  P2 := StrNew('Test');
  if P1 = P2 then
    Label1.Caption := 'Равно'
  else
    Label1.Caption := 'Не равно';
  StrDispose(P1);
  StrDispose(P2);
end;
В данном примере мы увидим надпись "Не равно". Это происходит потому, что в этом случае сравниваются указатели, а не содержимое строк, а указатели здесь будут разные. Попытка сравнить строки PChar с помощью оператора сравнения - весьма распространённая ошибка у начинающих. Для сравнения таких строк следует использовать специальную функцию - StrComp.

Следующий пример, на первый взгляд, в плане сравнения ничем не отличается от только что рассмотренного:
procedure TForm1.Button2Click(Sender: TObject);
var
  P1, P2: PChar;
begin
  P1 := 'Test';
  P2 := 'Test';
  if P1 = P2 then
    Label1.Caption := 'Равно'
  else
    Label1.Caption := 'Не равно';
end;
Разница только в том, что строки хранятся не в динамической памяти, а в сегменте кода. Тем не менее, на экране появится надпись "Равно". Это происходит, разумеется, не потому, что сравнивается содержимое строк, а потому, что в данном случае два указателя оказываются равны. Компилятор поступает достаточно интеллектуально: видя, что в разных местах используется одна и та же константа, он выделяет для неё место только один раз, а потом помещает в разные указатели один адрес. Поэтому сравнение даёт правильный (с интуитивной точки зрения) результат.

Такое положение дел только запутывает ситуацию со сравнением PChar: написав подобный тест, человек может сделать вывод, что строки PChar сравниваются не по указателю, а по значению, и действовать под руководством этого заблуждения.

Раз уж мы столкнулись с такой особенностью компилятора, немного отвлечёмся от сравнения строк и копнём этот вопрос немного глубже. В частности, распространяется ли интеллект компилятора на константы типа AnsiString.
procedure TForm1.Button3Click(Sender: TObject);
var
  S1, S2: string;
begin
  S1 := 'Test';
  S2 := 'Test';
  if Pointer(S1) = Pointer(S2) then
    Label1.Caption := 'Равно'
  else
    Label1.Caption := 'Не равно';
end;
В этом примере на экран будет выведено "Равно". Как мы видим, указатели равны, т.е. и здесь компилятор проявил интеллект.

Рассмотрим чуть более сложный случай:
procedure TForm1.Button4Click(Sender: TObject);
var
  P: PChar;
  S: string;
begin
  S := 'Test';
  P := 'Test';
  if Pointer(S) = P then
    Label1.Caption := 'Равно'
  else
    Label1.Caption := 'Не равно';
end;
В этом случае указатели окажутся не равны. Действительно, с формальной точки зрения константа типа AnsiString отличается от константы типа PChar: в ней есть счётчик ссылок (равный -1) и длина. Однако если забыть о существовании этой добавки, эти две константы одинаковы: четыре значащих символа и один #0, т.е. компилятор, в принципе, мог бы обойтись одной константой. Тем не менее, на это ему интеллекта уже не хватило.

Но вернёмся к сравнению строк. Как мы знаем, строки AnsiString сравниваются по значению, а PChar - по указателю. А что будет, если сравнить AnsiString с PChar?
procedure TForm1.Button5Click(Sender: TObject);
var
  P: PChar;
  S: string;
begin
  S := 'Test';
  P := 'Test';
  if S = P then
    Label1.Caption := 'Равно'
  else
    Label1.Caption := 'Не равно';
end;
Этот код выдаст "Равно". Как мы знаем из предыдущего примера, значения указателей не будут равны, следовательно, производится сравнение по содержанию, т.е. именно то, что и требуется. Если исследовать код, который генерирует компилятор, то можно увидеть, что сначала неявно создаётся строка AnsiString, в которую копируется содержимое строки PChar, а потом сравниваются две строки AnsiString. Сравниваются, естественно, по значению.

Для строк ShortString сравнение указателей невозможно, две таких строки всегда сравниваются по значению. Правила хранения констант и сравнения с другими типами следующие:

   1. Константы типа ShortString также размещаются в сегменте кода только один раз, сколько бы раз они ни повторялись в тексте.
   2. При сравнении строк ShortString и AnsiString первая сначала конвертируется в тип AnsiString, а потом выполняется сравнение.
   3. При сравнении строк ShortString и PChar строка PChar конвертируется в ShortString, затем эти строки сравниваются.

Последнее правило таит в себе подводный камень, который иллюстрируется следующим примером:
procedure TForm1.Button6Click(Sender: TObject);
var
  P: PChar;
  S: ShortString;
begin
  P := StrAlloc(300);
  FillChar(P^, 299, 'A');
  P[299] := #0;
  S[0] := #255;
  FillChar(S[1], 255, 'A');
  if S = P then
    Label1.Caption := 'Равно'
  else
    Label1.Caption := 'Не равно';
  StrDispose(P);
end;
Здесь формируется строка типа PChar, состоящая из 299 символов "A". Затем формируется строка ShortString, состоящая из 255 символов "А". Очевидно, что эти строки не равны, потому что имеют разную длину. Тем не менее, на экране появится "Равно".

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

Избежать этой ошибки поможет либо явное сравнение длины перед сравнением строк, либо приведение одной из сравниваемых строк к типу AnsiString (второй аргумент при этом также будет приведён к этому типу). Следующий пример даёт правильный результат "Не равно":
procedure TForm1.Button7Click(Sender: TObject);
var
  P: PChar;
  S: ShortString;
begin
  P := StrAlloc(300);
  FillChar(P^, 299, 'A');
  P[299] := #0;
  S[0] := #255;
  FillChar(S[1], 255, 'A');
  if string(S) = P then
    Label1.Caption := 'Равно'
  else
    Label1.Caption := 'Не равно';
  StrDispose(P);
end;
Учтите, что конвертирование в AnsiString - операция дорогостоящая в смысле процессорного времени (в этом примере будут выделены, а потом освобождены два блока памяти), поэтому там, где нужна производительность, лучше вручную сравнить длину, а ещё лучше - вообще по возможности избегать сравнения строк разных типов, так как без конвертирования это в любом случае не обходится.

Теперь зададимся глупым, на первый взгляд, вопросом: если мы приведём строку AnsiString к PChar, будут ли равны указатели? Проверим:
procedure TForm1.Button8Click(Sender: TObject);
var
  S: string;
  P: PChar;
begin
  S := 'Test';
  P := PChar(S);
  if Pointer(S) = P then
    Label1.Caption := 'Равно'
  else
    Label1.Caption := 'Не равно';
end;
Вполне ожидаемый результат - "Равно". Можно, например, перенести строку из сегмента кода в динамическую память с помощью UniqueString - результат не изменится. Однако выводы делать рано. Рассмотрим следующий пример:
procedure TForm1.Button9Click(Sender: TObject);
var
  S: string;
  P: PChar;
begin
  S := '';
  P := PChar(S);
  if Pointer(S) = P then
    Label1.Caption := 'Равно'
  else
    Label1.Caption := 'Не равно';
end;
От предыдущего он отличается только тем, что строка S имеет пустое значение. Тем не менее, на экране мы увидим "Не равно". Связано это с тем, что приведение строки AnsiString к типу PChar на самом деле не является приведением типов. Это - скрытый вызов функции _LStrToPChar, и сделано это для того, чтобы правильно обрабатывать пустые строки.

Значение '' (пустая строка) для строки AnsiString означает, что память для неё вообще не выделена, а указатель имеет значение nil. Для типа PChar пустая строка - это ненулевой указатель на символ #0. Нулевой указатель также может рассматриваться как пустая строка, но не всегда - иногда это рассматривается как отсутствие какого бы то ни было значения, даже пустого (аналог NULL в базах данных). Чтобы решить это противоречие, функция _LStrToPChar проверяет, пустая ли строка хранится в переменной, и, если не пустая, возвращает этот указатель, а если пустая, то возвращает не nil, а указатель на символ #0, который специально для этого размещён в сегменте кода. Таким образом, в случае пустой строки PChar(S) <> Pointer(S), потому что приведение строки AnsiString к указателю другого типа - это нормальное приведение типов без дополнительной обработки значения.



Побочное изменение

Из-за того, что две одинаковые строки AnsiString разделяют одну область памяти, на неожиданные эффекты можно натолкнуться, если модифицировать содержимое строки в обход стандартных механизмов. Следующий код (пример SideChange) иллюстрирует такую ситуацию:
procedure TForm1.Button1Click(Sender: TObject);
var
  S1, S2: string;
  P: PChar;
begin
  S1 := 'Test';
  UniqueString(S1);
  S2 := S1;
  P := PChar(S1);
  P[0] := 'F';
  Label1.Caption := S2;
end;
В этом примере требует комментариев процедура UniqueString. Она обеспечивает то, что счётчик ссылок на строку будет равен единице, т.е. для этой строки делается уникальная копия. Здесь это понадобилось для того, чтобы строка S1 хранилась в динамической памяти, а не в сегменте кода, иначе мы получили бы Access violation, как и во втором случае рассмотренного ранее примера Constants.

В результате работы этого примера на экран будет выедено не "Test", а "Fest", хотя значение S2, казалось бы, не должно меняться, потому что мы изменения, которые мы делаем, касаются только S1. Но более внимательный анализ подсказывает объяснение: после присваивания S2 := S1 счётчик ссылок строки становится равным двум, а сама строка разделяется двумя указателями: S1 и S2. Если бы мы попытались изменить непосредственно S1, сначала была бы создана копия этой строки, а потом были бы сделаны изменения в этой копии, а оригинал, на который указывала бы S2, остался бы без изменений. Но, использовав PChar, мы обошли механизм копирования, поэтому строка осталась в единственном экземпляре, и изменения затронули не только S1, но и S2.

В данном примере всё достаточно очевидно, но в более сложных случаях разработчик программы может и не подозревать, что строка, с которой он работает, разделяется несколькими переменными. Справка Delphi советует сначала обеспечить уникальность копии строки с помощью UniqueString и только потом работать с ней через PChar, если в этом есть необходимость.



Нулевой символ в середине строки

Хотя символ #0 и добавляется в конец каждой строки AnsiString, он уже не является признаком её конца, т.к. длина строки хранится отдельно. Это позволяет размещать символы #0 и в середине строки. Но нужно учитывать, что полноценное преобразование такой строки в PChar невозможно - это иллюстрируется примером Zero (в этом примере на форме одна кнопка и две метки):
procedure TForm1.Button1Click(Sender: TObject);
var
  S1, S2, S3: string;
  P: PChar;
begin
  S1 := 'Test'#0'Test';
  S2 := S1;
  UniqueString(S2);
  P := PChar(S1);
  S3 := P;
  Label1.Caption := IntToStr(Length(S2));
  Label2.Caption := IntToStr(Length(S3));
end;
В первую метку будет выведено число 9 (длина исходной строки), во вторую - 4. Мы видим, что при копировании одной строки AnsiString в другую символ #0 в середине строки - не помеха (вызов UniqueString добавлен для того, чтобы обеспечить реальное копирование строки, а не только копирование указателя). А вот как только мы превращаем эту строку в PChar, информация о её истинной длине теряется, и при обратном преобразовании компилятор ориентируется на символ #0, и строка обрубается.

Потеря куска строки после символа #0 происходит всегда, когда есть преобразование ShortString или AnsiString в PChar, даже неявное. Например, все API-функции работают с нуль-терминированными строками, а визуальные компоненты - просто обёртки над этими функциями, поэтому вывести с их помощью на экран строку, содержащую #0, целиком невозможно.

Но главный подводный камень, связанный с символом #0 в середине строки, заключается в том, что целый ряд стандартных функций для работы со строками AnsiString на самом деле используют API-функции (или даже библиотечные функции Delphi, предназначенные для работы с PChar), что приводит к игнорированию "хвоста" после #0. Следующий код (пример ZeroFind) иллюстрирует эту проблему:
procedure TForm1.Button1Click(Sender: TObject);
begin
  Label1.Caption := IntToStr(AnsiPos('Z', 'A'#0'Z'));
end;
Хотя символ "Z" присутствует в строке, в которой производится поиск, на экран будет выеден "0", что означает отсутствие искомой подстроки. Это связано с тем, что функция AnsiPos использует функции StrPos и CompareString, предназначенные для работы со строками PChar, поэтому поиск за символом #0 не производится. Если заменить в этом примере функцию AnsiPos на Pos, которая работает с типом AnsiString должным образом, на экран будет выведено правильное значение "3".

Описанные проблемы заставляют очень осторожно относиться к использованию символа #0 в середине строк AnsiString - это может стать источником неожиданных проблем.



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

Поля в записях могут иметь любой строковый тип без дополнительных ограничений. Однако следует учитывать, что, в отличие от полей простых типов, значения полей типа 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 к другим указателям категорически нельзя - выше мы уже делали это, и вполне успешно. Но применяя любой низкоуровневый механизм к таким строкам, разработчик должен чётко представлять, как это отразится на внутренних механизмах работы с ними. Иначе - вот такая непонятная ошибка.



Использование ShareMem

То, что мы сейчас рассмотрим - это даже не подводный камень, это то, что в форумах обычно называется "грабли". Всё новые и новые программисты с завидным упорством наступают на эти грабли и получают по лбу, хотя, казалось бы, таблички, предупреждающие об опасности, вокруг этих граблей стоят, только не ленись читать.

Итак, создаём новую динамически компонуемую библиотеку (DLL). Delphi делает нам следующую заготовку:
library Project1;

{ Important note about DLL memory management: ShareMem must be the
  first unit in your library's USES clause AND your project's (select
  Project-View Source) USES clause if your DLL exports any procedures or
  functions that pass strings as parameters or function results. This
  applies to all strings passed to and from your DLL--even those that
  are nested in records and classes. ShareMem is the interface unit to
  the BORLNDMM.DLL shared memory manager, which must be deployed along
  with your DLL. To avoid using BORLNDMM.DLL, pass string information
  using PChar or ShortString parameters. }

uses
  SysUtils,
  Classes;

{$R *.RES}

begin
end.
Самое важное здесь - комментарий. Его следует внимательно прочитать и осознать, а главное - выполнить эти советы, иначе при передаче строк AnsiString между DLL и программой вы будете получать Access violation в самых неожиданных местах. Почему-то многие им пренебрегают, а потом бегут с вопросами в разные форумы, хотя минимум внимательности и отсутствие снобизма по отношению "к этим, из Borland'а, которые навставляли тут никому не нужных комментариев" могли бы уберечь от ошибки.

Для начала разберёмся, что вообще является источником ошибки. Менеджер памяти Delphi работает следующим образом: он берёт у системы большие блоки памяти, а потом по мере необходимости выделяет их по частям. Это позволяет избежать частых выделений памяти системными средствами и тем самым повышает производительность. Следствием этого становится то, что менеджер памяти должен иметь информацию о том, какими блоками он распределил полученную от системы память между различными потребителями.

Менеджер памяти реализуется модулем System. Так как DLL компонуется отдельно от использующего её exe-файла, у неё будет своя копия кода System и, следовательно, свой менеджер памяти. И если объект, память для которого была выделена в коде основного модуля программы, попытаться освободить в коде DLL, получится, что освобождать память будет совсем не тот менеджер, который её выделил. А сделать он этого не сможет, т.к. не обладает информацией о выделенном блоке. Результат - ошибка (скорее всего, Access violation при выходе из процедуры). А при работе со строками AnsiString память постоянно выделяется и освобождается, поэтому, попытавшись работать с одной и той же строкой и в главном модуле, и в DLL, мы получим ошибку.

Теперь, когда мы поняли, почему возникает проблема, разберёмся, как ShareMem её решает. Delphi предоставляет возможность заменить стандартный менеджер памяти своим: для этого нужно написать функции выделения, освобождения и перераспределения памяти и сообщить их адреса через процедуру SetMemoryManager - после этого все функции для работы с динамической памятью будут работать через эти функции. Именно это и делает ShareMem: в секции инициализации этого модуля содержится код, заменяющий функции работы с памятью своими, причём эти функции находятся во внешней библиотеке BORLNDMM.DLL. Получается, что и библиотека, и главный модуль работают с одним менеджером памяти, что решает описанные выше проблемы.

Если менеджер памяти попытаться поменять не в самом начале программы, ему придётся освобождать память, которую успел выделить предыдущий менеджер памяти, что приведёт к той же самой проблеме. Поэтому заменить менеджер памяти нужно до того, как будет выполнена первая операция по её выделению. Отсюда возникает требование вставлять ShareMem первым модулем в dpr-файлах главного модуля и DLL - чтобы его секция инициализации была первым выполняемым программой кодом.

Кстати, к совету использовать вместо AnsiString PChar, чтобы избавиться от необходимости использования ShareMem, данному в комментарии, следует относится осторожно: если мы попытаемся, например, вызвать StrNew в основной программе, а StrDispose - в DLL, то получим ту же проблему. Вопрос не в типах данных, а в том, как манипулировать памятью.

Необходимость распространять со своей программой ещё и библиотеку BORNDMM.DLL иногда воспринимается как неудобство (хотя речь идёт только о тех ситуациях, когда программа и так вынуждена использовать другие DLL), поэтому существуют менеджеры памяти сторонних разработчиков, которые решают ту же проблему, что и ShareMem, но без использования библиотеки. Эти менеджеры памяти просто все запросы по работе с памятью передают системному менеджеру памяти, что избавляет их от необходимости хранить какую-либо информацию о выделенных блоках. Такое решение вполне работоспособно, но менее эффективно, особенно если нужно многократно выделять и освобождать небольшие блоки памяти.

Следует также упомянуть о ещё одной альтернативе передачи строк в DLL - типе WideString. Этот тип хранит строку в кодировке Unicode и является, по сути, обёрткой над системным типом BSTR. Работать с WideString так же просто, как и с AnsiString, перекодирование из ANSI в Unicode и обратно выполняется автоматически при присваивании значения одного типа переменной другого. В целях совместимости с COM и OLE для работы с памятью для строк WideString используется специальный системный менеджер памяти (через API-функции SysAllocString, SysFreeString и т.п.), поэтому передавать эти строки из DLL в главный модуль и обратно можно совершенно безопасно даже без ShareMem. Правда, при этом не стоит забывать о расходовании процессорного времени на перекодировку, если основная работа идёт не с Unicode, а с ANSI.

Отметим одну ошибку, которую делают новички, прочитавшие комментарий про ShareMem, но не умеющие работать с PChar. Они пишут, например, такой код для функции, находящейся в DLL и возвращающей строку:
function SomeFunction(...): PChar;
var
  S: string;
begin
  // Здесь присваивается значение S
  Result := PChar(S);
end;
Такой код компилируется и даже, за редким исключением, даёт ожидаемый результат. Но тем не менее, в этом коде грубая ошибка. Указатель, возвращаемый функцией, указывает на область памяти, которая считается свободной - после того как переменная S вышла за пределы области видимости, память, которую занимала эта строка, освободилась. Менеджер памяти может в любой момент вернуть эту память системе (тогда обращение к ней вызовет Access violation) или задействовать для других целей (тогда новая информация перетрёт содержащуюся там строку). Проблема маскируется тем, что обычно результат используется немедленно, до того как менеджер памяти что-то сделает с этим блоком. Тем не менее, полагаться на это и писать такой код не стоит.

Под использованием PChar в комментарии имеется ввиду использование его таким образом, как он используется в API-функциях: программа выделяет память для буфера, указатель на этот буфер передаёт в DLL как PChar, а DLL только заносит в этот буфер требуемое значение.

Вместо заключения

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