32 คะแนน โดย GN⁺ 2025-12-07 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • แนะนำ นิสัยการเขียนโค้ด ที่ใช้ ระบบชนิดข้อมูลและคอมไพเลอร์ของ Rust อย่างเต็มที่ เพื่อป้องกันบั๊กไว้ล่วงหน้า
  • ยกตัวอย่าง Code Smell ของโค้ดที่เปราะบาง เช่น การทำดัชนีเวกเตอร์, การใช้ Default แบบพร่ำเพรื่อ, match ที่ไม่ครอบคลุม, พารามิเตอร์บูลีนที่ไม่จำเป็น พร้อมอธิบายทางเลือก
  • หลักการสำคัญคือ ออกแบบโครงสร้างให้คอมไพเลอร์บังคับใช้อินวาเรียนต์ โดยใช้ pattern matching, ฟิลด์แบบ private, แอตทริบิวต์ #[must_use] เป็นต้น
  • นำเสนอ เทคนิคเชิงป้องกันในระดับโค้ดจริง อย่างเป็นรูปธรรม เช่น การใช้ TryFrom, การแยกโครงสร้าง struct แบบครบถ้วน, temporary mutability, การตรวจสอบความถูกต้องใน constructor
  • แพตเทิร์นเหล่านี้จำเป็นต่อ การรักษาความเสถียรระหว่างการรีแฟกเตอร์และการเพิ่มความสามารถในการบำรุงรักษาระยะยาว

ภาพรวมของการเขียนโปรแกรมเชิงป้องกัน

  • จุดที่มีคอมเมนต์ // this should never happen ติดอยู่ คือ ตำแหน่งที่อินวาเรียนต์โดยนัยถูกทำลาย
    • ในหลายกรณี นักพัฒนาไม่ได้คำนึงถึงเงื่อนไขขอบทั้งหมดหรือการเปลี่ยนแปลงโค้ดในอนาคต
  • คอมไพเลอร์ Rust รับประกันความปลอดภัยของหน่วยความจำได้ แต่ ข้อผิดพลาดของ business logic ก็ยังเกิดขึ้นได้
  • จากประสบการณ์ทำงานจริงหลายปี แพตเทิร์นเชิงนิสัยเล็ก ๆ (idiom) เหล่านี้ช่วยยกระดับคุณภาพโค้ดได้มาก

Code Smell: การทำดัชนีเวกเตอร์

  • รูปแบบ if !vec.is_empty() { let x = &vec[0]; } มี ความเสี่ยงต่อ runtime panic เพราะการตรวจความยาวและการทำดัชนีแยกกันอยู่
  • หากใช้ slice pattern matching (match vec.as_slice()) จะทำให้ คอมไพเลอร์บังคับตรวจสอบทุกสถานะ
    • สามารถจัดการได้อย่างชัดเจนทุกกรณี เช่น เวกเตอร์ว่าง, มีสมาชิกเดียว, มีสมาชิกซ้ำ
  • นี่คือตัวอย่างเด่นของการ ออกแบบให้คอมไพเลอร์รับประกันอินวาเรียนต์

Code Smell: การใช้ Default อย่างไม่ยั้งคิด

  • ..Default::default() ก่อให้เกิดปัญหา เสี่ยงลืมเมื่อมีการเพิ่มฟิลด์ใหม่ และ การตั้งค่าแบบนัย
  • หากกำหนดค่าเริ่มต้นให้ทุกฟิลด์อย่างชัดเจน จะทำให้ คอมไพเลอร์บังคับให้ตั้งค่าฟิลด์ใหม่เสมอ
  • สามารถใช้รูปแบบ let Foo { field1, field2, .. } = Foo::default(); เพื่อ แยกโครงสร้างจากค่าเริ่มต้นแล้ว override เฉพาะส่วน ได้
    • ช่วยรักษาสมดุลระหว่างการคงค่าเริ่มต้นกับการ override แบบชัดเจน

Code Smell: การ implement Trait ที่เปราะบาง

  • การแยกโครงสร้างฟิลด์ของ struct แบบครบถ้วนก่อนเปรียบเทียบ จะช่วย เตือนด้วย compile error เมื่อมีการเพิ่มฟิลด์ใหม่
    • ตัวอย่าง: ตอน implement PartialEq ใช้ let Self { size, toppings, .. } = self;
  • หากมีการเพิ่มฟิลด์ใหม่อย่าง extra_cheese ก็จะ บังคับให้ทบทวนตรรกะการเปรียบเทียบ
  • หลักการเดียวกันนี้ใช้ได้กับ trait อื่น ๆ เช่น Hash, Debug, Clone

Code Smell: ควรใช้ TryFrom แทน From

  • หากการแปลงค่าไม่ได้สำเร็จเสมอไป ควรใช้ TryFrom เพื่อประกาศความเป็นไปได้ที่จะล้มเหลว แทน From
  • การใช้ unwrap_or_else เป็น สัญญาณว่ากำลังซ่อนความล้มเหลวที่อาจเกิดขึ้น และแนวทาง fail fast ปลอดภัยกว่า

Code Smell: match ที่ไม่สมบูรณ์

  • แพตเทิร์น catch-all อย่าง _ => {} มี ความเสี่ยงตกหล่นเมื่อมีการเพิ่ม variant ใหม่
  • หากระบุทุก variant อย่างชัดเจน จะทำให้ คอมไพเลอร์เตือนเมื่อพลาดการจัดการเคสใหม่
  • ตรรกะเดียวกันสามารถจัดกลุ่มในรูปแบบ Variant3 | Variant4 ได้

Code Smell: การใช้ placeholder _ มากเกินไป

  • หากใช้เพียง _ จะ ไม่ชัดเจนว่ามีการละตัวแปรใดไว้บ้าง
  • การเขียนเป็น has_fuel: _, has_crew: _ ช่วย เพิ่มความอ่านง่ายด้วยชื่อที่ชัดเจน

Pattern: Temporary Mutability

  • เมื่อข้อมูล ควร mutable เฉพาะช่วงเริ่มต้นค่าเท่านั้น สามารถใช้รูปแบบ let mut data = ...; data.sort(); let data = data;
  • หากใช้ block scope จะช่วย ป้องกันไม่ให้ตัวแปรชั่วคราวรั่วออกไปภายนอก
    • ตัวอย่าง: let data = { let mut d = get_vec(); d.sort(); d };
  • ในกระบวนการตั้งค่าเริ่มต้นที่ใช้ตัวแปรชั่วคราวหลายตัว ยังช่วย แบ่งขอบเขตให้ชัดเจน ได้ด้วย

Pattern: บังคับการตรวจสอบใน constructor

  • บังคับให้ ต้องผ่านตรรกะการตรวจสอบความถูกต้องก่อนสร้าง struct
    • หากเพิ่มฟิลด์ _private: () จะทำให้สร้างจากภายนอกโดยตรงไม่ได้
    • แอตทริบิวต์ #[non_exhaustive] ช่วย ปิดกั้นการสร้างจากภายนอก crate และส่งสัญญาณถึงการขยายในอนาคต
  • หากต้องการบังคับแม้แต่ภายในโมดูลเอง ให้ใช้ โครงสร้าง nested module ที่มีชนิดข้อมูล private (Seal)
    • เนื่องจาก Seal มีอยู่เฉพาะภายใน จึงสร้างโดยตรงได้นอกจากผ่าน new() ไม่ได้
  • หากเก็บฟิลด์เป็น private และให้ getter แทน ก็จะช่วย รักษาสถานะที่ไม่เปลี่ยนแปลง
  • เกณฑ์การเลือกใช้
    • ป้องกันโค้ดภายนอก: _private หรือ #[non_exhaustive]
    • ป้องกันโค้ดภายใน: private module + Seal
    • เปลี่ยนตรรกะการตรวจสอบให้เป็นการรับประกันในระดับคอมไพเลอร์

Pattern: การใช้แอตทริบิวต์ #[must_use]

  • #[must_use] ช่วย ป้องกันการละเลยค่าที่คืนกลับซึ่งสำคัญ
    • ตัวอย่าง: #[must_use = "Configuration must be applied to take effect"]
  • หากผู้ใช้เพิกเฉยต่อค่าที่คืนกลับ คอมไพเลอร์จะขึ้นคำเตือน
  • นี่เป็น เครื่องมือเชิงป้องกันที่เรียบง่ายแต่ทรงพลัง ซึ่งถูกใช้แพร่หลายใน standard library เช่น Result

Code Smell: พารามิเตอร์บูลีน

  • รูปแบบ fn process_data(..., compress: bool, encrypt: bool, validate: bool) มีปัญหา ความหมายไม่ชัดเจนและเสี่ยงสลับลำดับ
  • ใช้ enum Compression, enum Encryption เป็นต้น เพื่อ แสดงเจตนาอย่างชัดเจน
  • หากมีตัวเลือกหลายอย่าง ให้ใช้ พารามิเตอร์ struct (Params struct)
    • เช่น ProcessDataParams::production() ซึ่งเป็น เมธอด preset ที่ช่วยเพิ่มการนำกลับมาใช้ซ้ำ
  • เมื่อเพิ่มตัวเลือกใหม่ ก็จะ กระทบต่อจุดเรียกใช้งานเดิมน้อยที่สุด

ทำให้อัตโนมัติด้วย Clippy Lints

  • แพตเทิร์นเชิงป้องกันหลัก ๆ สามารถ ตรวจสอบอัตโนมัติได้ด้วย Clippy lint
    • indexing_slicing: ห้ามทำดัชนีโดยตรง
    • fallible_impl_from: แนะนำ TryFrom แทน From
    • wildcard_enum_match_arm: ห้ามใช้แพตเทิร์น _
    • fn_params_excessive_bools: เตือนเมื่อมีพารามิเตอร์บูลีนมากเกินไป
    • must_use_candidate: เสนอแนะจุดที่ควรใช้ #[must_use]
  • สามารถใช้กับทั้งโปรเจกต์ได้ผ่าน #![deny(clippy::...)] หรือการตั้งค่าใน Cargo.toml

บทสรุป

  • แก่นของการเขียนโปรแกรมเชิงป้องกันคือ ใช้ระบบชนิดข้อมูลและคอมไพเลอร์ของ Rust อย่างเต็มที่ เพื่อทำให้อินวาเรียนต์ชัดเจนและตรวจสอบได้
  • แพตเทิร์นเหล่านี้ช่วย รักษาเสถียรภาพระหว่างการรีแฟกเตอร์ ลดโอกาสเกิดบั๊ก และเสริมความสามารถในการบำรุงรักษาระยะยาว
  • นี่คือแนวทางที่นำหลัก “บั๊กที่ดีที่สุดคือบั๊กที่คอมไพล์ไม่ผ่าน” มาปฏิบัติจริง

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

 
GN⁺ 2025-12-07
ความคิดเห็นจาก Hacker News
  • ชอบบทความนี้นะ แต่รู้สึกว่า ตัวอย่าง PizzaOrder เอา concern หลายอย่างไปกองไว้ใน struct เดียวมากเกินไป
    ถ้าจุดประสงค์คืออยากตัด ordered_at ออกจากการเปรียบเทียบ ผมคิดว่าแยกเป็นสอง struct คือ PizzaDetails กับ PizzaOrder จะดีกว่า
    แบบนี้เวลาทำ PartialEq ก็จะเขียนให้เทียบแค่ details ได้อย่างชัดเจน

    • ชี้ประเด็นได้ดี แต่ผมก็ยังคิดว่าในเชิงตรรกะมันเป็น การทำโมเดลที่ผิด อยู่ดี
      ถ้าเวลาสั่งต่างกันก็ไม่ใช่ออเดอร์เดียวกัน ดังนั้นการนิยามว่าเท่ากันในระดับ type จึงเสี่ยง
      จะใส่ PartialEq ให้ PizzaDetails ก็โอเค แต่ตรรกะการเปรียบเทียบออเดอร์ควรอยู่ใน business function แยกต่างหาก
    • แนวทางแยกโครงสร้างก็ดี แต่ปัญหาคือเวลาแก้ PizzaDetails การเปลี่ยนนั้นอาจไปกระทบ ตรรกะการลบรายการพิซซ่าซ้ำ ได้
      ตามอุดมคติแล้ว struct ควรใช้เพื่อรวมข้อมูลอย่างเดียว
      เพื่อไม่ให้การเปลี่ยนแปลงไปกระทบจุดอื่น อาจพิจารณาแยก type อย่าง PizzaComparator หรือ PizzaFlavor ออกมาต่างหากก็ได้
      ถ้ามี annotation ระดับฟิลด์ แบบ {important_to_flavour=true} เหมือน Protobuf ได้ก็คงดี
    • การแยกโครงสร้างเพียงเพื่อรองรับวิธีเปรียบเทียบที่ต่างกัน มันไม่ใช่แนวทางที่ทำให้เป็นทั่วไปได้
      เช่น ถ้าอยากเทียบสตริงแบบไม่สนตัวพิมพ์เล็กพิมพ์ใหญ่ จะจะแยกอย่างไร?
  • สิ่งที่ยอดเยี่ยมมากอย่างหนึ่งใน Rust คือหลายครั้ง ไม่จำเป็นต้องทำ defensive programming เลย
    ด้วยกฎเรื่อง ownership และ reference เราจึงรับประกันได้ว่าการเข้าถึงออบเจ็กต์บางตัวมีอยู่เพียงแห่งเดียวทั้งโปรแกรม
    reference เป็น null ไม่ได้ และ smart pointer ก็เป็น null ไม่ได้เช่นกัน
    ถ้าย้าย ownership ของ self ออกไป ระบบ type ก็รับประกันว่าจะเรียกเมธอดต่อจากนั้นไม่ได้
    ส่งผลให้เรื่อง thread safety, lifetime, ความสามารถในการ clone ฯลฯ ถูกตรวจสอบแบบครอบคลุมทั้งระบบตั้งแต่คอมไพล์ไทม์

    • ผมก็คิดว่าจุดแข็งที่แท้จริงของ Rust คือ “สิ่งที่เราไม่ต้องคอยกังวล”
      ในภาษาอื่น คุณต้องรักษา immutability ด้วยสไตล์แบบฟังก์ชันถึงจะได้ประโยชน์พวกนี้ แต่ Rust บังคับผ่าน type system เลย
    • แต่คอมเมนต์นี้ดูเหมือนไม่ค่อยเกี่ยวกับบทความต้นฉบับนะ
      ประเด็นของบทความคือ logical bug ที่แม้แต่ borrow checker ก็จับไม่ได้
    • เนื้อหาของบทความโฟกัสไปที่ pattern การเขียนโค้ดเพื่อหลีกเลี่ยงความผิดพลาดเชิงตรรกะ เวลาปรับปรุงโปรแกรมซ้ำ ๆ เป็นหลัก
  • รู้สึกว่าการ index เข้า array หรือ vector ตรง ๆ เป็นสิ่งที่ควรหลีกเลี่ยง
    วันที่เกิดเหตุ unwrap ของ Cloudflare ผมเองก็เจอบั๊กที่ slice เลยท้าย vector เช่นกัน
    หลังจากนั้นก็เปลี่ยนมาใช้ แนวทางอิง iterator แล้วรู้สึกปลอดภัยขึ้นมาก

    • ผมไม่คิดว่ากรณี unwrap ควรถูกเรียกว่า “อุบัติเหตุ”
      unwrap ใน Rust ก็เหมือน assert ใน C คือถ้าพังก็แค่ทำหน้าที่บอกว่ามีปัญหา
      ใน Rust เองก็ยังเขียนบั๊กได้อยู่ดี
    • สุดท้ายก็เป็นปัญหาแบบเดียวกันนั่นแหละ ฝั่ง Rust มักพูดกันว่าให้ทิ้ง C แต่ใน C เอง การใช้ handle แทน index ก็เป็นเรื่องปกติเหมือนกัน
  • นิสัยอย่างหนึ่งที่นักพัฒนา Rust ควรระวังคือการเพิ่ม dependency ของ crate ที่ไม่จำเป็น
    Rust มีแนวโน้มจะส่งเสริมนิสัยนี้ เช่น การที่ Rust Book ใช้ crate rand เป็นตัวอย่างพื้นฐานก็สร้างบรรยากาศแบบนั้น
    แน่นอนว่านี่เป็นการตัดสินใจเชิงกลยุทธ์เพื่อให้สลับแพ็กเกจด้านคริปโตได้ง่าย แต่การกลายเป็นความเคยชินก็ยังเป็นปัญหา

    • ผมเองตอนแรกก็รู้สึกต่อต้าน Rust เพราะตัวอย่างนั้นเหมือนกัน
      แต่พอหลัง ๆ เข้าใจเจตนาแล้ว มุมมองก็เปลี่ยนไป
  • การทำ partial equality น่าสนใจดี
    อีกอย่างที่ผมสงสัยคือวิธีใช้ enum เวลาต้องการเลี่ยง พารามิเตอร์แบบบูลีน
    ผมใช้ struct ที่ห่อ bool ไว้ แต่ก็เสียดายที่มันใช้งานเหมือน bool ปกติไม่ได้
    เลยสงสัยว่ามีวิธีทำให้ enum ใช้เหมือน bool ได้ไหม

    • ผมเองก็แทบจะเลือก enum + match! ตลอด
      จะรวบตรรกะที่ต้องใช้เข้าเป็น Trait หรือเพิ่มเมธอดร่วมไว้ในบล็อก impl <Enum> ก็ได้
      แบบนี้อ่านง่ายและกำหนดพฤติกรรมของแต่ละสมาชิกได้ชัดเจน
    • อาจลองใช้ impl Deref ดูก็ได้ แต่ไม่แน่ใจว่าเป็นความคิดที่ดีไหม
  • match ในตัวอย่างแรกดูเยอะเกินความจำเป็น
    Vec.first() หรือ Vec.iter().nth(0) ชัดเจนกว่าและตรงเจตนามากกว่า

    • ผมก็เห็นด้วย ใช้ match แล้วกลับกลายเป็น ทางแก้ที่ซับซ้อนกว่าตัวปัญหา
      ถ้าตัด if ออกได้ ก็ย่อมตัด match ออกได้เหมือนกัน ดังนั้นในแง่ความปลอดภัยไม่ได้ต่างกัน
      first() กระชับและชัดเจนกว่ามาก
    • ถ้าอยากเขียนพฤติกรรมเดียวกันให้สั้นลงอีก ก็ใช้ itertools ของ exactly_one ได้เหมือนกัน
    • แต่ match ก็มีความหมายในแง่ที่มันบังคับให้จัดการกรณี “มีสมาชิกอย่างน้อยหนึ่งตัว” ด้วย
      หรือก็คือมันสะท้อนหลักการว่า อย่าแยกการตรวจสอบออกจากโค้ดที่พึ่งพาการตรวจสอบนั้น
  • ทุกครั้งที่อ่านบทความแบบนี้ ผมก็มักสงสัยว่าทำไมถึงไม่มี ทีมเฉพาะทางคอยเฝ้าดู pattern ของโค้ด
    ถ้ามีทีมแบบ SOC หรือ QA ที่คอยสังเกต pattern ใน codebase ระยะยาวก็น่าจะดี
    เครื่องมือตรวจจับ code smell แบบอัตโนมัติก็มีข้อจำกัด

    • บริษัทผม (ขนาดราว 300 คน) มี ทีมดูแล technical debt โดยเฉพาะ ที่ทำหน้าที่นี้อยู่
      พวกเขาดูแลกฎ lint, เอกสาร, การฝึกอบรมนักพัฒนา และการบำรุงรักษาไลบรารีส่วนกลาง
      ถ้าหลายทีมเจอปัญหาเดียวกันซ้ำ ๆ ก็จะออกแบบ core API เพื่อรวมแนวทางแก้เข้าด้วยกัน
    • บริษัทเทคใหญ่ ๆ ส่วนมากก็มีทีมแบบนี้อยู่แล้ว
      แต่ความจริงคือถ้าโค้ดมีระดับหลายล้านบรรทัด การดูแลจัดการก็ยากมาก
  • ผมกำลังคิดอยู่ว่าจะส่งเสริม pattern การเขียนโค้ดที่ดี แบบนี้ในทีมได้อย่างไร
    ระหว่าง code review มันมักลุกลามเป็น “การถกเถียงเรื่องสไตล์” จนไม่ค่อยเกิดประโยชน์
    แต่ที่น่าสนใจคือพอ linter เป็นคนเตือน การถกเถียงแบบนั้นแทบหายไปเลย

  • การที่มี trait TryFrom เข้ามาในเวอร์ชัน 1.34 นี่มีประโยชน์มากจริง ๆ
    โค้ดที่ใช้ unwrap_or_else() น่าจะเป็นร่องรอยตกค้างจากช่วงก่อนหน้านั้น
    เอกสารของ trait From ตอนนี้อธิบายได้ชัดมากแล้วว่าควร implement เมื่อไร

    • ผมยังเรียน Rust อยู่ แต่ชื่อ unwrap_or_else() ฟังดูตลกเหมือน “ออกคำสั่งขู่คอมพิวเตอร์” เลย
  • ผมคิดว่า pattern ของ defensive programming แบบนี้น่าจะช่วยยกระดับ คุณภาพของการสร้างโค้ดด้วย AI ในสเกลใหญ่ได้ด้วย
    feedback ที่เฉพาะเจาะจงจาก Clippy หรือคอมไพเลอร์ของ Rust น่าจะมีบทบาทมากในการช่วยให้เอเจนต์ AI พลาดน้อยลงและจับทิศทางได้ดีขึ้น