Struct #
Struct adalah cara utama Rust untuk membuat tipe data kustom yang bermakna dalam domain masalahmu. Berbeda dari kelas di bahasa OOP tradisional, struct di Rust hanya menyimpan data — perilaku ditambahkan secara terpisah melalui blok impl, dan polimorfisme dicapai lewat trait, bukan inheritance. Pemisahan ini bukan keterbatasan: ia justru mendorong desain yang lebih komposabel dan lebih mudah diuji, karena data dan perilaku bisa dikembangkan secara independen. Artikel ini membahas tiga jenis struct, cara mendefinisikan method dan constructor, pengelolaan visibilitas, serta pola idiomatis seperti builder pattern dan newtype yang sering muncul di kode Rust produksi.
Tiga Jenis Struct #
Rust memiliki tiga bentuk struct yang masing-masing cocok untuk situasi berbeda:
flowchart TD
S[Struct di Rust]
S --> A["Named Struct\nField bernama\nPaling umum digunakan"]
S --> B["Tuple Struct\nField diakses via indeks\nUntuk tipe wrapper sederhana"]
S --> C["Unit Struct\nTanpa field\nUntuk marker types dan trait"]Named Struct #
Bentuk paling umum — setiap field punya nama dan tipe yang eksplisit:
struct Pengguna {
nama: String,
email: String,
usia: u8,
aktif: bool,
}
fn main() {
// Inisialisasi — semua field harus diisi
let user = Pengguna {
nama: String::from("Budi Santoso"),
email: String::from("[email protected]"),
usia: 28,
aktif: true,
};
// Akses field dengan dot notation
println!("Nama: {}", user.nama);
println!("Email: {}", user.email);
// Struct mutable — semua field ikut mutable
let mut user2 = Pengguna {
nama: String::from("Sari"),
email: String::from("[email protected]"),
usia: 25,
aktif: true,
};
user2.usia = 26; // ubah field
println!("Usia baru: {}", user2.usia);
}
Field Shorthand #
Ketika nama variabel lokal sama dengan nama field, kamu tidak perlu menulis nama: nama:
fn buat_pengguna(nama: String, email: String) -> Pengguna {
Pengguna {
nama, // field shorthand: setara dengan nama: nama
email, // setara dengan email: email
usia: 0,
aktif: true,
}
}
Struct Update Syntax #
Buat instance baru berdasarkan instance yang sudah ada, hanya ubah field yang berbeda:
struct Pengguna {
nama: String,
email: String,
usia: u8,
aktif: bool,
}
fn main() {
let user1 = Pengguna {
nama: String::from("Budi"),
email: String::from("[email protected]"),
usia: 28,
aktif: true,
};
// Salin semua field dari user1 kecuali email
let user2 = Pengguna {
email: String::from("[email protected]"),
..user1 // sisa field dari user1
};
// PERHATIAN: user1.nama sudah di-move ke user2 (String tidak Copy)
// println!("{}", user1.nama); // error: nilai sudah di-move
println!("{}", user2.nama); // "Budi" — berasal dari user1
println!("{}", user2.email); // "[email protected]"
// Field dengan tipe Copy (u8, bool) tetap bisa diakses dari user1
// println!("{}", user1.usia); // ✓ karena u8 adalah Copy type
}
Tuple Struct #
Field diakses dengan indeks (.0, .1, dst), cocok untuk tipe wrapper tipis yang memberikan makna semantik pada tipe primitif:
struct Meter(f64);
struct Kilogram(f64);
struct Warna(u8, u8, u8); // RGB
fn cetak_jarak(jarak: Meter) {
println!("{} meter", jarak.0);
}
fn main() {
let tinggi = Meter(1.75);
let berat = Kilogram(70.0);
let merah = Warna(255, 0, 0);
cetak_jarak(tinggi);
println!("{} kg", berat.0);
println!("RGB: {}, {}, {}", merah.0, merah.1, merah.2);
// ANTI-PATTERN: mencampur Meter dengan Kilogram tanpa tuple struct
// Tanpa tuple struct, keduanya hanya f64 — compiler tidak bisa membedakan
fn jarak_salah(d: f64) {}
// jarak_salah(berat.0); // tidak ada yang mencegah ini di compile time
// BENAR: dengan tuple struct, tipe berbeda tidak bisa dicampur
// cetak_jarak(berat); // error[E0308]: expected Meter, found Kilogram ✓
}
Unit Struct #
Struct tanpa field, berguna sebagai marker type atau untuk mengimplementasikan trait tanpa perlu menyimpan data:
// Marker struct — menandai tipe tanpa data tambahan
struct Terverifikasi;
struct BelumTerverifikasi;
struct Email<Status> {
alamat: String,
_status: std::marker::PhantomData<Status>,
}
// Unit struct sebagai implementor trait
struct Logger;
trait Catat {
fn catat(&self, pesan: &str);
}
impl Catat for Logger {
fn catat(&self, pesan: &str) {
println!("[LOG] {}", pesan);
}
}
fn main() {
let logger = Logger;
logger.catat("Aplikasi dimulai");
logger.catat("Proses selesai");
}
Blok impl — Method dan Associated Function
#
Perilaku struct ditambahkan lewat blok impl. Satu struct bisa punya beberapa blok impl — Rust menggabungkannya secara otomatis.
Associated Function sebagai Constructor #
Associated function tidak menerima self — dipanggil dengan NamaStruct::nama_fungsi(). Paling sering digunakan sebagai constructor:
#[derive(Debug)]
struct Persegi {
sisi: f64,
}
impl Persegi {
// Constructor standar — konvensi nama `new`
fn new(sisi: f64) -> Self {
assert!(sisi > 0.0, "Sisi harus positif");
Persegi { sisi }
}
// Constructor alternatif dengan nama deskriptif
fn dari_luas(luas: f64) -> Self {
assert!(luas > 0.0, "Luas harus positif");
Persegi { sisi: luas.sqrt() }
}
// Konstanta terkait — diakses dengan Persegi::SISI_DEFAULT
const SISI_DEFAULT: f64 = 1.0;
}
fn main() {
let p1 = Persegi::new(5.0);
let p2 = Persegi::dari_luas(25.0); // sisi = 5.0 juga
println!("{:?}", p1);
println!("{:?}", p2);
println!("Default: {}", Persegi::SISI_DEFAULT);
}
Method Instance — &self, &mut self, self
#
Method instance selalu punya parameter pertama yang merujuk ke instance itu sendiri:
#[derive(Debug, Clone)]
struct PersegPanjang {
lebar: f64,
tinggi: f64,
}
impl PersegPanjang {
fn new(lebar: f64, tinggi: f64) -> Self {
PersegPanjang { lebar, tinggi }
}
// &self — membaca data, tidak mengubah
fn luas(&self) -> f64 {
self.lebar * self.tinggi
}
fn keliling(&self) -> f64 {
2.0 * (self.lebar + self.tinggi)
}
fn diagonal(&self) -> f64 {
(self.lebar.powi(2) + self.tinggi.powi(2)).sqrt()
}
fn adalah_persegi(&self) -> bool {
(self.lebar - self.tinggi).abs() < f64::EPSILON
}
// &mut self — mengubah state instance
fn skalakan(&mut self, faktor: f64) {
self.lebar *= faktor;
self.tinggi *= faktor;
}
fn putar(&mut self) {
std::mem::swap(&mut self.lebar, &mut self.tinggi);
}
// self (tanpa &) — mengkonsumsi instance, return self baru
// Berguna untuk builder pattern
fn dengan_lebar(mut self, lebar: f64) -> Self {
self.lebar = lebar;
self
}
// Method yang menerima instance lain sebagai parameter
fn bisa_muat(&self, lain: &PersegPanjang) -> bool {
self.luas() >= lain.luas()
}
}
fn main() {
let mut p = PersegPanjang::new(10.0, 5.0);
println!("Luas: {}", p.luas());
println!("Keliling: {}", p.keliling());
println!("Diagonal: {:.2}", p.diagonal());
println!("Persegi? {}", p.adalah_persegi());
p.skalakan(2.0);
println!("Setelah skala 2x: {:?}", p);
p.putar();
println!("Setelah diputar: {:?}", p);
// Builder-style dengan method chaining
let p2 = PersegPanjang::new(1.0, 1.0)
.dengan_lebar(8.0);
println!("p2: {:?}", p2);
println!("p bisa muat p2? {}", p.bisa_muat(&p2));
}
Method Chaining (Builder Pattern) #
Method yang mengembalikan Self memungkinkan pemanggilan berantai — pola yang sangat umum untuk konfigurasi objek:
#[derive(Debug)]
struct KonfigurasiServer {
host: String,
port: u16,
maks_koneksi: u32,
timeout_detik: u64,
tls_aktif: bool,
}
impl KonfigurasiServer {
fn baru() -> Self {
KonfigurasiServer {
host: String::from("127.0.0.1"),
port: 8080,
maks_koneksi: 100,
timeout_detik: 30,
tls_aktif: false,
}
}
fn host(mut self, host: &str) -> Self {
self.host = host.to_string();
self
}
fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
fn maks_koneksi(mut self, maks: u32) -> Self {
self.maks_koneksi = maks;
self
}
fn timeout(mut self, detik: u64) -> Self {
self.timeout_detik = detik;
self
}
fn dengan_tls(mut self) -> Self {
self.tls_aktif = true;
self
}
}
fn main() {
// Semua nilai default
let server_lokal = KonfigurasiServer::baru();
// Konfigurasi produksi dengan chaining
let server_produksi = KonfigurasiServer::baru()
.host("0.0.0.0")
.port(443)
.maks_koneksi(1000)
.timeout(60)
.dengan_tls();
println!("{:?}", server_lokal);
println!("{:?}", server_produksi);
}
Visibilitas Field #
Secara default semua field struct adalah private. Tambahkan pub untuk mengeksposnya ke luar modul:
mod akun {
pub struct RekeningBank {
pub pemilik: String, // bisa dibaca dari luar modul
pub nomor: String, // bisa dibaca dari luar modul
saldo: f64, // PRIVATE — hanya bisa diakses dari dalam modul
}
impl RekeningBank {
pub fn baru(pemilik: &str, nomor: &str, saldo_awal: f64) -> Self {
RekeningBank {
pemilik: pemilik.to_string(),
nomor: nomor.to_string(),
saldo: saldo_awal,
}
}
pub fn saldo(&self) -> f64 {
self.saldo // expose saldo hanya via method baca
}
pub fn setor(&mut self, jumlah: f64) -> Result<(), String> {
if jumlah <= 0.0 {
return Err(String::from("Jumlah setor harus positif"));
}
self.saldo += jumlah;
Ok(())
}
pub fn tarik(&mut self, jumlah: f64) -> Result<f64, String> {
if jumlah <= 0.0 {
return Err(String::from("Jumlah tarik harus positif"));
}
if jumlah > self.saldo {
return Err(format!("Saldo tidak cukup: {}", self.saldo));
}
self.saldo -= jumlah;
Ok(jumlah)
}
}
}
fn main() {
let mut rek = akun::RekeningBank::baru("Budi", "001-234-567", 1_000_000.0);
println!("Pemilik: {}", rek.pemilik); // ✓ field pub
println!("Nomor: {}", rek.nomor); // ✓ field pub
// println!("{}", rek.saldo); // ✗ error: field private
println!("Saldo: {}", rek.saldo()); // ✓ via method pub
rek.setor(500_000.0).unwrap();
println!("Setelah setor: {}", rek.saldo());
match rek.tarik(200_000.0) {
Ok(jumlah) => println!("Berhasil tarik: {}", jumlah),
Err(e) => println!("Gagal: {}", e),
}
}
Derive Macro — Trait Otomatis #
Rust menyediakan #[derive(...)] untuk mengimplementasikan trait umum secara otomatis berdasarkan struktur field. Ini menghindari boilerplate yang berulang:
#[derive(
Debug, // println!("{:?}", ...) dan println!("{:#?}", ...)
Clone, // .clone() untuk membuat salinan
PartialEq, // == dan !=
PartialOrd, // <, >, <=, >=
)]
struct Titik {
x: f64,
y: f64,
}
#[derive(Debug, Clone, PartialEq)]
struct Segitiga {
a: Titik,
b: Titik,
c: Titik,
}
fn main() {
let p1 = Titik { x: 0.0, y: 0.0 };
let p2 = Titik { x: 3.0, y: 4.0 };
let p3 = p1.clone();
println!("{:?}", p1); // Debug
println!("{:?}", p2);
println!("p1 == p3: {}", p1 == p3); // PartialEq
println!("p1 < p2: {}", p1 < p2); // PartialOrd
let t1 = Segitiga {
a: Titik { x: 0.0, y: 0.0 },
b: Titik { x: 3.0, y: 0.0 },
c: Titik { x: 0.0, y: 4.0 },
};
let t2 = t1.clone();
println!("t1 == t2: {}", t1 == t2); // PartialEq pada struct bersarang
}
| Derive | Mengaktifkan | Kapan digunakan |
|---|---|---|
Debug | {:?} dan {:#?} | Hampir selalu — untuk debugging |
Clone | .clone() | Ketika butuh salinan eksplisit |
Copy | Copy semantics otomatis | Tipe kecil (semua field harus Copy) |
PartialEq | == dan != | Perbandingan kesamaan |
Eq | Garansi kesamaan total | Bersama PartialEq untuk HashMap |
PartialOrd | <, >, <=, >= | Pengurutan parsial |
Ord | .sort(), .min(), .max() | Pengurutan total |
Hash | Digunakan di HashMap / HashSet | Bersama Eq |
Default | Struct::default() | Nilai default untuk semua field |
Ownership di Dalam Struct #
Field struct ikut aturan ownership. Struct yang menyimpan referensi perlu lifetime annotation — ini topik lanjutan, tapi penting diketahui dari awal:
// ANTI-PATTERN: struct yang menyimpan &str tanpa lifetime
// struct NamaTanpaLifetime {
// nama: &str, // error: missing lifetime specifier
// }
// BENAR: gunakan String (owned) jika struct perlu punya datanya sendiri
#[derive(Debug)]
struct Produk {
nama: String, // owned — struct punya string ini
harga: f64,
stok: u32,
}
// BENAR: atau gunakan lifetime jika struct hanya meminjam data
#[derive(Debug)]
struct ProdukRef<'a> {
nama: &'a str, // borrowed — struct hanya pinjam, tidak punya
harga: f64,
}
fn main() {
// Produk owned — datanya hidup bersama struct
let p = Produk {
nama: String::from("Laptop"),
harga: 15_000_000.0,
stok: 10,
};
// ProdukRef borrowed — datanya harus hidup lebih lama dari struct
let nama = String::from("Monitor");
let pref = ProdukRef {
nama: &nama,
harga: 3_500_000.0,
};
println!("{:?}", p);
println!("{:?}", pref);
}
Newtype Pattern #
Tuple struct satu field sering digunakan sebagai newtype — membungkus tipe primitif untuk memberikan makna semantik dan keamanan tipe tambahan:
struct UserId(u64);
struct OrderId(u64);
struct Rupiah(f64);
struct Dolar(f64);
fn proses_pesanan(user: UserId, order: OrderId, total: Rupiah) {
println!(
"User {} memesan #{} seharga Rp{:.0}",
user.0, order.0, total.0
);
}
fn konversi_ke_rupiah(dolar: Dolar, kurs: f64) -> Rupiah {
Rupiah(dolar.0 * kurs)
}
fn main() {
let user = UserId(1001);
let order = OrderId(5042);
let harga_dolar = Dolar(99.99);
let kurs = 15_800.0;
let harga_rupiah = konversi_ke_rupiah(harga_dolar, kurs);
proses_pesanan(user, order, harga_rupiah);
// ANTI-PATTERN: tanpa newtype, mudah tertukar
fn proses_tanpa_tipe(user_id: u64, order_id: u64) {}
// proses_tanpa_tipe(5042, 1001); // tertukar urutan — tidak ada error!
// BENAR: dengan newtype, compiler menangkap kesalahan ini
// proses_pesanan(OrderId(5042), UserId(1001), ...); // error tipe ✓
}
Ringkasan #
- Tiga jenis struct — named struct (field bernama, paling umum), tuple struct (field diakses via indeks, untuk newtype), unit struct (tanpa field, untuk marker/trait).
implblock terpisah dari definisi struct — data dan perilaku dipisahkan secara eksplisit, berbeda dari kelas OOP. Boleh ada beberapaimplblock untuk satu struct.- Associated function untuk constructor — gunakan
Struct::new(...)sebagai konvensi. Tidak menerimaself, dipanggil dengan::.- Tiga varian
self—&selfuntuk membaca,&mut selfuntuk mengubah,self(tanpa ref) untuk mengkonsumsi instance (berguna di builder pattern).- Field shorthand —
Struct { nama, email }saat nama variabel lokal sama dengan nama field.- Struct update syntax
..instance— salin field yang tidak disebutkan dari instance lain. Perhatikan: field non-Copy akan di-move.- Visibilitas field default private — tambahkan
pubsecara eksplisit pada field yang perlu diakses dari luar modul. Field private mendorong enkapsulasi via method.#[derive(...)]untuk trait umum —Debug,Clone,PartialEq,PartialOrd,Hash,Defaultbisa digenerate otomatis. Hampir selalu tambahkanDebugminimal.- Newtype pattern — tuple struct satu field memberi makna semantik dan keamanan tipe pada primitif, mencegah
UserIdtertukar denganOrderIddi compile time.- Struct owned field lebih sederhana — gunakan
Stringbukan&strdi field struct untuk menghindari lifetime annotation kecuali ada alasan performa yang jelas.