Pola immutability

Menerapkan pola immutability dengan copyWith dan alur data satu arah.

Daftar isi

Topik ini membahas pola immutability, yaitu prinsip menjadikan objek model tidak dapat diubah setelah dibuat, beserta method copyWith yang menghasilkan salinan dengan sebagian field baru. Kelas model tak berubah dibuat utuh sebagai program Dart murni sehingga perilakunya terlihat pada keluaran nyata, lalu dibandingkan dengan kelas berkeadaan yang mengubah field langsung. Bagian penutup menyatukan pola ini dengan aliran data satu arah yang menjadi prinsip kerja pengelolaan state di Flutter. Hasil akhirnya adalah model yang aman diteruskan ke mana pun dan perubahannya selalu dapat dijelaskan.

Kelas model tak berubah#

Model adalah kelas yang membawa data, misalnya satu butir tugas pada jadwal piket. Pola immutability menuntut seluruh field model dinyatakan final, sehingga isinya tidak dapat diganti setelah objek dibuat, dan perubahan dilakukan dengan membuat objek baru alih-alih menyunting objek lama. Kelas berikut memodelkan satu tugas piket dengan dua field, yaitu nama dan selesai, lengkap dengan method copyWith dan fungsi main() untuk membuktikan perilakunya. Simpan berkas sebagai tugas_model.dart, lalu jalankan perintah dart tugas_model.dart dari direktori tempat berkas itu berada.

dart tugas_model.dart
// Kelas Tugas tak berubah dengan copyWith untuk jadwal piket.
class Tugas {
  const Tugas({required this.nama, required this.selesai});

  final String nama;
  final bool selesai;

  // copyWith mengembalikan objek baru, dan field yang tidak diberi nilai ikut aslinya.
  Tugas copyWith({String? nama, bool? selesai}) {
    return Tugas(
      nama: nama ?? this.nama,
      selesai: selesai ?? this.selesai,
    );
  }

  @override
  String toString() => 'Tugas(nama: $nama, selesai: $selesai)';
}

void main() {
  var piket = const Tugas(nama: 'Menyapu lantai', selesai: false);
  print('Awal        : $piket');

  var sudahSelesai = piket.copyWith(selesai: true);
  print('Ditandai    : $sudahSelesai');

  var diganti = piket.copyWith(nama: 'Membuang sampah');
  print('Diganti     : $diganti');

  // Objek lama tidak ikut berubah karena tidak pernah disentuh.
  print('Awal tetap  : $piket');
  print('Objek baru  : ${identical(piket, sudahSelesai)}');
}
text Keluaran
Awal        : Tugas(nama: Menyapu lantai, selesai: false)
Ditandai    : Tugas(nama: Menyapu lantai, selesai: true)
Diganti     : Tugas(nama: Membuang sampah, selesai: false)
Awal tetap  : Tugas(nama: Menyapu lantai, selesai: false)
Objek baru  : false

Keluaran memperlihatkan kontrak pola ini secara utuh. Pemanggilan copyWith(selesai: true) menghasilkan tugas dengan nama yang tetap dan status yang berubah, pemanggilan copyWith(nama: ...) menghasilkan salinan dengan nama baru tanpa menyentuh status, dan objek piket yang paling awal tetap menampilkan nilai semula pada baris keempat. Baris terakhir menegaskan bahwa salinan itu memang objek berbeda, karena identical(piket, sudahSelesai) bernilai false.

Cara kerja copyWith#

Method copyWith bekerja melalui tiga bagian yang saling menopang. Parameternya dinyatakan opsional dan bertipe nullable, yaitu String? nama dan bool? selesai, sehingga pemanggil hanya mengisi field yang ingin diganti. Di dalam tubuhnya, operator ?? menentukan nilai setiap field, yaitu parameter yang terisi digunakan sebagai nilai baru, sedangkan parameter yang dibiarkan null digantikan dengan nilai field lama. Hasilnya selalu objek Tugas baru yang dibangun melalui konstruktor, bukan objek lama yang disunting.

Bentuk ini memberi dua keuntungan yang langsung terasa pada aplikasi. Pertama, setiap perubahan state meninggalkan jejak keadaan sebelumnya, karena objek sebelum perubahan masih utuh dan masih dapat dibaca, sesuatu yang tidak mungkin apabila objek lama langsung disunting. Kedua, perbandingan menjadi akurat, karena dua objek dengan isi berbeda memang berbeda, dan objek yang tidak diubah tetap objek yang sama. Mekanisme Selector pada topik tentang kendali rebuild mengandalkan perbandingan semacam ini untuk menentukan perlunya build ulang, sehingga model tak berubah dan kendali rebuild saling menguatkan.

Kelas berkeadaan dan perubahan langsung#

Kebalikannya adalah kelas berkeadaan, yaitu kelas yang fieldnya dideklarasikan tanpa final sehingga nilainya dapat diisi ulang kapan saja. Program berikut mengulang skenario yang sama dengan kelas seperti itu. Simpan berkas sebagai tugas_berkeadaan.dart, lalu jalankan perintah dart tugas_berkeadaan.dart.

dart tugas_berkeadaan.dart
// Kelas berkeadaan: field diubah langsung pada objek yang sama.
class TugasBerkeadaan {
  TugasBerkeadaan(this.nama, this.selesai);

  String nama;
  bool selesai;

  @override
  String toString() => 'TugasBerkeadaan(nama: $nama, selesai: $selesai)';
}

void main() {
  var piket = TugasBerkeadaan('Menyapu lantai', false);
  print('Awal   : $piket');

  piket.selesai = true; // perubahan terjadi pada objek yang sama
  print('Sesudah: $piket');

  var arsip = piket; // referensi, bukan salinan
  piket.selesai = false;
  print('Arsip  : $arsip');
}
text Keluaran
Awal   : TugasBerkeadaan(nama: Menyapu lantai, selesai: false)
Sesudah: TugasBerkeadaan(nama: Menyapu lantai, selesai: true)
Arsip  : TugasBerkeadaan(nama: Menyapu lantai, selesai: false)

Baris terakhir adalah bukti masalahnya. Variabel arsip seharusnya menyimpan keadaan tugas sebelum perubahan terakhir, tetapi isinya ikut berubah menjadi selesai: false karena yang disimpan hanyalah referensi ke objek yang sama, bukan salinannya. Keadaan sebelum perubahan hilang tanpa jejak, dan bagian aplikasi lain yang sedang membaca objek itu ikut mendapati perubahan tanpa diberi tahu. Pada kelas tak berubah, kejadian semacam ini tidak mungkin terjadi karena objek lama tidak pernah disentuh.

Alur data satu arah dengan model tak berubah#

Pola immutability berperan penuh pada alur data satu arah, yaitu data mengalir turun ke widget dan event naik ke state. Widget menerima objek Tugas melalui konstruktor dan menampilkannya apa adanya, sedangkan perubahan dikirim naik melalui callback menuju kelas state. Kelas state itulah satu-satunya yang boleh mengubah data, dan perubahannya dinyatakan dengan copyWith, bukan penyuntingan langsung.

dart Kelas state membangun list baru dari salinan copyWith
// tidak-dijalankan
class StatePiket extends ChangeNotifier {
  var _tugas = <Tugas>[];

  List<Tugas> get tugas => List.unmodifiable(_tugas);

  void tambah(String nama) {
    _tugas = [..._tugas, Tugas(nama: nama, selesai: false)];
    notifyListeners();
  }

  void tandaiSelesai(String nama) {
    _tugas = [
      for (final t in _tugas)
        if (t.nama == nama) t.copyWith(selesai: true) else t,
    ];
    notifyListeners();
  }
}

Pada method tandaiSelesai, list baru dibangun dari objek lama, dan hanya tugas dengan nama yang cocok yang digantikan salinan berstatus selesai. Widget yang berlangganan kemudian menerima list terbaru pada build berikutnya, dan objek Tugas yang lama tetap utuh bagi bagian mana pun yang masih merujuknya. Model tak berubah juga mudah dibawa keluar dari layar. Objeknya aman diteruskan sebagai argumen antarlayar karena penerima tidak dapat mengubah isinya secara langsung, dan pola inilah yang menjadi dasar penguraian data dari jaringan pada pembahasan navigasi serta pengambilan data dari API.

Kesalahan umum#

  • Parameter copyWith yang tidak nullable. Parameter String nama tanpa tanda ? wajib diisi pada setiap pemanggilan, sehingga tidak ada lagi cara mengganti satu field saja. Nyatakan parameter opsional sebagai String? dan ambil nilainya dengan ??.
  • Lupa operator ?? di dalam copyWith. Pengisian langsung nama: nama menimbulkan error karena String? tidak boleh dimasukkan ke field String, atau apabila dipaksakan menghasilkan null untuk field yang seharusnya tetap. Gunakan nama: nama ?? this.nama untuk setiap field.
  • Menganggap field final melindungi isinya. Yang dilindungi final hanyalah referensinya, sehingga List yang final masih dapat diisi dan dikosongkan. Sediakan salinan yang tidak dapat diubah melalui List.unmodifiable atau bangun list baru pada setiap perubahan.

Ringkasan#

  • Model tak berubah memiliki seluruh field final, dan perubahan dilakukan dengan objek baru, bukan penyuntingan objek lama.
  • copyWith menggunakan parameter nullable dan operator ?? untuk mengganti sebagian field, lalu selalu mengembalikan objek baru yang berbeda dari asalnya.
  • Kelas berkeadaan yang mengubah field langsung menghilangkan jejak keadaan sebelumnya dan membuat referensi lama ikut berubah tanpa diberi tahu.
  • Pada alur data satu arah, widget menerima model melalui konstruktor, sedangkan event naik ke kelas state yang membangun data baru dengan copyWith.

Referensi#

  • Classes — Dart — Dokumentasi resmi kelas Dart yang mencakup field final, konstruktor, dan konstanta waktu kompilasi.
  • Start thinking declaratively — Flutter — Panduan resmi berpikir deklaratif, yaitu antarmuka dibangun ulang dari state dan perubahan dikirim balik sebagai event.