ข้ามไปยังเนื้อหา

Args, Kwargs และ Parameter

Python แยกความแตกต่างระหว่าง parameter (ชื่อที่อยู่ในนิยามของ function) กับ argument (ค่าที่ส่งเข้าไปตอนเรียกใช้จริง) Python ให้เราควบคุมได้อย่างละเอียดว่าผู้เรียกจะส่งค่าเหล่านั้นเข้ามาได้อย่างไร

โดยปกติแล้ว ทุก parameter สามารถส่งค่าแบบ positional หรือแบบระบุชื่อ keyword ก็ได้

def connect(host: str, port: int, timeout: int = 30) -> str:
return f"{host}:{port} (timeout={timeout}s)"
# Positional
connect("localhost", 5432)
# Keyword — order does not matter
connect(port=5432, host="localhost")
# Mixed — positional first, then keyword
connect("localhost", port=5432, timeout=60)

timeout มี ค่า default เป็น 30 จึงเป็นค่าที่ใส่หรือไม่ก็ได้ positional argument จะต้องมาก่อน keyword argument เสมอ ณ จุดที่เรียกใช้

ใส่ * นำหน้า parameter เพื่อรวบ positional argument จำนวนเท่าใดก็ได้เข้ามาเป็น tuple

def total(*amounts: float) -> float:
return sum(amounts)
print(total(10.0, 20.5, 5.0)) # 35.5
print(total()) # 0

sum() ของ iterable ที่ว่างเปล่าจะคืนค่า int 0 เว้นแต่จะส่ง start เข้าไป เช่น sum(amounts, 0.0)

ใส่ ** นำหน้า 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 ใด ๆ ที่อยู่ หลัง * เปล่า ๆ (หรือหลัง *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}")
send_email("[email protected]", "Hello", cc="[email protected]")
# Calling send_email("[email protected]", "Hi", "[email protected]") would raise TypeError

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 required
print(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 มีอยู่แล้วก่อนบทนี้จะแนะนำ / กับ *

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 แยกกัน” สัญลักษณ์เหมือนกัน แต่ว่าเรากำลังนิยามหรือกำลังเรียกใช้จะบอกว่ามันไปทางไหน

การรวม “รวบ” (ใน 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 ในนิยามจะต้องเป็นแบบนี้เสมอ:

  1. Positional-only (x, y, ตามด้วย /)
  2. Positional-or-keyword (x, y)
  3. *args (หรือ * เปล่า ๆ)
  4. Keyword-only (key=value หลัง *)
  5. **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))

เดโมเจาะลึก: /, unpacking, **kwargs ที่สดใหม่ และกับดัก default datetime.now()

หัวข้อที่มีชื่อว่า “เดโมเจาะลึก: /, unpacking, **kwargs ที่สดใหม่ และกับดัก default datetime.now()”

playground ด้านล่างรันหัวข้อเจาะลึกทั้งหมดข้างบน รวมถึงสองความล้มเหลวจริง ได้แก่ การเรียก positional-only parameter ผ่าน keyword และกับดักสไตล์ mutable ด้วย datetime.now() เป็นค่า default:

from datetime import datetime
import 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 * height
try:
area(width=3, height=4)
except TypeError as e:
print(type(e).__name__ + ":", e)

การอ่าน 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)"]
ลำดับ parameter: positional-only -> positional-or-keyword -> *args -> keyword-only -> **kwargs
สิ่งที่ได้ประโยชน์ต้นทุน
Positional-only (/)ผู้เขียนเปลี่ยนชื่อ parameter ทีหลังได้ โดยไม่ทำให้ผู้เรียกพังผู้เรียกอธิบายตัว call ด้วย divide(numerator=10, denominator=4) ไม่ได้
Keyword-only (*)ผู้เรียกต้องระบุชื่อ argument — อธิบายตัวเองได้ในตัว กันความผิดพลาดเรื่องลำดับต้องจำ * เพิ่มใน signature; ผู้เรียกที่ส่งแบบ positional จะได้ TypeError
forward ด้วย *args/**kwargswrapper ใช้ได้กับ 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() รันแค่ครั้งเดียว ตอนที่ statement def ทำงาน ดังนั้น 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 ในตำแหน่งที่ถูกต้องยังใช้ได้ปกติ
  • 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 ที่พิมพ์ผิดหรือมีเกินจะ raise TypeError: 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()

*args รวบ argument เข้ามาเป็น type อะไร?
parameter ที่นิยามไว้หลัง * เปล่า ๆ ใน signature ของ function คือ:
โค้ดนี้พิมพ์อะไร? def divide(numerator, denominator, /): return numerator / denominator try: print(divide(numerator=10, denominator=2)) except TypeError as e: print(type(e).__name__)
โค้ดนี้พิมพ์อะไร? def collect(**kwargs): kwargs['seen'] = True return kwargs a = collect(x=1) b = collect(y=2) print(a, b, a is b)
ทำไม def f(x, when=datetime.now()) ถึงเป็น bug ใน process ที่รันอยู่นาน?
ทำไม def wrapper(*a, **kw): return base(*a, **kw) ถึง forward call รูปแบบไหนก็ได้ที่ base รับได้อย่างถูกต้อง?