Тонкости работы со строками в Delphi - Использование ShareMem

ОГЛАВЛЕНИЕ


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