คุณต้องแก้ `assert` ให้ถูกต้อง
(kristoff.it)- assert เป็นกลไกสำหรับระบุเงื่อนไขก่อนเข้าใช้งาน เงื่อนไขหลังการทำงาน และอินวาเรียนต์ไว้ในโค้ด และถ้าข้อจำกัดใดบังคับได้ด้วยระบบชนิดข้อมูล ก็ควรแสดงออกด้วยความสามารถของภาษาแทน
std.debug.assertของ Zig ไม่ใช่แมโคร แต่เป็น ฟังก์ชันปกติ ที่ใช้unreachableเพื่อระบุเส้นทางที่ไม่ควรเข้าถึง และยังใช้ช่วยการปรับแต่งประสิทธิภาพได้- ในโหมด Debug และ ReleaseSafe เมื่อ assert ล้มเหลว โปรแกรมจะ panic แล้ว crash แต่ใน ReleaseFast และ ReleaseSmall อาจเกิด unchecked illegal behavior และทำงานผิดพลาดได้
- การปิด assert ในโปรดักชันทำให้เสียโอกาสในการพบสมมติฐานที่ผิดให้เร็วที่สุด และต่อมาโค้ดอาจเริ่ม พึ่งพา assert ที่ผิด จนนำไปสู่ช่องโหว่ได้
- จะเลือก ReleaseSafe หรือ ReleaseFast ขึ้นอยู่กับลำดับความสำคัญของโปรแกรม แต่ประเด็นสำคัญคือไม่ควรปิด assert กลบปัญหา แต่ต้อง แก้ assert ที่ผิดให้ถูกต้อง
บทบาทของ assert และพฤติกรรมพื้นฐานของ Zig
- assert เป็นกลไกสำหรับแสดงในโค้ดว่ามีเงื่อนไขบางอย่างที่ต้องเป็นจริงเสมอ เช่น “อาร์กิวเมนต์นี้ต้องไม่เป็น null” หรือ “จำนวนเต็มนี้ต้องไม่เป็นเลขคู่”
- ตัวอย่าง:
assert(my_arg != null);,assert(my_num % 2 != 0); - หากสามารถบังคับข้อจำกัดได้ด้วยระบบชนิดข้อมูล ก็มักดีกว่าที่จะใช้ความสามารถของภาษาแทน assert
- ใน Zig พอยน์เตอร์ปกติ
*Fooไม่สามารถเป็น null ได้ ส่วนพอยน์เตอร์แบบเลือกค่า?*Fooสามารถเป็น null ได้ แต่จะบังคับให้ตรวจสอบก่อนเข้าถึงค่า
- ตัวอย่าง:
- assert เหมาะสำหรับระบุ เงื่อนไขก่อนเข้าใช้งาน, เงื่อนไขหลังการทำงาน, และ อินวาเรียนต์
- assert ที่ดีอาจทรงพลังกว่าการทดสอบหน่วยในการจับความผิดพลาดจากการเขียนโปรแกรม
- หากใช้ร่วมกับ fuzzing ก็อาจยิ่งเพิ่มประสิทธิภาพของ assert ได้
unreachable และ assert ใน Zig
- assert ของ Zig อาศัยความสามารถของภาษาที่ใช้ระบุเส้นทางโค้ดที่ผิดพลาด นั่นคือ
unreachable- ใน
switchสามารถระบุสาขาที่ไม่มีทางเข้าถึงได้เป็น.a => unreachableเป็นต้น unreachableใช้ได้ทั้งในรูปแบบคำสั่ง และในตำแหน่งที่ต้องการ expression ของชนิดใดก็ได้- จึงไม่จำเป็นต้องฝืนสร้างค่าชั่วคราวสำหรับกรณีที่เข้าถึงไม่ได้
- ใน
std.debug.assertในไลบรารีมาตรฐานของ Zig ถูกเขียนไว้ประมาณนี้pub fn assert(ok: bool) void { if (!ok) unreachable; // assertion failure }- ข้อมูลจาก
unreachableสามารถนำไปใช้กับ การปรับแต่งประสิทธิภาพ ได้- คอมไพเลอร์สามารถตัดเส้นทางที่เข้าถึงไม่ได้ออก และข้อมูลนี้ยังแพร่ต่อไปจนทำให้เกิดการปรับแต่งแบบไม่จำกัดแค่จุดเดียวได้
- ไม่ใช่ว่าทุก assert จะเพิ่มประสิทธิภาพ แต่ก็อาจเปิดทางให้เกิดการปรับแต่งที่โปรแกรมเมอร์คาดไม่ถึงได้
โหมดการบิลด์และความปลอดภัยขณะรัน
- Zig มีโหมดการบิลด์ Debug, ReleaseSafe, ReleaseFast, และ ReleaseSmall
- การตั้งค่านี้ไม่จำเป็นต้องใช้แบบ global กับทั้งโปรแกรมเสมอไป
- สามารถบิลด์แต่ละ dependency ด้วยโหมดต่างกันได้ และใช้
@setRuntimeSafetyเพื่อปรับระดับ runtime safety ได้แม้ในระดับบล็อกภายในฟังก์ชัน
- การล้มเหลวของ assert ใน Zig ถือเป็น “illegal behavior”
- ในโหมด checked อย่าง Debug, ReleaseSafe, และ
@setRuntimeSafety(true)โปรแกรมจะ panic แล้ว crash - ในโหมด unchecked อย่าง ReleaseFast, ReleaseSmall, และ
@setRuntimeSafety(false)จะเกิด “unchecked illegal behavior” และโปรแกรมอาจทำงานผิดพลาด
- ในโหมด checked อย่าง Debug, ReleaseSafe, และ
- ผลลัพธ์ของ unchecked illegal behavior ไม่ได้รับการรับประกัน
- ในตัวอย่าง
switchอาจดูเหมือนว่ามันกระโดดไปยังสาขาอื่นเพราะลักษณะของ machine code ที่สร้างขึ้นในตอนนี้ - แต่ในคอมไพเลอร์เวอร์ชันอื่นอาจเกิดพฤติกรรมผิดพลาดที่ต่างออกไปโดยสิ้นเชิง
- ดูพฤติกรรมที่เกี่ยวข้องได้ใน ตัวอย่างบน godbolt
- ในตัวอย่าง
- ความแตกต่างของ assert และ
switchที่ตามมาใน ReleaseSafe กับ ReleaseFast ดูได้จาก ตัวอย่าง godbolt อีกอัน- ใน ReleaseFast จะเห็นรูปแบบที่ฟังก์ชันข้ามการเปรียบเทียบทั้งหมดแล้วคืนค่า
true - การปรับแต่งแบบนี้คือพฤติกรรมประเภทที่วิดีโอเกมและแอปพลิเคชันสื่อแบบเรียลไทม์อื่น ๆ พึ่งพาอย่างมาก
- ใน ReleaseFast จะเห็นรูปแบบที่ฟังก์ชันข้ามการเปรียบเทียบทั้งหมดแล้วคืนค่า
assert ของ Zig ไม่ใช่แมโคร
std.debug.assertของ Zig เป็น ฟังก์ชันปกติ ไม่ใช่แมโคร- Zig ไม่มีแมโคร
- นี่เป็นจุดที่นักพัฒนา C/C++ มักแปลกใจเมื่อเริ่มใช้ Zig
- ใน C/C++ การปิด assert มักทำให้ทั้งการเรียก assert และ expression ที่ส่งเข้าไปทำงานเหมือนถูกคอมเมนต์ทิ้ง
- เพราะเหตุนี้ ใน C/C++ จึงไม่ควรใส่ expression ที่มีผลข้างเคียงลงใน assert
- เนื่องจากเมื่อ assert ถูกปิด การคำนวณนั้นอาจหายไปทั้งชุด
- ใน Zig ตามกฎการเรียกฟังก์ชัน อาร์กิวเมนต์จะถูกประเมินก่อนเรียกฟังก์ชัน
- ไม่ว่าโลจิกภายใน
std.debug.assertจะเป็นอย่างไร expression ของอาร์กิวเมนต์ก็ยังถูกประเมิน - ดังนั้นจึงสามารถใส่ expression ที่มีผลข้างเคียงลงใน assert ได้แบบนี้
// assert that the remove operation is not a noop: assert(my_map.remove("expected-to-exist")); - ไม่ว่าโลจิกภายใน
- ในทางกลับกัน หากการคำนวณเงื่อนไขของ assert ต้องใช้การทำงานที่ซับซ้อน การคำนวณนั้นก็อาจไม่ได้ถูกตัดออกเสมอไปในโหมด unchecked
- กรณีแบบนี้ควรใช้
comptime ifเพื่อครอบโค้ด
const builtin = @import("builtin"); if (builtin.mode == .Debug) { var condition = ...; // whatever bookkeeping is necessary // to compute the condition assert(condition == .ok); } - กรณีแบบนี้ควรใช้
- หากคุ้นกับความหมายเชิงภาษาของ C/C++ เรื่องนี้อาจดูแปลก แต่ใน Zig มีสมมติฐานพื้นฐานว่าโดยทั่วไปจะไม่ปิด assert
ปัญหาของการปิด assert ในโปรดักชัน
- โดยใหญ่แล้วมีสามทางเลือกสำหรับ assert
- คงไว้เป็น runtime check และหากล้มเหลวก็ให้โปรเซส panic แล้ว crash
- ใช้ assert เพื่อช่วยปรับแต่งประสิทธิภาพ โดยยอมรับว่าถ้า assert ผิด โปรแกรมอาจทำงานผิดพลาด
- ปิด assert ไปเลยทั้งหมด
std.debug.assertไม่รองรับการปิด assert ทั้งหมดเป็นค่าปริยาย- หากเขียน assert ของตัวเองที่เช็กแฟล็กระหว่างบิลด์ภายใน ก็สามารถสร้างพฤติกรรมแบบ C/C++ ได้มากขึ้น
- เหตุผลที่คนอยากปิด assert มักมาจากการรวมกันของสองเรื่อง
- ไม่ต้องการคง runtime check ไว้เพราะไม่ชอบต้นทุนด้านประสิทธิภาพหรือไม่ต้องการให้แอปพลิเคชัน crash
- ไม่มั่นใจว่า assert จะถูกต้องเสมอ จึงกลัวพฤติกรรมผิดพลาดที่อาจเกิดเมื่อมันถูกนำไปใช้ในการปรับแต่งประสิทธิภาพ
- ดังที่ matklad เตือนไว้ในการถกเถียงที่เกี่ยวข้อง บางสถานการณ์มีเหตุผลทางวิศวกรรมที่ชอบธรรมจริง ๆ ที่ต้องหลีกเลี่ยงการ crash
- แต่สำหรับซอฟต์แวร์ทั่วไป การตั้งให้หลีกเลี่ยงการ crash เป็นค่าเริ่มต้นถือเป็นทางเลือกที่ไม่ดี
- หากปิด assert แล้ว ต่อให้เกิดเงื่อนไขที่เคยสมมติว่าเป็นไปไม่ได้ โปรแกรมก็จะยังทำงานต่อ
- โปรแกรมจะทำงานต่อภายใต้สมมติฐานที่ผิด ซึ่งเป็นรูปแบบหนึ่งของการทำงานผิดพลาด แม้จะไม่ใช่ unchecked illegal behavior ก็ตาม
- เหตุที่ unchecked illegal behavior หรือ undefined behavior ของ C อันตราย เพราะมันอาจเป็นเส้นทางที่เปลี่ยนโปรแกรมให้กลายเป็น weird machine ได้
- ในซอฟต์แวร์ที่ซับซ้อนมากพอ ต่อให้ไม่มี UIB โปรแกรมก็อาจบิดเบี้ยวไปทำสิ่งที่ไม่ได้ตั้งใจได้
- การที่ assert กลายเป็น false ตอนรันไทม์ หมายถึงหลุดออกจากสเปก และตัวมันเองก็อาจทำให้โปรแกรมไปทำงานที่ไม่ได้ตั้งใจ
- SQL injection เป็นตัวอย่างที่ชัดเจนและพบได้ทั่วไปของความผิดพลาดระดับ weird machine โดยไม่ต้องมี UIB
- หากต้นทุนของการที่โปรแกรมทำงานผิดพลาดสูงเกินไป ก็ควรเปิด assert เอาไว้
- หากประสิทธิภาพสำคัญมากจนยอมรับความเสี่ยงของการทำงานผิดพลาดได้ ก็ควรใช้ assert เป็นโอกาสในการปรับแต่งประสิทธิภาพ
- การปิด assert มักทำให้พลาดทั้งประสิทธิภาพ และยังเสี่ยงหลงคิดว่าระบบปลอดภัยกว่าความเป็นจริง
วิธีที่ assert ที่ผิดหลอกโค้ดเบส
- ความเสี่ยงหลักคือ assert ที่ผิด อาจไม่โผล่ตอนทดสอบ แต่ล้มเหลวจริงเฉพาะในโปรดักชัน
- ถ้ารับประกันได้ว่า assert ทั้งหมดเป็นจริงเสมอ การใช้ assert เพื่อการปรับแต่งประสิทธิภาพก็แทบไม่ใช่ประเด็นถกเถียง
- ถ้ารับประกันได้ว่าการทดสอบจะจับ assert ที่ผิดได้ทั้งหมด การปรับแต่งในโปรดักชันก็ย่อมปลอดภัย
- แต่ในโลกจริง เราอาจเขียน assert ผิดได้ และการทดสอบก็ไม่ได้รับประกันว่าจะจับได้เสมอ
- การปิด assert ในโปรดักชันทำให้เสียโอกาสในการค้นหา assert ที่ผิดให้เร็วที่สุด
- ที่แย่กว่านั้นคือโค้ดที่เขียนทีหลังอาจยังถูกเขียนต่อไปโดยอาศัย assert ที่ผิดนั้น
- ในตัวอย่างโค้ด
processThingถูกสมมติว่าต้องถูกเรียกกับthingที่เริ่มทำงานแล้วเท่านั้นfn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... } - assert นี้อาจไม่เคยล้มเหลวในระหว่างทดสอบ และอาจถูกมองข้ามว่าในโปรดักชันมันอาจเป็น false ได้จริงเพราะถูกปิดไว้
- หากไม่มีอาการผิดปกติที่ผู้ใช้สังเกตเห็นได้ ก็อาจดูเหมือนไม่มีปัญหาและการพัฒนาก็ดำเนินต่อไป
- จากนั้นอาจมีคนมาเพิ่มโค้ดโดยเห็นว่า
thingเริ่มทำงานแล้ว จึงสามารถเรียกbazได้โดยไม่ต้องเตรียมอะไรเพิ่มfn processThing(thing: Thing) void { // this function must always be invoked on // a thing that has already been started assert(thing.is_started); // ... // Since thing is already started, we don't // need to foo the bar before bazzing the qux. // It would be really bad to baz the qux otherwise, // so we add an assert for good measure. assert(thing.is_fooed); thing.baz(qux); } - แม้ assert อันที่สองจะถูกต้องในเชิงตรรกะ แต่ถ้า assert แรกสามารถเป็น false ได้จริง ความเสี่ยงก็เกิดขึ้นแล้ว
- ในการทดสอบ assert แรกไม่ล้มเหลว ดังนั้น assert ที่สองก็มักไม่ล้มเหลวเช่นกัน
- แต่ในโปรดักชัน เมื่อ assert ถูกปิดไว้ เราอาจไม่ทันสังเกตช่วงเวลาที่ช่องโหว่ถูกปล่อยเข้ามาในโค้ดเบส
- หาก assert ภายในโค้ดกำลังหลอกนักพัฒนาอยู่ การเขียนโค้ดให้ถูกต้องก็จะยากอย่างไม่สมเหตุสมผล
ทางเลือกขึ้นอยู่กับลำดับความสำคัญของโปรแกรม
- แต่ละโปรแกรมมีลำดับความสำคัญต่างกัน และบางโปรแกรมอาจมีเหตุผลชอบธรรมที่จะให้ความสำคัญกับประสิทธิภาพมากกว่าการลดความเสี่ยงจากการทำงานผิดพลาด
- ในกรณีนี้ การเปลี่ยน assert ให้เป็นโอกาสในการปรับแต่งประสิทธิภาพก็เป็นทางเลือกที่สมเหตุสมผล
- การปิด assert ในโปรดักชันแบบทำตามความเคยชินถือเป็นทางเลือกที่ด้อยกว่า ทั้งเมื่อเทียบกับการเปิด assert ไว้ และเมื่อเทียบกับการใช้การปรับแต่งประสิทธิภาพอย่างจริงจัง
- การ วิจารณ์อย่างหนัก ต่อ ReleaseFast ขณะเดียวกันกลับยอมรับการปิด assert แบบไม่ตั้งคำถาม เป็นท่าทีที่ขัดแย้งกัน
- Zine เป็น static site generator และตอนนี้ใช้หลัก ๆ เพื่อบิลด์บล็อกส่วนตัว
- ยังไม่มีการกำหนด threat model อย่างชัดเจน และนั่นก็ไม่ใช่ลำดับความสำคัญสูงสุด
- จึงแจกจ่ายบิลด์แบบ ReleaseFast เพราะชอบที่มันทำงานเร็วกว่า Hugo ถึงระดับหนึ่งหลักของ order of magnitude
- Awebo เป็น Discord ทางเลือกแบบ self-hosted ที่ยังอยู่ในช่วง pre-alpha
- ชัดเจนอยู่แล้วว่าเป็นซอฟต์แวร์ที่จัดการข้อมูลส่วนตัวและจะถูกเปิดเผยต่ออินเทอร์เน็ต
- เมื่อถึงเวลาปล่อยใช้งาน มีแผนจะให้บิลด์แบบ ReleaseSafe
- อย่างไรก็ตาม dependency สำคัญบางตัว เช่น FFmpeg, Xiph Opus, และ SQLite จะยังถูกบิลด์แบบ ReleaseFast
- สำหรับ dependency เหล่านี้ ผู้เขียนมองว่าประโยชน์ด้านประสิทธิภาพสำคัญกว่าการลดความเสี่ยงจากการทำงานผิดพลาดของโปรแกรมอย่างชัดเจน
การตัดสินใจของโปรเจกต์จริงและกรณีด้านความปลอดภัย
- TigerBeetle เป็นฐานข้อมูลการเงินและเปิด assert ไว้ตลอดเวลา
- Ghostty เป็น terminal emulator และแจกจ่ายบิลด์แบบ ReleaseFast สำหรับ macOS
- และยัง แนะนำแนวทางเดียวกัน ให้ผู้ใช้ปลายทาง downstream เช่นผู้ดูแลดิสทริบิวชัน Linux
- CVE ที่เปิดเผยต่อสาธารณะสองรายการของ Ghostty ที่ค่อนข้างร้ายแรง ล้วนเป็นกรณีที่สามารถรันคำสั่งตามอำเภอใจได้ โดยไม่มี memory corruption
- อันตรายไม่ได้มีแค่ memory corruption หรือ UIB เท่านั้น
assert โดยนัยที่หายไปไม่ได้ทั้งหมดใน Zig
- ต่อให้ assert ที่เขียนเองปิดได้ ก็ยังปิด assert ที่ภาษา Zig แทรกเข้ามาโดยนัยในโค้ดไม่ได้
- เช่น integer overflow, การหารด้วย 0, การเข้าถึงนอกขอบเขตอาร์เรย์ เป็นต้น
- เงื่อนไขเหล่านี้อาจทำให้เกิด runtime panic หรือถูกใช้เพื่อการปรับแต่งประสิทธิภาพ
- แนวปฏิบัติการปิด assert ในโปรดักชันอาจทำให้ assert ที่ผิดค่อย ๆ เน่าและเพิ่มจำนวนอยู่ในโค้ดเบส
- ผลคือความหวาดระแวงต่อ UIB จะยิ่งเพิ่มขึ้น และนักพัฒนาอาจเริ่มกลัวโดยไม่รู้ตัวที่จะเปิด assert กลับขึ้นมาแล้วต้องเผชิญผลลัพธ์
- ข้อสรุปที่หลีกเลี่ยงไม่ได้คือ ไม่ควรปิด assert เพื่อกลบปัญหา แต่ต้อง แก้ assert ที่ผิดให้ถูกต้อง
- ความถูกต้องของโปรแกรมต้องถูกไล่ให้ครอบคลุมทั้งระบบ ไม่ใช่แค่บางส่วนย่อย
1 ความคิดเห็น
ความเห็นจาก Lobste.rs
เห็นด้วยว่าใน
assertการทำให้โปรแกรมแครชไปเลย หรืออย่างน้อยให้แครชเฉพาะงานนั้นแบบ panic ของ Rust มักจะเป็นทางเลือกที่ดีที่สุด แต่ไม่ค่อยเห็นด้วยว่าการใช้assertเป็น hint สำหรับการ optimize จะดีกว่าการลบทิ้งไปเฉยๆ เสมออย่างแรก
assertแบบตามอำเภอใจมักช่วยเรื่อง optimization ได้ไม่มาก และมีหลายเงื่อนไขที่ optimizer เอาไปใช้ต่อได้ไม่ทันที ถ้าไม่ได้เป็นสมมติฐานตรงๆ อย่าง “branch นี้จะไม่มีวันถูกเรียก” การโปรยสมมติฐานสุ่มๆ ไว้ทั่วโค้ดก็คงไม่ได้ผลด้านประสิทธิภาพมากนักอย่างที่สอง การเปลี่ยน
assertให้กลายเป็นสมมติฐานทำให้ รัศมีความเสียหาย จากความผิดพลาดกว้างขึ้นมาก ตัวอย่างเช่น ในระบบที่ประมวลผลข้อมูลซึ่งแยกกันตามโปรเจกต์หรือผู้ใช้ สมมติว่ามีassertอยู่กลางฟังก์ชันคำนวณเพื่อจับสถานะที่ตามปกติไม่ควรเกิดขึ้น ถ้าใน release build ปิดมันเพราะมีต้นทุนสูง แล้วเป็นการปิดทิ้งเฉยๆ ความเสียหายอาจจำกัดอยู่แค่โปรเจกต์หรือผู้ใช้รายเดียว และอาจถูกจับได้ในจุดตรวจสอบถัดไป แต่ถ้าทำให้มันกลายเป็น พฤติกรรมที่ไม่กำหนดไว้ การคำนวณอาจกระโดดไปยังโค้ดผิดที่ ทำหน่วยความจำเสียหายแบบสุ่ม และทำให้ข้อมูลของทุกโปรเจกต์พังได้สุดท้ายแล้ว การเลือก
assertที่ไม่ปลอดภัยเป็นค่าเริ่มต้นของ release build ก็คือการรีบ optimize ตำแหน่งต่างๆ ของโค้ดแบบเหมารวม โดยแลกกับโอกาสที่น้อยลงในการจำกัดความเสียหายเมื่อเกิดปัญหา สำหรับ Rust ถือว่าออกแบบมาดีเพราะassert!()จะ panic เสมอ,debug_assert!()จะ panic เฉพาะใน debug mode, และassert_unchecked()จะ panic ใน debug แต่เป็น hint สำหรับ optimization ใน releaseReleaseSafeไม่ใช่ReleaseFastassertเป็นรายจุด แต่คัดค้านการ ปิดทั้งหมดแบบเหมาๆ ราวกับเป็นแนวปฏิบัติที่แนะนำโดยทั่วไปการตัดสินว่า
assertบางตัวมีผลต่อประสิทธิภาพมากเกินไปจนเก็บไว้ใน release build ไม่ไหวถือว่าสมเหตุสมผลเต็มที่ ยิ่งไปกว่านั้นassertที่มีต้นทุนการคำนวณสูงก็มักแทบไม่ช่วยให้เกิดการปรับปรุงประสิทธิภาพอย่างที่กล่าวไปก่อนหน้าใน Zine ก็มีตัวอย่างแบบนั้นอยู่หลายจุด:
https://github.com/kristoff-it/zine/…
https://github.com/kristoff-it/zine/…
Zig ไม่มี “โหมด release เริ่มต้น” คุณต้องเลือกเองเสมอว่าจะจัดการ
assertอย่างไร และตัวเลือกแบบครอบคลุมทั้งโปรเจกต์ก็มีทั้งแครชหรือ optimize ซึ่งไม่มีฝั่งไหนเป็นค่าเริ่มต้นมากกว่าอีกฝั่งเรื่องที่ชวนรู้สึกแปลกมากคือ CVE ที่ค่อนข้างร้ายแรงสองรายการซึ่งเปิดเผยมาแล้วใน Ghostty ล้วนลงเอยที่การรันคำสั่งตามอำเภอใจโดยไม่มี memory corruption เลย และยังเกิดขึ้นทั้งที่ปล่อยแบบ ReleaseFast ด้วย ซึ่งขัดกับความเข้าใจของฉันเกี่ยวกับโลกนี้โดยตรง
จากมุมคนที่เคยทำงานกับ terminal emulator ช่องโหว่พวกนี้เป็นปัญหาน่าปวดหัวแบบที่คาดเดาได้พอดี ไม่ได้จะดูแคลนนักพัฒนาหรือนักวิจัย แต่ command injection ในตำแหน่งแปลกๆ แบบนี้แทบจะเป็นของแถมประจำวงการนี้ เหมือนกับที่ในสายงานอื่นมักมีช่องโหว่ injection รูปแบบอื่นติดมาด้วย
ตลกดีที่ผมได้ยินข้อเสนอให้ปิด
assertและการตรวจสอบขอบเขตในโปรดักชัน “เพราะประสิทธิภาพ” มาเกือบ 40 ปี แล้ว ระหว่างนั้นคอมพิวเตอร์เร็วขึ้นไปกี่หลักไม่รู้ และซอฟต์แวร์ก็แทรกเข้าไปในชีวิตของทุกคนลึกกว่าเดิมมาก ทำให้ความถูกต้องของรันไทม์สำคัญกว่าที่เคยถ้าจะคุยให้สร้างสรรค์กว่านั้น สมัยก่อนใน Microsoft นอกจาก
assert,checkทั่วไปแล้ว ยังมีสิ่งที่ที่อื่นไม่ค่อยเห็นคือ reporting assert มันใช้ในกรณีที่มีเงื่อนไขบางอย่างซึ่งเราไม่ได้ควบคุมทั้งหมด เราคิดว่ามันควรเป็นจริง แต่ก็ยังเขียนทางป้องกันไว้สำหรับกรณีที่มันไม่จริง และอยากรู้ผ่าน log หรือ telemetry ระยะไกลว่ามันผิดจริงในภาคสนามหรือไม่ เช่น สมมติว่าผู้ใช้คงไม่ใส่รายการเกิน 1000 รายการในลิสต์หนึ่งแล้วเลือกใช้อัลกอริทึมกำลังสอง หรือสมมติว่า network latency จะต่ำกว่า 200ms แล้วใช้โปรโตคอลที่ต้อง round-trip หลายครั้งcheckยังไง?ในฐานะหนึ่งในคนที่ถูกลิงก์ถึง นี่เป็นการทำให้ความคิดของผมเรื่อง
assertกลายเป็น ทางเลือกจอมปลอมแบบมีแค่สองทาง และเป็นภาพล้อเลียน อย่างที่เขียนไว้ในคอมเมนต์อื่น ผมชอบให้ตัดสินเป็นรายassertมากกว่าว่าจะเปลี่ยนมันเป็นพฤติกรรมที่ไม่กำหนดไว้หรือไม่ คำวิจารณ์ของผมต่อ ReleaseFast คือมันเอาตัวเลือกนั้นไปมัดรวม ไม่ใช่แค่กับassertทั้งหมดในขอบเขตหนึ่ง แต่รวมถึงการตรวจสอบความปลอดภัยทั้งหมดด้วยผมเห็นด้วยกับ kristoff ว่าการปิด
assertที่ยังไม่แก้ไขเพียงเพราะมันทำให้แครชนั้นเป็นเรื่องโง่ แต่ไม่เห็นด้วยว่า “แครชหรือไม่ก็พฤติกรรมที่ไม่กำหนดไว้” คือทางเลือกที่สมเหตุสมผลเพียงสองแบบ ความเห็นของ goldstein ในคอมเมนต์พี่น้องใกล้เคียงกับสิ่งที่ผมคิดมากกว่าการทำพฤติกรรมของ
assert_unchecked()ให้เป็นค่าเริ่มต้นระดับ global นั้นปกป้องได้ยาก แต่ในฐานะเทคนิคการ optimize ด้านประสิทธิภาพก็ถือว่าสมเหตุสมผล ถ้าการเปลี่ยนassertทุกตัวให้เป็นสมมติฐานทำให้ production build เร็วขึ้นอย่างมีนัยสำคัญ ก็อาจมี สมมติฐานเพียงไม่กี่ตัว หรือหวังว่าแค่ตัวเดียว ที่สร้างผลด้านประสิทธิภาพเกือบทั้งหมด และน่าจะหาเจอได้ด้วยวิธีอย่าง binary searchReleaseSafeกับReleaseFast/ReleaseSmallในเอกสารด้านการวิเคราะห์โปรแกรม มี ภาวะคู่กัน ที่แบ่งข้อความยืนยันหรือ
assertในโค้ดออกเป็นสองแบบ แบบหนึ่งเกี่ยวกับบริบทรอบโค้ด ถ้าเป็นฟังก์ชันก็คือเงื่อนไขที่ผู้เรียกต้องทำให้เป็นจริง อีกแบบหนึ่งเกี่ยวกับตัวโค้ดเอง ถ้าเป็นฟังก์ชันก็คือเงื่อนไขที่ฟังก์ชันต้องทำให้เป็นจริงความแตกต่างนี้จะชัดเจนเมื่อมองผ่านแนวคิดเชิงวิชาการมาตรฐานเรื่อง “ความรับผิด (blame)” ในเอกสารว่าด้วยสัญญาและ gradual typing ถ้าข้อความยืนยันเกี่ยวกับบริบทล้มเหลว ก็ไม่ใช่ความผิดของเรา แต่เป็นความรับผิดของบริบทหรือผู้เรียก แม้ก็อาจเป็นไปได้ว่าผู้เรียกถูกต้องและตัวข้อความยืนยันเองมีบั๊ก ถ้าข้อความยืนยันเกี่ยวกับตัวโค้ดล้มเหลว ก็เป็นความรับผิดของเรา แม้ก็อาจเป็นไปได้ว่าโค้ดถูกต้องและตัวข้อความยืนยันเองมีบั๊ก
ในระดับฟังก์ชัน precondition คือข้อความยืนยันเกี่ยวกับบริบท และ postcondition คือข้อความยืนยันเกี่ยวกับตัวโค้ดเอง อย่างไรก็ดี ทั้งสองอย่างสามารถแทรกไว้กลางโค้ดได้เช่นกัน บางเฟรมเวิร์กสำหรับการตรวจพิสูจน์ใช้
assertสำหรับข้อความยืนยันเกี่ยวกับโค้ด และใช้assumeสำหรับข้อความยืนยันเกี่ยวกับบริบท เรื่องนี้ยังเชื่อมโยงกับวิธีที่เฟรมเวิร์กทดสอบบางตัว โดยเฉพาะเฟรมเวิร์กทดสอบแบบสุ่ม ตีความสิ่งเหล่านี้ด้วย ถ้าassertล้มเหลวจะถูกนับเป็นการทดสอบล้มเหลว แต่ถ้าassumeล้มเหลวจะข้ามการทดสอบนั้นเหมือนว่านี่จะพาดพิงถึง Bun เลยอยากทำการเชื่อมโยงให้เป็นทางการขึ้นอีกหน่อย มี issue ของ Zig ที่ Jarred Sumner ผู้สร้าง Bun เสนอไว้ในปี 2024 ว่า
unreachableควร panic ใน ReleaseFast คอมเมนต์ของ Andrew Kelley และ Matthew Lugg ในเธรดนั้นเกี่ยวข้องกับการอภิปรายนี้=> https://github.com/ziglang/zig/issues/19664
Bun ใช้ฟังก์ชัน
assertของตัวเอง ซึ่งในโหมดรีลีสจะ panic หรือถูกตัดออกไป แต่จะไม่ก่อให้เกิด undefined behavior อย่างไรก็ตาม ควรจำเชิงอรรถของ Loris ไว้ด้วย นั่นคือ “Zig ในฐานะภาษา ได้แอบเพิ่มassertจำนวนมากที่ปิดใช้งานไม่ได้เข้าไปในโค้ดโดยปริยาย”ไม่อยากพูดเรื่อง Bun ยืดยาวเกินไป เพราะมันเป็นโปรเจ็กต์เดียวของทีมเล็ก ๆ ประเด็นสำคัญคือ ถ้ามีความกังวลแม้เพียงเล็กน้อยก็ควรใช้ ReleaseSafe มีชื่อเสียงว่า ReleaseSafe ช้า แต่ในโปรเจ็กต์ Zig ขนาดเล็กของฉัน ฉันวัดความต่างของ benchmark ระหว่าง ReleaseSafe กับ ReleaseFast ไม่ได้เลย ถึงอย่างนั้นมันก็น่าจะยังเร็วกว่าอีกหลายภาษาอยู่ดี
หรือถ้าเหมาะกับบริบท ก็อาจแจกจ่ายไบนารี ReleaseFast ไปก่อน แล้วถ้าเริ่มมีรายงานบั๊กแบบไม่กำหนดแน่นอนอันเกิดจาก undefined behavior เข้ามา ค่อยย้อนกลับมาใช้ ReleaseSafe แบบนั้นคุณจะรวบรวมรายงานบั๊กที่นำไปลงมือแก้ได้จริงว่ามี
assertตัวไหนล้มเหลว รวมถึงการเข้าถึงนอกขอบเขตหรือ overflow แล้วค่อยแก้โค้ด ถึงจะเป็นการตัดสินใจในบริบทที่จริง ๆ ไม่ควรปล่อย ReleaseFast ตั้งแต่แรก ฉันก็ยังแนะนำแนวทางนี้อยู่ดี :^)คุณยังสามารถปรับ dependencies และใช้
@setRuntimeSafetyเพื่อใช้แนวทางเดียวกันนี้กับแค่บางส่วนของโปรเจ็กต์ได้ด้วย สุดท้ายแล้ว ถ้าคุณตั้งใจจะทำอย่างฉลาด เครื่องมือที่ต้องใช้ก็มีครบอยู่แล้วไม่ควรเขียนราวกับว่าสามารถใส่ นิพจน์ที่มีผลข้างเคียง ไว้ใน
assertได้ มันเป็นแนวปฏิบัติที่ไม่ดี และควรหลีกเลี่ยงการใช้assertเพื่อตรวจข้อผิดพลาดด้วย เพื่อความเป็นธรรม ดูเหมือนผู้เขียนไม่ได้กำลังอ้างแบบนั้นในทางกลับกัน ก็มีคำอธิบายว่าถ้า
assertพึ่งพาการคำนวณที่ซับซ้อน การคำนวณนั้นอาจไม่ได้ถูกลบออกแน่ ๆ ในโหมด unchecked ดังนั้นจึงควรป้องกันด้วยcomptime ifหวังว่าผู้เขียนจะไม่พลาดความประชดในคำว่า “เป็นโอกาสดีที่จะสลัดบาดแผลจากแมโครแล้วโอบรับความเรียบง่าย” เพราะมันเท่ากับบอกให้ยอมรับ “ความเรียบง่ายที่ต้องคอยพิจารณา build mode ของโปรแกรมและโปรย
comptime ifเชิงป้องกันไว้ทั่วโค้ด”ฉันเขียนโค้ดคำนวณเชิงตัวเลขด้วย C# อยู่บ้าง และใช้
assertจำนวนมากที่ถูกปิดในโหมดรีลีส มันแพงเกินไปที่จะรันในทุกลูปที่แน่นมาก ๆ แต่ใน unit test การให้มันระเบิดทันทีที่รูทีนเจอ อินพุต NaN ครั้งแรกนั้นมีประโยชน์NaN แบบนี้มักไม่ได้มาจากอินพุตของผู้ใช้ แต่มักเกิดจากบั๊กในโค้ด เช่น ตัว optimizer ไปถึงจุดที่ไม่ควรไป หรือเพราะต้องการข้อจำกัดที่ขอบเขตที่ดีกว่านี้ แน่นอนว่าอินพุตผู้ใช้อาจต้องมีการคัดกรองเช่นกัน แต่สิ่งนั้นควรทำที่ขอบนอกสุด ไม่ใช่ลึกเข้าไปในอัลกอริทึม จะดีมากถ้ามีระบบพิสูจน์ที่สามารถพิสูจน์ invariant ภายในอัลกอริทึมได้จากผลของการคัดกรองอินพุตผู้ใช้โดยไม่ต้องพึ่ง
assertแต่โปรเจ็กต์นี้เป็นโปรเจ็กต์เล่น ๆ และถึงพังก็ไม่มีใครตายความเห็นไม่ลงรอยกันเกี่ยวกับ
assertกว่า 90% เกิดจากการที่คำนี้มีความหมายหลายแบบและนิยามไม่ชัด ทำให้ทั้งการคิดและการสื่อสารพร่ามัว เพราะอย่างนั้นจึงควรแยกแนวคิดนี้ออกเป็น สามชื่อ และใช้มันอย่างเคร่งครัดassert(bool)หรือถ้าเป็น Rust ก็assert_unchecked()คือสิ่งที่โปรแกรมเมอร์เชื่อว่าต้องเป็นจริงเสมอ และคอมไพเลอร์ก็สมมติว่าจริงเสมอเพื่อนำไปใช้ปรับให้เหมาะสม เพื่อหลีกเลี่ยงการนึกโยงกับ assert แบบมีการตรวจของภาษาเก่า ๆ การเรียกมันว่าassume()อาจดีกว่าcheck(bool)คือถ้าเงื่อนไขเป็นเท็จให้ panic และถ้าเป็นจริงก็ทำงานต่อ และมันทำแบบนั้นเสมอdebug_check(bool)คือในโหมดดีบักจะเหมือนcheck()และในโหมดรีลีสจะทำงานต่อเสมอ ในทางปฏิบัติมันถูกควบคุมด้วยแฟลก--debug_checksที่เปิดไว้เป็นค่าเริ่มต้นในโหมดดีบักนอกจากนี้ยังต้องมีแฟลกคอมไพเลอร์
--check_assertsที่เปลี่ยนassert()ให้เป็นcheck()ด้วย ใช้เมื่ออยากตรวจสอบassertของตัวเองเพราะยังไม่แน่ใจ และในโหมดดีบักมันควรถูกเปิดเป็นค่าเริ่มต้น ถ้าเวลาเราพูดคำว่า “assert” ไม่ได้ชัดเจนมาก ๆ ว่าหมายถึงอะไร ก็เป็นไปไม่ได้เลยที่จะมีการอภิปรายที่เป็นผู้ใหญ่ และสุดท้ายก็มีแต่เสียคำพูดเปล่า ๆ