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

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

Читать на сайте автора.