Kelas #
Kelas adalah unit organisasi kode terpenting dalam pemrograman berorientasi objek — ia menggabungkan data (properti) dan perilaku (method) menjadi satu entitas yang kohesif. Di Dart, kelas adalah satu-satunya mekanisme utama untuk mendefinisikan tipe baru, dan hampir setiap nilai dalam program Dart adalah instance dari suatu kelas. Yang membedakan kelas Dart dari bahasa lain adalah beberapa fitur unik: privasi berbasis library (bukan kata kunci), implicit interface dari setiap kelas, mixin sebagai unit reuse yang terpisah dari inheritance, dan extension method untuk menambahkan fungsionalitas ke kelas yang sudah ada tanpa modifikasi. Artikel ini membahas semua mekanisme ini dari yang paling dasar hingga pola desain yang digunakan dalam aplikasi nyata.
Anatomi Kelas #
Sebuah kelas Dart bisa berisi enam jenis anggota: properti instance, properti static, konstruktor, getter/setter, method instance, dan method static.
class Produk {
// 1. Properti instance — dimiliki setiap objek
final String id;
String nama;
double harga;
// 2. Properti static — dimiliki kelas, bukan objek
static int totalDibuat = 0;
static const double pajakDefault = 0.11;
// 3. Konstruktor utama
Produk({required this.id, required this.nama, required this.harga}) {
totalDibuat++;
}
// 4. Getter — properti yang dihitung
double get hargaDenganPajak => harga * (1 + pajakDefault);
bool get tersedia => stok > 0;
// 5. Setter — validasi saat assignment
int _stok = 0;
int get stok => _stok;
set stok(int nilai) {
if (nilai < 0) throw ArgumentError('Stok tidak boleh negatif');
_stok = nilai;
}
// 6. Method instance
Produk salinDengan({String? nama, double? harga}) {
return Produk(
id: id,
nama: nama ?? this.nama,
harga: harga ?? this.harga,
);
}
// Method static
static void resetHitung() => totalDibuat = 0;
@override
String toString() => 'Produk($id: $nama, Rp${harga.toStringAsFixed(0)})';
}
classDiagram
class Produk {
+String id
+String nama
+double harga
-int _stok
+static int totalDibuat
+double hargaDenganPajak
+bool tersedia
+int stok
+salinDengan() Produk
+static resetHitung()
+toString() String
}
Konstruktor dan Variasinya #
Dart menyediakan beberapa bentuk konstruktor, masing-masing dengan tujuan berbeda. Memahami kapan menggunakan masing-masing adalah kunci desain kelas yang baik.
Konstruktor Utama #
Sintaks this.namaProperti di parameter konstruktor adalah initializing formal — cara singkat menginisialisasi properti tanpa menulis this.x = x di body:
class Titik {
final double x;
final double y;
// Initializing formal — ringkas dan idiomatis
Titik(this.x, this.y);
// Ekuivalen dengan:
// Titik(double x, double y) : this.x = x, this.y = y;
}
// Named parameter untuk konstruktor yang lebih deskriptif
class Pengguna {
final String id;
final String nama;
final String email;
final DateTime bergabung;
Pengguna({
required this.id,
required this.nama,
required this.email,
DateTime? bergabung,
}) : bergabung = bergabung ?? DateTime.now();
// ↑ initializer list — dieksekusi SEBELUM body konstruktor
}
Initializer List #
Initializer list (bagian setelah : sebelum body) digunakan untuk menginisialisasi properti final dengan ekspresi yang lebih kompleks dari sekadar parameter:
class Segitiga {
final double alas;
final double tinggi;
final double luas; // final — harus diinisialisasi di initializer list
final double keliling;
Segitiga(this.alas, this.tinggi, double sisi)
: luas = 0.5 * alas * tinggi, // dihitung dari parameter lain
keliling = alas + tinggi + sisi, // bisa referensi parameter
assert(alas > 0 && tinggi > 0, // assert di initializer list
'Alas dan tinggi harus positif');
}
assert di initializer list adalah cara terbaik untuk memvalidasi prasyarat konstruktor — error muncul saat debug (bukan production) dengan pesan yang jelas.
Named Constructor #
Named constructor memungkinkan beberapa cara membuat objek dengan niat yang berbeda — tiap nama mengkomunikasikan konteks pembuatannya:
class Warna {
final int r, g, b, a;
// Konstruktor utama
const Warna(this.r, this.g, this.b, {this.a = 255});
// Named constructor — dari nilai hex
factory Warna.dariHex(String hex) {
final h = hex.replaceAll('#', '');
return Warna(
int.parse(h.substring(0, 2), radix: 16),
int.parse(h.substring(2, 4), radix: 16),
int.parse(h.substring(4, 6), radix: 16),
);
}
// Named constructor — warna predefined
const Warna.merah() : r = 255, g = 0, b = 0, a = 255;
const Warna.hijau() : r = 0, g = 255, b = 0, a = 255;
const Warna.biru() : r = 0, g = 0, b = 255, a = 255;
const Warna.transparan() : r = 0, g = 0, b = 0, a = 0;
// Named constructor — salin dengan modifikasi
Warna.dariWarna(Warna lain, {int? r, int? g, int? b, int? a})
: r = r ?? lain.r,
g = g ?? lain.g,
b = b ?? lain.b,
a = a ?? lain.a;
}
// Penggunaan — setiap named constructor komunikasikan niatnya
const merah = Warna.merah();
final ungu = Warna.dariHex('#8B00FF');
final transparanMerah = Warna.dariWarna(merah, a: 128);
Factory Constructor #
Factory constructor tidak langsung membuat instance baru — ia bisa mengembalikan instance yang sudah ada, instance dari subkelas, atau hasil logika kompleks. Kata kunci factory memberi sinyal kepada pembaca bahwa pembuatan objek melibatkan logika khusus:
class Logger {
final String nama;
static final Map<String, Logger> _cache = {};
// Constructor privat — hanya factory yang bisa membuat Logger
Logger._(this.nama);
// Singleton per nama — factory mengembalikan instance yang sudah ada
factory Logger(String nama) {
return _cache.putIfAbsent(nama, () => Logger._(nama));
}
void log(String pesan) => print('[$nama] $pesan');
}
// Setiap panggilan dengan nama yang sama mengembalikan objek yang sama
final loggerA = Logger('Auth');
final loggerB = Logger('Auth');
print(identical(loggerA, loggerB)); // true — singleton
// Factory constructor dari JSON — pola yang sangat umum
class Pengguna {
final String id;
final String nama;
final String email;
const Pengguna({required this.id, required this.nama, required this.email});
factory Pengguna.dariJson(Map<String, dynamic> json) {
return Pengguna(
id: json['id'] as String,
nama: json['nama'] as String,
email: json['email'] as String,
);
}
Map<String, dynamic> keJson() => {
'id': id,
'nama': nama,
'email': email,
};
}
Getter dan Setter #
Getter dan setter memungkinkan properti yang dihitung dan validasi saat akses — antarmuka yang terlihat seperti properti biasa tapi di baliknya adalah logika:
class RekeningBank {
final String noRekening;
double _saldo;
RekeningBank({required this.noRekening, double saldoAwal = 0})
: _saldo = saldoAwal;
// Getter — akses read-only ke data privat
double get saldo => _saldo;
// Getter komputasi
bool get kaya => _saldo > 1_000_000_000;
String get ringkasan => 'Rek ${noRekening}: Rp${_saldo.toStringAsFixed(0)}';
// Setter dengan validasi
set setor(double jumlah) {
if (jumlah <= 0) throw ArgumentError('Jumlah setor harus positif');
_saldo += jumlah;
}
// Method untuk operasi yang butuh dua parameter
void tarik(double jumlah, {String? keterangan}) {
if (jumlah <= 0) throw ArgumentError('Jumlah tarik harus positif');
if (jumlah > _saldo) throw StateError('Saldo tidak mencukupi');
_saldo -= jumlah;
}
}
// ANTI-PATTERN: setter yang tidak melakukan validasi
class Siswa {
int nilaiUjian = 0; // ✗ siapapun bisa set nilai = -100 atau 999
}
// BENAR: setter dengan validasi memastikan invariant kelas
class Siswa {
int _nilaiUjian = 0;
int get nilaiUjian => _nilaiUjian;
set nilaiUjian(int nilai) {
if (nilai < 0 || nilai > 100) {
throw RangeError.range(nilai, 0, 100, 'nilaiUjian');
}
_nilaiUjian = nilai;
}
}
Immutable Class dan copyWith
#
Kelas immutable (semua properti final) adalah pola yang sangat direkomendasikan di Dart — terutama untuk model data. Objek immutable aman digunakan di berbagai tempat tanpa khawatir perubahan tak terduga, dan mudah di-debug karena state-nya tidak berubah setelah dibuat.
“Modifikasi” pada objek immutable dilakukan dengan membuat objek baru melalui method copyWith:
class PengaturanAplikasi {
final String bahasa;
final bool modeMalam;
final double ukuranFont;
final bool notifikasiAktif;
const PengaturanAplikasi({
this.bahasa = 'id',
this.modeMalam = false,
this.ukuranFont = 14.0,
this.notifikasiAktif = true,
});
// copyWith — buat objek baru dengan beberapa nilai yang diubah
PengaturanAplikasi copyWith({
String? bahasa,
bool? modeMalam,
double? ukuranFont,
bool? notifikasiAktif,
}) {
return PengaturanAplikasi(
bahasa: bahasa ?? this.bahasa,
modeMalam: modeMalam ?? this.modeMalam,
ukuranFont: ukuranFont ?? this.ukuranFont,
notifikasiAktif: notifikasiAktif ?? this.notifikasiAktif,
);
}
@override
bool operator ==(Object other) =>
other is PengaturanAplikasi &&
other.bahasa == bahasa &&
other.modeMalam == modeMalam &&
other.ukuranFont == ukuranFont &&
other.notifikasiAktif == notifikasiAktif;
@override
int get hashCode =>
Object.hash(bahasa, modeMalam, ukuranFont, notifikasiAktif);
@override
String toString() =>
'PengaturanAplikasi(bahasa: $bahasa, modeMalam: $modeMalam)';
}
// Penggunaan
final defaultPengaturan = PengaturanAplikasi();
final pengaturanMalam = defaultPengaturan.copyWith(modeMalam: true, ukuranFont: 16);
// defaultPengaturan tidak berubah sama sekali
Inheritance — extends
#
Inheritance memungkinkan kelas baru mewarisi semua properti dan method kelas lain, lalu memperluas atau mengubah perilakunya. Dart hanya mendukung single inheritance — sebuah kelas hanya bisa extends satu kelas lain.
abstract class Hewan {
final String nama;
final int umur;
const Hewan({required this.nama, required this.umur});
// Method abstrak — WAJIB diimplementasikan subkelas
String bersuara();
// Method konkret — bisa diwarisi langsung
String deskripsi() => '$nama ($umur tahun): ${bersuara()}';
}
class Kucing extends Hewan {
final String warnaBulu;
const Kucing({
required super.nama, // Dart 2.17+: super parameter
required super.umur,
required this.warnaBulu,
});
@override
String bersuara() => 'Meow!';
// Method tambahan — tidak ada di superclass
void mendengkur() => print('$nama mendengkur...');
}
class Anjing extends Hewan {
final String ras;
const Anjing({
required super.nama,
required super.umur,
required this.ras,
});
@override
String bersuara() => 'Woof!';
@override
String deskripsi() {
// super.deskripsi() memanggil implementasi superclass
return '${super.deskripsi()} [Ras: $ras]';
}
}
void main() {
final hewan = <Hewan>[
Kucing(nama: 'Milo', umur: 3, warnaBulu: 'oranye'),
Anjing(nama: 'Rex', umur: 5, ras: 'Labrador'),
];
// Polimorfisme — method yang dipanggil ditentukan tipe runtime
for (final h in hewan) {
print(h.deskripsi());
}
}
// ANTI-PATTERN: inheritance untuk code reuse saja — hubungan bukan "is-a"
class Logger extends ArrayList<String> { // ✗ Logger bukan jenis ArrayList
void log(String pesan) => add(pesan);
}
// BENAR: gunakan komposisi jika hubungannya bukan "is-a"
class Logger {
final List<String> _riwayat = []; // has-a ArrayList, bukan is-a
void log(String pesan) => _riwayat.add(pesan);
List<String> get riwayat => List.unmodifiable(_riwayat);
}
Abstract Class #
Abstract class mendefinisikan kontrak — method apa yang harus dimiliki — tanpa memberikan implementasi lengkap. Tidak bisa diinstansiasi langsung.
abstract class Repository<T, ID> {
// Kontrak CRUD yang harus diimplementasikan
Future<T?> cariById(ID id);
Future<List<T>> cariSemua();
Future<T> simpan(T entitas);
Future<void> hapus(ID id);
// Method konkret — bisa diwarisi tanpa override
Future<bool> ada(ID id) async {
return await cariById(id) != null;
}
}
// Implementasi konkret
class PenggunaSqlRepository extends Repository<Pengguna, String> {
final Database _db;
PenggunaSqlRepository(this._db);
@override
Future<Pengguna?> cariById(String id) async {
final hasil = await _db.query('pengguna', where: 'id = ?', whereArgs: [id]);
return hasil.isEmpty ? null : Pengguna.dariMap(hasil.first);
}
@override
Future<List<Pengguna>> cariSemua() async {
final hasil = await _db.query('pengguna');
return hasil.map(Pengguna.dariMap).toList();
}
@override
Future<Pengguna> simpan(Pengguna pengguna) async {
await _db.insert('pengguna', pengguna.keMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
return pengguna;
}
@override
Future<void> hapus(String id) => _db.delete('pengguna', where: 'id = ?', whereArgs: [id]);
}
implements — Mengimplementasikan Interface
#
Di Dart, setiap kelas secara otomatis mendefinisikan implicit interface — set method dan getter publiknya. implements mewajibkan kelas mengimplementasikan semua member publik dari kelas/interface yang ditentukan, tanpa mewarisi implementasinya.
// Kelas biasa bisa digunakan sebagai interface
class Loggable {
void log(String pesan) => print('[${runtimeType}] $pesan');
}
// abstract class sebagai interface murni — lebih eksplisit
abstract class Serializable {
Map<String, dynamic> keJson();
String toJsonString();
}
// implements mewajibkan mengimplementasikan SEMUA member publik
class Transaksi implements Loggable, Serializable {
final String id;
final double jumlah;
Transaksi({required this.id, required this.jumlah});
@override
void log(String pesan) => print('[Transaksi-$id] $pesan'); // override implementasi
@override
Map<String, dynamic> keJson() => {'id': id, 'jumlah': jumlah};
@override
String toJsonString() => jsonEncode(keJson());
}
Perbedaan extends vs implements
#
class A {
void hello() => print('Hello dari A');
void selamat() => print('Selamat dari A');
}
// extends — mewarisi implementasi, hanya override yang diperlukan
class B extends A {
@override
void hello() => print('Hello dari B'); // hanya override ini
// selamat() diwarisi dari A
}
// implements — WAJIB implementasikan semuanya, tidak ada yang diwarisi
class C implements A {
@override
void hello() => print('Hello dari C'); // wajib
@override
void selamat() => print('Selamat dari C'); // wajib
}
| Aspek | extends |
implements |
|---|---|---|
| Mewarisi implementasi | ✓ | ✗ |
| Bisa override | ✓ (opsional) | ✓ (wajib semua) |
| Jumlah maksimal | 1 kelas | tak terbatas |
| Tujuan utama | Spesialisasi | Kontrak tipe |
Mixin — Reuse Tanpa Inheritance #
Mixin adalah kumpulan method dan properti yang bisa “dicampur” ke dalam kelas tanpa inheritance. Gunakan mixin untuk kemampuan yang bisa dimiliki berbagai kelas yang tidak terkait dalam hierarki:
mixin Validasi {
// Mixin bisa memiliki properti dan method
List<String> _kesalahan = [];
bool get valid => _kesalahan.isEmpty;
List<String> get kesalahan => List.unmodifiable(_kesalahan);
void tambahKesalahan(String pesan) => _kesalahan.add(pesan);
void bersihkanKesalahan() => _kesalahan.clear();
bool validasiTidakKosong(String nilai, String namaField) {
if (nilai.trim().isEmpty) {
tambahKesalahan('$namaField tidak boleh kosong');
return false;
}
return true;
}
}
mixin Serialisasi {
Map<String, dynamic> keJson(); // abstract — subkelas harus implementasikan
String keJsonString() => jsonEncode(keJson());
void dariJsonString(String jsonStr) {
// implementasi umum
}
}
// Menggunakan mixin dengan with
class FormPendaftaran with Validasi, Serialisasi {
String nama = '';
String email = '';
bool validasi() {
bersihkanKesalahan();
validasiTidakKosong(nama, 'Nama');
if (!email.contains('@')) tambahKesalahan('Email tidak valid');
return valid;
}
@override
Map<String, dynamic> keJson() => {'nama': nama, 'email': email};
}
Mixin dengan on — Membatasi Target
#
on membatasi mixin hanya bisa digunakan oleh kelas yang extends/implements kelas tertentu. Ini memungkinkan mixin mengakses member dari kelas target:
abstract class Widget {
String get kunci;
void render();
}
// Mixin ini hanya bisa digunakan oleh Widget atau subkelasnya
mixin AnimasiWidget on Widget {
Duration get durasiAnimasi => Duration(milliseconds: 300);
void renderDenganAnimasi() {
print('Animasi $kunci selama ${durasiAnimasi.inMilliseconds}ms');
render(); // bisa akses method Widget karena ada constraint 'on Widget'
}
}
class Tombol extends Widget with AnimasiWidget {
@override
final String kunci;
Tombol(this.kunci);
@override
void render() => print('Render tombol: $kunci');
}
// Kelas yang bukan Widget TIDAK BISA menggunakan AnimasiWidget
// class BukanWidget with AnimasiWidget {} // ✗ error kompilasi
Extension Method — Menambah Kemampuan Tanpa Modifikasi #
Extension method memungkinkan menambahkan method baru ke kelas yang sudah ada — termasuk kelas bawaan Dart — tanpa mengubah kode aslinya dan tanpa inheritance:
// Extension pada String
extension StringExtension on String {
bool get isEmail => RegExp(r'^[\w\.-]+@[\w\.-]+\.\w{2,}$').hasMatch(this);
bool get isPhoneNumber => RegExp(r'^\+?[\d\s\-]{10,}$').hasMatch(this);
String capitalize() {
if (isEmpty) return this;
return '${this[0].toUpperCase()}${substring(1).toLowerCase()}';
}
String truncate(int maxLength, {String suffix = '...'}) {
if (length <= maxLength) return this;
return '${substring(0, maxLength - suffix.length)}$suffix';
}
}
// Extension pada List
extension ListExtension<T> on List<T> {
List<T> unik() => toSet().toList();
List<List<T>> batch(int ukuran) {
final hasil = <List<T>>[];
for (int i = 0; i < length; i += ukuran) {
hasil.add(sublist(i, (i + ukuran).clamp(0, length)));
}
return hasil;
}
}
// Extension pada int
extension DurasiExtension on int {
Duration get detik => Duration(seconds: this);
Duration get menit => Duration(minutes: this);
Duration get jam => Duration(hours: this);
}
// Penggunaan
void main() {
print('[email protected]'.isEmail); // true
print('Halo dunia'.capitalize()); // Halo Dunia
final data = [1, 2, 2, 3, 3, 3];
print(data.unik()); // [1, 2, 3]
print([1,2,3,4,5].batch(2)); // [[1,2],[3,4],[5]]
await Future.delayed(2.detik); // lebih ekspresif dari Duration(seconds: 2)
}
// ANTI-PATTERN: extension yang mengubah perilaku yang sudah ada secara mengejutkan
extension StringBerbahaya on String {
// ✗ nama sama dengan String.length tapi perilaku berbeda
// Extension tidak bisa override member yang sudah ada — ini akan diabaikan
}
// BENAR: extension menambah, bukan mengganti — berikan nama yang jelas
extension StringHelper on String {
int get panjangTanpaSpasi => trim().length; // ✓ nama jelas, tidak konflik
}
Static Members #
Static member adalah properti atau method yang dimiliki kelas secara keseluruhan, bukan oleh instance tertentu. Diakses melalui nama kelas, bukan objek.
class KonversiSuhu {
// Static const — konstanta yang terkait dengan domain kelas
static const double absoluteZeroCelsius = -273.15;
static const double nolAbsolutFahrenheit = -459.67;
// Counter static — dibagi semua instance
static int _jumlahKonversi = 0;
static int get jumlahKonversi => _jumlahKonversi;
// Static method — utilitas yang tidak butuh state instance
static double celsiusKeFahrenheit(double celsius) {
_jumlahKonversi++;
return celsius * 9 / 5 + 32;
}
static double fahrenheitKeCelsius(double fahrenheit) {
_jumlahKonversi++;
return (fahrenheit - 32) * 5 / 9;
}
static double celsiusKeKelvin(double celsius) {
_jumlahKonversi++;
return celsius - absoluteZeroCelsius;
}
}
// Akses tanpa membuat instance
print(KonversiSuhu.celsiusKeFahrenheit(100)); // 212.0
print(KonversiSuhu.jumlahKonversi); // 1
// ANTI-PATTERN: kelas yang hanya berisi static member tanpa private constructor
class Utilitas {
// Tidak ada yang mencegah Utilitas() dipanggil — tidak bermakna
static void bantu() { }
}
// BENAR: cegah instansiasi dengan constructor private atau abstract class
abstract class Utilitas {
Utilitas._(); // constructor private — tidak bisa diinstansiasi
static void bantu() { }
}
// Atau gunakan top-level function jika tidak perlu namespace kelas
Privasi Berbasis Library #
Di Dart, tidak ada kata kunci private, protected, atau public. Privasi ditentukan oleh awalan _ (underscore): member yang namanya diawali _ hanya bisa diakses dari dalam file (library) yang sama, bukan hanya dari dalam kelas.
// Dalam file: rekening_bank.dart
class RekeningBank {
double _saldo = 0; // privat untuk library rekening_bank.dart
String _pin = '0000'; // privat untuk library rekening_bank.dart
bool verifikasiPin(String pin) => pin == _pin;
}
class AuditRekening {
// Bisa mengakses _saldo karena dalam file yang sama!
void audit(RekeningBank rek) {
print('Saldo: ${rek._saldo}'); // ✓ — file yang sama
}
}
// Dalam file: main.dart
import 'rekening_bank.dart';
void main() {
final rek = RekeningBank();
// print(rek._saldo); // ✗ error: tidak bisa diakses dari file lain
print(rek.verifikasiPin('1234')); // ✓ — akses melalui method publik
}
Ringkasan #
- Initializer list (
: x = exprsebelum body) adalah tempat menginisialisasi propertifinaldengan ekspresi kompleks, memanggilsuper(), dan menempatkanassertuntuk validasi prasyarat.- Named constructor mengkomunikasikan niat pembuatan —
Warna.merah(),Pengguna.dariJson(),Titik.kosong()jauh lebih ekspresif dari satu konstruktor dengan banyak parameter opsional.- Factory constructor untuk logika pembuatan yang kompleks: singleton, caching, atau memilih subkelas yang tepat berdasarkan kondisi.
- Getter/setter memberikan antarmuka seperti properti dengan logika di baliknya — gunakan getter untuk properti komputasi, setter untuk validasi saat assignment.
copyWithadalah pola standar untuk “memodifikasi” objek immutable — menghasilkan objek baru dengan beberapa nilai berbeda tanpa mengubah objek asli.extendsuntuk hubungan “is-a” dengan mewarisi implementasi.implementsuntuk kontrak tipe yang mewajibkan semua member diimplementasikan ulang.withuntuk mencampur kemampuan dari mixin tanpa inheritance.- Mixin dengan
onmembatasi mixin hanya bisa digunakan oleh kelas tertentu, memungkinkan mixin mengakses member dari kelas target dengan aman.- Extension method menambahkan kemampuan ke kelas yang sudah ada tanpa modifikasi dan tanpa inheritance — cara terbaik untuk memperkaya String, List, int, dan tipe lainnya.
- Privasi Dart berbasis library (file), bukan berbasis kelas — member berawalan
_bisa diakses oleh semua kelas dalam file yang sama, tidak hanya kelas itu sendiri.- Komposisi lebih disukai dari inheritance untuk code reuse — gunakan
has-a(properti) bukanis-a(extends) ketika hubungannya bukan benar-benar “adalah jenis dari”.