Manfaatkan Sistem Tipe
(dzombak.com)- Saat pemrograman, sistem tipe dapat dimanfaatkan untuk membedakan dengan jelas makna data yang berbeda
- Menggunakan tipe umum apa adanya seperti string atau integer membuat konteks hilang dan dapat berujung pada bug
- Meski tipe dasarnya sama, jika tipe baru didefinisikan sesuai tujuan, kesalahan dapat dicegah lewat error saat compile time
- Dalam library Go libwx, didefinisikan tipe yang membedakan satuan pengukuran secara jelas untuk mencegah kesalahan akibat tercampurnya float64
- Pada contoh kode, tipe UUID dipisahkan menjadi UserID dan AccountID sehingga penggunaan yang salah diblokir oleh compiler
- Bahkan pada bahasa seperti Go yang sistem tipenya tidak terlalu kuat, bug dapat dicegah dengan pembungkusan tipe sederhana
Mari aktif memanfaatkan sistem tipe
Titik awal masalah: tercampurnya tipe sederhana
- Dalam pemrograman, banyak nilai sering direpresentasikan hanya dengan tipe dasar seperti
string,int, atauUUID - Namun ketika skala proyek membesar, kesalahan karena tipe-tipe sederhana ini dipakai bercampur tanpa pembeda yang jelas menjadi makin sering terjadi
- Contoh: string userID tanpa sengaja dikirim sebagai accountID, atau urutan argumen pada fungsi dengan 3 parameter
inttertukar
- Contoh: string userID tanpa sengaja dikirim sebagai accountID, atau urutan argumen pada fungsi dengan 3 parameter
Solusi: definisi tipe yang menampakkan maksud
intataustringhanyalah building block; jika diteruskan begitu saja ke seluruh sistem, konteks maknanya akan hilang- Untuk mencegahnya, kita perlu mendefinisikan tipe unik untuk tiap peran dan menggunakannya
- Contoh:
type AccountID uuid.UUID type UserID uuid.UUID func UUIDTypeMixup() { { userID := UserID(uuid.New()) DeleteUser(userID) // tidak ada error } { accountID := AccountID(uuid.New()) DeleteUser(accountID) // error: tipe AccountID tidak bisa digunakan sebagai UserID } { accountID := uuid.New() DeleteUserUntyped(accountID) // tidak ada compile-time error, kemungkinan besar masalah muncul saat runtime } }
- Contoh:
- Dengan cara ini, argumen dengan tipe yang salah dapat diblokir saat compile time
Contoh penerapan nyata: library libwx
- Penulis menerapkan teknik ini di library Go miliknya, libwx
- Untuk semua satuan pengukuran, ia mendefinisikan tipe khusus dan juga menghubungkan metode konversi satuan ke tipe tersebut
- Contoh: satuan dibedakan dengan jelas lewat metode
Km.Miles()
- Contoh: satuan dibedakan dengan jelas lewat metode
- Berikut contoh bagaimana compiler memblokir urutan argumen fungsi yang salah dan kebingungan satuan:
// deklarasi suhu Fahrenheit temp := libwx.TempF(84) // deklarasi kelembapan relatif (persen) humidity := libwx.RelHumidity(67) // salah mengirim ke fungsi yang meminta suhu Celsius, bukan Fahrenheit fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointC(temp, humidity)) // compiler langsung mendeteksi error type mismatch // temp (tipe TempF) tidak bisa digunakan sebagai TempC // salah urutan argumen saat memanggil fungsi fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointF(humidity, temp)) // compiler mencegah error tipe pada argumen - Jika hanya memakai
float64, semua kesalahan seperti ini bisa saja lolos
Kesimpulan: aktiflah memanfaatkan sistem tipe
- Sistem tipe bukan sekadar alat pemeriksaan sintaks, tetapi alat pencegah bug
- Untuk tiap model, definisikan tipe ID secara terpisah, dan bungkus argumen fungsi dengan tipe yang jelas alih-alih float atau int biasa
- Pendekatan ini sangat efektif dan mudah diimplementasikan, bahkan di bahasa seperti Go yang sistem tipenya tidak terlalu kuat
- Dalam praktik nyata, bug akibat UUID atau string yang tercampur memang sangat sering terjadi
- Penulis menekankan bahwa cukup mengejutkan cara sederhana ini belum umum dipakai dalam kode produksi
Kode terkait
- Contoh lengkap dapat dilihat di GitHub:
https://github.com/cdzombak/libwx_types_lab
8 komentar
Setahu saya, jika digunakan di Kotlin, ada potensi masalah performa karena
primitiveakan dibungkus sebagai wrapper sehingga disimpan di heap, bukan di stack. Tentu saja, dalam sebagian besar use case, maintainability lebih diprioritaskan. Selain itu, masalah performa dapat diminimalkan dengan menggunakan value class.Bahasa Ada memiliki sistem tipe yang sangat baik dalam hal ini. Nilai dengan jenis berbeda dapat dengan mudah dideklarasikan sebagai tipe terpisah, dan saat tercampur, kompilernya dapat menyaringnya dengan baik.
Saya penasaran dan ingin bertanya. Apakah ada kelebihan lain yang membedakannya dari bahasa bertipe lain yang juga populer? (
kotlin,rust,typescript, ...)Kelebihan Ada pada umumnya berada di sisi "lebih baik daripada C". Di C, banyak hal yang dibiarkan karena terlalu percaya pada pengembang dan pembatasannya kecil. Misalnya hal-hal seperti konversi tipe implisit. Tetapi kebanyakan pengembang tampaknya lebih menyukai C karena sudah terbiasa dengannya...
Mungkin ini ciri khas codebase yang saya kerjakan, tetapi kami mendeklarasikan dan menggunakan hampir semuanya sebagai tipe terpisah. Tipe dasar yang dipakai paling hanya untuk indeks array.
Saya mengerti, terima kasih.
Komentar Hacker News
Saya suka pendekatan ini, yakni prinsip 'make bad state unrepresentable', tetapi masalah yang sering muncul dalam pola ini adalah para pengembang berhenti di tahap pertama implementasi tipe: semuanya dijadikan tipe, namun tidak saling cocok dengan baik, lalu muncul banyak tipe yang sedikit berbeda sehingga kode jadi sulit ditelusuri dan dipahami. Dalam situasi seperti itu, saya malah lebih memilih bahasa dinamis yang bertipe lemah (JS) atau bahasa dinamis yang bertipe kuat (Elixir). Namun, jika pengembang terus mendorong alur yang digerakkan tipe—misalnya memindahkan logika percabangan ke union type yang bisa di-pattern-match, serta memanfaatkan delegasi dengan baik—pengalaman pengembangan jadi nyaman lagi. Misalnya, fungsi
DewPointbisa dibuat agar menerima beberapa tipe sekaligus dan tetap bekerja secara alami.Karena itu, saya berharap lebih banyak bahasa mendukung bounded type (tipe dengan batas rentang integer) secara bawaan. Misalnya, alih-alih
x: u32, saya ingin sistem tipe bisa memaksa bahwaxhanya boleh berada dalam rentang [0,10). Dengan begitu, pengecekan batas saat indexing array tidak lagi diperlukan. Untuk kasus sepertiOption, peephole optimization juga jadi jauh lebih mudah. Di Rust, berkat LLVM, sebagian dukungan seperti ini ada di dalam fungsi, tetapi belum didukung saat variabel diteruskan antar fungsi.Sebagai catatan, Ruby itu bukan bertipe lemah, melainkan bertipe kuat. Jika Anda melakukan operasi seperti
1 + "1", yang muncul adalah error sepertiTypeError: String can't be coerced into Integer.'Berhenti di tahap pertama implementasi tipe' itulah penyebab kegagalannya. Contohnya, mulai membungkus
intdalamstructuntuk dipakai sebagai UUID adalah awal yang baik, tetapi jika seseorang bisa mengambil sembarangint, membungkusnya sebagai tipe itu, lalu meneruskannya, maka sifat UUID yang seharusnya unik bisa rusak. Pada akhirnya, yang penting adalah 'Correct by construction'—yakni menjamin kebenaran sejak saat konstruksi. Tipe seperti UUID yang harus unik seharusnya tidak bisa dibuat kecuali memang benar-benar terbukti, entah dengan melempar exception di fungsi atau konstruktor, atau lewat mekanisme lain. Konsep ini berlaku bukan hanya untuk UUID, tetapi untuk tipe apa pun dan invariant apa pun.Belakangan ini saya mengikuti pola Red-Green-Refactor, tetapi alih-alih memakai test yang gagal, saya memperketat sistem tipe agar bug tertangkap oleh type checker. Fitur baru, edge case, atau bug yang tidak bisa dipicu sebagai error tipe tetap saya tangani dengan test, tetapi red-green-refactor yang memanfaatkan sistem tipe umumnya lebih cepat dan bisa sepenuhnya mencegah satu kategori besar bug.
Structural types bisa meredakan sebagian besar masalah ini. Jika benar-benar perlu, kita tetap bisa memaksakannya dengan nominal types.
Sedikit topik yang berdekatan dengan exception dan tipe: saya rasa checked exception sebaiknya dimanfaatkan dengan baik agar penanganannya sesuai per tipe. Saya tidak pernah paham kenapa checked exception di Java begitu banyak dicela. Saat saya memaksa proyek yang saya tangani untuk memakai checked exception, awalnya semua orang membencinya, tetapi setelah terbiasa memikirkan semua kasus exception dalam alur kode, semua jadi menyukainya. Untuk unit test kami memang tidak seketat itu, tetapi proyeknya menjadi sangat kokoh.
Keluhan terhadap checked exception di Java muncul karena penanganan exception terlalu merepotkan. Penulis library tidak bisa dengan jelas memutuskan checked exception mana yang tepat, dan di sisi klien, setiap kali memanggil fungsi, kita jadi harus menangani exception yang terasa tidak perlu. Kalau exception bisa dengan mudah dikonversi ke tipe lain atau ke runtime exception, atau cukup dideklarasikan di tingkat modul/aplikasi, masalah ini akan jauh berkurang, tetapi sekarang terlalu merepotkan. Selain itu, karena mudah merusak signature, kita terpaksa memakai exception per domain, dan Java juga membuat konversi exception terasa canggung. Checked exception itu bagus, tetapi usability penanganan exception di Java tidak saya sukai.
Checked exception dicela karena terlalu sering disalahgunakan. Menurut saya Java mendukung checked maupun unchecked exception itu keputusan yang baik. Namun, akan lebih tepat jika checked exception hanya dipakai untuk exception 'exogenous' seperti yang dijelaskan Eric Lippert, sementara sebagian besar lainnya diubah menjadi unchecked. Misalnya, koneksi DB memang bisa putus kapan saja, tetapi terlalu merepotkan jika
throws SQLExceptionharus terus muncul di seluruh call stack. Cukup tangani dengan catch-all di level paling atas dan kembalikan HTTP 500. Tulisan terkaitChecked exception (dibanding unchecked) berarti jika sebuah fungsi jauh di dalam call stack diubah agar melempar exception, maka bukan hanya fungsi penanganannya, tetapi juga semua fungsi di antaranya mungkin harus ikut diubah. Artinya, fleksibilitas berkurang ketika sistem berubah. Kontroversi soal coloring pada fungsi async juga punya konteks serupa. Jika suatu fungsi bisa melempar exception, kita harus membungkusnya dengan
try/catch, atau pemanggilnya juga harus mendeklarasikan bahwa ia melempar exception.C# mengadopsi tipe yang jelas tetapi exception yang unchecked. Hasilnya stack error bisa tetap bersih dan tidak ada masalah. Menurut saya ini lebih rapi daripada exception handler yang dipattern-match lalu melakukan penanganan bespoke di tiap level. Jika ada error result dengan unwrapping yang kuat, hasilnya mungkin serupa.
Di Java, memang ada masalah usability pada checked type. Misalnya saat memakai stream API, kalau fungsi
map/filtermelempar checked exception, situasinya benar-benar merepotkan. Jika beberapa pemanggilan service masing-masing punya checked exception sendiri, akhirnya kita terpaksa menangkapExceptionatau menulis daftar exception yang absurd panjangnya.Secara umum saya setuju dengan kebijakan 'membuat tipe khusus', tetapi saya juga sering merasa kesulitan dalam sistem di mana semuanya adalah tipe khusus, terutama ketika kode yang hanya memindahkan byte bercampur dengan kode perhitungan domain.
Saya paham perasaan itu. Datanya sebenarnya sudah ada, tetapi pertama-tama kita harus mencari cara membuat tipe atau membuat instansnya, jadi kalau tidak ada resepnya rasanya seperti berperang dengan dokumentasi. Misalnya kita punya objek
{x, y, z}, tetapi tetap harus memanggil fungsicreateVector(x, y, z): Vector, lalu untuk membuatFaceharus lagi memakai sesuatu seperticreateFace(vertices: Vector[]): Face, sehingga prosedurnya terasa tidak perlu jadi panjang. Pada library seperti BouncyCastle pun, walaupun array byte sudah siap, kita tetap harus membuat beberapa tipe dan memakai method antar tipe itu sebelum bisa menjalankan fungsi yang benar-benar kita inginkan.Di Go, cukup mudah untuk mengembalikan type alias ke tipe aslinya (misalnya
AccountID→int). Jika strukturnya disusun dengan benar, kita bisa memakai gaya clean architecture: logika domain menggunakan type alias, sementara library yang tidak peduli domain menangani higher/lower type lewat konversi. Tetapi, ini memerlukan sangat banyak kode konversi.Phantom types berguna dalam kasus seperti ini. Kita menambahkan type parameter (yakni generic), tetapi parameter itu sebenarnya tidak dipakai di mana pun. Dulu saat menulis kode kriptografi di Scala, semua array tetap berupa byte, tetapi phantom type mencegah semuanya tercampur. Contoh terkait
Secara ideal, akan bagus jika compiler cukup memeriksa tipenya, lalu menurunkan sisa logika domain menjadi sekadar penyalinan byte sederhana—kalau saya memang memahami maksud Anda dengan benar.
Saya rasa hukum 80/20 juga berlaku pada sistem tipe. Jika diterapkan secara berlebihan, penggunaan library jadi terasa berat dan manfaat nyatanya hampir tidak ada. UUID atau String masih terasa familier, tetapi hal seperti
AccountIDatauUserIDtidak, jadi ada biaya pembelajaran tambahan. Elaborate type system bisa bernilai, bisa juga tidak—terutama jika test sudah memadai. Referensi terkaitToh untuk memakai software, kita memang harus tahu apa itu Account atau User, jadi menurut saya fungsi seperti
getAccountByIdyang menerimaAccountIdtidak lebih sulit dipahami daripada fungsi yang menerima UUID.Sebenarnya
Stringhanyalah kumpulan byte dan tidak punya makna apa pun. Jika tipenyaAccountID, kebanyakan orang langsung tahu itu berarti 'ID akun'. Kalau benar-benar ingin tahu representasi internalnya, tinggal lihat definisi tipenya, tetapi di sebagian besar konteks cukup tahu bahwa ituAccountID. Intinya, tipe dengan nama yang jelas memang membuat pemakaian jadi tidak membingungkan. Tautan grugbrain.dev malah terasa terlalu dasar. Kalau benar-benar 'grug brain', justru pemisahan tipe seperti ini akan didukung.Bentuk
foo(AccountId, UserId)jauh lebih baik daripadafoo(UUID, UUID). Ia lebih self-explanatory, dan compiler bisa menangkap kesalahan jika kita tanpa sengaja menukar urutan argumennya. Bahkan pada struktur data yang kompleks pun hal ini tetap jelas tanpa perlu membuat tipe baru.Soal pernyataan 'kalau sudah UUID atau String berarti sudah familier', pada praktiknya sering tidak jelas UUID itu disimpan/dikonversi dalam bentuk seperti GUIDv1, UUIDv4, UUIDv7, atau yang lain. Dari pengalaman saya, pada kombinasi Java + MS SQL, saya pernah harus turun tangan langsung karena masalah konversi endianness antara UUID dan
uniqueidentifier. Saya curiga ini masalah yang mirip dengan kekacauan auto-conversion timezone pada database.Sebenarnya mengetahui tipe-tipe semacam ini memang sesuatu yang pada akhirnya tetap perlu dilakukan. Kalau tidak, kita hanya akan meneruskan data yang salah ke dalam fungsi.
Baru-baru ini tim kami juga mencoba menerapkan tipe pada beberapa nilai numerik yang tercampur di kode C++. Awalnya karena sedang mencari dan memperbaiki bug, lalu kami memperkenalkan tipe yang aman, dan ternyata itu sekaligus mengungkap tiga lokasi lain dengan kesalahan penggunaan nilai yang mirip.
Library mp-units(dokumentasi resmi mp-units) mengingatkan saya pada contoh yang berfokus pada masalah satuan fisika seperti ini. Dengan tipe satuan yang kuat, kita bisa memperoleh keamanan sekaligus mengotomatiskan logika konversi satuan yang kompleks, dan juga menangani berbagai unit melalui kode generik. Saya sempat ingin membawa ini ke dunia Prolog, tetapi rekan-rekan di sekitar saya tidak terlalu antusias. Contoh untuk Prolog
Dulu saya pernah mengerjakan proyek yang menangani banyak besaran fisik—jarak, kecepatan, suhu, tekanan, dan lain-lain—tetapi semuanya hanya dilewatkan sebagai
float, jadi kalau nilai jarak dimasukkan ke tempat kecepatan, compiler tidak protes dan bug baru muncul saat runtime. Masalah yang sama juga terjadi jika satuan yang salah dikirim, misalnya km/h vs miles/h. Saya ingin menambah tipe agar masalah seperti ini tertangkap sejak tahap pengembangan, tetapi saat itu saya masih junior dan sulit meyakinkan orang lain.Saya dulu sempat menyerah karena takut penerapan tipe per satuan fisika akan terlalu rumit, tetapi sekarang saya berencana melihat mp-units. Khususnya karena sering muncul masalah saat variabel tidak secara jelas menunjukkan satuannya. Pada data eksternal maupun fungsi standar, tidak adanya penanda satuan itu sangat umum.
Di C#, saya membuat tipe seperti berikut:
Maka,
Dengan cara ini, kita bisa membedakan berbagai ID integer yang berbeda. Ini juga bisa diperluas menjadi
IdGuidatauIdString, dan untuk marker type (M) baru, cukup tambahkan satu baris saja. Saya juga memakai variasi serupa di TypeScript dan Rust.Saya pernah memakai pola yang mirip. Lalu untuk ID berbasis
int,enumtampaknya punya friction paling rendah, tetapi saya merasa itu akan terlalu membingungkan sehingga tidak pernah saya masukkan ke kode sungguhan. Diskusi terkaitPola ini disebut 'phantom type' karena nilai
MFooatauMBartidak benar-benar ada saat runtime.Ada juga library seperti Vogen untuk tujuan ini. Vogen adalah singkatan dari Value Object Generator, dan mendukung penambahan tipe value object melalui code generation. Di README-nya juga ada library serupa dan tautannya.
Saya pernah melihat pendekatan ini sebelumnya tetapi tidak tahu tujuannya. Hari ini pun, saat menulis fungsi yang menerima tiga argumen string, saya sempat bingung apakah harus memaksa parsing tipe di awal atau melakukannya di dalam fungsi. Ternyata dalam situasi itu saya bahkan tidak membutuhkan nilai hasil parsingnya, jadi metode ini persis jawaban yang saya cari. Mungkin ini akan jadi pengaruh terbesar pada gaya coding saya tahun ini.
Teman saya Lukas pernah merangkum ide ini dengan istilah 'Safety Through Incompatibility'. Saya menerapkan pola ini di seluruh kode golang dan merasa sangat terbantu. Ini mencegah kesalahan pengiriman ID sejak awal.
Tulisan terkait 1
Tulisan terkait 2
Di Swift memang ada keyword
typealias, tetapi jika tipe dasarnya sama maka keduanya tetap bisa saling dikonversi dengan bebas, jadi secara praktis kurang cocok untuk tujuan ini. Wrapper struct cukup idiomatis di Swift, dan jika digabung denganExpressibleByStringLiteral, penggunaannya lumayan nyaman. Namun akan bagus jika ada keyword baru seperti 'strong typealias' (typecopy, misalnya) untuk menyatakan 'ini memang String biasa, tetapi String dengan makna khusus, jadi jangan dicampur dengan String lain'.Pada praktiknya kebanyakan bahasa memang seperti ini; misalnya rust/c/c++ juga demikian. Menyenangkan kalau kita tidak perlu membuat tipe wrapper seperti pada contoh Go. Di C++, kita juga harus ekstra hati-hati karena jika konstruktor tidak ditandai
explicit,intbisa bebas dimasukkan ke tempat yang mengharapkan tipeFoo.Secara teori ini terlihat elegan, tetapi penerapan di dunia nyata bisa rumit. Misalnya bagaimana dengan memasukkannya ke
std::cout, atau kompatibilitas dengan fungsi pihak ketiga maupun extension point yang sebelumnya menerimaString—hal-hal praktis seperti itu perlu dipikirkan.Haskell punya konsep seperti ini lewat
newtype. Dalam bahasa OOP, jika tipenya tidakfinal, kita bisa dengan mudah membuat subclass untuk menambahkan atau mengkhususkan perilaku yang diinginkan. Ini murah dan sederhana tanpa wrapper tambahan atau boxing. Namun di Java,Stringitufinal, sehingga pendekatan ini sulit dipakai dan spesialisasi langsung terhadapStringtidak mudah.Secara spesifik, saya penasaran seperti apa perilaku yang Anda inginkan agar berbeda dari wrapper struct.
Rust juga digunakan dengan cara seperti ini, dan memang terasa bagus.
Kalau memakai bahasa dengan sistem tipe yang bagus, bukankah hal seperti ini juga bisa dicegah..
Hilangnya NASA Mars Climate Orbiter pada September 1999