Интерпретируйте результаты экспериментов правильно

К моему переводу статьи Руди о 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;








Ответить
Хотите присоединиться к обсуждению?Не стесняйтесь вносить свой вклад!