7 ทริค Python ขั้นสูงเพื่อยกระดับทักษะโปรแกรมมิ่งของคุณ

ในจุดหนึ่ง นักพัฒนา Python มักจะเขียนลูป while True พร้อมกับ break หรือซ้อนบล็อก with ต่อกันเป็นชั้นๆ จนเริ่มไม่แน่ใจว่ามีวิธีที่ดีกว่านี้ไหม ซึ่งโดยปกติแล้วมักจะมีทางออกเสมอ ไลบรารีมาตรฐานได้แก้ปัญหาเหล่านี้ไว้แล้ว เพียงแต่เป็นมุมที่บทเรียนส่วนใหญ่มักไม่เคยเอ่ยถึง KDnuggets เคยนำเสนอ advanced tricks for data scientists มาก่อน โดยมี pandas และ NumPy เป็นตัวชูโรง
แต่รายการนี้แตกต่างออกไป ทุกหัวข้อในที่นี้เป็นข้อตกลงแบบ built-in หรืออยู่ในไลบรารีมาตรฐาน ไม่มีการพึ่งพาไลบรารีภายนอก (dependencies) และแต่ละข้อมาพร้อมกับคำเตือนเพื่อป้องกันการนำไปใช้ในทางที่ผิด การยกระดับทักษะระดับสูงมักไม่ใช่การเรียนรู้ไวยากรณ์ใหม่ แต่คือการเรียนรู้สิ่งที่ตัวภาษาเคยสัญญาไว้กับคุณอยู่แล้ว
| สิ่งที่คุณเขียนเอง | เครื่องมือ | ผลลัพธ์ที่ได้ | Python ขั้นต่ำ |
|---|---|---|---|
ลูปอ่านข้อมูลแบบ while True + break | iter(callable, sentinel) | ลูปสิ้นสุดลงเองเมื่อเจอค่า sentinel | 3.x ทุกเวอร์ชัน |
บล็อก with ซ้อนกันสำหรับเซ็ตข้อมูลขนาดตาม runtime | contextlib.ExitStack | การเคลียร์ทรัพยากรแบบย้อนลำดับ, ปลอดภัยจากข้อยกเว้น | 3.x ทุกเวอร์ชัน |
| การ Slice ข้อมูลไบต์ขนาดใหญ่ (มีการ copy แฝง) | memoryview | ใช้บัฟเฟอร์ร่วมกัน, การเขียนส่งผ่านถึงกันได้ | 3.x ทุกเวอร์ชัน |
| การจัดการ Batch แบบเจอข้อผิดพลาดแรกแล้วหยุด | ExceptionGroup + except* | เก็บความล้มเหลวทั้งหมด, แยกตามประเภท | 3.11 |
| การรวม config dict ที่ไม่มีใครแยกคืนได้ | collections.ChainMap | การค้นหาแบบเลเยอร์เรียลไทม์, การเขียนลงแมปแรก | 3.x ทุกเวอร์ชัน |
| การส่งคืน dict ภายในให้ผู้เรียกใช้ | types.MappingProxyType | มุมมองแบบอ่านอย่างเดียว, อัปเดตอยู่เสมอ | 3.x ทุกเวอร์ชัน |
| ใช้ Lambda เพื่อล็อกอาร์กิวเมนต์ตัวกลาง | functools.Placeholder | partial() สำหรับตำแหน่ง positional ใดๆ | 3.14 |
แผนผังคร่าวๆ ของทริคทั้งเจ็ด สิ่งที่แต่ละอย่างเข้ามาแทนที่ และเวอร์ชัน Python ที่คุณต้องการ
1. เปลี่ยน Callable ให้เป็น Iterator ด้วย Sentinel
iter() มีรูปแบบที่สองที่แทบไม่มีใครใช้ เพียงส่ง callable ที่ไม่รับอาร์กิวเมนต์ (zero-argument callable) พร้อมกับค่า sentinel เข้าไป Python จะเรียกฟังก์ชันนั้นซ้ำๆ และหยุดทันทีเมื่อค่าที่ส่งคืนเท่ากับค่า sentinel:
for chunk in iter(lambda: stream.read(64), b""):
process(chunk)วิธีนี้เข้ามาแทนที่ลูปการอ่านแบบ while True / break คลาสสิกได้อย่างสิ้นเชิง ลองป้อนสตรีมขนาด 200 ไบต์ แล้วคุณจะได้ chunk ขนาด 64, 64, 64 และ 8 จากนั้นมันจะหยุดทำงานทันทีเพราะ read() ส่งคืนค่า sentinel ที่เป็นไบต์ว่าง รูปแบบเดียวกันนี้ยังจัดการอะไรก็ได้ที่มีลักษณะการดึงข้อมูล (pull-shaped) ตั้งแต่ชุดข้อมูลใน database cursor ไปจนถึงข้อความใน queue
ข้อจำกัดคือส่วนที่เป็น zero-argument เนื่องจาก iter() จะไม่ส่งอาร์กิวเมนต์ให้คุณ ดังนั้นอะไรก็ตามที่จำเป็นต้องใช้อาร์กิวเมนต์ต้องถูกห่อหุ้มใน lambda หรือ partial เสียก่อน
2. จัดการชุดทรัพยากรด้วย ExitStack
บล็อก with ซ้อนกันนั้นทำงานได้ดีจนกระทั่งจำนวนทรัพยากรถูกกำหนดที่ runtime การเปิดรายการไฟล์ที่ผู้ใช้เลือกนั้นไม่เหมาะกับไวยากรณ์ที่ตายตัว และนั่นคือช่องว่างที่ ExitStack เข้ามาเติมเต็ม:
with ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths]
merge(files)ไฟล์ทุกไฟล์จะปิดลงเมื่อออกจากบล็อก รวมถึงกรณีที่เกิดข้อยกเว้นด้วย การเคลียร์ทรัพยากรจะทำงานย้อนลำดับการเข้าถึง เช่น หากลงทะเบียนตัวติดตามไว้สามตัว คุณจะเห็นพวกมันปิดในลำดับ 2, 1, 0 นอกจากนี้ stack ยังรองรับการประกอบร่าง: enter_context() รับอะไรก็ได้ที่มีอินเทอร์เฟซของ context-manager ดังนั้นไฟล์, lock และ network client สามารถใช้การรับประกันการเคลียร์ทรัพยากรร่วมกันได้ เมื่อจำนวนทรัพยากรคงที่และมีขนาดเล็ก ให้ใช้ with ปกติต่อไป เพราะอ่านง่ายกว่าสำหรับผู้อื่น
3. Slice ข้อมูล Binary โดยไม่ต้อง Copy
การ slicing bytes จะสร้างการ copy ซึ่งในข้อมูลขนาดเล็กอาจไม่ส่งผลนัก แต่ถ้า slice แพ็กเก็ตขนาดใหญ่หรือ image buffer ในลูป การ copy จะเริ่มใช้หน่วยความจำและเวลาอย่างมาก สิ่งที่ memoryview ทำคือการเปิดให้เห็นบัฟเฟอร์พื้นฐานตัวเดียวกันโดยไม่มีการ copy และ view ที่เขียนได้ยังสามารถเขียนลงไปได้โดยตรง:
packet = bytearray(16)
header = memoryview(packet)[:4]
header[0] = 0xFF # packet[0] กลายเป็น 0xFF ในขณะนี้มีคำเตือนสองข้อเพื่อให้ใช้งานได้อย่างถูกต้อง ประการแรกประสิทธิภาพที่เพิ่มขึ้นนั้นขึ้นอยู่กับลักษณะงาน ดังนั้นควรวัดผลก่อนตัดสินใจ และอีกข้อควรระวังคือ view ที่ถูกส่งออกมาจะทำการล็อค (pin) บัฟเฟอร์ไว้ หากพยายามปรับขนาด (resize) bytearray ในขณะที่มี view ทำงานอยู่ Python จะแจ้ง BufferError จนกว่าคุณจะเรียก release() พฤติกรรมนี้ถือเป็นฟีเจอร์ที่ช่วยดักจับบั๊กเกี่ยวกับวงจรชีวิตข้อมูล (lifetime bugs) ได้อย่างชัดเจน
4. รวมความล้มเหลวด้วย ExceptionGroup
เมื่อชุดของงานที่เป็นอิสระต่อกันล้มเหลวในหลายรูปแบบ โมเดลคลาสสิกจะบังคับให้ต้องเลือกว่าจะรายงานข้อผิดพลาดไหนและสูญเสียข้อมูลที่เหลือไป ตั้งแต่ Python 3.11 เป็นต้นมา exception groups สามารถเก็บข้อผิดพลาดทั้งหมดไว้ได้:
raise ExceptionGroup("batch failed", [ValueError("row 3"), OSError("disk"), ValueError("row 9")])ไวยากรณ์ except* ที่เข้าคู่กันจะส่งต่อแต่ละกลุ่มย่อยแยกกัน ดังนั้นตัวจัดการ ValueError จะเห็นความล้มเหลวของทั้งสองแถว ในขณะที่ตัวจัดการ OSError จะเห็นปัญหาเรื่องดิสก์ หากความล้มเหลวไม่มีตัวจัดการที่ตรงกัน พวกมันจะถูกส่งต่อไปเรื่อยๆ (propagate) ให้ใช้กลไกนี้สำหรับกรณีที่มีความล้มเหลวหลายอย่างเกิดขึ้นพร้อมกันจริงๆ เช่น งานแบบ concurrent ส่วนความล้มเหลวครั้งเดียวที่มีสาเหตุชัดเจนยังคงควรใช้ raise ปกติ
5. เลเยอร์ Dictionary ด้วย ChainMap
ลำดับความสำคัญของคอนฟิกูเรชัน (Configuration precedence) มักถูกใช้ในรูปแบบการ merge ที่แยกคืนไม่ได้ ChainMap จะแยกเลเยอร์ออกจากกันและค้นหาตามลำดับ:
cfg = ChainMap(cli_args, env_vars, defaults)
cfg["timeout"] # ค้นหาค่าจาก env หากไม่พบจะถอยกลับไปใช้ defaults
``` เนื่องจากมันเป็นการดูข้อมูลแบบเรียลไทม์ (live view) การอัปเดต `defaults` ภายหลังจะปรากฏใน `cfg` ทันที สิ่งที่ควรจำคือการเขียนและการลบจะมีผลกับ mapping แรกเท่านั้น หากกำหนด `cfg["retries"] = 5` เลเยอร์ CLI จะได้รับคีย์นั้นไปในขณะที่ `defaults` จะไม่ถูกแตะต้อง
นอกจากนี้ยังมีโบนัส `new_child()` ที่จะดันเลเยอร์ใหม่ไปไว้ด้านหน้าสุด เพื่อให้งานย่อยมีการตั้งค่าชั่วคราวของตัวเองได้โดยไม่กระทบข้อมูลข้างล่าง แต่หากคุณต้องการภาพถ่ายข้อมูลที่หยุดนิ่ง (frozen snapshot) ตัวดำเนินการ merge `|` จะเหมาะสมกว่า
## 6. ปกป้อง Mapping ด้วย MappingProxyType
การส่งคืน dictionary ภายในจากคลาสเปรียบเสมือนการส่งรีโมทคอนโทรลควบคุมสถานะ (state) ให้แก่ผู้ใช้ **[`MappingProxyType`](https://docs.python.org/3/library/types.html#types.MappingProxyType)** จะส่งคืนมุมมองแบบอ่านอย่างเดียวแทน:
```python
self._registry = {"csv": load_csv}
self.registry = MappingProxyType(self._registry)ผู้ใช้งานที่พยายามแก้ไขข้อมูลจะได้รับ TypeError ในขณะที่โค้ดภายในยังคงเขียนลงใน _registry ได้ปกติ และทุกการเปลี่ยนแปลงจะปรากฏผ่าน proxy ทันที การใช้ proxy ดีกว่าการส่งคืน copy เพราะจะอัปเดตอยู่เสมอโดยไม่มีค่าใช้จ่าย อย่างไรก็ตาม การป้องกันนี้เป็นแบบตื้น (shallow) ค่าที่เปลี่ยนแปลงได้ภายใน mapping ยังคงถูกแก้ได้ และนี่เป็นเครื่องมือเพื่อความชัดเจนของ API ไม่ใช่เพื่อความปลอดภัยระดับสูงสุด
7. เติมเต็ม Slot ด้วย functools.Placeholder
partial() มักจะล็อกอาร์กิวเมนต์จากทางซ้ายเสมอ ซึ่งไม่สะดวกหากอาร์กิวเมนต์ที่ต้องการล็อกอยู่ตรงกลาง Python 3.14 ได้เพิ่ม functools.Placeholder เพื่อจองตำแหน่งที่ว่างไว้:
send_json = partial(send, Placeholder, "application/json", retries=3)
send_json(payload) # payload จะเข้าไปเติมในช่องแรกที่จองไว้ช่องว่างจะถูกเติมจากซ้ายไปขวาเมื่อเรียกใช้งาน สำหรับเวอร์ชันเก่ากว่า 3.14 ทางเลือกคือการใช้ฟังก์ชันครอบ (wrapper function) หรือ lambda ซึ่งฟังก์ชันที่มีชื่อมักจะดีกว่าเพราะช่วยให้การตรวจสอบโค้ด (code review) และ traceback ระบุตำแหน่งได้ง่าย
เรียนรู้ข้อตกลง ไม่ใช่แค่ทางลัด
ก่อนใช้ทริคเหล่านี้ ควรตรวจสอบสามส่วน: ระบุชื่อกลไกเดิมที่กำลังจะถูกแทนที่, ตรวจสอบข้อตกลงเรื่องการเปลี่ยนแปลง (mutation) และวงจรชีวิต (lifetime) และสุดท้ายคือยืนยันเวอร์ชัน Python ที่รองรับ โดยเฉพาะ 3.11 สำหรับ exception groups และ 3.14 สำหรับ Placeholder
ทริคที่ดีที่สุดคือสิ่งที่ช่วยลบโค้ดส่วนเกินและทำให้พฤติกรรมของโปรแกรมอธิบายได้ง่ายขึ้น ผู้อ่านที่ต้องการปูพื้นฐานเพิ่มเติมสามารถศึกษา must-know Python concepts และเครื่องมือใน functools and itertools toolbox เพื่อใช้งานควบคู่กัน
[Nahla Davies](http://nahlawrites.com/) เป็นนักพัฒนาซอฟต์แวร์และนักเขียนด้านเทคนิค เคยดำรงตำแหน่งหัวหน้านักโปรแกรมเมอร์ในองค์กรสร้างแบรนด์ที่มีลูกค้าชั้นนำอย่าง Samsung, Netflix และ Sony
ความคิดเห็น (0)
เข้าสู่ระบบเพื่อร่วมแสดงความเห็น
สมัครสมาชิกมาเป็นคนแรกที่แสดงความเห็นกันเลยโบร
