Nama tidak menjamin keamanan. Haskell dan keamanan tipe

Pengembang Haskell berbicara banyak tentang keamanan tipe. Komunitas pengembangan Haskell mendukung gagasan "mendeskripsikan invarian pada tingkat sistem tipe" dan "mengecualikan status tidak valid." Kedengarannya seperti tujuan yang menginspirasi! Namun, tidak sepenuhnya jelas bagaimana cara mencapainya. Hampir setahun yang lalu, saya menerbitkan artikel "Parse, don't validate" - langkah pertama untuk mengisi celah ini.



Artikel tersebut diikuti dengan diskusi yang produktif, tetapi kami tidak pernah dapat mencapai konsensus tentang penggunaan yang benar dari konstruksi tipe baru di Haskell. Idenya cukup sederhana: kata kunci newtype mendeklarasikan jenis pembungkus yang namanya berbeda tetapi secara representatif setara dengan jenis yang dibungkusnya. Sekilas, ini adalah cara yang bisa dimengerti untuk mencapai keamanan tipe. Misalnya, pertimbangkan cara menggunakan deklarasi tipe baru untuk menentukan tipe alamat email:



newtype EmailAddress = EmailAddress Text
      
      





Trik ini memberi kita beberapa arti, dan ketika dikombinasikan dengan konstruktor cerdas dan batas enkapsulasi, ia bahkan dapat memberikan keamanan. Tapi ini adalah jenis keamanan yang sama sekali berbeda. Itu jauh lebih lemah dan berbeda dari yang saya identifikasi setahun yang lalu. Dengan sendirinya, tipe baru hanyalah sebuah alias.



Nama bukan tipe keamanan Β©



Keamanan internal dan eksternal



Untuk menunjukkan perbedaan antara pemodelan data konstruktif (lebih lanjut tentang itu di artikel sebelumnya ) dan pembungkus tipe baru, mari kita lihat contoh. Misalkan kita menginginkan tipe "integer dari 1 hingga 5 inklusif". Pendekatan alami untuk pemodelan konstruktif adalah pencacahan dengan lima kasus:



data OneToFive
  = One
  | Two
  | Three
  | Four
  | Five
      
      





Kemudian kami akan menulis beberapa fungsi untuk mengonversi antara Int dan tipe OneToFive:



toOneToFive :: Int -> Maybe OneToFive
toOneToFive 1 = Just One
toOneToFive 2 = Just Two
toOneToFive 3 = Just Three
toOneToFive 4 = Just Four
toOneToFive 5 = Just Five
toOneToFive _ = Nothing

fromOneToFive :: OneToFive -> Int
fromOneToFive One   = 1
fromOneToFive Two   = 2
fromOneToFive Three = 3
fromOneToFive Four  = 4
fromOneToFive Five  = 5
      
      





Ini akan cukup untuk mencapai tujuan yang dinyatakan, tetapi pada kenyataannya tidak nyaman untuk bekerja dengan teknologi seperti itu. Karena kami telah menemukan tipe yang benar-benar baru, kami tidak dapat menggunakan kembali fungsi numerik yang biasa disediakan oleh Haskell. Karenanya, banyak pengembang lebih suka menggunakan pembungkus tipe baru sebagai gantinya:



newtype OneToFive = OneToFive Int
      
      





Seperti dalam kasus pertama, kita bisa mendeklarasikan fungsi toOneToFive dan fromOneToFive dengan tipe yang identik:



toOneToFive :: Int -> Maybe OneToFive
toOneToFive n
  | n >= 1 && n <= 5 = Just $ OneToFive n
  | otherwise        = Nothing

fromOneToFive :: OneToFive -> Int
fromOneToFive (OneToFive n) = n
      
      





Jika kita meletakkan deklarasi ini dalam modul terpisah dan memilih untuk tidak mengekspor konstruktor OneToFive, API sepenuhnya dapat dipertukarkan. Tampaknya opsi tipe baru lebih sederhana dan lebih aman untuk tipe. Namun, ini tidak sepenuhnya benar.



Mari kita bayangkan bahwa kita sedang menulis fungsi yang menggunakan nilai OneToFive sebagai argumen. Dalam pemodelan konstruktif, fungsi seperti itu membutuhkan pencocokan pola dengan masing-masing dari lima konstruktor. GHC akan menerima definisi yang memadai:



ordinal :: OneToFive -> Text
ordinal One   = "first"
ordinal Two   = "second"
ordinal Three = "third"
ordinal Four  = "fourth"
ordinal Five  = "fifth"
      
      





Tampilan tipe baru berbeda. Newtype tidak tembus cahaya, jadi satu-satunya cara untuk mengamatinya adalah dengan mengubahnya kembali ke Int. Tentu saja, Int dapat berisi banyak nilai lain selain 1-5, jadi kita harus menambahkan pola untuk sisa nilai yang mungkin.



ordinal :: OneToFive -> Text
ordinal n = case fromOneToFive n of
  1 -> "first"
  2 -> "second"
  3 -> "third"
  4 -> "fourth"
  5 -> "fifth"
  _ -> error "impossible: bad OneToFive value"
      
      





Dalam contoh fiksi ini, Anda mungkin tidak melihat masalahnya. Namun demikian, hal itu menunjukkan perbedaan utama dalam jaminan yang diberikan oleh dua pendekatan yang dijelaskan:



  • Tipe data konstruktif memperbaiki invariannya sedemikian rupa sehingga tersedia untuk interaksi lebih lanjut. Ini membebaskan fungsi ordinal dari menangani nilai yang tidak valid, karena tidak lagi dapat diekspresikan.
  • Pembungkus tipe baru menyediakan konstruktor cerdas yang memvalidasi nilai, tetapi hasil boolean dari validasi ini hanya digunakan untuk aliran kontrol; itu tidak disimpan sebagai hasil dari fungsinya. Karenanya, kami tidak dapat menggunakan hasil pemeriksaan ini lebih lanjut dan pembatasan yang diperkenalkan; selama eksekusi berikutnya, kami berinteraksi dengan tipe Int.


Memeriksa kelengkapan mungkin tampak seperti langkah yang tidak perlu, tetapi sebenarnya tidak: mengeksploitasi bug telah menunjukkan kerentanan dalam sistem tipe kami. Jika kita menambahkan konstruktor lain ke tipe data OneToFive, versi ordinal yang menggunakan tipe data konstruktif akan segera menjadi non-lengkap pada waktu kompilasi. Sementara itu, versi lain yang menggunakan pembungkus tipe baru akan terus mengompilasi, tetapi akan rusak pada waktu proses dan beralih ke skenario yang tidak mungkin.



Ini semua adalah konsekuensi dari fakta bahwa pemodelan konstruktif secara inheren bersifat aman; yaitu, properti keamanan disediakan oleh deklarasi tipe. Nilai yang tidak valid memang tidak mungkin untuk diwakili: Anda tidak dapat menampilkan 6 menggunakan salah satu dari 5 konstruktor.



Ini tidak berlaku untuk deklarasi newtype, karena tidak ada perbedaan semantik intrinsik dari Int; nilainya ditentukan secara eksternal melalui konstruktor toOneToFive pintar. Setiap perbedaan semantik yang tersirat oleh tipe baru tidak terlihat oleh sistem tipe. Pengembang hanya mengingat ini.



Mengunjungi kembali daftar yang tidak kosong



Jenis data OneToFive ditemukan, tetapi pertimbangan serupa berlaku untuk skenario lain yang lebih realistis. Pertimbangkan NonEmpty yang saya tulis sebelumnya:



data NonEmpty a = a :| [a]
      
      





Untuk kejelasan, mari kita bayangkan versi NonEmpty, yang dideklarasikan melalui knowtype, dibandingkan dengan daftar biasa. Kita dapat menggunakan strategi konstruktor cerdas biasa untuk menyediakan properti tanpa kekosongan yang diinginkan:



newtype NonEmpty a = NonEmpty [a]

nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs

instance Foldable NonEmpty where
  toList (NonEmpty xs) = xs
      
      





Seperti OneToFive, kami akan segera menemukan konsekuensi tidak dapat menyimpan informasi ini di sistem tipe. Kami ingin menggunakan NonEmpty untuk menulis versi head yang aman, tetapi versi newtype memerlukan pernyataan yang berbeda:



head :: NonEmpty a -> a
head xs = case toList xs of
  x:_ -> x
  []  -> error "impossible: empty NonEmpty value"
      
      





Tampaknya tidak masalah: kemungkinan situasi seperti itu dapat terjadi sangat kecil kemungkinannya. Tetapi argumen seperti itu sepenuhnya bergantung pada kepercayaan pada kebenaran modul yang mendefinisikan NonEmpty, sedangkan definisi konstruktif hanya membutuhkan mempercayai pemeriksaan tipe GHC. Karena kami berasumsi secara default bahwa pemeriksaan jenis berfungsi dengan benar, yang terakhir adalah bukti yang lebih meyakinkan.



Tipe baru sebagai token



Jika Anda menyukai tipe baru, topik ini bisa membuat frustasi. Saya tidak bermaksud bahwa tipe baru lebih baik daripada komentar, meskipun yang terakhir efektif untuk pemeriksaan tipe. Untungnya, situasinya tidak terlalu buruk: tipe baru dapat memberikan keamanan yang lebih lemah.



Batasan abstraksi memberi tipe baru keuntungan keamanan yang sangat besar. Jika konstruktor tipe baru tidak diekspor, itu menjadi buram ke modul lain. Sebuah modul yang mendefinisikan tipe baru (yaitu, "modul rumah") dapat memanfaatkan ini untuk membuat batas kepercayaan tempat invarian internal diberlakukan dengan membatasi klien ke API yang aman.



Kita dapat menggunakan contoh NonEmpty di atas untuk mengilustrasikan teknologi ini. Untuk saat ini, mari kita menahan diri dari mengekspor konstruktor NonEmpty dan menyediakan operasi head dan tail. Kami yakin mereka bekerja dengan baik:



module Data.List.NonEmpty.Newtype
  ( NonEmpty
  , cons
  , nonEmpty
  , head
  , tail
  ) where

newtype NonEmpty a = NonEmpty [a]

cons :: a -> [a] -> NonEmpty a
cons x xs = NonEmpty (x:xs)

nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs

head :: NonEmpty a -> a
head (NonEmpty (x:_)) = x
head (NonEmpty [])    = error "impossible: empty NonEmpty value"

tail :: NonEmpty a -> [a]
tail (NonEmpty (_:xs)) = xs
tail (NonEmpty [])     = error "impossible: empty NonEmpty value"
      
      





Karena satu-satunya cara untuk membuat atau menggunakan nilai NonEmpty adalah dengan menggunakan fungsi di API Data.List.NonEmpty yang diekspor, implementasi di atas mencegah klien melanggar invarian non-kekosongan. Nilai dari tipe baru buram seperti token: modul implementasi mengeluarkan token melalui fungsi konstruktornya, dan token ini tidak memiliki arti internal. Satu-satunya cara untuk melakukan sesuatu yang berguna dengannya adalah dengan membuatnya tersedia untuk fungsi dalam modul yang menggunakannya dan untuk mengambil nilai yang dikandungnya. Dalam hal ini, fungsi tersebut adalah head and tail.



Pendekatan ini kurang efisien daripada menggunakan tipe data konstruktif karena bisa saja salah dan secara tidak sengaja memberikan cara untuk membuat nilai NonEmpty [] yang tidak valid. Untuk alasan ini, pendekatan tipe baru terhadap keamanan tipe tidak dengan sendirinya membuktikan bahwa invarian yang diinginkan berlaku.



Namun, pendekatan ini membatasi area di mana pelanggaran invarian untuk modul pendefinisian dapat terjadi. Untuk memastikan bahwa invariant benar-benar berlaku, diperlukan pengujian API modul menggunakan teknik fuzzing atau pengujian berdasarkan properti.



Kompromi ini bisa sangat berguna. Sulit untuk menjamin invarian yang menggunakan pemodelan data konstruktif, sehingga tidak selalu praktis. Namun, kita perlu berhati-hati agar tidak secara tidak sengaja menyediakan mekanisme untuk memutus invarian. Misalnya, pengembang dapat memanfaatkan kelas tipe kenyamanan GHC yang berasal dari kelas tipe Generik untuk NonEmpty:



{-# LANGUAGE DeriveGeneric #-}

import GHC.Generics (Generic)

newtype NonEmpty a = NonEmpty [a]
  deriving (Generic)
      
      





Hanya satu baris yang menyediakan mekanisme sederhana untuk melintasi batas abstraksi:



ghci> GHC.Generics.to @(NonEmpty ()) (M1 $ M1 $ M1 $ K1 [])
NonEmpty []
      
      





Contoh ini tidak mungkin dalam praktiknya, karena instance Generik turunan secara fundamental merusak abstraksi. Selain itu, masalah seperti itu dapat muncul dalam kondisi lain yang kurang jelas. Misalnya, dengan turunan Baca:



ghci> read @(NonEmpty ()) "NonEmpty []"
NonEmpty []
      
      





Bagi beberapa pembaca, perangkap ini mungkin tampak biasa, tetapi kerentanan seperti itu sangat umum. Terutama untuk tipe data dengan invarian yang lebih kompleks, karena terkadang sulit untuk menentukan apakah didukung oleh implementasi modul. Penggunaan yang tepat dari metode ini membutuhkan perawatan dan perhatian:



  • Semua invarian harus jelas bagi pengelola modul terpercaya. Untuk tipe sederhana seperti NonEmpty, invariannya sudah jelas, tetapi untuk tipe yang lebih kompleks, komentar diperlukan.
  • Setiap perubahan pada modul terpercaya perlu diperiksa karena dapat melemahkan invarian yang diinginkan.
  • Anda harus menahan diri untuk tidak menambahkan celah tidak aman yang dapat membahayakan invarian jika disalahgunakan.
  • Pemfaktoran ulang berkala mungkin diperlukan untuk menjaga area tepercaya tetap kecil. Jika tidak, seiring waktu, kemungkinan interaksi akan meningkat tajam, yang menyebabkan pelanggaran invarian.


Pada saat yang sama, tipe data yang benar menurut konstruksinya tidak memiliki masalah di atas. Invarian tidak dapat dilanggar tanpa mengubah definisi tipe data, ini mempengaruhi program lainnya. Tidak diperlukan upaya pengembang karena pemeriksaan jenis secara otomatis menerapkan invarian. Tidak ada "kode tepercaya" untuk tipe data ini, karena semua bagian program sama-sama tunduk pada batasan yang diberlakukan oleh tipe data.



Di perpustakaan, masuk akal untuk menggunakan konsep keamanan baru (berkat tipe baru) melalui enkapsulasi, karena perpustakaan sering menyediakan blok penyusun yang digunakan untuk membuat struktur data yang lebih kompleks. Pustaka semacam itu biasanya menerima lebih banyak studi dan pengawasan daripada kode aplikasi, terutama karena mereka lebih jarang berubah.



Dalam kode aplikasi, teknik ini masih berguna, tetapi perubahan dalam basis kode produksi dari waktu ke waktu melemahkan batas enkapsulasi, jadi desain harus lebih disukai jika memungkinkan.



Penggunaan lain dari tipe baru, penyalahgunaan dan penyalahgunaan



Bagian sebelumnya menjelaskan penggunaan utama newtype. Namun, dalam praktiknya, tipe baru biasanya digunakan secara berbeda dari yang kami jelaskan di atas. Beberapa dari aplikasi ini dibenarkan, misalnya:



  • Di Haskell, gagasan tentang konsistensi kelas tipe membatasi setiap tipe menjadi satu instance dari kelas mana pun. Untuk tipe yang memungkinkan lebih dari satu contoh berguna, tipe baru adalah solusi tradisional dan dapat digunakan dengan sukses. Misalnya newtypes Sum dan Product dari Data.Monoid menyediakan instance Monoid yang berguna untuk tipe numerik.
  • Demikian juga, tipe baru dapat digunakan untuk memasukkan atau mengubah parameter tipe. Newtype Flip dari Data.Bifunctor.Flip adalah contoh sederhana yang menukar argumen Bifunctor sehingga instance Functor dapat bekerja dengan urutan sebaliknya dari argumen:


newtype Flip p a b = Flip { runFlip :: p b a }
      
      





Tipe baru diperlukan untuk manipulasi semacam ini karena Haskell belum mendukung ekspresi lambda level tipe.



  • Tipe baru transparan dapat digunakan untuk mencegah penyalahgunaan ketika nilai perlu diteruskan antara bagian-bagian program yang jauh dan tidak ada alasan untuk kode perantara untuk memvalidasi nilai. Misalnya, ByteString yang berisi kunci rahasia dapat digabungkan dengan newtype (dengan contoh Show dikecualikan) untuk mencegah kode dicatat secara tidak sengaja atau diekspos.


Semua praktik ini baik, tetapi tidak ada hubungannya dengan keamanan tipe. Poin terakhir sering disalahartikan sebagai keamanan, dan itu memang menggunakan sistem tipe untuk membantu menghindari kesalahan logika. Namun, adalah salah untuk menyatakan bahwa penggunaan semacam itu mencegah penyalahgunaan; bagian mana pun dari program dapat memeriksa nilainya kapan saja.



Terlalu sering, ilusi keamanan ini mengarah pada penyalahgunaan tipe baru secara mencolok. Misalnya, berikut definisi dari basis kode yang saya tangani secara pribadi:



newtype ArgumentName = ArgumentName { unArgumentName :: GraphQL.Name }
  deriving ( Show, Eq, FromJSON, ToJSON, FromJSONKey, ToJSONKey
           , Hashable, ToTxt, Lift, Generic, NFData, Cacheable )
      
      





Dalam hal ini, tipe baru adalah langkah yang tidak berguna. Secara fungsional, ini benar-benar dapat dipertukarkan dengan tipe Nama, sedemikian rupa sehingga menghasilkan selusin kelas tipe! Di mana pun tipe baru digunakan, tipe baru itu meluas segera setelah diambil dari rekaman penutup. Jadi tidak ada manfaatnya untuk mengetik keamanan dalam kasus ini. Selain itu, tidak jelas mengapa menunjuk newtype sebagai ArgumentName, jika nama bidang sudah menjelaskan perannya.



Bagi saya, penggunaan tipe baru ini muncul dari keinginan untuk menggunakan sistem tipe sebagai cara taksonomi (klasifikasi) dunia. Nama argumen lebih spesifik dari pada nama generik, jadi tentunya harus memiliki tipenya sendiri. Pernyataan ini masuk akal, tetapi salah: taksonomi berguna untuk mendokumentasikan bidang yang diminati, tetapi tidak selalu berguna untuk pemodelannya. Saat memprogram, kami menggunakan tipe untuk tujuan yang berbeda:



  • Terutama, tipe menyoroti perbedaan fungsional antar nilai. Nilai tipe NonEmpty a secara fungsional berbeda dari nilai tipe [a] karena pada dasarnya berbeda dalam struktur dan memungkinkan operasi tambahan. Dalam pengertian ini, tipe bersifat struktural; mereka menjelaskan nilai-nilai apa yang ada di dalam bahasa pemrograman.
  • -, , . Distance Duration, - , , .


Perhatikan bahwa kedua tujuan ini pragmatis; mereka memahami sistem tipe sebagai alat. Ini adalah sikap yang cukup alami, karena sistem tipe statis secara harfiah adalah sebuah alat. Namun demikian, sudut pandang ini tampaknya tidak biasa bagi kami, meskipun penggunaan tipe untuk mengklasifikasikan dunia biasanya menciptakan noise yang tidak berguna seperti ArgumentName.



Mungkin tidak terlalu praktis jika tipe baru sepenuhnya transparan dan dibungkus dan diterapkan kembali ke dalamnya sesuai keinginan. Dalam kasus khusus ini, saya akan sepenuhnya mengesampingkan perbedaan dan menggunakan Nama, tetapi dalam situasi di mana label yang berbeda jelas, Anda selalu dapat menggunakan jenis alias:



type ArgumentName = GraphQL.Name
      
      





Tipe baru ini adalah cangkang asli. Melewati beberapa langkah bukanlah tipe aman. Percayalah, pengembang akan dengan senang hati melompat tanpa berpikir dua kali.



Kesimpulan dan bacaan yang direkomendasikan



Saya sudah lama ingin menulis artikel tentang topik ini. Ini mungkin tip yang sangat tidak biasa tentang tipe baru di Haskell. Saya memutuskan untuk menceritakannya dengan cara ini, karena saya sendiri mencari nafkah dengan Haskell dan terus-menerus menghadapi masalah serupa dalam praktiknya. Padahal, ide utamanya jauh lebih dalam.



Newtypes adalah salah satu mekanisme untuk mendefinisikan tipe wrapper. Konsep ini ada di hampir setiap bahasa, bahkan bahasa yang menggunakan pengetikan dinamis. Jika Anda tidak menulis Haskell, sebagian besar artikel ini kemungkinan besar berlaku untuk bahasa pilihan Anda. Kami dapat mengatakan bahwa ini adalah kelanjutan dari satu gagasan yang telah saya coba sampaikan dengan cara yang berbeda selama setahun terakhir: sistem tipe adalah alat. Kita perlu lebih sadar dan fokus tentang jenis apa yang sebenarnya disediakan dan bagaimana menggunakannya secara efektif.



Alasan untuk menulis artikel ini adalah artikel yang baru diterbitkan Tagged bukanlah Newtype... Ini adalah posting yang bagus dan saya benar-benar membagikan ide utamanya. Tetapi saya pikir penulis melewatkan kesempatan untuk menyuarakan pemikiran yang lebih serius. Faktanya, Tagged adalah tipe baru menurut definisi, jadi judul artikel membawa kita ke jalur yang salah. Masalah sebenarnya masuk lebih dalam.



Tipe baru berguna jika diterapkan dengan hati-hati, tetapi keamanan bukanlah properti defaultnya. Kami tidak percaya bahwa plastik tempat dibuatnya kerucut lalu lintas memberikan keamanan jalan dengan sendirinya. Penting untuk meletakkan kerucut dalam konteks yang benar! Tanpa klausa yang sama, newtypes hanyalah sebuah label, sebuah cara untuk memberi nama.



Dan namanya tidak aman untuk tipe!



All Articles