7 февраля 2018 г.

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

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

3 комментария :

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

    ОтветитьУдалить
  2. > счётчик ссылок равный –1
    - одно печально, если передавать литерал в качестве параметра и параметр не имеет модификатора const, сразу выполняется копирование(в D7 как минимум).
    А я так надеялся на транзитивность copy-on-write...

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.