String dan binary

Sifat UTF-8 serta perbedaan binary dan charlist.

Daftar isi

Topik ini membahas string dan binary di Elixir, mulai dari sifat UTF-8 yang membuat byte_size/1 dan String.length/1 memberi jawaban berbeda, pembangunan binary dengan notasi <<>>, pattern matching pada binary, hingga perbedaan string dan charlist. Setelah topik ini dibaca, data teks dapat diperiksa dan dipotong dengan pemahaman tentang mana yang dihitung per byte dan mana yang dihitung per karakter. Seluruh contoh menggunakan string pendek yang byte dan karakternya mudah dihitung ulang.

String adalah binary UTF-8#

String di Elixir adalah binary, yaitu deretan byte yang disimpan dalam pengodean UTF-8. Satu karakter Latin seperti k menempati satu byte, sedangkan karakter di luar ASCII seperti é menempati dua byte. Akibatnya, byte_size/1 dan String.length/1 menjawab pertanyaan yang berbeda. Yang pertama menghitung byte penyimpanan, sedangkan yang kedua menghitung codepoint, yakni satuan karakter menurut UTF-8.

elixir string_dan_byte.exs
# "kafé" terdiri atas empat codepoint yang disimpan sebagai lima byte
teks = "kafé"

IO.puts("byte_size: #{byte_size(teks)}")
IO.puts("String.length: #{String.length(teks)}")

IO.inspect(String.to_charlist(teks), label: "codepoints")
IO.inspect(:binary.bin_to_list(teks), label: "bytes")
text Keluaran
byte_size: 5
String.length: 4
codepoints: [107, 97, 102, 233]
bytes: [107, 97, 102, 195, 169]

Daftar codepoints menunjukkan é bernilai 233, sedangkan daftar bytes menunjukkan dua byte 195 dan 169 yang bersama-sama menyimpannya. Fungsi String.to_charlist/1 pada contoh mengubah string menjadi list codepoint, sedangkan :binary.bin_to_list/1 dari Erlang mengubahnya menjadi list byte. String kosong merupakan binary kosong, sehingga kedua penghitung menghasilkan nol padanya. Tipe binary itu sendiri lebih umum daripada string, karena binary boleh berisi data apa pun, sedangkan string menuntut isinya UTF-8 valid. Dua sudut pandang ini menjadi alasan mengapa operasi teks di Elixir dibedakan secara hati-hati antara level byte dan level karakter. Perbedaannya akan terasa pada pemotongan di bagian akhir topik.

Membangun binary dengan «»#

Notasi <<>> membangun binary dari byte, codepoint, atau binary lain, dan bentuk paling sederhananya adalah deretan byte yang dipisahkan koma. Nilai satu byte berkisar dari 0 sampai 255, sedangkan modifier ::utf8 menampung seluruh rentang codepoint Unicode dan menyatakan bahwa bilangan tersebut harus diodekan sebagai UTF-8. Operator ?k menghasilkan codepoint karakter k, sehingga notasi ini sekaligus menjadi cara membaca nilai byte sebuah karakter.

elixir Contoh: binary dibangun dari byte dan codepoint
IO.inspect(<<107, 97, 102, 195, 169>>, label: "dari byte")
IO.inspect(<<?é::utf8>>, label: "dari codepoint")
IO.inspect(?k, label: "codepoint huruf k")
IO.inspect(<<>>, label: "kosong")
text Keluaran
dari byte: "kafé"
dari codepoint: "é"
codepoint huruf k: 107
kosong: ""

Tanpa modifier, bilangan 233 disimpan sebagai satu byte yang bukan UTF-8 valid untuk karakter tersebut. Segmen berupa string juga sah, sehingga <<"ka", "fé">> menghasilkan string yang sama dengan "kafé". Tanda tanya pada ?k adalah notasi karakter tunggal, sehingga ?k bernilai sama dengan bilangan 107. Inspect menampilkan binary yang isinya UTF-8 valid sebagai string, seperti lima byte di atas yang tampil sebagai "kafé". Binary yang bukan UTF-8 valid ditampilkan sebagai daftar byte mentah, dan perilaku itu digunakan lagi pada bagian kesalahan umum untuk membaca potongan yang rusak.

Pattern matching pada binary#

Binary mendukung pencocokan pola seperti tipe data lain. Pola <<awal, sisa::binary>> mengambil byte pertama ke variabel awal dan seluruh sisanya ke sisa, sedangkan modifier ::utf8 pada pola mengambil satu codepoint penuh. Segmen ::binary menandai bagian yang panjangnya bebas, sehingga penempatannya wajib berada di ujung pola, sedangkan segmen lain boleh diberi ukuran tetap.

elixir pola_binary.exs
# Mencocokkan binary: byte pertama, codepoint, dan literal
<<awal, sisa::binary>> = "kafé"
IO.puts("byte pertama: #{awal}")
IO.inspect(sisa, label: "sisa")

<<kode::utf8, sisa2::binary>> = "éna"
IO.puts("codepoint pertama: #{kode}")
IO.inspect(sisa2, label: "sisa")

<<"laporan.", ekstensi::binary>> = "laporan.pdf"
IO.inspect(ekstensi, label: "ekstensi")
text Keluaran
byte pertama: 107
sisa: "afé"
codepoint pertama: 233
sisa: "na"
ekstensi: "pdf"

Segmen juga dapat diberi ukuran tetap, misalnya <<dua::binary-size(2), sisa::binary>> mengambil dua byte pertama dan menyisakan sisanya. Modifier ::utf8 pada pola menuntut byte penyusun codepoint yang lengkap, sehingga pola gagal apabila datanya terpotong di tengah codepoint. Pola semacam ini lazim untuk mengurai format biner, misalnya header berkas atau pesan protokol, dan bukan hanya untuk teks.

Pencocokan dengan literal berperan seperti pengambilan akhiran pada nama berkas. Apabila awalannya tidak cocok, pencocokan gagal sebagaimana pencocokan nilai lain, yaitu memunculkan MatchError pada ekspresi = biasa atau tidak cocok dengan head fungsi mana pun. Bentuk ini menggantikan perbandingan awalan yang biasanya ditulis dengan beberapa pemanggilan fungsi string.

Codepoint dan grapheme#

UTF-8 menyimpan codepoint, tetapi yang dilihat pengguna sebagai satu karakter adalah grapheme, yaitu codepoint atau gabungan codepoint yang dirender sebagai satu tanda. String.codepoints/1 memecah string per codepoint, sedangkan String.graphemes/1 memecahnya per grapheme. Keduanya mengembalikan list string, sehingga hasilnya dapat langsung diproses fungsi Enum. Contoh bendera 🇮🇩 memperlihatkan perbedaannya, karena dua codepoint penyusunnya digabung menjadi satu grapheme. Gabungan codepoint yang lazim lainnya adalah huruf dasar beserta tanda diakritiknya, yang tetap dirender sebagai satu huruf.

elixir Contoh: grapheme bendera terdiri atas dua codepoint
bendera = "🇮🇩"

IO.inspect(String.graphemes(bendera), label: "graphemes")
IO.inspect(String.codepoints(bendera), label: "codepoints")
IO.puts("String.length: #{String.length(bendera)}")
IO.puts("byte_size: #{byte_size(bendera)}")
text Keluaran
graphemes: ["🇮🇩"]
codepoints: ["🇮", "🇩"]
String.length: 1
byte_size: 8

String.length/1 menghitung grapheme, sehingga bendera tersebut dihitung satu karakter meskipun byte_size/1 melaporkan delapan byte. Penghitungan grapheme juga lebih mahal daripada byte_size/1, karena byte_size/1 hanya membaca ukuran penyimpanannya, sedangkan String.length/1 harus membaca seluruh string dengan menerapkan aturan Unicode. Perbedaan ini penting ketika batas panjang teks ditetapkan, misalnya pada kolom isian formulir. Apabila batasnya jumlah karakter, ukur dengan String.length/1 agar tanda gabungan tetap dihitung satu, sedangkan apabila batasnya kapasitas penyimpanan dalam byte, ukur dengan byte_size/1.

Charlist dari Erlang#

Charlist adalah list berisi codepoint dan ditulis dengan sigil ~c, yaitu notasi berawalan tilde. Sigil ~c"halo" menghasilkan list [104, 97, 108, 111], yang berbeda dari string "halo" yang berupa binary. Notasi kutip tunggal 'halo' masih dikenali compiler, tetapi pada Elixir 1.20 ditandai sebagai deprecated dengan saran menggunakan sigil ~c, sehingga contoh di topik ini menggunakan bentuk sigil.

Charlist ditemukan terutama saat memanggil fungsi Erlang secara langsung, karena sebagian API modul Erlang seperti :file, :io, dan :code menerima atau mengembalikannya. Nilai kembalian fungsi Erlang yang berupa charlist biasanya dikonversi dengan to_string/1 sebelum diproses oleh fungsi string Elixir. Fungsi to_charlist/1 dan to_string/1 melakukan konversi dua arah, dan keduanya juga menerima tipe lain seperti integer.

elixir charlist.exs
# Charlist versus string beserta konversinya
IO.inspect(~c"halo", label: "charlist")
IO.inspect("halo", label: "string")
IO.puts("charlist adalah list: #{is_list(~c"halo")}")
IO.puts("string adalah binary: #{is_binary("halo")}")
IO.inspect(~c"halo" == "halo", label: "sama?")
IO.inspect(Enum.map(~c"halo", &(&1 + 1)), label: "map byte")
IO.inspect(to_charlist("halo"), label: "to_charlist")
IO.inspect(to_string(~c"halo"), label: "to_string")
IO.inspect(to_string(42), label: "to_string int")
text Keluaran
charlist: ~c"halo"
string: "halo"
charlist adalah list: true
string adalah binary: true
sama?: false
map byte: ~c"ibmp"
to_charlist: ~c"halo"
to_string: "halo"
to_string int: "42"

Hasil map byte memperlihatkan sifat charlist sebagai list biasa: Enum.map/2 bekerja padanya dan menghasilkan charlist baru karena setiap codepoint bertambah satu. Sebaliknya, fungsi String.* tidak menerima charlist, sehingga konversi to_string/1 diperlukan sebelum fungsi string digunakan. Dengan kebiasaan ini, data teks yang berasal dari fungsi Erlang dapat diseragamkan sejak awal.

Kesalahan umum#

Kesalahan pertama adalah membandingkan charlist dengan string. Seperti ditunjukkan pada bagian sebelumnya, ~c"halo" == "halo" menghasilkan false karena keduanya bertipe berbeda. Samakan tipenya lebih dahulu dengan to_string/1 atau to_charlist/1 sebelum membandingkan isi teksnya.

Kesalahan kedua adalah memotong teks dengan fungsi level byte. binary_part/3 memotong pada batas byte tanpa memandang codepoint, sehingga potongannya dapat membelah karakter menjadi dua. Hasilnya tampak berhasil pada pandangan pertama karena tetap berupa binary yang sah.

elixir Contoh: String.slice dan binary_part memotong dengan aturan berbeda
teks = "kafé"

IO.inspect(String.slice(teks, 0, 3), label: "String.slice 3 karakter")
IO.inspect(binary_part(teks, 0, 4), label: "binary_part 4 byte")
IO.puts("String.valid? potongan: #{String.valid?(binary_part(teks, 0, 4))}")
text Keluaran
String.slice 3 karakter: "kaf"
binary_part 4 byte: <<107, 97, 102, 195>>
String.valid? potongan: false

String.slice/3 memotong string berdasarkan batas grapheme sehingga menghasilkan string yang tetap utuh. Sebaliknya, potongan yang dihasilkan binary_part/3 hanya berisi byte pertama dari karakter é, sehingga karakter tersebut terpotong dan menghasilkan binary yang bukan UTF-8 valid. Inspect menampilkan binary tersebut sebagai daftar byte mentah, bukan sebagai string. Fungsi String.valid?/1 dalam contoh ini menegaskan perbedaan tersebut secara eksplisit.

Kesalahan ketiga adalah mengasumsikan satu karakter berarti satu byte. Perhitungan kapasitas, pembatasan panjang isian, dan pemotongan laporan perlu menyebut secara eksplisit mana yang dihitungnya, lalu menggunakan byte_size/1 untuk kebutuhan byte dan String.length/1 untuk kebutuhan karakter. Kedua fungsi penghitung itu digunakan sesuai maksud masing-masing, bukan ditukar mengikuti kebiasaan lama.

Ringkasan#

  • String adalah binary UTF-8, sehingga byte_size/1 menghitung byte penyimpanan dan String.length/1 menghitung codepoint, dan keduanya dapat berbeda.
  • Notasi <<>> membangun binary dari byte atau codepoint, dan pattern matching pada binary mengambil byte, codepoint, atau literal awalan.
  • Grapheme dapat terdiri atas beberapa codepoint, sehingga String.graphemes/1 dan String.codepoints/1 memberi hasil yang berbeda.
  • Charlist adalah list codepoint dari Erlang yang ditulis dengan sigil ~c, berbeda tipe dari string, dan dikonversi dengan to_charlist/1 serta to_string/1.

Referensi#