Kodokon kodokon.com

Type hint และ dataclass: ใส่ชนิดข้อมูลให้ Python สมัยใหม่อย่างจริงจัง

ทำความเข้าใจว่า annotation ทำอะไรจริง ๆ ตอนรันไทม์ และดึงศักยภาพของ dataclass ออกมาให้เต็มที่ด้วย frozen, slots และ default_factory

10 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

Type annotation คือ เมตาดาต้า ไม่ใช่ข้อบังคับ ตัวแปลภาษาเก็บมันไว้ใน __annotations__ และไม่เคยตรวจสอบมันตอนรันไทม์เลย การตรวจสอบเป็นหน้าที่ของตัววิเคราะห์แบบสถิต เช่น mypy หรือ pyright ซึ่งทำงานก่อนการรัน ผลที่ตามมาโดยตรงคือ โปรแกรมที่ใส่ชนิดข้อมูลผิดก็ยังรันได้โดยไม่บ่นสักคำ - และนั่นเป็นไปตามการออกแบบ gradual typing ช่วยให้คุณค่อย ๆ ใส่ annotation ให้โค้ดเบสที่มีอยู่แล้วทีละน้อย โดยไม่ทำให้อะไรพัง

PYTHON
from typing import get_type_hints

def add(a: int, b: int) -> int:
    return a + b

print(add("py", "thon"))
print(add.__annotations__)
print(get_type_hints(add))
annotation ไม่ได้ขวางอะไรเลย การเรียกที่ "ไม่ถูกต้อง" ก็ยังสำเร็จ

ตั้งแต่ Python 3.9 เป็นต้นมา ชนิดข้อมูลในตัวเป็น generic ได้โดยตรง เขียน list[int] หรือ dict[str, float] ได้เลยโดยไม่ต้องนำเข้า typing.List ส่วนยูเนียนเขียนเป็น int | None ได้ตั้งแต่ 3.10 หากต้องการผูกชนิดของค่าที่รับเข้ากับชนิดของค่าที่คืน ให้ใช้ TypeVar - Python 3.12 ยังมีไวยากรณ์แบบกระชับอย่าง def first[T](items: list[T]) -> T ให้ด้วย จุดที่มักถูกมองข้ามคือ from __future__ import annotations ทำให้ทุก annotation กลายเป็นแบบ lazy (เก็บไว้เป็นสตริง) ซึ่งอนุญาตให้อ้างถึงคลาสที่นิยามไว้ด้านล่างได้ และลดต้นทุนตอนนำเข้าโมดูล

PYTHON
from collections.abc import Iterable, Iterator
from typing import TypeVar

T = TypeVar("T")

def dedupe(items: Iterable[T]) -> Iterator[T]:
    seen: set[T] = set()
    for item in items:
        if item not in seen:
            seen.add(item)
            yield item

print(list(dedupe([3, 1, 3, 2, 1])))
TypeVar รับประกันว่า dedupe(list[int]) คืนค่า int ออกมาจริง ๆ
PYTHON
from dataclasses import dataclass, field

@dataclass(frozen=True, slots=True)
class Point:
    x: float
    y: float

@dataclass
class Basket:
    items: list[str] = field(default_factory=list)
    total: int = 0

    def __post_init__(self) -> None:
        self.total = len(self.items)

point = Point(1.0, 2.0)
print(point, hash(point))
print(Basket(["book", "pen"]))
Point ไม่เปลี่ยนแปลง แฮชได้ และไม่มี __dict__ ประจำอินสแตนซ์

พารามิเตอร์สามตัวเปลี่ยน @dataclass ให้เป็นเครื่องมือระดับโปรดักชัน frozen=True ทำให้อินสแตนซ์ไม่เปลี่ยนแปลง และเมื่อรวมกับการเปรียบเทียบความเท่ากันที่ถูกสร้างให้ ก็ทำให้มัน แฮชได้ - ใช้เป็นคีย์ของดิกชันนารีได้ slots=True (Python 3.10+) ตัด __dict__ ประจำอินสแตนซ์ออก ใช้หน่วยความจำน้อยลง เข้าถึงแอตทริบิวต์เร็วขึ้น และการกำหนดค่าแอตทริบิวต์ที่ไม่ได้คาดไว้จะล้มเหลว สุดท้าย kw_only=True บังคับให้เรียกด้วยคีย์เวิร์ดอย่างชัดเจน ส่วน __post_init__ คือที่ที่ควรวาง invariant และฟิลด์ที่คำนวณจากฟิลด์อื่น สำหรับกลุ่มข้อมูลที่ไม่เปลี่ยนแปลงแบบเรียบง่าย NamedTuple ก็ยังสมเหตุสมผล แต่ทันทีที่มีพฤติกรรมหรือการตรวจสอบความถูกต้องเข้ามา ให้เลือก dataclass

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. จะเกิดอะไรขึ้นตอนรันไทม์เมื่อคุณเรียก add('py', 'thon') ในขณะที่ฟังก์ชันถูก annotate ไว้ว่า (a: int, b: int) -> int?
    • Python โยน TypeError ทันที
    • การเรียกทำงานตามปกติ annotation ไม่เคยถูกตรวจสอบตอนรันไทม์
    • Python ส่ง RuntimeWarning ออกมาแล้วจึงรันการเรียกนั้น
    • โมดูลปฏิเสธที่จะถูกนำเข้า
  2. ทำไม items: list[str] = [] จึงถูกห้ามใช้เป็นค่าเริ่มต้นของฟิลด์ใน dataclass?
    • เพราะ list[str] ไม่ใช่ annotation ที่ถูกต้องสำหรับฟิลด์
    • เพราะ dataclass ยอมรับได้เฉพาะฟิลด์ที่ไม่มีค่าเริ่มต้นเท่านั้น
    • เพราะค่าเริ่มต้นที่ mutate ได้นี้จะถูกใช้ร่วมกันทุกอินสแตนซ์ dataclass จึงโยน ValueError และเรียกร้องให้ใช้ field(default_factory=list)
    • เพราะค่าเริ่มต้นต้องถูกกำหนดใน __post_init__
  3. frozen=True ทำอะไรกับ dataclass?
    • อินสแตนซ์กลายเป็นไม่เปลี่ยนแปลง และเมื่อมีการเปรียบเทียบความเท่ากันที่ถูกสร้างให้ ก็แฮชได้ - ใช้เป็นคีย์ของดิกชันนารีได้
    • คลาสไม่สามารถถูกสืบทอดได้อีกต่อไป
    • ฟิลด์ต่าง ๆ กลายเป็นแอตทริบิวต์ของคลาสที่ใช้ร่วมกัน
    • annotation ถูกตรวจสอบในทุกการกำหนดค่า