2 poin oleh GN⁺ 2025-01-13 | 1 komentar | Bagikan ke WhatsApp
  • Hingga C23, C standar terus berubah, tetapi Walter Bright menilai ketidaknyamanan lama seperti evaluasi ekspresi konstan, referensi maju, dan ketergantungan header masih mengganggu alur pengembangan
  • ImportC, kompiler C yang tertanam di kompiler D, memanfaatkan keunggulan sebagai implementasi baru sehingga dapat menjalankan fungsi yang memenuhi syarat pada waktu kompilasi di posisi constant-expression
  • Jika CTFE dimungkinkan, pengujian unit seperti _Static_assert(sum(3, 4) == 7,...) dapat dijalankan setiap kali kompilasi tanpa berkas eksekusi terpisah
  • Batasan urutan deklarasi di C standar menimbulkan pengulangan deklarasi maju dan penataan kode terbalik, tetapi ImportC memilih pendekatan yang tidak terlalu terikat pada urutan deklarasi global
  • Dengan mengambil deklarasi dari file .c secara langsung seperti __import dex;, kebutuhan akan file .h terpisah bisa dikurangi, sekaligus menurunkan beban debugging akibat ketidaksesuaian antara header dan implementasi

Ketidaknyamanan yang Masih Tersisa di C Standar

  • C standar terus diperbaiki secara berkala hingga C23, tetapi Walter Bright menilai masih ada bagian yang belum diperbaiki
  • Komunitas Dlang menanamkan kompiler C di dalam kompiler bahasa pemrograman D, dan kompiler ini disebut ImportC
  • ImportC dibuat baru dari awal, sehingga ada ruang untuk menangani kekurangan C dengan teknologi kompiler modern
  • Ada empat hal yang dibahas dalam tulisan ini
    • evaluasi constant-expression
    • pengujian unit pada waktu kompilasi
    • referensi maju pada deklarasi
    • impor deklarasi

Eksekusi Fungsi Saat Kompilasi dan Evaluasi Ekspresi Konstan

  • C dapat menghitung ekspresi sederhana pada waktu kompilasi melalui constant folding, tetapi di C standar fungsi tidak dapat dijalankan pada waktu kompilasi
  • Jika kode contoh enum E { A = 3, B = 4, C = sum(5, 6) }; dikompilasi dengan gcc, akan muncul error bahwa nilai enumerator C bukan konstanta bilangan bulat
  • ImportC dapat mengompilasi kode yang sama
  • Arah yang diambil adalah bahwa kompiler seharusnya dapat menjalankan fungsi pada waktu kompilasi di semua tempat dalam sintaks C tempat constant-expression muncul
  • Namun, fungsi tersebut tidak boleh melakukan pekerjaan seperti I/O, mengakses variabel global yang dapat berubah, atau pemanggilan sistem

Pengujian Unit yang Berjalan Setiap Kali Kompilasi

  • Jika CTFE, yaitu evaluasi fungsi pada waktu kompilasi, dimungkinkan, cara pengujian unit juga bisa berubah
  • Salah satu alasan pengujian unit jarang ada di kode C adalah kerepotan membuat target build dan berkas eksekusi terpisah lalu menjalankannya
  • Jika kode contoh _Static_assert(sum(3, 4) == 7, "test #1"); dikompilasi dengan gcc, akan muncul error bahwa ekspresi di dalam assertion statis bukan konstanta
  • ImportC dapat mengompilasi kode ini
  • Pendekatan ini mempermudah pengujian unit untuk fungsi yang bisa dijalankan pada waktu kompilasi
    • tidak perlu build terpisah
    • tidak perlu pekerjaan tambahan
    • pengujian dijalankan setiap kali kode dikompilasi
  • Test suite ImportC juga banyak menggunakan cara ini

Pengulangan Deklarasi yang Dihasilkan oleh Referensi Maju

  • Di C standar, referensi maju tidak diizinkan karena kompiler hanya mengetahui deklarasi yang muncul lebih dulu secara leksikal
  • Dalam kode contoh, jika floo() lebih dulu memanggil dex() yang didefinisikan belakangan, gcc akan mengeluarkan error bahwa deklarasi implisit dex bertentangan tipenya dengan definisi sebenarnya
  • Jika urutan floo dan dex ditukar, kode yang sama dapat dikompilasi dengan normal
  • ImportC dapat menerima deklarasi global dalam urutan apa pun
  • Jika referensi maju tidak dimungkinkan, deklarasi terpisah harus ditambahkan untuk setiap definisi yang dirujuk lebih dulu
    • tulis lebih dulu deklarasi seperti char dex(char *s, int i);
    • lalu tulis lagi definisi fungsi tersebut setelahnya
  • Cara ini adalah pengulangan kerja yang tidak perlu, dan membuat programmer menaruh fungsi leaf di atas serta fungsi antarmuka global di bawah dalam penataan terbalik
  • Susunan seperti ini dikritik karena sama seperti membaca artikel koran dari bawah ke atas dan tidak masuk akal

Mengimpor Deklarasi Tanpa File Header

  • Di C, untuk memakai modul eksternal biasanya deklarasi dibuat di file .h, lalu di-include dari file .c
  • Struktur contohnya seperti berikut
    • floo.c meng-include dex.h dan memanggil dex()
    • dex.h mendeklarasikan char dex(char *s, int i);
    • dex.c meng-include dex.h dan mendefinisikan dex()
  • Membuat file .h untuk setiap modul eksternal menambah pekerjaan berulang
  • Jika file .h tidak persis cocok dengan file .c, mencari apa yang salah bisa memakan banyak waktu
  • Pendekatan yang diusulkan adalah mengimpor dex.c secara langsung
    • di floo.c, gunakan __import dex;
    • di dex.c, cukup letakkan definisi seperti char dexx(char *s, int i) { return s[i]; }
  • Dengan cara ini, file .h sama sekali tidak perlu ditulis
  • ImportC juga mendukung cara ini

Dokumen Terkait

1 komentar

 
GN⁺ 2025-01-13
Pendapat Hacker News
  • Salah satu hal yang paling saya rindukan saat memakai bahasa selain C adalah file header
    Saya terutama menyukai dalam kode C bagaimana publik/privat dan antarmuka/implementasi dipisahkan dengan sangat jelas. Menyenangkan karena cukup menelusuri file .h sebuah library untuk memahami cara memakainya, dan biasanya .h berisi dokumentasi penggunaan tanpa menduplikasi .c. Dokumentasi memang bisa ditaruh di .c, tetapi dari sisi pengguna, membaca antarmukanya menjadi jauh kurang nyaman

    • Klaim ini selalu terasa aneh bagi saya; bahasa lain menyelesaikan masalah ini dengan alat, dan menurut saya itu lebih baik
      Misalnya di Rust, untuk melihat antarmuka dan cara penggunaan library cukup jalankan cargo doc --open. Seluruh API publik otomatis dibuat dalam bentuk yang bisa dicari, tanpa perlu menduplikasi kode atau mengelolanya secara manual antara header dan source
    • Menurut saya file header lebih seperti hack lemah untuk menangani platform dengan keterbatasan sumber daya pada era 70-an
      Ia hanya bekerja dengan benar jika konvensinya dipatuhi, dan tampak buruk jika dibandingkan dengan bahasa seperti Ada, yang spesifikasi antarmuka dan implementasinya dirancang baik sehingga tidak perlu parsing berulang. Saya suka memakai C, tetapi bagian ini seharusnya dirancang lebih baik
    • Saya setuju sekaligus tidak setuju
      File header C bekerja dengan cara menyisipkan teks apa adanya, jadi tingkatnya hanya “terlihat kira-kira berjalan”. Sebaliknya, Ada punya konsep package dan package body; package setara dengan file header, dan package body setara dengan implementasi. Ketika dulu saya memakai Ada, meskipun implementasi package body belum siap, semua orang tetap bisa mengompilasi berdasarkan package, sehingga antarmuka bisa diselaraskan bahkan sebelum implementasinya ada. Dari arah lain, import di Python juga terasa cocok sebagai “file header” karena terpetakan secara alami ke sistem file dan tidak perlu berurusan dengan semantik include milik C
    • Sepertinya ada perbedaan cara berpikir di balik ini
      Jika ada file header, orang akan memandang antarmuka sebagai sesuatu yang berbeda dari implementasi. Karena itu, orang yang terbiasa dengan pendekatan ini merasa tidak nyaman dengan gagasan bahwa antarmuka dihasilkan oleh alat. Antarmuka bukan produk sampingan dari implementasi, melainkan sesuatu yang dirancang secara terpisah dan disengaja; bagi sebagian orang, itu bahkan lebih penting daripada implementasi. Sebaliknya, orang yang terbiasa dengan dokumentasi yang dibuat otomatis merasa tidak nyaman jika antarmuka tidak dihasilkan dari satu sumber kebenaran berupa source implementasi. Setelah lama memakai bahasa yang punya file antarmuka terpisah dan bahasa yang tidak, orang cenderung mengeras di kubunya masing-masing dan lupa bagaimana rasanya berpikir dengan pendekatan sebaliknya
    • Di sebagian besar bahasa modul terkompilasi, ini sudah memungkinkan
      Entah dipisahkan seperti Modula-2, Modula-3, Ada, Standard ML, Caml Light, OCaml, F#, D, atau bisa dihasilkan dengan alat teks maupun grafis seperti Object Pascal, D, Haskell, Java, C#, F#, Swift, Go, Rust. Semuanya punya tipe yang lebih kuat, kompilasi lebih cepat, dan namespace yang benar. Toolchain Rust dan Swift memang masih punya bagian yang perlu diperbaiki, tetapi alat C selalu primitif dibandingkan hal-hal yang terjadi di luar Bell Labs. Seandainya AT&T bisa memanfaatkannya secara komersial, sejarah mungkin berbeda; pada akhirnya kita seperti mendapat lemon gratis alih-alih jeruk matang. Meski begitu, mereka membuat semacam TypeScript untuk C, dan sekarang juga mendukung tipe koleksi dengan pemeriksaan batas serta modul yang layak
  • Di bidang ini, penulis punya jauh lebih banyak pengetahuan dan pengalaman, jadi saya penasaran bagaimana masalah-masalah berikut akan dipecahkan
    Evaluasi ekspresi konstan menjadi sederhana jika hanya dilakukan di dalam unit terjemahan, tetapi hal yang bisa dilakukan tanpa pengulangan kode menjadi sangat terbatas. Unit test waktu kompilasi sampai taraf tertentu dimungkinkan jika test bisa diekspresikan sebagai makro, dan menjadi lebih mudah jika poin sebelumnya ditambahkan. Referensi maju pada deklarasi bisa memicu penolakan besar, karena compiler berubah dari 1-pass menjadi 2-pass dan menimbulkan dampak performa. Bagi orang yang mengompilasi codebase raksasa sekaligus memparalelkan unit terjemahan, ini mungkin sulit diterima. Mengimpor deklarasi adalah perubahan yang memutus kompatibilitas. Saya pernah mendefinisikan variabel di C lalu meng-import file .c, mengubah variabelnya, lalu meng-import file .c yang sama lagi untuk membuat sesuatu mirip template. Saya juga pernah menaruh beberapa definisi di depan SQLite C Amalgamation dan menambahkan fungsi untuk mengekspos fungsi internal; semua kasus penggunaan seperti ini sepertinya akan rusak. Saya penasaran apakah ada solusi untuk masalah-masalah seperti ini

    • Memang benar bahwa source code fungsi yang dievaluasi harus bisa dilihat oleh compiler
      Di C itu bisa dilakukan dengan #include, dan di D dilakukan dengan meng-import modul yang berisi kode yang diperlukan. Pendekatan mengekspresikan test sebagai makro tidak cocok jika yang ingin diuji adalah fungsi. Contohnya dibuat sederhana agar mudah dipahami; penggunaan nyata bisa jauh lebih kompleks. Dari sisi performa, D dikompilasi lebih cepat daripada compiler C, terutama karena preprocessor C adalah struktur fosil yang membutuhkan beberapa pass, sedangkan D memakai import alih-alih #include, sehingga file .h tidak dikompilasi berulang-ulang. Strategi D adalah memisahkan parsing dan analisis semantik; mungkin sedikit lebih lambat, tetapi tidak ada biaya mengompilasi ulang deklarasi duplikat lalu melipatnya menjadi satu. Eksekusi fungsi waktu kompilasi bisa menjadi bottleneck jika banyak dipakai, tetapi jika digunakan ringan performanya baik-baik saja. Jika Anda menerapkan template di C lewat hack, berarti Anda sudah melampaui bahasanya dan membutuhkan bahasa yang lebih kuat. D memiliki metaprogramming kelas atas, dan bahasa template lain sering mengikuti jalan D
    • Secara pribadi, saya tidak suka referensi maju karena membuat kode lebih sulit dibaca
      Kita tidak lagi bisa percaya bahwa grafik dependensi diletakkan dalam urutan topologis
    • Bahasa lain tampaknya baik-baik saja tanpa file header atau deklarasi maju, jadi saya sulit memahami mengapa ada penolakan
      Saya juga penasaran apakah compiler C modern benar-benar masih single-pass
  • Contoh evaluasi ekspresi konstan di artikel itu cukup sederhana, tetapi untuk kasus yang lebih kompleks, kecepatan compiler dan penggunaan memori bisa memburuk secara signifikan, dan sepertinya diperlukan mesin virtual untuk memanfaatkannya
    Jadi bisa dipahami kemungkinan bahwa hal itu dianggap “terlalu kompleks” untuk dimasukkan ke standar. Akan lebih baik jika C++ atau C bergerak ke arah mengimpor deklarasi, alih-alih campuran aneh yang didefinisikan di C++20. Misalnya, mengimpor modul sebagai sebuah simbol seperti #import "string.c" as str, lalu membuat semua simbol non-statis di file tersebut dapat diakses seperti str.trim(" Hello World ");. Secara terpisah, saya tidak suka bentuk seperti __import dex; yang tidak menyebutkan path file secara eksplisit. Dalam kasus ini, tidak jelas apakah yang diimpor adalah dex.d atau dex.c

    • Bahasa populer lain juga bisa melakukannya, dan di D, eksekusi fungsi saat compile-time adalah fitur yang sangat populer dan berguna
      Jika banyak digunakan, tentu saja akan memakai waktu kompilasi dan memori. Mengenai mesin virtual, constant folding itu sendiri pada dasarnya sudah merupakan mesin virtual, lalu tinggal diperluas dengan penanganan pemanggilan fungsi. Semantik C sederhana, jadi tidak terlalu buruk. Cara import yang diusulkan hampir sama dengan yang dilakukan D import: https://dlang.org/spec/module.html#import-declaration. Masalah apakah itu dex.d atau dex.c memang benar-benar muncul, dan jawabannya adalah pengaturan path import. Mirip dengan include path pada compiler C
    • Sebagian besar compiler C nyata tampaknya sudah melakukan evaluasi compile-time sampai taraf tertentu untuk optimisasi
      C juga sudah memiliki ekspresi konstan. Hambatan yang lebih besar adalah compiler harus punya akses ke source code fungsi, jadi kemungkinan akan dibatasi pada fungsi dalam translation unit yang sama. Selain itu, persoalan manusia—komite yang terdiri dari perwakilan berbagai compiler harus menyepakati semantik evaluasi konstan semacam itu—mungkin jauh lebih besar
    • Ini sebenarnya terlihat hampir 1:1 dengan fitur constexpr di C++
      Karena hampir semua compiler C juga merupakan compiler C++, saya bertanya-tanya apakah mendukung fungsi dan evaluasi constexpr di C benar-benar bisa seburuk itu
  • Saya selalu menulis unit test untuk kode C
    Dengan build system yang baik dan sedikit boilerplate, itu tidak sulit. Test di library npy memanggil npy_load("tests/npy/uint8.npy") di test_load_uint8(), melakukan assert pada dimensi, ukuran, dan tipe, lalu memanggil npy_free, dan dijalankan dari main seperti PRINT_RUN(test_load_uint8);. Sebagian test mungkin bisa dihasilkan dengan preprocessor, tetapi saya lebih suka menjaganya tetap sederhana

    • Fungsi itu tampaknya melakukan I/O, jadi tidak akan bekerja sebagai test compile-time
      Contoh unit test di compiler ImportC berbentuk seperti _Static_assert(sizeof(struct S22079){1,2,3} == sizeof(int)*3, "ok"); dan _Static_assert(sizeof(struct S22079){1,2,3}.a == sizeof(int), "ok");. Karena semantik diperiksa pada waktu kompilasi, tidak perlu melakukan link dan menjalankannya. Semakin banyak test, cara ini menjadi cukup jauh lebih cepat, dan semakin cepat kumpulan test berjalan, produktivitas meningkat
    • Saya juga melakukannya hampir dengan cara yang sama
      Dulu saya pernah mendalami framework unit test yang mewah, tetapi menyadari bahwa manfaatnya tidak banyak, lalu akhirnya menetap pada bentuk yang hampir sama. Hal yang mungkin dipandang buruk oleh orang lain adalah saya rela #include file .c yang diuji untuk memanggil fungsi statis. Hanya satu file .c yang di-include. Selain itu, sebelum #include, saya melakukan sedikit pemrosesan preprocessor agar NDEBUG tidak terdefinisi, untuk mencegah kasus ketika seseorang membangun dalam “mode rilis” sehingga assert dinonaktifkan
    • Membuat test semudah mungkin dijalankan benar-benar sangat membantu menuju codebase yang memiliki test
      Saya memakai sesuatu yang mirip: https://github.com/ensisoft/detonator/blob/master/base/test_.... Banyak mengambil dari boost.test.minimal, dan awalnya berupa single header, tetapi seiring waktu perlu menambahkan satu translation unit. Kesimpulan saya, jika codebase dijaga dalam keadaan test selalu lulus, kompleksitas seperti pelaporan error atau toleransi cacat dari tool test menjadi jauh kurang diperlukan
  • Unit test compile-time adalah gagasan yang tidak lebih baik daripada menjadikan “import/variabel/hasil yang tidak digunakan” sebagai error, bukan warning
    Itu adalah “fitur pengasuh” yang mengambil kendali dari developer dan pada akhirnya membuat mereka menjalani prosedur birokratis agar pekerjaan selesai. Test yang menggagalkan build seperti ini bagus untuk build “sepertinya sudah selesai”, tetapi tidak cocok untuk build “masih dikerjakan” yang mencakup 99% dari build. Mirip seperti mengatakan “Anda tidak boleh memakai gergaji meja sebelum membereskan bor”

    • Saya sulit setuju dengan bagian ini
      Jika kita mencoba mengekspresikan suatu gagasan lalu test compile-time memberi tahu bahwa itu salah, sebenarnya gagasan tersebut mungkin masih belum lengkap, atau kita belum cukup memikirkan semua konsekuensi dari ekspresi itu. Mirip dengan pemeriksaan tipe di Haskell. Program yang tidak lolos type checking tidak bisa dikompilasi, dan memaksa programmer untuk selalu mengekspresikan hanya gagasan yang lengkap. Secara teori, ini bisa menghasilkan program yang lebih matang dipikirkan. Namun, penulisannya menjadi lebih sulit karena programmer dipaksa menemukan juga sudut-sudut yang “diketahui tidak valid tetapi tidak dipedulikan”
    • Test compile-time seperti ini mungkin lebih dekat dengan static_assert, dan sangat bernilai untuk menangkap penggunaan yang tidak kompatibel dari fungsi library
      Saya menganggapnya ide yang cukup bagus
  • Pernyataan bahwa “fungsi leaf datang lebih dulu dan fungsi antarmuka global datang terakhir” terasa terbalik bagi saya
    Karena berbagai alasan, saya lebih suka menulis kode dalam urutan topological sort. Ini mirip dengan cara menulis kode di dalam sebuah fungsi, membuat jelas di mana fungsi harus ditempatkan dalam modul, dan yang paling penting, membuat dependensi siklik antara potongan kode internal modul menjadi sangat terlihat. Saya kurang menyukai dependensi siklik karena membuat codebase makin saling kusut dan modul sulit dipahami sebagai unit mandiri. Di Python, ini juga bisa menimbulkan masalah yang tidak terlihat sampai waktu eksekusi[0], dan karena circular import begitu umum, tampaknya type checker saat ini mematikan diagnosis itu secara default[1]. Bahasa seperti C, OCaml, dan SML yang tidak mendukung forward reference memungkinkan “prinsip kejutan minimum” diterapkan pada dependensi siklik. OCaml bahkan melarang dependensi rekursif antar-fungsi kecuali dideklarasikan seperti let rec fn1 = .. and fn2 = ..; saat menulis memang agak merepotkan, tetapi saat membaca itu menjadi informasi penting
    [0]: https://gist.github.com/Mark24Code/2073470277437f2241033c200...
    [1]: https://microsoft.github.io/pyright/#/configuration?id=type-... (lihat reportImportCycles)

  • Ada penjelasan bahwa karena compiler hanya mengetahui hal-hal yang muncul lebih dulu secara leksikal, urutannya menjadi “dari bawah ke atas”, dengan fungsi leaf lebih dulu dan fungsi antarmuka global terakhir; tetapi urutan seperti ini juga umum pada bahasa yang mengizinkan forward reference seperti Python[0]
    Saya penasaran apakah ini warisan dari bahasa yang tidak mengizinkan forward reference, atau memang cara yang lebih masuk akal untuk jenis kode tertentu
    [0] https://stackoverflow.com/a/73131538

    • Python tidak punya forward reference
      Hanya saja identifier di dalam body fungsi tidak di-resolve sampai fungsi itu sendiri dieksekusi, dan pada saat itu semua hal di scope modul sudah terdefinisi. Anda bisa memastikannya sendiri dengan memasukkan nama apa saja ke body fungsi lalu memuat modulnya
    • Dalam kode saya, antarmuka publik cenderung selalu berada di atas, dan detail implementasi berada di akhir file
      Tidak ada aturan ketat, tetapi menurut saya cara membacanya lebih rapi dan masuk akal. Terutama jika implementasinya besar, saya tidak ingin harus men-scroll semuanya lebih dulu baru melihat apa yang pada dasarnya dilakukan. Penasaran bagaimana orang lain melakukannya
  • “Hal-hal jelas” yang saya harap dilakukan C mencakup dukungan tipe slice yang menyimpan pointer dan panjang, versi API yang memakai state global yang reentrant dan sebisa mungkin thread-safe, standardisasi sesuatu seperti defer di Go atau Zig atau atribut cleanup di GCC, serta dukungan portabel untuk Unicode dan UTF-8

    • Bukankah sebagian besar dari ini adalah fitur yang diinginkan di standard library, bukan sesuatu yang harus dilakukan bahasa itu sendiri?
  • Gagasan bahwa di semua tempat dalam sintaks C tempat constant expression muncul, compiler seharusnya bisa menjalankan fungsi pada waktu kompilasi selama fungsi itu tidak melakukan I/O, mengakses variabel global yang dapat diubah, system call, dan sebagainya, mudah sekali rusak
    Cukup pilih fungsi murni yang berjalan sangat lama. Misalnya, jika int busybeaver(int n) {...} adalah fungsi murni yang mengembalikan durasi maksimum mesin busy beaver dengan n state, maka kode seperti int x = busybeaver(99); akan menjadi masalah

  • C23 memiliki constexpr, tetapi belum bisa ditempelkan ke fungsi
    Namun ada proposalnya: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf