ประเภทแบบสแตติกกับพลั่ว
(carefully.understood.systems)- เหตุผลที่ความนิยมของการพิมพ์แบบสแตติกลดลงตั้งแต่ช่วงทศวรรษ 2000 ถึงต้นทศวรรษ 2010 แล้วกลับมาเพิ่มขึ้นอีกครั้งในช่วงกลางถึงปลายทศวรรษ 2010 คือ การพัฒนาคุณภาพของระบบประเภทแบบสแตติก ไม่ใช่กระแส
- เวลาขุดหลุม การใช้มือเปล่า ยังดีกว่า พลั่วที่ทำจากกระดาษ เช่นเดียวกับที่ระบบประเภทแบบไดนามิกดีกว่าระบบประเภทแบบสแตติกที่ย่ำแย่
- ระบบประเภทแบบสแตติกในอดีตอย่าง Java ยุคแรกหรือ C++98 แยก nullable กับ non-nullable ไม่ได้ด้วยซ้ำ ไม่มี sum type และต้องเขียนชื่อ type ด้วยมือลงไปทีละจุด
- ระบบ type สมัยใหม่อย่าง TypeScript, Haskell, Swift, Rust มี การแยก null, sum type/union type, type inference มาให้เป็นพื้นฐาน
- การแพร่หลายของ การเติมชื่อเมธอดอัตโนมัติ ใน IDE ทำให้ข้อมูลที่ป้อนให้ประเภทแบบสแตติกสร้างผลลัพธ์ด้านผลิตภาพเพิ่มขึ้น นอกเหนือจากการตรวจจับข้อผิดพลาด
สมมติฐานเกี่ยวกับการเปลี่ยนแปลงของความนิยมของการพิมพ์แบบสแตติก
- ปรากฏการณ์ที่การพิมพ์แบบสแตติกลดความนิยมลงในช่วงทศวรรษ 2000 ถึงต้นทศวรรษ 2010 แล้วกลับมาเพิ่มขึ้นอีกครั้งในช่วงกลางถึงปลายทศวรรษ 2010
ไม่ได้เกิดจากการเขียนโปรแกรมเป็นอุตสาหกรรมที่ขับเคลื่อนด้วยกระแส แต่เป็นเพราะคุณภาพของระบบประเภทแบบสแตติกที่ถูกใช้อย่างแพร่หลายดีขึ้น
อุปมาเรื่องพลั่ว — คุณภาพของเครื่องมือเป็นตัวกำหนดการเลือก
- เวลาขุดหลุม หากพลั่วใช้งานได้ดี แน่นอนว่าย่อมใช้พลั่วแทนมือเปล่า
แต่ถ้าพลั่วที่มีให้เพียงอันเดียว ทำจากกระดาษ มันก็จะได้แค่กวนดินไปอย่างไร้ความหมาย จนใช้มือเปล่ายังดีกว่า - ในระบบประเภทแบบไดนามิก เราต้องติดตามสถานะและเนื้อหาของตัวแปรกับฟิลด์ ทั้งหมดด้วยหัวของตัวเอง โดยตรง
คอมพิวเตอร์ไม่ได้ช่วยและไม่ได้ขัดขวาง จึงเทียบได้กับ การขุดด้วยมือเปล่า - ระบบประเภทแบบสแตติกที่อ่อนแอซึ่งพบได้บ่อยในช่วงทศวรรษ 1990 และต้นทศวรรษ 2000 ก็เหมือน พลั่วกระดาษ
- ช่วยไม่ได้แม้แต่งานง่าย ๆ อย่างการแยก pointer แบบ nullable กับ non-nullable
- มีแต่ product type และไม่มี sum type
- มีภาระที่ต้องเขียนชื่อ type เองด้วยมือกระจายไปทั่ว
- โค้ดอย่าง
BufferedReader bufferedReader = new BufferedReader(new FileReader(filename));คือหายนะขนาดย่อม
สิ่งที่ระบบ type สมัยใหม่มีให้เสมอ
- เมื่อนำไปเทียบกับระบบ type สมัยใหม่อย่าง TypeScript, Haskell, MyPy, Swift, Rust จะพบว่ามีสิ่งต่อไปนี้ให้เสมอ
-
การแยก nullable กับ non-nullable
- Haskell มี
Maybe t, TypeScript มีT | null, Swift มีT?, Rust มีOptional<T> - ระบบ type จะบอกได้ง่าย ๆ ว่า ตำแหน่งใดบ้างที่ต้องมีการตรวจ null และจุดใดตกหล่นไป
- ในทางปฏิบัติ แทบไม่ค่อยเห็น ข้อผิดพลาด null pointer ตอนรันจริงอีกเลย
- Haskell มี
-
sum type หรือ union type
- ทำให้แนวปฏิบัติ "ทำให้ไม่สามารถแสดงสถานะที่ไม่ถูกต้องได้ (Make invalid states unrepresentable)" เป็นไปได้
- สามารถสร้างอ็อบเจ็กต์แบบ state machine ที่มีหลายฟิลด์ได้ และแต่ละฟิลด์จะมีอยู่เฉพาะเมื่อระบบอยู่ในสถานะที่เกี่ยวข้องเท่านั้น
-
type inference
- คอมไพเลอร์สามารถตัดสินได้ว่า
let x = 5;เป็นตัวเลข จึงไม่จำเป็นต้องเขียนแบบlet x: number = 5;
- คอมไพเลอร์สามารถตัดสินได้ว่า
ประโยชน์ที่เพิ่มขึ้นจากการแพร่หลายของความสามารถใน IDE
- ความสามารถของ IDE อย่างการเติมชื่อเมธอดอัตโนมัติที่แพร่หลายมากขึ้น ทำให้ระบบประเภทแบบสแตติกมีประโยชน์มากขึ้น
- ในทศวรรษ 1990 Intellisense ของ Visual Studio เคยเป็นฟีเจอร์ชูโรง แต่ในทศวรรษ 2020 แทบทุก IDE และเอดิเตอร์ต่างก็มีความสามารถคล้ายกันให้ใช้
- ข้อมูลที่ใส่เข้าไปในระบบประเภทแบบสแตติกจึงต่อยอดเป็น ข้อได้เปรียบด้านผลิตภาพเพิ่มเติม นอกเหนือจากการตรวจข้อผิดพลาด
บทสรุป
- ระบบประเภทแบบไดนามิกที่ดี ดีกว่าระบบประเภทแบบสแตติกที่แย่
- แต่ตอนนี้เรามี ระบบประเภทแบบสแตติกที่ดีกว่าในอดีตมาก
1 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
บทความนี้ดี แต่ผมไม่ได้เห็นด้วยทั้งหมด แม้ว่าระบบชนิดแบบสถิตในช่วงต้นทศวรรษ 2000 จะไม่ได้ยอดเยี่ยม แต่ผมคิดว่ามันก็ดีกว่าการไม่มี static type เลยมาก
แม้จะยังไม่มี closed sum type แต่ก็สามารถจำลองได้พอสมควรด้วย subtyping และแม้จะยังไม่มี non-nullable type แต่ C++ ก็มี reference กับ non-pointer type และ Java ก็มี primitive type ที่ช่วยรองรับบางส่วน ใน Ruby หรือ JavaScript ไม่เพียงแต่ทุก type จะเป็น nullable เท่านั้น แต่ยังถูกปฏิบัติได้ทั้งเหมือนสตริง เหมือนจำนวนเต็ม หรือเหมือนทุก type อื่นในโปรแกรมด้วย ซึ่งแย่กว่าเดิมอีก
ผมคิดว่าเหตุผลใหญ่ที่กระแสเริ่มเปลี่ยนไปในมุมมองต่อ static type เป็นเพราะในยุคบูมของโซเชียลเน็ตเวิร์ก Web 2.0 นั้น first-mover advantage สำคัญกว่าทุกอย่าง ต่อให้สร้างหนี้ทางเทคนิคด้วย Ruby หรือ Python ก็ยังดีกว่าปล่อยของเร็ว ทำซ้ำเร็ว มากกว่าจะถูกทิ้งไว้ข้างหลังแบบ Friendster หรือ Digg และถ้าช้าก็แค่ซื้อเซิร์ฟเวอร์เพิ่มด้วยเงินทุนดอกเบี้ยต่ำที่หาได้ง่ายในตอนนั้น
ต่อมาในยุคบูมของมือถือ ซอฟต์แวร์ต้องไปรันบนอุปกรณ์ผู้ใช้ที่มีข้อจำกัดและควบคุมไม่ได้ แอปแบบ dynamic type ที่ช้าก็คือช้าจริง และถ้าเกิด type error ก็ไม่สามารถกู้คืนอย่างสวยงามด้วย top-level response handler แบบฝั่งเซิร์ฟเวอร์ได้ ในสภาพแวดล้อมนั้น ความปลอดภัยและประสิทธิภาพของ static type จึงดูน่าเชื่อถือขึ้นมาก
ตอนต้นทศวรรษ 2000 ผมก็เห็นด้วย เพราะระบบ type ตอนนั้นมักบังคับใช้คุณสมบัติที่แทบไม่ค่อยผิดอยู่แล้ว แต่กลับเพิ่มข้อจำกัดที่ไม่ได้ช่วยให้จัดโครงสร้างโค้ดได้ดีขึ้น โดยเฉพาะวิธีที่ผูก subtyping เข้ากับ implementation inheritance ซึ่งไม่ยืดหยุ่น
แต่พอได้ใช้ระบบ type ที่ทันสมัยขึ้น ผมก็เปลี่ยนความคิด ใน snmalloc นั้นใช้ระบบ type ของ C++ เพื่อบังคับ state machine ของการเป็นเจ้าของหน่วยความจำ และในโค้ดเบสอื่นก็ใช้ตรวจสอบพฤติกรรม overflow ที่ถูกต้องของตัวนับใน ring buffer ทั้งสองอย่างถ้าพลาดจะดีบักน่ารำคาญและเป็นสาเหตุของข้อผิดพลาดที่พบบ่อย แต่ระบบ type ทำให้โค้ดที่ผมคิดว่าเขียนถูกแต่จริง ๆ ผิด คอมไพล์ไม่ผ่าน จึงกันไม่ให้บั๊กเข้าไปอยู่ใน tree ได้
ใน IDE แค่กด
.แล้วพิมพ์ชื่อเมธอดไปนิดหน่อยก่อนกด Enter ที่ตัวเลือกที่ถูกต้อง ก็ประหยัดเวลาได้ทีละ 2 วินาทีทุก ๆ ไม่กี่วินาที และยังประหยัดเวลา 30 วินาทีที่ต้องไปเปิดดู class definition เวลาที่ไม่รู้ว่ามีเมธอดอะไรบ้าง หลักการนี้อธิบายไว้ดีใน https://grugbrain.dev/#grug-on-type-systemsเพราะเราเขียนบรรทัดโค้ดที่เรียกใช้เมธอดบ่อยกว่าการเขียนชนิดของพารามิเตอร์ฟังก์ชันมาก การแลกเปลี่ยนนี้จึงเสียเปรียบฝั่ง dynamic type อย่างหนัก สิ่งที่มีค่าจริง ๆ ไม่ใช่การอนุญาตให้โค้ดเหลวไหลที่ควรล้มเหลวตอนรันไทม์ผ่านได้ แต่คือการละชนิดของตัวแปรโลคัลได้ และภาษา static type ก็ไม่จำเป็นต้องห้ามเรื่องนั้นตั้งแต่แรก
โค้ดเบสหายากที่ใช้ระบบ type อย่างจริงจังมักเต็มไปด้วยโค้ดเป็นหน้าที่แทบไม่ได้สื่ออะไรเลย แต่ก็ยังมีเงื่อนไขตอนรันไทม์กองโต และถ้าเป็น Java โปรแกรมก็ช้าลงอย่างมีนัยสำคัญเมื่อ type hierarchy โตขึ้น โค้ดเบสส่วนใหญ่ใช้ type แบบกระจัดกระจายพร้อมเงื่อนไขตอนรันไทม์จำนวนมาก ซึ่งก็ไม่ได้ช่วยลดขอบเขตการทดสอบที่ต้องมีเมื่อเทียบกับระบบ dynamic type มากนัก
ภาษากลุ่ม dynamic type แม้จะไม่ได้รางวัลแบบ static แต่มีข้อดีคือกระชับ อ่านและรีวิวง่าย และทดสอบง่ายกว่า โดยเฉพาะในสภาพแวดล้อมแบบ dependency injection framework ช่วงปลายยุค 90 ถึงต้นยุค 2000 ที่ทุกครั้งจะเพิ่ม service ใหม่ต้องไปแก้ XML หลายไฟล์ และยังทำงานได้โดยไม่ต้องใช้ IDE ที่กิน RAM ไปครึ่งเครื่อง
ช่วงต้นอาชีพของผมก็เป็นแบบนี้เป๊ะ เลยเห็นด้วยกับบทความมาก ค่าใช้จ่ายเทียบประโยชน์ของ Java 1.4 ถึง Java 6 มันแย่มากจนเกือบทำให้ผมเลิกใช้ภาษาที่มี static type ไปเลย และกว่าจะมารู้ทีหลังจากลองเล่น Haskell เป็นงานอดิเรกว่า static type ก็สามารถมีความคุ้มค่าได้ และปัญหาที่แท้จริงคือ Java ต่างหาก บทความ “python is not java” ก็สะท้อนยุคมืดนั้นได้ดี
หลังจากที่ static typing กลายเป็นกระแสหลักของยุคสมัย ผมก็ยังสงสัยว่าเราได้เห็น ประโยชน์ด้านความน่าเชื่อถือ ของซอฟต์แวร์จริง ๆ มากแค่ไหน
เดิมทีผมคิดว่าจุดเด่นของ static typing อยู่ที่ feedback ระหว่างพัฒนาที่ได้ทันทีและการลดความล้มเหลวร้ายแรงตอนรันไทม์มากกว่า แม้ในทางทฤษฎีความล้มเหลวแบบนั้นจะเกิดขึ้นได้เสมอ แต่ในทางปฏิบัติดูเหมือนมันไม่ได้เกิดบ่อยขนาดนั้น
undefinedและnullก็ลดลงอย่างมากทั้งรุ่นน้องและบางคนที่เป็นซีเนียร์ตอนแรกก็มองโลกในแง่ร้าย คิดว่าจะมี
@ts-ignoreโผล่เต็มไปหมด แต่ในความเป็นจริงมีอยู่แค่ประมาณสามจุดเท่านั้น รวมถึงกรณีที่มาจาก type ของ dependency ที่พังด้วย เมื่อก่อนใน development branch แอปจะล่มเพราะสับสนเรื่อง type ราวสัปดาห์ละครั้งจนขัดขวางงานผม แต่ตอนนี้จำไม่ได้แล้วด้วยซ้ำว่าครั้งล่าสุดเกิดขึ้นเมื่อไรแค่ทำให้
tscผ่านก็ช่วยลด บั๊กที่เกี่ยวกับ type ได้ แม้ในกรณีที่ผมไม่ได้เป็นคนเขียนโค้ดเองด้วย ตรงกันข้าม linter สมัยนี้ขยันเกินเหตุ และการพยายามทำให้เครื่องมืออย่าง Sonar พอใจกลับทำให้เห็นความเสียหายจากการ refactor จริง ๆ คำเตือน 95% เป็นของปลอม 3% เป็นบั๊กของตัวเครื่องมือ และอีก 2% ที่พอช่วยได้ก็ไม่ใช่สาเหตุของบั๊กจริงอยู่ดี แทนที่จะใช้เวลา 1 สัปดาห์ปรับโค้ดเบสให้เข้ากับมันแล้วจับบั๊กได้ 1 ตัว ระหว่างทางกลับใส่บั๊กเพิ่มไปอีก 2 ตัวงานที่ทำเพื่อให้
tscผ่าน โดยเฉลี่ยแล้วทำให้เกิดการแก้บั๊กล้วน ๆ ราววันละ 2 จุด และ regression 1 จุด แต่ regression มักมีความรุนแรงต่ำกว่า ไม่ใช่การ crash ทั้งระบบ แค่เป็นพฤติกรรมที่ผิดไปถ้าเพิ่ม property-based testing เข้าไปด้วย จะใช้เวลาเฉลี่ย 2–4 ชั่วโมง และเปิดเผยบั๊กได้อย่างน้อย 1 ตัวทุกครั้ง ถ้าโค้ดของคุณสามารถทำ property-based testing ได้ ก็ควรทำ
ผมใช้ DeepSeek V4 Flash ซึ่งเป็นโมเดลราคาถูกเพื่อเพิ่มขอบเขตการทดสอบ โดยระวังไม่ให้เกิดเทสต์ขยะ สุดท้ายก็แก้บั๊กเชิงตรรกะได้ราววันละ 2–3 ตัว และไม่มี crash แต่ชุดทดสอบก็อยู่ในระดับที่แทบจะพอบำรุงรักษาไหวเท่านั้น
ตอนให้รุ่นน้องใช้ Sonnet กับสาย Opus 4.5 และ 4.6 สร้างเทสต์แบบคร่าว ๆ โมเดลเหล่านั้นกลับสร้างแต่เทสต์ที่ “บันทึกพฤติกรรมปัจจุบัน” ทำให้ผลของการแก้ไขมีน้อย และชุดทดสอบก็ดูแลต่อไม่ได้จนต้องทิ้ง
การทดสอบที่อิงโมเดลนั้นยอดเยี่ยมมากในการจับบั๊ก แต่การตั้งค่ายุ่งยาก และการชี้นำให้มันขุดคุ้ยตามมุมต่าง ๆ โดยไม่เผาผลาญรอบการทำงานไปกับฟีเจอร์ผิวเผินนั้นยุ่งมาก ถ้ามีอะไรอย่าง profile-based model-based fuzzer ก็น่าจะน่าสนใจ
สรุปคือ type checker จับความล้มเหลวร้ายแรงและความสับสนหลายแบบได้ดี ส่วน property-based testing ยอดเยี่ยมมาก สำหรับการทดสอบทั่วไปนั้น ต้องมีวินัยสูงพอสมควรถึงจะได้ผลตอบแทนอย่างสม่ำเสมอ
ตรงที่เอา TypeScript ไปรวมเป็นพวกเดียวกับ type system ที่ดี อันนี้ผมเห็นด้วยได้ยากที่สุด
awaitนี่กัดผมมาหลายรอบแล้ว ถึงอย่างนั้นก็เป็นความจริงว่ามันทำให้สถานการณ์ดีขึ้นอย่างมากพูดตามตรง ผมเองสุดท้ายก็ยอมรับ structural typing ได้ และคิดว่ามันจะส่งผลบวกต่อการออกแบบภาษาในอนาคต
ข้ออ้างนี้ฟังไม่ค่อยน่าเชื่อถือ ภาษาโปรแกรมที่ใช้การได้และมี algebraic data types กับ type inference ก็มีมาตั้งแต่กลางยุค 90 แล้ว
type system ของ Java และ C++ นั้นอ่อนมาก แต่ SML, OCaml และ Haskell มีอยู่แล้ว และให้ความรู้สึกไม่ต่างจากปัจจุบันมากนัก ถ้าผู้คนไม่ใช้ภาษาเหล่านั้น ปัญหาก็น่าจะอยู่ที่วัฒนธรรม การยอมรับใช้ และข้อกำหนดที่ไม่ได้ถูกพูดออกมา มากกว่าจะอธิบายได้ด้วยแค่ว่า “type system ที่ใช้งานได้ยังดีไม่พอ”
หรือถ้าจะบอกว่า “type system ของภาษายอดนิยมในตอนนั้นแย่ แต่ type system ของภาษายอดนิยมในวันนี้ดีขึ้น เลยทำให้ type system ได้รับความนิยมมากขึ้น” แบบนั้นก็ฟังดูเป็นตรรกะแบบวนกลับ
อีกทั้งยังมีความต่างเชิงนัยสำคัญมากระหว่างภาษาที่ถูกออกแบบมาพร้อมกับ type system ตั้งแต่ต้น กับภาษาที่เดิมทีออกแบบมาแบบไม่มี type แล้วค่อยเอา type system มาครอบทีหลัง
ต่อให้เดิมผมจะชอบ dynamic typing มากกว่า ผมก็ยังคิดว่าบทความนี้ยุติธรรมพอสมควร ทุกวันนี้ผมทำงานด้วย C# งานอดิเรกใช้ Lisp และเมื่อก่อนก็เคยใช้ Python
ตอนที่ต้องใช้ Java 5 ผมมักต้องสู้กับ type system อยู่ตลอด ซึ่งโดยมากเป็นผลจากการตัดสินใจแย่ ๆ ของคนทำไลบรารี พอย้ายมา C# ราวปี 2010 type system ก็ไม่ได้เป็นโทษแบบเชิงรุก แต่มักซ้ำซ้อนเสียมากกว่า และยังกัน null pointer exception ซึ่งเป็นความสับสนเรื่อง type ที่พบบ่อยที่สุดใน Python ไม่ได้ด้วย
type system ของ C# เริ่มช่วยได้จริง ๆ ก็ราวปี 2020 ตอนมี non-nullable reference types เข้ามา ปีนี้ก็จะมี native union types เข้ามาด้วย แต่ไลบรารี union types ที่บังคับความครบถ้วนได้นั้นมีให้ใช้ได้มาตั้งแต่ปี 2016 เป็นอย่างน้อย และผมเริ่มใช้ตั้งแต่ปี 2020
ผมคิดว่าเรื่องกระแสก็ยังมีบทบาทอยู่ แต่บางส่วนก็ไม่ใช่เรื่องแย่ ภาษาที่กำลังนิยมและมี type system ที่แสดงออกได้มากกว่าก็นำพาการปรับปรุงมาสู่ภาษาทั่วไปที่พวกเราใช้ทำงานหาเงินกันด้วย
Haskell และระบบ type ของมันมีอยู่แล้วตั้งแต่ยุค 2000s แม้จะยังไม่ได้ถูกใช้อย่างแพร่หลายเหมือนตอนนี้ แต่ก็มีอยู่จริง ดังนั้นข้ออ้างนี้ควรเสริมส่วนนี้ด้วย
โดยส่วนตัวผมคิดว่า TypeScript เป็นปัจจัยสำคัญที่ทำให้ผู้ใช้ภาษากระแสหลักคุ้นเคยกับระบบ type ที่ดีกว่า นอกจากคุณภาพและการสนับสนุนจาก Microsoft แล้ว มันยังมีข้อได้เปรียบตรงที่ใช้กับ JavaScript ได้ และ JavaScript ก็ต้องการ type มากกว่า Python ด้วยซ้ำ เพราะ “Undefined is not a function.” และ “The good parts.”
“Real World Haskell” ออกมาในปี 2008 และมีเป้าหมายจะทำให้ Haskell ดูน่าสนใจขึ้นสำหรับโปรแกรมเมอร์กระแสหลัก แต่ไม่แน่ใจว่ามันช่วยเผยแพร่ข่าวดีได้มากแค่ไหน
ในโลก Java มี Scala ที่นำ type เจ๋ง ๆ เข้ามาตั้งแต่ปี 2004 และฝั่ง .NET ก็มี F# ในปี 2005 Scala อาจเป็นภาษาที่ได้ผู้ใช้ที่โดดเด่นอย่าง Twitter มากที่สุด แต่ก็ไม่ได้อยู่ในตำแหน่งที่จะดึงผู้ใช้บนแพลตฟอร์มนั้นมาได้ในสัดส่วนมากแบบ TypeScript และก็ไม่ได้ดึงดูดผู้ใช้จากภาษาอื่นได้เป็นจำนวนมากแบบ Rust หรือ Go
ในย่อหน้าถัดไปบทความก็พูดถึง Haskell ว่าเป็น “ระบบ type สมัยใหม่” แต่คนที่มีประสบการณ์กับ Haskell ในช่วงปลายยุค 90s ถึงต้นยุค 2000s นั้นแทบจะใกล้เคียง 0% แม้นับรวมคนที่แค่เคยลองจับเล่นส่วนตัวด้วยก็ตาม บทความกำลังพูดถึงว่าตอนนั้นนักพัฒนาส่วนใหญ่สัมผัสกับภาษา static type กันอย่างไร และทำไมคนส่วนใหญ่เหล่านั้นถึงพร้อมใจกันหลีกเลี่ยงภาษา static type
ตัวอย่างเช่น ถ้าจะใช้
duneใน OCaml คุณต้องเข้าใจไฟล์opam, ไฟล์dune, ไวยากรณ์ocaml moduleและไวยากรณ์ocamlHaskell เองก็มี compiler extension แบบเลือกใช้ที่ให้ความรู้สึกน่ากลัวพอ ๆ กันซึ่งต่างจาก
cargoที่แค่รู้tomlกับ Rust ก็พอ