O mesmo código-fonte em Object Pascal pode comportar-se de forma diferente no Delphi e no FPC/Lazarus de quatro maneiras que afetam repetidamente o código do PDFium Component: o FPC elimina registos temporários resultantes de funções antes de um teste de pertença in concluir a sua leitura, o dcc32 é distribuído com a verificação de limites (range checking) desativada pelo que índices de array fora dos limites leem lixo de forma silenciosa, apenas o Delphi 13 aceita a atribuição de um array of Byte anónimo a TBytes sem um cast, e a concatenação de AnsiString no Delphi pode corromper bytes iguais ou superiores a $80 devido a uma conversão implícita de página de código. Cada uma destas situações gera um cenário que passa com sucesso num compilador e falha — ou pior, corre com erros silenciosos — no outro
Se está a configurar um projeto com dois compiladores pela primeira vez, o guia do visualizador Lazarus e FPC cobre o caminho ideal: pacotes, caminhos de pesquisa e a criação de uma janela de renderização no ecrã. Este artigo constitui o oposto de um tutorial. Consiste na lista de problemas com os quais nos deparámos depois de o caminho ideal ter funcionado, quando o CI estava com sinal verde no FPC, verde no Delphi, e depois uma alteração que passava num lado falhava no outro. Cada armadilha abaixo provém de uma falha real na suite de testes do PDFiumPas ou nas suas demonstrações, com a análise ao nível do commit resumida numa reprodução mínima, na causa raiz e na correção que adotámos como padrão
Por que razão um conjunto (set) é lido como vazio no FPC mas não no Delphi?
Resumindo numa frase: o FPC pode libertar a variável temporária que armazena o registo resultante de uma função antes de uma expressão que lê um campo desse resultado terminar, pelo que X in Func().Issues pode testar a pertença num conjunto já libertado, enquanto a expressão equivalente no Delphi funciona sem problemas. Os nossos testes de conformidade de PDF/E depararam-se com esta situação na sua primeira versão. O validador devolve um registo cuja propriedade Issues consiste num conjunto de flags de violação, e as asserções continham a chamada em linha (inlined)
// 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;
A forma em linha lia o conjunto como vazio no FPC, pelo que todas as asserções que esperavam uma flag falhavam, enquanto a mesma compilação no Delphi passava com sucesso. A causa raiz prende-se com uma diferença na forma como os dois compiladores gerem o tempo de vida dos registos temporários resultantes de funções em expressões maiores: o Delphi mantém o registo temporário ativo até ao fim da instrução, ao passo que a libertação do registo temporário pelo FPC pode entrar em conflito com o operador de pertença do conjunto que ainda o está a ler. Já tínhamos documentado este mesmo comportamento anteriormente, num comentário no helper FlagPresent na unidade de testes de PDF/A, e acabámos por reintroduzir o bug ao escrever novos testes do zero, o que demonstra quão natural a forma incorreta se afigura. A correção é mecânica e merece ser adotada como regra geral: nunca encadeie um acesso a campo ou teste de conjunto diretamente numa chamada de função que devolva um registo; atribua primeiro o resultado a uma variável local e, em seguida, leia o campo. Custa apenas uma linha e elimina toda uma categoria de inconsistências dependentes do compilador
Por que razão o Delphi aceita um índice de array que o FPC recusa compilar?
Resumindo numa frase: o dcc32 compila um índice fora do intervalo num array com limites fixos e, com a verificação de limites (range checking) desativada por padrão, lê ou escreve na memória adjacente em tempo de execução sem gerar erro, ao passo que o FPC rejeita o mesmo índice em tempo de compilação. O PDFium Component declara os pontos do quadrilátero como um array baseado em 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, correspondendo à numeração habitual das entradas QuadPoints em PDF. Uma demonstração que preenchia o array com um loop recursivo baseado em 0 funcionou durante meses no 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;
A compilação do Delphi constituiu um falso positivo: com a verificação de limites desativa, que é o padrão do dcc32, o índice 0 incidia sobre qualquer campo que precedesse o array no registo, e a demonstração parecia funcionar. A conversão da mesma demonstração para o Lazarus gerou de imediato um erro de verificação de limites em tempo de compilação no FPC, e a correção do índice acabou por expor um segundo erro mais profundo no percurso de anotações da biblioteca, que a leitura de dados corrompidos estava a ocultar, o qual é detalhado no artigo sobre anotações de QuadPoints. Dois ensinamentos resultaram deste incidente. Primeiro, prefira a utilização de Low() e High() face a limites fixos literais sempre que o tipo do array não seja baseado em 0 por conceção. Segundo, encare a compilação no FPC, ou no mínimo uma compilação no Delphi com {$R+} ativo, como uma validação inicial obrigatória para qualquer nova demonstração ou teste: as definições padrão do dcc32 não o alertarão para esta classe de erro, e um programa que corre não prova que o código esteja correto
A atribuição de TBytes que apenas o Delphi 13 aceita
Resumindo numa frase: a atribuição de um campo declarado como array of Byte anónimo a uma variável do tipo TBytes compila no Delphi 13 (versão do compilador 37.0) mas falha no Delphi 12 Athens e em todas as versões anteriores com o erro E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Esta questão não se deve a uma divergência entre Delphi e FPC, mas sim a uma diferença entre o Delphi e as suas próprias versões anteriores, afetando no entanto a base de código multicompilador da mesma forma: o compilador mais recente aceita silenciosamente uma estrutura que todos os outros rejeitam
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;
Disponibilizámos precisamente isto numa rotina de validação, desenvolvida e testada localmente no Delphi 13, onde a conversão implícita foi aceite de forma silenciosa. O instalador com código-fonte completo serve um grande número de utilizadores em Delphi 12 e anteriores, e para estes a unidade simplesmente não compilava. A correção estrutural passa pelo cast explícito demonstrado acima — seguro porque um array of Byte anónimo e o TBytes partilham uma disposição idêntica de array dinâmico — ou, de preferência, declarando à partida o campo com um tipo nomeado como TBytes, de forma a evitar conversões. A correção do processo é ainda mais relevante: uma estrutura que compile na sua cadeia de ferramentas mais recente não prova nada relativamente aos compiladores mais antigos que os seus utilizadores realmente executam, tornando esta categoria de regressão invisível até que compile o código em todas as versões suportadas. Os nossos scripts de lançamento compilam agora a biblioteca em toda a matriz de compiladores precisamente porque um build local na versão 37.0 não consegue detetar uma tolerância exclusiva do Delphi 13
O byte de AnsiString que desaparece num Windows em Chinês
Resumindo numa frase: concatenar um byte bruto igual ou superior a $80 numa AnsiString com o operador + pode substituir silenciosamente esse byte por ? ($3F) no Delphi, porque a expressão realiza uma conversão implícita de ida e volta de AnsiString para UnicodeString e novamente para AnsiString através da página de código do sistema. Descobrimos isto através de um teste de PDF/A que constrói um nome contendo um byte $FE isolado — que nunca constitui um byte inicial válido em UTF-8 — para verificar se o validador assinala nomes que não são UTF-8 válidos de acordo com a cláusula 6.1.8 da ISO 19005-2
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;
Num sistema Windows em chinês configurado com a página de código 936, a string concatenada nunca continha o byte $FE, pelo que a biblioteca legitimamente não reportava nada e o teste falhava, aparentando tratar-se de um bug da biblioteca. O documento original nunca esteve errado: um ambiente FPC que processasse um PDF com o byte $FE real obtinha a flag esperada. A corrupção ocorria dentro do próprio executável de testes em Delphi enquanto a expressão de string era avaliada, uma vez que o modelo de strings focado em Unicode do Delphi converte expressões mistas de AnsiString através de UnicodeString, e o byte $FE não é um byte inicial válido na CP936, pelo que a conversão de ida e volta o substitui. Devemos ser realistas quanto a este limite: numa página de código ocidental de byte único, como a CP1252, a mesma expressão costuma sobreviver, razão pela qual este erro se oculta na maioria das máquinas de desenvolvimento e surge apenas em sistemas da Ásia Oriental ou em ambientes de integração contínua (CI) localizados. A regra que adotámos: nunca construa vetores de teste binários que contenham bytes iguais ou superiores a $80 através de concatenação de AnsiString; modifique os bytes diretamente após a string estar definida, como demonstrado acima, ou construa o vetor usando TBytes desde o início
O que um fluxo de trabalho com dois compiladores deve validar por padrão
Quatro armadilhas, um padrão: cada compilador alerta-o para um subconjunto diferente dos seus erros. A análise de limites em tempo de compilação do FPC detetou um índice fora do intervalo que o dcc32 executou silenciosamente durante meses, e o modelo de strings Unicode do dcc32 expôs uma dependência de página de código que uma compilação FPC pura focada em bytes nunca acionaria. A consequência prática é que ter apenas um pipeline com sinal verde não é suficiente. A compilação cruzada (cross-compiling) não constitui apenas uma meta de portabilidade; representa um segundo analisador estático e um segundo modelo de tempo de execução aplicados ao mesmo código-fonte, na mesma linha das verificações defensivas descritas no artigo sobre reforço de segurança de memória e ABI
As regras estabelecidas após estes incidentes são fáceis de memorizar. Associe os registos resultantes de funções a uma variável local antes de aceder aos campos. Percorra arrays de limites fixos com Low() e High() e execute pelo menos uma compilação com verificação de limites ou no FPC antes de confiar numa nova demonstração. Realize casts explícitos em campos do tipo array dinâmico anónimo, ou declare-os com tipos nomeados, e compile toda a matriz de compiladores antes de lançar o código. Exclua por completo bytes brutos elevados na concatenação de AnsiString. Nenhuma destas práticas exige um esforço significativo após se tornarem hábitos, e cada uma delas previne um modo de falha que um fluxo focado num único compilador é incapaz de detetar
Todos os quatro problemas foram identificados e corrigidos no âmbito da manutenção do PDFium Component, que disponibiliza o mesmo código-fonte em Object Pascal para Delphi, C++Builder e FPC/Lazarus, executando as suas suites de conformidade e regressão em cada uma destas ferramentas, pelo que as armadilhas colocadas neste artigo encontram-se protegidas por testes e não apenas por memória