17 คะแนน โดย GN⁺ 2025-07-26 | 8 ความคิดเห็น | แชร์ทาง WhatsApp
  • ระหว่างการเขียนโปรแกรม เราสามารถใช้ ระบบประเภท เพื่อแยกความหมายของข้อมูลที่แตกต่างกันให้ชัดเจนได้
  • การ ใช้ประเภททั่วไปอย่างสตริงหรือจำนวนเต็มโดยตรง ทำให้สูญเสียบริบท และ อาจนำไปสู่บั๊กได้
  • แม้จะมีประเภทพื้นฐานเดียวกัน แต่หาก กำหนดประเภทใหม่ให้เหมาะกับจุดประสงค์ ก็จะ ป้องกันความผิดพลาดได้ด้วยข้อผิดพลาดตอนคอมไพล์
  • ในไลบรารี Go อย่าง libwx มีการกำหนดประเภทที่แยกหน่วยการวัดอย่างชัดเจน เพื่อ ป้องกันความผิดพลาดจากการปะปนกันของ float64
  • ในโค้ดตัวอย่าง มีการ แยกประเภท UUID ออกเป็น UserID และ AccountID เพื่อให้ คอมไพเลอร์บล็อกการใช้งานที่ผิดพลาด
  • แม้ในภาษาอย่าง Go ที่ระบบประเภทไม่ได้เข้มแข็งมากนัก ก็ยัง ป้องกันบั๊กได้ด้วยการห่อประเภทอย่างง่าย

มาใช้ระบบประเภทอย่างเต็มที่กันเถอะ

จุดเริ่มต้นของปัญหา: การปะปนกันของประเภทแบบง่าย

  • ในการเขียนโปรแกรม มักมีหลายกรณีที่ใช้เพียง ประเภทพื้นฐาน อย่าง string, int, UUID เพื่อแทนค่าต่าง ๆ
  • แต่เมื่อโปรเจ็กต์มีขนาดใหญ่ขึ้น ความผิดพลาดจากการ นำประเภทแบบง่ายเหล่านี้มาใช้ปะปนกันโดยไม่แยกแยะ ก็เกิดขึ้นบ่อย
    • ตัวอย่าง: ส่งสตริง userID ไปเป็น accountID โดยไม่ตั้งใจ หรือส่งลำดับอาร์กิวเมนต์ผิดในฟังก์ชันที่มีพารามิเตอร์ int 3 ตัว เป็นต้น

ทางแก้: กำหนดประเภทที่สะท้อนเจตนา

  • int หรือ string เป็นเพียง building block เท่านั้น หากส่งผ่านไปทั่วทั้งระบบโดยตรง ก็จะ สูญเสียบริบทที่มีความหมาย
  • เพื่อป้องกันสิ่งนี้ ควร กำหนดประเภทเฉพาะตามบทบาท แล้วใช้งาน
    • ตัวอย่าง:
      type AccountID uuid.UUID  
      type UserID uuid.UUID  
      
      func UUIDTypeMixup() {  
          {  
              userID := UserID(uuid.New())  
              DeleteUser(userID)  
              // ไม่มีข้อผิดพลาด  
          }  
      
          {  
              accountID := AccountID(uuid.New())  
              DeleteUser(accountID)  
              // ข้อผิดพลาด: ไม่สามารถใช้ประเภท AccountID เป็น UserID ได้  
          }  
      
          {  
              accountID := uuid.New()  
              DeleteUserUntyped(accountID)  
              // ไม่มีข้อผิดพลาดตอนคอมไพล์ แต่มีโอกาสสูงที่จะเกิดปัญหาระหว่างรันไทม์  
          }  
      }  
      
  • วิธีนี้ทำให้ บล็อกอาร์กิวเมนต์ที่มีประเภทผิดได้ตั้งแต่ตอนคอมไพล์

กรณีใช้งานจริง: ไลบรารี libwx

  • ผู้เขียนได้นำเทคนิคนี้มาใช้ในไลบรารี Go ของตนชื่อ libwx
  • สำหรับทุกหน่วยการวัด จะมีการ กำหนดประเภทเฉพาะ และ ผูกเมธอดแปลงหน่วยเข้ากับประเภทนั้น
    • ตัวอย่าง: แยกหน่วยอย่างชัดเจนผ่านเมธอด Km.Miles()
  • ด้านล่างคือตัวอย่างที่ คอมไพเลอร์บล็อก การสลับลำดับอาร์กิวเมนต์ของฟังก์ชันและความสับสนเรื่องหน่วย:
    // ประกาศอุณหภูมิฟาเรนไฮต์  
    temp := libwx.TempF(84)  
    
    // ประกาศความชื้นสัมพัทธ์ (เปอร์เซ็นต์)  
    humidity := libwx.RelHumidity(67)  
    
    // ส่งค่าผิดให้กับฟังก์ชันที่ต้องการอุณหภูมิเซลเซียสแทนฟาเรนไฮต์  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointC(temp, humidity))  
    // คอมไพเลอร์ตรวจจับข้อผิดพลาด type mismatch ได้ทันที  
    // temp (ประเภท TempF) ไม่สามารถใช้เป็น TempC ได้  
    
    // ส่งลำดับอาร์กิวเมนต์ให้ฟังก์ชันผิด  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointF(humidity, temp))  
    // คอมไพเลอร์ช่วยป้องกันข้อผิดพลาดของประเภทอาร์กิวเมนต์  
    
  • หากใช้เพียง float64 ก็อาจเกิดความผิดพลาดเหล่านี้ได้ทั้งหมด แต่แนวทางนี้ ช่วยป้องกันได้ครบถ้วน

สรุป: ใช้ระบบประเภทให้เต็มที่

  • ระบบประเภทไม่ได้มีไว้เพียงตรวจสอบไวยากรณ์ แต่เป็น เครื่องมือป้องกันบั๊ก
  • ควร กำหนดประเภท ID แยกตามแต่ละโมเดล และควร ห่ออาร์กิวเมนต์ของฟังก์ชันด้วยประเภทที่ชัดเจน แทนการใช้ float หรือ int ตรง ๆ
  • วิธีนี้ มีประสิทธิภาพมากและทำได้ง่าย แม้ในภาษาอย่าง Go ที่ระบบประเภทไม่ได้เข้มแข็งมากนัก
  • ในโลกจริง บั๊กจากการปะปนกันของ UUID หรือประเภทสตริงเกิดขึ้นบ่อยมาก
  • ผู้เขียนย้ำว่าการที่วิธีง่าย ๆ แบบนี้ ยังไม่ถูกใช้อย่างแพร่หลายในโค้ดโปรดักชันเป็นเรื่องน่าประหลาดใจ

โค้ดที่เกี่ยวข้อง

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

 
vk8520 2025-07-29

เท่าที่ทราบ ถ้าจะใช้ใน Kotlin อาจมีปัญหาด้านประสิทธิภาพเพราะ primitive จะถูกห่อด้วย wrapper ทำให้ถูกเก็บบน heap แทนที่จะเป็น stack ได้ แน่นอนว่าในกรณีการใช้งานส่วนใหญ่ ความง่ายในการบำรุงรักษามักมาก่อน นอกจากนี้ยังสามารถใช้ value class เพื่อลดปัญหาด้านประสิทธิภาพให้เหลือน้อยที่สุดได้

 
regentag 2025-07-28

ภาษา Ada มีระบบชนิดข้อมูลที่ยอดเยี่ยมมากในแง่นี้ เราสามารถประกาศค่าคนละประเภทให้เป็นชนิดข้อมูลแยกกันได้อย่างง่ายดาย และเมื่อมีการปะปนกัน คอมไพเลอร์ก็จะช่วยตรวจจับได้อย่างดี

 
roxie 2025-07-28

ขอถามด้วยความสงสัยครับ/ค่ะ นอกจากนั้นยังมีข้อดีที่แตกต่างจากภาษาแบบมีไทป์ยอดนิยมอื่น ๆ ด้วยหรือเปล่า? (kotlin, rust, typescript, ...)

 
regentag 2025-07-28

ข้อดีของ Ada โดยทั่วไปจะอยู่ในแนวว่า “ดีกว่า C” ครับ ใน C มีหลายอย่างที่ปล่อยให้เชื่อนักพัฒนาและเปิดให้ทำได้ค่อนข้างมาก เช่นการแปลงชนิดข้อมูลโดยปริยายอะไรทำนองนั้น แต่ดูเหมือนว่านักพัฒนาส่วนใหญ่จะชอบ C มากกว่าเพราะคุ้นเคยกับมัน...

อาจเป็นลักษณะเฉพาะของโค้ดเบสที่ผมทำงานอยู่ก็ได้ แต่เราแทบจะประกาศเกือบทุกอย่างเป็นชนิดข้อมูลแยกต่างหากแล้วค่อยใช้งาน การใช้ชนิดข้อมูลพื้นฐานมีแค่ประมาณดัชนีของอาร์เรย์เท่านั้นครับ

 
roxie 2025-07-28

เข้าใจแล้ว ขอบคุณ

 
GN⁺ 2025-07-26
ความคิดเห็นจาก Hacker News
  • ฉันชอบแนวทางนี้ คือแนวคิดแบบ 'ทำให้สถานะที่ไม่ดีไม่สามารถแสดงออกได้ในตัวแบบเอง (make bad state unrepresentable)' แต่ปัญหาที่พบบ่อยของแพตเทิร์นนี้คือ นักพัฒนามักหยุดอยู่แค่ขั้นแรกของการทำ type implementation ทุกอย่างกลายเป็น type และเข้ากันได้ไม่ดี เกิด type ที่ดัดแปลงต่างกันเล็กน้อยเต็มไปหมดจนตามโค้ดและทำความเข้าใจได้ยาก ถ้าเป็นแบบนั้นฉันกลับอยากเขียนด้วยภาษาที่เป็น dynamic แบบ type อ่อนกว่าอย่าง JS หรือ dynamic ที่ type แข็งแรงอย่าง Elixir มากกว่า แต่ถ้านักพัฒนายังคงผลักดัน flow ที่ขับเคลื่อนด้วย type ต่อไป เช่น ย้ายตรรกะเงื่อนไขเข้าไปอยู่ใน union type ที่ pattern match ได้ และใช้ delegation ให้ดี ประสบการณ์พัฒนาก็จะกลับมาราบรื่นอีกครั้ง เช่น ฟังก์ชัน DewPoint สามารถทำให้รับหลาย type แล้วทำงานได้อย่างเป็นธรรมชาติ

    • ด้วยเหตุนี้เลยอยากให้ภาษาอื่น ๆ รองรับ bounded type (เช่น จำกัดช่วงของ Integer) แบบเป็นพื้นฐานมากขึ้น เช่น แทนที่จะเป็น x: u32 ก็อยากให้บังคับใน type system ได้เลยว่า x รับได้แค่ช่วง [0,10) แบบนี้จะไม่ต้องมี bound check ตอนทำ array indexing และในกรณีอย่าง Option ก็ทำ peephole optimization ได้ง่ายขึ้นมาก ใน Rust มีบางส่วนที่ LLVM ช่วยได้ภายในฟังก์ชัน แต่ยังไม่รองรับเวลาส่งตัวแปรข้ามฟังก์ชัน

    • อ้างอิงไว้ก่อนว่า Ruby ไม่ใช่ weakly typed แต่เป็น strongly typed ถ้าทำ 1 + "1" จะได้ error อย่าง TypeError: String can't be coerced into Integer

    • การ 'หยุดอยู่ที่ขั้นแรกของการทำ type implementation' นี่แหละคือสาเหตุของความล้มเหลว เช่น การเอา int ไปห่อใน struct แล้วใช้เป็น UUID เป็นจุดเริ่มต้นที่ดี แต่ถ้าใครมีแค่ int ก็ห่อ type ส่งเข้ามาได้อยู่ดี ทำให้คุณสมบัติความเป็น UUID ที่ควรไม่ซ้ำกันพังได้ สุดท้ายสิ่งสำคัญคือ 'Correct by construction (รับประกันความถูกต้องตั้งแต่ตอนสร้าง)' กล่าวคือ type ที่ต้องไม่ซ้ำอย่าง UUID ควรถูกห้ามไม่ให้สร้างได้เลย เว้นแต่จะพิสูจน์ได้จริงผ่านฟังก์ชันหรือ constructor ไม่ว่าจะด้วยวิธีใดก็ตาม แนวคิดนี้ใช้ได้ไม่ใช่แค่กับ UUID แต่ใช้ได้กับทุก type และ invariant

    • ช่วงนี้ฉันทำตามแพตเทิร์น Red-Green-Refactor แต่แทนที่จะเริ่มจากเทสต์ที่ล้มเหลว ก็ทำให้ type system เข้มงวดขึ้นเพื่อให้ตัวตรวจ type จับบั๊กแทน ฟีเจอร์ใหม่ edge case หรือบั๊กที่ type ชักนำ error ไม่ได้ก็ยังใช้เทสต์ตามปกติ แต่ red-green-refactor ที่อาศัย type system มักเร็วกว่าและกันบั๊กได้ทั้งกลุ่มใหญ่แบบหมดจด

    • ปัญหาส่วนใหญ่บรรเทาได้ด้วย structural types และถ้าจำเป็นจริง ๆ ก็ยังบังคับด้วย nominal types ได้

  • ในประเด็นที่ใกล้เคียงกับ exception และ type ฉันคิดว่าการใช้ checked exception ให้ดีและจัดการให้เหมาะตามแต่ละ type เป็นเรื่องที่ดี ฉันไม่เคยเข้าใจว่าทำไม checked exception ของ Java ถึงโดนตำหนินัก ตอนที่ฉันบังคับให้โปรเจกต์ที่ดูแลอยู่ใช้ checked exception ช่วงแรกทุกคนเกลียดมัน แต่พอคุ้นกับกระบวนการคิดถึงทุกกรณียกเว้นของ flow โค้ดแล้ว ทุกคนกลับชอบมัน แม้จะไม่เคร่งกับ unit test มากนัก แต่โปรเจกต์แข็งแรงมาก

    • ข้อบ่นเรื่อง checked exception ของ Java คือการจัดการ exception มันจุกจิกเกินไป คนเขียนไลบรารีก็ตัดสินใจ checked exception ให้ชัดได้ยาก ส่วนฝั่ง client ก็ต้องมาจัดการ exception แบบไม่จำเป็นทุกครั้งที่เรียกฟังก์ชัน เลยรู้สึกน่ารำคาญ ถ้ามันแปลงเป็น type อื่นหรือ runtime exception ได้ง่ายขึ้น หรือแค่ประกาศทีเดียวในระดับโมดูล/แอปได้ ปัญหานี้ก็คงลดลง แต่ตอนนี้มันยุ่งยากเกินไป อีกอย่างคือมันทำให้พัง signature ได้ง่าย เลยต้องใช้ exception ตามโดเมน แต่ Java ก็ทำให้การแปลง exception ไม่สะดวก checked exception ดีอยู่หรอก แต่ฉันไม่ชอบ usability ของการจัดการ exception ใน Java

    • เหตุผลที่ checked exception ถูกด่าคือมันถูกใช้เกินจำเป็น Java รองรับทั้ง checked และ unchecked ถือว่าเป็นการตัดสินใจที่ดี แต่ควรใช้ checked exception เฉพาะกับกรณีอย่าง exception แบบ 'exogenous' ตามที่ Eric Lippert พูดถึง แล้วส่วนใหญ่ที่เหลือควรแปลงเป็น unchecked เช่น ฐานข้อมูลอาจตัดการเชื่อมต่อเมื่อไรก็ได้ แต่การเขียน throws SQLException ไว้บน call stack ไปเรื่อย ๆ มันน่ารำคาญเกินไป จับแบบ catch-all ที่ชั้นบนสุดแล้วคืน HTTP 500 ก็พอ บทความที่เกี่ยวข้อง

    • checked exception (เมื่อเทียบกับ unchecked) มีปัญหาว่า ถ้าฟังก์ชันลึก ๆ ใน call stack ถูกเปลี่ยนให้โยน exception ได้ อาจต้องแก้ไม่ใช่แค่ฟังก์ชันที่จัดการ แต่รวมถึงทุกฟังก์ชันคั่นกลางด้วย นั่นทำให้ความยืดหยุ่นเวลาปรับระบบลดลง ประเด็นถกเถียงเรื่อง async function coloring ก็อยู่ในบริบทคล้ายกัน ถ้าฟังก์ชันโยน exception ได้ ก็ต้องห่อด้วย try/catch หรือไม่ก็ให้ caller ประกาศด้วยว่าโยน exception ได้

    • C# มี type ที่ชัดเจนแต่เลือกใช้ unchecked exception ทำให้ error stack ถูกจัดได้สะอาดและไม่มีปัญหา ดูสะอาดกว่าการมี exception handler แบบ pattern matching ที่ทำ bespoke handling ตามแต่ละระดับ ถ้ามีผลลัพธ์ error ที่ unwrap ได้อย่างแข็งแรงก็คงคล้ายกัน

    • ใน Java ยังมีปัญหาเรื่อง usability ของ checked type ด้วย เช่น ตอนใช้ stream API ถ้าในฟังก์ชัน map/filter จะโยน checked exception นี่ลำบากมาก ถ้ามีการเรียกหลาย service ที่แต่ละตัวมี checked exception ของตัวเอง สุดท้ายก็ต้องจับ Exception หรือไม่ก็เขียนรายการ exception ยาวจนน่าเกินไป

  • โดยรวมฉันเห็นด้วยกับแนวทาง 'สร้าง type เฉพาะ' แต่ก็เคยมีประสบการณ์เหนื่อยกับระบบที่ทุกอย่างเป็น type เฉพาะ โดยเฉพาะเวลาโค้ดที่แค่ย้าย byte ไปมา กับโค้ดคำนวณโดเมนมาปะปนกัน มันรู้สึกลำบากมาก

    • เข้าใจความรู้สึกนั้นเลย คือข้อมูลที่ต้องใช้ก็มีอยู่แล้ว แต่ดันต้องไปหาวิธีสร้าง type หรือสร้าง instance ก่อน ถ้าไม่มี recipe ก็เหมือนต้องสู้กับเอกสาร เช่น มี object {x, y, z} อยู่แล้ว แต่ต้องใช้ฟังก์ชัน createVector(x, y, z): Vector ก่อน แล้วถ้าจะสร้าง Face ก็ต้อง createFace(vertices: Vector[]): Face อะไรแบบนี้ ทำให้ขั้นตอนยืดยาวโดยไม่จำเป็น อย่าง BouncyCastle ก็เช่นกัน ต่อให้มี byte array พร้อมแล้ว ก็ยังต้องสร้างหลาย type และเรียก methods ของมันก่อนถึงจะใช้ความสามารถที่ต้องการจริงได้

    • ในภาษา Go การย้อน type alias กลับไปเป็น type เดิม (เช่น AccountID → int) ทำได้ค่อนข้างง่าย ถ้าวางโครงดี ๆ ก็ทำสไตล์ clean architecture ได้ โดยฝั่ง business logic ใช้ type alias ส่วนไลบรารีที่ไม่สนโดเมนก็แปลงเป็น higher/lower type เพื่อประมวลผลได้ แต่จะต้องมีโค้ดแปลงเยอะมาก

    • Phantom types มีประโยชน์กับกรณีแบบนี้ คือเพิ่ม type parameter (ก็คือ generic) เข้าไป แต่จริง ๆ แล้วไม่ได้ใช้ parameter นั้นที่ไหนเลย เมื่อก่อนฉันเคยเขียนโค้ดเข้ารหัสใน Scala ที่ array ทั้งหมดเป็นแค่ byte แต่ใช้ phantom types ป้องกันไม่ให้ปะปนกัน กรณีที่เกี่ยวข้อง

    • ในอุดมคติ อยากให้ compiler แค่ตรวจ type แล้วลดตรรกะโดเมนที่เหลือทั้งหมดลงเป็นการคัดลอก byte แบบง่าย ๆ ให้เลย ไม่แน่ใจว่าฉันเข้าใจเจตนาคุณถูกไหมนะ

  • ฉันคิดว่า type system ก็ใช้กฎ 80/20 เหมือนกัน ถ้าใช้หนักเกินไปจะทำให้การใช้ไลบรารีเป็นภาระ แต่แทบไม่ได้ประโยชน์เพิ่ม UUID หรือ String เป็นสิ่งที่คุ้นเคย แต่ AccountID, UserID ไม่ใช่ จึงมีต้นทุนในการเรียนรู้เพิ่ม elaborate type system อาจคุ้มก็ได้หรือไม่คุ้มก็ได้ โดยเฉพาะถ้ามีเทสต์เพียงพออยู่แล้ว อ้างอิงที่เกี่ยวข้อง

    • ยังไงเสียถ้าจะใช้ซอฟต์แวร์ก็ต้องรู้อยู่แล้วว่า Account หรือ User คืออะไร เพราะงั้นฟังก์ชันอย่าง getAccountById ที่รับ AccountId ก็ไม่ได้เข้าใจยากไปกว่าฟังก์ชันที่รับ UUID

    • จริง ๆ แล้ว String ก็เป็นแค่ชุดของ byte ที่ไม่มีความหมายอะไร ถ้าเป็น AccountID อย่างน้อยก็มักพอรู้ว่าเป็น ‘ID ของบัญชี’ ถ้าอยากรู้ representation ภายในจริง ๆ ก็ไปดู type definition ได้ แต่ในบริบทส่วนใหญ่แค่รู้ว่า AccountID คืออะไร ก็พอแล้ว สุดท้าย type ที่มีชื่อชัดเจนก็น่าสับสนน้อยกว่าเวลานำไปใช้ ลิงก์ grugbrain.dev นั้นกลับดูพื้นฐานเกินไปด้วยซ้ำ ถ้าเป็น grug brain จริงก็น่าจะเห็นด้วยกับการแยก type ระดับนี้

    • foo(UUID, UUID) สู้ foo(AccountId, UserId) ไม่ได้เลย แบบหลังดีกว่ามาก เพราะสื่อความหมายในตัว และถ้าเผลอสลับลำดับตอนเรียก compiler ก็ช่วยจับได้ แม้กับโครงสร้างข้อมูลซับซ้อนก็ยังเขียนให้ชัดได้โดยไม่ต้องสร้าง type ใหม่

      Map<UUID, List<UUID>>
      Map<AccountId, List<UserId>>
      
    • สำหรับคำพูดที่ว่า "ถ้าเป็น UUID หรือ String ก็ถือว่าคุ้นเคยอยู่แล้ว" นั้น ในความเป็นจริงกลับยากที่จะรู้ว่า UUID ถูกเก็บหรือแปลงในรูปแบบไหนกันแน่ เช่น GUIDv1, UUIDv4, UUIDv7 จากประสบการณ์ของฉันกับ Java+MS SQL เคยต้องแก้การแปลงระหว่าง UUID กับ uniqueidentifier เองเพราะติดปัญหา endian conversion คิดว่าน่าจะคล้ายปัญหาการแปลง timezone อัตโนมัติของฐานข้อมูลที่มักยุ่งกันได้

    • ที่จริงการต้องรู้ type พวกนี้ก็เป็นสิ่งที่จำเป็นอยู่แล้ว ไม่อย่างนั้นก็มีแต่จะส่งข้อมูลผิดเข้าไปในฟังก์ชัน

  • เมื่อไม่นานมานี้ทีมของเราก็ลองนำ type ไปใช้กับส่วนในโค้ด C++ ที่มีค่าตัวเลขหลายชนิดปะปนกัน จุดเริ่มมาจากการหาและแก้บั๊ก แล้วลองใส่ type ที่ปลอดภัยเข้าไป ปรากฏว่าเจอจุดใช้ค่าผิดคล้ายกันอีกสามแห่ง

  • ไลบรารี mp-units(เอกสารทางการของ mp-units) ทำให้นึกถึงตัวอย่างที่โฟกัสปัญหาเรื่องหน่วยทางกายภาพ ถ้าใช้ unit type ที่แข็งแรงก็จะได้ทั้งความปลอดภัยและการทำให้ตรรกะแปลงหน่วยที่ซับซ้อนเป็นอัตโนมัติ แถมยังใช้ generic code จัดการหลายหน่วยได้ด้วย ฉันเคยพยายามเอาแนวคิดนี้ไปใช้ในโลก Prolog แต่คนรอบตัวไม่ค่อยอิน ตัวอย่างสำหรับ prolog

    • ฉันเคยทำโปรเจกต์ที่ต้องจัดการปริมาณทางกายภาพหลายอย่าง เช่น ระยะทาง ความเร็ว อุณหภูมิ ความดัน ฯลฯ แต่ทั้งหมดถูกส่งกันเป็นแค่ float หมดเลย เลยไม่มีอะไรห้ามไม่ให้เอาค่าระยะทางไปใส่ตรงที่ควรเป็นความเร็ว บั๊กก็จะไปโผล่ตอนรันเท่านั้น ปัญหาเรื่องส่งหน่วยผิด เช่น km/h กับ miles/h ก็เหมือนกัน ตอนนั้นฉันอยากเพิ่ม type เพื่อจับปัญหาแบบนี้ตั้งแต่ตอนพัฒนา แต่ตอนนั้นยังเป็น junior อยู่และโน้มน้าวคนอื่นยาก

    • ฉันเคยยอมแพ้กับการใส่ type แยกตามหน่วยทางกายภาพเพราะกลัวว่ามันจะซับซ้อนเกินไป แต่มีแผนจะลองดู mp-units โดยเฉพาะเพราะปัญหามักเกิดจากการไม่ระบุชัดว่าตัวแปรอยู่ในหน่วยไหน ข้อมูลภายนอกหรือฟังก์ชันมาตรฐานก็มักไม่ได้บอกหน่วยไว้

  • ใน C# ฉันสร้าง type แบบนี้

    readonly struct Id32<M> {
      public readonly int Value { get; }
    }
    

    จากนั้นก็เขียนแบบนี้ได้

    public sealed class MFoo { }
    public sealed class MBar { }
    Id32<MFoo> x;
    Id32<MBar> y;
    

    วิธีนี้ทำให้แยก integer ID คนละชนิดออกจากกันได้ และยังขยายเป็น IdGuid หรือ IdString ได้ด้วย ถ้าจะเพิ่ม marker type (M) ใหม่ก็แค่เพิ่มอีกหนึ่งบรรทัด ใน TypeScript กับ Rust ก็ใช้รูปแบบดัดแปลงคล้ายกัน

    • ฉันเคยใช้แพตเทิร์นคล้ายกัน และถ้าเป็น int ID แล้ว enum จะเป็นทางเลือกที่ friction ต่ำที่สุด แต่เพราะคิดว่าน่าจะชวนสับสนเกินไปเลยไม่ได้ใส่ลงในโค้ดจริง การถกเถียงที่เกี่ยวข้อง

    • แพตเทิร์นนี้เรียกว่า 'phantom type' เพราะค่าของ MFoo หรือ MBar ไม่มีอยู่จริงตอนรันไทม์

    • มีไลบรารีอย่าง Vogen สำหรับงานแบบนี้ด้วย Vogen ย่อมาจาก Value Object Generator รองรับการเพิ่ม type แบบ Value object ผ่านการ generate source code และใน readme ก็มีลิงก์ไปยังไลบรารีลักษณะใกล้เคียงกันด้วย

  • ฉันเคยเห็นวิธีนี้มาก่อนแต่ไม่รู้ว่ามันมีไว้ทำอะไร วันนี้เองก็เพิ่งเขียนฟังก์ชันที่รับ string สามตัว แล้วยังลังเลว่าจะบังคับ parse type จากภายนอกดีหรือจะทำในฟังก์ชัน สุดท้ายกลับกลายเป็นว่าไม่ได้ต้องใช้ค่าที่ parse เลย ดังนั้นวิธีนี้แหละคือคำตอบที่ฉันตามหา น่าจะเป็นสิ่งที่ส่งผลกับสไตล์การเขียนโค้ดของฉันมากที่สุดในปีนี้

  • เพื่อนฉัน Lukas เคยสรุปแนวคิดนี้ไว้ในชื่อ 'Safety Through Incompatibility' ฉันเอาแพตเทิร์นนี้ไปใช้ทั่วโค้ด golang แล้วพบว่ามีประโยชน์มาก มันช่วยกันการส่ง ID ผิดตั้งแต่ต้นทาง
    บทความที่เกี่ยวข้อง 1
    บทความที่เกี่ยวข้อง 2

  • ใน Swift มีคีย์เวิร์ด typealias ก็จริง แต่ถ้า type พื้นฐานเหมือนกันก็แปลงหากันได้อย่างอิสระ จึงไม่เหมาะกับจุดประสงค์นี้จริง ๆ wrapper struct กลับเป็นแนวทางที่ใช้กันเป็นธรรมชาติใน Swift และถ้าใช้ ExpressibleByStringLiteral ร่วมด้วยก็พอสะดวกอยู่ แต่ก็คงดีถ้ามีคีย์เวิร์ดใหม่อย่าง 'strong typealias' (typecopy อะไรทำนองนั้น) ที่บอกได้ชัดว่า "นี่ก็แค่ String แต่เป็น String ที่มีความหมายพิเศษ จึงห้ามปะปนกับ String อื่น"

    • จริง ๆ ภาษาส่วนใหญ่ก็เป็นแบบนี้ เช่น rust/c/c++ และอย่างในตัวอย่าง Go มันให้ความรู้สึกดีเวลาไม่ต้องสร้าง wrapper type ขึ้นมา C++ เองถ้าไม่ระบุ constructor เป็น explicit ก็เอา int ไปใส่ตรงที่ควรเป็น Foo ได้อย่างอิสระ ยิ่งต้องระวัง

    • แม้ในเชิงทฤษฎีจะดูสวยงาม แต่เวลาใช้จริงอาจซับซ้อน เช่น การส่งเข้า std::cout ใน C++ หรือความเข้ากันได้กับฟังก์ชันของ third-party ที่เดิมรับ String รวมถึง extension point ต่าง ๆ ล้วนเป็นเรื่องที่ต้องคิด

    • Haskell มีแนวคิดนี้ในรูปแบบ newtype ส่วนในภาษา OOP ถ้า type ไม่เป็น final ก็อาจทำ subclass ได้ง่ายเพื่อเพิ่มหรือทำพฤติกรรมเฉพาะที่ต้องการ โดยไม่ต้องมี wrapper หรือ boxing เพิ่ม ทำได้ประหยัดและเรียบง่าย แต่ใน Java นั้น String เป็น final จึงใช้วิธีนี้ได้ยาก และการ specialization ตัว String เองก็ทำได้ลำบาก

    • อยากรู้ว่าคุณคาดหวังให้มันต่างจาก wrapper struct แบบปกติอย่างไรในเชิงพฤติกรรม

 
brain1401 2025-07-28

Rust ก็ใช้งานในลักษณะนี้เหมือนกันนะครับ ดูแล้วน่าจะเป็นสิ่งที่ดีอย่างชัดเจน

 
regentag 2025-07-28

ถ้าใช้ภาษาที่มี type system ที่ดี ก็น่าจะป้องกันเรื่องแบบนี้ได้ไม่ใช่เหรอ..
ยานสำรวจ Mars Climate Orbiter ของ NASA สูญหายในเดือนกันยายน 1999

  • ยานถูกควบคุมผิดพลาดจนตก เนื่องจากปัญหาการเชื่อมต่อข้อมูลระหว่างโมดูลที่ใช้หน่วยปอนด์กับโมดูลที่ใช้หน่วยนิวตันในการแสดงขนาดของแรง