Hal-hal yang Seharusnya Jelas Perlu Dilakukan Bahasa C
(digitalmars.com)- 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
.csecara langsung seperti__import dex;, kebutuhan akan file.hterpisah 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
- evaluasi
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 enumeratorCbukan 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-expressionmuncul - 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 memanggildex()yang didefinisikan belakangan, gcc akan mengeluarkan error bahwa deklarasi implisitdexbertentangan tipenya dengan definisi sebenarnya - Jika urutan
floodandexditukar, 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
- tulis lebih dulu deklarasi seperti
- 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.cmeng-includedex.hdan memanggildex()dex.hmendeklarasikanchar dex(char *s, int i);dex.cmeng-includedex.hdan mendefinisikandex()
- Membuat file
.huntuk setiap modul eksternal menambah pekerjaan berulang - Jika file
.htidak persis cocok dengan file.c, mencari apa yang salah bisa memakan banyak waktu - Pendekatan yang diusulkan adalah mengimpor
dex.csecara langsung- di
floo.c, gunakan__import dex; - di
dex.c, cukup letakkan definisi sepertichar dexx(char *s, int i) { return s[i]; }
- di
- Dengan cara ini, file
.hsama sekali tidak perlu ditulis - ImportC juga mendukung cara ini
Dokumen Terkait
- ImportC: dokumentasi ImportC
- D Language: dokumentasi bahasa D
1 komentar
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
.hsebuah library untuk memahami cara memakainya, dan biasanya.hberisi dokumentasi penggunaan tanpa menduplikasi.c. Dokumentasi memang bisa ditaruh di.c, tetapi dari sisi pengguna, membaca antarmukanya menjadi jauh kurang nyamanMisalnya 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 sourceIa 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
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,
importdi Python juga terasa cocok sebagai “file header” karena terpetakan secara alami ke sistem file dan tidak perlu berurusan dengan semantik include milik CJika 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
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.cyang 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 iniDi 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.htidak 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 DKita tidak lagi bisa percaya bahwa grafik dependensi diletakkan dalam urutan topologis
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 sepertistr.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 adalahdex.dataudex.cJika 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.dataudex.cmemang benar-benar muncul, dan jawabannya adalah pengaturan path import. Mirip dengan include path pada compiler CC 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
constexprdi C++Karena hampir semua compiler C juga merupakan compiler C++, saya bertanya-tanya apakah mendukung fungsi dan evaluasi
constexprdi C benar-benar bisa seburuk ituSaya selalu menulis unit test untuk kode C
Dengan build system yang baik dan sedikit boilerplate, itu tidak sulit. Test di library
npymemanggilnpy_load("tests/npy/uint8.npy")ditest_load_uint8(), melakukanassertpada dimensi, ukuran, dan tipe, lalu memanggilnpy_free, dan dijalankan darimainsepertiPRINT_RUN(test_load_uint8);. Sebagian test mungkin bisa dihasilkan dengan preprocessor, tetapi saya lebih suka menjaganya tetap sederhanaContoh 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 meningkatDulu 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
#includefile.cyang diuji untuk memanggil fungsi statis. Hanya satu file.cyang di-include. Selain itu, sebelum#include, saya melakukan sedikit pemrosesan preprocessor agarNDEBUGtidak terdefinisi, untuk mencegah kasus ketika seseorang membangun dalam “mode rilis” sehinggaassertdinonaktifkanSaya 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 diperlukanUnit 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”
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”
static_assert, dan sangat bernilai untuk menangkap penggunaan yang tidak kompatibel dari fungsi librarySaya 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
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
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
deferdi Go atau Zig atau atributcleanupdi GCC, serta dukungan portabel untuk Unicode dan UTF-8Gagasan 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 sepertiint x = busybeaver(99);akan menjadi masalahC23 memiliki
constexpr, tetapi belum bisa ditempelkan ke fungsiNamun ada proposalnya: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf