Topic 38 การเขียนโปรแกรมด้วยความบังเอิญ

คุณเคยดูหนังสงครามขาวดำเก่า ๆ ไหมครับ ทหารที่เหนื่อยล้าคนหนึ่งค่อย ๆ คลานออกมาจากพุ่มไม้อย่างระมัดระวัง ข้างหน้าเป็นพื้นที่โล่ง: มีทุ่นระเบิดอยู่หรือเปล่า หรือว่าข้ามไปได้อย่างปลอดภัย ไม่มีสัญญาณบ่งบอกว่าเป็นทุ่งทุ่นระเบิดเลย ไม่มีป้ายเตือน ไม่มีลวดหนาม ไม่มีหลุมระเบิด ทหารคนนั้นใช้ดาบปลายปืนจิ้มพื้นข้างหน้าและยักไหล่ด้วยความคาดว่าจะเกิดระเบิดขึ้น แต่ก็ไม่มีอะไรเกิดขึ้น เขาจึงค่อย ๆ เดินลุยไปในทุ่งนั้นสักพัก พลางจิ้มและสำรวจไปเรื่อย ๆ ในที่สุด เมื่อมั่นใจว่าพื้นที่นี้ปลอดภัยแล้ว เขาก็ยืดตัวขึ้นและเดินอย่างภาคภูมิใจไปข้างหน้า ก่อนจะถูกระเบิดจนแหลกละเอียด

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

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

How to Program by Coincidence (จะเขียนโปรแกรมด้วยความบังเอิญได้อย่างไร)

สมมติว่า Fred ได้รับมอบหมายงานเขียนโปรแกรมชิ้นหนึ่ง Fred พิมพ์โค้ดบางส่วน ลองรัน แล้วดูเหมือนจะทำงานได้ Fred พิมพ์โค้ดเพิ่มอีก ลองรัน ก็ยังดูเหมือนจะทำงานได้ หลังจากเขียนโค้ดแบบนี้ไปหลายสัปดาห์ โปรแกรมก็หยุดทำงานกะทันหัน และหลังจากพยายามแก้อยู่หลายชั่วโมง เขาก็ยังไม่รู้ว่าทำไม Fred อาจต้องเสียเวลาไล่ตามหาโค้ดส่วนนี้ไปเรื่อย ๆ โดยไม่มีทางแก้ได้เลย ไม่ว่าเขาจะทำอะไรก็ตาม มันก็ดูเหมือนจะไม่มีวันทำงานถูกต้องเสียที

Fred ไม่รู้ว่าทำไมโค้ดถึงล้มเหลว เพราะ เขาไม่รู้ตั้งแต่แรกอยู่แล้วว่าทำไมมันถึงทำงานได้ มันดูเหมือนจะทำงานได้ จากการ “ทดสอบ” อันจำกัดที่ Fred ทำ แต่นั่นก็แค่เรื่องบังเอิญเท่านั้น ด้วยความมั่นใจที่ผิด ๆ Fred จึงบุกหน้าต่อไปสู่หายนะ คนฉลาดส่วนใหญ่คงรู้จักใครสักคนที่เป็นแบบ Fred แต่ เรา รู้ดีกว่านั้น เราไม่พึ่งพาความบังเอิญ—ใช่ไหมครับ

บางครั้งเราก็อาจจะพึ่งพามันอยู่เหมือนกันครับ บางทีมันก็ง่ายมากที่จะสับสนระหว่างความบังเอิญอันน่ายินดีกับแผนการที่ตั้งใจไว้ ลองมาดูตัวอย่างสักสองสามข้อกันครับ

Accidents of Implementation (อุบัติเหตุจากการ Implement)

อุบัติเหตุจากการ implement คือสิ่งที่เกิดขึ้นเพียงเพราะโค้ดตอนนี้ถูกเขียนไว้แบบนั้น สุดท้ายคุณก็ไปพึ่งพา error หรือ boundary condition ที่ไม่มีการบันทึกไว้เป็นเอกสาร

สมมติว่าคุณเรียก routine หนึ่งด้วยข้อมูลที่ผิดพลาด routine นั้นตอบสนองออกมาในรูปแบบหนึ่ง และคุณก็เขียนโค้ดโดยอิงจากการตอบสนองนั้น แต่ผู้เขียนไม่ได้ตั้งใจให้ routine ทำงานแบบนั้นเลย—ไม่เคยถูกพิจารณาด้วยซ้ำ พอ routine นั้นถูก “แก้” โค้ดของคุณก็อาจพังได้ ในกรณีที่สุดโต่งที่สุด routine ที่คุณเรียกอาจไม่ได้ถูกออกแบบมาให้ทำสิ่งที่คุณต้องการเลยด้วยซ้ำ แต่มัน ดูเหมือน จะทำงานได้โอเค การเรียกสิ่งต่าง ๆ ผิดลำดับ หรือผิดบริบท ก็เป็นปัญหาในลักษณะเดียวกันครับ

ดูเหมือนตรงนี้ Fred กำลังพยายามอย่างสุดชีวิตเพื่อให้อะไรบางอย่างแสดงขึ้นมาบนหน้าจอ โดยใช้ GUI rendering framework ตัวหนึ่ง:

​ paint();
​ invalidate();
​ validate();
​ revalidate();
​ repaint();
​ paintImmediately();

แต่ routine เหล่านี้ไม่เคยถูกออกแบบมาให้เรียกแบบนี้เลย แม้มันจะดูเหมือนทำงานได้ แต่จริง ๆ แล้วก็แค่เรื่องบังเอิญเท่านั้น

ซ้ำร้ายไปกว่านั้น เมื่อฉากสุดท้ายถูกวาดออกมาได้สำเร็จ Fred ก็จะไม่ย้อนกลับไปเอาการเรียกที่ไม่จำเป็นออก “ตอนนี้มันทำงานได้แล้ว อย่าไปยุ่งกับมันดีกว่า….”

มันง่ายมากที่จะถูกความคิดแบบนี้หลอก ทำไมคุณถึงต้องเสี่ยงไปยุ่งกับสิ่งที่ทำงานได้อยู่แล้วด้วยล่ะ ที่จริงเราคิดเหตุผลได้หลายข้อเลยครับ:

  • มันอาจไม่ได้ทำงานได้จริง ๆ—มันอาจแค่ดูเหมือนทำงานได้เท่านั้น

  • boundary condition ที่คุณพึ่งพาอยู่อาจเป็นแค่เรื่องบังเอิญ ในสถานการณ์ที่ต่างออกไป (ความละเอียดหน้าจอที่ต่างกัน จำนวน CPU core ที่มากขึ้น) มันอาจทำงานต่างออกไปก็ได้

  • พฤติกรรมที่ไม่มีการบันทึกไว้อาจเปลี่ยนไปเมื่อ library ออกเวอร์ชันถัดไป

  • การเรียกที่เพิ่มเข้ามาโดยไม่จำเป็นทำให้โค้ดของคุณทำงานช้าลง

  • การเรียกที่เพิ่มเข้ามาก็เพิ่มความเสี่ยงที่จะสร้างบั๊กใหม่ ๆ ขึ้นมาเองด้วย

สำหรับโค้ดที่คุณเขียนให้คนอื่นเรียกใช้ หลักการพื้นฐานของการทำ modularization ที่ดี และการซ่อน implementation ไว้หลัง interface ที่เล็กและมีเอกสารกำกับอย่างดี ล้วนช่วยได้ครับ contract ที่ระบุไว้อย่างชัดเจน (ดู Topic 23, ​ Design by Contract (การออกแบบด้วยสัญญา) ​ ) ก็ช่วยลดความเข้าใจผิดได้เช่นกัน

สำหรับ routine ที่คุณเรียกใช้ จงพึ่งพาเฉพาะพฤติกรรมที่มีเอกสารระบุไว้เท่านั้น ถ้าทำแบบนั้นไม่ได้ไม่ว่าจะด้วยเหตุผลใดก็ตาม ก็จงบันทึกข้อสันนิษฐานของคุณไว้ให้ชัดเจน

Close Enough Isn't (ใกล้เคียงไม่ได้แปลว่าใช่)

ครั้งหนึ่งเราเคยทำงานในโปรเจกต์ขนาดใหญ่ที่รายงานข้อมูลซึ่งส่งมาจากอุปกรณ์เก็บข้อมูลฮาร์ดแวร์จำนวนมากในพื้นที่จริง อุปกรณ์เหล่านี้กระจายอยู่ในหลายรัฐและหลาย time zone และด้วยเหตุผลด้าน logistics และประวัติศาสตร์หลายอย่าง แต่ละอุปกรณ์จึงถูกตั้งค่าให้ใช้เวลาท้องถิ่น [50] ผลจากการตีความ time zone ที่ขัดแย้งกันและความไม่สอดคล้องกันของนโยบาย Daylight Savings Time ทำให้ผลลัพธ์แทบจะผิดอยู่ตลอด แต่ผิดไปแค่หนึ่งหน่วยเท่านั้น นักพัฒนาในโปรเจกต์นี้จึงติดนิสัยแค่บวกหนึ่งหรือลบหนึ่งเพื่อให้ได้คำตอบที่ถูกต้อง โดยให้เหตุผลว่ามันผิดไป แค่ หนึ่งหน่วยในสถานการณ์นี้เท่านั้น แล้ว function ถัดไปก็จะเห็นค่านั้นผิดไปหนึ่งหน่วยในทิศทางตรงกันข้าม แล้วก็เปลี่ยนมันกลับอีกที

แต่ความจริงที่ว่ามันผิดไป “แค่” หนึ่งหน่วยในบางครั้งนั้นเป็นเรื่องบังเอิญ ซึ่งบดบังข้อบกพร่องพื้นฐานที่ลึกกว่านั้นเอาไว้ เพราะไม่มีโมเดลจัดการเรื่องเวลาที่ถูกต้อง code base ขนาดใหญ่ทั้งหมดจึงค่อย ๆ เสื่อมลงเมื่อเวลาผ่านไป จนกลายเป็นกองคำสั่ง +1 และ -1 ที่ยุ่งเหยิงจนแก้ไขไม่ไหว สุดท้ายแล้วไม่มีส่วนไหนถูกต้องเลย และโปรเจกต์นี้ก็ถูกยกเลิกไปครับ

Phantom Patterns (รูปแบบลวงตา)

มนุษย์ถูกออกแบบมาให้มองเห็นรูปแบบและสาเหตุ แม้ในเวลาที่มันเป็นแค่เรื่องบังเอิญก็ตาม ตัวอย่างเช่น ผู้นำรัสเซียมักสลับกันระหว่างหัวล้านและผมดกอยู่เสมอ: ผู้นำรัสเซียที่หัวล้าน (หรือกำลังจะล้านอย่างเห็นได้ชัด) มักขึ้นดำรงตำแหน่งต่อจากคนที่ไม่หัวล้าน (“ผมดก”) และสลับกันไปมาแบบนี้มานานเกือบ 200 ปีแล้วครับ [51]

แต่ในขณะที่คุณคงไม่เขียนโค้ดที่ขึ้นอยู่กับว่าผู้นำรัสเซียคนถัดไปจะหัวล้านหรือผมดก ในบางสาขาเราก็คิดแบบนั้นอยู่ตลอดเวลา นักพนันมักจินตนาการเห็นรูปแบบในตัวเลขลอตเตอรี เกมลูกเต๋า หรือรูเล็ตต์ ทั้งที่จริง ๆ แล้วเหตุการณ์เหล่านี้เป็นอิสระต่อกันทางสถิติ ในโลกการเงิน การเทรดหุ้นและพันธบัตรก็เต็มไปด้วยความบังเอิญในลักษณะเดียวกัน แทนที่จะเป็นรูปแบบที่จับต้องได้จริง

log file ที่แสดง error เป็นระยะทุก ๆ 1,000 requests อาจเป็น race condition ที่วินิจฉัยได้ยาก หรืออาจเป็นแค่บั๊กธรรมดา ๆ ก็ได้ test ที่ดูเหมือนจะผ่านบนเครื่องของคุณแต่ไม่ผ่านบน server อาจบ่งชี้ถึงความแตกต่างระหว่างสองสภาพแวดล้อม หรือบางทีก็อาจแค่เป็นเรื่องบังเอิญเท่านั้น

อย่าสันนิษฐานเอาเอง จงพิสูจน์มันครับ

Accidents of Context (อุบัติเหตุจาก Context)

“อุบัติเหตุจาก context” ก็เกิดขึ้นได้เช่นกันครับ สมมติว่าคุณกำลังเขียน utility module อยู่ แค่เพราะตอนนี้คุณกำลังเขียนโค้ดสำหรับสภาพแวดล้อมแบบ GUI จำเป็นหรือที่ module นี้จะต้องพึ่งพาการมีอยู่ของ GUI คุณกำลังพึ่งพาผู้ใช้ที่พูดภาษาอังกฤษอยู่หรือเปล่า พึ่งพาผู้ใช้ที่อ่านออกเขียนได้หรือเปล่า แล้วยังมีอะไรอีกที่คุณกำลังพึ่งพาอยู่ ทั้งที่ไม่มีอะไรรับประกันได้เลย

คุณกำลังพึ่งพาว่า current directory เขียนได้อยู่หรือเปล่า พึ่งพาว่ามี environment variable หรือ configuration file บางตัวอยู่หรือเปล่า พึ่งพาว่าเวลาบน server ถูกต้องแม่นยำ—ในระดับความคลาดเคลื่อนเท่าไร คุณกำลังพึ่งพาความพร้อมใช้งานและความเร็วของเครือข่ายอยู่หรือเปล่าครับ

เมื่อคุณคัดลอกโค้ดมาจากคำตอบแรกที่เจอบนอินเทอร์เน็ต คุณแน่ใจหรือเปล่าว่า context ของคุณเหมือนกัน หรือว่าคุณกำลังสร้างโค้ดแบบ “cargo cult” ที่เลียนแบบแค่รูปแบบโดยไม่มีเนื้อหาจริง ๆ อยู่ข้างใน? [52]

การเจอคำตอบที่บังเอิญเข้ากันได้พอดี ไม่เหมือนกับการเจอคำตอบที่ถูกต้องนะครับ

Tip 62 อย่าเขียนโปรแกรมด้วยความบังเอิญ

Implicit Assumptions (ข้อสันนิษฐานโดยนัย)

ความบังเอิญสามารถชักนำให้หลงทางได้ในทุกระดับ—ตั้งแต่การสร้าง requirement ไปจนถึงการ test การ test นั้นเสี่ยงต่อความสัมพันธ์เชิงเหตุผลที่ผิดและผลลัพธ์ที่เกิดจากความบังเอิญเป็นพิเศษ มันง่ายมากที่จะสันนิษฐานว่า X เป็นสาเหตุของ Y แต่อย่างที่เราพูดไว้ใน Topic 20, ​ Debugging (การดีบัก) ​ : อย่าสันนิษฐานเอาเอง จงพิสูจน์มัน

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

How to Program Deliberately (จะเขียนโปรแกรมอย่างตั้งใจได้อย่างไร)

เราอยากใช้เวลาผลิตโค้ดให้น้อยลง ดักจับและแก้ error ให้เร็วที่สุดเท่าที่จะทำได้ในวงจรการพัฒนา และสร้าง error ให้น้อยลงตั้งแต่แรก มันจะช่วยได้มากถ้าเราสามารถเขียนโปรแกรมอย่างตั้งใจ:

  • รู้ตัวอยู่เสมอว่าคุณกำลังทำอะไรอยู่ Fred ปล่อยให้สถานการณ์ค่อย ๆ เลยเถิดไปจนควบคุมไม่ได้ จนสุดท้ายเขาก็จบเหมือนกบที่ถูกต้ม ในตัวอย่างนี้ .

  • คุณสามารถอธิบายโค้ดอย่างละเอียดให้โปรแกรมเมอร์รุ่นน้องฟังได้ไหม ถ้าทำไม่ได้ คุณอาจกำลังพึ่งพาความบังเอิญอยู่

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

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

  • พึ่งพาเฉพาะสิ่งที่เชื่อถือได้เท่านั้น อย่าพึ่งพาข้อสันนิษฐาน ถ้าคุณบอกไม่ได้ว่าสิ่งนั้นเชื่อถือได้หรือไม่ ให้สันนิษฐานในกรณีที่แย่ที่สุดไว้ก่อน

  • บันทึกข้อสันนิษฐานของคุณไว้เป็นเอกสาร Topic 23, ​ Design by Contract (การออกแบบด้วยสัญญา) ​ , ช่วยทำให้ข้อสันนิษฐานในหัวของคุณชัดเจนขึ้น รวมถึงช่วยสื่อสารมันไปยังคนอื่นได้ด้วย

  • อย่า test แค่โค้ดของคุณ แต่ให้ test ข้อสันนิษฐานของคุณด้วย อย่าเดา จงลองทำจริง ๆ เขียน assertion เพื่อ test ข้อสันนิษฐานของคุณ (ดู Topic 25, ​ Assertive Programming (การเขียนโปรแกรมเชิงยืนยัน) ​ ) ถ้า assertion ของคุณถูกต้อง แสดงว่าคุณได้ปรับปรุงเอกสารในโค้ดของคุณให้ดีขึ้นแล้ว แต่ถ้าคุณพบว่าข้อสันนิษฐานของคุณผิด ก็ให้ถือว่าคุณโชคดีไปเลยครับ

  • จัดลำดับความสำคัญของความพยายามของคุณ ใช้เวลากับส่วนที่สำคัญ ซึ่งส่วนใหญ่แล้วมักเป็นส่วนที่ยากที่สุด ถ้าพื้นฐานหรือ infrastructure ของคุณไม่ถูกต้อง ฟีเจอร์เสริมสวยหรูแค่ไหนก็ไม่มีความหมายอะไรเลย

  • อย่าตกเป็นทาสของประวัติศาสตร์ อย่าปล่อยให้โค้ดที่มีอยู่กำหนดโค้ดในอนาคต โค้ดทุกส่วนสามารถถูกแทนที่ได้ถ้ามันไม่เหมาะสมอีกต่อไป แม้แต่ในโปรแกรมเดียวกัน ก็อย่าปล่อยให้สิ่งที่คุณทำไปแล้วมาจำกัดสิ่งที่คุณจะทำต่อไป—พร้อมที่จะ refactor อยู่เสมอ (ดู Topic 40, ​ Refactoring (การปรับโครงสร้างโค้ด) ​ ) การตัดสินใจนี้อาจส่งผลต่อ schedule ของโปรเจกต์ ข้อสันนิษฐานคือผลกระทบนั้นจะน้อยกว่าต้นทุนของการ ไม่ เปลี่ยนแปลงครับ [53]

ดังนั้นครั้งต่อไปที่มีอะไรดูเหมือนจะทำงานได้ แต่คุณไม่รู้ว่าทำไม จงตรวจสอบให้แน่ใจว่ามันไม่ใช่แค่เรื่องบังเอิญครับ

Related Sections Include (หัวข้อที่เกี่ยวข้อง)

  • Topic 4, ​ Stone Soup and Boiled Frogs (ซุปหินกับกบต้ม) ​
  • Topic 9, ​ DRY—The Evils of Duplication (DRY—ความชั่วร้ายของการซ้ำซ้อน) ​
  • Topic 23, ​ Design by Contract (การออกแบบด้วยสัญญา) ​
  • Topic 34, ​ Shared State Is Incorrect State (สถานะที่ใช้ร่วมกันคือสถานะที่ผิดพลาด) ​
  • Topic 43, ​ Stay Safe Out There (ระวังตัวไว้ให้ดี) ​

Exercises (แบบฝึกหัด)

แบบฝึกหัด 25 ( คำตอบที่เป็นไปได้ )

แหล่งข้อมูลจาก vendor ส่ง array ของ tuple ที่แทนคู่ key-value มาให้คุณ โดย key ของ DepositAccount จะเก็บ string หมายเลขบัญชีไว้ใน value ที่คู่กัน:

​ [
​ ...
​ {​ :DepositAccount ​, ​ "564-904-143-00" ​}
​ ...
​ ]

มันทำงานได้อย่างสมบูรณ์แบบตอน test บนโน้ตบุ๊กนักพัฒนาที่มี 4-core และบน build machine ที่มี 12-core แต่บน production server ที่รันอยู่ใน container คุณกลับได้หมายเลขบัญชีที่ผิดอยู่เรื่อย ๆ เกิดอะไรขึ้นครับ

แบบฝึกหัด 26 ( คำตอบที่เป็นไปได้ )

คุณกำลังเขียน autodialer สำหรับแจ้งเตือนด้วยเสียง และต้องจัดการฐานข้อมูลข้อมูลติดต่อ มาตรฐาน ITU ระบุว่าหมายเลขโทรศัพท์ต้องมีความยาวไม่เกิน 15 หลัก คุณจึงเก็บหมายเลขโทรศัพท์ของผู้ติดต่อไว้ใน field ตัวเลขที่รับประกันว่าเก็บได้อย่างน้อย 15 หลัก คุณ test อย่างละเอียดทั่วอเมริกาเหนือแล้วทุกอย่างก็ดูเหมือนจะเรียบร้อยดี แต่จู่ ๆ คุณก็ได้รับข้อร้องเรียนถล่มทลายจากส่วนอื่น ๆ ของโลก ทำไมล่ะครับ

แบบฝึกหัด 27 ( คำตอบที่เป็นไปได้ )

คุณเขียนแอปที่ช่วยขยายสูตรอาหารทั่วไปให้เหมาะกับห้องอาหารบนเรือสำราญที่นั่งได้ 5,000 คน แต่คุณกลับได้รับข้อร้องเรียนว่าการแปลงหน่วยไม่แม่นยำ คุณตรวจสอบดูแล้วพบว่าโค้ดใช้สูตรแปลงหน่วย 16 ถ้วยต่อ 1 แกลลอน นั่นถูกต้องแล้วใช่ไหมครับ