- หากค่าที่ตัดส่วนทศนิยมของ
floatออกแล้วอยู่นอกช่วงของชนิดจำนวนเต็มปลายทาง จะเกิด พฤติกรรมที่ไม่กำหนด (UB) และมีผลทั้งกับการแปลงแบบปริยาย, functional cast และstatic_cast -Wallและ-Wextraไม่เตือนเรื่องนี้ และ-Wconversionก็ ตรวจจับได้เฉพาะการแปลงแบบปริยาย จึงพลาดได้ง่าย- แม้แต่ฟังก์ชันแปลงแบบ narrowing อย่างปลอดภัย
gsl::narrowของ Microsoft GSL ก็ยังก่อให้เกิด UB กับอินพุตแบบจุดลอยตัว→จำนวนเต็มบางส่วน ทำให้ไม่เป็นไปตามพฤติกรรมในเอกสารที่ระบุว่าจะโยนข้อยกเว้นเมื่อค่าไม่สามารถแทนได้ CVTTSS2SIของ x86 จัดการค่าที่แทนไม่ได้เป็นINT_MINแต่FCVTZSของ AArch64 ใช้การแปลงแบบอิ่มตัวและเปลี่ยน NaN เป็น 0 ดังนั้น ผลลัพธ์อาจต่างกันไปตามฮาร์ดแวร์- หากต้องการแปลงอย่างปลอดภัย ต้อง ตรวจสอบช่วงก่อนทำ cast และสามารถใช้ตัวเลือก UBSan ของ Clang·GCC
-fsanitize=float-cast-overflowเพื่อตรวจจับปัญหาได้
กฎการแปลงและข้อจำกัดของการตรวจจับ
- ตาม กฎการแปลงค่าจุดลอยตัว-จำนวนเต็มของ C++ หากหลังตัดส่วนทศนิยมแล้วค่ายังไม่อยู่ในชนิดจำนวนเต็มปลายทาง จะถือเป็น พฤติกรรมที่ไม่กำหนด
- แม้ปลายทางจะเป็น unsigned ก็ไม่ได้ใช้เลขคณิตแบบโมดูลาร์
int i0 = f,int(f),static_cast<int>(f)ล้วนทำให้เกิด UB ได้กับอินพุตบางค่า
- เป็นเรื่องยากที่จะค้นหาปัญหาทั้งหมดได้ด้วยคำเตือนของคอมไพเลอร์ทั่วไปเพียงอย่างเดียว
-Wallและ-Wextraไม่เตือนทั้งสามรูปแบบการแปลง-Wconversionเตือนเฉพาะการแปลงแบบปริยายเท่านั้น
- แม้โปรแกรมจะยังทำงานต่อไปได้บนโปรเซสเซอร์และคอมไพเลอร์ปัจจุบัน แต่ผลลัพธ์อาจต่างกันไปในแต่ละแพลตฟอร์ม
CVTTSS2SIของ x86 แมปอินพุตที่แทนค่าไม่ได้เป็นINT_MINFCVTZSของ AArch64 จะจัดการแบบอิ่มตัวและแมป NaN เป็น 0- UB ที่เกิดขึ้นแล้วอาจทำให้โค้ดเริ่มทำงานผิดพลาดอย่างกะทันหันเมื่อคอมไพเลอร์ใช้การแปลงแบบอื่น
กรณีของ GSL และแนวทางรับมืออย่างปลอดภัย
gsl::narrowของ Microsoft Guidelines Support Library ระบุว่าตนเป็นการแปลงแบบ narrowing ที่ปลอดภัย โดยจะโยนข้อยกเว้นเมื่อค่าไม่สามารถแทนได้ในชนิดปลายทาง- แต่ในการแปลงจุดลอยตัว→จำนวนเต็มจริง ๆ จะ เกิด UB ก่อน สำหรับอินพุตบางค่า จึงไม่ตรงกับเอกสาร
- ฝั่ง GSL เห็นว่า UB ภายในนั้นไม่เป็นอันตราย เพราะไม่ได้ไปแตะ trap representation ของฮาร์ดแวร์บนแพลตฟอร์มเป้าหมาย และตรรกะนี้ก็สะท้อนอยู่ในโค้ด ทำให้ปัญหานี้ยังไม่ได้รับการแก้ไข
- วิธีแก้ที่ถูกต้องคือ ตรวจสอบช่วงก่อนทำ cast
- มี ไลบรารีต้นแบบแนวคิด cpp-clamp-cast ที่อิงจากแนวทางการแปลงแบบอิ่มตัวของ Rust
- ใน Undefined Behavior Sanitizer ของ Clang และ GCC สามารถใช้
-fsanitize=float-cast-overflowเพื่อตรวจจับ UB นี้ได้- แนะนำให้ทดสอบโค้ด C++ ทั้งหมดด้วย UBSan
1 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
แม้จะรู้เรื่องพฤติกรรมที่ไม่ได้นิยามไว้อันละเอียดอ่อนของ C และ C++ มามากแล้ว แต่กรณีนี้ก็ยังน่าตกใจ
Rust ก็สืบทอดกฎการแปลงจากจำนวนทศนิยม→จำนวนเต็มแบบเดียวกันใน LLVM IR มา ทำให้มีพฤติกรรมที่ไม่ได้นิยามไว้อยู่พักหนึ่ง และเพิ่งแก้ในปี 2020 ให้สร้าง IR ที่ซับซ้อนขึ้น เนื่องจากความต่างด้านประสิทธิภาพค่อนข้างมาก จึงมี การแปลงจากจำนวนทศนิยม→จำนวนเต็มแบบไม่ตรวจสอบ ให้ใช้สำหรับลูปประสิทธิภาพสูงที่รู้แน่ว่าค่าเป็น finite และอยู่ในช่วงของชนิดเป้าหมาย
การที่ C++ Core Guidelines Library มองเรื่องนี้เป็นเรื่องเล็กน้อยนั้นเหลวไหลมาก LLVM ใช้ข้อมูลที่ได้จากการแปลงเพื่อตัดการตรวจสอบขอบเขตออก และผลก็คือมี กรณีที่หลุดออกนอกขอบเขตอาร์เรย์ทั้งที่มีการตรวจสอบขอบเขตอยู่ ถ้าให้ความสำคัญกับ ความปลอดภัยของหน่วยความจำ และการหลีกเลี่ยงพฤติกรรมที่ไม่ได้นิยามไว้อย่างจริงจัง ก็ไม่อาจมองข้ามเรื่องนี้ได้ และการที่ Herb Sutter พูดถึง “พฤติกรรมที่ไม่ได้นิยามไว้แบบไม่เป็นพิษเป็นภัย” ก็ชวนผิดหวัง
รู้ว่า C++ มีพฤติกรรมที่ไม่ได้นิยามไว้เยอะ แต่กรณีนี้น่าตกใจเป็นพิเศษ สงสัยว่าเป็นเพราะพยายามทำให้การแปลงเร็วที่สุด และแต่ละสถาปัตยกรรมมีคำสั่งที่จัดการค่าขอบสุดแบบนี้ต่างกัน เลยปล่อยไว้เป็น พฤติกรรมที่ไม่ได้นิยามไว้ หรือเปล่า ดูเหมือนเป็นเหตุผลหลักที่ทำให้มีกฎแบบนี้ คล้ายกับ integer overflow แบบมีเครื่องหมาย
พฤติกรรมที่ไม่ได้นิยามไว้ควรถูกจำกัดไว้กับกรณีที่ไม่สามารถรับประกันผลลัพธ์ที่สม่ำเสมอแม้บนแพลตฟอร์มเดียวกันได้ เพราะมีผลกระทบนอกเครื่องจักรนามธรรมของ C เช่น use-after-free หรือกรณีที่อาจ trap บนบาง target
implementation ที่เป็นไปตามมาตรฐานสามารถนิยามพฤติกรรมที่ไม่ได้นิยามไว้เองได้ และ GCC ก็ทำแบบนั้นกับบางรายการ ถ้าสามารถรับประกันความหมายที่เสถียรได้โดยไม่มีต้นทุนด้านประสิทธิภาพ ก็อาจนิยามให้ตรงกับพฤติกรรมของคำสั่งแปลงจำนวนทศนิยม→จำนวนเต็มตามแต่ละ target ได้ แต่ใน implementation อื่นก็ยังคงเป็นพฤติกรรมที่ไม่ได้นิยามไว้อยู่
fctiwของ PowerPC ทำการแปลงแบบ saturating และเปลี่ยน NaN เป็นINT_MINพร้อมตั้งค่าแฟล็ก FPSCR ด้วย ส่วนfctidของ Power ISA 64 บิตก็จัดการแบบเดียวกันกับจำนวนเต็มที่ใหญ่กว่า แต่ทั้งสองแบบไม่ตรงกับพฤติกรรมของ AArch64 หรือ x86กรณีแบบนี้ควรจัดเป็น พฤติกรรมที่กำหนดโดย implementation หรือค่าที่ไม่ได้ระบุจะเหมาะกว่า แค่แปลง infinity เป็น
intไม่ควรทำให้ทั้งโปรแกรมหลุดออกนอกการควบคุมของมาตรฐาน C++C++26 ได้ลบพฤติกรรมที่ไม่ได้นิยามไว้ที่ไม่สมเหตุสมผลออกไปบางส่วนแล้ว และกรณีนี้ก็เป็นผู้สมัครที่ชัดเจนว่าควรถูกลบด้วย จากการทดลอง ดูเหมือน GCC และ Clang ไม่ได้ใช้ประโยชน์จากพฤติกรรมที่ไม่ได้นิยามไว้นี้ในการ optimize ดังนั้นผลกระทบจริงน่าจะจำกัด
เป็นอีกกรณีน่าหงุดหงิดที่ไม่มีเหตุผลให้สเปกระบุ operation นี้เป็น พฤติกรรมที่ไม่ได้นิยามไว้
ได้เหตุผลเพิ่มอีกข้อให้ไม่ชอบ IEEE 754 ถ้าไม่ได้จำเป็นจริงๆ เพราะไลบรารีอื่นหรือประสิทธิภาพ ก็พยายามใช้จำนวนเต็มล้วนๆ, จำนวนตรรกยะที่ใช้เศษและส่วนเป็น big integer, หรือ fixed-point decimal แทน floating point ให้มากที่สุด
ถ้าเคยใช้ C หรือ C++ ความแตกต่างระหว่าง
float32กับint32หรือแม้แต่int64ก็ไม่ควรน่าแปลกใจfloat32ที่มีเลขชี้กำลังใหญ่สามารถแทนค่าจำนวนเต็มที่ใหญ่กว่าint64ได้มากระหว่างรูปแบบที่ไม่มีใครเป็น superset ของอีกฝ่าย และมีความสามารถในการแทนค่าต่างกัน ไม่มีเหตุผลให้สมมติว่า การแปลงจากจำนวนทศนิยม→จำนวนเต็ม ปลอดภัย ไม่ว่าจะเกี่ยวกับไวยากรณ์ภาษาหรือไม่ก็ตาม
uint32_tก็ไม่สามารถแทนค่าทั้งหมดของuint64_tได้ แต่ความหมายของการแปลงถูกนิยามไว้เป็นการตัดทิ้ง ประเด็นในที่นี้คือสำหรับ input บางอย่าง compiler สามารถทำอะไรก็ได้ ซึ่งเป็นปัญหาที่ต่างกันโดยพื้นฐานถ้าเป็นภาษาระดับต่ำ ก็อาจจับคู่กับคำสั่ง assembly อย่าง
CVTTSS2SIหรือFCVTZSได้เช่นกัน ในเมื่อภาษาระดับต่ำมีความใกล้ชิดกับ assembly และพฤติกรรมที่ไม่ได้นิยามไว้แบบ “ไม่เป็นพิษเป็นภัย” อื่นๆ ก็เป็นที่ถกเถียงมากอยู่แล้ว ความจริงที่ว่าการแปลงนี้ยอมให้เป็นพฤติกรรมที่ไม่ได้นิยามไว้จึงยิ่งน่าตกใจแต่การอนุญาตให้คอมไพล์โค้ดที่ไม่เกี่ยวข้องผิดเพี้ยนไปอย่างประหลาด, ฟอร์แมตฮาร์ดไดรฟ์, หรือทำให้ “ปีศาจบินออกมาจากจมูก” ได้นั้นน่าตกใจ
สำหรับ double free หรือการเขียนออกนอกขอบเขตอาร์เรย์ แนวคิดของพฤติกรรมที่ไม่ได้นิยามไว้ว่าทุกอย่างอาจเกิดขึ้นได้นั้นสมเหตุสมผล แต่ C และ C++ ใช้มันพร่ำเพรื่อแม้ในจุดที่ควรมีกฎที่เข้มงวดกว่าได้ เช่น ค่าขยะที่กำหนดโดย implementation หรือการหยุดโปรแกรม Rust ทำให้ integer overflow บางกรณี trap ในโหมด debug และคืนค่าที่ไม่ได้ระบุในโหมด release แต่ไม่ปล่อยให้มันทำลายโค้ดที่ไม่เกี่ยวข้อง
C++ ไม่จำเป็นต้องกลายเป็น Java หรือ Rust แต่ถ้า ลดพฤติกรรมที่ไม่ได้นิยามไว้ ที่ไม่จำเป็นลง ก็จะเป็นภาษาที่ดีขึ้นอย่างชัดเจน