คลาสและ OOP ใน Python: __init__, self, เมธอด, การสืบทอด และ dataclass
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 โดยใช้โปรแกรมบันทึกค่าใช้จ่ายเป็นตัวอย่าง
คำถามที่พบบ่อย
คลาสต่างจากอ็อบเจกต์ใน Python อย่างไร?
คลาสคือคำอธิบายชนิดข้อมูลว่ามีฟิลด์และเมธอดอะไรบ้าง ส่วนอ็อบเจกต์หรืออินสแตนซ์ถูกสร้างขึ้นเมื่อเรียกคลาส เช่น Expense("กาแฟ", 250) และเก็บค่าจริงของฟิลด์ไว้
ต้องเขียน __init__ ในคลาสทุกครั้งหรือไม่?
ไม่จำเป็น หากไม่ได้ประกาศ __init__ จะใช้ตัวที่สืบทอดมาจาก object ซึ่งไม่รับอาร์กิวเมนต์ ส่วน dataclass จะสร้างให้เอง
__str__ ต่างจาก __repr__ อย่างไร?
__repr__ คือการแทนค่าที่ไม่กำกวม สำหรับนักพัฒนาและการดีบัก ส่วน __str__ คือข้อความที่อ่านง่ายสำหรับผู้ใช้ในการ print และ f-string หากไม่มี __str__ Python จะใช้ __repr__ แทน
เมื่อใดควรใช้ dataclass และเมื่อใดควรใช้คลาสธรรมดา?
dataclass เหมาะเมื่อคลาสเก็บข้อมูลเป็นหลักและต้องการ __init__ __repr__ และการเปรียบเทียบที่พร้อมใช้ ส่วนคลาสธรรมดาเหมาะเมื่อการกำหนดค่าซับซ้อนหรือพฤติกรรมสำคัญกว่าข้อมูล
ใน Python มีฟิลด์ส่วนตัวหรือไม่?
ไม่มีการป้องกันอย่างเคร่งครัด ขีดล่างหนึ่งตัวที่ต้นชื่อเป็นข้อตกลงว่า "ไม่ใช่สำหรับใช้ภายนอก" ส่วนขีดล่างสองตัวจะเปลี่ยนชื่อภายในเพื่อป้องกันการทับจากคลาสลูก
หัวข้อนี้สอนอย่างละเอียดพร้อมฝึกปฏิบัติในคอร์ส “Python 3.15: от новичка до профи”
ไปที่คอร์ส