Desain API

Signature yang ramah ownership dan pola builder.

Daftar isi

Topik ini membahas desain API dari sisi kepemilikan data, yaitu aturan memilih antara meminjam dan memindah pada setiap parameter, tipe Cow untuk hasil yang kadang cukup dipinjam dan kadang harus dimiliki, serta pola pembangun (builder) untuk konstruktor dengan banyak opsi. Fokusnya adalah signature yang tidak memaksa pemanggil mengkloning data atau melepaskan nilai yang masih diperlukan. Seluruh contoh memakai pustaka standar sehingga dapat dikompilasi langsung oleh rustc. Setelah membaca topik ini, tipe parameter dapat dipilih dengan aturan yang jelas, dan konstruktor dengan banyak opsi dapat disusun sebagai rantai pemanggilan yang ringkas sekaligus aman terhadap konfigurasi tidak valid.

Meminjam untuk membaca, memindah untuk menyimpan#

Aturan dasarnya ditentukan oleh lamanya fungsi memerlukan nilai yang diterimanya. Fungsi yang hanya membaca nilai selama pemanggilan berlangsung cukup meminjam melalui &str, &[T], atau &T. Fungsi yang menyimpan nilai agar hidup lebih lama daripada pemanggilannya, misalnya konstruktor yang menaruh data ke dalam struct, sebaiknya meminta kepemilikan berupa String, Vec<T>, atau T. Pinjaman membuat pemanggil tetap memegang nilainya, sedangkan pemindahan menyerahkan tanggung jawab alokasi ke struct tujuan tanpa salinan di tengah jalan.

Aturan tersebut menjaga biaya tetap rendah bagi fungsi maupun pemanggilnya. Apabila fungsi pembaca meminta kepemilikan, setiap pemanggil harus mengkloning atau rela kehilangan nilainya. Sebaliknya, apabila konstruktor meminta pinjaman lalu menyimpan, fungsi itu terpaksa mengalokasikan salinan sendiri walaupun pemanggil mungkin sudah tidak memerlukan nilai aslinya. Tabel berikut merangkum pilihan yang umum.

Kebutuhan pada fungsiBentuk parameterContoh pemakaian
hanya membaca nilai&str, &[T], &Tpencarian kata, perbandingan
menyimpan nilai lebih lama dari pemanggilanString, Vec<T>, Tkonstruktor struct
menerima literal sekaligus nilai milik sendiriimpl Into<String>pengisi pembangun
hasil kadang sama dengan masukanCow<'_, str>pembersihan teks

Program berikut memperlihatkan ketiga bentuk tersebut pada satu struct sederhana. Konstruktor baru memindahkan String ke dalam struct tanpa salinan, metode cocok hanya membaca melalui pinjaman, dan fungsi salam menerima impl Into<String> agar pemanggil dapat melewatkan literal maupun nilai milik sendiri.

rust pinjam-vs-pindah.rs
struct Tugas {
    nama: String,
}

impl Tugas {
    // menyimpan: kepemilikan dipindah, tidak ada salinan
    fn baru(nama: String) -> Tugas {
        Tugas { nama }
    }

    // membaca: cukup pinjaman, pemanggil tetap memegang nilainya
    fn cocok(&self, kata: &str) -> bool {
        self.nama.to_lowercase().contains(kata)
    }
}

// masukan lentur: menerima &str maupun String tanpa konversi eksplisit
fn salam<S: Into<String>>(nama: S) -> String {
    format!("selamat datang, {}", nama.into())
}

fn main() {
    let teks = String::from("susun laporan keuangan");
    let tugas = Tugas::baru(teks);

    println!("{}", salam("Citra"));
    println!("{}", salam(String::from("Dewi")));
    println!("cocok \"laporan\": {}", tugas.cocok("laporan"));
    println!("nama tugas: {}", tugas.nama);
}
text Keluaran
selamat datang, Citra
selamat datang, Dewi
cocok "laporan": true
nama tugas: susun laporan keuangan

Pemanggilan Tugas::baru(teks) memindahkan isi teks ke dalam struct, dan baris terakhir membuktikan nilainya tersimpan utuh di sana tanpa pengkloningan. Kedua pemanggilan salam memakai jalur yang berbeda, yaitu literal &str yang dikonversi Into::into menjadi String milik fungsi, dan String yang dipindahkan langsung. Metode cocok meminjam nama tugas hanya selama pemeriksaan, sehingga struct tetap dapat dipakai setelahnya. Bentuk seperti inilah yang membuat API terasa ringan dari sisi pemanggil.

Masukan lentur dengan Cow#

Sebagian fungsi menghasilkan nilai yang kadang sama persis dengan masukannya dan kadang merupakan teks baru. Fungsi pembersih nama yang hanya memangkas spasi tepi tidak mengubah isi, sedangkan masukan yang memuat spasi ganda di tengah memerlukan teks hasil sambungan. Enum Cow<'a, str> menyediakan dua variant untuk kedua keadaan itu, yaitu Borrowed yang membungkus &'a str dan Owned yang membungkus String. Kembalian bertipe Cow menyatakan bahwa keputusan meminjam atau memiliki diambil pada saat program berjalan sesuai isi masukan.

rust masukan-cow.rs
use std::borrow::Cow;

fn rapikan(nama: &str) -> Cow<'_, str> {
    let padat = nama.trim();
    let rapat = padat.split_whitespace().collect::<Vec<&str>>().join(" ");
    if rapat == padat {
        Cow::Borrowed(padat)
    } else {
        Cow::Owned(rapat)
    }
}

fn main() {
    for nama in ["Ali Budi   Dewi", "  Citra  ", "Budi"] {
        let hasil = rapikan(nama);
        let bentuk = match &hasil {
            Cow::Borrowed(_) => "dipinjam",
            Cow::Owned(_) => "dimiliki",
        };
        println!("{:?} -> {} -> \"{}\"", nama, bentuk, hasil);
    }
}
text Keluaran
"Ali Budi   Dewi" -> dimiliki -> "Ali Budi Dewi"
"  Citra  " -> dipinjam -> "Citra"
"Budi" -> dipinjam -> "Budi"

Masukan pertama memiliki spasi berlebih di tengah sehingga teks baru harus dibangun dan variant Owned yang dipakai. Dua masukan berikutnya hanya mengalami pemangkasan tepi atau tidak berubah sama sekali, sehingga fungsi cukup mengembalikan potongan pinjaman tanpa alokasi baru. Pola yang sama berlaku untuk parameter, yaitu fungsi yang menerima Cow<'_, str> dapat dipanggil dengan literal tanpa salinan sekaligus dengan String yang dipindahkan. Kebutuhan seperti ini muncul pada pustaka yang memproses teks masukan sekaligus menjaga jalur cepat bagi masukan yang sudah rapi.

Pola pembangun untuk banyak parameter#

Konstruktor dengan lima parameter atau lebih sulit dibaca karena urutan posisi harus dihafal, dan nilai bawaan terpaksa ditulis eksplisit pada setiap pemanggilan. Pola pembangun mengatasinya dengan struct antara yang medannya bertipe Option. Setiap pengisi menuliskan satu field lalu mengembalikan pembangun itu sendiri, dan metode bangun menjadi satu-satunya tempat nilai akhir dibuat sekaligus divalidasi. Tipe Antrean berikut mencontohkan pola tersebut untuk pengaturan antrean tiket loket.

rust pembangun-antrean.rs
use std::error::Error;
use std::fmt;

struct Antrean {
    kapasitas: usize,
    jumlah_loket: usize,
    awalan_tiket: String,
}

impl Antrean {
    fn builder() -> AntreanBuilder {
        AntreanBuilder::baru()
    }

    fn uraikan(&self) -> String {
        format!(
            "kapasitas {}, loket {}, awalan {}",
            self.kapasitas, self.jumlah_loket, self.awalan_tiket
        )
    }
}

#[derive(Debug)]
enum ErrorPembangun {
    KapasitasNol,
    AwalanKosong,
}

impl fmt::Display for ErrorPembangun {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ErrorPembangun::KapasitasNol => write!(f, "kapasitas harus lebih dari 0"),
            ErrorPembangun::AwalanKosong => write!(f, "awalan tiket tidak boleh kosong"),
        }
    }
}

impl Error for ErrorPembangun {}

struct AntreanBuilder {
    kapasitas: Option<usize>,
    jumlah_loket: Option<usize>,
    awalan_tiket: Option<String>,
}

impl AntreanBuilder {
    fn baru() -> AntreanBuilder {
        AntreanBuilder {
            kapasitas: None,
            jumlah_loket: None,
            awalan_tiket: None,
        }
    }

    // memindah dirinya sendiri agar pemanggilan dapat dirantai
    fn kapasitas(mut self, nilai: usize) -> AntreanBuilder {
        self.kapasitas = Some(nilai);
        self
    }

    fn jumlah_loket(mut self, nilai: usize) -> AntreanBuilder {
        self.jumlah_loket = Some(nilai);
        self
    }

    fn awalan_tiket(mut self, nilai: impl Into<String>) -> AntreanBuilder {
        self.awalan_tiket = Some(nilai.into());
        self
    }

    // validasi terkumpul di satu titik, nilai gagal dibangun tidak pernah ada
    fn bangun(self) -> Result<Antrean, ErrorPembangun> {
        let kapasitas = self.kapasitas.unwrap_or(100);
        let jumlah_loket = self.jumlah_loket.unwrap_or(1);
        let awalan_tiket = self.awalan_tiket.unwrap_or_else(|| String::from("LKT"));

        if kapasitas == 0 {
            return Err(ErrorPembangun::KapasitasNol);
        }
        if awalan_tiket.trim().is_empty() {
            return Err(ErrorPembangun::AwalanKosong);
        }

        Ok(Antrean {
            kapasitas,
            jumlah_loket,
            awalan_tiket,
        })
    }
}

fn main() {
    let siap = Antrean::builder()
        .awalan_tiket("TIK")
        .kapasitas(100)
        .jumlah_loket(3)
        .bangun();

    match siap {
        Ok(antrean) => println!("antrean siap: {}", antrean.uraikan()),
        Err(error) => println!("antrean gagal: {}", error),
    }

    let rusak = Antrean::builder().kapasitas(0).bangun();

    match rusak {
        Ok(_) => println!("tidak seharusnya berhasil"),
        Err(error) => println!("antrean gagal: {}", error),
    }
}
text Keluaran
antrean siap: kapasitas 100, loket 3, awalan TIK
antrean gagal: kapasitas harus lebih dari 0

Rantai pemanggilan pada main membaca seperti daftar pengaturan, dan setiap pengisi bebas diurutkan maupun ditinggalkan. Nilai bawaan ditetapkan oleh unwrap_or di dalam bangun, sehingga pemanggil yang tidak menulis jumlah_loket tetap memperoleh satu loket. Pengisi memakai signature mut self yang mengembalikan Self, sehingga pembangun dipindahkan dari satu pemanggilan ke pemanggilan berikutnya tanpa pinjaman yang saling menahan. Karena bangun mengembalikan Result, konfigurasi kapasitas(0) ditolak di satu titik dan nilai Antrean yang tidak sah tidak pernah terbentuk. Diagram berikut merangkum alur data pada pola ini.

pembangun menunda pembuatan nilai sampai validasi selesaiAntrean::builder()semua field Nonemetode pengisi berantaimut self -> Selfbangun()validasi sekaliOk(Antrean)nilai siap dipakaiErr(ErrorPembangun)nilai tidak dibuat
Alur pola pembangun dari pembangun kosong hingga nilai akhir beserta cabang kegagalannya

Kesalahan umum#

Kesalahan pertama adalah menulis parameter &String atau &Vec<T>. Bentuk itu memaksa pemanggil mengalokasikan nilai milik sendiri sebelum nilai tersebut dapat dipinjam, padahal fungsi hanya membaca. rustc menerima bentuk tersebut tanpa peringatan, sehingga kebiasaan buruk ini mudah terbawa ke seluruh API. Gunakan &str dan &[T], yang keduanya diterima secara otomatis dari referensi ke nilai milik sendiri melalui deref coercion. Linter clippy menandai bentuk keliru itu lewat lint ptr_arg yang aktif secara bawaan.

Kesalahan kedua adalah meminta kepemilikan untuk operasi yang hanya membaca. Signature seperti fn hitung_kata(teks: String) -> usize memaksa setiap pemanggil mengkloning argumennya atau kehilangan nilainya demi satu angka hitungan. Pinjamkan dengan &, dan pindahkan hanya ketika nilai benar-benar disimpan di dalam struct atau collection.

Kesalahan ketiga adalah kebalikannya pada konstruktor, yaitu menerima &str lalu menyalinnya sendiri dengan to_string di dalam badan fungsi. Pemanggil yang sudah selesai dengan nilainya sebenarnya dapat memindahkan nilai tersebut langsung, sehingga salinan di dalam badan fungsi menjadi beban yang tidak perlu. Terima String atau impl Into<String> agar pemanggil dapat memindahkan nilainya, sementara pemanggil yang hanya memegang pinjaman tetap terlayani lewat konversi otomatis Into.

Ringkasan#

  • Fungsi yang hanya membaca meminjam lewat &str atau &[T], fungsi yang menyimpan meminta kepemilikan lewat String atau Vec<T>, dan impl Into<String> menerima literal sekaligus nilai milik sendiri.
  • Cow<'_, str> memilih variant Borrowed atau Owned saat program berjalan, sehingga salinan hanya terjadi ketika isi benar-benar berubah.
  • Pola pembangun menampung banyak opsi pada field Option, memakai pengisi mut self yang mengembalikan Self agar rantai pemanggilan utuh, dan menetapkan nilai bawaan di bangun.
  • Metode bangun yang mengembalikan Result menjadi satu-satunya titik validasi, sehingga nilai tidak sah tidak pernah terbentuk.

Referensi#