From C++ to Python (1)
ภาษาไพธอน เป็นภาษาที่ผมอยากหาโอกาสเรียนมานานแล้ว แต่ด้วยงานส่วนใหญ่ที่ผมทำยังวนเวียนอยู่แถว ๆ ระบบระดับล่างซึ่งใช้ C/C++ เป็นหลัก ก็เลยยังปล่อยมือมาเรียนภาษาอื่นไม่ถนัดถนี่ จนกระทั่งระบบภาษาไทยเริ่มจะอยู่ตัวแล้ว ถึงเริ่มเรียนภาษาอื่น ๆ เพื่อเพิ่มโอกาสในการทำงานแขนงอื่นบ้าง
ในการเรียนครั้งนี้ ผมได้อาศัยบทเรียนจาก python-course.eu (และไปอ่านทบทวนกับ บทเรียนที่ debianclub ที่คุณวิทยาเคยลงไว้)
บทเรียนที่ python-course.eu นับว่าเหมาะกับคนที่มีพื้นฐาน C/C++ มาแล้วมาก เพราะเขาอธิบายบนพื้นฐานของคนเคยเขียนโปรแกรมมาแล้ว ไม่ใช่เริ่มต้นจากศูนย์ ยกตัวอย่างเปรียบเทียบกับ C/C++ และเน้นประเด็นที่เป็นหลุมพรางของผู้ที่ย้ายมาจาก C/C++
เท่าที่ได้ตั้งโจทย์ฝึกหัดให้กับตัวเอง ด้วยการทดลองพอร์ตโค้ดภาษา C/C++ ของตัวเองที่เคยเขียนไว้ให้เป็นไพธอน พร้อมกับทำ unit test ไปด้วย ทำให้ได้พบเจอประเด็นต่าง ๆ ที่โปรแกรมเมอร์ภาษา C/C++ จะสะดุด ผมเองก็พบว่าต้องก้าวข้ามสิ่งเหล่านี้ถึงจะเริ่มจับทางไพธอนได้ จึงเขียนบันทึกไว้สักหน่อย
Indentation
ข้อนี้ดูเหมือนเป็นเรื่องเล็ก แต่กลับเป็นสาเหตุหลักข้อหนึ่งที่ทำให้ผมยี้ไพธอนก่อนหน้านี้ เพราะโปรแกรมเมอร์ภาษา C/C++ จะคุ้นกับ free-form syntax และพบเจอการ indent แบบตามใจฉันมาแล้วมากมาย บางคนใช้ tab บางคนใช้ space 2, 4 หรือ 8 ช่อง บางคนใช้ปนกันทั้ง tab ทั้ง space ซึ่งซอร์สจะเละทันทีเมื่อมีการเปลี่ยนขนาดของ tab stop นี่ยังไม่นับคนที่ไม่สนใจ style ใด ๆ ทั้งสิ้นอีกนะ แต่ไม่ว่าอย่างไรโปรแกรมก็ยังคอมไพล์ผ่าน แล้วถ้ามันกลายเป็นการกำหนด syntax ของภาษา มันจะเละเทะขนาดไหน
เมื่อพยายามนึกถึงตัวอย่างอื่นที่กำหนดอะไรทำนองนี้ ผมก็นึกถึง Makefile ที่ใช้ tab ในการกำหนดขอบเขตของ rule แต่มันก็แค่ระดับเดียว ไม่ได้ซ้อนกันหลายชั้น จึงดูไม่มีอะไรมาก และอีกตัวอย่างหนึ่งคือ Fortran 77 ที่เคยเขียนในสมัยเรียน ซึ่งอาศัยตำแหน่งคอลัมน์เป็นส่วนหนึ่งของ syntax อันเป็นมรดกตกทอดมาจากสมัยใช้บัตรเจาะรู (ตอนที่เรียน Fortran 77 รู้สึกอึดอัดตรงนี้มาก เพราะตอนนั้นผ่านภาษา Pascal มาแล้ว แม้แต่ภาษา BASIC [AppleSoft, GW] ที่ว่าไม่ค่อยมีโครงสร้างก็ยังไม่ทำอะไรโลว์เทคเยี่ยงนี้) แต่ละอย่างที่นึกถึงก็ไม่ได้ชวนพิสมัยเอาเสียเลย
นั่นคือ mindset ของผมก่อนเรียน แต่ คำอธิบาย ในบทเรียนก็ทำให้ผมเข้าใจและยอมรับ ทำให้เรียนไพธอนต่อไปได้อย่างผ่อนคลาย มันคือการกำหนดขอบเขตของบล็อคคำสั่งโดยเปลี่ยนจาก style ให้เป็น syntax เสีย คนที่เขียนโปรแกรมภาษา C/C++ โดยเคร่งครัดกับ style อยู่แล้วจึงยอมรับตรงนี้ได้ไม่ยาก ส่วนประเด็นเรื่องการใช้ tab กับ space ปนกัน ไพธอนก็มีข้อกำหนดที่ชัดเจนที่ทำให้แยกแยะออกจากกันได้ คือถือเป็น indentation คนละระดับไปเลย ไม่นับปนกัน
มันคือการกำจัดวิจิกิจฉานิวรณ์ครับ ผ่านตรงนี้ไปได้ก็ลื่นไหลขึ้นเยอะ
Public, Protected, Private
ภาษา C++ จะมีการกำหนด access specifier สำหรับ member ต่าง ๆ ของคลาสเป็น public, protected, private จากนั้น โปรแกรมเมอร์แต่ละคนก็จะมีวิธีการต่าง ๆ ในการตั้งชื่อ member ให้สามารถแยกแยะ access ได้ บางคนใช้ตัวพิมพ์เล็ก-พิมพ์ใหญ่ต้นชื่อ บางคนใช้ prefix บางคนใช้ suffix บางคนไม่แยกเลย (อันนี้ยุ่ง)
แต่ไพธอนไม่มี access specifier แต่จะใช้การตั้งชื่อแยกแยะทันที โดยชื่อปกติจะถือเป็น public ชื่อที่ขึ้นต้นด้วย _
ถือเป็น protected และชื่อที่ขึ้นต้นด้วย __
ถือเป็น private นับว่าเป็นการเปลี่ยน style ให้เป็น syntax อีกหนึ่งเรื่อง
Dynamic Type & Scope
การย้ายจากภาษาที่เป็น static typing มาเป็น dynamic typing จะต้องปรับโหมดความคิดสักหน่อย ซึ่งพื้นฐานภาษา BASIC สมัยเก่า บวกกับความรู้จากวิชา Compilers & Programming Languages พอช่วยได้ ไม่เป็นปัญหาสำหรับผมนัก สิ่งที่ต้องปรับเปลี่ยนแนวคิดก็เช่น:
- ชนิดของตัวแปรเป็น dynamic ไม่ใช่ตายตัวเปลี่ยนชนิดไม่ได้เหมือนในภาษา static type ทั้งหลาย ถึงตัวแปรหนึ่งจะเก็บค่า integer อยู่ คุณจะ assign สตริงหรือลิสต์ให้มันใหม่ก็ยังได้
>>> x = 1 >>> print(x) 1 >>> x = "Hello!" >>> print(x) Hello!
- scope ของตัวแปรเป็นแบบ dynamic ฟังก์ชันหนึ่ง ๆ ทำงานแต่ละครั้งอาจเข้าถึงตัวแปรได้ไม่เหมือนกัน ขึ้นอยู่กับ state ของทั้งโปรแกรมในขณะนั้น
>>> def f(): ... print(s) ... >>> s = "Hello" >>> f() Hello >>> s = 2 >>> f() 2
แต่ dynamic scope นี้ก็ถูกขี่ทับด้วย block scope ได้ ถ้ามีการกำหนดตัวแปรโลคอลในชื่อเดียวกัน>>> def g(): ... s = "Bye" ... print(s) ... >>> s = "Hello" >>> g() Bye >>> print(s) Hello
อ่านเพิ่มเติม
Call by Value หรือ Call by Reference?
ภาษาไพธอนไม่มีพอยน์เตอร์เหมือน C/C++ แต่กลไกภายในกลับใช้พอยน์เตอร์เต็มไปหมด แม้แต่ตัวแปรกับค่าของมันก็เชื่อมกันด้วยพอยน์เตอร์ การ assign ตัวแปรก็เป็นการ assign พอยน์เตอร์ไปยังออบเจกต์ที่เป็นค่า ดังสามารถตรวจสอบได้ดังนี้:
>>> x = 1 >>> y = x >>> id(x) 10861696 >>> id(y) 10861696
จะเห็นว่า หลัง assignment แล้ว x
กับ y
ชี้ไปยังออบเจกต์เดียวกัน ไม่ใช่แค่มีค่าเท่ากัน! อย่างไรก็ดี ถ้าเรา assign ค่าใหม่ให้กับ y
จะไม่ได้ทำให้ค่าของ x
เปลี่ยน แต่เป็นการกำหนดให้ y
ชี้ไปยังออบเจกต์ใหม่:
>>> y = 2 >>> id(y) 10861728 >>> x 1
ก็น่าจะปลอดภัยดี แถมยังเป็นการประหยัดหน่วยความจำด้วยถ้าค่าเป็นออบเจกต์ใหญ่ ๆ ที่ไม่ใช่ integer เรื่องประหยัดน่ะใช่ แต่แน่ใจหรือเรื่องความปลอดภัย?
>>> p = [1, 2, 3] >>> q = p >>> q[1] = 'x' >>> p [1, 'x', 3]
จะเห็นว่าลิสต์ p
เปลี่ยนตาม q
หลัง assignment ทั้งนี้เพราะทั้ง p
และ q
ชี้ไปยังออบเจกต์เดียวกันตลอดเวลา และการ assign q[1]
ก็เป็นการเปลี่ยนพอยน์เตอร์ q[1]
จากที่ชี้ไปยัง integer 2
ให้ชี้ไปยัง string 'x'
แทน แต่ในเมื่อ q
ชี้ไปยังออบเจกต์เดียวกันกับ p
การเปลี่ยนพอยน์เตอร์ q[1]
จึงเป็นการเปลี่ยนพอยน์เตอร์ p[1]
ด้วย!
พฤติกรรมแบบนี้ เกิดกับออบเจกต์ที่เป็น complex data structure ทั้งหมด ไม่ว่าจะเป็น list, dictionary หรือ class instance ซึ่งหากอธิบายในภาษาของ C/C++ ด้วยคำว่า พอยน์เตอร์
(จากชื่อตัวแปรไปยังค่า) ก็จะสามารถทำความเข้าใจได้ รวมถึงประโยชน์ของ deepcopy ด้วย
ความสนุกเกิดขึ้นเมื่อเราส่งอาร์กิวเมนต์ให้กับฟังก์ชัน
แบบนี้พฤติกรรมจะเหมือน call by value:
>>> def f(x): ... x = x + [1] ... >>> p = [1, 2, 3] >>> f(p) >>> p [1, 2, 3]
แต่แบบนี้กลับเหมือน call by reference:
>>> def g(x): ... x += [1] ... >>> p = [1, 2, 3] >>> g(p) >>> p [1, 2, 3, 1]
คำอธิบายก็คือ x
ซึ่งเป็นพารามิเตอร์ของ f()
และ g()
นั้น จะก็อปปี้พอยน์เตอร์ไปยังค่าของ p
มาทั้งคู่ จากนั้น x
ใน f()
ถูก assign ให้ชี้ไปยังออบเจกต์ใหม่ที่เป็นผลลัพธ์ของการบวก x
กับลิสต์ [1]
ในขณะที่ x
ใน g()
ถูกเพิ่มอิลิเมนต์ใหม่ต่อท้ายโดยตรงในออบเจกต์เดิม ซึ่งเป็นออบเจกต์เดียวกับที่ p
ของผู้เรียกชี้อยู่ ผลจึงเป็นการเปลี่ยนค่าของ p
ของผู้เรียกไปด้วย
คำถามที่ว่า การเรียกฟังก์ชันของไพธอนเป็น call by value หรือ call by reference จึงตอบได้แค่ว่า ไม่ใช่ทั้งสองอย่าง ผู้ที่ย้ายมากจากภาษา C/C++ ต้องระวังให้ดี ภาษาไพธอนไม่มีพอยน์เตอร์ก็จริง แต่เวลาเขียนไพธอนให้นึกถึงพอยน์เตอร์เข้าไว้
Function Overloading?
ฟังก์ชันในภาษา C++ สามารถโอเวอร์โหลดเพื่อรับพารามิเตอร์หลายแบบได้ ถึงแม้มันจะเพิ่มความยุ่งยากในการลิงก์จากโค้ดภายนอกอันเนื่องมาจาก symbol mangling แต่มันก็ไม่ได้สร้างขึ้นมาเท่ ๆ มี use case ที่ได้ประโยชน์จากการโอเวอร์โหลดฟังก์ชัน เช่น:
- ช่วยให้สร้าง constructor หลายแบบได้
- ช่วยในการ overload operator เพื่อรับ operand หลายชนิดได้
แต่ไพธอนไม่สามารถโอเวอร์โหลดฟังก์ชันได้ มีให้อย่างมากก็แค่การใช้ default argument แต่ถ้ากำหนดฟังก์ชันชื่อเดียวกันหลายครั้ง มันก็จะเอาอันหลังสุดเท่านั้น ถือว่าทับอันแรก ๆ ไป
ด้วยความเป็น dynamic typing ของภาษาไพธอน ทำให้พอเข้าใจได้ว่าการโอเวอร์โหลดฟังก์ชันสามารถทำให้เกิดความยุ่งยากได้ กรณีทั่วไปเราอาจเลี่ยงได้ด้วยการตั้งชื่อฟังก์ชันหลบกันเสีย แต่สำหรับกรณีของ magic methods ทั้งหลายของคลาส เราไม่สามารถตั้งชื่อหลบได้ ซึ่งกรณีของ constructor และ operator overloading ก็เข้าข่าย magic methods ทั้งสิ้น
แล้วจะจัดการกับ use case ข้างต้นได้อย่างไร?
การโอเวอร์โหลด constructor
กรณีที่สามารถใช้ default argument ได้ ก็ใช้ default argument เช่น:
class Time: def __init__ (self, h=0, m=0, s=0): self.h, self.m, self.s = h, m, s t1 = Time() t2 = Time(8) t3 = Time(8, 20) t4 = Time(8, 20, 45)
หากโอเวอร์โหลดโดยใช้อาร์กิวเมนต์ต่างชนิดกัน ก็ไม่สามารถใช้ default argument ได้ ก็อาจใช้วิธีพิเศษ เช่น การใช้ argument tuple หรือ argument dict:
class Time: def __init__ (self, *args): if len (args) == 1 and type (args[0]) == str: h, m, s = args[0].split(":") self.h, self.m, self.s = int(h), int(m), int(s) else: self.h, self.m, self.s = 0, 0, 0 if len (args) >= 1: self.h = int(args[0]) if len (args) >= 2: self.m = int(args[1]) if len (args) >= 3: self.s = int(args[2]) t1 = Time() t2 = Time(8) t3 = Time(8, 20) t4 = Time(8, 20, 45) t5 = Time("8:20:50")
หรือใช้ factory method ซะเลย:
class Time: @classmethod def from_str (cls, s): h, m, s = s.split(":") return cls (int(h), int(m), int(s)) @classmethod def from_hms (cls, h=0, m=0, s=0): return cls (h, m, s) def __init__ (self, h, m, s): self.h, self.m, self.s = h, m, s t1 = Time.from_hms() t2 = Time.from_hms(8) t3 = Time.from_hms(8, 20) t4 = Time.from_hms(8, 20, 45) t5 = Time.from_str("8:20:50")
(ดัดแปลงจาก กระทู้ Stack Overflow)
การโอเวอร์โหลด operator
สมมุติว่าเรามีคลาส Date
(วันที่) ซึ่งต้องการโอเวอร์โหลดเครื่องหมายลบดังนี้:
d1 = Date("2016-04-26") d2 = Date("2016-05-03") assert d2 - d1 == 7 assert d2 - 7 == d1
กล่าวคือ:
- ถ้าตัวลบเป็นชนิด
Date
ให้หาจำนวนวันระหว่างวันที่ทั้งสอง - ถ้าตัวลบเป็นชนิด
int
ให้หาวันที่ถอยหลังเป็นจำนวนวันที่ลบ
กรณีนี้ดูจะไม่มีทางอื่น นอกจากตรวจสอบชนิดของตัวลบเอา:
class Date: # ... def __sub__ (self, other): if type (other) == int: # ... subtract days from self ... elif type (other) == Date: # ... subtract two Dates ... else: raise TypeError ("Invalid operand type")
ซึ่งออกจะดูอัปลักษณ์ถ้าเป็นโค้ด C++ แต่นี่คือไพธอนมาตรฐาน
อย่างไรก็ดี เท่าที่ลองค้นดู ดูเหมือนจะมีแพกเกจ overload และ multimethod เพื่อการนี้ และดูจะมี PEP 3124 เพื่อเพิ่มการรองรับการโอเวอร์โหลดฟังก์ชันอย่างเป็นทางการ แต่ยังอยู่ในสถานะ Deferred อยู่
Polymorphism?
หนึ่งในเครื่องมือที่ใช้กันมากของ OOP ก้คือ polymorphism ซึ่งในภาษา C++ จะใช้ virtual function ประกอบกันกับ class hierarchy ซึ่ง implementation ภายในคือ pointer to function ใน v-table ของคลาส
แต่พอมาเขียนไพธอน คุณจะหา keyword หรือ naming convention ที่เทียบเคียงกับ virtual function ไม่ได้เลย ทั้งนี้เพราะไพธอนเป็น polymorphic โดยธรรมชาติอยู่แล้ว!
เวลาที่เขียนฟังก์ชันแบบนี้ใน C++:
inline int max (int x, int y) { return (x > y) ? x : y; } inline int max (double x, double y) { return (x > y) ? x : y; }
หรือจะใช้ generic programming ด้วย template ซึ่ง implementation ภายในเทียบเท่ากัน แต่ใช้กับชนิดใดก็ได้ที่รองรับ operator >
:
template <class T> inline int max (T x, T y) { return (x > y) ? x : y; }
แต่ในไพธอนคุณเขียนแค่นี้ก็ทำงานได้กับทุกชนิดแล้ว:
def max (x, y): return x if x > y else y
เพราะ dynamic typing นั่นเอง ทำให้สามารถส่งออบเจกต์ชนิดไหนก็ได้ แล้วไปว่ากันที่ run-time ถ้าชนิดนั้น ๆ รองรับ operation ที่เรียกใช้ ก็เป็นอันใช้ได้ ตามแนวคิดที่เรียกว่า duck-typing ซึ่งกล่าวว่า If it looks like a duck and quacks like a duck, it must be a duck.
คุณจึงสามารถใช้ polymorphism ได้ ไม่ว่าจะเขียนโค้ดแบบนี้:
class Animal: def cry (self): pass class Cat (Animal): def cry (self): print ("Meow!") class Duck (Animal): def cry (self): print ("Quack!") farm = [] farm.append (Cat()) farm.append (Duck()) for i in farm: i.cry()
หรือแบบนี้:
class Cat: def cry (self): print ("Meow!") class Duck: def cry (self): print ("Quack!") farm = [] farm.append (Cat()) farm.append (Duck()) for i in farm: i.cry()
คงพอเห็นภาพนะครับ
blog นี้เขียนถึงสิ่งที่สะดุด ก็ชักจะยาวแล้ว เดี๋ยว blog หน้าค่อยเขียนถึงสิ่งที่ผมชอบในไพธอนต่อนะครับ
หมายเหตุ: ด้วยความที่ผมยังเตาะแตะกับไพธอนอยู่ ที่เขียนไปอาจมีข้อผิดพลาดจากความไม่เข้าใจ ก็ยินดีรับข้อชี้แนะจากผู้รู้ครับ
ป้ายกำกับ: c++, programming, python