Един и същ изходен код на Object Pascal може да се държи различно под Delphi и FPC/Lazarus по четири начина, които многократно засягат кода на компонента PDFium: FPC освобождава временните записи (records), върнати като резултат от функция, преди тестът за членство in да е приключил с четенето им; dcc32 се доставя с изключена проверка на диапазоните, така че излизащи извън границите индекси на масиви четат тихомълком невалидна памет; само Delphi 13 позволява присвояване на анонимен array of Byte към TBytes без преобразуване на типа; и конкатенацията на AnsiString в Delphi може да унищожи байтове със стойност $80 или по-висока чрез скрито двупосочно преобразуване на кодовата страница; Всеки от тези случаи води до ситуация, при която кодът е изряден при единия компилатор, но се проваля или — по-лошо — работи тихомълком грешно при другия
Ако настройвате проект с два компилатора за първи път, ръководството за Lazarus и FPC визуализатор описва лесния път: пакети, пътища за търсене и извеждане на прозорец за рендериране на екрана; Тази статия е обратното на урок; Тя е списък с проблеми, които срещнахме, след като лесният път проработи — когато CI тестовете бяха успешни под FPC и под Delphi, но промяна, преминала успешно при единия компилатор, се сриваше при другия; Всеки капан по-долу идва от реален проблем в тестовия пакет на PDFiumPas или неговите демонстрационни програми, като анализите са обобщени в пример за възпроизвеждане, първопричината и корекцията, която стандартизирахме
Защо под FPC даден набор (set) се чете като празен, но не и в Delphi?
С едно изречение: FPC може да финализира временната променлива, съдържаща записа от резултата на функция, преди изразът, който чете поле от този резултат, да е приключил, така че X in Func().Issues може да тества членство срещу вече освободен набор, докато еквивалентният израз в Delphi работи; Нашите тестове за съответствие с PDF/E се сблъскаха с това в първата си версия; Валидаторът връща запис, чието поле Issues е набор от флагове за нарушения, а твърденията (assertions) извикваха функцията в същия ред
// Ненадеждно под FPC: временният запис от резултата на функцията
// може да бъде освободен, преди тестът 'in' да прочете Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// Надеждно и при двата компилатора: първо закрепете резултата в локална променлива
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
Вградената форма прочете набора като празен под FPC, така че всяко твърдение, което очакваше флаг, се проваляше, докато идентичната компилация с Delphi преминаваше успешно; Първопричината е разликата в начина, по който двата компилатора управляват жизнения цикъл на временните резултати от функции в по-големи изрази: Delphi поддържа временната променлива жива до края на оператора, докато освобождаването на временния запис от страна на FPC може да изпревари оператора за членство в набор, който все още го чете; Вече бяхме документирали същото поведение веднъж преди това в коментар към помощната функция FlagPresent в тестовия модул за PDF/A, и след това отново допуснахме същата грешка при писането на нови тестове, което показва колко естествено изглежда счупената форма; Корекцията е механична и си струва да се приеме като общо правило: никога не свързвайте достъп до поле или тест на набор директно към извикване на функция, която връща запис; първо присвоете резултата на локална променлива, след което прочетете полето; Това струва един ред и премахва цял клас зависима от компилатора нестабилност
Защо Delphi приема индекс на масив, който FPC отказва да компилира?
С едно изречение: dcc32 компилира индекс извън диапазона в масив с фиксирани граници и при изключена по подразбиране проверка на диапазоните тихомълком чете или записва съседна памет по време на изпълнение без никаква грешка, докато FPC отхвърля същия индекс още по време на компилация; Компонентът PDFium декларира точки на четириъгълник като 1-базиран масив, TQuadrilateralPoint = array [1..4] of TPdfPoint, съответстващ на начина, по който обикновено се номерират записите QuadPoints в PDF; Демонстрационна програма, която го попълваше с рефлексивен 0-базиран цикъл, работеше с месеци под Delphi
var
I: Integer;
begin
for I := 0 to 3 do // грешно: масивът е [1..4]
Data.AttachmentPoints[I] := Corner[I]; // dcc32 по подразбиране: компилира се, индекс 0
// тихомълком докосва съседна памет
// FPC: грешка за проверка на диапазона при компилация
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // правилно и при двата компилатора
end;
Компилацията с Delphi беше фалшиво положителна: при изключена проверка на диапазоните, което е поведението на dcc32 по подразбиране, индекс 0 попадаше върху полето, което предшества масива в записа, и демонстрационната програма изглеждаше, че работи; Пренасянето на същата програма към Lazarus доведе до незабавна грешка за проверка на диапазона от страна на FPC при компилация, а коригирането на индекса разкри втори, по-дълбок бъг в пътя на анотациите на библиотеката, който маскираше невалидното четене — този, разгледан в статията за анотации с 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; // анонимен тип динамичен масив
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // само Delphi 13; E2010 в Delphi 12
// Athens и по-рано
OrigBytes := TBytes(FBuffer); // компилира се навсякъде; същата подредба на байтовете,
// безопасно твърдо преобразуване
end;
Ние доставихме точно това в рутина за валидиране, разработена и тествана локално на Delphi 13, където неявното преобразуване беше прието безшумно; Инсталаторът с пълни изходни кодове обслужва голям брой потребители на Delphi 12 и по-стари версии, и за тях модулът просто не се компилираше; Структурното решение е или твърдото преобразуване, показано по-горе, което е безопасно, тъй като анонимен array of Byte и TBytes споделят идентична структура на динамичния масив, или по-добре — деклариране на полето като именован тип TBytes от самото начало, за да не се налага преобразуване; Процесната корекция е по-важна: конструкция, която се компилира на най-новия ви компилатор, не доказва нищо за по-старите версии, които вашите потребители действително изпълняват, и тази категория регресии е невидима, докато не компилирате срещу всяка поддържана версия; Нашите скриптове за пускане на версии сега компилират библиотеката на пълната матрица от компилатори, именно защото локалната компилация с 37.0 не може да улови ленивост, специфична за 13
Байтът в AnsiString, когото изчезва на китайска Windows машина
С едно изречение: конкатенирането на суров байт със стойност $80 или по-висока в AnsiString с оператора + може тихомълком да замени този байт с ? ($3F) под Delphi, тъй като изразът извършва неявно двупосочно преобразуване AnsiString -> UnicodeString -> AnsiString през системната кодова страница; Открихме това чрез тест за PDF/A, който конструира име, съдържащо изолиран байт $FE, който никога не е валиден водещ байт за UTF-8, за да провери дали валидаторът маркира имена, които не са валиден UTF-8 съгласно ISO 19005-2 клауза 6.1.8
var
BadName: AnsiString;
begin
// В Delphi с многобайтова системна кодова страница (наблюдавана при CP936),
// конкатенацията преминава през UnicodeString и $FE, което
// не е валидна CP936 последователност, се връща като '?' ($3F)
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// Безопасно: изградете с ASCII маркер, след което коригирайте байта на място;
// индексното присвояване в установен AnsiString не извършва преобразуване
BadName := '/Bad' + #1 + 'Name';
BadName[5] := AnsiChar($FE);
end;
На китайска Windows система, работеща с кодова страница 936, конкатенираният низ никога не съдържаше $FE, така че библиотеката правилно докладваше нищо и тестът се проваляше, изглеждайки като бъг в библиотеката; Библиотеката никога не е грешала: тестова среда с FPC, която подаваше PDF, действително съдържащ байта $FE, получаваше очаквания флаг; Повредата се случваше вътре в изпълнимия файл на Delphi теста, докато се изчисляваше изразът за низ, тъй като Unicode моделът на Delphi преобразува смесени изрази с AnsiString през UnicodeString, а $FE не е валиден водещ байт в CP936, така че двупосочното преобразуване го заменя; Бъдете честни за границите: на еднобайтова западна кодова страница като CP1252 същият израз обикновено оцелява, което е причината този бъг да се крие на повечето машини за разработка и да се появява само на източноазиатски системи или локализирани CI сървъри; Правилото, което приехме: никога не изграждайте двоични тестови вектори, съдържащи байтове със стойност $80 или по-висока чрез конкатенация на AnsiString; или коригирайте байтовете на място, след като низът е установен, както е показано по-горе, или конструирайте вектора в TBytes от самото начало
Какво трябва да проверява по подразбиране работният процес с два компилатора
Четири капана, един модел: всеки компилатор ви казва за различен поднабор от вашите бъгове; Анализът на FPC по време на компилация улови индекс извън диапазона, който dcc32 изпълняваше безшумно в продължение на месеци, а Unicode моделът на dcc32 разкри зависимост от кодовата страница, която чиста байтово-ориентирана компилация с FPC никога не задейства; Практическото следствие е, че нито един от двата успешни конвейера за компилация не е достатъчен сам по себе си; Крос-компилацията не е просто отметка за преносимост, тя е втори статичен анализатор и втори модел за изпълнение, приложен към същия източник, в същия дух като защитните проверки на границите в статията за укрепване на ABI и безопасността на паметта
Постоянните правила, които произтекоха от тези инциденти, са достатъчно кратки, за да бъдат запомнени; Записвайте резултатите от функции, които са записи, в локална променлива, преди да четете полета; Обхождайте масиви с фиксирани граници чрез Low() и High() и изпълнявайте поне одна компилация с проверка на диапазоните или с FPC, преди да се доверите на ново демо; Преобразувайте анонимните динамични масиви изрично или ги декларирайте с именовани типове, и компилирайте на пълната матрица от компилатори преди пускане на версия; Дръжте суровите високи байтове извън конкатенацията на AnsiString изцяло; Нито едно от тези не струва измеримо усилие, след като се превърне в навик, а всяко от тях затваря режим на отказ, който работният процес с един компилатор структурно не може да види
Всичките четири проблема бяха открити и коригирани в хода на поддръжката на PDFium Component, който доставя същия изходен код на Object Pascal за Delphi, C++Builder и FPC/Lazarus и изпълнява своите пакети за съвместимост и регресия на всяка от тези платформи, така че капаните в тази статия са защитени от тестове, а не от памет