Один и тот же исходный код на 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 и регулярно тестирующего сборки на всех целевых платформах