Args, Kwargs และ Parameter
Parameter และ argument
หัวข้อที่มีชื่อว่า “Parameter และ argument”Python แยกความแตกต่างระหว่าง parameter (ชื่อที่อยู่ในนิยามของ function) กับ argument (ค่าที่ส่งเข้าไปตอนเรียกใช้จริง) Python ให้เราควบคุมได้อย่างละเอียดว่าผู้เรียกจะส่งค่าเหล่านั้นเข้ามาได้อย่างไร
Positional และ keyword argument
หัวข้อที่มีชื่อว่า “Positional และ keyword argument”โดยปกติแล้ว ทุก parameter สามารถส่งค่าแบบ positional หรือแบบระบุชื่อ keyword ก็ได้
def connect(host: str, port: int, timeout: int = 30) -> str: return f"{host}:{port} (timeout={timeout}s)"
# Positionalconnect("localhost", 5432)
# Keyword — order does not matterconnect(port=5432, host="localhost")
# Mixed — positional first, then keywordconnect("localhost", port=5432, timeout=60)timeout มี ค่า default เป็น 30 จึงเป็นค่าที่ใส่หรือไม่ก็ได้
positional argument จะต้องมาก่อน keyword argument เสมอ ณ จุดที่เรียกใช้
Variadic positional args: *args
หัวข้อที่มีชื่อว่า “Variadic positional args: *args”ใส่ * นำหน้า parameter เพื่อรวบ positional argument จำนวนเท่าใดก็ได้เข้ามาเป็น tuple
def total(*amounts: float) -> float: return sum(amounts)
print(total(10.0, 20.5, 5.0)) # 35.5print(total()) # 0sum() ของ iterable ที่ว่างเปล่าจะคืนค่า int 0 เว้นแต่จะส่ง start เข้าไป เช่น sum(amounts, 0.0)
Variadic keyword args: **kwargs
หัวข้อที่มีชื่อว่า “Variadic keyword args: **kwargs”ใส่ ** นำหน้า parameter เพื่อรวบ keyword argument จำนวนเท่าใดก็ได้เข้ามาเป็น dict
def log_event(event: str, **metadata) -> None: parts = [f"event={event}"] for key, value in metadata.items(): parts.append(f"{key}={value}") print(", ".join(parts))
log_event("login", user_id=42, ip="127.0.0.1")# event=login, user_id=42, ip=127.0.0.1**kwargs เป็น dict ใหม่ในทุกการเรียก — มันไม่เคยถูกใช้ร่วมกันข้ามการเรียก แม้แต่การ mutate มันภายใน function ก็ไม่มีผลกับการเรียกครั้งถัดไป:
def collect(**kwargs): kwargs["seen"] = True return kwargs
first = collect(a=1)second = collect(b=2)print(first) # {'a': 1, 'seen': True}print(second) # {'b': 2, 'seen': True}print(first is second) # False — two different dict objectsนี่เป็นกฎคนละอันกับกับดัก mutable default argument ที่จะพูดถึงในบทนี้ต่อไป: ค่า default อย่าง items=[] จะถูกสร้างครั้งเดียวตอน definition time แล้วถูกใช้ร่วมกัน แต่ **kwargs เองถูก Python สร้างขึ้นใหม่ทุกครั้งที่เรียก ไม่ว่าจะมี default หรือไม่ก็ตาม
Parameter แบบ keyword-only
หัวข้อที่มีชื่อว่า “Parameter แบบ keyword-only”parameter ใด ๆ ที่อยู่ หลัง * เปล่า ๆ (หรือหลัง *args) จะส่งค่าได้ผ่าน keyword เท่านั้น ส่งแบบ positional ไม่ได้
วิธีนี้ทำให้ API อธิบายตัวเองได้ในตัว และป้องกันความผิดพลาดเรื่องลำดับของ argument
def send_email(to: str, subject: str, *, cc: str = "", bcc: str = "") -> None: print(f"To: {to} | Subject: {subject} | CC: {cc} | BCC: {bcc}")
# Calling send_email("[email protected]", "Hi", "[email protected]") would raise TypeErrorParameter แบบ positional-only: /
หัวข้อที่มีชื่อว่า “Parameter แบบ positional-only: /”parameter ใด ๆ ที่อยู่ ก่อน / เปล่า ๆ จะส่งค่าได้ผ่าน positional เท่านั้น ส่งแบบระบุชื่อ keyword ไม่ได้ นี่คือภาพสะท้อนของ keyword-only * และมีอยู่ด้วยเหตุผลเดียวกัน คือให้ผู้เขียน library เปลี่ยนชื่อ parameter ภายในทีหลังได้ โดยไม่ทำให้ผู้เรียกที่ไม่เคยพึ่งพาชื่อนั้นพัง
def divide(numerator, denominator, /, *, round_to=2): return round(numerator / denominator, round_to)
print(divide(10, 4)) # 2.5 — positional, as requiredprint(divide(10, 4, round_to=0)) # 2.0 — round_to is keyword-only, after *
divide(numerator=10, denominator=4)# TypeError: divide() got some positional-only arguments passed as keyword arguments: 'numerator, denominator'ทุกอย่างก่อน / เป็น positional-only ทุกอย่างหลัง * เปล่า ๆ เป็น keyword-only และอะไรที่อยู่ตรงกลาง (ไม่มี / หรือ * ล้อมรอบ) ส่งได้ทั้งสองแบบ ซึ่งก็คือพฤติกรรม default ที่ทุก parameter มีอยู่แล้วก่อนบทนี้จะแนะนำ / กับ *
Unpacking ที่ call site: * และ **
หัวข้อที่มีชื่อว่า “Unpacking ที่ call site: * และ **”syntax */** ตัวเดียวกันที่ รวบ argument ในนิยามของ function ก็ กระจาย sequence หรือ mapping ออกเป็น argument แยกกันได้ที่ call site ด้วยเช่นกัน:
def connect(host: str, port: int, timeout: int = 30) -> str: return f"{host}:{port} (timeout={timeout}s)"
args = ("localhost", 5432)kwargs = {"timeout": 60}print(connect(*args, **kwargs))# localhost:5432 (timeout=60s) — same as connect("localhost", 5432, timeout=60)*args ใน นิยาม แปลว่า “รวบ positional argument ส่วนเกินเข้าเป็น tuple ชื่อ args” *args ที่ call site แปลว่า “กระจาย iterable นี้ออกเป็น positional argument แยกกัน” สัญลักษณ์เหมือนกัน แต่ว่าเรากำลังนิยามหรือกำลังเรียกใช้จะบอกว่ามันไปทางไหน
Pattern การ forward: def wrapper(*a, **kw)
หัวข้อที่มีชื่อว่า “Pattern การ forward: def wrapper(*a, **kw)”การรวม “รวบ” (ใน signature) เข้ากับ “กระจาย” (ใน body) ทำให้ wrapper function หนึ่งรับ call รูปแบบไหนก็ได้ แล้ว forward มันต่อไปยัง function อีกตัวโดยไม่เปลี่ยนแปลง — นี่คือกลไกเดียวกับที่ wrapper ของทุก decorator ในบท Decorators ใช้เพื่อให้เข้ากันได้กับ function ที่มันห่ออยู่ไม่ว่าจะมี signature แบบไหน:
def base(a, b, *, c=10): return a + b + c
def wrapper(*a, **kw): print("forwarding", a, kw) return base(*a, **kw)
print(wrapper(1, 2, c=3))# forwarding (1, 2) {'c': 3}# 6ลำดับ parameter ทั้งหมด
หัวข้อที่มีชื่อว่า “ลำดับ parameter ทั้งหมด”ลำดับของชนิด parameter ในนิยามจะต้องเป็นแบบนี้เสมอ:
- Positional-only (
x,y, ตามด้วย/) - Positional-or-keyword (
x,y) *args(หรือ*เปล่า ๆ)- Keyword-only (
key=valueหลัง*) **kwargs
def describe( name: str, *adjectives: str, separator: str = ", ", **extras) -> str: adj_str = separator.join(adjectives) if adjectives else "no adjectives" extra_str = ", ".join(f"{k}={v}" for k, v in extras.items()) result = f"{name}: {adj_str}" if extra_str: result += f" ({extra_str})" return result
print(describe("Python"))print(describe("Python", "fast", "readable"))print(describe("Python", "fast", "readable", separator=" | "))print(describe("Python", "fast", separator=" + ", version="3.11", typed=True))Loading Python runtime (first run only)…
เดโมเจาะลึก: /, unpacking, **kwargs ที่สดใหม่ และกับดัก default datetime.now()
หัวข้อที่มีชื่อว่า “เดโมเจาะลึก: /, unpacking, **kwargs ที่สดใหม่ และกับดัก default datetime.now()”playground ด้านล่างรันหัวข้อเจาะลึกทั้งหมดข้างบน รวมถึงสองความล้มเหลวจริง ได้แก่ การเรียก positional-only parameter ผ่าน keyword และกับดักสไตล์ mutable ด้วย datetime.now() เป็นค่า default:
from datetime import datetimeimport time
# --- positional-only parameters with / ---def divide(numerator, denominator, /, *, round_to=2): return round(numerator / denominator, round_to)
print("--- positional-only / ---")print(divide(10, 4))print(divide(10, 4, round_to=0))try: divide(numerator=10, denominator=4)except TypeError as e: print(type(e).__name__ + ":", e)
# --- unpacking at call sites: * and ** ---print("\n--- unpacking at call sites ---")
def connect(host: str, port: int, timeout: int = 30) -> str: return f"{host}:{port} (timeout={timeout}s)"
args = ("localhost", 5432)kwargs = {"timeout": 60}print(connect(*args, **kwargs))
# --- **kwargs is a fresh dict every call ---print("\n--- **kwargs is a fresh dict each call ---")
def collect(**kwargs): kwargs["seen"] = True return kwargs
first = collect(a=1)second = collect(b=2)print("first:", first)print("second:", second)print("same dict object?", first is second)
# --- datetime.now() as a default is evaluated once ---print("\n--- datetime.now() default trap ---")
def log_event_broken(message, when=datetime.now()): return f"{when.isoformat()}: {message}"
first_call = log_event_broken("first")time.sleep(0.05)second_call = log_event_broken("second")print("same timestamp both calls:", first_call.split(":")[0:2] == second_call.split(":")[0:2])
def log_event_fixed(message, when=None): when = when if when is not None else datetime.now() return f"{when.isoformat()}: {message}"
fixed_first = log_event_fixed("first")time.sleep(0.05)fixed_second = log_event_fixed("second")print("fixed version differs:", fixed_first != fixed_second)
# --- forwarding pattern def wrapper(*a, **kw) ---print("\n--- forwarding pattern ---")
def base(a, b, *, c=10): return a + b + c
def wrapper(*a, **kw): print("forwarding", a, kw) return base(*a, **kw)
print(wrapper(1, 2, c=3))
# --- positional-only rejects the keyword form ---print("\n--- positional-only rejects keyword form ---")def area(width, height, /): return width * heighttry: area(width=3, height=4)except TypeError as e: print(type(e).__name__ + ":", e)Loading Python runtime (first run only)…
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”การอ่าน signature จากซ้ายไปขวาแสดงชัดเจนว่า argument ตัวไหนเป็น positional ได้, ตัวไหนเป็นได้ทั้งสองแบบ, และตัวไหนต้องระบุชื่อ — / กับ * คือตัวแบ่งสองตัวนั้น:
flowchart LR A["positional-only\n(before /)"] --> B["positional-or-keyword\n(the default, no marker)"] B --> C["*args\n(extra positionals -> tuple)"] C --> D["keyword-only\n(after * or *args)"] D --> E["**kwargs\n(extra keywords -> dict)"]
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
Positional-only (/) | ผู้เขียนเปลี่ยนชื่อ parameter ทีหลังได้ โดยไม่ทำให้ผู้เรียกพัง | ผู้เรียกอธิบายตัว call ด้วย divide(numerator=10, denominator=4) ไม่ได้ |
Keyword-only (*) | ผู้เรียกต้องระบุชื่อ argument — อธิบายตัวเองได้ในตัว กันความผิดพลาดเรื่องลำดับ | ต้องจำ * เพิ่มใน signature; ผู้เรียกที่ส่งแบบ positional จะได้ TypeError |
forward ด้วย *args/**kwargs | wrapper ใช้ได้กับ signature อะไรก็ได้ที่ function ที่ถูกห่อรับ | inspect.signature บน wrapper เพียงลำพังจะเห็นแค่ (*args, **kwargs) ซ่อน parameter จริง (ดูบท Decorators สำหรับทางแก้ด้วย functools.wraps) |
Unpacking (*/**) ที่ call site | สร้าง argument list/dict แบบ dynamic ได้ เช่นจาก config หรือ return value ของ function อื่น | ความไม่ตรงกันระหว่างโครงสร้างที่ unpack กับ signature จะพังแค่ตอนเรียกใช้ ไม่ใช่ตอนนิยาม |
Mutable default (items=[]) / default datetime.now() | ดูเหมือนจะเป็น “ค่าว่าง/ค่าปัจจุบันใหม่ทุกครั้งที่เรียก” | ถูกประเมินครั้งเดียวตอน definition time — ทุก call ที่ไม่ override จะใช้ object เดียวกันร่วมกัน (หรือค่าเดิมที่ถูกแช่แข็งไว้) |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “positional-only parameter เป็นแค่เรื่อง style ชอบไม่ชอบ ยังไงก็ส่งผ่านชื่อได้เสมอ” — ข้อเท็จจริง:
/ทำให้การส่ง parameter เหล่านั้นผ่าน keyword เป็นTypeErrorจริง ๆ ที่ call site ไม่ใช่แค่ lint warning - “
**kwargsมีพฤติกรรมเหมือน mutable default การ mutate มันจะรั่วไปถึงการเรียกครั้งถัดไป” — ข้อเท็จจริง:**kwargsเป็น dict ใหม่ที่ Python สร้างขึ้นทุกครั้งที่เรียก มีแค่ค่า default จริง ๆ (items=[]) เท่านั้นที่ถูกสร้างครั้งเดียวแล้วใช้ร่วมกัน อย่าสับสนสองอย่างนี้ - “
def f(when=datetime.now())ให้ทุก call ได้ timestamp สดใหม่” — ข้อเท็จจริง:datetime.now()รันแค่ครั้งเดียว ตอนที่ statementdefทำงาน ดังนั้นwhenจึงเป็นค่าเดียวกันที่แช่แข็งไว้ในทุก call ที่ไม่ override — bug กลุ่มเดียวกับ mutable default เพียงแต่ค่าเป็นแบบไม่ mutable ที่เห็นชัด - “
*argsและ**kwargsใน signature กับที่ call site ทำสิ่งเดียวกัน” — ข้อเท็จจริง: ใน signature มันจะ รวบ argument ส่วนเกินเข้า tuple/dict ที่ call site syntax เดียวกันนี้จะ กระจาย tuple/dict ออกเป็น argument แยกกัน — ทิศทางตรงข้ามกัน แต่สัญลักษณ์เหมือนกัน - “
TypeErrorเรื่อง positional-only argument แปลว่า function นี้ไม่รับ argument นั้นเลย” — ข้อเท็จจริง: มันยังรับค่านั้นอยู่ — แค่ไม่รับผ่านชื่อ keyword นั้นเท่านั้น การส่งแบบ positional ในตำแหน่งที่ถูกต้องยังใช้ได้ปกติ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- default argument หน้าตาเหมือน
def f(x, when=datetime.now())หรือ function call อื่นใดเป็น default (ไม่ใช่แค่[]/{}) หรือเปล่า? มันรันแค่ครั้งเดียวตอน definition time เหมือน mutable default เป๊ะ ๆ - wrapper function
def wrapper(*a, **kw)forward argument ทุกตัว ไปยัง call ที่ถูกห่อจริงหรือเปล่า (func(*a, **kw)) หรือมันทิ้งหรือสลับลำดับบางตัว? - parameter ถูก document ไว้ว่า “ต้องส่งผ่านชื่อ” แต่ signature ไม่มี
*อยู่ข้างหน้าหรือเปล่า? ไม่มีอะไรบังคับ contract นั้นจริง — ผู้เรียกยังส่งแบบ positional ได้อยู่ - code unpack dict เข้าเป็น keyword argument (
f(**config)) โดยที่ key ของconfigไม่ได้ถูกรับประกันว่าจะตรงกับชื่อ parameter ของfหรือเปล่า? key ที่พิมพ์ผิดหรือมีเกินจะ raiseTypeError: f() got an unexpected keyword argument *args/**kwargsถูกใช้ใน signature ที่ function คาดหวัง parameter ชุดคงที่และรู้แน่ชัดอยู่แล้วหรือเปล่า? parameter ที่ตั้งชื่อชัดเจน (พร้อม//*ตามความเหมาะสม) จะ document contract ได้ดีกว่า และให้ type checker จับความไม่ตรงกันได้
🐛 หา bug ในโค้ดนี้
from datetime import datetime
def create_ticket(title: str, opened_at: datetime = datetime.now()) -> dict: return {"title": title, "opened_at": opened_at}
ticket_a = create_ticket("Server down")# ... hours later, in the same long-running process ...ticket_b = create_ticket("Disk full")
print(ticket_a["opened_at"] == ticket_b["opened_at"])เฉลย
datetime.now() ในค่า default ถูกประเมิน ครั้งเดียว ตอนที่ statement def create_ticket(...) ทำงาน — ไม่ใช่ทุกครั้งที่เรียก ทุก ticket ที่ถูกสร้างโดยไม่ระบุ opened_at เองจะได้ timestamp ที่แช่แข็งไว้ตัวเดียวกันเป๊ะ ๆ ไม่ว่าเวลาจริงจะผ่านไปเท่าไหร่ระหว่าง call ใน process ที่รันอยู่นาน (web server, worker process) บรรทัดที่พิมพ์ออกมาคือ True ซึ่งแทบจะแน่นอนว่าไม่ใช่ความตั้งใจสำหรับ field ที่ชื่อ opened_at นี่คือ bug กลุ่มเดียวกับกับดัก mutable default argument เพียงแต่ default เป็นแบบไม่ mutable ที่เห็นชัด ทางแก้คือ pattern เดียวกันคือใช้ None เป็น sentinel: def create_ticket(title, opened_at=None): opened_at = opened_at if opened_at is not None else datetime.now()