- แนะนำ นิสัยการเขียนโค้ด ที่ใช้ ระบบชนิดข้อมูลและคอมไพเลอร์ของ 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 ความคิดเห็น
ความคิดเห็นจาก 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 ฯลฯ ถูกตรวจสอบแบบครอบคลุมทั้งระบบตั้งแต่คอมไพล์ไทม์
ในภาษาอื่น คุณต้องรักษา immutability ด้วยสไตล์แบบฟังก์ชันถึงจะได้ประโยชน์พวกนี้ แต่ Rust บังคับผ่าน type system เลย
ประเด็นของบทความคือ logical bug ที่แม้แต่ borrow checker ก็จับไม่ได้
รู้สึกว่าการ index เข้า array หรือ vector ตรง ๆ เป็นสิ่งที่ควรหลีกเลี่ยง
วันที่เกิดเหตุ unwrap ของ Cloudflare ผมเองก็เจอบั๊กที่ slice เลยท้าย vector เช่นกัน
หลังจากนั้นก็เปลี่ยนมาใช้ แนวทางอิง iterator แล้วรู้สึกปลอดภัยขึ้นมาก
unwrapใน Rust ก็เหมือนassertใน C คือถ้าพังก็แค่ทำหน้าที่บอกว่ามีปัญหาใน Rust เองก็ยังเขียนบั๊กได้อยู่ดี
นิสัยอย่างหนึ่งที่นักพัฒนา Rust ควรระวังคือการเพิ่ม dependency ของ crate ที่ไม่จำเป็น
Rust มีแนวโน้มจะส่งเสริมนิสัยนี้ เช่น การที่ Rust Book ใช้ crate
randเป็นตัวอย่างพื้นฐานก็สร้างบรรยากาศแบบนั้นแน่นอนว่านี่เป็นการตัดสินใจเชิงกลยุทธ์เพื่อให้สลับแพ็กเกจด้านคริปโตได้ง่าย แต่การกลายเป็นความเคยชินก็ยังเป็นปัญหา
แต่พอหลัง ๆ เข้าใจเจตนาแล้ว มุมมองก็เปลี่ยนไป
การทำ partial equality น่าสนใจดี
อีกอย่างที่ผมสงสัยคือวิธีใช้ enum เวลาต้องการเลี่ยง พารามิเตอร์แบบบูลีน
ผมใช้ struct ที่ห่อ bool ไว้ แต่ก็เสียดายที่มันใช้งานเหมือน bool ปกติไม่ได้
เลยสงสัยว่ามีวิธีทำให้ enum ใช้เหมือน bool ได้ไหม
จะรวบตรรกะที่ต้องใช้เข้าเป็น Trait หรือเพิ่มเมธอดร่วมไว้ในบล็อก
impl <Enum>ก็ได้แบบนี้อ่านง่ายและกำหนดพฤติกรรมของแต่ละสมาชิกได้ชัดเจน
impl Derefดูก็ได้ แต่ไม่แน่ใจว่าเป็นความคิดที่ดีไหมmatchในตัวอย่างแรกดูเยอะเกินความจำเป็นVec.first()หรือVec.iter().nth(0)ชัดเจนกว่าและตรงเจตนามากกว่าmatchแล้วกลับกลายเป็น ทางแก้ที่ซับซ้อนกว่าตัวปัญหาถ้าตัด
ifออกได้ ก็ย่อมตัดmatchออกได้เหมือนกัน ดังนั้นในแง่ความปลอดภัยไม่ได้ต่างกันfirst()กระชับและชัดเจนกว่ามากmatchก็มีความหมายในแง่ที่มันบังคับให้จัดการกรณี “มีสมาชิกอย่างน้อยหนึ่งตัว” ด้วยหรือก็คือมันสะท้อนหลักการว่า อย่าแยกการตรวจสอบออกจากโค้ดที่พึ่งพาการตรวจสอบนั้น
ทุกครั้งที่อ่านบทความแบบนี้ ผมก็มักสงสัยว่าทำไมถึงไม่มี ทีมเฉพาะทางคอยเฝ้าดู pattern ของโค้ด
ถ้ามีทีมแบบ SOC หรือ QA ที่คอยสังเกต pattern ใน codebase ระยะยาวก็น่าจะดี
เครื่องมือตรวจจับ code smell แบบอัตโนมัติก็มีข้อจำกัด
พวกเขาดูแลกฎ lint, เอกสาร, การฝึกอบรมนักพัฒนา และการบำรุงรักษาไลบรารีส่วนกลาง
ถ้าหลายทีมเจอปัญหาเดียวกันซ้ำ ๆ ก็จะออกแบบ core API เพื่อรวมแนวทางแก้เข้าด้วยกัน
แต่ความจริงคือถ้าโค้ดมีระดับหลายล้านบรรทัด การดูแลจัดการก็ยากมาก
ผมกำลังคิดอยู่ว่าจะส่งเสริม pattern การเขียนโค้ดที่ดี แบบนี้ในทีมได้อย่างไร
ระหว่าง code review มันมักลุกลามเป็น “การถกเถียงเรื่องสไตล์” จนไม่ค่อยเกิดประโยชน์
แต่ที่น่าสนใจคือพอ linter เป็นคนเตือน การถกเถียงแบบนั้นแทบหายไปเลย
การที่มี trait
TryFromเข้ามาในเวอร์ชัน 1.34 นี่มีประโยชน์มากจริง ๆโค้ดที่ใช้
unwrap_or_else()น่าจะเป็นร่องรอยตกค้างจากช่วงก่อนหน้านั้นเอกสารของ trait From ตอนนี้อธิบายได้ชัดมากแล้วว่าควร implement เมื่อไร
unwrap_or_else()ฟังดูตลกเหมือน “ออกคำสั่งขู่คอมพิวเตอร์” เลยผมคิดว่า pattern ของ defensive programming แบบนี้น่าจะช่วยยกระดับ คุณภาพของการสร้างโค้ดด้วย AI ในสเกลใหญ่ได้ด้วย
feedback ที่เฉพาะเจาะจงจาก Clippy หรือคอมไพเลอร์ของ Rust น่าจะมีบทบาทมากในการช่วยให้เอเจนต์ AI พลาดน้อยลงและจับทิศทางได้ดีขึ้น