1 คะแนน โดย GN⁺ 2 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • 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” และโปรแกรมอาจทำงานผิดพลาด
  • ผลลัพธ์ของ unchecked illegal behavior ไม่ได้รับการรับประกัน
    • ในตัวอย่าง switch อาจดูเหมือนว่ามันกระโดดไปยังสาขาอื่นเพราะลักษณะของ machine code ที่สร้างขึ้นในตอนนี้
    • แต่ในคอมไพเลอร์เวอร์ชันอื่นอาจเกิดพฤติกรรมผิดพลาดที่ต่างออกไปโดยสิ้นเชิง
    • ดูพฤติกรรมที่เกี่ยวข้องได้ใน ตัวอย่างบน godbolt
  • ความแตกต่างของ assert และ switch ที่ตามมาใน ReleaseSafe กับ ReleaseFast ดูได้จาก ตัวอย่าง godbolt อีกอัน
    • ใน ReleaseFast จะเห็นรูปแบบที่ฟังก์ชันข้ามการเปรียบเทียบทั้งหมดแล้วคืนค่า true
    • การปรับแต่งแบบนี้คือพฤติกรรมประเภทที่วิดีโอเกมและแอปพลิเคชันสื่อแบบเรียลไทม์อื่น ๆ พึ่งพาอย่างมาก

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 เหล่านี้ ผู้เขียนมองว่าประโยชน์ด้านประสิทธิภาพสำคัญกว่าการลดความเสี่ยงจากการทำงานผิดพลาดของโปรแกรมอย่างชัดเจน

การตัดสินใจของโปรเจกต์จริงและกรณีด้านความปลอดภัย

assert โดยนัยที่หายไปไม่ได้ทั้งหมดใน Zig

  • ต่อให้ assert ที่เขียนเองปิดได้ ก็ยังปิด assert ที่ภาษา Zig แทรกเข้ามาโดยนัยในโค้ดไม่ได้
    • เช่น integer overflow, การหารด้วย 0, การเข้าถึงนอกขอบเขตอาร์เรย์ เป็นต้น
    • เงื่อนไขเหล่านี้อาจทำให้เกิด runtime panic หรือถูกใช้เพื่อการปรับแต่งประสิทธิภาพ
  • แนวปฏิบัติการปิด assert ในโปรดักชันอาจทำให้ assert ที่ผิดค่อย ๆ เน่าและเพิ่มจำนวนอยู่ในโค้ดเบส
    • ผลคือความหวาดระแวงต่อ UIB จะยิ่งเพิ่มขึ้น และนักพัฒนาอาจเริ่มกลัวโดยไม่รู้ตัวที่จะเปิด assert กลับขึ้นมาแล้วต้องเผชิญผลลัพธ์
  • ข้อสรุปที่หลีกเลี่ยงไม่ได้คือ ไม่ควรปิด assert เพื่อกลบปัญหา แต่ต้อง แก้ assert ที่ผิดให้ถูกต้อง
    • ความถูกต้องของโปรแกรมต้องถูกไล่ให้ครอบคลุมทั้งระบบ ไม่ใช่แค่บางส่วนย่อย

1 ความคิดเห็น

 
GN⁺ 2 시간 전
ความเห็นจาก 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 ใน release

    • ถ้ากังวลเรื่องรัศมีความเสียหายจากความผิดพลาด ก็ควรใช้ ReleaseSafe ไม่ใช่ ReleaseFast
    • ไม่ได้คัดค้านการปิด assert เป็นรายจุด แต่คัดค้านการ ปิดทั้งหมดแบบเหมาๆ ราวกับเป็นแนวปฏิบัติที่แนะนำโดยทั่วไป
      การตัดสินว่า 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 ด้วย ซึ่งขัดกับความเข้าใจของฉันเกี่ยวกับโลกนี้โดยตรง

    • ผมว่าไม่ได้แปลกขนาดนั้นนะ ถึงจะเชื่อรายงานที่ว่าช่องโหว่ร้ายแรง 70% เกี่ยวกับหน่วยความจำ แต่นั่นอิงจาก C และ C++ ส่วน Zig อาจดีกว่านิดหน่อยในด้าน memory safety อีกอย่าง ถ้า ขนาดตัวอย่างมีแค่ 2 กรณี ก็ไม่แปลกเลยที่ประมาณ 1 ใน 10 โปรเจกต์จะออกผลแบบนี้
      จากมุมคนที่เคยทำงานกับ 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 search

    • ไม่มีค่าเริ่มต้น ผู้ใช้ต้องเลือกเองอย่างชัดเจนระหว่าง ReleaseSafe กับ ReleaseFast/ReleaseSmall
  • ในเอกสารด้านการวิเคราะห์โปรแกรม มี ภาวะคู่กัน ที่แบ่งข้อความยืนยันหรือ assert ในโค้ดออกเป็นสองแบบ แบบหนึ่งเกี่ยวกับบริบทรอบโค้ด ถ้าเป็นฟังก์ชันก็คือเงื่อนไขที่ผู้เรียกต้องทำให้เป็นจริง อีกแบบหนึ่งเกี่ยวกับตัวโค้ดเอง ถ้าเป็นฟังก์ชันก็คือเงื่อนไขที่ฟังก์ชันต้องทำให้เป็นจริง
    ความแตกต่างนี้จะชัดเจนเมื่อมองผ่านแนวคิดเชิงวิชาการมาตรฐานเรื่อง “ความรับผิด (blame)” ในเอกสารว่าด้วยสัญญาและ gradual typing ถ้าข้อความยืนยันเกี่ยวกับบริบทล้มเหลว ก็ไม่ใช่ความผิดของเรา แต่เป็นความรับผิดของบริบทหรือผู้เรียก แม้ก็อาจเป็นไปได้ว่าผู้เรียกถูกต้องและตัวข้อความยืนยันเองมีบั๊ก ถ้าข้อความยืนยันเกี่ยวกับตัวโค้ดล้มเหลว ก็เป็นความรับผิดของเรา แม้ก็อาจเป็นไปได้ว่าโค้ดถูกต้องและตัวข้อความยืนยันเองมีบั๊ก
    ในระดับฟังก์ชัน precondition คือข้อความยืนยันเกี่ยวกับบริบท และ postcondition คือข้อความยืนยันเกี่ยวกับตัวโค้ดเอง อย่างไรก็ดี ทั้งสองอย่างสามารถแทรกไว้กลางโค้ดได้เช่นกัน บางเฟรมเวิร์กสำหรับการตรวจพิสูจน์ใช้ assert สำหรับข้อความยืนยันเกี่ยวกับโค้ด และใช้ assume สำหรับข้อความยืนยันเกี่ยวกับบริบท เรื่องนี้ยังเชื่อมโยงกับวิธีที่เฟรมเวิร์กทดสอบบางตัว โดยเฉพาะเฟรมเวิร์กทดสอบแบบสุ่ม ตีความสิ่งเหล่านี้ด้วย ถ้า assert ล้มเหลวจะถูกนับเป็นการทดสอบล้มเหลว แต่ถ้า assume ล้มเหลวจะข้ามการทดสอบนั้น

    • BIND9 ใช้สไตล์ที่ใกล้กับ Design by Contract โดยใช้มาโคร REQUIRE() เพื่อตรวจ precondition ที่ผู้เรียกต้องทำให้เป็นจริง และ ENSURE() เพื่อตรวจ postcondition ที่ฟังก์ชันรับประกัน นอกจากนี้ยังมี INSIST() สำหรับตรวจระหว่างทาง และ INVARIANT() สำหรับลูปหรือโครงสร้างข้อมูลด้วย ในเอกสารของฟังก์ชันควรมีหมายเหตุ “requires” และ “ensures” ที่สอดคล้องกับ precondition และ postcondition
  • เหมือนว่านี่จะพาดพิงถึง 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 ไม่ได้เลย ถึงอย่างนั้นมันก็น่าจะยังเร็วกว่าอีกหลายภาษาอยู่ดี

    • เห็นด้วยว่าถ้ามีความกังวลแม้เพียงเล็กน้อยก็ควรใช้ ReleaseSafe แต่ก็มีวิธีที่น่าสนใจกว่านั้นได้เช่นกัน ระหว่างที่กำลังแก้โค้ดอยู่ หรือก็คือช่วงที่มีโอกาสใส่บั๊กเข้าไป ให้ใช้ 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” ไม่ได้ชัดเจนมาก ๆ ว่าหมายถึงอะไร ก็เป็นไปไม่ได้เลยที่จะมีการอภิปรายที่เป็นผู้ใหญ่ และสุดท้ายก็มีแต่เสียคำพูดเปล่า ๆ