Lewatkan u64 user ID ke fungsi yang mengharapkan order ID, dan Rust tidak akan protes. Keduanya adalah u64. Compiler melihat tipe yang identik, jadi ia tidak bisa membantu Anda. Anda mengetahuinya saat runtime, biasanya di production, biasanya setelah refactor yang Anda pikir aman.

Inilah kelas bug yang tepat untuk dihilangkan oleh newtype.

Newtype adalah tuple struct satu-field yang membungkus tipe yang sudah ada: struct UserId(u64);. Saat compile time, UserId dan OrderId tidak kompatibel. Saat runtime, mereka menempati byte yang persis sama dengan u64 murni. Tidak ada alokasi ekstra, tidak ada indirection, tidak ada biaya.

Masalah apa yang sebenarnya dipecahkan ini?

Setiap bahasa dengan static types memiliki masalah ini. Anda memiliki dua nilai dengan representasi yang sama tetapi makna yang berbeda. Database key, physical unit, jumlah mata uang, persentase versus raw count. Di C Anda akan menggunakan typedef, di Go type alias, tetapi itu hanyalah nama untuk tipe underlying yang sama. Compiler masih memperlakukannya sebagai identik.

Pattern newtype Rust berbeda. struct UserId(u64); membuat tipe yang berbeda. Anda tidak bisa melewatkan UserId di mana OrderId diharapkan. Anda tidak bisa secara tidak sengaja menambahkan persentase ke raw count. Error muncul saat compile time, bukan di production incident.

Inilah yang dimaksud orang dengan “membuat illegal state tidak dapat direpresentasikan.” Ini bukan teoretis. Saya pernah melihat endpoint API yang menerima account ID dan transfer amount, keduanya u64. Sebuah refactor menukarnya di caller. Tidak ada yang rusak saat compilation. Uang berpindah ke tempat yang salah. Dengan newtype, refactor itu akan gagal compile sebelum ada yang mendeploynya.

Bagaimana newtype bekerja di balik layar

Newtype di Rust hanyalah struct dengan satu field tanpa nama:

struct UserId(u64);
struct OrderId(u64);

Compiler memperlakukan UserId sebagai tipe yang sepenuhnya terpisah dari OrderId dan dari u64. Anda membangunnya secara eksplisit: let id = UserId(42);. Anda mengakses nilai dalamnya dengan id.0.

Karena struct memiliki satu field dengan ukuran yang diketahui, compiler menerapkan optimasi yang sebenarnya bukan optimasi sama sekali, hanya cara kerja struct. Memory layout UserId identik dengan u64. Ukuran sama, alignment sama, ABI sama.

Anda dapat memverifikasi ini sendiri:

use std::mem;

assert_eq!(mem::size_of::<UserId>(), mem::size_of::<u64>());
assert_eq!(mem::align_of::<UserId>(), mem::align_of::<u64>());

Tidak ada vtable, tidak ada discriminant, tidak ada wrapper object. Machine code yang dihasilkan untuk melewatkan UserId ke fungsi identik dengan melewatkan u64. Type system menegakkan perbedaannya. Runtime menghapusnya.

Contoh konkret: mencampuradukkan pixel dan point

Berikut adalah pattern yang pernah menggigit saya. Sebuah graphics library memiliki jarak dalam pixel dan device-independent point. Keduanya adalah f32. Saya melewatkan point di mana pixel diharapkan, dan UI saya dirender dengan ukuran setengah di layar high-DPI. Compiler diam karena keduanya adalah f32.

Dengan newtype:

struct Pixels(f32);
struct Points(f32);

fn scale_to_pixels(points: Points, dpi: f32) -> Pixels {
    Pixels(points.0 * dpi / 96.0)
}

fn draw_line(length: Pixels) {
    // render at this pixel length
}

fn main() {
    let width = Points(150.0);
    let dpi = 192.0;

    // This compiles:
    draw_line(scale_to_pixels(width, dpi));

    // This does not:
    // draw_line(width);
    // error: expected `Pixels`, found `Points`
}

Compiler menolak kesalahan sebelum Anda menjalankan program. Nilai Points dan Pixels menggunakan empat byte yang sama dengan f32. Safety tidak memerlukan biaya apa pun saat runtime.

Trade-off yang tidak diceritakan siapa pun kepada Anda

Newtype tidak gratis untuk ditulis. Method tipe yang dibungkus tidak otomatis dipropagasikan. u64 memiliki wrapping_add, leading_zeros, puluhan method. UserId murni tidak memiliki satupun kecuali Anda mengimplementasikannya sendiri.

Anda memiliki tiga pilihan, dan hanya satu yang bagus.

Pilihan 1: Implementasikan Deref. Ini memberi Anda semua method tipe dalamnya melalui auto-deref:

use std::ops::Deref;

struct UserId(u64);

impl Deref for UserId {
    type Target = u64;
    fn deref(&self) -> &u64 { &self.0 }
}

Ini berfungsi, tetapi mengalahkan tujuannya. Deref memungkinkan implicit coercion dari UserId ke u64, yang berarti Anda bisa melewatkan UserId di mana pun u64 diharapkan. Anda kehilangan type safety yang baru saja Anda dapatkan. Jangan lakukan ini untuk newtype.

Pilihan 2: Implementasikan semuanya secara manual. Ini membosankan, tetapi Anda hanya mengekspos operasi yang masuk akal untuk domain Anda. Untuk tipe ID, mungkin Anda hanya memerlukan Display, Debug, PartialEq, Eq, dan Hash:

use std::fmt;

#[derive(Debug, PartialEq, Eq, Hash)]
struct UserId(u64);

impl fmt::Display for UserId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

Untuk tipe numerik dengan aritmatika, Anda mengimplementasikan Add, Sub, dan seterusnya. Ini adalah boilerplate. Macro derive standar dan crate seperti derive_more membantu, tetapi ini masih lebih banyak kode daripada tipe raw.

Pilihan 3: Gunakan nilai dalamnya secara langsung saat Anda membutuhkannya. Ini adalah preferensi saya. Pertahankan newtype di batas API, unwrap dengan .0 saat Anda membutuhkan nilai raw, dan hindari Deref sepenuhnya. Ini sedikit lebih verbose, tetapi mempertahankan jaminan safety.

Macro derive yang membuat ini dapat ditoleransi

Standard library Rust memberikan #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] secara gratis. Untuk aritmatika, Anda mengambil std::ops atau crate seperti derive_more:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);

#[derive(Debug, Clone, Copy, PartialEq)]
struct Meters(f64);

impl std::ops::Add for Meters {
    type Output = Meters;
    fn add(self, other: Meters) -> Meters {
        Meters(self.0 + other.0)
    }
}

Compiler masih menghasilkan machine code yang sama dengan penjumlahan f64 raw. Trait Add juga merupakan zero-cost abstraction. Ia monomorphizes untuk meng-inline operasi tersebut.

Kapan newtype adalah alat yang salah

Tidak setiap primitive membutuhkan newtype. Jika sebuah fungsi menerima timeout_ms: u64 dan hanya digunakan secara lokal, membungkusnya menambahkan noise tanpa menangkap bug yang nyata. Cadangkan newtype untuk nilai yang melintasi batas API, persist di database, atau merepresentasikan konsep di mana mencampurkannya memiliki konsekuensi nyata.

Serialization juga menjadi aneh. Jika Anda menggunakan serde, UserId(u64) secara default diserialisasi sebagai map {"0": 42}, bukan sebagai 42. Anda memerlukan #[serde(transparent)] untuk memperbaikinya:

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct UserId(u64);

Ini mudah dilupakan dan menyebalkan untuk di-debug ketika JSON API Anda tiba-tiba mengharapkan objek, bukan angka.

Mulai dari API publik Anda

Temukan fungsi apa pun yang menerima beberapa argumen dari tipe primitive yang sama tetapi makna yang berbeda. Bungkus yang melintasi batas module atau service. Jangan derive Deref. Gunakan #[serde(transparent)] jika Anda melakukan serialization.

Jika Anda memiliki cargo-expand yang terinstal, jalankan cargo expand pada newtype dan lihat kode yang dihasilkan. Anda akan melihat struct hanyalah nilai raw dengan nama tipe yang berbeda. Klaim zero-cost bukan marketing. Ini secara harfiah adalah apa yang dihasilkan compiler.