Python: Object Oriented #2 : Duck type

เกริ่นนำ

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

บทความก็เช่นกัน ว่าด้วยเรื่องของ duck type ซึ่งเป็นการเก็บตกจากบทความที่แล้ว สามารถประยุกต์ใช้กับ ECMAScript ได้เช่นกัน ลองติดตามดูนะครับ

กาลครั้งหนึ่งยังมี C

duck type … duck type … คืออะไร เพื่อความเข้าใจ ผมขอแยกคำว่า duck และ type ออกจากกัน ในหัวข้อนี้ขอคุยคำว่า type ก่อน ซึ่งนั่นก็คือชนิดตัวแปรที่เราคุ้นเคยกันอยู่แล้ว อย่าเพิ่งทำหน้าตาดูเบื่อขนาดนั้นครับ มันอาจจะไม่น่าเบื่อก็ได้นะครับ ลองอ่านดูกันก่อน

ผมขอย้อนไปถึงภาษา C ซึ่งได้ชื่อว่าเป็นภาษาที่มีความยืดหยุ่นสูง ผมขอให้พิจารณาตัวแปรสองชนิดคือ int ที่มีขนาด 4 bytes ดังโปรแกรมนี้

เรากำหนดตัวแปร a เป็น int ซึ่งใน CPU ทั่วไปสามารถรองรับตัวแปรขนาด 32 bits นี้เอาไว้ใน register ได้อยู่แล้ว ดังนั้นคอมไพเลอร์จึงคัดเลือกเอา registers ขึ้นมาสองตัวเพื่อเก็บค่า จากนั้นก็ไปเรียกเอาคำสั่งบวกเลขของภาษาเครื่องมาใช้งาน ผลลัพธ์อาจเก็บเอาไว้ใน registers ที่ 3

แต่ถ้าชนิดตัวแปรไม่ใช่ int แต่หากเป็น double จะเกิดอะไรขึ้น double นั้นปกติแล้วมีขนาด 8 bytes ซึ่งเกินขนาดที่ CPU ขนาด 32 bits ทั่วไปจะเก็บได้ ผมขอสมมุติก็แล้วกันว่า CPU ของเราเป็นแบบ 32 bits ไม่ใช่ 64 bits ตลอดทั้งบทความนี้ ดังนั้นจึงมิอาจเก็บค่า 64 bits ได้โดยตรง โดยปกติแล้ว CPU จะมี math-coprocessor ที่มีความสามารถในการจัดการตัวเลข floating point อยู่แล้ว ดังนั้นจึงอาจโยนงานการบวกนี้ให้แก่ math-coprocessor ก็เป็นได้

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

คราวนี้มาลองดูส่วนของโปรแกรมนี้ครับ

อันนี้ก็เช่นกันครับ เรากำหนด ptr เป็นประเภทตัวชี้ int ซึ่งการชี้นั้นชี้ไปยังตำแหน่งที่จองค่าเอาไว้ใน heap การใช้งานฟังก์ชัน malloc() นั้นน่าสนใจ เนื่องจากตัวมันเองก็ไม่ทราบว่าเราอยากได้เท่าไหร่ ที่ผมใส่เอาไปว่า sizeof(int) นั่นคือผมบอก malloc() ให้จอง 4 bytes มิใช่ malloc() จะรู้เองได้

ตัวชี้ ptr ก็จะอ่านค่าที่มันชี้ 4 bytes เพราะมันเป็นตัวชี้ของ int นั่นเอง ภาษา C นั้นมีความยืดหยุ่นที่เราชี้ไปยังที่ใดในหน่วยความจำก็ได้ ปลายทางก็ไม่จำเป็นต้องเป็น int แต่อย่างใด ยกตัวอย่างเช่น

การทำงานของโค้ดนี้ก็คือ ptr จะชี้ไปยังหน่วยความจำตำแหน่งที่ 1000 ซึ่งถ้าเราบรรจุค่าอะไรลงไปโดยใช้ *ptr มันจะเก็บเป็นตัวเลข int ซึ่งกินหน่วยความจำตำแหน่งที่ 1000-1003 (กรณีที่หน่วยความจำช่องละ 8 bits) ภาษา C จึงเป็นภาษาที่มีความพิเศษตรงที่สามารถเข้าถึงหน่วยความจำตำแหน่งใดก็ได้ ได้โดยตรง จึงเหมาะกับการเขียนโปรแกรมที่ไร้ตัวจัดการหรือไร้ OS นั่นเอง อย่างเช่นพวกไมโครคอนโทรลเลอร์เป็นต้น

มาลองดูปัญหากันหน่อยครับ

เวลาเรียกใช้

เราสามารถส่งตัวชี้ไม่ว่าชนิดใดก็ได้ไปยัง func1() เพราะ func1() รับเป็น void * ซึ่งเป็นชนิดตัวแปรที่ยืดหยุ่น ชนิดตัวแปร void * ในภาษา C คือ pointer ที่ไม่ระบุชนิด ดังนั้นมันจึงบอกไม่ได้ว่า จะชี้ไปยังข้อมูลขนาดเท่าไหร่ 4 bytes, 8 bytes หรือเท่าไหร่กันแน่ มันก็ไม่รู้ คอมไพเลอร์ก็จนด้วยเกล้าที่จะสร้างโค้ดที่ถูกต้องให้ครับ

เมื่อเราส่ง intPtr หรือ doublePtr เข้าไปยัง func1() แต่ func1() ไม่อาจจะแยกแยะออกได้เลยว่าที่เราส่งไปนั้น มันชนิดตัวแปรอะไรแน่ (ตัวชี้ทุกแบบมีขนาดเท่ากันหมดคือ 4 bytes)

ถ้าเป็นกรณีนี้หน้ามืดจริงๆ ครับ แทบจะเป็นไปไม่ได้เลยที่ func() จะวิเคราะห์ได้ว่าเราส่งชนิดตัวแปรใดเข้ามา และจะ cast เป็นชนิดที่เหมาะสมจะได้ทำงานได้ต่อไป

พอนึกกันออกไหมครับว่าภาษา C แก้ปัญหานี้อย่างไร ซึ่งการแก้นี้เป็นสิ่งที่เราคุ้นเคยกันอยู่แล้วแน่นอนครับ ดังนี้

ทางแก้นั่นก็คือท่านต้องบอกคอมไพเลอร์เอง ดังตัวอย่างครับ printf() นั้นรับพารามิเตอร์ตัวแรกเป็น string เสมอ ส่วนพารามิเตอร์ตัวอื่น ท่านอาจไม่รู้ตัวว่าท่านเป็นคนบอกมัน นั่นคือ ตัวที่ 2 เป็น %s หรือ string ส่วนตัวที่ 3 เป็น %d หรือตัวเลขจำนวนเต็มนั่นเอง

จาก C มาสู่ C++

C++ นั้นได้รับแนวคิดจาก abstract datatype (ADT) และนำมาพัฒนากลายเป็น class ซึ่งเป็นการสร้างชนิดตัวแปรใหม่ขึ้นมา

ดังที่กล่าวไว้แล้วว่าปัญหาของ C นั้นอยู่ที่การเอาชนิดไปผูกอยู่กับตัวชี้ และเมื่อชนิดตัวชี้ถูกทำลาย กลายเป็น void * ก็ต้องอาศัยส่วนอื่นมาเติมเต็มเพื่อบอกชนิดตัวแปรให้ถูก จะได้ไปต่อได้ ซึ่ง C++ ก็เล็งเห็นปัญหานี้จึงเพิ่มความสามารถของ RTTI ขึ้นมา

RTTI ย่อมาจากคำว่า “Run-Time Type Information” ซึ่งเป็นการฝังตัวบอกชนิดตัวแปรเอาไว้ที่ตัวข้อมูลเลย แทนที่จะพึ่งแต่ตัวชี้เพียงอย่างเดียว ลองดูตัวอย่างนี้ครับ

ใน func1() รับเป็นตัวชี้ Base แต่เมื่อเราส่ง ทั้ง b1 และ d1 เข้าไป func1() ก็สามารถวิเคราะห์ถูกต้องว่าเป็น Base หรือ Derive ตัวแปรที่เป็น primitive ก็แสดงให้เห็นอย่างถูกต้องเช่นกัน แต่ก็อย่างหลงไปนะครับ ไม่มีการแอบเติมค่าชนิดลงไปใน primitive หรอกครับ มิฉะนั้นระบบมันจะเพี้ยนหมดเช่นตอนที่คำนวณตำแหน่งของ array เป็นต้น ระบบอาศัยวิเคราะห์จากชนิดของตัวชี้นั่นเอง ไม่ใช่ที่ตัวข้อมูล

ชนิดตัวแปรที่ท่านเห็นอย่างเช่น 4Base อย่าไปจริงจังอะไรมากนะครับ มันไม่ได้มีมาตรฐานแต่อย่างใด ขึ้นอยู่กับคอมไพเลอร์แต่ละยี่ห้อ ที่ผมใช้รันตัวนี้เป็นของ GNU C++ ถ้าท่านใช้ยี่ห้ออื่นก็จะแตกต่างกันออกไป

RTTI จะทำให้เราสามารถเขียนโปรแกรมที่ยืดหยุ่นขึ้น ฟังก์ชันหนึ่งฟังก์ชันสามารถรองรับพารามิเตอร์ที่หลากหลายขึ้น ผมอยากให้ท่านพิจารณา func1() ให้ดี ในเมื่อ func1() ขอรับข้อมูลมาเป็น Base * นั่นหมายความว่าเขาต้องการมอง object นี้ให้เป็น Base นั่นเอง จะไม่ใช้อะไรที่พิเศษขึ้นมาใน class ที่ inherit ไปจาก Base คุ้นๆ ไหมครับ

ในโลกของ OOP จะค่อนข้างมอง downcasting เป็นลบค่อนข้างมาก ถ้าท่านงงเรื่อง downcasting ก็ขออธิบายคร่าวๆ ตรงนี้ (ตอนหน้าค่อยว่ากันยาวๆ) ก็คือเมื่อ func1() รับพารามิเตอร์มาเป็น Base * แต่ใน func1() กลับพยายาม cast Base * ให้กลายเป็น Derive * เพื่อดึงเอาความสามารถเฉพาะของ Derive มาใช้

ถ้าว่ากันตามโครงสร้างลำดับชั้นของ class จะเป็นการ cast จาก class ที่อยู่ข้างบน มาเป็น class ที่ inherit อยู่ในชั้นที่ล่างกว่า แบบนี้เขาว่าไม่ดี การ cast ควรขึ้นบนหรือ upcast เท่านั้น การ cast แบบ downcast นี้นำมาซึ่งมาความผิดสองสถานดังนี้

ความผิดแรกคือ เหตุผลทำไมต้องทำเช่นนั้น เป็นการออกแบบโปรแกรมที่ไม่เข้าท่า ผมขอละประเด็นนี้ไปครับ ลองดูในบทความก่อนหน้าท่านอาจจะได้คำตอบ มาดูความผิดที่สองกันดีกว่า นั่นคือความเสี่ยงการทำ downcast ครับ ก่อนใช้ท่านควรจุดธูป 5 ดอกบอกกล่าวให้เป็นเรื่องเป็นราวก่อน เพราะอะไรหรือครับ ก็เพราะท่านอาจ cast ผิดสาย มองหนูเป็นแมว แล้วจะไปบังคับให้มันร้องเหมียวๆ มันย่อมทำไม่ได้ครับ

แต่ปัญหานี้ไม่ได้ร้ายแรงมากมายนะครับ เพราะการที่เรามี RTTI เราสามารถตรวจสอบชนิดของตัวแปรได้ก่อน cast อยู่แล้ว ที่เราเรียกว่า type introspection ดังนั้น RTTI ก็สามารถนำมาช่วยลดปัญหา downcasting ได้ในระดับหนึ่งครับ

ถ้าท่านไม่ได้ใช้ RTTI กล่าวคือท่านไม่ได้ใช้ typeid() หรือ dynamic_cast การที่แอบใส่ชนิดข้อข้อมูลลงไปด้วยจะทำให้สิ้นเปลือง ท่านสามารถตัดทิ้งได้ เช่นถ้าเป็น GNU C++ ก็ให้เพิ่ม -fno-rtti เวลาคอมไพล์โปรแกรมได้ครับ โปรแกรมจะกินหน่วยความจำลดลงและทำงานเร็วขึ้น

มาถึงกระแสหลักอย่าง Java และ C#

แน่นอนครับครับ Java ก็ได้แนวคิดทางตรงมาจาก C++ และ C# ก็ไปได้มาจาก Java อีกที ทำให้ Java/C# ได้แนวคิด class การ inheritance และ polymorphism มา แต่ก็เอามาปรับโดยที่ตัดเอา multiple inheritance ออก และเพิ่มแนวคิด class ผู้สร้างที่ชื่อว่า Object ขึ้นมา ซึ่งว่าไปแล้วก็เป็นนำเอา void * ของ C กลับมาใช้อีกครั้ง

Java/C# มีการชดเชย multiple inheritance โดยการพัฒนา interface ขึ้นมา ที่ยืดหยุ่นขึ้น รองรับ interfaces ได้หลายตัวใน class เดียวกัน และแน่นอนครับ RTTI ก็ย่อมต้องรองรับ และพัฒนาขึ้นไปกลายเป็น reflection ซึ่งสามารถดูข้อมูลของ class ในระหว่างรันโปรแกรมได้

Java/C# มีการสร้างชนิดตัวแปรแบบ reference ขึ้นมา โดยที่ตัวแปรแบบ reference นั้นก็เป็นเหมือนกับตัวแปรตัวชี้แบบ pointer แต่หากว่า เวลาชี้นั้น หัวขั้วของ reference กับชนิดของ object (RTTI) จะต้องตรงกัน หรืออยู่ใน โครงสร้างลำดับชั้นของ class เดียวกัน จึงจะยอมชี้ ซึ่งตัวแปร reference จะไม่ยอมชี้ไปที่มั่วๆ ได้เหมือน pointer

Type ของ Python

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

ดังนั้นจึงทำให้ ตัวชี้ทุกตัวเป็นอิสระ เราสามารถเปลี่ยนใจให้ไปชี้ที่ไหนก็ได้ เช่น

จากโค้ดนี้เห็นว่า a เปลี่ยนใจไปชี้ object 3 ตัวซึ่งล้วนแล้วแต่เป็นคนละชนิดกัน เข้าใจตรงกันนะครับ

Duck type: ต้นคำ

เราได้เรียนรู้ถึงพัฒนาการของ type พอสมควรแล้ว คราวนี้เรามาเติมเต็มคำว่า duck type กัน ท่านไม่ต้องไปเปิดพจนานุกรมแต่อย่างใดครับ duck ในที่นี้ความหมายตรงตัวเลยครับว่ามันคือเป็ด ไม่ได้มีความหมายแฝงอื่นได

คำว่า duck type น่าจะได้มาจากประโยคยอดฮิตยุคสงครามเย็นคือ

I can’t prove you are a Communist. But when I see a bird that quacks like a duck, walks like a duck, has feathers and webbed feet and associated with ducks – I’m certainly going to assume that he is a duck.

หรือแปลเป็นไทยแบบผมได้ว่า

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

เรื่องการเมืองยกเอาไว้เถอะครับ (กำลังเศร้าๆ อยู่เลยที่เมื่อคืนเกิดระเบิดที่ราชประสงค์) เรามาพิจารณาจุดที่น่าสนใจกันดีกว่า นั่นก็คือวิธีการทดสอบความเป็นเป็ด ซึ่งน่าจะคล้ายๆ กับอันนี้ครับ

Up in the sky, look: It’s a bird. It’s a plane. It’s Superman!

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

Duck type in Python

ภาษา Python นั้น อย่างที่ผมแสดงให้เห็นในบทที่แล้วเรื่องของ monkey patching ว่ามีความยืดหยุ่นสูงมาก เราสามารถเพิ่ม methods/properties เข้าไปได้ระหว่างการรันโปรแกรม แม้กระทั่งหยิบยืม method จาก class อื่น ดังนี้

ซึ่งการเพิ่มความสามารถให้ class นั้นถ้าเทียบกับภาษาต่างๆ ข้างต้นนั้น มีความแตกต่างกันอย่างเห็นได้ชัดหลายประการเช่น

  • การเพิ่มความสามารถของ class นั้นทำเวลารันไม่ใช่เวลาคอมไพล์
  • การเพิ่มนั้นเพิ่มเข้าไปรวมกับ interface หลักของ class แทนที่จะแยกออกมาต่างหาก
  • สามารถลบออกหรือเปลี่ยนแปลงได้ มิใช่เพิ่มได้แต่อย่างเดียว

ซึ่งเรื่องราวเหล่านี้ผมนำเสนอไปแล้วในบทความก่อนหน้า แต่ผมยังกล่าวไม่ครบถ้วน เลยมาขอเก็บตกในบทความนี้คือในส่วนของ Duck type นั้นเอง ผมยกตัวอย่างเดิม คือ func1() ต้องการเรียกใช้ฟังก์ชัน gabgab() ดังนี้ครับ

เรามีทางเลือกอะไรบ้าง

ศรัทธาในโปรแกรมเมอร์

ก็เขียนฟังก์ชันตรงไปตรงมาอย่างนี้เลย duck ที่ส่งมาจะต้องมี method gabgab() อยู่แล้ว ถ้าไม่มีก็ error เท่านั้น โปรแกรมหยุดทำงานทันที ซึ่งอย่างที่ผมเคยบอกว่า ถ้าเป็น OOP บางสาย จะยอมรับเหตุการณ์นี้ไม่ได้ เพราะมันอาจจะเกิดตอนที่ยานอวกาศโคจรรอบดาวพลูโต ทำให้ยานโหม่งดาว เสียหายกันเป็นแสนล้านเหรียญ หรือในทางกลับกัน เลือกที่จะเชื่อว่า “เราก็โตๆ กันแล้ว” 

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

แปลงให้เป็นเป็ด

วิธีที่สองนี้ เวลาเรียกใช้ func1() เราก็ไปใช้ฟังก์ชันแปลงให้เป็นเป็ดก่อน คือถ้าไม่มี gabgab() ก็เติมเข้าไป ถ้ามีอยู่แล้วก็ไม่ต้องเติม และถ้าเรามั่นใจอยู่แล้วว่า myObj มี gabgab() ก็ไม่ต้องแปลง ทำให้ประสิทธิภาพดีขึ้น แต่ก็อย่างว่าครับ โอกาสหลุดมันก็มี ถ้าลืมแปลง แต่ “เราก็โตๆ กันแล้ว” ไม่ใช่หรือครับ

เราอาจจะย้าย toDuck() เข้าไปอยู่ใน func1() ถ้าทำอย่างนั้นปลอดภัยแน่ แต่ประสิทธิภาพโดยรวมจะลดลง โดยปกติแล้ว คงไม่มีใครทำสักเท่าไหร่ครับ

การพิสูจน์เป็ด

วิธีนี้ก็ดังที่ผมได้กล่าวเอาไว้ค่อนข้างยาว ในเรื่องของ duck test เราทำการทดสอบก่อนใช้ ดังนี้ครับ

วิธีนี้เหมาะกับคนที่กลัวว่ายานอวกาศจะโหม่งดาวพลูโต ตรวจสอบให้แน่ใจว่าเป็นเป็ด จึงค่อยทำงาน วิธีนี้ก็เป็นวิธียอดนิยมวิธีหนึ่งครับ

ลองทำดู หนูทำได้

วิธีสุดท้ายเป็นอีกวิธีหนึ่งมีการใช้กัน ก็คือใช้การ try/catch ลองทำดูก่อน ถ้าทำไม่ได้ก็ค่อยแสดงความผิดพลาด แต่โปรแกรมไม่หลุดนะครับ เพราะใช้ try/catch ป้องกันเอาไว้แล้ว

สรุป

ขอจบบทความนี้แบบดื้อๆ ครับ บทความนี้สั้น เพราะตั้งใจเขียนเป็นตัวเก็บตกในบทความที่แล้วเท่านั้น แต่ผมก็ได้ปูพื้นฐานเรื่องของ type system เพื่อจะได้นำเอาไปใช้ในตอนหน้า

Object-Oriented ใน Python ยังมีอะไรน่าสนใจอีกไม่น้อยครับ จะค่อยๆ ทยอยเขียนให้ท่านอ่านกัน แล้วพบกันใหม่ในบทความหน้า

[Total: 2    Average: 5/5]

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *