Desain mengutamakan tipe

Desain mengutamakan tipe: model null-safe dan hierarki sealed sejak awal.

Daftar isi

Topik ini merancang model untuk alat konversi satuan dengan prinsip desain mengutamakan tipe, yaitu membuat keadaan yang tidak sah tidak dapat diwakili oleh kode sama sekali. Modelnya menyatukan enum satuan, kelas dengan konstruktor yang menjamin keadaan sah, hierarki sealed untuk hasil penguraian, record untuk nilai agregat, dan switch eksaustif sebagai penjaga kelengkapan. Setiap potongan dieksekusi dengan Dart SDK 3.13, termasuk bukti bahwa analyzer menolak kasus switch yang hilang.

Enum satuan dan konstruktor yang menjamin keadaan sah#

Langkah pertama desain mengutamakan tipe adalah memilih tipe yang mempersempit kemungkinan sejak deklarasi. Satuan panjang pada alat ini hanya empat, sehingga satuan diwakili enum SatuanPanjang dan bukan String. Dengan enum, kesalahan ketik seperti kilomoter tidak dapat dikompilasi sama sekali, sedangkan String mengizinkan nilai apa pun hingga error baru muncul saat program berjalan. Setiap nilai enum membawa faktor konversinya ke meter, sehingga data dan perilakunya tersimpan di satu tempat.

Batasan berikutnya menyangkut nilai panjangnya. Kelas NilaiPanjang menampung sebuah bilangan beserta satuannya, dengan kedua bidangnya final dan non-nullable sesuai null safety. Konstruktor menolak nilai negatif maupun NaN dengan melempar ArgumentError, sehingga setiap objek NilaiPanjang yang berhasil dibuat pasti berada dalam keadaan sah. Tanpa pemeriksaan ini, keadaan tidak sah baru tertangkap jauh setelah objek beredar di banyak fungsi. Blok berikut menunjukkan kedua jaminan itu, yaitu konstruksi yang sah dan konstruksi yang ditolak.

dart Contoh: enum satuan dan konstruktor yang menjamin keadaan sah
// Enum membatasi satuan hanya pada empat nilai yang didefinisikan.
enum SatuanPanjang {
  meter(1.0),
  kilometer(1000.0),
  kaki(0.3048),
  mil(1609.344);

  const SatuanPanjang(this.faktorKeMeter);

  final double faktorKeMeter;
}

// Konstruktor menolak keadaan yang tidak sah sejak awal.
class NilaiPanjang {
  NilaiPanjang(this.nilai, this.satuan) {
    if (nilai.isNegative || nilai.isNaN) {
      throw ArgumentError('nilai panjang tidak boleh negatif atau NaN');
    }
  }

  final double nilai;
  final SatuanPanjang satuan;

  double get dalamMeter => nilai * satuan.faktorKeMeter;
}

void main() {
  final jarak = NilaiPanjang(42.195, SatuanPanjang.kilometer);
  print('jarak: ${jarak.dalamMeter.toStringAsFixed(1)} m');

  try {
    NilaiPanjang(-5, SatuanPanjang.meter);
  } on ArgumentError catch (error) {
    print('ditolak: ${error.message}');
  }
}
text Keluaran
jarak: 42195.0 m
ditolak: nilai panjang tidak boleh negatif atau NaN

Keluaran memperlihatkan dua hasil yang berbeda. Objek jarak yang sah menghitung panjangnya dalam meter melalui getter dalamMeter, sedangkan usaha membuat NilaiPanjang dengan nilai minus ditolak sebelum objek mana pun beredar. Pemeriksaan seperti ini tampak kecil, tetapi setiap fungsi pada bagian berikutnya bebas dari percabangan untuk menolak masukan, karena keadaan sahnya sudah dijamin di pintu masuk.

Hierarki sealed untuk hasil penguraian#

Masukan alat konversi datang dalam bentuk teks seperti 5 kilometer ke mil, dan teks semacam itu dapat gagal diuraikan dalam banyak cara. Formatnya bisa salah, angkanya bisa bukan bilangan, atau satuannya bisa di luar daftar. Dua pendekatan yang lazim namun lemah adalah mengembalikan null ketika gagal dan melempar exception untuk setiap kegagalan. Mengembalikan null menyembunyikan penyebabnya, sedangkan exception memaksa try-catch di setiap tempat pemanggilan dan membuat alur pembacaan terputus-putus.

Desain mengutamakan tipe menawarkan jalan ketiga, yaitu menjadikan hasil penguraian sebagai tipe tersendiri. Kelas sealed HasilParsing memiliki tepat dua subkelas, yaitu Sukses yang membawa nilai beserta dua satuannya dan Gagal yang membawa pesan kegagalan. Modifier sealed menutup hierarki pada satu library sehingga compiler mengetahui seluruh subkelas yang mungkin ada. Fungsi parse mengembalikan HasilParsing dan tidak pernah melempar exception, sedangkan pemanggilnya menangani kedua keadaan melalui switch dengan pattern objek.

dart parsing.dart
// Hasil penguraian permintaan konversi hanya berupa Sukses atau Gagal.
sealed class HasilParsing {}

class Sukses extends HasilParsing {
  Sukses(this.nilai, this.dari, this.ke);

  final double nilai;
  final String dari;
  final String ke;
}

class Gagal extends HasilParsing {
  Gagal(this.pesan);

  final String pesan;
}

const satuanDikenal = {'meter', 'kilometer', 'kaki', 'mil'};

HasilParsing parse(String masukan) {
  final bagian = masukan.split(' ke ');
  if (bagian.length != 2) {
    return Gagal('format tidak dikenal: $masukan');
  }
  final komponen = bagian[0].trim().split(' ');
  if (komponen.length != 2) {
    return Gagal('format nilai tidak dikenal: ${bagian[0].trim()}');
  }
  final nilai = double.tryParse(komponen[0]);
  if (nilai == null || nilai < 0) {
    return Gagal('nilai tidak sah: ${komponen[0]}');
  }
  final dari = komponen[1];
  final ke = bagian[1].trim();
  if (!satuanDikenal.contains(dari) || !satuanDikenal.contains(ke)) {
    return Gagal('satuan tidak dikenal pada: $masukan');
  }
  return Sukses(nilai, dari, ke);
}

void main() {
  final masukan = [
    '5 kilometer ke mil',
    'tiga meter ke kaki',
    '2,5 kilometer ke meter',
    '120 kaki ke meter',
  ];
  for (final teks in masukan) {
    switch (parse(teks)) {
      case Sukses(:final nilai, :final dari, :final ke):
        print('OK    $nilai $dari -> $ke');
      case Gagal(:final pesan):
        print('GAGAL $pesan');
    }
  }
}
text Keluaran
OK    5.0 kilometer -> mil
GAGAL nilai tidak sah: tiga
GAGAL nilai tidak sah: 2,5
OK    120.0 kaki -> meter

Keluaran memuat empat baris yang mewakili dua keadaan hasil. Baris OK melaporkan nilai yang berhasil diuraikan beserta pasangan satuannya, sedangkan baris GAGAL menyebut penyebabnya secara persis, misalnya nilai tiga yang bukan bilangan atau 2,5 yang menggunakan koma desimal. Pesan selengkap ini hanya mungkin karena setiap kegagalan memperoleh tempatnya sendiri sebagai tipe, bukan sebagai nilai null atau exception yang harus ditangkap.

Record untuk nilai agregat#

Setelah hasil penguraian menjadi tipe, nilai agregat dari sekelompok hasil dapat dihitung dengan mudah. Agregat yang berguna untuk alat kecil adalah jumlah keberhasilan dan kegagalan pada satu kumpulan masukan. Data semacam itu hanya berupa dua bilangan bulat yang berpasangan, sehingga kelas baru tampak berlebihan hanya untuk membungkusnya. Record menyelesaikan masalah ini, yaitu mengemas beberapa nilai menjadi satu nilai bertipe tanpa mendeklarasikan kelas.

Tipe kembalian fungsi ringkas ditulis (int sukses, int gagal). Nama bidang pada bentuk ini berperan sebagai dokumentasi, sedangkan tipe recordnya tetap sepasang int sesuai urutan. Fungsi ringkas pada berkas berikut menghitung pasangan itu melalui switch eksaustif, sedangkan main membongkarnya langsung ke dua variabel melalui destrukturisasi. Berkas agregat.dart mengimpor parsing.dart dari folder yang sama, sehingga keduanya perlu diletakkan berdampingan sebelum dart run agregat.dart dijalankan.

dart agregat.dart
import 'parsing.dart';

// Nilai agregat sekelompok hasil penguraian dikemas sebagai record.
(int sukses, int gagal) ringkas(List<HasilParsing> hasil) {
  var sukses = 0;
  var gagal = 0;
  for (final item in hasil) {
    switch (item) {
      case Sukses():
        sukses++;
      case Gagal():
        gagal++;
    }
  }
  return (sukses, gagal);
}

void main() {
  final masukan = [
    '5 kilometer ke mil',
    'tiga meter ke kaki',
    '2,5 kilometer ke meter',
    '120 kaki ke meter',
    '-1 kilometer ke mil',
  ];
  final (sukses, gagal) = ringkas(masukan.map(parse).toList());
  print('sukses: $sukses, gagal: $gagal');
}
text Keluaran
sukses: 2, gagal: 3

Keluaran menampilkan sukses 2 dan gagal 3 dari lima masukan yang diproses. Tiga kegagalan terdiri atas nilai tiga yang bukan bilangan, nilai 2,5 dengan koma desimal, dan nilai minus yang ditolak. Angka agregat semacam ini berguna sebagai laporan singkat, misalnya pada akhir pemrosesan satu berkas arsip, tanpa perlu menambah kelas baru yang hanya digunakan sekali.

Switch eksaustif sebagai penjaga kelengkapan#

Keuntungan terbesar dari hierarki sealed baru tampak ketika hierarki berubah. Apabila satu subkelas ditambahkan, misalnya keadaan Kosong untuk masukan kosong, compiler harus memastikan seluruh switch yang mengonsumsi HasilParsing ikut menangani subkelas baru itu. Pemeriksaan ini berjalan saat analisis, jauh sebelum program dieksekusi. Blok berikut sengaja menuliskan switch yang hanya menangani Sukses, kemudian menjalankan dart analyze untuk memperlihatkan reaksi analyzer.

bash
# buat berkas dengan satu kasus yang hilang, lalu periksa dengan dart analyze
rm -rf /tmp/uji/desain-mengutamakan-tipe/hilang && mkdir -p /tmp/uji/desain-mengutamakan-tipe/hilang
cat > /tmp/uji/desain-mengutamakan-tipe/hilang/hilang.dart <<'EOF'
sealed class HasilParsing {}

class Sukses extends HasilParsing {
  Sukses(this.nilai);

  final double nilai;
}

class Gagal extends HasilParsing {
  Gagal(this.pesan);

  final String pesan;
}

String deskripsi(HasilParsing hasil) => switch (hasil) {
      Sukses(:final nilai) => 'sukses mengurai $nilai',
    };

void main() {
  print(deskripsi(Sukses(5)));
}
EOF
cd /tmp/uji/desain-mengutamakan-tipe/hilang
export PATH=/home/kal/dart-sdk/bin:$PATH
dart analyze
text Keluaran
Analyzing hilang...

  error - hilang.dart:15:41 - The type 'HasilParsing' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Gagal()'. Try adding a wildcard pattern or cases that match 'Gagal()'. - non_exhaustive_switch_expression

1 issue found.

Pesan error menyebut tipe HasilParsing yang tidak dicocokkan dengan pattern Gagal() beserta saran perbaikannya. Lokasi yang dilaporkan, yaitu baris 15 pada hilang.dart, menunjuk ekspresi switch yang kekurangan kasus. Kode error non_exhaustive_switch_expression membuat penyebabnya mudah dicari pada dokumentasi. Perbaikannya sederhana, yaitu menambahkan kasus Gagal, dan versi yang sudah lengkap berikut ini dieksekusi untuk memastikan kedua keadaan ditangani.

dart Contoh: switch yang sudah mencakup kedua keadaan
sealed class HasilParsing {}

class Sukses extends HasilParsing {
  Sukses(this.nilai);

  final double nilai;
}

class Gagal extends HasilParsing {
  Gagal(this.pesan);

  final String pesan;
}

String deskripsi(HasilParsing hasil) => switch (hasil) {
      Sukses(:final nilai) => 'sukses mengurai $nilai',
      Gagal(:final pesan) => 'gagal: $pesan',
    };

void main() {
  print(deskripsi(Sukses(5)));
  print(deskripsi(Gagal('nilai bukan angka')));
}
text Keluaran
sukses mengurai 5.0
gagal: nilai bukan angka

Kedua baris keluaran membuktikan bahwa kedua keadaan kini memiliki penanganan. Penjaga kelengkapan semacam ini bekerja tanpa konfigurasi apa pun, karena sumbernya adalah modifier sealed pada hierarki yang dirancang sejak awal. Inilah inti desain mengutamakan tipe, yaitu alat yang menolak kode yang belum tuntas sebelum kode itu sempat dijalankan.

Ringkasan#

  • Enum SatuanPanjang membatasi satuannya pada empat nilai dan kelas NilaiPanjang menolak nilai negatif maupun NaN, sehingga objek yang berhasil dibuat selalu sah.
  • Hierarki sealed HasilParsing mewakili hasil penguraian dengan subkelas Sukses dan Gagal, dan fungsi parse tidak pernah melempar exception.
  • Record (int sukses, int gagal) mengemas nilai agregat tanpa kelas baru, dan destrukturisasi membongkarnya langsung ke dua variabel.
  • Analyzer menolak switch yang tidak mencakup seluruh subkelas sealed, sehingga kasus yang hilang tertangkap sebelum program dijalankan.

Referensi#