Exit code: 0 Wall time: 1.5 seconds Output: Exit code: 0 Wall time: 0.8 seconds Output: Exit code: 0 Wall time: 1.2 seconds Output: FPC for 迴圈缺陷:點選 Cancel 無法停止列印

技術文章

FPC for 迴圈缺陷:點選 Cancel 無法停止列印

在 PDFium Component 檢視器執行列印工作時,點選 Cancel 有時毫無作用:迴圈會繼續轉譯所有剩餘頁面與副本,工作最終仍會送至列印机。根因在于 Free Pascal 的 for 迴圈會在進入迴圈時固定上限,因此在迴圈途中将 CopyCountToPage 背後的變數歸零,並不會影响已經開始執行的迴圈。這個 for 迴圈邊界缺陷,是同一輪 PDFiumPas Delphi/FPC 相容性稽核發現的第五個陷阱,另有四個跨編譯器陷阱;它與前四個不同,完全位於檢視器的列印例程內部,直到測試人员在一次长時間列印工作中持續按住 Cancel,才注意到列印机實際上從未停止

為什麼點選 Cancel 後列印工作仍會繼續

列印工作之所以繼續,是因為取消檢查只重設了提供迴圈邊界的變數,并沒有停止迴圈本身;而 Free Pascal 在每個迴圈開始時就已經鎖定了這些邊界。PDFViewer 與 MultiPageViewer 範例中 Print 按鈕背後的 SpeedButtonPrintClick 處理常式,用三個巢狀迴圈建置每個列印工作:最外層遍历已整理的副本組,中間層遍历頁面,最內層遍历同一頁面的未整理副本。PrintDialog.Collate 决定 CollateCopyCountCopyCount 哪個计數器實際儲存請求的副本數,另一個计數器維持為 1。取消操作必須同時穿過這三層,而這段代碼的第一版試圖在 Cancel 變為 true 的瞬間直接重設邊界變數來實現這一點

for CollateCopy:= 1 to CollateCopyCount do
  for Page:= FromPage to ToPage do
    for Copy:= 1 to CopyCount do
    begin
      // ... render the page and send it to the printer ...
      Application.ProcessMessages;
      if Cancel then
      begin
        CollateCopyCount:= 0;
        ToPage:= 0;
        CopyCount:= 0;
      end;
    end;
Printer.EndDoc;  // runs whether or not Cancel fired

這個意圖看起來很合理:如果定義整理組數、頁面數與副本數的计數器都降為零,迴圈就应当自行耗盡工作并自然結束。迴圈結束後無论它如何結束,Printer.EndDoc 都會無條件執行,因此即使使用者以為已經停止的工作,仍會在 Cancel 被發現之前轉譯的所有頁面後提交至後台列印程式

Free Pascal 在 for 迴圈開始時會鎖定什麼

Free Pascal 會在 for 迴圈開始的瞬間精确計算一次終值,并且在該迴圈的整個生命周期內不再計算。for Page := FromPage to ToPage do 只讀取一次 ToPage 來計算迭代次數,之後迴圈不再关心名為 ToPage 的變數,只使用已經捕获的迭代次數。在迴圈體內设置 ToPage := 0,改變的是執行中的迴圈已經不再查詢的變數,這正是 Cancel 已經為 true 而列印机仍收到後續多個頁面的原因,有時甚至會收到全部頁面

這種模式从 C 或 C++ 延續过來完全合理,而 PDFium Component 的 C++Builder 檢視器範例與 Pascal 範例足够相似,正好可以直接比較。它的 for (Copy = 1; Copy <= CopyCount; Copy++) 每次迭代都會将 Copy <= CopyCountCopyCount 的目前值重新比較,因此在那里将计數器歸零确实會讓迴圈在下一次檢查時結束。C++Builder 範例使用了完全相同的歸零计數器代碼,而且确实能够工作,這正是同一思路看起來可以安全重複使用於旁邊 Pascal 建置的原因

為什麼 Delphi 建置沒有遇到同一個缺陷

Delphi PDFViewer 範例從未依赖迴圈去發現邊界發生變化,因為它的取消路徑透過例外展開,而非透過比較。最內層迴圈在 Cancel 變為 true 的瞬間呼叫 RTL 的 Abort 程序;這會引發一個靜默的 EAbort,直接穿過三層巢狀的 for 迴圈,抵達包圍整個列印代碼块的處理常式

Printer.BeginDoc;
try
  for CollateCopy:= 1 to CollateCopyCount do
    for Page:= FromPage to ToPage do
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Abort;  // raises EAbort, unwinds all three loops at once
      end;
  Printer.EndDoc;
except
  on E: EAbort do
    Printer.Abort;
else
  begin
    Printer.Abort;
    raise;
  end;
end;

例外並不在意觸發點與捕获處理常式之間隔着多少個 for 迴圈,這正是該問題所需要的特性。這種稳健性並不是為了防御上述邊界鎖定行為而有意设计的——Delphi 範例的作者只是選擇了另一種工具。不過,基于例外的退出仍值得作為更穩妥的模式明確提出:以後即使增加第四層巢狀,它仍然有效;而一串手动放置的 Break 語句,则必須在每次變更迴圈巢狀時记得补齐

修正方式:在每一層巢狀中使用 Break,并由 PrintSucceeded 旗標控制

PDFiumPas v2.27.0 發布的修正保留了 Lazarus 與 C++Builder 檢視器範例中的三層迴圈結構,但在每一層都明確處理取消,并用一個只有在迴圈完全結束後才讀取的旗標,将停止迴圈與提交列印工作分開

Printer.BeginDoc;
try
  Cancel:= False;
  for CollateCopy:= 1 to CollateCopyCount do
  begin
    for Page:= FromPage to ToPage do
    begin
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Break;
      end;
      if Cancel then
        Break;
    end;
    if Cancel then
      Break;
  end;
  PrintSucceeded:= not Cancel;
finally
  if PrintSucceeded then
    Printer.EndDoc
  else
    Printer.Abort;
end;

PrintSucceeded 會在三層巢狀迴圈退出後立即計算一次,依據的只有 not Cancel。迴圈體中的任何代碼都不能自行决定工作是否成功——迴圈只有兩種結束方式:整理組、頁面與副本全部耗盡,或遇到由 Cancel 觸發的 Break 链,而 PrintSucceeded 會在事後讀取結果,而非随着迴圈持續追蹤。這样計算,finally 代碼块在 Printer.EndDocPrinter.Abort 之間做出的選擇才可信,因為它不會在迴圈真正穩定下來之前觸發

稽核自己的取消驱动型列印迴圈

有三項檢查可以推广到這一個列印例程之外。不要假設 Pascal 的 for 迴圈在啟動後會注意到邊界變數發生變化;如果迴圈需要提前結束,就应在取消必須穿過的每一層巢狀中直接使用 Break,而不只是放在最內層。巢狀足够深、漏掉某個 Break 的可能性足够高時,優先使用例外這類天然能够展開的退出機制——Delphi 範例的 Abort/EAbort 組合無需額外操作就具备這一點。任何類似 EndDoc 的提交步骤,都应由迴圈結束後嚴格計算的旗標控制,绝不要在迴圈內部决定,這样提前停止的工作就不會被誤認為已經完成

修正這個迴圈的同一輪稽核還收緊了九個檢視器範例在取消按鈕可見時的鍵盤處理:現在除 Esc 外的所有按鍵都會被吞掉,因此在列印或搜尋進行期間,偶爾按下 Ctrl+PCtrl+F 都不會再在目前操作之上啟動第二個操作。這項可重入性修正的缺陷與機制都不同,但同样源于对 Cancel 在操作途中實際行為的稽核。从這兩項修正中真正值得带走的更多是一種測試習慣,而非编碼技巧:共享列印例程實際發布到哪些編譯器,就要在每個編譯器上測試取消,因為歸零计數器并相信迴圈會察觉這一想法,可能在 C++Builder 下透過測試,却未经验證地進入 Free Pascal 建置;它在源代碼中看起來完全相同,最終却可能直到有人长時間按住 Cancel、眼看工作仍然完成時才暴露。关于這套取消逻辑所依托的列印设置,使用 PDFium VCL 組件列印 PDF 檔案的教學介绍了其余內容

本次列印取消修正随 PDFium Component 一同提供,支援 Delphi、C++Builder 與 FPC/Lazarus;每次發布都會讓三個編譯器執行同一套範例,因此這類缺口會由建置矩阵發現,而非等到支援工單出現