Functions
การกำหนดฟังก์ชัน
หัวข้อที่มีชื่อว่า “การกำหนดฟังก์ชัน”ใช้ def ตามด้วยชื่อฟังก์ชัน, รายการพารามิเตอร์ในวงเล็บ, และเครื่องหมายโคลอน
Body ของฟังก์ชันเยื้อง 4 ช่องว่าง
Type hints เป็นสิ่งเสริม แต่แนะนำอย่างยิ่ง — ช่วยบอกเจตนาและเปิดใช้การวิเคราะห์แบบ static
def greet(name: str, greeting: str = "Hello") -> str: """Return a greeting string for the given name.""" return f"{greeting}, {name}!"
print(greet("Alice")) # Hello, Alice!print(greet("Bob", greeting="Hi")) # Hi, Bob!พารามิเตอร์ที่สองมี ค่าเริ่มต้น — ผู้เรียกสามารถละเว้นได้
ค่าเริ่มต้นถูกประเมิน ครั้งเดียว ตอนที่ statement def รัน — ไม่ใช่ทุกครั้งที่เรียก ข้อเท็จจริงข้อเดียวนี้คือต้นเหตุของ bug ที่พบบ่อยที่สุดสองแบบในโค้ด Python ที่ AI เขียน จะอธิบายด้านล่าง
พารามิเตอร์และรูปแบบการเรียก
หัวข้อที่มีชื่อว่า “พารามิเตอร์และรูปแบบการเรียก”Python มีพารามิเตอร์สี่ประเภท:
def demo(pos, /, normal, *, kw_only): print(pos, normal, kw_only)
demo(1, 2, kw_only=3) # OKdemo(1, normal=2, kw_only=3) # OK- Positional-only (ก่อน
/): ไม่สามารถส่งผ่าน keyword ได้ - Normal: positional หรือ keyword
- Keyword-only (หลัง
*): ต้องส่งผ่าน keyword เท่านั้น
Positional-only parameter มีไว้เพื่อให้ library เปลี่ยนชื่อพารามิเตอร์ภายในทีหลังได้โดยไม่ทำให้ผู้เรียกพัง — ไม่มีใครส่งผ่านชื่อนั้นได้อยู่แล้วตั้งแต่แรก เพราะภาษาห้ามไว้:
def power(base, /, exponent=2): return base ** exponent
print(power(3)) # 9try: power(base=3) # base is positional-only -- keyword form rejectedexcept TypeError as e: print(f"power(base=3) -> TypeError: {e}")*args, **kwargs, และการ unpack ตอนเรียก
หัวข้อที่มีชื่อว่า “*args, **kwargs, และการ unpack ตอนเรียก”พารามิเตอร์ที่นำหน้าด้วย * จะรวบรวม positional argument ส่วนเกินเข้าเป็น tuple ส่วนที่นำหน้าด้วย ** จะรวบรวม keyword argument ส่วนเกินเข้าเป็น dict ใหม่
*/** แบบเดียวกันนี้ใช้กลับด้านได้ที่ จุดเรียก เพื่อกระจาย sequence หรือ mapping ออกเป็น argument แยก:
def total(*args, **kwargs): print("args:", args, "kwargs:", kwargs) return sum(args) + sum(kwargs.values())
numbers = [1, 2, 3]extra = {"bonus": 10}print(total(*numbers, **extra))# args: (1, 2, 3) kwargs: {'bonus': 10}# 16รูปแบบนี้ — def wrapper(*args, **kwargs): return inner(*args, **kwargs) — คือวิธีที่ wrapper หรือ decorator ทั่วไปส่งต่อทุกอย่างที่ถูกเรียกมา โดยไม่ต้องรู้ signature จริงของฟังก์ชันที่ห่ออยู่
Mutable default argument
หัวข้อที่มีชื่อว่า “Mutable default argument”ค่าเริ่มต้นถูกประเมินครั้งเดียวและเก็บไว้บน function object เอง — ดังนั้นค่าเริ่มต้นที่เป็น mutable (literal ของ list, dict, หรือ set) จะเป็น object เดิม ที่ถูกใช้ร่วมกันและอาจถูกแก้ไขซ้ำในทุกการเรียกที่ไม่ได้ override มัน:
def add_item(item, items=[]): # BUG: the same list object is reused across calls items.append(item) return items
print(add_item("apple")) # ['apple']print(add_item("banana")) # ['apple', 'banana'] -- "apple" leaked in from the last callไม่มีตรงไหนในการเรียกครั้งที่สองที่พูดถึง "apple" เลย — bug นี้มองไม่เห็นจนกว่าจะมีการเรียกครั้งที่สองเกิดขึ้นจริง ซึ่งเป็นเหตุผลที่มันรอดจาก code review และการทดสอบเร็ว ๆ ได้ ทางแก้คือรูปแบบ None sentinel: ตั้งค่าเริ่มต้นเป็น None แล้วสร้าง mutable object ใหม่ภายใน body ของฟังก์ชันเมื่อไม่มีอะไรถูกส่งมา:
def add_item_fixed(item, items=None): if items is None: # a brand-new list every call that doesn't supply one items = [] items.append(item) return items
print(add_item_fixed("apple")) # ['apple']print(add_item_fixed("banana")) # ['banana']กฎ “ประเมินครั้งเดียว” เดียวกันนี้ก็กัดค่าเริ่มต้นที่ตั้งใจให้เป็น “เวลาปัจจุบัน” หรือ “object ใหม่” ด้วย — def log_event(message, at=datetime.now()): จะ freeze at ไว้ตอนโมดูลถูก import ไม่ใช่ตอนเรียก ใช้ทางแก้แบบเดียวกันคือ None sentinel: at=None แล้วค่อย at = at if at is not None else datetime.now() ภายใน body
การคืนหลายค่า
หัวข้อที่มีชื่อว่า “การคืนหลายค่า”ฟังก์ชันสามารถคืนค่าหลายค่าโดยคั่นด้วยคอมมา Python จะรวมไว้ใน tuple อัตโนมัติ
def min_max(numbers: list) -> tuple: """Return (minimum, maximum) of a list.""" return min(numbers), max(numbers)
low, high = min_max([3, 1, 4, 1, 5, 9])print(f"min={low}, max={high}") # min=1, max=9Tuple unpacking ทางซ้ายของการกำหนดค่าจะแจกจ่ายค่าที่คืนไปยังตัวแปรแยกกัน
Docstrings
หัวข้อที่มีชื่อว่า “Docstrings”Docstring คือ string literal ที่วางทันทีหลัง def
ใช้บันทึกว่าฟังก์ชันทำอะไร, พารามิเตอร์, และค่าที่คืน
เข้าถึงได้ผ่าน help(fn) หรือ fn.__doc__
def add(a: int, b: int) -> int: """Return the sum of a and b.
Args: a: First operand. b: Second operand.
Returns: The integer sum. """ return a + bLEGB scope rules
หัวข้อที่มีชื่อว่า “LEGB scope rules”Python resolve ชื่อผ่านสี่ scope ตามลำดับนี้:
- Local — ภายใน function ปัจจุบัน
- Enclosing — function ที่ครอบอยู่ (สำหรับ nested function)
- Global — ระดับโมดูล
- Built-in — ชื่อ built-in ของ Python (
len,printฯลฯ)
x = 10 # global
def outer(): y = 20 # enclosing def inner(): z = 30 # local return x + y + z # finds x globally, y in enclosing, z locally return inner()
print(outer()) # 60การอ่านชื่อใช้แค่การค้นหาแบบ LEGB ก็พอ แต่การกำหนดค่าให้ชื่อภายใน function นั้นต่างกัน — Python ตัดสินใจตอน compile ว่าชื่อใดก็ตามที่ถูก assign ที่ไหนก็ตามใน body ของ function จะเป็น local สำหรับ ทั้ง function นั้น แม้แต่บรรทัดก่อนหน้าการ assign นั้นเอง นี่คือสิ่งที่ทำให้ count += 1 อันตราย:
counter = 0
def increment_buggy(): counter += 1 # BUG: this assignment makes counter LOCAL for the whole function return counter
increment_buggy() # UnboundLocalError: cannot access local variable 'counter' # where it is not associated with a valuecounter += 1 จริง ๆ แล้วคือ counter = counter + 1 — ทั้งอ่าน และ assign เพราะ counter ถูก assign ที่ไหนสักแห่งใน function นี้ Python จึงถือว่า counter เป็น local ทุกที่ใน function รวมถึงการอ่านฝั่งขวาด้วย ซึ่งตอนนี้เกิดขึ้นก่อนที่ local counter จะเคยมีค่าเลย ใช้ global name เพื่อบอก Python ชัดเจนว่าการ reassign ควรผูกกับตัวแปรระดับโมดูล ใช้ nonlocal name สำหรับสิ่งเดียวกันในระดับ enclosing function (ที่ไม่ใช่ global):
def increment_fixed(): global counter counter += 1 return counter
print(increment_fixed()) # 1print(increment_fixed()) # 2callable
หัวข้อที่มีชื่อว่า “callable”callable(obj) บอกว่า obj เรียกด้วย () ได้ไหม — จริงสำหรับ function, class, และ object ใด ๆ ที่ class ของมันนิยาม __call__ ไว้ ผิดสำหรับข้อมูลธรรมดาอย่าง int หรือ str:
print(callable(len)) # Trueprint(callable(42)) # Falseprint(callable(power)) # True -- functions are callable objects tooนี่เป็นวิธีตรวจสอบเร็ว ๆ ว่า “สิ่งนี้เป็น function ที่เรียกได้จริงไหม” ก่อนจะเรียกอะไรบางอย่างที่ถูกส่งเข้ามาเป็นพารามิเตอร์ — มีประโยชน์เวลาอ่านโค้ดที่รับ callback
Demo ที่รันได้จริง
หัวข้อที่มีชื่อว่า “Demo ที่รันได้จริง”def greet(name: str, greeting: str = "Hello") -> str: """Return a greeting string.""" return f"{greeting}, {name}!"
def min_max(numbers: list) -> tuple: """Return (minimum, maximum) of a list.""" return min(numbers), max(numbers)
x = 10
def outer(): y = 20 def inner(): z = 30 return x + y + z return inner()
print(greet("Alice"))print(greet("Bob", greeting="Hi"))
low, high = min_max([3, 1, 4, 1, 5, 9])print(f"min={low}, max={high}")
print(f"LEGB sum: {outer()}")
def no_return(): pass
result = no_return()print(f"no_return() gives: {result}")Loading Python runtime (first run only)…
Playground ที่สองเป็นทัวร์ของกับดักทุกอันด้านบน แสดงให้เห็นว่าพังตั้งใจแล้วค่อยแก้
from datetime import datetimeimport time
# 1. Mutable default argument: evaluated ONCE, at def time, then shared/reused.def add_item(item, items=[]): # BUG: the same list object is reused across calls items.append(item) return items
print("call 1:", add_item("apple"))print("call 2:", add_item("banana")) # apple is still there -- shared default!
def add_item_fixed(item, items=None): if items is None: # None sentinel: a fresh list every call items = [] items.append(item) return items
print("fixed call 1:", add_item_fixed("apple"))print("fixed call 2:", add_item_fixed("banana"))
# 2. A default value that looks dynamic but is frozen at def time.def log_event(message, at=datetime.now()): # BUG: at is fixed once, at import time return f"{at.isoformat()}: {message}"
first = log_event("start")time.sleep(0.05)second = log_event("still running")print("same timestamp both calls:", first.split(":")[0:2] == second.split(":")[0:2])
def log_event_fixed(message, at=None): at = at if at is not None else datetime.now() return f"{at.isoformat()}: {message}"
# 3. *args / **kwargs and call-site unpacking.def total(*args, **kwargs): print("args:", args, "kwargs:", kwargs) return sum(args) + sum(kwargs.values())
numbers = [1, 2, 3]extra = {"bonus": 10}print("total:", total(*numbers, **extra))
# 4. Positional-only parameters.def power(base, /, exponent=2): return base ** exponent
print("power(3):", power(3))try: power(base=3) # base is positional-only -- keyword form rejectedexcept TypeError as e: print(f"power(base=3) -> TypeError: {e}")
# 5. UnboundLocalError from count += 1 without global.counter = 0
def increment_buggy(): counter += 1 # BUG: this makes counter a LOCAL name -- read before assignment return counter
try: increment_buggy()except UnboundLocalError as e: print(f"increment_buggy() -> UnboundLocalError: {e}")
def increment_fixed(): global counter counter += 1 return counter
print("increment_fixed():", increment_fixed())print("increment_fixed():", increment_fixed())
# 6. callable().print("callable(len):", callable(len))print("callable(42):", callable(42))print("callable(power):", callable(power))Loading Python runtime (first run only)…
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”การเรียก function หนึ่งครั้งสร้าง frame ใหม่ — namespace ส่วนตัวของมันเอง — แล้วทิ้งไปเมื่อ call จบ การค้นหาชื่อสำหรับการอ่านจะเดินออกด้านนอกผ่าน frame ตามลำดับ LEGB ส่วนการกำหนดค่าไม่เดินออกด้านนอกเองเลย — Python ตัดสินใจว่าชื่อนั้นเป็นของ frame ไหนตั้งแต่ก่อน function จะรันด้วยซ้ำ โดยดูว่ามีการ assign ชื่อนั้นที่ไหนใน body หรือไม่ — นี่คือเหตุผลที่ counter += 1 แบบไม่ประกาศอะไรเลยสร้าง local counter ใหม่แทนที่จะไปถึงตัวข้างนอก
flowchart TB L["Local: current function's frame"] --> E["Enclosing: any outer function's frame"] E --> G["Global: the module namespace"] G --> B["Built-in: len, print, callable, ..."] L -.->|"assignment stays here unless declared global/nonlocal"| L
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| ค่าเริ่มต้นประเมินครั้งเดียวตอน def | เร็ว — ไม่ต้องประเมินซ้ำทุกครั้งที่เรียก | ค่าเริ่มต้นที่ mutable หรือ “ค่าปัจจุบัน” จะ freeze สถานะข้ามการเรียก |
*args / **kwargs | signature ยืดหยุ่น เหมาะกับการส่งต่อ | signature ไม่บอกอีกต่อไปว่า argument ไหนถูกคาดหวังจริง ๆ |
Positional-only / | library เปลี่ยนชื่อพารามิเตอร์นั้นทีหลังได้โดยไม่ทำผู้เรียกพัง | อ่านยากขึ้นเล็กน้อย ไม่คุ้นเคยสำหรับมือใหม่ |
คืน None โดยปริยาย | ทุก function มีค่าที่คืนแน่นอนและสม่ำเสมอ | บัง bug “ลืม return” ไว้เงียบ ๆ ใช้ผลลัพธ์ต่อโดยไม่ทันสังเกต |
LEGB กับ global/nonlocal ที่ต้องประกาศชัดเจน | การอ่านชื่อข้างนอกไม่ต้องมีอะไรพิเศษ | การ reassign ต้องประกาศ ลืมแล้วได้ UnboundLocalError |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ค่าเริ่มต้น
[]ให้ list เปล่าใหม่ทุกครั้งที่เรียก” ไม่จริง — list ถูกสร้างครั้งเดียวตอน statementdefรัน แล้ว object เดิมนั้นถูกใช้ซ้ำทุกครั้งที่เรียกโดยไม่ override - “
items=Noneเพียงอย่างเดียวทำให้พารามิเตอร์ optional และปลอดภัย”Noneเป็นแค่ sentinel คุณยังต้องเขียนif items is None: items = []เองภายใน body - “การอ่านตัวแปร global ภายใน function ต้องใช้ keyword
global” ไม่จริง — การอ่านไม่ต้องมีอะไรพิเศษprint(counter)ใน function ไม่ต้องประกาศอะไร แต่counter += 1ต้อง - “function ที่ไม่มี
returnstatement คือ error หรือไม่คืนอะไรเลย” มันคืนNone— object ที่ถูกต้องจริง — ทุกครั้ง โดยไม่มี exception เกิดขึ้น - “
*argsกับ**kwargsต้องใช้ชื่อนี้เป๊ะ ๆ” มีแค่*กับ**เท่านั้นที่สำคัญ ชื่อเป็นแค่ธรรมเนียม ไม่ใช่ syntax —def f(*values, **options):ทำงานเหมือนกันทุกประการ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- ค่าเริ่มต้นใช้ mutable literal (
[],{},set()) หรือการเรียกที่รันแค่ครั้งเดียว (datetime.now(),time.time(),uuid.uuid4()) หรือเปล่า? ทั้งคู่ freeze ตอนdefไม่ใช่ตอนเรียก - function reassign (ไม่ใช่แค่อ่าน) ชื่อที่มีอยู่ระดับโมดูลหรือเปล่า เช่น
count += 1หรือcache[key] = valueที่cacheเองถูก reassign? ดูว่าขาดglobal/nonlocalหรือไม่ - ทุก code path จริง ๆ แล้ว
returnค่าหรือเปล่า หรือปล่อยให้ execution ตกไปท้าย function แล้วคืนNoneเงียบ ๆ? *args/**kwargsกลืนพารามิเตอร์ที่ควรจะชัดเจนและมี documentation หรือเปล่า จนซ่อนสิ่งที่ function รับจริง ๆ ไปจากทั้งผู้อ่านและ type checker?- ถ้า function ตั้งใจให้เรียกด้วย positional เท่านั้น (เพื่อเปลี่ยนชื่อทีหลังได้) มี
/ใน signature หรือเปล่า หรือปล่อยให้เป็นแค่ธรรมเนียมกับ docstring?
🐛 หา bug ในโค้ดนี้
def add_item(item, items=[]): items.append(item) return itemsเฉลย
items=[] ถูกประเมินครั้งเดียวตอน statement def นี้รัน แล้ว list object เดียวนั้นถูกผูกกับ function และใช้ซ้ำทุกครั้งที่เรียกโดยไม่ส่ง items มา การเรียกครั้งแรกคืน ["apple"] การเรียกครั้งที่สองด้วย item อื่นไม่ได้เริ่มจาก list เปล่า — มันต่อเข้าไปใน list เดิมจากครั้งก่อน สะสมสถานะข้ามการเรียกที่ไม่เกี่ยวข้องกันแบบเงียบ ๆ แก้ด้วยรูปแบบ None sentinel: def add_item(item, items=None): items = items if items is not None else [] (หรือ if items is None: items = [] ใน body) เพื่อให้ทุกการเรียกที่ไม่ส่ง items มาได้ list ใหม่เสมอ