33. Data Classes untuk Data Terstruktur Sederhana
Di Bab 30, kita belajar cara membuat class untuk mendefinisikan tipe kita sendiri. Kita menulis method __init__ untuk menginisialisasi instance, method __repr__ untuk menampilkannya, dan method __eq__ untuk membandingkannya. Walaupun pendekatan ini bekerja dengan sempurna, ini melibatkan penulisan banyak kode berulang, terutama ketika sebuah class terutama ada untuk menyimpan data.
data class Python menyediakan cara yang lebih bersih dan ringkas untuk membuat class yang utamanya adalah wadah untuk data. Dengan menggunakan decorator @dataclass, Python otomatis menghasilkan method umum seperti __init__, __repr__, dan __eq__ berdasarkan atribut class yang kamu definisikan. Ini mengurangi boilerplate code dan membuat tujuanmu lebih jelas.
33.1) Apa Itu Data Class dan Kapan Menggunakannya
Sebuah data class adalah class yang dirancang terutama untuk menyimpan nilai data. Alih-alih menulis method inisialisasi dan perbandingan secara manual, kamu mendefinisikan atribut yang seharusnya dimiliki class-mu, dan Python menghasilkan method yang diperlukan secara otomatis.
Mengapa Data Class Penting
Pertimbangkan sebuah class biasa untuk merepresentasikan buku:
class Book:
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r}, year={self.year})"
def __eq__(self, other):
if not isinstance(other, Book):
return False
return (self.title == other.title and
self.author == other.author and
self.year == other.year)
book1 = Book("1984", "George Orwell", 1949)
print(book1) # Output: Book(title='1984', author='George Orwell', year=1949)
book2 = Book("1984", "George Orwell", 1949)
print(book1 == book2) # Output: TrueIni bekerja, tapi perhatikan berapa banyak kode yang kita tulis hanya untuk menyimpan tiga informasi. Method __init__, __repr__, dan __eq__ mengikuti pola yang bisa ditebak—mereka hanya menangani atribut yang kita definisikan.
Data class menghilangkan pengulangan ini. Data class sangat berguna terutama ketika:
- Class-mu terutama menyimpan data ketimbang mengimplementasikan perilaku yang kompleks
- Kamu membutuhkan method standar seperti inisialisasi, representasi string, dan perbandingan kesetaraan
- Kamu ingin kode yang lebih jelas dan mudah dirawat dengan lebih sedikit boilerplate
- Kamu membuat objek konfigurasi, data transfer object, atau record sederhana
Data class tidak menggantikan class biasa—mereka melengkapinya. Gunakan class biasa ketika kamu butuh logika inisialisasi kustom, method yang kompleks, atau hierarki inheritance. Gunakan data class ketika kamu terutama butuh wadah terstruktur untuk data terkait.
Hubungan Antara Data Class dan Class Biasa
Data class tetaplah class Python biasa. Mereka mendukung semua fitur yang kita pelajari di Bab 30-32: method, property, inheritance, dan special method. Decorator @dataclass hanya mengotomatisasi pembuatan method umum, sehingga kamu tidak perlu menulis kode berulang.
33.2) Membuat Data Class dengan @dataclass
Untuk membuat data class, kamu mengimpor decorator dataclass dari modul dataclasses dan menerapkannya pada definisi class-mu. Di dalam class, kamu mendefinisikan atribut class dengan type annotation yang menentukan data apa yang harus disimpan oleh class tersebut.
Sintaks Data Class Dasar
from dataclasses import dataclass
@dataclass
class Student:
name: str
student_id: int
gpa: float
# Membuat instance
alice = Student("Alice Johnson", 12345, 3.8)
bob = Student("Bob Smith", 12346, 3.5)
print(alice) # Output: Student(name='Alice Johnson', student_id=12345, gpa=3.8)
print(bob) # Output: Student(name='Bob Smith', student_id=12346, gpa=3.5)Mari kita uraikan apa yang dilakukan oleh decorator @dataclass:
-
@dataclass: Menerapkan decorator ini membuat Python otomatis menuliskan method__init__,__repr__, dan__eq__untukmu -
__init__otomatis: Python membuat method inisialisasi yang menerima tiga parameter ini dalam urutan seperti didefinisikan, lalu menetapkannya ke atribut instance -
__repr__otomatis: Python membuat representasi string yang menampilkan nama class dan semua nilai atribut -
__eq__otomatis: Python membuat method perbandingan kesetaraan yang membandingkan semua atribut -
Mengubah type annotation menjadi atribut instance: Pada class biasa, menulis
name: strdi body class membuat atribut class. Namun decorator@dataclassmengubah perilaku ini—ia memakai type annotation tersebut untuk mendefinisikan atribut instance. Setiap instance mendapatkan atributname,student_id, dangpamiliknya sendiri.
Perbedaan kunci dari class biasa:
# Class biasa - ini adalah atribut class (dibagikan oleh semua instance)
class RegularStudent:
name: str
student_id: int
# Data class - ini menjadi atribut instance (setiap instance punya miliknya sendiri)
@dataclass
class DataStudent:
name: str
student_id: intMemahami Type Annotation dalam Data Class
Dalam data class, type annotation mendefinisikan atribut dan mendokumentasikan tipe yang diharapkan:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool
# Menggunakan tipe yang benar sesuai dokumentasi
laptop = Product("Laptop", 999.99, True)
print(laptop) # Output: Product(name='Laptop', price=999.99, in_stock=True)
# Python tidak memaksa tipe - ini berjalan tanpa error
macbook = Product("Macbook", "expensive", True)
print(macbook) # Output: Product(name='Macbook', price='expensive', in_stock=True)
# Tapi memakai tipe yang salah akan menimbulkan masalah nanti:
discounted = laptop.price * 0.9 # Works: 899.991
discounted = macbook.price * 0.9 # TypeError: can't multiply sequence by non-int of type 'float'
tax = laptop.price + 50 # Works: 1049.99
tax = macbook.price + 50 # TypeError: can only concatenate str (not "int") to strPython tidak akan menghentikanmu untuk memberikan tipe yang salah saat membuat instance data class. Type annotation utamanya adalah dokumentasi—mereka memberi tahu programmer lain (dan alat pengecekan tipe seperti mypy) tipe apa yang kamu harapkan, tetapi Python tidak menegakkannya saat runtime. Ini konsisten dengan filosofi dynamic typing Python.
Namun, mengikuti type annotation membuat kode kamu lebih bisa diprediksi dan lebih mudah di-debug. Ketika kamu memakai tipe yang salah, error akan muncul belakangan saat kamu mencoba memakai data tersebut, membuat bug lebih sulit dilacak. Alat pengecekan tipe dapat menangkap ketidaksesuaian ini sebelum kamu menjalankan kode, membantu kamu menemukan masalah lebih awal.
Mengakses dan Mengubah Atribut
Instance data class bekerja persis seperti instance class biasa. Kamu mengakses dan mengubah atribut menggunakan dot notation:
from dataclasses import dataclass
@dataclass
class Employee:
name: str
position: str
salary: float
emp = Employee("Sarah Chen", "Software Engineer", 95000.0)
# Mengakses atribut
print(emp.name) # Output: Sarah Chen
print(emp.position) # Output: Software Engineer
# Mengubah atribut
emp.salary = 100000.0
emp.position = "Senior Software Engineer"
print(emp) # Output: Employee(name='Sarah Chen', position='Senior Software Engineer', salary=100000.0)Data class secara default bersifat mutable—kamu bisa mengubah atributnya setelah dibuat. Ini berbeda dari tuple atau named tuple, yang immutable. Jika kamu membutuhkan immutability, kamu bisa mengonfigurasi data class dengan frozen=True (kita akan membahas ini di Bagian 33.4).
33.3) Method yang Dihasilkan: __init__, __repr__, dan __eq__
Decorator @dataclass otomatis menghasilkan tiga method penting. Memahami apa yang dilakukan method-method ini membantumu memakai data class secara efektif dan tahu kapan harus mengustomisasinya.
Method __init__ yang Dihasilkan
Method __init__ menginisialisasi instance baru dengan nilai yang diberikan. Python menghasilkannya berdasarkan urutan definisi atributmu:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
# __init__ yang dihasilkan menerima width dan height dalam urutan itu
rect = Rectangle(10.5, 5.0)
print(rect.width) # Output: 10.5
print(rect.height) # Output: 5.0
# Kamu juga bisa memakai keyword argument
rect2 = Rectangle(height=8.0, width=12.0)
print(rect2.width) # Output: 12.0
print(rect2.height) # Output: 8.0__init__ yang dihasilkan setara dengan menulis:
def __init__(self, width: float, height: float):
self.width = width
self.height = heightPembuatan otomatis ini menghematmu dari menulis kode inisialisasi yang berulang, terutama untuk class dengan banyak atribut.
Method __repr__ yang Dihasilkan
Method __repr__ menyediakan representasi string dari instance yang menampilkan semua nilai atribut. Ini sangat berharga untuk debugging dan logging:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
label: str
point = Point(3.5, 7.2, "A")
print(point) # Output: Point(x=3.5, y=7.2, label='A')
print(repr(point)) # Output: Point(x=3.5, y=7.2, label='A')__repr__ yang dihasilkan mengikuti konvensi untuk menampilkan nama class dan semua atribut dalam format yang bisa dipakai untuk membuat ulang objek tersebut. Ini jauh lebih membantu daripada representasi default yang akan kamu dapatkan tanpa __repr__: <__main__.Point object at 0x...>.
Method __eq__ yang Dihasilkan
Method __eq__ memungkinkan perbandingan kesetaraan antar-instance. Dua instance data class dianggap sama jika semua atribut yang bersesuaian sama:
from dataclasses import dataclass
@dataclass
class Color:
red: int
green: int
blue: int
color1 = Color(255, 0, 0)
color2 = Color(255, 0, 0)
color3 = Color(0, 255, 0)
print(color1 == color2) # Output: True (same RGB values)
print(color1 == color3) # Output: False (different RGB values)
print(color1 is color2) # Output: False (different objects in memory)Perbandingan kesetaraan otomatis ini didasarkan pada kesetaraan nilai, bukan identitas. Walaupun color1 dan color2 adalah objek yang berbeda di memori (seperti ditunjukkan oleh is), mereka dianggap sama karena atributnya cocok.
Method __eq__ yang dihasilkan membandingkan atribut dalam urutan seperti didefinisikan:
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
year: int
book1 = Book("1984", "George Orwell", 1949)
book2 = Book("1984", "George Orwell", 1949)
book3 = Book("Animal Farm", "George Orwell", 1945)
print(book1 == book2) # Output: True (all attributes match)
print(book1 == book3) # Output: False (title and year differ)
# Perbandingan dengan objek non-Book mengembalikan False
print(book1 == "1984") # Output: False
print(book1 == None) # Output: FalseMembandingkan Method yang Dihasilkan dengan Implementasi Manual
Untuk mengapresiasi apa yang disediakan data class, mari bandingkan versi data class dengan implementasi manual:
from dataclasses import dataclass
# Versi data class (ringkas)
@dataclass
class PersonData:
first_name: str
last_name: str
age: int
# Versi manual yang setara (panjang)
class PersonManual:
def __init__(self, first_name: str, last_name: str, age: int):
self.first_name = first_name
self.last_name = last_name
self.age = age
def __repr__(self):
return f"PersonManual(first_name={self.first_name!r}, last_name={self.last_name!r}, age={self.age})"
def __eq__(self, other):
if not isinstance(other, PersonManual):
return False
return (self.first_name == other.first_name and
self.last_name == other.last_name and
self.age == other.age)
# Keduanya bekerja secara identik
p1 = PersonData("Alice", "Johnson", 30)
p2 = PersonManual("Alice", "Johnson", 30)
print(p1) # Output: PersonData(first_name='Alice', last_name='Johnson', age=30)
print(p2) # Output: PersonManual(first_name='Alice', last_name='Johnson', age=30)Versi data class mencapai fungsionalitas yang sama dengan kode yang jauh lebih sedikit. Pengurangan boilerplate ini membuat kode kamu lebih mudah dibaca, dirawat, dan dimodifikasi.
Menambahkan Method Kustom ke Data Class
Data class bisa punya method kustom seperti class biasa. Decorator @dataclass hanya menghasilkan method inisialisasi, representasi, dan kesetaraan—kamu bebas menambahkan fungsionalitas lain apa pun:
from dataclasses import dataclass
@dataclass
class Temperature:
celsius: float
def to_fahrenheit(self):
"""Convert temperature to Fahrenheit."""
return (self.celsius * 9/5) + 32
def to_kelvin(self):
"""Convert temperature to Kelvin."""
return self.celsius + 273.15
def is_freezing(self):
"""Check if temperature is at or below freezing point."""
return self.celsius <= 0
temp = Temperature(25.0)
print(temp) # Output: Temperature(celsius=25.0)
print(f"{temp.celsius}°C = {temp.to_fahrenheit()}°F") # Output: 25.0°C = 77.0°F
print(f"Kelvin: {temp.to_kelvin()}") # Output: Kelvin: 298.15
print(f"Freezing: {temp.is_freezing()}") # Output: Freezing: False
cold_temp = Temperature(-5.0)
print(f"Freezing: {cold_temp.is_freezing()}") # Output: Freezing: TrueData class menangani bagian yang repetitif (inisialisasi, representasi, dan perbandingan) sambil membiarkanmu menambahkan method kustom sesuai kebutuhan spesifikmu, seperti ditunjukkan pada method konversi suhu di atas.
33.4) Nilai Default dan Opsi Field
Data class mendukung nilai default untuk atribut, memungkinkanmu membuat instance tanpa harus menentukan setiap parameter. Kamu juga bisa memakai fungsi field() untuk mengonfigurasi perilaku lanjutan seperti mengecualikan atribut dari perbandingan atau mengontrol bagaimana atribut tersebut muncul dalam representasi string.
Memberikan Nilai Default
Kamu bisa menetapkan nilai default ke atribut secara langsung di definisi class. Atribut dengan default harus diletakkan setelah atribut tanpa default:
from dataclasses import dataclass
@dataclass
class User:
username: str
email: str
is_active: bool = True # Nilai default
role: str = "user" # Nilai default
# Create instances with and without defaults
user1 = User("alice", "alice@example.com")
print(user1) # Output: User(username='alice', email='alice@example.com', is_active=True, role='user')
user2 = User("bob", "bob@example.com", False, "admin")
print(user2) # Output: User(username='bob', email='bob@example.com', is_active=False, role='admin')
# Use keyword arguments to override specific defaults
user3 = User("charlie", "charlie@example.com", role="moderator")
print(user3) # Output: User(username='charlie', email='charlie@example.com', is_active=True, role='moderator')Aturan urutan (atribut tanpa default sebelum atribut dengan default) mencegah ambiguitas pada method __init__ yang dihasilkan. Ini adalah persyaratan yang sama seperti pada parameter fungsi dengan nilai default, yang kita pelajari di Bab 20.
Nilai Default Mutable dan Mengapa Itu Tidak Diizinkan
Data class melindungimu dari kesalahan umum terkait default yang mutable. Jika kamu mencoba memakai objek mutable seperti list atau dictionary secara langsung sebagai default, kamu akan mendapatkan error:
from dataclasses import dataclass
# Ini akan memunculkan error
@dataclass
class ShoppingCart:
customer: str
items: list = [] # ValueError: mutable default <class 'list'> for field items is not allowed: use default_factoryError ini mencegah masalah yang sama seperti yang kita lihat pada default argument fungsi di Bab 20, di mana semua instance akan berbagi objek mutable yang sama.
Menggunakan field() dengan default_factory untuk Default Mutable
Solusinya adalah memakai fungsi field() dengan default_factory, yang membuat nilai default baru untuk setiap instance:
from dataclasses import dataclass, field
@dataclass
class ShoppingCart:
customer: str
items: list = field(default_factory=list) # Benar: list baru per instance
# Sekarang setiap instance mendapatkan list-nya sendiri
cart1 = ShoppingCart("Alice")
cart1.items.append("Book")
print(cart1.items) # Output: ['Book']
cart2 = ShoppingCart("Bob")
print(cart2.items) # Output: [] - Bob has an empty list
cart2.items.append("Laptop")
print(cart1.items) # Output: ['Book'] - Alice's cart unchanged
print(cart2.items) # Output: ['Laptop'] - Bob's cart independentParameter default_factory menerima sebuah fungsi (seperti list, dict, atau set) yang akan dipanggil untuk membuat nilai default baru setiap kali kamu membuat instance tanpa memberikan atribut tersebut. Misalnya, default_factory=list berarti Python akan memanggil list() untuk membuat list kosong baru untuk setiap instance.
Mengecualikan Field dari Perbandingan
Kadang kamu ingin atribut tertentu dikecualikan dari perbandingan kesetaraan. Gunakan field(compare=False) untuk ini:
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class LogEntry:
message: str
level: str
timestamp: datetime = field(compare=False) # Jangan bandingkan timestamp
# Membuat dua log entry dengan message yang sama tapi waktu berbeda
entry1 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 30))
entry2 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 35))
# Mereka sama karena timestamp dikecualikan dari perbandingan
print(entry1 == entry2) # Output: True
# Tapi mereka punya timestamp yang berbeda
print(entry1.timestamp) # Output: 2024-01-15 10:30:00
print(entry2.timestamp) # Output: 2024-01-15 10:35:00Ini berguna ketika kamu punya field metadata (seperti timestamp, ID, atau counter internal) yang seharusnya tidak memengaruhi apakah dua instance dianggap sama.
Mengecualikan Field dari Representasi
Kamu juga bisa mengecualikan field dari representasi string menggunakan field(repr=False):
from dataclasses import dataclass, field
@dataclass
class Account:
username: str
email: str
password: str = field(repr=False) # Jangan tampilkan password di repr
account = Account("alice", "alice@example.com", "secret123")
print(account) # Output: Account(username='alice', email='alice@example.com')
# Password tidak ditampilkan, tapi tetap disimpan
print(account.password) # Output: secret123Ini sangat berguna untuk data sensitif seperti password, API key, atau struktur data besar yang akan mengotori representasi.
Membuat Data Class Immutable dengan frozen=True
Secara default, instance data class bersifat mutable—kamu bisa mengubah atributnya setelah dibuat. Jika kamu ingin instance yang immutable (seperti tuple), gunakan frozen=True:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
point = Point(3.0, 4.0)
print(point) # Output: Point(x=3.0, y=4.0)
# Mencoba mengubah akan memunculkan error
try:
point.x = 5.0
except AttributeError as e:
print(f"Error: {e}") # Output: Error: cannot assign to field 'x'Data class frozen berguna ketika kamu ingin memastikan integritas data atau memakai instance sebagai key dictionary (karena key dictionary harus immutable). Ketika sebuah data class dibuat frozen, Python juga menghasilkan method __hash__, membuat instance menjadi hashable:
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
# Instance frozen bisa menjadi key dictionary
locations = {
Coordinate(40.7128, -74.0060): "New York",
Coordinate(51.5074, -0.1278): "London",
Coordinate(35.6762, 139.6503): "Tokyo"
}
nyc = Coordinate(40.7128, -74.0060)
print(locations[nyc]) # Output: New York33.5) Inisialisasi Kustom dengan __post_init__
Terkadang kamu perlu melakukan setup tambahan setelah method __init__ yang dihasilkan dijalankan. Method __post_init__ dipanggil otomatis setelah inisialisasi, memungkinkanmu memvalidasi data, menghitung atribut turunan, atau melakukan tugas setup lainnya.
Penggunaan Dasar __post_init__
Method __post_init__ dipanggil setelah semua atribut sudah diset oleh __init__ yang dihasilkan:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
area: float = 0.0 # Akan dihitung di __post_init__
def __post_init__(self):
"""Calculate area after initialization."""
self.area = self.width * self.height
rect = Rectangle(5.0, 3.0)
print(rect) # Output: Rectangle(width=5.0, height=3.0, area=15.0)
print(f"Area: {rect.area}") # Output: Area: 15.0Method __post_init__ memiliki akses ke semua atribut instance yang diset saat inisialisasi. Ini berguna untuk menghitung nilai turunan yang bergantung pada beberapa atribut.
Memvalidasi Data di post_init
Penggunaan umum __post_init__ adalah memvalidasi bahwa data yang diberikan memenuhi persyaratan tertentu:
from dataclasses import dataclass
@dataclass
class BankAccount:
account_number: str
balance: float
def __post_init__(self):
"""Validate account data."""
if self.balance < 0:
raise ValueError("Balance cannot be negative")
# Akun yang valid
account1 = BankAccount("ACC001", 1000.0)
print(account1) # Output: BankAccount(account_number='ACC001', balance=1000.0)
# Akun tidak valid - saldo negatif
try:
account2 = BankAccount("ACC002", -500.0)
except ValueError as e:
print(f"Error: {e}") # Output: Error: Balance cannot be negativeValidasi ini memastikan bahwa instance selalu berada dalam keadaan yang valid. Jika data tidak memenuhi persyaratan, instance tidak pernah dibuat, mencegah objek yang tidak valid ada di programmu.
Menggunakan post_init dengan field(init=False)
Terkadang kamu menginginkan atribut yang dihitung di __post_init__ tetapi seharusnya tidak menjadi parameter di __init__. Gunakan field(init=False) untuk ini:
from dataclasses import dataclass, field
import math
@dataclass
class Circle:
radius: float
area: float = field(init=False) # Bukan parameter di __init__
circumference: float = field(init=False)
def __post_init__(self):
"""Compute area and circumference from radius."""
self.area = math.pi * self.radius ** 2
self.circumference = 2 * math.pi * self.radius
# Hanya radius yang diperlukan saat inisialisasi
circle = Circle(5.0)
print(circle) # Output: Circle(radius=5.0, area=78.53981633974483, circumference=31.41592653589793)
print(f"Area: {circle.area:.2f}") # Output: Area: 78.54
print(f"Circumference: {circle.circumference:.2f}") # Output: Circumference: 31.42Pola ini berguna ketika kamu punya atribut yang selalu dihitung dari atribut lain dan seharusnya tidak pernah diset langsung saat inisialisasi.
Data class merepresentasikan fitur Python modern yang mengurangi boilerplate sambil tetap mempertahankan kekuatan penuh dari class. Mereka sangat berharga untuk membuat kode yang bersih dan mudah dibaca saat bekerja dengan data terstruktur. Saat kamu terus belajar Python, kamu akan menemukan data class menjadi pilihan yang natural untuk banyak tugas pemrograman yang berfokus pada data, melengkapi class biasa yang kamu pelajari di Bab 30-32.