Сообщений 7    Оценка 185 [+0/-1]         Оценить  
Система Orphus

Длинные строки и динамические массивы в Delphi

Автор: Вишневский Павел
http://www.nsk.su/~pvs

Источник: RSDN Magazine #3-2004
Опубликовано: 12.12.2004
Исправлено: 13.03.2005
Версия текста: 1.0
Реализация
Присваивание и передача в качестве параметра
Длинные строки
Динамические массивы
Локальная переменная Result
Переменные типа строки и динамические массивы все-таки указатели
Хранение в переменных другого типа
Многомерные динамические массивы
Ошибка компилятора Delphi
Функции для работы с массивами

PvsLib for Delphi

Среди типов переменных в Delphi есть несколько типов, существенно отличающихся от обычных.

ПРИМЕЧАНИЕ

В данной статье речь по сути идет о Delphi версии 7 и ниже. В Delphi.NET все, что связано с массивами и строками основано на .NET Framework и можно говорить скорее об эмуляции поведения массивов и строк. Их реализация же совершенно отличается от того, что было в Delphi прошлых версий. – прим.ред

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

Рассмотрим следующий код:

procedure E1()
var
  I: integer;
  S: string;
  A: TIntegerDynArray;
begin
  // Перед присваиванием...
end;

Реализуемый компилятором код выглядит приблизительно так:

procedure E1_D()
var
  I: integer;
  S: string;
  A: TIntegerDynArray;
begin
  Pointer(S) := nil;
  Pointer(A) := nil;
  // Перед присваиванием...
  _DynArrayClear(A, typeinfo(TIntegerDynArray));
  _LStrClr(S);
end;

Что показывает приведенный пример:

  1. Переменная I, имеющая значение типа Integer, расположена в стеке и содержит случайный мусор. В отличие от переменной простого типа, для переменных S и A (являющихся указателями, также располагающимися в стеке), компилятор всегда вставляет код, инициализирующий их в nil.
  2. При присваивании длинным строкам значений или изменении размера массива компилятор вставляет код, динамически выделяющий область памяти, и присваивает указатель на нее этой переменной. То есть само содержимое строки или массива располагается в динамической памяти.
  3. Перед выходом из процедуры компилятор вставляет специальные функции финализации, ответственные за освобождение выделенной динамической памяти.
  4. И, наконец, компилятор заменяет почти все операции с этими переменными на свои системные функции.

Реализация

В начале выделяемой области памяти содержится заголовок, используемый системными функциями Delphi (определен в System.pas) для управления переменными обсуждаемых типов. Заголовок описывается записью TPvsDynRec. Вот ее описание:

Type
  TPvsDynRec = packed record
{$IFDEF MSWINDOWS}
    sizeFlags: Integer;
{$ENDIF}
    refCnt: Integer;
    length: Integer;
  end;
  PPvsDynRec = ^TPvsDynRec;

Для строк и массивов использование полей этой структуры несколько отличается, но сам принцип одинаковый. Вот описание этих полей:

Присваивание и передача в качестве параметра

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

При выходе переменной из области видимости или вызове деструктора объекта, вкотором расположена переменная, вызывается финализация, которая уменьшает счетчик и, если он равен 0, память освобождается.

При передаче динамического массива в функцию или процедуру, Delphi, в зависимости от типа передачи, вставляет системные функции, управляющие счетчиком и финализацией. При передаче по значению счетчик увеличивается. Передача по ссылке (var) или константы (const) не влияет на счетчик. Так что передача как константы (const) не только делает код более безопасным, но и оптимизирует его.

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

Длинные строки

Особенностью длинных строк является возможность присваивания константного литерала. В зависимости от того, является переменная локальной или нет, генерируется различный код.

Если происходит присваивание локальной переменной:

  procedure E2;
  var
    S: string;
  begin
    S := 'String'; //  refCnt = -1
  end;

Переменная S получит счетчик равный -1 и будет ссылаться прямо на литерал. При присваивании другой локальной переменной или передаче в процедуру счетчик никогда не меняется. Естественно, ни о каком управлении памятью в данном случае речи не идет.

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

Любое изменение строки заменяется на вызов системных функций. Если счетчик строки в этот момент не равен 1, создается новая строка. Исключением из этого правила является доступ к содержимому строки по указателю (приведение к типу PChar). В этом случае уже ничего не контролируется. Ниже приведены два примера, иллюстрирующих такое поведение.

procedure E3;
var
  S1, S2, S3: string;
begin
  S1 := 'q';      // refCnt = -1
  S2 := S1 + 'w'; // refCnt = 1
  S3 := S2;
  // Сейчас в памяти располагаются 2 строки 
  // 'q' refCnt = -1 (ссылка на которую содержится в S1)
  // 'qw' refCnt = 2 (ссылка на которую содержится в S2, S3)
  S3[1] := '1';
  // А сейчас уже три разных строки 
  // 'q'; refCnt = -1 (ссылка на которую содержится в S1)
  // 'qw' refCnt = 1  (ссылка на которую содержится в S2)
  // '1w' refCnt = 1  (ссылка на которую содержится в S3)
end;

procedure E4;
var
  S1, S2, S3: string;
begin
  S1 := 'q';      // refCnt = -1
  S2 := S1 + 'w';	 // refCnt = 1
  S3 := S2;
  // Сейчас в памяти располагаются 2 строки
  // S1 = 'q'  refCnt = -1 
  // S2 = S3 = 'qw'  refCnt = 2
  PChar(S3)[0] := '1'; 
  // Сейчас также две строки 
  // S1 = 'q';     refCnt = -1
  // S2 = S3 = '1w'  refCnt = 2 т.е. изменились обе строки
end;

К изменению строки через PChar нужно относится с осторожностью. Рассмотрим код:

procedure E5;
var
  S: string;
begin
  S := 'qqqqqqq'; // refCnt = -1
  PChar(S)[0] := '1';
end;

Это код правильно скомпилируется, но при выполнении выдаст ошибку нарушения доступа. Причина в том, что строка S (refCnt = -1) находится в сегменте памяти, защищенном от записи.

Поэтому казалось бы, безобидная процедура:

procedure E6(S: string)
begin
  if length(S) > 0 then
    PChar(S)[0] := 'q';
  //...
end;

вызовет ошибку нарушения доступа при передаче в нее строки с refCnt = -1.

Чтобы получить уникальную ссылку для строки, состоящей из некоторой последовательности символов, можно воспользоваться функцией UniqueString. Это позволяет ускорить вычисления со строками, так как при этом можно будет сравнивать строки, просто сравнивая указатели на них. У таких строк refCnt всегда равен 1.

Динамические массивы

В отличие от строк, динамический массив не может инициализироваться литералами, поэтому он не может иметь refCnt, равный -1. Причем Delphi уже не контролирует изменение содержимого массива, а создает новый массив только при попытке изменения его размера (причем даже если сам размер не меняется), если refCnt > 1.

procedure E7;
var
  A1, A2, A3: TIntegerDynArray;
begin
  SetLength(A1, 1);
  A1[0] := 5;
  A2 := A1;
  A3 := A1;
  // В этот момент существует один экземпляр массива, на который ссылаются 
  // переменные A1, A2 и A3. Элемент с индексом 0 содержит значение 5.
  // refCnt этого массива равен 3.
  A1[0] := 1;
  // Элемент 0 массива устанавливается в 1, refCnt при этом не изменяется.
  SetLength(A1, 1);
  // Предыдущая строка создает копию массива. При этом A1 указывает на эту 
  // копию, а A2 и A3 указывают на исходный экземпляр. 
  A1[0] := 2;
  // Предыдущая строка изменяет значение нулевой ячейки копии массива.
  // Исходный массив, на который ссылаются А2 и А3, не изменяется.
end;

В этом смысле поведение массива и строки принципиально отличается. Для иллюстрации этого различия рассмотрим две процедуры:

procedure E8(S: string);
begin
  if Length(S) > 0 then
    S[1] := 't';
end;

procedure E9(A: TIntegerDynArray);
begin
  if Length(A) > 0 then
    A[0] := 100;
end;

После выполнения E8 переданная строка не изменится, так как при S[1] := ‘t’ будет создана новая строка. Вызов же E9 приведет к изменению содержимого массива.

Рассмотрим более интересный пример. Пусть массив A не пуст.

procedure E10(A: TIntegerDynArray);
begin
  A[0] := 100;
  SetLength(A, Length(A));
end;

procedure E11(A: TIntegerDynArray);
begin
  SetLength(A, Length(A));
  A[0] := 100;
end;

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

Рассмотрим, почему так происходит. При вызове любой из этих процедур A - локальная переменная на стеке, указывающая на переданный массив. Пусть входной массив A = [0,1,2,3,4].

procedure E10_D(A: TIntegerDynArray);
begin
  A[0] := 100;  // изменяет содержимое массива A [100,1,2,3,4]
  SetLength(A, Length(A)); 
  // так как счетчик ссылок явно больше 1, 
  // A теперь ссылается на новый массив, копируя в него содержимое старого.
  // С этого момента любые изменения массива A никак не скажутся на 
  // входном массиве, а сам новый массив будет освобожден в конце процедуры.
  A[1] := 10;  // изменяет содержимое уже нового массива A [100,10,3,4]
end;

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

Эта разница особенно заметна при передаче как константным параметром:

procedure E12(const S: string; const A: TIntegerDynArray);
begin
  S[1] := ‘q’;      // ошибка компиляции
  S := ‘qqqq’;      // ошибка компиляции
  A[0] := 5;        // можно !!!!! хотя формально запрещено
  SetLength(A, 5);  // ошибка компиляции
end;

Локальная переменная Result

Хотя Result есть реальная локальная переменная, ее реализация в вопросе инициализации несколько отличается от вышесказанного. Причиной является то, что она создается в вызывающей процедуре, а в вызываемую функцию передается как скрытый параметр. Рассмотрим пример:

function E13: string;
begin
 Result := Result + 'w';
end;

procedure E14;
var
 S: string;
begin
 S := 'q';
 S := E13;
 // S = 'qw'
end;

Результат может показаться неожиданным. Реализуемый Delphi код выглядит примерно так:

function E13_D(var Result: string);
begin
 Result := Result + 'w';
end;

procedure E14_D;
var
 S: string;
begin
 S := 'q';
 E13_D(S);  
 // S = 'qw'
end;

В данном случае сама переменная S используется как Result.

Несколько другой пример работает уже, как и ожидается.

procedure E15;
var
 S: string;
begin
 S := 'q';
 S := S + E13;
 // S = 'qw'
end;

Но реализуемый код аналогичен, только вводится временная переменная Tmp исполняющая роль Result

procedure E15_D;
var
 S: string;
 Tmp: string;
begin
 S := 'q';
 ResultStr1(Tmp);
 S := S + Tmp;
 // S = 'qw'
end;

Почему поведение столь различно? Во первых, локальная переменная Result реализуется и инициализируется в nil в вызывающей функции и передается в вызываемую функцию по ссылке. Во вторых, сама вызываемая функция ее уже не инициализирует. По этой причине, Result как локальная переменная, при входе в функцию может содержать любое значение.

Но полагаться на использование начального значения Result опасно, как показано в приведенных примерах, при различных вызовах одной и той же функции вы можете получить разные результаты.

Переменные типа строки и динамические массивы все-таки указатели

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

Например:

var
  S: string;
procedure E16(const Str: string);
begin
  S := ‘www’;
  // Str -???????????  Память освобождена
end;
procedure E17;
begin
  S := ‘qqqq’;
  E16(S);
end;

Каково значение Str при вызове E16(S) из E17? Формально, раз параметр передается в процедуру E16 по значению, а тем более как константа, то изменение глобальной переменной S ничего не должно изменить. Но так как параметр есть только указатель, то изменение глобальной переменной S создаст новую строку, а память под старой будет освобождена (счетчик ссылок равен 1). В данном примере это неминуемо приведет к ошибке доступа к памяти. Можно привести множество подобных примеров, так что следует лучше полагаться на знание реализации таких типов данных, чем на формальные правила языка.

Хранение в переменных другого типа

Рассмотрим код:

var
  P: Pointer;
procedure E18;
var
  S: string;
begin
  S := 'qqq';
  S[1] := 'w';
  P := Pointer(S);
end;

Спрашивается, куда указывает P после выполнения процедуры E18? Естественно на мусор, так как память под строку S по выходе из процедуры будет освобождена. По этой же причине компилятор не позволяет помещать длинные строки и динамические массивы в вариантную часть записи, он просто не знает, что реально там будет и не может вставить код для управления ссылками и памятью.

Тем не менее, иногда возникает такая необходимость.

Для решения данной проблемы нужно присваивать в предыдущем примере

  String(P) := S;

это приведет к увеличению счетчика, и все будет нормально, кроме вопроса освобождения памяти от строки, так как тип P не string и Delphi не генерирует код освобождения памяти. Вам самим придется вызвать

  Finalize(string(P));

Для массива абсолютно аналогично.

Многомерные динамические массивы

Если одномерные массивы реализуются как непрерывный блок памяти, то многомерные динамические массивы реализуются как массивы указателей на массивы.

Например, вызов SetLength(A,2,3) создает 3 массива:

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

SetLength(A, 3);
SetLength(A[0], 1);
SetLength(A[1], 2);
SetLength(A[2], 3);

Создает треугольный массив.

Ошибка компилятора Delphi

Работая еще на Delphi6, я столкнулся с очень неприятной ошибкой. Рассмотрим абсолютно безобидную функцию:

function E18(const A: TIntegerDynArray; I, J: integer): integer;
begin
  Result := A[I] - A[J];
end;

При включенной оптимизации {$O+} и отключения range-checking {$R-}, вызов этой функции приводил к ошибке доступа к памяти. Компилятор порождал, очевидно, ошибочный код:

mov eax, [eax+edx*4]
sub eax, [eax+ecx*4]

Причем эта ошибка никак не связана с динамическими массивами, то же самое происходило и с функцией:

function E19(const A: Pointer; I, J: integer): integer;
begin
 Result := PInteger(Integer(A) + I* SizeOf(Integer)) ^ -
   PInteger(Integer(A) + J* SizeOf(Integer)) ^;
end;

Когда в Borland узнали об этой ошибке, они ее быстро исправили и в Delphi6.2, она уже отсутствует. Для тех, кто продолжает работать с более старыми версиями, я советую обратить на это внимание. Для правильной работы функции E18, ее следует несколько изменить:

function E20(const A: TIntegerDynArray; I, J: integer): integer;
var
 N: integer;
begin
 N := A[I];
 Result := N - A[J];
end;

Такой код, хоть и менее оптимально, правильно компилируется во всех версиях (я надеюсь).

Функции для работы с массивами

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

Кроме того, для алгоритма быстрой сортировки, нет никакой необходимости что-либо знать о типе и содержимом массива, достаточно иметь две процедуры: сравнения и обмена. Аналогично и для двоичного поиска, необходима только функция сравнения. Это позволяет написать функции сортировки и двоичного поиска в любом массиве.

Несколько замечаний по применению:

  1. Одним из параметров этих функций является RTTI о типе массива, возвращаемая оператором TypeInfo. И действительно, как можно изменить размер массива, не зная размер его элемента, ведь в заголовке массива размер элемента не хранится. И это не искусственный прием, так поступает сама Delphi. Таких функций, как SetLength или Length фактически не существует, компилятор заменяет их своими системными функциями. В частности вызов SetLength транслируется в DynArraySetLength, с передачей ему RTTI.
  2. Элементами динамического массива могут быть длинные строки, интерфейсы, динамические массивы и т.п. Поэтому при удалении такого элемента требуется выполнить финализацию всех находящихся в нем значений, какой бы вложенности они небыли. Предлагаемые функции корректно выполняют финализацию.
  3. Функции полностью следуют стандартному поведению в отношении учета счетчика ссылок.
  4. Для работы с динамическими массивами используются те же системные процедуры, которые использует сама Delphi.


Эта статья опубликована в журнале RSDN Magazine #3-2004. Информацию о журнале можно найти здесь
    Сообщений 7    Оценка 185 [+0/-1]         Оценить