Signatures That Don't Lie
เคยไหมครับ อ่านชื่อฟังก์ชันในโค้ด เห็น Input และ Output ดูปกติมาก แต่พอรันจริงกลับเจอ Runtime Error พ่นใส่หน้า หรือเจอ Null โผล่มาแบบไม่ได้รับเชิญ ทั้งที่ Type ไม่ได้บอกไว้...
ในโลกของ Functional Programming (FP) เรามีเป้าหมายอย่างหนึ่งคือการเขียน "Honest Functions" หรือฟังก์ชันที่มี Signature "ซื่อสัตย์" ต่อผู้ใช้ วันนี้เราจะมาเจาะลึกกันว่าแนวคิดนี้คืออะไร และทำไมมันถึงช่วยให้เรานอนหลับเต็มอิ่มขึ้นครับ 🥹
ฟังก์ชันที่ "โกหก" (Dishonest Functions) คืออะไร?
ฟังก์ชันที่โกหก คือฟังก์ชันที่ Signature (สิ่งที่มันประกาศ) กับ Behavior (สิ่งที่มันทำจริง) ไม่ตรงกัน โดยส่วนใหญ่มักจะโกหกเราใน 3 (+1 ในบางภาษา) รูปแบบหลัก:
1. Partial Functions
มันบอกว่ารับ number นะ แต่พอยัดเลข 0 เข้าไป กลับระเบิดตัวเองตาย (Crash) เพราะมันไม่ได้รองรับเลขศูนย์จริงๆ ตั้งแต่แรก แถมสัญญาว่าจะ return number แต่ดัน throw exception
2. Hidden Side Effects
มันบอกว่าคืนค่า string แต่ลึกๆ แอบไปลบไฟล์ในเครื่อง หรือไปเปลี่ยนค่าตัวแปร Global โดยที่เราไม่รู้
3. การใช้ Exceptions ในการจัดการ Business Logic
การโยน Exception ใน Functions ที่ใช้จัดการ Business ต่างๆ ก็ถือเป็นการโกหก เพราะมันคือการข้ามลำดับการทำงานปกติ (Non-local GOTO) และทำให้ผู้เรียกไม่ทราบว่าฟังก์ชันอาจจะล้มเหลวได้หากดูเพียงแค่ Signature
4. การใช้ Nulls (ในบางภาษา)
ในบางภาษา ประเภทข้อมูลอ้างอิงอาจเป็นค่าว่าง (null) ได้เสมอ ทำให้ Signature ที่ระบุว่าจะส่งกลับ Object กลายเป็นการโกหก เพราะบางครั้งมันส่งกลับ null แทน
แล้วฟังก์ชันที่ "ไม่โกหก" (Honest Functions) คืออะไร?
มันคือฟังก์ชันที่มีคุณสมบัติตรงข้ามกับฟังก์ชันที่โกหก ผ่ามๆ หยอกๆ หลักๆคือควรจะมีคุณสมบัติดังนี้:
1. บอกอินพุตที่เป็นไปได้ทั้งหมด
Signature ต้องระบุอย่างชัดเจนว่ารับค่าอะไรได้บ้าง และจะไม่มีการทำงานผิดพลาดหากส่งค่าตามประเภทที่ระบุมา
2. บอกผลลัพธ์ที่เป็นไปได้ทั้งหมด
ไม่ว่าผลลัพธ์จะสำเร็จหรือล้มเหลว (เช่น Error หรือค่าว่าง) ข้อมูลเหล่านี้ต้องถูกระบุไว้ในประเภทข้อมูลที่ส่งกลับ (Return Type)
3. No Hidden Side Effects
ฟังก์ชันต้องไม่แอบไปแก้ไขค่า Global หรือติดต่อฐานข้อมูลโดยที่ไม่ได้ระบุไว้ใน Signature ซึ่งจะช่วยให้เกิด Referential Transparency (ไม่อธิบายใน blog นี้นะ 🤣) หรือความสามารถในการแทนที่ฟังก์ชันด้วยค่าของมันได้โดยไม่ทำให้พฤติกรรมของโปรแกรมเปลี่ยนไป
เทคนิคการเบื้องต้นในการช่วยให้เราออกแบบ Honest Functions
1. การใช้ Wrapper Types
แทนที่จะส่งกลับค่าตรง ๆ ให้ใช้ประเภทข้อมูลอย่าง Maybe, Option หรือ Either เพื่อระบุชัดเจนว่าผลลัพธ์อาจจะไม่มีค่าหรือเกิดข้อผิดพลาดได้
2. การจำกัดประเภทอินพุต (Restrict Input Types)
เช่น แทนที่จะรับ int สำหรับตัวหาร ให้สร้างประเภทข้อมูลใหม่ชื่อ NonZeroInteger เพื่อให้ Compiler บังคับว่าห้ามส่งค่า 0 มาตั้งแต่ต้น
3. เน้น Immutability และ Pure Functions
การใช้ข้อมูลที่เปลี่ยนแปลงไม่ได้ช่วยให้เรามั่นใจว่าฟังก์ชันจะทำงานแค่ตามที่ระบุใน Signature เท่านั้น
จริงๆมีเทคนิคอีกมากมายในการทำให้ Function ของเรา "ซื่อสัตย์" มากขึ้นลองไปศึกษากันเพิ่มดูนะ
เปรียบเทียบ Honest Functions กับ Dishonest Functions
ลองมาดูตัวอย่าง function สำหรับหารเลข
function divide(a: number, b: number): number {
if (b === 0) throw new Error("หารด้วยศูนย์ไม่ได้นะจ๊ะ!");
return a / b;
}ปัญหา: function นี้ ดูผ่านๆ ถ้าคนที่เอาฟังก์ชันนี้ไปใช้ไม่ได้เข้ามาดู "ไม่มีทางรู้เลย" ว่าต้องครอบ try-catch เว้นแต่จะเข้าไปอ่าน Code ข้างในหรือรอให้มันพังกลางอากาศ
ถ้าจะแก้ให้เป็น Honest Functions โดยส่วนมากเราจะใช้ระบบ Type มาเพื่อช่วยบอกความจริงทั้งหมด ในตัวอย่างจะสร้าง Type ใหม่เป็น Sum Type ที่ชื่อว่า Result มา โดยที่ Result จะเป็นได้ทั้ง Success และ Error ดังตัวอย่างด้านล่างนี้
type Result<T> = { status: 'success', value: T } | { status: 'error', message: string };
/**
* Signature นี้บอกความจริง 100%:
* "ฉันรับ number นะ แต่อาจจะคืนเป็นผลลัพธ์ หรือข้อความ Error ก็ได้"
*/
function safeDivide(a: number, b: number): Result<number> {
if (b === 0) {
return { status: 'error', message: "Division by zero" };
}
return { status: 'success', value: a / b };
}ข้อดี: ในบางภาษา Compiler จะบังคับให้คุณจัดการกรณี Error ทันที คุณลืมไม่ได้เพราะ Type มันค้ำคออยู่
ทำไมผมถึงแคร์ ?
เมื่อเราเขียนโปรแกรมตามหลัก Signatures that don't lie ผลลัพธ์ที่ตามมาคือ:
- Code คือ Documentation: คุณไม่จำเป็นต้องเขียนคอมเมนต์อธิบายยาวเหยียดว่าฟังก์ชันจะพังตอนไหน เพราะ Type มันบอกไว้หมดแล้ว
- Refactoring อย่างมั่นใจ: เมื่อคุณเปลี่ยน Logic ระบบ Type จะฟ้องทันทีว่าจุดไหนที่คุณยังจัดการไม่ครบ
- ลด Cognitive Load: สมองไม่ต้องจำว่า "ฟังก์ชันนี้ห้ามส่งค่านี้เข้านะ" เพราะ Compiler จะเป็นคนจำแทนคุณเอง
- ลดความซับซ้อน (Reduce Complexity): คุณสามารถทำความเข้าใจฟังก์ชันได้จากการดูแค่ Signature โดยไม่ต้องไล่โค้ดทีละบรรทัด (Local Reasoning)
สรุปส่งท้าย
การเปลี่ยนมาเขียนฟังก์ชันที่ "ไม่โกหก" อาจจะดูยุ่งยาก (มาก) ในช่วงแรก เพราะเราต้องห่อหุ้มค่า (Wrap values) และจัดการเคสต่างๆ มากขึ้น แต่ในระยะยาว มันคือการลงทุนที่คุ้มค่า เพราะมันเปลี่ยนจาก "การไล่ตามแก้ Bug" มาเป็นการ "ออกแบบระบบที่ไม่เปิดโอกาสให้เกิด Bug" ตั้งแต่แรกครับ
"Be honest with your types, and your code will be honest with you."
Happy Coding ครับ