# คลาสและ OOP ใน Python: __init__, self, เมธอด, การสืบทอด และ dataclass

URL: https://skillaria.top/th/guides/python/python-classes

OOP ใน Python ใช้คลาสเป็นแม่แบบ โดยคลาสกำหนดฟิลด์และเมธอด ส่วนออบเจกต์หรืออินสแตนซ์สร้างจากการเรียกใช้คลาส โดย __init__ กำหนดค่าเริ่มต้นให้ฟิลด์ และ self ชี้ไปยังออบเจกต์ปัจจุบัน การสืบทอดช่วยนำพฤติกรรมของคลาสแม่ไปใช้และแก้ไขบางเมธอดได้ ส่วนตัวตกแต่ง @dataclass สร้าง __init__, __repr__ และ __eq__ ให้อัตโนมัติสำหรับคลาสที่เก็บข้อมูลเป็นหลัก

คลาสใน Python เปรียบเหมือนแบบพิมพ์เขียว มันอธิบายว่าอ็อบเจกต์จะเก็บข้อมูลอะไรบ้างและทำอะไรกับข้อมูลนั้นได้ ส่วนอ็อบเจกต์ (อินสแตนซ์) คือผลิตภัณฑ์ชิ้นหนึ่งที่สร้างตามแบบพิมพ์เขียวนั้น OOP ใน Python อาศัยสามสิ่งหลัก ได้แก่ คลาสที่มีเมธอด การสืบทอด และเมธอดพิเศษอย่าง `__init__` และ `__repr__` บทความนี้จะไล่ทีละหัวข้อด้วยตัวอย่างเดียวกันตลอดทั้งบท คือการบันทึกค่าใช้จ่าย

## ทำไมต้องใช้คลาสเมื่อมีดิกชันนารีอยู่แล้ว

ข้อมูลสามารถเก็บในดิกชันนารีได้ เช่น `{"title": "กาแฟ", "amount": 250}` แต่ปัญหาคือดิกชันนารีไม่รู้ว่าคีย์ใดถูกต้อง การพิมพ์ผิดอย่าง `expense.get("amout", 0)` จะไม่แจ้งข้อผิดพลาดใด ๆ แต่คืนค่าศูนย์ และรายงานจะเริ่มผิดโดยไม่มีใครรู้

ออบเจกต์ของคลาสมีฟิลด์ที่ประกาศไว้ล่วงหน้า การเข้าถึงแอตทริบิวต์ที่ไม่มีอยู่จะเกิด `AttributeError` ทันที และ Python สมัยใหม่ยังแนะนำชื่อที่ถูกต้องให้ด้วย นอกจากนี้ คลาสยังรวมข้อมูลกับการกระทำที่เกี่ยวข้องไว้ในที่เดียว เช่น รายจ่ายรู้วิธีคำนวณยอดรวมพร้อมภาษีด้วยตัวเอง และรายงานคำนวณผลรวมเองได้

## วิธีสร้างคลาสใน Python

ประกาศคลาสด้วยคำว่า `class` ชื่อคลาสตามธรรมเนียม PEP 8 ใช้รูปแบบ CamelCase ตัวอย่างคลาสที่ง่ายที่สุด:

```
class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

coffee = Expense("กาแฟ", 250)
taxi = Expense("แท็กซี่", 300)
print(coffee.title, coffee.amount)   # กาแฟ 250
```

การเรียก `Expense("กาแฟ", 250)` สร้างอินสแตนซ์ใหม่ `coffee` และ `taxi` เป็นอ็อบเจกต์สองตัวจากคลาสเดียวกันที่มีค่าฟิลด์ของตัวเอง ตรวจสอบได้ด้วย `isinstance(coffee, Expense)` ซึ่งจะได้ `True`

### ตัวสร้าง __init__: การกำหนดค่าเริ่มต้น

หลายคนเรียก `__init__` ว่าตัวสร้าง (constructor) แต่ที่ถูกต้องกว่าคือตัวกำหนดค่าเริ่มต้น (initializer) เพราะอ็อบเจกต์ถูกสร้างโดยเมธอด `__new__` ส่วน `__init__` เรียกต่อจากนั้นเพื่อนำอาร์กิวเมนต์ไปใส่ในฟิลด์ และไม่จำเป็นต้องคืนค่าอะไรออกมา (ต้องคืน `None` เท่านั้น)

ใน `__init__` สามารถตรวจสอบข้อมูลที่รับเข้าได้ เพื่อไม่ให้มีอ็อบเจกต์ที่ผิดพลาดหลุดเข้าไปในโปรแกรม:

```
class Expense:
    def __init__(self, title, amount):
        if amount < 0:
            raise ValueError("จำนวนเงินติดลบไม่ได้")
        self.title = title
        self.amount = amount
```

### self ใน Python คืออะไร

`self` คือการอ้างอิงถึงอ็อบเจกต์ที่เมธอดกำลังทำงานอยู่ เมื่อเขียน `coffee.with_tax()` Python จะเรียก `Expense.with_tax(coffee)` โดยส่งอ็อบเจกต์เข้าไปเป็นอาร์กิวเมนต์ตัวแรกให้โดยอัตโนมัติ ดังนั้นในการประกาศเมธอด `self` จะอยู่ตัวแรก แต่ตอนเรียกใช้ไม่ต้องเขียน

`self` ไม่ใช่คำสงวนของภาษา แต่เป็นธรรมเนียม จะตั้งชื่ออื่นก็ได้ทางเทคนิค แต่ไม่มีใครทำเช่นนั้น ข้อผิดพลาดที่มือใหม่พบบ่อยคือลืมใส่ `self` ในการประกาศเมธอด ซึ่งจะเกิด `TypeError: Expense.with_tax() takes 0 positional arguments but 1 was given`

## เมธอดของคลาสและเมธอดพิเศษ

เมธอดคือฟังก์ชันที่ประกาศไว้ในคลาส และเข้าถึงฟิลด์ของอ็อบเจกต์ผ่าน `self`:

```
class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    def with_tax(self, rate=0.2):
        return self.amount * (1 + rate)

    def __repr__(self):
        return f"Expense({self.title!r}, {self.amount})"

coffee = Expense("กาแฟ", 250)
print(coffee.with_tax())   # 300.0
print(coffee)              # Expense('กาแฟ', 250)
```

หากไม่มี `__repr__` การพิมพ์อ็อบเจกต์จะได้ `<__main__.Expense object at 0x7f...>` ซึ่งไม่มีประโยชน์ต่อการดีบัก เมธอดที่มีขีดล่างสองตัวทั้งสองด้าน เรียกว่าเมธอดพิเศษ (dunder method) ซึ่ง Python จะเรียกให้เองในสถานการณ์ที่เหมาะสม `__str__` ควบคุมการแสดงผลสำหรับผู้ใช้ `__eq__` ควบคุมการเปรียบเทียบด้วย `==` และ `__len__` ควบคุม `len(obj)`

### @classmethod และ @staticmethod

เมธอดปกติทำงานกับอินสแตนซ์ เมธอดที่มีตัวตกแต่ง `@classmethod` จะได้รับคลาสเองเป็นอาร์กิวเมนต์แรก (`cls`) ซึ่งมักใช้เป็นตัวสร้างทางเลือก ส่วน `@staticmethod` ไม่ได้รับทั้งอ็อบเจกต์หรือคลาส เป็นเพียงฟังก์ชันธรรมดาที่เกี่ยวข้องกับคลาสในเชิงตรรกะ

```
class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    @classmethod
    def from_csv(cls, line):
        title, amount = line.split(";")
        return cls(title, float(amount))

    @staticmethod
    def is_valid_amount(value):
        return value >= 0

e = Expense.from_csv("มื้อเที่ยง;520")
print(e.amount)                    # 520.0
print(Expense.is_valid_amount(-1)) # False
```

### ตัวทำลาย: __del__

Python มีเมธอด `__del__` ที่ถูกเรียกก่อนอ็อบเจกต์จะถูกลบโดยตัวเก็บขยะ (garbage collector) อย่าพึ่งพามันในการคืนทรัพยากร เพราะไม่รับประกันเวลาที่จะถูกเรียก สำหรับไฟล์และการเชื่อมต่อ ให้ใช้ตัวจัดการบริบท `with` หรือเมธอด `close()` โดยตรง

## แอตทริบิวต์ของคลาสและแอตทริบิวต์ของอินสแตนซ์

ฟิลด์ที่กำหนดด้วย `self.name = ...` เป็นของอ็อบเจกต์ตัวใดตัวหนึ่ง ส่วนตัวแปรที่ประกาศในเนื้อหาของคลาสโดยตรงเป็นแอตทริบิวต์ของคลาส ซึ่งทุกอินสแตนซ์ใช้ร่วมกัน ค่าคงที่ของคลาสมักถูกเก็บในลักษณะนี้

```
class Expense:
    CURRENCY = "RUB"          # แอตทริบิวต์ของคลาส ค่าคงที่

    def __init__(self, title, amount):
        self.title = title    # แอตทริบิวต์ของอินสแตนซ์
        self.amount = amount

a = Expense("กาแฟ", 250)
b = Expense("ชา", 150)
print(a.CURRENCY, Expense.CURRENCY)   # RUB RUB
```

กับดักที่อันตรายคือแอตทริบิวต์ของคลาสที่เป็นอ็อบเจกต์แก้ไขได้ หากประกาศ `rows = []` ในเนื้อหาคลาสแล้วเพิ่มข้อมูลผ่าน `self.rows.append(...)` ลิสต์นั้นจะถูกใช้ร่วมกันทุกอ็อบเจกต์ ดังนั้นฟิลด์ที่แก้ไขได้ควรสร้างใน `__init__` เสมอ

ดูฟิลด์ของอ็อบเจกต์ได้ด้วย `vars(obj)` หรือ `obj.__dict__` และอ่านแอตทริบิวต์จากชื่อในสตริงด้วย `getattr(obj, "amount")`

## property ของคลาส: @property

Property คือเมธอดที่อ่านจากภายนอกเหมือนฟิลด์ธรรมดา ไม่ต้องใส่วงเล็บ เหมาะกับค่าที่คำนวณจากข้อมูลอื่น ยอดรวมของรายงานไม่จำเป็นต้องเก็บแยก และไม่มีทางลืมอัปเดต

```
class Report:
    def __init__(self):
        self.rows = []

    def add(self, expense):
        self.rows.append(expense)

    @property
    def total(self):
        return sum(e.amount for e in self.rows)

r = Report()
r.add(Expense("กาแฟ", 250))
r.add(Expense("แท็กซี่", 300))
print(r.total)   # 550
```

หากต้องควบคุมการเขียนค่า ให้เพิ่มตัวตั้งค่า (setter) ด้วย `@total.setter` หากไม่มี setter การกำหนดค่า `r.total = 0` จะเกิด `AttributeError`

เรื่องการห่อหุ้มข้อมูล: จริง ๆ แล้วใน Python ไม่มีฟิลด์ส่วนตัวอย่างเคร่งครัด ขีดล่างหนึ่งตัว (`_cache`) เป็นข้อตกลงว่า "อย่าแตะจากภายนอก" ส่วนขีดล่างสองตัว (`__secret`) จะเปลี่ยนชื่อภายใน (name mangling) เพื่อป้องกันไม่ให้คลาสลูกเผลอทับฟิลด์นั้น

## การสืบทอดคลาสใน Python

การสืบทอดช่วยสร้างคลาสลูกจากคลาสแม่ คลาสลูกได้รับฟิลด์และเมธอดทั้งหมด และสามารถแก้ไขเมธอดที่ต้องการได้ ระบุคลาสแม่ไว้ในวงเล็บ

```
class MonthlyReport(Report):
    def __init__(self, month):
        super().__init__()        # เรียก __init__ ของคลาสแม่
        self.month = month

    def add(self, expense):       # แก้ไขเมธอด
        print(f"[{self.month}] + {expense}")
        super().add(expense)      # คงพฤติกรรมเดิมของคลาสแม่ไว้

m = MonthlyReport("ตุลาคม")
m.add(Expense("กาแฟ", 250))   # [ตุลาคม] + Expense('กาแฟ', 250)
print(m.total)                # 250 — property ถูกสืบทอดมาด้วย
```

`super()` คืนออบเจกต์ตัวกลางสำหรับเรียกเมธอดของคลาสแม่ ข้อผิดพลาดคลาสสิกคือลืมเรียก `super().__init__()` ใน `__init__` ของคลาสลูก ทำให้ฟิลด์ `rows` ไม่ถูกสร้าง และการเรียก `add` ครั้งแรกจะเกิด `AttributeError`

ตรวจความสัมพันธ์ระหว่างคลาสได้ด้วย `issubclass(MonthlyReport, Report)` คลาสทุกตัวใน Python 3 สืบทอดจาก `object` โดยปริยาย จึงได้เมธอดพื้นฐาน เช่น `__repr__` และ `__eq__` มา Python รองรับการสืบทอดหลายคลาสด้วย (`class C(A, B)`) และดูลำดับการค้นหาเมธอดได้จาก `C.__mro__`

### คลาสนามธรรม (abstract class) ใน Python

คลาสนามธรรมกำหนดอินเทอร์เฟซ คือเมธอดที่คลาสลูกทุกตัวต้องเขียนขึ้นเอง และไม่สามารถสร้างอินสแตนซ์จากมันได้โดยตรง ใช้โมดูล `abc` ในการทำเช่นนี้:

```
from abc import ABC, abstractmethod

class Exporter(ABC):
    @abstractmethod
    def export(self, report): ...

class CsvExporter(Exporter):
    def export(self, report):
        return "\n".join(f"{e.title};{e.amount}" for e in report.rows)

Exporter()   # TypeError: Can't instantiate abstract class Exporter ...
```

คลาสลูกที่ไม่ได้เขียนเมธอดนามธรรมครบทุกตัว จะยังคงเป็นนามธรรมและจะเกิดข้อผิดพลาดเดียวกันเมื่อสร้างอ็อบเจกต์

## การประกอบ (composition) แทนลำดับชั้นที่ลึก

เมื่อเข้าใจการสืบทอดแล้ว มักอยากสร้างลำดับชั้นยาว เช่น รายจ่ายพื้นฐาน รายจ่ายที่เป็นเงิน รายจ่ายเป็นบาท ไปจนถึงห้าระดับ แต่ใน Python แนวทางนี้พบได้น้อย การสืบทอดอธิบายความสัมพันธ์แบบ "เป็น" ส่วนการประกอบอธิบายแบบ "มี" รายงานไม่ได้เป็นลิสต์ของรายจ่าย แต่มีลิสต์ของรายจ่ายอยู่ในฟิลด์ `rows`

หลักปฏิบัติคือ หากต้องการใช้ความสามารถของอ็อบเจกต์อื่น ให้เก็บมันไว้ในฟิลด์ ไม่ต้องสืบทอด สำหรับข้อมูลมักพอกับ dataclass และตรรกะสามารถแยกเป็นฟังก์ชันธรรมดาได้

## dataclass ใน Python: คลาสที่ไม่มีโค้ดซ้ำ

สำหรับคลาสที่เก็บข้อมูลเป็นหลัก การเขียน `__init__` `__repr__` และ `__eq__` ด้วยมือเป็นเรื่องน่าเบื่อ ตัวตกแต่ง `@dataclass` จากโมดูล `dataclasses` จะสร้างเมธอดเหล่านี้ให้จากคำอธิบายชนิดของฟิลด์ (annotation):

```
from dataclasses import dataclass, field

@dataclass
class Expense:
    title: str
    amount: float
    tags: list[str] = field(default_factory=list)

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("จำนวนเงินติดลบไม่ได้")

e = Expense("กาแฟ", 250)
print(e)                           # Expense(title='กาแฟ', amount=250, tags=[])
print(e == Expense("กาแฟ", 250))   # True
```

การตรวจค่าที่เคยเขียนไว้ใน `__init__` ย้ายไปไว้ใน `__post_init__` ซึ่งถูกเรียกหลังจากตัวกำหนดค่าที่ dataclass สร้างให้ทำงานเสร็จ ค่าเริ่มต้นที่แก้ไขได้ต้องกำหนดด้วย `field(default_factory=list)` เท่านั้น การเขียน `tags: list = []` dataclass จะไม่ยอมรับและแสดง `ValueError`

### frozen และ slots

พารามิเตอร์ `frozen=True` ทำให้อ็อบเจกต์แก้ไขไม่ได้ และ `slots=True` (ตั้งแต่ Python 3.10) สร้าง `__slots__` ซึ่งประหยัดหน่วยความจำ การพยายามแก้ฟิลด์ของอ็อบเจกต์ที่ frozen จะเรียก `FrozenInstanceError` หากต้องการค่าใหม่ ให้สร้างสำเนาขึ้นมา:

```
from dataclasses import dataclass, replace

@dataclass(frozen=True, slots=True)
class Expense:
    title: str
    amount: float

e = Expense("กาแฟ", 250)
# e.amount = 300   -> dataclasses.FrozenInstanceError
e2 = replace(e, amount=300)
print(e, e2)   # Expense(title='กาแฟ', amount=250) Expense(title='กาแฟ', amount=300)
```

ตั้งแต่ Python 3.13 มีฟังก์ชันทั่วไป `copy.replace()` ที่ทำงานแบบเดียวกัน อ็อบเจกต์ dataclass ที่ frozen สามารถนำไปใส่ในเซตหรือใช้เป็นคีย์ของดิกชันนารีได้ เพราะมีเมธอด `__hash__`

## ของใหม่เกี่ยวกับคลาส: Self, @override, __slots__

สำหรับการกำหนดชนิดของคลาส มีเครื่องมือที่มีประโยชน์สองอย่าง `typing.Self` (Python 3.11, PEP 673) หมายถึง "จะคืนอ็อบเจกต์ของคลาสเดียวกัน" ซึ่งใช้ได้แม้ในเมธอดที่สืบทอดมา ส่วนตัวตกแต่ง `@override` (Python 3.12, PEP 698) ระบุว่าเมธอดนั้นต้องแทนที่เมธอดของคลาสแม่ หากสะกดชื่อผิด mypy หรือ pyright จะแจ้งว่าไม่มีเมธอดฐานชื่อนั้น

```
from typing import Self, override

class Report:
    def __init__(self):
        self.rows = []

    def add(self, e) -> Self:
        self.rows.append(e)
        return self              # เรียกต่อกันเป็นสายได้

class MonthlyReport(Report):
    @override
    def add(self, e) -> Self:
        return super().add(e)
```

ใน Python 3.15 มีการยกเลิกข้อจำกัดเฉพาะกรณีหนึ่ง คือคลาสลูกของ `tuple` สามารถมี `__slots__` ที่ไม่ว่างได้ และสามารถระบุ `__dict__` กับ `__weakref__` ใน `__slots__` ของคลาสใดก็ได้ ส่วนใน 3.14 การประกาศแบบนี้ในคลาสลูกของทูเพิลจะเกิด `TypeError: nonempty __slots__ not supported for subtype of 'tuple'`

## ข้อผิดพลาดที่พบบ่อยกับคลาส

| ข้อผิดพลาด | อาการ | วิธีแก้ |
| --- | --- | --- |

| ลืม `self` ในเมธอด | `TypeError: ... takes 0 positional arguments but 1 was given` | พารามิเตอร์ตัวแรกของเมธอดต้องเป็น `self` เสมอ |

| ฟิลด์ใน `__init__` ไม่มี `self.` | ค่าหายไปหลังจาก `__init__` | เขียน `self.amount = amount` |

| ใช้ลิสต์เป็นแอตทริบิวต์ของคลาส | ข้อมูลไหลข้ามอ็อบเจกต์ | สร้างลิสต์ใน `__init__` หรือใช้ `default_factory` |

| ไม่เรียก `super().__init__()` | `AttributeError` ที่ฟิลด์ของคลาสแม่ | เรียกตัวกำหนดค่าของคลาสแม่ |

| เรียก property ด้วยวงเล็บ | `TypeError: 'int' object is not callable` | อ่าน property โดยไม่มีวงเล็บ: `r.total` |

## สรุป

OOP ใน Python เริ่มจากเรื่องง่าย ๆ คือคลาสกำหนดฟิลด์และเมธอด `__init__` กำหนดค่าเริ่มต้น และ `self` ชี้ไปยังอ็อบเจกต์ปัจจุบัน การสืบทอดกับ `super()` ช่วยขยายพฤติกรรม คลาสนามธรรมกำหนดอินเทอร์เฟซ และ `@property` ทำให้มีฟิลด์ที่คำนวณได้ สำหรับคลาสที่เก็บข้อมูลให้ใช้ `@dataclass` และสำหรับลำดับชั้นที่ซับซ้อนให้ใช้การประกอบแทนการสืบทอด หัวข้อนี้ถูกสอนแบบลงมือทำในคอร์สเรื่อง Python โดยใช้โปรแกรมบันทึกค่าใช้จ่ายเป็นตัวอย่าง

## FAQ

**คลาสต่างจากอ็อบเจกต์ใน Python อย่างไร?**

คลาสคือคำอธิบายชนิดข้อมูลว่ามีฟิลด์และเมธอดอะไรบ้าง ส่วนอ็อบเจกต์หรืออินสแตนซ์ถูกสร้างขึ้นเมื่อเรียกคลาส เช่น Expense("กาแฟ", 250) และเก็บค่าจริงของฟิลด์ไว้

**ต้องเขียน __init__ ในคลาสทุกครั้งหรือไม่?**

ไม่จำเป็น หากไม่ได้ประกาศ __init__ จะใช้ตัวที่สืบทอดมาจาก object ซึ่งไม่รับอาร์กิวเมนต์ ส่วน dataclass จะสร้างให้เอง

**__str__ ต่างจาก __repr__ อย่างไร?**

__repr__ คือการแทนค่าที่ไม่กำกวม สำหรับนักพัฒนาและการดีบัก ส่วน __str__ คือข้อความที่อ่านง่ายสำหรับผู้ใช้ในการ print และ f-string หากไม่มี __str__ Python จะใช้ __repr__ แทน

**เมื่อใดควรใช้ dataclass และเมื่อใดควรใช้คลาสธรรมดา?**

dataclass เหมาะเมื่อคลาสเก็บข้อมูลเป็นหลักและต้องการ __init__ __repr__ และการเปรียบเทียบที่พร้อมใช้ ส่วนคลาสธรรมดาเหมาะเมื่อการกำหนดค่าซับซ้อนหรือพฤติกรรมสำคัญกว่าข้อมูล

**ใน Python มีฟิลด์ส่วนตัวหรือไม่?**

ไม่มีการป้องกันอย่างเคร่งครัด ขีดล่างหนึ่งตัวที่ต้นชื่อเป็นข้อตกลงว่า "ไม่ใช่สำหรับใช้ภายนอก" ส่วนขีดล่างสองตัวจะเปลี่ยนชื่อภายในเพื่อป้องกันการทับจากคลาสลูก


---
Source: Skillaria.Top (https://skillaria.top)
