Интерпретируйте результаты экспериментов правильно
К моему переводу статьи Руди о String
и PChar
добавили замечательный комментарий, на который мне хотелось бы ответить, но объём заблуждений не позволяет это сделать простым ответом в комментарии.
Текст из статьи, реакцией на который был написан комментарий, видимо, такой:
Это означает, что вы не должны использовать
PChar
для указания на строки, а затем изменять строку. Лучше всего избегать таких вещей:// ПРЕДУПРЕЖДЕНИЕ: ПЛОХОЙ ПРИМЕР
var
S: string;
P: PChar;
begin
S := ParamStr(0); // например, нам вернули 'C:Test.exe';
P := PChar(S);
S := 'Something else';Если
S
изменяется на'Something else'
, указательP
не будет изменён и будет продолжать указывать на'C:Test.exe'
. ПосколькуP
не является строковой (в смыслеString
) ссылкой на этот текст, и у нас нет никакой другой переменной, ссылающейся на него, то его счётчик ссылок станет равным 0, и текст будет удалён из памяти. Это означает, чтоP
теперь указывает на недоступную память.
Сам же комментарий:
Michael Shamrovsky
По факту, командаPChar(S)
, гдеS: String
ничего плохого не сделает, даже если мы после присвоения переменной типаPChar
, поменяем строку. Наша куча символов не измениться, и будет указывать на живую страницу.var
S: String;
P: PChar;
begin
S := 'Hallo!';
P := PChar(S);
ShowMessage(P);
Delete(S, 2, 1);
ShowMessage(P);
end;Результат — на экране дважды
Hello!
, хотя по Вашим словам будет ЖОПА!Учим мат часть! В начале выделяется новая страница, в ней образуется копия строки но без лидирующих 4х байт, затем дается ссылка.
PChar
это не просто преобразователь типов, но и копировщик строки!!!
Если вы хотите, вы можете самостоятельно выяснить, что не так с этим экспериментом. Считайте это очередной задачкой. Либо листайте ниже для ответа.
Оставим сладкое («дважды Hello!
«) на конец, а пока давайте начнём со слона в комнате: «PChar
это не просто преобразователь типов, но и копировщик строки!!!«. Это весьма сильное утверждение, которое легко проверить: достаточно посмотреть исходный код подпрограммы преобразования String
к PChar
. Для этого достаточно запустить программу, остановиться на строке присваивания и нажать F7 (вход в подпрограмму) — при условии, конечно, что у вас включены отладочные DCU для системного кода (Use Debug DCUs):
Как видим, ни о каком копировании здесь речи не идёт: происходит прямое копирование указателя. Для надёжности можно проверить в машинном отладчике, что никакого иного кода (кроме вызова _UStrToPWChar
) в строке присвоения нет:
Упражнение: почему в машинном отладчике процедура называется @UStrToPWChar
, а не _UStrToPWChar
?
Хм, но если строка не копируется, а P
указывает на S
, то почему-же при изменении S
не меняется P
?
Ошибка в рассуждениях состоит в том, что предполагается, что операция изменения (в данном случае — Delete
) произведёт операцию с самой строкой. Но на деле Delete
сделает копию строки и изменит её. Оригинальная строка при этом не изменится. Соответственно, P
продолжит указывать на оригинальную (не изменённую) строку.
Как я узнал, что Delete
делает копию? Отложим этот вопрос на потом, я подробно покажу это, а пока поверьте мне на слово.
Постойте, но почему Delete
делает копию? И что, в таком случае, будет являться оригиналом строки? В какой переменной он хранится, этот оригинал? Неужели, в P
и Михаил был прав?
Наводящий вопрос: а где, собственно, хранится сам текст 'Hallo!'
? В строку он же должен попасть откуда-то.
Возможно, я открою тайну, но: сложные константы хранятся в специальном сегменте данных программы. Поскольку константы не должны меняться, то этот сегмент имеет атрибуты «только для чтения» (read only). Это означает, что любая попытка записать что-то в этот участок памяти приведёт к возбуждению исключения EAccessViolation
. Вот почему Delete
делает копию строки — потому что оригинальную строку изменить нельзя.
Наверное, к этому моменту возникает много вопросов: не слишком ли много я делаю допущений? И что Delete
делает копию? И что строка хранится в блоке констант, а не в переменной (т.е. не копируется при присваивании)?
Ну, давайте посмотрим, как мы можем это подтвердить. Вспомним, что у строк типа String
есть счётчик ссылок. Он увеличивается на 1 при копировании строк и уменьшается при выходе переменной за область видимости. Свежесозданные строки имеют счётчик ссылок равный 1, а удаляются из памяти, когда счётчик опускается до нуля. Соответственно, если счётчик равен 1, то на строку ссылается одна переменная (и тогда её можно спокойно модифицировать), а если счётчик больше 1, то на строку ссылается две или более переменных (и тогда строку модифицировать нельзя, надо сначала сделать копию).
Как мы можем узнать счётчик ссылок?
Если у вас относительно новая Delphi, то в ней должна быть функция StringRefCount
, а если очень старая — то нужно вспомнить внутреннее устройство строк. Строка является указателем, который указывает на начало данных строки (символов). Непосредственно перед этими данными лежит служебный заголовок — в котором, помимо всего прочего, хранится счётчик ссылок строки. Служебный заголовок представлен записью (record
) TStrRec
в модуле System
.
Таким образом, если у нас есть строка S
, то, чтобы узнать её счётчик ссылок, нужно преобразовать её в указатель (например: PAnsiChar(S)
в старых Delphi или PByte(S)
— в новых; PAnsiChar
/PByte
здесь нужен только чтобы иметь адресную арифметику), затем отступить на размер заголовка ( - SizeOf(TStrRec)
), разыменовать указатель (Pointer(...)^
), обозначить, что это — заголовок (TStrRec(...)
) и, наконец, прочитать счётчик ссылок (.refCnt
). Итого, вы можете добавить в Watches такое выражение:
TStrRec(Pointer(PByte(S) - SizeOf(TStrRec))^).refCnt
где S
— это ваша переменная типа String
.
Примечание: если вы захотите использовать просто StringRefCount
вместо этого страшного выражения, то вам нужно будет вставить вызов этой функции в любое место — этого будет достаточно, чтобы Delphi поместила машинный код функции в вашу программу. По умолчанию эта функция нигде не вызывается, её код в вашу программу не попадает, соответственно, вызывать то, чего нет, — нельзя. Ах, да, галку «Allow side effects and function calls» надо будет ещё установить в свойствах Watch-а.
Посмотрим же, чему будет равен счётчик ссылок. Запустим программу. Сначала строка не инициализирована, т.е. указывает на nil
, и счётчика ссылок у неё просто нет.
Но после присвоения:
Счётчик ссылок стал равен… минус единице. Несложно сообразить, что -1 в данном случае является специальным флагом, который указывает, что память для строки не была выделена в куче (в противном случае счётчик ссылок имел бы значение просто 1), т.е. что строка является константой. Именно на этот признак смотрит Delete
, когда принимает решение о необходимости копировать строку.
Пройдёмся дальше по коду. До вызова Delete
всё скучно, нет никаких изменений, счётчик ссылок по-прежнему равен -1. А вот после вызова Delete
:
Счётчик становится равным 1 — именно потому, что Delete
сделала копию строки. Т.е. выделила в куче новый блок памяти, счётчик ссылок которого будет равен 1. Чтобы он мог опуститься до 0, когда S
выйдет из поля видимости. И при этом память, которую выделила Delete
, будет освобождена.
А что же P
? А P
мы не меняли. Она по-прежнему указывает на «старую S
«, которая, как мы помним является константой. Таким образом, в памяти программы будет две строки: одна — 'Hallo!'
(в области памяти для констант, на неё указывает P
), а другая — 'Hllo!'
(в динамической куче, на неё указывает S
). Как видим, никакого копирования памяти P
не выполняет. Михаил ошибся.
Вот ещё пара экспериментов, которые можно провести: во-первых, попробуйте записать что-то в P
:
var
S: String;
P: PChar;
begin
S := 'Hallo!';
P := PChar(S);
ShowMessage(P);
Delete(S, 2, 1);
ShowMessage(P);
P^ := 'A'; // - добавили; должно изменить первый символ строки
ShowMessage(P); // - добавили
end;
Если вы запустите этот код, то получите исключение EAccessViolation
при попытке записи в P
. Происходит это именно потому, что P
указывает на read-only область памяти констант, куда невозможно произвести запись. Если бы P
указывала на строку S
(которая размещена в read-write памяти кучи), то операция записи в P
была бы успешна.
Во-вторых, вы можете зайти в Delete
по F7:
Как вы можете видеть, Delete
первым действием проверяет, нужно ли копировать строку:
И строка копируется, если её счётчик ссылок отличен от 1 (т.е. в том числе — когда он равен минус единице).
Вот, собственно, и всё. Правильно трактуйте результаты своих экспериментов.
P.S.
Возможно, если бы Михаил дочитал статью до конца, он бы и сам догадался:
Я не рассказал всё, что известно, и даже, возможно, немного переврал правду (например, не любая строка типа
String
управляется счётчиком ссылок – к примеру, строковые константы всегда имеют счётчик ссылок равный –1), но эти мелкие детали не столь важны для общей, большой картины и не оказывают влияния на использование и взаимодействие междуString
-ми иPChar
-ми.
P.P.S.
Упражнение: чем отличается модифицирование строки указанными способами?
var
S: String;
P: PChar;
begin
S := { ... };
P := S;
// Способ 1:
S[1] := 'A';
// Способ 2:
P^ := 'A';
end;
Ответить
Хотите присоединиться к обсуждению?Не стесняйтесь вносить свой вклад!