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

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) # OK
demo(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)) # 9
try:
power(base=3) # base is positional-only -- keyword form rejected
except TypeError as e:
print(f"power(base=3) -> TypeError: {e}")

พารามิเตอร์ที่นำหน้าด้วย * จะรวบรวม 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 จริงของฟังก์ชันที่ห่ออยู่

ค่าเริ่มต้นถูกประเมินครั้งเดียวและเก็บไว้บน 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=9

Tuple unpacking ทางซ้ายของการกำหนดค่าจะแจกจ่ายค่าที่คืนไปยังตัวแปรแยกกัน

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 + b

Python resolve ชื่อผ่านสี่ scope ตามลำดับนี้:

  1. Local — ภายใน function ปัจจุบัน
  2. Enclosing — function ที่ครอบอยู่ (สำหรับ nested function)
  3. Global — ระดับโมดูล
  4. 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 value

counter += 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()) # 1
print(increment_fixed()) # 2

callable(obj) บอกว่า obj เรียกด้วย () ได้ไหม — จริงสำหรับ function, class, และ object ใด ๆ ที่ class ของมันนิยาม __call__ ไว้ ผิดสำหรับข้อมูลธรรมดาอย่าง int หรือ str:

print(callable(len)) # True
print(callable(42)) # False
print(callable(power)) # True -- functions are callable objects too

นี่เป็นวิธีตรวจสอบเร็ว ๆ ว่า “สิ่งนี้เป็น function ที่เรียกได้จริงไหม” ก่อนจะเรียกอะไรบางอย่างที่ถูกส่งเข้ามาเป็นพารามิเตอร์ — มีประโยชน์เวลาอ่านโค้ดที่รับ callback

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}")

Playground ที่สองเป็นทัวร์ของกับดักทุกอันด้านบน แสดงให้เห็นว่าพังตั้งใจแล้วค่อยแก้

from datetime import datetime
import 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 rejected
except 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))

การเรียก 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
การอ่านชื่อค้นหาออกด้านนอกตาม LEGB; การกำหนดค่าจะเล็งไปที่ local frame เว้นแต่บอก global/nonlocal ไว้
สิ่งที่ได้ประโยชน์ต้นทุน
ค่าเริ่มต้นประเมินครั้งเดียวตอน defเร็ว — ไม่ต้องประเมินซ้ำทุกครั้งที่เรียกค่าเริ่มต้นที่ mutable หรือ “ค่าปัจจุบัน” จะ freeze สถานะข้ามการเรียก
*args / **kwargssignature ยืดหยุ่น เหมาะกับการส่งต่อsignature ไม่บอกอีกต่อไปว่า argument ไหนถูกคาดหวังจริง ๆ
Positional-only /library เปลี่ยนชื่อพารามิเตอร์นั้นทีหลังได้โดยไม่ทำผู้เรียกพังอ่านยากขึ้นเล็กน้อย ไม่คุ้นเคยสำหรับมือใหม่
คืน None โดยปริยายทุก function มีค่าที่คืนแน่นอนและสม่ำเสมอบัง bug “ลืม return” ไว้เงียบ ๆ ใช้ผลลัพธ์ต่อโดยไม่ทันสังเกต
LEGB กับ global/nonlocal ที่ต้องประกาศชัดเจนการอ่านชื่อข้างนอกไม่ต้องมีอะไรพิเศษการ reassign ต้องประกาศ ลืมแล้วได้ UnboundLocalError
  • “ค่าเริ่มต้น [] ให้ list เปล่าใหม่ทุกครั้งที่เรียก” ไม่จริง — list ถูกสร้างครั้งเดียวตอน statement def รัน แล้ว object เดิมนั้นถูกใช้ซ้ำทุกครั้งที่เรียกโดยไม่ override
  • “items=None เพียงอย่างเดียวทำให้พารามิเตอร์ optional และปลอดภัย” None เป็นแค่ sentinel คุณยังต้องเขียน if items is None: items = [] เองภายใน body
  • “การอ่านตัวแปร global ภายใน function ต้องใช้ keyword global” ไม่จริง — การอ่านไม่ต้องมีอะไรพิเศษ print(counter) ใน function ไม่ต้องประกาศอะไร แต่ counter += 1 ต้อง
  • “function ที่ไม่มี return statement คือ error หรือไม่คืนอะไรเลย” มันคืน None — object ที่ถูกต้องจริง — ทุกครั้ง โดยไม่มี exception เกิดขึ้น
  • “*args กับ **kwargs ต้องใช้ชื่อนี้เป๊ะ ๆ” มีแค่ * กับ ** เท่านั้นที่สำคัญ ชื่อเป็นแค่ธรรมเนียม ไม่ใช่ syntax — def f(*values, **options): ทำงานเหมือนกันทุกประการ
  • ค่าเริ่มต้นใช้ 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 ใหม่เสมอ

โค้ดนี้พิมพ์อะไร?
def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item("a"))
print(add_item("b"))
จะเกิดอะไรขึ้น?
counter = 0

def increment():
    counter += 1
    return counter

increment()
ค่าเริ่มต้นของพารามิเตอร์ใน Python ถูกประเมินเมื่อไหร่?
การเรียกแบบไหน raise TypeError?
def power(base, /, exponent=2):
    return base ** exponent
callable(42) คืนค่าอะไร?
โค้ดนี้พิมพ์อะไร?
def total(*args, **kwargs):
    return sum(args) + sum(kwargs.values())

numbers = [1, 2, 3]
extra = {"bonus": 10}
print(total(*numbers, **extra))