Техническая статья

Delphi против FPC: 4 скрытые ловушки в кодовой базе PDFium

Один и тот же исходный код на Object Pascal может вести себя по-разному в Delphi и FPC/Lazarus. Существует четыре ключевых архитектурных различия, которые регулярно затрагивают кодовую базу PDFium Component: FPC уничтожает временные переменные записей, возвращаемых функциями, до завершения проверки членства in; компилятор dcc32 по умолчанию собирает код без проверки диапазонов, из-за чего выход индекса за границы массива молча читает мусор; только в Delphi 13 разрешено присваивать анонимный тип array of Byte переменной TBytes без явного приведения; а конкатенация AnsiString в Delphi может искажать байты со значениями от $80 и выше из-за скрытого преобразования кодовых страниц. Каждая из этих ловушек приводит к тому, что тесты успешно проходят в одном компиляторе и завершаются с ошибками (или, что хуже, работают неверно без ошибок) в другом

Если вы впервые настраиваете кросс-компиляцию для двух компиляторов, руководство по созданию приложения просмотра в Lazarus и FPC описывает стандартный рабочий процесс: пакеты, пути поиска и вывод окна рендеринга на экран. Эта статья — прямая противоположность обычному туториалу. Это список технических проблем, с которыми мы столкнулись после прохождения стандартного пути, когда CI-тесты успешно завершались на FPC, успешно завершались на Delphi, но затем изменение, прошедшее на одной стороне, полностью ломало сборку на другой. Каждая из описанных ниже проблем произошла в реальных тестах или демонстрационных проектах PDFiumPas

Почему проверка множества возвращает ложь на FPC, но работает в Delphi?

Коротко: компилятор FPC может финализировать временную переменную, хранящую запись-результат функции, до завершения оценки выражения, считывающего поле этой записи. Поэтому выражение X in Func().Issues на FPC проверяет вхождение элемента в уже уничтоженное множество, тогда как в Delphi аналогичный код работает корректно. Наши тесты соответствия PDF/E столкнулись с этим в первой же версии: метод валидатора возвращает запись, поле Issues которой представляет собой множество флагов нарушений, а код проверки был написан в одну строку

// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Reliable on both compilers: pin the result to a local first
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Встраиваемая форма проверки на FPC считывала множество как пустое, из-за чего все проверки AssertionError завершались сбоем, в то время как сборка на Delphi проходила успешно. Первопричина кроется в различиях управления жизненным циклом временных результатов функций внутри больших выражений: Delphi сохраняет временную переменную живой до конца выполнения текущего оператора, тогда как в FPC уничтожение временной записи может опередить оператор проверки вхождения in, который все еще считывает данные. Ранее мы уже сталкивались с аналогичным поведением и оставили комментарий у вспомогательного метода FlagPresent в модуле тестирования PDF/A, но затем снова допустили эту ошибку при написании новых тестов с нуля — настолько естественным кажется некорректный вариант. Решение простое и его стоит сделать общим правилом: никогда не обращайтесь к полям и не проверяйте вхождение во множество непосредственно из функции, возвращающей запись; сначала присвойте результат локальной переменной, а затем считывайте ее свойства

Почему Delphi пропускает индекс массива, который FPC отказывается компилировать?

Коротко: компилятор dcc32 собирает код с заведомо неверным индексом массива с фиксированными границами и при отключенной по умолчанию проверке диапазонов молча читает или пишет данные в соседние области памяти во время выполнения программы, тогда как FPC блокирует этот код еще на этапе компиляции. В компоненте PDFium Component четырехточечные полигоны (quad points) объявлены как массив с базой 1: TQuadrilateralPoint = array [1..4] of TPdfPoint (согласно нумерации записей QuadPoints в спецификации PDF). Демонстрационный пример, заполняющий его в цикле с 0 до 3, успешно работал в Delphi на протяжении месяцев

var
  I: Integer;
begin
  for I := 0 to 3 do                       // wrong: the array is [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
                                           // silently touches adjacent memory
                                           // FPC: compile-time range check error
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // correct on both compilers
end;

Успешная работа в Delphi была ложноположительной: при отключенной проверке диапазонов (стандартная настройка dcc32) индекс 0 ссылался на область памяти перед массивом внутри записи, и пример внешне работал без сбоев. Портирование того же примера в Lazarus сразу вызвало ошибку компиляции FPC (range check error), а исправление индекса выявило скрытую логическую ошибку в обработке аннотаций, которую ранее маскировало чтение неверных данных (эта деталь разобрана в статье о создании текстовых аннотаций с QuadPoints). Мы извлекли два вывода: во-первых, всегда используйте Low() и High() вместо жестко прописанных констант, если массив по своей природе не начинается с 0; во-вторых, всегда выполняйте тестовую компиляцию в FPC или как минимум сборку в Delphi с включенной опцией {$R+} — стандартные настройки dcc32 не сообщат вам о подобных ошибках, а факт запуска программы не гарантирует ее корректность

Проблема присвоения TBytes, компилируемая только в Delphi 13

Коротко: присвоение поля, объявленного как анонимный тип array of Byte, переменной строгого типа TBytes успешно собирается в Delphi 13 (версия компилятора 37.0), но вызывает ошибку сборки в Delphi 12 Athens и во всех более ранних версиях: E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Эта проблема связана не столько с различиями Delphi и FPC, сколько с эволюцией самого компилятора Delphi: новая версия молча допускает конструкцию, которую все предыдущие версии считали некорректной

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonymous dynamic array type
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Delphi 13 only; E2010 on Delphi 12
                                 // Athens and earlier
  OrigBytes := TBytes(FBuffer);  // compiles everywhere; same byte layout,
                                 // safe hard cast
end;

Мы написали этот код в процедуре валидации, разрабатывая и проверяя его локально в Delphi 13, где неявное приведение прошло незамеченным. Однако инсталлятор исходного кода поставляется в том числе пользователям с Delphi 12 и более старыми версиями, и у них модуль просто перестал собираться. Решением является либо явное приведение типов (поскольку анонимный array of Byte и TBytes имеют идентичное представление динамического массива в памяти), либо, что предпочтительнее, изначальное объявление поля с типом TBytes для исключения преобразований. Вывод на будущее: успешная сборка на новейшем компиляторе не гарантирует совместимость с предыдущими версиями, которые используют ваши клиенты. Наши скрипты сборки теперь компилируют библиотеку во всех поддерживаемых версиях компиляторов перед релизом

Искажение байтов в AnsiString на китайской версии Windows

Коротко: конкатенация символа со значением байта от $80 и выше с помощью оператора + в тип AnsiString в Delphi может молча заменить этот символ знаком вопроса ? ($3F), так как выражение претерпевает неявное преобразование AnsiString -> UnicodeString -> AnsiString через системную кодовую страницу. Мы обнаружили это в тесте PDF/A, который генерирует имя файла с изолированным байтом $FE (заведомо некорректный байт для UTF-8) с целью проверки работы валидатора имен на соответствие стандарту ISO 19005-2 (раздел 6.1.8)

var
  BadName: AnsiString;
begin
  // On Delphi with a multi-byte system code page (observed on CP936),
  // the concatenation round-trips through UnicodeString and $FE, which
  // is not a valid CP936 sequence, comes back as '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Safe: build with an ASCII placeholder, then patch the byte in place;
  // indexed assignment into a settled AnsiString does not round-trip
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

На китайской версии Windows с системной кодовой страницей CP936 конкатенированная строка вообще не содержала байта $FE, из-за чего библиотека не регистрировала ошибку, а тест завершался сбоем (что выглядело как баг в самой библиотеке). Сама библиотека работала верно: тест в FPC, передававший файл с байтом $FE напрямую, успешно регистрировал ошибку. Искажение происходило в самом тестовом приложении Delphi при вычислении выражения: Unicode-ориентированная модель строк Delphi преобразует смешанные выражения AnsiString через промежуточный UnicodeString, а так как $FE не является допустимым байтом в CP936, преобразование заменило его символом '?' ($3F). Эта ошибка коварна тем, что на западных кодовых страницах (например, CP1252) выражение отрабатывает без потерь, из-за чего баг годами маскируется на компьютерах разработчиков и проявляется только на азиатских версиях Windows или на локализованных CI-серверах. Принятое нами правило: никогда не собирайте тестовые бинарные последовательности с байтами от $80 и выше путем конкатенации строк; либо записывайте байты по индексам в готовую строку, либо формируйте массив в типе TBytes изначально

Что должна включать проверка при кросс-компиляции

Четыре разные ловушки иллюстрируют одну суть: каждый компилятор помогает найти свой уникальный набор багов. Анализ выходов за границы в FPC выявил неверный индекс, успешно собиравшийся в dcc32, а модель строк Unicode в dcc32 обнаружила зависимость от системной локали, незаметную в чисто байтовом представлении FPC. Кросс-компиляция — это не просто галочка в списке совместимости, а дополнительное средство статического и динамического анализа кода, аналогичное проверкам границ в материале по обеспечению безопасности памяти и ABI

Правила по итогам этих инцидентов просты. Всегда сохраняйте результаты функций, возвращающих записи, в локальных переменных перед чтением полей. Обходите массивы фиксированной длины через Low() и High() и проверяйте код сборкой с контролем диапазонов или в FPC. Явно приводите анонимные типы динамических массивов или сразу объявляйте их именованными типами. Никогда не используйте байты от $80 и выше в конкатенации строк AnsiString. Эти привычки не требуют усилий при написании кода, но защищают от целого класса ошибок, которые невозможно обнаружить в рамках одного компилятора

Все четыре проблемы были обнаружены и устранены в процессе поддержки компонента PDFium Component, поставляющего один исходный код для Delphi, C++Builder и FPC/Lazarus и регулярно тестирующего сборки на всех целевых платформах