ซอร์สโค้ด Object Pascal เดียวกันสามารถแสดงพฤติกรรมแตกต่างกันภายใต้ Delphi และ FPC/Lazarus ได้ในสี่ลักษณะที่มักส่งผลกระทบต่อรหัสคอมโพเนนต์ PDFium เสมอ: ได้แก่ FPC จะทำลายตัวแปรเรกคอร์ดชั่วคราวที่เป็นผลลัพธ์ของฟังก์ชันก่อนที่ตัวดำเนินการตรวจสอบสมาชิก in จะอ่านค่าเสร็จ, ตัวคอมไพเลอร์ dcc32 จัดส่งมาพร้อมกับการปิดระบบตรวจสอบขอบเขต (range checking) ส่งผลให้การระบุดัชนีอาร์เรย์นอกขอบเขตจะอ่านค่าข้อมูลขยะอย่างเงียบๆ, มีเพียง Delphi 13 เท่านั้นที่อนุญาตให้มอบหมายค่า array of Byte แบบนิรนามให้กับ TBytes โดยไม่มีการหล่อประเภทข้อมูล (cast) และขั้นตอนการเชื่อมต่อข้อความ (concatenation) ของ AnsiString ใน Delphi สามารถทำลายค่าไบต์ข้อมูลที่เทียบเท่าหรือสูงกว่า $80 ผ่านการแปลงรหัสหน้ากระดาษ (code-page) ที่ซ่อนอยู่ ความแตกต่างแต่ละตัวเหล่านี้ทำให้ชุดทดสอบผ่าน (เป็นสีเขียว) บนคอมไพเลอร์ตัวหนึ่ง แต่อาจล้มเหลว (เป็นสีแดง) หรือที่แย่กว่านั้นคือได้ผลลัพธ์ที่ผิดพลาดอย่างเงียบๆ บนคอมไพเลอร์อีกตัว
หากคุณกำลังตั้งค่าโครงการที่ใช้สองคอมไพเลอร์เป็นครั้งแรก คู่มือการสร้างโปรแกรมแสดงผลด้วย Lazarus และ FPC จะช่วยครอบคลุมเส้นทางที่ราบรื่น: ทั้งเรื่องแพ็กเกจ, เส้นทางการค้นหา และการรันหน้าต่างแสดงผลกราฟิกขึ้นหน้าจอ ส่วนบทความนี้จะตรงกันข้ามกับบทแนะนำสอนใช้งาน มันคือรายการอุปสรรคที่เราพบหลังจากที่เส้นทางแรกเริ่มทำงานผ่านแล้ว เมื่อกระบวนการตรวจสอบความถูกต้องอัตโนมัติ (CI) ผ่านฉลุยทั้งบน FPC และ Delphi แต่การแก้ไขจุดหนึ่งที่ผ่านจากคอมไพเลอร์ข้างหนึ่งกลับทำให้ระบบระเบิดบนคอมไพเลอร์อีกข้าง ทุกกับดักด้านล่างนำมาจากความล้มเหลวที่เกิดขึ้นจริงในชุดการทดสอบ PDFiumPas หรือโค้ดตัวอย่าง โดยสรุปการวิเคราะห์สาเหตุหลักและแนวทางแก้ไขมาตรฐานที่เราเลือกใช้
ทำไมชุดข้อมูล (set) จึงอ่านได้ค่าว่างภายใต้ FPC แต่ไม่เป็นเช่นนั้นใน Delphi?
การเขียนแบบยัดบรรทัดเดียว (inlined) มักจะทำงานไม่ตรงกันระหว่าง Delphi และ FPC: FPC อาจทำลายข้อมูลเรกคอร์ดผลลัพธ์เร็วกว่าเวลาที่ใช้ประเมินค่า ทำให้นิพจน์อ่านได้ข้อมูลว่างเปล่า:
// ไม่น่าเชื่อถือภายใต้ FPC: ตัวแปรเรกคอร์ดชั่วคราวที่เป็นผลลัพธ์
// ของฟังก์ชันอาจถูกทำลายก่อนที่ตัวดำเนินการ 'in' จะอ่านค่า Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// น่าเชื่อถือบนทั้งสองคอมไพเลอร์: กำหนดผลลัพธ์ลงตัวแปรท้องถิ่นก่อน
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
รูปแบบการเขียนยัดบรรทัดเดียว (inlined) จะส่งผลให้อ่านค่าชุดข้อมูลเป็นค่าว่างภายใต้ FPC ทำให้ทุกการทดสอบที่คาดหวังจะพบแฟล็กแจ้งเตือนล้มเหลวไปทั้งหมด ในขณะที่บิลด์ของ Delphi ที่เหมือนกันทุกประการผ่านการทดสอบตามปกติ สาเหตุหลักเกิดจากความแตกต่างในกลไกจัดการอายุการใช้งานของตัวแปรชั่วคราวที่เป็นผลลัพธ์ของฟังก์ชันภายในนิพจน์ขนาดใหญ่: Delphi จะคงตัวแปรชั่วคราวไว้จนจบคำสั่ง ในขณะที่ FPC อาจทำลายเรกคอร์ดชั่วคราวเร็วกว่าตัวดำเนินการประเมินผลสมาชิกชุดข้อมูลที่กำลังอ่านอยู่ เราเคยจดบันทึกพฤติกรรมนี้ไว้แล้วหนึ่งครั้งในความคิดเห็นสำหรับตัวช่วย FlagPresent ในหน่วยทดสอบ PDF/A แต่ก็ยังเขียนโค้ดผิดพลาดซ้ำเดิมเมื่อเริ่มเขียนการทดสอบใหม่ตั้งแต่ต้น ซึ่งเป็นเครื่องยืนยันว่าโค้ดรูปแบบที่ผิดนั้นดูเป็นธรรมชาติเพียงใด แนวทางแก้ไขคือการปรับเปลี่ยนโครงสร้างอย่างเป็นระบบและควรยึดถือเป็นกฎทั่วไป: คือห้ามเรียกใช้ฟิลด์หรือตรวจสอบชุดข้อมูลต่อท้ายฟังก์ชันที่ส่งกลับข้อมูลประเภทเรกคอร์ดโดยตรง ให้กำหนดผลลัพธ์ลงในตัวแปรท้องถิ่นก่อนแล้วจึงเข้าไปอ่านฟิลด์ข้อมูล มันแลกมาด้วยความยาวของรหัสโปรแกรมเพิ่มขึ้นหนึ่งบรรทัดแต่ช่วยแก้ปัญหาความไม่เสถียรที่ขึ้นกับตัวคอมไพเลอร์ไปได้ทั้งหมด
ทำไม Delphi จึงยอมรับดัชนีอาร์เรย์ที่ FPC ปฏิเสธไม่ยอมคอมไพล์?
คอมโพเนนต์ PDFium กำหนดให้อาร์เรย์รูปสี่เหลี่ยมเริ่มต้นด้วยดัชนี 1 (1-based) ตามมาตรฐานพิกัดของ PDF ใน Delphi หากคุณใช้การวนลูปแบบดั้งเดิมที่เริ่มด้วยดัชนี 0 ตัวระบบคอมไพล์ dcc32 จะไม่มีการทักท้วงใดๆ เพราะ Range Checking ถูกปิดไว้:
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 แสดงผลผ่านแบบลวง (false positive): เมื่อปิดระบบตรวจสอบขอบเขตซึ่งเป็นค่าเริ่มต้นของ dcc32 ดัชนี 0 จะชี้ไปยังฟิลด์ใดก็ตามที่อยู่ข้างหน้าอาร์เรย์ในเรกคอร์ด ทำให้โปรแกรมตัวอย่างดูเหมือนรันได้ปกติ การพอร์ตโค้ดเดียวกันไปยัง Lazarus ทำให้ FPC แจ้งข้อผิดพลาดการตรวจสอบขอบเขตตั้งแต่ช่วงคอมไพล์ทันที และการแก้ไขดัชนีชิ้นนั้นก็นำไปสู่การค้นพบข้อผิดพลาดส่วนที่สองที่ลึกกว่าในส่วนระบบวิเคราะห์คำอธิบายประกอบของไลบรารี ซึ่งข้อมูลเศษขยะได้บดบังไว้ก่อนหน้านี้ รายละเอียดอธิบายในบทความเกี่ยวกับการสร้างคำอธิบายประกอบรูปสี่เหลี่ยมด้านไม่เท่า บทเรียนที่ได้มีสองประการ ประการแรกคือให้เลือกใช้คำสั่ง Low() และ High() แทนการระบุขอบเขตตัวเลขดิบเมื่อประเภทอาร์เรย์ไม่ได้เริ่มต้นจาก 0 ประการที่สองคือให้รันคอมไพล์ผ่าน FPC หรือรันบิลด์ของ Delphi ที่ตั้งค่า {$R+} เป็นขั้นตอนทดสอบเริ่มต้นสำหรับตัวอย่างโค้ดหรือชุดทดสอบใหม่เสมอ ค่าเริ่มต้นของ dcc32 จะไม่แจ้งเตือนข้อผิดพลาดประเภทนี้ และโปรแกรมที่รันได้ไม่ได้เป็นหลักฐานยืนยันว่าโค้ดนั้นทำงานถูกต้อง
การมอบหมายค่า TBytes ที่มีเพียง Delphi 13 เท่านั้นที่ยอมรับ
การเขียนมอบหมายอาร์เรย์ Byte แบบนิรนามให้กับประเภทข้อมูล TBytes ได้รับการอำนวยความสะดวกใน Delphi 13 แต่จะทำให้บิลด์ล้มเหลวบนคอมไพเลอร์รุ่นเก่ากว่า:
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 ไปประมวลผลผ่านสตริงแบบผสม จะทำให้ Delphi ทำการแปลงผ่าน UnicodeString โดยระบบจะแปลงไบต์แปลกปลอมเหล่านี้ให้กลายเป็นเครื่องหมายคำถามหากมันไม่เข้าคู่กับหน้ารหัสภาษาของระบบคอมพิวเตอร์นั้น:
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 ที่เป็นแบบเน้นไบต์ข้อมูลไม่มีวันพบ ผลลัพธ์ในทางปฏิบัติคือความสำเร็จของกระบวนการ CI คอมไพเลอร์ใดตัวหนึ่งไม่สามารถใช้การันตีประสิทธิภาพได้ การคอมไพล์ข้ามระบบจึงไม่ใช่เรื่องพอร์ตย้ายระบบธรรมดา แต่มันคือการนำตัววิเคราะห์โครงสร้างตัวที่สองและแบบจำลองเวลาทำงานตัวที่สองมาใช้ตรวจสอบซอร์สโค้ดชุดเดียวกัน ในแนวทางเดียวกับการออกแบบขั้นตอนป้องกันขอบเขตในบทความเกี่ยวกับการเสริมความแข็งแกร่งให้กับ ABI และความปลอดภัยของหน่วยความจำ
กฎข้อบังคับที่เรากำหนดขึ้นจากเหตุการณ์เหล่านี้มีความยาวสั้นพอให้จดจำ: ให้เก็บผลลัพธ์ฟังก์ชันประเภทเรกคอร์ดลงตัวแปรท้องถิ่นก่อนเข้าไปอ่านฟิลด์ข้อมูล, รันจัดการอาร์เรย์ขอบเขตคงที่ด้วยคำสั่ง Low() และ High() และรันบิลด์ FPC หรือรันโหมดตรวจสอบขอบเขตอย่างน้อยหนึ่งครั้งก่อนเปิดเผยโค้ดตัวอย่างใหม่, จัดการคาสต์ตัวแปรอาร์เรย์ไดนามิกแบบนิรนามอย่างชัดเจน หรือประกาศใช้ประเภทข้อมูลที่มีชื่อ และรันบิลด์ทดสอบกับทุกเวอร์ชันคอมไพเลอร์ก่อนปล่อยผลิตภัณฑ์, หลีกเลี่ยงการเชื่อมไบต์รหัสสูงเข้ากับข้อความ AnsiString โดยตรง สิ่งเหล่านี้ไม่มีข้อจำกัดใดๆ ในกระบวนการทำงานเมื่อมันกลายเป็นนิสัยการเขียนโปรแกรมของคุณแล้ว และช่วยปิดโอกาสเกิดข้อผิดพลาดประเภทที่คอมไพเลอร์ระบบเดี่ยวไม่มีทางตรวจพบได้
ปัญหาทั้งสี่ประการได้รับการตรวจสอบและแก้ไขเรียบร้อยแล้วในระหว่างการพัฒนาและดูแลระบบ PDFium Component ซึ่งจัดส่งซอร์สโค้ด Object Pascal ตัวเดียวกันสำหรับ Delphi, C++Builder และ FPC/Lazarus และทำการรันชุดทดสอบบนทุกระบบ เพื่อให้มั่นใจว่ากับดักเหล่านี้นิพจน์ทั้งหมดได้รับการคุ้มครองด้วยการตรวจสอบทดสอบแทนที่จะใช้การระวังในหน่วยความจำเพียงอย่างเดียว