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

Dicts and Sets

dict จับคู่ hashable key กับ value ใด ๆ ตั้งแต่ Python 3.7 ลำดับการแทรก key ได้รับการรับประกันและเก็บรักษาไว้

scores = {'alice': 95, 'bob': 87}
scores['carol'] = 92 # add a new key
print(scores['alice']) # 95
del scores['bob'] # remove a key
print(scores) # {'alice': 95, 'carol': 92}

การเข้าถึง key ที่ไม่มีด้วย [] จะเกิด KeyError ใช้ .get() สำหรับการอ่านที่ปลอดภัยพร้อม default ที่กำหนดได้ หรือ .setdefault() เพื่อแทรก default เมื่อ key ไม่มีอยู่ — มันจะ ไม่ เขียนทับค่าที่มีอยู่แล้ว

scores = {'alice': 95, 'bob': 87}
unknown = scores.get('dave', 0) # returns 0 — no KeyError
scores.setdefault('eve', 88) # inserts eve=88 only if absent
print(scores)
# {'alice': 95, 'bob': 87, 'eve': 88}

เมื่อ ทุก key ที่หายไปควรได้ default — ไม่ใช่แค่การเข้าถึงครั้งแรก — collections.defaultdict เขียนโค้ดน้อยกว่าการเรียก setdefault ซ้ำ ๆ:

from collections import defaultdict
counts = defaultdict(int)
for word in ['a', 'b', 'a', 'c', 'b', 'a']:
counts[word] += 1
print(dict(counts)) # {'a': 3, 'b': 2, 'c': 1}

.items() ส่งคืน pair (key, value) ที่ unpack ได้โดยตรงใน for loop

for name, score in scores.items():
print(f'{name}: {score}')

สามารถวนซ้ำด้วย .keys() (ค่าเริ่มต้น) หรือ .values() แยกกันได้เช่นกัน

.keys(), .values(), และ .items() คืนค่าเป็น view object — มันสะท้อน dict ตามสภาพ ปัจจุบัน ทุกครั้งที่คุณดูมัน แทนที่จะ freeze copy ไว้ตอนเรียก:

d = {'a': 1, 'b': 2}
keys_view = d.keys()
d['c'] = 3
print(list(keys_view)) # ['a', 'b', 'c'] — the view saw the later insert

ถ้าต้องการ snapshot ที่ freeze ไว้เพื่อวนซ้ำอย่างปลอดภัยขณะแก้ไข dict ไปด้วย ให้ครอบ view ด้วย list(...) — มัน copy key ณ ตอนนั้นครั้งเดียว

ตั้งแต่ Python 3.9 | merge dict สองตัวเข้าด้วยกันเป็น dict ใหม่ (key ของตัวหลังชนะเมื่อขัดแย้งกัน) และ |= merge dict ทางขวาเข้ากับ dict ทางซ้ายแบบใน place:

a = {'x': 1, 'y': 2}
b = {'y': 99, 'z': 3}
merged = a | b
print(merged) # {'x': 1, 'y': 99, 'z': 3} — new dict, b's y wins
print(a) # {'x': 1, 'y': 2} — a itself is untouched by |
a |= b
print(a) # {'x': 1, 'y': 99, 'z': 3} — now a is updated in place

| คือตัวแทนยุคใหม่ของ {**a, **b}; |= คือตัวแทนยุคใหม่ของ a.update(b)

เหมือน list comprehension แต่สร้าง dict ใหม่ในนิพจน์เดียว

squared = {n: n**2 for n in range(1, 6)}
# {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

key ต้อง hashable ซึ่งหมายถึงต้องมี __hash__ ที่สอดคล้องกับ __eq__ เสมอ (key สองตัวที่เท่ากันต้อง hash เท่ากัน) list ไม่มี __hash__ — เพราะมันเปลี่ยนแปลงได้ hash ของมันจึงไม่มีทางคงที่ — และ tuple ที่มี list อยู่ข้างในก็สืบทอดปัญหาเดียวกัน:

bad_key = {[1, 2]: 'value'}
# TypeError: cannot use 'list' as a dict key (unhashable type: 'list')
bad_key2 = {(1, [2, 3]): 'value'}
# TypeError: cannot use 'tuple' as a dict key (unhashable type: 'list')

str, int, float, bool, tuple ของ hashable ล้วน ๆ, และ frozenset ล้วนเป็น key ที่ปลอดภัย

collections.Counter คือ dict subclass ที่สร้างมาสำหรับนับ hashable item พร้อม arithmetic และการจัดอันดับด้วย .most_common() มาให้ในตัว:

from collections import Counter
letters = Counter('abracadabra')
print(letters) # Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
print(letters.most_common(2)) # [('a', 5), ('b', 2)]

ลำดับการแทรกได้รับการรับประกันสำหรับการวนซ้ำ (.keys(), .values(), .items(), for key in d) — แต่ dict สองตัวยังคง == กันถ้ามี key และ value ชุดเดียวกัน ไม่ว่าลำดับจะเป็นอย่างไร:

d1 = {'a': 1, 'b': 2}
d2 = {'b': 2, 'a': 1}
print(d1 == d2) # True — equality ignores insertion order

อย่าพึ่งพาลำดับเพื่อบอกว่า “dict สองตัวนี้ต่างกัน” — ใช้ == เพื่อตรวจ equality และวนซ้ำเพื่อดูลำดับ

การเปลี่ยนขนาดของ dict (เพิ่มหรือลบ key) ขณะที่ loop กำลังวนซ้ำอยู่จะ raise RuntimeError — CPython ตรวจจับการเปลี่ยนขนาดได้และปฏิเสธที่จะเดาว่าควรทำอะไรต่อ:

d = {'a': 1, 'b': 2, 'c': 3}
for key in d:
if key == 'b':
del d[key]
# RuntimeError: dictionary changed size during iteration

ให้วนซ้ำบน snapshot ของ key แทน — list(d) copy key ทั้งหมดครั้งเดียวล่วงหน้า ดังนั้นการลบออกจาก d ระหว่าง loop จึงไม่เปลี่ยนสิ่งที่ loop กำลังวนซ้ำอยู่อีกต่อไป:

d = {'a': 1, 'b': 2, 'c': 3}
for key in list(d):
if key == 'b':
del d[key]
print(d) # {'a': 1, 'c': 3}

การ reassign value ของ key ที่มีอยู่แล้ว (ไม่ใช่การเพิ่มหรือลบ) ไม่ได้เปลี่ยนขนาดของ dict จึงไม่ทำให้เกิด error นี้

การจัดการ KeyError: เลือกสไตล์เดียวแล้วใช้ให้สม่ำเสมอ

หัวข้อที่มีชื่อว่า “การจัดการ KeyError: เลือกสไตล์เดียวแล้วใช้ให้สม่ำเสมอ”

มีสามวิธีที่ถูกต้องเท่ากันในการจัดการ key ที่อาจไม่มีอยู่ — การผสมทั้งสามแบบในโค้ดเดียวกันคือสิ่งที่ทำให้โค้ด AI เชื่อถือได้ยาก:

inventory = {'apples': 10}
for item in ['apples', 'bananas']:
if item in inventory: # LBYL: check first
print(inventory[item])
else:
print('out of stock')

.get(item, default) (แนว EAFP กึ่งหนึ่ง เป็น expression เดียว) และ try: ... except KeyError: (EAFP แท้) คืออีกสองแบบ — ใช้ .get() เมื่อ default value เพียงพอแล้ว และใช้ try/except เมื่อ key ที่หายไปถือเป็นกรณีผิดปกติที่ต้องจัดการต่างจาก “ใช้ค่าแทน”

set คือ collection ที่ไม่มีลำดับของ hashable element ที่ไม่ซ้ำกัน ถูก optimize สำหรับการทดสอบสมาชิกภาพด้วย in (O(1) เฉลี่ย) และรองรับการดำเนินการ set ทางคณิตศาสตร์

a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # union: {1, 2, 3, 4, 5, 6}
print(a & b) # intersection: {3, 4}
print(a - b) # difference: {1, 2}
print(a ^ b) # symmetric diff: {1, 2, 5, 6}

ใช้ frozenset สำหรับ set ที่เปลี่ยนแปลงไม่ได้ — เพราะมันไม่เปลี่ยนแปลงเลย มันจึง hashable ในตัวเอง จึงใช้เป็น dict key หรืออยู่ใน set อื่นได้:

fs = frozenset([1, 2, 3])
group_lookup = {fs: 'group-a'} # a plain set could never do this
fs.add(4) # AttributeError: 'frozenset' object has no attribute 'add'
# --- dict safe access ---
scores = {'alice': 95, 'bob': 87}
scores['carol'] = 92
print('scores:', scores)
unknown = scores.get('dave', 0)
print('dave score:', unknown)
scores.setdefault('eve', 88)
print('after setdefault:', scores)
for name, score in scores.items():
print(f' {name}: {score}')
# --- dict comprehension ---
squared = {n: n**2 for n in range(1, 6)}
print('squared:', squared)
# --- set algebra ---
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print('union:', sorted(a | b))
print('intersection:', sorted(a & b))
print('difference:', sorted(a - b))
print('symmetric diff:', sorted(a ^ b))

playground ด้านล่างรันหัวข้อความลึกด้านบนทั้งหมด — การ merge ด้วย |/|=, live view, defaultdict, ข้อตกลงเรื่อง hashability, Counter, ลำดับการแทรกเทียบกับ equality, RuntimeError จากการแก้ไขขณะวนซ้ำ, และ frozenset:

# --- dict | and |= (3.9+) ---
a = {'x': 1, 'y': 2}
b = {'y': 99, 'z': 3}
merged = a | b
print('merged (new dict):', merged)
print('a unchanged:', a)
a |= b
print('a after |=:', a)
# --- dict views are live, not a snapshot ---
d = {'a': 1, 'b': 2}
keys_view = d.keys()
print('view before:', list(keys_view))
d['c'] = 3
print('view after adding c:', list(keys_view))
# --- d[k] vs .get vs .setdefault vs defaultdict ---
scores = {'alice': 95}
try:
scores['bob']
except KeyError as e:
print('KeyError:', e)
print('get missing:', scores.get('bob', 0))
scores.setdefault('bob', 80)
print('after setdefault:', scores)
scores.setdefault('alice', 999)
print('setdefault does not overwrite existing:', scores)
from collections import defaultdict
counts = defaultdict(int)
for word in ['a', 'b', 'a', 'c', 'b', 'a']:
counts[word] += 1
print('defaultdict counts:', dict(counts))
# --- hashability contract: __hash__ + __eq__ ---
try:
bad_key = {[1, 2]: 'value'}
except TypeError as e:
print('TypeError:', e)
try:
bad_key2 = {(1, [2, 3]): 'value'}
except TypeError as e:
print('TypeError:', e)
# --- Counter ---
from collections import Counter
letters = Counter('abracadabra')
print('Counter:', letters)
print('most common 2:', letters.most_common(2))
# --- insertion order since 3.7; dict equality ignores order ---
d1 = {'a': 1, 'b': 2}
d2 = {'b': 2, 'a': 1}
print('d1 == d2 (order ignored):', d1 == d2)
print('d1 keys order:', list(d1.keys()))
print('d2 keys order:', list(d2.keys()))
# --- mutate a dict while iterating over it -> RuntimeError ---
d3 = {'a': 1, 'b': 2, 'c': 3}
try:
for key in d3:
if key == 'b':
del d3['b']
except RuntimeError as e:
print('RuntimeError:', e)
# Safe pattern: iterate over a snapshot of the keys
d4 = {'a': 1, 'b': 2, 'c': 3}
for key in list(d4):
if key == 'b':
del d4['b']
print('safe delete result:', d4)
# --- frozenset: hashable, usable as dict key or set element ---
fs = frozenset([1, 2, 3])
group_lookup = {fs: 'group-a'}
print('frozenset as key:', group_lookup)
try:
fs.add(4)
except AttributeError as e:
print('AttributeError:', e)
# --- KeyError handling: try/except vs .get vs 'in' check ---
inventory = {'apples': 10}
for item in ['apples', 'bananas']:
if item in inventory:
print(f'{item}: {inventory[item]}')
else:
print(f'{item}: out of stock')
flowchart LR
  subgraph Dict["dict buckets (hash table)"]
    H0["hash('alice') % n -> bucket 0"] --> KV0["'alice': 95"]
    H1["hash('bob') % n -> bucket 1"] --> KV1["'bob': 87"]
    H2["hash('carol') % n -> bucket 2"] --> KV2["'carol': 92"]
  end
  Lookup["scores['bob']"] -->|hash then compare with ==| H1
dict จะ hash key เพื่อหา bucket แล้วยืนยันด้วย == — นี่คือเหตุผลที่ key ต้อง hashable และต้องมี equality ที่มีความหมาย
สิ่งที่ได้ประโยชน์ต้นทุน
lookup ด้วย key ใน dictอ่าน/เขียนเฉลี่ย O(1) ไม่ว่าขนาดจะใหญ่แค่ไหนทุก key ต้อง hashable — ใช้ list เป็น key ไม่ได้
dict view (.keys()/.items())ไม่ copy — สะท้อน dict ที่เป็น live เสมอการแก้ไขขนาด dict ขณะวนซ้ำ view จะ raise RuntimeError
defaultdictไม่ต้องเขียน if key not in dการอ่านเฉย ๆ (d[missing_key]) จะ สร้าง key นั้นด้วยค่า default แบบเงียบ ๆ
membership ของ set / frozensetตรวจ in เฉลี่ย O(1)ไม่มีลำดับ — index ไม่ได้ ไม่รับประกันลำดับการวนซ้ำ
frozenset เทียบกับ sethashable — ใช้เป็น dict key หรือ set element ได้เปลี่ยนแปลงไม่ได้ — เรียก .add()/.remove() หลังสร้างไม่ได้
  • “a.setdefault(k, v) ตั้งค่าเป็น v เสมอ” — ความจริง: มันตั้งค่า v เฉพาะเมื่อ k ไม่มีอยู่; ถ้า k มีอยู่แล้ว ค่าเดิมจะถูกเก็บไว้และคืนค่ากลับมา
  • “d.keys() คือ list ที่เป็น snapshot คงที่ ณ ตอนที่เรียก” — ความจริง: มันคือ live view — การแทรกในภายหลังลง d จะเห็นได้ผ่าน view object เดิม
  • “object ใด ๆ ก็ใช้เป็น dict key ได้” — ความจริง: มีแค่ object ที่ hashable เท่านั้น — container ที่เปลี่ยนแปลงได้ (list และ tuple ที่มีมันอยู่ข้างใน) จะ raise TypeError
  • “dict ที่มี key ชุดเดียวกันแต่แทรกคนละลำดับคือ dict ที่ต่างกัน” — ความจริง: == ไม่สนใจลำดับการแทรก มีแค่ลำดับการวนซ้ำเท่านั้นที่ต่างกัน
  • “ลบ key จาก dict ขณะวนซ้ำได้ ตราบใดที่ระมัดระวัง” — ความจริง: การเปลี่ยนขนาดของ dict ขณะวนซ้ำจะ raise RuntimeError เสมอ ไม่ว่าจะระวังแค่ไหน ให้วนซ้ำบน list(d) แทน
  • loop ทำ for key in some_dict: แล้ว del some_dict[key] (หรือเพิ่ม key ใหม่) ข้างใน loop หรือเปล่า? นั่นคือ RuntimeError: dictionary changed size during iteration ที่รับประกันว่าจะเกิด — มองหา list(some_dict) ในบรรทัด for แทน
  • โค้ดใช้ dict[key] ที่ key อาจไม่มีอยู่ โดยไม่มี try/except, .get(), หรือการเช็ค in ล้อมรอบหรือเปล่า? นั่นคือ KeyError ที่ไม่มีการป้องกัน รอวันที่ cache miss หรือ input ผิดรูปแบบ
  • list หรือ tuple ที่มี list อยู่ข้างใน ถูกใช้ (หรือกำลังจะถูกใช้) เป็น dict key หรือ set element หรือเปล่า? นั่น raise TypeError: unhashable type ทันที
  • โค้ดสันนิษฐานว่าลำดับการวนซ้ำของ dict สะท้อน เวลาที่ value ถูกอัปเดตล่าสุด แทนที่จะเป็น เวลาที่ key ถูกแทรกครั้งแรก หรือเปล่า? การ reassign value ของ key ที่มีอยู่แล้วไม่ได้ย้ายตำแหน่งมันในลำดับการวนซ้ำ
  • defaultdict(int) (หรือคล้ายกัน) ถูกอ่านด้วย d[key] ในบริบทที่ “key นี้มีอยู่จริงหรือไม่” สำคัญหรือเปล่า? การอ่านเฉย ๆ จะสร้าง key นั้นด้วยค่า default แบบเงียบ ๆ — การอ่านนั้นไม่ได้ปราศจาก side effect

🐛 หา bug ในโค้ดนี้

def purge_expired(cache, now):
for key, expires_at in cache.items():
if expires_at < now:
del cache[key]
return cache
data = {'a': 1, 'b': 5, 'c': 2}
print(purge_expired(data, now=3))
เฉลย

การลบ key ออกจาก cache ขณะที่ loop for กำลังวนซ้ำ cache.items() อยู่ ทำให้ขนาดของ dict เปลี่ยนกลางคัน CPython ตรวจจับสิ่งนี้และ raise RuntimeError: dictionary changed size during iteration — ตรวจสอบแล้วบน Python 3.14 traceback ชี้ไปที่บรรทัด for เอง ไม่ใช่ del วิธีแก้คือวนซ้ำบน snapshot ของ items แทน: for key, expires_at in list(cache.items()): หรือรวบรวม key ที่จะลบไว้ก่อน แล้วค่อยลบทีหลังในรอบที่สองหลัง loop จบ

scores.get('dave', 0) คืนค่าอะไรเมื่อ 'dave' ไม่อยู่ใน dict?
scores.setdefault('eve', 88) ทำอะไรเมื่อ 'eve' มีอยู่ใน scores แล้ว?
โค้ดนี้พิมพ์อะไร? d = {'a': 1} view = d.keys() d['b'] = 2 print(list(view))
โค้ดนี้ raise อะไร? d = {'a': 1, 'b': 2} for key in d: if key == 'a': del d[key]
ข้อใดใช้เป็น dict key ได้โดยไม่ raise TypeError?
{'a': 1, 'b': 2} == {'b': 2, 'a': 1} ได้ผลลัพธ์เป็นอะไร?