จงใช้ระบบประเภทให้เป็นประโยชน์
(dzombak.com)- ระหว่างการเขียนโปรแกรม เราสามารถใช้ ระบบประเภท เพื่อแยกความหมายของข้อมูลที่แตกต่างกันให้ชัดเจนได้
- การ ใช้ประเภททั่วไปอย่างสตริงหรือจำนวนเต็มโดยตรง ทำให้สูญเสียบริบท และ อาจนำไปสู่บั๊กได้
- แม้จะมีประเภทพื้นฐานเดียวกัน แต่หาก กำหนดประเภทใหม่ให้เหมาะกับจุดประสงค์ ก็จะ ป้องกันความผิดพลาดได้ด้วยข้อผิดพลาดตอนคอมไพล์
- ในไลบรารี Go อย่าง libwx มีการกำหนดประเภทที่แยกหน่วยการวัดอย่างชัดเจน เพื่อ ป้องกันความผิดพลาดจากการปะปนกันของ
float64 - ในโค้ดตัวอย่าง มีการ แยกประเภท UUID ออกเป็น
UserIDและAccountIDเพื่อให้ คอมไพเลอร์บล็อกการใช้งานที่ผิดพลาด - แม้ในภาษาอย่าง Go ที่ระบบประเภทไม่ได้เข้มแข็งมากนัก ก็ยัง ป้องกันบั๊กได้ด้วยการห่อประเภทอย่างง่าย
มาใช้ระบบประเภทอย่างเต็มที่กันเถอะ
จุดเริ่มต้นของปัญหา: การปะปนกันของประเภทแบบง่าย
- ในการเขียนโปรแกรม มักมีหลายกรณีที่ใช้เพียง ประเภทพื้นฐาน อย่าง
string,int,UUIDเพื่อแทนค่าต่าง ๆ - แต่เมื่อโปรเจ็กต์มีขนาดใหญ่ขึ้น ความผิดพลาดจากการ นำประเภทแบบง่ายเหล่านี้มาใช้ปะปนกันโดยไม่แยกแยะ ก็เกิดขึ้นบ่อย
- ตัวอย่าง: ส่งสตริง
userIDไปเป็นaccountIDโดยไม่ตั้งใจ หรือส่งลำดับอาร์กิวเมนต์ผิดในฟังก์ชันที่มีพารามิเตอร์int3 ตัว เป็นต้น
- ตัวอย่าง: ส่งสตริง
ทางแก้: กำหนดประเภทที่สะท้อนเจตนา
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 หรือประเภทสตริงเกิดขึ้นบ่อยมาก
- ผู้เขียนย้ำว่าการที่วิธีง่าย ๆ แบบนี้ ยังไม่ถูกใช้อย่างแพร่หลายในโค้ดโปรดักชันเป็นเรื่องน่าประหลาดใจ
โค้ดที่เกี่ยวข้อง
- ดูตัวอย่างทั้งหมดได้บน GitHub:
https://github.com/cdzombak/libwx_types_lab
8 ความคิดเห็น
เท่าที่ทราบ ถ้าจะใช้ใน Kotlin อาจมีปัญหาด้านประสิทธิภาพเพราะ
primitiveจะถูกห่อด้วย wrapper ทำให้ถูกเก็บบน heap แทนที่จะเป็น stack ได้ แน่นอนว่าในกรณีการใช้งานส่วนใหญ่ ความง่ายในการบำรุงรักษามักมาก่อน นอกจากนี้ยังสามารถใช้value classเพื่อลดปัญหาด้านประสิทธิภาพให้เหลือน้อยที่สุดได้ภาษา Ada มีระบบชนิดข้อมูลที่ยอดเยี่ยมมากในแง่นี้ เราสามารถประกาศค่าคนละประเภทให้เป็นชนิดข้อมูลแยกกันได้อย่างง่ายดาย และเมื่อมีการปะปนกัน คอมไพเลอร์ก็จะช่วยตรวจจับได้อย่างดี
ขอถามด้วยความสงสัยครับ/ค่ะ นอกจากนั้นยังมีข้อดีที่แตกต่างจากภาษาแบบมีไทป์ยอดนิยมอื่น ๆ ด้วยหรือเปล่า? (
kotlin,rust,typescript, ...)ข้อดีของ Ada โดยทั่วไปจะอยู่ในแนวว่า “ดีกว่า C” ครับ ใน C มีหลายอย่างที่ปล่อยให้เชื่อนักพัฒนาและเปิดให้ทำได้ค่อนข้างมาก เช่นการแปลงชนิดข้อมูลโดยปริยายอะไรทำนองนั้น แต่ดูเหมือนว่านักพัฒนาส่วนใหญ่จะชอบ C มากกว่าเพราะคุ้นเคยกับมัน...
อาจเป็นลักษณะเฉพาะของโค้ดเบสที่ผมทำงานอยู่ก็ได้ แต่เราแทบจะประกาศเกือบทุกอย่างเป็นชนิดข้อมูลแยกต่างหากแล้วค่อยใช้งาน การใช้ชนิดข้อมูลพื้นฐานมีแค่ประมาณดัชนีของอาร์เรย์เท่านั้นครับ
เข้าใจแล้ว ขอบคุณ
ความคิดเห็นจาก 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 ใหม่สำหรับคำพูดที่ว่า "ถ้าเป็น 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 แบบนี้
จากนั้นก็เขียนแบบนี้ได้
วิธีนี้ทำให้แยก 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 แบบปกติอย่างไรในเชิงพฤติกรรม
Rust ก็ใช้งานในลักษณะนี้เหมือนกันนะครับ ดูแล้วน่าจะเป็นสิ่งที่ดีอย่างชัดเจน
ถ้าใช้ภาษาที่มี type system ที่ดี ก็น่าจะป้องกันเรื่องแบบนี้ได้ไม่ใช่เหรอ..
ยานสำรวจ Mars Climate Orbiter ของ NASA สูญหายในเดือนกันยายน 1999